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; count: (args?: unknown) => Promise; findUnique: (args: { where: { id: number } }) => Promise | null>; create: (args: { data: Record }) => Promise; update: (args: { where: { id: number }; data: Record }) => Promise; delete: (args: { where: { id: number } }) => Promise; } @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)[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 = {}) { 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) } }); } 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).partial(); const parsed = partialSchema.safeParse(payload); if (!parsed.success) throw AppError.badRequest(formatZodError(parsed.error)); const data = { ...(parsed.data as Record) }; 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(';'); }