310 lines
12 KiB
TypeScript
310 lines
12 KiB
TypeScript
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(';');
|
||
}
|