补齐runtime story到STDB的兼容桥

This commit is contained in:
2026-04-20 11:22:37 +00:00
parent 00edcfe121
commit 9d27284a64
14 changed files with 478 additions and 9 deletions

View File

@@ -128,6 +128,10 @@ function createTestConfig(
refreshCookieSameSite: 'Lax',
refreshCookiePath: '/api/auth',
},
spacetime: {
uri: 'ws://127.0.0.1:3000',
databaseName: 'genarrative-test',
},
};
return {

View File

@@ -80,6 +80,10 @@ export type AppConfig = {
refreshCookieSameSite: 'Lax' | 'Strict' | 'None';
refreshCookiePath: string;
};
spacetime: {
uri: string;
databaseName: string;
};
};
type LoadConfigOptions = {
@@ -509,5 +513,17 @@ export function loadConfig(options: LoadConfigOptions = {}): AppConfig {
'/api/auth',
),
},
spacetime: {
uri: readString(
env,
'SPACETIME_URI',
readString(env, 'VITE_SPACETIME_URI', 'wss://maincloud.spacetimedb.com'),
),
databaseName: readString(
env,
'SPACETIME_DATABASE_NAME',
readString(env, 'VITE_SPACETIME_DATABASE_NAME', 'xushi-p4wfr'),
),
},
};
}

View File

@@ -87,6 +87,10 @@ function createTestConfig(databaseUrl: string): AppConfig {
refreshCookieSameSite: 'Lax',
refreshCookiePath: '/api/auth',
},
spacetime: {
uri: 'ws://127.0.0.1:3000',
databaseName: 'genarrative-test',
},
};
}

View File

@@ -97,6 +97,10 @@ function createTestConfig(testName: string): AppConfig {
refreshCookieSameSite: 'Lax',
refreshCookiePath: '/api/auth',
},
spacetime: {
uri: 'ws://127.0.0.1:3000',
databaseName: 'genarrative-test',
},
};
}

View File

@@ -11,6 +11,11 @@ import {
getRuntimeStoryState,
resolveRuntimeStoryAction,
} from './storyActionService.js';
import {
authenticateRuntimeStoryViaSpacetime,
createRuntimeStorySnapshotRepository,
shouldUseSpacetimeStoryAuth,
} from './storySpacetimeBridge.js';
const actionPayloadSchema = z.record(z.string(), z.unknown());
@@ -29,7 +34,17 @@ export function createStoryActionRoutes(context: AppContext) {
const router = Router();
const requireAuth = requireJwtAuth(context.config, context.userRepository);
router.use(requireAuth);
router.use(
asyncHandler(async (request, _response, next) => {
if (shouldUseSpacetimeStoryAuth(request)) {
await authenticateRuntimeStoryViaSpacetime(request, context);
next();
return;
}
request.runtimeStoryAuthMode = 'jwt';
await requireAuth(request, _response, next);
}),
);
router.post(
'/actions/resolve',
@@ -41,7 +56,11 @@ export function createStoryActionRoutes(context: AppContext) {
sendApiResponse(
response,
await resolveRuntimeStoryAction({
runtimeRepository: context.runtimeRepository,
runtimeRepository: createRuntimeStorySnapshotRepository({
request,
runtimeRepository: context.runtimeRepository,
config: context.config,
}),
llmClient: context.llmClient,
userId: request.userId!,
request: payload,
@@ -62,7 +81,11 @@ export function createStoryActionRoutes(context: AppContext) {
sendApiResponse(
response,
await getRuntimeStoryState({
runtimeRepository: context.runtimeRepository,
runtimeRepository: createRuntimeStorySnapshotRepository({
request,
runtimeRepository: context.runtimeRepository,
config: context.config,
}),
userId: request.userId!,
sessionId,
}),

View File

@@ -6,7 +6,10 @@ import type {
RuntimeStoryPatch,
} from '../../../../packages/shared/src/contracts/story.js';
import { conflict, invalidRequest } from '../../errors.js';
import type { RuntimeRepositoryPort } from '../../repositories/runtimeRepository.js';
import type {
RuntimeRepositoryPort,
SavedSnapshot,
} from '../../repositories/runtimeRepository.js';
import type { UpstreamLlmClient } from '../../services/llmClient.js';
import {
buildStrictNpcChatDialoguePrompt,
@@ -77,6 +80,11 @@ type GeneratedStoryPayload = {
savedCurrentStory: JsonRecord;
};
type RuntimeSnapshotRepositoryPort = Pick<
RuntimeRepositoryPort,
'getSnapshot' | 'putSnapshot'
>;
const CONTINUE_ADVENTURE_OPTION = {
functionId: 'story_continue_adventure',
actionText: '继续冒险',
@@ -855,7 +863,7 @@ function resolveStoryFlowAction(
}
export async function resolveRuntimeStoryAction(params: {
runtimeRepository: RuntimeRepositoryPort;
runtimeRepository: RuntimeSnapshotRepositoryPort;
llmClient?: UpstreamLlmClient;
userId: string;
request: RuntimeStoryActionRequest;
@@ -1058,7 +1066,7 @@ export async function resolveRuntimeStoryAction(params: {
}
export async function getRuntimeStoryState(params: {
runtimeRepository: RuntimeRepositoryPort;
runtimeRepository: Pick<RuntimeSnapshotRepositoryPort, 'getSnapshot'>;
userId: string;
sessionId: string;
}) {

View File

@@ -0,0 +1,196 @@
import type { Request } from 'express';
import { DbConnection } from '../../../../src/spacetime/generated/index.ts';
import type {
RequestMeta,
SnapshotView,
} from '../../../../src/spacetime/generated/types.ts';
import { conflict, unauthorized } from '../../errors.js';
import type {
RuntimeRepositoryPort,
SavedSnapshot,
} from '../../repositories/runtimeRepository.js';
import type { AppConfig } from '../../config.js';
import { hydrateSavedSnapshot } from '../runtime/runtimeSnapshotHydration.js';
const STORY_STDB_AUTH_HEADER = 'x-genarrative-runtime-story-auth';
type RuntimeSnapshotRepositoryPort = Pick<
RuntimeRepositoryPort,
'getSnapshot' | 'putSnapshot'
>;
type StoryStdbBridgeContext = {
config: AppConfig;
};
function readBearerToken(request: Request) {
const authorization = request.header('authorization')?.trim() || '';
if (!authorization.startsWith('Bearer ')) {
return '';
}
return authorization.slice('Bearer '.length).trim();
}
function buildRequestMeta(request: Request): RequestMeta {
const userAgent = request.header('user-agent')?.trim() || undefined;
const forwardedFor = request.header('x-forwarded-for')?.split(',')[0]?.trim();
const ip = forwardedFor || request.ip || undefined;
return {
clientType: 'server-node-runtime-story-bridge',
userAgent,
ip,
};
}
function toSavedSnapshot(row: SnapshotView): SavedSnapshot {
return hydrateSavedSnapshot({
version: Number(row.version),
savedAt: new Date(Number(row.savedAtMs)).toISOString(),
gameState: JSON.parse(row.gameStateJson) as Record<string, unknown>,
bottomTab: row.bottomTab,
currentStory: row.currentStoryJson
? (JSON.parse(row.currentStoryJson) as Record<string, unknown> | null)
: null,
})!;
}
async function connectWithToken(config: AppConfig, token: string) {
return new Promise<DbConnection>((resolve, reject) => {
let settled = false;
const finish = (callback: () => void) => {
if (settled) {
return;
}
settled = true;
callback();
};
DbConnection.builder()
.withUri(config.spacetime.uri)
.withDatabaseName(config.spacetime.databaseName)
.withLightMode(true)
.withToken(token)
.onConnect((connection) => {
connection
.subscriptionBuilder()
.onApplied(() => {
finish(() => {
resolve(connection);
});
})
.onError((_ctx, error) => {
finish(() => {
connection.disconnect();
reject(error);
});
})
.subscribeToAllTables();
})
.onConnectError((_ctx, error) => {
finish(() => reject(error));
})
.onDisconnect((_ctx, error) => {
if (!settled) {
finish(() => reject(error ?? new Error('Spacetime 连接已断开')));
}
})
.build();
});
}
async function withBridgeConnection<T>(
config: AppConfig,
token: string,
run: (connection: DbConnection) => Promise<T>,
) {
const connection = await connectWithToken(config, token);
try {
return await run(connection);
} finally {
connection.disconnect();
}
}
export function shouldUseSpacetimeStoryAuth(request: Request) {
return (
request.header(STORY_STDB_AUTH_HEADER)?.trim() === 'spacetime-token'
);
}
export async function authenticateRuntimeStoryViaSpacetime(
request: Request,
context: StoryStdbBridgeContext,
) {
const token = readBearerToken(request);
if (!token) {
throw unauthorized('缺少 runtime story 兼容认证 token');
}
const accountId = await withBridgeConnection(
context.config,
token,
async (connection) => {
const row = Array.from(connection.db.my_auth_state.iter())[0] ?? null;
const nextAccountId = row?.accountId?.trim() || '';
if (!nextAccountId) {
throw unauthorized('runtime story STDB 账号态不存在');
}
return nextAccountId;
},
);
request.userId = accountId;
request.runtimeStoryAuthMode = 'spacetime';
}
export function createRuntimeStorySnapshotRepository(params: {
request: Request;
runtimeRepository: RuntimeRepositoryPort;
config: AppConfig;
}): RuntimeSnapshotRepositoryPort {
if (params.request.runtimeStoryAuthMode !== 'spacetime') {
return params.runtimeRepository;
}
const token = readBearerToken(params.request);
if (!token) {
throw unauthorized('缺少 runtime story STDB token');
}
const requestMeta = buildRequestMeta(params.request);
return {
async getSnapshot() {
return withBridgeConnection(params.config, token, async (connection) => {
const row = Array.from(connection.db.my_snapshot.iter())[0] ?? null;
return row ? toSavedSnapshot(row) : null;
});
},
async putSnapshot(_userId: string, payload) {
return withBridgeConnection(params.config, token, async (connection) => {
const result = await connection.procedures.saveSnapshot({
meta: requestMeta,
savedAtMs: BigInt(Date.parse(payload.savedAt) || Date.now()),
gameStateJson: JSON.stringify(payload.gameState),
bottomTab: payload.bottomTab,
currentStoryJson:
payload.currentStory === null || payload.currentStory === undefined
? undefined
: JSON.stringify(payload.currentStory),
});
if (!result.ok) {
throw conflict(result.message || 'runtime story STDB 快照写入失败');
}
const row = Array.from(connection.db.my_snapshot.iter())[0] ?? null;
if (!row) {
throw conflict('runtime story STDB 快照写入后未返回最新快照');
}
return toSavedSnapshot(row);
});
},
};
}

View File

@@ -99,6 +99,10 @@ function createTestConfig(testName: string): AppConfig {
refreshCookieSameSite: 'Lax',
refreshCookiePath: '/api/auth',
},
spacetime: {
uri: 'ws://127.0.0.1:3000',
databaseName: 'genarrative-test',
},
};
}

View File

@@ -39,6 +39,10 @@ function createAliyunSmsConfig(): AppConfig {
blockPhoneDurationMinutes: 30,
blockIpDurationMinutes: 30,
},
spacetime: {
uri: 'ws://127.0.0.1:3000',
databaseName: 'genarrative-test',
},
} as AppConfig;
}

View File

@@ -4,6 +4,7 @@ declare global {
requestId: string;
requestStartedAt: number;
userId?: string;
runtimeStoryAuthMode?: 'jwt' | 'spacetime';
auth?: {
userId: string;
tokenVersion: number;