diff --git a/docs/project-memory/shared-memory/decision-log.md b/docs/project-memory/shared-memory/decision-log.md index 6d94b0d8e..82b99b8be 100644 --- a/docs/project-memory/shared-memory/decision-log.md +++ b/docs/project-memory/shared-memory/decision-log.md @@ -4414,3 +4414,9 @@ - 目标边界:固定目标列表只登记现役 `image-editor:agent-sidebar`;管理员仍可直接输入其他通用 Gate Key。不得恢复 `creation-entry:*` 动态目标、入口公告、入口开关、旧作品可见性页面或任何旧模板接口。 - 运行语义:环境变量继续是画布 Agent 总开关,feature gate 只在总开关开启后做黑名单、白名单、标签和稳定百分比受众限制;本次不修改 SpacetimeDB schema、灰度优先级或后端契约。 - 验证方式:后台路由与灰度页面 Vitest、`npm run admin-web:typecheck`、定向 ESLint、`npm run check:encoding`、`git diff --check`。 + +## 2026-07-23 手机号认证统一使用国家码与纯号码双字段 + +- 决策:普通手机号认证请求统一使用可选 `countryCode` 与必填 `purePhoneNumber`,省略国家码时默认中国大陆 `86`,直接替换旧 `phone` 字段。前端把浏览器 E.164 自动填充值拆成这两个字段;后端先验证国家码,再复用纯手机号规范化并生成 E.164 存储。 +- 微信边界:小程序客户端仍只上传 `wechatPhoneCode`;`platform-auth` 必须要求微信成功响应中的 `phoneNumber`、`countryCode` 与 `purePhoneNumber` 均存在且非空,但只使用后两项执行国家码校验和 E.164 构造。腾讯官方仅说明境外 `phoneNumber` 会带区号,并未承诺 E.164 格式,中国号码示例中它与纯号码相同,因此不得校验 `phoneNumber == +{countryCode}{purePhoneNumber}`。微信字段缺失时失败关闭,不能使用普通请求的 `86` 默认值。 +- 数据边界:认证投影与 SpacetimeDB 的 `phone_number_e164` 保持不变,不新增国家码或纯号码列,也不需要 schema 迁移或 bindings 生成。 diff --git a/docs/project-memory/shared-memory/pitfalls.md b/docs/project-memory/shared-memory/pitfalls.md index db9c44746..e31c1c8cd 100644 --- a/docs/project-memory/shared-memory/pitfalls.md +++ b/docs/project-memory/shared-memory/pitfalls.md @@ -1807,6 +1807,14 @@ - 验证:即使 `/api/auth/login-options` 返回空、失败或只返回 `["password"]`,登录弹窗也应同时显示 `短信登录`、`密码登录`、`验证码` 输入和“获取验证码”按钮;短信发送真实可用性再通过 `POST /api/auth/phone/send-code` 验证。 - 关联:`src/components/auth/AuthGate.tsx`、`src/components/auth/LoginScreen.tsx`、`src/components/auth/AuthGate.test.tsx`、`scripts/dev-utils.mjs`、`scripts/dev.mjs`。 +## 浏览器自动填充手机号带 `+86` + +- 现象:登录弹窗的手机号被浏览器回填为 `+86 1xxxxxxxxxx`,点击获取验证码或登录后返回“手机号格式不正确”。 +- 原因:`autocomplete="tel"` 允许浏览器回填含国家码的完整电话号码,`inputMode="numeric"` 只提示软键盘布局,不会过滤自动填充;如果把完整号码和纯号码混在一个 `phone` 字段中,微信 `purePhoneNumber` 又与 `countryCode` 分开传递,后端容易在国家码丢失后把境外号码误判为 `+86`。 +- 处理:手机号字段保留 `autocomplete="tel"`;`authService` 在请求前把 `+86 1xxxxxxxxxx`、`86 1xxxxxxxxxx` 拆为 `countryCode=86 + purePhoneNumber=1xxxxxxxxxx`。普通认证请求缺少 `countryCode` 时默认 `86`,但微信授权必须使用 provider 真实返回的 `countryCode + purePhoneNumber`,不能默认国家码。`module-auth` 先校验国家码,再用原纯手机号规则校验 `purePhoneNumber` 并生成 E.164;数据库仍只保存 E.164。 +- 验证:`cargo test -p module-auth --manifest-path server-rs/Cargo.toml`、定向 `api-server` 认证测试和 `npm run test -- src/services/authService.test.ts src/components/auth/AuthGate.test.tsx`,覆盖省略 / 显式 `86`、境外国家码、浏览器 `+86` 自动填充以及微信 provider 国家码路径。 +- 关联:`server-rs/crates/module-auth/src/domain.rs`、`server-rs/crates/module-auth/src/errors.rs`、`server-rs/crates/api-server/src/phone_auth.rs`、`server-rs/crates/api-server/src/wechat/auth.rs`、`src/services/authService.ts`、`src/components/auth/LoginScreen.tsx`。 + ## 本地短信收不到验证码先查 provider - 现象:登录弹窗可以进入短信页签,但点击“获取验证码”后,手机没有收到短信。 diff --git a/docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md b/docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md index 65f9c6f32..80a9ade1f 100644 --- a/docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md +++ b/docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md @@ -101,6 +101,7 @@ npm run check:server-rs-ddd ### 认证态用户与会话摘要下发口径 +- `/api/auth/entry`、`/api/auth/phone/*`、`/api/auth/password/reset` 与 `/api/auth/wechat/bind-phone` 的普通手机号请求统一使用 `purePhoneNumber` 与可选 `countryCode`,不再接受旧 `phone` 字段;`countryCode` 未提供时默认中国大陆 `86`,显式值必须使用微信同口径的无加号国家码并且当前只允许 `86`。`module-auth` 先校验国家码,再复用纯手机号规范化规则,最终统一以 `+86` E.164 写入认证投影。微信小程序 `getPhoneNumber` 链路仍只接收客户端 `wechatPhoneCode`,后端必须要求微信 provider 成功响应中的 `phoneNumber`、`purePhoneNumber` 与 `countryCode` 均存在且非空,并只使用后两项执行国家码校验和 E.164 构造;腾讯未承诺 `phoneNumber` 为 E.164,不得依赖其前缀格式,也不得在微信链路默认 `86`。 - `AuthUserPayload` / `AuthUser` 只保留前端当前会用到的身份与绑定展示字段:`id`、`publicUserCode`、`displayName`、`avatarUrl`、`phoneNumber`、`phoneNumberMasked`、`loginMethod`、`bindingStatus`、`wechatBound`、`wechatDisplayName`、`wechatAccount`。账号信息面板展示微信绑定时优先使用 `wechatDisplayName`;该字段只能来自微信平台 profile、历史已保存的微信身份资料,或小程序原生 `input type="nickname"` 提交的 `displayName`,不得用系统账号显示名或“微信旅人”这类假昵称兜底。小程序 `/api/auth/wechat/miniprogram-login` 与 `/api/auth/wechat/bind-phone` 可接收 `displayName`;`/api/auth/wechat/miniprogram-login` 额外返回 `created`,供小程序壳在快捷登录后判断是否需要补采集微信昵称。`jscode2session` 无法直接返回微信昵称或个人微信号,只能稳定拿到小程序维度 `openid`,后端以 `wechatAccount` 下发可区分的绑定账号标识,前端在缺少真实昵称时展示账号尾号。 - `AuthSessionSummaryPayload` / `AuthSessionSummary` 只保留设备卡片与撤销需要的摘要字段:`sessionId`、`sessionIds`、`sessionCount`、`clientLabel`、`ipMasked`、`isCurrent`、`createdAt`、`lastSeenAt`、`expiresAt`。 - 设备诊断信息(例如原始 `clientType` / `clientRuntime` / `clientPlatform` / `userAgent` / `miniProgramAppId` / `miniProgramEnv` / `deviceDisplayName`)不再默认下发到前端;若未来确需展示,优先单独加窄 DTO,而不是把账号 / 会话快照恢复为全量对象。 diff --git a/docs/【项目基线】当前产品与工程约束-2026-05-15.md b/docs/【项目基线】当前产品与工程约束-2026-05-15.md index 1d40696c2..082347839 100644 --- a/docs/【项目基线】当前产品与工程约束-2026-05-15.md +++ b/docs/【项目基线】当前产品与工程约束-2026-05-15.md @@ -55,6 +55,7 @@ Genarrative / 陶泥儿是一个 AI 原生互动内容与小游戏平台。当 9. 账号信息面板只展示 `账号信息` 标题;绑定手机号和绑定微信以紧凑模块展示当前绑定状态,已绑定手机号展示完整手机号,已绑定微信优先展示微信平台实际返回并由后端保存的 `wechatDisplayName`。小程序 `jscode2session` 不能直接返回微信昵称或个人微信号,只能稳定拿到当前小程序维度的 `openid`,并在满足微信开放平台条件时拿到 `unionid`;小程序昵称来自快捷登录后按需展示的原生 `input type="nickname"` 提交的 `displayName`。后端下发 `wechatAccount` 作为绑定账号标识,前端在没有真实昵称时展示微信账号尾号,不展示裸“已绑定”。换绑入口放在对应模块右上角,退出登录和退出全部设备固定放在面板内容最底部。 10. H5 登录态从未登录变为已登录,或从已登录变为未登录后,必须刷新当前页面一次,确保推荐运行态、作品架、个人缓存和私有 query 都按新身份重新初始化;普通 access token 续期、账号资料更新和同一登录态内的设置变化不得触发整页刷新。 11. 同一账号允许多端同时在线。新增登录和单设备退出只影响对应 refresh session,不得提升账号级 `tokenVersion` 让其它设备的 access token 失效;只有“退出全部设备”、修改密码、重置密码等明确安全动作才吊销全端 refresh session 并提升 `tokenVersion`。 +12. 手机号认证只支持中国大陆号码:验证码、密码登录、绑定、换绑和重置密码请求统一提交 `purePhoneNumber` 与可选 `countryCode`,省略国家码时默认 `86`,旧 `phone` 字段不再接受;显式国家码必须为无加号的 `86`,其他值返回“仅支持中国大陆手机号(+86)”。主站输入框保留 `autocomplete="tel"` 与浏览器默认电话号码回填能力,认证 service 把浏览器可能回填的 `+86 1xxxxxxxxxx` 或 `86 1xxxxxxxxxx` 拆成 `{ countryCode: "86", purePhoneNumber: "1xxxxxxxxxx" }` 后提交。微信小程序手机号授权必须使用微信真实返回的 `countryCode + purePhoneNumber`,不得套用普通请求的缺省国家码。后端分别验证国家码和纯号码后再生成 E.164 存储;前端校验只提供即时反馈,`inputMode="numeric"` 也只提示软键盘布局。 ## 账户与充值 diff --git a/packages/shared/src/contracts/auth.ts b/packages/shared/src/contracts/auth.ts index 5bb17107b..a49ffde03 100644 --- a/packages/shared/src/contracts/auth.ts +++ b/packages/shared/src/contracts/auth.ts @@ -27,8 +27,12 @@ export type PublicUserSearchResponse = { user: PublicUserSummary; }; -export type AuthEntryRequest = { - phone: string; +export type AuthPhoneNumberInput = { + countryCode?: string; + purePhoneNumber: string; +}; + +export type AuthEntryRequest = AuthPhoneNumberInput & { password: string; }; @@ -55,8 +59,7 @@ export type AuthProfileUpdateResponse = { user: AuthUser; }; -export type AuthPasswordResetRequest = { - phone: string; +export type AuthPasswordResetRequest = AuthPhoneNumberInput & { code: string; newPassword: string; }; @@ -66,8 +69,7 @@ export type AuthPasswordResetResponse = { user: AuthUser; }; -export type AuthPhoneSendCodeRequest = { - phone: string; +export type AuthPhoneSendCodeRequest = AuthPhoneNumberInput & { scene?: 'login' | 'bind_phone' | 'change_phone' | 'reset_password'; captchaChallengeId?: string; captchaAnswer?: string; @@ -80,8 +82,7 @@ export type AuthPhoneSendCodeResponse = { providerRequestId: string | null; }; -export type AuthPhoneLoginRequest = { - phone: string; +export type AuthPhoneLoginRequest = AuthPhoneNumberInput & { code: string; inviteCode?: string; }; @@ -116,7 +117,8 @@ export type AuthWechatStartResponse = { }; export type AuthWechatBindPhoneRequest = { - phone?: string; + countryCode?: string; + purePhoneNumber?: string; code?: string; wechatPhoneCode?: string; displayName?: string; @@ -139,8 +141,7 @@ export type AuthWechatMiniProgramLoginResponse = { created: boolean; }; -export type AuthPhoneChangeRequest = { - phone: string; +export type AuthPhoneChangeRequest = AuthPhoneNumberInput & { code: string; }; diff --git a/server-rs/crates/api-server/src/app.rs b/server-rs/crates/api-server/src/app.rs index 5ae7f642d..ef49e1123 100644 --- a/server-rs/crates/api-server/src/app.rs +++ b/server-rs/crates/api-server/src/app.rs @@ -377,7 +377,7 @@ mod tests { .header("content-type", "application/json") .body(Body::from( serde_json::json!({ - "phone": phone_number, + "purePhoneNumber": phone_number, "password": password }) .to_string(), @@ -408,7 +408,7 @@ mod tests { .header("x-forwarded-for", forwarded_for) .body(Body::from( serde_json::json!({ - "phone": phone_number, + "purePhoneNumber": phone_number, "password": password }) .to_string(), @@ -2111,7 +2111,8 @@ mod tests { .header("content-type", "application/json") .body(Body::from( serde_json::json!({ - "phone": "13800138000", + "countryCode": "86", + "purePhoneNumber": "13800138000", "scene": "login" }) .to_string(), @@ -2147,6 +2148,52 @@ mod tests { ); } + #[tokio::test] + async fn send_phone_code_rejects_foreign_country_code() { + let config = AppConfig { + sms_auth_enabled: true, + ..AppConfig::default() + }; + let app = build_router(AppState::new(config).expect("state should build")); + + let response = app + .oneshot( + Request::builder() + .method("POST") + .uri("/api/auth/phone/send-code") + .header("content-type", "application/json") + .body(Body::from( + serde_json::json!({ + "countryCode": "1", + "purePhoneNumber": "12025550123", + "scene": "login" + }) + .to_string(), + )) + .expect("request should build"), + ) + .await + .expect("request should succeed"); + + assert_eq!(response.status(), StatusCode::BAD_REQUEST); + let body = response + .into_body() + .collect() + .await + .expect("body should collect") + .to_bytes(); + let payload: Value = serde_json::from_slice(&body).expect("body should be valid json"); + + assert_eq!( + payload["error"]["code"], + Value::String("BAD_REQUEST".to_string()) + ); + assert_eq!( + payload["error"]["message"], + Value::String("仅支持中国大陆手机号(+86)".to_string()) + ); + } + #[tokio::test] async fn send_phone_code_rejects_same_scene_during_cooldown() { let config = AppConfig { @@ -2164,7 +2211,7 @@ mod tests { .header("content-type", "application/json") .body(Body::from( serde_json::json!({ - "phone": "13800138000", + "purePhoneNumber": "13800138000", "scene": "login" }) .to_string(), @@ -2183,7 +2230,7 @@ mod tests { .header("content-type", "application/json") .body(Body::from( serde_json::json!({ - "phone": "13800138000", + "purePhoneNumber": "13800138000", "scene": "login" }) .to_string(), @@ -2243,7 +2290,7 @@ mod tests { .header("content-type", "application/json") .body(Body::from( serde_json::json!({ - "phone": "13800138000", + "purePhoneNumber": "13800138000", "scene": "login" }) .to_string(), @@ -2266,7 +2313,7 @@ mod tests { ) .body(Body::from( serde_json::json!({ - "phone": "13800138000", + "purePhoneNumber": "13800138000", "code": "123456" }) .to_string(), @@ -2329,7 +2376,7 @@ mod tests { .header("content-type", "application/json") .body(Body::from( serde_json::json!({ - "phone": "13900139000", + "purePhoneNumber": "13900139000", "scene": "login" }) .to_string(), @@ -2349,7 +2396,7 @@ mod tests { .header("content-type", "application/json") .body(Body::from( serde_json::json!({ - "phone": "13900139000", + "purePhoneNumber": "13900139000", "code": "123456" }) .to_string(), @@ -2377,7 +2424,7 @@ mod tests { .header("content-type", "application/json") .body(Body::from( serde_json::json!({ - "phone": "13900139000", + "purePhoneNumber": "13900139000", "scene": "login" }) .to_string(), @@ -2396,7 +2443,7 @@ mod tests { .header("content-type", "application/json") .body(Body::from( serde_json::json!({ - "phone": "13900139000", + "purePhoneNumber": "13900139000", "code": "123456" }) .to_string(), @@ -2438,7 +2485,7 @@ mod tests { .header("content-type", "application/json") .body(Body::from( serde_json::json!({ - "phone": "13600136000", + "purePhoneNumber": "13600136000", "scene": "login" }) .to_string(), @@ -2457,7 +2504,7 @@ mod tests { .header("content-type", "application/json") .body(Body::from( serde_json::json!({ - "phone": "13600136000", + "purePhoneNumber": "13600136000", "code": "123456", "inviteCode": "SPRING2026" }) @@ -2503,7 +2550,7 @@ mod tests { .header("content-type", "application/json") .body(Body::from( serde_json::json!({ - "phone": "13500135000", + "purePhoneNumber": "13500135000", "scene": "login" }) .to_string(), @@ -2523,7 +2570,7 @@ mod tests { .header("content-type", "application/json") .body(Body::from( serde_json::json!({ - "phone": "13500135000", + "purePhoneNumber": "13500135000", "code": "123456" }) .to_string(), @@ -2543,7 +2590,7 @@ mod tests { .header("content-type", "application/json") .body(Body::from( serde_json::json!({ - "phone": "13500135000", + "purePhoneNumber": "13500135000", "scene": "login" }) .to_string(), @@ -2562,7 +2609,7 @@ mod tests { .header("content-type", "application/json") .body(Body::from( serde_json::json!({ - "phone": "13500135000", + "purePhoneNumber": "13500135000", "code": "123456", "inviteCode": "SPRING2026" }) @@ -2604,7 +2651,7 @@ mod tests { .header("content-type", "application/json") .body(Body::from( serde_json::json!({ - "phone": "13700137000", + "purePhoneNumber": "13700137000", "scene": "login" }) .to_string(), @@ -2625,7 +2672,7 @@ mod tests { .header("content-type", "application/json") .body(Body::from( serde_json::json!({ - "phone": "13700137000", + "purePhoneNumber": "13700137000", "code": "000000" }) .to_string(), @@ -2646,7 +2693,7 @@ mod tests { .header("content-type", "application/json") .body(Body::from( serde_json::json!({ - "phone": "13700137000", + "purePhoneNumber": "13700137000", "code": "000000" }) .to_string(), @@ -2679,7 +2726,7 @@ mod tests { .header("content-type", "application/json") .body(Body::from( serde_json::json!({ - "phone": "13700137000", + "purePhoneNumber": "13700137000", "code": "123456" }) .to_string(), @@ -2699,7 +2746,7 @@ mod tests { .header("content-type", "application/json") .body(Body::from( serde_json::json!({ - "phone": "13700137000", + "purePhoneNumber": "13700137000", "scene": "login" }) .to_string(), @@ -2718,7 +2765,7 @@ mod tests { .header("content-type", "application/json") .body(Body::from( serde_json::json!({ - "phone": "13700137000", + "purePhoneNumber": "13700137000", "code": "123456" }) .to_string(), @@ -3213,7 +3260,7 @@ mod tests { .header("content-type", "application/json") .body(Body::from( serde_json::json!({ - "phone": "13800138000", + "purePhoneNumber": "13800138000", "scene": "login" }) .to_string(), @@ -3233,7 +3280,7 @@ mod tests { .header("content-type", "application/json") .body(Body::from( serde_json::json!({ - "phone": "13800138000", + "purePhoneNumber": "13800138000", "code": "123456" }) .to_string(), @@ -3327,7 +3374,7 @@ mod tests { .header("content-type", "application/json") .body(Body::from( serde_json::json!({ - "phone": "13800138000", + "purePhoneNumber": "13800138000", "scene": "bind_phone" }) .to_string(), @@ -3348,7 +3395,7 @@ mod tests { .header("content-type", "application/json") .body(Body::from( serde_json::json!({ - "phone": "13800138000", + "purePhoneNumber": "13800138000", "code": "123456" }) .to_string(), @@ -3409,7 +3456,7 @@ mod tests { .header("content-type", "application/json") .body(Body::from( serde_json::json!({ - "phone": "13800138000", + "purePhoneNumber": "13800138000", "scene": "login" }) .to_string(), @@ -3429,7 +3476,7 @@ mod tests { .header("content-type", "application/json") .body(Body::from( serde_json::json!({ - "phone": "13800138000", + "purePhoneNumber": "13800138000", "code": "123456" }) .to_string(), @@ -3575,7 +3622,7 @@ mod tests { .header("x-client-instance-id", "chrome-instance-001") .body(Body::from( serde_json::json!({ - "phone": "13800138013", + "purePhoneNumber": "13800138013", "password": TEST_PASSWORD }) .to_string(), @@ -3619,7 +3666,7 @@ mod tests { .header("user-agent", "Mozilla/5.0 Chrome/123.0 MicroMessenger") .body(Body::from( serde_json::json!({ - "phone": "13800138013", + "purePhoneNumber": "13800138013", "password": TEST_PASSWORD }) .to_string(), @@ -3679,7 +3726,7 @@ mod tests { seed_phone_user_with_password(&state, "13800138028", TEST_PASSWORD).await; let app = build_router(state); let login_body = serde_json::json!({ - "phone": "13800138028", + "purePhoneNumber": "13800138028", "password": TEST_PASSWORD }) .to_string(); @@ -3836,7 +3883,7 @@ mod tests { .header("content-type", "application/json") .body(Body::from( serde_json::json!({ - "phone": "13800138026", + "purePhoneNumber": "13800138026", "scene": "reset_password" }) .to_string(), @@ -3856,7 +3903,7 @@ mod tests { .header("content-type", "application/json") .body(Body::from( serde_json::json!({ - "phone": "13800138026", + "purePhoneNumber": "13800138026", "code": "123456", "newPassword": "secret456" }) @@ -3979,7 +4026,7 @@ mod tests { .header("content-type", "application/json") .body(Body::from( serde_json::json!({ - "phone": "user@example.com", + "purePhoneNumber": "user@example.com", "password": TEST_PASSWORD }) .to_string(), @@ -4571,7 +4618,7 @@ mod tests { ) .body(Body::from( serde_json::json!({ - "phone": "13800138020", + "purePhoneNumber": "13800138020", "password": TEST_PASSWORD }) .to_string(), @@ -4610,7 +4657,7 @@ mod tests { .header("x-client-instance-id", "logout-all-instance-002") .body(Body::from( serde_json::json!({ - "phone": "13800138020", + "purePhoneNumber": "13800138020", "password": TEST_PASSWORD }) .to_string(), diff --git a/server-rs/crates/api-server/src/auth_public_user.rs b/server-rs/crates/api-server/src/auth_public_user.rs index d1a641adc..e07b4f6ab 100644 --- a/server-rs/crates/api-server/src/auth_public_user.rs +++ b/server-rs/crates/api-server/src/auth_public_user.rs @@ -65,6 +65,7 @@ fn map_public_user_search_error(error: module_auth::PasswordEntryError) -> AppEr module_auth::PasswordEntryError::Store(_) | module_auth::PasswordEntryError::PasswordHash(_) | module_auth::PasswordEntryError::InvalidPhoneNumber + | module_auth::PasswordEntryError::UnsupportedPhoneCountryCode | module_auth::PasswordEntryError::InvalidPasswordLength | module_auth::PasswordEntryError::InvalidDisplayName | module_auth::PasswordEntryError::InvalidAvatarDataUrl diff --git a/server-rs/crates/api-server/src/creation_agent_document_input.rs b/server-rs/crates/api-server/src/creation_agent_document_input.rs index cb0bd80bd..10d82001c 100644 --- a/server-rs/crates/api-server/src/creation_agent_document_input.rs +++ b/server-rs/crates/api-server/src/creation_agent_document_input.rs @@ -352,7 +352,8 @@ mod tests { .phone_auth_service() .send_code( SendPhoneCodeInput { - phone_number: phone_number.to_string(), + country_code: None, + pure_phone_number: phone_number.to_string(), scene: PhoneAuthScene::Login, }, now, @@ -363,7 +364,8 @@ mod tests { .phone_auth_service() .login( PhoneLoginInput { - phone_number: phone_number.to_string(), + country_code: None, + pure_phone_number: phone_number.to_string(), verify_code: "123456".to_string(), }, now + time::Duration::seconds(1), diff --git a/server-rs/crates/api-server/src/password_entry.rs b/server-rs/crates/api-server/src/password_entry.rs index eaa05fa86..f2e82660e 100644 --- a/server-rs/crates/api-server/src/password_entry.rs +++ b/server-rs/crates/api-server/src/password_entry.rs @@ -28,7 +28,8 @@ pub async fn password_entry( Json(payload): Json, ) -> Result { let input = PasswordEntryInput { - phone_number: payload.phone, + country_code: payload.country_code, + pure_phone_number: payload.pure_phone_number, password: payload.password, }; let result = if state.config.dev_password_entry_auto_register_enabled { @@ -88,8 +89,15 @@ fn map_password_entry_error(error: PasswordEntryError) -> AppError { PasswordEntryError::InvalidPhoneNumber => AppError::from_status(StatusCode::BAD_REQUEST) .with_message("手机号格式不正确") .with_details(json!({ - "field": "phone", + "field": "purePhoneNumber", })), + PasswordEntryError::UnsupportedPhoneCountryCode => { + AppError::from_status(StatusCode::BAD_REQUEST) + .with_message(error.to_string()) + .with_details(json!({ + "field": "countryCode", + })) + } PasswordEntryError::InvalidPasswordLength => AppError::from_status(StatusCode::BAD_REQUEST) .with_message("密码长度需要在 6 到 128 位之间") .with_details(json!({ @@ -98,7 +106,7 @@ fn map_password_entry_error(error: PasswordEntryError) -> AppError { PasswordEntryError::InvalidPublicUserCode => AppError::from_status(StatusCode::BAD_REQUEST) .with_message("陶泥号格式不正确") .with_details(json!({ - "field": "phone", + "field": "purePhoneNumber", })), PasswordEntryError::InvalidDisplayName | PasswordEntryError::InvalidAvatarDataUrl diff --git a/server-rs/crates/api-server/src/password_management.rs b/server-rs/crates/api-server/src/password_management.rs index 596140a2a..70f620791 100644 --- a/server-rs/crates/api-server/src/password_management.rs +++ b/server-rs/crates/api-server/src/password_management.rs @@ -85,7 +85,8 @@ pub async fn reset_password( .phone_auth_service() .reset_password( ResetPasswordInput { - phone_number: payload.phone, + country_code: payload.country_code, + pure_phone_number: payload.pure_phone_number, verify_code: payload.code, new_password: payload.new_password, }, @@ -135,7 +136,9 @@ pub async fn reset_password( fn map_password_management_error(error: PasswordEntryError) -> AppError { match error { - PasswordEntryError::InvalidPhoneNumber | PasswordEntryError::InvalidPublicUserCode => { + PasswordEntryError::InvalidPhoneNumber + | PasswordEntryError::UnsupportedPhoneCountryCode + | PasswordEntryError::InvalidPublicUserCode => { AppError::from_status(StatusCode::BAD_REQUEST).with_message(error.to_string()) } PasswordEntryError::InvalidDisplayName diff --git a/server-rs/crates/api-server/src/phone_auth.rs b/server-rs/crates/api-server/src/phone_auth.rs index 55b2cce3e..a0e9cd753 100644 --- a/server-rs/crates/api-server/src/phone_auth.rs +++ b/server-rs/crates/api-server/src/phone_auth.rs @@ -41,7 +41,7 @@ pub async fn send_phone_code( ); } let scene = map_phone_auth_scene(payload.scene.as_deref())?; - let phone_input_masked = mask_phone_input(payload.phone.as_str()); + let phone_input_masked = mask_phone_input(payload.pure_phone_number.as_str()); info!( request_id = request_context.request_id(), operation = request_context.operation(), @@ -54,7 +54,8 @@ pub async fn send_phone_code( .phone_auth_service() .send_code( SendPhoneCodeInput { - phone_number: payload.phone, + country_code: payload.country_code, + pure_phone_number: payload.pure_phone_number, scene: scene.clone(), }, OffsetDateTime::now_utc(), @@ -118,7 +119,8 @@ pub async fn phone_login( .phone_auth_service() .login( PhoneLoginInput { - phone_number: payload.phone, + country_code: payload.country_code, + pure_phone_number: payload.pure_phone_number, verify_code: payload.code, }, OffsetDateTime::now_utc(), @@ -300,6 +302,7 @@ fn mask_phone_digits(value: &str) -> String { pub fn map_phone_auth_error(error: PhoneAuthError) -> AppError { match error { PhoneAuthError::InvalidPhoneNumber + | PhoneAuthError::UnsupportedPhoneCountryCode | PhoneAuthError::InvalidVerifyCode | PhoneAuthError::VerifyCodeNotFound | PhoneAuthError::VerifyCodeExpired diff --git a/server-rs/crates/api-server/src/profile_identity.rs b/server-rs/crates/api-server/src/profile_identity.rs index 4a00cd1a7..fd91b8eb6 100644 --- a/server-rs/crates/api-server/src/profile_identity.rs +++ b/server-rs/crates/api-server/src/profile_identity.rs @@ -96,6 +96,7 @@ fn map_profile_update_error(error: PasswordEntryError) -> AppError { AppError::from_status(StatusCode::INTERNAL_SERVER_ERROR).with_message(error.to_string()) } PasswordEntryError::InvalidPhoneNumber + | PasswordEntryError::UnsupportedPhoneCountryCode | PasswordEntryError::InvalidPasswordLength | PasswordEntryError::InvalidPublicUserCode | PasswordEntryError::InvalidCredentials => { diff --git a/server-rs/crates/api-server/src/state.rs b/server-rs/crates/api-server/src/state.rs index 4e263c3f3..0edcdf386 100644 --- a/server-rs/crates/api-server/src/state.rs +++ b/server-rs/crates/api-server/src/state.rs @@ -1537,7 +1537,8 @@ impl AppState { self.phone_auth_service() .send_code( module_auth::SendPhoneCodeInput { - phone_number: phone_number.to_string(), + country_code: None, + pure_phone_number: phone_number.to_string(), scene: module_auth::PhoneAuthScene::Login, }, now, @@ -1548,7 +1549,8 @@ impl AppState { .phone_auth_service() .login( module_auth::PhoneLoginInput { - phone_number: phone_number.to_string(), + country_code: None, + pure_phone_number: phone_number.to_string(), verify_code: "123456".to_string(), }, now + time::Duration::seconds(1), diff --git a/server-rs/crates/api-server/src/volcengine_speech.rs b/server-rs/crates/api-server/src/volcengine_speech.rs index e6a5f7464..5b30638cd 100644 --- a/server-rs/crates/api-server/src/volcengine_speech.rs +++ b/server-rs/crates/api-server/src/volcengine_speech.rs @@ -506,7 +506,7 @@ mod tests { .header("content-type", "application/json") .body(Body::from( json!({ - "phone": "13800138088", + "purePhoneNumber": "13800138088", "password": "Password123" }) .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 7496ad4e1..420438146 100644 --- a/server-rs/crates/api-server/src/wechat/auth.rs +++ b/server-rs/crates/api-server/src/wechat/auth.rs @@ -270,14 +270,15 @@ pub async fn bind_wechat_phone( .phone_auth_service() .bind_wechat_verified_phone(BindWechatVerifiedPhoneInput { user_id: authenticated.claims().user_id().to_string(), - phone_number: phone_profile.phone_number, + country_code: phone_profile.country_code, + pure_phone_number: phone_profile.pure_phone_number, wechat_display_name: payload.display_name.clone(), }) .await .map_err(map_wechat_bind_phone_error)? } else { - let phone = payload - .phone + let pure_phone_number = payload + .pure_phone_number .as_deref() .map(str::trim) .filter(|value| !value.is_empty()) @@ -297,7 +298,8 @@ pub async fn bind_wechat_phone( .bind_wechat_phone( BindWechatPhoneInput { user_id: authenticated.claims().user_id().to_string(), - phone_number: phone.to_string(), + country_code: payload.country_code.clone(), + pure_phone_number: pure_phone_number.to_string(), verify_code: code.to_string(), wechat_display_name: payload.display_name.clone(), }, @@ -556,6 +558,7 @@ fn map_wechat_auth_error(error: WechatAuthError) -> AppError { fn map_wechat_bind_phone_error(error: module_auth::PhoneAuthError) -> AppError { match error { module_auth::PhoneAuthError::InvalidPhoneNumber + | module_auth::PhoneAuthError::UnsupportedPhoneCountryCode | module_auth::PhoneAuthError::InvalidVerifyCode | module_auth::PhoneAuthError::VerifyCodeNotFound | module_auth::PhoneAuthError::VerifyCodeExpired diff --git a/server-rs/crates/module-auth/src/commands.rs b/server-rs/crates/module-auth/src/commands.rs index 6706afe65..b014cb0a6 100644 --- a/server-rs/crates/module-auth/src/commands.rs +++ b/server-rs/crates/module-auth/src/commands.rs @@ -9,7 +9,8 @@ use crate::domain::{ #[derive(Clone, Debug, PartialEq, Eq)] pub struct PasswordEntryInput { - pub phone_number: String, + pub country_code: Option, + pub pure_phone_number: String, pub password: String, } @@ -22,7 +23,8 @@ pub struct ChangePasswordInput { #[derive(Clone, Debug, PartialEq, Eq)] pub struct ResetPasswordInput { - pub phone_number: String, + pub country_code: Option, + pub pure_phone_number: String, pub verify_code: String, pub new_password: String, } @@ -36,13 +38,15 @@ pub struct UpdateProfileInput { #[derive(Clone, Debug, PartialEq, Eq)] pub struct SendPhoneCodeInput { - pub phone_number: String, + pub country_code: Option, + pub pure_phone_number: String, pub scene: PhoneAuthScene, } #[derive(Clone, Debug, PartialEq, Eq)] pub struct PhoneLoginInput { - pub phone_number: String, + pub country_code: Option, + pub pure_phone_number: String, pub verify_code: String, } @@ -68,7 +72,8 @@ pub struct CreateWechatAuthStateInput { #[derive(Clone, Debug, PartialEq, Eq)] pub struct BindWechatPhoneInput { pub user_id: String, - pub phone_number: String, + pub country_code: Option, + pub pure_phone_number: String, pub verify_code: String, pub wechat_display_name: Option, } @@ -76,7 +81,8 @@ pub struct BindWechatPhoneInput { #[derive(Clone, Debug, PartialEq, Eq)] pub struct BindWechatVerifiedPhoneInput { pub user_id: String, - pub phone_number: String, + pub country_code: String, + pub pure_phone_number: String, pub wechat_display_name: Option, } diff --git a/server-rs/crates/module-auth/src/domain.rs b/server-rs/crates/module-auth/src/domain.rs index 1321b9824..e602d5dd5 100644 --- a/server-rs/crates/module-auth/src/domain.rs +++ b/server-rs/crates/module-auth/src/domain.rs @@ -13,6 +13,7 @@ pub const SMS_CODE_LENGTH: usize = 6; pub const SMS_CODE_TTL_MINUTES: i64 = 5; pub const SMS_CODE_COOLDOWN_SECONDS: u64 = 60; pub const SMS_CODE_MAX_FAILED_ATTEMPTS: u32 = 5; +pub const MAINLAND_CHINA_COUNTRY_CODE: &str = "86"; /// 用户最近一次完成认证的入口类型。 #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] @@ -252,9 +253,9 @@ pub fn verify_sms_code_format(verify_code: &str) -> Result<(), PhoneAuthError> { } pub fn normalize_mainland_china_phone_number( - raw_phone_number: &str, + pure_phone_number: &str, ) -> Result { - let digits = raw_phone_number + let digits = pure_phone_number .trim() .chars() .filter(|character| character.is_ascii_digit()) @@ -269,6 +270,16 @@ pub fn normalize_mainland_china_phone_number( }) } +pub fn validate_mainland_china_country_code( + country_code: Option<&str>, +) -> Result<(), PhoneAuthError> { + match country_code { + None => Ok(()), + Some(country_code) if country_code.trim() == MAINLAND_CHINA_COUNTRY_CODE => Ok(()), + Some(_) => Err(PhoneAuthError::UnsupportedPhoneCountryCode), + } +} + pub fn mask_phone_number(phone_number: &str) -> String { format!("{}****{}", &phone_number[..3], &phone_number[7..11]) } diff --git a/server-rs/crates/module-auth/src/errors.rs b/server-rs/crates/module-auth/src/errors.rs index 1757606ca..75c4040e7 100644 --- a/server-rs/crates/module-auth/src/errors.rs +++ b/server-rs/crates/module-auth/src/errors.rs @@ -7,6 +7,7 @@ use std::{error::Error, fmt}; #[derive(Clone, Debug, PartialEq, Eq)] pub enum PasswordEntryError { InvalidPhoneNumber, + UnsupportedPhoneCountryCode, InvalidPasswordLength, InvalidDisplayName, InvalidAvatarDataUrl, @@ -21,6 +22,7 @@ pub enum PasswordEntryError { #[derive(Clone, Debug, PartialEq, Eq)] pub enum PhoneAuthError { InvalidPhoneNumber, + UnsupportedPhoneCountryCode, InvalidVerifyCode, VerifyCodeNotFound, VerifyCodeExpired, @@ -66,6 +68,7 @@ impl fmt::Display for PasswordEntryError { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { match self { Self::InvalidPhoneNumber => f.write_str("手机号格式不正确"), + Self::UnsupportedPhoneCountryCode => f.write_str("仅支持中国大陆手机号(+86)"), Self::InvalidPasswordLength => f.write_str("密码长度需要在 6 到 128 位之间"), Self::InvalidDisplayName => f.write_str("昵称格式不正确"), Self::InvalidAvatarDataUrl => f.write_str("头像图片格式不正确"), @@ -84,6 +87,7 @@ impl fmt::Display for PhoneAuthError { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { match self { Self::InvalidPhoneNumber => f.write_str("手机号格式不正确"), + Self::UnsupportedPhoneCountryCode => f.write_str("仅支持中国大陆手机号(+86)"), Self::InvalidVerifyCode => f.write_str("验证码错误"), Self::VerifyCodeNotFound => f.write_str("验证码不存在或已失效"), Self::VerifyCodeExpired => f.write_str("验证码已过期"), @@ -147,6 +151,7 @@ pub(crate) fn map_password_store_error(error: PasswordEntryError) -> RefreshSess match error { PasswordEntryError::Store(message) => RefreshSessionError::Store(message), PasswordEntryError::InvalidPhoneNumber + | PasswordEntryError::UnsupportedPhoneCountryCode | PasswordEntryError::InvalidPasswordLength | PasswordEntryError::InvalidDisplayName | PasswordEntryError::InvalidAvatarDataUrl @@ -165,6 +170,7 @@ pub(crate) fn map_password_error_to_phone_error(error: PasswordEntryError) -> Ph PasswordEntryError::Store(message) => PhoneAuthError::Store(message), PasswordEntryError::PasswordHash(message) => PhoneAuthError::PasswordHash(message), PasswordEntryError::InvalidPhoneNumber + | PasswordEntryError::UnsupportedPhoneCountryCode | PasswordEntryError::InvalidPasswordLength | PasswordEntryError::InvalidDisplayName | PasswordEntryError::InvalidAvatarDataUrl @@ -179,6 +185,7 @@ pub(crate) fn map_password_error_to_logout_error(error: PasswordEntryError) -> L match error { PasswordEntryError::Store(message) => LogoutError::Store(message), PasswordEntryError::InvalidPhoneNumber + | PasswordEntryError::UnsupportedPhoneCountryCode | PasswordEntryError::InvalidPasswordLength | PasswordEntryError::InvalidDisplayName | PasswordEntryError::InvalidAvatarDataUrl diff --git a/server-rs/crates/module-auth/src/lib.rs b/server-rs/crates/module-auth/src/lib.rs index 6682c8406..6336495d0 100644 --- a/server-rs/crates/module-auth/src/lib.rs +++ b/server-rs/crates/module-auth/src/lib.rs @@ -207,8 +207,10 @@ impl PasswordEntryService { input: PasswordEntryInput, ) -> Result { validate_password(&input.password)?; - let normalized_phone = normalize_mainland_china_phone_number(&input.phone_number) - .map_err(|_| PasswordEntryError::InvalidPhoneNumber)?; + validate_mainland_china_country_code(input.country_code.as_deref()) + .map_err(map_phone_number_error_to_password_error)?; + let normalized_phone = normalize_mainland_china_phone_number(&input.pure_phone_number) + .map_err(map_phone_number_error_to_password_error)?; let Some(existing_user) = self .store .find_by_phone_number_for_password(&normalized_phone.e164)? @@ -224,8 +226,10 @@ impl PasswordEntryService { input: PasswordEntryInput, ) -> Result { validate_password(&input.password)?; - let normalized_phone = normalize_mainland_china_phone_number(&input.phone_number) - .map_err(|_| PasswordEntryError::InvalidPhoneNumber)?; + validate_mainland_china_country_code(input.country_code.as_deref()) + .map_err(map_phone_number_error_to_password_error)?; + let normalized_phone = normalize_mainland_china_phone_number(&input.pure_phone_number) + .map_err(map_phone_number_error_to_password_error)?; if let Some(existing_user) = self .store .find_by_phone_number_for_password(&normalized_phone.e164)? @@ -508,7 +512,8 @@ impl PhoneAuthService { now: OffsetDateTime, ) -> Result { let scene = input.scene.clone(); - let normalized_phone = normalize_mainland_china_phone_number(&input.phone_number)?; + validate_mainland_china_country_code(input.country_code.as_deref())?; + let normalized_phone = normalize_mainland_china_phone_number(&input.pure_phone_number)?; let national_phone_number = build_national_phone_number(&normalized_phone.e164)?; let verify_code = self.generate_phone_verify_code(); info!( @@ -591,7 +596,8 @@ impl PhoneAuthService { input: PhoneLoginInput, now: OffsetDateTime, ) -> Result { - let normalized_phone = normalize_mainland_china_phone_number(&input.phone_number)?; + validate_mainland_china_country_code(input.country_code.as_deref())?; + let normalized_phone = normalize_mainland_china_phone_number(&input.pure_phone_number)?; verify_sms_code_format(&input.verify_code)?; let provider_out_id = self.verify_phone_code( &normalized_phone.e164, @@ -640,7 +646,8 @@ impl PhoneAuthService { input: ResetPasswordInput, now: OffsetDateTime, ) -> Result { - let normalized_phone = normalize_mainland_china_phone_number(&input.phone_number)?; + validate_mainland_china_country_code(input.country_code.as_deref())?; + let normalized_phone = normalize_mainland_china_phone_number(&input.pure_phone_number)?; verify_sms_code_format(&input.verify_code)?; validate_password(&input.new_password).map_err(map_password_error_to_phone_error)?; let provider_out_id = self.verify_phone_code( @@ -673,7 +680,8 @@ impl PhoneAuthService { input: BindWechatPhoneInput, now: OffsetDateTime, ) -> Result { - let normalized_phone = normalize_mainland_china_phone_number(&input.phone_number)?; + validate_mainland_china_country_code(input.country_code.as_deref())?; + let normalized_phone = normalize_mainland_china_phone_number(&input.pure_phone_number)?; verify_sms_code_format(&input.verify_code)?; self.verify_phone_code( &normalized_phone.e164, @@ -739,7 +747,8 @@ impl PhoneAuthService { &self, input: BindWechatVerifiedPhoneInput, ) -> Result { - let normalized_phone = normalize_mainland_china_phone_number(&input.phone_number)?; + validate_mainland_china_country_code(Some(&input.country_code))?; + let normalized_phone = normalize_mainland_china_phone_number(&input.pure_phone_number)?; let current_user = self .store .find_by_user_id(&input.user_id) @@ -2569,6 +2578,15 @@ fn map_sms_provider_error_to_phone_error(error: SmsProviderError) -> PhoneAuthEr } } +fn map_phone_number_error_to_password_error(error: PhoneAuthError) -> PasswordEntryError { + match error { + PhoneAuthError::UnsupportedPhoneCountryCode => { + PasswordEntryError::UnsupportedPhoneCountryCode + } + _ => PasswordEntryError::InvalidPhoneNumber, + } +} + async fn verify_stored_password_user( existing_user: StoredPasswordUser, password: &str, @@ -2733,6 +2751,32 @@ mod tests { ); } + #[test] + fn mainland_china_phone_normalization_accepts_pure_phone_number() { + let national = normalize_mainland_china_phone_number("13800138000") + .expect("national phone should normalize"); + + assert_eq!(national.e164, "+8613800138000"); + } + + #[test] + fn mainland_china_country_code_defaults_to_china_and_accepts_explicit_86() { + validate_mainland_china_country_code(None).expect("missing country code should default"); + validate_mainland_china_country_code(Some(" 86 ")) + .expect("explicit mainland China country code should pass"); + } + + #[test] + fn mainland_china_country_code_rejects_foreign_or_non_wechat_format() { + for country_code in ["1", "+86", ""] { + let error = validate_mainland_china_country_code(Some(country_code)) + .expect_err("unsupported country code should fail"); + + assert_eq!(error, PhoneAuthError::UnsupportedPhoneCountryCode); + assert_eq!(error.to_string(), "仅支持中国大陆手机号(+86)"); + } + } + fn build_store() -> InMemoryAuthStore { InMemoryAuthStore::default() } @@ -2830,7 +2874,8 @@ mod tests { phone_service .send_code( SendPhoneCodeInput { - phone_number: phone_number.to_string(), + country_code: None, + pure_phone_number: phone_number.to_string(), scene: PhoneAuthScene::Login, }, now, @@ -2840,7 +2885,8 @@ mod tests { phone_service .login( PhoneLoginInput { - phone_number: phone_number.to_string(), + country_code: None, + pure_phone_number: phone_number.to_string(), verify_code: "123456".to_string(), }, now + Duration::seconds(1), @@ -2856,7 +2902,8 @@ mod tests { let error = service .execute(PasswordEntryInput { - phone_number: "13800138000".to_string(), + country_code: None, + pure_phone_number: "13800138000".to_string(), password: "secret123".to_string(), }) .await @@ -2871,21 +2918,24 @@ mod tests { let created = service .execute_with_dev_registration(PasswordEntryInput { - phone_number: "13800138009".to_string(), + country_code: None, + pure_phone_number: "13800138009".to_string(), password: "secret123".to_string(), }) .await .expect("dev registration should create user"); let reused = service .execute_with_dev_registration(PasswordEntryInput { - phone_number: "13800138009".to_string(), + country_code: None, + pure_phone_number: "13800138009".to_string(), password: "secret123".to_string(), }) .await .expect("same password should reuse created user"); let wrong_password = service .execute_with_dev_registration(PasswordEntryInput { - phone_number: "13800138009".to_string(), + country_code: None, + pure_phone_number: "13800138009".to_string(), password: "secret999".to_string(), }) .await @@ -2914,7 +2964,8 @@ mod tests { .expect("phone user should set first password"); let result = service .execute(PasswordEntryInput { - phone_number: "13800138000".to_string(), + country_code: None, + pure_phone_number: "13800138000".to_string(), password: "secret123".to_string(), }) .await @@ -3020,7 +3071,8 @@ mod tests { assert_eq!( password_service .execute(PasswordEntryInput { - phone_number: "13800138030".to_string(), + country_code: None, + pure_phone_number: "13800138030".to_string(), password: "secret123".to_string(), }) .await @@ -3029,7 +3081,8 @@ mod tests { ); let login = password_service .execute(PasswordEntryInput { - phone_number: "13800138030".to_string(), + country_code: None, + pure_phone_number: "13800138030".to_string(), password: "secret456".to_string(), }) .await @@ -3053,7 +3106,8 @@ mod tests { let error = service .execute(PasswordEntryInput { - phone_number: "13800138001".to_string(), + country_code: None, + pure_phone_number: "13800138001".to_string(), password: "secret999".to_string(), }) .await @@ -3070,7 +3124,8 @@ mod tests { phone_service .send_code( SendPhoneCodeInput { - phone_number: "13800138002".to_string(), + country_code: None, + pure_phone_number: "13800138002".to_string(), scene: PhoneAuthScene::ResetPassword, }, now, @@ -3081,7 +3136,8 @@ mod tests { let error = phone_service .reset_password( ResetPasswordInput { - phone_number: "13800138002".to_string(), + country_code: None, + pure_phone_number: "13800138002".to_string(), verify_code: "123456".to_string(), new_password: "secret123".to_string(), }, @@ -3099,7 +3155,8 @@ mod tests { let created = service .execute_with_dev_registration(PasswordEntryInput { - phone_number: "13800138004".to_string(), + country_code: None, + pure_phone_number: "13800138004".to_string(), password: "secret123".to_string(), }) .await @@ -3119,7 +3176,8 @@ mod tests { phone_service .send_code( SendPhoneCodeInput { - phone_number: "13800138005".to_string(), + country_code: None, + pure_phone_number: "13800138005".to_string(), scene: PhoneAuthScene::Login, }, now, @@ -3130,7 +3188,8 @@ mod tests { let created = phone_service .login( PhoneLoginInput { - phone_number: "13800138005".to_string(), + country_code: None, + pure_phone_number: "13800138005".to_string(), verify_code: "123456".to_string(), }, now + Duration::seconds(1), @@ -3164,7 +3223,8 @@ mod tests { phone_service .send_code( SendPhoneCodeInput { - phone_number: "13800138009".to_string(), + country_code: None, + pure_phone_number: "13800138009".to_string(), scene: PhoneAuthScene::Login, }, now, @@ -3175,7 +3235,8 @@ mod tests { let reused = phone_service .login( PhoneLoginInput { - phone_number: "13800138009".to_string(), + country_code: None, + pure_phone_number: "13800138009".to_string(), verify_code: "123456".to_string(), }, now + Duration::seconds(1), @@ -3263,7 +3324,8 @@ mod tests { local_phone_service .send_code( SendPhoneCodeInput { - phone_number: "13800138034".to_string(), + country_code: None, + pure_phone_number: "13800138034".to_string(), scene: PhoneAuthScene::Login, }, local_now, @@ -3286,7 +3348,8 @@ mod tests { local_phone_service .send_code( SendPhoneCodeInput { - phone_number: "13800138034".to_string(), + country_code: None, + pure_phone_number: "13800138034".to_string(), scene: PhoneAuthScene::Login, }, local_now + Duration::seconds(5), @@ -3304,7 +3367,8 @@ mod tests { phone_service .send_code( SendPhoneCodeInput { - phone_number: "13800138032".to_string(), + country_code: None, + pure_phone_number: "13800138032".to_string(), scene: PhoneAuthScene::Login, }, now, @@ -3314,7 +3378,8 @@ mod tests { let result = phone_service .login( PhoneLoginInput { - phone_number: "13800138032".to_string(), + country_code: None, + pure_phone_number: "13800138032".to_string(), verify_code: DEFAULT_SMS_MOCK_VERIFY_CODE.to_string(), }, now + Duration::seconds(1), @@ -3335,7 +3400,8 @@ mod tests { let error = service .execute(PasswordEntryInput { - phone_number: "user@example.com".to_string(), + country_code: None, + pure_phone_number: "user@example.com".to_string(), password: "secret123".to_string(), }) .await @@ -3344,6 +3410,22 @@ mod tests { assert_eq!(error, PasswordEntryError::InvalidPhoneNumber); } + #[tokio::test] + async fn password_entry_rejects_foreign_country_code() { + let service = build_password_service(build_store()); + + let error = service + .execute(PasswordEntryInput { + country_code: Some("1".to_string()), + pure_phone_number: "12025550123".to_string(), + password: "secret123".to_string(), + }) + .await + .expect_err("foreign phone should fail"); + + assert_eq!(error, PasswordEntryError::UnsupportedPhoneCountryCode); + } + #[tokio::test] async fn phone_send_code_rejects_same_scene_during_cooldown() { let service = build_phone_service(build_store()); @@ -3352,7 +3434,8 @@ mod tests { service .send_code( SendPhoneCodeInput { - phone_number: "13800138000".to_string(), + country_code: None, + pure_phone_number: "13800138000".to_string(), scene: PhoneAuthScene::Login, }, now, @@ -3363,7 +3446,8 @@ mod tests { let error = service .send_code( SendPhoneCodeInput { - phone_number: "13800138000".to_string(), + country_code: None, + pure_phone_number: "13800138000".to_string(), scene: PhoneAuthScene::Login, }, now + Duration::seconds(10), @@ -3387,7 +3471,8 @@ mod tests { service .send_code( SendPhoneCodeInput { - phone_number: "13800138000".to_string(), + country_code: None, + pure_phone_number: "13800138000".to_string(), scene: PhoneAuthScene::Login, }, now, @@ -3396,7 +3481,8 @@ mod tests { .expect("login scene code should send"); let bind_result = service.send_code( SendPhoneCodeInput { - phone_number: "13800138000".to_string(), + country_code: None, + pure_phone_number: "13800138000".to_string(), scene: PhoneAuthScene::BindPhone, }, now + Duration::seconds(1), @@ -3421,7 +3507,8 @@ mod tests { service .send_code( SendPhoneCodeInput { - phone_number: "13800138000".to_string(), + country_code: None, + pure_phone_number: "13800138000".to_string(), scene: PhoneAuthScene::Login, }, now, @@ -3433,7 +3520,8 @@ mod tests { let error = service .login( PhoneLoginInput { - phone_number: "13800138000".to_string(), + country_code: None, + pure_phone_number: "13800138000".to_string(), verify_code: "000000".to_string(), }, now + Duration::seconds(i64::from(attempt)), @@ -3446,7 +3534,8 @@ mod tests { let exhausted_error = service .login( PhoneLoginInput { - phone_number: "13800138000".to_string(), + country_code: None, + pure_phone_number: "13800138000".to_string(), verify_code: "000000".to_string(), }, now + Duration::seconds(i64::from(SMS_CODE_MAX_FAILED_ATTEMPTS)), @@ -3458,7 +3547,8 @@ mod tests { let missing_error = service .login( PhoneLoginInput { - phone_number: "13800138000".to_string(), + country_code: None, + pure_phone_number: "13800138000".to_string(), verify_code: DEFAULT_SMS_MOCK_VERIFY_CODE.to_string(), }, now + Duration::seconds(i64::from(SMS_CODE_MAX_FAILED_ATTEMPTS + 1)), @@ -3470,7 +3560,8 @@ mod tests { service .send_code( SendPhoneCodeInput { - phone_number: "13800138000".to_string(), + country_code: None, + pure_phone_number: "13800138000".to_string(), scene: PhoneAuthScene::Login, }, now + Duration::seconds(i64::from(SMS_CODE_MAX_FAILED_ATTEMPTS + 2)), @@ -3480,7 +3571,8 @@ mod tests { let login = service .login( PhoneLoginInput { - phone_number: "13800138000".to_string(), + country_code: None, + pure_phone_number: "13800138000".to_string(), verify_code: DEFAULT_SMS_MOCK_VERIFY_CODE.to_string(), }, now + Duration::seconds(i64::from(SMS_CODE_MAX_FAILED_ATTEMPTS + 3)), @@ -3930,7 +4022,8 @@ mod tests { phone_service .send_code( SendPhoneCodeInput { - phone_number: "13800138000".to_string(), + country_code: None, + pure_phone_number: "13800138000".to_string(), scene: PhoneAuthScene::Login, }, now, @@ -3940,7 +4033,8 @@ mod tests { let phone_user = phone_service .login( PhoneLoginInput { - phone_number: "13800138000".to_string(), + country_code: None, + pure_phone_number: "13800138000".to_string(), verify_code: "123456".to_string(), }, now + Duration::seconds(1), @@ -4009,7 +4103,8 @@ mod tests { phone_service .send_code( SendPhoneCodeInput { - phone_number: "13800138000".to_string(), + country_code: None, + pure_phone_number: "13800138000".to_string(), scene: PhoneAuthScene::Login, }, now, @@ -4019,7 +4114,8 @@ mod tests { let phone_user = phone_service .login( PhoneLoginInput { - phone_number: "13800138000".to_string(), + country_code: None, + pure_phone_number: "13800138000".to_string(), verify_code: "123456".to_string(), }, now + Duration::seconds(1), @@ -4055,7 +4151,8 @@ mod tests { phone_service .send_code( SendPhoneCodeInput { - phone_number: "13800138000".to_string(), + country_code: None, + pure_phone_number: "13800138000".to_string(), scene: PhoneAuthScene::BindPhone, }, now + Duration::seconds(2), @@ -4066,7 +4163,8 @@ mod tests { .bind_wechat_phone( BindWechatPhoneInput { user_id: wechat_user.id.clone(), - phone_number: "13800138000".to_string(), + country_code: None, + pure_phone_number: "13800138000".to_string(), verify_code: "123456".to_string(), wechat_display_name: None, }, @@ -4107,6 +4205,46 @@ mod tests { assert_eq!(reused_wechat_user.user.display_name, "已归并微信用户"); } + #[tokio::test] + async fn bind_wechat_verified_phone_rejects_foreign_country_before_account_merge() { + let store = build_store(); + let phone_service = build_phone_service(store.clone()); + let wechat_user = WechatAuthService::new(store.clone()) + .resolve_login(ResolveWechatLoginInput { + profile: WechatIdentityProfile { + provider_uid: "wx-openid-foreign-phone".to_string(), + provider_union_id: None, + display_name: Some("境外手机号用户".to_string()), + avatar_url: None, + session_key: None, + }, + }) + .await + .expect("wechat login should succeed") + .user; + + let error = phone_service + .bind_wechat_verified_phone(BindWechatVerifiedPhoneInput { + user_id: wechat_user.id.clone(), + country_code: "1".to_string(), + pure_phone_number: "12025550123".to_string(), + wechat_display_name: None, + }) + .await + .expect_err("foreign country code must fail before account merge"); + + assert_eq!(error, PhoneAuthError::UnsupportedPhoneCountryCode); + let unchanged_user = store + .find_by_user_id(&wechat_user.id) + .expect("user lookup should succeed") + .expect("pending wechat user should remain"); + assert_eq!( + unchanged_user.user.binding_status, + AuthBindingStatus::PendingBindPhone + ); + assert!(unchanged_user.user.phone_number.is_none()); + } + #[tokio::test] async fn bind_wechat_phone_merges_when_existing_phone_restored_from_projection() { let store = InMemoryAuthStore::from_projection_view(AuthStoreProjectionView { @@ -4140,7 +4278,8 @@ mod tests { phone_service .send_code( SendPhoneCodeInput { - phone_number: "13800138032".to_string(), + country_code: None, + pure_phone_number: "13800138032".to_string(), scene: PhoneAuthScene::BindPhone, }, now, @@ -4151,7 +4290,8 @@ mod tests { .bind_wechat_phone( BindWechatPhoneInput { user_id: wechat_user.id, - phone_number: "13800138032".to_string(), + country_code: None, + pure_phone_number: "13800138032".to_string(), verify_code: "123456".to_string(), wechat_display_name: None, }, @@ -4191,7 +4331,8 @@ mod tests { phone_service .send_code( SendPhoneCodeInput { - phone_number: "13800138031".to_string(), + country_code: None, + pure_phone_number: "13800138031".to_string(), scene: PhoneAuthScene::Login, }, now, @@ -4201,7 +4342,8 @@ mod tests { let phone_user = phone_service .login( PhoneLoginInput { - phone_number: "13800138031".to_string(), + country_code: None, + pure_phone_number: "13800138031".to_string(), verify_code: "123456".to_string(), }, now + Duration::seconds(1), @@ -4234,7 +4376,8 @@ mod tests { phone_service .send_code( SendPhoneCodeInput { - phone_number: "13800138031".to_string(), + country_code: None, + pure_phone_number: "13800138031".to_string(), scene: PhoneAuthScene::BindPhone, }, now + Duration::seconds(2), @@ -4245,7 +4388,8 @@ mod tests { .bind_wechat_phone( BindWechatPhoneInput { user_id: wechat_user.id.clone(), - phone_number: "13800138031".to_string(), + country_code: None, + pure_phone_number: "13800138031".to_string(), verify_code: "123456".to_string(), wechat_display_name: Some("补填微信昵称".to_string()), }, diff --git a/server-rs/crates/platform-auth/src/lib.rs b/server-rs/crates/platform-auth/src/lib.rs index 03898a03a..177e1df28 100644 --- a/server-rs/crates/platform-auth/src/lib.rs +++ b/server-rs/crates/platform-auth/src/lib.rs @@ -242,9 +242,8 @@ pub struct RealWechatProvider { #[derive(Clone, Debug, PartialEq, Eq)] pub struct WechatPhoneNumberProfile { - pub phone_number: String, - pub pure_phone_number: Option, - pub country_code: Option, + pub pure_phone_number: String, + pub country_code: String, } #[derive(Clone, Debug)] @@ -362,17 +361,44 @@ struct WechatPhoneNumberResponse { phone_info: Option, } +// 微信成功响应按官方文档必须包含这三个字段: +// https://developers.weixin.qq.com/miniprogram/dev/server/API/user-info/phone-number/api_getphonenumber.html#Res-phone-info-Object-Payload #[derive(Debug, Deserialize)] struct WechatPhoneNumberInfo { - #[serde(default)] - #[serde(alias = "phoneNumber")] - phone_number: Option, - #[serde(default)] - #[serde(alias = "purePhoneNumber")] - pure_phone_number: Option, - #[serde(default)] - #[serde(alias = "countryCode")] - country_code: Option, + #[serde(rename = "phoneNumber")] + phone_number: String, + #[serde(rename = "purePhoneNumber")] + pure_phone_number: String, + #[serde(rename = "countryCode")] + country_code: String, +} + +fn normalize_wechat_phone_number_info( + phone_info: WechatPhoneNumberInfo, +) -> Result { + let pure_phone_number = phone_info.pure_phone_number.trim(); + if pure_phone_number.is_empty() { + return Err(WechatProviderError::MissingProfile( + "微信手机号授权失败:缺少纯手机号".to_string(), + )); + } + let country_code = phone_info.country_code.trim(); + if country_code.is_empty() { + return Err(WechatProviderError::MissingProfile( + "微信手机号授权失败:缺少国家码".to_string(), + )); + } + let phone_number = phone_info.phone_number.trim(); + if phone_number.is_empty() { + return Err(WechatProviderError::MissingProfile( + "微信手机号授权失败:缺少完整手机号".to_string(), + )); + } + + Ok(WechatPhoneNumberProfile { + pure_phone_number: pure_phone_number.to_string(), + country_code: country_code.to_string(), + }) } #[derive(Debug, Deserialize)] @@ -792,9 +818,8 @@ impl WechatProvider { .unwrap_or("13800138000") .to_string(); Ok(WechatPhoneNumberProfile { - phone_number: phone_number.clone(), - pure_phone_number: Some(phone_number), - country_code: Some("86".to_string()), + pure_phone_number: phone_number, + country_code: "86".to_string(), }) } Self::Real(provider) => provider.resolve_mini_program_phone_number(code).await, @@ -1118,20 +1143,7 @@ impl RealWechatProvider { let phone_info = payload.phone_info.ok_or_else(|| { WechatProviderError::MissingProfile("微信手机号授权失败:缺少手机号信息".to_string()) })?; - let phone_number = phone_info - .pure_phone_number - .clone() - .or(phone_info.phone_number.clone()) - .filter(|value| !value.trim().is_empty()) - .ok_or_else(|| { - WechatProviderError::MissingProfile("微信手机号授权失败:缺少手机号".to_string()) - })?; - - Ok(WechatPhoneNumberProfile { - phone_number, - pure_phone_number: phone_info.pure_phone_number, - country_code: phone_info.country_code, - }) + normalize_wechat_phone_number_info(phone_info) } async fn request_mini_program_access_token( @@ -2074,7 +2086,7 @@ mod tests { "errcode": 0, "errmsg": "ok", "phone_info": { - "phoneNumber": "+8613800138000", + "phoneNumber": "13800138000", "purePhoneNumber": "13800138000", "countryCode": "86" } @@ -2083,9 +2095,54 @@ mod tests { .expect("wechat phone number response should parse"); let phone_info = payload.phone_info.expect("phone info should exist"); - assert_eq!(phone_info.phone_number.as_deref(), Some("+8613800138000")); - assert_eq!(phone_info.pure_phone_number.as_deref(), Some("13800138000")); - assert_eq!(phone_info.country_code.as_deref(), Some("86")); + assert_eq!(phone_info.phone_number, "13800138000"); + assert_eq!(phone_info.pure_phone_number, "13800138000"); + assert_eq!(phone_info.country_code, "86"); + let profile = normalize_wechat_phone_number_info(phone_info) + .expect("wechat phone profile should normalize"); + assert_eq!(profile.pure_phone_number, "13800138000"); + assert_eq!(profile.country_code, "86"); + } + + #[test] + fn wechat_phone_number_success_response_requires_country_code() { + let error = serde_json::from_str::( + r#"{ + "errcode": 0, + "phone_info": { + "phoneNumber": "+8613800138000", + "purePhoneNumber": "13800138000" + } + }"#, + ) + .expect_err("missing provider country code should fail deserialization"); + + assert!(error.to_string().contains("countryCode")); + } + + #[test] + fn wechat_phone_number_error_response_may_omit_phone_info() { + let payload = serde_json::from_str::( + r#"{ + "errcode": 40029, + "errmsg": "invalid code" + }"#, + ) + .expect("wechat error response should remain parseable"); + + assert!(payload.phone_info.is_none()); + } + + #[test] + fn wechat_phone_number_profile_requires_non_empty_phone_number() { + let error = normalize_wechat_phone_number_info(WechatPhoneNumberInfo { + phone_number: " ".to_string(), + pure_phone_number: "13800138000".to_string(), + country_code: "86".to_string(), + }) + .expect_err("empty provider phone number should fail closed"); + + assert!(matches!(error, WechatProviderError::MissingProfile(_))); } #[test] diff --git a/server-rs/crates/shared-contracts/src/auth.rs b/server-rs/crates/shared-contracts/src/auth.rs index a654414ec..8555480e4 100644 --- a/server-rs/crates/shared-contracts/src/auth.rs +++ b/server-rs/crates/shared-contracts/src/auth.rs @@ -47,7 +47,9 @@ pub struct PublicUserSearchResponse { #[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)] #[serde(rename_all = "camelCase")] pub struct PasswordEntryRequest { - pub phone: String, + #[serde(default)] + pub country_code: Option, + pub pure_phone_number: String, pub password: String, } @@ -87,7 +89,9 @@ pub struct ProfileUpdateResponse { #[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)] #[serde(rename_all = "camelCase")] pub struct PasswordResetRequest { - pub phone: String, + #[serde(default)] + pub country_code: Option, + pub pure_phone_number: String, pub code: String, pub new_password: String, } @@ -149,7 +153,9 @@ pub struct RevokeAuthSessionResponse { #[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)] #[serde(rename_all = "camelCase")] pub struct PhoneSendCodeRequest { - pub phone: String, + #[serde(default)] + pub country_code: Option, + pub pure_phone_number: String, pub scene: Option, } @@ -165,7 +171,9 @@ pub struct PhoneSendCodeResponse { #[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)] #[serde(rename_all = "camelCase")] pub struct PhoneLoginRequest { - pub phone: String, + #[serde(default)] + pub country_code: Option, + pub pure_phone_number: String, pub code: String, #[serde(default)] pub invite_code: Option, @@ -214,7 +222,9 @@ pub struct WechatCallbackQuery { #[serde(rename_all = "camelCase")] pub struct WechatBindPhoneRequest { #[serde(default)] - pub phone: Option, + pub country_code: Option, + #[serde(default)] + pub pure_phone_number: Option, #[serde(default)] pub code: Option, #[serde(default)] @@ -294,7 +304,8 @@ mod tests { #[test] fn password_entry_request_uses_camel_case_fields() { let payload = serde_json::to_value(PasswordEntryRequest { - phone: "13800138000".to_string(), + country_code: Some("86".to_string()), + pure_phone_number: "13800138000".to_string(), password: "secret123".to_string(), }) .expect("payload should serialize"); @@ -302,12 +313,32 @@ mod tests { assert_eq!( payload, json!({ - "phone": "13800138000", + "countryCode": "86", + "purePhoneNumber": "13800138000", "password": "secret123" }) ); } + #[test] + fn password_entry_request_defaults_country_code_and_rejects_legacy_phone_field() { + let request = serde_json::from_value::(json!({ + "purePhoneNumber": "13800138000", + "password": "secret123" + })) + .expect("country code should be optional"); + assert_eq!(request.country_code, None); + + let legacy = serde_json::from_value::(json!({ + "phone": "13800138000", + "password": "secret123" + })); + assert!( + legacy.is_err(), + "legacy phone field must not satisfy request" + ); + } + #[test] fn profile_update_request_uses_camel_case_fields() { let payload = serde_json::to_value(ProfileUpdateRequest { @@ -347,7 +378,8 @@ mod tests { #[test] fn wechat_bind_phone_request_accepts_mini_program_phone_code() { let payload = serde_json::to_value(WechatBindPhoneRequest { - phone: None, + country_code: None, + pure_phone_number: None, code: None, wechat_phone_code: Some("wx-phone-code-001".to_string()), display_name: Some("陶泥儿玩家".to_string()), @@ -357,7 +389,8 @@ mod tests { assert_eq!( payload, json!({ - "phone": null, + "countryCode": null, + "purePhoneNumber": null, "code": null, "wechatPhoneCode": "wx-phone-code-001", "displayName": "陶泥儿玩家" diff --git a/src/components/auth/AuthGate.test.tsx b/src/components/auth/AuthGate.test.tsx index 9715780b5..11cdae721 100644 --- a/src/components/auth/AuthGate.test.tsx +++ b/src/components/auth/AuthGate.test.tsx @@ -39,10 +39,9 @@ const authMocks = vi.hoisted(() => ({ })); vi.mock('../../services/apiClient', async () => { - const actual = - await vi.importActual( - '../../services/apiClient', - ); + const actual = await vi.importActual< + typeof import('../../services/apiClient') + >('../../services/apiClient'); return { ...actual, @@ -480,9 +479,7 @@ test('auth gate opens a login modal for protected actions and resumes after logi const phoneInput = within(dialog).getByLabelText( '手机号', ) as HTMLInputElement; - const codeInput = within(dialog).getByLabelText( - '验证码', - ) as HTMLInputElement; + const codeInput = within(dialog).getByLabelText('验证码') as HTMLInputElement; expect(phoneInput.className).toContain('platform-text-field'); expect(codeInput.className).toContain('platform-text-field'); @@ -937,6 +934,28 @@ test('auth gate shows sms send feedback in the login modal', async () => { expect(within(dialog).getByRole('button', { name: '60s' })).toBeTruthy(); }); +test('auth gate shows mainland China phone validation errors', async () => { + const user = userEvent.setup(); + authMocks.sendPhoneLoginCode.mockRejectedValueOnce( + new Error('仅支持中国大陆手机号(+86)'), + ); + + render( + + + , + ); + + await user.click(await screen.findByRole('button', { name: '进入作品' })); + const dialog = screen.getByRole('dialog', { name: '账号入口' }); + await user.type(within(dialog).getByLabelText('手机号'), '+12025550123'); + await user.click(within(dialog).getByRole('button', { name: '获取验证码' })); + + expect( + await within(dialog).findByText('仅支持中国大陆手机号(+86)'), + ).toBeTruthy(); +}); + test('login modal resets draft state every time it is reopened', async () => { const user = userEvent.setup(); diff --git a/src/components/auth/LoginScreen.tsx b/src/components/auth/LoginScreen.tsx index 669704f3d..15a94e055 100644 --- a/src/components/auth/LoginScreen.tsx +++ b/src/components/auth/LoginScreen.tsx @@ -530,7 +530,9 @@ function PhoneCodeForm({ tone="secondary" size="lg" className="shrink-0 text-sm" - onClick={() => void onSendCode()} + onClick={() => { + void onSendCode().catch(() => undefined); + }} > {sendingCode ? '发送中' @@ -624,7 +626,9 @@ function PasswordResetPanel({ tone="secondary" size="lg" className="shrink-0 text-sm" - onClick={() => void onSendCode()} + onClick={() => { + void onSendCode().catch(() => undefined); + }} > {sendingCode ? '发送中' diff --git a/src/services/authService.test.ts b/src/services/authService.test.ts index d275cf9f4..d01f5e814 100644 --- a/src/services/authService.test.ts +++ b/src/services/authService.test.ts @@ -20,10 +20,9 @@ vi.mock('./apiClient', async () => { }); vi.mock('./host-bridge/hostBridge', async () => { - const actual = - await vi.importActual( - './host-bridge/hostBridge', - ); + const actual = await vi.importActual< + typeof import('./host-bridge/hostBridge') + >('./host-bridge/hostBridge'); return { ...actual, openHostExternalUrl: hostBridgeMocks.openHostExternalUrl, @@ -49,6 +48,7 @@ import { liftAuthRiskBlock, loginWithPhoneCode, logoutAllAuthSessions, + normalizePhoneInput, redeemRegistrationInviteCode, requestWechatMiniProgramPhoneLogin, revokeAuthSession, @@ -57,6 +57,7 @@ import { startWechatBind, startWechatLogin, updateAuthProfile, + validateAndNormalizeMainlandChinaPhoneInput, } from './authService'; function createLocalStorageMock() { @@ -104,6 +105,26 @@ describe('authService', () => { clearStoredAccessToken({ emit: false }); }); + it('normalizes mainland China browser autofill phone numbers to national format', () => { + expect(normalizePhoneInput('+86 198 7654 3210')).toBe('19876543210'); + expect(normalizePhoneInput('86-198-7654-3210')).toBe('19876543210'); + expect(normalizePhoneInput('198 7654 3210')).toBe('19876543210'); + }); + + it('validates mainland China phone numbers before calling auth APIs', async () => { + expect( + validateAndNormalizeMainlandChinaPhoneInput('+86 198 7654 3210'), + ).toBe('19876543210'); + expect(validateAndNormalizeMainlandChinaPhoneInput('198 7654 3210')).toBe( + '19876543210', + ); + + await expect(sendPhoneLoginCode('+1 202 555 0123')).rejects.toThrow( + '仅支持中国大陆手机号(+86)', + ); + expect(apiClientMocks.requestJson).not.toHaveBeenCalled(); + }); + it('auth entry posts phone password credentials and 写入 access token', async () => { apiClientMocks.requestJson.mockResolvedValue({ token: 'jwt-entry-token', @@ -126,7 +147,8 @@ describe('authService', () => { '/api/auth/entry', expect.objectContaining({ body: JSON.stringify({ - phone: '13800138000', + countryCode: '86', + purePhoneNumber: '13800138000', password: 'secret123', }), }), @@ -217,14 +239,15 @@ describe('authService', () => { providerRequestId: 'mock-request-id', }); - const result = await sendPhoneLoginCode(' 138 0013 8000 '); + const result = await sendPhoneLoginCode('+86 138 0013 8000'); expect(result.cooldownSeconds).toBe(60); expect(apiClientMocks.requestJson).toHaveBeenCalledWith( '/api/auth/phone/send-code', expect.objectContaining({ body: JSON.stringify({ - phone: '13800138000', + countryCode: '86', + purePhoneNumber: '13800138000', scene: 'login', }), }), @@ -277,7 +300,7 @@ describe('authService', () => { }); const response = await loginWithPhoneCode( - '13800138000', + '+86 138 0013 8000', '123456', 'spring-2026', ); @@ -287,7 +310,8 @@ describe('authService', () => { '/api/auth/phone/login', expect.objectContaining({ body: JSON.stringify({ - phone: '13800138000', + countryCode: '86', + purePhoneNumber: '13800138000', code: '123456', inviteCode: 'SPRING2026', }), @@ -356,6 +380,17 @@ describe('authService', () => { const user = await bindWechatPhone('13800138000', '123456'); expect(user.wechatBound).toBe(true); + expect(apiClientMocks.requestJson).toHaveBeenCalledWith( + '/api/auth/wechat/bind-phone', + expect.objectContaining({ + body: JSON.stringify({ + countryCode: '86', + purePhoneNumber: '13800138000', + code: '123456', + }), + }), + '绑定手机号失败', + ); expect(getStoredAccessToken()).toBe('jwt-wechat-bind-token'); expect(window.dispatchEvent).not.toHaveBeenCalled(); }); @@ -377,6 +412,17 @@ describe('authService', () => { const user = await changePhoneNumber('13900139000', '123456'); expect(user.phoneNumberMasked).toBe('139****9000'); + expect(apiClientMocks.requestJson).toHaveBeenCalledWith( + '/api/auth/phone/change', + expect.objectContaining({ + body: JSON.stringify({ + countryCode: '86', + purePhoneNumber: '13900139000', + code: '123456', + }), + }), + '更换手机号失败', + ); expect(apiClientMocks.emitAuthStateChange).not.toHaveBeenCalled(); }); @@ -504,9 +550,11 @@ describe('authService', () => { }); it('requests mini program phone login by opening the native auth page', async () => { - const navigateTo = vi.fn((options: { url: string; success?: () => void }) => { - options.success?.(); - }); + const navigateTo = vi.fn( + (options: { url: string; success?: () => void }) => { + options.success?.(); + }, + ); vi.stubGlobal( 'window', createWindowMock({ @@ -555,16 +603,16 @@ describe('authService', () => { }); it('waits for an existing WeChat JS SDK script before opening the native auth page', async () => { - const navigateTo = vi.fn((options: { url: string; success?: () => void }) => { - options.success?.(); - }); + const navigateTo = vi.fn( + (options: { url: string; success?: () => void }) => { + options.success?.(); + }, + ); const scriptListeners = new Map(); const existingScript = { - addEventListener: vi.fn( - (type: string, listener: EventListener) => { - scriptListeners.set(type, listener); - }, - ), + addEventListener: vi.fn((type: string, listener: EventListener) => { + scriptListeners.set(type, listener); + }), }; vi.stubGlobal( 'window', diff --git a/src/services/authService.ts b/src/services/authService.ts index 8cb86f967..98936f574 100644 --- a/src/services/authService.ts +++ b/src/services/authService.ts @@ -67,9 +67,38 @@ const PUBLIC_AUTH_REQUEST_OPTIONS = { } satisfies ApiRequestOptions; const LAST_LOGIN_PHONE_STORAGE_KEY = 'genarrative:last-login-phone'; +const INVALID_MAINLAND_CHINA_PHONE_MESSAGE = '手机号格式不正确'; +const UNSUPPORTED_PHONE_COUNTRY_CODE_MESSAGE = '仅支持中国大陆手机号(+86)'; export function normalizePhoneInput(phoneInput: string) { - return phoneInput.replace(/[^\d+]/gu, '').trim(); + const compactPhone = phoneInput.trim().replace(/[^\d+]/gu, ''); + const mainlandChinaInternationalPhone = + compactPhone.match(/^\+?86(1\d{10})$/u); + + return mainlandChinaInternationalPhone?.[1] ?? compactPhone; +} + +export function validateAndNormalizeMainlandChinaPhoneInput( + phoneInput: string, +) { + const compactPhone = phoneInput.trim().replace(/[^\d+]/gu, ''); + if (compactPhone.startsWith('+') && !compactPhone.startsWith('+86')) { + throw new Error(UNSUPPORTED_PHONE_COUNTRY_CODE_MESSAGE); + } + + const normalizedPhone = normalizePhoneInput(phoneInput); + if (!/^1\d{10}$/u.test(normalizedPhone)) { + throw new Error(INVALID_MAINLAND_CHINA_PHONE_MESSAGE); + } + + return normalizedPhone; +} + +function buildMainlandChinaPhoneInput(phoneInput: string) { + return { + countryCode: '86', + purePhoneNumber: validateAndNormalizeMainlandChinaPhoneInput(phoneInput), + } as const; } export function normalizeInviteCodeInput(inviteCode: string | undefined) { @@ -92,10 +121,7 @@ export function setStoredLastLoginPhone(phone: string) { return; } - const normalizedPhone = normalizePhoneInput(phone); - if (!normalizedPhone) { - return; - } + const normalizedPhone = validateAndNormalizeMainlandChinaPhoneInput(phone); window.localStorage.setItem(LAST_LOGIN_PHONE_STORAGE_KEY, normalizedPhone); } @@ -146,7 +172,7 @@ export async function sendPhoneLoginCode( method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ - phone: normalizePhoneInput(phone), + ...buildMainlandChinaPhoneInput(phone), scene, captchaChallengeId: captcha?.challengeId?.trim() || undefined, captchaAnswer: captcha?.answer?.trim() || undefined, @@ -171,7 +197,7 @@ export async function loginWithPhoneCode( method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ - phone: normalizePhoneInput(phone), + ...buildMainlandChinaPhoneInput(phone), code: code.trim(), ...(normalizedInviteCode ? { inviteCode: normalizedInviteCode } : {}), }), @@ -200,7 +226,7 @@ export async function redeemRegistrationInviteCode(inviteCode: string) { export async function bindWechatPhone(phone: string, code: string) { const payload: AuthWechatBindPhoneRequest = { - phone: normalizePhoneInput(phone), + ...buildMainlandChinaPhoneInput(phone), code: code.trim(), }; const response = await requestJson( @@ -224,7 +250,7 @@ export async function changePhoneNumber(phone: string, code: string) { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ - phone: normalizePhoneInput(phone), + ...buildMainlandChinaPhoneInput(phone), code: code.trim(), }), }, @@ -289,7 +315,7 @@ export async function authEntry(phone: string, password: string) { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ - phone: normalizePhoneInput(phone), + ...buildMainlandChinaPhoneInput(phone), password: password.trim(), }), }, @@ -350,7 +376,7 @@ export async function resetPassword( method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ - phone: normalizePhoneInput(phone), + ...buildMainlandChinaPhoneInput(phone), code: code.trim(), newPassword: newPassword.trim(), }),