feat: support mini program phone authorization binding
This commit is contained in:
@@ -103,6 +103,9 @@ WECHAT_REDIRECT_PATH="/"
|
|||||||
WECHAT_AUTHORIZE_ENDPOINT="https://open.weixin.qq.com/connect/qrconnect"
|
WECHAT_AUTHORIZE_ENDPOINT="https://open.weixin.qq.com/connect/qrconnect"
|
||||||
WECHAT_ACCESS_TOKEN_ENDPOINT="https://api.weixin.qq.com/sns/oauth2/access_token"
|
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_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_STATE_TTL_MINUTES="15"
|
||||||
WECHAT_MOCK_USER_ID="wx-mock-user"
|
WECHAT_MOCK_USER_ID="wx-mock-user"
|
||||||
WECHAT_MOCK_UNION_ID="wx-mock-union"
|
WECHAT_MOCK_UNION_ID="wx-mock-union"
|
||||||
|
|||||||
@@ -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 拼图与抓大鹅结果页音频资产复用通用创作音频链路
|
## 2026-05-11 拼图与抓大鹅结果页音频资产复用通用创作音频链路
|
||||||
|
|
||||||
- 背景:拼图和抓大鹅结果页需要接入 Suno 背景音乐,抓大鹅还需要物体点击音效,但当前两类作品没有独立的作品级音频表或 metadata 字段。
|
- 背景:拼图和抓大鹅结果页需要接入 Suno 背景音乐,抓大鹅还需要物体点击音效,但当前两类作品没有独立的作品级音频表或 metadata 字段。
|
||||||
|
|||||||
@@ -6,12 +6,13 @@
|
|||||||
|
|
||||||
本次先用微信小程序 `web-view` 承载现有 H5,不重写 React/Vite 主前端,也不把 SpacetimeDB SDK 或业务规则搬进小程序端。
|
本次先用微信小程序 `web-view` 承载现有 H5,不重写 React/Vite 主前端,也不把 SpacetimeDB SDK 或业务规则搬进小程序端。
|
||||||
|
|
||||||
当前小程序壳只承担四件事:
|
当前小程序壳只承担五件事:
|
||||||
|
|
||||||
1. 提供微信开发者工具可识别的 `miniprogram/` 工程根目录。
|
1. 提供微信开发者工具可识别的 `miniprogram/` 工程根目录。
|
||||||
2. 在原生小程序壳中调用 `wx.login` 获取小程序 `code`。
|
2. 在原生小程序壳中调用 `wx.login` 获取小程序 `code`。
|
||||||
3. 调用服务器域名下的 `/api/auth/wechat/miniprogram-login`,由 Rust `api-server` 兑换微信身份并签发系统登录态。
|
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_AUTH_PROVIDER=real
|
||||||
WECHAT_MINI_PROGRAM_APP_ID="你的微信小程序 AppID"
|
WECHAT_MINI_PROGRAM_APP_ID="你的微信小程序 AppID"
|
||||||
WECHAT_MINI_PROGRAM_APP_SECRET="你的微信小程序 AppSecret"
|
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,也可以继续使用已有:
|
如果开放平台网页 OAuth 与小程序使用同一个 AppID/Secret,也可以继续使用已有:
|
||||||
@@ -110,13 +114,28 @@ Content-Type: application/json
|
|||||||
- 再按 `openid` 命中已有身份
|
- 再按 `openid` 命中已有身份
|
||||||
- 都没有命中时创建 `pending_bind_phone` 的微信壳账号
|
- 都没有命中时创建 `pending_bind_phone` 的微信壳账号
|
||||||
6. `api-server` 签发系统 access token,并写入 refresh session。
|
6. `api-server` 签发系统 access token,并写入 refresh session。
|
||||||
7. 小程序壳打开:
|
7. 如果返回 `bindingStatus=active`,小程序壳打开:
|
||||||
|
|
||||||
```text
|
```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. 微信后台配置
|
## 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。
|
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`。
|
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` 时,页面显示配置提示,不出现空白页。
|
2. 未填写 `WEB_VIEW_ENTRY_URL` 或 `API_BASE_URL` 时,页面显示配置提示,不出现空白页。
|
||||||
3. 填写已配置业务域名后,小程序先请求 `/api/auth/wechat/miniprogram-login`。
|
3. 填写已配置业务域名后,小程序先请求 `/api/auth/wechat/miniprogram-login`。
|
||||||
4. 后端返回 `token/bindingStatus/user`,并写入 refresh cookie。
|
4. 后端返回 `token/bindingStatus/user`,并写入 refresh cookie。
|
||||||
5. 首页全屏打开 H5,URL hash 中包含 `auth_provider=wechat`、`auth_token`、`auth_binding_status`。
|
5. 若返回 `pending_bind_phone`,先看到小程序原生授权手机号按钮;用户同意后,小程序请求 `/api/auth/wechat/bind-phone` 且请求体包含 `wechatPhoneCode`。
|
||||||
6. H5 内 `consumeAuthCallbackResult()` 消费 hash 后,`/api/auth/me` 能返回当前用户。
|
6. 绑定成功后首页全屏打开 H5,URL hash 中包含 `auth_provider=wechat`、`auth_token`、`auth_binding_status=active`。
|
||||||
7. `/api/auth/sessions` 能看到来源为 `mini_program / wechat_mini_program` 的会话记录。
|
7. H5 内 `consumeAuthCallbackResult()` 消费 hash 后,`/api/auth/me` 能返回当前用户。
|
||||||
|
8. `/api/auth/sessions` 能看到来源为 `mini_program / wechat_mini_program` 的会话记录。
|
||||||
|
|||||||
@@ -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() {
|
async function resolveAuthResult() {
|
||||||
const code = await wxLogin();
|
const code = await wxLogin();
|
||||||
const response = await requestMiniProgramLogin(code);
|
const response = await requestMiniProgramLogin(code);
|
||||||
@@ -169,8 +211,11 @@ async function resolveAuthResult() {
|
|||||||
|
|
||||||
Page({
|
Page({
|
||||||
data: {
|
data: {
|
||||||
|
authResult: null,
|
||||||
|
bindingPhone: false,
|
||||||
errorMessage: '',
|
errorMessage: '',
|
||||||
loading: true,
|
loading: true,
|
||||||
|
phoneBindingRequired: false,
|
||||||
webViewUrl: '',
|
webViewUrl: '',
|
||||||
},
|
},
|
||||||
|
|
||||||
@@ -196,27 +241,94 @@ Page({
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
const authResult = await resolveAuthResult();
|
const authResult = await resolveAuthResult();
|
||||||
|
if (authResult.bindingStatus === 'pending_bind_phone') {
|
||||||
|
this.setData({
|
||||||
|
authResult,
|
||||||
|
errorMessage: '',
|
||||||
|
loading: false,
|
||||||
|
phoneBindingRequired: true,
|
||||||
|
webViewUrl: '',
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
this.setData({
|
this.setData({
|
||||||
|
authResult,
|
||||||
errorMessage: '',
|
errorMessage: '',
|
||||||
loading: false,
|
loading: false,
|
||||||
|
phoneBindingRequired: false,
|
||||||
webViewUrl: resolveWebViewUrl(authResult),
|
webViewUrl: resolveWebViewUrl(authResult),
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
this.setData({
|
this.setData({
|
||||||
|
authResult: null,
|
||||||
errorMessage:
|
errorMessage:
|
||||||
error && error.message
|
error && error.message
|
||||||
? error.message
|
? error.message
|
||||||
: '微信登录失败,请稍后重试。',
|
: '微信登录失败,请稍后重试。',
|
||||||
loading: false,
|
loading: false,
|
||||||
|
phoneBindingRequired: false,
|
||||||
webViewUrl: '',
|
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() {
|
handleRetryLogin() {
|
||||||
this.setData({
|
this.setData({
|
||||||
|
authResult: null,
|
||||||
|
bindingPhone: false,
|
||||||
errorMessage: '',
|
errorMessage: '',
|
||||||
loading: true,
|
loading: true,
|
||||||
|
phoneBindingRequired: false,
|
||||||
webViewUrl: '',
|
webViewUrl: '',
|
||||||
});
|
});
|
||||||
this.onLoad();
|
this.onLoad();
|
||||||
|
|||||||
@@ -13,6 +13,31 @@
|
|||||||
</view>
|
</view>
|
||||||
</view>
|
</view>
|
||||||
|
|
||||||
|
<view wx:elif="{{phoneBindingRequired}}" class="setup-screen">
|
||||||
|
<view class="setup-card">
|
||||||
|
<view class="setup-title">绑定手机号</view>
|
||||||
|
<view wx:if="{{errorMessage}}" class="setup-text setup-text--danger">
|
||||||
|
{{errorMessage}}
|
||||||
|
</view>
|
||||||
|
<button
|
||||||
|
class="retry-button"
|
||||||
|
open-type="getPhoneNumber"
|
||||||
|
bindgetphonenumber="handleGetPhoneNumber"
|
||||||
|
loading="{{bindingPhone}}"
|
||||||
|
disabled="{{bindingPhone}}"
|
||||||
|
>
|
||||||
|
{{bindingPhone ? '正在绑定' : '微信授权手机号'}}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
class="ghost-button"
|
||||||
|
disabled="{{bindingPhone}}"
|
||||||
|
bindtap="handleRetryLogin"
|
||||||
|
>
|
||||||
|
重新登录
|
||||||
|
</button>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
|
||||||
<view wx:else class="setup-screen">
|
<view wx:else class="setup-screen">
|
||||||
<view class="setup-card">
|
<view class="setup-card">
|
||||||
<view class="setup-title">无法进入</view>
|
<view class="setup-title">无法进入</view>
|
||||||
|
|||||||
@@ -32,6 +32,10 @@
|
|||||||
color: rgba(245, 247, 251, 0.72);
|
color: rgba(245, 247, 251, 0.72);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.setup-text--danger {
|
||||||
|
color: #ffb4a9;
|
||||||
|
}
|
||||||
|
|
||||||
.retry-button {
|
.retry-button {
|
||||||
margin-top: 28rpx;
|
margin-top: 28rpx;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
@@ -41,3 +45,14 @@
|
|||||||
font-size: 28rpx;
|
font-size: 28rpx;
|
||||||
line-height: 2.6;
|
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;
|
||||||
|
}
|
||||||
|
|||||||
@@ -114,8 +114,9 @@ export type AuthWechatStartResponse = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export type AuthWechatBindPhoneRequest = {
|
export type AuthWechatBindPhoneRequest = {
|
||||||
phone: string;
|
phone?: string;
|
||||||
code: string;
|
code?: string;
|
||||||
|
wechatPhoneCode?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type AuthWechatBindPhoneResponse = {
|
export type AuthWechatBindPhoneResponse = {
|
||||||
|
|||||||
@@ -55,18 +55,24 @@ console.log('\n[wechat-miniprogram-auth-smoke] 通过');
|
|||||||
|
|
||||||
function checkMiniProgramShell() {
|
function checkMiniProgramShell() {
|
||||||
const shellPath = join(repoRoot, 'miniprogram', 'pages', 'web-view', 'index.js');
|
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');
|
const authServiceTestPath = join(repoRoot, 'src', 'services', 'authService.test.ts');
|
||||||
|
|
||||||
ensureNeedles(shellPath, [
|
ensureNeedles(shellPath, [
|
||||||
'/api/auth/wechat/miniprogram-login',
|
'/api/auth/wechat/miniprogram-login',
|
||||||
|
'/api/auth/wechat/bind-phone',
|
||||||
"'x-client-type': MINI_PROGRAM_CLIENT_TYPE",
|
"'x-client-type': MINI_PROGRAM_CLIENT_TYPE",
|
||||||
"'x-client-runtime': MINI_PROGRAM_CLIENT_RUNTIME",
|
"'x-client-runtime': MINI_PROGRAM_CLIENT_RUNTIME",
|
||||||
'auth_provider',
|
'auth_provider',
|
||||||
'auth_token',
|
'auth_token',
|
||||||
'auth_binding_status',
|
'auth_binding_status',
|
||||||
'bindingStatus',
|
'bindingStatus',
|
||||||
|
'pending_bind_phone',
|
||||||
|
'wechatPhoneCode',
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
ensureNeedles(shellTemplatePath, ['getPhoneNumber', 'bindgetphonenumber']);
|
||||||
|
|
||||||
// 中文注释:这里锁定 H5 消费回跳 hash 的真实测试输入,避免只检查实现文本。
|
// 中文注释:这里锁定 H5 消费回跳 hash 的真实测试输入,避免只检查实现文本。
|
||||||
ensureNeedles(authServiceTestPath, [
|
ensureNeedles(authServiceTestPath, [
|
||||||
'#auth_provider=wechat&auth_token=jwt-callback-token&auth_binding_status=pending_bind_phone',
|
'#auth_provider=wechat&auth_token=jwt-callback-token&auth_binding_status=pending_bind_phone',
|
||||||
|
|||||||
@@ -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]
|
#[tokio::test]
|
||||||
async fn wechat_bind_phone_merges_into_existing_phone_user() {
|
async fn wechat_bind_phone_merges_into_existing_phone_user() {
|
||||||
let config = AppConfig {
|
let config = AppConfig {
|
||||||
|
|||||||
@@ -64,6 +64,8 @@ pub struct AppConfig {
|
|||||||
pub wechat_access_token_endpoint: String,
|
pub wechat_access_token_endpoint: String,
|
||||||
pub wechat_user_info_endpoint: String,
|
pub wechat_user_info_endpoint: String,
|
||||||
pub wechat_js_code_session_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_state_ttl_minutes: u32,
|
||||||
pub wechat_mock_user_id: String,
|
pub wechat_mock_user_id: String,
|
||||||
pub wechat_mock_union_id: Option<String>,
|
pub wechat_mock_union_id: Option<String>,
|
||||||
@@ -178,6 +180,10 @@ impl Default for AppConfig {
|
|||||||
wechat_user_info_endpoint: "https://api.weixin.qq.com/sns/userinfo".to_string(),
|
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"
|
wechat_js_code_session_endpoint: "https://api.weixin.qq.com/sns/jscode2session"
|
||||||
.to_string(),
|
.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_state_ttl_minutes: 15,
|
||||||
wechat_mock_user_id: "wx-mock-user".to_string(),
|
wechat_mock_user_id: "wx-mock-user".to_string(),
|
||||||
wechat_mock_union_id: Some("wx-mock-union".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;
|
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) =
|
if let Some(wechat_state_ttl_minutes) =
|
||||||
read_first_positive_u32_env(&["WECHAT_STATE_TTL_MINUTES"])
|
read_first_positive_u32_env(&["WECHAT_STATE_TTL_MINUTES"])
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -5,7 +5,8 @@ use axum::{
|
|||||||
response::{IntoResponse, Redirect, Response},
|
response::{IntoResponse, Redirect, Response},
|
||||||
};
|
};
|
||||||
use module_auth::{
|
use module_auth::{
|
||||||
AuthLoginMethod, BindWechatPhoneInput, CreateWechatAuthStateInput, WechatAuthError,
|
AuthLoginMethod, BindWechatPhoneInput, BindWechatVerifiedPhoneInput,
|
||||||
|
CreateWechatAuthStateInput, WechatAuthError,
|
||||||
};
|
};
|
||||||
use platform_auth::WechatAuthScene;
|
use platform_auth::WechatAuthScene;
|
||||||
use shared_contracts::auth::{
|
use shared_contracts::auth::{
|
||||||
@@ -191,18 +192,55 @@ pub async fn bind_wechat_phone(
|
|||||||
if !state.config.wechat_auth_enabled {
|
if !state.config.wechat_auth_enabled {
|
||||||
return Err(AppError::from_status(StatusCode::BAD_REQUEST).with_message("微信登录暂未启用"));
|
return Err(AppError::from_status(StatusCode::BAD_REQUEST).with_message("微信登录暂未启用"));
|
||||||
}
|
}
|
||||||
let result = state
|
let result = if let Some(wechat_phone_code) = payload
|
||||||
.phone_auth_service()
|
.wechat_phone_code
|
||||||
.bind_wechat_phone(
|
.as_deref()
|
||||||
BindWechatPhoneInput {
|
.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(),
|
user_id: authenticated.claims().user_id().to_string(),
|
||||||
phone_number: payload.phone,
|
phone_number: phone_profile.phone_number,
|
||||||
verify_code: payload.code,
|
})
|
||||||
},
|
.await
|
||||||
OffsetDateTime::now_utc(),
|
.map_err(map_wechat_bind_phone_error)?
|
||||||
)
|
} else {
|
||||||
.await
|
let phone = payload
|
||||||
.map_err(map_wechat_bind_phone_error)?;
|
.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 {
|
if result.activated_new_user {
|
||||||
crate::registration_reward::grant_new_user_registration_wallet_reward(
|
crate::registration_reward::grant_new_user_registration_wallet_reward(
|
||||||
&state,
|
&state,
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
use platform_auth::{
|
use platform_auth::{
|
||||||
DEFAULT_WECHAT_ACCESS_TOKEN_ENDPOINT, DEFAULT_WECHAT_AUTHORIZE_ENDPOINT,
|
DEFAULT_WECHAT_ACCESS_TOKEN_ENDPOINT, DEFAULT_WECHAT_AUTHORIZE_ENDPOINT,
|
||||||
DEFAULT_WECHAT_JS_CODE_SESSION_ENDPOINT, DEFAULT_WECHAT_USER_INFO_ENDPOINT, WechatAuthConfig,
|
DEFAULT_WECHAT_JS_CODE_SESSION_ENDPOINT, DEFAULT_WECHAT_PHONE_NUMBER_ENDPOINT,
|
||||||
WechatProvider,
|
DEFAULT_WECHAT_STABLE_ACCESS_TOKEN_ENDPOINT, DEFAULT_WECHAT_USER_INFO_ENDPOINT,
|
||||||
|
WechatAuthConfig, WechatProvider,
|
||||||
};
|
};
|
||||||
|
|
||||||
use crate::config::AppConfig;
|
use crate::config::AppConfig;
|
||||||
@@ -30,6 +31,14 @@ pub fn build_wechat_provider(config: &AppConfig) -> WechatProvider {
|
|||||||
&config.wechat_js_code_session_endpoint,
|
&config.wechat_js_code_session_endpoint,
|
||||||
DEFAULT_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_user_id.clone(),
|
||||||
config.wechat_mock_union_id.clone(),
|
config.wechat_mock_union_id.clone(),
|
||||||
config.wechat_mock_display_name.clone(),
|
config.wechat_mock_display_name.clone(),
|
||||||
|
|||||||
@@ -67,6 +67,12 @@ pub struct BindWechatPhoneInput {
|
|||||||
pub verify_code: String,
|
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)]
|
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||||
pub struct CreateRefreshSessionInput {
|
pub struct CreateRefreshSessionInput {
|
||||||
pub user_id: String,
|
pub user_id: String,
|
||||||
|
|||||||
@@ -627,6 +627,33 @@ impl PhoneAuthService {
|
|||||||
activated_new_user,
|
activated_new_user,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub async fn bind_wechat_verified_phone(
|
||||||
|
&self,
|
||||||
|
input: BindWechatVerifiedPhoneInput,
|
||||||
|
) -> Result<BindWechatPhoneResult, PhoneAuthError> {
|
||||||
|
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 {
|
impl WechatAuthStateService {
|
||||||
|
|||||||
@@ -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_USER_INFO_ENDPOINT: &str = "https://api.weixin.qq.com/sns/userinfo";
|
||||||
pub const DEFAULT_WECHAT_JS_CODE_SESSION_ENDPOINT: &str =
|
pub const DEFAULT_WECHAT_JS_CODE_SESSION_ENDPOINT: &str =
|
||||||
"https://api.weixin.qq.com/sns/jscode2session";
|
"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<Sha256>;
|
type HmacSha256 = Hmac<Sha256>;
|
||||||
|
|
||||||
@@ -184,6 +188,8 @@ pub struct WechatAuthConfig {
|
|||||||
pub access_token_endpoint: String,
|
pub access_token_endpoint: String,
|
||||||
pub user_info_endpoint: String,
|
pub user_info_endpoint: String,
|
||||||
pub js_code_session_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_user_id: String,
|
||||||
pub mock_union_id: Option<String>,
|
pub mock_union_id: Option<String>,
|
||||||
pub mock_display_name: String,
|
pub mock_display_name: String,
|
||||||
@@ -224,6 +230,15 @@ pub struct RealWechatProvider {
|
|||||||
access_token_endpoint: String,
|
access_token_endpoint: String,
|
||||||
user_info_endpoint: String,
|
user_info_endpoint: String,
|
||||||
js_code_session_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<String>,
|
||||||
|
pub country_code: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Clone, Debug)]
|
#[derive(Clone, Debug)]
|
||||||
@@ -325,6 +340,31 @@ struct WechatJsCodeSessionResponse {
|
|||||||
errmsg: Option<String>,
|
errmsg: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
struct WechatStableAccessTokenResponse {
|
||||||
|
access_token: Option<String>,
|
||||||
|
errcode: Option<i64>,
|
||||||
|
errmsg: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
struct WechatPhoneNumberResponse {
|
||||||
|
errcode: Option<i64>,
|
||||||
|
errmsg: Option<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
phone_info: Option<WechatPhoneNumberInfo>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
struct WechatPhoneNumberInfo {
|
||||||
|
#[serde(default)]
|
||||||
|
phone_number: Option<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
pure_phone_number: Option<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
country_code: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Debug, Deserialize)]
|
#[derive(Debug, Deserialize)]
|
||||||
struct AliyunSendSmsVerifyCodeResponse {
|
struct AliyunSendSmsVerifyCodeResponse {
|
||||||
// 阿里云 RPC 原始 JSON 使用首字母大写字段名,这里必须显式映射,避免把成功响应误判成空值。
|
// 阿里云 RPC 原始 JSON 使用首字母大写字段名,这里必须显式映射,避免把成功响应误判成空值。
|
||||||
@@ -648,6 +688,8 @@ impl WechatAuthConfig {
|
|||||||
access_token_endpoint: String,
|
access_token_endpoint: String,
|
||||||
user_info_endpoint: String,
|
user_info_endpoint: String,
|
||||||
js_code_session_endpoint: String,
|
js_code_session_endpoint: String,
|
||||||
|
stable_access_token_endpoint: String,
|
||||||
|
phone_number_endpoint: String,
|
||||||
mock_user_id: String,
|
mock_user_id: String,
|
||||||
mock_union_id: Option<String>,
|
mock_union_id: Option<String>,
|
||||||
mock_display_name: String,
|
mock_display_name: String,
|
||||||
@@ -664,6 +706,8 @@ impl WechatAuthConfig {
|
|||||||
access_token_endpoint,
|
access_token_endpoint,
|
||||||
user_info_endpoint,
|
user_info_endpoint,
|
||||||
js_code_session_endpoint,
|
js_code_session_endpoint,
|
||||||
|
stable_access_token_endpoint,
|
||||||
|
phone_number_endpoint,
|
||||||
mock_user_id,
|
mock_user_id,
|
||||||
mock_union_id,
|
mock_union_id,
|
||||||
mock_display_name,
|
mock_display_name,
|
||||||
@@ -717,6 +761,8 @@ impl WechatProvider {
|
|||||||
access_token_endpoint: config.access_token_endpoint,
|
access_token_endpoint: config.access_token_endpoint,
|
||||||
user_info_endpoint: config.user_info_endpoint,
|
user_info_endpoint: config.user_info_endpoint,
|
||||||
js_code_session_endpoint: config.js_code_session_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,
|
Self::Real(provider) => provider.resolve_mini_program_login_profile(code).await,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub async fn resolve_mini_program_phone_number(
|
||||||
|
&self,
|
||||||
|
code: Option<&str>,
|
||||||
|
) -> Result<WechatPhoneNumberProfile, WechatProviderError> {
|
||||||
|
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 {
|
impl MockWechatProvider {
|
||||||
@@ -990,6 +1058,141 @@ impl RealWechatProvider {
|
|||||||
avatar_url: None,
|
avatar_url: None,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async fn resolve_mini_program_phone_number(
|
||||||
|
&self,
|
||||||
|
code: Option<&str>,
|
||||||
|
) -> Result<WechatPhoneNumberProfile, WechatProviderError> {
|
||||||
|
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::<WechatPhoneNumberResponse>()
|
||||||
|
.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<String, WechatProviderError> {
|
||||||
|
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::<WechatStableAccessTokenResponse>()
|
||||||
|
.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(
|
fn build_mock_wechat_authorization_url(
|
||||||
@@ -1919,6 +2122,8 @@ mod tests {
|
|||||||
DEFAULT_WECHAT_ACCESS_TOKEN_ENDPOINT.to_string(),
|
DEFAULT_WECHAT_ACCESS_TOKEN_ENDPOINT.to_string(),
|
||||||
DEFAULT_WECHAT_USER_INFO_ENDPOINT.to_string(),
|
DEFAULT_WECHAT_USER_INFO_ENDPOINT.to_string(),
|
||||||
DEFAULT_WECHAT_JS_CODE_SESSION_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(),
|
"wx-user-001".to_string(),
|
||||||
Some("wx-union-001".to_string()),
|
Some("wx-union-001".to_string()),
|
||||||
"微信测试用户".to_string(),
|
"微信测试用户".to_string(),
|
||||||
@@ -1950,6 +2155,8 @@ mod tests {
|
|||||||
DEFAULT_WECHAT_ACCESS_TOKEN_ENDPOINT.to_string(),
|
DEFAULT_WECHAT_ACCESS_TOKEN_ENDPOINT.to_string(),
|
||||||
DEFAULT_WECHAT_USER_INFO_ENDPOINT.to_string(),
|
DEFAULT_WECHAT_USER_INFO_ENDPOINT.to_string(),
|
||||||
DEFAULT_WECHAT_JS_CODE_SESSION_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(),
|
"wx-user-001".to_string(),
|
||||||
Some("wx-union-001".to_string()),
|
Some("wx-union-001".to_string()),
|
||||||
"微信测试用户".to_string(),
|
"微信测试用户".to_string(),
|
||||||
|
|||||||
@@ -211,8 +211,12 @@ pub struct WechatCallbackQuery {
|
|||||||
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
|
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
|
||||||
#[serde(rename_all = "camelCase")]
|
#[serde(rename_all = "camelCase")]
|
||||||
pub struct WechatBindPhoneRequest {
|
pub struct WechatBindPhoneRequest {
|
||||||
pub phone: String,
|
#[serde(default)]
|
||||||
pub code: String,
|
pub phone: Option<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub code: Option<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub wechat_phone_code: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
|
#[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"
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ import type {
|
|||||||
AuthSessionsResponse,
|
AuthSessionsResponse,
|
||||||
AuthSessionSummary,
|
AuthSessionSummary,
|
||||||
AuthWechatBindPhoneResponse,
|
AuthWechatBindPhoneResponse,
|
||||||
|
AuthWechatBindPhoneRequest,
|
||||||
AuthWechatStartResponse,
|
AuthWechatStartResponse,
|
||||||
LogoutResponse,
|
LogoutResponse,
|
||||||
PublicUserSearchResponse,
|
PublicUserSearchResponse,
|
||||||
@@ -193,15 +194,16 @@ export async function redeemRegistrationInviteCode(inviteCode: string) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function bindWechatPhone(phone: string, code: string) {
|
export async function bindWechatPhone(phone: string, code: string) {
|
||||||
|
const payload: AuthWechatBindPhoneRequest = {
|
||||||
|
phone: normalizePhoneInput(phone),
|
||||||
|
code: code.trim(),
|
||||||
|
};
|
||||||
const response = await requestJson<AuthWechatBindPhoneResponse>(
|
const response = await requestJson<AuthWechatBindPhoneResponse>(
|
||||||
'/api/auth/wechat/bind-phone',
|
'/api/auth/wechat/bind-phone',
|
||||||
{
|
{
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({
|
body: JSON.stringify(payload),
|
||||||
phone: normalizePhoneInput(phone),
|
|
||||||
code: code.trim(),
|
|
||||||
}),
|
|
||||||
},
|
},
|
||||||
'绑定手机号失败',
|
'绑定手机号失败',
|
||||||
);
|
);
|
||||||
|
|||||||
Reference in New Issue
Block a user