From 05d68d106c5ab060fae7e2494403b71e40f91357 Mon Sep 17 00:00:00 2001 From: lapuda <577732344@qq.com> Date: Thu, 23 Jul 2026 17:23:10 +0800 Subject: [PATCH] =?UTF-8?q?=E2=9C=A8=20feat:=20=E6=B7=BB=E5=8A=A0=E5=BE=AE?= =?UTF-8?q?=E4=BF=A1=E5=B0=8F=E7=A8=8B=E5=BA=8F=E7=8E=AF=E5=A2=83=E7=89=88?= =?UTF-8?q?=E6=9C=AC=E9=85=8D=E7=BD=AE=E5=8F=8A=E7=9B=B8=E5=85=B3=E5=8A=9F?= =?UTF-8?q?=E8=83=BD=E5=AE=9E=E7=8E=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- WECHAT_DEVTOOLS_MCP_GUIDE.md | 509 ++++++++++++++++++ .../src/main/resources/application.yml | 2 + .../app/AppOutdoorFairController.java | 4 +- .../service/WeChatMiniProgramApiClient.java | 7 +- .../WeChatMiniProgramQrCodeService.java | 41 +- .../app/AppOutdoorFairControllerTest.java | 17 +- .../WeChatMiniProgramQrCodeServiceTest.java | 24 +- 7 files changed, 586 insertions(+), 18 deletions(-) create mode 100644 WECHAT_DEVTOOLS_MCP_GUIDE.md diff --git a/WECHAT_DEVTOOLS_MCP_GUIDE.md b/WECHAT_DEVTOOLS_MCP_GUIDE.md new file mode 100644 index 0000000..e8d5050 --- /dev/null +++ b/WECHAT_DEVTOOLS_MCP_GUIDE.md @@ -0,0 +1,509 @@ +# 微信开发者工具 MCP:安装、使用与调试指南 + +> 适用环境:macOS、Codex、本机微信开发者工具,以及本项目的 uni-app 微信小程序。 +> 最后验证:2026-07-23。 +> 已验证版本:`wechat-devtools-mcp v0.9.10`、微信开发者工具、Node.js `v22.22.2`。 + +本指南记录了从安装 MCP、正确打开项目,到读取页面、截图和安全模拟点击的完整流程;也包含本机实际遇到的端口、构建目录和 WebSocket 健康检查问题。 + +## 1. 最终结论与最短可行流程 + +本项目的 uni-app **源码根目录不是可直接运行的微信小程序目录**。微信开发者工具/MCP 必须打开一个包含 `app.json` 的已构建目录。 + +当前已验证可运行的小程序根目录为: + +```text +/Users/lapuda/code/shz-employment-service/unpackage/dist/build/mp-weixin +``` + +以下是已验证的最短稳定流程: + +```bash +CLI="/Applications/wechatwebdevtools.app/Contents/MacOS/cli" +MP_ROOT="/Users/lapuda/code/shz-employment-service/unpackage/dist/build/mp-weixin" + +# 1) 明确启动 IDE HTTP 服务;不要依赖 CLI 记住的旧端口。 +"$CLI" open --project "$MP_ROOT" --port 60423 --debug + +# 2) 确认开发者工具登录状态;未登录时按提示扫码。 +"$CLI" islogin --port 60423 + +# 3) 启动小程序自动化服务。 +"$CLI" auto --project "$MP_ROOT" --port 60423 --auto-port 9420 +``` + +随后可通过 MCP 调用页面栈、元素信息、点击与截图。例如,当前已验证过: + +```text +page_stack → pages/index/index +tap → 首页“政策查询” +page_stack → packageRc/pages/policy/policyList +screenshot → 政策列表页截图成功 +``` + +## 2. 概念与端口说明 + +MCP、微信开发者工具 IDE 和小程序自动化服务是三个不同层次。下面的端口不能互相替代。 + +| 层次 | 本机端口 | 作用 | 何时需要 | +|---|---:|---|---| +| 微信开发者工具 IDE HTTP 服务 | `60423` | 接收 `cli open`、`login`、`auto` 等命令 | 启动、登录、打开项目、启用自动化 | +| 小程序自动化 WebSocket | `9420` | `miniprogram-automator` 读取页面、点击、输入、截图 | 页面栈、控件操作、运行时读取、截图 | +| CDP 调试端口 | 通常为 `9222` | 采集渲染/控制台日志等调试信息 | 仅在需要 CDP 日志时 | + +本次曾遇到 CLI 试图连接已失效的 `42075` 端口。此时即使开发者工具窗口还存在,CLI/MCP 也会显示连接超时。使用显式的 `--port 60423` 启动和调用 CLI 可以消除这个歧义。 + +## 3. 前置条件 + +### 3.1 软件与账户 + +- 已安装微信开发者工具,macOS 默认 CLI 路径为: + + ```text + /Applications/wechatwebdevtools.app/Contents/MacOS/cli + ``` + +- 已安装并可使用 Node.js。此机使用: + + ```text + /Users/lapuda/.nvm/versions/node/v22.22.2/bin/node + ``` + +- 已安装 [`uv`](https://docs.astral.sh/uv/)。 +- 使用有权打开该 AppID 项目的微信账号登录微信开发者工具。登录二维码和登录态属于敏感信息,不应写入代码、文档、终端记录或聊天内容。 + +### 3.2 确认实际小程序目录 + +不要只根据目录名判断。微信开发者工具打开的根目录必须至少包含: + +```text +app.json +project.config.json +``` + +本项目可这样确认: + +```bash +MP_ROOT="/Users/lapuda/code/shz-employment-service/unpackage/dist/build/mp-weixin" +test -f "$MP_ROOT/app.json" && test -f "$MP_ROOT/project.config.json" && echo "小程序产物目录有效" +``` + +以下目录是 uni-app 源码根目录,**目前没有**根级 `app.json`,不应直接交给微信开发者工具自动化: + +```text +/Users/lapuda/code/shz-employment-service +``` + +当前 `unpackage/dist/dev/mp-weixin` 也不具备完整 `app.json` 产物;以实际文件检查为准,不要假定 `dev` 目录始终可运行。 + +## 4. 安装 `wechat-devtools-mcp` + +### 4.1 使用 uv 安装 + +```bash +uv tool install wechat-devtools-mcp +uv tool list +command -v wechat-devtools-mcp +``` + +本机安装后的可执行文件为: + +```text +/Users/lapuda/.local/bin/wechat-devtools-mcp +``` + +升级时可使用: + +```bash +uv tool upgrade wechat-devtools-mcp +``` + +升级后应重新启动 Codex 会话,并重复第 7 节的只读验证,避免将不同版本的工具行为混在一起。 + +### 4.2 在 Codex 中注册 STDIO MCP + +Codex 的本地 STDIO MCP 配置位于 `~/.codex/config.toml`;ChatGPT 桌面应用、Codex CLI 与 Codex IDE 扩展会共享同一主机上的 MCP 配置。官方配置说明见:[Codex MCP 文档](https://learn.chatgpt.com/docs/extend/mcp)。 + +将下列内容添加到 `~/.codex/config.toml`。请按自己的 Node.js 安装位置调整路径: + +```toml +[mcp_servers.wechat-devtools] +command = "/Users/lapuda/.local/bin/wechat-devtools-mcp" +startup_timeout_sec = 30.0 +tool_timeout_sec = 120.0 + +[mcp_servers.wechat-devtools.env] +WECHAT_DEVTOOLS_CLI = "/Applications/wechatwebdevtools.app/Contents/MacOS/cli" + +# 必须指向包含 app.json 的可运行小程序产物目录,而不是 uni-app 源码目录。 +WECHAT_PROJECT_PATH = "/Users/lapuda/code/shz-employment-service/unpackage/dist/build/mp-weixin" + +# v0.9.10 将 NODE_PATH 当作 Node.js 可执行文件路径使用。 +NODE_PATH = "/Users/lapuda/.nvm/versions/node/v22.22.2/bin/node" + +PATH = "/Users/lapuda/.local/bin:/Users/lapuda/.nvm/versions/node/v22.22.2/bin:/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin" +``` + +> 上述 `WECHAT_PROJECT_PATH` 是本项目当前可工作的值。源码变动并重新构建后,仍需确认该目录里存在新的 `app.json`,再继续使用。 + +### 4.3 重新加载与确认 + +保存配置后,新开 Codex 会话,或按所使用 Codex 客户端的方式重启/刷新 MCP server。然后确认: + +```bash +codex mcp get wechat-devtools +``` + +期望看到该 server 已启用,传输方式为 `stdio`。在 Codex 会话中,成功初始化后应出现以下七个工具: + +- `wechat_ide` +- `wechat_build` +- `wechat_automator` +- `wechat_inspector` +- `wechat_screenshot` +- `wechat_navigate` +- `wechat_file` + +可先执行只读环境检查: + +```json +{ + "action": "status" +} +``` + +对应工具:`wechat_ide`。 + +预期关键字段包括:`cli_exists: true`、`project_exists: true`、`node_available: true`、正确的 `appid` 和 `lib_version`。 + +## 5. HBuilderX、uni-app 与微信开发者工具的关系 + +HBuilderX 负责将 `.vue`、`pages.json` 等 uni-app 源码转换为微信小程序产物;微信开发者工具实际运行的是转换后的 WXML/WXSS/JS/JSON 文件。 + +| 需求 | 建议动作 | +|---|---| +| 只是验证已生成的页面、截图或自动化 | 不需要启动 HBuilderX;直接打开可运行的 `mp-weixin` 目录 | +| 修改了 uni-app 源码 | 在 HBuilderX 使用“运行到小程序模拟器”或项目约定的构建流程刷新产物 | +| 需要发布包 | 使用项目规定的发行/构建流程;这不是 MCP 自动化验证的一部分 | +| HBuilderX 自动打开了微信开发者工具 | 可以使用,但需确认它实际打开的是包含 `app.json` 的产物目录,并避免与 MCP 重复启动 IDE | + +不要把“源码目录能够被 HBuilderX 打开”误认为“源码目录可以直接被微信开发者工具自动化”。这正是本次 `currentPage()` 健康检查失败的根因。 + +## 6. 推荐的确定性启动流程 + +### 6.1 开始前的安全检查 + +关闭或重启微信开发者工具可能中断模拟器,也可能影响未保存的 IDE 编辑状态。因此,在以下情形先征得操作者确认: + +- 需要关闭当前项目或退出整个微信开发者工具; +- 当前窗口可能存在未保存配置、调试数据或编辑内容; +- 即将调用可能改变业务数据的控件。 + +在本地确认当前端口和进程: + +```bash +lsof -nP -iTCP -sTCP:LISTEN | rg ':(60423|9420|9222)\\b' || true +ps -axo pid=,ppid=,stat=,comm= | rg '/wechatwebdevtools\\.app/' || true +``` + +### 6.2 正常退出并清理失效会话 + +优先使用微信开发者工具自身的正常退出路径,或 macOS 的正常应用退出请求: + +```bash +osascript -e 'tell application id "com.tencent.webplusdevtools" to quit' +``` + +如果主进程已退出但确实留下了、且已通过 `ps` 核实属于微信开发者工具的孤儿辅助进程,可只向这些明确 PID 发送温和终止信号: + +```bash +kill -TERM +``` + +不要对不明 PID 使用 `kill`,也不要在未确认未保存内容前强制关闭 IDE。 + +### 6.3 显式启动 IDE HTTP 服务 + +不要依赖 CLI 历史保存的端口。以下命令会启动正确项目,并让 CLI 服务监听 `60423`: + +```bash +CLI="/Applications/wechatwebdevtools.app/Contents/MacOS/cli" +MP_ROOT="/Users/lapuda/code/shz-employment-service/unpackage/dist/build/mp-weixin" + +"$CLI" open --project "$MP_ROOT" --port 60423 --debug +``` + +成功输出中应包含: + +```text +IDE server started successfully, listening on http://127.0.0.1:60423 +``` + +确认端口: + +```bash +lsof -nP -iTCP:60423 -sTCP:LISTEN +``` + +### 6.4 登录 + +先检查: + +```bash +"$CLI" islogin --port 60423 +``` + +若未登录,执行: + +```bash +"$CLI" login --port 60423 --qr-format terminal +``` + +扫码、确认和所用账号均由操作者处理。命令或 MCP 响应中的二维码、登录结果、Token、Cookie、Storage 内容不应复制到文档、源代码、终端历史或聊天中。 + +### 6.5 启动自动化端口 + +```bash +"$CLI" auto --project "$MP_ROOT" --port 60423 --auto-port 9420 +``` + +成功时会输出: + +```text +Using AppID: wx99193c507b93d6db +auto +``` + +检查监听状态: + +```bash +lsof -nP -iTCP:9420 -sTCP:LISTEN +nc -vz 127.0.0.1 9420 +``` + +对 `http://127.0.0.1:9420/` 发起普通 HTTP 请求时,返回 `HTTP/1.1 426 Upgrade Required` 是正常现象:该端口只接受 WebSocket 升级,而不是普通网页请求。 + +## 7. 在 Codex 中使用 MCP + +### 7.1 为什么本机建议先用 CLI 启动,再用 MCP 交互 + +`wechat-devtools-mcp v0.9.10` 的 `wechat_automator(action="start")` 不提供 IDE HTTP 服务端口参数;它会调用未带 `--port` 的 CLI `auto`。当 CLI 记住了失效端口(本机为 `42075`)时,该调用会超时,即使 `9420` 最终有监听也可能无法完成 MCP 健康检查。 + +因此,本机推荐: + +1. 用第 6 节的 CLI 命令显式设置 `--port 60423`; +2. 再用 MCP 执行页面查询、元素查询、点击、截图等运行时操作; +3. 不要把 `wechat_automator.start` 的失败误判为小程序业务代码故障。 + +### 7.2 最小只读验证顺序 + +在 CLI 已成功启动 `9420` 后,按以下顺序调用 MCP: + +1. `wechat_ide(action="status")`:确认 CLI、Node、项目路径; +2. `wechat_automator(action="page_stack", auto_port=9420)`:确认实际页面运行; +3. `wechat_screenshot(full_page=false, auto_port=9420)`:确认模拟器视觉捕获; +4. 需要时,`wechat_automator(action="element_info", selector="…")`:先只读获取控件信息。 + +页面栈成功的示例响应: + +```json +{ + "success": true, + "data": { + "depth": 1, + "pages": [ + { "path": "pages/index/index", "query": {} } + ] + } +} +``` + +当前视口截图建议保存到临时目录,避免把验证文件写入业务项目: + +```json +{ + "full_page": false, + "auto_port": 9420, + "output_path": "/tmp/wechat-mcp-current-viewport.png" +} +``` + +### 7.3 点击模拟的安全流程 + +真实点击必须先确认用户明确授权的目标,并先判断该动作是否会提交、支付、删除、发布、登录切换或写入业务数据。 + +建议按下面的顺序: + +1. 使用 CodeGraph 或源码确认控件的跳转/业务语义; +2. 用 `element_info` 查询选择器,确认它唯一且位于预期页面; +3. 选择只导航、不改数据的入口; +4. 执行一次 `tap`; +5. 立即以 `page_stack` 和截图验证结果。 + +本次已验证的安全示例: + +```text +selector: .service-icon-4 +首页文字: 政策查询 +源码导航: /packageRc/pages/policy/policyList +``` + +点击前后页面栈: + +```text +点击前: pages/index/index +点击后: pages/index/index → packageRc/pages/policy/policyList +``` + +该点击只进行了页面导航,没有输入、提交、删除、登录切换或业务数据写入。 + +### 7.4 常用 MCP 操作与边界 + +| 工具/动作 | 用途 | 默认安全性 | +|---|---|---| +| `wechat_ide.status` | 环境诊断 | 只读 | +| `wechat_ide.is_login` | 检查 IDE 登录状态 | 只读 | +| `wechat_file.project_info` / `read_file` | 读取项目、构建目录与文件 | 只读 | +| `wechat_automator.page_stack` / `page_data` / `system_info` | 读取运行时状态 | 只读 | +| `wechat_automator.element_info` | 读取指定选择器信息 | 只读 | +| `wechat_screenshot` | 捕获模拟器界面 | 只读,但会产生图片文件 | +| `wechat_automator.tap` | 真实点击 | 可能有业务副作用,需先确认目标 | +| `wechat_automator.input` / `set_data` / `call_wx` | 输入或调用运行时能力 | 默认视为可能有副作用,需明确授权 | +| `wechat_build.compile` / `preview` / `upload` | 构建、预览或上传 | 非只读;不要在普通调试中调用 | + +## 8. 故障排查手册 + +### 8.1 `islogin` 或 `login` 报端口 `42075` 超时 + +典型信息: + +```text +IDE may already started at port 42075, trying to connect +#initialize-error: wait IDE port timeout +``` + +原因:微信开发者工具 CLI 保存了一个已失效的 IDE 服务端口。不是小程序页面错误,也不是 HBuilderX 构建错误。 + +处理: + +1. 保存 IDE 中需要保留的内容; +2. 正常退出微信开发者工具; +3. 按第 6.3 节使用 `open --port 60423` 重启; +4. 所有后续 `islogin`、`login`、`auto` 命令都显式加 `--port 60423`。 + +### 8.2 只看到 `9222`,没有 `60423` + +原因:可能通过 CDP 模式启动了渲染/调试进程,但未建立微信开发者工具 CLI HTTP 服务。此时 MCP 的 `open(cdp_enabled=true)` 能看到调试端口,不代表 `login` 或 `auto` 能正常工作。 + +处理:退出当前残留 IDE 后,使用 CLI 的确定性启动命令: + +```bash +"$CLI" open --project "$MP_ROOT" --port 60423 --debug +``` + +### 8.3 `auto` 成功、`9420` 也监听,但 MCP 报: + +```text +Connection not ready: health check failed after connect +``` + +优先检查项目根目录是否错误: + +```bash +test -f "$MP_ROOT/app.json" || echo "当前目录不是可运行小程序根目录" +``` + +本项目曾将 MCP 指向 uni-app 源码根目录。此时 TCP/WebSocket 本身可以打开,但没有能被 `currentPage()` 读取的实际小程序页面;改为 `unpackage/dist/build/mp-weixin` 后,`page_stack` 立即成功。 + +还可做两项只读诊断: + +```bash +# 应返回 426 Upgrade Required,说明 WebSocket 服务端实际可达。 +curl --max-time 5 --http1.1 -sS -D - -o /dev/null http://127.0.0.1:9420/ + +# 仅确认 TCP 可达。 +nc -vz 127.0.0.1 9420 +``` + +### 8.4 `cli auto` 在 30 秒后超时 + +首先不要重建项目,也不要直接归因于 HBuilderX。检查命令是否缺少 `--port 60423`,再执行: + +```bash +"$CLI" auto --project "$MP_ROOT" --port 60423 --auto-port 9420 +``` + +如果仍失败,检查: + +- `60423` 是否真的在监听; +- CLI 是否已登录; +- `MP_ROOT/app.json` 是否存在; +- 当前 AppID 是否有该账号的开发权限; +- 微信开发者工具窗口是否卡在授权、升级或未保存提示。 + +### 8.5 HBuilderX 关闭后状态发生变化 + +关闭 HBuilderX 不保证微信开发者工具自动退出,也不保证其登录/服务端口状态保持不变。出现问题时应以 `lsof`、`islogin --port 60423` 和 `page_stack` 的实际结果为准,不要根据窗口是否可见猜测状态。 + +### 8.6 截图成功但尺寸或滚动位置不符合预期 + +使用: + +```json +{ + "full_page": false, + "scroll_top": 0, + "output_path": "/tmp/wechat-viewport.png" +} +``` + +`full_page: false` 只捕获当前视口;长图需要使用默认的 `full_page: true`。截图会保存文件,因此建议默认写到 `/tmp`,验证完成后再按需要保留或删除。 + +## 9. 可复用的新会话请求 + +将下面内容发送给新 Codex 会话,可快速恢复工作上下文: + +```text +请按 WECHAT_DEVTOOLS_MCP_GUIDE.md 继续验证微信小程序 MCP。 + +目标不是 uni-app 源码根目录,而是: +/Users/lapuda/code/shz-employment-service/unpackage/dist/build/mp-weixin + +先只读检查 app.json、IDE 服务端口 60423、登录状态和 9420。 +若服务未启动,使用微信开发者工具 CLI 显式传 --port 60423: +open → islogin/login → auto。 + +CLI auto 成功后,用 MCP 执行 page_stack 和 full_page=false 截图。 +只有我明确指定控件、且确认不会产生业务副作用时,才可使用 tap/input。 +``` + +## 10. 当前验证记录 + +本机已完成的端到端验证: + +1. 显式启动 IDE HTTP 服务:`127.0.0.1:60423`; +2. 通过官方 CLI 确认微信开发者工具已登录; +3. 使用构建产物目录启动自动化:`*:9420`; +4. MCP 成功读取首页 `pages/index/index`; +5. MCP 成功捕获首页视口截图; +6. MCP 成功读取首页“政策查询”元素; +7. MCP 成功点击该安全导航入口; +8. 页面栈确认跳转至 `packageRc/pages/policy/policyList`; +9. MCP 成功捕获政策列表页截图。 + +这说明 MCP 的环境诊断、运行时查询、真实点击、页面跳转确认和截图能力均已验证可用。 + +## 11. 安全与维护原则 + +- 默认使用只读调用:状态、页面栈、元素信息和截图; +- 点击、输入、设置页面数据、调用 `wx` API、构建、预览、上传前,确认用户授权与业务副作用; +- 不在文档、聊天、代码或命令历史中暴露二维码、Token、Cookie、Local Storage、登录响应或用户数据; +- 不将 MCP 验证过程当成部署流程;MCP 自动化成功不等于已构建、上传或发布; +- 更换微信开发者工具、Node.js 或 `wechat-devtools-mcp` 版本后,重复第 6、7 节的最小只读验证; +- 如果项目源代码发生变化,先刷新真正的 `mp-weixin` 产物,再重新打开该产物目录,而不是直接把源码根目录交给微信开发者工具。 + +## 12. 参考资料 + +- [OpenAI Codex MCP 配置文档](https://learn.chatgpt.com/docs/extend/mcp):Codex 的 STDIO/HTTP MCP、`config.toml`、环境变量、超时与工具权限配置。 +- [OpenAI Codex 配置参考](https://learn.chatgpt.com/docs/config-file/config-reference):`~/.codex/config.toml` 和项目级 `.codex/config.toml` 的作用域与可用配置项。 +- [微信开发者工具 CLI](https://developers.weixin.qq.com/miniprogram/dev/devtools/cli.html):请以当前安装版本对应的官方 CLI 文档为准;本指南中的命令已在上述本机环境实际验证。 diff --git a/ruoyi-admin/src/main/resources/application.yml b/ruoyi-admin/src/main/resources/application.yml index b5a113a..d8082a3 100644 --- a/ruoyi-admin/src/main/resources/application.yml +++ b/ruoyi-admin/src/main/resources/application.yml @@ -154,6 +154,8 @@ policy: wx: appid: wx99193c507b93d6db secret: 79bf8648880746c11f9ef9c5c12e030f + # 当前小程序尚未正式发布,默认生成体验版小程序码;正式上线后可通过环境变量切换为 release + env-version: ${WX_MINI_PROGRAM_ENV_VERSION:trial} #统一门户认证 oauth: diff --git a/ruoyi-bussiness/src/main/java/com/ruoyi/cms/controller/app/AppOutdoorFairController.java b/ruoyi-bussiness/src/main/java/com/ruoyi/cms/controller/app/AppOutdoorFairController.java index 9ee261d..d476594 100644 --- a/ruoyi-bussiness/src/main/java/com/ruoyi/cms/controller/app/AppOutdoorFairController.java +++ b/ruoyi-bussiness/src/main/java/com/ruoyi/cms/controller/app/AppOutdoorFairController.java @@ -221,10 +221,10 @@ public class AppOutdoorFairController extends BaseController return AjaxResult.error("招聘会不存在"); } if (fair.getHoldTime() == null) { - return AjaxResult.error("招聘会开始时间未设置,暂不能签到"); + return AjaxResult.error("招聘会还没有开始,暂不能签到"); } if (!hasFairStarted(fair, new Date())) { - return AjaxResult.error("招聘会尚未开始,暂不能签到"); + return AjaxResult.error("招聘会还没有开始,暂不能签到"); } // 同一招聘会下同手机号已签到则不重复记录 Long exists = outdoorFairAttendeeService.count(new LambdaQueryWrapper() diff --git a/ruoyi-bussiness/src/main/java/com/ruoyi/cms/service/WeChatMiniProgramApiClient.java b/ruoyi-bussiness/src/main/java/com/ruoyi/cms/service/WeChatMiniProgramApiClient.java index 9e2d62e..220a60a 100644 --- a/ruoyi-bussiness/src/main/java/com/ruoyi/cms/service/WeChatMiniProgramApiClient.java +++ b/ruoyi-bussiness/src/main/java/com/ruoyi/cms/service/WeChatMiniProgramApiClient.java @@ -42,14 +42,15 @@ public class WeChatMiniProgramApiClient return new AccessTokenResult(accessToken, expiresIn == null ? 7200 : expiresIn); } - public QrCodeImage getUnlimitedQrCode(String accessToken, String scene, String page) + public QrCodeImage getUnlimitedQrCode(String accessToken, String scene, String page, + String envVersion) { JSONObject requestBody = new JSONObject(); requestBody.put("scene", scene); requestBody.put("page", page); - // 新页面尚未随小程序版本发布时也允许先生成码;扫码仍会进入正式版小程序。 + // 新页面尚未随小程序版本发布时也允许先生成码;扫码进入配置指定的小程序版本。 requestBody.put("check_path", false); - requestBody.put("env_version", "release"); + requestBody.put("env_version", envVersion); requestBody.put("width", 430); HttpResponse response = null; diff --git a/ruoyi-bussiness/src/main/java/com/ruoyi/cms/service/WeChatMiniProgramQrCodeService.java b/ruoyi-bussiness/src/main/java/com/ruoyi/cms/service/WeChatMiniProgramQrCodeService.java index 969fba3..2055e70 100644 --- a/ruoyi-bussiness/src/main/java/com/ruoyi/cms/service/WeChatMiniProgramQrCodeService.java +++ b/ruoyi-bussiness/src/main/java/com/ruoyi/cms/service/WeChatMiniProgramQrCodeService.java @@ -18,13 +18,15 @@ import java.util.function.LongSupplier; public class WeChatMiniProgramQrCodeService { public static final String OUTDOOR_FAIR_PAGE = "packageA/pages/outdoorFair/detail"; + public static final String DEFAULT_ENV_VERSION = "trial"; private static final long TOKEN_REFRESH_AHEAD_MILLIS = 300_000L; private final WeChatMiniProgramApiClient apiClient; private final String appId; private final String appSecret; + private final String envVersion; private final LongSupplier currentTimeMillis; - private final Map qrCodeDataUrlCache = new ConcurrentHashMap<>(); + private final Map qrCodeDataUrlCache = new ConcurrentHashMap<>(); private volatile String accessToken; private volatile long accessTokenExpiresAt; @@ -32,17 +34,26 @@ public class WeChatMiniProgramQrCodeService @Autowired public WeChatMiniProgramQrCodeService(WeChatMiniProgramApiClient apiClient, @Value("${wx.appid}") String appId, - @Value("${wx.secret}") String appSecret) + @Value("${wx.secret}") String appSecret, + @Value("${wx.env-version:trial}") String envVersion) { - this(apiClient, appId, appSecret, System::currentTimeMillis); + this(apiClient, appId, appSecret, envVersion, System::currentTimeMillis); } WeChatMiniProgramQrCodeService(WeChatMiniProgramApiClient apiClient, String appId, String appSecret, LongSupplier currentTimeMillis) + { + this(apiClient, appId, appSecret, DEFAULT_ENV_VERSION, currentTimeMillis); + } + + WeChatMiniProgramQrCodeService(WeChatMiniProgramApiClient apiClient, String appId, + String appSecret, String envVersion, + LongSupplier currentTimeMillis) { this.apiClient = apiClient; this.appId = appId; this.appSecret = appSecret; + this.envVersion = normalizeEnvVersion(envVersion); this.currentTimeMillis = currentTimeMillis; } @@ -52,16 +63,17 @@ public class WeChatMiniProgramQrCodeService throw new ServiceException("招聘会参数不正确"); } String scene = "fairId=" + fairId; - String imageData = qrCodeDataUrlCache.get(fairId); + String cacheKey = envVersion + ":" + fairId; + String imageData = qrCodeDataUrlCache.get(cacheKey); if (imageData == null) { synchronized (qrCodeDataUrlCache) { - imageData = qrCodeDataUrlCache.get(fairId); + imageData = qrCodeDataUrlCache.get(cacheKey); if (imageData == null) { WeChatMiniProgramApiClient.QrCodeImage image = apiClient.getUnlimitedQrCode( - getValidAccessToken(), scene, OUTDOOR_FAIR_PAGE); + getValidAccessToken(), scene, OUTDOOR_FAIR_PAGE, envVersion); imageData = "data:" + image.getContentType() + ";base64," + Base64.getEncoder().encodeToString(image.getContent()); - qrCodeDataUrlCache.put(fairId, imageData); + qrCodeDataUrlCache.put(cacheKey, imageData); } } } @@ -70,6 +82,7 @@ public class WeChatMiniProgramQrCodeService data.put("fairId", fairId); data.put("page", OUTDOOR_FAIR_PAGE); data.put("scene", scene); + data.put("envVersion", envVersion); data.put("imageData", imageData); // getUnlimitedQRCode 生成的小程序码长期有效;需要轮换的是 access token,而不是已经生成的码。 data.put("permanent", true); @@ -77,6 +90,20 @@ public class WeChatMiniProgramQrCodeService return data; } + private String normalizeEnvVersion(String value) + { + String normalized = value == null ? "" : value.trim().toLowerCase(); + if (normalized.isEmpty()) { + return DEFAULT_ENV_VERSION; + } + if (!"trial".equals(normalized) + && !"release".equals(normalized) + && !"develop".equals(normalized)) { + throw new ServiceException("微信小程序版本配置不正确,仅支持 trial、release 或 develop"); + } + return normalized; + } + private String getValidAccessToken() { long now = currentTimeMillis.getAsLong(); diff --git a/ruoyi-bussiness/src/test/java/com/ruoyi/cms/controller/app/AppOutdoorFairControllerTest.java b/ruoyi-bussiness/src/test/java/com/ruoyi/cms/controller/app/AppOutdoorFairControllerTest.java index 8997526..0d67ddd 100644 --- a/ruoyi-bussiness/src/test/java/com/ruoyi/cms/controller/app/AppOutdoorFairControllerTest.java +++ b/ruoyi-bussiness/src/test/java/com/ruoyi/cms/controller/app/AppOutdoorFairControllerTest.java @@ -44,7 +44,22 @@ class AppOutdoorFairControllerTest AjaxResult result = controller.checkIn(attendee(12L)); assertEquals(500, result.get(AjaxResult.CODE_TAG)); - assertEquals("招聘会尚未开始,暂不能签到", result.get(AjaxResult.MSG_TAG)); + assertEquals("招聘会还没有开始,暂不能签到", result.get(AjaxResult.MSG_TAG)); + verify(attendeeService, never()).save(any(OutdoorFairAttendee.class)); + } + + @Test + void fairWithoutStartTimeUsesNotStartedMessage() throws Exception + { + IOutdoorFairService fairService = mock(IOutdoorFairService.class); + IOutdoorFairAttendeeService attendeeService = mock(IOutdoorFairAttendeeService.class); + when(fairService.getById(12L)).thenReturn(fairAt(null)); + AppOutdoorFairController controller = controllerWith(fairService, attendeeService); + + AjaxResult result = controller.checkIn(attendee(12L)); + + assertEquals(500, result.get(AjaxResult.CODE_TAG)); + assertEquals("招聘会还没有开始,暂不能签到", result.get(AjaxResult.MSG_TAG)); verify(attendeeService, never()).save(any(OutdoorFairAttendee.class)); } diff --git a/ruoyi-bussiness/src/test/java/com/ruoyi/cms/service/WeChatMiniProgramQrCodeServiceTest.java b/ruoyi-bussiness/src/test/java/com/ruoyi/cms/service/WeChatMiniProgramQrCodeServiceTest.java index 9a38915..d777b81 100644 --- a/ruoyi-bussiness/src/test/java/com/ruoyi/cms/service/WeChatMiniProgramQrCodeServiceTest.java +++ b/ruoyi-bussiness/src/test/java/com/ruoyi/cms/service/WeChatMiniProgramQrCodeServiceTest.java @@ -26,7 +26,7 @@ class WeChatMiniProgramQrCodeServiceTest when(client.getAccessToken("app-id", "app-secret")) .thenReturn(new WeChatMiniProgramApiClient.AccessTokenResult("token-1", 7200)); when(client.getUnlimitedQrCode("token-1", "fairId=12", - WeChatMiniProgramQrCodeService.OUTDOOR_FAIR_PAGE)) + WeChatMiniProgramQrCodeService.OUTDOOR_FAIR_PAGE, "trial")) .thenReturn(new WeChatMiniProgramApiClient.QrCodeImage( "image-content".getBytes(StandardCharsets.UTF_8), "image/png")); @@ -38,13 +38,14 @@ class WeChatMiniProgramQrCodeServiceTest assertEquals(12L, first.get("fairId")); assertEquals("fairId=12", first.get("scene")); assertEquals("packageA/pages/outdoorFair/detail", first.get("page")); + assertEquals("trial", first.get("envVersion")); assertTrue((Boolean) first.get("permanent")); assertFalse((Boolean) first.get("rotationRequired")); assertTrue(String.valueOf(first.get("imageData")).startsWith("data:image/png;base64,")); assertEquals(first.get("imageData"), second.get("imageData")); verify(client, times(1)).getAccessToken("app-id", "app-secret"); verify(client, times(1)).getUnlimitedQrCode( - "token-1", "fairId=12", WeChatMiniProgramQrCodeService.OUTDOOR_FAIR_PAGE); + "token-1", "fairId=12", WeChatMiniProgramQrCodeService.OUTDOOR_FAIR_PAGE, "trial"); } @Test @@ -55,7 +56,7 @@ class WeChatMiniProgramQrCodeServiceTest when(client.getAccessToken("app-id", "app-secret")) .thenReturn(new WeChatMiniProgramApiClient.AccessTokenResult("token-1", 7200)) .thenReturn(new WeChatMiniProgramApiClient.AccessTokenResult("token-2", 7200)); - when(client.getUnlimitedQrCode(anyString(), anyString(), anyString())) + when(client.getUnlimitedQrCode(anyString(), anyString(), anyString(), anyString())) .thenReturn(new WeChatMiniProgramApiClient.QrCodeImage(new byte[]{1, 2, 3}, "image/png")); WeChatMiniProgramQrCodeService service = new WeChatMiniProgramQrCodeService( @@ -66,9 +67,9 @@ class WeChatMiniProgramQrCodeServiceTest verify(client, times(2)).getAccessToken("app-id", "app-secret"); verify(client).getUnlimitedQrCode( - "token-1", "fairId=1", WeChatMiniProgramQrCodeService.OUTDOOR_FAIR_PAGE); + "token-1", "fairId=1", WeChatMiniProgramQrCodeService.OUTDOOR_FAIR_PAGE, "trial"); verify(client).getUnlimitedQrCode( - "token-2", "fairId=2", WeChatMiniProgramQrCodeService.OUTDOOR_FAIR_PAGE); + "token-2", "fairId=2", WeChatMiniProgramQrCodeService.OUTDOOR_FAIR_PAGE, "trial"); } @Test @@ -83,4 +84,17 @@ class WeChatMiniProgramQrCodeServiceTest assertEquals("微信小程序配置不完整,请联系管理员", exception.getMessage()); } + + @Test + void rejectsUnsupportedEnvironmentVersion() + { + WeChatMiniProgramApiClient client = mock(WeChatMiniProgramApiClient.class); + + ServiceException exception = assertThrows(ServiceException.class, + () -> new WeChatMiniProgramQrCodeService( + client, "app-id", "app-secret", "unknown", () -> 1_000L)); + + assertEquals("微信小程序版本配置不正确,仅支持 trial、release 或 develop", + exception.getMessage()); + } }