diff --git a/WECHAT_DEVTOOLS_MCP_HANDOFF.md b/WECHAT_DEVTOOLS_MCP_HANDOFF.md new file mode 100644 index 0000000..89d3d32 --- /dev/null +++ b/WECHAT_DEVTOOLS_MCP_HANDOFF.md @@ -0,0 +1,157 @@ +# 微信开发者工具 MCP:配置与验证交接 + +## 当前结论 + +`wechat-devtools-mcp` 已安装并注册到 Codex,全局配置和基础环境均已验证成功;但当前微信开发者工具的**自动化 WebSocket 会话尚未可用**,因此暂时不能可靠地进行页面栈读取、点击、输入或截图。 + +已验证的小程序项目为: + +- 项目根目录:`/Users/lapuda/code/shz-employment-service` +- AppID:`wx99193c507b93d6db` +- 小程序基础库:`3.14.3` +- 微信开发者工具服务端口:`60423`(正在监听) +- 自动化端口:`9420`(正在监听,但 WebSocket 健康检查失败) + +> 该文档不包含令牌、登录态或其他敏感信息。 + +## 已完成的本机配置 + +MCP Server 已通过 `uv` 安装: + +```text +wechat-devtools-mcp v0.9.10 +可执行文件:/Users/lapuda/.local/bin/wechat-devtools-mcp +``` + +Codex 全局配置文件为 `~/.codex/config.toml`,其中已加入: + +```toml +[mcp_servers.wechat-devtools] +command = "/Users/lapuda/.local/bin/wechat-devtools-mcp" +startup_timeout_sec = 30.0 + +[mcp_servers.wechat-devtools.env] +WECHAT_DEVTOOLS_CLI = "/Applications/wechatwebdevtools.app/Contents/MacOS/cli" +WECHAT_PROJECT_PATH = "/Users/lapuda/code/shz-employment-service" +PATH = "/Users/lapuda/.local/bin:/Users/lapuda/.nvm/versions/node/v22.22.2/bin:/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin" +NODE_PATH = "/Users/lapuda/.nvm/versions/node/v22.22.2/bin/node" +``` + +`codex mcp get wechat-devtools` 已确认该 Server 为 `enabled`,传输方式为 `stdio`。 + +## 已完成的 MCP 验证 + +### 1. Server 和运行环境 + +通过 MCP 标准握手完成初始化,Server 成功暴露 7 个工具: + +- `wechat_ide` +- `wechat_build` +- `wechat_automator` +- `wechat_inspector` +- `wechat_screenshot` +- `wechat_navigate` +- `wechat_file` + +随后调用只读的 `wechat_ide(action="status")`,返回成功: + +```json +{ + "success": true, + "data": { + "mcp_version": "0.9.10", + "cli_exists": true, + "project_exists": true, + "node_available": true, + "appid": "wx99193c507b93d6db", + "lib_version": "3.14.3" + }, + "message": "状态正常" +} +``` + +### 2. 微信开发者工具端口 + +本机端口监听已确认: + +```text +127.0.0.1:60423 → 微信开发者工具 Renderer 进程监听 +*:9420 → 微信开发者工具 Renderer 进程监听 +``` + +注意: + +- `60423` 是微信开发者工具的 IDE 服务端口,开启它只是 MCP 能下发 IDE/CLI 指令的前提。 +- `9420` 是小程序自动化连接端口。MCP 要通过其 WebSocket 会话进行页面读取和交互。 + +### 3. 自动化控制验证及阻塞原因 + +已调用: + +```text +wechat_automator(action="start", auto_port=9420) +``` + +返回结果: + +```json +{ + "success": true, + "data": { + "port": 9420, + "verified": false, + "tcp_ready": true, + "ws_ready": false, + "verify_attempts": 2, + "retry_after_ms": 3000 + }, + "message": "自动化端口 9420 的 WS 层未就绪(已尝试 2 次)。" +} +``` + +随后尝试只读的: + +```text +wechat_automator(action="page_stack", auto_port=9420) +``` + +最终失败信息: + +```text +连接失败: Connection not ready: health check failed after connect +``` + +因此,当前状态应判断为:**MCP 配置正常;微信开发者工具的自动化 WebSocket 尚不可用;暂不能宣称小程序已可被控制。** + +## 新对话应如何继续 + +先新开一个 Codex 对话/会话,让 Codex 从 `~/.codex/config.toml` 加载新注册的 MCP 工具。新会话应能直接看到 `wechat_ide`、`wechat_automator` 等工具。 + +建议按以下顺序继续,且先保持只读: + +1. 调用 `wechat_ide(action="status")`,再次确认环境。 +2. 调用 `wechat_automator(action="start", auto_port=9420)`。 +3. 若返回 `ws_ready: true`,调用 `wechat_automator(action="page_stack", auto_port=9420)`。 +4. 读取成功后,调用 `wechat_screenshot(full_page=false)` 获取当前视口截图。 +5. 仅在用户明确指定目标控件且确认不会触发提交、支付、删除、登录切换等副作用后,才使用 `wechat_automator(action="tap", selector="…")` 验证真实点击控制。 + +## 若 WebSocket 仍不可用 + +官方工具的已知建议是:手动启动的微信开发者工具有时不会生成 MCP 需要的自动化/CDP 会话。可采取以下恢复方式: + +1. **先取得用户确认**:关闭/重开当前微信开发者工具可能打断正在运行的小程序,且可能影响未保存的编辑状态。 +2. 得到确认后,关闭当前 IDE 项目或退出 IDE。 +3. 通过 MCP 用配置中的项目目录重新打开:`wechat_ide(action="open", cdp_enabled=true)`。 +4. 等待项目完全载入后,再执行 `wechat_automator(action="start")` 与 `page_stack`。 + +在此前的操作中,没有擅自关闭、重开或重编译微信开发者工具,也没有修改小程序业务代码。 + +## 可直接发送给新对话的请求 + +```text +请阅读 shz-backend/WECHAT_DEVTOOLS_MCP_HANDOFF.md,继续测试微信小程序 MCP 控制能力。 + +当前 MCP 已配置为 wechat-devtools-mcp v0.9.10,项目路径是 /Users/lapuda/code/shz-employment-service,IDE 服务端口为 60423。上次测试发现自动化端口 9420 的 TCP 已监听但 WebSocket 未就绪,page_stack 健康检查失败。 + +先执行只读验证:status → automator start → page_stack → 当前视口截图。若仍无法连接,不要擅自关闭微信开发者工具;先向我确认是否允许关闭并通过 MCP 重新打开项目。 +``` diff --git a/docs/app-interview-delete-api.md b/docs/app-interview-delete-api.md new file mode 100644 index 0000000..2a770b3 --- /dev/null +++ b/docs/app-interview-delete-api.md @@ -0,0 +1,92 @@ +# 移动端面试邀约删除接口 + +## 1. 接口说明 + +求职者在移动端删除自己收到的面试邀约。接口同时支持单个删除和批量删除;服务端根据登录 Token 自动识别当前用户,客户端不需要也不能传递 `userId`。 + +删除采用逻辑删除:记录会被标记为已删除,后续调用面试邀约列表接口时不会再返回该记录。 + +## 2. 接口地址 + +`DELETE /app/interview/{ids}` + +## 3. 鉴权 + +请求头需携带已登录移动端用户的 Token: + +```http +Authorization: Bearer <移动端登录 Token> +``` + +未登录时,接口返回业务码 `500` 和提示信息“未登录”。 + +## 4. 路径参数 + +| 参数名 | 位置 | 类型 | 必填 | 说明 | +| --- | --- | --- | --- | --- | +| ids | Path | Long / Long[] | 是 | 面试邀约 ID。单个 ID 直接传递;批量 ID 使用英文逗号 `,` 分隔。 | + +## 5. 请求示例 + +### 5.1 删除单个面试邀约 + +```http +DELETE /app/interview/12 +Authorization: Bearer <移动端登录 Token> +``` + +### 5.2 批量删除面试邀约 + +```http +DELETE /app/interview/12,13,14 +Authorization: Bearer <移动端登录 Token> +``` + +### 5.3 cURL 示例 + +```bash +curl -X DELETE "https://<后端地址>/app/interview/12,13,14" \ + -H "Authorization: Bearer <移动端登录 Token>" +``` + +## 6. 成功响应 + +`data` 为实际删除的面试邀约数量。 + +```json +{ + "code": 200, + "msg": "操作成功", + "data": 3 +} +``` + +## 7. 失败响应 + +### 7.1 未登录 + +```json +{ + "code": 500, + "msg": "未登录" +} +``` + +### 7.2 没有可删除的邀约 + +当全部 ID 不存在、已删除,或均不属于当前登录用户时,实际删除数量为 `0`,接口返回: + +```json +{ + "code": 500, + "msg": "操作失败" +} +``` + +## 8. 删除规则 + +1. 服务端只会删除 `id` 在请求参数内且 `user_id` 等于当前登录用户 ID 的记录。 +2. 传入他人的面试邀约 ID 不会删除他人的数据。 +3. 批量请求中同时包含本人和他人的邀约时,仅删除本人的记录;成功响应中的 `data` 是实际删除数量,不会逐条返回未删除 ID 的原因。 +4. 删除为逻辑删除,记录的 `del_flag` 更新为已删除标识,移动端列表 `GET /app/interview/list` 不再展示该记录。 +5. 前端收到 `code: 200` 后应刷新面试邀约列表;若需要确保全部选中记录都已删除,可将响应中的 `data` 与本次提交的 ID 数量进行比较。 diff --git a/ruoyi-bussiness/src/main/java/com/ruoyi/cms/controller/app/AppInterviewController.java b/ruoyi-bussiness/src/main/java/com/ruoyi/cms/controller/app/AppInterviewController.java index 14af942..7ebf850 100644 --- a/ruoyi-bussiness/src/main/java/com/ruoyi/cms/controller/app/AppInterviewController.java +++ b/ruoyi-bussiness/src/main/java/com/ruoyi/cms/controller/app/AppInterviewController.java @@ -119,4 +119,17 @@ public class AppInterviewController extends BaseController { } return success(interviewInvitationService.updateReadAll(SiteSecurityUtils.getUserId())); } + + /** + * 删除我的面试邀约,路径中的逗号分隔 ID 可用于批量删除。 + */ + @ApiOperation("删除我的面试邀约(支持批量)") + @DeleteMapping("/{ids}") + public AjaxResult remove(@PathVariable Long[] ids) { + if (!SiteSecurityUtils.isLogin()) { + return error("未登录"); + } + return toAjax(interviewInvitationService.deleteInterviewInvitationIdsByUserId( + ids, SiteSecurityUtils.getUserId())); + } } diff --git a/ruoyi-bussiness/src/main/java/com/ruoyi/cms/service/InterviewInvitationService.java b/ruoyi-bussiness/src/main/java/com/ruoyi/cms/service/InterviewInvitationService.java index 0458c57..8e965c5 100644 --- a/ruoyi-bussiness/src/main/java/com/ruoyi/cms/service/InterviewInvitationService.java +++ b/ruoyi-bussiness/src/main/java/com/ruoyi/cms/service/InterviewInvitationService.java @@ -25,6 +25,15 @@ public interface InterviewInvitationService { int deleteInterviewInvitationIds(Long[] ids); + /** + * 删除指定求职者自己的面试邀约(支持批量)。 + * + * @param ids 面试邀约 ID 集合 + * @param userId 当前登录求职者 ID + * @return 删除记录数 + */ + int deleteInterviewInvitationIdsByUserId(Long[] ids, Long userId); + InterviewInvitation getInterviewInvitationById(Long id); InterviewInvitationVO getInterviewInvitationVOById(Long id); diff --git a/ruoyi-bussiness/src/main/java/com/ruoyi/cms/service/impl/InterviewInvitationServiceImpl.java b/ruoyi-bussiness/src/main/java/com/ruoyi/cms/service/impl/InterviewInvitationServiceImpl.java index 678c278..9f05f25 100644 --- a/ruoyi-bussiness/src/main/java/com/ruoyi/cms/service/impl/InterviewInvitationServiceImpl.java +++ b/ruoyi-bussiness/src/main/java/com/ruoyi/cms/service/impl/InterviewInvitationServiceImpl.java @@ -203,6 +203,16 @@ public class InterviewInvitationServiceImpl implements InterviewInvitationServic return interviewInvitationMapper.deleteBatchIds(Arrays.asList(ids)); } + @Override + public int deleteInterviewInvitationIdsByUserId(Long[] ids, Long userId) { + if (ids == null || ids.length == 0 || userId == null) { + return 0; + } + return interviewInvitationMapper.delete(Wrappers.lambdaQuery() + .in(InterviewInvitation::getId, Arrays.asList(ids)) + .eq(InterviewInvitation::getUserId, userId)); + } + @Override public InterviewInvitation getInterviewInvitationById(Long id) { return interviewInvitationMapper.selectById(id); diff --git a/ruoyi-bussiness/src/test/java/com/ruoyi/cms/controller/app/AppInterviewControllerTest.java b/ruoyi-bussiness/src/test/java/com/ruoyi/cms/controller/app/AppInterviewControllerTest.java new file mode 100644 index 0000000..ae328fd --- /dev/null +++ b/ruoyi-bussiness/src/test/java/com/ruoyi/cms/controller/app/AppInterviewControllerTest.java @@ -0,0 +1,73 @@ +package com.ruoyi.cms.controller.app; + +import com.ruoyi.cms.service.InterviewInvitationService; +import com.ruoyi.common.core.domain.AjaxResult; +import com.ruoyi.common.core.domain.entity.AppUser; +import com.ruoyi.common.core.domain.model.LoginSiteUser; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.context.SecurityContextHolder; + +import java.lang.reflect.Field; +import java.util.Collections; + +import static org.junit.jupiter.api.Assertions.assertArrayEquals; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +class AppInterviewControllerTest { + + @AfterEach + void clearSecurityContext() { + SecurityContextHolder.clearContext(); + } + + @Test + void loggedInUserCanDeleteOneOrMoreOwnInvitations() throws Exception { + InterviewInvitationService service = mock(InterviewInvitationService.class); + when(service.deleteInterviewInvitationIdsByUserId(any(Long[].class), eq(12L))).thenReturn(2); + authenticateSiteUser(12L); + + AppInterviewController controller = controllerWith(service); + AjaxResult result = controller.remove(new Long[]{10L, 11L}); + + ArgumentCaptor idsCaptor = ArgumentCaptor.forClass(Long[].class); + verify(service).deleteInterviewInvitationIdsByUserId(idsCaptor.capture(), eq(12L)); + assertArrayEquals(new Long[]{10L, 11L}, idsCaptor.getValue()); + assertEquals(200, result.get(AjaxResult.CODE_TAG)); + } + + @Test + void anonymousUserCannotDeleteInvitations() throws Exception { + InterviewInvitationService service = mock(InterviewInvitationService.class); + AppInterviewController controller = controllerWith(service); + + AjaxResult result = controller.remove(new Long[]{10L}); + + assertEquals(500, result.get(AjaxResult.CODE_TAG)); + assertEquals("未登录", result.get(AjaxResult.MSG_TAG)); + verify(service, never()).deleteInterviewInvitationIdsByUserId(new Long[]{10L}, 12L); + } + + private AppInterviewController controllerWith(InterviewInvitationService service) throws Exception { + AppInterviewController controller = new AppInterviewController(); + Field field = AppInterviewController.class.getDeclaredField("interviewInvitationService"); + field.setAccessible(true); + field.set(controller, service); + return controller; + } + + private void authenticateSiteUser(Long userId) { + LoginSiteUser loginUser = new LoginSiteUser(userId, new AppUser()); + UsernamePasswordAuthenticationToken authentication = new UsernamePasswordAuthenticationToken( + loginUser, null, Collections.emptyList()); + SecurityContextHolder.getContext().setAuthentication(authentication); + } +}