diff --git a/.env.example b/.env.example index 0a336db4..f95a6784 100644 --- a/.env.example +++ b/.env.example @@ -19,7 +19,9 @@ NODE_SERVER_TARGET="http://127.0.0.1:8081" # 前端直连 SpacetimeDB 所需配置。 # 浏览器端当前直接订阅认证/存档/资料库相关 view,并调用对应 procedure。 -VITE_SPACETIME_URI="ws://127.0.0.1:3000" +# 默认指向 maincloud 上的 `xushi-p4wfr`。 +# 若要切到本地 `spacetime start`,请在 `.env.local` 中显式改成 `ws://127.0.0.1:3000`。 +VITE_SPACETIME_URI="wss://maincloud.spacetimedb.com" VITE_SPACETIME_DATABASE_NAME="xushi-p4wfr" # Local Caddy upstream target used for dist-based testing. diff --git a/docs/technical/NODE_DEV_STARTUP_HOTFIX_2026-04-20.md b/docs/technical/NODE_DEV_STARTUP_HOTFIX_2026-04-20.md new file mode 100644 index 00000000..7d233714 --- /dev/null +++ b/docs/technical/NODE_DEV_STARTUP_HOTFIX_2026-04-20.md @@ -0,0 +1,40 @@ +# Node Dev 启动热修记录 2026-04-20 + +## 背景 + +2026-04-20 执行 `npm run dev` 时,Node 后端在加载 `server-node/src/modules/story/storyActionService.ts` 过程中报错: + +- `TransformError` +- `Unterminated string literal` +- 触发行位于 `storyActionService.ts:17` + +该问题会导致前端 Vite 服务启动成功,但 `server-node` 无法完成编译和监听,开发态故事运行链路不可用。 + +## 根因 + +`storyActionService.ts` 中导入库存剧情动作服务的语句被意外拆断,形成了非法字符串字面量: + +- 路径在 `../inventory/inventory` 后被换行截断 +- 真实目标文件名 `inventoryStoryActionService.js` 没有完整写入 + +这属于单点语法错误,不涉及运行时逻辑回归。 + +## 修复 + +本次只做最小代码补丁: + +- 将导入恢复为 `../inventory/inventoryStoryActionService.js` +- 保持原文件其余中文文案、剧情文本与结构不动 + +## 验证 + +修复后已完成以下验证: + +- `npm run dev`:`server-node` 于 2026-04-20 03:51:24 UTC 成功启动,日志显示监听 `:8081` +- `npm run check:encoding`:通过,`1641` 个文件编码检查正常 +- `npm run server-node:build`:通过 + +## 后续约束 + +- 继续对包含中文文本的运行时故事文件采用局部补丁,避免整文件重写 +- 再次调整 `storyActionService.ts` 或相邻剧情服务时,优先补跑编码检查与后端构建 diff --git a/docs/technical/README.md b/docs/technical/README.md index 09b8038b..ac1fed43 100644 --- a/docs/technical/README.md +++ b/docs/technical/README.md @@ -4,6 +4,9 @@ ## 文档列表 +- [SPACETIME_DEV_URI_HOTFIX_2026-04-20.md](./SPACETIME_DEV_URI_HOTFIX_2026-04-20.md):修复开发默认配置把 Spacetime 连接误指向 Vite `3000` 端口的问题。 +- [SPACETIME_AUTH_TOKEN_FALLBACK_HOTFIX_2026-04-20.md](./SPACETIME_AUTH_TOKEN_FALLBACK_HOTFIX_2026-04-20.md):本地 token 失效时自动降级匿名连接,并提示“登录已过期”的热修记录。 +- [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 运行时后端的技术栈、入口、鉴权、存储与接口知识图谱。 - [EXPRESS_BACKEND_INTEGRATION_FREEZE_2026-04-09.md](./EXPRESS_BACKEND_INTEGRATION_FREEZE_2026-04-09.md):Express 后端当前 contract 冻结版本、热点文件编辑规则与集成窗口清单。 - [EXPRESS_BACKEND_WORKSTREAM_AUDIT_2026-04-09.md](./EXPRESS_BACKEND_WORKSTREAM_AUDIT_2026-04-09.md):按并行工作流文档逐项核对后的完成度审计与剩余收口点。 diff --git a/docs/technical/SPACETIME_AUTH_TOKEN_FALLBACK_HOTFIX_2026-04-20.md b/docs/technical/SPACETIME_AUTH_TOKEN_FALLBACK_HOTFIX_2026-04-20.md new file mode 100644 index 00000000..053cfdc2 --- /dev/null +++ b/docs/technical/SPACETIME_AUTH_TOKEN_FALLBACK_HOTFIX_2026-04-20.md @@ -0,0 +1,67 @@ +# Spacetime 账号连接过期回退热修 2026-04-20 + +## 背景 + +当前网页启动时会先尝试复用本地保存的 Spacetime token 建立账号连接。 + +问题表现: + +- 页面长时间停留在“正在建立账号连接...” +- 本地 token 已失效时,前端没有稳定降级到匿名账号 +- 用户看不到明确的“登录已过期”提示,只会感觉页面卡住 + +## 目标 + +本次热修只处理登录恢复链路,不扩散到其他运行时模块: + +- 本地 token 登录失败或超时后,自动清理失效 token +- 立即改走匿名连接,恢复页面可用性 +- 在进入匿名账号后给出一次明确提示:登录已过期,已切换为匿名账号 + +## 落地方案 + +### 1. 账号恢复层增加 token 失效回退 + +文件: + +- `src/services/authService.ts` + +调整点: + +- `getCurrentAuthUser()` 底层恢复流程增加 `recoveryNotice` +- 当浏览器本地存在 token 时,首次建连增加短超时保护 +- 若首次建连失败或超时: + - 断开当前 Spacetime 连接 + - 清空本地 access token + - 重新以匿名身份建连 +- 匿名建连成功后,返回 `login_expired` 恢复结果给 UI + +### 2. 认证门面增加非阻塞提示 + +文件: + +- `src/components/auth/AuthGate.tsx` + +调整点: + +- `AuthGate` 成功恢复到匿名账号后,展示顶部轻量提示条 +- 文案为“登录已过期,已切换为匿名账号。” +- 提示条会自动收起,也允许用户手动关闭 +- 不阻断页面继续进入主内容 + +## 验证 + +已完成: + +- `npx vitest run src/services/authService.test.ts src/components/auth/AuthGate.test.tsx` +- `npm run check:encoding` + +新增覆盖: + +- 本地 token 连接卡住时,会自动回退到匿名账号 +- 匿名回退成功后,页面会显示“登录已过期”提示 + +## 备注 + +- `npm run typecheck` 当前仓库仍存在其他模块的存量报错,本次热修没有继续扩散处理 +- 本次已消除本热修涉及文件新增的类型问题,剩余报错集中在 `storageService`、`CustomWorld*` 与 `runtimeStoryService.test.ts` diff --git a/docs/technical/SPACETIME_DEV_URI_HOTFIX_2026-04-20.md b/docs/technical/SPACETIME_DEV_URI_HOTFIX_2026-04-20.md new file mode 100644 index 00000000..7c1a274f --- /dev/null +++ b/docs/technical/SPACETIME_DEV_URI_HOTFIX_2026-04-20.md @@ -0,0 +1,63 @@ +# Spacetime 开发连接地址热修 2026-04-20 + +## 背景 + +当前前端账号、存档和资料库链路已经切到 SpacetimeDB。 + +但本地开发默认配置存在一处关键错配: + +- 默认数据库名是 maincloud 上的 `xushi-p4wfr` +- 默认 `VITE_SPACETIME_URI` 却写成了 `ws://127.0.0.1:3000` +- 同时 `npm run dev` 的 Vite 开发服也默认占用 `127.0.0.1:3000` + +结果是页面启动后,浏览器会把账号 WebSocket 误连到 Vite,而不是实际的 SpacetimeDB 服务。 + +这会直接表现为: + +- 页面长时间停留在“正在建立账号连接...” +- 或者账号连接相关逻辑异常,但 `npm run dev` 终端表面上看起来前后端都已经启动 + +## 根因 + +本次问题不是 `server-node` 编译失败,而是前端默认环境变量错误: + +- `src/spacetime/client.ts` 内置默认 URI 错误 +- `.env.example` 示例值错误 +- `scripts/dev-node.mjs` 没有把正确的 Spacetime 默认值显式注入启动环境 + +## 修复 + +本次做了三处最小修复: + +1. `src/spacetime/client.ts` + +- 默认 `VITE_SPACETIME_URI` 改为 `wss://maincloud.spacetimedb.com` +- 默认数据库名继续保持 `xushi-p4wfr` + +2. `scripts/dev-node.mjs` + +- 当本地未配置时,自动注入: + - `VITE_SPACETIME_URI=wss://maincloud.spacetimedb.com` + - `VITE_SPACETIME_DATABASE_NAME=xushi-p4wfr` +- 启动日志增加当前使用的 Spacetime URI 和数据库名,便于排查 + +3. `.env.example` + +- 示例值改为 maincloud URI +- 补充说明:若要连本地 `spacetime start`,需在 `.env.local` 中显式覆盖为 `ws://127.0.0.1:3000` + +## 验证 + +已完成: + +- `npm run dev` + - Vite 正常启动到 `http://127.0.0.1:3000` + - `server-node` 正常启动到 `:8081` + - 启动日志会明确打印当前 `VITE_SPACETIME_URI` 与 `VITE_SPACETIME_DATABASE_NAME` +- `npm run check:encoding` + +## 结论 + +默认开发链路现在会优先连到和默认数据库名一致的 maincloud Spacetime 服务,不再把账号连接误打到本地 Vite 端口。 + +如果后续要切回本地 SpacetimeDB,只需要在 `.env.local` 中显式覆盖 URI,不需要再改代码。 diff --git a/scripts/dev-node.mjs b/scripts/dev-node.mjs index 36aff1d9..52dcedf0 100644 --- a/scripts/dev-node.mjs +++ b/scripts/dev-node.mjs @@ -1,7 +1,7 @@ -import net from 'node:net'; -import path from 'node:path'; import {spawn} from 'node:child_process'; import {existsSync, readFileSync} from 'node:fs'; +import net from 'node:net'; +import path from 'node:path'; import {fileURLToPath, pathToFileURL} from 'node:url'; const repoRoot = fileURLToPath(new URL('../', import.meta.url)); @@ -25,6 +25,8 @@ const bundledNpmCliPath = fileURLToPath( const npmCommand = process.platform === 'win32' ? 'npm.cmd' : 'npm'; const DEFAULT_DEV_DATABASE_URL = 'postgresql://postgres:postgres@127.0.0.1:5432/genarrative'; const DEV_MEMORY_DATABASE_URL = 'pg-mem://genarrative-dev'; +const DEFAULT_SPACETIME_URI = 'wss://maincloud.spacetimedb.com'; +const DEFAULT_SPACETIME_DATABASE_NAME = 'xushi-p4wfr'; function parseEnvContents(contents) { return contents @@ -198,6 +200,10 @@ mergedEnv.NODE_SERVER_TARGET = mergedEnv.NODE_SERVER_TARGET || resolveServerTarget(mergedEnv.NODE_SERVER_ADDR); mergedEnv.DATABASE_URL = mergedEnv.DATABASE_URL || DEFAULT_DEV_DATABASE_URL; +mergedEnv.VITE_SPACETIME_URI = + mergedEnv.VITE_SPACETIME_URI || DEFAULT_SPACETIME_URI; +mergedEnv.VITE_SPACETIME_DATABASE_NAME = + mergedEnv.VITE_SPACETIME_DATABASE_NAME || DEFAULT_SPACETIME_DATABASE_NAME; mergedEnv.VITE_DEV_HOST = mergedEnv.VITE_DEV_HOST || '127.0.0.1'; prependEnvPath(mergedEnv, runtimeNodeDir); mergedEnv.npm_config_scripts_prepend_node_path = 'true'; @@ -229,6 +235,10 @@ console.log(`[dev:node] PROJECT_ROOT=${mergedEnv.PROJECT_ROOT}`); console.log(`[dev:node] NODE_SERVER_ADDR=${mergedEnv.NODE_SERVER_ADDR}`); console.log(`[dev:node] NODE_SERVER_TARGET=${mergedEnv.NODE_SERVER_TARGET}`); console.log(`[dev:node] DATABASE_URL=${redactDatabaseUrl(mergedEnv.DATABASE_URL)}`); +console.log(`[dev:node] VITE_SPACETIME_URI=${mergedEnv.VITE_SPACETIME_URI}`); +console.log( + `[dev:node] VITE_SPACETIME_DATABASE_NAME=${mergedEnv.VITE_SPACETIME_DATABASE_NAME}`, +); console.log(`[dev:node] VITE_DEV_HOST=${mergedEnv.VITE_DEV_HOST}`); console.log(`[dev:node] NODE_RUNTIME=${runtimeNodePath}`); diff --git a/server-node/src/modules/story/storyActionService.ts b/server-node/src/modules/story/storyActionService.ts index a8f7c488..55fc7032 100644 --- a/server-node/src/modules/story/storyActionService.ts +++ b/server-node/src/modules/story/storyActionService.ts @@ -14,8 +14,10 @@ import { } from '../ai/chatPromptBuilders.js'; import { generateNextStoryFromOrchestrator } from '../ai/storyOrchestrator.js'; import { resolveCombatAction } from '../combat/combatResolutionService.js'; -import { isSupportedInventoryStoryFunctionId,resolveInventoryStoryAction } from '../inventory/inventory -.js'; +import { + isSupportedInventoryStoryFunctionId, + resolveInventoryStoryAction, +} from '../inventory/inventoryStoryActionService.js'; import { ensureNpcInventorySessionState, isSupportedNpcInventoryStoryFunctionId, diff --git a/src/components/auth/AuthGate.test.tsx b/src/components/auth/AuthGate.test.tsx index 8c212af6..710c147e 100644 --- a/src/components/auth/AuthGate.test.tsx +++ b/src/components/auth/AuthGate.test.tsx @@ -72,6 +72,7 @@ test('auth gate renders app content after spacetime auth session is ready', asyn authMocks.getCurrentAuthUser.mockResolvedValue({ user: activeUser, availableLoginMethods: ['phone'], + recoveryNotice: null, }); render( @@ -90,6 +91,7 @@ test('auth gate opens phone verification modal for pending sms verification user bindingStatus: 'pending_bind_phone', }, availableLoginMethods: ['phone'], + recoveryNotice: null, }); render( @@ -100,3 +102,23 @@ test('auth gate opens phone verification modal for pending sms verification user expect(await screen.findByText('完成短信验证')).toBeTruthy(); }); + +test('auth gate shows login expired notice after anonymous fallback recovery', async () => { + authMocks.getCurrentAuthUser.mockResolvedValue({ + user: activeUser, + availableLoginMethods: ['phone'], + recoveryNotice: { + code: 'login_expired', + message: '登录已过期,已切换为匿名账号。', + }, + }); + + render( + +
应用内容
+
, + ); + + expect(await screen.findByText('应用内容')).toBeTruthy(); + expect(await screen.findByText('登录已过期,已切换为匿名账号。')).toBeTruthy(); +}); diff --git a/src/components/auth/AuthGate.tsx b/src/components/auth/AuthGate.tsx index f7efcdb7..f1251b05 100644 --- a/src/components/auth/AuthGate.tsx +++ b/src/components/auth/AuthGate.tsx @@ -1,13 +1,6 @@ import { type ReactNode, useEffect, useMemo, useState } from 'react'; -import { - SPACETIME_KICK_EVENT, - SPACETIME_SESSION_REVOKED_EVENT, - SPACETIME_VERIFICATION_REQUIRED_EVENT, - type KickEventDetail, - type SessionRevokedDetail, - type VerificationRequiredDetail, -} from '../../spacetime/client'; +import { AUTH_STATE_EVENT } from '../../services/apiClient'; import { type AuthAuditLogEntry, type AuthCaptchaChallenge, @@ -26,7 +19,14 @@ import { revokeAuthSession, sendPhoneLoginCode, } from '../../services/authService'; -import { AUTH_STATE_EVENT } from '../../services/apiClient'; +import { + type KickEventDetail, + type SessionRevokedDetail, + SPACETIME_KICK_EVENT, + SPACETIME_SESSION_REVOKED_EVENT, + SPACETIME_VERIFICATION_REQUIRED_EVENT, + type VerificationRequiredDetail, +} from '../../spacetime/client'; import { AccountModal } from './AccountModal'; import { AuthUiContext } from './AuthUiContext'; import { PhoneVerificationModal } from './PhoneVerificationModal'; @@ -41,6 +41,7 @@ export function AuthGate({ children }: AuthGateProps) { const [status, setStatus] = useState('checking'); const [user, setUser] = useState(null); const [error, setError] = useState(''); + const [notice, setNotice] = useState(''); const [sendingCode, setSendingCode] = useState(false); const [verifyingPhone, setVerifyingPhone] = useState(false); const [showAccountModal, setShowAccountModal] = useState(false); @@ -80,11 +81,13 @@ export function AuthGate({ children }: AuthGateProps) { setUser(nextSession.user); setStatus('ready'); setError(''); - if (nextSession.user.bindingStatus === 'pending_bind_phone') { + setNotice(nextSession.recoveryNotice?.message ?? ''); + const nextUser = nextSession.user; + if (nextUser.bindingStatus === 'pending_bind_phone') { setShowVerificationModal(true); setVerificationPrompt((current) => current ?? { - phoneNumberMasked: nextSession.user.phoneNumberMasked, + phoneNumberMasked: nextUser.phoneNumberMasked, title: '完成短信验证', detail: '验证手机号后,才能继续进行需要服务端状态写入的操作。', }, @@ -97,6 +100,7 @@ export function AuthGate({ children }: AuthGateProps) { setUser(null); setStatus('error'); + setNotice(''); setError( hydrateError instanceof Error ? hydrateError.message @@ -166,6 +170,20 @@ export function AuthGate({ children }: AuthGateProps) { }; }, [user?.phoneNumberMasked]); + useEffect(() => { + if (!notice) { + return; + } + + const timeoutId = window.setTimeout(() => { + setNotice(''); + }, 5000); + + return () => { + window.clearTimeout(timeoutId); + }; + }, [notice]); + useEffect(() => { if (!showAccountModal || status !== 'ready') { return; @@ -290,6 +308,26 @@ export function AuthGate({ children }: AuthGateProps) { return (
+ {notice ? ( +
+
+
{notice}
+ +
+
+ ) : null} + {showGlobalAccountActions ? (
diff --git a/src/services/authService.test.ts b/src/services/authService.test.ts index 555f49e7..736e5875 100644 --- a/src/services/authService.test.ts +++ b/src/services/authService.test.ts @@ -1,11 +1,16 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'; -import { ApiClientError, clearStoredAccessToken, clearStoredAutoAuthCredentials } from './apiClient'; +import { + ApiClientError, + clearStoredAccessToken, + clearStoredAutoAuthCredentials, + setStoredAccessToken, +} from './apiClient'; import { createAutoAuthCredentials, - getCaptchaChallengeFromError, getAuthRiskBlocks, getAuthSessions, + getCaptchaChallengeFromError, getCurrentAuthUser, liftAuthRiskBlock, loginWithPhoneCode, @@ -240,6 +245,43 @@ describe('authService with SpacetimeDB', () => { expect(session.user?.displayName).toBe('游客阿青'); expect(session.user?.loginMethod).toBe('phone'); expect(session.availableLoginMethods).toEqual(['phone', 'wechat']); + expect(session.recoveryNotice).toBeNull(); + }); + + it('falls back to anonymous auth when stored token connection stalls', async () => { + vi.useFakeTimers(); + setStoredAccessToken('expired-token', { emit: false }); + + const stalledConnection = new Promise(() => {}); + spacetimeMocks.ensureSpacetimeConnection + .mockImplementationOnce(() => stalledConnection) + .mockResolvedValueOnce( + createConnection({ + authRows: [ + createAuthStateRow({ + displayName: '匿名玩家', + }), + ], + }), + ); + + try { + const sessionPromise = getCurrentAuthUser(); + + await vi.advanceTimersByTimeAsync(3500); + + const session = await sessionPromise; + + expect(session.user?.displayName).toBe('匿名玩家'); + expect(session.recoveryNotice).toEqual({ + code: 'login_expired', + message: '登录已过期,已切换为匿名账号。', + }); + expect(spacetimeMocks.disconnectSpacetimeConnection).toHaveBeenCalled(); + expect(window.localStorage.getItem('genarrative.auth.access-token.v1')).toBeNull(); + } finally { + vi.useRealTimers(); + } }); it('sends phone login code through spacetime procedure', async () => { diff --git a/src/services/authService.ts b/src/services/authService.ts index f2198028..ae32e850 100644 --- a/src/services/authService.ts +++ b/src/services/authService.ts @@ -1,9 +1,9 @@ import type { AuthAuditLogEntry, AuthCaptchaChallenge, + AuthLiftRiskBlockResponse, AuthLoginMethod, AuthLoginOptionsResponse, - AuthLiftRiskBlockResponse, AuthLogoutAllResponse, AuthPhoneChangeResponse, AuthPhoneLoginResponse, @@ -14,17 +14,16 @@ import type { AuthWechatBindPhoneResponse, LogoutResponse, } from '../../packages/shared/src/contracts/auth'; -import { - ApiClientError, - clearStoredAccessToken, - clearStoredAutoAuthCredentials, - getStoredAccessToken, -} from './apiClient'; import { disconnectSpacetimeConnection, ensureSpacetimeConnection, getCurrentSpacetimeSessionId, } from '../spacetime/client'; +import type { + ClientAppConfigView, + RequestMeta, + SmsAuthScene, +} from '../spacetime/generated/types'; import { mapAuditLogEntry, mapAuthRiskBlock, @@ -32,11 +31,12 @@ import { mapAuthUser, mapAvailableLoginMethods, } from '../spacetime/mappers'; -import type { - ClientAppConfigView, - RequestMeta, - SmsAuthScene, -} from '../spacetime/generated/types'; +import { + ApiClientError, + clearStoredAccessToken, + clearStoredAutoAuthCredentials, + getStoredAccessToken, +} from './apiClient'; export type { AuthUser } from '../../packages/shared/src/contracts/auth'; export type { AuthLoginMethod } from '../../packages/shared/src/contracts/auth'; @@ -49,6 +49,10 @@ export type AutoAuthCredentials = { export type AuthSessionSnapshot = { user: import('../../packages/shared/src/contracts/auth').AuthUser | null; availableLoginMethods: AuthLoginMethod[]; + recoveryNotice: { + code: 'login_expired'; + message: string; + } | null; }; export type { AuthSessionSummary }; export type { AuthCaptchaChallenge }; @@ -66,6 +70,8 @@ let pendingAutoAuthUser: Promise<{ credentials: AutoAuthCredentials; }> | null = null; +const TOKEN_RECOVERY_TIMEOUT_MS = 3500; + function buildRandomSegment(length: number) { const alphabet = 'abcdefghijklmnopqrstuvwxyz0123456789'; const cryptoApi = globalThis.crypto; @@ -101,8 +107,10 @@ function buildRequestMeta(): RequestMeta { return { clientType: 'web', userAgent: - typeof navigator !== 'undefined' ? navigator.userAgent.trim() || null : null, - ip: null, + typeof navigator !== 'undefined' + ? navigator.userAgent.trim() || undefined + : undefined, + ip: undefined, }; } @@ -121,6 +129,12 @@ function mapSmsScene( async function readCurrentSessionFromConnection() { const connection = await ensureSpacetimeConnection(); + return readCurrentSessionSnapshot(connection); +} + +function readCurrentSessionSnapshot( + connection: Awaited>, +) { const authRow = getSingleRow(connection.db.my_auth_state.iter()); const configRow = getSingleRow( connection.db.client_app_config.iter(), @@ -129,20 +143,64 @@ async function readCurrentSessionFromConnection() { return { user: authRow ? mapAuthUser(authRow) : null, availableLoginMethods: mapAvailableLoginMethods(configRow), - } satisfies AuthSessionSnapshot; + } satisfies Omit; +} + +async function waitForConnectionWithTimeout(timeoutMs: number) { + if (!Number.isFinite(timeoutMs) || timeoutMs <= 0) { + return ensureSpacetimeConnection(); + } + + const connectionPromise = ensureSpacetimeConnection(); + + return Promise.race([ + connectionPromise, + new Promise((_, reject) => { + const timeoutId = globalThis.setTimeout(() => { + reject(new Error('账号连接超时,请稍后重试')); + }, timeoutMs); + + void connectionPromise.finally(() => { + globalThis.clearTimeout(timeoutId); + }); + }), + ]); +} + +async function readCurrentSessionWithConnectionTimeout(timeoutMs: number | null) { + const connection = + typeof timeoutMs === 'number' + ? await waitForConnectionWithTimeout(timeoutMs) + : await ensureSpacetimeConnection(); + return readCurrentSessionSnapshot(connection); } async function readCurrentSessionWithRetry() { + const hasStoredToken = Boolean(getStoredAccessToken()); + try { - return await readCurrentSessionFromConnection(); + const session = await readCurrentSessionWithConnectionTimeout( + hasStoredToken ? TOKEN_RECOVERY_TIMEOUT_MS : null, + ); + return { + ...session, + recoveryNotice: null, + } satisfies AuthSessionSnapshot; } catch (error) { - if (!getStoredAccessToken()) { + if (!hasStoredToken) { throw error; } disconnectSpacetimeConnection(); clearStoredAccessToken({ emit: false }); - return readCurrentSessionFromConnection(); + const session = await readCurrentSessionFromConnection(); + return { + ...session, + recoveryNotice: { + code: 'login_expired', + message: '登录已过期,已切换为匿名账号。', + }, + } satisfies AuthSessionSnapshot; } } diff --git a/src/spacetime/client.ts b/src/spacetime/client.ts index 2195c7e3..69faa097 100644 --- a/src/spacetime/client.ts +++ b/src/spacetime/client.ts @@ -3,6 +3,9 @@ import type { Identity } from 'spacetimedb'; import { AUTH_STATE_EVENT, clearStoredAccessToken, getStoredAccessToken, setStoredAccessToken } from '../services/apiClient'; import { DbConnection } from './generated'; +const DEFAULT_SPACETIME_URI = 'wss://maincloud.spacetimedb.com'; +const DEFAULT_SPACETIME_DATABASE_NAME = 'xushi-p4wfr'; + export const SPACETIME_VERIFICATION_REQUIRED_EVENT = 'genarrative-spacetime-verification-required'; export const SPACETIME_KICK_EVENT = 'genarrative-spacetime-kick'; @@ -52,7 +55,7 @@ function emitAuthStateChange() { function normalizeSpacetimeUri(rawValue: string) { const trimmed = rawValue.trim(); if (!trimmed) { - return 'ws://127.0.0.1:3000'; + return DEFAULT_SPACETIME_URI; } if (trimmed.startsWith('ws://') || trimmed.startsWith('wss://')) { @@ -72,13 +75,14 @@ function normalizeSpacetimeUri(rawValue: string) { function resolveSpacetimeUri() { return normalizeSpacetimeUri( - import.meta.env.VITE_SPACETIME_URI?.trim() || 'ws://127.0.0.1:3000', + import.meta.env.VITE_SPACETIME_URI?.trim() || DEFAULT_SPACETIME_URI, ); } function resolveDatabaseName() { return ( - import.meta.env.VITE_SPACETIME_DATABASE_NAME?.trim() || 'xushi-p4wfr' + import.meta.env.VITE_SPACETIME_DATABASE_NAME?.trim() || + DEFAULT_SPACETIME_DATABASE_NAME ); }