This commit is contained in:
2026-04-22 20:14:15 +08:00
parent 0773a0d0ca
commit 0e9c286a57
205 changed files with 25790 additions and 1623 deletions

View File

@@ -10,12 +10,14 @@ 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';
import { createCustomWorldAgentRoutes } from './routes/customWorldAgent.js';
function matchesRoutePrefix(
request: express.Request,
@@ -212,6 +214,16 @@ export function createApp(context: AppContext) {
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,

View File

@@ -0,0 +1,329 @@
import { Readable } from 'node:stream';
import { type Request, type Response,Router } from 'express';
import type { AppContext } from '../context.js';
import { badRequest, upstreamError } from '../errors.js';
import {
API_RESPONSE_ENVELOPE_HEADER,
API_VERSION_HEADER,
asyncHandler,
prepareApiResponse,
RESPONSE_TIME_HEADER,
ROUTE_VERSION_HEADER,
} from '../http.js';
import { requireJwtAuth } from '../middleware/auth.js';
import { routeMeta } from '../middleware/routeMeta.js';
const BIG_FISH_ROUTE_VERSION = '2026-04-22';
const DEFAULT_RUST_API_TARGET = 'http://127.0.0.1:3100';
const DEFAULT_INTERNAL_API_SECRET = 'genarrative-dev-internal-bridge';
const INTERNAL_USER_HEADER = 'x-genarrative-authenticated-user-id';
const INTERNAL_SECRET_HEADER = 'x-genarrative-internal-api-secret';
function resolveRustApiTarget(context: AppContext) {
const configured =
context.config.rawEnv.GENARRATIVE_API_TARGET?.trim() ||
context.config.rawEnv.RUST_API_SERVER_TARGET?.trim() ||
'';
return configured || DEFAULT_RUST_API_TARGET;
}
function resolveInternalApiSecret(context: AppContext) {
return (
context.config.rawEnv.GENARRATIVE_INTERNAL_API_SECRET?.trim() ||
DEFAULT_INTERNAL_API_SECRET
);
}
function normalizeRouteSuffix(path: string) {
const normalized = path.startsWith('/') ? path : `/${path}`;
return normalized.replace(/\/+$/u, '');
}
function buildUpstreamUrl(context: AppContext, pathSuffix: string) {
const baseUrl = resolveRustApiTarget(context).replace(/\/+$/u, '');
return `${baseUrl}${normalizeRouteSuffix(pathSuffix)}`;
}
function pickForwardHeaders(
request: Request,
context: AppContext,
userId: string,
) {
const forwardedHeaders = new Headers();
const contentType = request.header('content-type')?.trim();
if (contentType) {
forwardedHeaders.set('content-type', contentType);
}
const accept = request.header('accept')?.trim();
if (accept) {
forwardedHeaders.set('accept', accept);
}
const requestId = request.requestId?.trim();
if (requestId) {
forwardedHeaders.set('x-request-id', requestId);
}
const envelope = request.header(API_RESPONSE_ENVELOPE_HEADER)?.trim();
if (envelope) {
forwardedHeaders.set(API_RESPONSE_ENVELOPE_HEADER, envelope);
}
forwardedHeaders.set(INTERNAL_USER_HEADER, userId);
const internalSecret = resolveInternalApiSecret(context);
if (internalSecret) {
forwardedHeaders.set(INTERNAL_SECRET_HEADER, internalSecret);
}
return forwardedHeaders;
}
function readBodyAllowed(method: string) {
return !['GET', 'HEAD'].includes(method.toUpperCase());
}
async function proxyBigFishRequest(params: {
context: AppContext;
request: Request;
response: Response;
pathSuffix: string;
streamBody?: boolean;
}) {
const { context, request, response, pathSuffix, streamBody = false } = params;
const userId = request.userId?.trim();
if (!userId) {
throw badRequest('缺少已认证用户上下文');
}
const upstreamUrl = buildUpstreamUrl(context, pathSuffix);
const method = request.method.toUpperCase();
const body =
readBodyAllowed(method) && request.body !== undefined
? JSON.stringify(request.body)
: undefined;
let upstreamResponse: globalThis.Response;
try {
upstreamResponse = await fetch(upstreamUrl, {
method,
// 这里显式转发“已通过 Node 校验的用户身份”,让 Big Fish 继续由 Rust 真相后端处理。
headers: pickForwardHeaders(request, context, userId),
body,
});
} catch (error) {
request.log?.error(
{
err: error,
user_id: userId,
upstream_url: upstreamUrl,
},
'big fish upstream request failed',
);
throw upstreamError('大鱼吃小鱼后端暂时不可用');
}
prepareApiResponse(request, response, {
statusCode: upstreamResponse.status,
headers: {
'Content-Type':
upstreamResponse.headers.get('content-type') ||
'application/json; charset=utf-8',
'Cache-Control':
upstreamResponse.headers.get('cache-control') || 'no-cache',
},
routeMeta: {
routeVersion: BIG_FISH_ROUTE_VERSION,
},
});
const upstreamRequestId = upstreamResponse.headers.get('x-request-id');
if (upstreamRequestId) {
response.setHeader('x-upstream-request-id', upstreamRequestId);
}
const upstreamRouteVersion = upstreamResponse.headers.get(ROUTE_VERSION_HEADER);
if (upstreamRouteVersion) {
response.setHeader('x-upstream-route-version', upstreamRouteVersion);
}
const upstreamApiVersion = upstreamResponse.headers.get(API_VERSION_HEADER);
if (upstreamApiVersion) {
response.setHeader('x-upstream-api-version', upstreamApiVersion);
}
const upstreamLatency = upstreamResponse.headers.get(RESPONSE_TIME_HEADER);
if (upstreamLatency) {
response.setHeader('x-upstream-response-time-ms', upstreamLatency);
}
if (streamBody) {
if (!upstreamResponse.body) {
throw upstreamError('大鱼吃小鱼流式响应不可用');
}
response.flushHeaders?.();
await Readable.fromWeb(upstreamResponse.body as never).pipe(response);
return;
}
response.end(await upstreamResponse.text());
}
function readParam(value: string | string[] | undefined) {
return Array.isArray(value) ? value[0]?.trim() || '' : value?.trim() || '';
}
export function createBigFishProxyRoutes(context: AppContext) {
const router = Router();
const requireAuth = requireJwtAuth(context.config, context.userRepository);
router.use(requireAuth);
router.post(
'/agent/sessions',
routeMeta({ operation: 'runtime.bigFish.createSession', routeVersion: BIG_FISH_ROUTE_VERSION }),
asyncHandler(async (request, response) => {
await proxyBigFishRequest({
context,
request,
response,
pathSuffix: '/api/runtime/big-fish/agent/sessions',
});
}),
);
router.get(
'/agent/sessions/:sessionId',
routeMeta({ operation: 'runtime.bigFish.getSession', routeVersion: BIG_FISH_ROUTE_VERSION }),
asyncHandler(async (request, response) => {
const sessionId = readParam(request.params.sessionId);
if (!sessionId) {
throw badRequest('sessionId is required');
}
await proxyBigFishRequest({
context,
request,
response,
pathSuffix: `/api/runtime/big-fish/agent/sessions/${encodeURIComponent(sessionId)}`,
});
}),
);
router.post(
'/agent/sessions/:sessionId/messages',
routeMeta({ operation: 'runtime.bigFish.sendMessage', routeVersion: BIG_FISH_ROUTE_VERSION }),
asyncHandler(async (request, response) => {
const sessionId = readParam(request.params.sessionId);
if (!sessionId) {
throw badRequest('sessionId is required');
}
await proxyBigFishRequest({
context,
request,
response,
pathSuffix: `/api/runtime/big-fish/agent/sessions/${encodeURIComponent(sessionId)}/messages`,
});
}),
);
router.post(
'/agent/sessions/:sessionId/messages/stream',
routeMeta({
operation: 'runtime.bigFish.streamMessage',
routeVersion: BIG_FISH_ROUTE_VERSION,
}),
asyncHandler(async (request, response) => {
const sessionId = readParam(request.params.sessionId);
if (!sessionId) {
throw badRequest('sessionId is required');
}
await proxyBigFishRequest({
context,
request,
response,
pathSuffix: `/api/runtime/big-fish/agent/sessions/${encodeURIComponent(sessionId)}/messages/stream`,
streamBody: true,
});
}),
);
router.post(
'/agent/sessions/:sessionId/actions',
routeMeta({ operation: 'runtime.bigFish.executeAction', routeVersion: BIG_FISH_ROUTE_VERSION }),
asyncHandler(async (request, response) => {
const sessionId = readParam(request.params.sessionId);
if (!sessionId) {
throw badRequest('sessionId is required');
}
await proxyBigFishRequest({
context,
request,
response,
pathSuffix: `/api/runtime/big-fish/agent/sessions/${encodeURIComponent(sessionId)}/actions`,
});
}),
);
router.post(
'/sessions/:sessionId/runs',
routeMeta({ operation: 'runtime.bigFish.startRun', routeVersion: BIG_FISH_ROUTE_VERSION }),
asyncHandler(async (request, response) => {
const sessionId = readParam(request.params.sessionId);
if (!sessionId) {
throw badRequest('sessionId is required');
}
await proxyBigFishRequest({
context,
request,
response,
pathSuffix: `/api/runtime/big-fish/sessions/${encodeURIComponent(sessionId)}/runs`,
});
}),
);
router.get(
'/runs/:runId',
routeMeta({ operation: 'runtime.bigFish.getRun', routeVersion: BIG_FISH_ROUTE_VERSION }),
asyncHandler(async (request, response) => {
const runId = readParam(request.params.runId);
if (!runId) {
throw badRequest('runId is required');
}
await proxyBigFishRequest({
context,
request,
response,
pathSuffix: `/api/runtime/big-fish/runs/${encodeURIComponent(runId)}`,
});
}),
);
router.post(
'/runs/:runId/input',
routeMeta({ operation: 'runtime.bigFish.submitInput', routeVersion: BIG_FISH_ROUTE_VERSION }),
asyncHandler(async (request, response) => {
const runId = readParam(request.params.runId);
if (!runId) {
throw badRequest('runId is required');
}
await proxyBigFishRequest({
context,
request,
response,
pathSuffix: `/api/runtime/big-fish/runs/${encodeURIComponent(runId)}/input`,
});
}),
);
return router;
}

View File

@@ -0,0 +1,439 @@
import { Readable } from 'node:stream';
import { type Request, type Response, Router } from 'express';
import type { AppContext } from '../context.js';
import { badRequest, upstreamError } from '../errors.js';
import {
API_RESPONSE_ENVELOPE_HEADER,
API_VERSION_HEADER,
asyncHandler,
prepareApiResponse,
RESPONSE_TIME_HEADER,
ROUTE_VERSION_HEADER,
} from '../http.js';
import { requireJwtAuth } from '../middleware/auth.js';
import { routeMeta } from '../middleware/routeMeta.js';
const PUZZLE_ROUTE_VERSION = '2026-04-22';
const DEFAULT_RUST_API_TARGET = 'http://127.0.0.1:3100';
const DEFAULT_INTERNAL_API_SECRET = 'genarrative-dev-internal-bridge';
const INTERNAL_USER_HEADER = 'x-genarrative-authenticated-user-id';
const INTERNAL_SECRET_HEADER = 'x-genarrative-internal-api-secret';
function resolveRustApiTarget(context: AppContext) {
const configured =
context.config.rawEnv.GENARRATIVE_API_TARGET?.trim() ||
context.config.rawEnv.RUST_API_SERVER_TARGET?.trim() ||
'';
return configured || DEFAULT_RUST_API_TARGET;
}
function resolveInternalApiSecret(context: AppContext) {
return (
context.config.rawEnv.GENARRATIVE_INTERNAL_API_SECRET?.trim() ||
DEFAULT_INTERNAL_API_SECRET
);
}
function normalizeRouteSuffix(path: string) {
const normalized = path.startsWith('/') ? path : `/${path}`;
return normalized.replace(/\/+$/u, '');
}
function buildUpstreamUrl(context: AppContext, pathSuffix: string) {
const baseUrl = resolveRustApiTarget(context).replace(/\/+$/u, '');
return `${baseUrl}${normalizeRouteSuffix(pathSuffix)}`;
}
function pickForwardHeaders(
request: Request,
context: AppContext,
userId: string,
) {
const forwardedHeaders = new Headers();
const contentType = request.header('content-type')?.trim();
if (contentType) {
forwardedHeaders.set('content-type', contentType);
}
const accept = request.header('accept')?.trim();
if (accept) {
forwardedHeaders.set('accept', accept);
}
const requestId = request.requestId?.trim();
if (requestId) {
forwardedHeaders.set('x-request-id', requestId);
}
const envelope = request.header(API_RESPONSE_ENVELOPE_HEADER)?.trim();
if (envelope) {
forwardedHeaders.set(API_RESPONSE_ENVELOPE_HEADER, envelope);
}
forwardedHeaders.set(INTERNAL_USER_HEADER, userId);
const internalSecret = resolveInternalApiSecret(context);
if (internalSecret) {
forwardedHeaders.set(INTERNAL_SECRET_HEADER, internalSecret);
}
return forwardedHeaders;
}
function readBodyAllowed(method: string) {
return !['GET', 'HEAD'].includes(method.toUpperCase());
}
async function proxyPuzzleRequest(params: {
context: AppContext;
request: Request;
response: Response;
pathSuffix: string;
streamBody?: boolean;
}) {
const { context, request, response, pathSuffix, streamBody = false } = params;
const userId = request.userId?.trim();
if (!userId) {
throw badRequest('缺少已认证用户上下文');
}
const upstreamUrl = buildUpstreamUrl(context, pathSuffix);
const method = request.method.toUpperCase();
const body =
readBodyAllowed(method) && request.body !== undefined
? JSON.stringify(request.body)
: undefined;
let upstreamResponse: globalThis.Response;
try {
upstreamResponse = await fetch(upstreamUrl, {
method,
headers: pickForwardHeaders(request, context, userId),
body,
});
} catch (error) {
request.log?.error(
{
err: error,
user_id: userId,
upstream_url: upstreamUrl,
},
'puzzle upstream request failed',
);
throw upstreamError('拼图后端暂时不可用');
}
prepareApiResponse(request, response, {
statusCode: upstreamResponse.status,
headers: {
'Content-Type':
upstreamResponse.headers.get('content-type') ||
'application/json; charset=utf-8',
'Cache-Control':
upstreamResponse.headers.get('cache-control') || 'no-cache',
},
routeMeta: {
routeVersion: PUZZLE_ROUTE_VERSION,
},
});
const upstreamRequestId = upstreamResponse.headers.get('x-request-id');
if (upstreamRequestId) {
response.setHeader('x-upstream-request-id', upstreamRequestId);
}
const upstreamRouteVersion = upstreamResponse.headers.get(ROUTE_VERSION_HEADER);
if (upstreamRouteVersion) {
response.setHeader('x-upstream-route-version', upstreamRouteVersion);
}
const upstreamApiVersion = upstreamResponse.headers.get(API_VERSION_HEADER);
if (upstreamApiVersion) {
response.setHeader('x-upstream-api-version', upstreamApiVersion);
}
const upstreamLatency = upstreamResponse.headers.get(RESPONSE_TIME_HEADER);
if (upstreamLatency) {
response.setHeader('x-upstream-response-time-ms', upstreamLatency);
}
if (streamBody) {
if (!upstreamResponse.body) {
throw upstreamError('拼图流式响应不可用');
}
response.flushHeaders?.();
await Readable.fromWeb(upstreamResponse.body as never).pipe(response);
return;
}
response.end(await upstreamResponse.text());
}
function readParam(value: string | string[] | undefined) {
return Array.isArray(value) ? value[0]?.trim() || '' : value?.trim() || '';
}
export function createPuzzleProxyRoutes(context: AppContext) {
const router = Router();
const requireAuth = requireJwtAuth(context.config, context.userRepository);
router.use(requireAuth);
router.post(
'/agent/sessions',
routeMeta({ operation: 'runtime.puzzle.createSession', routeVersion: PUZZLE_ROUTE_VERSION }),
asyncHandler(async (request, response) => {
await proxyPuzzleRequest({
context,
request,
response,
pathSuffix: '/api/runtime/puzzle/agent/sessions',
});
}),
);
router.get(
'/agent/sessions/:sessionId',
routeMeta({ operation: 'runtime.puzzle.getSession', routeVersion: PUZZLE_ROUTE_VERSION }),
asyncHandler(async (request, response) => {
const sessionId = readParam(request.params.sessionId);
if (!sessionId) {
throw badRequest('sessionId is required');
}
await proxyPuzzleRequest({
context,
request,
response,
pathSuffix: `/api/runtime/puzzle/agent/sessions/${encodeURIComponent(sessionId)}`,
});
}),
);
router.post(
'/agent/sessions/:sessionId/messages',
routeMeta({ operation: 'runtime.puzzle.sendMessage', routeVersion: PUZZLE_ROUTE_VERSION }),
asyncHandler(async (request, response) => {
const sessionId = readParam(request.params.sessionId);
if (!sessionId) {
throw badRequest('sessionId is required');
}
await proxyPuzzleRequest({
context,
request,
response,
pathSuffix: `/api/runtime/puzzle/agent/sessions/${encodeURIComponent(sessionId)}/messages`,
});
}),
);
router.post(
'/agent/sessions/:sessionId/messages/stream',
routeMeta({
operation: 'runtime.puzzle.streamMessage',
routeVersion: PUZZLE_ROUTE_VERSION,
}),
asyncHandler(async (request, response) => {
const sessionId = readParam(request.params.sessionId);
if (!sessionId) {
throw badRequest('sessionId is required');
}
await proxyPuzzleRequest({
context,
request,
response,
pathSuffix: `/api/runtime/puzzle/agent/sessions/${encodeURIComponent(sessionId)}/messages/stream`,
streamBody: true,
});
}),
);
router.post(
'/agent/sessions/:sessionId/actions',
routeMeta({ operation: 'runtime.puzzle.executeAction', routeVersion: PUZZLE_ROUTE_VERSION }),
asyncHandler(async (request, response) => {
const sessionId = readParam(request.params.sessionId);
if (!sessionId) {
throw badRequest('sessionId is required');
}
await proxyPuzzleRequest({
context,
request,
response,
pathSuffix: `/api/runtime/puzzle/agent/sessions/${encodeURIComponent(sessionId)}/actions`,
});
}),
);
router.get(
'/works',
routeMeta({ operation: 'runtime.puzzle.listWorks', routeVersion: PUZZLE_ROUTE_VERSION }),
asyncHandler(async (request, response) => {
await proxyPuzzleRequest({
context,
request,
response,
pathSuffix: '/api/runtime/puzzle/works',
});
}),
);
router.get(
'/works/:profileId',
routeMeta({ operation: 'runtime.puzzle.getWorkDetail', routeVersion: PUZZLE_ROUTE_VERSION }),
asyncHandler(async (request, response) => {
const profileId = readParam(request.params.profileId);
if (!profileId) {
throw badRequest('profileId is required');
}
await proxyPuzzleRequest({
context,
request,
response,
pathSuffix: `/api/runtime/puzzle/works/${encodeURIComponent(profileId)}`,
});
}),
);
router.put(
'/works/:profileId',
routeMeta({ operation: 'runtime.puzzle.updateWork', routeVersion: PUZZLE_ROUTE_VERSION }),
asyncHandler(async (request, response) => {
const profileId = readParam(request.params.profileId);
if (!profileId) {
throw badRequest('profileId is required');
}
await proxyPuzzleRequest({
context,
request,
response,
pathSuffix: `/api/runtime/puzzle/works/${encodeURIComponent(profileId)}`,
});
}),
);
router.get(
'/gallery',
routeMeta({ operation: 'runtime.puzzle.listGallery', routeVersion: PUZZLE_ROUTE_VERSION }),
asyncHandler(async (request, response) => {
await proxyPuzzleRequest({
context,
request,
response,
pathSuffix: '/api/runtime/puzzle/gallery',
});
}),
);
router.get(
'/gallery/:profileId',
routeMeta({ operation: 'runtime.puzzle.getGalleryDetail', routeVersion: PUZZLE_ROUTE_VERSION }),
asyncHandler(async (request, response) => {
const profileId = readParam(request.params.profileId);
if (!profileId) {
throw badRequest('profileId is required');
}
await proxyPuzzleRequest({
context,
request,
response,
pathSuffix: `/api/runtime/puzzle/gallery/${encodeURIComponent(profileId)}`,
});
}),
);
router.post(
'/runs',
routeMeta({ operation: 'runtime.puzzle.startRun', routeVersion: PUZZLE_ROUTE_VERSION }),
asyncHandler(async (request, response) => {
await proxyPuzzleRequest({
context,
request,
response,
pathSuffix: '/api/runtime/puzzle/runs',
});
}),
);
router.get(
'/runs/:runId',
routeMeta({ operation: 'runtime.puzzle.getRun', routeVersion: PUZZLE_ROUTE_VERSION }),
asyncHandler(async (request, response) => {
const runId = readParam(request.params.runId);
if (!runId) {
throw badRequest('runId is required');
}
await proxyPuzzleRequest({
context,
request,
response,
pathSuffix: `/api/runtime/puzzle/runs/${encodeURIComponent(runId)}`,
});
}),
);
router.post(
'/runs/:runId/swap',
routeMeta({ operation: 'runtime.puzzle.swapPieces', routeVersion: PUZZLE_ROUTE_VERSION }),
asyncHandler(async (request, response) => {
const runId = readParam(request.params.runId);
if (!runId) {
throw badRequest('runId is required');
}
await proxyPuzzleRequest({
context,
request,
response,
pathSuffix: `/api/runtime/puzzle/runs/${encodeURIComponent(runId)}/swap`,
});
}),
);
router.post(
'/runs/:runId/drag',
routeMeta({ operation: 'runtime.puzzle.dragPiece', routeVersion: PUZZLE_ROUTE_VERSION }),
asyncHandler(async (request, response) => {
const runId = readParam(request.params.runId);
if (!runId) {
throw badRequest('runId is required');
}
await proxyPuzzleRequest({
context,
request,
response,
pathSuffix: `/api/runtime/puzzle/runs/${encodeURIComponent(runId)}/drag`,
});
}),
);
router.post(
'/runs/:runId/next-level',
routeMeta({ operation: 'runtime.puzzle.nextLevel', routeVersion: PUZZLE_ROUTE_VERSION }),
asyncHandler(async (request, response) => {
const runId = readParam(request.params.runId);
if (!runId) {
throw badRequest('runId is required');
}
await proxyPuzzleRequest({
context,
request,
response,
pathSuffix: `/api/runtime/puzzle/runs/${encodeURIComponent(runId)}/next-level`,
});
}),
);
return router;
}