四端上传
This commit is contained in:
1 parent
25bbb5f9c8
commit
c6e2cb4d5b
276 files changed
+17812
-3
No files matched your search
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
@@ -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`),
|
||||
};
|
||||
|
||||
Reference in new issue
Block a user