241 lines
7.2 KiB
TypeScript
241 lines
7.2 KiB
TypeScript
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<string, unknown>,
|
|
) => 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<string, unknown> & { 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<string, unknown> & { 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;
|
|
}
|