fix:前后端校验手机号区域码 (#104)
Project CI / Frontend tests (push) Successful in 34s
Project CI / Backend tests (push) Failing after 39s
Project CI / Repository checks (push) Successful in 52s
Project CI / Native shell tests (push) Successful in 2m4s

来自今早群友反映
before:
![shotmd-1784692399.jpg](/attachments/3ab36252-78f4-44e2-bb51-fca84badc79e)
and failed
after:
![shotmd-1784702966.jpg](/attachments/3eea7ec9-8aca-43f6-a675-1316162619d4)
![shotmd-1784702843.jpg](/attachments/29182a41-26cf-4954-be16-de060d844d9c)
![shotmd-1784787134.jpg](/attachments/c04a1981-c6bf-4a26-b5ad-d2af51d74a29)

* https://developers.weixin.qq.com/miniprogram/dev/server/API/user-info/phone-number/api_getphonenumber.html#Res-phone-info-Object-Payload 参考微信这个文档修改了微信返回的struct 手机号 区域码字段不应是Optional

* 登录注册改密码改绑定等 api 把单一 phone字段改成 country_code(可选,缺省为86) + pure_phone_number 分别进行了前后端校验

Reviewed-on: https://git.genarrative.world/git/GenarrativeAI/Genarrative/pulls/104
Co-authored-by: 王德宇 <kvtodev@outlook.com>
Co-committed-by: 王德宇 <kvtodev@outlook.com>
This commit was merged in pull request #104.
This commit is contained in:
2026-07-23 14:52:57 +08:00
committed by 段舒康
parent b72e2163d7
commit 44748b7846
25 changed files with 650 additions and 208 deletions
@@ -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 生成。
@@ -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
- 现象:登录弹窗可以进入短信页签,但点击“获取验证码”后,手机没有收到短信。
@@ -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,而不是把账号 / 会话快照恢复为全量对象。
@@ -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"` 也只提示软键盘布局。
## 账户与充值
+12 -11
View File
@@ -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;
};
+84 -37
View File
@@ -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(),
@@ -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
@@ -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),
@@ -28,7 +28,8 @@ pub async fn password_entry(
Json(payload): Json<PasswordEntryRequest>,
) -> Result<impl IntoResponse, AppError> {
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
@@ -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
@@ -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
@@ -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 => {
+4 -2
View File
@@ -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),
@@ -506,7 +506,7 @@ mod tests {
.header("content-type", "application/json")
.body(Body::from(
json!({
"phone": "13800138088",
"purePhoneNumber": "13800138088",
"password": "Password123"
})
.to_string(),
@@ -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
+12 -6
View File
@@ -9,7 +9,8 @@ use crate::domain::{
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct PasswordEntryInput {
pub phone_number: String,
pub country_code: Option<String>,
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<String>,
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<String>,
pub pure_phone_number: String,
pub scene: PhoneAuthScene,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct PhoneLoginInput {
pub phone_number: String,
pub country_code: Option<String>,
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<String>,
pub pure_phone_number: String,
pub verify_code: String,
pub wechat_display_name: Option<String>,
}
@@ -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<String>,
}
+13 -2
View File
@@ -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<PhoneNumberSnapshot, PhoneAuthError> {
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])
}
@@ -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
File diff suppressed because it is too large Load Diff
+90 -33
View File
@@ -242,9 +242,8 @@ pub struct RealWechatProvider {
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct WechatPhoneNumberProfile {
pub phone_number: String,
pub pure_phone_number: Option<String>,
pub country_code: Option<String>,
pub pure_phone_number: String,
pub country_code: String,
}
#[derive(Clone, Debug)]
@@ -362,17 +361,44 @@ struct WechatPhoneNumberResponse {
phone_info: Option<WechatPhoneNumberInfo>,
}
// 微信成功响应按官方文档必须包含这三个字段:
// 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<String>,
#[serde(default)]
#[serde(alias = "purePhoneNumber")]
pure_phone_number: Option<String>,
#[serde(default)]
#[serde(alias = "countryCode")]
country_code: Option<String>,
#[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<WechatPhoneNumberProfile, WechatProviderError> {
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::<WechatPhoneNumberResponse>(
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::<WechatPhoneNumberResponse>(
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]
+42 -9
View File
@@ -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<String>,
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<String>,
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<String>,
pub pure_phone_number: String,
pub scene: Option<String>,
}
@@ -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<String>,
pub pure_phone_number: String,
pub code: String,
#[serde(default)]
pub invite_code: Option<String>,
@@ -214,7 +222,9 @@ pub struct WechatCallbackQuery {
#[serde(rename_all = "camelCase")]
pub struct WechatBindPhoneRequest {
#[serde(default)]
pub phone: Option<String>,
pub country_code: Option<String>,
#[serde(default)]
pub pure_phone_number: Option<String>,
#[serde(default)]
pub code: Option<String>,
#[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::<PasswordEntryRequest>(json!({
"purePhoneNumber": "13800138000",
"password": "secret123"
}))
.expect("country code should be optional");
assert_eq!(request.country_code, None);
let legacy = serde_json::from_value::<PasswordEntryRequest>(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": "陶泥儿玩家"
+26 -7
View File
@@ -39,10 +39,9 @@ const authMocks = vi.hoisted(() => ({
}));
vi.mock('../../services/apiClient', async () => {
const actual =
await vi.importActual<typeof import('../../services/apiClient')>(
'../../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(
<AuthGate>
<ProtectedActionButton onAuthenticated={vi.fn()} />
</AuthGate>,
);
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();
+6 -2
View File
@@ -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
? '发送中'
+68 -20
View File
@@ -20,10 +20,9 @@ vi.mock('./apiClient', async () => {
});
vi.mock('./host-bridge/hostBridge', async () => {
const actual =
await vi.importActual<typeof import('./host-bridge/hostBridge')>(
'./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<string, EventListener>();
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',
+37 -11
View File
@@ -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<AuthWechatBindPhoneResponse>(
@@ -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(),
}),