Files
YelayMaths/yelaymaths_api/src/modules/admin/admin.service.ts
T
2026-09-29 19:34:24 +08:00

310 lines
12 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { Inject, Injectable } from '@nestjs/common';
import { z } from 'zod';
import { APP_CONFIG, type AppConfig } from '../../config/config';
import { hashPassword, verifyPassword } from '../../common/crypto';
import { AppError } from '../../common/errors';
import { PrismaService } from '../../prisma/prisma.service';
import { OrderStatus, PaymentsService } from '../payments/payments.service';
import { WxPayService } from '../payments/wxpay.service';
import { READONLY_RESOURCES, RESOURCES, type ResourceDef } from './resource-registry';
/** 通用委托接口。用最小结构约束代替 any,保持 strict 校验通过。 */
interface Delegate {
findMany: (args?: unknown) => Promise<unknown[]>;
count: (args?: unknown) => Promise<number>;
findUnique: (args: { where: { id: number } }) => Promise<Record<string, unknown> | null>;
create: (args: { data: Record<string, unknown> }) => Promise<unknown>;
update: (args: { where: { id: number }; data: Record<string, unknown> }) => Promise<unknown>;
delete: (args: { where: { id: number } }) => Promise<unknown>;
}
@Injectable()
export class AdminService {
constructor(
@Inject(APP_CONFIG) private readonly cfg: AppConfig,
private readonly prisma: PrismaService,
private readonly payments: PaymentsService,
private readonly wxpay: WxPayService,
) {}
private delegate(name: string): Delegate {
const d = (this.prisma as unknown as Record<string, Delegate>)[name];
if (!d) throw AppError.notFound(`未知资源:${name}`);
return d;
}
// ── 登录 ───────────────────────────────────────────────────────────────
async verifyAdmin(username: string, password: string): Promise<{ id: number; role: number; displayName: string | null }> {
const admin = await this.prisma.adminUser.findUnique({ where: { username } });
if (!admin || admin.status !== 1) throw AppError.unauthorized('账号或密码错误');
if (!verifyPassword(password, admin.password_hash)) throw AppError.unauthorized('账号或密码错误');
await this.prisma.adminUser.update({ where: { id: admin.id }, data: { last_login_at: new Date() } });
return { id: admin.id, role: admin.role, displayName: admin.display_name };
}
/**
* 首次部署时用于创建超管账号。已存在任何管理员时拒绝执行。
* 避免在生产环境留下一个可被随意调用的建号后门。
*/
async bootstrapAdmin(username: string, password: string): Promise<{ created: boolean }> {
const count = await this.prisma.adminUser.count();
if (count > 0) return { created: false };
if (username.length < 3 || password.length < 8) {
throw AppError.badRequest('用户名至少 3 位,密码至少 8 位');
}
await this.prisma.adminUser.create({
data: { username, password_hash: hashPassword(password), display_name: '超级管理员', role: 1 },
});
return { created: true };
}
// ── 看板 ───────────────────────────────────────────────────────────────
async stats() {
const [users, courses, orders, delivered, refunded, revenue] = await Promise.all([
this.prisma.user.count(),
this.prisma.course.count({ where: { status: 1 } }),
this.prisma.order.count(),
this.prisma.order.count({ where: { status: OrderStatus.DELIVERED } }),
this.prisma.order.count({ where: { status: OrderStatus.REFUNDED } }),
this.prisma.order.aggregate({
where: { status: OrderStatus.DELIVERED },
_sum: { amount_fen: true },
}),
]);
return {
users,
courses,
orders,
delivered,
refunded,
revenueFen: revenue._sum.amount_fen ?? 0,
payEnabled: this.wxpay.enabled,
payEnv: this.wxpay.env,
};
}
// ── 用户 ───────────────────────────────────────────────────────────────
async listUsers(page = 1, pageSize = 20, keyword = '') {
const size = Math.min(Math.max(pageSize, 1), 100);
const current = Math.max(page, 1);
const where = keyword
? { OR: [{ nickname: { contains: keyword } }, { openid: { contains: keyword } }] }
: {};
const [items, total] = await this.prisma.$transaction([
this.prisma.user.findMany({
where,
orderBy: { id: 'desc' },
skip: (current - 1) * size,
take: size,
include: { _count: { select: { entitlements: true } } },
}),
this.prisma.user.count({ where }),
]);
return {
items: items.map((u) => ({
id: u.id,
openid: u.openid,
nickname: u.nickname,
headicon_id: u.headicon_id,
status: u.status,
registered_at: u.registered_at,
last_login_at: u.last_login_at,
entitlement_count: u._count.entitlements,
})),
total,
page: current,
pageSize: size,
};
}
async updateUser(id: number, input: { nickname?: string; headicon_id?: number; status?: number }) {
return this.prisma.user.update({ where: { id }, data: input });
}
// ── 通用内容 CRUD ──────────────────────────────────────────────────────
async listResource(name: string, page = 1, pageSize = 20, keyword = '', filter: Record<string, unknown> = {}) {
const readonly = READONLY_RESOURCES[name];
const def: ResourceDef | undefined = RESOURCES[name];
if (!readonly && !def) throw AppError.notFound(`未知资源:${name}`);
const size = Math.min(Math.max(pageSize, 1), 100);
const current = Math.max(page, 1);
const searchField = def?.searchField;
const where = {
...filter,
...(keyword && searchField ? { [searchField]: { contains: keyword } } : {}),
};
const d = this.delegate(readonly ? readonly.delegate : def!.delegate);
const [items, total] = await Promise.all([
d.findMany({ where, orderBy: readonly ? readonly.orderBy : def!.orderBy, skip: (current - 1) * size, take: size }),
d.count({ where }),
]);
return { items, total, page: current, pageSize: size };
}
async createResource(name: string, payload: unknown) {
const def = RESOURCES[name];
if (!def) throw AppError.notFound(`资源不可创建:${name}`);
const parsed = def.create.safeParse(payload);
if (!parsed.success) throw AppError.badRequest(formatZodError(parsed.error));
return this.delegate(def.delegate).create({ data: { ...def.defaults, ...(parsed.data as Record<string, unknown>) } });
}
async updateResource(name: string, id: number, payload: unknown) {
const def = RESOURCES[name];
if (!def) throw AppError.notFound(`资源不可修改:${name}`);
// 更新时所有字段可选;不可变字段随后被剔除
const partialSchema = (def.create as unknown as z.ZodObject<z.ZodRawShape>).partial();
const parsed = partialSchema.safeParse(payload);
if (!parsed.success) throw AppError.badRequest(formatZodError(parsed.error));
const data = { ...(parsed.data as Record<string, unknown>) };
for (const field of def.immutable ?? []) delete data[field];
if (Object.keys(data).length === 0) throw AppError.badRequest('没有需要修改的内容');
return this.delegate(def.delegate).update({ where: { id }, data });
}
async deleteResource(name: string, id: number): Promise<{ deleted: true }> {
const def = RESOURCES[name];
if (!def) throw AppError.notFound(`资源不可删除:${name}`);
await this.delegate(def.delegate).delete({ where: { id } });
return { deleted: true };
}
// ── 配置 ───────────────────────────────────────────────────────────────
async listSettings() {
return this.prisma.setting.findMany({ orderBy: { id: 'asc' } });
}
async upsertSetting(key: string, value: string, remark?: string) {
return this.prisma.setting.upsert({
where: { key },
create: { key, value, remark: remark ?? null },
update: { value, remark: remark ?? undefined },
});
}
// ── 订单与权益 ─────────────────────────────────────────────────────────
async listOrders(page = 1, pageSize = 20, status?: number) {
const size = Math.min(Math.max(pageSize, 1), 100);
const current = Math.max(page, 1);
const where = status === undefined ? {} : { status };
const [items, total] = await this.prisma.$transaction([
this.prisma.order.findMany({
where,
orderBy: { created_at: 'desc' },
skip: (current - 1) * size,
take: size,
include: { user: { select: { nickname: true, openid: true } }, course: { select: { title: true } } },
}),
this.prisma.order.count({ where }),
]);
return {
items: items.map((o) => ({
id: o.id,
out_trade_no: o.out_trade_no,
wx_order_id: o.wx_order_id,
status: o.status,
amount_fen: o.amount_fen,
nickname: o.user.nickname,
openid: o.user.openid,
course_title: o.course.title,
created_at: o.created_at,
paid_at: o.paid_at,
delivered_at: o.delivered_at,
refunded_at: o.refunded_at,
})),
total,
page: current,
pageSize: size,
};
}
/** 发起退款(仅非 iOS 终端)。退款成功由退款推送回写订单状态。 */
async refundOrder(orderId: number, reason: string) {
const order = await this.prisma.order.findUnique({ where: { id: orderId }, include: { user: true } });
if (!order) throw AppError.notFound('订单不存在');
if (order.status !== OrderStatus.DELIVERED) throw AppError.conflict('只有已开通的订单可以退款');
const result = await this.wxpay.refundOrder({
outTradeNo: order.out_trade_no,
openid: order.user.openid,
refundOrderId: `R${order.out_trade_no}`,
refundAmountFen: order.amount_fen,
reason: reason.slice(0, 100),
});
return { submitted: true, providerResponse: result };
}
async listEntitlements(userId: number) {
const rows = await this.prisma.entitlement.findMany({
where: { user_id: userId },
orderBy: { granted_at: 'desc' },
include: { course: { select: { title: true, code: true } } },
});
return rows.map((r) => ({
id: r.id,
course_id: r.course_id,
course_title: r.course.title,
course_code: r.course.code,
source: r.source,
granted_at: r.granted_at,
expire_at: r.expire_at,
note: r.note,
}));
}
/** 手动发放权益(赠课、补发、老用户补救)。 */
async grantEntitlement(input: { userId: number; courseId: number; expireAt?: Date | null; note?: string }) {
const [user, course] = await Promise.all([
this.prisma.user.findUnique({ where: { id: input.userId } }),
this.prisma.course.findUnique({ where: { id: input.courseId } }),
]);
if (!user) throw AppError.notFound('用户不存在');
if (!course) throw AppError.notFound('课程不存在');
return this.prisma.entitlement.upsert({
where: { user_id_course_id: { user_id: input.userId, course_id: input.courseId } },
create: {
user_id: input.userId,
course_id: input.courseId,
source: 3,
granted_at: new Date(),
expire_at: input.expireAt ?? null,
note: input.note ?? '后台手动发放',
},
update: {
expire_at: input.expireAt ?? null,
note: input.note ?? '后台手动发放',
},
});
}
async revokeEntitlement(userId: number, courseId: number): Promise<{ deleted: true }> {
await this.prisma.entitlement.delete({
where: { user_id_course_id: { user_id: userId, course_id: courseId } },
});
return { deleted: true };
}
}
function formatZodError(error: z.ZodError): string {
return error.issues.map((i) => `${i.path.join('.') || '(root)'}: ${i.message}`).join(';');
}