diff --git a/.env b/.env new file mode 100644 index 00000000..e1ed925f --- /dev/null +++ b/.env @@ -0,0 +1,5 @@ +# 微信小程序 web-view 登录配置。 +# 留空时不覆盖已有微信网页 OAuth 配置;正式联调时再填小程序 AppID / AppSecret。 +WECHAT_MINI_PROGRAM_APP_ID="" +WECHAT_MINI_PROGRAM_APP_SECRET="" +WECHAT_JS_CODE_SESSION_ENDPOINT="" diff --git a/docs/technical/README.md b/docs/technical/README.md index 5fa1c834..42052f2c 100644 --- a/docs/technical/README.md +++ b/docs/technical/README.md @@ -4,6 +4,7 @@ ## 文档列表 +- [WECHAT_MINIPROGRAM_WEB_VIEW_SHELL_2026-05-03.md](./WECHAT_MINIPROGRAM_WEB_VIEW_SHELL_2026-05-03.md):记录微信小程序 `web-view` 壳的最小接入范围、需要填写的 H5 业务域名、微信后台配置、`npm run check:wechat-miniprogram-auth` 可重复登录链路 smoke 和后续原生化边界。 - [BARK_BATTLE_BACKEND_DDD_TECHNICAL_PLAN_2026-05-11.md](./BARK_BATTLE_BACKEND_DDD_TECHNICAL_PLAN_2026-05-11.md):冻结“汪汪声浪大作战 / bark-battle”后端 DDD 技术方案,明确 `server-rs + Axum + SpacetimeDB` 分层边界、shared contracts、作品配置、runtime run、派生成绩、排行榜、`work_play_start` 埋点、migration/绑定生成策略,以及不保存原始麦克风音频的隐私与反作弊约束。 - [BARK_BATTLE_2D_RUNTIME_TECHNICAL_PLAN_2026-05-11.md](./BARK_BATTLE_2D_RUNTIME_TECHNICAL_PLAN_2026-05-11.md):冻结“汪汪声浪大作战 / bark-battle”2D 浏览器 runtime 技术方案,明确 Phaser + TypeScript + Vite 选型、纯 TS simulation 与 Phaser renderer/DOM HUD 边界、Web Audio 输入适配、移动端权限降级和后续测试验证命令。 - [PUBLIC_WORK_DETAIL_NOT_FOUND_RECOVERY_2026-05-11.md](./PUBLIC_WORK_DETAIL_NOT_FOUND_RECOVERY_2026-05-11.md):记录直接访问公开作品详情深链时作品不存在或已下架的回首页修复,避免关闭提示后停在 `work-detail` 空状态白屏。 diff --git a/docs/technical/WECHAT_LOGIN_AXUM_IMPLEMENTATION_DESIGN_2026-04-21.md b/docs/technical/WECHAT_LOGIN_AXUM_IMPLEMENTATION_DESIGN_2026-04-21.md index 36fbee52..9e1d1685 100644 --- a/docs/technical/WECHAT_LOGIN_AXUM_IMPLEMENTATION_DESIGN_2026-04-21.md +++ b/docs/technical/WECHAT_LOGIN_AXUM_IMPLEMENTATION_DESIGN_2026-04-21.md @@ -92,6 +92,25 @@ 2. 若是并入已有手机号正式账号,则返回目标正式账号快照,当前实现会保持其账号主登录方式,例如 `loginMethod = phone`。 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. 当前最小实现策略 当前阶段为了先打通 Rust 后端闭环,采用以下最小实现: @@ -125,6 +144,7 @@ 4. `GET /api/auth/wechat/start` 5. `GET /api/auth/wechat/callback` 6. `POST /api/auth/wechat/bind-phone` +7. `POST /api/auth/wechat/miniprogram-login` ## 6. 环境变量 @@ -139,11 +159,14 @@ 7. `WECHAT_AUTHORIZE_ENDPOINT` 8. `WECHAT_ACCESS_TOKEN_ENDPOINT` 9. `WECHAT_USER_INFO_ENDPOINT` -10. `WECHAT_STATE_TTL_MINUTES` -11. `WECHAT_MOCK_USER_ID` -12. `WECHAT_MOCK_UNION_ID` -13. `WECHAT_MOCK_DISPLAY_NAME` -14. `WECHAT_MOCK_AVATAR_URL` +10. `WECHAT_JS_CODE_SESSION_ENDPOINT` +11. `WECHAT_MINI_PROGRAM_APP_ID` +12. `WECHAT_MINI_PROGRAM_APP_SECRET` +13. `WECHAT_STATE_TTL_MINUTES` +14. `WECHAT_MOCK_USER_ID` +15. `WECHAT_MOCK_UNION_ID` +16. `WECHAT_MOCK_DISPLAY_NAME` +17. `WECHAT_MOCK_AVATAR_URL` ## 7. 与后续 SpacetimeDB 的衔接要求 diff --git a/docs/technical/WECHAT_LOGIN_REAL_INTEGRATION_RUNBOOK_2026-04-21.md b/docs/technical/WECHAT_LOGIN_REAL_INTEGRATION_RUNBOOK_2026-04-21.md index 053b67e5..6dd75c74 100644 --- a/docs/technical/WECHAT_LOGIN_REAL_INTEGRATION_RUNBOOK_2026-04-21.md +++ b/docs/technical/WECHAT_LOGIN_REAL_INTEGRATION_RUNBOOK_2026-04-21.md @@ -100,6 +100,9 @@ real 模式行为固定为: | `WECHAT_AUTHORIZE_ENDPOINT` | 否 | 默认桌面二维码授权地址 | | `WECHAT_ACCESS_TOKEN_ENDPOINT` | 否 | 默认 access_token 接口 | | `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` 分钟 | 补充说明: @@ -225,7 +228,46 @@ https://game.example.com - `wechatBound = true` - `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` 映射补齐 2. 若后续绑定手机号时发现该手机号已经属于正式账号,则会把微信身份并入这个正式账号 -## 9. 前端验收点 +## 10. 前端验收点 前端联调时至少检查以下行为: diff --git a/docs/technical/WECHAT_MINIPROGRAM_WEB_VIEW_SHELL_2026-05-03.md b/docs/technical/WECHAT_MINIPROGRAM_WEB_VIEW_SHELL_2026-05-03.md new file mode 100644 index 00000000..d7d793cf --- /dev/null +++ b/docs/technical/WECHAT_MINIPROGRAM_WEB_VIEW_SHELL_2026-05-03.md @@ -0,0 +1,166 @@ +# 微信小程序 web-view 壳接入记录 + +日期:`2026-05-03` + +## 1. 目标 + +本次先用微信小程序 `web-view` 承载现有 H5,不重写 React/Vite 主前端,也不把 SpacetimeDB SDK 或业务规则搬进小程序端。 + +当前小程序壳只承担四件事: + +1. 提供微信开发者工具可识别的 `miniprogram/` 工程根目录。 +2. 在原生小程序壳中调用 `wx.login` 获取小程序 `code`。 +3. 调用服务器域名下的 `/api/auth/wechat/miniprogram-login`,由 Rust `api-server` 兑换微信身份并签发系统登录态。 +4. 用一个全屏 `web-view` 打开现有 H5 入口,并把系统 `auth_token` 放入 H5 现有登录回调 hash。 + +重要边界: + +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. 文件入口 + +| 文件 | 说明 | +| --- | --- | +| `project.config.json` | 指定 `miniprogramRoot: "miniprogram/"`。 | +| `miniprogram/app.json` | 小程序全局配置,注册 `pages/web-view/index`。 | +| `miniprogram/config.js` | 业务域名入口配置,需要部署时填写。 | +| `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. 需要手工填写的配置 + +在 `miniprogram/config.js` 中填写: + +```js +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`。 +2. 不能是 `localhost` 或 IP。 +3. `WEB_VIEW_ENTRY_URL` 域名需要在微信小程序后台配置为业务域名。 +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` 默认附加: + +```text +clientType=mini_program +clientRuntime=wechat_mini_program +``` + +小程序壳调用登录接口时会补传: + +```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= +x-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 的域名。 +2. `request 合法域名`:`API_BASE_URL` 对应的服务器域名。 +3. `socket 合法域名`:若后续小程序原生层直连 WebSocket 才需要;当前不启用。 + +当前仓库的 H5 仍建议通过同域 `/api/*` 访问 Rust `api-server`,避免在小程序和 H5 中分别维护跨域白名单。 + +## 6. 当前不做的事 + +本次不做原生小程序页面迁移,原因是当前主前端依赖: + +1. React DOM 挂载、浏览器 history 和 `window.location`。 +2. `localStorage` / `sessionStorage`。 +3. 浏览器 `fetch` 与 `ReadableStream` SSE。 +4. DOM、Canvas、Three.js 等浏览器渲染能力。 + +这些能力不能稳定原样运行在原生小程序宿主中。后续如要原生化,应新建小程序端宿主,复用 `packages/shared` 契约和 `api-server` BFF,而不是把 `src/` 整体搬过去。 + +本次也不做 `openid` query 直登。原因是 `openid` 不是本系统签发的登录凭证,不能表达 token 版本、会话 ID、绑定状态、角色与过期时间,也不能被 H5 直接信任。 + +## 7. 验收口径 + +可重复自动化 smoke: + +```bash +npm run check:wechat-miniprogram-auth +``` + +该命令固定覆盖三段链路: + +1. 静态确认 `miniprogram/pages/web-view/index.js` 会请求 `/api/auth/wechat/miniprogram-login`,携带 `mini_program / wechat_mini_program` 客户端来源头,并把 `auth_provider/auth_token/auth_binding_status` 拼入 H5 hash。 +2. 运行 `api-server` 定向测试 `wechat_miniprogram_login_returns_system_token_and_marks_session_source`,断言小程序登录返回 `token/bindingStatus/user`、写入 refresh cookie,并且 `/api/auth/sessions` 能看到 `clientType=mini_program`、`clientRuntime=wechat_mini_program`、`miniProgramAppId`。 +3. 运行前端 `authService` 定向测试,断言 `consumeAuthCallbackResult()` 会消费 `#auth_provider=wechat&auth_token=...&auth_binding_status=...`、保存 access token,并清理地址栏 hash。 + +手工联调仍按以下口径确认真实微信与域名配置: + +1. 微信开发者工具打开项目根目录后,识别 `miniprogram/` 为小程序源码目录。 +2. 未填写 `WEB_VIEW_ENTRY_URL` 或 `API_BASE_URL` 时,页面显示配置提示,不出现空白页。 +3. 填写已配置业务域名后,小程序先请求 `/api/auth/wechat/miniprogram-login`。 +4. 后端返回 `token/bindingStatus/user`,并写入 refresh cookie。 +5. 首页全屏打开 H5,URL hash 中包含 `auth_provider=wechat`、`auth_token`、`auth_binding_status`。 +6. H5 内 `consumeAuthCallbackResult()` 消费 hash 后,`/api/auth/me` 能返回当前用户。 +7. `/api/auth/sessions` 能看到来源为 `mini_program / wechat_mini_program` 的会话记录。 diff --git a/miniprogram/app.js b/miniprogram/app.js new file mode 100644 index 00000000..c060f609 --- /dev/null +++ b/miniprogram/app.js @@ -0,0 +1,10 @@ +App({ + globalData: { + launchOptions: null, + }, + + onLaunch(options) { + // 中文注释:保留启动参数,后续如果要把分享路径映射到 H5 深链,可以从这里统一读取。 + this.globalData.launchOptions = options; + }, +}); diff --git a/miniprogram/app.json b/miniprogram/app.json new file mode 100644 index 00000000..b83a1148 --- /dev/null +++ b/miniprogram/app.json @@ -0,0 +1,21 @@ +{ + "pages": [ + "pages/web-view/index" + ], + "window": { + "navigationBarTitleText": "百梦", + "navigationBarBackgroundColor": "#0b0f14", + "navigationBarTextStyle": "white", + "backgroundColor": "#0b0f14", + "backgroundTextStyle": "light" + }, + "networkTimeout": { + "request": 60000, + "connectSocket": 60000, + "uploadFile": 60000, + "downloadFile": 60000 + }, + "permission": {}, + "style": "v2", + "sitemapLocation": "sitemap.json" +} diff --git a/miniprogram/app.wxss b/miniprogram/app.wxss new file mode 100644 index 00000000..8cc4f9c8 --- /dev/null +++ b/miniprogram/app.wxss @@ -0,0 +1,5 @@ +page { + min-height: 100vh; + background: #0b0f14; + color: #f5f7fb; +} diff --git a/miniprogram/config.js b/miniprogram/config.js new file mode 100644 index 00000000..20ddf987 --- /dev/null +++ b/miniprogram/config.js @@ -0,0 +1,28 @@ +// 中文注释:这里填写已经在“小程序后台-开发-开发设置-业务域名”配置过的 H5 入口。 +// 示例:https://game.example.com/ +// 注意:必须是 https 域名,不能是 localhost、IP 地址或未备案域名。 +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 宿主。 +const WEB_VIEW_SOURCE_QUERY = { + clientType: 'mini_program', + clientRuntime: 'wechat_mini_program', +}; + +module.exports = { + API_BASE_URL, + MINI_PROGRAM_APP_ID, + MINI_PROGRAM_ENV, + WEB_VIEW_ENTRY_URL, + WEB_VIEW_SOURCE_QUERY, +}; diff --git a/miniprogram/pages/web-view/index.js b/miniprogram/pages/web-view/index.js new file mode 100644 index 00000000..421f9cb6 --- /dev/null +++ b/miniprogram/pages/web-view/index.js @@ -0,0 +1,237 @@ +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) { + const trimmed = String(value || '').trim(); + 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) { + const pairs = Object.keys(query) + .filter((key) => query[key]) + .map( + (key) => + `${encodeURIComponent(key)}=${encodeURIComponent(String(query[key]))}`, + ); + + if (pairs.length === 0) { + return url; + } + + return `${url}${url.includes('?') ? '&' : '?'}${pairs.join('&')}`; +} + +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(); + if (!isConfiguredEntryUrl(entryUrl)) { + return ''; + } + + 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({ + data: { + errorMessage: '', + loading: true, + webViewUrl: '', + }, + + async onLoad() { + // 中文注释: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({ + errorMessage: '', + loading: true, + webViewUrl: '', + }); + this.onLoad(); + }, + + handleWebViewLoad(event) { + console.info('[web-view] loaded', event.detail); + }, + + handleWebViewError(event) { + console.error('[web-view] load failed', event.detail); + }, + + handleWebViewMessage(event) { + // 中文注释:H5 如需和小程序壳通信,可通过 wx.miniProgram.postMessage 发送轻量消息。 + console.info('[web-view] message', event.detail); + }, +}); diff --git a/miniprogram/pages/web-view/index.json b/miniprogram/pages/web-view/index.json new file mode 100644 index 00000000..8835af06 --- /dev/null +++ b/miniprogram/pages/web-view/index.json @@ -0,0 +1,3 @@ +{ + "usingComponents": {} +} \ No newline at end of file diff --git a/miniprogram/pages/web-view/index.wxml b/miniprogram/pages/web-view/index.wxml new file mode 100644 index 00000000..02712a78 --- /dev/null +++ b/miniprogram/pages/web-view/index.wxml @@ -0,0 +1,22 @@ + + + + + + + 正在登录 + + + + + + 无法进入 + {{errorMessage}} + + + diff --git a/miniprogram/pages/web-view/index.wxss b/miniprogram/pages/web-view/index.wxss new file mode 100644 index 00000000..fd71b8a1 --- /dev/null +++ b/miniprogram/pages/web-view/index.wxss @@ -0,0 +1,43 @@ +.setup-screen { + min-height: 100vh; + display: flex; + align-items: center; + justify-content: center; + padding: 48rpx; + background: #0b0f14; + box-sizing: border-box; +} + +.setup-card { + width: 100%; + max-width: 560rpx; + padding: 36rpx; + border: 1rpx solid rgba(255, 255, 255, 0.14); + border-radius: 12rpx; + background: rgba(255, 255, 255, 0.06); + box-sizing: border-box; +} + +.setup-title { + font-size: 34rpx; + font-weight: 600; + line-height: 1.35; + color: #f5f7fb; +} + +.setup-text { + margin-top: 16rpx; + font-size: 26rpx; + line-height: 1.55; + 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; +} diff --git a/miniprogram/sitemap.json b/miniprogram/sitemap.json new file mode 100644 index 00000000..1de189d2 --- /dev/null +++ b/miniprogram/sitemap.json @@ -0,0 +1,8 @@ +{ + "rules": [ + { + "action": "allow", + "page": "*" + } + ] +} diff --git a/package.json b/package.json index 71d5ba5a..94592926 100644 --- a/package.json +++ b/package.json @@ -25,6 +25,7 @@ "assets:child-motion-demo": "node scripts/generate-child-motion-demo-assets.mjs", "check:visual-novel-vn11": "node scripts/check-visual-novel-vn11-negative-scan.mjs", "check:visual-novel-vn12": "node scripts/check-visual-novel-vn12-acceptance.mjs", + "check:wechat-miniprogram-auth": "node scripts/check-wechat-miniprogram-auth-smoke.mjs", "check:server-rs-ddd": "node scripts/check-server-rs-ddd-boundaries.mjs", "lint:eslint": "eslint . --ext .ts,.tsx,.js,.mjs,.cjs --max-warnings 0", "lint:guardrails": "npm run lint:eslint", diff --git a/packages/shared/src/contracts/auth.ts b/packages/shared/src/contracts/auth.ts index 4ceb9b66..9b6c2db5 100644 --- a/packages/shared/src/contracts/auth.ts +++ b/packages/shared/src/contracts/auth.ts @@ -123,6 +123,16 @@ export type AuthWechatBindPhoneResponse = { user: AuthUser; }; +export type AuthWechatMiniProgramLoginRequest = { + code: string; +}; + +export type AuthWechatMiniProgramLoginResponse = { + token: string; + bindingStatus: AuthBindingStatus; + user: AuthUser; +}; + export type AuthPhoneChangeRequest = { phone: string; code: string; diff --git a/project.config.json b/project.config.json new file mode 100644 index 00000000..f526e96e --- /dev/null +++ b/project.config.json @@ -0,0 +1,26 @@ +{ + "setting": { + "es6": true, + "postcss": true, + "minified": true, + "uglifyFileName": false, + "enhance": true, + "packNpmRelationList": [], + "babelSetting": { + "ignore": [], + "disablePlugins": [], + "outputPath": "" + }, + "useCompilerPlugins": false, + "minifyWXML": true + }, + "compileType": "miniprogram", + "miniprogramRoot": "miniprogram/", + "simulatorPluginLibVersion": {}, + "packOptions": { + "ignore": [], + "include": [] + }, + "appid": "wx3da23ea14ca66b65", + "editorSetting": {} +} diff --git a/project.private.config.json b/project.private.config.json new file mode 100644 index 00000000..220cf2f7 --- /dev/null +++ b/project.private.config.json @@ -0,0 +1,14 @@ +{ + "libVersion": "3.15.2", + "projectname": "Genarrative", + "setting": { + "urlCheck": true, + "coverView": true, + "lazyloadPlaceholderEnable": false, + "skylineRenderEnable": false, + "preloadBackgroundData": false, + "autoAudits": false, + "showShadowRootInWxmlPanel": true, + "compileHotReLoad": true + } +} \ No newline at end of file diff --git a/scripts/check-wechat-miniprogram-auth-smoke.mjs b/scripts/check-wechat-miniprogram-auth-smoke.mjs new file mode 100644 index 00000000..9d87e3f5 --- /dev/null +++ b/scripts/check-wechat-miniprogram-auth-smoke.mjs @@ -0,0 +1,114 @@ +import { spawnSync } from 'node:child_process'; +import { existsSync, readFileSync } from 'node:fs'; +import { join } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const repoRoot = process.cwd(); +const failures = []; + +const smokeSteps = [ + { + label: '小程序壳请求与 hash 回跳静态检查', + run: checkMiniProgramShell, + }, + { + label: 'api-server 小程序登录与会话来源测试', + run: () => + runCommand('cargo', [ + 'test', + '-p', + 'api-server', + 'wechat_miniprogram_login_returns_system_token_and_marks_session_source', + '--manifest-path', + 'server-rs/Cargo.toml', + '--', + '--nocapture', + ]), + }, + { + label: 'H5 auth hash 消费测试', + run: () => + runCommand(process.execPath, [ + fileURLToPath(new URL('../node_modules/vitest/vitest.mjs', import.meta.url)), + 'run', + 'src/services/authService.test.ts', + '-t', + 'consumes auth callback hash and persists the returned access token', + ]), + }, +]; + +for (const step of smokeSteps) { + console.log(`[wechat-miniprogram-auth-smoke] ${step.label}`); + step.run(); +} + +if (failures.length > 0) { + console.error('\n[wechat-miniprogram-auth-smoke] 未通过:'); + for (const failure of failures) { + console.error(`- ${failure}`); + } + process.exit(1); +} + +console.log('\n[wechat-miniprogram-auth-smoke] 通过'); + +function checkMiniProgramShell() { + const shellPath = join(repoRoot, 'miniprogram', 'pages', 'web-view', 'index.js'); + const authServiceTestPath = join(repoRoot, 'src', 'services', 'authService.test.ts'); + + ensureNeedles(shellPath, [ + '/api/auth/wechat/miniprogram-login', + "'x-client-type': MINI_PROGRAM_CLIENT_TYPE", + "'x-client-runtime': MINI_PROGRAM_CLIENT_RUNTIME", + 'auth_provider', + 'auth_token', + 'auth_binding_status', + 'bindingStatus', + ]); + + // 中文注释:这里锁定 H5 消费回跳 hash 的真实测试输入,避免只检查实现文本。 + ensureNeedles(authServiceTestPath, [ + '#auth_provider=wechat&auth_token=jwt-callback-token&auth_binding_status=pending_bind_phone', + 'consumeAuthCallbackResult()', + "bindingStatus: 'pending_bind_phone'", + "expect(getStoredAccessToken()).toBe('jwt-callback-token')", + ]); +} + +function ensureNeedles(relativeOrFullPath, needles) { + if (!existsSync(relativeOrFullPath)) { + failures.push(`缺少文件:${relativeOrFullPath}`); + return; + } + + const content = readFileSync(relativeOrFullPath, 'utf8'); + for (const needle of needles) { + if (!content.includes(needle)) { + failures.push(`${relativeOrFullPath} 缺少内容:${needle}`); + } + } +} + +function runCommand(command, args) { + const result = spawnSync(command, args, { + cwd: repoRoot, + env: process.env, + shell: false, + stdio: 'inherit', + }); + + if (result.error) { + failures.push(`${command} 启动失败:${result.error.message}`); + return; + } + + if (result.signal) { + failures.push(`${command} 被信号终止:${result.signal}`); + return; + } + + if ((result.status ?? 0) !== 0) { + failures.push(`${command} ${args.join(' ')} 退出码 ${result.status}`); + } +} diff --git a/server-rs/crates/api-server/src/app.rs b/server-rs/crates/api-server/src/app.rs index b52213f8..f1cb251a 100644 --- a/server-rs/crates/api-server/src/app.rs +++ b/server-rs/crates/api-server/src/app.rs @@ -173,7 +173,9 @@ use crate::{ get_volcengine_speech_config, stream_volcengine_asr, stream_volcengine_tts_bidirection, stream_volcengine_tts_sse, }, - 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; @@ -343,6 +345,10 @@ pub fn build_router(state: AppState) -> Router { .route("/api/auth/phone/login", post(phone_login)) .route("/api/auth/wechat/start", get(start_wechat_login)) .route("/api/auth/wechat/callback", get(handle_wechat_callback)) + .route( + "/api/auth/wechat/miniprogram-login", + post(login_wechat_mini_program), + ) .route( "/api/auth/wechat/bind-phone", post(bind_wechat_phone).route_layer(middleware::from_fn_with_state( @@ -3698,6 +3704,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] async fn wechat_bind_phone_merges_into_existing_phone_user() { let config = AppConfig { diff --git a/server-rs/crates/api-server/src/config.rs b/server-rs/crates/api-server/src/config.rs index d8e42168..641b2c51 100644 --- a/server-rs/crates/api-server/src/config.rs +++ b/server-rs/crates/api-server/src/config.rs @@ -56,11 +56,14 @@ pub struct AppConfig { pub wechat_auth_provider: String, pub wechat_app_id: Option, pub wechat_app_secret: Option, + pub wechat_mini_program_app_id: Option, + pub wechat_mini_program_app_secret: Option, pub wechat_callback_path: String, pub wechat_redirect_path: String, pub wechat_authorize_endpoint: String, pub wechat_access_token_endpoint: String, pub wechat_user_info_endpoint: String, + pub wechat_js_code_session_endpoint: String, pub wechat_state_ttl_minutes: u32, pub wechat_mock_user_id: String, pub wechat_mock_union_id: Option, @@ -165,12 +168,16 @@ impl Default for AppConfig { wechat_auth_provider: "mock".to_string(), wechat_app_id: 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_redirect_path: "/".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" .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_mock_user_id: "wx-mock-user".to_string(), wechat_mock_union_id: Some("wx-mock-union".to_string()), @@ -389,6 +396,10 @@ impl AppConfig { } 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_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"]) { config.wechat_callback_path = wechat_callback_path; } @@ -410,6 +421,11 @@ impl AppConfig { { 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) = read_first_positive_u32_env(&["WECHAT_STATE_TTL_MINUTES"]) { diff --git a/server-rs/crates/api-server/src/match3d.rs b/server-rs/crates/api-server/src/match3d.rs index f814a2b7..91d24ecd 100644 --- a/server-rs/crates/api-server/src/match3d.rs +++ b/server-rs/crates/api-server/src/match3d.rs @@ -3372,13 +3372,6 @@ fn match3d_bad_request( ) } -fn match3d_bad_gateway(message: impl Into) -> AppError { - AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({ - "provider": "match3d", - "message": message.into(), - })) -} - fn map_match3d_client_error(error: SpacetimeClientError) -> AppError { let status = match &error { SpacetimeClientError::Runtime(_) => StatusCode::BAD_REQUEST, diff --git a/server-rs/crates/api-server/src/puzzle.rs b/server-rs/crates/api-server/src/puzzle.rs index 37797e7f..6dd6b374 100644 --- a/server-rs/crates/api-server/src/puzzle.rs +++ b/server-rs/crates/api-server/src/puzzle.rs @@ -57,8 +57,8 @@ use spacetime_client::{ PuzzleAgentMessageRecord, PuzzleAgentMessageSubmitRecordInput, PuzzleAgentSessionCreateRecordInput, PuzzleAgentSessionRecord, PuzzleAgentSuggestedActionRecord, PuzzleAnchorItemRecord, PuzzleAnchorPackRecord, - PuzzleAudioAssetRecord, PuzzleCreatorIntentRecord, PuzzleDraftLevelRecord, PuzzleFormDraftRecord, - PuzzleFormDraftSaveRecordInput, PuzzleGeneratedImageCandidateRecord, + PuzzleAudioAssetRecord, PuzzleCreatorIntentRecord, PuzzleDraftLevelRecord, + PuzzleFormDraftRecord, PuzzleFormDraftSaveRecordInput, PuzzleGeneratedImageCandidateRecord, PuzzleGeneratedImagesSaveRecordInput, PuzzleLeaderboardEntryRecord, PuzzleLeaderboardSubmitRecordInput, PuzzlePublishRecordInput, PuzzleRecommendedNextWorkRecord, PuzzleResultDraftRecord, PuzzleResultPreviewBlockerRecord, PuzzleResultPreviewFindingRecord, @@ -2061,7 +2061,9 @@ fn map_puzzle_draft_level_response(level: PuzzleDraftLevelRecord) -> PuzzleDraft level_name: level.level_name, picture_description: level.picture_description, picture_reference: level.picture_reference, - background_music: level.background_music.map(map_puzzle_audio_asset_record_response), + background_music: level + .background_music + .map(map_puzzle_audio_asset_record_response), candidates: level .candidates .into_iter() @@ -2667,7 +2669,9 @@ fn parse_puzzle_level_records_from_module_json( level_name: level.level_name, picture_description: level.picture_description, picture_reference: level.picture_reference, - background_music: level.background_music.map(map_puzzle_audio_asset_domain_record), + background_music: level + .background_music + .map(map_puzzle_audio_asset_domain_record), candidates: level .candidates .into_iter() @@ -4608,8 +4612,7 @@ mod tests { let levels_json = serialize_puzzle_levels_response(&request_context, &[level]) .expect("levels should serialize"); - let payload: Value = - serde_json::from_str(&levels_json).expect("levels json should parse"); + let payload: Value = serde_json::from_str(&levels_json).expect("levels json should parse"); assert_eq!( payload[0]["background_music"]["audio_src"], Value::String("/generated-puzzle-assets/audio.mp3".to_string()) diff --git a/server-rs/crates/api-server/src/wechat_auth.rs b/server-rs/crates/api-server/src/wechat_auth.rs index 2584e504..10338feb 100644 --- a/server-rs/crates/api-server/src/wechat_auth.rs +++ b/server-rs/crates/api-server/src/wechat_auth.rs @@ -9,7 +9,8 @@ use module_auth::{ }; use platform_auth::WechatAuthScene; use shared_contracts::auth::{ - WechatBindPhoneRequest, WechatBindPhoneResponse, WechatCallbackQuery, WechatStartQuery, + WechatBindPhoneRequest, WechatBindPhoneResponse, WechatCallbackQuery, + WechatMiniProgramLoginRequest, WechatMiniProgramLoginResponse, WechatStartQuery, WechatStartResponse, }; use time::OffsetDateTime; @@ -250,6 +251,68 @@ pub async fn bind_wechat_phone( )) } +pub async fn login_wechat_mini_program( + State(state): State, + Extension(request_context): Extension, + headers: HeaderMap, + Json(payload): Json, +) -> Result { + 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 { let user_agent = user_agent.unwrap_or_default(); let is_wechat = user_agent.contains("MicroMessenger"); diff --git a/server-rs/crates/api-server/src/wechat_provider.rs b/server-rs/crates/api-server/src/wechat_provider.rs index da4448ef..02e043b1 100644 --- a/server-rs/crates/api-server/src/wechat_provider.rs +++ b/server-rs/crates/api-server/src/wechat_provider.rs @@ -1,6 +1,7 @@ use platform_auth::{ 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; @@ -11,6 +12,8 @@ pub fn build_wechat_provider(config: &AppConfig) -> WechatProvider { config.wechat_auth_provider.clone(), config.wechat_app_id.clone(), config.wechat_app_secret.clone(), + config.wechat_mini_program_app_id.clone(), + config.wechat_mini_program_app_secret.clone(), normalize_wechat_endpoint( &config.wechat_authorize_endpoint, DEFAULT_WECHAT_AUTHORIZE_ENDPOINT, @@ -23,6 +26,10 @@ pub fn build_wechat_provider(config: &AppConfig) -> WechatProvider { &config.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_union_id.clone(), config.wechat_mock_display_name.clone(), diff --git a/server-rs/crates/platform-auth/src/lib.rs b/server-rs/crates/platform-auth/src/lib.rs index bccf1127..ad9031ed 100644 --- a/server-rs/crates/platform-auth/src/lib.rs +++ b/server-rs/crates/platform-auth/src/lib.rs @@ -40,6 +40,8 @@ pub const DEFAULT_WECHAT_IN_APP_AUTHORIZE_ENDPOINT: &str = pub const DEFAULT_WECHAT_ACCESS_TOKEN_ENDPOINT: &str = "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_JS_CODE_SESSION_ENDPOINT: &str = + "https://api.weixin.qq.com/sns/jscode2session"; type HmacSha256 = Hmac; @@ -176,9 +178,12 @@ pub struct WechatAuthConfig { pub provider: String, pub app_id: Option, pub app_secret: Option, + pub mini_program_app_id: Option, + pub mini_program_app_secret: Option, pub authorize_endpoint: String, pub access_token_endpoint: String, pub user_info_endpoint: String, + pub js_code_session_endpoint: String, pub mock_user_id: String, pub mock_union_id: Option, pub mock_display_name: String, @@ -211,11 +216,14 @@ pub struct MockWechatProvider { #[derive(Clone, Debug)] pub struct RealWechatProvider { client: Client, - app_id: String, - app_secret: String, + app_id: Option, + app_secret: Option, + mini_program_app_id: Option, + mini_program_app_secret: Option, authorize_endpoint: String, access_token_endpoint: String, user_info_endpoint: String, + js_code_session_endpoint: String, } #[derive(Clone, Debug)] @@ -309,6 +317,14 @@ struct WechatUserInfoResponse { errmsg: Option, } +#[derive(Debug, Deserialize)] +struct WechatJsCodeSessionResponse { + openid: Option, + unionid: Option, + errcode: Option, + errmsg: Option, +} + #[derive(Debug, Deserialize)] struct AliyunSendSmsVerifyCodeResponse { // 阿里云 RPC 原始 JSON 使用首字母大写字段名,这里必须显式映射,避免把成功响应误判成空值。 @@ -626,9 +642,12 @@ impl WechatAuthConfig { provider: String, app_id: Option, app_secret: Option, + mini_program_app_id: Option, + mini_program_app_secret: Option, authorize_endpoint: String, access_token_endpoint: String, user_info_endpoint: String, + js_code_session_endpoint: String, mock_user_id: String, mock_union_id: Option, mock_display_name: String, @@ -639,9 +658,12 @@ impl WechatAuthConfig { provider, app_id, app_secret, + mini_program_app_id, + mini_program_app_secret, authorize_endpoint, access_token_endpoint, user_info_endpoint, + js_code_session_endpoint, mock_user_id, mock_union_id, mock_display_name, @@ -665,20 +687,36 @@ impl WechatProvider { }); } - let Some(app_id) = config.app_id else { - return Self::Disabled; - }; - let Some(app_secret) = config.app_secret else { + let has_web_oauth_config = config + .app_id + .as_ref() + .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; }; Self::Real(RealWechatProvider { client: Client::new(), - app_id, - app_secret, + app_id: config.app_id, + 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, access_token_endpoint: config.access_token_endpoint, user_info_endpoint: config.user_info_endpoint, + js_code_session_endpoint: config.js_code_session_endpoint, }) } @@ -706,6 +744,17 @@ impl WechatProvider { Self::Real(provider) => provider.resolve_callback_profile(code).await, } } + + pub async fn resolve_mini_program_login_profile( + &self, + code: Option<&str>, + ) -> Result { + 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 { @@ -738,8 +787,11 @@ impl RealWechatProvider { let mut url = Url::parse(endpoint).map_err(|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() - .append_pair("appid", &self.app_id) + .append_pair("appid", app_id) .append_pair("redirect_uri", callback_url) .append_pair("response_type", "code") .append_pair( @@ -762,13 +814,19 @@ impl RealWechatProvider { .filter(|value| !value.is_empty()) .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| { WechatProviderError::InvalidConfig(format!("微信 access_token 地址非法:{error}")) })?; access_token_url .query_pairs_mut() - .append_pair("appid", &self.app_id) - .append_pair("secret", &self.app_secret) + .append_pair("appid", app_id) + .append_pair("secret", app_secret) .append_pair("code", code) .append_pair("grant_type", "authorization_code"); @@ -854,6 +912,84 @@ impl RealWechatProvider { avatar_url: user_info_payload.headimgurl, }) } + + async fn resolve_mini_program_login_profile( + &self, + code: Option<&str>, + ) -> Result { + let code = code + .map(str::trim) + .filter(|value| !value.is_empty()) + .ok_or(WechatProviderError::MissingCode)?; + let app_id = self + .mini_program_app_id + .as_ref() + .or(self.app_id.as_ref()) + .ok_or_else(|| { + WechatProviderError::InvalidConfig("微信小程序 AppID 未配置".to_string()) + })?; + let app_secret = self + .mini_program_app_secret + .as_ref() + .or(self.app_secret.as_ref()) + .ok_or_else(|| { + WechatProviderError::InvalidConfig("微信小程序 AppSecret 未配置".to_string()) + })?; + + let 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::() + .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( @@ -1777,9 +1913,12 @@ mod tests { "mock".to_string(), None, None, + None, + None, DEFAULT_WECHAT_AUTHORIZE_ENDPOINT.to_string(), DEFAULT_WECHAT_ACCESS_TOKEN_ENDPOINT.to_string(), DEFAULT_WECHAT_USER_INFO_ENDPOINT.to_string(), + DEFAULT_WECHAT_JS_CODE_SESSION_ENDPOINT.to_string(), "wx-user-001".to_string(), Some("wx-union-001".to_string()), "微信测试用户".to_string(), @@ -1805,9 +1944,12 @@ mod tests { "mock".to_string(), None, None, + None, + None, DEFAULT_WECHAT_AUTHORIZE_ENDPOINT.to_string(), DEFAULT_WECHAT_ACCESS_TOKEN_ENDPOINT.to_string(), DEFAULT_WECHAT_USER_INFO_ENDPOINT.to_string(), + DEFAULT_WECHAT_JS_CODE_SESSION_ENDPOINT.to_string(), "wx-user-001".to_string(), Some("wx-union-001".to_string()), "微信测试用户".to_string(), diff --git a/server-rs/crates/shared-contracts/README.md b/server-rs/crates/shared-contracts/README.md index ce9bfcb0..753f99c9 100644 --- a/server-rs/crates/shared-contracts/README.md +++ b/server-rs/crates/shared-contracts/README.md @@ -32,6 +32,7 @@ 7. `auth/wechat/start` 8. `auth/wechat/callback` 9. `auth/wechat/bind-phone` +10. `auth/wechat/miniprogram-login` 当前阶段继续补齐的 Stage3 公开请求 DTO: diff --git a/server-rs/crates/shared-contracts/src/auth.rs b/server-rs/crates/shared-contracts/src/auth.rs index 3bd58ccf..dd04498a 100644 --- a/server-rs/crates/shared-contracts/src/auth.rs +++ b/server-rs/crates/shared-contracts/src/auth.rs @@ -222,6 +222,20 @@ pub struct WechatBindPhoneResponse { 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( sms_auth_enabled: bool, password_auth_enabled: bool,