视频面试

This commit is contained in:
戈经莹
2026-07-31 22:48:45 +08:00
parent 6c176aeb4a
commit 3b6b92b5aa
4 changed files with 242 additions and 150 deletions

View File

@@ -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());
}
}

View File

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

View File

@@ -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();
}