feat: migrate backend foundation to NestJS

This commit is contained in:
2026-07-17 17:41:41 +08:00
parent 39f7332f33
commit f219eb0bf8
37 changed files with 2691 additions and 402 deletions

1
.gitignore vendored
View File

@@ -30,6 +30,7 @@ scripts/satellite/sync-config.json
# ✅ IDE / AI 工具配置
.qoder/
.codex-backups/
/.codegraph/
# ✅ 补丁与压缩包
*.patch

View File

@@ -10,11 +10,20 @@
"check": "tsc -p tsconfig.json --noEmit"
},
"dependencies": {
"@nestjs/common": "^11.1.28",
"@nestjs/core": "^11.1.28",
"@nestjs/platform-fastify": "^11.1.28",
"@nestjs/swagger": "^11.4.6",
"@scalar/nestjs-api-reference": "^1.0.31",
"@supabase/storage-js": "^2.108.2",
"ali-oss": "^6.23.0",
"class-transformer": "^0.5.1",
"class-validator": "^0.14.4",
"jose": "^6.2.3",
"pg": "^8.16.3",
"read-excel-file": "^9.2.0"
"read-excel-file": "^9.2.0",
"reflect-metadata": "^0.2.2",
"rxjs": "^7.8.2"
},
"devDependencies": {
"@types/node": "^24.0.4",

View File

@@ -10,6 +10,7 @@ export interface RequestContext {
res: ServerResponse;
url: URL;
requestId: string;
parsedBody?: unknown;
}
export type Handler = (ctx: RequestContext) => Promise<unknown>;

View File

@@ -90,6 +90,16 @@ export interface ReadJsonBodyOptions {
export async function readJsonBody(ctx: RequestContext, options: ReadJsonBodyOptions = {}): Promise<JsonObject> {
const maxBytes = options.maxBytes ?? config.maxJsonBodyBytes;
if (ctx.parsedBody !== undefined) {
const serializedBytes = Buffer.byteLength(JSON.stringify(ctx.parsedBody));
if (serializedBytes > maxBytes) {
throw new HttpError(413, `JSON body is too large. Max ${maxBytes} bytes.`, 'JSON_BODY_TOO_LARGE');
}
if (!ctx.parsedBody || typeof ctx.parsedBody !== 'object' || Array.isArray(ctx.parsedBody)) {
throw new HttpError(400, 'JSON body must be an object', 'INVALID_JSON_BODY');
}
return ctx.parsedBody as JsonObject;
}
const contentLength = Number(getHeader(ctx.req, 'content-length') || 0);
if (Number.isFinite(contentLength) && contentLength > maxBytes) {
throw new HttpError(413, `JSON body is too large. Max ${maxBytes} bytes.`, 'JSON_BODY_TOO_LARGE');

View File

@@ -28,7 +28,7 @@ export function createRouter(definitions: RouteDefinition[] = allRoutes) {
return routes;
}
const allRoutes: RouteDefinition[] = [
export const allRoutes: RouteDefinition[] = [
...healthRoutes,
...authRoutes,
...tenantRoutes,

View File

@@ -0,0 +1,48 @@
import { Body, Controller, Get, HttpCode, Inject, Injectable, Module, Post, Query, Req, Res } from '@nestjs/common';
import { ApiBearerAuth, ApiBody, ApiOperation, ApiTags } from '@nestjs/swagger';
import type { FastifyReply, FastifyRequest } from 'fastify';
import * as routes from '../features/ai/routes.js';
import { ApiStandardResponses } from './api-doc.decorators.js';
import { DomainRouteService } from './domain-route.service.js';
import { GenerateRecommendationDto } from './request.dto.js';
import { RecommendationDetailQueryDto, RecommendationExportQueryDto, RecommendationListQueryDto } from './query.dto.js';
import { RequestContextFactory } from './request-context.factory.js';
const AI_HANDLERS = Symbol('AI_HANDLERS');
const aiHandlers = {
list: routes.schoolRecommendationReportsRoute,
detail: routes.schoolRecommendationReportDetailRoute,
export: routes.schoolRecommendationReportExportRoute,
generate: routes.generateSchoolRecommendationRoute,
};
@Injectable()
class AiService extends DomainRouteService {
constructor(factory: RequestContextFactory, @Inject(AI_HANDLERS) injectedHandlers: typeof aiHandlers) {
super(factory, injectedHandlers);
}
}
@ApiTags('AI 推荐')
@ApiBearerAuth()
@Controller('/api/ai/school-recommendations')
class AiController {
constructor(private readonly service: AiService) {}
private run(name: string, req: FastifyRequest, res: FastifyReply) { return this.service.execute(name, req, res); }
@Get() @ApiOperation({ summary: '查询院校推荐报告' })
@ApiStandardResponses('items')
list(@Query() _q: RecommendationListQueryDto, @Req() req: FastifyRequest, @Res({ passthrough: true }) res: FastifyReply) { return this.run('list', req, res); }
@Get('detail') @ApiOperation({ summary: '获取院校推荐报告详情' })
@ApiStandardResponses('item')
detail(@Query() _q: RecommendationDetailQueryDto, @Req() req: FastifyRequest, @Res({ passthrough: true }) res: FastifyReply) { return this.run('detail', req, res); }
@Get('export') @ApiOperation({ summary: '导出院校推荐报告' })
@ApiStandardResponses('item')
exportReport(@Query() _q: RecommendationExportQueryDto, @Req() req: FastifyRequest, @Res({ passthrough: true }) res: FastifyReply) { return this.run('export', req, res); }
@Post('generate') @HttpCode(200) @ApiBody({ type: GenerateRecommendationDto }) @ApiOperation({ summary: '生成院校推荐报告', description: '根据学生成绩、地区、专业偏好等条件生成并保存推荐报告。' })
@ApiStandardResponses('item')
generate(@Body() _b: GenerateRecommendationDto, @Req() req: FastifyRequest, @Res({ passthrough: true }) res: FastifyReply) { return this.run('generate', req, res); }
}
@Module({ controllers: [AiController], providers: [AiService, { provide: AI_HANDLERS, useValue: aiHandlers }] })
export class AiModule {}

View File

@@ -0,0 +1,38 @@
import { applyDecorators } from '@nestjs/common';
import { ApiBadRequestResponse, ApiExtraModels, ApiForbiddenResponse, ApiNotFoundResponse, ApiOkResponse, ApiUnauthorizedResponse, getSchemaPath } from '@nestjs/swagger';
import { ApiErrorResponseDto, ApiResponseMetaDto, GenericItemDto, envelopeSchema, itemEnvelopeSchema, itemsEnvelopeSchema, okEnvelopeSchema } from './api-response.dto.js';
type ResponseKind = 'item' | 'items' | 'ok' | 'object';
export function ApiStandardResponses(kind: ResponseKind = 'object', description = '请求成功') {
const schema = kind === 'item'
? itemEnvelopeSchema
: kind === 'items'
? itemsEnvelopeSchema
: kind === 'ok'
? okEnvelopeSchema
: envelopeSchema({ data: { type: 'object', additionalProperties: true, description: '接口业务数据' } });
return applyDecorators(
ApiExtraModels(ApiResponseMetaDto, ApiErrorResponseDto, GenericItemDto),
ApiOkResponse({ description, schema }),
ApiBadRequestResponse({ description: '请求参数或业务输入不合法', type: ApiErrorResponseDto }),
ApiUnauthorizedResponse({ description: '未登录、会话无效或已过期', type: ApiErrorResponseDto }),
ApiForbiddenResponse({ description: '当前用户无权访问该租户或资源', type: ApiErrorResponseDto }),
ApiNotFoundResponse({ description: '请求的业务资源不存在', type: ApiErrorResponseDto }),
);
}
export function ApiEnvelopeProperties(properties: Record<string, unknown>, description = '请求成功') {
return applyDecorators(
ApiExtraModels(ApiResponseMetaDto, ApiErrorResponseDto, GenericItemDto),
ApiOkResponse({ description, schema: envelopeSchema(properties) }),
ApiBadRequestResponse({ description: '请求参数或业务输入不合法', type: ApiErrorResponseDto }),
ApiUnauthorizedResponse({ description: '未登录、会话无效或已过期', type: ApiErrorResponseDto }),
ApiForbiddenResponse({ description: '当前用户无权访问该租户或资源', type: ApiErrorResponseDto }),
ApiNotFoundResponse({ description: '请求的业务资源不存在', type: ApiErrorResponseDto }),
);
}
export const itemProperty = { $ref: getSchemaPath(GenericItemDto) };
export const itemsProperty = { type: 'array', items: itemProperty };

View File

@@ -0,0 +1,35 @@
import type { ArgumentsHost, ExceptionFilter } from '@nestjs/common';
import { Catch, HttpException } from '@nestjs/common';
import type { FastifyReply } from 'fastify';
import { config } from '../core/config.js';
import { HttpError } from '../core/errors.js';
import { withResponseMeta } from '../core/http.js';
@Catch()
export class ApiExceptionFilter implements ExceptionFilter {
catch(error: unknown, host: ArgumentsHost) {
const reply = host.switchToHttp().getResponse<FastifyReply>();
const requestId = String(reply.getHeader('x-request-id') || '');
let statusCode = 500;
let message = config.isProduction ? 'Internal server error' : error instanceof Error ? error.message : 'Unknown error';
let code = 'INTERNAL_ERROR';
if (error instanceof HttpError) {
statusCode = error.statusCode;
message = error.message;
code = error.code;
} else if (error instanceof HttpException) {
statusCode = error.getStatus();
const response = error.getResponse();
if (typeof response === 'string') message = response;
else if (response && typeof response === 'object') {
const body = response as Record<string, unknown>;
const responseMessage = body.message;
message = Array.isArray(responseMessage) ? responseMessage.join('; ') : String(responseMessage || message);
code = typeof body.code === 'string' ? body.code : statusCode === 400 ? 'VALIDATION_ERROR' : 'HTTP_ERROR';
}
}
reply.status(statusCode).send(withResponseMeta({ error: message, code, requestId }, requestId));
}
}

View File

@@ -0,0 +1,69 @@
import { ApiProperty, ApiPropertyOptional, getSchemaPath } from '@nestjs/swagger';
export class ApiResponseMetaDto {
@ApiProperty({ description: '请求追踪 ID排查问题时请提供该值', example: '8e25cb25-2347-4c41-a734-2dd7c35df90f' })
requestId!: string;
}
export class ApiErrorResponseDto {
@ApiProperty({ description: '面向调用方的错误信息', example: 'questionId is required' })
error!: string;
@ApiProperty({ description: '稳定的业务错误码', example: 'REQUIRED_FIELD' })
code!: string;
@ApiProperty({ description: '请求追踪 ID' })
requestId!: string;
@ApiProperty({ type: ApiResponseMetaDto })
meta!: ApiResponseMetaDto;
}
export class GenericItemDto {
@ApiPropertyOptional({ description: '资源 ID', format: 'uuid' }) id?: string;
@ApiPropertyOptional({ description: '租户 ID', format: 'uuid' }) tenantId?: string;
@ApiPropertyOptional({ description: '用户 ID', format: 'uuid' }) userId?: string;
@ApiPropertyOptional({ description: '题目 ID', format: 'uuid' }) questionId?: string;
@ApiPropertyOptional({ description: '练习会话 ID', format: 'uuid' }) practiceSessionId?: string;
@ApiPropertyOptional({ description: '单词 ID', format: 'uuid' }) wordId?: string;
@ApiPropertyOptional({ description: '资源名称或标题' }) name?: string;
@ApiPropertyOptional({ description: '标题' }) title?: string;
@ApiPropertyOptional({ description: '资源类型' }) type?: string;
@ApiPropertyOptional({ description: '资源状态' }) status?: string;
@ApiPropertyOptional({ description: '是否操作成功' }) ok?: boolean;
@ApiPropertyOptional({ description: '是否收藏' }) favorite?: boolean;
@ApiPropertyOptional({ description: '积分或数量值' }) score?: number;
@ApiPropertyOptional({ description: '统计数量' }) count?: number;
@ApiPropertyOptional({ description: '扩展元数据', type: 'object', additionalProperties: true }) metadata?: Record<string, unknown>;
@ApiPropertyOptional({ description: '创建时间', format: 'date-time' }) createdAt?: string;
@ApiPropertyOptional({ description: '更新时间', format: 'date-time' }) updatedAt?: string;
}
export class BooleanResultDto {
@ApiProperty({ description: '操作是否成功', example: true }) ok!: boolean;
@ApiPropertyOptional({ description: '当前是否收藏', example: true }) favorite?: boolean;
}
export function envelopeSchema(payload: Record<string, unknown>) {
return {
type: 'object',
properties: {
...payload,
meta: { $ref: getSchemaPath(ApiResponseMetaDto) },
},
required: ['meta'],
};
}
export const itemEnvelopeSchema = envelopeSchema({
item: { $ref: getSchemaPath(GenericItemDto), description: '接口返回的单个业务对象;具体字段见接口说明。' },
});
export const itemsEnvelopeSchema = envelopeSchema({
items: { type: 'array', items: { $ref: getSchemaPath(GenericItemDto) }, description: '业务对象列表' },
});
export const okEnvelopeSchema = envelopeSchema({
ok: { type: 'boolean', example: true },
favorite: { type: 'boolean', description: '收藏接口返回当前收藏状态' },
});

View File

@@ -0,0 +1,14 @@
import type { CallHandler, ExecutionContext, NestInterceptor } from '@nestjs/common';
import { Injectable } from '@nestjs/common';
import type { FastifyReply } from 'fastify';
import type { Observable } from 'rxjs';
import { map } from 'rxjs/operators';
import { withResponseMeta } from '../core/http.js';
@Injectable()
export class ApiEnvelopeInterceptor implements NestInterceptor {
intercept(context: ExecutionContext, next: CallHandler): Observable<unknown> {
const reply = context.switchToHttp().getResponse<FastifyReply>();
return next.handle().pipe(map(body => withResponseMeta(body, String(reply.getHeader('x-request-id') || ''))));
}
}

View File

@@ -0,0 +1,21 @@
import { Global, Module } from '@nestjs/common';
import { AiModule } from './ai.module.js';
import { AuthModule } from './auth.module.js';
import { DatabaseLifecycle } from './database.provider.js';
import { HealthModule } from './health.module.js';
import { LearningModule } from './learning.module.js';
import { ProfileModule } from './profile.module.js';
import { RequestContextFactory } from './request-context.factory.js';
import { TenantModule } from './tenant.module.js';
@Global()
@Module({
providers: [RequestContextFactory, DatabaseLifecycle],
exports: [RequestContextFactory],
})
class CoreModule {}
@Module({
imports: [CoreModule, HealthModule, TenantModule, AuthModule, ProfileModule, LearningModule, AiModule],
})
export class AppModule {}

View File

@@ -0,0 +1,57 @@
import { Body, Controller, Get, HttpCode, Inject, Injectable, Module, Post, Req, Res } from '@nestjs/common';
import { ApiBearerAuth, ApiBody, ApiOperation, ApiTags } from '@nestjs/swagger';
import type { FastifyReply, FastifyRequest } from 'fastify';
import * as handlers from '../features/auth/routes.js';
import { DomainRouteService } from './domain-route.service.js';
import { BindPhoneDto, OAuthCodeDto, SmsSendDto, SmsVerifyDto } from './dto.js';
import { RequestContextFactory } from './request-context.factory.js';
import { ApiEnvelopeProperties, ApiStandardResponses } from './api-doc.decorators.js';
const AUTH_HANDLERS = Symbol('AUTH_HANDLERS');
const authHandlers = {
sendSms: handlers.sendSmsCodeRoute, verifySms: handlers.verifySmsCodeRoute, me: handlers.meRoute,
logout: handlers.logoutRoute, bindPhone: handlers.bindPhoneRoute, wechat: handlers.wechatWebLoginRoute,
miniapp: handlers.wechatMiniappLoginRoute, qq: handlers.qqLoginRoute,
};
@Injectable()
class AuthService extends DomainRouteService {
constructor(factory: RequestContextFactory, @Inject(AUTH_HANDLERS) injectedHandlers: typeof authHandlers) {
super(factory, injectedHandlers);
}
}
@ApiTags('认证与登录')
@Controller('/api/auth')
class AuthController {
constructor(private readonly service: AuthService) {}
private run(name: string, req: FastifyRequest, res: FastifyReply) { return this.service.execute(name, req, res); }
@Post('sms/send') @HttpCode(200) @ApiBody({ type: SmsSendDto }) @ApiOperation({ summary: '发送短信验证码', description: '根据用途发送登录、绑定手机号或重置密码验证码,并应用租户级限流。' })
@ApiEnvelopeProperties({ item: { type: 'object', properties: { id: { type: 'string', format: 'uuid' }, phone: { type: 'string' }, purpose: { type: 'string' }, provider: { type: 'string' }, status: { type: 'string' }, expiresAt: { type: 'string', format: 'date-time' } } }, expireIn: { type: 'integer', description: '验证码有效秒数' }, cooldown: { type: 'integer', description: '再次发送冷却秒数' }, debugCode: { type: 'string', description: '仅开发环境 mock provider 返回' } })
sendSms(@Body() _body: SmsSendDto, @Req() req: FastifyRequest, @Res({ passthrough: true }) res: FastifyReply) { return this.run('sendSms', req, res); }
@Post('sms/verify') @HttpCode(200) @ApiBody({ type: SmsVerifyDto }) @ApiOperation({ summary: '校验短信验证码并登录', description: '校验验证码;登录用途会创建或更新用户,并签发应用会话。' })
@ApiEnvelopeProperties({ ok: { type: 'boolean' }, verified: { type: 'boolean' }, purpose: { type: 'string' }, phone: { type: 'string' }, user: { type: 'object', description: '登录用户;非 login 用途可能不返回', additionalProperties: true }, isNewUser: { type: 'boolean' }, session: { type: 'object', description: '应用会话 token 与过期时间', properties: { token: { type: 'string' }, expiresAt: { type: 'string', format: 'date-time' } } } })
verifySms(@Body() _body: SmsVerifyDto, @Req() req: FastifyRequest, @Res({ passthrough: true }) res: FastifyReply) { return this.run('verifySms', req, res); }
@Get('me') @ApiBearerAuth() @ApiOperation({ summary: '获取当前登录用户', description: '根据 Bearer Token 返回当前用户与会话有效期。' })
@ApiEnvelopeProperties({ user: { type: 'object', description: '当前用户信息', additionalProperties: true }, session: { type: 'object', description: '当前会话信息', additionalProperties: true } })
me(@Req() req: FastifyRequest, @Res({ passthrough: true }) res: FastifyReply) { return this.run('me', req, res); }
@Post('logout') @HttpCode(200) @ApiBearerAuth() @ApiOperation({ summary: '退出登录', description: '撤销当前应用会话Supabase JWT 会话由其认证服务管理。' })
@ApiStandardResponses('ok')
logout(@Req() req: FastifyRequest, @Res({ passthrough: true }) res: FastifyReply) { return this.run('logout', req, res); }
@Post('phone/bind') @HttpCode(200) @ApiBearerAuth() @ApiBody({ type: BindPhoneDto }) @ApiOperation({ summary: '绑定手机号', description: '使用 bind_phone 用途的短信验证码为当前用户绑定中国大陆手机号。' })
@ApiEnvelopeProperties({ ok: { type: 'boolean' }, phone: { type: 'string' }, user: { type: 'object', additionalProperties: true } })
bindPhone(@Body() _body: BindPhoneDto, @Req() req: FastifyRequest, @Res({ passthrough: true }) res: FastifyReply) { return this.run('bindPhone', req, res); }
@Post('oauth/wechat') @HttpCode(200) @ApiBody({ type: OAuthCodeDto }) @ApiOperation({ summary: '微信网页 OAuth 登录', description: '使用微信网页授权 code 换取用户身份并创建应用会话。' })
@ApiEnvelopeProperties({ provider: { type: 'string' }, user: { type: 'object', additionalProperties: true }, isNewUser: { type: 'boolean' }, session: { type: 'object', additionalProperties: true }, identity: { type: 'object', additionalProperties: true } })
wechat(@Body() _body: OAuthCodeDto, @Req() req: FastifyRequest, @Res({ passthrough: true }) res: FastifyReply) { return this.run('wechat', req, res); }
@Post('oauth/wechat-miniapp') @HttpCode(200) @ApiBody({ type: OAuthCodeDto }) @ApiOperation({ summary: '微信小程序登录', description: '使用小程序 wx.login 返回的 code 换取 openid 并创建应用会话。' })
@ApiEnvelopeProperties({ provider: { type: 'string' }, user: { type: 'object', additionalProperties: true }, isNewUser: { type: 'boolean' }, session: { type: 'object', additionalProperties: true }, identity: { type: 'object', additionalProperties: true } })
miniapp(@Body() _body: OAuthCodeDto, @Req() req: FastifyRequest, @Res({ passthrough: true }) res: FastifyReply) { return this.run('miniapp', req, res); }
@Post('oauth/qq') @HttpCode(200) @ApiBody({ type: OAuthCodeDto }) @ApiOperation({ summary: 'QQ OAuth 登录', description: '使用 QQ OAuth 授权 code 和回调地址换取用户身份并创建应用会话。' })
@ApiEnvelopeProperties({ provider: { type: 'string' }, user: { type: 'object', additionalProperties: true }, isNewUser: { type: 'boolean' }, session: { type: 'object', additionalProperties: true }, identity: { type: 'object', additionalProperties: true } })
qq(@Body() _body: OAuthCodeDto, @Req() req: FastifyRequest, @Res({ passthrough: true }) res: FastifyReply) { return this.run('qq', req, res); }
}
@Module({ controllers: [AuthController], providers: [AuthService, { provide: AUTH_HANDLERS, useValue: authHandlers }] })
export class AuthModule {}

View File

@@ -0,0 +1,9 @@
import { Injectable, OnApplicationShutdown } from '@nestjs/common';
import { closePool } from '../core/db.js';
@Injectable()
export class DatabaseLifecycle implements OnApplicationShutdown {
async onApplicationShutdown() {
await closePool();
}
}

View File

@@ -0,0 +1,16 @@
import type { FastifyReply, FastifyRequest } from 'fastify';
import type { Handler } from '../core/http.js';
import { RequestContextFactory } from './request-context.factory.js';
export abstract class DomainRouteService {
protected constructor(
protected readonly contextFactory: RequestContextFactory,
private readonly handlers: Record<string, Handler>,
) {}
execute(name: string, request: FastifyRequest, reply: FastifyReply) {
const handler = this.handlers[name];
if (!handler) throw new Error(`Missing domain route handler: ${name}`);
return handler(this.contextFactory.create(request, reply));
}
}

55
apps/api/src/nest/dto.ts Normal file
View File

@@ -0,0 +1,55 @@
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import { IsIn, IsInt, IsObject, IsOptional, IsString, Max, Min } from 'class-validator';
import { Type } from 'class-transformer';
export class TenantResolveQueryDto {
@ApiPropertyOptional({ description: '要解析的访问域名,例如 student.example.com' }) @IsOptional() @IsString() host?: string;
@ApiPropertyOptional({ description: '租户编码;本地开发或无独立域名时使用', example: 'master' }) @IsOptional() @IsString() tenantCode?: string;
}
export class PaginationQueryDto {
@ApiPropertyOptional({ minimum: 1, maximum: 500 })
@IsOptional()
@Type(() => Number)
@IsInt()
@Min(1)
@Max(500)
limit?: number;
}
export class FlexibleQueryDto extends PaginationQueryDto {
@ApiPropertyOptional() @IsOptional() @IsString() id?: string;
@ApiPropertyOptional() @IsOptional() @IsString() sessionId?: string;
@ApiPropertyOptional() @IsOptional() @IsString() questionId?: string;
@ApiPropertyOptional() @IsOptional() @IsString() unitId?: string;
@ApiPropertyOptional() @IsOptional() @IsString() reportId?: string;
@ApiPropertyOptional() @IsOptional() @IsString() days?: string;
}
export class JsonObjectDto {
[key: string]: unknown;
}
const SMS_PURPOSES = ['login', 'bind_phone', 'reset_password'] as const;
export class SmsSendDto {
@ApiProperty({ description: '中国大陆手机号,允许 +86 前缀和空格', example: '13800138000' }) @IsString() phone!: string;
@ApiPropertyOptional({ description: '验证码用途', enum: SMS_PURPOSES, default: 'login' }) @IsOptional() @IsIn(SMS_PURPOSES) purpose?: string;
@ApiPropertyOptional({ description: '客户端设备标识,用于风控限流' }) @IsOptional() @IsString() deviceId?: string;
@ApiPropertyOptional({ description: '短信请求扩展元数据', type: 'object', additionalProperties: true }) @IsOptional() @IsObject() metadata?: Record<string, unknown>;
}
export class SmsVerifyDto {
@ApiProperty({ description: '中国大陆手机号', example: '13800138000' }) @IsString() phone!: string;
@ApiProperty({ description: '收到的短信验证码', example: '123456' }) @IsString() code!: string;
@ApiPropertyOptional({ description: '验证码用途', enum: SMS_PURPOSES, default: 'login' }) @IsOptional() @IsIn(SMS_PURPOSES) purpose?: string;
}
export class BindPhoneDto extends SmsVerifyDto {}
export class OAuthCodeDto {
@ApiProperty({ description: 'OAuth 平台返回的一次性授权 code' }) @IsString() code!: string;
@ApiPropertyOptional({ description: '客户端可提供的公开用户资料,不允许包含 token/secret', type: 'object', additionalProperties: true }) @IsOptional() @IsObject() profile?: Record<string, unknown>;
@ApiPropertyOptional({ description: '微信用户资料语言', example: 'zh_CN' }) @IsOptional() @IsString() lang?: string;
@ApiPropertyOptional({ description: 'QQ OAuth 回调地址;租户未配置时必填' }) @IsOptional() @IsString() redirectUri?: string;
}

View File

@@ -0,0 +1,31 @@
import { Controller, Get, Inject, Injectable, Module } from '@nestjs/common';
import { ApiOperation, ApiTags } from '@nestjs/swagger';
import { healthRoute } from '../features/health/routes.js';
import { ApiEnvelopeProperties } from './api-doc.decorators.js';
const HEALTH_CHECK = Symbol('HEALTH_CHECK');
@Injectable()
class HealthService {
constructor(@Inject(HEALTH_CHECK) private readonly healthCheck: typeof healthRoute) {}
check() { return this.healthCheck(); }
}
@ApiTags('健康检查')
@Controller()
class HealthController {
constructor(private readonly service: HealthService) {}
@Get('/health')
@ApiOperation({ summary: '检查 API 与数据库健康状态', description: '用于部署探针和人工排查,返回 API、PostgreSQL 连接及服务器时间。' })
@ApiEnvelopeProperties({
ok: { type: 'boolean', example: true },
service: { type: 'string', example: 'tiku-saas-api' },
db: { type: 'string', example: 'ok' },
time: { type: 'string', format: 'date-time' },
})
check() { return this.service.check(); }
}
@Module({ controllers: [HealthController], providers: [HealthService, { provide: HEALTH_CHECK, useValue: healthRoute }] })
export class HealthModule {}

View File

@@ -0,0 +1,63 @@
import type { FastifyInstance, FastifyRequest } from 'fastify';
import { config } from '../core/config.js';
import { authorizeCorsRequest, CorsPolicy } from '../core/cors.js';
import { requestIdFrom } from '../core/request-id.js';
import { withResponseMeta } from '../core/http.js';
function writeLog(event: Record<string, unknown>, error = false) {
const line = JSON.stringify({ timestamp: new Date().toISOString(), service: 'tiku-saas-api', ...event });
if (error) console.error(line);
else console.log(line);
}
function requestPath(request: FastifyRequest) {
try {
return new URL(request.raw.url || '/', 'http://localhost').pathname;
} catch {
return '/';
}
}
export function registerHttpHooks(instance: FastifyInstance) {
// 使用 Fastify hook 覆盖原生与兼容路由,避免两套路由出现不同的 CORS 和访问日志行为。
const corsPolicy = new CorsPolicy({
staticOrigins: config.corsOrigins,
tenantDomainsEnabled: config.corsTenantDomainsEnabled,
positiveCacheTtlMs: config.corsTenantDomainCacheTtlMs,
negativeCacheTtlMs: config.corsTenantDomainNegativeCacheTtlMs,
maxCacheEntries: config.corsTenantDomainCacheMaxEntries,
onLookupError(error, host) {
writeLog({ event: 'cors_tenant_domain_lookup_failed', host, error: error instanceof Error ? error.message : 'unknown' }, true);
},
});
instance.addHook('onRequest', async (request, reply) => {
const requestId = requestIdFrom(request.raw);
reply.header('x-request-id', requestId);
(request as FastifyRequest & { startedAt?: bigint }).startedAt = process.hrtime.bigint();
const corsDecision = await authorizeCorsRequest(request.raw, reply.raw, corsPolicy);
if (!corsDecision.allowed) {
reply.status(403).send(withResponseMeta({ error: 'Request origin is not allowed', code: 'CORS_ORIGIN_DENIED', requestId }, requestId));
return reply;
}
if (request.method === 'OPTIONS') {
reply.status(204).send();
return reply;
}
});
instance.addHook('onResponse', async (request, reply) => {
const startedAt = (request as FastifyRequest & { startedAt?: bigint }).startedAt || process.hrtime.bigint();
const durationMs = Number(process.hrtime.bigint() - startedAt) / 1_000_000;
writeLog({
event: 'http_request',
requestId: String(reply.getHeader('x-request-id') || ''),
method: request.method,
path: requestPath(request),
status: reply.statusCode,
durationMs: Number(durationMs.toFixed(2)),
}, reply.statusCode >= 500);
});
}
export { writeLog };

View File

@@ -0,0 +1,87 @@
import { Body, Controller, Get, HttpCode, Inject, Injectable, Module, Post, Query, Req, Res } from '@nestjs/common';
import { ApiBearerAuth, ApiBody, ApiOperation, ApiTags } from '@nestjs/swagger';
import type { FastifyReply, FastifyRequest } from 'fastify';
import * as routes from '../features/learning/routes.js';
import { learningLeaderboardRoute } from '../features/learning/leaderboard.js';
import { ApiEnvelopeProperties, ApiStandardResponses, itemsProperty } from './api-doc.decorators.js';
import { DomainRouteService } from './domain-route.service.js';
import { CreatePracticeSessionDto, FavoriteWordDto, QuestionActionDto, SubmitAnswerDto, SubmitPracticeSessionDto, WordProgressDto, WordReviewDto } from './request.dto.js';
import { LeaderboardQueryDto, LearningWindowQueryDto, LimitQueryDto, PracticeSessionQueryDto, UnitQueryDto, WordProgressQueryDto, WordReviewPlanQueryDto, WrongQuestionQueryDto, WrongReviewPlanQueryDto } from './query.dto.js';
import { RequestContextFactory } from './request-context.factory.js';
const learningHandlers = {
leaderboard: learningLeaderboardRoute, createSession: routes.createPracticeSessionRoute,
sessionDetail: routes.practiceSessionDetailRoute, submitSession: routes.submitPracticeSessionRoute,
sessionReport: routes.practiceSessionReportRoute, history: routes.practiceHistoryRoute,
reports: routes.practiceReportsRoute, stats: routes.learningStatsRoute, trend: routes.learningTrendRoute,
answer: routes.submitAnswerRoute, favoriteQuestions: routes.favoriteQuestionsRoute,
toggleFavoriteQuestion: routes.toggleFavoriteQuestionRoute, wrongQuestions: routes.wrongQuestionsRoute,
wrongPlan: routes.wrongQuestionReviewPlanRoute, resolveWrong: routes.resolveWrongQuestionRoute,
wordProgress: routes.wordProgressRoute, updateWordProgress: routes.updateWordProgressRoute,
wordPlan: routes.wordReviewPlanRoute, reviewWord: routes.reviewWordRoute, favoriteWords: routes.favoriteWordsRoute,
toggleFavoriteWord: routes.toggleFavoriteWordRoute, wordStats: routes.wordStatsRoute,
};
const LEARNING_HANDLERS = Symbol('LEARNING_HANDLERS');
@Injectable()
class LearningService extends DomainRouteService {
constructor(factory: RequestContextFactory, @Inject(LEARNING_HANDLERS) injectedHandlers: typeof learningHandlers) {
super(factory, injectedHandlers);
}
}
@ApiTags('学习与刷题')
@ApiBearerAuth()
@Controller('/api/learning')
class LearningController {
constructor(private readonly service: LearningService) {}
private run(name: string, req: FastifyRequest, res: FastifyReply) { return this.service.execute(name, req, res); }
@Get('leaderboard') @ApiOperation({ summary: '查询学习排行榜' }) @ApiEnvelopeProperties({ metric: { type: 'string' }, label: { type: 'string' }, unit: { type: 'string' }, period: { type: 'string' }, scope: { type: 'object', additionalProperties: true }, page: { type: 'integer' }, pageSize: { type: 'integer' }, items: itemsProperty, currentUser: { type: 'object', nullable: true, additionalProperties: true }, generatedAt: { type: 'string', format: 'date-time' } })
leaderboard(@Query() _q: LeaderboardQueryDto, @Req() req: FastifyRequest, @Res({ passthrough: true }) res: FastifyReply) { return this.run('leaderboard', req, res); }
@Post('practice-sessions') @HttpCode(200) @ApiBody({ type: CreatePracticeSessionDto }) @ApiOperation({ summary: '创建练习会话', description: '可通过蓝图、题集、内容节点或练习模式组装题目,并校验练习权益。' }) @ApiStandardResponses('item')
createSession(@Body() _b: CreatePracticeSessionDto, @Req() req: FastifyRequest, @Res({ passthrough: true }) res: FastifyReply) { return this.run('createSession', req, res); }
@Get('practice-sessions/detail') @ApiOperation({ summary: '获取练习会话详情' }) @ApiStandardResponses('item')
sessionDetail(@Query() _q: PracticeSessionQueryDto, @Req() req: FastifyRequest, @Res({ passthrough: true }) res: FastifyReply) { return this.run('sessionDetail', req, res); }
@Post('practice-sessions/submit') @HttpCode(200) @ApiBody({ type: SubmitPracticeSessionDto }) @ApiOperation({ summary: '提交练习会话', description: '结束练习、生成成绩报告并触发自动徽章判定。' }) @ApiStandardResponses('item')
submitSession(@Body() _b: SubmitPracticeSessionDto, @Req() req: FastifyRequest, @Res({ passthrough: true }) res: FastifyReply) { return this.run('submitSession', req, res); }
@Get('practice-sessions/report') @ApiOperation({ summary: '获取练习会话报告' }) @ApiStandardResponses('item')
sessionReport(@Query() _q: PracticeSessionQueryDto, @Req() req: FastifyRequest, @Res({ passthrough: true }) res: FastifyReply) { return this.run('sessionReport', req, res); }
@Get('practice-sessions/history') @ApiOperation({ summary: '查询练习历史' }) @ApiStandardResponses('items')
history(@Query() _q: LimitQueryDto, @Req() req: FastifyRequest, @Res({ passthrough: true }) res: FastifyReply) { return this.run('history', req, res); }
@Get('practice-reports') @ApiOperation({ summary: '查询练习报告列表' }) @ApiStandardResponses('items')
reports(@Query() _q: LimitQueryDto, @Req() req: FastifyRequest, @Res({ passthrough: true }) res: FastifyReply) { return this.run('reports', req, res); }
@Get('stats') @ApiOperation({ summary: '查询学习统计' }) @ApiStandardResponses('item')
stats(@Query() _q: LearningWindowQueryDto, @Req() req: FastifyRequest, @Res({ passthrough: true }) res: FastifyReply) { return this.run('stats', req, res); }
@Get('trend') @ApiOperation({ summary: '查询学习趋势' }) @ApiStandardResponses('items')
trend(@Query() _q: LearningWindowQueryDto, @Req() req: FastifyRequest, @Res({ passthrough: true }) res: FastifyReply) { return this.run('trend', req, res); }
@Post('answers') @HttpCode(200) @ApiBody({ type: SubmitAnswerDto }) @ApiOperation({ summary: '提交题目答案', description: '支持选择题、主观题自评和复合题子题答案;可关联练习会话。' }) @ApiStandardResponses('item')
answer(@Body() _b: SubmitAnswerDto, @Req() req: FastifyRequest, @Res({ passthrough: true }) res: FastifyReply) { return this.run('answer', req, res); }
@Get('favorites/questions') @ApiOperation({ summary: '查询收藏题目' }) @ApiStandardResponses('items')
favoriteQuestions(@Query() _q: LimitQueryDto, @Req() req: FastifyRequest, @Res({ passthrough: true }) res: FastifyReply) { return this.run('favoriteQuestions', req, res); }
@Post('favorites/questions') @HttpCode(200) @ApiBody({ type: QuestionActionDto }) @ApiOperation({ summary: '收藏或取消收藏题目' }) @ApiStandardResponses('ok')
toggleFavoriteQuestion(@Body() _b: QuestionActionDto, @Req() req: FastifyRequest, @Res({ passthrough: true }) res: FastifyReply) { return this.run('toggleFavoriteQuestion', req, res); }
@Get('wrong-questions') @ApiOperation({ summary: '查询错题列表' }) @ApiStandardResponses('items')
wrongQuestions(@Query() _q: WrongQuestionQueryDto, @Req() req: FastifyRequest, @Res({ passthrough: true }) res: FastifyReply) { return this.run('wrongQuestions', req, res); }
@Get('wrong-questions/review-plan') @ApiOperation({ summary: '生成错题复习计划' }) @ApiEnvelopeProperties({ items: itemsProperty, nextAction: { type: 'object', description: '下一步复习建议', additionalProperties: true } })
wrongPlan(@Query() _q: WrongReviewPlanQueryDto, @Req() req: FastifyRequest, @Res({ passthrough: true }) res: FastifyReply) { return this.run('wrongPlan', req, res); }
@Post('wrong-questions/resolve') @HttpCode(200) @ApiBody({ type: QuestionActionDto }) @ApiOperation({ summary: '将错题标记为已解决' }) @ApiStandardResponses('ok')
resolveWrong(@Body() _b: QuestionActionDto, @Req() req: FastifyRequest, @Res({ passthrough: true }) res: FastifyReply) { return this.run('resolveWrong', req, res); }
@Get('vocabulary/progress') @ApiOperation({ summary: '查询单词学习进度' }) @ApiStandardResponses('items')
wordProgress(@Query() _q: WordProgressQueryDto, @Req() req: FastifyRequest, @Res({ passthrough: true }) res: FastifyReply) { return this.run('wordProgress', req, res); }
@Post('vocabulary/progress') @HttpCode(200) @ApiBody({ type: WordProgressDto }) @ApiOperation({ summary: '更新单词学习进度' }) @ApiStandardResponses('item')
updateWordProgress(@Body() _b: WordProgressDto, @Req() req: FastifyRequest, @Res({ passthrough: true }) res: FastifyReply) { return this.run('updateWordProgress', req, res); }
@Get('vocabulary/review-plan') @ApiOperation({ summary: '生成单词复习计划' }) @ApiStandardResponses('item')
wordPlan(@Query() _q: WordReviewPlanQueryDto, @Req() req: FastifyRequest, @Res({ passthrough: true }) res: FastifyReply) { return this.run('wordPlan', req, res); }
@Post('vocabulary/review') @HttpCode(200) @ApiBody({ type: WordReviewDto }) @ApiOperation({ summary: '提交单词复习结果' }) @ApiStandardResponses('item')
reviewWord(@Body() _b: WordReviewDto, @Req() req: FastifyRequest, @Res({ passthrough: true }) res: FastifyReply) { return this.run('reviewWord', req, res); }
@Get('vocabulary/favorites') @ApiOperation({ summary: '查询收藏单词' }) @ApiStandardResponses('items')
favoriteWords(@Query() _q: UnitQueryDto, @Req() req: FastifyRequest, @Res({ passthrough: true }) res: FastifyReply) { return this.run('favoriteWords', req, res); }
@Post('vocabulary/favorites') @HttpCode(200) @ApiBody({ type: FavoriteWordDto }) @ApiOperation({ summary: '收藏或取消收藏单词' }) @ApiStandardResponses('ok')
toggleFavoriteWord(@Body() _b: FavoriteWordDto, @Req() req: FastifyRequest, @Res({ passthrough: true }) res: FastifyReply) { return this.run('toggleFavoriteWord', req, res); }
@Get('vocabulary/stats') @ApiOperation({ summary: '查询单词学习统计' }) @ApiStandardResponses('item')
wordStats(@Query() _q: UnitQueryDto, @Req() req: FastifyRequest, @Res({ passthrough: true }) res: FastifyReply) { return this.run('wordStats', req, res); }
}
@Module({ controllers: [LearningController], providers: [LearningService, { provide: LEARNING_HANDLERS, useValue: learningHandlers }] })
export class LearningModule {}

View File

@@ -0,0 +1,79 @@
import type { FastifyInstance, HTTPMethods } from 'fastify';
import { publicErrorBody, withResponseMeta } from '../core/http.js';
import { allRoutes, type RouteDefinition } from '../core/router.js';
import { RequestContextFactory } from './request-context.factory.js';
export const NATIVE_ROUTE_KEYS = new Set([
...['GET /health', 'GET /api/tenant/resolve'],
...[
'POST /api/auth/sms/send', 'POST /api/auth/sms/verify', 'GET /api/auth/me', 'POST /api/auth/logout',
'POST /api/auth/phone/bind', 'POST /api/auth/oauth/wechat', 'POST /api/auth/oauth/wechat-miniapp', 'POST /api/auth/oauth/qq',
],
...[
'GET /api/profile/me', 'PATCH /api/profile/me', 'POST /api/profile/check-in', 'GET /api/profile/score-events',
'GET /api/profile/activity-tasks', 'POST /api/profile/activity-tasks/claim', 'GET /api/profile/exchange-items',
'POST /api/profile/exchange-items/redeem', 'GET /api/profile/notifications', 'POST /api/profile/notifications/status',
'GET /api/profile/badges', 'GET /api/profile/feedbacks', 'POST /api/profile/feedbacks', 'GET /api/profile/exam-countdowns',
],
...[
'GET /api/learning/leaderboard', 'POST /api/learning/practice-sessions', 'GET /api/learning/practice-sessions/detail',
'POST /api/learning/practice-sessions/submit', 'GET /api/learning/practice-sessions/report',
'GET /api/learning/practice-sessions/history', 'GET /api/learning/practice-reports', 'GET /api/learning/stats',
'GET /api/learning/trend', 'POST /api/learning/answers', 'GET /api/learning/favorites/questions',
'POST /api/learning/favorites/questions', 'GET /api/learning/wrong-questions',
'GET /api/learning/wrong-questions/review-plan', 'POST /api/learning/wrong-questions/resolve',
'GET /api/learning/vocabulary/progress', 'POST /api/learning/vocabulary/progress',
'GET /api/learning/vocabulary/review-plan', 'POST /api/learning/vocabulary/review',
'GET /api/learning/vocabulary/favorites', 'POST /api/learning/vocabulary/favorites',
'GET /api/learning/vocabulary/stats',
],
...[
'GET /api/ai/school-recommendations', 'GET /api/ai/school-recommendations/detail',
'GET /api/ai/school-recommendations/export', 'POST /api/ai/school-recommendations/generate',
],
]);
export function legacyRoutes(definitions: RouteDefinition[] = allRoutes) {
return definitions.filter(([method, path]) => !NATIVE_ROUTE_KEYS.has(`${method} ${path}`));
}
export function registerLegacyRoutes(instance: FastifyInstance, contextFactory: RequestContextFactory) {
// 未迁移模块仍由 Fastify 承载,但请求上下文、错误结构和 requestId 与 Nest 原生路由保持一致。
const seen = new Set<string>();
for (const [method, path, handler] of legacyRoutes()) {
const key = `${method} ${path}`;
if (seen.has(key) || NATIVE_ROUTE_KEYS.has(key)) throw new Error(`Duplicate API route: ${key}`);
seen.add(key);
instance.route({
method: method as HTTPMethods,
url: path,
handler: async (request, reply) => {
const requestId = String(reply.getHeader('x-request-id') || '');
try {
const result = await handler(contextFactory.create(request, reply));
return reply.send(withResponseMeta(result, requestId));
} catch (error) {
const { statusCode, body } = publicErrorBody(error);
return reply.status(statusCode).send(withResponseMeta({ ...body, requestId }, requestId));
}
},
});
}
instance.get('/api/questions/:questionId/videos', async (request, reply) => {
const route = allRoutes.find(([method, path]) => method === 'GET' && path === '/api/questions/videos');
if (!route) return reply.status(404).send();
const params = request.params as { questionId: string };
const url = new URL(request.raw.url || '/', `http://${request.headers.host || 'localhost'}`);
url.searchParams.set('questionId', params.questionId);
const ctx = contextFactory.create(request, reply);
ctx.url = url;
const requestId = ctx.requestId;
try {
return reply.send(withResponseMeta(await route[2](ctx), requestId));
} catch (error) {
const { statusCode, body } = publicErrorBody(error);
return reply.status(statusCode).send(withResponseMeta({ ...body, requestId }, requestId));
}
});
}

View File

@@ -0,0 +1,38 @@
import type { INestApplication } from '@nestjs/common';
import { DocumentBuilder, SwaggerModule } from '@nestjs/swagger';
import { apiReference } from '@scalar/nestjs-api-reference';
import type { FastifyInstance } from 'fastify';
import { allRoutes } from '../core/router.js';
import { legacyRoutes } from './legacy-bridge.js';
export function registerOpenApi(app: INestApplication, instance: FastifyInstance) {
const document = SwaggerModule.createDocument(app, new DocumentBuilder()
.setTitle('Tiku SaaS API')
.setDescription('NestJS migration API reference')
.setVersion('0.1.0')
.addBearerAuth()
.addApiKey({ type: 'apiKey', in: 'header', name: 'x-tenant-id' }, 'tenant-id')
.build());
for (const [method, path] of legacyRoutes(allRoutes)) {
const lowerMethod = method.toLowerCase() as 'get' | 'post' | 'put' | 'patch' | 'delete';
const pathItem = document.paths[path] || {};
if (!pathItem[lowerMethod]) {
pathItem[lowerMethod] = {
tags: ['legacy'],
summary: `待迁移接口:${method} ${path}`,
description: '该接口仍通过兼容桥运行,后续按业务模块迁移为原生 Nest Controller 和强类型 DTO。',
responses: { '200': { description: '兼容接口响应;具体字段暂以现有调用契约为准。' } },
'x-migration-status': 'legacy',
} as never;
}
document.paths[path] = pathItem;
}
instance.get('/openapi.json', async (_request, reply) => reply.send(document));
const scalar = apiReference({ withFastify: true, content: document }) as (request: unknown, response: unknown) => void;
instance.get('/docs', async (request, reply) => {
scalar(request, reply.raw);
return reply;
});
}

View File

@@ -0,0 +1,83 @@
import { Body, Controller, Get, HttpCode, Inject, Injectable, Module, Patch, Post, Query, Req, Res } from '@nestjs/common';
import { ApiBearerAuth, ApiBody, ApiOperation, ApiTags } from '@nestjs/swagger';
import type { FastifyReply, FastifyRequest } from 'fastify';
import * as routes from '../features/profile/routes.js';
import * as points from '../features/profile/points.js';
import * as notifications from '../features/profile/notifications.js';
import { ApiEnvelopeProperties, ApiStandardResponses, itemProperty, itemsProperty } from './api-doc.decorators.js';
import { DomainRouteService } from './domain-route.service.js';
import { PaginationQueryDto } from './dto.js';
import { ActivityTaskClaimDto, ExchangeRedeemDto, NotificationStatusDto, SubmitFeedbackDto, UpdateProfileDto } from './request.dto.js';
import { BadgeQueryDto, FeedbackQueryDto, LimitQueryDto, ProfileNotificationQueryDto } from './query.dto.js';
import { RequestContextFactory } from './request-context.factory.js';
const PROFILE_HANDLERS = Symbol('PROFILE_HANDLERS');
const profileHandlers = {
me: routes.profileMeRoute, updateMe: routes.updateProfileMeRoute, checkIn: routes.checkInRoute,
scoreEvents: routes.scoreEventsRoute, tasks: points.activityTasksRoute, claimTask: points.claimActivityTaskRoute,
exchangeItems: points.exchangeItemsRoute, redeem: points.redeemExchangeItemRoute,
notifications: notifications.profileNotificationsRoute, notificationStatus: notifications.updateProfileNotificationStatusRoute,
badges: routes.profileBadgesRoute, feedbacks: routes.feedbacksRoute, submitFeedback: routes.submitFeedbackRoute,
countdowns: routes.examCountdownRoute,
};
@Injectable()
class ProfileService extends DomainRouteService {
constructor(factory: RequestContextFactory, @Inject(PROFILE_HANDLERS) injectedHandlers: typeof profileHandlers) {
super(factory, injectedHandlers);
}
}
@ApiTags('个人中心')
@ApiBearerAuth()
@Controller('/api/profile')
class ProfileController {
constructor(private readonly service: ProfileService) {}
private run(name: string, req: FastifyRequest, res: FastifyReply) { return this.service.execute(name, req, res); }
@Get('me') @ApiOperation({ summary: '获取当前学生资料' })
@ApiStandardResponses('item')
me(@Req() req: FastifyRequest, @Res({ passthrough: true }) res: FastifyReply) { return this.run('me', req, res); }
@Patch('me') @ApiBody({ type: UpdateProfileDto }) @ApiOperation({ summary: '更新当前学生资料', description: '更新姓名、预设头像、地区、意向院校专业及学习扩展数据。' })
@ApiStandardResponses('item')
updateMe(@Body() _body: UpdateProfileDto, @Req() req: FastifyRequest, @Res({ passthrough: true }) res: FastifyReply) { return this.run('updateMe', req, res); }
@Post('check-in') @HttpCode(200) @ApiOperation({ summary: '每日签到', description: '完成当日签到并返回积分奖励及连续签到状态。' })
@ApiStandardResponses('item')
checkIn(@Req() req: FastifyRequest, @Res({ passthrough: true }) res: FastifyReply) { return this.run('checkIn', req, res); }
@Get('score-events') @ApiOperation({ summary: '查询积分流水' })
@ApiStandardResponses('items')
scoreEvents(@Query() _query: LimitQueryDto, @Req() req: FastifyRequest, @Res({ passthrough: true }) res: FastifyReply) { return this.run('scoreEvents', req, res); }
@Get('activity-tasks') @ApiOperation({ summary: '查询积分活动任务' })
@ApiStandardResponses('items')
tasks(@Query() _query: LimitQueryDto, @Req() req: FastifyRequest, @Res({ passthrough: true }) res: FastifyReply) { return this.run('tasks', req, res); }
@Post('activity-tasks/claim') @HttpCode(200) @ApiBody({ type: ActivityTaskClaimDto }) @ApiOperation({ summary: '领取活动任务奖励', description: '使用任务 ID 或编码领取奖励,部分任务还需要 sourceId 作为完成证据。' })
@ApiStandardResponses('item')
claimTask(@Body() _body: ActivityTaskClaimDto, @Req() req: FastifyRequest, @Res({ passthrough: true }) res: FastifyReply) { return this.run('claimTask', req, res); }
@Get('exchange-items') @ApiOperation({ summary: '查询积分兑换商品' })
@ApiStandardResponses('items')
exchangeItems(@Query() _query: LimitQueryDto, @Req() req: FastifyRequest, @Res({ passthrough: true }) res: FastifyReply) { return this.run('exchangeItems', req, res); }
@Post('exchange-items/redeem') @HttpCode(200) @ApiBody({ type: ExchangeRedeemDto }) @ApiOperation({ summary: '兑换积分商品', description: '使用商品 ID 或编码创建积分兑换订单;建议提供客户端幂等键。' })
@ApiStandardResponses('item')
redeem(@Body() _body: ExchangeRedeemDto, @Req() req: FastifyRequest, @Res({ passthrough: true }) res: FastifyReply) { return this.run('redeem', req, res); }
@Get('notifications') @ApiOperation({ summary: '查询用户通知' })
@ApiEnvelopeProperties({ items: itemsProperty, summary: { type: 'object', description: '按状态统计的通知数量', additionalProperties: { type: 'integer' } } })
notificationList(@Query() _query: ProfileNotificationQueryDto, @Req() req: FastifyRequest, @Res({ passthrough: true }) res: FastifyReply) { return this.run('notifications', req, res); }
@Post('notifications/status') @HttpCode(200) @ApiBody({ type: NotificationStatusDto }) @ApiOperation({ summary: '更新通知状态', description: '批量将通知更新为已读、忽略或归档,最多 100 条。' })
@ApiStandardResponses('item')
notificationStatus(@Body() _body: NotificationStatusDto, @Req() req: FastifyRequest, @Res({ passthrough: true }) res: FastifyReply) { return this.run('notificationStatus', req, res); }
@Get('badges') @ApiOperation({ summary: '查询徽章列表' })
@ApiEnvelopeProperties({ items: itemsProperty, summary: { type: 'object', properties: { total: { type: 'integer' }, unlocked: { type: 'integer' }, includeLocked: { type: 'boolean' } } } })
badges(@Query() _query: BadgeQueryDto, @Req() req: FastifyRequest, @Res({ passthrough: true }) res: FastifyReply) { return this.run('badges', req, res); }
@Get('feedbacks') @ApiOperation({ summary: '查询反馈记录' })
@ApiStandardResponses('items')
feedbacks(@Query() _query: FeedbackQueryDto, @Req() req: FastifyRequest, @Res({ passthrough: true }) res: FastifyReply) { return this.run('feedbacks', req, res); }
@Post('feedbacks') @HttpCode(200) @ApiBody({ type: SubmitFeedbackDto }) @ApiOperation({ summary: '提交意见反馈', description: '提交题目错误或产品建议,可附带题目 ID、联系方式和附件引用。' })
@ApiStandardResponses('item')
submitFeedback(@Body() _body: SubmitFeedbackDto, @Req() req: FastifyRequest, @Res({ passthrough: true }) res: FastifyReply) { return this.run('submitFeedback', req, res); }
@Get('exam-countdowns') @ApiOperation({ summary: '查询考试倒计时' })
@ApiStandardResponses('items')
countdowns(@Query() _query: LimitQueryDto, @Req() req: FastifyRequest, @Res({ passthrough: true }) res: FastifyReply) { return this.run('countdowns', req, res); }
}
@Module({ controllers: [ProfileController], providers: [ProfileService, { provide: PROFILE_HANDLERS, useValue: profileHandlers }] })
export class ProfileModule {}

View File

@@ -0,0 +1,75 @@
import { ApiPropertyOptional } from '@nestjs/swagger';
import { Type } from 'class-transformer';
import { IsBoolean, IsIn, IsInt, IsOptional, IsString, IsUUID, Max, Min } from 'class-validator';
export class LimitQueryDto {
@ApiPropertyOptional({ description: '返回条数上限', minimum: 1, maximum: 500 })
@IsOptional() @Type(() => Number) @IsInt() @Min(1) @Max(500) limit?: number;
}
export class ProfileNotificationQueryDto extends LimitQueryDto {
@ApiPropertyOptional({ description: '通知状态', enum: ['unread', 'read', 'dismissed', 'archived'] }) @IsOptional() @IsString() status?: string;
@ApiPropertyOptional({ description: '通知业务类型type 是兼容别名' }) @IsOptional() @IsString() notificationType?: string;
@ApiPropertyOptional({ description: 'notificationType 的兼容别名' }) @IsOptional() @IsString() type?: string;
}
export class BadgeQueryDto extends LimitQueryDto {
@ApiPropertyOptional({ description: '是否同时返回未解锁徽章', default: false }) @IsOptional() @Type(() => Boolean) @IsBoolean() includeLocked?: boolean;
@ApiPropertyOptional({ description: '徽章分类' }) @IsOptional() @IsString() category?: string;
}
export class FeedbackQueryDto extends LimitQueryDto {
@ApiPropertyOptional({ description: '反馈处理状态' }) @IsOptional() @IsString() status?: string;
}
export class PracticeSessionQueryDto {
@ApiPropertyOptional({ description: '练习会话 ID', format: 'uuid' }) @IsOptional() @IsUUID() practiceSessionId?: string;
}
export class LearningWindowQueryDto {
@ApiPropertyOptional({ description: '统计时间窗口天数', example: 30 }) @IsOptional() @Type(() => Number) @IsInt() @Min(1) days?: number;
}
export class LeaderboardQueryDto extends LimitQueryDto {
@ApiPropertyOptional({ description: '排行指标' }) @IsOptional() @IsString() metric?: string;
@ApiPropertyOptional({ description: '统计周期' }) @IsOptional() @IsString() period?: string;
@ApiPropertyOptional({ description: '页码', minimum: 1 }) @IsOptional() @Type(() => Number) @IsInt() @Min(1) page?: number;
@ApiPropertyOptional({ description: '地区 ID', format: 'uuid' }) @IsOptional() @IsUUID() regionId?: string;
@ApiPropertyOptional({ description: '班级 ID', format: 'uuid' }) @IsOptional() @IsUUID() classId?: string;
}
export class WrongQuestionQueryDto extends LimitQueryDto {
@ApiPropertyOptional({ description: '传 all 返回全部错题,否则仅返回未解决错题', example: 'all' }) @IsOptional() @IsString() status?: string;
}
export class WrongReviewPlanQueryDto extends LimitQueryDto {
@ApiPropertyOptional({ description: '科目 ID', format: 'uuid' }) @IsOptional() @IsUUID() subjectId?: string;
@ApiPropertyOptional({ description: '分类 ID', format: 'uuid' }) @IsOptional() @IsUUID() categoryId?: string;
}
export class WordProgressQueryDto extends LimitQueryDto {
@ApiPropertyOptional({ description: '词汇单元 ID', format: 'uuid' }) @IsOptional() @IsUUID() unitId?: string;
@ApiPropertyOptional({ description: '学习状态筛选' }) @IsOptional() @IsString() status?: string;
}
export class WordReviewPlanQueryDto {
@ApiPropertyOptional({ description: '词汇单元 ID', format: 'uuid' }) @IsOptional() @IsUUID() unitId?: string;
@ApiPropertyOptional({ description: '到期复习词数量', minimum: 1, maximum: 200, default: 30 }) @IsOptional() @Type(() => Number) @IsInt() @Min(1) @Max(200) reviewLimit?: number;
@ApiPropertyOptional({ description: '新词数量', minimum: 1, maximum: 100, default: 20 }) @IsOptional() @Type(() => Number) @IsInt() @Min(1) @Max(100) newLimit?: number;
}
export class UnitQueryDto extends LimitQueryDto {
@ApiPropertyOptional({ description: '词汇单元 ID', format: 'uuid' }) @IsOptional() @IsUUID() unitId?: string;
}
export class RecommendationListQueryDto extends LimitQueryDto {
@ApiPropertyOptional({ description: '地区 ID', format: 'uuid' }) @IsOptional() @IsUUID() regionId?: string;
}
export class RecommendationDetailQueryDto {
@ApiPropertyOptional({ description: '推荐报告 ID', format: 'uuid' }) @IsOptional() @IsUUID() reportId?: string;
}
export class RecommendationExportQueryDto extends RecommendationDetailQueryDto {
@ApiPropertyOptional({ description: '导出格式', enum: ['markdown', 'html'], default: 'markdown' }) @IsOptional() @IsIn(['markdown', 'html']) format?: string;
}

View File

@@ -0,0 +1,17 @@
import { Injectable } from '@nestjs/common';
import type { FastifyReply, FastifyRequest } from 'fastify';
import type { RequestContext } from '../core/http.js';
@Injectable()
export class RequestContextFactory {
create(request: FastifyRequest, reply: FastifyReply): RequestContext {
const requestId = String(reply.getHeader('x-request-id') || request.headers['x-request-id'] || '');
return {
req: request.raw,
res: reply.raw,
url: new URL(request.raw.url || '/', `http://${request.headers.host || 'localhost'}`),
requestId,
parsedBody: request.body,
};
}
}

View File

@@ -0,0 +1,113 @@
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import { Type } from 'class-transformer';
import { IsArray, IsBoolean, IsIn, IsInt, IsNumber, IsObject, IsOptional, IsString, IsUUID, Max, MaxLength, Min } from 'class-validator';
export class UpdateProfileDto {
@ApiPropertyOptional({ description: '学生姓名', example: '张三' }) @IsOptional() @IsString() name?: string;
@ApiPropertyOptional({ description: '预设头像标识,不允许直接传 avatarUrl', example: 'male' }) @IsOptional() @IsString() avatarPreset?: string;
@ApiPropertyOptional({ description: '所属地区 ID', format: 'uuid' }) @IsOptional() @IsUUID() regionId?: string;
@ApiPropertyOptional({ description: '已选择院校 ID', format: 'uuid' }) @IsOptional() @IsUUID() selectedSchoolId?: string;
@ApiPropertyOptional({ description: '已选择专业 ID', format: 'uuid' }) @IsOptional() @IsUUID() selectedMajorId?: string;
@ApiPropertyOptional({ description: '个人统计扩展数据', type: 'object', additionalProperties: true }) @IsOptional() @IsObject() stats?: Record<string, unknown>;
@ApiPropertyOptional({ description: '学习进度扩展数据', type: 'object', additionalProperties: true }) @IsOptional() @IsObject() progress?: Record<string, unknown>;
@ApiPropertyOptional({ description: '模块选择配置', type: 'object', additionalProperties: true }) @IsOptional() @IsObject() moduleSelections?: Record<string, unknown>;
@ApiPropertyOptional({ description: '最近活动记录', type: 'array', items: { type: 'object' } }) @IsOptional() @IsArray() recentActivities?: unknown[];
}
export class ActivityTaskClaimDto {
@ApiPropertyOptional({ description: '活动任务 ID与 code 至少提供一个', format: 'uuid' }) @IsOptional() @IsUUID() taskId?: string;
@ApiPropertyOptional({ description: '活动任务编码,与 taskId 至少提供一个' }) @IsOptional() @IsString() code?: string;
@ApiPropertyOptional({ description: '业务来源类型' }) @IsOptional() @IsString() sourceType?: string;
@ApiPropertyOptional({ description: '业务来源记录 ID', format: 'uuid' }) @IsOptional() @IsUUID() sourceId?: string;
@ApiPropertyOptional({ description: '幂等键;重复请求返回冲突或既有结果' }) @IsOptional() @IsString() idempotencyKey?: string;
}
export class ExchangeRedeemDto {
@ApiPropertyOptional({ description: '兑换商品 ID与 code 至少提供一个', format: 'uuid' }) @IsOptional() @IsUUID() itemId?: string;
@ApiPropertyOptional({ description: '兑换商品编码,与 itemId 至少提供一个' }) @IsOptional() @IsString() code?: string;
@ApiPropertyOptional({ description: '客户端幂等键' }) @IsOptional() @IsString() idempotencyKey?: string;
}
export class NotificationStatusDto {
@ApiProperty({ description: '要更新的通知 ID最多 100 个', type: [String], format: 'uuid' }) @IsArray() @IsUUID(undefined, { each: true }) notificationIds!: string[];
@ApiPropertyOptional({ description: '目标状态', enum: ['read', 'dismissed', 'archived'], default: 'read' }) @IsOptional() @IsIn(['read', 'dismissed', 'archived']) status?: string;
}
export class SubmitFeedbackDto {
@ApiPropertyOptional({ description: '相关题目 ID', format: 'uuid' }) @IsOptional() @IsUUID() questionId?: string;
@ApiPropertyOptional({ description: '反馈类型', example: 'question_error' }) @IsOptional() @IsString() type?: string;
@ApiPropertyOptional({ description: '反馈分类' }) @IsOptional() @IsString() category?: string;
@ApiPropertyOptional({ description: '反馈标题' }) @IsOptional() @IsString() title?: string;
@ApiProperty({ description: '问题详细描述', example: '题目答案与解析不一致' }) @IsString() description!: string;
@ApiPropertyOptional({ description: '优先级', enum: ['low', 'normal', 'high', 'urgent'], default: 'normal' }) @IsOptional() @IsIn(['low', 'normal', 'high', 'urgent']) priority?: string;
@ApiPropertyOptional({ description: '联系方式' }) @IsOptional() @IsString() contact?: string;
@ApiPropertyOptional({ description: '附件描述或资源引用数组', type: 'array', items: { type: 'object' } }) @IsOptional() @IsArray() attachments?: unknown[];
@ApiPropertyOptional({ description: '扩展元数据', type: 'object', additionalProperties: true }) @IsOptional() @IsObject() metadata?: Record<string, unknown>;
}
export class CreatePracticeSessionDto {
@ApiPropertyOptional({ description: '练习模式', example: 'practice' }) @IsOptional() @IsString() mode?: string;
@ApiPropertyOptional({ description: '练习目标类型' }) @IsOptional() @IsString() targetType?: string;
@ApiPropertyOptional({ description: '练习目标 ID', format: 'uuid' }) @IsOptional() @IsUUID() targetId?: string;
@ApiPropertyOptional({ description: '组卷蓝图 ID', format: 'uuid' }) @IsOptional() @IsUUID() blueprintId?: string;
@ApiPropertyOptional({ description: '题集 ID', format: 'uuid' }) @IsOptional() @IsUUID() collectionId?: string;
@ApiPropertyOptional({ description: '内容条目 ID', format: 'uuid' }) @IsOptional() @IsUUID() entryId?: string;
@ApiPropertyOptional({ description: '内容节点 ID', format: 'uuid' }) @IsOptional() @IsUUID() contentNodeId?: string;
@ApiPropertyOptional({ description: '题目数量', minimum: 1, default: 100 }) @IsOptional() @Type(() => Number) @IsInt() @Min(1) questionLimit?: number;
@ApiPropertyOptional({ description: '限时分钟数', minimum: 1, maximum: 1440 }) @IsOptional() @Type(() => Number) @IsInt() @Min(1) @Max(1440) durationMinutes?: number;
@ApiPropertyOptional({ description: '试卷总分' }) @IsOptional() @Type(() => Number) @IsNumber() totalScore?: number;
@ApiPropertyOptional({ description: '组卷分区定义', type: 'array', items: { type: 'object' } }) @IsOptional() @IsArray() sections?: unknown[];
@ApiPropertyOptional({ description: '组卷规则', type: 'object', additionalProperties: true }) @IsOptional() @IsObject() rules?: Record<string, unknown>;
@ApiPropertyOptional({ description: '会话扩展元数据', type: 'object', additionalProperties: true }) @IsOptional() @IsObject() metadata?: Record<string, unknown>;
}
export class SubmitAnswerDto {
@ApiProperty({ description: '题目 ID', format: 'uuid' }) @IsUUID() questionId!: string;
@ApiPropertyOptional({ description: '所属练习会话 ID', format: 'uuid' }) @IsOptional() @IsUUID() practiceSessionId?: string;
@ApiPropertyOptional({ description: '选择题选项值数组', type: [String], example: ['A'] }) @IsOptional() @IsArray() @IsString({ each: true }) selectedOptions?: string[];
@ApiPropertyOptional({ description: '主观题文本答案' }) @IsOptional() @IsString() answerText?: string;
@ApiPropertyOptional({ description: '主观题由用户自评是否正确' }) @IsOptional() @IsBoolean() selfJudgedCorrect?: boolean;
@ApiPropertyOptional({ description: '复合题子题答案', type: 'array', items: { type: 'object' } }) @IsOptional() @IsArray() subAnswers?: unknown[];
}
export class SubmitPracticeSessionDto {
@ApiProperty({ description: '要提交的练习会话 ID', format: 'uuid' }) @IsUUID() practiceSessionId!: string;
}
export class QuestionActionDto {
@ApiProperty({ description: '题目 ID', format: 'uuid' }) @IsUUID() questionId!: string;
@ApiPropertyOptional({ description: '是否收藏;不传时默认为 true' }) @IsOptional() @IsBoolean() favorite?: boolean;
}
export class WordReviewDto {
@ApiProperty({ description: '单词 ID', format: 'uuid' }) @IsUUID() wordId!: string;
@ApiPropertyOptional({ description: '复习结果', enum: ['known', 'unknown', 'again', 'hard', 'good', 'easy'] }) @IsOptional() @IsString() result?: string;
@ApiPropertyOptional({ description: 'result 的兼容别名' }) @IsOptional() @IsString() answerResult?: string;
}
export class WordProgressDto {
@ApiProperty({ description: '单词 ID', format: 'uuid' }) @IsUUID() wordId!: string;
@ApiPropertyOptional({ description: '学习状态', enum: ['new', 'learning', 'reviewing', 'mastered'], default: 'learning' }) @IsOptional() @IsString() status?: string;
@ApiPropertyOptional({ description: '正确次数增量', minimum: 0 }) @IsOptional() @Type(() => Number) @IsInt() @Min(0) correctDelta?: number;
@ApiPropertyOptional({ description: '错误次数增量', minimum: 0 }) @IsOptional() @Type(() => Number) @IsInt() @Min(0) wrongDelta?: number;
@ApiPropertyOptional({ description: '下次复习时间', format: 'date-time' }) @IsOptional() @IsString() nextReviewDate?: string;
}
export class FavoriteWordDto {
@ApiProperty({ description: '单词 ID', format: 'uuid' }) @IsUUID() wordId!: string;
@ApiPropertyOptional({ description: '是否收藏;不传时默认为 true' }) @IsOptional() @IsBoolean() favorite?: boolean;
@ApiPropertyOptional({ description: '收藏备注' }) @IsOptional() @IsString() @MaxLength(1000) note?: string;
}
export class GenerateRecommendationDto {
@ApiPropertyOptional({ description: '地区 ID未传时使用学生资料中的地区', format: 'uuid' }) @IsOptional() @IsUUID() regionId?: string;
@ApiPropertyOptional({ description: '预估成绩', minimum: 0, maximum: 1000 }) @IsOptional() @Type(() => Number) @IsNumber() @Min(0) @Max(1000) estimatedScore?: number;
@ApiPropertyOptional({ description: '考试科类或选科', maxLength: 80 }) @IsOptional() @IsString() @MaxLength(80) examTrack?: string;
@ApiPropertyOptional({ description: '意向城市', maxLength: 80 }) @IsOptional() @IsString() @MaxLength(80) preferredCity?: string;
@ApiPropertyOptional({ description: '目标院校 ID', format: 'uuid' }) @IsOptional() @IsUUID() targetSchoolId?: string;
@ApiPropertyOptional({ description: '目标专业 ID', format: 'uuid' }) @IsOptional() @IsUUID() targetMajorId?: string;
@ApiPropertyOptional({ description: '风险偏好', enum: ['safe', 'balanced', 'sprint'], default: 'balanced' }) @IsOptional() @IsIn(['safe', 'balanced', 'sprint']) riskPreference?: string;
@ApiPropertyOptional({ description: '其他限制条件', maxLength: 500 }) @IsOptional() @IsString() @MaxLength(500) constraints?: string;
@ApiPropertyOptional({ description: '补充说明', maxLength: 500 }) @IsOptional() @IsString() @MaxLength(500) notes?: string;
@ApiPropertyOptional({ description: '推荐数量', minimum: 1, maximum: 12, default: 5 }) @IsOptional() @Type(() => Number) @IsInt() @Min(1) @Max(12) recommendationLimit?: number;
}

View File

@@ -0,0 +1,40 @@
import { Controller, Get, Inject, Injectable, Module, Query, Req, Res } from '@nestjs/common';
import { ApiOperation, ApiTags } from '@nestjs/swagger';
import type { FastifyReply, FastifyRequest } from 'fastify';
import { resolveTenantRoute } from '../features/tenant/routes.js';
import { DomainRouteService } from './domain-route.service.js';
import { TenantResolveQueryDto } from './dto.js';
import { RequestContextFactory } from './request-context.factory.js';
import { ApiEnvelopeProperties } from './api-doc.decorators.js';
const TENANT_HANDLERS = Symbol('TENANT_HANDLERS');
const tenantHandlers = { resolve: resolveTenantRoute };
@Injectable()
class TenantService extends DomainRouteService {
constructor(factory: RequestContextFactory, @Inject(TENANT_HANDLERS) injectedHandlers: typeof tenantHandlers) {
super(factory, injectedHandlers);
}
}
@ApiTags('租户解析')
@Controller('/api/tenant')
class TenantController {
constructor(private readonly service: TenantService) {}
@Get('resolve')
@ApiOperation({ summary: '解析当前租户', description: '根据访问域名、host 参数或 tenantCode 解析启用中的租户及公开品牌配置。' })
@ApiEnvelopeProperties({
tenant: { type: 'object', description: '租户基本信息', properties: { id: { type: 'string', format: 'uuid' }, slug: { type: 'string' }, name: { type: 'string' }, mode: { type: 'string' } } },
branding: { type: 'object', description: '公开品牌与主题配置', additionalProperties: true },
features: { type: 'object', description: '学生端功能开关', additionalProperties: true },
adminFeatures: { type: 'object', description: '管理端功能开关', additionalProperties: true },
publicConfig: { type: 'object', description: '允许公开给客户端的租户配置', additionalProperties: true },
})
resolve(@Query() _query: TenantResolveQueryDto, @Req() req: FastifyRequest, @Res({ passthrough: true }) res: FastifyReply) {
return this.service.execute('resolve', req, res);
}
}
@Module({ controllers: [TenantController], providers: [TenantService, { provide: TENANT_HANDLERS, useValue: tenantHandlers }] })
export class TenantModule {}

View File

@@ -1,148 +1,64 @@
import http from 'node:http';
import 'reflect-metadata';
import { ValidationPipe } from '@nestjs/common';
import { NestFactory } from '@nestjs/core';
import { FastifyAdapter, type NestFastifyApplication } from '@nestjs/platform-fastify';
import { config } from './core/config.js';
import { authorizeCorsRequest, CorsPolicy } from './core/cors.js';
import { closePool } from './core/db.js';
import { publicErrorBody, routeKey, sendJson, withResponseMeta } from './core/http.js';
import { requestIdFrom } from './core/request-id.js';
import { createRouter } from './core/router.js';
import { ApiExceptionFilter } from './nest/api-exception.filter.js';
import { ApiEnvelopeInterceptor } from './nest/api.interceptor.js';
import { AppModule } from './nest/app.module.js';
import { registerHttpHooks, writeLog } from './nest/http-hooks.js';
import { registerLegacyRoutes } from './nest/legacy-bridge.js';
import { registerOpenApi } from './nest/openapi.js';
import { RequestContextFactory } from './nest/request-context.factory.js';
const routes = createRouter();
async function bootstrap() {
const adapter = new FastifyAdapter({
bodyLimit: config.maxImportJsonBodyBytes,
requestTimeout: config.apiRequestTimeoutMs,
keepAliveTimeout: config.apiKeepAliveTimeoutMs,
maxRequestsPerSocket: config.apiMaxRequestsPerSocket,
});
const app = await NestFactory.create<NestFastifyApplication>(AppModule, adapter, { logger: false });
app.useGlobalPipes(new ValidationPipe({ transform: true, whitelist: false, forbidUnknownValues: false }));
app.useGlobalInterceptors(new ApiEnvelopeInterceptor());
app.useGlobalFilters(new ApiExceptionFilter());
const instance = adapter.getInstance();
registerHttpHooks(instance);
registerLegacyRoutes(instance, app.get(RequestContextFactory));
if (!config.isProduction) registerOpenApi(app, instance);
function resolveHandler(method: string | undefined, url: URL) {
const exact = routes.get(routeKey(method, url.pathname));
if (exact) return exact;
const questionVideosMatch = url.pathname.match(/^\/api\/questions\/([^/]+)\/videos$/);
if (method === 'GET' && questionVideosMatch?.[1]) {
url.searchParams.set('questionId', decodeURIComponent(questionVideosMatch[1]));
return routes.get(routeKey(method, '/api/questions/videos'));
}
return null;
}
function writeLog(event: Record<string, unknown>, error = false) {
const line = JSON.stringify({ timestamp: new Date().toISOString(), service: 'tiku-saas-api', ...event });
if (error) console.error(line);
else console.log(line);
}
let shuttingDown = false;
const corsPolicy = new CorsPolicy({
staticOrigins: config.corsOrigins,
tenantDomainsEnabled: config.corsTenantDomainsEnabled,
positiveCacheTtlMs: config.corsTenantDomainCacheTtlMs,
negativeCacheTtlMs: config.corsTenantDomainNegativeCacheTtlMs,
maxCacheEntries: config.corsTenantDomainCacheMaxEntries,
onLookupError(error, host) {
writeLog({
event: 'cors_tenant_domain_lookup_failed',
host,
error: error instanceof Error ? error.message : 'unknown',
}, true);
},
});
export const server = http.createServer(async (req, res) => {
const requestId = requestIdFrom(req);
const startedAt = process.hrtime.bigint();
res.setHeader('x-request-id', requestId);
res.once('finish', () => {
const durationMs = Number(process.hrtime.bigint() - startedAt) / 1_000_000;
writeLog({
event: 'http_request',
requestId,
method: req.method || 'GET',
path: (() => {
try { return new URL(req.url || '/', 'http://localhost').pathname; } catch { return '/'; }
})(),
status: res.statusCode,
durationMs: Number(durationMs.toFixed(2)),
}, res.statusCode >= 500);
instance.server.headersTimeout = config.apiHeadersTimeoutMs;
instance.server.requestTimeout = config.apiRequestTimeoutMs;
instance.server.keepAliveTimeout = config.apiKeepAliveTimeoutMs;
instance.server.maxRequestsPerSocket = config.apiMaxRequestsPerSocket;
instance.server.on('clientError', error => {
writeLog({ event: 'http_client_error', code: (error as NodeJS.ErrnoException).code || 'CLIENT_ERROR' }, true);
});
if (shuttingDown) {
res.setHeader('connection', 'close');
sendJson(res, 503, withResponseMeta({ error: 'Service is shutting down', code: 'SERVICE_UNAVAILABLE', requestId }, requestId));
return;
}
const corsDecision = await authorizeCorsRequest(req, res, corsPolicy);
if (!corsDecision.allowed) {
sendJson(res, 403, withResponseMeta({ error: 'Request origin is not allowed', code: 'CORS_ORIGIN_DENIED', requestId }, requestId));
return;
}
if (req.method === 'OPTIONS') {
res.statusCode = 204;
res.end();
return;
}
const url = new URL(req.url || '/', `http://${req.headers.host || 'localhost'}`);
const handler = resolveHandler(req.method, url);
if (!handler) {
sendJson(res, 404, withResponseMeta({ error: 'Not found', code: 'NOT_FOUND', path: url.pathname, requestId }, requestId));
return;
}
try {
const result = await handler({ req, res, url, requestId });
sendJson(res, 200, withResponseMeta(result, requestId));
} catch (error) {
const { statusCode, body } = publicErrorBody(error);
sendJson(res, statusCode, withResponseMeta({ ...body, requestId }, requestId));
}
});
server.headersTimeout = config.apiHeadersTimeoutMs;
server.requestTimeout = config.apiRequestTimeoutMs;
server.keepAliveTimeout = config.apiKeepAliveTimeoutMs;
server.maxRequestsPerSocket = config.apiMaxRequestsPerSocket;
server.on('clientError', (error, socket) => {
writeLog({ event: 'http_client_error', code: (error as NodeJS.ErrnoException).code || 'CLIENT_ERROR' }, true);
if (socket.writable) socket.end('HTTP/1.1 400 Bad Request\r\nConnection: close\r\n\r\n');
});
let shutdownPromise: Promise<void> | null = null;
export function shutdown(signal: string) {
if (shutdownPromise) return shutdownPromise;
shuttingDown = true;
shutdownPromise = new Promise(resolve => {
let closing = false;
const shutdown = async (signal: string) => {
if (closing) return;
closing = true;
writeLog({ event: 'shutdown_started', signal });
const forceTimer = setTimeout(() => {
writeLog({ event: 'shutdown_deadline_reached', signal }, true);
server.closeAllConnections();
}, config.apiShutdownGracePeriodMs);
forceTimer.unref();
server.close(() => {
clearTimeout(forceTimer);
closePool()
.catch(error => writeLog({ event: 'database_pool_close_failed', error: error instanceof Error ? error.message : 'unknown' }, true))
.finally(() => {
writeLog({ event: 'shutdown_complete', signal });
resolve();
});
});
server.closeIdleConnections();
});
return shutdownPromise;
}
const timer = setTimeout(() => instance.server.closeAllConnections(), config.apiShutdownGracePeriodMs);
timer.unref();
try {
await app.close();
writeLog({ event: 'shutdown_complete', signal });
} finally {
clearTimeout(timer);
}
};
for (const signal of ['SIGTERM', 'SIGINT'] as const) {
process.once(signal, () => void shutdown(signal).catch(error => {
writeLog({ event: 'shutdown_failed', signal, error: error instanceof Error ? error.message : 'unknown' }, true);
process.exitCode = 1;
}));
}
server.listen(config.port, () => {
await app.listen(config.port, '127.0.0.1');
writeLog({ event: 'server_listening', host: '127.0.0.1', port: config.port });
});
for (const signal of ['SIGTERM', 'SIGINT'] as const) {
process.once(signal, () => {
shutdown(signal)
.then(() => { process.exitCode = 0; })
.catch(error => {
writeLog({ event: 'shutdown_failed', signal, error: error instanceof Error ? error.message : 'unknown' }, true);
process.exitCode = 1;
});
});
return app;
}
export const application = bootstrap();

View File

@@ -4,6 +4,8 @@
"module": "NodeNext",
"moduleResolution": "NodeNext",
"strict": true,
"experimentalDecorators": true,
"emitDecoratorMetadata": true,
"esModuleInterop": true,
"skipLibCheck": true,
"outDir": "dist",

View File

@@ -25,6 +25,8 @@
"student-supervision:once": "tsx src/index.ts --once --job student-supervision"
},
"dependencies": {
"@nestjs/common": "^11.1.28",
"@nestjs/core": "^11.1.28",
"@resvg/resvg-js": "^2.6.2",
"@supabase/storage-js": "^2.108.2",
"ali-oss": "^6.23.0",
@@ -32,7 +34,9 @@
"iconv-lite": "^0.6.3",
"jszip": "^3.10.1",
"pdfkit": "^0.19.1",
"pg": "^8.16.3"
"pg": "^8.16.3",
"reflect-metadata": "^0.2.2",
"rxjs": "^7.8.2"
},
"devDependencies": {
"@types/node": "^24.0.4",

View File

@@ -1,210 +1,13 @@
import { closePool } from './db.js';
import { config } from './config.js';
import { processCrmBatch } from './jobs/crm.js';
import { processCommerceBatch } from './jobs/commerce.js';
import { processAssetBatch } from './jobs/assets.js';
import {
parseWorkerCli,
type ContinuousWorkerJob,
type WorkerJob,
} from './cli.js';
const extraClosers = new Set<() => Promise<void>>();
async function runOnce(job: WorkerJob, month?: string) {
if (job === 'crm') {
const result = await processCrmBatch();
console.log(`[worker] crm batch processed=${result.processed} sent=${result.sent} failed=${result.failed} retrying=${result.retrying} discarded=${result.discarded}`);
return;
}
if (job === 'commerce') {
const result = await processCommerceBatch();
console.log(
`[worker] commerce batch processed=${result.processed}`
+ ` payments=${result.payments.processed} paid=${result.payments.paid} pending=${result.payments.pending} closed=${result.payments.closed} failed=${result.payments.failed} paymentErrors=${result.payments.errors}`
+ ` refunds=${result.refunds.processed} succeeded=${result.refunds.succeeded} processing=${result.refunds.processing} refundFailed=${result.refunds.failed} refundErrors=${result.refunds.errors}`,
);
return;
}
if (job === 'provider-bills') {
const { closePool: closeApiPool } = await import('../../api/src/core/db.js');
const { processProviderBillBatch } = await import('./jobs/provider-bills.js');
extraClosers.add(closeApiPool);
const result = await processProviderBillBatch();
console.log(
`[worker] provider-bills batch processed=${result.processed}`
+ ` completed=${result.completed} failed=${result.failed} skipped=${result.skipped}`,
);
return;
}
if (job === 'platform-billing') {
const { processPlatformBillingBatch } = await import('./jobs/platform-billing.js');
const result = await processPlatformBillingBatch();
console.log(
`[worker] platform-billing batch processed=${result.processed}`
+ ` created=${result.created} skipped=${result.skipped} failed=${result.failed}`,
);
return;
}
if (job === 'platform-usage') {
const { processPlatformUsageBatch } = await import('./jobs/platform-usage.js');
const result = await processPlatformUsageBatch({ month });
console.log(
`[worker] platform-usage batch processed=${result.processed}`
+ ` metrics=${result.metrics} created=${result.created}`
+ ` updated=${result.updated} failed=${result.failed} skipped=${result.skipped}`,
);
return;
}
if (job === 'platform-usage-overage') {
const { processPlatformUsageOverageBatch } = await import('./jobs/platform-usage-overage.js');
const result = await processPlatformUsageOverageBatch({ month });
console.log(
`[worker] platform-usage-overage batch processed=${result.processed}`
+ ` created=${result.created} skipped=${result.skipped}`
+ ` failed=${result.failed} totalCents=${result.totalCents}`,
);
return;
}
if (job === 'platform-dunning') {
const { processPlatformDunningBatch } = await import('./jobs/platform-dunning.js');
const result = await processPlatformDunningBatch();
console.log(
`[worker] platform-dunning batch processed=${result.processed}`
+ ` markedOverdue=${result.markedOverdue} reminderCreated=${result.reminderCreated}`
+ ` skippedReminder=${result.skippedReminder}`,
);
return;
}
if (job === 'platform-dunning-notifications') {
const { processPlatformDunningNotificationBatch } = await import('./jobs/platform-dunning-notifications.js');
const result = await processPlatformDunningNotificationBatch();
console.log(
`[worker] platform-dunning-notifications batch enqueued=${result.enqueued}`
+ ` processed=${result.processed} sent=${result.sent} failed=${result.failed}`
+ ` retrying=${result.retrying} discarded=${result.discarded}`,
);
return;
}
if (job === 'platform-audit-alerts') {
const { processPlatformAuditAlertBatch } = await import('./jobs/platform-audit-alerts.js');
const result = await processPlatformAuditAlertBatch();
console.log(
`[worker] platform-audit-alerts batch processed=${result.processed}`
+ ` created=${result.created} skipped=${result.skipped}`,
);
return;
}
if (job === 'platform-audit-notifications') {
const { processPlatformAuditNotificationBatch } = await import('./jobs/platform-audit-notifications.js');
const result = await processPlatformAuditNotificationBatch();
console.log(
`[worker] platform-audit-notifications batch enqueued=${result.enqueued}`
+ ` processed=${result.processed} sent=${result.sent} failed=${result.failed}`
+ ` retrying=${result.retrying} discarded=${result.discarded}`,
);
return;
}
if (job === 'assets') {
const result = await processAssetBatch();
console.log(
`[worker] assets batch processed=${result.processed}`
+ ` verified=${result.verified} failed=${result.failed} skipped=${result.skipped} errors=${result.errors}`,
);
return;
}
if (job === 'imports') {
const { closeImportExecutorPool, processImportBatch } = await import('./jobs/imports.js');
extraClosers.add(closeImportExecutorPool);
const result = await processImportBatch();
console.log(
`[worker] imports batch processed=${result.processed}`
+ ` completed=${result.completed} completedWithErrors=${result.completedWithErrors}`
+ ` failed=${result.failed} retrying=${result.retrying}`
+ ` leaseLost=${result.leaseLost} skipped=${result.skipped}`,
);
return;
}
if (job === 'public-banks') {
const { closePublicBankSyncExecutorPool, processPublicBankSyncBatch } = await import('./jobs/public-banks.js');
extraClosers.add(closePublicBankSyncExecutorPool);
const result = await processPublicBankSyncBatch();
console.log(
`[worker] public-banks batch processed=${result.processed}`
+ ` synced=${result.synced} conflicts=${result.conflicts}`
+ ` failed=${result.failed} skipped=${result.skipped}`,
);
return;
}
if (job === 'exports') {
const { closeExportExecutorPool, processExportBatch } = await import('./jobs/exports.js');
extraClosers.add(closeExportExecutorPool);
const result = await processExportBatch();
console.log(
`[worker] exports batch processed=${result.processed}`
+ ` completed=${result.completed} failed=${result.failed}`
+ ` retrying=${result.retrying} skipped=${result.skipped}`,
);
return;
}
if (job === 'student-supervision') {
const { closePool: closeApiPool } = await import('../../api/src/core/db.js');
const { processStudentSupervisionBatch } = await import('./jobs/student-supervision.js');
extraClosers.add(closeApiPool);
const result = await processStudentSupervisionBatch();
console.log(
`[worker] student-supervision batch processed=${result.processed}`
+ ` generated=${result.generated} followups=${result.followups}`
+ ` failed=${result.failed} skipped=${result.skipped}`,
);
return;
}
throw new Error(`Unsupported worker job: ${job}`);
}
function loopPollIntervalMs(job: ContinuousWorkerJob) {
if (job === 'crm') return config.crmPollIntervalMs;
if (job === 'commerce') return config.commercePollIntervalMs;
if (job === 'provider-bills') return config.providerBillPollIntervalMs;
if (job === 'platform-dunning-notifications') return config.platformDunningNotificationPollIntervalMs;
if (job === 'platform-audit-notifications') return config.platformAuditNotificationPollIntervalMs;
if (job === 'assets') return config.assetPollIntervalMs;
if (job === 'imports') return config.importPollIntervalMs;
if (job === 'public-banks') return config.publicBankSyncPollIntervalMs;
return config.exportPollIntervalMs;
}
async function runLoop(job: ContinuousWorkerJob) {
const pollIntervalMs = loopPollIntervalMs(job);
console.log(`[worker] started job=${job} pollIntervalMs=${pollIntervalMs}`);
let stopped = false;
const stop = () => {
stopped = true;
};
process.once('SIGINT', stop);
process.once('SIGTERM', stop);
while (!stopped) {
try {
await runOnce(job);
} catch (error) {
console.error(`[worker] job=${job} failed`, error);
}
if (!stopped) await new Promise(resolve => setTimeout(resolve, pollIntervalMs));
}
}
import 'reflect-metadata';
import { NestFactory } from '@nestjs/core';
import { parseWorkerCli } from './cli.js';
import { WorkerModule, WorkerRunner } from './worker.module.js';
const cli = parseWorkerCli(process.argv.slice(2));
const app = await NestFactory.createApplicationContext(WorkerModule, { logger: false });
try {
if (cli.loop) {
await runLoop(cli.job as ContinuousWorkerJob);
} else {
await runOnce(cli.job, cli.month);
}
await app.get(WorkerRunner).execute(cli);
} finally {
for (const closeExtra of extraClosers) {
await closeExtra();
}
await closePool();
await app.close();
}

View File

@@ -0,0 +1,161 @@
import { Injectable, Module, OnApplicationShutdown } from '@nestjs/common';
import { config } from './config.js';
import { closePool } from './db.js';
import { type ContinuousWorkerJob, type WorkerCliOptions, type WorkerJob } from './cli.js';
import { processAssetBatch } from './jobs/assets.js';
import { processCommerceBatch } from './jobs/commerce.js';
import { processCrmBatch } from './jobs/crm.js';
@Injectable()
export class WorkerCloserRegistry implements OnApplicationShutdown {
// 动态任务可能额外加载 API 或导入导出连接池,统一登记后由 Nest 生命周期关闭。
private readonly closers = new Set<() => Promise<void>>([closePool]);
add(closer: () => Promise<void>) { this.closers.add(closer); }
async onApplicationShutdown() {
for (const closer of this.closers) await closer();
}
}
@Injectable()
export class WorkerJobRegistry {
constructor(private readonly closers: WorkerCloserRegistry) {}
async run(job: WorkerJob, month?: string) {
if (job === 'crm') {
const result = await processCrmBatch();
console.log(`[worker] crm batch processed=${result.processed} sent=${result.sent} failed=${result.failed} retrying=${result.retrying} discarded=${result.discarded}`);
return;
}
if (job === 'commerce') {
const result = await processCommerceBatch();
console.log(`[worker] commerce batch processed=${result.processed} payments=${result.payments.processed} paid=${result.payments.paid} pending=${result.payments.pending} closed=${result.payments.closed} failed=${result.payments.failed} paymentErrors=${result.payments.errors} refunds=${result.refunds.processed} succeeded=${result.refunds.succeeded} processing=${result.refunds.processing} refundFailed=${result.refunds.failed} refundErrors=${result.refunds.errors}`);
return;
}
if (job === 'provider-bills') {
const { closePool: closeApiPool } = await import('../../api/src/core/db.js');
const { processProviderBillBatch } = await import('./jobs/provider-bills.js');
this.closers.add(closeApiPool);
const result = await processProviderBillBatch();
console.log(`[worker] provider-bills batch processed=${result.processed} completed=${result.completed} failed=${result.failed} skipped=${result.skipped}`);
return;
}
if (job === 'platform-billing') {
const { processPlatformBillingBatch } = await import('./jobs/platform-billing.js');
const result = await processPlatformBillingBatch();
console.log(`[worker] platform-billing batch processed=${result.processed} created=${result.created} skipped=${result.skipped} failed=${result.failed}`);
return;
}
if (job === 'platform-usage') {
const { processPlatformUsageBatch } = await import('./jobs/platform-usage.js');
const result = await processPlatformUsageBatch({ month });
console.log(`[worker] platform-usage batch processed=${result.processed} metrics=${result.metrics} created=${result.created} updated=${result.updated} failed=${result.failed} skipped=${result.skipped}`);
return;
}
if (job === 'platform-usage-overage') {
const { processPlatformUsageOverageBatch } = await import('./jobs/platform-usage-overage.js');
const result = await processPlatformUsageOverageBatch({ month });
console.log(`[worker] platform-usage-overage batch processed=${result.processed} created=${result.created} skipped=${result.skipped} failed=${result.failed} totalCents=${result.totalCents}`);
return;
}
if (job === 'platform-dunning') {
const { processPlatformDunningBatch } = await import('./jobs/platform-dunning.js');
const result = await processPlatformDunningBatch();
console.log(`[worker] platform-dunning batch processed=${result.processed} markedOverdue=${result.markedOverdue} reminderCreated=${result.reminderCreated} skippedReminder=${result.skippedReminder}`);
return;
}
if (job === 'platform-dunning-notifications') {
const { processPlatformDunningNotificationBatch } = await import('./jobs/platform-dunning-notifications.js');
const result = await processPlatformDunningNotificationBatch();
console.log(`[worker] platform-dunning-notifications batch enqueued=${result.enqueued} processed=${result.processed} sent=${result.sent} failed=${result.failed} retrying=${result.retrying} discarded=${result.discarded}`);
return;
}
if (job === 'platform-audit-alerts') {
const { processPlatformAuditAlertBatch } = await import('./jobs/platform-audit-alerts.js');
const result = await processPlatformAuditAlertBatch();
console.log(`[worker] platform-audit-alerts batch processed=${result.processed} created=${result.created} skipped=${result.skipped}`);
return;
}
if (job === 'platform-audit-notifications') {
const { processPlatformAuditNotificationBatch } = await import('./jobs/platform-audit-notifications.js');
const result = await processPlatformAuditNotificationBatch();
console.log(`[worker] platform-audit-notifications batch enqueued=${result.enqueued} processed=${result.processed} sent=${result.sent} failed=${result.failed} retrying=${result.retrying} discarded=${result.discarded}`);
return;
}
if (job === 'assets') {
const result = await processAssetBatch();
console.log(`[worker] assets batch processed=${result.processed} verified=${result.verified} failed=${result.failed} skipped=${result.skipped} errors=${result.errors}`);
return;
}
if (job === 'imports') {
const { closeImportExecutorPool, processImportBatch } = await import('./jobs/imports.js');
this.closers.add(closeImportExecutorPool);
const result = await processImportBatch();
console.log(`[worker] imports batch processed=${result.processed} completed=${result.completed} completedWithErrors=${result.completedWithErrors} failed=${result.failed} retrying=${result.retrying} leaseLost=${result.leaseLost} skipped=${result.skipped}`);
return;
}
if (job === 'public-banks') {
const { closePublicBankSyncExecutorPool, processPublicBankSyncBatch } = await import('./jobs/public-banks.js');
this.closers.add(closePublicBankSyncExecutorPool);
const result = await processPublicBankSyncBatch();
console.log(`[worker] public-banks batch processed=${result.processed} synced=${result.synced} conflicts=${result.conflicts} failed=${result.failed} skipped=${result.skipped}`);
return;
}
if (job === 'exports') {
const { closeExportExecutorPool, processExportBatch } = await import('./jobs/exports.js');
this.closers.add(closeExportExecutorPool);
const result = await processExportBatch();
console.log(`[worker] exports batch processed=${result.processed} completed=${result.completed} failed=${result.failed} retrying=${result.retrying} skipped=${result.skipped}`);
return;
}
if (job === 'student-supervision') {
const { closePool: closeApiPool } = await import('../../api/src/core/db.js');
const { processStudentSupervisionBatch } = await import('./jobs/student-supervision.js');
this.closers.add(closeApiPool);
const result = await processStudentSupervisionBatch();
console.log(`[worker] student-supervision batch processed=${result.processed} generated=${result.generated} followups=${result.followups} failed=${result.failed} skipped=${result.skipped}`);
return;
}
throw new Error(`Unsupported worker job: ${job}`);
}
}
@Injectable()
export class WorkerPollIntervals {
get(job: ContinuousWorkerJob) {
if (job === 'crm') return config.crmPollIntervalMs;
if (job === 'commerce') return config.commercePollIntervalMs;
if (job === 'provider-bills') return config.providerBillPollIntervalMs;
if (job === 'platform-dunning-notifications') return config.platformDunningNotificationPollIntervalMs;
if (job === 'platform-audit-notifications') return config.platformAuditNotificationPollIntervalMs;
if (job === 'assets') return config.assetPollIntervalMs;
if (job === 'imports') return config.importPollIntervalMs;
if (job === 'public-banks') return config.publicBankSyncPollIntervalMs;
return config.exportPollIntervalMs;
}
}
@Injectable()
export class WorkerRunner {
constructor(private readonly jobs: WorkerJobRegistry, private readonly intervals: WorkerPollIntervals) {}
async execute(cli: WorkerCliOptions) {
if (!cli.loop) return this.jobs.run(cli.job, cli.month);
const job = cli.job as ContinuousWorkerJob;
const pollIntervalMs = this.intervals.get(job);
console.log(`[worker] started job=${job} pollIntervalMs=${pollIntervalMs}`);
let stopped = false;
const stop = () => { stopped = true; };
process.once('SIGINT', stop);
process.once('SIGTERM', stop);
while (!stopped) {
try { await this.jobs.run(job); }
catch (error) { console.error(`[worker] job=${job} failed`, error); }
if (!stopped) await new Promise(resolve => setTimeout(resolve, pollIntervalMs));
}
}
}
@Module({ providers: [WorkerCloserRegistry, WorkerJobRegistry, WorkerPollIntervals, WorkerRunner] })
export class WorkerModule {}

View File

@@ -4,6 +4,8 @@
"module": "NodeNext",
"moduleResolution": "NodeNext",
"strict": true,
"experimentalDecorators": true,
"emitDecoratorMetadata": true,
"esModuleInterop": true,
"skipLibCheck": true,
"outDir": "dist",

1337
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -80,6 +80,9 @@
"test:deploy:contract": "node scripts/deploy-contract-test.js && node --import tsx scripts/worker-scheduling-contract-test.js",
"test:worker:scheduling": "node --import tsx scripts/worker-scheduling-contract-test.js",
"test:api:operations": "npm run build:api && node scripts/api-server-operations-contract-test.js",
"test:nest:migration": "node scripts/nest-migration-contract-test.js",
"test:nest:runtime": "npm run build:api && node scripts/nest-api-runtime-test.js",
"test:openapi:docs": "node scripts/openapi-documentation-contract-test.js",
"test:api:cors": "node --import tsx scripts/api-cors-policy-test.js && npm run test:api:operations",
"test:auth:foundation": "node --import tsx scripts/auth-context-platform-admin-test.js && node --import tsx scripts/tenant-permission-resolution-test.js && node --import tsx scripts/tenant-resolve-contract-test.js && node scripts/bootstrap-platform-admin-test.js",
"test:data-api:security": "node scripts/data-api-security-contract-test.js",

View File

@@ -0,0 +1,89 @@
import assert from 'node:assert/strict';
import net from 'node:net';
import { spawn } from 'node:child_process';
function freePort() {
return new Promise((resolve, reject) => {
const server = net.createServer();
server.once('error', reject);
server.listen(0, '127.0.0.1', () => {
const address = server.address();
const port = typeof address === 'object' && address ? address.port : 0;
server.close(error => error ? reject(error) : resolve(port));
});
});
}
async function startApi(nodeEnv) {
const port = await freePort();
const child = spawn(process.execPath, ['apps/api/dist/apps/api/src/server.js'], {
cwd: process.cwd(),
env: {
...process.env,
NODE_ENV: nodeEnv,
PORT: String(port),
CORS_ORIGIN: '*',
CORS_TENANT_DOMAINS_ENABLED: 'false',
AUTH_CODE_PEPPER: 'runtime-test-auth-code-pepper-0123456789',
AUTH_SESSION_SECRET: 'runtime-test-auth-session-secret-0123456789',
AUTH_JWT_SECRET: 'runtime-test-auth-jwt-secret-0123456789',
PLATFORM_ADMIN_API_KEY: 'runtime-test-platform-key-0123456789',
},
stdio: ['ignore', 'pipe', 'pipe'],
});
let output = '';
child.stdout.on('data', chunk => { output += chunk.toString(); });
child.stderr.on('data', chunk => { output += chunk.toString(); });
const startedAt = Date.now();
while (!output.includes('"event":"server_listening"')) {
if (child.exitCode !== null) throw new Error(`API exited during startup: ${output}`);
if (Date.now() - startedAt > 10_000) throw new Error(`API startup timed out: ${output}`);
await new Promise(resolve => setTimeout(resolve, 25));
}
return { child, port, output: () => output };
}
async function stopApi(api) {
api.child.kill('SIGTERM');
await new Promise((resolve, reject) => {
const timer = setTimeout(() => reject(new Error(`API shutdown timed out: ${api.output()}`)), 8_000);
api.child.once('exit', code => {
clearTimeout(timer);
code === 0 ? resolve() : reject(new Error(`API shutdown failed code=${code}: ${api.output()}`));
});
});
}
const development = await startApi('development');
try {
const docs = await fetch(`http://127.0.0.1:${development.port}/docs`);
assert.equal(docs.status, 200);
assert.match(await docs.text(), /scalar/i);
const openapiResponse = await fetch(`http://127.0.0.1:${development.port}/openapi.json`);
assert.equal(openapiResponse.status, 200);
const openapi = await openapiResponse.json();
assert.ok(openapi.paths['/api/auth/sms/send']?.post, 'native auth route must be documented');
assert.equal(openapi.paths['/api/catalog/regions']?.get?.['x-migration-status'], 'legacy');
const invalidNative = await fetch(`http://127.0.0.1:${development.port}/api/auth/sms/send`, {
method: 'POST',
headers: { 'content-type': 'application/json', 'x-tenant-id': 'not-a-uuid' },
body: JSON.stringify({ phone: 123 }),
});
assert.equal(invalidNative.status, 400);
assert.equal((await invalidNative.json()).code, 'VALIDATION_ERROR');
const legacyPost = await fetch(`http://127.0.0.1:${development.port}/api/commerce/orders`, {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({}),
});
assert.notEqual(legacyPost.status, 404, 'legacy POST route must remain registered');
const legacyBody = await legacyPost.json();
assert.ok(legacyBody.meta?.requestId, 'legacy response must include requestId metadata');
} finally {
await stopApi(development);
}
console.log('[PASS] NestJS API docs, DTO validation and legacy bridge runtime');

View File

@@ -0,0 +1,28 @@
import assert from 'node:assert/strict';
import fs from 'node:fs';
const read = file => fs.readFileSync(file, 'utf8').replace(/\r\n/g, '\n');
const legacyBridge = read('apps/api/src/nest/legacy-bridge.ts');
const server = read('apps/api/src/server.ts');
const worker = read('apps/worker/src/index.ts');
const workerModule = read('apps/worker/src/worker.module.ts');
const nativeKeys = [...legacyBridge.matchAll(/'(GET|POST|PUT|PATCH|DELETE) ([^']+)'/g)]
.map(match => `${match[1]} ${match[2]}`);
assert.equal(new Set(nativeKeys).size, 50, 'the first NestJS batch must own exactly 50 unique routes');
assert.equal(nativeKeys.length, 50, 'native route declarations must not contain duplicates');
assert.match(legacyBridge, /definitions\.filter\(\(\[method, path\]\) => !NATIVE_ROUTE_KEYS\.has/);
assert.match(legacyBridge, /\/api\/questions\/:questionId\/videos/);
assert.match(server, /NestFactory\.create<.*NestFastifyApplication>/);
assert.match(server, /registerLegacyRoutes/);
assert.match(server, /if \(!config\.isProduction\) registerOpenApi/);
assert.match(server, /ApiEnvelopeInterceptor/);
assert.match(server, /ApiExceptionFilter/);
assert.match(worker, /NestFactory\.createApplicationContext\(WorkerModule/);
assert.match(worker, /app\.get\(WorkerRunner\)/);
assert.match(workerModule, /constructor\(private readonly jobs: WorkerJobRegistry, private readonly intervals: WorkerPollIntervals\)/);
assert.match(workerModule, /constructor\(private readonly closers: WorkerCloserRegistry\)/);
console.log('[PASS] NestJS first-batch routes, legacy bridge, docs guard and worker DI contract');

View File

@@ -0,0 +1,39 @@
import assert from 'node:assert/strict';
import fs from 'node:fs';
const document = JSON.parse(fs.readFileSync(process.argv[2] || '/tmp/tiku-openapi.json', 'utf8'));
const schemas = document.components?.schemas || {};
const operations = [];
for (const [path, pathItem] of Object.entries(document.paths || {})) {
for (const [method, operation] of Object.entries(pathItem)) {
if (!['get', 'post', 'put', 'patch', 'delete'].includes(method)) continue;
if (operation['x-migration-status'] === 'legacy') continue;
operations.push({ path, method, operation });
}
}
assert.equal(operations.length, 50, '首批原生 Nest 接口数量应为 50');
for (const { path, method, operation } of operations) {
assert.match(operation.summary || '', /[\u4e00-\u9fff]/, `${method.toUpperCase()} ${path} 缺少中文摘要`);
assert.ok(operation.responses?.['200']?.content?.['application/json']?.schema, `${method.toUpperCase()} ${path} 缺少 200 响应 schema`);
}
const bodyless = new Set([
'POST /api/auth/logout',
'POST /api/profile/check-in',
]);
for (const { path, method, operation } of operations.filter(item => ['post', 'put', 'patch'].includes(item.method))) {
const key = `${method.toUpperCase()} ${path}`;
if (bodyless.has(key)) continue;
const bodySchema = operation.requestBody?.content?.['application/json']?.schema;
assert.ok(bodySchema, `${key} 缺少 requestBody schema`);
const ref = bodySchema.$ref?.split('/').pop();
const resolved = ref ? schemas[ref] : bodySchema;
assert.ok(resolved?.properties && Object.keys(resolved.properties).length > 0, `${key} 的 DTO 没有可见字段`);
for (const [field, property] of Object.entries(resolved.properties)) {
assert.ok(property.description, `${key} 的字段 ${field} 缺少中文说明`);
}
}
console.log('[PASS] 50 个 Nest 接口均有中文摘要、请求 DTO 字段和响应 schema');

View File

@@ -5,6 +5,8 @@ const read = file => fs.readFileSync(file, 'utf8').replace(/\r\n/g, '\n');
const httpSource = read('apps/api/src/core/http.ts');
const serverSource = read('apps/api/src/server.ts');
const envelopeInterceptorSource = read('apps/api/src/nest/api.interceptor.ts');
const exceptionFilterSource = read('apps/api/src/nest/api-exception.filter.ts');
const apiSource = read('apps/taro/src/services/api.ts');
const typesSource = read('apps/taro/src/types.ts');
const tenantLocatorSource = read('apps/api/src/features/tenant/locator.ts');
@@ -13,8 +15,10 @@ const studentRouteSource = read('apps/api/src/features/tenant-admin/classes.ts')
const studentCursorSource = read('apps/api/src/features/tenant-admin/student-cursor.ts');
assert.match(httpSource, /meta:\s*\{ \.\.\.existingMeta, requestId \}/, 'API responses must expose requestId without discarding endpoint metadata');
assert.match(serverSource, /sendJson\(res, 200, withResponseMeta\(result, requestId\)\)/, 'Successful API responses must carry requestId metadata');
assert.match(serverSource, /withResponseMeta\(\{ \.\.\.body, requestId \}, requestId\)/, 'Error API responses must carry the same requestId in the legacy field and metadata envelope');
assert.match(serverSource, /useGlobalInterceptors\(new ApiEnvelopeInterceptor\(\)\)/, 'Successful responses must use the global API envelope interceptor');
assert.match(envelopeInterceptorSource, /withResponseMeta\(body, String\(reply\.getHeader\('x-request-id'\)/, 'Successful API responses must carry requestId metadata');
assert.match(serverSource, /useGlobalFilters\(new ApiExceptionFilter\(\)\)/, 'Errors must use the global API exception filter');
assert.match(exceptionFilterSource, /withResponseMeta\(\{ error: message, code, requestId \}, requestId\)/, 'Error API responses must carry the same requestId in the legacy field and metadata envelope');
assert.match(apiSource, /responseHeaderValue\(response\.header, 'x-request-id'\)/, 'The Taro API client must fall back to the response header requestId');
assert.match(apiSource, /this\.requestId = payload\.requestId/, 'ApiError must retain requestId for support and observability');
assert.doesNotMatch(typesSource, /\[key:\s*string\]:\s*unknown/, 'The shared API envelope must not silently accept arbitrary response fields');