fix auth login state race

This commit is contained in:
2026-05-09 01:03:56 +08:00
parent 23ba2703b4
commit 9ca66715a4
11 changed files with 219 additions and 11 deletions

View File

@@ -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`

View File

@@ -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`

View File

@@ -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`

View File

@@ -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 占位链,以及前端点击路径、日志观察点和通过标准。

View File

@@ -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 {

View File

@@ -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())
}

View File

@@ -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())
}

View File

@@ -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),
}
}

View File

@@ -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),
}
}

View File

@@ -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(
<AuthGate>
<ProtectedActionButton onAuthenticated={onAuthenticated} />
<LogoutStateProbe />
</AuthGate>,
);
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');

View File

@@ -120,6 +120,7 @@ export function AuthGate({ children }: AuthGateProps) {
const pendingProtectedActionRef = useRef<(() => void) | null>(null);
const autoOpenedInviteCodeRef = useRef<string | null>(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);