补齐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

@@ -13,6 +13,7 @@
- [STDB_AUTH_TAIL_PHASE2_TOKEN_SLOT_SPLIT_2026-04-20.md](./STDB_AUTH_TAIL_PHASE2_TOKEN_SLOT_SPLIT_2026-04-20.md):将 STDB token 与旧 HTTP Bearer token 拆成独立存储槽。
- [RUNTIME_STORY_TO_STDB_PHASE1_TRANSPORT_ABSTRACTION_2026-04-20.md](./RUNTIME_STORY_TO_STDB_PHASE1_TRANSPORT_ABSTRACTION_2026-04-20.md):把 `runtimeStoryService` 改成可替换 transport为后续 STDB provider 接入预留稳定边界。
- [RUNTIME_STORY_TO_STDB_PHASE2_CONTRACT_DESIGN_2026-04-20.md](./RUNTIME_STORY_TO_STDB_PHASE2_CONTRACT_DESIGN_2026-04-20.md):梳理 runtime story 从 Express 迁到 STDB 所需的聚合 view、procedure、mapper 与前端 provider 设计。
- [RUNTIME_STORY_TO_STDB_PHASE2A_COMPAT_BRIDGE_2026-04-20.md](./RUNTIME_STORY_TO_STDB_PHASE2A_COMPAT_BRIDGE_2026-04-20.md):确认 runtime story 当前 STDB/Express 快照真源分裂,并补一层只改 story 边界的 STDB 兼容桥。
- [TASK_AUTO_COMMIT_WORKFLOW_2026-04-20.md](./TASK_AUTO_COMMIT_WORKFLOW_2026-04-20.md):任务完成后按文件边界自动提交的脚本与协作约定。
- [NODE_DEV_STARTUP_HOTFIX_2026-04-20.md](./NODE_DEV_STARTUP_HOTFIX_2026-04-20.md)`npm run dev` 启动失败的热修记录、根因与验证结果。
- [NODE_SERVER_KNOWLEDGE_GRAPH_2026-04-08.md](./NODE_SERVER_KNOWLEDGE_GRAPH_2026-04-08.md):当前 Node 运行时后端的技术栈、入口、鉴权、存储与接口知识图谱。

View File

@@ -0,0 +1,136 @@
# Runtime Story 向 STDB 迁移 Phase 2A兼容桥2026-04-20
## 1. 本轮目标
这轮不是直接把 `runtime story` 全量改成 SpacetimeDB provider而是先补一层最小兼容桥解决当前仓库里已经实际出现的两类断裂
1. `runtime story` 前置快照写入已经走 STDB
2. `/api/runtime/story/*` 仍然从 `server-node` 的旧 `RuntimeRepository` 读取 Postgres 快照
3. 前端认证主链路已经切到 STDB旧 HTTP Bearer token 不再保证可持续刷新
因此本轮目标是:
1. 保持 `runtimeStoryCoordinator` 和上层页面契约不变
2. 保持 `server-node/src/modules/story/storyActionService.ts` 现有规则实现不重写
3. 只在 `runtime story` 边界补齐:
- 前端请求可携带 STDB token
- `server-node` 的 story route 可直接使用 STDB token 解析身份
- `server-node` 的 story route 可直接从 STDB 读写 snapshot
## 2. 当前问题确认
### 2.1 快照真源已经分裂
当前前端 [`src/hooks/story/runtimeStoryCoordinator.ts`](/home/Genarrative/src/hooks/story/runtimeStoryCoordinator.ts) 在读取或执行 runtime story 之前,会先调用:
1. [`putSaveSnapshot`](/home/Genarrative/src/services/storageService.ts)
2. 该调用实际走 STDB `saveSnapshot` procedure
但旧的 `/api/runtime/story/*` 仍然走:
1. [`storyActionRoutes.ts`](/home/Genarrative/server-node/src/modules/story/storyActionRoutes.ts)
2. [`storyActionService.ts`](/home/Genarrative/server-node/src/modules/story/storyActionService.ts)
3. [`RuntimeRepository.getSnapshot()`](/home/Genarrative/server-node/src/repositories/runtimeRepository.ts)
也就是:
1. 写快照在 STDB
2. 读快照在 Postgres
这已经不是“未来可能出现”的风险,而是当前迁移阶段的真实断链。
### 2.2 旧 HTTP token 不再是可靠前提
当前前端认证主链路已经切到:
1. [`src/services/authService.ts`](/home/Genarrative/src/services/authService.ts)
2. [`src/spacetime/client.ts`](/home/Genarrative/src/spacetime/client.ts)
也就是说:
1. 页面登录、恢复会话、匿名建连主要依赖 STDB token
2. 旧 HTTP access token 仅剩兼容用途
3. `/api/auth/refresh` 也不再保证始终存在有效 refresh cookie
因此继续假设 `/api/runtime/story/*` 一定能依赖旧 JWT Bearer是不稳的。
## 3. 本轮兼容桥方案
### 3.1 前端
[`runtimeStoryService.ts`](/home/Genarrative/src/services/runtimeStoryService.ts) 继续保留 HTTP transport但在请求 `/api/runtime/story/*` 时:
1. 优先携带现有 HTTP access token
2. 若不存在 HTTP access token则回退携带 STDB token
3. 新增专用 header明确这是一条 runtime story 的 STDB 兼容认证链路
这样上层调用不变,但 `server-node` 侧已经能识别 STDB token。
### 3.2 后端鉴权
`server-node``/api/runtime/story/*` 单独补一层兼容认证:
1. 先尝试旧 JWT Bearer
2. 若 Bearer 不是 JWT 或 JWT 校验失败,则尝试按 STDB token 建连解析身份
3. 解析成功后,把 `request.userId` 设为 STDB account id
注意:
1. 这不是把所有 `server-node` 路由都改成 STDB 认证
2. 只对 runtime story 这条仍未迁完的兼容链路生效
### 3.3 快照读写
`storyActionService` 现有规则仍保持不变,但其读取与写回的 `runtimeRepository` 改为支持 STDB snapshot 的兼容仓储。
优先级:
1. runtime story 若通过 STDB token 认证进入
2. 则快照直接从 STDB `my_snapshot` / `save_snapshot` / `delete_snapshot` 读取和写回
3. 旧 JWT 路径继续维持原来的 Postgres 仓储行为
这样可以最小化改动:
1. 不重写 story rule
2. 不要求 runtime story 前端立刻切成 STDB provider
3. 先让“当前真实主快照”恢复一致
## 4. 为什么不选其他方案
### 4.1 不恢复前端双写到 Postgres
不选原因:
1. 会把已经迁到 STDB 的快照主链路重新拉回双写状态
2. 会继续制造写时漂移和清理成本
3. 与 express -> stdb 迁移方向相反
### 4.2 不把所有 `/api/runtime/story/*` 立刻删掉
不选原因:
1. 现有 `storyActionService.ts` 承接了大量 battle / npc / inventory / quest 规则
2. 一次性重写成 STDB procedure 风险过高
3. 当前更紧急的是先修复“已断开的运行时快照来源”
## 5. 本轮边界
本轮只做:
1. runtime story 的 STDB token 兼容认证
2. runtime story 的 STDB snapshot 兼容读写
3. 保持现有前端 coordinator / response contract 不变
本轮不做:
1. 新的 STDB runtime story table / procedure / view 正式 contract
2. `runtimeStoryService` 切为真正的 STDB transport
3. 删除 `/api/runtime/story/*`
## 6. 下一步
Phase 2A 稳定后,再继续:
1.`get state``resolve action` 的正式 contract 收到 STDB
2. 在前端接入真正的 STDB transport
3. 删除 `/api/runtime/story/*` 这层兼容桥

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;

View File

@@ -10,10 +10,16 @@ vi.mock('./apiClient', async () => {
return {
...actual,
requestJson: requestJsonMock,
getStoredAccessToken: vi.fn(() => ''),
getStoredSpacetimeToken: vi.fn(() => ''),
};
});
import { AnimationState } from '../types';
import {
getStoredAccessToken,
getStoredSpacetimeToken,
} from './apiClient';
import {
buildStoryMomentFromRuntimeOptions,
getRuntimeClientVersion,
@@ -32,6 +38,8 @@ describe('runtimeStoryService', () => {
beforeEach(() => {
requestJsonMock.mockReset();
resetRuntimeStoryTransport();
vi.mocked(getStoredAccessToken).mockReturnValue('');
vi.mocked(getStoredSpacetimeToken).mockReturnValue('');
});
function createMockRuntimeResponse(overrides: Record<string, unknown> = {}) {
@@ -110,7 +118,33 @@ describe('runtimeStoryService', () => {
}),
}),
'执行运行时动作失败',
expect.any(Object),
expect.objectContaining({
skipAuth: true,
skipRefresh: true,
}),
);
});
it('prefers spacetime token auth headers for runtime story compatibility bridge', async () => {
vi.mocked(getStoredAccessToken).mockReturnValue('legacy-http-token');
vi.mocked(getStoredSpacetimeToken).mockReturnValue('stdb-token');
requestJsonMock.mockResolvedValue(createMockRuntimeResponse());
await getRuntimeStoryState('runtime-main');
expect(requestJsonMock).toHaveBeenCalledWith(
'/api/runtime/story/state/runtime-main',
expect.objectContaining({
headers: expect.objectContaining({
Authorization: 'Bearer stdb-token',
'x-genarrative-runtime-story-auth': 'spacetime-token',
}),
}),
'读取运行时故事状态失败',
expect.objectContaining({
skipAuth: true,
skipRefresh: true,
}),
);
});

View File

@@ -16,7 +16,12 @@ import type {
} from '../persistence/runtimeSnapshotTypes';
import type { GameState, StoryMoment, StoryOption } from '../types';
import { AnimationState } from '../types';
import { type ApiRetryOptions, requestJson } from './apiClient';
import {
getStoredAccessToken,
getStoredSpacetimeToken,
type ApiRetryOptions,
requestJson,
} from './apiClient';
const RUNTIME_STORY_API_BASE = '/api/runtime/story';
const DEFAULT_SESSION_ID = 'runtime-main';
@@ -33,6 +38,7 @@ const TASK5_RUNTIME_FUNCTION_ID_SET = new Set<string>(
const SERVER_RUNTIME_FUNCTION_ID_SET = new Set<string>([
...SERVER_RUNTIME_FUNCTION_IDS,
]);
const RUNTIME_STORY_STDB_AUTH_HEADER = 'x-genarrative-runtime-story-auth';
export type RuntimeStoryServiceOptions = {
signal?: AbortSignal;
@@ -64,6 +70,29 @@ export type RuntimeStoryTransport = {
) => Promise<RuntimeStoryResponse>;
};
function withRuntimeStoryAuthHeaders(headers?: HeadersInit) {
const nextHeaders =
headers instanceof Headers
? Object.fromEntries(headers.entries())
: Array.isArray(headers)
? Object.fromEntries(headers)
: { ...(headers ?? {}) };
const httpAccessToken = getStoredAccessToken();
const spacetimeToken = getStoredSpacetimeToken();
const bearerToken = spacetimeToken || httpAccessToken;
if (bearerToken) {
nextHeaders.Authorization = `Bearer ${bearerToken}`;
}
if (spacetimeToken) {
nextHeaders[RUNTIME_STORY_STDB_AUTH_HEADER] = 'spacetime-token';
} else if (httpAccessToken) {
nextHeaders[RUNTIME_STORY_STDB_AUTH_HEADER] = 'http-access-token';
}
return nextHeaders;
}
function requestRuntimeStoryJson<T>(
path: string,
init: RequestInit,
@@ -75,9 +104,14 @@ function requestRuntimeStoryJson<T>(
{
...init,
signal: options.signal,
headers: withRuntimeStoryAuthHeaders(init.headers),
},
fallbackMessage,
{ retry: options.retry ?? RUNTIME_STORY_RETRY },
{
retry: options.retry ?? RUNTIME_STORY_RETRY,
skipAuth: true,
skipRefresh: true,
},
);
}