diff --git a/.env.example b/.env.example index 74c656dc..482669b7 100644 --- a/.env.example +++ b/.env.example @@ -103,6 +103,9 @@ WECHAT_REDIRECT_PATH="/" WECHAT_AUTHORIZE_ENDPOINT="https://open.weixin.qq.com/connect/qrconnect" WECHAT_ACCESS_TOKEN_ENDPOINT="https://api.weixin.qq.com/sns/oauth2/access_token" WECHAT_USER_INFO_ENDPOINT="https://api.weixin.qq.com/sns/userinfo" +WECHAT_JS_CODE_SESSION_ENDPOINT="https://api.weixin.qq.com/sns/jscode2session" +WECHAT_STABLE_ACCESS_TOKEN_ENDPOINT="https://api.weixin.qq.com/cgi-bin/stable_token" +WECHAT_PHONE_NUMBER_ENDPOINT="https://api.weixin.qq.com/wxa/business/getuserphonenumber" WECHAT_STATE_TTL_MINUTES="15" WECHAT_MOCK_USER_ID="wx-mock-user" WECHAT_MOCK_UNION_ID="wx-mock-union" diff --git a/.hermes/shared-memory/decision-log.md b/.hermes/shared-memory/decision-log.md index df2ae283..180d7fd8 100644 --- a/.hermes/shared-memory/decision-log.md +++ b/.hermes/shared-memory/decision-log.md @@ -16,6 +16,14 @@ --- +## 2026-05-12 微信小程序待绑定手机号优先走原生手机号授权 + +- 背景:微信小程序 `web-view` 壳登录后若返回 `pending_bind_phone`,H5 仍会展示手输手机号和短信验证码绑定页,体验上多了一步。 +- 决策:小程序壳在 `pending_bind_phone` 时暂不打开 H5,先展示原生 `button open-type="getPhoneNumber"`;用户同意后把 `bindgetphonenumber` 返回的 `code` 作为 `wechatPhoneCode` 调用 `/api/auth/wechat/bind-phone`。后端通过微信 `stable_token` 与 `getuserphonenumber` 换取平台验证后的手机号,再复用现有微信待绑定账号合并逻辑并重新签发 active 系统 token。H5 旧短信验证码绑定流程继续作为非小程序环境兜底。 +- 影响范围:`miniprogram/pages/web-view/index.*`、`server-rs/crates/platform-auth`、`server-rs/crates/api-server/src/wechat_auth.rs`、认证共享契约、微信小程序 web-view 壳技术文档。 +- 验证方式:执行 `npm run check:encoding`、`node scripts/check-wechat-miniprogram-auth-smoke.mjs`、`cargo test -p shared-contracts wechat_bind_phone_request_accepts_mini_program_phone_code --manifest-path server-rs/Cargo.toml`、`cargo test -p api-server wechat_miniprogram_bind_phone_code_activates_pending_user --manifest-path server-rs/Cargo.toml -- --nocapture`。 +- 关联文档:`docs/technical/WECHAT_MINIPROGRAM_WEB_VIEW_SHELL_2026-05-03.md`。 + ## 2026-05-11 拼图与抓大鹅结果页音频资产复用通用创作音频链路 - 背景:拼图和抓大鹅结果页需要接入 Suno 背景音乐,抓大鹅还需要物体点击音效,但当前两类作品没有独立的作品级音频表或 metadata 字段。 diff --git a/docs/technical/WECHAT_MINIPROGRAM_WEB_VIEW_SHELL_2026-05-03.md b/docs/technical/WECHAT_MINIPROGRAM_WEB_VIEW_SHELL_2026-05-03.md index d7d793cf..3584ec12 100644 --- a/docs/technical/WECHAT_MINIPROGRAM_WEB_VIEW_SHELL_2026-05-03.md +++ b/docs/technical/WECHAT_MINIPROGRAM_WEB_VIEW_SHELL_2026-05-03.md @@ -6,12 +6,13 @@ 本次先用微信小程序 `web-view` 承载现有 H5,不重写 React/Vite 主前端,也不把 SpacetimeDB SDK 或业务规则搬进小程序端。 -当前小程序壳只承担四件事: +当前小程序壳只承担五件事: 1. 提供微信开发者工具可识别的 `miniprogram/` 工程根目录。 2. 在原生小程序壳中调用 `wx.login` 获取小程序 `code`。 3. 调用服务器域名下的 `/api/auth/wechat/miniprogram-login`,由 Rust `api-server` 兑换微信身份并签发系统登录态。 -4. 用一个全屏 `web-view` 打开现有 H5 入口,并把系统 `auth_token` 放入 H5 现有登录回调 hash。 +4. 若后端返回 `pending_bind_phone`,先在小程序原生层通过 `button open-type="getPhoneNumber"` 取得用户同意后的手机号动态令牌,再调用 `/api/auth/wechat/bind-phone` 完成绑定。 +5. 用一个全屏 `web-view` 打开现有 H5 入口,并把系统 `auth_token` 放入 H5 现有登录回调 hash。 重要边界: @@ -56,6 +57,9 @@ WECHAT_AUTH_ENABLED=true WECHAT_AUTH_PROVIDER=real WECHAT_MINI_PROGRAM_APP_ID="你的微信小程序 AppID" WECHAT_MINI_PROGRAM_APP_SECRET="你的微信小程序 AppSecret" +WECHAT_JS_CODE_SESSION_ENDPOINT="https://api.weixin.qq.com/sns/jscode2session" +WECHAT_STABLE_ACCESS_TOKEN_ENDPOINT="https://api.weixin.qq.com/cgi-bin/stable_token" +WECHAT_PHONE_NUMBER_ENDPOINT="https://api.weixin.qq.com/wxa/business/getuserphonenumber" ``` 如果开放平台网页 OAuth 与小程序使用同一个 AppID/Secret,也可以继续使用已有: @@ -110,13 +114,28 @@ Content-Type: application/json - 再按 `openid` 命中已有身份 - 都没有命中时创建 `pending_bind_phone` 的微信壳账号 6. `api-server` 签发系统 access token,并写入 refresh session。 -7. 小程序壳打开: +7. 如果返回 `bindingStatus=active`,小程序壳打开: ```text -https://你的H5业务域名/#auth_provider=wechat&auth_token=<系统JWT>&auth_binding_status=active|pending_bind_phone +https://你的H5业务域名/#auth_provider=wechat&auth_token=<系统JWT>&auth_binding_status=active ``` -8. H5 复用 `consumeAuthCallbackResult()` 消费 `auth_token` 并进入现有登录态恢复流程。 +8. 如果返回 `bindingStatus=pending_bind_phone`,小程序壳暂不打开 H5,而是展示原生 `getPhoneNumber` 按钮。用户点击并同意后,小程序把 `bindgetphonenumber` 事件里的 `detail.code` 作为 `wechatPhoneCode` 传给: + +```http +POST /api/auth/wechat/bind-phone +Authorization: Bearer <小程序登录返回的系统JWT> +Content-Type: application/json + +{ + "wechatPhoneCode": "getPhoneNumber 返回的 code" +} +``` + +9. `api-server` 通过微信 `stable_token` 获取小程序 `access_token`,再调用 `getuserphonenumber` 换取平台验证后的手机号,并复用现有微信待绑定账号合并逻辑。成功后重新签发 `active` 系统 token。 +10. H5 复用 `consumeAuthCallbackResult()` 消费 `auth_token` 并进入现有登录态恢复流程。 + +补充:H5 里的旧短信验证码绑定页继续保留为非小程序环境兜底;小程序原生手机号授权只替代“手动输入手机号 + 短信验证码”这一步,不代表后台静默读取本机号码。 ## 5. 微信后台配置 @@ -153,7 +172,8 @@ npm run check:wechat-miniprogram-auth 1. 静态确认 `miniprogram/pages/web-view/index.js` 会请求 `/api/auth/wechat/miniprogram-login`,携带 `mini_program / wechat_mini_program` 客户端来源头,并把 `auth_provider/auth_token/auth_binding_status` 拼入 H5 hash。 2. 运行 `api-server` 定向测试 `wechat_miniprogram_login_returns_system_token_and_marks_session_source`,断言小程序登录返回 `token/bindingStatus/user`、写入 refresh cookie,并且 `/api/auth/sessions` 能看到 `clientType=mini_program`、`clientRuntime=wechat_mini_program`、`miniProgramAppId`。 -3. 运行前端 `authService` 定向测试,断言 `consumeAuthCallbackResult()` 会消费 `#auth_provider=wechat&auth_token=...&auth_binding_status=...`、保存 access token,并清理地址栏 hash。 +3. 静态确认小程序壳在 `pending_bind_phone` 时使用 `getPhoneNumber` 和 `wechatPhoneCode` 调用 `/api/auth/wechat/bind-phone`,而不是打开 H5 后再要求手输手机号。 +4. 运行前端 `authService` 定向测试,断言 `consumeAuthCallbackResult()` 会消费 `#auth_provider=wechat&auth_token=...&auth_binding_status=...`、保存 access token,并清理地址栏 hash。 手工联调仍按以下口径确认真实微信与域名配置: @@ -161,6 +181,7 @@ npm run check:wechat-miniprogram-auth 2. 未填写 `WEB_VIEW_ENTRY_URL` 或 `API_BASE_URL` 时,页面显示配置提示,不出现空白页。 3. 填写已配置业务域名后,小程序先请求 `/api/auth/wechat/miniprogram-login`。 4. 后端返回 `token/bindingStatus/user`,并写入 refresh cookie。 -5. 首页全屏打开 H5,URL hash 中包含 `auth_provider=wechat`、`auth_token`、`auth_binding_status`。 -6. H5 内 `consumeAuthCallbackResult()` 消费 hash 后,`/api/auth/me` 能返回当前用户。 -7. `/api/auth/sessions` 能看到来源为 `mini_program / wechat_mini_program` 的会话记录。 +5. 若返回 `pending_bind_phone`,先看到小程序原生授权手机号按钮;用户同意后,小程序请求 `/api/auth/wechat/bind-phone` 且请求体包含 `wechatPhoneCode`。 +6. 绑定成功后首页全屏打开 H5,URL hash 中包含 `auth_provider=wechat`、`auth_token`、`auth_binding_status=active`。 +7. H5 内 `consumeAuthCallbackResult()` 消费 hash 后,`/api/auth/me` 能返回当前用户。 +8. `/api/auth/sessions` 能看到来源为 `mini_program / wechat_mini_program` 的会话记录。 diff --git a/miniprogram/pages/web-view/index.js b/miniprogram/pages/web-view/index.js index 421f9cb6..40065664 100644 --- a/miniprogram/pages/web-view/index.js +++ b/miniprogram/pages/web-view/index.js @@ -155,6 +155,48 @@ function requestMiniProgramLogin(code) { }); } +function requestMiniProgramBindPhone(authToken, wechatPhoneCode) { + return new Promise((resolve, reject) => { + const apiBaseUrl = trimTrailingSlash(API_BASE_URL); + if (!isConfiguredApiBaseUrl(apiBaseUrl)) { + reject(new Error('请先配置 API_BASE_URL')); + return; + } + + wx.request({ + url: `${apiBaseUrl}/api/auth/wechat/bind-phone`, + method: 'POST', + data: { wechatPhoneCode }, + header: { + authorization: `Bearer ${authToken}`, + 'content-type': 'application/json', + 'x-client-type': MINI_PROGRAM_CLIENT_TYPE, + 'x-client-runtime': MINI_PROGRAM_CLIENT_RUNTIME, + 'x-client-platform': resolveClientPlatform(), + 'x-client-instance-id': getClientInstanceId(), + 'x-mini-program-app-id': MINI_PROGRAM_APP_ID, + 'x-mini-program-env': MINI_PROGRAM_ENV, + }, + success(response) { + if (response.statusCode >= 200 && response.statusCode < 300) { + resolve(response.data); + return; + } + const message = + response.data && + response.data.error && + response.data.error.message + ? response.data.error.message + : `绑定手机号失败:${response.statusCode}`; + reject(new Error(message)); + }, + fail(error) { + reject(new Error(error.errMsg || '绑定手机号请求失败')); + }, + }); + }); +} + async function resolveAuthResult() { const code = await wxLogin(); const response = await requestMiniProgramLogin(code); @@ -169,8 +211,11 @@ async function resolveAuthResult() { Page({ data: { + authResult: null, + bindingPhone: false, errorMessage: '', loading: true, + phoneBindingRequired: false, webViewUrl: '', }, @@ -196,27 +241,94 @@ Page({ try { const authResult = await resolveAuthResult(); + if (authResult.bindingStatus === 'pending_bind_phone') { + this.setData({ + authResult, + errorMessage: '', + loading: false, + phoneBindingRequired: true, + webViewUrl: '', + }); + return; + } + this.setData({ + authResult, errorMessage: '', loading: false, + phoneBindingRequired: false, webViewUrl: resolveWebViewUrl(authResult), }); } catch (error) { this.setData({ + authResult: null, errorMessage: error && error.message ? error.message : '微信登录失败,请稍后重试。', loading: false, + phoneBindingRequired: false, webViewUrl: '', }); } }, + async handleGetPhoneNumber(event) { + if (!this.data.authResult || !this.data.authResult.token) { + this.handleRetryLogin(); + return; + } + + const detail = event.detail || {}; + if (!detail.code) { + this.setData({ + errorMessage: detail.errMsg || '需要授权手机号后才能完成绑定。', + }); + return; + } + + this.setData({ + bindingPhone: true, + errorMessage: '', + }); + try { + const response = await requestMiniProgramBindPhone( + this.data.authResult.token, + detail.code, + ); + if (!response || !response.token) { + throw new Error('服务器未返回绑定后的登录态'); + } + const nextAuthResult = { + token: response.token, + bindingStatus: 'active', + }; + this.setData({ + authResult: nextAuthResult, + bindingPhone: false, + errorMessage: '', + loading: false, + phoneBindingRequired: false, + webViewUrl: resolveWebViewUrl(nextAuthResult), + }); + } catch (error) { + this.setData({ + bindingPhone: false, + errorMessage: + error && error.message + ? error.message + : '绑定手机号失败,请稍后重试。', + }); + } + }, + handleRetryLogin() { this.setData({ + authResult: null, + bindingPhone: false, errorMessage: '', loading: true, + phoneBindingRequired: false, webViewUrl: '', }); this.onLoad(); diff --git a/miniprogram/pages/web-view/index.wxml b/miniprogram/pages/web-view/index.wxml index 02712a78..5d830465 100644 --- a/miniprogram/pages/web-view/index.wxml +++ b/miniprogram/pages/web-view/index.wxml @@ -13,6 +13,31 @@ + + + 绑定手机号 + + {{errorMessage}} + + + + + + 无法进入 diff --git a/miniprogram/pages/web-view/index.wxss b/miniprogram/pages/web-view/index.wxss index fd71b8a1..5877f417 100644 --- a/miniprogram/pages/web-view/index.wxss +++ b/miniprogram/pages/web-view/index.wxss @@ -32,6 +32,10 @@ color: rgba(245, 247, 251, 0.72); } +.setup-text--danger { + color: #ffb4a9; +} + .retry-button { margin-top: 28rpx; width: 100%; @@ -41,3 +45,14 @@ font-size: 28rpx; line-height: 2.6; } + +.ghost-button { + margin-top: 18rpx; + width: 100%; + border-radius: 8rpx; + border: 1rpx solid rgba(255, 255, 255, 0.24); + background: transparent; + color: rgba(245, 247, 251, 0.86); + font-size: 26rpx; + line-height: 2.6; +} diff --git a/packages/shared/src/contracts/auth.ts b/packages/shared/src/contracts/auth.ts index 9b6c2db5..2fffab28 100644 --- a/packages/shared/src/contracts/auth.ts +++ b/packages/shared/src/contracts/auth.ts @@ -114,8 +114,9 @@ export type AuthWechatStartResponse = { }; export type AuthWechatBindPhoneRequest = { - phone: string; - code: string; + phone?: string; + code?: string; + wechatPhoneCode?: string; }; export type AuthWechatBindPhoneResponse = { diff --git a/scripts/check-wechat-miniprogram-auth-smoke.mjs b/scripts/check-wechat-miniprogram-auth-smoke.mjs index 9d87e3f5..373e0327 100644 --- a/scripts/check-wechat-miniprogram-auth-smoke.mjs +++ b/scripts/check-wechat-miniprogram-auth-smoke.mjs @@ -55,18 +55,24 @@ console.log('\n[wechat-miniprogram-auth-smoke] 通过'); function checkMiniProgramShell() { const shellPath = join(repoRoot, 'miniprogram', 'pages', 'web-view', 'index.js'); + const shellTemplatePath = join(repoRoot, 'miniprogram', 'pages', 'web-view', 'index.wxml'); const authServiceTestPath = join(repoRoot, 'src', 'services', 'authService.test.ts'); ensureNeedles(shellPath, [ '/api/auth/wechat/miniprogram-login', + '/api/auth/wechat/bind-phone', "'x-client-type': MINI_PROGRAM_CLIENT_TYPE", "'x-client-runtime': MINI_PROGRAM_CLIENT_RUNTIME", 'auth_provider', 'auth_token', 'auth_binding_status', 'bindingStatus', + 'pending_bind_phone', + 'wechatPhoneCode', ]); + ensureNeedles(shellTemplatePath, ['getPhoneNumber', 'bindgetphonenumber']); + // 中文注释:这里锁定 H5 消费回跳 hash 的真实测试输入,避免只检查实现文本。 ensureNeedles(authServiceTestPath, [ '#auth_provider=wechat&auth_token=jwt-callback-token&auth_binding_status=pending_bind_phone', diff --git a/server-rs/crates/api-server/src/app.rs b/server-rs/crates/api-server/src/app.rs index f1cb251a..257ae1e0 100644 --- a/server-rs/crates/api-server/src/app.rs +++ b/server-rs/crates/api-server/src/app.rs @@ -3801,6 +3801,113 @@ mod tests { ); } + #[tokio::test] + async fn wechat_miniprogram_bind_phone_code_activates_pending_user() { + let config = AppConfig { + wechat_auth_enabled: true, + ..AppConfig::default() + }; + let app = build_router(AppState::new(config).expect("state should build")); + + let login_response = app + .clone() + .oneshot( + Request::builder() + .method("POST") + .uri("/api/auth/wechat/miniprogram-login") + .header("content-type", "application/json") + .header("x-client-type", "mini_program") + .header("x-client-runtime", "wechat_mini_program") + .header("x-client-platform", "ios") + .header("x-client-instance-id", "mini-bind-instance-001") + .header("x-mini-program-app-id", "wx-mini-test") + .header("x-mini-program-env", "develop") + .body(Body::from( + serde_json::json!({ + "code": "wx-mini-code-bind-001" + }) + .to_string(), + )) + .expect("mini program login request should build"), + ) + .await + .expect("mini program login request should succeed"); + + assert_eq!(login_response.status(), StatusCode::OK); + let login_body = login_response + .into_body() + .collect() + .await + .expect("mini program login body should collect") + .to_bytes(); + let login_payload: Value = + serde_json::from_slice(&login_body).expect("mini program login payload should be json"); + let token = login_payload["token"] + .as_str() + .expect("system token should exist") + .to_string(); + assert_eq!( + login_payload["bindingStatus"], + Value::String("pending_bind_phone".to_string()) + ); + + let bind_response = app + .oneshot( + Request::builder() + .method("POST") + .uri("/api/auth/wechat/bind-phone") + .header("authorization", format!("Bearer {token}")) + .header("content-type", "application/json") + .header("x-client-type", "mini_program") + .header("x-client-runtime", "wechat_mini_program") + .header("x-client-platform", "ios") + .header("x-client-instance-id", "mini-bind-instance-001") + .header("x-mini-program-app-id", "wx-mini-test") + .header("x-mini-program-env", "develop") + .body(Body::from( + serde_json::json!({ + "wechatPhoneCode": "13800138000" + }) + .to_string(), + )) + .expect("bind request should build"), + ) + .await + .expect("bind request should succeed"); + + assert_eq!(bind_response.status(), StatusCode::OK); + assert!( + bind_response + .headers() + .get("set-cookie") + .and_then(|value| value.to_str().ok()) + .is_some_and(|value| value.contains("genarrative_refresh_session=")) + ); + let bind_body = bind_response + .into_body() + .collect() + .await + .expect("bind body should collect") + .to_bytes(); + let bind_payload: Value = + serde_json::from_slice(&bind_body).expect("bind payload should be json"); + + assert_eq!( + bind_payload["user"]["bindingStatus"], + Value::String("active".to_string()) + ); + assert_eq!(bind_payload["user"]["wechatBound"], Value::Bool(true)); + assert_eq!( + bind_payload["user"]["phoneNumberMasked"], + Value::String("138****8000".to_string()) + ); + assert!( + bind_payload["token"] + .as_str() + .is_some_and(|value| !value.is_empty()) + ); + } + #[tokio::test] async fn wechat_bind_phone_merges_into_existing_phone_user() { let config = AppConfig { diff --git a/server-rs/crates/api-server/src/config.rs b/server-rs/crates/api-server/src/config.rs index 641b2c51..6f6a2d47 100644 --- a/server-rs/crates/api-server/src/config.rs +++ b/server-rs/crates/api-server/src/config.rs @@ -64,6 +64,8 @@ pub struct AppConfig { pub wechat_access_token_endpoint: String, pub wechat_user_info_endpoint: String, pub wechat_js_code_session_endpoint: String, + pub wechat_stable_access_token_endpoint: String, + pub wechat_phone_number_endpoint: String, pub wechat_state_ttl_minutes: u32, pub wechat_mock_user_id: String, pub wechat_mock_union_id: Option, @@ -178,6 +180,10 @@ impl Default for AppConfig { wechat_user_info_endpoint: "https://api.weixin.qq.com/sns/userinfo".to_string(), wechat_js_code_session_endpoint: "https://api.weixin.qq.com/sns/jscode2session" .to_string(), + wechat_stable_access_token_endpoint: "https://api.weixin.qq.com/cgi-bin/stable_token" + .to_string(), + wechat_phone_number_endpoint: + "https://api.weixin.qq.com/wxa/business/getuserphonenumber".to_string(), wechat_state_ttl_minutes: 15, wechat_mock_user_id: "wx-mock-user".to_string(), wechat_mock_union_id: Some("wx-mock-union".to_string()), @@ -426,6 +432,16 @@ impl AppConfig { { config.wechat_js_code_session_endpoint = wechat_js_code_session_endpoint; } + if let Some(wechat_stable_access_token_endpoint) = + read_first_non_empty_env(&["WECHAT_STABLE_ACCESS_TOKEN_ENDPOINT"]) + { + config.wechat_stable_access_token_endpoint = wechat_stable_access_token_endpoint; + } + if let Some(wechat_phone_number_endpoint) = + read_first_non_empty_env(&["WECHAT_PHONE_NUMBER_ENDPOINT"]) + { + config.wechat_phone_number_endpoint = wechat_phone_number_endpoint; + } if let Some(wechat_state_ttl_minutes) = read_first_positive_u32_env(&["WECHAT_STATE_TTL_MINUTES"]) { diff --git a/server-rs/crates/api-server/src/wechat_auth.rs b/server-rs/crates/api-server/src/wechat_auth.rs index 10338feb..d7381253 100644 --- a/server-rs/crates/api-server/src/wechat_auth.rs +++ b/server-rs/crates/api-server/src/wechat_auth.rs @@ -5,7 +5,8 @@ use axum::{ response::{IntoResponse, Redirect, Response}, }; use module_auth::{ - AuthLoginMethod, BindWechatPhoneInput, CreateWechatAuthStateInput, WechatAuthError, + AuthLoginMethod, BindWechatPhoneInput, BindWechatVerifiedPhoneInput, + CreateWechatAuthStateInput, WechatAuthError, }; use platform_auth::WechatAuthScene; use shared_contracts::auth::{ @@ -191,18 +192,55 @@ pub async fn bind_wechat_phone( if !state.config.wechat_auth_enabled { return Err(AppError::from_status(StatusCode::BAD_REQUEST).with_message("微信登录暂未启用")); } - let result = state - .phone_auth_service() - .bind_wechat_phone( - BindWechatPhoneInput { + let result = if let Some(wechat_phone_code) = payload + .wechat_phone_code + .as_deref() + .map(str::trim) + .filter(|value| !value.is_empty()) + { + let phone_profile = state + .wechat_provider() + .resolve_mini_program_phone_number(Some(wechat_phone_code)) + .await + .map_err(map_wechat_provider_error)?; + state + .phone_auth_service() + .bind_wechat_verified_phone(BindWechatVerifiedPhoneInput { user_id: authenticated.claims().user_id().to_string(), - phone_number: payload.phone, - verify_code: payload.code, - }, - OffsetDateTime::now_utc(), - ) - .await - .map_err(map_wechat_bind_phone_error)?; + phone_number: phone_profile.phone_number, + }) + .await + .map_err(map_wechat_bind_phone_error)? + } else { + let phone = payload + .phone + .as_deref() + .map(str::trim) + .filter(|value| !value.is_empty()) + .ok_or_else(|| { + AppError::from_status(StatusCode::BAD_REQUEST).with_message("缺少需要绑定的手机号") + })?; + let code = payload + .code + .as_deref() + .map(str::trim) + .filter(|value| !value.is_empty()) + .ok_or_else(|| { + AppError::from_status(StatusCode::BAD_REQUEST).with_message("缺少短信验证码") + })?; + state + .phone_auth_service() + .bind_wechat_phone( + BindWechatPhoneInput { + user_id: authenticated.claims().user_id().to_string(), + phone_number: phone.to_string(), + verify_code: code.to_string(), + }, + OffsetDateTime::now_utc(), + ) + .await + .map_err(map_wechat_bind_phone_error)? + }; if result.activated_new_user { crate::registration_reward::grant_new_user_registration_wallet_reward( &state, diff --git a/server-rs/crates/api-server/src/wechat_provider.rs b/server-rs/crates/api-server/src/wechat_provider.rs index 02e043b1..60722cb8 100644 --- a/server-rs/crates/api-server/src/wechat_provider.rs +++ b/server-rs/crates/api-server/src/wechat_provider.rs @@ -1,7 +1,8 @@ use platform_auth::{ DEFAULT_WECHAT_ACCESS_TOKEN_ENDPOINT, DEFAULT_WECHAT_AUTHORIZE_ENDPOINT, - DEFAULT_WECHAT_JS_CODE_SESSION_ENDPOINT, DEFAULT_WECHAT_USER_INFO_ENDPOINT, WechatAuthConfig, - WechatProvider, + DEFAULT_WECHAT_JS_CODE_SESSION_ENDPOINT, DEFAULT_WECHAT_PHONE_NUMBER_ENDPOINT, + DEFAULT_WECHAT_STABLE_ACCESS_TOKEN_ENDPOINT, DEFAULT_WECHAT_USER_INFO_ENDPOINT, + WechatAuthConfig, WechatProvider, }; use crate::config::AppConfig; @@ -30,6 +31,14 @@ pub fn build_wechat_provider(config: &AppConfig) -> WechatProvider { &config.wechat_js_code_session_endpoint, DEFAULT_WECHAT_JS_CODE_SESSION_ENDPOINT, ), + normalize_wechat_endpoint( + &config.wechat_stable_access_token_endpoint, + DEFAULT_WECHAT_STABLE_ACCESS_TOKEN_ENDPOINT, + ), + normalize_wechat_endpoint( + &config.wechat_phone_number_endpoint, + DEFAULT_WECHAT_PHONE_NUMBER_ENDPOINT, + ), config.wechat_mock_user_id.clone(), config.wechat_mock_union_id.clone(), config.wechat_mock_display_name.clone(), diff --git a/server-rs/crates/module-auth/src/commands.rs b/server-rs/crates/module-auth/src/commands.rs index 35302bd3..da48cffb 100644 --- a/server-rs/crates/module-auth/src/commands.rs +++ b/server-rs/crates/module-auth/src/commands.rs @@ -67,6 +67,12 @@ pub struct BindWechatPhoneInput { pub verify_code: String, } +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct BindWechatVerifiedPhoneInput { + pub user_id: String, + pub phone_number: String, +} + #[derive(Clone, Debug, PartialEq, Eq)] pub struct CreateRefreshSessionInput { pub user_id: String, diff --git a/server-rs/crates/module-auth/src/lib.rs b/server-rs/crates/module-auth/src/lib.rs index 78bbd7ef..6b1ac1e4 100644 --- a/server-rs/crates/module-auth/src/lib.rs +++ b/server-rs/crates/module-auth/src/lib.rs @@ -627,6 +627,33 @@ impl PhoneAuthService { activated_new_user, }) } + + pub async fn bind_wechat_verified_phone( + &self, + input: BindWechatVerifiedPhoneInput, + ) -> Result { + let normalized_phone = normalize_mainland_china_phone_number(&input.phone_number)?; + let current_user = self + .store + .find_by_user_id(&input.user_id) + .map_err(map_password_error_to_phone_error)? + .ok_or(PhoneAuthError::UserNotFound)?; + if current_user.user.binding_status != AuthBindingStatus::PendingBindPhone { + return Err(PhoneAuthError::UserStateMismatch); + } + if !current_user.user.wechat_bound { + return Err(PhoneAuthError::UserStateMismatch); + } + + let (merged_user, activated_new_user) = self + .store + .bind_wechat_phone_to_user(&input.user_id, normalized_phone)?; + + Ok(BindWechatPhoneResult { + user: merged_user, + activated_new_user, + }) + } } impl WechatAuthStateService { diff --git a/server-rs/crates/platform-auth/src/lib.rs b/server-rs/crates/platform-auth/src/lib.rs index ad9031ed..1d7be11b 100644 --- a/server-rs/crates/platform-auth/src/lib.rs +++ b/server-rs/crates/platform-auth/src/lib.rs @@ -42,6 +42,10 @@ pub const DEFAULT_WECHAT_ACCESS_TOKEN_ENDPOINT: &str = pub const DEFAULT_WECHAT_USER_INFO_ENDPOINT: &str = "https://api.weixin.qq.com/sns/userinfo"; pub const DEFAULT_WECHAT_JS_CODE_SESSION_ENDPOINT: &str = "https://api.weixin.qq.com/sns/jscode2session"; +pub const DEFAULT_WECHAT_STABLE_ACCESS_TOKEN_ENDPOINT: &str = + "https://api.weixin.qq.com/cgi-bin/stable_token"; +pub const DEFAULT_WECHAT_PHONE_NUMBER_ENDPOINT: &str = + "https://api.weixin.qq.com/wxa/business/getuserphonenumber"; type HmacSha256 = Hmac; @@ -184,6 +188,8 @@ pub struct WechatAuthConfig { pub access_token_endpoint: String, pub user_info_endpoint: String, pub js_code_session_endpoint: String, + pub stable_access_token_endpoint: String, + pub phone_number_endpoint: String, pub mock_user_id: String, pub mock_union_id: Option, pub mock_display_name: String, @@ -224,6 +230,15 @@ pub struct RealWechatProvider { access_token_endpoint: String, user_info_endpoint: String, js_code_session_endpoint: String, + stable_access_token_endpoint: String, + phone_number_endpoint: String, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct WechatPhoneNumberProfile { + pub phone_number: String, + pub pure_phone_number: Option, + pub country_code: Option, } #[derive(Clone, Debug)] @@ -325,6 +340,31 @@ struct WechatJsCodeSessionResponse { errmsg: Option, } +#[derive(Debug, Deserialize)] +struct WechatStableAccessTokenResponse { + access_token: Option, + errcode: Option, + errmsg: Option, +} + +#[derive(Debug, Deserialize)] +struct WechatPhoneNumberResponse { + errcode: Option, + errmsg: Option, + #[serde(default)] + phone_info: Option, +} + +#[derive(Debug, Deserialize)] +struct WechatPhoneNumberInfo { + #[serde(default)] + phone_number: Option, + #[serde(default)] + pure_phone_number: Option, + #[serde(default)] + country_code: Option, +} + #[derive(Debug, Deserialize)] struct AliyunSendSmsVerifyCodeResponse { // 阿里云 RPC 原始 JSON 使用首字母大写字段名,这里必须显式映射,避免把成功响应误判成空值。 @@ -648,6 +688,8 @@ impl WechatAuthConfig { access_token_endpoint: String, user_info_endpoint: String, js_code_session_endpoint: String, + stable_access_token_endpoint: String, + phone_number_endpoint: String, mock_user_id: String, mock_union_id: Option, mock_display_name: String, @@ -664,6 +706,8 @@ impl WechatAuthConfig { access_token_endpoint, user_info_endpoint, js_code_session_endpoint, + stable_access_token_endpoint, + phone_number_endpoint, mock_user_id, mock_union_id, mock_display_name, @@ -717,6 +761,8 @@ impl WechatProvider { access_token_endpoint: config.access_token_endpoint, user_info_endpoint: config.user_info_endpoint, js_code_session_endpoint: config.js_code_session_endpoint, + stable_access_token_endpoint: config.stable_access_token_endpoint, + phone_number_endpoint: config.phone_number_endpoint, }) } @@ -755,6 +801,28 @@ impl WechatProvider { Self::Real(provider) => provider.resolve_mini_program_login_profile(code).await, } } + + pub async fn resolve_mini_program_phone_number( + &self, + code: Option<&str>, + ) -> Result { + match self { + Self::Disabled => Err(WechatProviderError::Disabled), + Self::Mock(_) => { + let phone_number = code + .map(str::trim) + .filter(|value| !value.is_empty()) + .unwrap_or("13800138000") + .to_string(); + Ok(WechatPhoneNumberProfile { + phone_number: phone_number.clone(), + pure_phone_number: Some(phone_number), + country_code: Some("86".to_string()), + }) + } + Self::Real(provider) => provider.resolve_mini_program_phone_number(code).await, + } + } } impl MockWechatProvider { @@ -990,6 +1058,141 @@ impl RealWechatProvider { avatar_url: None, }) } + + async fn resolve_mini_program_phone_number( + &self, + code: Option<&str>, + ) -> Result { + let code = code + .map(str::trim) + .filter(|value| !value.is_empty()) + .ok_or(WechatProviderError::MissingCode)?; + let app_id = self + .mini_program_app_id + .as_ref() + .or(self.app_id.as_ref()) + .ok_or_else(|| { + WechatProviderError::InvalidConfig("微信小程序 AppID 未配置".to_string()) + })?; + let app_secret = self + .mini_program_app_secret + .as_ref() + .or(self.app_secret.as_ref()) + .ok_or_else(|| { + WechatProviderError::InvalidConfig("微信小程序 AppSecret 未配置".to_string()) + })?; + + let access_token = self + .request_mini_program_access_token(app_id, app_secret) + .await?; + let mut phone_number_url = Url::parse(&self.phone_number_endpoint).map_err(|error| { + WechatProviderError::InvalidConfig(format!("微信手机号接口地址非法:{error}")) + })?; + phone_number_url + .query_pairs_mut() + .append_pair("access_token", &access_token); + + let payload = self + .client + .post(phone_number_url.as_str()) + .json(&serde_json::json!({ "code": code })) + .send() + .await + .map_err(|error| { + warn!(error = %error, "微信小程序手机号请求失败"); + WechatProviderError::RequestFailed("微信手机号授权失败:手机号请求失败".to_string()) + })? + .json::() + .await + .map_err(|error| { + warn!(error = %error, "微信小程序手机号响应解析失败"); + WechatProviderError::DeserializeFailed( + "微信手机号授权失败:手机号响应非法".to_string(), + ) + })?; + + if let Some(errcode) = payload.errcode.filter(|value| *value != 0) { + return Err(WechatProviderError::Upstream(format!( + "微信手机号授权失败:{}", + payload + .errmsg + .unwrap_or_else(|| format!("getuserphonenumber 返回错误 {errcode}")) + ))); + } + + 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, + }) + } + + async fn request_mini_program_access_token( + &self, + app_id: &str, + app_secret: &str, + ) -> Result { + let url = Url::parse(&self.stable_access_token_endpoint).map_err(|error| { + WechatProviderError::InvalidConfig(format!("微信 stable_token 地址非法:{error}")) + })?; + let payload = self + .client + .post(url.as_str()) + .json(&serde_json::json!({ + "grant_type": "client_credential", + "appid": app_id, + "secret": app_secret, + "force_refresh": false + })) + .send() + .await + .map_err(|error| { + warn!(error = %error, "微信小程序 stable_token 请求失败"); + WechatProviderError::RequestFailed( + "微信手机号授权失败:access_token 请求失败".to_string(), + ) + })? + .json::() + .await + .map_err(|error| { + warn!(error = %error, "微信小程序 stable_token 响应解析失败"); + WechatProviderError::DeserializeFailed( + "微信手机号授权失败:access_token 响应非法".to_string(), + ) + })?; + + if let Some(errcode) = payload.errcode.filter(|value| *value != 0) { + return Err(WechatProviderError::Upstream(format!( + "微信手机号授权失败:{}", + payload + .errmsg + .unwrap_or_else(|| format!("stable_token 返回错误 {errcode}")) + ))); + } + + payload + .access_token + .filter(|value| !value.trim().is_empty()) + .ok_or_else(|| { + WechatProviderError::Upstream( + payload + .errmsg + .unwrap_or_else(|| "微信手机号授权失败:缺少 access_token".to_string()), + ) + }) + } } fn build_mock_wechat_authorization_url( @@ -1919,6 +2122,8 @@ mod tests { DEFAULT_WECHAT_ACCESS_TOKEN_ENDPOINT.to_string(), DEFAULT_WECHAT_USER_INFO_ENDPOINT.to_string(), DEFAULT_WECHAT_JS_CODE_SESSION_ENDPOINT.to_string(), + DEFAULT_WECHAT_STABLE_ACCESS_TOKEN_ENDPOINT.to_string(), + DEFAULT_WECHAT_PHONE_NUMBER_ENDPOINT.to_string(), "wx-user-001".to_string(), Some("wx-union-001".to_string()), "微信测试用户".to_string(), @@ -1950,6 +2155,8 @@ mod tests { DEFAULT_WECHAT_ACCESS_TOKEN_ENDPOINT.to_string(), DEFAULT_WECHAT_USER_INFO_ENDPOINT.to_string(), DEFAULT_WECHAT_JS_CODE_SESSION_ENDPOINT.to_string(), + DEFAULT_WECHAT_STABLE_ACCESS_TOKEN_ENDPOINT.to_string(), + DEFAULT_WECHAT_PHONE_NUMBER_ENDPOINT.to_string(), "wx-user-001".to_string(), Some("wx-union-001".to_string()), "微信测试用户".to_string(), diff --git a/server-rs/crates/shared-contracts/src/auth.rs b/server-rs/crates/shared-contracts/src/auth.rs index dd04498a..038133a4 100644 --- a/server-rs/crates/shared-contracts/src/auth.rs +++ b/server-rs/crates/shared-contracts/src/auth.rs @@ -211,8 +211,12 @@ pub struct WechatCallbackQuery { #[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)] #[serde(rename_all = "camelCase")] pub struct WechatBindPhoneRequest { - pub phone: String, - pub code: String, + #[serde(default)] + pub phone: Option, + #[serde(default)] + pub code: Option, + #[serde(default)] + pub wechat_phone_code: Option, } #[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)] @@ -332,4 +336,23 @@ mod tests { }) ); } + + #[test] + fn wechat_bind_phone_request_accepts_mini_program_phone_code() { + let payload = serde_json::to_value(WechatBindPhoneRequest { + phone: None, + code: None, + wechat_phone_code: Some("wx-phone-code-001".to_string()), + }) + .expect("payload should serialize"); + + assert_eq!( + payload, + json!({ + "phone": null, + "code": null, + "wechatPhoneCode": "wx-phone-code-001" + }) + ); + } } diff --git a/src/services/authService.ts b/src/services/authService.ts index ee0cab50..407287d2 100644 --- a/src/services/authService.ts +++ b/src/services/authService.ts @@ -21,6 +21,7 @@ import type { AuthSessionsResponse, AuthSessionSummary, AuthWechatBindPhoneResponse, + AuthWechatBindPhoneRequest, AuthWechatStartResponse, LogoutResponse, PublicUserSearchResponse, @@ -193,15 +194,16 @@ export async function redeemRegistrationInviteCode(inviteCode: string) { } export async function bindWechatPhone(phone: string, code: string) { + const payload: AuthWechatBindPhoneRequest = { + phone: normalizePhoneInput(phone), + code: code.trim(), + }; const response = await requestJson( '/api/auth/wechat/bind-phone', { method: 'POST', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ - phone: normalizePhoneInput(phone), - code: code.trim(), - }), + body: JSON.stringify(payload), }, '绑定手机号失败', );