四端上传

This commit is contained in:
tianmo committed 2026-09-29 15:25:40 +08:00
1 parent 25bbb5f9c8
commit c6e2cb4d5b
276 files changed
+17812 -3

No files matched your search

@@ -0,0 +1,308 @@
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,
};
}
// ── 用户 ───────────────────────────────────────────────────────────────
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(';');
}