fix:前后端校验手机号区域码 (#104)
来自今早群友反映 before:  and failed after:    * 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:
@@ -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"` 也只提示软键盘布局。
|
||||
|
||||
## 账户与充值
|
||||
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
|
||||
@@ -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 => {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,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
@@ -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]
|
||||
|
||||
@@ -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": "陶泥儿玩家"
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -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
|
||||
? '发送中'
|
||||
|
||||
@@ -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
@@ -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(),
|
||||
}),
|
||||
|
||||
Reference in New Issue
Block a user