智联招聘-在线面试-创建房间返回token

This commit is contained in:
戈经莹
2026-07-31 11:42:44 +08:00
parent 408724a14a
commit 764996f0a9
7 changed files with 388 additions and 0 deletions

View File

@@ -156,3 +156,16 @@ lc_cms_auth:
getUserInfoUrl: http://218.31.252.15:9081/prod-api/system/app/authorize/user/info
resrtPwdUrl: http://218.31.252.15:9081/prod-api/ps/user/resetPwd/zkr
changeStatusUrl: http://218.31.252.15:9081/prod-api/ps/user/change/status/zkr
# MyRTC 面试视频服务。敏感值通过部署环境变量注入,不要提交真实密钥。
# JWT_SECRET 必须与 /opt/docker-app/myrtc/.env 中的 JWT_SECRET 一致;
# ADMIN_TOKEN 仅由后端调用 MyRTC 控制面时使用。
myrtc:
enabled: true
base-url: https://rtc.zhaopinzao8dian.com
admin-token: fa84d58b5778507a3acf47925053409335a37643a286e38d216889a8dcb720e5
jwt-secret: b0f4e29d30dbfa4be0d5c71bf32709522b72a46ef8da622420cfe78b483e3c03
jwt-expiry: 5m
connect-timeout-millis: 5000
read-timeout-millis: 10000

View File

@@ -156,3 +156,16 @@ backdoor:
max-users: 100
portal-base-path: /shihezi
password: zkr2024
# MyRTC 面试视频服务。敏感值通过部署环境变量注入,不要提交真实密钥。
# JWT_SECRET 必须与 /opt/docker-app/myrtc/.env 中的 JWT_SECRET 一致;
# ADMIN_TOKEN 仅由后端调用 MyRTC 控制面时使用。
myrtc:
enabled: true
base-url: https://rtc.zhaopinzao8dian.com
admin-token: fa84d58b5778507a3acf47925053409335a37643a286e38d216889a8dcb720e5
jwt-secret: b0f4e29d30dbfa4be0d5c71bf32709522b72a46ef8da622420cfe78b483e3c03
jwt-expiry: 5m
connect-timeout-millis: 5000
read-timeout-millis: 10000

View File

@@ -0,0 +1,79 @@
package com.ruoyi.cms.config;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.stereotype.Component;
/**
* MyRTC 服务配置。
*
* <p>JWT_SECRET 必须与 MyRTC 容器中的 JWT_SECRET 完全一致ADMIN_TOKEN
* 仅用于后端调用 MyRTC 的控制面接口,不能返回给浏览器。</p>
*/
@Component
@ConfigurationProperties(prefix = "myrtc")
public class MyRtcProperties {
private boolean enabled = true;
private String baseUrl;
private String adminToken;
private String jwtSecret;
private String jwtExpiry = "5m";
private int connectTimeoutMillis = 5000;
private int readTimeoutMillis = 10000;
public boolean isEnabled() {
return enabled;
}
public void setEnabled(boolean enabled) {
this.enabled = enabled;
}
public String getBaseUrl() {
return baseUrl;
}
public void setBaseUrl(String baseUrl) {
this.baseUrl = baseUrl;
}
public String getAdminToken() {
return adminToken;
}
public void setAdminToken(String adminToken) {
this.adminToken = adminToken;
}
public String getJwtSecret() {
return jwtSecret;
}
public void setJwtSecret(String jwtSecret) {
this.jwtSecret = jwtSecret;
}
public String getJwtExpiry() {
return jwtExpiry;
}
public void setJwtExpiry(String jwtExpiry) {
this.jwtExpiry = jwtExpiry;
}
public int getConnectTimeoutMillis() {
return connectTimeoutMillis;
}
public void setConnectTimeoutMillis(int connectTimeoutMillis) {
this.connectTimeoutMillis = connectTimeoutMillis;
}
public int getReadTimeoutMillis() {
return readTimeoutMillis;
}
public void setReadTimeoutMillis(int readTimeoutMillis) {
this.readTimeoutMillis = readTimeoutMillis;
}
}

View File

@@ -0,0 +1,71 @@
package com.ruoyi.cms.controller.app;
import com.fasterxml.jackson.databind.JsonNode;
import com.ruoyi.cms.domain.vo.InterviewRtcSessionVO;
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;
/** 面试视频房间接口。 */
@RestController
@RequestMapping("/app/interview/rtc")
@Api(tags = "移动端:面试视频房间")
public class AppInterviewRtcController extends BaseController {
private final MyRtcService myRtcService;
public AppInterviewRtcController(MyRtcService myRtcService) {
this.myRtcService = myRtcService;
}
/**
* 创建面试房间并同时签发面试官、候选人的 token。
* roomId 和两个 peerId 均由后端生成,前端不传入也不能覆盖。
*/
@ApiOperation("创建面试视频房间并签发双方 token")
@PostMapping("/token")
public AjaxResult issueToken() {
if (!isAuthenticated()) {
return error("未登录");
}
InterviewRtcSessionVO session = myRtcService.issueInterviewSession();
return AjaxResult.success(session);
}
/** 查询房间详情。 */
@ApiOperation("查询面试视频房间")
@GetMapping({"/room/{roomId}", "/rooms/{roomId}"})
public AjaxResult getRoom(@PathVariable String roomId) {
if (!isAuthenticated()) {
return error("未登录");
}
JsonNode room = myRtcService.getRoom(roomId);
return AjaxResult.success(room);
}
/** 销毁房间并踢出所有成员。 */
@ApiOperation("销毁面试视频房间")
@DeleteMapping({"/room/{roomId}", "/rooms/{roomId}"})
public AjaxResult destroyRoom(@PathVariable String roomId) {
if (!isAuthenticated()) {
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,21 @@
package com.ruoyi.cms.domain.vo;
import io.swagger.annotations.ApiModelProperty;
import lombok.Data;
/** 一场面试的 MyRTC 房间及双方连接凭据。 */
@Data
public class InterviewRtcSessionVO {
@ApiModelProperty("MyRTC 房间 ID")
private String roomId;
@ApiModelProperty("JWT 有效期,例如 5m")
private String expiresIn;
@ApiModelProperty("面试官连接凭据")
private RtcPeerTokenVO interviewer;
@ApiModelProperty("候选人连接凭据")
private RtcPeerTokenVO candidate;
}

View File

@@ -0,0 +1,20 @@
package com.ruoyi.cms.domain.vo;
import io.swagger.annotations.ApiModelProperty;
import lombok.AllArgsConstructor;
import lombok.Data;
/** MyRTC 单个参与者的连接凭据。 */
@Data
@AllArgsConstructor
public class RtcPeerTokenVO {
@ApiModelProperty("参与者角色interviewer/candidate")
private String role;
@ApiModelProperty("房间内唯一 peerId")
private String peerId;
@ApiModelProperty("一次性 WebSocket JWT")
private String token;
}

View File

@@ -0,0 +1,171 @@
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.common.constant.HttpStatus;
import com.ruoyi.common.exception.ServiceException;
import io.jsonwebtoken.Jwts;
import io.jsonwebtoken.SignatureAlgorithm;
import org.apache.commons.lang3.StringUtils;
import org.springframework.http.HttpEntity;
import org.springframework.http.HttpHeaders;
import org.springframework.http.HttpMethod;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.http.client.SimpleClientHttpRequestFactory;
import org.springframework.stereotype.Service;
import org.springframework.web.client.HttpStatusCodeException;
import org.springframework.web.client.ResourceAccessException;
import org.springframework.web.client.RestTemplate;
import java.nio.charset.StandardCharsets;
import java.util.Date;
import java.util.UUID;
import java.util.regex.Pattern;
/**
* MyRTC 业务适配层。
*
* <p>MyRTC 的生产环境会关闭 /api/dev/token因此业务后端直接按照
* MyRTC 的 JWT claim 结构签发 token。房间本身在客户端 join 成功后由
* MyRTC 创建;本服务负责在签发凭据时生成并绑定 roomId。</p>
*/
@Service
public class MyRtcService {
private static final Pattern IDENTIFIER_PATTERN = Pattern.compile("^[A-Za-z0-9_-]{1,64}$");
private final MyRtcProperties properties;
private final RestTemplate restTemplate;
public MyRtcService(MyRtcProperties properties) {
this.properties = properties;
SimpleClientHttpRequestFactory requestFactory = new SimpleClientHttpRequestFactory();
requestFactory.setConnectTimeout(Math.max(1000, properties.getConnectTimeoutMillis()));
requestFactory.setReadTimeout(Math.max(1000, properties.getReadTimeoutMillis()));
this.restTemplate = new RestTemplate(requestFactory);
}
/** 创建一个房间并为面试双方各签发一枚一次性 token。 */
public InterviewRtcSessionVO issueInterviewSession() {
ensureEnabled();
String roomId = "room-" + UUID.randomUUID().toString().replace("-", "");
String interviewerPeerId = "interviewer-" + UUID.randomUUID().toString().replace("-", "");
String candidatePeerId = "candidate-" + UUID.randomUUID().toString().replace("-", "");
InterviewRtcSessionVO result = new InterviewRtcSessionVO();
result.setRoomId(roomId);
result.setExpiresIn(properties.getJwtExpiry());
result.setInterviewer(new RtcPeerTokenVO(
"interviewer", interviewerPeerId, signRoomToken(roomId, interviewerPeerId)));
result.setCandidate(new RtcPeerTokenVO(
"candidate", candidatePeerId, signRoomToken(roomId, candidatePeerId)));
return result;
}
/** 查询 MyRTC 房间详情。 */
public JsonNode getRoom(String roomId) {
validateRoomId(roomId);
return exchangeRoom(roomId, HttpMethod.GET, "查询房间失败");
}
/** 强制销毁 MyRTC 房间并踢出所有成员。 */
public JsonNode destroyRoom(String roomId) {
validateRoomId(roomId);
return exchangeRoom(roomId, HttpMethod.DELETE, "销毁房间失败");
}
private JsonNode exchangeRoom(String roomId, HttpMethod method, String operation) {
ensureEnabled();
if (StringUtils.isBlank(properties.getBaseUrl())) {
throw new ServiceException("MyRTC base URL 未配置", HttpStatus.ERROR);
}
if (StringUtils.isBlank(properties.getAdminToken())) {
throw new ServiceException("MyRTC ADMIN_TOKEN 未配置", HttpStatus.ERROR);
}
HttpHeaders headers = new HttpHeaders();
headers.setBearerAuth(properties.getAdminToken());
headers.setAccept(java.util.Collections.singletonList(MediaType.APPLICATION_JSON));
HttpEntity<Void> request = new HttpEntity<>(headers);
String url = trimTrailingSlash(properties.getBaseUrl()) + "/api/rooms/" + roomId;
try {
ResponseEntity<JsonNode> response = restTemplate.exchange(
url, method, request, JsonNode.class);
return response.getBody();
} catch (HttpStatusCodeException e) {
int status = e.getRawStatusCode();
int mappedStatus = status >= 400 && status < 600 ? status : HttpStatus.ERROR;
throw new ServiceException(operation + "MyRTC 返回 HTTP " + status, mappedStatus)
.setDetailMessage(e.getResponseBodyAsString());
} catch (ResourceAccessException e) {
throw new ServiceException(operation + ":无法连接 MyRTC", HttpStatus.ERROR)
.setDetailMessage(e.getMessage());
}
}
private String signRoomToken(String roomId, String peerId) {
if (StringUtils.isBlank(properties.getJwtSecret())) {
throw new ServiceException("MyRTC JWT_SECRET 未配置", HttpStatus.ERROR);
}
if (properties.getJwtSecret().getBytes(StandardCharsets.UTF_8).length < 32) {
throw new ServiceException("MyRTC JWT_SECRET 长度不能少于 32 个字符", HttpStatus.ERROR);
}
Date issuedAt = new Date();
Date expiresAt = new Date(issuedAt.getTime() + parseExpiryMillis(properties.getJwtExpiry()));
return Jwts.builder()
.claim("roomId", roomId)
.claim("peerId", peerId)
.claim("jti", roomId + ":" + peerId + ":" + UUID.randomUUID())
.setIssuedAt(issuedAt)
.setExpiration(expiresAt)
.signWith(SignatureAlgorithm.HS256, properties.getJwtSecret())
.compact();
}
private long parseExpiryMillis(String expiry) {
if (StringUtils.isBlank(expiry)) {
throw new ServiceException("MyRTC JWT_EXPIRY 未配置", HttpStatus.ERROR);
}
String value = expiry.trim().toLowerCase();
try {
if (value.matches("\\d+")) {
return Math.multiplyExact(Long.parseLong(value), 1000L);
}
if (value.matches("\\d+s")) {
return Math.multiplyExact(Long.parseLong(value.substring(0, value.length() - 1)), 1000L);
}
if (value.matches("\\d+m")) {
return Math.multiplyExact(Long.parseLong(value.substring(0, value.length() - 1)), 60_000L);
}
if (value.matches("\\d+h")) {
return Math.multiplyExact(Long.parseLong(value.substring(0, value.length() - 1)), 3_600_000L);
}
} catch (ArithmeticException | NumberFormatException e) {
throw new ServiceException("MyRTC JWT_EXPIRY 超出允许范围", HttpStatus.ERROR);
}
throw new ServiceException("MyRTC JWT_EXPIRY 格式不支持仅支持数字、s、m 或 h", HttpStatus.ERROR);
}
private void validateRoomId(String roomId) {
if (StringUtils.isBlank(roomId) || !IDENTIFIER_PATTERN.matcher(roomId).matches()) {
throw new ServiceException("roomId 格式不正确", HttpStatus.BAD_REQUEST);
}
}
private void ensureEnabled() {
if (!properties.isEnabled()) {
throw new ServiceException("MyRTC 功能未启用", HttpStatus.NOT_IMPLEMENTED);
}
}
private String trimTrailingSlash(String value) {
String result = value.trim();
while (result.endsWith("/")) {
result = result.substring(0, result.length() - 1);
}
return result;
}
}