视频面试
This commit is contained in:
@@ -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());
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user