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),
},
'绑定手机号失败',
);