From 764996f0a930fcf33a9ddd15acbf32c62d86f9fe 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 11:42:44 +0800 Subject: [PATCH] =?UTF-8?q?=E6=99=BA=E8=81=94=E6=8B=9B=E8=81=98-=E5=9C=A8?= =?UTF-8?q?=E7=BA=BF=E9=9D=A2=E8=AF=95-=E5=88=9B=E5=BB=BA=E6=88=BF?= =?UTF-8?q?=E9=97=B4=E8=BF=94=E5=9B=9Etoken?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../src/main/resources/application-local.yml | 13 ++ .../src/main/resources/application-test.yml | 13 ++ .../com/ruoyi/cms/config/MyRtcProperties.java | 79 ++++++++ .../app/AppInterviewRtcController.java | 71 ++++++++ .../cms/domain/vo/InterviewRtcSessionVO.java | 21 +++ .../ruoyi/cms/domain/vo/RtcPeerTokenVO.java | 20 ++ .../com/ruoyi/cms/service/MyRtcService.java | 171 ++++++++++++++++++ 7 files changed, 388 insertions(+) create mode 100644 ruoyi-bussiness/src/main/java/com/ruoyi/cms/config/MyRtcProperties.java create mode 100644 ruoyi-bussiness/src/main/java/com/ruoyi/cms/controller/app/AppInterviewRtcController.java create mode 100644 ruoyi-bussiness/src/main/java/com/ruoyi/cms/domain/vo/InterviewRtcSessionVO.java create mode 100644 ruoyi-bussiness/src/main/java/com/ruoyi/cms/domain/vo/RtcPeerTokenVO.java create mode 100644 ruoyi-bussiness/src/main/java/com/ruoyi/cms/service/MyRtcService.java diff --git a/ruoyi-admin/src/main/resources/application-local.yml b/ruoyi-admin/src/main/resources/application-local.yml index 6fa3ae7..80c3311 100644 --- a/ruoyi-admin/src/main/resources/application-local.yml +++ b/ruoyi-admin/src/main/resources/application-local.yml @@ -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 diff --git a/ruoyi-admin/src/main/resources/application-test.yml b/ruoyi-admin/src/main/resources/application-test.yml index 7f91f4f..3178887 100644 --- a/ruoyi-admin/src/main/resources/application-test.yml +++ b/ruoyi-admin/src/main/resources/application-test.yml @@ -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 \ No newline at end of file diff --git a/ruoyi-bussiness/src/main/java/com/ruoyi/cms/config/MyRtcProperties.java b/ruoyi-bussiness/src/main/java/com/ruoyi/cms/config/MyRtcProperties.java new file mode 100644 index 0000000..daef3bb --- /dev/null +++ b/ruoyi-bussiness/src/main/java/com/ruoyi/cms/config/MyRtcProperties.java @@ -0,0 +1,79 @@ +package com.ruoyi.cms.config; + +import org.springframework.boot.context.properties.ConfigurationProperties; +import org.springframework.stereotype.Component; + +/** + * MyRTC 服务配置。 + * + *

JWT_SECRET 必须与 MyRTC 容器中的 JWT_SECRET 完全一致;ADMIN_TOKEN + * 仅用于后端调用 MyRTC 的控制面接口,不能返回给浏览器。

+ */ +@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; + } +} 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 new file mode 100644 index 0000000..70e9033 --- /dev/null +++ b/ruoyi-bussiness/src/main/java/com/ruoyi/cms/controller/app/AppInterviewRtcController.java @@ -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()); + } +} diff --git a/ruoyi-bussiness/src/main/java/com/ruoyi/cms/domain/vo/InterviewRtcSessionVO.java b/ruoyi-bussiness/src/main/java/com/ruoyi/cms/domain/vo/InterviewRtcSessionVO.java new file mode 100644 index 0000000..8868929 --- /dev/null +++ b/ruoyi-bussiness/src/main/java/com/ruoyi/cms/domain/vo/InterviewRtcSessionVO.java @@ -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; +} diff --git a/ruoyi-bussiness/src/main/java/com/ruoyi/cms/domain/vo/RtcPeerTokenVO.java b/ruoyi-bussiness/src/main/java/com/ruoyi/cms/domain/vo/RtcPeerTokenVO.java new file mode 100644 index 0000000..b940a70 --- /dev/null +++ b/ruoyi-bussiness/src/main/java/com/ruoyi/cms/domain/vo/RtcPeerTokenVO.java @@ -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; +} 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 new file mode 100644 index 0000000..2cb3a46 --- /dev/null +++ b/ruoyi-bussiness/src/main/java/com/ruoyi/cms/service/MyRtcService.java @@ -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 业务适配层。 + * + *

MyRTC 的生产环境会关闭 /api/dev/token,因此业务后端直接按照 + * MyRTC 的 JWT claim 结构签发 token。房间本身在客户端 join 成功后由 + * MyRTC 创建;本服务负责在签发凭据时生成并绑定 roomId。

+ */ +@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 request = new HttpEntity<>(headers); + String url = trimTrailingSlash(properties.getBaseUrl()) + "/api/rooms/" + roomId; + try { + ResponseEntity 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; + } +}