From 3b6b92b5aa2135df659eb4b3b14d2b9bb811370e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=88=88=E7=BB=8F=E8=8E=B9?= Date: Fri, 31 Jul 2026 22:48:45 +0800 Subject: [PATCH] =?UTF-8?q?=E8=A7=86=E9=A2=91=E9=9D=A2=E8=AF=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/app-interview-rtc-api.md | 241 +++++++++--------- .../app/AppInterviewRtcController.java | 29 +-- .../cms/CmsInterviewRtcController.java | 62 +++++ .../com/ruoyi/cms/service/MyRtcService.java | 60 ++++- 4 files changed, 242 insertions(+), 150 deletions(-) create mode 100644 ruoyi-bussiness/src/main/java/com/ruoyi/cms/controller/cms/CmsInterviewRtcController.java diff --git a/docs/app-interview-rtc-api.md b/docs/app-interview-rtc-api.md index 9a954a5..52b7e87 100644 --- a/docs/app-interview-rtc-api.md +++ b/docs/app-interview-rtc-api.md @@ -1,38 +1,57 @@ -# 面试视频房间接口 +# 面试视频 Token 接口 -## 1. 接口说明 +## 1. 接口用途 -本组接口用于创建一场面试视频通话,并分别获取面试官和候选人的 MyRTC 连接凭据。 +面试官和候选人分别调用同一个 Token 接口,但每次请求只返回当前调用者自己的连接凭据: -后端会在一次请求中完成以下操作: +```text +企业端调用一次 → 面试官 peerId + token +候选人端调用一次 → 候选人 peerId + token +``` -1. 自动生成一个唯一的 `roomId`; -2. 生成面试官专用的 `peerId`; -3. 生成候选人专用的 `peerId`; -4. 使用与 MyRTC 服务一致的 JWT 密钥,分别签发两枚一次性 token; -5. 保证两枚 token 绑定同一个 `roomId`。 +企业端不会拿到候选人的 token,候选人端也不会拿到面试官的 token。 -前端不需要调用房间详情查询和房间销毁接口。房间在 MyRTC 客户端成功执行 `join` 后创建;双方离开后由 MyRTC 自动回收空房间。 +两次请求必须关联到同一条面试邀约记录,后端会为同一个 `interviewId` 复用同一个 `roomId`,并为每次调用生成新的 `peerId` 和一次性 token。 -## 2. 创建房间并获取双方凭据 +## 2. 面试官获取 Token -### 请求 +企业端使用后台用户登录态调用: ```http -POST /app/interview/rtc/token -Authorization: Bearer <业务系统登录Token> +POST /cms/interview/rtc/token?interviewId=123 +Authorization: Bearer <企业端登录Token> Content-Type: application/json ``` -请求体为空即可,不需要传入 `roomId`、`peerId` 或 token。 +请求体为空: ```json {} ``` -### 成功响应 +企业端接口只返回当前面试官的一套凭据。 -接口遵循后端统一的 `AjaxResult` 返回格式: +## 3. 候选人获取 Token + +候选人端使用移动端登录态调用: + +```http +POST /app/interview/rtc/token?interviewId=123 +Authorization: Bearer <候选人登录Token> +Content-Type: application/json +``` + +请求体为空: + +```json +{} +``` + +候选人端接口只返回当前候选人的一套凭据。 + +## 4. 成功响应 + +两个接口的返回格式相同,但每次只对应当前调用者: ```json { @@ -40,17 +59,9 @@ Content-Type: application/json "msg": "操作成功", "data": { "roomId": "room-0a4c3fbd8f9e4b70a0e2de99f6b5f2a8", - "expiresIn": "5m", - "interviewer": { - "role": "interviewer", - "peerId": "interviewer-4a78d8f0c4a5468fb0d8d05a8199b3bb", - "token": "eyJhbGciOiJIUzI1NiJ9..." - }, - "candidate": { - "role": "candidate", - "peerId": "candidate-1d2f7e2e2ae8462da3bb514d2f39b8e3", - "token": "eyJhbGciOiJIUzI1NiJ9..." - } + "peerId": "peer-4a78d8f0c4a5468fb0d8d05a8199b3bb", + "token": "eyJhbGciOiJIUzI1NiJ9...", + "expiresIn": "5m" } } ``` @@ -61,113 +72,126 @@ Content-Type: application/json | --- | --- | --- | | `code` | number | `200` 表示成功 | | `msg` | string | 接口提示信息 | -| `data.roomId` | string | 本次面试房间 ID,双方必须使用同一个值 | +| `data.roomId` | string | 当前面试房间 ID;同一个 `interviewId` 的双方得到相同值 | +| `data.peerId` | string | 当前调用者在房间内的唯一 ID | +| `data.token` | string | 当前调用者的一次性 MyRTC JWT | | `data.expiresIn` | string | token 有效期,当前通常为 `5m` | -| `data.interviewer.role` | string | 固定为 `interviewer` | -| `data.interviewer.peerId` | string | 面试官在该房间内的唯一 ID | -| `data.interviewer.token` | string | 面试官的一次性 MyRTC JWT | -| `data.candidate.role` | string | 固定为 `candidate` | -| `data.candidate.peerId` | string | 候选人在该房间内的唯一 ID | -| `data.candidate.token` | string | 候选人的一次性 MyRTC JWT | -## 3. 前端使用流程 +## 5. 调用流程 -### 3.1 调用后端接口 +假设面试邀约 ID 为 `123`。 + +### 5.1 企业端 ```javascript -const response = await request.post('/app/interview/rtc/token', {}) +const response = await request.post( + '/cms/interview/rtc/token?interviewId=123', + {} +) if (response.code !== 200) { - throw new Error(response.msg || '创建面试视频房间失败') + throw new Error(response.msg || '获取面试官视频凭据失败') } -const session = response.data +const rtc = response.data -const roomId = session.roomId -const interviewer = session.interviewer -const candidate = session.candidate +await rtcClient.connect({ + roomId: rtc.roomId, + peerId: rtc.peerId, + token: rtc.token +}) ``` -注意:`roomId` 在响应的 `data.roomId` 中,不是在响应最外层。 - -### 3.2 面试官加入房间 - -面试官客户端使用: +### 5.2 候选人端 ```javascript -{ - roomId: session.roomId, - peerId: session.interviewer.peerId, - token: session.interviewer.token +const response = await request.post( + '/app/interview/rtc/token?interviewId=123', + {} +) + +if (response.code !== 200) { + throw new Error(response.msg || '获取候选人视频凭据失败') } + +const rtc = response.data + +await rtcClient.connect({ + roomId: rtc.roomId, + peerId: rtc.peerId, + token: rtc.token +}) ``` -### 3.3 候选人加入房间 +两端的 `roomId` 必须相同,但两端的 `peerId` 和 `token` 必须使用各自接口返回的值,不能互换。 -候选人客户端使用: +## 6. MyRTC WebSocket 连接 -```javascript -{ - roomId: session.roomId, - peerId: session.candidate.peerId, - token: session.candidate.token -} -``` - -两端的 `roomId` 必须完全相同,但 `peerId` 和 `token` 必须使用各自对应角色的数据,不能交叉使用。 - -## 4. MyRTC WebSocket 连接参数 - -MyRTC 的 WebSocket 地址由 MyRTC 服务部署地址决定。当前服务的信令路径是 `/signaling`,JWT 通过 WebSocket 握手阶段的 `token` 查询参数传入,例如: +当前 MyRTC 服务的信令地址是: ```text wss://rtc.zhaopinzao8dian.com/signaling?token= ``` -如果使用 `WebSocket` 原生 API,建议使用 `URL` 对 token 做编码,不要直接拼接未编码的 token: +原生 WebSocket 建议使用 `URL` 自动编码 token: ```javascript const wsUrl = new URL('wss://rtc.zhaopinzao8dian.com/signaling') -wsUrl.searchParams.set('token', token) +wsUrl.searchParams.set('token', rtc.token) + const ws = new WebSocket(wsUrl.toString()) ``` -加入房间时,发送的业务消息需要包含 `roomId`、`peerId` 和 token。协议形式如下: +连接成功后发送 `join` 请求: ```json { "type": "request", + "id": 1, "action": "join", - "requestId": "join-001", "payload": { - "roomId": "room-0a4c3fbd8f9e4b70a0e2de99f6b5f2a8", - "peerId": "interviewer-4a78d8f0c4a5468fb0d8d05a8199b3bb" + "roomId": "room-0a4c3fbd8f9e4b70a0e2de99f6b3bb", + "peerId": "peer-4a78d8f0c4a5468fb0d8d05a8199b3bb" } } ``` -token 通常在建立 WebSocket 连接时通过 SDK 配置或连接参数传入,不能把面试官 token 和候选人 token 互换。 +`id` 必须是正整数。当前 MyRTC 服务要求使用 `id`,不是 `requestId`。 -如果使用项目已有 MyRTC SDK,前端只需要将以下三个字段传给 SDK: +## 7. interviewId 的来源 -```javascript -await rtcClient.connect({ - roomId, - peerId, - token -}) +`interviewId` 就是业务表 `interview_invitation` 的主键 ID,不是 MyRTC 临时生成的 ID。 + +候选人端可以从以下接口获得面试邀约 ID: + +```http +GET /app/interview/list +GET /app/interview/{id} ``` -## 5. token 注意事项 +返回数据中的: -- token 是一次性的,成功 `join` 后即会被 MyRTC 消费; -- token 默认有效期为 `5m`,过期后不能继续使用; -- 页面刷新或连接断开后重新连接,不能继续复用已经成功 `join` 的 token,需要重新调用后端接口签发; -- 面试官和候选人的 token 绑定不同的 `peerId`,不能让两个客户端使用同一组凭据; -- `roomId`、`peerId` 和 token 应保存在当前面试页面的内存状态中,不建议写入长期本地存储; -- 前端不需要保存或传递 MyRTC 的 `ADMIN_TOKEN`,该令牌只由业务后端访问 MyRTC 控制面时使用。 +```json +{ + "id": 123 +} +``` -## 6. 错误响应 +其中 `id` 就是获取 RTC Token 时使用的 `interviewId`。 + +企业端也应使用企业端面试列表中的同一条面试邀约记录 ID。 + +## 8. Token 注意事项 + +- token 是一次性的,成功 `join` 后即被 MyRTC 消费; +- token 默认有效期为 `5m`; +- 页面刷新或连接断开后重新连接,需要重新获取 token; +- 每次获取 token 都会生成新的 `peerId`; +- 同一个 `interviewId` 在 Redis 中对应同一个 `roomId`,有效期默认 6 小时; +- 前端不需要调用房间详情查询和房间销毁接口; +- 前端不需要保存或传递 MyRTC 的 `ADMIN_TOKEN`。 + +## 9. 错误响应 ### 未登录 @@ -178,9 +202,16 @@ await rtcClient.connect({ } ``` -### MyRTC JWT 密钥未配置或配置不一致 +### 缺少 interviewId -后端可能返回类似以下错误: +```json +{ + "code": 400, + "msg": "Required request parameter 'interviewId' for method parameter type Long is not present" +} +``` + +### MyRTC JWT 密钥未配置 ```json { @@ -189,30 +220,4 @@ await rtcClient.connect({ } ``` -如果后端签发 token 使用的密钥和 MyRTC 服务配置不一致,前端会在连接或 `join` 阶段收到 token 签名错误。此时需要检查业务后端和 MyRTC 的 `JWT_SECRET` 是否完全一致。 - -### MyRTC 服务不可用 - -```json -{ - "code": 500, - "msg": "无法连接 MyRTC" -} -``` - -## 7. 房间查询和销毁接口 - -这两个接口不是前端视频通话流程的一部分,前端无需调用: - -```text -GET /app/interview/rtc/room/{roomId} -DELETE /app/interview/rtc/room/{roomId} -``` - -它们仅供后端管理或运维场景使用。正常情况下,前端只需要: - -```text -POST /app/interview/rtc/token -``` - -然后使用响应中的双方凭据加入同一个 `roomId` 即可。 +如果后端签发 token 使用的密钥和 MyRTC 服务配置不一致,WebSocket 握手会失败,需要检查业务后端和 MyRTC 的 `JWT_SECRET` 是否完全一致。 diff --git a/ruoyi-bussiness/src/main/java/com/ruoyi/cms/controller/app/AppInterviewRtcController.java b/ruoyi-bussiness/src/main/java/com/ruoyi/cms/controller/app/AppInterviewRtcController.java index 70e9033..6ac0e9c 100644 --- a/ruoyi-bussiness/src/main/java/com/ruoyi/cms/controller/app/AppInterviewRtcController.java +++ b/ruoyi-bussiness/src/main/java/com/ruoyi/cms/controller/app/AppInterviewRtcController.java @@ -1,26 +1,20 @@ package com.ruoyi.cms.controller.app; import com.fasterxml.jackson.databind.JsonNode; -import com.ruoyi.cms.domain.vo.InterviewRtcSessionVO; +import com.ruoyi.cms.domain.vo.InterviewRtcTokenVO; import com.ruoyi.cms.service.MyRtcService; import com.ruoyi.common.core.controller.BaseController; import com.ruoyi.common.core.domain.AjaxResult; -import com.ruoyi.common.utils.SecurityUtils; import com.ruoyi.common.utils.SiteSecurityUtils; import io.swagger.annotations.Api; import io.swagger.annotations.ApiOperation; -import org.springframework.web.bind.annotation.DeleteMapping; -import org.springframework.web.bind.annotation.GetMapping; -import org.springframework.web.bind.annotation.PathVariable; -import org.springframework.web.bind.annotation.PostMapping; -import org.springframework.web.bind.annotation.RequestMapping; -import org.springframework.web.bind.annotation.RestController; +import org.springframework.web.bind.annotation.*; /** 面试视频房间接口。 */ @RestController @RequestMapping("/app/interview/rtc") @Api(tags = "移动端:面试视频房间") -public class AppInterviewRtcController extends BaseController { +public class AppInterviewRtcController extends BaseController { private final MyRtcService myRtcService; @@ -34,19 +28,19 @@ public class AppInterviewRtcController extends BaseController { */ @ApiOperation("创建面试视频房间并签发双方 token") @PostMapping("/token") - public AjaxResult issueToken() { - if (!isAuthenticated()) { + public AjaxResult issueToken(@RequestParam Long interviewId) { + if (!SiteSecurityUtils.isLogin()) { return error("未登录"); } - InterviewRtcSessionVO session = myRtcService.issueInterviewSession(); - return AjaxResult.success(session); + InterviewRtcTokenVO token = myRtcService.issueToken(interviewId); + return AjaxResult.success(token); } /** 查询房间详情。 */ @ApiOperation("查询面试视频房间") @GetMapping({"/room/{roomId}", "/rooms/{roomId}"}) public AjaxResult getRoom(@PathVariable String roomId) { - if (!isAuthenticated()) { + if (!SiteSecurityUtils.isLogin()) { return error("未登录"); } JsonNode room = myRtcService.getRoom(roomId); @@ -57,15 +51,12 @@ public class AppInterviewRtcController extends BaseController { @ApiOperation("销毁面试视频房间") @DeleteMapping({"/room/{roomId}", "/rooms/{roomId}"}) public AjaxResult destroyRoom(@PathVariable String roomId) { - if (!isAuthenticated()) { + if (!SiteSecurityUtils.isLogin()) { return error("未登录"); } JsonNode result = myRtcService.destroyRoom(roomId); return AjaxResult.success(result); } - private boolean isAuthenticated() { - return Boolean.TRUE.equals(SiteSecurityUtils.isLogin()) - || Boolean.TRUE.equals(SecurityUtils.isLogin()); - } } + diff --git a/ruoyi-bussiness/src/main/java/com/ruoyi/cms/controller/cms/CmsInterviewRtcController.java b/ruoyi-bussiness/src/main/java/com/ruoyi/cms/controller/cms/CmsInterviewRtcController.java new file mode 100644 index 0000000..fe9d659 --- /dev/null +++ b/ruoyi-bussiness/src/main/java/com/ruoyi/cms/controller/cms/CmsInterviewRtcController.java @@ -0,0 +1,62 @@ +package com.ruoyi.cms.controller.cms; + +import com.fasterxml.jackson.databind.JsonNode; +import com.ruoyi.cms.domain.vo.InterviewRtcTokenVO; +import com.ruoyi.cms.service.MyRtcService; +import com.ruoyi.common.core.controller.BaseController; +import com.ruoyi.common.core.domain.AjaxResult; +import com.ruoyi.common.utils.SecurityUtils; +import io.swagger.annotations.Api; +import io.swagger.annotations.ApiOperation; +import org.springframework.web.bind.annotation.*; + +/** 面试视频房间接口。 */ +@RestController +@RequestMapping("/cms/interview/rtc") +@Api(tags = "后台:面试视频房间") +public class CmsInterviewRtcController extends BaseController { + + private final MyRtcService myRtcService; + + public CmsInterviewRtcController(MyRtcService myRtcService) { + this.myRtcService = myRtcService; + } + + /** + * 创建面试房间并同时签发面试官、候选人的 token。 + * roomId 和两个 peerId 均由后端生成,前端不传入也不能覆盖。 + */ + @ApiOperation("创建面试视频房间并签发双方 token") + @PostMapping("/token") + public AjaxResult issueToken(@RequestParam Long interviewId) { + if (!SecurityUtils.isLogin()) { + return error("未登录"); + } + InterviewRtcTokenVO token = myRtcService.issueToken(interviewId); + return AjaxResult.success(token); + } + + /** 查询房间详情。 */ + @ApiOperation("查询面试视频房间") + @GetMapping({"/room/{roomId}", "/rooms/{roomId}"}) + public AjaxResult getRoom(@PathVariable String roomId) { + if (!SecurityUtils.isLogin()) { + return error("未登录"); + } + JsonNode room = myRtcService.getRoom(roomId); + return AjaxResult.success(room); + } + + /** 销毁房间并踢出所有成员。 */ + @ApiOperation("销毁面试视频房间") + @DeleteMapping({"/room/{roomId}", "/rooms/{roomId}"}) + public AjaxResult destroyRoom(@PathVariable String roomId) { + if (!SecurityUtils.isLogin()) { + return error("未登录"); + } + JsonNode result = myRtcService.destroyRoom(roomId); + return AjaxResult.success(result); + } + +} + diff --git a/ruoyi-bussiness/src/main/java/com/ruoyi/cms/service/MyRtcService.java b/ruoyi-bussiness/src/main/java/com/ruoyi/cms/service/MyRtcService.java index 2cb3a46..993443b 100644 --- a/ruoyi-bussiness/src/main/java/com/ruoyi/cms/service/MyRtcService.java +++ b/ruoyi-bussiness/src/main/java/com/ruoyi/cms/service/MyRtcService.java @@ -2,9 +2,9 @@ package com.ruoyi.cms.service; import com.fasterxml.jackson.databind.JsonNode; import com.ruoyi.cms.config.MyRtcProperties; -import com.ruoyi.cms.domain.vo.InterviewRtcSessionVO; -import com.ruoyi.cms.domain.vo.RtcPeerTokenVO; +import com.ruoyi.cms.domain.vo.InterviewRtcTokenVO; import com.ruoyi.common.constant.HttpStatus; +import com.ruoyi.common.core.redis.RedisCache; import com.ruoyi.common.exception.ServiceException; import io.jsonwebtoken.Jwts; import io.jsonwebtoken.SignatureAlgorithm; @@ -20,8 +20,10 @@ import org.springframework.web.client.HttpStatusCodeException; import org.springframework.web.client.ResourceAccessException; import org.springframework.web.client.RestTemplate; +import javax.crypto.spec.SecretKeySpec; import java.nio.charset.StandardCharsets; import java.util.Date; +import java.util.concurrent.TimeUnit; import java.util.UUID; import java.util.regex.Pattern; @@ -36,11 +38,15 @@ import java.util.regex.Pattern; public class MyRtcService { private static final Pattern IDENTIFIER_PATTERN = Pattern.compile("^[A-Za-z0-9_-]{1,64}$"); + private static final String ROOM_CACHE_KEY_PREFIX = "interview:rtc:room:"; + private static final int ROOM_CACHE_TTL_HOURS = 6; private final MyRtcProperties properties; + private final RedisCache redisCache; private final RestTemplate restTemplate; - public MyRtcService(MyRtcProperties properties) { + public MyRtcService(MyRtcProperties properties, RedisCache redisCache) { this.properties = properties; + this.redisCache = redisCache; SimpleClientHttpRequestFactory requestFactory = new SimpleClientHttpRequestFactory(); requestFactory.setConnectTimeout(Math.max(1000, properties.getConnectTimeoutMillis())); requestFactory.setReadTimeout(Math.max(1000, properties.getReadTimeoutMillis())); @@ -48,22 +54,44 @@ public class MyRtcService { } /** 创建一个房间并为面试双方各签发一枚一次性 token。 */ - public InterviewRtcSessionVO issueInterviewSession() { + public InterviewRtcTokenVO issueToken(Long interviewId) { ensureEnabled(); - String roomId = "room-" + UUID.randomUUID().toString().replace("-", ""); - String interviewerPeerId = "interviewer-" + UUID.randomUUID().toString().replace("-", ""); - String candidatePeerId = "candidate-" + UUID.randomUUID().toString().replace("-", ""); + if (interviewId == null || interviewId <= 0) { + throw new ServiceException("interviewId 格式不正确", HttpStatus.BAD_REQUEST); + } - InterviewRtcSessionVO result = new InterviewRtcSessionVO(); + String roomId = getOrCreateRoomId(interviewId); + String peerId = "peer-" + UUID.randomUUID().toString().replace("-", ""); + InterviewRtcTokenVO result = new InterviewRtcTokenVO(); result.setRoomId(roomId); + result.setPeerId(peerId); + result.setToken(signRoomToken(roomId, peerId)); result.setExpiresIn(properties.getJwtExpiry()); - result.setInterviewer(new RtcPeerTokenVO( - "interviewer", interviewerPeerId, signRoomToken(roomId, interviewerPeerId))); - result.setCandidate(new RtcPeerTokenVO( - "candidate", candidatePeerId, signRoomToken(roomId, candidatePeerId))); return result; } + private String getOrCreateRoomId(Long interviewId) { + String cacheKey = ROOM_CACHE_KEY_PREFIX + interviewId; + String roomId = redisCache.getCacheObject(cacheKey); + if (StringUtils.isNotBlank(roomId)) { + return roomId; + } + + String generatedRoomId = "room-" + UUID.randomUUID().toString().replace("-", ""); + // 使用 Redis 原子写,避免企业端和候选人并发首次请求时生成两个房间。 + Boolean created = redisCache.redisTemplate.opsForValue().setIfAbsent( + cacheKey, generatedRoomId, ROOM_CACHE_TTL_HOURS, TimeUnit.HOURS); + if (Boolean.TRUE.equals(created)) { + return generatedRoomId; + } + + roomId = redisCache.getCacheObject(cacheKey); + if (StringUtils.isBlank(roomId)) { + throw new ServiceException("创建面试视频房间失败", HttpStatus.ERROR); + } + return roomId; + } + /** 查询 MyRTC 房间详情。 */ public JsonNode getRoom(String roomId) { validateRoomId(roomId); @@ -115,13 +143,19 @@ public class MyRtcService { Date issuedAt = new Date(); Date expiresAt = new Date(issuedAt.getTime() + parseExpiryMillis(properties.getJwtExpiry())); + // JJWT 0.9.x 的 signWith(algorithm, String) 会把 String 当作 Base64 + // 密钥处理;MyRTC Node 服务使用 JWT_SECRET 的原始 UTF-8 字节签名, + // 因此这里必须显式构造 SecretKey,保证两端签名算法完全一致。 + SecretKeySpec signingKey = new SecretKeySpec( + properties.getJwtSecret().getBytes(StandardCharsets.UTF_8), + SignatureAlgorithm.HS256.getJcaName()); return Jwts.builder() .claim("roomId", roomId) .claim("peerId", peerId) .claim("jti", roomId + ":" + peerId + ":" + UUID.randomUUID()) .setIssuedAt(issuedAt) .setExpiration(expiresAt) - .signWith(SignatureAlgorithm.HS256, properties.getJwtSecret()) + .signWith(SignatureAlgorithm.HS256, signingKey) .compact(); }