feat: 添加面试邀约删除接口及相关服务实现

This commit is contained in:
2026-07-23 16:41:57 +08:00
parent 397e36144a
commit ba9f32fd02
6 changed files with 354 additions and 0 deletions

View File

@@ -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-serviceIDE 服务端口为 60423。上次测试发现自动化端口 9420 的 TCP 已监听但 WebSocket 未就绪page_stack 健康检查失败。
先执行只读验证status → automator start → page_stack → 当前视口截图。若仍无法连接,不要擅自关闭微信开发者工具;先向我确认是否允许关闭并通过 MCP 重新打开项目。
```

View File

@@ -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 数量进行比较。

View File

@@ -119,4 +119,17 @@ public class AppInterviewController extends BaseController {
} }
return success(interviewInvitationService.updateReadAll(SiteSecurityUtils.getUserId())); 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()));
}
} }

View File

@@ -25,6 +25,15 @@ public interface InterviewInvitationService {
int deleteInterviewInvitationIds(Long[] ids); int deleteInterviewInvitationIds(Long[] ids);
/**
* 删除指定求职者自己的面试邀约(支持批量)。
*
* @param ids 面试邀约 ID 集合
* @param userId 当前登录求职者 ID
* @return 删除记录数
*/
int deleteInterviewInvitationIdsByUserId(Long[] ids, Long userId);
InterviewInvitation getInterviewInvitationById(Long id); InterviewInvitation getInterviewInvitationById(Long id);
InterviewInvitationVO getInterviewInvitationVOById(Long id); InterviewInvitationVO getInterviewInvitationVOById(Long id);

View File

@@ -203,6 +203,16 @@ public class InterviewInvitationServiceImpl implements InterviewInvitationServic
return interviewInvitationMapper.deleteBatchIds(Arrays.asList(ids)); 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.<InterviewInvitation>lambdaQuery()
.in(InterviewInvitation::getId, Arrays.asList(ids))
.eq(InterviewInvitation::getUserId, userId));
}
@Override @Override
public InterviewInvitation getInterviewInvitationById(Long id) { public InterviewInvitation getInterviewInvitationById(Long id) {
return interviewInvitationMapper.selectById(id); return interviewInvitationMapper.selectById(id);

View File

@@ -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<Long[]> 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);
}
}