diff --git a/.hermes/shared-memory/pitfalls.md b/.hermes/shared-memory/pitfalls.md index 64a688e3..9aca5c60 100644 --- a/.hermes/shared-memory/pitfalls.md +++ b/.hermes/shared-memory/pitfalls.md @@ -118,6 +118,22 @@ - 验证:`http://127.0.0.1:3000/api/auth/login-options` 返回至少 `{"availableLoginMethods":["phone","password"]}` 后,登录弹窗会恢复短信登录页签和“获取验证码”按钮。 - 关联:`scripts/api-server-dev.mjs`、`scripts/api-server-maincloud.mjs`、`scripts/dev-rust-stack.sh`、`scripts/dev-web-rust.mjs`、`docs/technical/AUTH_LOGIN_OPTIONS_DESIGN_2026-04-21.md`。 +## 手机验证码登录 500 先查短信 provider 语义 + +- 现象:登录弹窗手机号验证码登录失败,浏览器看到 `POST /api/auth/phone/login 500`,后端日志里同时出现阿里云短信 `UNKNOWN`、`biz.FREQUENCY` 或 `check frequency failed`。 +- 原因:真实短信 provider 的配置错误或上游失败曾被 `module-auth` 折叠成 `PhoneAuthError::Store`,HTTP 层只能按内部错误返回 `500`,掩盖了 provider 失败。 +- 处理:保留 provider 错误语义,配置错误映射 `503 Service Unavailable`,上游短信失败映射 `502 Bad Gateway`;本地只验证 UI/账号链路时可用 shell 临时覆盖 `SMS_AUTH_PROVIDER=mock` 后启动 `npm run api-server`。 +- 验证:`cargo test -p api-server phone_auth_sms_provider_errors_keep_upstream_http_semantics --manifest-path server-rs/Cargo.toml`,真实 provider 频控时接口不再返回 `500`。 +- 关联:`server-rs/crates/module-auth/src/errors.rs`、`server-rs/crates/api-server/src/phone_auth.rs`、`docs/technical/PHONE_SMS_PROVIDER_ERROR_HTTP_MAPPING_FIX_2026-05-08.md`。 + +## 手机验证码登录成功后又瞬间回到未登录 + +- 现象:手机号验证码登录先成功,随后 UI 又闪回“未登录”,登录弹窗可能重新出现。 +- 原因:`AuthGate` 首次 hydrate 会异步轮换 refresh cookie 并请求 `/api/auth/me`。如果用户在 hydrate 完成前已经登录,晚到的旧 hydrate 仍可能把刚写入的 `user` 覆盖成 `null`。 +- 处理:给 `AuthGate` 的 hydrate 增加版本号保护;登录成功、退出登录和全局 auth 事件都会推进版本号,旧 hydrate 结果到达后直接丢弃。 +- 验证:`npm run test -- src/components/auth/AuthGate.test.tsx`,新增用例应覆盖“旧 guest hydrate 不覆盖新登录态”。 +- 关联:`src/components/auth/AuthGate.tsx`、`src/components/auth/AuthGate.test.tsx`、`docs/technical/AUTH_GATE_LOGIN_RACE_GUARD_FIX_2026-05-09.md`。 + ## Rust 冷编译导致 api-server 健康检查误超时 - 现象:`npm run dev:rust` 在 Windows 冷编译/链接阶段误判 `/healthz` 等待超时并杀掉 `cargo run`。 diff --git a/docs/technical/AUTH_GATE_LOGIN_RACE_GUARD_FIX_2026-05-09.md b/docs/technical/AUTH_GATE_LOGIN_RACE_GUARD_FIX_2026-05-09.md new file mode 100644 index 00000000..04447b4a --- /dev/null +++ b/docs/technical/AUTH_GATE_LOGIN_RACE_GUARD_FIX_2026-05-09.md @@ -0,0 +1,37 @@ +# `AuthGate` 登录后又回到未登录状态修复 + +日期:`2026-05-09` + +## 背景 + +本地联调中,手机号验证码登录有时会先显示登录成功,随后又瞬间回到未登录态。 + +## 根因 + +`AuthGate` 首次挂载时会异步 hydrate: + +1. 先轮换 refresh cookie +2. 再请求 `/api/auth/me` +3. 再根据结果写入 `user` 和 `status` + +如果用户在这轮 hydrate 尚未完成时已经完成了登录,后到达的旧 hydrate 结果仍可能把刚写入的 `user` 覆盖回 `null`,导致登录态闪回未登录。 + +## 修复 + +`AuthGate` 增加 hydrate 版本号保护: + +1. 每次启动 hydrate 都分配独立版本号。 +2. 登录成功、退出登录、收到全局 auth state 事件时递增版本号。 +3. 旧版本 hydrate 的结果到达后直接丢弃,不再覆盖当前 `user` / `status`。 + +## 验证 + +1. `npm run test -- src/components/auth/AuthGate.test.tsx` +2. `npm run test -- src/services/apiClient.test.ts src/services/authService.test.ts` +3. `npm run check:encoding` + +## 关联 + +- `src/components/auth/AuthGate.tsx` +- `src/components/auth/AuthGate.test.tsx` +- `.hermes/shared-memory/pitfalls.md` diff --git a/docs/technical/PHONE_SMS_PROVIDER_ERROR_HTTP_MAPPING_FIX_2026-05-08.md b/docs/technical/PHONE_SMS_PROVIDER_ERROR_HTTP_MAPPING_FIX_2026-05-08.md new file mode 100644 index 00000000..1fe10c07 --- /dev/null +++ b/docs/technical/PHONE_SMS_PROVIDER_ERROR_HTTP_MAPPING_FIX_2026-05-08.md @@ -0,0 +1,66 @@ +# 手机验证码短信 Provider 错误 HTTP 映射修复 + +日期:`2026-05-08` + +## 背景 + +本地登录弹窗点击手机号验证码登录时,浏览器报: + +```text +POST /api/auth/phone/login 500 +``` + +排查发现当前 `.env.local` 使用: + +```text +SMS_AUTH_PROVIDER=aliyun +``` + +因此 `send-code` 会走真实阿里云短信 provider。真实 provider 返回 `UNKNOWN` 或 `biz.FREQUENCY / check frequency failed` 时,`module-auth` 曾把 provider 失败统一折叠成 `PhoneAuthError::Store`,`api-server` 再映射为 `500 Internal Server Error`,前端只能看到登录失败。 + +## 根因 + +短信 provider 失败不是认证仓储内部错误: + +1. 阿里云配置缺失或配置非法属于服务配置问题。 +2. 阿里云返回频控、网关失败或业务失败属于上游短信 provider 问题。 +3. 这些错误不应被映射成 `Store`,否则 HTTP 层无法区分真实内部错误与外部 provider 失败。 + +## 修复 + +`module-auth` 新增短信 provider 错误分类: + +1. `PhoneAuthError::SmsProviderInvalidConfig` +2. `PhoneAuthError::SmsProviderUpstream` + +`api-server` 映射规则调整为: + +1. provider 配置错误返回 `503 Service Unavailable` +2. provider 上游失败返回 `502 Bad Gateway` +3. 验证码不存在、错误、过期仍返回 `400` +4. 本地仓储或签发错误仍返回 `500` + +## 本地排查 + +如果本地只想验证登录 UI 和账号链路,可以临时用 shell 环境覆盖真实短信 provider: + +```powershell +$env:SMS_AUTH_PROVIDER="mock" +npm run api-server +``` + +若要验证真实短信链路,保持 `SMS_AUTH_PROVIDER=aliyun`,并查看 `api-server` 日志中的: + +1. `阿里云短信发送接口返回响应` +2. `阿里云短信发送接口返回业务失败` +3. `手机号验证码发送失败` + +看到 `biz.FREQUENCY` / `check frequency failed` 时,说明请求已到达短信 provider,但被 provider 频控或业务规则拒绝。 + +## 验收 + +1. `cargo test -p api-server phone_auth_sms_provider_errors_keep_upstream_http_semantics --manifest-path server-rs/Cargo.toml` +2. `cargo test -p api-server send_phone --manifest-path server-rs/Cargo.toml` +3. `cargo test -p api-server phone_login_creates_user_and_sets_refresh_cookie --manifest-path server-rs/Cargo.toml` +4. `cargo check -p api-server --manifest-path server-rs/Cargo.toml` +5. `npm run check:encoding` diff --git a/docs/technical/README.md b/docs/technical/README.md index 3954b8ed..b8a8ab98 100644 --- a/docs/technical/README.md +++ b/docs/technical/README.md @@ -5,6 +5,7 @@ ## 文档列表 - [RUST_WORKSPACE_DEPENDENCY_CONSOLIDATION_2026-05-07.md](./RUST_WORKSPACE_DEPENDENCY_CONSOLIDATION_2026-05-07.md):记录 `server-rs` Cargo 依赖集中配置口径,第三方版本和 workspace 内部 crate path 统一维护在根 `server-rs/Cargo.toml`,成员 crate 只保留 feature/optional 差异。 +- [AUTH_GATE_LOGIN_RACE_GUARD_FIX_2026-05-09.md](./AUTH_GATE_LOGIN_RACE_GUARD_FIX_2026-05-09.md):记录 `AuthGate` 登录成功后又被旧 hydrate 覆盖回未登录态的竞态根因、版本号保护修复与回归测试。 - [VOLCENGINE_SPEECH_STREAMING_INTEGRATION_2026-05-08.md](./VOLCENGINE_SPEECH_STREAMING_INTEGRATION_2026-05-08.md):记录火山引擎大模型 ASR 双向流式、TTS WebSocket 双向流式和 TTS HTTP SSE 单向流式的后端代理、环境变量、协议帧和验收边界。 - [VECTOR_ENGINE_AUDIO_GENERATION_SUNO_VIDU_2026-05-08.md](./VECTOR_ENGINE_AUDIO_GENERATION_SUNO_VIDU_2026-05-08.md):记录视觉小说结果页接入 VectorEngine Suno 文生背景音乐与 Vidu 文生音效的接口、环境变量、后端路由、OSS 资产回写和前端弹层交互边界。 - [PROFILE_FEEDBACK_BACKEND_INTEGRATION_2026-05-08.md](./PROFILE_FEEDBACK_BACKEND_INTEGRATION_2026-05-08.md):冻结“我的”页签帮助与反馈入口的后端接入方案,覆盖 `POST /api/profile/feedback`、`profile_feedback_submission`、凭证图片 Data URL 校验和前端预览/提交边界。 @@ -163,6 +164,7 @@ - [PHONE_AUTH_AXUM_REAL_SMS_PROVIDER_DESIGN_2026-04-22.md](./PHONE_AUTH_AXUM_REAL_SMS_PROVIDER_DESIGN_2026-04-22.md):冻结 Rust `api-server + module-auth + platform-auth` 接入真实阿里云短信 provider 的 crate 边界、发送与校验职责、配置项和错误语义。 - [PHONE_SMS_ALIYUN_RESPONSE_FIELD_MAPPING_FIX_2026-04-23.md](./PHONE_SMS_ALIYUN_RESPONSE_FIELD_MAPPING_FIX_2026-04-23.md):记录 Rust `platform-auth` 把阿里云 PascalCase 响应字段误判成空值的问题根因,并冻结字段映射修复与回归标准。 - [PHONE_SMS_SEND_CODE_OBSERVABILITY_FIX_2026-04-23.md](./PHONE_SMS_SEND_CODE_OBSERVABILITY_FIX_2026-04-23.md):冻结手机号验证码发送链路的日志补强口径,确保 `api-server`、`module-auth`、`platform-auth` 能直接暴露发送前后与错误分类关键字段。 +- [PHONE_SMS_PROVIDER_ERROR_HTTP_MAPPING_FIX_2026-05-08.md](./PHONE_SMS_PROVIDER_ERROR_HTTP_MAPPING_FIX_2026-05-08.md):记录真实短信 provider 返回 `UNKNOWN` / `biz.FREQUENCY` 时被误映射成登录 `500` 的根因,冻结 provider 配置错误 `503`、上游失败 `502` 的 HTTP 映射。 - [PHONE_SMS_DELIVERY_OBSERVABILITY_AND_RECEIPT_DESIGN_2026-04-22.md](./PHONE_SMS_DELIVERY_OBSERVABILITY_AND_RECEIPT_DESIGN_2026-04-22.md):冻结短信平台受理成功与最终送达状态的区分方式、追踪字段、送达回执接口和前端提示文案边界。 - [PHONE_SMS_REAL_PROVIDER_MANUAL_VERIFICATION_RUNBOOK_2026-04-23.md](./PHONE_SMS_REAL_PROVIDER_MANUAL_VERIFICATION_RUNBOOK_2026-04-23.md):冻结验证清单第一项“真实短信验证码链路”的本地启动、前端操作、日志观察点、通过标准与失败排查步骤。 - [ASSET_EXTERNAL_GENERATION_MANUAL_VERIFICATION_RUNBOOK_2026-04-23.md](./ASSET_EXTERNAL_GENERATION_MANUAL_VERIFICATION_RUNBOOK_2026-04-23.md):冻结验证清单第四项“图片、视频、动作真实外部生成”的人工联调口径,明确哪些入口已接真实外部图片服务、哪些入口仍是 Stage 1 占位链,以及前端点击路径、日志观察点和通过标准。 diff --git a/server-rs/crates/api-server/src/app.rs b/server-rs/crates/api-server/src/app.rs index 37e11519..0170c726 100644 --- a/server-rs/crates/api-server/src/app.rs +++ b/server-rs/crates/api-server/src/app.rs @@ -3269,6 +3269,31 @@ mod tests { assert_eq!(login_response.status(), StatusCode::OK); } + #[test] + fn phone_auth_sms_provider_errors_keep_upstream_http_semantics() { + let invalid_config = crate::phone_auth::map_phone_auth_error( + module_auth::PhoneAuthError::SmsProviderInvalidConfig( + "阿里云短信 AccessKeyId 未配置".to_string(), + ), + ); + assert_eq!( + invalid_config.status_code(), + StatusCode::SERVICE_UNAVAILABLE + ); + assert_eq!(invalid_config.message(), "阿里云短信 AccessKeyId 未配置"); + + let upstream = crate::phone_auth::map_phone_auth_error( + module_auth::PhoneAuthError::SmsProviderUpstream( + "短信验证码发送失败:check frequency failed".to_string(), + ), + ); + assert_eq!(upstream.status_code(), StatusCode::BAD_GATEWAY); + assert_eq!( + upstream.message(), + "短信验证码发送失败:check frequency failed" + ); + } + #[tokio::test] async fn wechat_start_returns_mock_callback_url_with_state() { let config = AppConfig { diff --git a/server-rs/crates/api-server/src/phone_auth.rs b/server-rs/crates/api-server/src/phone_auth.rs index 16b2ed5d..31e64afa 100644 --- a/server-rs/crates/api-server/src/phone_auth.rs +++ b/server-rs/crates/api-server/src/phone_auth.rs @@ -320,6 +320,12 @@ pub fn map_phone_auth_error(error: PhoneAuthError) -> AppError { PhoneAuthError::UserNotFound => { AppError::from_status(StatusCode::UNAUTHORIZED).with_message(error.to_string()) } + PhoneAuthError::SmsProviderInvalidConfig(_) => { + AppError::from_status(StatusCode::SERVICE_UNAVAILABLE).with_message(error.to_string()) + } + PhoneAuthError::SmsProviderUpstream(_) => { + AppError::from_status(StatusCode::BAD_GATEWAY).with_message(error.to_string()) + } PhoneAuthError::Store(_) | PhoneAuthError::PasswordHash(_) => { map_phone_auth_platform_store_error(error.to_string()) } diff --git a/server-rs/crates/api-server/src/wechat_auth.rs b/server-rs/crates/api-server/src/wechat_auth.rs index be1b6faa..2584e504 100644 --- a/server-rs/crates/api-server/src/wechat_auth.rs +++ b/server-rs/crates/api-server/src/wechat_auth.rs @@ -396,6 +396,12 @@ fn map_wechat_bind_phone_error(error: module_auth::PhoneAuthError) -> AppError { module_auth::PhoneAuthError::UserNotFound => { AppError::from_status(StatusCode::UNAUTHORIZED).with_message(error.to_string()) } + module_auth::PhoneAuthError::SmsProviderInvalidConfig(_) => { + AppError::from_status(StatusCode::SERVICE_UNAVAILABLE).with_message(error.to_string()) + } + module_auth::PhoneAuthError::SmsProviderUpstream(_) => { + AppError::from_status(StatusCode::BAD_GATEWAY).with_message(error.to_string()) + } module_auth::PhoneAuthError::Store(_) | module_auth::PhoneAuthError::PasswordHash(_) => { AppError::from_status(StatusCode::INTERNAL_SERVER_ERROR).with_message(error.to_string()) } diff --git a/server-rs/crates/module-auth/src/errors.rs b/server-rs/crates/module-auth/src/errors.rs index 22c39527..9117ab6e 100644 --- a/server-rs/crates/module-auth/src/errors.rs +++ b/server-rs/crates/module-auth/src/errors.rs @@ -28,6 +28,8 @@ pub enum PhoneAuthError { VerifyAttemptsExceeded, UserNotFound, UserStateMismatch, + SmsProviderInvalidConfig(String), + SmsProviderUpstream(String), Store(String), PasswordHash(String), } @@ -88,6 +90,9 @@ impl fmt::Display for PhoneAuthError { Self::VerifyAttemptsExceeded => f.write_str("验证码错误次数过多,请重新获取验证码"), Self::UserNotFound => f.write_str("用户不存在"), Self::UserStateMismatch => f.write_str("当前账号状态不允许执行该操作"), + Self::SmsProviderInvalidConfig(message) | Self::SmsProviderUpstream(message) => { + f.write_str(message) + } Self::Store(message) | Self::PasswordHash(message) => f.write_str(message), } } diff --git a/server-rs/crates/module-auth/src/lib.rs b/server-rs/crates/module-auth/src/lib.rs index 60f0e9fe..78bbd7ef 100644 --- a/server-rs/crates/module-auth/src/lib.rs +++ b/server-rs/crates/module-auth/src/lib.rs @@ -1862,9 +1862,10 @@ impl InMemoryAuthStore { fn map_sms_provider_error_to_phone_error(error: SmsProviderError) -> PhoneAuthError { match error { SmsProviderError::InvalidVerifyCode => PhoneAuthError::InvalidVerifyCode, - SmsProviderError::InvalidConfig(message) | SmsProviderError::Upstream(message) => { - PhoneAuthError::Store(message) + SmsProviderError::InvalidConfig(message) => { + PhoneAuthError::SmsProviderInvalidConfig(message) } + SmsProviderError::Upstream(message) => PhoneAuthError::SmsProviderUpstream(message), } } diff --git a/src/components/auth/AuthGate.test.tsx b/src/components/auth/AuthGate.test.tsx index 42c4411e..e5ae9b62 100644 --- a/src/components/auth/AuthGate.test.tsx +++ b/src/components/auth/AuthGate.test.tsx @@ -330,6 +330,42 @@ test('auth gate opens a login modal for protected actions and resumes after logi expect(screen.queryByRole('dialog', { name: '账号入口' })).toBeNull(); }); +test('phone login result is not overwritten by an older guest hydrate', async () => { + const user = userEvent.setup(); + const onAuthenticated = vi.fn(); + authMocks.getAuthLoginOptions.mockResolvedValue({ + availableLoginMethods: ['phone'], + }); + authMocks.getCurrentAuthUser + .mockResolvedValueOnce({ + user: null, + availableLoginMethods: ['phone'], + }) + .mockResolvedValue({ + user: mockUser, + availableLoginMethods: ['phone'], + }); + + render( + + + + , + ); + + await user.click(await screen.findByRole('button', { name: '进入作品' })); + const dialog = screen.getByRole('dialog', { name: '账号入口' }); + await user.type(within(dialog).getByLabelText('手机号'), '13800000000'); + await user.type(within(dialog).getByLabelText('验证码'), '123456'); + await user.click(within(dialog).getByRole('button', { name: '登录' })); + + expect(await screen.findByText('当前用户:测试玩家')).toBeTruthy(); + expect(onAuthenticated).toHaveBeenCalledTimes(1); + + expect(screen.getByText('当前用户:测试玩家')).toBeTruthy(); + expect(screen.queryByRole('dialog', { name: '账号入口' })).toBeNull(); +}); + test('auth gate hides register entry and opens invite modal for new sms account', async () => { const user = userEvent.setup(); window.history.replaceState(null, '', '/?inviteCode=spring-2026'); diff --git a/src/components/auth/AuthGate.tsx b/src/components/auth/AuthGate.tsx index a853a157..b009dfa1 100644 --- a/src/components/auth/AuthGate.tsx +++ b/src/components/auth/AuthGate.tsx @@ -120,6 +120,7 @@ export function AuthGate({ children }: AuthGateProps) { const pendingProtectedActionRef = useRef<(() => void) | null>(null); const autoOpenedInviteCodeRef = useRef(null); const hasRenderedPlatformContentRef = useRef(false); + const authHydrateVersionRef = useRef(0); const canKeepPlatformContentMounted = hasRenderedPlatformContentRef.current && (status === 'checking' || status === 'recovering'); @@ -134,6 +135,7 @@ export function AuthGate({ children }: AuthGateProps) { const activateReadyUser = useCallback((nextUser: AuthUser) => { // 受保护业务 hook 只在 readyUser 暴露后启动,必须先保证请求层能带 Bearer token。 + authHydrateVersionRef.current += 1; setUser(nextUser); setStatus('ready'); }, []); @@ -141,6 +143,7 @@ export function AuthGate({ children }: AuthGateProps) { const clearLocalAuthenticatedState = useCallback(() => { // 退出动作必须先收回前端鉴权上下文,再等待后端吊销完成。 // 否则平台壳层会在无刷新状态下继续暴露旧用户的私有作品缓存。 + authHydrateVersionRef.current += 1; pendingProtectedActionRef.current = null; setUser(null); setStatus('unauthenticated'); @@ -268,11 +271,13 @@ export function AuthGate({ children }: AuthGateProps) { useEffect(() => { let isActive = true; - const hydrate = async () => { + const hydrate = async (hydrateToken: number) => { + const isCurrentHydrate = () => + isActive && hydrateToken === authHydrateVersionRef.current; const callbackResult = consumeAuthCallbackResult(); const loadLoginOptions = async () => { const options = await getAuthLoginOptions(); - if (!isActive) { + if (!isCurrentHydrate()) { return null; } @@ -285,14 +290,14 @@ export function AuthGate({ children }: AuthGateProps) { const resolveGuestFallback = async () => { try { await loadLoginOptions(); - if (!isActive) { + if (!isCurrentHydrate()) { return; } setUser(null); setStatus('unauthenticated'); } catch (optionsError) { - if (!isActive) { + if (!isCurrentHydrate()) { return; } @@ -305,7 +310,7 @@ export function AuthGate({ children }: AuthGateProps) { } }; - if (callbackResult?.error && isActive) { + if (callbackResult?.error && isCurrentHydrate()) { setError(callbackResult.error); setShowLoginModal(true); } @@ -315,8 +320,11 @@ export function AuthGate({ children }: AuthGateProps) { // 后端只在 refresh/session 成功续期时写每日登录埋点;如果本地 access token 尚未过期, // 仅调用 /auth/me 不会进入续期链路,导致“打开网页”没有登录埋点。 await refreshStoredAccessToken(); + if (!isCurrentHydrate()) { + return; + } const nextSession = await getCurrentAuthUser(); - if (!isActive) { + if (!isCurrentHydrate()) { return; } @@ -339,7 +347,7 @@ export function AuthGate({ children }: AuthGateProps) { ); setError(callbackResult?.error ?? ''); } catch { - if (!isActive) { + if (!isCurrentHydrate()) { return; } @@ -347,11 +355,11 @@ export function AuthGate({ children }: AuthGateProps) { } }; - void hydrate(); + void hydrate(++authHydrateVersionRef.current); const handleAuthStateChange = () => { setStatus('checking'); - void hydrate(); + void hydrate(++authHydrateVersionRef.current); }; window.addEventListener(AUTH_STATE_EVENT, handleAuthStateChange);