feat: migrate backend foundation to NestJS

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

View File

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

View File

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

View File

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

View File

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