feat: add wechat miniprogram webview login
This commit is contained in:
5
.env
Normal file
5
.env
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
# 微信小程序 web-view 登录配置。
|
||||||
|
# 留空时不覆盖已有微信网页 OAuth 配置;正式联调时再填小程序 AppID / AppSecret。
|
||||||
|
WECHAT_MINI_PROGRAM_APP_ID=""
|
||||||
|
WECHAT_MINI_PROGRAM_APP_SECRET=""
|
||||||
|
WECHAT_JS_CODE_SESSION_ENDPOINT=""
|
||||||
@@ -92,6 +92,25 @@
|
|||||||
2. 若是并入已有手机号正式账号,则返回目标正式账号快照,当前实现会保持其账号主登录方式,例如 `loginMethod = phone`。
|
2. 若是并入已有手机号正式账号,则返回目标正式账号快照,当前实现会保持其账号主登录方式,例如 `loginMethod = phone`。
|
||||||
3. 但 access token 中的 `provider` 仍按**本次登录来源**签发,不依赖账号主登录方式推断,因此微信绑定后的当前会话仍会签发 `provider = wechat`。
|
3. 但 access token 中的 `provider` 仍按**本次登录来源**签发,不依赖账号主登录方式推断,因此微信绑定后的当前会话仍会签发 `provider = wechat`。
|
||||||
|
|
||||||
|
### 3.4 `POST /api/auth/wechat/miniprogram-login`
|
||||||
|
|
||||||
|
职责固定为:
|
||||||
|
|
||||||
|
1. 接收微信小程序原生壳通过 `wx.login` 拿到的 `code`。
|
||||||
|
2. 在 `Axum` 内调用微信 `jscode2session`,兑换 `openid/unionid`。
|
||||||
|
3. 复用 `resolve_login` 处理 `unionid/openid -> user_id` 的查找、补写和待绑定账号创建。
|
||||||
|
4. 签发本系统 access token,并创建 refresh session。
|
||||||
|
5. 返回:
|
||||||
|
- `token`
|
||||||
|
- `bindingStatus`
|
||||||
|
- `user`
|
||||||
|
|
||||||
|
关键约束:
|
||||||
|
|
||||||
|
1. 小程序壳不能把裸 `openid` 直接拼给 H5 做登录。
|
||||||
|
2. H5 仍只消费本系统 `auth_token`,小程序壳只是把这枚 token 放入既有 hash 回调格式。
|
||||||
|
3. 小程序请求必须补传 `x-client-type=mini_program` 与 `x-client-runtime=wechat_mini_program`,用于 refresh session 记录来源。
|
||||||
|
|
||||||
## 4. 当前最小实现策略
|
## 4. 当前最小实现策略
|
||||||
|
|
||||||
当前阶段为了先打通 Rust 后端闭环,采用以下最小实现:
|
当前阶段为了先打通 Rust 后端闭环,采用以下最小实现:
|
||||||
@@ -125,6 +144,7 @@
|
|||||||
4. `GET /api/auth/wechat/start`
|
4. `GET /api/auth/wechat/start`
|
||||||
5. `GET /api/auth/wechat/callback`
|
5. `GET /api/auth/wechat/callback`
|
||||||
6. `POST /api/auth/wechat/bind-phone`
|
6. `POST /api/auth/wechat/bind-phone`
|
||||||
|
7. `POST /api/auth/wechat/miniprogram-login`
|
||||||
|
|
||||||
## 6. 环境变量
|
## 6. 环境变量
|
||||||
|
|
||||||
@@ -139,11 +159,14 @@
|
|||||||
7. `WECHAT_AUTHORIZE_ENDPOINT`
|
7. `WECHAT_AUTHORIZE_ENDPOINT`
|
||||||
8. `WECHAT_ACCESS_TOKEN_ENDPOINT`
|
8. `WECHAT_ACCESS_TOKEN_ENDPOINT`
|
||||||
9. `WECHAT_USER_INFO_ENDPOINT`
|
9. `WECHAT_USER_INFO_ENDPOINT`
|
||||||
10. `WECHAT_STATE_TTL_MINUTES`
|
10. `WECHAT_JS_CODE_SESSION_ENDPOINT`
|
||||||
11. `WECHAT_MOCK_USER_ID`
|
11. `WECHAT_MINI_PROGRAM_APP_ID`
|
||||||
12. `WECHAT_MOCK_UNION_ID`
|
12. `WECHAT_MINI_PROGRAM_APP_SECRET`
|
||||||
13. `WECHAT_MOCK_DISPLAY_NAME`
|
13. `WECHAT_STATE_TTL_MINUTES`
|
||||||
14. `WECHAT_MOCK_AVATAR_URL`
|
14. `WECHAT_MOCK_USER_ID`
|
||||||
|
15. `WECHAT_MOCK_UNION_ID`
|
||||||
|
16. `WECHAT_MOCK_DISPLAY_NAME`
|
||||||
|
17. `WECHAT_MOCK_AVATAR_URL`
|
||||||
|
|
||||||
## 7. 与后续 SpacetimeDB 的衔接要求
|
## 7. 与后续 SpacetimeDB 的衔接要求
|
||||||
|
|
||||||
|
|||||||
@@ -100,6 +100,9 @@ real 模式行为固定为:
|
|||||||
| `WECHAT_AUTHORIZE_ENDPOINT` | 否 | 默认桌面二维码授权地址 |
|
| `WECHAT_AUTHORIZE_ENDPOINT` | 否 | 默认桌面二维码授权地址 |
|
||||||
| `WECHAT_ACCESS_TOKEN_ENDPOINT` | 否 | 默认 access_token 接口 |
|
| `WECHAT_ACCESS_TOKEN_ENDPOINT` | 否 | 默认 access_token 接口 |
|
||||||
| `WECHAT_USER_INFO_ENDPOINT` | 否 | 默认用户信息接口 |
|
| `WECHAT_USER_INFO_ENDPOINT` | 否 | 默认用户信息接口 |
|
||||||
|
| `WECHAT_JS_CODE_SESSION_ENDPOINT` | 否 | 默认小程序 `jscode2session` 接口 |
|
||||||
|
| `WECHAT_MINI_PROGRAM_APP_ID` | 小程序 `real` 模式必填 | 微信小程序 AppID;不填时回退 `WECHAT_APP_ID` |
|
||||||
|
| `WECHAT_MINI_PROGRAM_APP_SECRET` | 小程序 `real` 模式必填 | 微信小程序 AppSecret;不填时回退 `WECHAT_APP_SECRET` |
|
||||||
| `WECHAT_STATE_TTL_MINUTES` | 否 | state 有效期,默认 `15` 分钟 |
|
| `WECHAT_STATE_TTL_MINUTES` | 否 | state 有效期,默认 `15` 分钟 |
|
||||||
|
|
||||||
补充说明:
|
补充说明:
|
||||||
@@ -225,7 +228,46 @@ https://game.example.com
|
|||||||
- `wechatBound = true`
|
- `wechatBound = true`
|
||||||
- `bindingStatus` 已更新为目标状态
|
- `bindingStatus` 已更新为目标状态
|
||||||
|
|
||||||
## 8. 账号命中规则
|
## 8. 小程序 web-view 登录联调步骤
|
||||||
|
|
||||||
|
小程序壳走原生 `wx.login`,不走网页 OAuth callback。联调前需要额外确认:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
WECHAT_AUTH_ENABLED=true
|
||||||
|
WECHAT_AUTH_PROVIDER=real
|
||||||
|
WECHAT_MINI_PROGRAM_APP_ID="你的微信小程序 AppID"
|
||||||
|
WECHAT_MINI_PROGRAM_APP_SECRET="你的微信小程序 AppSecret"
|
||||||
|
```
|
||||||
|
|
||||||
|
在 `miniprogram/config.js` 中确认:
|
||||||
|
|
||||||
|
```js
|
||||||
|
const WEB_VIEW_ENTRY_URL = 'https://你的H5业务域名/';
|
||||||
|
const API_BASE_URL = 'https://你的服务器域名/';
|
||||||
|
const MINI_PROGRAM_APP_ID = '你的微信小程序 AppID';
|
||||||
|
```
|
||||||
|
|
||||||
|
联调流程:
|
||||||
|
|
||||||
|
1. 微信开发者工具打开项目根目录。
|
||||||
|
2. 小程序启动后调用 `wx.login`。
|
||||||
|
3. 小程序壳请求:
|
||||||
|
|
||||||
|
```http
|
||||||
|
POST /api/auth/wechat/miniprogram-login
|
||||||
|
```
|
||||||
|
|
||||||
|
4. 后端通过 `jscode2session` 兑换 `openid/unionid`。
|
||||||
|
5. 后端返回系统 `token`、`bindingStatus` 与 `user`。
|
||||||
|
6. 小程序壳打开 H5,并在 hash 中附加:
|
||||||
|
- `auth_provider=wechat`
|
||||||
|
- `auth_token=...`
|
||||||
|
- `auth_binding_status=active|pending_bind_phone`
|
||||||
|
7. H5 消费 hash 后通过 `/api/auth/me` 恢复登录态。
|
||||||
|
|
||||||
|
这里不能把裸 `openid` 作为 web-view query 登录凭证;`openid` 只能留在后端身份绑定层,H5 只消费本系统 JWT。
|
||||||
|
|
||||||
|
## 9. 账号命中规则
|
||||||
|
|
||||||
当前实现固定按以下顺序命中已有账号:
|
当前实现固定按以下顺序命中已有账号:
|
||||||
|
|
||||||
@@ -238,7 +280,7 @@ https://game.example.com
|
|||||||
1. 若按 `unionid` 命中了已有微信身份,但本次微信回调带来了新的 `openid`,后端会把新的 `openid -> user_id` 映射补齐
|
1. 若按 `unionid` 命中了已有微信身份,但本次微信回调带来了新的 `openid`,后端会把新的 `openid -> user_id` 映射补齐
|
||||||
2. 若后续绑定手机号时发现该手机号已经属于正式账号,则会把微信身份并入这个正式账号
|
2. 若后续绑定手机号时发现该手机号已经属于正式账号,则会把微信身份并入这个正式账号
|
||||||
|
|
||||||
## 9. 前端验收点
|
## 10. 前端验收点
|
||||||
|
|
||||||
前端联调时至少检查以下行为:
|
前端联调时至少检查以下行为:
|
||||||
|
|
||||||
|
|||||||
@@ -6,11 +6,18 @@
|
|||||||
|
|
||||||
本次先用微信小程序 `web-view` 承载现有 H5,不重写 React/Vite 主前端,也不把 SpacetimeDB SDK 或业务规则搬进小程序端。
|
本次先用微信小程序 `web-view` 承载现有 H5,不重写 React/Vite 主前端,也不把 SpacetimeDB SDK 或业务规则搬进小程序端。
|
||||||
|
|
||||||
当前小程序壳只承担三件事:
|
当前小程序壳只承担四件事:
|
||||||
|
|
||||||
1. 提供微信开发者工具可识别的 `miniprogram/` 工程根目录。
|
1. 提供微信开发者工具可识别的 `miniprogram/` 工程根目录。
|
||||||
2. 用一个全屏 `web-view` 打开现有 H5 入口。
|
2. 在原生小程序壳中调用 `wx.login` 获取小程序 `code`。
|
||||||
3. 给 H5 URL 附加来源标记,便于后续识别 `wechat_mini_program` 宿主。
|
3. 调用服务器域名下的 `/api/auth/wechat/miniprogram-login`,由 Rust `api-server` 兑换微信身份并签发系统登录态。
|
||||||
|
4. 用一个全屏 `web-view` 打开现有 H5 入口,并把系统 `auth_token` 放入 H5 现有登录回调 hash。
|
||||||
|
|
||||||
|
重要边界:
|
||||||
|
|
||||||
|
1. `openid` 只作为后端微信身份绑定依据,不直接暴露给 H5 当登录凭证。
|
||||||
|
2. H5 继续消费本系统 JWT,也就是 `#auth_provider=wechat&auth_token=...&auth_binding_status=...`。
|
||||||
|
3. 这与 [`WECHAT_LOGIN_AXUM_IMPLEMENTATION_DESIGN_2026-04-21.md`](./WECHAT_LOGIN_AXUM_IMPLEMENTATION_DESIGN_2026-04-21.md) 中“微信只提供三方身份,Axum 签发系统 JWT”的边界一致。
|
||||||
|
|
||||||
## 2. 文件入口
|
## 2. 文件入口
|
||||||
|
|
||||||
@@ -20,6 +27,8 @@
|
|||||||
| `miniprogram/app.json` | 小程序全局配置,注册 `pages/web-view/index`。 |
|
| `miniprogram/app.json` | 小程序全局配置,注册 `pages/web-view/index`。 |
|
||||||
| `miniprogram/config.js` | 业务域名入口配置,需要部署时填写。 |
|
| `miniprogram/config.js` | 业务域名入口配置,需要部署时填写。 |
|
||||||
| `miniprogram/pages/web-view/index.*` | 最小 web-view 页面。 |
|
| `miniprogram/pages/web-view/index.*` | 最小 web-view 页面。 |
|
||||||
|
| `server-rs/crates/api-server/src/wechat_auth.rs` | 新增小程序登录接口 `/api/auth/wechat/miniprogram-login`。 |
|
||||||
|
| `server-rs/crates/platform-auth/src/lib.rs` | 新增 `jscode2session` 兑换能力。 |
|
||||||
|
|
||||||
## 3. 需要手工填写的配置
|
## 3. 需要手工填写的配置
|
||||||
|
|
||||||
@@ -27,14 +36,36 @@
|
|||||||
|
|
||||||
```js
|
```js
|
||||||
const WEB_VIEW_ENTRY_URL = 'https://你的H5业务域名/';
|
const WEB_VIEW_ENTRY_URL = 'https://你的H5业务域名/';
|
||||||
|
const API_BASE_URL = 'https://你的服务器域名/';
|
||||||
|
const MINI_PROGRAM_APP_ID = '你的微信小程序 AppID';
|
||||||
|
const MINI_PROGRAM_ENV = 'develop';
|
||||||
```
|
```
|
||||||
|
|
||||||
约束:
|
约束:
|
||||||
|
|
||||||
1. 必须是 `https`。
|
1. 必须是 `https`。
|
||||||
2. 不能是 `localhost` 或 IP。
|
2. 不能是 `localhost` 或 IP。
|
||||||
3. 域名需要在微信小程序后台配置为业务域名。
|
3. `WEB_VIEW_ENTRY_URL` 域名需要在微信小程序后台配置为业务域名。
|
||||||
4. H5 页面里的 API、图片、音视频、iframe 等外链也要满足微信侧域名与证书要求。
|
4. `API_BASE_URL` 域名需要在微信小程序后台配置为 request 合法域名。
|
||||||
|
5. H5 页面里的 API、图片、音视频、iframe 等外链也要满足微信侧域名与证书要求。
|
||||||
|
|
||||||
|
在 `api-server` 环境变量中填写:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
WECHAT_AUTH_ENABLED=true
|
||||||
|
WECHAT_AUTH_PROVIDER=real
|
||||||
|
WECHAT_MINI_PROGRAM_APP_ID="你的微信小程序 AppID"
|
||||||
|
WECHAT_MINI_PROGRAM_APP_SECRET="你的微信小程序 AppSecret"
|
||||||
|
```
|
||||||
|
|
||||||
|
如果开放平台网页 OAuth 与小程序使用同一个 AppID/Secret,也可以继续使用已有:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
WECHAT_APP_ID="你的微信 AppID"
|
||||||
|
WECHAT_APP_SECRET="你的微信 AppSecret"
|
||||||
|
```
|
||||||
|
|
||||||
|
但正式部署建议把小程序配置写到 `WECHAT_MINI_PROGRAM_APP_ID` 与 `WECHAT_MINI_PROGRAM_APP_SECRET`,避免和网页 OAuth 配置混淆。
|
||||||
|
|
||||||
`WEB_VIEW_SOURCE_QUERY` 默认附加:
|
`WEB_VIEW_SOURCE_QUERY` 默认附加:
|
||||||
|
|
||||||
@@ -43,19 +74,61 @@ clientType=mini_program
|
|||||||
clientRuntime=wechat_mini_program
|
clientRuntime=wechat_mini_program
|
||||||
```
|
```
|
||||||
|
|
||||||
后续如果 H5 或 `api-server` 需要更严格地区分来源,应优先使用请求头或登录态中的客户端身份字段,不要只信任 URL query。
|
小程序壳调用登录接口时会补传:
|
||||||
|
|
||||||
## 4. 微信后台配置
|
```text
|
||||||
|
x-client-type=mini_program
|
||||||
|
x-client-runtime=wechat_mini_program
|
||||||
|
x-client-platform=ios|android|unknown
|
||||||
|
x-client-instance-id=<小程序本地持久化随机值>
|
||||||
|
x-mini-program-app-id=<MINI_PROGRAM_APP_ID>
|
||||||
|
x-mini-program-env=<MINI_PROGRAM_ENV>
|
||||||
|
```
|
||||||
|
|
||||||
|
这些字段会进入 refresh session 的客户端身份快照;URL query 只作为 H5 识别宿主来源的轻量标记,不作为鉴权依据。
|
||||||
|
|
||||||
|
## 4. 登录链路
|
||||||
|
|
||||||
|
当前登录链路固定为:
|
||||||
|
|
||||||
|
1. 小程序页面启动。
|
||||||
|
2. 调用 `wx.login` 获取一次性 `code`。
|
||||||
|
3. 小程序壳请求:
|
||||||
|
|
||||||
|
```http
|
||||||
|
POST /api/auth/wechat/miniprogram-login
|
||||||
|
Content-Type: application/json
|
||||||
|
|
||||||
|
{
|
||||||
|
"code": "wx.login 返回的 code"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
4. `api-server` 调用微信 `jscode2session` 兑换 `openid/unionid`。
|
||||||
|
5. `api-server` 复用现有微信身份逻辑:
|
||||||
|
- 先按 `unionid` 命中已有身份
|
||||||
|
- 再按 `openid` 命中已有身份
|
||||||
|
- 都没有命中时创建 `pending_bind_phone` 的微信壳账号
|
||||||
|
6. `api-server` 签发系统 access token,并写入 refresh session。
|
||||||
|
7. 小程序壳打开:
|
||||||
|
|
||||||
|
```text
|
||||||
|
https://你的H5业务域名/#auth_provider=wechat&auth_token=<系统JWT>&auth_binding_status=active|pending_bind_phone
|
||||||
|
```
|
||||||
|
|
||||||
|
8. H5 复用 `consumeAuthCallbackResult()` 消费 `auth_token` 并进入现有登录态恢复流程。
|
||||||
|
|
||||||
|
## 5. 微信后台配置
|
||||||
|
|
||||||
至少需要在小程序后台配置:
|
至少需要在小程序后台配置:
|
||||||
|
|
||||||
1. `业务域名`:承载 H5 的域名。
|
1. `业务域名`:承载 H5 的域名。
|
||||||
2. `request 合法域名`:H5 里如果仍有小程序原生层直接请求 API,才需要配置;当前 web-view 壳本身不直接请求业务 API。
|
2. `request 合法域名`:`API_BASE_URL` 对应的服务器域名。
|
||||||
3. `socket 合法域名`:若后续小程序原生层直连 WebSocket 才需要;当前不启用。
|
3. `socket 合法域名`:若后续小程序原生层直连 WebSocket 才需要;当前不启用。
|
||||||
|
|
||||||
当前仓库的 H5 仍建议通过同域 `/api/*` 访问 Rust `api-server`,避免在小程序和 H5 中分别维护跨域白名单。
|
当前仓库的 H5 仍建议通过同域 `/api/*` 访问 Rust `api-server`,避免在小程序和 H5 中分别维护跨域白名单。
|
||||||
|
|
||||||
## 5. 当前不做的事
|
## 6. 当前不做的事
|
||||||
|
|
||||||
本次不做原生小程序页面迁移,原因是当前主前端依赖:
|
本次不做原生小程序页面迁移,原因是当前主前端依赖:
|
||||||
|
|
||||||
@@ -66,9 +139,14 @@ clientRuntime=wechat_mini_program
|
|||||||
|
|
||||||
这些能力不能稳定原样运行在原生小程序宿主中。后续如要原生化,应新建小程序端宿主,复用 `packages/shared` 契约和 `api-server` BFF,而不是把 `src/` 整体搬过去。
|
这些能力不能稳定原样运行在原生小程序宿主中。后续如要原生化,应新建小程序端宿主,复用 `packages/shared` 契约和 `api-server` BFF,而不是把 `src/` 整体搬过去。
|
||||||
|
|
||||||
## 6. 验收口径
|
本次也不做 `openid` query 直登。原因是 `openid` 不是本系统签发的登录凭证,不能表达 token 版本、会话 ID、绑定状态、角色与过期时间,也不能被 H5 直接信任。
|
||||||
|
|
||||||
|
## 7. 验收口径
|
||||||
|
|
||||||
1. 微信开发者工具打开项目根目录后,识别 `miniprogram/` 为小程序源码目录。
|
1. 微信开发者工具打开项目根目录后,识别 `miniprogram/` 为小程序源码目录。
|
||||||
2. 未填写 `WEB_VIEW_ENTRY_URL` 时,页面显示配置提示,不出现空白页。
|
2. 未填写 `WEB_VIEW_ENTRY_URL` 或 `API_BASE_URL` 时,页面显示配置提示,不出现空白页。
|
||||||
3. 填写已配置业务域名后,首页全屏打开 H5。
|
3. 填写已配置业务域名后,小程序先请求 `/api/auth/wechat/miniprogram-login`。
|
||||||
4. H5 内登录、SSE、图片资源等能力按 H5 自身部署域名和 `/api/*` 代理链路验证。
|
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` 的会话记录。
|
||||||
|
|||||||
@@ -15,6 +15,7 @@
|
|||||||
"uploadFile": 60000,
|
"uploadFile": 60000,
|
||||||
"downloadFile": 60000
|
"downloadFile": 60000
|
||||||
},
|
},
|
||||||
|
"permission": {},
|
||||||
"style": "v2",
|
"style": "v2",
|
||||||
"sitemapLocation": "sitemap.json"
|
"sitemapLocation": "sitemap.json"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,6 +3,16 @@
|
|||||||
// 注意:必须是 https 域名,不能是 localhost、IP 地址或未备案域名。
|
// 注意:必须是 https 域名,不能是 localhost、IP 地址或未备案域名。
|
||||||
const WEB_VIEW_ENTRY_URL = 'https://dev.genarrative.world/';
|
const WEB_VIEW_ENTRY_URL = 'https://dev.genarrative.world/';
|
||||||
|
|
||||||
|
// 中文注释:这里填写 Rust api-server 的公网 HTTPS 域名,必须在“小程序后台-开发设置-request 合法域名”中配置。
|
||||||
|
// 如果 H5 和 API 同域,可保持和 WEB_VIEW_ENTRY_URL 同一个域名;请求路径会固定走 /api/auth/wechat/miniprogram-login。
|
||||||
|
const API_BASE_URL = 'https://dev.genarrative.world/';
|
||||||
|
|
||||||
|
// 中文注释:这里填写微信小程序 AppID,用于后端记录会话来源;project.config.json 里的 appid 也要保持一致。
|
||||||
|
const MINI_PROGRAM_APP_ID = 'wx3da23ea14ca66b65';
|
||||||
|
|
||||||
|
// 中文注释:按当前上传版本填写 develop / trial / release,后端会写入会话来源快照。
|
||||||
|
const MINI_PROGRAM_ENV = 'develop';
|
||||||
|
|
||||||
// 中文注释:给 H5 加一个来源标记,便于后续前端或后端识别这是微信小程序 web-view 宿主。
|
// 中文注释:给 H5 加一个来源标记,便于后续前端或后端识别这是微信小程序 web-view 宿主。
|
||||||
const WEB_VIEW_SOURCE_QUERY = {
|
const WEB_VIEW_SOURCE_QUERY = {
|
||||||
clientType: 'mini_program',
|
clientType: 'mini_program',
|
||||||
@@ -10,6 +20,9 @@ const WEB_VIEW_SOURCE_QUERY = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
module.exports = {
|
module.exports = {
|
||||||
|
API_BASE_URL,
|
||||||
|
MINI_PROGRAM_APP_ID,
|
||||||
|
MINI_PROGRAM_ENV,
|
||||||
WEB_VIEW_ENTRY_URL,
|
WEB_VIEW_ENTRY_URL,
|
||||||
WEB_VIEW_SOURCE_QUERY,
|
WEB_VIEW_SOURCE_QUERY,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,10 +1,28 @@
|
|||||||
const { WEB_VIEW_ENTRY_URL, WEB_VIEW_SOURCE_QUERY } = require('../../config');
|
const {
|
||||||
|
API_BASE_URL,
|
||||||
|
MINI_PROGRAM_APP_ID,
|
||||||
|
MINI_PROGRAM_ENV,
|
||||||
|
WEB_VIEW_ENTRY_URL,
|
||||||
|
WEB_VIEW_SOURCE_QUERY,
|
||||||
|
} = require('../../config');
|
||||||
|
|
||||||
|
const MINI_PROGRAM_CLIENT_TYPE = 'mini_program';
|
||||||
|
const MINI_PROGRAM_CLIENT_RUNTIME = 'wechat_mini_program';
|
||||||
|
const CLIENT_INSTANCE_STORAGE_KEY = 'genarrative:mini-program-client-instance-id';
|
||||||
|
|
||||||
function isConfiguredEntryUrl(value) {
|
function isConfiguredEntryUrl(value) {
|
||||||
const trimmed = String(value || '').trim();
|
const trimmed = String(value || '').trim();
|
||||||
return /^https:\/\/[^/]+/i.test(trimmed);
|
return /^https:\/\/[^/]+/i.test(trimmed);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function trimTrailingSlash(value) {
|
||||||
|
return String(value || '').trim().replace(/\/+$/u, '');
|
||||||
|
}
|
||||||
|
|
||||||
|
function isConfiguredApiBaseUrl(value) {
|
||||||
|
return /^https:\/\/[^/]+/i.test(String(value || '').trim());
|
||||||
|
}
|
||||||
|
|
||||||
function appendQuery(url, query) {
|
function appendQuery(url, query) {
|
||||||
const pairs = Object.keys(query)
|
const pairs = Object.keys(query)
|
||||||
.filter((key) => query[key])
|
.filter((key) => query[key])
|
||||||
@@ -20,25 +38,188 @@ function appendQuery(url, query) {
|
|||||||
return `${url}${url.includes('?') ? '&' : '?'}${pairs.join('&')}`;
|
return `${url}${url.includes('?') ? '&' : '?'}${pairs.join('&')}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
function resolveWebViewUrl() {
|
function appendHashParams(url, params) {
|
||||||
|
const pairs = Object.keys(params)
|
||||||
|
.filter((key) => params[key])
|
||||||
|
.map(
|
||||||
|
(key) =>
|
||||||
|
`${encodeURIComponent(key)}=${encodeURIComponent(String(params[key]))}`,
|
||||||
|
);
|
||||||
|
if (pairs.length === 0) {
|
||||||
|
return url;
|
||||||
|
}
|
||||||
|
|
||||||
|
const hashIndex = url.indexOf('#');
|
||||||
|
const baseUrl = hashIndex >= 0 ? url.slice(0, hashIndex) : url;
|
||||||
|
const rawHash = hashIndex >= 0 ? url.slice(hashIndex + 1) : '';
|
||||||
|
const separator = rawHash ? '&' : '';
|
||||||
|
return `${baseUrl}#${rawHash}${separator}${pairs.join('&')}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveWebViewUrl(authResult) {
|
||||||
const entryUrl = String(WEB_VIEW_ENTRY_URL || '').trim();
|
const entryUrl = String(WEB_VIEW_ENTRY_URL || '').trim();
|
||||||
if (!isConfiguredEntryUrl(entryUrl)) {
|
if (!isConfiguredEntryUrl(entryUrl)) {
|
||||||
return '';
|
return '';
|
||||||
}
|
}
|
||||||
|
|
||||||
return appendQuery(entryUrl, WEB_VIEW_SOURCE_QUERY);
|
const sourcedUrl = appendQuery(entryUrl, WEB_VIEW_SOURCE_QUERY);
|
||||||
|
if (!authResult || !authResult.token) {
|
||||||
|
return sourcedUrl;
|
||||||
|
}
|
||||||
|
|
||||||
|
return appendHashParams(sourcedUrl, {
|
||||||
|
auth_provider: 'wechat',
|
||||||
|
auth_token: authResult.token,
|
||||||
|
auth_binding_status: authResult.bindingStatus,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function getClientInstanceId() {
|
||||||
|
const stored = wx.getStorageSync(CLIENT_INSTANCE_STORAGE_KEY);
|
||||||
|
if (stored) {
|
||||||
|
return String(stored);
|
||||||
|
}
|
||||||
|
|
||||||
|
const nextId = `wxmp_${Date.now()}_${Math.random().toString(36).slice(2, 10)}`;
|
||||||
|
wx.setStorageSync(CLIENT_INSTANCE_STORAGE_KEY, nextId);
|
||||||
|
return nextId;
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveClientPlatform() {
|
||||||
|
const info = wx.getSystemInfoSync();
|
||||||
|
const platform = String(info.platform || '').toLowerCase();
|
||||||
|
if (platform === 'ios') {
|
||||||
|
return 'ios';
|
||||||
|
}
|
||||||
|
if (platform === 'android') {
|
||||||
|
return 'android';
|
||||||
|
}
|
||||||
|
return 'unknown';
|
||||||
|
}
|
||||||
|
|
||||||
|
function wxLogin() {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
wx.login({
|
||||||
|
success(result) {
|
||||||
|
if (result.code) {
|
||||||
|
resolve(result.code);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
reject(new Error('微信登录未返回 code'));
|
||||||
|
},
|
||||||
|
fail(error) {
|
||||||
|
reject(new Error(error.errMsg || '微信登录失败'));
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function requestMiniProgramLogin(code) {
|
||||||
|
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/miniprogram-login`,
|
||||||
|
method: 'POST',
|
||||||
|
data: { code },
|
||||||
|
header: {
|
||||||
|
'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);
|
||||||
|
if (!response || !response.token) {
|
||||||
|
throw new Error('服务器未返回登录态');
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
token: response.token,
|
||||||
|
bindingStatus: response.bindingStatus || 'pending_bind_phone',
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
Page({
|
Page({
|
||||||
data: {
|
data: {
|
||||||
|
errorMessage: '',
|
||||||
|
loading: true,
|
||||||
webViewUrl: '',
|
webViewUrl: '',
|
||||||
},
|
},
|
||||||
|
|
||||||
onLoad() {
|
async onLoad() {
|
||||||
// 中文注释:web-view 只能打开已配置业务域名;未配置时展示本地提示,避免空白页误判。
|
// 中文注释:web-view 只能打开已配置业务域名;未配置时展示本地提示,避免空白页误判。
|
||||||
|
if (!isConfiguredEntryUrl(WEB_VIEW_ENTRY_URL)) {
|
||||||
|
this.setData({
|
||||||
|
errorMessage: '请先在 miniprogram/config.js 填写 WEB_VIEW_ENTRY_URL。',
|
||||||
|
loading: false,
|
||||||
|
webViewUrl: '',
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!isConfiguredApiBaseUrl(API_BASE_URL)) {
|
||||||
|
this.setData({
|
||||||
|
errorMessage: '请先在 miniprogram/config.js 填写 API_BASE_URL。',
|
||||||
|
loading: false,
|
||||||
|
webViewUrl: '',
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const authResult = await resolveAuthResult();
|
||||||
|
this.setData({
|
||||||
|
errorMessage: '',
|
||||||
|
loading: false,
|
||||||
|
webViewUrl: resolveWebViewUrl(authResult),
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
this.setData({
|
||||||
|
errorMessage:
|
||||||
|
error && error.message
|
||||||
|
? error.message
|
||||||
|
: '微信登录失败,请稍后重试。',
|
||||||
|
loading: false,
|
||||||
|
webViewUrl: '',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
handleRetryLogin() {
|
||||||
this.setData({
|
this.setData({
|
||||||
webViewUrl: resolveWebViewUrl(),
|
errorMessage: '',
|
||||||
|
loading: true,
|
||||||
|
webViewUrl: '',
|
||||||
});
|
});
|
||||||
|
this.onLoad();
|
||||||
},
|
},
|
||||||
|
|
||||||
handleWebViewLoad(event) {
|
handleWebViewLoad(event) {
|
||||||
|
|||||||
@@ -7,9 +7,16 @@
|
|||||||
/>
|
/>
|
||||||
</block>
|
</block>
|
||||||
|
|
||||||
<view wx:else class="setup-screen">
|
<view wx:elif="{{loading}}" class="setup-screen">
|
||||||
<view class="setup-card">
|
<view class="setup-card">
|
||||||
<view class="setup-title">需要配置 H5 入口</view>
|
<view class="setup-title">正在登录</view>
|
||||||
<view class="setup-text">请先在 miniprogram/config.js 填写 WEB_VIEW_ENTRY_URL。</view>
|
</view>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<view wx:else class="setup-screen">
|
||||||
|
<view class="setup-card">
|
||||||
|
<view class="setup-title">无法进入</view>
|
||||||
|
<view class="setup-text">{{errorMessage}}</view>
|
||||||
|
<button class="retry-button" bindtap="handleRetryLogin">重试</button>
|
||||||
</view>
|
</view>
|
||||||
</view>
|
</view>
|
||||||
|
|||||||
@@ -31,3 +31,13 @@
|
|||||||
line-height: 1.55;
|
line-height: 1.55;
|
||||||
color: rgba(245, 247, 251, 0.72);
|
color: rgba(245, 247, 251, 0.72);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.retry-button {
|
||||||
|
margin-top: 28rpx;
|
||||||
|
width: 100%;
|
||||||
|
border-radius: 8rpx;
|
||||||
|
background: #f5f7fb;
|
||||||
|
color: #0b0f14;
|
||||||
|
font-size: 28rpx;
|
||||||
|
line-height: 2.6;
|
||||||
|
}
|
||||||
|
|||||||
@@ -123,6 +123,16 @@ export type AuthWechatBindPhoneResponse = {
|
|||||||
user: AuthUser;
|
user: AuthUser;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type AuthWechatMiniProgramLoginRequest = {
|
||||||
|
code: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type AuthWechatMiniProgramLoginResponse = {
|
||||||
|
token: string;
|
||||||
|
bindingStatus: AuthBindingStatus;
|
||||||
|
user: AuthUser;
|
||||||
|
};
|
||||||
|
|
||||||
export type AuthPhoneChangeRequest = {
|
export type AuthPhoneChangeRequest = {
|
||||||
phone: string;
|
phone: string;
|
||||||
code: string;
|
code: string;
|
||||||
|
|||||||
@@ -123,7 +123,9 @@ use crate::{
|
|||||||
begin_story_runtime_session, begin_story_session, continue_story,
|
begin_story_runtime_session, begin_story_session, continue_story,
|
||||||
get_story_runtime_projection, get_story_session_state, resolve_story_runtime_action,
|
get_story_runtime_projection, get_story_session_state, resolve_story_runtime_action,
|
||||||
},
|
},
|
||||||
wechat_auth::{bind_wechat_phone, handle_wechat_callback, start_wechat_login},
|
wechat_auth::{
|
||||||
|
bind_wechat_phone, handle_wechat_callback, login_wechat_mini_program, start_wechat_login,
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
const PUZZLE_REFERENCE_IMAGE_BODY_LIMIT_BYTES: usize = 12 * 1024 * 1024;
|
const PUZZLE_REFERENCE_IMAGE_BODY_LIMIT_BYTES: usize = 12 * 1024 * 1024;
|
||||||
@@ -241,6 +243,10 @@ pub fn build_router(state: AppState) -> Router {
|
|||||||
.route("/api/auth/phone/login", post(phone_login))
|
.route("/api/auth/phone/login", post(phone_login))
|
||||||
.route("/api/auth/wechat/start", get(start_wechat_login))
|
.route("/api/auth/wechat/start", get(start_wechat_login))
|
||||||
.route("/api/auth/wechat/callback", get(handle_wechat_callback))
|
.route("/api/auth/wechat/callback", get(handle_wechat_callback))
|
||||||
|
.route(
|
||||||
|
"/api/auth/wechat/miniprogram-login",
|
||||||
|
post(login_wechat_mini_program),
|
||||||
|
)
|
||||||
.route(
|
.route(
|
||||||
"/api/auth/wechat/bind-phone",
|
"/api/auth/wechat/bind-phone",
|
||||||
post(bind_wechat_phone).route_layer(middleware::from_fn_with_state(
|
post(bind_wechat_phone).route_layer(middleware::from_fn_with_state(
|
||||||
@@ -2716,6 +2722,103 @@ mod tests {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn wechat_miniprogram_login_returns_system_token_and_marks_session_source() {
|
||||||
|
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-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-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 refresh_cookie = login_response
|
||||||
|
.headers()
|
||||||
|
.get("set-cookie")
|
||||||
|
.and_then(|value| value.to_str().ok())
|
||||||
|
.expect("refresh cookie should exist")
|
||||||
|
.to_string();
|
||||||
|
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())
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
login_payload["user"]["loginMethod"],
|
||||||
|
Value::String("wechat".to_string())
|
||||||
|
);
|
||||||
|
assert!(refresh_cookie.contains("genarrative_refresh_session="));
|
||||||
|
|
||||||
|
let sessions_response = app
|
||||||
|
.oneshot(
|
||||||
|
Request::builder()
|
||||||
|
.uri("/api/auth/sessions")
|
||||||
|
.header("authorization", format!("Bearer {token}"))
|
||||||
|
.header("cookie", refresh_cookie)
|
||||||
|
.body(Body::empty())
|
||||||
|
.expect("sessions request should build"),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.expect("sessions request should succeed");
|
||||||
|
|
||||||
|
assert_eq!(sessions_response.status(), StatusCode::OK);
|
||||||
|
let sessions_body = sessions_response
|
||||||
|
.into_body()
|
||||||
|
.collect()
|
||||||
|
.await
|
||||||
|
.expect("sessions body should collect")
|
||||||
|
.to_bytes();
|
||||||
|
let sessions_payload: Value =
|
||||||
|
serde_json::from_slice(&sessions_body).expect("sessions payload should be json");
|
||||||
|
assert_eq!(
|
||||||
|
sessions_payload["sessions"][0]["clientType"],
|
||||||
|
Value::String("mini_program".to_string())
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
sessions_payload["sessions"][0]["clientRuntime"],
|
||||||
|
Value::String("wechat_mini_program".to_string())
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
sessions_payload["sessions"][0]["miniProgramAppId"],
|
||||||
|
Value::String("wx-mini-test".to_string())
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
#[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 {
|
||||||
|
|||||||
@@ -52,11 +52,14 @@ pub struct AppConfig {
|
|||||||
pub wechat_auth_provider: String,
|
pub wechat_auth_provider: String,
|
||||||
pub wechat_app_id: Option<String>,
|
pub wechat_app_id: Option<String>,
|
||||||
pub wechat_app_secret: Option<String>,
|
pub wechat_app_secret: Option<String>,
|
||||||
|
pub wechat_mini_program_app_id: Option<String>,
|
||||||
|
pub wechat_mini_program_app_secret: Option<String>,
|
||||||
pub wechat_callback_path: String,
|
pub wechat_callback_path: String,
|
||||||
pub wechat_redirect_path: String,
|
pub wechat_redirect_path: String,
|
||||||
pub wechat_authorize_endpoint: String,
|
pub wechat_authorize_endpoint: String,
|
||||||
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_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>,
|
||||||
@@ -146,12 +149,16 @@ impl Default for AppConfig {
|
|||||||
wechat_auth_provider: "mock".to_string(),
|
wechat_auth_provider: "mock".to_string(),
|
||||||
wechat_app_id: None,
|
wechat_app_id: None,
|
||||||
wechat_app_secret: None,
|
wechat_app_secret: None,
|
||||||
|
wechat_mini_program_app_id: None,
|
||||||
|
wechat_mini_program_app_secret: None,
|
||||||
wechat_callback_path: "/api/auth/wechat/callback".to_string(),
|
wechat_callback_path: "/api/auth/wechat/callback".to_string(),
|
||||||
wechat_redirect_path: "/".to_string(),
|
wechat_redirect_path: "/".to_string(),
|
||||||
wechat_authorize_endpoint: "https://open.weixin.qq.com/connect/qrconnect".to_string(),
|
wechat_authorize_endpoint: "https://open.weixin.qq.com/connect/qrconnect".to_string(),
|
||||||
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"
|
||||||
.to_string(),
|
.to_string(),
|
||||||
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"
|
||||||
|
.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()),
|
||||||
@@ -355,6 +362,10 @@ impl AppConfig {
|
|||||||
}
|
}
|
||||||
config.wechat_app_id = read_first_non_empty_env(&["WECHAT_APP_ID"]);
|
config.wechat_app_id = read_first_non_empty_env(&["WECHAT_APP_ID"]);
|
||||||
config.wechat_app_secret = read_first_non_empty_env(&["WECHAT_APP_SECRET"]);
|
config.wechat_app_secret = read_first_non_empty_env(&["WECHAT_APP_SECRET"]);
|
||||||
|
config.wechat_mini_program_app_id =
|
||||||
|
read_first_non_empty_env(&["WECHAT_MINI_PROGRAM_APP_ID", "WECHAT_APP_ID"]);
|
||||||
|
config.wechat_mini_program_app_secret =
|
||||||
|
read_first_non_empty_env(&["WECHAT_MINI_PROGRAM_APP_SECRET", "WECHAT_APP_SECRET"]);
|
||||||
if let Some(wechat_callback_path) = read_first_non_empty_env(&["WECHAT_CALLBACK_PATH"]) {
|
if let Some(wechat_callback_path) = read_first_non_empty_env(&["WECHAT_CALLBACK_PATH"]) {
|
||||||
config.wechat_callback_path = wechat_callback_path;
|
config.wechat_callback_path = wechat_callback_path;
|
||||||
}
|
}
|
||||||
@@ -376,6 +387,11 @@ impl AppConfig {
|
|||||||
{
|
{
|
||||||
config.wechat_user_info_endpoint = wechat_user_info_endpoint;
|
config.wechat_user_info_endpoint = wechat_user_info_endpoint;
|
||||||
}
|
}
|
||||||
|
if let Some(wechat_js_code_session_endpoint) =
|
||||||
|
read_first_non_empty_env(&["WECHAT_JS_CODE_SESSION_ENDPOINT"])
|
||||||
|
{
|
||||||
|
config.wechat_js_code_session_endpoint = wechat_js_code_session_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"])
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -9,7 +9,8 @@ use module_auth::{
|
|||||||
};
|
};
|
||||||
use platform_auth::WechatAuthScene;
|
use platform_auth::WechatAuthScene;
|
||||||
use shared_contracts::auth::{
|
use shared_contracts::auth::{
|
||||||
WechatBindPhoneRequest, WechatBindPhoneResponse, WechatCallbackQuery, WechatStartQuery,
|
WechatBindPhoneRequest, WechatBindPhoneResponse, WechatCallbackQuery,
|
||||||
|
WechatMiniProgramLoginRequest, WechatMiniProgramLoginResponse, WechatStartQuery,
|
||||||
WechatStartResponse,
|
WechatStartResponse,
|
||||||
};
|
};
|
||||||
use time::OffsetDateTime;
|
use time::OffsetDateTime;
|
||||||
@@ -234,6 +235,68 @@ pub async fn bind_wechat_phone(
|
|||||||
))
|
))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub async fn login_wechat_mini_program(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
Extension(request_context): Extension<RequestContext>,
|
||||||
|
headers: HeaderMap,
|
||||||
|
Json(payload): Json<WechatMiniProgramLoginRequest>,
|
||||||
|
) -> Result<impl IntoResponse, AppError> {
|
||||||
|
if !state.config.wechat_auth_enabled {
|
||||||
|
return Err(AppError::from_status(StatusCode::BAD_REQUEST).with_message("微信登录暂未启用"));
|
||||||
|
}
|
||||||
|
let code = payload.code.trim();
|
||||||
|
if code.is_empty() {
|
||||||
|
return Err(
|
||||||
|
AppError::from_status(StatusCode::BAD_REQUEST).with_message("缺少微信授权 code")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
let profile = state
|
||||||
|
.wechat_provider()
|
||||||
|
.resolve_mini_program_login_profile(Some(code))
|
||||||
|
.await
|
||||||
|
.map_err(map_wechat_provider_error)?;
|
||||||
|
let result = state
|
||||||
|
.wechat_auth_service()
|
||||||
|
.resolve_login(module_auth::ResolveWechatLoginInput {
|
||||||
|
profile: map_wechat_profile_to_domain(profile),
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.map_err(map_wechat_auth_error)?;
|
||||||
|
let session_client = resolve_session_client_context(&headers);
|
||||||
|
let signed_session = create_auth_session(
|
||||||
|
&state,
|
||||||
|
&result.user,
|
||||||
|
&session_client,
|
||||||
|
AuthLoginMethod::Wechat,
|
||||||
|
)?;
|
||||||
|
state
|
||||||
|
.sync_auth_store_snapshot_to_spacetime()
|
||||||
|
.await
|
||||||
|
.map_err(|error| {
|
||||||
|
AppError::from_status(StatusCode::INTERNAL_SERVER_ERROR)
|
||||||
|
.with_message(format!("同步认证快照失败:{error}"))
|
||||||
|
})?;
|
||||||
|
|
||||||
|
let mut response_headers = HeaderMap::new();
|
||||||
|
attach_set_cookie_header(
|
||||||
|
&mut response_headers,
|
||||||
|
build_refresh_session_cookie_header(&state, &signed_session.refresh_token)?,
|
||||||
|
);
|
||||||
|
|
||||||
|
Ok((
|
||||||
|
response_headers,
|
||||||
|
json_success_body(
|
||||||
|
Some(&request_context),
|
||||||
|
WechatMiniProgramLoginResponse {
|
||||||
|
token: signed_session.access_token,
|
||||||
|
binding_status: result.user.binding_status.as_str().to_string(),
|
||||||
|
user: map_auth_user_payload(result.user),
|
||||||
|
},
|
||||||
|
),
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
fn resolve_wechat_scene(user_agent: Option<&str>) -> Result<WechatAuthScene, AppError> {
|
fn resolve_wechat_scene(user_agent: Option<&str>) -> Result<WechatAuthScene, AppError> {
|
||||||
let user_agent = user_agent.unwrap_or_default();
|
let user_agent = user_agent.unwrap_or_default();
|
||||||
let is_wechat = user_agent.contains("MicroMessenger");
|
let is_wechat = user_agent.contains("MicroMessenger");
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
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_USER_INFO_ENDPOINT, WechatAuthConfig, WechatProvider,
|
DEFAULT_WECHAT_JS_CODE_SESSION_ENDPOINT, DEFAULT_WECHAT_USER_INFO_ENDPOINT, WechatAuthConfig,
|
||||||
|
WechatProvider,
|
||||||
};
|
};
|
||||||
|
|
||||||
use crate::config::AppConfig;
|
use crate::config::AppConfig;
|
||||||
@@ -11,6 +12,8 @@ pub fn build_wechat_provider(config: &AppConfig) -> WechatProvider {
|
|||||||
config.wechat_auth_provider.clone(),
|
config.wechat_auth_provider.clone(),
|
||||||
config.wechat_app_id.clone(),
|
config.wechat_app_id.clone(),
|
||||||
config.wechat_app_secret.clone(),
|
config.wechat_app_secret.clone(),
|
||||||
|
config.wechat_mini_program_app_id.clone(),
|
||||||
|
config.wechat_mini_program_app_secret.clone(),
|
||||||
normalize_wechat_endpoint(
|
normalize_wechat_endpoint(
|
||||||
&config.wechat_authorize_endpoint,
|
&config.wechat_authorize_endpoint,
|
||||||
DEFAULT_WECHAT_AUTHORIZE_ENDPOINT,
|
DEFAULT_WECHAT_AUTHORIZE_ENDPOINT,
|
||||||
@@ -23,6 +26,10 @@ pub fn build_wechat_provider(config: &AppConfig) -> WechatProvider {
|
|||||||
&config.wechat_user_info_endpoint,
|
&config.wechat_user_info_endpoint,
|
||||||
DEFAULT_WECHAT_USER_INFO_ENDPOINT,
|
DEFAULT_WECHAT_USER_INFO_ENDPOINT,
|
||||||
),
|
),
|
||||||
|
normalize_wechat_endpoint(
|
||||||
|
&config.wechat_js_code_session_endpoint,
|
||||||
|
DEFAULT_WECHAT_JS_CODE_SESSION_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(),
|
||||||
|
|||||||
@@ -42,6 +42,8 @@ pub const DEFAULT_WECHAT_IN_APP_AUTHORIZE_ENDPOINT: &str =
|
|||||||
pub const DEFAULT_WECHAT_ACCESS_TOKEN_ENDPOINT: &str =
|
pub const DEFAULT_WECHAT_ACCESS_TOKEN_ENDPOINT: &str =
|
||||||
"https://api.weixin.qq.com/sns/oauth2/access_token";
|
"https://api.weixin.qq.com/sns/oauth2/access_token";
|
||||||
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 =
|
||||||
|
"https://api.weixin.qq.com/sns/jscode2session";
|
||||||
|
|
||||||
type HmacSha1 = Hmac<Sha1>;
|
type HmacSha1 = Hmac<Sha1>;
|
||||||
|
|
||||||
@@ -178,9 +180,12 @@ pub struct WechatAuthConfig {
|
|||||||
pub provider: String,
|
pub provider: String,
|
||||||
pub app_id: Option<String>,
|
pub app_id: Option<String>,
|
||||||
pub app_secret: Option<String>,
|
pub app_secret: Option<String>,
|
||||||
|
pub mini_program_app_id: Option<String>,
|
||||||
|
pub mini_program_app_secret: Option<String>,
|
||||||
pub authorize_endpoint: String,
|
pub authorize_endpoint: String,
|
||||||
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 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,
|
||||||
@@ -213,11 +218,14 @@ pub struct MockWechatProvider {
|
|||||||
#[derive(Clone, Debug)]
|
#[derive(Clone, Debug)]
|
||||||
pub struct RealWechatProvider {
|
pub struct RealWechatProvider {
|
||||||
client: Client,
|
client: Client,
|
||||||
app_id: String,
|
app_id: Option<String>,
|
||||||
app_secret: String,
|
app_secret: Option<String>,
|
||||||
|
mini_program_app_id: Option<String>,
|
||||||
|
mini_program_app_secret: Option<String>,
|
||||||
authorize_endpoint: String,
|
authorize_endpoint: String,
|
||||||
access_token_endpoint: String,
|
access_token_endpoint: String,
|
||||||
user_info_endpoint: String,
|
user_info_endpoint: String,
|
||||||
|
js_code_session_endpoint: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Clone, Debug)]
|
#[derive(Clone, Debug)]
|
||||||
@@ -311,6 +319,14 @@ struct WechatUserInfoResponse {
|
|||||||
errmsg: Option<String>,
|
errmsg: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
struct WechatJsCodeSessionResponse {
|
||||||
|
openid: Option<String>,
|
||||||
|
unionid: Option<String>,
|
||||||
|
errcode: Option<i64>,
|
||||||
|
errmsg: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Debug, Deserialize)]
|
#[derive(Debug, Deserialize)]
|
||||||
struct AliyunSendSmsVerifyCodeResponse {
|
struct AliyunSendSmsVerifyCodeResponse {
|
||||||
// 阿里云 RPC 原始 JSON 使用首字母大写字段名,这里必须显式映射,避免把成功响应误判成空值。
|
// 阿里云 RPC 原始 JSON 使用首字母大写字段名,这里必须显式映射,避免把成功响应误判成空值。
|
||||||
@@ -628,9 +644,12 @@ impl WechatAuthConfig {
|
|||||||
provider: String,
|
provider: String,
|
||||||
app_id: Option<String>,
|
app_id: Option<String>,
|
||||||
app_secret: Option<String>,
|
app_secret: Option<String>,
|
||||||
|
mini_program_app_id: Option<String>,
|
||||||
|
mini_program_app_secret: Option<String>,
|
||||||
authorize_endpoint: String,
|
authorize_endpoint: String,
|
||||||
access_token_endpoint: String,
|
access_token_endpoint: String,
|
||||||
user_info_endpoint: String,
|
user_info_endpoint: String,
|
||||||
|
js_code_session_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,
|
||||||
@@ -641,9 +660,12 @@ impl WechatAuthConfig {
|
|||||||
provider,
|
provider,
|
||||||
app_id,
|
app_id,
|
||||||
app_secret,
|
app_secret,
|
||||||
|
mini_program_app_id,
|
||||||
|
mini_program_app_secret,
|
||||||
authorize_endpoint,
|
authorize_endpoint,
|
||||||
access_token_endpoint,
|
access_token_endpoint,
|
||||||
user_info_endpoint,
|
user_info_endpoint,
|
||||||
|
js_code_session_endpoint,
|
||||||
mock_user_id,
|
mock_user_id,
|
||||||
mock_union_id,
|
mock_union_id,
|
||||||
mock_display_name,
|
mock_display_name,
|
||||||
@@ -667,20 +689,36 @@ impl WechatProvider {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
let Some(app_id) = config.app_id else {
|
let has_web_oauth_config = config
|
||||||
return Self::Disabled;
|
.app_id
|
||||||
};
|
.as_ref()
|
||||||
let Some(app_secret) = config.app_secret else {
|
.is_some_and(|value| !value.is_empty())
|
||||||
|
&& config
|
||||||
|
.app_secret
|
||||||
|
.as_ref()
|
||||||
|
.is_some_and(|value| !value.is_empty());
|
||||||
|
let has_mini_program_config = config
|
||||||
|
.mini_program_app_id
|
||||||
|
.as_ref()
|
||||||
|
.is_some_and(|value| !value.is_empty())
|
||||||
|
&& config
|
||||||
|
.mini_program_app_secret
|
||||||
|
.as_ref()
|
||||||
|
.is_some_and(|value| !value.is_empty());
|
||||||
|
if !has_web_oauth_config && !has_mini_program_config {
|
||||||
return Self::Disabled;
|
return Self::Disabled;
|
||||||
};
|
};
|
||||||
|
|
||||||
Self::Real(RealWechatProvider {
|
Self::Real(RealWechatProvider {
|
||||||
client: Client::new(),
|
client: Client::new(),
|
||||||
app_id,
|
app_id: config.app_id,
|
||||||
app_secret,
|
app_secret: config.app_secret,
|
||||||
|
mini_program_app_id: config.mini_program_app_id,
|
||||||
|
mini_program_app_secret: config.mini_program_app_secret,
|
||||||
authorize_endpoint: config.authorize_endpoint,
|
authorize_endpoint: config.authorize_endpoint,
|
||||||
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,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -708,6 +746,17 @@ impl WechatProvider {
|
|||||||
Self::Real(provider) => provider.resolve_callback_profile(code).await,
|
Self::Real(provider) => provider.resolve_callback_profile(code).await,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub async fn resolve_mini_program_login_profile(
|
||||||
|
&self,
|
||||||
|
code: Option<&str>,
|
||||||
|
) -> Result<WechatIdentityProfile, WechatProviderError> {
|
||||||
|
match self {
|
||||||
|
Self::Disabled => Err(WechatProviderError::Disabled),
|
||||||
|
Self::Mock(provider) => Ok(provider.resolve_callback_profile(code)),
|
||||||
|
Self::Real(provider) => provider.resolve_mini_program_login_profile(code).await,
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
impl MockWechatProvider {
|
impl MockWechatProvider {
|
||||||
@@ -740,8 +789,11 @@ impl RealWechatProvider {
|
|||||||
let mut url = Url::parse(endpoint).map_err(|error| {
|
let mut url = Url::parse(endpoint).map_err(|error| {
|
||||||
WechatProviderError::InvalidConfig(format!("微信授权地址非法:{error}"))
|
WechatProviderError::InvalidConfig(format!("微信授权地址非法:{error}"))
|
||||||
})?;
|
})?;
|
||||||
|
let app_id = self.app_id.as_ref().ok_or_else(|| {
|
||||||
|
WechatProviderError::InvalidConfig("微信开放平台 AppID 未配置".to_string())
|
||||||
|
})?;
|
||||||
url.query_pairs_mut()
|
url.query_pairs_mut()
|
||||||
.append_pair("appid", &self.app_id)
|
.append_pair("appid", app_id)
|
||||||
.append_pair("redirect_uri", callback_url)
|
.append_pair("redirect_uri", callback_url)
|
||||||
.append_pair("response_type", "code")
|
.append_pair("response_type", "code")
|
||||||
.append_pair(
|
.append_pair(
|
||||||
@@ -764,13 +816,19 @@ impl RealWechatProvider {
|
|||||||
.filter(|value| !value.is_empty())
|
.filter(|value| !value.is_empty())
|
||||||
.ok_or(WechatProviderError::MissingCode)?;
|
.ok_or(WechatProviderError::MissingCode)?;
|
||||||
|
|
||||||
|
let app_id = self.app_id.as_ref().ok_or_else(|| {
|
||||||
|
WechatProviderError::InvalidConfig("微信开放平台 AppID 未配置".to_string())
|
||||||
|
})?;
|
||||||
|
let app_secret = self.app_secret.as_ref().ok_or_else(|| {
|
||||||
|
WechatProviderError::InvalidConfig("微信开放平台 AppSecret 未配置".to_string())
|
||||||
|
})?;
|
||||||
let mut access_token_url = Url::parse(&self.access_token_endpoint).map_err(|error| {
|
let mut access_token_url = Url::parse(&self.access_token_endpoint).map_err(|error| {
|
||||||
WechatProviderError::InvalidConfig(format!("微信 access_token 地址非法:{error}"))
|
WechatProviderError::InvalidConfig(format!("微信 access_token 地址非法:{error}"))
|
||||||
})?;
|
})?;
|
||||||
access_token_url
|
access_token_url
|
||||||
.query_pairs_mut()
|
.query_pairs_mut()
|
||||||
.append_pair("appid", &self.app_id)
|
.append_pair("appid", app_id)
|
||||||
.append_pair("secret", &self.app_secret)
|
.append_pair("secret", app_secret)
|
||||||
.append_pair("code", code)
|
.append_pair("code", code)
|
||||||
.append_pair("grant_type", "authorization_code");
|
.append_pair("grant_type", "authorization_code");
|
||||||
|
|
||||||
@@ -856,6 +914,84 @@ impl RealWechatProvider {
|
|||||||
avatar_url: user_info_payload.headimgurl,
|
avatar_url: user_info_payload.headimgurl,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async fn resolve_mini_program_login_profile(
|
||||||
|
&self,
|
||||||
|
code: Option<&str>,
|
||||||
|
) -> Result<WechatIdentityProfile, 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 mut js_code_session_url =
|
||||||
|
Url::parse(&self.js_code_session_endpoint).map_err(|error| {
|
||||||
|
WechatProviderError::InvalidConfig(format!("微信 jscode2session 地址非法:{error}"))
|
||||||
|
})?;
|
||||||
|
js_code_session_url
|
||||||
|
.query_pairs_mut()
|
||||||
|
.append_pair("appid", app_id)
|
||||||
|
.append_pair("secret", app_secret)
|
||||||
|
.append_pair("js_code", code)
|
||||||
|
.append_pair("grant_type", "authorization_code");
|
||||||
|
|
||||||
|
let payload = self
|
||||||
|
.client
|
||||||
|
.get(js_code_session_url.as_str())
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.map_err(|error| {
|
||||||
|
warn!(error = %error, "微信小程序 jscode2session 请求失败");
|
||||||
|
WechatProviderError::RequestFailed(
|
||||||
|
"微信小程序登录失败:jscode2session 请求失败".to_string(),
|
||||||
|
)
|
||||||
|
})?
|
||||||
|
.json::<WechatJsCodeSessionResponse>()
|
||||||
|
.await
|
||||||
|
.map_err(|error| {
|
||||||
|
warn!(error = %error, "微信小程序 jscode2session 响应解析失败");
|
||||||
|
WechatProviderError::DeserializeFailed(
|
||||||
|
"微信小程序登录失败:jscode2session 响应非法".to_string(),
|
||||||
|
)
|
||||||
|
})?;
|
||||||
|
|
||||||
|
if let Some(errcode) = payload.errcode.filter(|value| *value != 0) {
|
||||||
|
return Err(WechatProviderError::Upstream(format!(
|
||||||
|
"微信小程序登录失败:{}",
|
||||||
|
payload
|
||||||
|
.errmsg
|
||||||
|
.unwrap_or_else(|| format!("jscode2session 返回错误 {errcode}"))
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
|
||||||
|
let provider_uid = payload
|
||||||
|
.openid
|
||||||
|
.filter(|value| !value.trim().is_empty())
|
||||||
|
.ok_or_else(|| {
|
||||||
|
WechatProviderError::MissingProfile("微信小程序登录失败:缺少 openid".to_string())
|
||||||
|
})?;
|
||||||
|
|
||||||
|
Ok(WechatIdentityProfile {
|
||||||
|
provider_uid,
|
||||||
|
provider_union_id: payload.unionid,
|
||||||
|
display_name: None,
|
||||||
|
avatar_url: None,
|
||||||
|
})
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn build_mock_wechat_authorization_url(
|
fn build_mock_wechat_authorization_url(
|
||||||
@@ -1723,9 +1859,12 @@ mod tests {
|
|||||||
"mock".to_string(),
|
"mock".to_string(),
|
||||||
None,
|
None,
|
||||||
None,
|
None,
|
||||||
|
None,
|
||||||
|
None,
|
||||||
DEFAULT_WECHAT_AUTHORIZE_ENDPOINT.to_string(),
|
DEFAULT_WECHAT_AUTHORIZE_ENDPOINT.to_string(),
|
||||||
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(),
|
||||||
"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(),
|
||||||
@@ -1751,9 +1890,12 @@ mod tests {
|
|||||||
"mock".to_string(),
|
"mock".to_string(),
|
||||||
None,
|
None,
|
||||||
None,
|
None,
|
||||||
|
None,
|
||||||
|
None,
|
||||||
DEFAULT_WECHAT_AUTHORIZE_ENDPOINT.to_string(),
|
DEFAULT_WECHAT_AUTHORIZE_ENDPOINT.to_string(),
|
||||||
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(),
|
||||||
"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(),
|
||||||
|
|||||||
@@ -32,6 +32,7 @@
|
|||||||
7. `auth/wechat/start`
|
7. `auth/wechat/start`
|
||||||
8. `auth/wechat/callback`
|
8. `auth/wechat/callback`
|
||||||
9. `auth/wechat/bind-phone`
|
9. `auth/wechat/bind-phone`
|
||||||
|
10. `auth/wechat/miniprogram-login`
|
||||||
|
|
||||||
当前阶段继续补齐的 Stage3 公开请求 DTO:
|
当前阶段继续补齐的 Stage3 公开请求 DTO:
|
||||||
|
|
||||||
|
|||||||
@@ -222,6 +222,20 @@ pub struct WechatBindPhoneResponse {
|
|||||||
pub user: AuthUserPayload,
|
pub user: AuthUserPayload,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct WechatMiniProgramLoginRequest {
|
||||||
|
pub code: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct WechatMiniProgramLoginResponse {
|
||||||
|
pub token: String,
|
||||||
|
pub binding_status: String,
|
||||||
|
pub user: AuthUserPayload,
|
||||||
|
}
|
||||||
|
|
||||||
pub fn build_available_login_methods(
|
pub fn build_available_login_methods(
|
||||||
sms_auth_enabled: bool,
|
sms_auth_enabled: bool,
|
||||||
password_auth_enabled: bool,
|
password_auth_enabled: bool,
|
||||||
|
|||||||
Reference in New Issue
Block a user