import express from 'express'; import pinoHttp from 'pino-http'; import type { AppContext } from './context.js'; import { buildApiLogContext, withRouteMeta } from './http.js'; import { errorHandler } from './middleware/errorHandler.js'; import { requestIdMiddleware } from './middleware/requestId.js'; import { responseEnvelopeMiddleware } from './middleware/responseEnvelope.js'; import { createCharacterAssetRoutes } from './modules/assets/characterAssetRoutes.js'; import { createQwenSpriteRoutes } from './modules/assets/qwenSpriteRoutes.js'; import { createEditorRoutes } from './modules/editor/editorRoutes.js'; import { createStoryActionRoutes } from './modules/story/storyActionRoutes.js'; import { createAuthRoutes } from './routes/authRoutes.js'; import { createRuntimeRoutes } from './routes/runtimeRoutes.js'; import { notFound } from './errors.js'; function matchesRoutePrefix( request: express.Request, prefixes: readonly string[], ) { const requestPath = request.path || request.originalUrl || request.url || '/'; return prefixes.some((prefix) => { const normalizedPrefix = prefix.endsWith('/') ? prefix.slice(0, -1) : prefix; return ( requestPath === normalizedPrefix || requestPath.startsWith(`${normalizedPrefix}/`) ); }); } function scopeToPrefixes( prefixes: readonly string[], handler: express.RequestHandler, ): express.RequestHandler { return (request, response, next) => { if (!matchesRoutePrefix(request, prefixes)) { next(); return; } handler(request, response, next); }; } export function createApp(context: AppContext) { const app = express(); const createHttpLogger = pinoHttp as unknown as ( options: Record, ) => express.RequestHandler; app.disable('x-powered-by'); app.use(requestIdMiddleware); app.use( createHttpLogger({ logger: context.logger, genReqId: (request: express.Request) => request.requestId, customSuccessObject: ( request: express.Request, response: express.Response, baseObject: Record & { responseTime?: number }, ) => ({ ...baseObject, ...buildApiLogContext(request, response), user_id: request.userId ?? null, method: request.method, path: request.url, status: response.statusCode, latency_ms: baseObject.responseTime, }), customErrorObject: ( request: express.Request, response: express.Response, error: unknown, baseObject: Record & { responseTime?: number }, ) => ({ ...baseObject, ...buildApiLogContext(request, response), user_id: request.userId ?? null, method: request.method, path: request.url, status: response.statusCode, latency_ms: baseObject.responseTime, err: error, }), }), ); app.use(express.json({ limit: '10mb' })); app.use(responseEnvelopeMiddleware); app.get( '/healthz', withRouteMeta({ operation: 'health.check' }), (_request, response) => { response.json({ ok: true, service: 'genarrative-node-server', }); }, ); app.use( scopeToPrefixes( ['/api/editor'], withRouteMeta({ routeVersion: '2026-04-08', operation: 'editor.api' }), ), ); app.use(scopeToPrefixes(['/api/editor'], createEditorRoutes(context.config))); app.use( scopeToPrefixes( ['/api/assets'], withRouteMeta({ routeVersion: '2026-04-08', operation: 'assets.api' }), ), ); app.use( scopeToPrefixes(['/api/assets'], createCharacterAssetRoutes(context.config)), ); app.use( scopeToPrefixes( ['/api/assets/qwen-sprite'], withRouteMeta({ routeVersion: '2026-04-08', operation: 'assets.qwen' }), ), ); app.use( scopeToPrefixes( ['/api/assets/qwen-sprite'], createQwenSpriteRoutes(context.config), ), ); app.use( '/api/auth', withRouteMeta({ routeVersion: '2026-04-08' }), createAuthRoutes(context), ); app.use( '/api/runtime/story', withRouteMeta({ routeVersion: '2026-04-08' }), createStoryActionRoutes(context), ); app.use( '/api', withRouteMeta({ routeVersion: '2026-04-08' }), createRuntimeRoutes(context), ); app.use((request, _response, next) => { next(notFound(`接口不存在:${request.method} ${request.originalUrl}`)); }); app.use(errorHandler); return app; }