智联招聘-在线面试-创建房间返回token
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user