修复报错
This commit is contained in:
@@ -19,7 +19,9 @@ NODE_SERVER_TARGET="http://127.0.0.1:8081"
|
|||||||
|
|
||||||
# 前端直连 SpacetimeDB 所需配置。
|
# 前端直连 SpacetimeDB 所需配置。
|
||||||
# 浏览器端当前直接订阅认证/存档/资料库相关 view,并调用对应 procedure。
|
# 浏览器端当前直接订阅认证/存档/资料库相关 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"
|
VITE_SPACETIME_DATABASE_NAME="xushi-p4wfr"
|
||||||
|
|
||||||
# Local Caddy upstream target used for dist-based testing.
|
# Local Caddy upstream target used for dist-based testing.
|
||||||
|
|||||||
40
docs/technical/NODE_DEV_STARTUP_HOTFIX_2026-04-20.md
Normal file
40
docs/technical/NODE_DEV_STARTUP_HOTFIX_2026-04-20.md
Normal file
@@ -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` 或相邻剧情服务时,优先补跑编码检查与后端构建
|
||||||
@@ -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 运行时后端的技术栈、入口、鉴权、存储与接口知识图谱。
|
- [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_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):按并行工作流文档逐项核对后的完成度审计与剩余收口点。
|
- [EXPRESS_BACKEND_WORKSTREAM_AUDIT_2026-04-09.md](./EXPRESS_BACKEND_WORKSTREAM_AUDIT_2026-04-09.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`
|
||||||
63
docs/technical/SPACETIME_DEV_URI_HOTFIX_2026-04-20.md
Normal file
63
docs/technical/SPACETIME_DEV_URI_HOTFIX_2026-04-20.md
Normal file
@@ -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,不需要再改代码。
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
import net from 'node:net';
|
|
||||||
import path from 'node:path';
|
|
||||||
import {spawn} from 'node:child_process';
|
import {spawn} from 'node:child_process';
|
||||||
import {existsSync, readFileSync} from 'node:fs';
|
import {existsSync, readFileSync} from 'node:fs';
|
||||||
|
import net from 'node:net';
|
||||||
|
import path from 'node:path';
|
||||||
import {fileURLToPath, pathToFileURL} from 'node:url';
|
import {fileURLToPath, pathToFileURL} from 'node:url';
|
||||||
|
|
||||||
const repoRoot = fileURLToPath(new URL('../', import.meta.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 npmCommand = process.platform === 'win32' ? 'npm.cmd' : 'npm';
|
||||||
const DEFAULT_DEV_DATABASE_URL = 'postgresql://postgres:postgres@127.0.0.1:5432/genarrative';
|
const DEFAULT_DEV_DATABASE_URL = 'postgresql://postgres:postgres@127.0.0.1:5432/genarrative';
|
||||||
const DEV_MEMORY_DATABASE_URL = 'pg-mem://genarrative-dev';
|
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) {
|
function parseEnvContents(contents) {
|
||||||
return contents
|
return contents
|
||||||
@@ -198,6 +200,10 @@ mergedEnv.NODE_SERVER_TARGET =
|
|||||||
mergedEnv.NODE_SERVER_TARGET || resolveServerTarget(mergedEnv.NODE_SERVER_ADDR);
|
mergedEnv.NODE_SERVER_TARGET || resolveServerTarget(mergedEnv.NODE_SERVER_ADDR);
|
||||||
mergedEnv.DATABASE_URL =
|
mergedEnv.DATABASE_URL =
|
||||||
mergedEnv.DATABASE_URL || DEFAULT_DEV_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';
|
mergedEnv.VITE_DEV_HOST = mergedEnv.VITE_DEV_HOST || '127.0.0.1';
|
||||||
prependEnvPath(mergedEnv, runtimeNodeDir);
|
prependEnvPath(mergedEnv, runtimeNodeDir);
|
||||||
mergedEnv.npm_config_scripts_prepend_node_path = 'true';
|
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_ADDR=${mergedEnv.NODE_SERVER_ADDR}`);
|
||||||
console.log(`[dev:node] NODE_SERVER_TARGET=${mergedEnv.NODE_SERVER_TARGET}`);
|
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] 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] VITE_DEV_HOST=${mergedEnv.VITE_DEV_HOST}`);
|
||||||
console.log(`[dev:node] NODE_RUNTIME=${runtimeNodePath}`);
|
console.log(`[dev:node] NODE_RUNTIME=${runtimeNodePath}`);
|
||||||
|
|
||||||
|
|||||||
@@ -14,8 +14,10 @@ import {
|
|||||||
} from '../ai/chatPromptBuilders.js';
|
} from '../ai/chatPromptBuilders.js';
|
||||||
import { generateNextStoryFromOrchestrator } from '../ai/storyOrchestrator.js';
|
import { generateNextStoryFromOrchestrator } from '../ai/storyOrchestrator.js';
|
||||||
import { resolveCombatAction } from '../combat/combatResolutionService.js';
|
import { resolveCombatAction } from '../combat/combatResolutionService.js';
|
||||||
import { isSupportedInventoryStoryFunctionId,resolveInventoryStoryAction } from '../inventory/inventory
|
import {
|
||||||
.js';
|
isSupportedInventoryStoryFunctionId,
|
||||||
|
resolveInventoryStoryAction,
|
||||||
|
} from '../inventory/inventoryStoryActionService.js';
|
||||||
import {
|
import {
|
||||||
ensureNpcInventorySessionState,
|
ensureNpcInventorySessionState,
|
||||||
isSupportedNpcInventoryStoryFunctionId,
|
isSupportedNpcInventoryStoryFunctionId,
|
||||||
|
|||||||
@@ -72,6 +72,7 @@ test('auth gate renders app content after spacetime auth session is ready', asyn
|
|||||||
authMocks.getCurrentAuthUser.mockResolvedValue({
|
authMocks.getCurrentAuthUser.mockResolvedValue({
|
||||||
user: activeUser,
|
user: activeUser,
|
||||||
availableLoginMethods: ['phone'],
|
availableLoginMethods: ['phone'],
|
||||||
|
recoveryNotice: null,
|
||||||
});
|
});
|
||||||
|
|
||||||
render(
|
render(
|
||||||
@@ -90,6 +91,7 @@ test('auth gate opens phone verification modal for pending sms verification user
|
|||||||
bindingStatus: 'pending_bind_phone',
|
bindingStatus: 'pending_bind_phone',
|
||||||
},
|
},
|
||||||
availableLoginMethods: ['phone'],
|
availableLoginMethods: ['phone'],
|
||||||
|
recoveryNotice: null,
|
||||||
});
|
});
|
||||||
|
|
||||||
render(
|
render(
|
||||||
@@ -100,3 +102,23 @@ test('auth gate opens phone verification modal for pending sms verification user
|
|||||||
|
|
||||||
expect(await screen.findByText('完成短信验证')).toBeTruthy();
|
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(
|
||||||
|
<AuthGate>
|
||||||
|
<div>应用内容</div>
|
||||||
|
</AuthGate>,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(await screen.findByText('应用内容')).toBeTruthy();
|
||||||
|
expect(await screen.findByText('登录已过期,已切换为匿名账号。')).toBeTruthy();
|
||||||
|
});
|
||||||
|
|||||||
@@ -1,13 +1,6 @@
|
|||||||
import { type ReactNode, useEffect, useMemo, useState } from 'react';
|
import { type ReactNode, useEffect, useMemo, useState } from 'react';
|
||||||
|
|
||||||
import {
|
import { AUTH_STATE_EVENT } from '../../services/apiClient';
|
||||||
SPACETIME_KICK_EVENT,
|
|
||||||
SPACETIME_SESSION_REVOKED_EVENT,
|
|
||||||
SPACETIME_VERIFICATION_REQUIRED_EVENT,
|
|
||||||
type KickEventDetail,
|
|
||||||
type SessionRevokedDetail,
|
|
||||||
type VerificationRequiredDetail,
|
|
||||||
} from '../../spacetime/client';
|
|
||||||
import {
|
import {
|
||||||
type AuthAuditLogEntry,
|
type AuthAuditLogEntry,
|
||||||
type AuthCaptchaChallenge,
|
type AuthCaptchaChallenge,
|
||||||
@@ -26,7 +19,14 @@ import {
|
|||||||
revokeAuthSession,
|
revokeAuthSession,
|
||||||
sendPhoneLoginCode,
|
sendPhoneLoginCode,
|
||||||
} from '../../services/authService';
|
} 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 { AccountModal } from './AccountModal';
|
||||||
import { AuthUiContext } from './AuthUiContext';
|
import { AuthUiContext } from './AuthUiContext';
|
||||||
import { PhoneVerificationModal } from './PhoneVerificationModal';
|
import { PhoneVerificationModal } from './PhoneVerificationModal';
|
||||||
@@ -41,6 +41,7 @@ export function AuthGate({ children }: AuthGateProps) {
|
|||||||
const [status, setStatus] = useState<AuthStatus>('checking');
|
const [status, setStatus] = useState<AuthStatus>('checking');
|
||||||
const [user, setUser] = useState<AuthUser | null>(null);
|
const [user, setUser] = useState<AuthUser | null>(null);
|
||||||
const [error, setError] = useState('');
|
const [error, setError] = useState('');
|
||||||
|
const [notice, setNotice] = useState('');
|
||||||
const [sendingCode, setSendingCode] = useState(false);
|
const [sendingCode, setSendingCode] = useState(false);
|
||||||
const [verifyingPhone, setVerifyingPhone] = useState(false);
|
const [verifyingPhone, setVerifyingPhone] = useState(false);
|
||||||
const [showAccountModal, setShowAccountModal] = useState(false);
|
const [showAccountModal, setShowAccountModal] = useState(false);
|
||||||
@@ -80,11 +81,13 @@ export function AuthGate({ children }: AuthGateProps) {
|
|||||||
setUser(nextSession.user);
|
setUser(nextSession.user);
|
||||||
setStatus('ready');
|
setStatus('ready');
|
||||||
setError('');
|
setError('');
|
||||||
if (nextSession.user.bindingStatus === 'pending_bind_phone') {
|
setNotice(nextSession.recoveryNotice?.message ?? '');
|
||||||
|
const nextUser = nextSession.user;
|
||||||
|
if (nextUser.bindingStatus === 'pending_bind_phone') {
|
||||||
setShowVerificationModal(true);
|
setShowVerificationModal(true);
|
||||||
setVerificationPrompt((current) =>
|
setVerificationPrompt((current) =>
|
||||||
current ?? {
|
current ?? {
|
||||||
phoneNumberMasked: nextSession.user.phoneNumberMasked,
|
phoneNumberMasked: nextUser.phoneNumberMasked,
|
||||||
title: '完成短信验证',
|
title: '完成短信验证',
|
||||||
detail: '验证手机号后,才能继续进行需要服务端状态写入的操作。',
|
detail: '验证手机号后,才能继续进行需要服务端状态写入的操作。',
|
||||||
},
|
},
|
||||||
@@ -97,6 +100,7 @@ export function AuthGate({ children }: AuthGateProps) {
|
|||||||
|
|
||||||
setUser(null);
|
setUser(null);
|
||||||
setStatus('error');
|
setStatus('error');
|
||||||
|
setNotice('');
|
||||||
setError(
|
setError(
|
||||||
hydrateError instanceof Error
|
hydrateError instanceof Error
|
||||||
? hydrateError.message
|
? hydrateError.message
|
||||||
@@ -166,6 +170,20 @@ export function AuthGate({ children }: AuthGateProps) {
|
|||||||
};
|
};
|
||||||
}, [user?.phoneNumberMasked]);
|
}, [user?.phoneNumberMasked]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!notice) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const timeoutId = window.setTimeout(() => {
|
||||||
|
setNotice('');
|
||||||
|
}, 5000);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
window.clearTimeout(timeoutId);
|
||||||
|
};
|
||||||
|
}, [notice]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!showAccountModal || status !== 'ready') {
|
if (!showAccountModal || status !== 'ready') {
|
||||||
return;
|
return;
|
||||||
@@ -290,6 +308,26 @@ export function AuthGate({ children }: AuthGateProps) {
|
|||||||
return (
|
return (
|
||||||
<AuthUiContext.Provider value={authUiValue}>
|
<AuthUiContext.Provider value={authUiValue}>
|
||||||
<div className="relative">
|
<div className="relative">
|
||||||
|
{notice ? (
|
||||||
|
<div
|
||||||
|
className="pointer-events-none fixed inset-x-0 top-3 z-[60] flex justify-center px-4"
|
||||||
|
style={{
|
||||||
|
paddingTop: 'calc(env(safe-area-inset-top, 0px) + 0.25rem)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div className="pointer-events-auto flex w-full max-w-md items-start gap-3 rounded-2xl border border-amber-300/30 bg-[rgba(84,48,12,0.92)] px-4 py-3 text-sm text-amber-50 shadow-[0_18px_50px_rgba(0,0,0,0.35)] backdrop-blur">
|
||||||
|
<div className="min-w-0 flex-1 leading-5">{notice}</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="shrink-0 rounded-full border border-amber-100/20 px-3 py-1 text-[11px] text-amber-50 transition hover:border-amber-100/40 hover:bg-white/8"
|
||||||
|
onClick={() => setNotice('')}
|
||||||
|
>
|
||||||
|
知道了
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
{showGlobalAccountActions ? (
|
{showGlobalAccountActions ? (
|
||||||
<div className="pointer-events-none fixed right-3 top-3 z-50 flex justify-end">
|
<div className="pointer-events-none fixed right-3 top-3 z-50 flex justify-end">
|
||||||
<div className="pointer-events-auto flex items-center gap-2 rounded-full border border-white/10 bg-black/45 px-3 py-2 text-xs text-zinc-200 backdrop-blur">
|
<div className="pointer-events-auto flex items-center gap-2 rounded-full border border-white/10 bg-black/45 px-3 py-2 text-xs text-zinc-200 backdrop-blur">
|
||||||
|
|||||||
@@ -1,11 +1,16 @@
|
|||||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||||
|
|
||||||
import { ApiClientError, clearStoredAccessToken, clearStoredAutoAuthCredentials } from './apiClient';
|
import {
|
||||||
|
ApiClientError,
|
||||||
|
clearStoredAccessToken,
|
||||||
|
clearStoredAutoAuthCredentials,
|
||||||
|
setStoredAccessToken,
|
||||||
|
} from './apiClient';
|
||||||
import {
|
import {
|
||||||
createAutoAuthCredentials,
|
createAutoAuthCredentials,
|
||||||
getCaptchaChallengeFromError,
|
|
||||||
getAuthRiskBlocks,
|
getAuthRiskBlocks,
|
||||||
getAuthSessions,
|
getAuthSessions,
|
||||||
|
getCaptchaChallengeFromError,
|
||||||
getCurrentAuthUser,
|
getCurrentAuthUser,
|
||||||
liftAuthRiskBlock,
|
liftAuthRiskBlock,
|
||||||
loginWithPhoneCode,
|
loginWithPhoneCode,
|
||||||
@@ -240,6 +245,43 @@ describe('authService with SpacetimeDB', () => {
|
|||||||
expect(session.user?.displayName).toBe('游客阿青');
|
expect(session.user?.displayName).toBe('游客阿青');
|
||||||
expect(session.user?.loginMethod).toBe('phone');
|
expect(session.user?.loginMethod).toBe('phone');
|
||||||
expect(session.availableLoginMethods).toEqual(['phone', 'wechat']);
|
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<never>(() => {});
|
||||||
|
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 () => {
|
it('sends phone login code through spacetime procedure', async () => {
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
import type {
|
import type {
|
||||||
AuthAuditLogEntry,
|
AuthAuditLogEntry,
|
||||||
AuthCaptchaChallenge,
|
AuthCaptchaChallenge,
|
||||||
|
AuthLiftRiskBlockResponse,
|
||||||
AuthLoginMethod,
|
AuthLoginMethod,
|
||||||
AuthLoginOptionsResponse,
|
AuthLoginOptionsResponse,
|
||||||
AuthLiftRiskBlockResponse,
|
|
||||||
AuthLogoutAllResponse,
|
AuthLogoutAllResponse,
|
||||||
AuthPhoneChangeResponse,
|
AuthPhoneChangeResponse,
|
||||||
AuthPhoneLoginResponse,
|
AuthPhoneLoginResponse,
|
||||||
@@ -14,17 +14,16 @@ import type {
|
|||||||
AuthWechatBindPhoneResponse,
|
AuthWechatBindPhoneResponse,
|
||||||
LogoutResponse,
|
LogoutResponse,
|
||||||
} from '../../packages/shared/src/contracts/auth';
|
} from '../../packages/shared/src/contracts/auth';
|
||||||
import {
|
|
||||||
ApiClientError,
|
|
||||||
clearStoredAccessToken,
|
|
||||||
clearStoredAutoAuthCredentials,
|
|
||||||
getStoredAccessToken,
|
|
||||||
} from './apiClient';
|
|
||||||
import {
|
import {
|
||||||
disconnectSpacetimeConnection,
|
disconnectSpacetimeConnection,
|
||||||
ensureSpacetimeConnection,
|
ensureSpacetimeConnection,
|
||||||
getCurrentSpacetimeSessionId,
|
getCurrentSpacetimeSessionId,
|
||||||
} from '../spacetime/client';
|
} from '../spacetime/client';
|
||||||
|
import type {
|
||||||
|
ClientAppConfigView,
|
||||||
|
RequestMeta,
|
||||||
|
SmsAuthScene,
|
||||||
|
} from '../spacetime/generated/types';
|
||||||
import {
|
import {
|
||||||
mapAuditLogEntry,
|
mapAuditLogEntry,
|
||||||
mapAuthRiskBlock,
|
mapAuthRiskBlock,
|
||||||
@@ -32,11 +31,12 @@ import {
|
|||||||
mapAuthUser,
|
mapAuthUser,
|
||||||
mapAvailableLoginMethods,
|
mapAvailableLoginMethods,
|
||||||
} from '../spacetime/mappers';
|
} from '../spacetime/mappers';
|
||||||
import type {
|
import {
|
||||||
ClientAppConfigView,
|
ApiClientError,
|
||||||
RequestMeta,
|
clearStoredAccessToken,
|
||||||
SmsAuthScene,
|
clearStoredAutoAuthCredentials,
|
||||||
} from '../spacetime/generated/types';
|
getStoredAccessToken,
|
||||||
|
} from './apiClient';
|
||||||
|
|
||||||
export type { AuthUser } from '../../packages/shared/src/contracts/auth';
|
export type { AuthUser } from '../../packages/shared/src/contracts/auth';
|
||||||
export type { AuthLoginMethod } 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 = {
|
export type AuthSessionSnapshot = {
|
||||||
user: import('../../packages/shared/src/contracts/auth').AuthUser | null;
|
user: import('../../packages/shared/src/contracts/auth').AuthUser | null;
|
||||||
availableLoginMethods: AuthLoginMethod[];
|
availableLoginMethods: AuthLoginMethod[];
|
||||||
|
recoveryNotice: {
|
||||||
|
code: 'login_expired';
|
||||||
|
message: string;
|
||||||
|
} | null;
|
||||||
};
|
};
|
||||||
export type { AuthSessionSummary };
|
export type { AuthSessionSummary };
|
||||||
export type { AuthCaptchaChallenge };
|
export type { AuthCaptchaChallenge };
|
||||||
@@ -66,6 +70,8 @@ let pendingAutoAuthUser: Promise<{
|
|||||||
credentials: AutoAuthCredentials;
|
credentials: AutoAuthCredentials;
|
||||||
}> | null = null;
|
}> | null = null;
|
||||||
|
|
||||||
|
const TOKEN_RECOVERY_TIMEOUT_MS = 3500;
|
||||||
|
|
||||||
function buildRandomSegment(length: number) {
|
function buildRandomSegment(length: number) {
|
||||||
const alphabet = 'abcdefghijklmnopqrstuvwxyz0123456789';
|
const alphabet = 'abcdefghijklmnopqrstuvwxyz0123456789';
|
||||||
const cryptoApi = globalThis.crypto;
|
const cryptoApi = globalThis.crypto;
|
||||||
@@ -101,8 +107,10 @@ function buildRequestMeta(): RequestMeta {
|
|||||||
return {
|
return {
|
||||||
clientType: 'web',
|
clientType: 'web',
|
||||||
userAgent:
|
userAgent:
|
||||||
typeof navigator !== 'undefined' ? navigator.userAgent.trim() || null : null,
|
typeof navigator !== 'undefined'
|
||||||
ip: null,
|
? navigator.userAgent.trim() || undefined
|
||||||
|
: undefined,
|
||||||
|
ip: undefined,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -121,6 +129,12 @@ function mapSmsScene(
|
|||||||
|
|
||||||
async function readCurrentSessionFromConnection() {
|
async function readCurrentSessionFromConnection() {
|
||||||
const connection = await ensureSpacetimeConnection();
|
const connection = await ensureSpacetimeConnection();
|
||||||
|
return readCurrentSessionSnapshot(connection);
|
||||||
|
}
|
||||||
|
|
||||||
|
function readCurrentSessionSnapshot(
|
||||||
|
connection: Awaited<ReturnType<typeof ensureSpacetimeConnection>>,
|
||||||
|
) {
|
||||||
const authRow = getSingleRow(connection.db.my_auth_state.iter());
|
const authRow = getSingleRow(connection.db.my_auth_state.iter());
|
||||||
const configRow = getSingleRow(
|
const configRow = getSingleRow(
|
||||||
connection.db.client_app_config.iter(),
|
connection.db.client_app_config.iter(),
|
||||||
@@ -129,20 +143,64 @@ async function readCurrentSessionFromConnection() {
|
|||||||
return {
|
return {
|
||||||
user: authRow ? mapAuthUser(authRow) : null,
|
user: authRow ? mapAuthUser(authRow) : null,
|
||||||
availableLoginMethods: mapAvailableLoginMethods(configRow),
|
availableLoginMethods: mapAvailableLoginMethods(configRow),
|
||||||
} satisfies AuthSessionSnapshot;
|
} satisfies Omit<AuthSessionSnapshot, 'recoveryNotice'>;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function waitForConnectionWithTimeout(timeoutMs: number) {
|
||||||
|
if (!Number.isFinite(timeoutMs) || timeoutMs <= 0) {
|
||||||
|
return ensureSpacetimeConnection();
|
||||||
|
}
|
||||||
|
|
||||||
|
const connectionPromise = ensureSpacetimeConnection();
|
||||||
|
|
||||||
|
return Promise.race([
|
||||||
|
connectionPromise,
|
||||||
|
new Promise<never>((_, 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() {
|
async function readCurrentSessionWithRetry() {
|
||||||
|
const hasStoredToken = Boolean(getStoredAccessToken());
|
||||||
|
|
||||||
try {
|
try {
|
||||||
return await readCurrentSessionFromConnection();
|
const session = await readCurrentSessionWithConnectionTimeout(
|
||||||
|
hasStoredToken ? TOKEN_RECOVERY_TIMEOUT_MS : null,
|
||||||
|
);
|
||||||
|
return {
|
||||||
|
...session,
|
||||||
|
recoveryNotice: null,
|
||||||
|
} satisfies AuthSessionSnapshot;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (!getStoredAccessToken()) {
|
if (!hasStoredToken) {
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
|
|
||||||
disconnectSpacetimeConnection();
|
disconnectSpacetimeConnection();
|
||||||
clearStoredAccessToken({ emit: false });
|
clearStoredAccessToken({ emit: false });
|
||||||
return readCurrentSessionFromConnection();
|
const session = await readCurrentSessionFromConnection();
|
||||||
|
return {
|
||||||
|
...session,
|
||||||
|
recoveryNotice: {
|
||||||
|
code: 'login_expired',
|
||||||
|
message: '登录已过期,已切换为匿名账号。',
|
||||||
|
},
|
||||||
|
} satisfies AuthSessionSnapshot;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -3,6 +3,9 @@ import type { Identity } from 'spacetimedb';
|
|||||||
import { AUTH_STATE_EVENT, clearStoredAccessToken, getStoredAccessToken, setStoredAccessToken } from '../services/apiClient';
|
import { AUTH_STATE_EVENT, clearStoredAccessToken, getStoredAccessToken, setStoredAccessToken } from '../services/apiClient';
|
||||||
import { DbConnection } from './generated';
|
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 =
|
export const SPACETIME_VERIFICATION_REQUIRED_EVENT =
|
||||||
'genarrative-spacetime-verification-required';
|
'genarrative-spacetime-verification-required';
|
||||||
export const SPACETIME_KICK_EVENT = 'genarrative-spacetime-kick';
|
export const SPACETIME_KICK_EVENT = 'genarrative-spacetime-kick';
|
||||||
@@ -52,7 +55,7 @@ function emitAuthStateChange() {
|
|||||||
function normalizeSpacetimeUri(rawValue: string) {
|
function normalizeSpacetimeUri(rawValue: string) {
|
||||||
const trimmed = rawValue.trim();
|
const trimmed = rawValue.trim();
|
||||||
if (!trimmed) {
|
if (!trimmed) {
|
||||||
return 'ws://127.0.0.1:3000';
|
return DEFAULT_SPACETIME_URI;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (trimmed.startsWith('ws://') || trimmed.startsWith('wss://')) {
|
if (trimmed.startsWith('ws://') || trimmed.startsWith('wss://')) {
|
||||||
@@ -72,13 +75,14 @@ function normalizeSpacetimeUri(rawValue: string) {
|
|||||||
|
|
||||||
function resolveSpacetimeUri() {
|
function resolveSpacetimeUri() {
|
||||||
return normalizeSpacetimeUri(
|
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() {
|
function resolveDatabaseName() {
|
||||||
return (
|
return (
|
||||||
import.meta.env.VITE_SPACETIME_DATABASE_NAME?.trim() || 'xushi-p4wfr'
|
import.meta.env.VITE_SPACETIME_DATABASE_NAME?.trim() ||
|
||||||
|
DEFAULT_SPACETIME_DATABASE_NAME
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user