forked from wangziqi/gongxue-base
feat: migrate backend foundation to NestJS
This commit is contained in:
@@ -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",
|
||||
|
||||
@@ -10,6 +10,7 @@ export interface RequestContext {
|
||||
res: ServerResponse;
|
||||
url: URL;
|
||||
requestId: string;
|
||||
parsedBody?: unknown;
|
||||
}
|
||||
|
||||
export type Handler = (ctx: RequestContext) => Promise<unknown>;
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -28,7 +28,7 @@ export function createRouter(definitions: RouteDefinition[] = allRoutes) {
|
||||
return routes;
|
||||
}
|
||||
|
||||
const allRoutes: RouteDefinition[] = [
|
||||
export const allRoutes: RouteDefinition[] = [
|
||||
...healthRoutes,
|
||||
...authRoutes,
|
||||
...tenantRoutes,
|
||||
|
||||
48
apps/api/src/nest/ai.module.ts
Normal file
48
apps/api/src/nest/ai.module.ts
Normal 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 {}
|
||||
38
apps/api/src/nest/api-doc.decorators.ts
Normal file
38
apps/api/src/nest/api-doc.decorators.ts
Normal 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 };
|
||||
35
apps/api/src/nest/api-exception.filter.ts
Normal file
35
apps/api/src/nest/api-exception.filter.ts
Normal 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));
|
||||
}
|
||||
}
|
||||
69
apps/api/src/nest/api-response.dto.ts
Normal file
69
apps/api/src/nest/api-response.dto.ts
Normal 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: '收藏接口返回当前收藏状态' },
|
||||
});
|
||||
14
apps/api/src/nest/api.interceptor.ts
Normal file
14
apps/api/src/nest/api.interceptor.ts
Normal 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') || ''))));
|
||||
}
|
||||
}
|
||||
21
apps/api/src/nest/app.module.ts
Normal file
21
apps/api/src/nest/app.module.ts
Normal 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 {}
|
||||
57
apps/api/src/nest/auth.module.ts
Normal file
57
apps/api/src/nest/auth.module.ts
Normal 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 {}
|
||||
9
apps/api/src/nest/database.provider.ts
Normal file
9
apps/api/src/nest/database.provider.ts
Normal 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();
|
||||
}
|
||||
}
|
||||
16
apps/api/src/nest/domain-route.service.ts
Normal file
16
apps/api/src/nest/domain-route.service.ts
Normal 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
55
apps/api/src/nest/dto.ts
Normal 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;
|
||||
}
|
||||
31
apps/api/src/nest/health.module.ts
Normal file
31
apps/api/src/nest/health.module.ts
Normal 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 {}
|
||||
63
apps/api/src/nest/http-hooks.ts
Normal file
63
apps/api/src/nest/http-hooks.ts
Normal 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 };
|
||||
87
apps/api/src/nest/learning.module.ts
Normal file
87
apps/api/src/nest/learning.module.ts
Normal 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 {}
|
||||
79
apps/api/src/nest/legacy-bridge.ts
Normal file
79
apps/api/src/nest/legacy-bridge.ts
Normal 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));
|
||||
}
|
||||
});
|
||||
}
|
||||
38
apps/api/src/nest/openapi.ts
Normal file
38
apps/api/src/nest/openapi.ts
Normal 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;
|
||||
});
|
||||
}
|
||||
83
apps/api/src/nest/profile.module.ts
Normal file
83
apps/api/src/nest/profile.module.ts
Normal 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 {}
|
||||
75
apps/api/src/nest/query.dto.ts
Normal file
75
apps/api/src/nest/query.dto.ts
Normal 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;
|
||||
}
|
||||
17
apps/api/src/nest/request-context.factory.ts
Normal file
17
apps/api/src/nest/request-context.factory.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
}
|
||||
113
apps/api/src/nest/request.dto.ts
Normal file
113
apps/api/src/nest/request.dto.ts
Normal 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;
|
||||
}
|
||||
40
apps/api/src/nest/tenant.module.ts
Normal file
40
apps/api/src/nest/tenant.module.ts
Normal 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 {}
|
||||
@@ -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();
|
||||
|
||||
@@ -4,6 +4,8 @@
|
||||
"module": "NodeNext",
|
||||
"moduleResolution": "NodeNext",
|
||||
"strict": true,
|
||||
"experimentalDecorators": true,
|
||||
"emitDecoratorMetadata": true,
|
||||
"esModuleInterop": true,
|
||||
"skipLibCheck": true,
|
||||
"outDir": "dist",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
161
apps/worker/src/worker.module.ts
Normal file
161
apps/worker/src/worker.module.ts
Normal 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 {}
|
||||
@@ -4,6 +4,8 @@
|
||||
"module": "NodeNext",
|
||||
"moduleResolution": "NodeNext",
|
||||
"strict": true,
|
||||
"experimentalDecorators": true,
|
||||
"emitDecoratorMetadata": true,
|
||||
"esModuleInterop": true,
|
||||
"skipLibCheck": true,
|
||||
"outDir": "dist",
|
||||
|
||||
Reference in New Issue
Block a user