四端上传
This commit is contained in:
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(';');
|
||||
}
|
||||
Reference in new issue
Block a user