四端上传

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

+58
View File
@@ -0,0 +1,58 @@
import { Module } from '@nestjs/common';
import { APP_FILTER, APP_GUARD, APP_INTERCEPTOR, Reflector } from '@nestjs/core';
import { JwtModule, JwtService } from '@nestjs/jwt';
import { ScheduleModule } from '@nestjs/schedule';
import { LoggerModule } from 'nestjs-pino';
import { APP_CONFIG, AppConfigModule, type AppConfig } from './config/config';
import { AuthGuard } from './common/auth';
import { AllExceptionsFilter, ResponseInterceptor } from './common/http';
import { PrismaModule } from './prisma/prisma.module';
import { HealthController } from './health.controller';
import { AdminModule } from './modules/admin/admin.module';
import { AuthModule } from './modules/auth/auth.module';
import { CoursesModule } from './modules/courses/courses.module';
import { HomeModule } from './modules/home/home.module';
import { MediaModule } from './modules/media/media.module';
import { OrdersModule } from './modules/orders/orders.module';
import { PaymentsModule } from './modules/payments/payments.module';
import { UsersModule } from './modules/users/users.module';
@Module({
imports: [
AppConfigModule,
PrismaModule,
ScheduleModule.forRoot(),
// 不设默认密钥:所有签发与校验都显式传密钥,避免用户态与管理端密钥混用
JwtModule.register({ global: true }),
LoggerModule.forRoot({
pinoHttp: {
level: process.env.NODE_ENV === 'production' ? 'info' : 'debug',
// 脱敏:授权头与 Cookie 一律不落日志
redact: ['req.headers.authorization', 'req.headers.cookie'],
autoLogging: { ignore: (req: { url?: string }) => req.url === '/healthz' },
},
}),
AuthModule,
UsersModule,
HomeModule,
CoursesModule,
MediaModule,
OrdersModule,
PaymentsModule,
AdminModule,
],
controllers: [HealthController],
providers: [
{
provide: APP_GUARD,
useFactory: (reflector: Reflector, jwt: JwtService, cfg: AppConfig) =>
new AuthGuard(reflector, jwt, cfg.JWT_ACCESS_SECRET),
inject: [Reflector, JwtService, APP_CONFIG],
},
{ provide: APP_INTERCEPTOR, useClass: ResponseInterceptor },
{ provide: APP_FILTER, useClass: AllExceptionsFilter },
],
})
export class AppModule {}
+76
View File
@@ -0,0 +1,76 @@
import {
type CanActivate,
createParamDecorator,
type ExecutionContext,
Injectable,
SetMetadata,
} from '@nestjs/common';
import { Reflector } from '@nestjs/core';
import { JwtService } from '@nestjs/jwt';
import type { Request } from 'express';
import { AppError } from './errors';
export type Audience = 'user' | 'admin';
export interface AuthPrincipal {
id: number;
audience: Audience;
}
const PUBLIC_KEY = 'isPublic';
const AUDIENCE_KEY = 'audience';
/** 标记无需登录的接口。 */
export const Public = (): MethodDecorator & ClassDecorator => SetMetadata(PUBLIC_KEY, true);
/** 标记该路由要求的身份类型,默认 user。 */
export const RequiresAudience = (audience: Audience): MethodDecorator & ClassDecorator =>
SetMetadata(AUDIENCE_KEY, audience);
export const IsPublic = (reflector: Reflector, ctx: ExecutionContext): boolean =>
reflector.getAllAndOverride<boolean>(PUBLIC_KEY, [ctx.getHandler(), ctx.getClass()]) === true;
export const CurrentUser = createParamDecorator((_data: unknown, ctx: ExecutionContext): AuthPrincipal => {
const req = ctx.switchToHttp().getRequest<Request & { principal?: AuthPrincipal }>();
if (!req.principal) throw AppError.unauthorized();
return req.principal;
});
/**
* 全局鉴权守卫。
*
* - 默认所有接口都需要登录
* - 用 @Public() 放行
* - 用 @RequiresAudience('admin') 要求管理员身份
* - 小程序 token 与管理后台 token 使用不同密钥与不同 audience,互不通用
*/
@Injectable()
export class AuthGuard implements CanActivate {
constructor(
private readonly reflector: Reflector,
private readonly jwt: JwtService,
private readonly accessSecret: string,
) {}
async canActivate(ctx: ExecutionContext): Promise<boolean> {
if (IsPublic(this.reflector, ctx)) return true;
const req = ctx.switchToHttp().getRequest<Request & { principal?: AuthPrincipal }>();
const header = req.headers.authorization ?? '';
const token = header.startsWith('Bearer ') ? header.slice(7).trim() : '';
if (!token) throw AppError.unauthorized();
const required =
this.reflector.getAllAndOverride<Audience>(AUDIENCE_KEY, [ctx.getHandler(), ctx.getClass()]) ?? 'user';
let payload: { sub: number | string; aud: Audience };
try {
payload = await this.jwt.verifyAsync(token, { secret: this.accessSecret, audience: required });
} catch {
throw AppError.unauthorized('登录已过期,请重新进入小程序');
}
req.principal = { id: Number(payload.sub), audience: payload.aud };
return true;
}
}
+49
View File
@@ -0,0 +1,49 @@
import { createCipheriv, createDecipheriv, createHash, randomBytes, scryptSync, timingSafeEqual } from 'node:crypto';
/**
* 用 AES-256-GCM 加密短字符串(当前用于 wx_session_key)。
* 输出格式:v1.<iv b64url>.<tag b64url>.<ciphertext b64url>
*/
export function encryptSecret(plain: string, secret: string): string {
const key = createHash('sha256').update(secret).digest();
const iv = randomBytes(12);
const cipher = createCipheriv('aes-256-gcm', key, iv);
const enc = Buffer.concat([cipher.update(plain, 'utf8'), cipher.final()]);
return ['v1', iv.toString('base64url'), cipher.getAuthTag().toString('base64url'), enc.toString('base64url')].join('.');
}
export function decryptSecret(payload: string, secret: string): string | null {
const parts = payload.split('.');
if (parts.length !== 4 || parts[0] !== 'v1') return null;
try {
const key = createHash('sha256').update(secret).digest();
const decipher = createDecipheriv('aes-256-gcm', key, Buffer.from(parts[1]!, 'base64url'));
decipher.setAuthTag(Buffer.from(parts[2]!, 'base64url'));
return Buffer.concat([decipher.update(Buffer.from(parts[3]!, 'base64url')), decipher.final()]).toString('utf8');
} catch {
return null;
}
}
/**
* 管理员口令哈希。用 Node 内置 scrypt,避免引入需要本地编译的 argon2。
* 格式:scrypt.<salt b64url>.<hash b64url>
*/
export function hashPassword(password: string): string {
const salt = randomBytes(16);
const hash = scryptSync(password, salt, 64);
return ['scrypt', salt.toString('base64url'), hash.toString('base64url')].join('.');
}
export function verifyPassword(password: string, stored: string): boolean {
const parts = stored.split('.');
if (parts.length !== 3 || parts[0] !== 'scrypt') return false;
try {
const expected = Buffer.from(parts[2]!, 'base64url');
const actual = scryptSync(password, Buffer.from(parts[1]!, 'base64url'), expected.length);
return timingSafeEqual(expected, actual);
} catch {
return false;
}
}
+73
View File
@@ -0,0 +1,73 @@
import { HttpException } from '@nestjs/common';
/**
* 业务错误码分段。HTTP 状态码独立设置,两者互不替代。
* 0 成功
* 1000-1999 通用
* 2000-2999 鉴权
* 3000-3999 业务
* 4000-4999 支付
* 5000+ 服务端
*/
export const ErrorCode = {
OK: 0,
BAD_REQUEST: 1000,
NOT_FOUND: 1001,
CONFLICT: 1002,
UNAUTHORIZED: 2000,
TOKEN_EXPIRED: 2001,
FORBIDDEN: 2002,
NO_ENTITLEMENT: 3000,
COURSE_OFFLINE: 3001,
LOGIN_FAILED: 3002,
ORDER_INVALID_STATE: 4000,
PAY_SIGN_FAILED: 4001,
PAY_NOT_ENABLED: 4002,
INTERNAL: 5000,
} as const;
export type ErrorCodeValue = (typeof ErrorCode)[keyof typeof ErrorCode];
/** 业务异常。被全局异常过滤器转换为统一响应体。 */
export class AppError extends HttpException {
constructor(
readonly code: ErrorCodeValue,
message: string,
httpStatus: number,
) {
super(message, httpStatus);
}
static badRequest(message: string): AppError {
return new AppError(ErrorCode.BAD_REQUEST, message, 422);
}
static notFound(message = '资源不存在'): AppError {
return new AppError(ErrorCode.NOT_FOUND, message, 404);
}
static unauthorized(message = '请先登录'): AppError {
return new AppError(ErrorCode.UNAUTHORIZED, message, 401);
}
static forbidden(message = '无权访问'): AppError {
return new AppError(ErrorCode.FORBIDDEN, message, 403);
}
static noEntitlement(message = '尚未拥有该课程'): AppError {
return new AppError(ErrorCode.NO_ENTITLEMENT, message, 403);
}
static offline(message = '课程已下架'): AppError {
return new AppError(ErrorCode.COURSE_OFFLINE, message, 403);
}
static conflict(message: string): AppError {
return new AppError(ErrorCode.CONFLICT, message, 409);
}
}
+92
View File
@@ -0,0 +1,92 @@
import {
type ArgumentsHost,
Catch,
type CallHandler,
type ExceptionFilter,
type ExecutionContext,
HttpException,
Injectable,
Logger,
type NestInterceptor,
} from '@nestjs/common';
import type { Request, Response } from 'express';
import { randomUUID } from 'node:crypto';
import type { Observable } from 'rxjs';
import { map } from 'rxjs/operators';
import { AppError, ErrorCode } from './errors';
export interface ApiResponse<T> {
code: number;
message: string;
data: T;
requestId: string;
}
export function getRequestId(req: Request): string {
return (req as Request & { requestId?: string }).requestId ?? randomUUID();
}
/**
* 为每个请求分配 requestId,并统一包装成功响应。
*
* 这里刻意不写成泛型:响应体的 data 由各控制器决定,
* 用 unknown 承接可以避免 Nest 的类型推导在每个控制器上打架。
*/
@Injectable()
export class ResponseInterceptor implements NestInterceptor<unknown, ApiResponse<unknown>> {
intercept(context: ExecutionContext, next: CallHandler<unknown>): Observable<ApiResponse<unknown>> {
const req = context.switchToHttp().getRequest<Request>();
const requestId = randomUUID();
(req as Request & { requestId?: string }).requestId = requestId;
context.switchToHttp().getResponse<Response>().setHeader('x-request-id', requestId);
return next.handle().pipe(
map((data: unknown): ApiResponse<unknown> => ({
code: ErrorCode.OK,
message: 'ok',
data: data === undefined ? null : data,
requestId,
})),
);
}
}
/** 把异常统一成同一响应体。 */
@Catch()
export class AllExceptionsFilter implements ExceptionFilter {
private readonly logger = new Logger('Exception');
catch(exception: unknown, host: ArgumentsHost): void {
const ctx = host.switchToHttp();
const res = ctx.getResponse<Response>();
const req = ctx.getRequest<Request>();
const requestId = getRequestId(req);
let code: number = ErrorCode.INTERNAL;
let message = '服务端异常';
let status = 500;
if (exception instanceof AppError) {
code = exception.code;
message = exception.message;
status = exception.getStatus();
} else if (exception instanceof HttpException) {
status = exception.getStatus();
const body = exception.getResponse();
message =
typeof body === 'string'
? body
: ((body as { message?: string | string[] }).message as string) ?? exception.message;
if (Array.isArray(message)) message = message.join(';');
code = status === 401 ? ErrorCode.UNAUTHORIZED : status === 403 ? ErrorCode.FORBIDDEN : ErrorCode.BAD_REQUEST;
} else {
this.logger.error(
`未处理异常 [${requestId}] ${req.method} ${req.url}: ${
exception instanceof Error ? exception.stack : String(exception)
}`,
);
}
res.status(status).json({ code, message, data: null, requestId });
}
}
+23
View File
@@ -0,0 +1,23 @@
/**
* 静态资源 URL 构造。
*
* 这些路径由小程序直接拼接使用,**目录名与文件名规则不可更改**,
* 否则线上资源全部 404。所有拼接集中在此处,便于审查。
*/
const join = (base: string, ...parts: string[]): string =>
[base.replace(/\/+$/, ''), ...parts.map((p) => String(p).replace(/^\/+/, ''))].join('/');
export const staticUrls = {
banner: (base: string, filename: string): string => join(base, 'Images/Banner', filename),
newCourseCover: (base: string, courseName: string): string =>
join(base, 'Images/NewCourse', `${courseName}.png`),
freeCourseCover: (base: string, courseName: string): string =>
join(base, 'Images/FreeCourse', `${courseName}.png`),
articleThumb: (base: string, legacyPageId: string): string => join(base, 'WebPages', `${legacyPageId}.png`),
courseCover: (base: string, courseCode: string): string => join(base, 'Course', `${courseCode}.png`),
headicon: (base: string, headiconId: number): string =>
join(base, 'Images/Headicons', `Headicon${headiconId}.png`),
wechatCode: (base: string, filename: string): string => join(base, 'Images/WXCode', `${filename}.png`),
};
+73
View File
@@ -0,0 +1,73 @@
import { Global, Inject, Module } from '@nestjs/common';
import { z } from 'zod';
/** 进程启动时加载 .env(Node 20.12+ 内置,无需 dotenv)。 */
export function loadEnvFile(path = '.env'): void {
try {
process.loadEnvFile(path);
} catch {
// 文件不存在时忽略:生产环境通常由进程管理器注入环境变量
}
}
const envSchema = z.object({
NODE_ENV: z.enum(['development', 'test', 'production']).default('development'),
PORT: z.coerce.number().int().positive().default(3000),
TZ: z.string().default('Asia/Shanghai'),
DATABASE_URL: z.string().min(1, '缺少数据库连接串'),
/** 对外域名 */
API_BASE_URL: z.string().url(),
STATIC_BASE_URL: z.string().url(),
ADMIN_ORIGIN: z.string().url(),
/** 密钥:生产环境必须替换为随机值 */
JWT_ACCESS_SECRET: z.string().min(16),
JWT_REFRESH_SECRET: z.string().min(16),
/** 用于 AES-256-GCM 加密存储用户 sessionKey,长度至少 32 */
SESSION_KEY_ENC_SECRET: z.string().min(32),
/** 微信小程序 */
WX_APPID: z.string().min(1),
WX_APPSECRET: z.string().min(1),
/** 虚拟支付,未开通时留空 */
WX_OFFER_ID: z.string().default(''),
WX_PAY_APPKEY: z.string().default(''),
/** 播放地址签名有效期(秒) */
PLAY_URL_TTL_SECONDS: z.coerce.number().int().positive().default(1800),
/** 播放地址签名密钥 */
MEDIA_SIGN_SECRET: z.string().min(16),
/** 课程数据端根目录(媒体文件所在目录) */
DATA_ROOT: z.string().min(1).default('../yelaymaths_data.yeomai.site'),
});
export type AppConfig = z.infer<typeof envSchema>;
export const APP_CONFIG = Symbol('APP_CONFIG');
/** 供 NestJS 之外的工具脚本使用。 */
export function readConfig(): AppConfig {
const parsed = envSchema.safeParse(process.env);
if (!parsed.success) {
const detail = parsed.error.issues
.map((i) => ` ${i.path.join('.') || '(root)'}: ${i.message}`)
.join('\n');
throw new Error(`环境变量校验失败:\n${detail}`);
}
return parsed.data;
}
@Global()
@Module({
providers: [{ provide: APP_CONFIG, useFactory: readConfig }],
exports: [APP_CONFIG],
})
export class AppConfigModule {}
/** 注入配置的类型安全简写。 */
export const InjectConfig = (): ParameterDecorator => Inject(APP_CONFIG);
+22
View File
@@ -0,0 +1,22 @@
import { Controller, Get } from '@nestjs/common';
import { Public } from './common/auth';
import { PrismaService } from './prisma/prisma.service';
@Controller()
export class HealthController {
constructor(private readonly prisma: PrismaService) {}
/** 探活。数据库不可用时不返回 500,只标记 db 状态,便于区分进程挂掉与数据库挂掉。 */
@Public()
@Get('healthz')
async health() {
let db = 'ok';
try {
await this.prisma.$queryRaw`SELECT 1`;
} catch {
db = 'down';
}
return { status: 'ok', db, time: new Date().toISOString() };
}
}
+32
View File
@@ -0,0 +1,32 @@
import { NestFactory } from '@nestjs/core';
import { Logger } from 'nestjs-pino';
import { AppModule } from './app.module';
import { loadEnvFile, readConfig } from './config/config';
async function bootstrap(): Promise<void> {
loadEnvFile();
const cfg = readConfig();
const app = await NestFactory.create(AppModule, {
// 虚拟支付回调是 XML,需要拿到原始报文
rawBody: true,
bufferLogs: true,
});
app.useLogger(app.get(Logger));
app.getHttpAdapter().getInstance().set('trust proxy', 1);
// 管理后台跨域;小程序不走 CORS
app.enableCors({
origin: [cfg.ADMIN_ORIGIN],
credentials: true,
});
app.enableShutdownHooks();
await app.listen(cfg.PORT, '0.0.0.0');
app.get(Logger).log(`API 已启动:${cfg.API_BASE_URL} 端口 ${cfg.PORT}`);
}
void bootstrap();
@@ -0,0 +1,172 @@
import { Body, Controller, Delete, Get, Param, ParseIntPipe, Patch, Post, Put, Query } from '@nestjs/common';
import { z } from 'zod';
import { CurrentUser, Public, RequiresAudience, type AuthPrincipal } from '../../common/auth';
import { AppError } from '../../common/errors';
import { AuthService } from '../auth/auth.service';
import { AdminService } from './admin.service';
const loginSchema = z.object({ username: z.string().min(1), password: z.string().min(1) });
const bootstrapSchema = z.object({ username: z.string().min(3), password: z.string().min(8) });
const updateUserSchema = z
.object({
nickname: z.string().max(32).optional(),
headicon_id: z.coerce.number().int().min(0).optional(),
status: z.coerce.number().int().min(0).max(1).optional(),
})
.strict();
const settingSchema = z.object({ key: z.string().min(1).max(64), value: z.string(), remark: z.string().optional() });
const grantSchema = z.object({
userId: z.coerce.number().int().positive(),
courseId: z.coerce.number().int().positive(),
expireAt: z.coerce.date().nullish(),
note: z.string().max(255).optional(),
});
const refundSchema = z.object({ reason: z.string().min(1).max(100) });
/** 管理员登录。必须公开,否则登录接口本身会被守卫拦住。 */
@Public()
@Controller('admin/v1/auth')
export class AdminAuthController {
constructor(
private readonly admin: AdminService,
private readonly auth: AuthService,
) {}
@Post('login')
async login(@Body() body: unknown) {
const parsed = loginSchema.safeParse(body);
if (!parsed.success) throw AppError.badRequest('请输入账号与密码');
const admin = await this.admin.verifyAdmin(parsed.data.username, parsed.data.password);
return { accessToken: await this.auth.signAdminToken(admin.id), admin };
}
/**
* 首次部署创建超管。仅当系统中尚无任何管理员时生效,
* 因此可以安全地保留在接口里作为初始化入口。
*/
@Post('bootstrap')
bootstrap(@Body() body: unknown) {
const parsed = bootstrapSchema.safeParse(body);
if (!parsed.success) throw AppError.badRequest('用户名至少 3 位,密码至少 8 位');
return this.admin.bootstrapAdmin(parsed.data.username, parsed.data.password);
}
}
/** 管理后台主接口。全部要求管理员身份。 */
@RequiresAudience('admin')
@Controller('admin/v1')
export class AdminController {
constructor(private readonly admin: AdminService) {}
@Get('stats')
stats() {
return this.admin.stats();
}
// ── 用户 ───────────────────────────────────────────────────────────────
@Get('users')
listUsers(
@Query('page') page?: string,
@Query('pageSize') pageSize?: string,
@Query('keyword') keyword?: string,
) {
return this.admin.listUsers(Number(page) || 1, Number(pageSize) || 20, keyword ?? '');
}
@Patch('users/:id')
updateUser(@Param('id', ParseIntPipe) id: number, @Body() body: unknown) {
const parsed = updateUserSchema.safeParse(body);
if (!parsed.success) throw AppError.badRequest('参数错误');
return this.admin.updateUser(id, parsed.data);
}
// ── 通用内容 CRUD ──────────────────────────────────────────────────────
@Get('data/:resource')
listResource(
@Param('resource') resource: string,
@Query('page') page?: string,
@Query('pageSize') pageSize?: string,
@Query('keyword') keyword?: string,
@Query('courseId') courseId?: string,
) {
const filter = courseId ? { course_id: Number(courseId) } : {};
return this.admin.listResource(resource, Number(page) || 1, Number(pageSize) || 20, keyword ?? '', filter);
}
@Post('data/:resource')
createResource(@Param('resource') resource: string, @Body() body: unknown) {
return this.admin.createResource(resource, body);
}
@Patch('data/:resource/:id')
updateResource(
@Param('resource') resource: string,
@Param('id', ParseIntPipe) id: number,
@Body() body: unknown,
) {
return this.admin.updateResource(resource, id, body);
}
@Delete('data/:resource/:id')
deleteResource(@Param('resource') resource: string, @Param('id', ParseIntPipe) id: number) {
return this.admin.deleteResource(resource, id);
}
// ── 配置 ───────────────────────────────────────────────────────────────
@Get('settings')
listSettings() {
return this.admin.listSettings();
}
@Put('settings')
upsertSetting(@Body() body: unknown) {
const parsed = settingSchema.safeParse(body);
if (!parsed.success) throw AppError.badRequest('参数错误');
return this.admin.upsertSetting(parsed.data.key, parsed.data.value, parsed.data.remark);
}
// ── 订单 / 权益 ────────────────────────────────────────────────────────
@Get('orders')
listOrders(@Query('page') page?: string, @Query('pageSize') pageSize?: string, @Query('status') status?: string) {
const s = status === undefined || status === '' ? undefined : Number(status);
return this.admin.listOrders(Number(page) || 1, Number(pageSize) || 20, Number.isFinite(s) ? s : undefined);
}
@Post('orders/:id/refund')
refundOrder(@Param('id', ParseIntPipe) id: number, @Body() body: unknown) {
const parsed = refundSchema.safeParse(body);
if (!parsed.success) throw AppError.badRequest('请填写退款原因');
return this.admin.refundOrder(id, parsed.data.reason);
}
@Get('entitlements')
listEntitlements(@Query('userId', ParseIntPipe) userId: number) {
return this.admin.listEntitlements(userId);
}
@Post('entitlements')
grantEntitlement(@Body() body: unknown) {
const parsed = grantSchema.safeParse(body);
if (!parsed.success) throw AppError.badRequest('参数错误');
return this.admin.grantEntitlement({
userId: parsed.data.userId,
courseId: parsed.data.courseId,
expireAt: parsed.data.expireAt ?? null,
note: parsed.data.note,
});
}
@Delete('entitlements')
revokeEntitlement(@Query('userId', ParseIntPipe) userId: number, @Query('courseId', ParseIntPipe) courseId: number) {
return this.admin.revokeEntitlement(userId, courseId);
}
@Get('me')
me(@CurrentUser() admin: AuthPrincipal) {
return { id: admin.id, audience: admin.audience };
}
}
@@ -0,0 +1,13 @@
import { Module } from '@nestjs/common';
import { AuthModule } from '../auth/auth.module';
import { PaymentsModule } from '../payments/payments.module';
import { AdminAuthController, AdminController } from './admin.controller';
import { AdminService } from './admin.service';
@Module({
imports: [AuthModule, PaymentsModule],
controllers: [AdminAuthController, AdminController],
providers: [AdminService],
})
export class AdminModule {}
@@ -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(';');
}
@@ -0,0 +1,139 @@
import { z } from 'zod';
/**
* 可 CRUD 的资源注册表。
*
* 字段名与 Prisma 模型一致(snake_case),因此校验通过后可直接透传给 Prisma,
* 不需要额外的字段映射层。
*
* 一个通用控制器 + 这张表覆盖了管理后台的绝大部分内容管理功能。
*/
const dateish = z.union([z.string(), z.date(), z.null()]);
const optionalDate = dateish.optional();
export interface ResourceDef {
/** Prisma 委托名,如 'course' → prisma.course */
delegate: string;
create: z.ZodTypeAny;
/** 创建后不可修改的字段(如 courses.code,改动会让视频路径失效) */
immutable?: string[];
orderBy: Record<string, 'asc' | 'desc'>[];
/** 列表模糊搜索字段 */
searchField?: string;
/** 创建时需要自动补齐的字段 */
defaults?: Record<string, unknown>;
}
export const RESOURCES: Record<string, ResourceDef> = {
courses: {
delegate: 'course',
create: z.object({
code: z.string().min(1).max(64),
title: z.string().min(1).max(128),
intro: z.string().max(255).nullish(),
cover_image: z.string().max(255).nullish(),
price_fen: z.coerce.number().int().min(0).default(0),
status: z.coerce.number().int().min(0).max(1).default(1),
sort_order: z.coerce.number().int().default(0),
}),
// code 是视频目录名与封面名,改动即失效,只允许创建时设置
immutable: ['code'],
orderBy: [{ sort_order: 'asc' }, { id: 'asc' }],
searchField: 'title',
},
lessons: {
delegate: 'lesson',
create: z.object({
course_id: z.coerce.number().int().positive(),
play_id: z.string().min(1).max(64),
chapter_id: z.string().min(1).max(64),
chapter_title: z.string().max(255).nullish(),
lesson_title: z.string().max(255).nullish(),
duration: z.string().max(16).nullish(),
published_at: optionalDate,
cover_image: z.string().max(255).nullish(),
sort_order: z.coerce.number().int().default(0),
}),
orderBy: [{ sort_order: 'asc' }, { id: 'asc' }],
searchField: 'lesson_title',
},
'home-courses': {
delegate: 'homeCourse',
create: z.object({
course_name: z.string().min(1).max(255),
intro: z.string().max(255).nullish(),
published_at: optionalDate,
cover_image: z.string().max(255).nullish(),
content: z.string().nullish(),
course_id: z.coerce.number().int().positive().nullish(),
sort_order: z.coerce.number().int().default(0),
status: z.coerce.number().int().min(0).max(1).default(1),
}),
orderBy: [{ sort_order: 'asc' }, { id: 'asc' }],
searchField: 'course_name',
},
'home-free-courses': {
delegate: 'homeFreeCourse',
create: z.object({
series_no: z.coerce.number().int(),
course_name: z.string().min(1).max(255),
intro: z.string().max(255).nullish(),
published_at: optionalDate,
cover_image: z.string().max(255).nullish(),
sort_order: z.coerce.number().int().default(0),
status: z.coerce.number().int().min(0).max(1).default(1),
}),
orderBy: [{ sort_order: 'asc' }, { id: 'asc' }],
searchField: 'course_name',
},
banners: {
delegate: 'banner',
create: z.object({
filename: z.string().min(1).max(255),
sort_order: z.coerce.number().int().default(0),
status: z.coerce.number().int().min(0).max(1).default(1),
}),
orderBy: [{ sort_order: 'asc' }, { id: 'asc' }],
searchField: 'filename',
},
articles: {
delegate: 'article',
create: z.object({
legacy_page_id: z.string().max(32).nullish(),
title: z.string().min(1).max(255),
intro: z.string().max(255).nullish(),
publisher: z.string().max(255).nullish(),
published_at: optionalDate,
content: z.string().nullish(),
sort_order: z.coerce.number().int().default(0),
status: z.coerce.number().int().min(0).max(1).default(1),
}),
orderBy: [{ published_at: 'desc' }, { id: 'desc' }],
searchField: 'title',
},
products: {
delegate: 'product',
create: z.object({
course_id: z.coerce.number().int().positive(),
wx_product_id: z.string().min(1).max(64),
name: z.string().min(1).max(128),
price_fen: z.coerce.number().int().min(0),
status: z.coerce.number().int().min(0).max(1).default(1),
}),
orderBy: [{ id: 'asc' }],
searchField: 'name',
},
};
/** 免费试听课时(只读展示用,实际内容由数据端维护) */
export const READONLY_RESOURCES: Record<string, { delegate: string; orderBy: Record<string, 'asc' | 'desc'>[] }> = {
'free-lessons': { delegate: 'freeLesson', orderBy: [{ series_no: 'asc' }, { sort_order: 'asc' }] },
};
@@ -0,0 +1,35 @@
import { Body, Controller, Post } from '@nestjs/common';
import { z } from 'zod';
import { CurrentUser, Public, type AuthPrincipal } from '../../common/auth';
import { AppError } from '../../common/errors';
import { AuthService, type LoginResult } from './auth.service';
const loginSchema = z.object({ code: z.string().min(1) });
const refreshSchema = z.object({ refreshToken: z.string().min(1) });
@Controller('v1/auth')
export class AuthController {
constructor(private readonly auth: AuthService) {}
@Public()
@Post('wx-login')
async wxLogin(@Body() body: unknown): Promise<LoginResult> {
const parsed = loginSchema.safeParse(body);
if (!parsed.success) throw AppError.badRequest('参数错误:缺少 code');
return this.auth.loginWithCode(parsed.data.code);
}
@Public()
@Post('refresh')
refresh(@Body() body: unknown): Promise<{ accessToken: string }> {
const parsed = refreshSchema.safeParse(body);
if (!parsed.success) throw AppError.badRequest('参数错误:缺少 refreshToken');
return this.auth.refresh(parsed.data.refreshToken);
}
@Post('logout')
logout(@CurrentUser() user: AuthPrincipal): Promise<null> {
return this.auth.logOut(user.id);
}
}
@@ -0,0 +1,12 @@
import { Module } from '@nestjs/common';
import { AuthController } from './auth.controller';
import { AuthService } from './auth.service';
import { WechatService } from './wechat.service';
@Module({
controllers: [AuthController],
providers: [AuthService, WechatService],
exports: [AuthService],
})
export class AuthModule {}
@@ -0,0 +1,103 @@
import { Inject, Injectable } from '@nestjs/common';
import { JwtService } from '@nestjs/jwt';
import { APP_CONFIG, type AppConfig } from '../../config/config';
import { encryptSecret } from '../../common/crypto';
import { AppError } from '../../common/errors';
import { PrismaService } from '../../prisma/prisma.service';
import { WechatService } from './wechat.service';
// 用字面量类型,匹配 jsonwebtoken 的 expiresIn 签名
export const ACCESS_TTL = '2h' as const;
export const REFRESH_TTL = '30d' as const;
export interface LoginResult {
accessToken: string;
refreshToken: string;
user: { id: number; nickname: string | null; headiconId: number; isNew: boolean };
}
@Injectable()
export class AuthService {
constructor(
@Inject(APP_CONFIG) private readonly cfg: AppConfig,
private readonly prisma: PrismaService,
private readonly jwt: JwtService,
private readonly wechat: WechatService,
) {}
async loginWithCode(code: string): Promise<LoginResult> {
if (!code) throw AppError.badRequest('缺少 code');
const session = await this.wechat.code2Session(code);
const existing = await this.prisma.user.findUnique({ where: { openid: session.openid } });
// sessionKey 必须托管:虚拟支付的用户态签名依赖它,且只在登录时能拿到
const now = new Date();
const sessionKeyEnc = encryptSecret(session.sessionKey, this.cfg.SESSION_KEY_ENC_SECRET);
const user = existing
? await this.prisma.user.update({
where: { id: existing.id },
data: { wx_session_key: sessionKeyEnc, session_key_updated_at: now, last_login_at: now },
})
: await this.prisma.user.create({
data: {
openid: session.openid,
wx_session_key: sessionKeyEnc,
session_key_updated_at: now,
registered_at: now,
last_login_at: now,
},
});
if (user.status !== 1) throw AppError.forbidden('账号已被封禁');
return {
accessToken: await this.sign(user.id, 'user', ACCESS_TTL, this.cfg.JWT_ACCESS_SECRET),
refreshToken: await this.sign(user.id, 'user', REFRESH_TTL, this.cfg.JWT_REFRESH_SECRET),
user: { id: user.id, nickname: user.nickname, headiconId: user.headicon_id, isNew: !existing },
};
}
async refresh(refreshToken: string): Promise<{ accessToken: string }> {
if (!refreshToken) throw AppError.badRequest('缺少 refreshToken');
let sub: number;
try {
const payload = await this.jwt.verifyAsync<{ sub: number }>(refreshToken, {
secret: this.cfg.JWT_REFRESH_SECRET,
audience: 'user',
});
sub = Number(payload.sub);
} catch {
throw AppError.unauthorized('登录已过期,请重新进入小程序');
}
const user = await this.prisma.user.findUnique({ where: { id: sub } });
if (!user || user.status !== 1) throw AppError.unauthorized();
return { accessToken: await this.sign(user.id, 'user', ACCESS_TTL, this.cfg.JWT_ACCESS_SECRET) };
}
/** 退出:清掉服务端托管的 sessionKey,前端丢弃 token。 */
async logOut(userId: number): Promise<null> {
await this.prisma.user.updateMany({
where: { id: userId },
data: { wx_session_key: null, session_key_updated_at: null },
});
return null;
}
/** 管理员登录签发,供管理端模块复用。 */
signAdminToken(adminId: number): Promise<string> {
return this.sign(adminId, 'admin', ACCESS_TTL, this.cfg.JWT_ACCESS_SECRET);
}
private sign(
sub: number,
aud: 'user' | 'admin',
expiresIn: ExpiresIn,
secret: string,
): Promise<string> {
return this.jwt.signAsync({ sub }, { audience: aud, expiresIn, secret });
}
}
/** jsonwebtoken 接受的过期时间写法(避免直接依赖其类型包) */
type ExpiresIn = number | `${number}${'s' | 'm' | 'h' | 'd' | 'w' | 'y'}`;
@@ -0,0 +1,44 @@
import { Inject, Injectable, Logger } from '@nestjs/common';
import { APP_CONFIG, type AppConfig } from '../../config/config';
import { AppError } from '../../common/errors';
interface Code2SessionResult {
openid?: string;
session_key?: string;
unionid?: string;
errcode?: number;
errmsg?: string;
}
export interface WxSession {
openid: string;
sessionKey: string;
unionid: string | null;
}
/** 微信开放接口封装。仅 code2Session 一个方法,用 Node 内置 fetch,不引第三方 SDK。 */
@Injectable()
export class WechatService {
private readonly logger = new Logger(WechatService.name);
constructor(@Inject(APP_CONFIG) private readonly cfg: AppConfig) {}
async code2Session(code: string): Promise<WxSession> {
const url =
'https://api.weixin.qq.com/sns/jscode2session' +
`?appid=${encodeURIComponent(this.cfg.WX_APPID)}` +
`&secret=${encodeURIComponent(this.cfg.WX_APPSECRET)}` +
`&js_code=${encodeURIComponent(code)}` +
'&grant_type=authorization_code';
const res = await fetch(url);
const body = (await res.json()) as Code2SessionResult;
if (!body.openid || !body.session_key) {
this.logger.warn(`code2Session 失败:errcode=${body.errcode ?? '-'} errmsg=${body.errmsg ?? ''}`);
throw new AppError(3002, '微信登录失败,请重试', 401);
}
return { openid: body.openid, sessionKey: body.session_key, unionid: body.unionid ?? null };
}
}
@@ -0,0 +1,44 @@
import { Controller, Get, Param, Query } from '@nestjs/common';
import { CurrentUser, Public, type AuthPrincipal } from '../../common/auth';
import { AppError } from '../../common/errors';
import { CoursesService } from './courses.service';
@Controller('v1')
export class CoursesController {
constructor(private readonly courses: CoursesService) {}
@Get('courses')
list(@CurrentUser() user: AuthPrincipal) {
return this.courses.list(user.id);
}
@Get('courses/:code')
detail(@CurrentUser() user: AuthPrincipal, @Param('code') code: string) {
return this.courses.detail(user.id, code);
}
@Get('courses/:code/lessons')
lessons(@CurrentUser() user: AuthPrincipal, @Param('code') code: string) {
return this.courses.lessons(user.id, code);
}
@Get('courses/:code/play')
play(
@CurrentUser() user: AuthPrincipal,
@Param('code') code: string,
@Query('chapterId') chapterId?: string,
@Query('playId') playId?: string,
) {
if (!chapterId || !playId) throw AppError.badRequest('参数错误:缺少 chapterId 或 playId');
return this.courses.playUrl(user.id, code, chapterId, playId);
}
@Public()
@Get('free-lessons')
freeLessons(@Query('seriesNo') seriesNo?: string) {
if (seriesNo === undefined || seriesNo === '') return this.courses.freeLessons();
const n = Number(seriesNo);
return this.courses.freeLessons(Number.isFinite(n) ? n : undefined);
}
}
@@ -0,0 +1,13 @@
import { Module } from '@nestjs/common';
import { MediaModule } from '../media/media.module';
import { CoursesController } from './courses.controller';
import { CoursesService } from './courses.service';
@Module({
imports: [MediaModule],
controllers: [CoursesController],
providers: [CoursesService],
exports: [CoursesService],
})
export class CoursesModule {}
@@ -0,0 +1,146 @@
import { Inject, Injectable } from '@nestjs/common';
import { APP_CONFIG, type AppConfig } from '../../config/config';
import { AppError } from '../../common/errors';
import { staticUrls } from '../../common/urls';
import { PrismaService } from '../../prisma/prisma.service';
import { MediaService } from '../media/media.service';
@Injectable()
export class CoursesService {
constructor(
@Inject(APP_CONFIG) private readonly cfg: AppConfig,
private readonly prisma: PrismaService,
private readonly media: MediaService,
) {}
private staticBase(): string {
return this.cfg.STATIC_BASE_URL;
}
private async ownedCourseIds(userId: number): Promise<Set<number>> {
const rows = await this.prisma.entitlement.findMany({
where: { user_id: userId },
select: { course_id: true },
});
return new Set(rows.map((r) => r.course_id));
}
/** 课程列表。含未购买课程,供小程序做购买入口。 */
async list(userId: number) {
const [courses, owned] = await Promise.all([
this.prisma.course.findMany({
where: { status: 1 },
orderBy: [{ sort_order: 'asc' }, { id: 'asc' }],
include: { _count: { select: { lessons: true } } },
}),
this.ownedCourseIds(userId),
]);
return courses.map((c) => ({
id: c.id,
code: c.code,
title: c.title,
intro: c.intro,
priceFen: c.price_fen,
lessonCount: c._count.lessons,
coverUrl: staticUrls.courseCover(this.staticBase(), c.code),
owned: owned.has(c.id),
}));
}
async detail(userId: number, code: string) {
const course = await this.prisma.course.findUnique({ where: { code } });
if (!course || course.status !== 1) throw AppError.notFound('课程不存在或已下架');
const owned = await this.prisma.entitlement.findUnique({
where: { user_id_course_id: { user_id: userId, course_id: course.id } },
});
return {
id: course.id,
code: course.code,
title: course.title,
intro: course.intro,
priceFen: course.price_fen,
coverUrl: staticUrls.courseCover(this.staticBase(), course.code),
owned: owned !== null,
};
}
/** 课时列表。必须拥有该课程才能取到。 */
async lessons(userId: number, code: string) {
const course = await this.requireEntitlement(userId, code);
const rows = await this.prisma.lesson.findMany({
where: { course_id: course.id },
orderBy: [{ sort_order: 'asc' }, { id: 'asc' }],
});
return rows.map((l) => ({
id: l.id,
chapterId: l.chapter_id,
playId: l.play_id,
chapterTitle: l.chapter_title,
lessonTitle: l.lesson_title,
duration: l.duration,
publishedAt: l.published_at,
coverUrl: staticUrls.courseCover(this.staticBase(), course.code),
}));
}
/** 下发带签名的播放地址。视频不做校验拿不到,防盗链的关键在这里。 */
async playUrl(userId: number, code: string, chapterId: string, playId: string) {
const course = await this.requireEntitlement(userId, code);
const lesson = await this.prisma.lesson.findFirst({
where: { course_id: course.id, chapter_id: chapterId, play_id: playId },
});
if (!lesson) throw AppError.notFound('课时不存在');
const relative = `Course/${course.code}/${lesson.chapter_id}/${lesson.play_id}/playlist.m3u8`;
return {
url: this.media.buildSignedUrl(relative),
expiresIn: this.cfg.PLAY_URL_TTL_SECONDS,
title: lesson.lesson_title,
};
}
/** 免费试听课。公开内容,直接给静态地址,不签名。 */
async freeLessons(seriesNo?: number) {
const rows = await this.prisma.freeLesson.findMany({
where: seriesNo === undefined ? undefined : { series_no: seriesNo },
orderBy: [{ series_no: 'asc' }, { sort_order: 'asc' }],
});
const base = this.staticBase().replace(/\/+$/, '');
return rows.map((l) => ({
id: l.id,
seriesNo: l.series_no,
courseName: l.course_name,
videoTitle: l.video_title,
intro: l.intro,
publisher: l.publisher,
publishedAt: l.published_at,
duration: l.duration,
coverUrl: l.cover_image ? `${base}/${l.cover_image.replace(/^\/+/, '')}` : null,
videoUrl: l.video_m3u8 ? `${base}/${l.video_m3u8.replace(/^\/+/, '')}` : null,
}));
}
/** 取课程并校验权益,是唯一的鉴权入口。 */
private async requireEntitlement(userId: number, code: string) {
const course = await this.prisma.course.findUnique({ where: { code } });
if (!course) throw AppError.notFound('课程不存在');
if (course.status !== 1) throw AppError.offline();
const owned = await this.prisma.entitlement.findUnique({
where: { user_id_course_id: { user_id: userId, course_id: course.id } },
});
if (!owned) throw AppError.noEntitlement();
if (owned.expire_at && owned.expire_at.getTime() < Date.now()) {
throw AppError.noEntitlement('课程已过期');
}
return course;
}
}
@@ -0,0 +1,51 @@
import { Controller, Get, Param, ParseIntPipe, Query } from '@nestjs/common';
import { Public } from '../../common/auth';
import { HomeService } from './home.service';
@Controller('v1')
export class HomeController {
constructor(private readonly home: HomeService) {}
@Public()
@Get('config')
getConfig(): Promise<Record<string, string>> {
return this.home.getConfig();
}
@Public()
@Get('banners')
getBanners() {
return this.home.getBanners();
}
@Public()
@Get('home/courses')
getHomeCourses() {
return this.home.getHomeCourses();
}
@Public()
@Get('home/free-courses')
getHomeFreeCourses() {
return this.home.getHomeFreeCourses();
}
@Public()
@Get('articles')
getArticles(@Query('page') page?: string, @Query('pageSize') pageSize?: string) {
return this.home.getArticles(Number(page) || 1, Number(pageSize) || 10);
}
@Public()
@Get('articles/:id')
getArticle(@Param('id', ParseIntPipe) id: number) {
return this.home.getArticle(id);
}
@Public()
@Get('tips')
getTips() {
return this.home.getTips();
}
}
@@ -0,0 +1,10 @@
import { Module } from '@nestjs/common';
import { HomeController } from './home.controller';
import { HomeService } from './home.service';
@Module({
controllers: [HomeController],
providers: [HomeService],
})
export class HomeModule {}
@@ -0,0 +1,123 @@
import { Inject, Injectable } from '@nestjs/common';
import { APP_CONFIG, type AppConfig } from '../../config/config';
import { staticUrls } from '../../common/urls';
import { PrismaService } from '../../prisma/prisma.service';
const PAGE_SIZE_MAX = 50;
@Injectable()
export class HomeService {
constructor(
@Inject(APP_CONFIG) private readonly cfg: AppConfig,
private readonly prisma: PrismaService,
) {}
private get staticBase(): string {
return this.cfg.STATIC_BASE_URL;
}
/** 站点配置,返回 key → value 的扁平对象。 */
async getConfig(): Promise<Record<string, string>> {
const rows = await this.prisma.setting.findMany();
const map: Record<string, string> = {};
for (const row of rows) map[row.key] = row.value ?? '';
return map;
}
async getBanners() {
const rows = await this.prisma.banner.findMany({
where: { status: 1 },
orderBy: [{ sort_order: 'asc' }, { id: 'asc' }],
});
return rows.map((r) => ({
id: r.id,
filename: r.filename,
imageUrl: staticUrls.banner(this.staticBase, r.filename),
}));
}
async getHomeCourses() {
const rows = await this.prisma.homeCourse.findMany({
where: { status: 1 },
orderBy: [{ sort_order: 'asc' }, { id: 'asc' }],
include: { course: { select: { code: true, title: true } } },
});
return rows.map((r) => ({
id: r.id,
courseName: r.course_name,
intro: r.intro,
publishedAt: r.published_at,
coverUrl: staticUrls.newCourseCover(this.staticBase, r.course_name),
// 长图正文里的绝对地址已在迁移时改写到新静态域名
content: r.content,
courseCode: r.course?.code ?? null,
courseTitle: r.course?.title ?? null,
}));
}
async getHomeFreeCourses() {
const rows = await this.prisma.homeFreeCourse.findMany({
where: { status: 1 },
orderBy: [{ sort_order: 'asc' }, { id: 'asc' }],
});
return rows.map((r) => ({
id: r.id,
seriesNo: r.series_no,
courseName: r.course_name,
intro: r.intro,
coverUrl: staticUrls.freeCourseCover(this.staticBase, r.course_name),
}));
}
async getArticles(page = 1, pageSize = 10) {
const size = Math.min(Math.max(pageSize, 1), PAGE_SIZE_MAX);
const current = Math.max(page, 1);
const where = { status: 1 };
const [items, total] = await this.prisma.$transaction([
this.prisma.article.findMany({
where,
// 用 published_at + id 排序,替代旧库对 varchar 网页id 的字符串排序
orderBy: [{ published_at: 'desc' }, { id: 'desc' }],
skip: (current - 1) * size,
take: size,
}),
this.prisma.article.count({ where }),
]);
return {
items: items.map((r) => ({
id: r.id,
title: r.title,
intro: r.intro,
publisher: r.publisher,
publishedAt: r.published_at,
thumbUrl: r.legacy_page_id ? staticUrls.articleThumb(this.staticBase, r.legacy_page_id) : null,
})),
total,
page: current,
pageSize: size,
};
}
async getArticle(id: number) {
const row = await this.prisma.article.findFirst({ where: { id, status: 1 } });
if (!row) return null;
return {
id: row.id,
title: row.title,
intro: row.intro,
publisher: row.publisher,
publishedAt: row.published_at,
content: row.content,
thumbUrl: row.legacy_page_id ? staticUrls.articleThumb(this.staticBase, row.legacy_page_id) : null,
};
}
async getTips(): Promise<{ content: string; title: string | null }> {
// 旧前端读的是 tip_key=0 那条
const row = await this.prisma.courseTip.findUnique({ where: { tip_key: 0 } });
return { content: row?.content ?? '', title: row?.title ?? null };
}
}
@@ -0,0 +1,34 @@
import { Controller, Get, Param, Query, Req, Res } from '@nestjs/common';
import type { Request, Response } from 'express';
import { Public } from '../../common/auth';
import { MediaService } from './media.service';
@Controller('media')
export class MediaController {
constructor(private readonly media: MediaService) {}
/**
* 签名媒体入口。签名由 /v1/courses/:code/play 下发,
* 有效期由 PLAY_URL_TTL_SECONDS 控制。
*
* 用通配路由承接多级路径(HLS 是 {code}/{chapter}/{play}/playlist.m3u8 这种层级)。
*/
@Public()
@Get('*path')
async serve(
@Req() req: Request,
@Res() res: Response,
@Query('t') t?: string,
@Query('s') s?: string,
): Promise<void> {
const wildcard = (req.params as Record<string, string | string[]>).path;
const relativePath = Array.isArray(wildcard) ? wildcard.join('/') : (wildcard ?? '');
if (!t || !s || !this.media.verify(relativePath, Number(t), s)) {
res.status(403).json({ code: 2002, message: '播放地址无效或已过期', data: null });
return;
}
await this.media.send(res, relativePath);
}
}
@@ -0,0 +1,11 @@
import { Module } from '@nestjs/common';
import { MediaController } from './media.controller';
import { MediaService } from './media.service';
@Module({
controllers: [MediaController],
providers: [MediaService],
exports: [MediaService],
})
export class MediaModule {}
@@ -0,0 +1,122 @@
import { Inject, Injectable } from '@nestjs/common';
import { createHmac, timingSafeEqual } from 'node:crypto';
import { createReadStream, existsSync, statSync } from 'node:fs';
import { readFile } from 'node:fs/promises';
import path from 'node:path';
import type { Response } from 'express';
import { APP_CONFIG, type AppConfig } from '../../config/config';
const MIME: Record<string, string> = {
'.m3u8': 'application/vnd.apple.mpegurl',
'.ts': 'video/mp2t',
'.mp4': 'video/mp4',
'.key': 'application/octet-stream',
'.jpg': 'image/jpeg',
'.jpeg': 'image/jpeg',
'.png': 'image/png',
'.webp': 'image/webp',
};
/** 播放列表里需要带签名的资源类型 */
const REWRITABLE = /\.(ts|m3u8|key|mp4|jpg|jpeg|png|webp)(\?.*)?$/i;
@Injectable()
export class MediaService {
private readonly root: string;
constructor(@Inject(APP_CONFIG) private readonly cfg: AppConfig) {
this.root = path.resolve(process.cwd(), cfg.DATA_ROOT);
}
private sign(relativePath: string, expiresAt: number): string {
return createHmac('sha256', this.cfg.MEDIA_SIGN_SECRET)
.update(`${relativePath}|${expiresAt}`)
.digest('base64url');
}
/** 生成带有效期的播放地址。relativePath 形如 Course/{code}/{chapter}/{play}/playlist.m3u8 */
buildSignedUrl(relativePath: string): string {
const expiresAt = Math.floor(Date.now() / 1000) + this.cfg.PLAY_URL_TTL_SECONDS;
const sig = this.sign(relativePath, expiresAt);
const clean = relativePath.split('/').map(encodeURIComponent).join('/');
return `${this.cfg.API_BASE_URL.replace(/\/+$/, '')}/media/${clean}?t=${expiresAt}&s=${sig}`;
}
verify(relativePath: string, expiresAt: number, sig: string): boolean {
if (!Number.isFinite(expiresAt) || expiresAt * 1000 < Date.now()) return false;
const expected = Buffer.from(this.sign(relativePath, expiresAt));
const given = Buffer.from(sig);
return expected.length === given.length && timingSafeEqual(expected, given);
}
/**
* 把请求路径解析到数据端根目录内的真实文件。
* 越界(路径穿越)一律拒绝。
*/
resolve(relativePath: string): string | null {
const normalized = path
.normalize(decodeURIComponent(relativePath))
.replace(/^([/\\])+/, '');
const abs = path.resolve(this.root, normalized);
if (abs !== this.root && !abs.startsWith(this.root + path.sep)) return null;
if (!existsSync(abs) || !statSync(abs).isFile()) return null;
return abs;
}
/**
* 播放列表重写:HLS 的 .ts 分片与子播放列表是相对引用,
* 只给主 m3u8 签名保护不了分片,必须把签名透传到每一行引用上。
*/
private rewritePlaylist(content: string, relativePath: string): string {
const dir = path.posix.dirname(relativePath);
const expiresAt = Math.floor(Date.now() / 1000) + this.cfg.PLAY_URL_TTL_SECONDS;
const withToken = (ref: string): string => {
if (/^[a-z]+:\/\//i.test(ref) || ref.startsWith('data:')) return ref;
const target = ref.startsWith('/') ? ref.slice(1) : path.posix.normalize(path.posix.join(dir, ref));
const sig = this.sign(target, expiresAt);
const sep = ref.includes('?') ? '&' : '?';
return `${ref}${sep}t=${expiresAt}&s=${sig}`;
};
return content
.split('\n')
.map((line) => {
const trimmed = line.trim();
if (trimmed === '') return line;
// URI="..." 形式的属性(EXT-X-KEY / EXT-X-MAP)
if (trimmed.startsWith('#') ) {
return line.replace(/URI="([^"]+)"/g, (_m, uri: string) => `URI="${withToken(uri)}"`);
}
return REWRITABLE.test(trimmed) ? withToken(trimmed) : line;
})
.join('\n');
}
/** 把文件写入响应。播放列表会先重写。 */
async send(res: Response, relativePath: string): Promise<void> {
const abs = this.resolve(relativePath);
if (!abs) {
res.status(404).json({ code: 1001, message: '资源不存在', data: null });
return;
}
const ext = path.extname(abs).toLowerCase();
res.setHeader('Content-Type', MIME[ext] ?? 'application/octet-stream');
res.setHeader('Cache-Control', 'private, max-age=300');
if (ext === '.m3u8') {
const content = await readFile(abs, 'utf8');
res.send(this.rewritePlaylist(content, relativePath));
return;
}
await new Promise<void>((resolve, reject) => {
const stream = createReadStream(abs);
stream.on('error', reject);
stream.on('end', resolve);
stream.pipe(res);
});
}
}
@@ -0,0 +1,33 @@
import { Body, Controller, Get, Param, Post } from '@nestjs/common';
import { z } from 'zod';
import { CurrentUser, type AuthPrincipal } from '../../common/auth';
import { AppError } from '../../common/errors';
import { OrdersService } from './orders.service';
const createSchema = z.object({
courseCode: z.string().min(1),
quantity: z.coerce.number().int().min(1).max(10).optional(),
});
@Controller('v1/orders')
export class OrdersController {
constructor(private readonly orders: OrdersService) {}
@Post()
create(@CurrentUser() user: AuthPrincipal, @Body() body: unknown) {
const parsed = createSchema.safeParse(body);
if (!parsed.success) throw AppError.badRequest('参数错误:缺少 courseCode');
return this.orders.create(user.id, parsed.data.courseCode, parsed.data.quantity ?? 1);
}
@Get()
list(@CurrentUser() user: AuthPrincipal) {
return this.orders.list(user.id);
}
@Get(':outTradeNo')
status(@CurrentUser() user: AuthPrincipal, @Param('outTradeNo') outTradeNo: string) {
return this.orders.status(user.id, outTradeNo);
}
}
@@ -0,0 +1,12 @@
import { Module } from '@nestjs/common';
import { PaymentsModule } from '../payments/payments.module';
import { OrdersController } from './orders.controller';
import { OrdersService } from './orders.service';
@Module({
imports: [PaymentsModule],
controllers: [OrdersController],
providers: [OrdersService],
})
export class OrdersModule {}
@@ -0,0 +1,146 @@
import { randomInt } from 'node:crypto';
import { Injectable } from '@nestjs/common';
import { AppError, ErrorCode } from '../../common/errors';
import { PrismaService } from '../../prisma/prisma.service';
import { OrderStatus, PaymentsService } from '../payments/payments.service';
import { WxPayService } from '../payments/wxpay.service';
export interface PayData {
signData: string;
mode: 'short_series_goods';
paySig: string;
signature: string;
}
@Injectable()
export class OrdersService {
constructor(
private readonly prisma: PrismaService,
private readonly wxpay: WxPayService,
private readonly payments: PaymentsService,
) {}
/**
* 下单并返回支付参数。
*
* 关键点:
* - out_trade_no 每次重新生成且唯一,8-32 位,不能以下划线开头
* - signData 是要原样传给客户端的字符串,签名基于同一份字符串
* - goodsPrice 单位是分,必须与 MP 后台道具价格一致
*/
async create(userId: number, courseCode: string, quantity = 1): Promise<{ outTradeNo: string; payData: PayData }> {
if (!this.wxpay.enabled) {
throw new AppError(ErrorCode.PAY_NOT_ENABLED, '支付尚未开通,请联系管理员', 503);
}
if (!Number.isInteger(quantity) || quantity < 1 || quantity > 10) {
throw AppError.badRequest('购买数量不合法');
}
const course = await this.prisma.course.findUnique({ where: { code: courseCode } });
if (!course || course.status !== 1) throw AppError.notFound('课程不存在或已下架');
const owned = await this.prisma.entitlement.findUnique({
where: { user_id_course_id: { user_id: userId, course_id: course.id } },
});
if (owned) throw AppError.conflict('你已拥有该课程');
const product = await this.prisma.product.findFirst({
where: { course_id: course.id, status: 1 },
});
if (!product) throw AppError.notFound('该课程尚未配置支付商品');
const outTradeNo = this.newOutTradeNo();
const amountFen = product.price_fen * quantity;
await this.prisma.order.create({
data: {
out_trade_no: outTradeNo,
user_id: userId,
course_id: course.id,
product_id: product.wx_product_id,
quantity,
amount_fen: amountFen,
status: OrderStatus.CREATED,
env: 0,
attach: `course:${course.code}`,
},
});
const sessionKey = await this.payments.requireSessionKey(userId);
const signData = JSON.stringify({
offerId: this.wxpay.offerId,
buyQuantity: quantity,
env: 0,
currencyType: 'CNY',
productId: product.wx_product_id,
goodsPrice: product.price_fen,
outTradeNo,
attach: `course:${course.code}`,
});
return {
outTradeNo,
payData: {
signData,
mode: 'short_series_goods',
paySig: this.wxpay.calcPaySig('requestVirtualPayment', signData),
signature: this.wxpay.calcSignature(signData, sessionKey),
},
};
}
/** 查询自己的订单状态。前端支付返回后用它确认是否已发货。 */
async status(userId: number, outTradeNo: string) {
const order = await this.prisma.order.findUnique({
where: { out_trade_no: outTradeNo },
include: { course: { select: { code: true, title: true } } },
});
if (!order || order.user_id !== userId) throw AppError.notFound('订单不存在');
return {
outTradeNo: order.out_trade_no,
status: order.status,
statusText: STATUS_TEXT[order.status] ?? '未知',
amountFen: order.amount_fen,
courseCode: order.course.code,
courseTitle: order.course.title,
createdAt: order.created_at,
paidAt: order.paid_at,
deliveredAt: order.delivered_at,
refundedAt: order.refunded_at,
};
}
/** 我的订单列表。 */
async list(userId: number) {
const rows = await this.prisma.order.findMany({
where: { user_id: userId },
orderBy: { created_at: 'desc' },
take: 100,
include: { course: { select: { code: true, title: true } } },
});
return rows.map((o) => ({
outTradeNo: o.out_trade_no,
status: o.status,
statusText: STATUS_TEXT[o.status] ?? '未知',
amountFen: o.amount_fen,
courseCode: o.course.code,
courseTitle: o.course.title,
createdAt: o.created_at,
}));
}
private newOutTradeNo(): string {
// 13 位时间戳 + 4 位随机,共 18 位,满足 8-32 位且不以 _ 开头
return `T${Date.now()}${String(randomInt(1000, 10000))}`;
}
}
export const STATUS_TEXT: Record<number, string> = {
[OrderStatus.CREATED]: '待支付',
[OrderStatus.PAID]: '已支付',
[OrderStatus.DELIVERED]: '已开通',
[OrderStatus.REFUNDED]: '已退款',
[OrderStatus.CLOSED]: '已关闭',
};
@@ -0,0 +1,34 @@
import { Controller, Post, Req, Res } from '@nestjs/common';
import type { Request, Response } from 'express';
import { Public } from '../../common/auth';
import { PaymentsService } from './payments.service';
/** 取原始请求体。虚拟支付回调是 XML,必须拿到未经解析的字节。 */
function rawBodyOf(req: Request): string {
const withRaw = req as Request & { rawBody?: Buffer };
if (withRaw.rawBody) return withRaw.rawBody.toString('utf8');
return typeof req.body === 'string' ? req.body : String(req.body ?? '');
}
/**
* 虚拟支付回调。由微信服务器调用,不带小程序身份,靠签名与业务校验保证安全。
* 请求体是 XML,应答也必须是 XML。
*/
@Controller('v1/payments')
export class PaymentsController {
constructor(private readonly payments: PaymentsService) {}
@Public()
@Post('notify')
async notify(@Req() req: Request, @Res() res: Response): Promise<void> {
const xml = await this.payments.handleDeliverNotify(rawBodyOf(req));
res.type('application/xml').send(xml);
}
@Public()
@Post('refund-notify')
async refundNotify(@Req() req: Request, @Res() res: Response): Promise<void> {
const xml = await this.payments.handleRefundNotify(rawBodyOf(req));
res.type('application/xml').send(xml);
}
}
@@ -0,0 +1,13 @@
import { Module } from '@nestjs/common';
import { PaymentsController } from './payments.controller';
import { PaymentsScheduler } from './payments.scheduler';
import { PaymentsService } from './payments.service';
import { WxPayService } from './wxpay.service';
@Module({
controllers: [PaymentsController],
providers: [PaymentsService, WxPayService, PaymentsScheduler],
exports: [PaymentsService, WxPayService],
})
export class PaymentsModule {}
@@ -0,0 +1,25 @@
import { Injectable, Logger } from '@nestjs/common';
import { Cron, CronExpression } from '@nestjs/schedule';
import { PaymentsService } from './payments.service';
/**
* 发货推送可能丢失(用户中途退出等情况),必须有一条兜底路径。
* 进程内调度,不依赖系统 cron。
*/
@Injectable()
export class PaymentsScheduler {
private readonly logger = new Logger(PaymentsScheduler.name);
constructor(private readonly payments: PaymentsService) {}
@Cron(CronExpression.EVERY_5_MINUTES)
async reconcile(): Promise<void> {
try {
const count = await this.payments.reconcilePendingOrders();
if (count > 0) this.logger.log(`兜底补发 ${count} 笔订单`);
} catch (err) {
this.logger.error(`兜底查单异常:${err instanceof Error ? err.message : String(err)}`);
}
}
}
@@ -0,0 +1,204 @@
import { Inject, Injectable, Logger } from '@nestjs/common';
import { XMLParser } from 'fast-xml-parser';
import { APP_CONFIG, type AppConfig } from '../../config/config';
import { decryptSecret } from '../../common/crypto';
import { AppError, ErrorCode } from '../../common/errors';
import { PrismaService } from '../../prisma/prisma.service';
import { WxPayService } from './wxpay.service';
export const OrderStatus = {
CREATED: 0,
PAID: 1,
DELIVERED: 2,
REFUNDED: 3,
CLOSED: 4,
} as const;
interface DeliverNotify {
Event?: string;
OpenId?: string;
OutTradeNo?: string;
WeChatPayInfo?: { MchOrderNo?: string };
GoodsInfo?: { ProductId?: string; Quantity?: number };
}
@Injectable()
export class PaymentsService {
private readonly logger = new Logger(PaymentsService.name);
private readonly parser = new XMLParser({ ignoreAttributes: true, trimValues: true });
constructor(
@Inject(APP_CONFIG) private readonly cfg: AppConfig,
private readonly prisma: PrismaService,
private readonly wxpay: WxPayService,
) {}
/**
* 处理发货推送。
*
* 纪律:
* 1. 发货只认推送与查单,前端支付成功回调不作数
* 2. 幂等键是平台单号 wx_order_id,不是自己的 out_trade_no
* 3. 无论如何都要返回 XML,非 0 会被平台重试(最多 15 次)
*/
async handleDeliverNotify(rawXml: string): Promise<string> {
const logId = await this.logNotify(rawXml);
try {
const parsed = this.parser.parse(rawXml) as { xml?: DeliverNotify };
const notify = parsed.xml ?? {};
if (notify.Event !== 'xpay_goods_deliver_notify') {
await this.markNotify(logId, 1, `忽略事件 ${notify.Event ?? '未知'}`);
return this.okXml();
}
const outTradeNo = notify.OutTradeNo ?? '';
const wxOrderId = notify.WeChatPayInfo?.MchOrderNo ?? '';
const openid = notify.OpenId ?? '';
if (!outTradeNo || !wxOrderId) throw new Error('推送缺少 OutTradeNo 或 MchOrderNo');
const order = await this.prisma.order.findUnique({
where: { out_trade_no: outTradeNo },
include: { user: true },
});
if (!order) throw new Error(`订单不存在:${outTradeNo}`);
if (order.user.openid !== openid) throw new Error(`推送 openid 与订单不符:${outTradeNo}`);
if (order.status === OrderStatus.DELIVERED || order.status === OrderStatus.REFUNDED) {
await this.markNotify(logId, 1, '重复推送,已忽略');
return this.okXml();
}
await this.grantEntitlement(order.user_id, order.course_id, order.id, order.out_trade_no, wxOrderId);
await this.markNotify(logId, 1, '发货成功');
this.logger.log(`发货成功 order=${outTradeNo} wx=${wxOrderId}`);
return this.okXml();
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
this.logger.error(`发货失败:${message}`);
await this.markNotify(logId, 2, message);
return `<xml><ErrCode>1</ErrCode><ErrMsg><![CDATA[${message}]]></ErrMsg></xml>`;
}
}
/** 处理退款推送。 */
async handleRefundNotify(rawXml: string): Promise<string> {
const logId = await this.logNotify(rawXml);
try {
const parsed = this.parser.parse(rawXml) as { xml?: { OutTradeNo?: string } };
const outTradeNo = parsed.xml?.OutTradeNo ?? '';
if (outTradeNo) {
await this.prisma.order.updateMany({
where: { out_trade_no: outTradeNo },
data: { status: OrderStatus.REFUNDED, refunded_at: new Date() },
});
}
await this.markNotify(logId, 1, '退款已记录');
return this.okXml();
} catch (err) {
await this.markNotify(logId, 2, err instanceof Error ? err.message : String(err));
return '<xml><ErrCode>1</ErrCode><ErrMsg><![CDATA[fail]]></ErrMsg></xml>';
}
}
/**
* 兜底查单:补发「已支付但没收到推送」的订单。
* 由定时任务每 5 分钟调用一次。
*/
async reconcilePendingOrders(): Promise<number> {
if (!this.wxpay.enabled) return 0;
const pending = await this.prisma.order.findMany({
where: { status: { in: [OrderStatus.CREATED, OrderStatus.PAID] } },
include: { user: true },
orderBy: { created_at: 'asc' },
take: 100,
});
let delivered = 0;
for (const order of pending) {
try {
const result = await this.wxpay.queryOrder(order.out_trade_no, order.user.openid);
const wxOrderId = result.wx_order_id ?? result.order?.wx_order_id;
const isPaid = result.status === 2 || result.order?.status === 2 || Boolean(wxOrderId);
if (!isPaid) continue;
await this.grantEntitlement(
order.user_id,
order.course_id,
order.id,
order.out_trade_no,
wxOrderId ?? `reconciled-${order.out_trade_no}`,
);
delivered++;
this.logger.log(`兜底补发成功 order=${order.out_trade_no}`);
} catch (err) {
this.logger.warn(
`兜底查单失败 order=${order.out_trade_no}: ${err instanceof Error ? err.message : String(err)}`,
);
}
}
return delivered;
}
/** 取用户 sessionKey(明文)。不存在时提示重新登录。 */
async requireSessionKey(userId: number): Promise<string> {
const user = await this.prisma.user.findUnique({ where: { id: userId } });
if (!user?.wx_session_key) throw AppError.unauthorized('请重新进入小程序后再试');
const key = decryptSecret(user.wx_session_key, this.cfg.SESSION_KEY_ENC_SECRET);
if (!key) throw new AppError(ErrorCode.INTERNAL, 'sessionKey 解密失败', 500);
return key;
}
/** 标记订单已发货并发放权益。两个写操作必须同事务。 */
private async grantEntitlement(
userId: number,
courseId: number,
orderId: number,
outTradeNo: string,
wxOrderId: string,
): Promise<void> {
const now = new Date();
await this.prisma.$transaction([
this.prisma.order.update({
where: { id: orderId },
data: {
status: OrderStatus.DELIVERED,
wx_order_id: wxOrderId,
paid_at: now,
delivered_at: now,
},
}),
this.prisma.entitlement.upsert({
where: { user_id_course_id: { user_id: userId, course_id: courseId } },
create: {
user_id: userId,
course_id: courseId,
source: 1,
ref_id: orderId,
granted_at: now,
note: `订单 ${outTradeNo}`,
},
update: {},
}),
]);
}
private async logNotify(rawXml: string): Promise<number> {
const row = await this.prisma.paymentNotifyLog.create({ data: { raw_body: rawXml } });
return row.id;
}
private async markNotify(id: number, processed: number, errMsg: string): Promise<void> {
await this.prisma.paymentNotifyLog.update({
where: { id },
data: { processed, err_msg: errMsg.slice(0, 255) },
});
}
private okXml(): string {
return '<xml><ErrCode>0</ErrCode><ErrMsg><![CDATA[success]]></ErrMsg></xml>';
}
}
@@ -0,0 +1,109 @@
import { Inject, Injectable, Logger } from '@nestjs/common';
import { createHmac } from 'node:crypto';
import { APP_CONFIG, type AppConfig } from '../../config/config';
import { AppError } from '../../common/errors';
const XPAY_BASE = 'https://api.weixin.qq.com';
/**
* 微信小程序虚拟支付客户端。
*
* 签名规则(两套,缺一不可):
* paySig 服务端身份,密钥是后台的「现网 AppKey」:HMAC_SHA256(appkey, uri + '&' + postBody)
* signature 用户态身份,密钥是该用户的 sessionKey:HMAC_SHA256(sessionKey, postBody)
*
* 注意:postBody 必须与实际发出的字符串逐字节一致,不能格式化、不能改键顺序。
*/
@Injectable()
export class WxPayService {
private readonly logger = new Logger(WxPayService.name);
constructor(@Inject(APP_CONFIG) private readonly cfg: AppConfig) {}
get enabled(): boolean {
return this.cfg.WX_OFFER_ID !== '' && this.cfg.WX_PAY_APPKEY !== '';
}
get offerId(): string {
return this.cfg.WX_OFFER_ID;
}
/** 服务端身份签名。uri 不带问号参数。 */
calcPaySig(uri: string, postBody: string): string {
return createHmac('sha256', this.cfg.WX_PAY_APPKEY).update(`${uri}&${postBody}`).digest('hex');
}
/** 用户态签名。 */
calcSignature(postBody: string, sessionKey: string): string {
return createHmac('sha256', sessionKey).update(postBody).digest('hex');
}
/**
* 查单。用于发货推送丢失时兜底补发。
* 参数名是 order_id(传自己的 outTradeNo),不是 out_trade_no。
*/
async queryOrder(outTradeNo: string, openid: string): Promise<XpayQueryResult> {
const uri = '/xpay/query_order';
const body = JSON.stringify({ openid, env: 0, order_id: outTradeNo });
const paySig = this.calcPaySig(uri, body);
const res = await fetch(`${XPAY_BASE}${uri}?pay_sig=${paySig}`, {
method: 'POST',
headers: { 'content-type': 'application/json' },
body,
});
const json = (await res.json()) as XpayQueryResult;
if (json.errcode && json.errcode !== 0) {
this.logger.warn(`查单失败 ${outTradeNo}: ${json.errcode} ${json.errmsg ?? ''}`);
}
return json;
}
/**
* 主动退款(仅 Android 等非 iOS 终端可用;iOS 需用户向 App Store 申请,开发者无法发起)。
*
* 注意:参数集合需以官方《虚拟支付 - 退款》文档核对后再上线启用,
* 当前按已知字段拼装,失败时抛错而非静默。
*/
async refundOrder(input: {
outTradeNo: string;
openid: string;
refundOrderId: string;
refundAmountFen: number;
reason: string;
}): Promise<Record<string, unknown>> {
if (!this.enabled) throw new AppError(4002, '虚拟支付未配置', 503);
const uri = '/xpay/refund_order';
const body = JSON.stringify({
openid: input.openid,
env: 0,
order_id: input.outTradeNo,
refund_order_id: input.refundOrderId,
refund_amount: input.refundAmountFen,
refund_reason: input.reason,
});
const paySig = this.calcPaySig(uri, body);
const res = await fetch(`${XPAY_BASE}${uri}?pay_sig=${paySig}`, {
method: 'POST',
headers: { 'content-type': 'application/json' },
body,
});
return (await res.json()) as Record<string, unknown>;
}
}
export interface XpayQueryResult {
errcode?: number;
errmsg?: string;
order?: {
order_id?: string;
wx_order_id?: string;
status?: number;
paid_time?: number;
};
status?: number;
wx_order_id?: string;
}
@@ -0,0 +1,35 @@
import { Body, Controller, Get, Patch } from '@nestjs/common';
import { z } from 'zod';
import { CurrentUser, type AuthPrincipal } from '../../common/auth';
import { AppError } from '../../common/errors';
import { UsersService } from './users.service';
const updateSchema = z
.object({
nickname: z.string().optional(),
headiconId: z.coerce.number().int().optional(),
})
.strict();
@Controller('v1/me')
export class UsersController {
constructor(private readonly users: UsersService) {}
@Get()
me(@CurrentUser() user: AuthPrincipal) {
return this.users.me(user.id);
}
@Patch()
update(@CurrentUser() user: AuthPrincipal, @Body() body: unknown) {
const parsed = updateSchema.safeParse(body);
if (!parsed.success) throw AppError.badRequest('参数错误');
return this.users.updateProfile(user.id, parsed.data);
}
@Get('entitlements')
entitlements(@CurrentUser() user: AuthPrincipal) {
return this.users.entitlements(user.id);
}
}
@@ -0,0 +1,11 @@
import { Module } from '@nestjs/common';
import { UsersController } from './users.controller';
import { UsersService } from './users.service';
@Module({
controllers: [UsersController],
providers: [UsersService],
exports: [UsersService],
})
export class UsersModule {}
@@ -0,0 +1,83 @@
import { Inject, Injectable } from '@nestjs/common';
import { APP_CONFIG, type AppConfig } from '../../config/config';
import { AppError } from '../../common/errors';
import { staticUrls } from '../../common/urls';
import { PrismaService } from '../../prisma/prisma.service';
@Injectable()
export class UsersService {
constructor(
@Inject(APP_CONFIG) private readonly cfg: AppConfig,
private readonly prisma: PrismaService,
) {}
async me(userId: number) {
const user = await this.prisma.user.findUnique({ where: { id: userId } });
if (!user || user.status !== 1) throw AppError.unauthorized();
const base = this.cfg.STATIC_BASE_URL;
return {
id: user.id,
nickname: user.nickname,
headiconId: user.headicon_id,
// headiconId 为 0 表示未设置,小程序用本地占位图
headiconUrl: user.headicon_id > 0 ? staticUrls.headicon(base, user.headicon_id) : null,
registeredAt: user.registered_at,
lastLoginAt: user.last_login_at,
};
}
/** 修改昵称 / 头像。昵称与头像序号的取值范围在此校验。 */
async updateProfile(userId: number, input: { nickname?: string; headiconId?: number }) {
const data: { nickname?: string; headicon_id?: number } = {};
if (input.nickname !== undefined) {
const nickname = input.nickname.trim();
if (nickname.length === 0) throw AppError.badRequest('昵称不能为空');
if (nickname.length > 8) throw AppError.badRequest('昵称最多 8 个字符');
data.nickname = nickname;
}
if (input.headiconId !== undefined) {
const max = await this.getHeadiconMax();
if (!Number.isInteger(input.headiconId) || input.headiconId < 0 || input.headiconId > max) {
throw AppError.badRequest(`头像序号需在 0 到 ${max} 之间`);
}
data.headicon_id = input.headiconId;
}
if (Object.keys(data).length === 0) throw AppError.badRequest('没有需要修改的内容');
await this.prisma.user.update({ where: { id: userId }, data });
return this.me(userId);
}
/** 我的课程。 */
async entitlements(userId: number) {
const rows = await this.prisma.entitlement.findMany({
where: { user_id: userId },
orderBy: { granted_at: 'desc' },
include: { course: true },
});
return rows
.filter((r) => r.course.status === 1)
.map((r) => ({
courseId: r.course_id,
code: r.course.code,
title: r.course.title,
intro: r.course.intro,
coverUrl: staticUrls.courseCover(this.cfg.STATIC_BASE_URL, r.course.code),
grantedAt: r.granted_at,
expireAt: r.expire_at,
}));
}
/** 头像总数取自 settings,缺省 50。 */
private async getHeadiconMax(): Promise<number> {
const row = await this.prisma.setting.findUnique({ where: { key: 'profile.headicon_count' } });
const n = Number(row?.value);
return Number.isFinite(n) && n > 0 ? Math.trunc(n) : 50;
}
}
@@ -0,0 +1,10 @@
import { Global, Module } from '@nestjs/common';
import { PrismaService } from './prisma.service';
@Global()
@Module({
providers: [PrismaService],
exports: [PrismaService],
})
export class PrismaModule {}
@@ -0,0 +1,21 @@
import { Injectable, Logger, type OnModuleDestroy, type OnModuleInit } from '@nestjs/common';
import { PrismaClient } from '@prisma/client';
@Injectable()
export class PrismaService extends PrismaClient implements OnModuleInit, OnModuleDestroy {
private readonly logger = new Logger(PrismaService.name);
async onModuleInit(): Promise<void> {
// 延迟连接:即使数据库暂时不可用也允许进程启动,由具体请求暴露问题
try {
await this.$connect();
} catch (err) {
this.logger.error(`数据库连接失败:${err instanceof Error ? err.message : String(err)}`);
}
}
async onModuleDestroy(): Promise<void> {
await this.$disconnect();
}
}