四端上传

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

+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`),
};