import express from 'express'; import pinoHttp from 'pino-http'; import type { AppContext } from './context.js'; import { notFound } from './errors.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 { createEditorRoutes } from './modules/editor/editorRoutes.js'; import { createAuthRoutes } from './routes/authRoutes.js'; import { createBigFishProxyRoutes } from './routes/bigFishProxyRoutes.js'; import { createCustomWorldAgentRoutes } from './routes/customWorldAgent.js'; import { createPuzzleProxyRoutes } from './routes/puzzleProxyRoutes.js'; import { createRpgEntrySaveRoutes } from './routes/rpg-entry/rpgEntrySaveRoutes.js'; import { createRpgWorldLibraryRoutes } from './routes/rpg-entry/rpgWorldLibraryRoutes.js'; import { createRpgProfileRoutes } from './routes/rpg-profile/rpgProfileRoutes.js'; import { createRpgRuntimeAiAssistRoutes } from './routes/rpg-runtime/rpgRuntimeAiAssistRoutes.js'; import { createRpgRuntimeStoryRoutes } from './routes/rpg-runtime/rpgRuntimeStoryRoutes.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, context.llmClient), ), ); app.use( '/api', scopeToPrefixes( ['/runtime/profile', '/profile', '/runtime/settings'], withRouteMeta({ routeVersion: '2026-04-21', operation: 'rpg.profile.api' }), ), createRpgProfileRoutes(context), ); app.use( '/api', scopeToPrefixes( ['/runtime/save', '/runtime/profile/save-archives', '/profile/save-archives'], withRouteMeta({ routeVersion: '2026-04-21', operation: 'rpg.entry.save.api' }), ), createRpgEntrySaveRoutes(context), ); app.use( '/api', scopeToPrefixes( ['/runtime/custom-world-gallery', '/runtime/custom-world/works', '/runtime/custom-world-library'], withRouteMeta({ routeVersion: '2026-04-21', operation: 'rpg.entry.worldLibrary.api', }), ), createRpgWorldLibraryRoutes(context), ); app.use( '/api/auth', withRouteMeta({ routeVersion: '2026-04-08' }), createAuthRoutes(context), ); app.use( '/api/runtime/story', withRouteMeta({ routeVersion: '2026-04-21' }), createRpgRuntimeStoryRoutes(context), ); app.use( scopeToPrefixes( [ '/llm/chat/completions', '/custom-world/cover-image', '/custom-world/cover-upload', '/custom-world/scene-image', '/custom-world/entity', '/custom-world/scene-npc', '/runtime/custom-world/entity', '/runtime/custom-world/scene-npc', '/runtime/custom-world/profile', '/runtime/story/initial', '/runtime/story/continue', '/runtime/chat', '/runtime/items', '/runtime/quests', '/ws/health', ], withRouteMeta({ routeVersion: '2026-04-21', operation: 'rpg.runtime.aiAssist.api', }), ), ); app.use( '/api', scopeToPrefixes( [ '/llm/chat/completions', '/custom-world/cover-image', '/custom-world/cover-upload', '/custom-world/scene-image', '/custom-world/entity', '/custom-world/scene-npc', '/runtime/custom-world/entity', '/runtime/custom-world/scene-npc', '/runtime/custom-world/profile', '/runtime/story/initial', '/runtime/story/continue', '/runtime/chat', '/runtime/items', '/runtime/quests', '/ws/health', ], createRpgRuntimeAiAssistRoutes(context), ), ); app.use( '/api/runtime/custom-world/agent', withRouteMeta({ routeVersion: '2026-04-21', operation: 'rpg.creation.agent.api' }), createCustomWorldAgentRoutes(context), ); app.use( '/api/runtime/big-fish', withRouteMeta({ routeVersion: '2026-04-22', operation: 'bigFish.runtime.proxy.api' }), createBigFishProxyRoutes(context), ); app.use( '/api/runtime/puzzle', withRouteMeta({ routeVersion: '2026-04-22', operation: 'puzzle.runtime.proxy.api' }), createPuzzleProxyRoutes(context), ); app.use( express.static(context.config.publicDir, { fallthrough: true, index: false, }), ); app.use((request, _response, next) => { next(notFound(`接口不存在:${request.method} ${request.originalUrl}`)); }); app.use(errorHandler); return app; }