docs: 基本完成初版数据库设计
This commit is contained in:
@@ -0,0 +1,31 @@
|
||||
import { NestFactory } from '@nestjs/core';
|
||||
import type { NestExpressApplication } from '@nestjs/platform-express';
|
||||
import { AppModule } from './app.module';
|
||||
import { configureApplication } from './app.setup';
|
||||
import { getServerConfig, type ServerConfig } from './config/server-config';
|
||||
import { AppLogger } from './logging/app-logger';
|
||||
|
||||
/** 创建并配置应用,但不监听端口;启动入口和测试均通过此工厂组装服务。 */
|
||||
export async function createApplication(
|
||||
config: ServerConfig = getServerConfig(),
|
||||
logger = new AppLogger(config.logLevel),
|
||||
): Promise<NestExpressApplication> {
|
||||
const app = await NestFactory.create<NestExpressApplication>(
|
||||
AppModule.forRoot(config),
|
||||
{
|
||||
// 解析器由 configureApplication 显式安装,确保大小限制和注册顺序一致。
|
||||
bodyParser: false,
|
||||
logger,
|
||||
// 将初始化错误交给调用方处理,不在 Nest 内部直接退出整个进程。
|
||||
abortOnError: false,
|
||||
},
|
||||
);
|
||||
try {
|
||||
configureApplication(app, config, logger);
|
||||
return app;
|
||||
} catch (error) {
|
||||
// 配置阶段失败也关闭已经建立的资源,避免测试或重复启动遗留句柄。
|
||||
await app.close();
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,18 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { Module, type DynamicModule } from '@nestjs/common';
|
||||
import { HealthController } from './health/health.controller';
|
||||
import { AuthModule } from './auth/auth.module';
|
||||
import { ServerConfigModule, type ServerConfig } from './config/server-config';
|
||||
|
||||
@Module({
|
||||
imports: [AuthModule],
|
||||
controllers: [HealthController],
|
||||
})
|
||||
export class AppModule {}
|
||||
export class AppModule {
|
||||
/** 注入调用方已经校验的配置;同一模块结构用于生产启动和隔离测试。 */
|
||||
static forRoot(config: ServerConfig): DynamicModule {
|
||||
return {
|
||||
module: AppModule,
|
||||
imports: [ServerConfigModule.forRoot(config)],
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,107 @@
|
||||
import type { INestApplication } from '@nestjs/common';
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import { ValidationPipe } from '@nestjs/common';
|
||||
import type { NestExpressApplication } from '@nestjs/platform-express';
|
||||
import { DocumentBuilder, SwaggerModule } from '@nestjs/swagger';
|
||||
import type { NextFunction, Response } from 'express';
|
||||
import helmet from 'helmet';
|
||||
import { API_PREFIX, type ServerConfig } from './config/server-config';
|
||||
import { AppLogger } from './logging/app-logger';
|
||||
import type { HttpRequest } from './common/http-request';
|
||||
import { ApiErrorFilter } from './common/api-error.filter';
|
||||
|
||||
export function configureApplication(app: INestApplication): void {
|
||||
app.setGlobalPrefix('api/v1');
|
||||
/** 在业务路由初始化前安装 HTTP 基础设施;生产和测试复用相同处理顺序。 */
|
||||
export function configureApplication(
|
||||
app: NestExpressApplication,
|
||||
config: ServerConfig,
|
||||
logger: AppLogger,
|
||||
): void {
|
||||
app.useLogger(logger);
|
||||
const express = app.getHttpAdapter().getInstance();
|
||||
express.disable('x-powered-by');
|
||||
// 仅信任部署明确声明的代理地址,默认不采信客户端伪造的转发头。
|
||||
if (config.trustedProxyCidrs.length)
|
||||
express.set('trust proxy', config.trustedProxyCidrs);
|
||||
// 解析 body 前安装,格式错误和超限响应也携带 requestId。
|
||||
app.use((request: HttpRequest, response: Response, next: NextFunction) => {
|
||||
const suppliedId = request.headers['x-request-id'];
|
||||
// 合法上游 ID 可以贯穿调用链;其余输入替换为服务端生成的标识。
|
||||
const requestId =
|
||||
typeof suppliedId === 'string' &&
|
||||
/^[A-Za-z0-9_-]{8,128}$/.test(suppliedId)
|
||||
? suppliedId
|
||||
: `req_${randomUUID()}`;
|
||||
const startedAt = performance.now();
|
||||
request.requestId = requestId;
|
||||
response.setHeader('X-Request-Id', requestId);
|
||||
// 响应完成后记录状态与耗时;只记录 path,排除 URL 查询中的登录 code/token。
|
||||
response.once('finish', () =>
|
||||
logger.log('http_request_completed', {
|
||||
request_id: requestId,
|
||||
method: request.method,
|
||||
path: request.path,
|
||||
status_code: response.statusCode,
|
||||
duration_ms: Math.round(performance.now() - startedAt),
|
||||
}),
|
||||
);
|
||||
next();
|
||||
});
|
||||
// 开启 Swagger 时兼容其页面脚本;关闭文档时恢复 Helmet 默认 CSP。
|
||||
app.use(
|
||||
helmet(config.swaggerEnabled ? { contentSecurityPolicy: false } : {}),
|
||||
);
|
||||
// 小程序/服务端请求通常不带 Origin;浏览器请求按精确白名单开放。
|
||||
// 使用 Bearer token,不启用 Cookie 凭证;CORS 不代替服务端鉴权。
|
||||
app.enableCors({
|
||||
origin: (origin, callback) =>
|
||||
callback(null, !origin || config.corsOrigins.includes(origin)),
|
||||
credentials: false,
|
||||
methods: ['GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'OPTIONS'],
|
||||
allowedHeaders: ['Authorization', 'Content-Type', 'X-Request-Id'],
|
||||
exposedHeaders: ['X-Request-Id'],
|
||||
});
|
||||
// 应用工厂已禁用 Nest 默认解析器,此处统一限制 JSON 和表单请求大小。
|
||||
// 表单不解析嵌套对象,上传文件另由后续 assets 模块处理。
|
||||
app.useBodyParser('json', { limit: config.bodyLimitBytes });
|
||||
app.useBodyParser('urlencoded', {
|
||||
limit: config.bodyLimitBytes,
|
||||
extended: false,
|
||||
});
|
||||
// DTO 拒绝未声明字段;不隐式转换类型,也不将原始参数值放进校验错误。
|
||||
app.useGlobalPipes(
|
||||
new ValidationPipe({
|
||||
transform: true,
|
||||
whitelist: true,
|
||||
forbidNonWhitelisted: true,
|
||||
validationError: { target: false, value: false },
|
||||
transformOptions: { enableImplicitConversion: false },
|
||||
}),
|
||||
);
|
||||
// 统一错误格式;成功响应继续由控制器遵循公共契约,避免重复包装。
|
||||
app.useGlobalFilters(new ApiErrorFilter(logger));
|
||||
app.setGlobalPrefix(API_PREFIX);
|
||||
app.enableShutdownHooks();
|
||||
// 文档只包含当前实际注册的路由。它直接挂载在 Express 上,不经过 JWT 守卫。
|
||||
if (config.swaggerEnabled) {
|
||||
const options = new DocumentBuilder()
|
||||
.setTitle('Doodle Store API')
|
||||
.setDescription('统一业务 API;当前仅健康检查,业务接口逐步接入')
|
||||
.setVersion('0.1.0')
|
||||
.addBearerAuth(
|
||||
{ type: 'http', scheme: 'bearer', bearerFormat: 'JWT' },
|
||||
'access-token',
|
||||
)
|
||||
// 默认声明 Bearer 鉴权,公开操作在各自 Swagger 元数据中显式覆盖。
|
||||
.addSecurityRequirements('access-token')
|
||||
.build();
|
||||
SwaggerModule.setup(
|
||||
'api-docs',
|
||||
app,
|
||||
SwaggerModule.createDocument(app, options),
|
||||
{
|
||||
// 刷新页面后不保留 token,避免共享浏览器残留授权凭证。
|
||||
swaggerOptions: { persistAuthorization: false },
|
||||
jsonDocumentUrl: 'api-docs-json',
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
import {
|
||||
ForbiddenException,
|
||||
Inject,
|
||||
Injectable,
|
||||
UnauthorizedException,
|
||||
type CanActivate,
|
||||
type ExecutionContext,
|
||||
} from '@nestjs/common';
|
||||
import { Reflector } from '@nestjs/core';
|
||||
import type { HttpRequest } from '../common/http-request';
|
||||
import { ACCOUNT_READER, type AccountReader } from './account-reader';
|
||||
import { IS_PUBLIC, REQUIRED_ROLES } from './auth.decorators';
|
||||
import { JwtTokenService } from './jwt-token.service';
|
||||
|
||||
@Injectable()
|
||||
/** 全局访问守卫:JWT 验签 → 当前账户核对 → 服务端角色检查。 */
|
||||
export class AccessTokenGuard implements CanActivate {
|
||||
constructor(
|
||||
private readonly reflector: Reflector,
|
||||
private readonly tokens: JwtTokenService,
|
||||
@Inject(ACCOUNT_READER) private readonly accounts: AccountReader,
|
||||
) {}
|
||||
|
||||
async canActivate(context: ExecutionContext): Promise<boolean> {
|
||||
// 方法级声明优先于控制器级声明;只有显式 @Public() 的路由跳过鉴权。
|
||||
const targets = [context.getHandler(), context.getClass()];
|
||||
if (this.reflector.getAllAndOverride<boolean>(IS_PUBLIC, targets))
|
||||
return true;
|
||||
const request = context.switchToHttp().getRequest<HttpRequest>();
|
||||
// 身份只从 Authorization Bearer 读取,不接受 query/body 中的 token 或 userId。
|
||||
const authorization = request.headers.authorization;
|
||||
const match =
|
||||
typeof authorization === 'string'
|
||||
? /^Bearer ([^\s]+)$/i.exec(authorization)
|
||||
: null;
|
||||
const token = match?.[1];
|
||||
// 先限制 token 长度,再进行签名解析,避免无意义的超长输入消耗资源。
|
||||
if (!token || token.length > 8192) throw new UnauthorizedException();
|
||||
const claims = await this.tokens.verifyAccessToken(token);
|
||||
// 验签成功不等于账户仍有效;注销、禁用、凭证版本变更必须立即阻止旧 token。
|
||||
// 账户读取异常向上抛出,不退回到只检查 JWT 的宽松模式。
|
||||
const account = await this.accounts.findById(claims.sub);
|
||||
if (
|
||||
!account ||
|
||||
account.id !== claims.sub ||
|
||||
account.status !== 'active' ||
|
||||
account.authVersion !== claims.authVersion
|
||||
) {
|
||||
throw new UnauthorizedException();
|
||||
}
|
||||
// 后续控制器共享已核对身份,角色取自当前账户,而非客户端或 token 自报值。
|
||||
request.auth = {
|
||||
userId: account.id,
|
||||
authVersion: account.authVersion,
|
||||
role: account.role,
|
||||
};
|
||||
// 未声明角色时只要求有效登录;有角色限制时不匹配返回 403。
|
||||
const roles = this.reflector.getAllAndOverride<Array<'user' | 'admin'>>(
|
||||
REQUIRED_ROLES,
|
||||
targets,
|
||||
);
|
||||
if (roles?.length && !roles.includes(account.role))
|
||||
throw new ForbiddenException();
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
import { Injectable, ServiceUnavailableException } from '@nestjs/common';
|
||||
|
||||
/** 用户模块通过此注入标识替换账户查询实现,JWT 模块不直接依赖数据库模型。 */
|
||||
export const ACCOUNT_READER = Symbol('ACCOUNT_READER');
|
||||
/** 鉴权所需的最小账户快照,不包含昵称、头像或微信身份信息。 */
|
||||
export interface Account {
|
||||
id: string;
|
||||
authVersion: number;
|
||||
role: 'user' | 'admin';
|
||||
status: 'active' | 'disabled';
|
||||
}
|
||||
/** 查询当前账户;不存在返回 null,数据库不可用应抛错,不能当作有效账户。 */
|
||||
export interface AccountReader {
|
||||
findById(userId: string): Promise<Account | null>;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
/** Prisma 账户查询接入前的占位实现:返回 503,确保受保护接口不会被提前放行。 */
|
||||
export class UnconfiguredAccountReader implements AccountReader {
|
||||
async findById(): Promise<Account | null> {
|
||||
// 后续 Prisma 用户模块替换此 provider;不能仅凭 JWT 放行账户数据接口。
|
||||
throw new ServiceUnavailableException();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
import {
|
||||
createParamDecorator,
|
||||
SetMetadata,
|
||||
type ExecutionContext,
|
||||
} from '@nestjs/common';
|
||||
import type { HttpRequest } from '../common/http-request';
|
||||
|
||||
export const IS_PUBLIC = Symbol('IS_PUBLIC');
|
||||
export const REQUIRED_ROLES = Symbol('REQUIRED_ROLES');
|
||||
/** 显式标记公开控制器或操作;仅跳过身份检查,不改变其他中间件及校验行为。 */
|
||||
export const Public = () => SetMetadata(IS_PUBLIC, true);
|
||||
/** 声明允许访问的账户角色,由全局守卫使用当前账户资料判断。 */
|
||||
export const Roles = (...roles: Array<'user' | 'admin'>) =>
|
||||
SetMetadata(REQUIRED_ROLES, roles);
|
||||
/** 从守卫写入的请求上下文取身份;公开路由未鉴权时该值可能为 undefined。 */
|
||||
export const CurrentUser = createParamDecorator(
|
||||
(_data: unknown, context: ExecutionContext) =>
|
||||
context.switchToHttp().getRequest<HttpRequest>().auth,
|
||||
);
|
||||
@@ -0,0 +1,21 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { APP_GUARD } from '@nestjs/core';
|
||||
import { JwtModule } from '@nestjs/jwt';
|
||||
import { ACCOUNT_READER, UnconfiguredAccountReader } from './account-reader';
|
||||
import { AccessTokenGuard } from './access-token.guard';
|
||||
import { JwtTokenService } from './jwt-token.service';
|
||||
|
||||
@Module({
|
||||
// 签名和验证参数由 JwtTokenService 统一提供,避免不同调用点使用不同策略。
|
||||
imports: [JwtModule.register({})],
|
||||
providers: [
|
||||
JwtTokenService,
|
||||
// 用户模块接入 Prisma 后替换此 provider;当前有效 token 也不能绕过账户检查。
|
||||
{ provide: ACCOUNT_READER, useClass: UnconfiguredAccountReader },
|
||||
// 注册为全局守卫,新路由默认受保护,无需逐个添加 @UseGuards。
|
||||
{ provide: APP_GUARD, useClass: AccessTokenGuard },
|
||||
],
|
||||
exports: [JwtTokenService, ACCOUNT_READER],
|
||||
})
|
||||
/** JWT 与鉴权基础设施;本模块尚不提供微信登录或公开 token 签发接口。 */
|
||||
export class AuthModule {}
|
||||
@@ -0,0 +1,78 @@
|
||||
import { Inject, Injectable, UnauthorizedException } from '@nestjs/common';
|
||||
import { JwtService } from '@nestjs/jwt';
|
||||
import { SERVER_CONFIG, type ServerConfig } from '../config/server-config';
|
||||
|
||||
/** 最小身份载荷;authVersion 用于与账户当前版本比较,不在 token 中固化角色。 */
|
||||
export interface AccessTokenClaims {
|
||||
sub: string;
|
||||
authVersion: number;
|
||||
iat: number;
|
||||
exp: number;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
/** 统一签发和验证访问凭证,固定算法、签发方、受众和有效期。 */
|
||||
export class JwtTokenService {
|
||||
constructor(
|
||||
private readonly jwt: JwtService,
|
||||
@Inject(SERVER_CONFIG) private readonly config: ServerConfig,
|
||||
) {}
|
||||
|
||||
/** 仅供可信登录业务调用;调用方须先验证微信身份并取得真实账户。 */
|
||||
async signAccessToken(
|
||||
userId: string,
|
||||
authVersion: number,
|
||||
): Promise<string> {
|
||||
if (
|
||||
!userId ||
|
||||
userId.length > 128 ||
|
||||
!Number.isSafeInteger(authVersion) ||
|
||||
authVersion < 0
|
||||
) {
|
||||
throw new Error('Invalid access token identity');
|
||||
}
|
||||
return this.jwt.signAsync(
|
||||
{ sub: userId, authVersion },
|
||||
{
|
||||
secret: this.config.jwtSecret,
|
||||
algorithm: 'HS256',
|
||||
issuer: this.config.jwtIssuer,
|
||||
audience: this.config.jwtAudience,
|
||||
expiresIn: this.config.jwtExpiresInSeconds,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
/** 验签及校验载荷;当前账户状态与权限仍由全局守卫继续核对。 */
|
||||
async verifyAccessToken(token: string): Promise<AccessTokenClaims> {
|
||||
try {
|
||||
const claims = await this.jwt.verifyAsync<AccessTokenClaims>(
|
||||
token,
|
||||
{
|
||||
secret: this.config.jwtSecret,
|
||||
// 不根据 token 头自由选择算法,并限制凭证用途和签发方。
|
||||
algorithms: ['HS256'],
|
||||
issuer: this.config.jwtIssuer,
|
||||
audience: this.config.jwtAudience,
|
||||
},
|
||||
);
|
||||
// 签名正确仍需检查字段类型及必填时间,拒绝无期限或畸形身份载荷。
|
||||
if (
|
||||
typeof claims.sub !== 'string' ||
|
||||
!claims.sub ||
|
||||
claims.sub.length > 128 ||
|
||||
!Number.isSafeInteger(claims.authVersion) ||
|
||||
claims.authVersion < 0 ||
|
||||
!Number.isSafeInteger(claims.iat) ||
|
||||
!Number.isSafeInteger(claims.exp) ||
|
||||
claims.exp <= claims.iat
|
||||
) {
|
||||
throw new Error('Invalid access token claims');
|
||||
}
|
||||
return claims;
|
||||
} catch {
|
||||
// 过期、伪造或载荷错误统一返回 401,不向客户端暴露验签细节。
|
||||
throw new UnauthorizedException();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
import {
|
||||
ArgumentsHost,
|
||||
Catch,
|
||||
HttpException,
|
||||
type ExceptionFilter,
|
||||
} from '@nestjs/common';
|
||||
import type { Response } from 'express';
|
||||
import type { HttpRequest } from './http-request';
|
||||
import { AppLogger } from '../logging/app-logger';
|
||||
|
||||
// 对外只提供稳定的安全提示;异常原始 message 可能包含请求值、路径或密钥。
|
||||
const messages: Record<number, string> = {
|
||||
400: '请求参数或格式不正确',
|
||||
401: '请重新登录',
|
||||
403: '没有访问权限',
|
||||
404: '接口不存在',
|
||||
409: '请求与当前数据冲突',
|
||||
413: '请求体超过大小限制',
|
||||
415: '不支持的请求格式',
|
||||
429: '请求过于频繁',
|
||||
503: '服务暂时不可用',
|
||||
};
|
||||
|
||||
@Catch()
|
||||
/** 将 Nest 和解析器异常转为统一 HTTP 错误,使用 requestId 关联服务端日志。 */
|
||||
export class ApiErrorFilter implements ExceptionFilter {
|
||||
constructor(private readonly logger: AppLogger) {}
|
||||
|
||||
catch(exception: unknown, host: ArgumentsHost): void {
|
||||
const request = host.switchToHttp().getRequest<HttpRequest>();
|
||||
const response = host.switchToHttp().getResponse<Response>();
|
||||
// 未识别异常默认视为服务端错误;已知 HTTP 异常保留原状态码。
|
||||
let status = 500;
|
||||
if (exception instanceof HttpException) status = exception.getStatus();
|
||||
else if (
|
||||
exception &&
|
||||
typeof exception === 'object' &&
|
||||
'type' in exception
|
||||
) {
|
||||
// body-parser 在进入控制器前抛出的错误不一定是 Nest HttpException。
|
||||
if (exception.type === 'entity.too.large') status = 413;
|
||||
else if (exception.type === 'entity.parse.failed') status = 400;
|
||||
else if (
|
||||
exception.type === 'encoding.unsupported' ||
|
||||
exception.type === 'charset.unsupported'
|
||||
)
|
||||
status = 415;
|
||||
}
|
||||
// 请求完成中间件记录全部状态;服务端错误另记诊断事件,不记录原请求及堆栈。
|
||||
if (status >= 500) {
|
||||
this.logger.error('http_request_failed', {
|
||||
request_id: request.requestId,
|
||||
method: request.method,
|
||||
path: request.path,
|
||||
status_code: status,
|
||||
error_type:
|
||||
exception instanceof Error
|
||||
? exception.name
|
||||
: 'UnknownError',
|
||||
});
|
||||
}
|
||||
// HTTP 状态与响应 code 保持一致,客户端可用 requestId 反馈问题。
|
||||
response.status(status).json({
|
||||
code: status,
|
||||
message: messages[status] ?? '服务器暂时无法处理请求',
|
||||
requestId: request.requestId,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
import type { Request } from 'express';
|
||||
|
||||
/** 守卫核对账户后写入的身份,后续业务不能用客户端提交的 userId 替代它。 */
|
||||
export interface AuthenticatedUser {
|
||||
userId: string;
|
||||
authVersion: number;
|
||||
role: 'user' | 'admin';
|
||||
}
|
||||
|
||||
/** 生命周期内共享的请求上下文;未经过中间件或鉴权时字段可能尚未赋值。 */
|
||||
export type HttpRequest = Request & {
|
||||
requestId?: string;
|
||||
auth?: AuthenticatedUser;
|
||||
};
|
||||
@@ -3,6 +3,7 @@ export interface RuntimeOptions {
|
||||
port: number;
|
||||
}
|
||||
|
||||
/** 解析监听地址与端口;默认仅本机可访问,部署时显式配置外部监听地址。 */
|
||||
export function getRuntimeOptions(
|
||||
env: NodeJS.ProcessEnv = process.env,
|
||||
): RuntimeOptions {
|
||||
@@ -10,6 +11,7 @@ export function getRuntimeOptions(
|
||||
const port = Number(rawPort);
|
||||
const host = (env.HOST ?? '127.0.0.1').trim();
|
||||
|
||||
// 先检查原始字符串,拒绝 Number 会接受的空串、科学计数法等输入。
|
||||
if (
|
||||
!/^\d+$/.test(rawPort) ||
|
||||
!Number.isInteger(port) ||
|
||||
|
||||
@@ -0,0 +1,154 @@
|
||||
import { DynamicModule, Global, Module } from '@nestjs/common';
|
||||
import {
|
||||
getRuntimeOptions,
|
||||
type RuntimeOptions,
|
||||
} from '../common/runtime-options';
|
||||
|
||||
/** 注入已经校验的配置,业务模块无需直接读取 process.env。 */
|
||||
export const SERVER_CONFIG = Symbol('SERVER_CONFIG');
|
||||
/** API 契约的主版本前缀,与服务端发布版本、小程序版本分别维护。 */
|
||||
export const API_PREFIX = 'api/v1';
|
||||
export type LogLevel = 'error' | 'warn' | 'info' | 'debug';
|
||||
|
||||
/** 规范化的启动配置;仅在服务端使用,包含不得下发或记录的 JWT 密钥。 */
|
||||
export interface ServerConfig extends RuntimeOptions {
|
||||
nodeEnv: 'development' | 'test' | 'production';
|
||||
logLevel: LogLevel;
|
||||
bodyLimitBytes: number;
|
||||
swaggerEnabled: boolean;
|
||||
corsOrigins: string[];
|
||||
trustedProxyCidrs: string[];
|
||||
jwtSecret: string;
|
||||
jwtIssuer: string;
|
||||
jwtAudience: string;
|
||||
jwtExpiresInSeconds: number;
|
||||
}
|
||||
|
||||
/** 按十进制整数字符串解析并检查范围,防止空串、科学计数法等被 Number 宽松接受。 */
|
||||
function integer(
|
||||
env: NodeJS.ProcessEnv,
|
||||
key: string,
|
||||
fallback: number,
|
||||
min: number,
|
||||
max: number,
|
||||
): number {
|
||||
const raw = env[key] ?? String(fallback);
|
||||
const value = Number(raw);
|
||||
if (
|
||||
!/^\d+$/.test(raw) ||
|
||||
!Number.isSafeInteger(value) ||
|
||||
value < min ||
|
||||
value > max
|
||||
) {
|
||||
throw new Error(`${key} must be an integer between ${min} and ${max}`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
/** 启动前一次性校验环境变量;错误消息只包含配置项要求,不回显实际配置值。 */
|
||||
export function getServerConfig(
|
||||
env: NodeJS.ProcessEnv = process.env,
|
||||
): ServerConfig {
|
||||
const nodeEnv = env.NODE_ENV ?? 'development';
|
||||
if (!['development', 'test', 'production'].includes(nodeEnv)) {
|
||||
throw new Error('NODE_ENV must be development, test or production');
|
||||
}
|
||||
const logLevel = env.LOG_LEVEL ?? 'info';
|
||||
if (!['error', 'warn', 'info', 'debug'].includes(logLevel)) {
|
||||
throw new Error('LOG_LEVEL must be error, warn, info or debug');
|
||||
}
|
||||
// 未显式配置时生产关闭文档,避免沿用开发环境的公开文档入口。
|
||||
const rawSwagger =
|
||||
env.SWAGGER_ENABLED ?? (nodeEnv === 'production' ? 'false' : 'true');
|
||||
if (!['true', 'false'].includes(rawSwagger)) {
|
||||
throw new Error('SWAGGER_ENABLED must be true or false');
|
||||
}
|
||||
// 密钥没有默认值;按字节检查长度,并拒绝示例中的占位密码。
|
||||
const jwtSecret = env.JWT_SECRET ?? '';
|
||||
if (
|
||||
Buffer.byteLength(jwtSecret.trim()) < 32 ||
|
||||
jwtSecret.includes('change-me')
|
||||
) {
|
||||
throw new Error(
|
||||
'JWT_SECRET must be a configured secret of at least 32 bytes',
|
||||
);
|
||||
}
|
||||
const jwtIssuer = (env.JWT_ISSUER ?? 'doodle-store').trim();
|
||||
const jwtAudience = (env.JWT_AUDIENCE ?? 'doodle-mini').trim();
|
||||
if (!jwtIssuer || !jwtAudience) {
|
||||
throw new Error('JWT_ISSUER and JWT_AUDIENCE must not be empty');
|
||||
}
|
||||
// 只接受可直接比较的完整 origin;路径、凭证、通配符不属于白名单项。
|
||||
const corsOrigins = (env.CORS_ORIGINS ?? '')
|
||||
.split(',')
|
||||
.map((origin) => origin.trim())
|
||||
.filter(Boolean);
|
||||
for (const origin of corsOrigins) {
|
||||
let url: URL;
|
||||
try {
|
||||
url = new URL(origin);
|
||||
} catch {
|
||||
throw new Error('CORS_ORIGINS must contain HTTP(S) origins');
|
||||
}
|
||||
if (
|
||||
!['http:', 'https:'].includes(url.protocol) ||
|
||||
url.origin !== origin
|
||||
) {
|
||||
throw new Error(
|
||||
'CORS_ORIGINS must contain exact HTTP(S) origins without paths',
|
||||
);
|
||||
}
|
||||
}
|
||||
const trustedProxyCidrs = (env.TRUSTED_PROXY_CIDRS ?? '')
|
||||
.split(',')
|
||||
.map((cidr) => cidr.trim())
|
||||
.filter(Boolean);
|
||||
// Express 在安装 trust proxy 时校验具体网段;不接受 true 或 hop count。
|
||||
if (
|
||||
trustedProxyCidrs.some(
|
||||
(value) => ['true', 'false'].includes(value) || /^\d+$/.test(value),
|
||||
)
|
||||
) {
|
||||
throw new Error(
|
||||
'TRUSTED_PROXY_CIDRS must contain explicit proxy addresses or CIDRs',
|
||||
);
|
||||
}
|
||||
return {
|
||||
...getRuntimeOptions(env),
|
||||
nodeEnv: nodeEnv as ServerConfig['nodeEnv'],
|
||||
logLevel: logLevel as LogLevel,
|
||||
bodyLimitBytes: integer(
|
||||
env,
|
||||
'API_BODY_LIMIT_BYTES',
|
||||
1_048_576,
|
||||
1024,
|
||||
10 * 1024 * 1024,
|
||||
),
|
||||
swaggerEnabled: rawSwagger === 'true',
|
||||
corsOrigins,
|
||||
trustedProxyCidrs,
|
||||
jwtSecret,
|
||||
jwtIssuer,
|
||||
jwtAudience,
|
||||
jwtExpiresInSeconds: integer(
|
||||
env,
|
||||
'JWT_EXPIRES_IN_SECONDS',
|
||||
86_400,
|
||||
60,
|
||||
604_800,
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
@Global()
|
||||
@Module({})
|
||||
/** 将同一份配置提供给所有模块;测试可传入独立配置,避免修改进程环境。 */
|
||||
export class ServerConfigModule {
|
||||
static forRoot(config: ServerConfig): DynamicModule {
|
||||
return {
|
||||
module: ServerConfigModule,
|
||||
providers: [{ provide: SERVER_CONFIG, useValue: config }],
|
||||
exports: [SERVER_CONFIG],
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -1,9 +1,19 @@
|
||||
import { Controller, Get } from '@nestjs/common';
|
||||
import type { ApiSuccess, HealthStatus } from '@doodle/contracts';
|
||||
import { ApiOperation, ApiResponse, ApiTags } from '@nestjs/swagger';
|
||||
import { Public } from '../auth/auth.decorators';
|
||||
|
||||
@Controller('health')
|
||||
@ApiTags('health')
|
||||
export class HealthController {
|
||||
@Get()
|
||||
@Public()
|
||||
@ApiOperation({
|
||||
summary: 'HTTP 进程存活检查,不检测数据库或七牛',
|
||||
security: [],
|
||||
})
|
||||
@ApiResponse({ status: 200, description: '服务进程可响应' })
|
||||
/** 仅确认 HTTP 进程能响应;数据库、七牛及业务可用性应另行检查。 */
|
||||
getHealth(): ApiSuccess<HealthStatus> {
|
||||
return {
|
||||
code: 0,
|
||||
|
||||
@@ -0,0 +1,90 @@
|
||||
import type { LoggerService } from '@nestjs/common';
|
||||
import type { LogLevel } from '../config/server-config';
|
||||
|
||||
// 数值越大越详细;只输出不超过当前配置级别的事件。
|
||||
const levels: Record<LogLevel, number> = {
|
||||
error: 0,
|
||||
warn: 1,
|
||||
info: 2,
|
||||
debug: 3,
|
||||
};
|
||||
// 按结构化字段名递归脱敏;调用方仍不得将 token/query 等拼入普通消息字符串。
|
||||
const sensitiveKeys =
|
||||
/authorization|cookie|password|secret|token|api[_-]?key|session[_-]?key|openid|unionid|^code$|body|query/i;
|
||||
|
||||
/** 将日志上下文转为可安全序列化的值,处理密钥、bigint 和循环引用。 */
|
||||
function sanitize(value: unknown, seen = new WeakSet<object>()): unknown {
|
||||
if (typeof value === 'bigint') return value.toString();
|
||||
// Error 的 message/stack 可能带有原始解析内容或连接凭证,只保留异常类型。
|
||||
if (value instanceof Error) return { name: value.name };
|
||||
if (value && typeof value === 'object') {
|
||||
if (seen.has(value)) return '[Circular]';
|
||||
seen.add(value);
|
||||
if (Array.isArray(value))
|
||||
return value.map((item) => sanitize(item, seen));
|
||||
return Object.fromEntries(
|
||||
Object.entries(value).map(([key, child]) => [
|
||||
key,
|
||||
sensitiveKeys.test(key) ? '[REDACTED]' : sanitize(child, seen),
|
||||
]),
|
||||
);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
/** Nest 日志适配器:每条事件输出单行 JSON,便于按 request_id、状态及耗时检索。 */
|
||||
export class AppLogger implements LoggerService {
|
||||
constructor(
|
||||
private readonly minimumLevel: LogLevel,
|
||||
// 生产默认输出到标准流;测试注入 sink 收集日志,无需修改 console。
|
||||
private readonly sink: (line: string, level: LogLevel) => void = (
|
||||
line,
|
||||
level,
|
||||
) => {
|
||||
if (level === 'error') console.error(line);
|
||||
else console.log(line);
|
||||
},
|
||||
) {}
|
||||
|
||||
log(message: unknown, context?: unknown): void {
|
||||
this.write('info', message, context);
|
||||
}
|
||||
warn(message: unknown, context?: unknown): void {
|
||||
this.write('warn', message, context);
|
||||
}
|
||||
debug(message: unknown, context?: unknown): void {
|
||||
this.write('debug', message, context);
|
||||
}
|
||||
// Nest 的 verbose 归入本项目 debug 级别,避免额外增加一套配置值。
|
||||
verbose(message: unknown, context?: unknown): void {
|
||||
this.debug(message, context);
|
||||
}
|
||||
fatal(message: unknown, context?: unknown): void {
|
||||
this.write('error', message, context);
|
||||
}
|
||||
error(message: unknown, stackOrContext?: unknown, context?: unknown): void {
|
||||
// 不默认输出异常 stack/message;可包含登录 code、连接密码或原始解析内容。
|
||||
this.write(
|
||||
'error',
|
||||
message,
|
||||
context ??
|
||||
(typeof stackOrContext === 'object'
|
||||
? stackOrContext
|
||||
: undefined),
|
||||
);
|
||||
}
|
||||
|
||||
/** 在唯一出口统一过滤级别、脱敏和序列化,保证 Nest 与业务日志格式一致。 */
|
||||
private write(level: LogLevel, message: unknown, context?: unknown): void {
|
||||
if (levels[level] > levels[this.minimumLevel]) return;
|
||||
this.sink(
|
||||
JSON.stringify({
|
||||
timestamp: new Date().toISOString(),
|
||||
level,
|
||||
message: sanitize(message),
|
||||
context: sanitize(context),
|
||||
}),
|
||||
level,
|
||||
);
|
||||
}
|
||||
}
|
||||
+28
-19
@@ -1,13 +1,13 @@
|
||||
import 'reflect-metadata';
|
||||
import { Logger } from '@nestjs/common';
|
||||
import { NestFactory } from '@nestjs/core';
|
||||
import { AppModule } from './app.module';
|
||||
import { configureApplication } from './app.setup';
|
||||
import { getRuntimeOptions } from './common/runtime-options';
|
||||
import { resolve } from 'node:path';
|
||||
import { createApplication } from './app.factory';
|
||||
import { API_PREFIX, getServerConfig } from './config/server-config';
|
||||
import { AppLogger } from './logging/app-logger';
|
||||
|
||||
async function bootstrap(): Promise<void> {
|
||||
export async function bootstrap(): Promise<void> {
|
||||
try {
|
||||
process.loadEnvFile();
|
||||
// 仅读取服务端 .env,避免读到父目录 Compose 的 .env。
|
||||
process.loadEnvFile(resolve(__dirname, '../.env'));
|
||||
} catch (error) {
|
||||
if (!(
|
||||
error instanceof Error &&
|
||||
@@ -18,17 +18,26 @@ async function bootstrap(): Promise<void> {
|
||||
}
|
||||
}
|
||||
|
||||
const { port, host } = getRuntimeOptions();
|
||||
const app = await NestFactory.create(AppModule);
|
||||
configureApplication(app);
|
||||
await app.listen(port, host);
|
||||
Logger.log(
|
||||
`Listening on http://${host}:${port}/api/v1/health`,
|
||||
'Bootstrap',
|
||||
);
|
||||
const config = getServerConfig();
|
||||
const logger = new AppLogger(config.logLevel);
|
||||
const app = await createApplication(config, logger);
|
||||
try {
|
||||
await app.listen(config.port, config.host);
|
||||
logger.log('server_started', {
|
||||
host: config.host,
|
||||
port: config.port,
|
||||
api_prefix: API_PREFIX,
|
||||
swagger_enabled: config.swaggerEnabled,
|
||||
});
|
||||
} catch (error) {
|
||||
await app.close();
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
void bootstrap().catch((error: unknown) => {
|
||||
Logger.error(error, undefined, 'Bootstrap');
|
||||
process.exit(1);
|
||||
});
|
||||
if (require.main === module) {
|
||||
void bootstrap().catch((error: unknown) => {
|
||||
new AppLogger('error').error('server_startup_failed', { error });
|
||||
process.exitCode = 1;
|
||||
});
|
||||
}
|
||||
|
||||
@@ -2,4 +2,4 @@
|
||||
|
||||
后续按迁移方案逐步加入 auth、users、worksheets、categories、favorites、downloads、content、assets 和 legacy 模块。
|
||||
|
||||
当前没有创建任何业务 Controller、Service、数据库模型或伪接口。健康检查独立放在 `src/health`,不代表业务迁移已完成。
|
||||
当前 `src/auth` 已提供 JWT 签发/验签、全局守卫、角色校验及账户读取接入点;真实微信登录、Prisma 账户读取和其他业务模块尚未实现。健康检查独立放在 `src/health`,不代表业务迁移已完成。基础设置见 [Server 文档](../../../../docs/v3.5/Server基础设置.md)。
|
||||
|
||||
Reference in New Issue
Block a user