diff --git a/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/controller/AihrPracticeController.java b/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/controller/AihrPracticeController.java index 260715c8..f17d0061 100644 --- a/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/controller/AihrPracticeController.java +++ b/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/controller/AihrPracticeController.java @@ -12,6 +12,8 @@ import org.dromara.aihr.domain.AihrPracticeDto.CalibrationResponse; import org.dromara.aihr.domain.AihrPracticeDto.FinishRequest; import org.dromara.aihr.domain.AihrPracticeDto.FinishResponse; import org.dromara.aihr.domain.AihrPracticeDto.PrepCardResponse; +import org.dromara.aihr.domain.AihrPracticeDto.RealtimeSdpRequest; +import org.dromara.aihr.domain.AihrPracticeDto.RealtimeSdpResponse; import org.dromara.aihr.domain.AihrPracticeDto.RubricResponse; import org.dromara.aihr.domain.AihrPracticeDto.RubricAdminResponse; import org.dromara.aihr.domain.AihrPracticeDto.RubricEnabledRequest; @@ -24,6 +26,7 @@ import org.dromara.aihr.domain.AihrPracticeDto.StartResponse; import org.dromara.aihr.domain.AihrPracticeDto.TurnRequest; import org.dromara.aihr.domain.AihrPracticeDto.TurnResponse; import org.dromara.aihr.service.AihrPracticeSeedService; +import org.dromara.aihr.service.AihrRealtimePracticeService; import org.dromara.common.core.constant.TenantConstants; import org.dromara.common.core.domain.R; import org.dromara.common.core.domain.model.LoginUser; @@ -57,6 +60,7 @@ public class AihrPracticeController { private static final String HR_OPERATOR_ROLE = "hr_operator"; private final AihrPracticeSeedService practiceSeedService; + private final AihrRealtimePracticeService realtimePracticeService; @GetMapping("/scenarios") public R> scenarios(@RequestParam(required = false) String position, @@ -129,6 +133,18 @@ public class AihrPracticeController { return R.ok(practiceSeedService.start(bindMobileStartIdentity(request))); } + /** + * Only proxies the WebRTC SDP handshake. Media flows directly between the H5 client and Model Studio. + */ + @PostMapping("/realtime/sdp") + public R realtimeSdp(@RequestBody RealtimeSdpRequest request) { + String username = currentAppUsername(); + if (username.isBlank()) { + return R.fail("请使用员工端账号开启实时陪练"); + } + return R.ok(realtimePracticeService.exchangeSdp(request, username)); + } + @PostMapping("/turn") public R turn(@RequestBody TurnRequest request) { return R.ok(practiceSeedService.turn(request, currentAppUsername(), currentAppUserId())); diff --git a/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/domain/AihrPracticeDto.java b/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/domain/AihrPracticeDto.java index d5274789..3436d36e 100644 --- a/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/domain/AihrPracticeDto.java +++ b/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/domain/AihrPracticeDto.java @@ -107,6 +107,12 @@ public final class AihrPracticeDto { public record PrepCardResponse(List keyPoints, List redLines, List scripts, String promptVersion) { } + public record RealtimeSdpRequest(String offerSdp) { + } + + public record RealtimeSdpResponse(String answerSdp, String model) { + } + public record TurnRequest(String sessionId, Integer roundIndex, String traineeText, String traineeAudioUrl, Long traineeAudioOssId, Boolean regenerate, String style) { } diff --git a/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/service/AihrRealtimePracticeService.java b/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/service/AihrRealtimePracticeService.java new file mode 100644 index 00000000..66f16e78 --- /dev/null +++ b/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/service/AihrRealtimePracticeService.java @@ -0,0 +1,134 @@ +package org.dromara.aihr.service; + +import lombok.extern.slf4j.Slf4j; +import org.dromara.aihr.domain.AihrPracticeDto.RealtimeSdpRequest; +import org.dromara.aihr.domain.AihrPracticeDto.RealtimeSdpResponse; +import org.dromara.common.core.exception.ServiceException; +import org.dromara.common.redis.utils.RedisUtils; +import org.dromara.common.satoken.utils.LoginHelper; +import org.redisson.api.RateType; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Service; + +import java.io.IOException; +import java.net.URI; +import java.net.URLEncoder; +import java.net.http.HttpClient; +import java.net.http.HttpRequest; +import java.net.http.HttpResponse; +import java.nio.charset.StandardCharsets; +import java.time.Duration; +import java.util.Locale; + +/** + * Minimal WebRTC signaling boundary for the H5 realtime-practice beta. + * Audio is deliberately not proxied or persisted here. + */ +@Service +@Slf4j +public class AihrRealtimePracticeService { + + private static final int MAX_SDP_CHARS = 64 * 1024; + private static final HttpClient HTTP_CLIENT = HttpClient.newBuilder() + .connectTimeout(Duration.ofSeconds(10)) + .build(); + + @Value("${aihr.realtime-practice.enabled:${AIHR_REALTIME_PRACTICE_ENABLED:false}}") + private boolean enabled; + + @Value("${aihr.realtime-practice.endpoint:${AIHR_QWEN_REALTIME_ENDPOINT:}}") + private String endpoint; + + @Value("${aihr.realtime-practice.api-key:${AIHR_QWEN_REALTIME_API_KEY:}}") + private String apiKey; + + @Value("${aihr.realtime-practice.model:${AIHR_QWEN_REALTIME_MODEL:qwen3.5-omni-flash-realtime}}") + private String model; + + public RealtimeSdpResponse exchangeSdp(RealtimeSdpRequest request, String username) { + if (!enabled) { + throw new ServiceException("实时陪练尚未启用,请使用录音对练"); + } + String offer = request == null || request.offerSdp() == null ? "" : request.offerSdp(); + String verifiedOffer = offer.trim(); + if (verifiedOffer.isBlank() || offer.length() > MAX_SDP_CHARS || !verifiedOffer.contains("m=audio")) { + throw new ServiceException("实时陪练连接请求无效"); + } + String account = username == null ? "" : username.trim(); + if (account.isBlank()) { + throw new ServiceException("请使用员工端账号开启实时陪练"); + } + enforceRateLimit(account); + + URI signalingUri = signalingUri(endpoint, model); + if (apiKey == null || apiKey.isBlank()) { + throw new ServiceException("实时陪练尚未完成模型配置,请使用录音对练"); + } + try { + HttpRequest httpRequest = HttpRequest.newBuilder() + .uri(signalingUri) + .timeout(Duration.ofSeconds(20)) + .header("Content-Type", "application/sdp") + .header("Authorization", "Bearer " + apiKey.trim()) + .POST(HttpRequest.BodyPublishers.ofString(offer, StandardCharsets.UTF_8)) + .build(); + HttpResponse response = HTTP_CLIENT.send(httpRequest, HttpResponse.BodyHandlers.ofString(StandardCharsets.UTF_8)); + String answer = response.body() == null ? "" : response.body(); + String verifiedAnswer = answer.trim(); + if (response.statusCode() != 200 || verifiedAnswer.isBlank() || answer.length() > MAX_SDP_CHARS || !verifiedAnswer.contains("m=audio")) { + log.warn("qwen realtime SDP exchange failed: status={}", response.statusCode()); + throw new ServiceException("实时陪练暂时无法连接,请使用录音对练"); + } + return new RealtimeSdpResponse(answer, normalizedModel(model)); + } catch (IOException e) { + log.warn("qwen realtime SDP connection failed", e); + throw new ServiceException("实时陪练网络连接失败,请使用录音对练"); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + throw new ServiceException("实时陪练连接被中断,请重试"); + } + } + + static URI signalingUri(String configuredEndpoint, String configuredModel) { + String value = configuredEndpoint == null ? "" : configuredEndpoint.trim(); + URI base; + try { + base = URI.create(value); + } catch (IllegalArgumentException e) { + throw new ServiceException("实时陪练模型地址配置无效"); + } + String host = base.getHost() == null ? "" : base.getHost().toLowerCase(Locale.ROOT); + if (!"https".equalsIgnoreCase(base.getScheme()) || !host.endsWith(".maas.aliyuncs.com") + || base.getUserInfo() != null || base.getQuery() != null || base.getFragment() != null) { + throw new ServiceException("实时陪练模型地址配置无效"); + } + String basePath = base.getPath() == null ? "" : base.getPath().replaceAll("/+$", ""); + if (!basePath.isEmpty()) { + throw new ServiceException("实时陪练模型地址配置无效"); + } + String encodedModel = URLEncoder.encode(normalizedModel(configuredModel), StandardCharsets.UTF_8); + return URI.create(base.getScheme() + "://" + host + "/api/v1/webrtc/realtime?model=" + encodedModel); + } + + private static String normalizedModel(String configuredModel) { + String value = configuredModel == null ? "" : configuredModel.trim(); + if (!value.matches("qwen3\\.5-omni-(flash|plus)-realtime")) { + throw new ServiceException("实时陪练模型配置无效"); + } + return value; + } + + private static void enforceRateLimit(String username) { + String key = "aihr:practice:realtime:sdp:" + LoginHelper.getTenantId() + ":" + username; + try { + if (RedisUtils.rateLimiter(key, RateType.OVERALL, 3, 60) < 0) { + throw new ServiceException("实时陪练连接过于频繁,请稍后重试", 429); + } + } catch (ServiceException known) { + throw known; + } catch (RuntimeException unavailable) { + log.warn("realtime practice rate limiter unavailable"); + throw new ServiceException("实时陪练限流暂时不可用,请稍后重试", 503); + } + } +} diff --git a/backend/ruoyi-modules/ruoyi-aihr/src/test/java/org/dromara/aihr/service/AihrRealtimePracticeServiceTest.java b/backend/ruoyi-modules/ruoyi-aihr/src/test/java/org/dromara/aihr/service/AihrRealtimePracticeServiceTest.java new file mode 100644 index 00000000..be01585b --- /dev/null +++ b/backend/ruoyi-modules/ruoyi-aihr/src/test/java/org/dromara/aihr/service/AihrRealtimePracticeServiceTest.java @@ -0,0 +1,29 @@ +package org.dromara.aihr.service; + +import org.dromara.common.core.exception.ServiceException; +import org.junit.jupiter.api.Tag; +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; + +class AihrRealtimePracticeServiceTest { + + @Test + @Tag("dev") + void signalingUriOnlyAllowsOfficialModelStudioHostsAndRealtimeModels() { + assertEquals( + "https://workspace.cn-beijing.maas.aliyuncs.com/api/v1/webrtc/realtime?model=qwen3.5-omni-flash-realtime", + AihrRealtimePracticeService.signalingUri( + "https://workspace.cn-beijing.maas.aliyuncs.com", + "qwen3.5-omni-flash-realtime" + ).toString() + ); + assertThrows(ServiceException.class, () -> AihrRealtimePracticeService.signalingUri( + "https://example.com", "qwen3.5-omni-flash-realtime" + )); + assertThrows(ServiceException.class, () -> AihrRealtimePracticeService.signalingUri( + "https://workspace.cn-beijing.maas.aliyuncs.com", "other-model" + )); + } +} diff --git a/mobile-uni/src/pages/user/practice/index.vue b/mobile-uni/src/pages/user/practice/index.vue index 95eb45fd..a99f14d6 100644 --- a/mobile-uni/src/pages/user/practice/index.vue +++ b/mobile-uni/src/pages/user/practice/index.vue @@ -51,6 +51,15 @@ > {{ status === 'finished' ? '再练一次' : '我已准备好,开始练习' }} + + 自然打断、边听边说;本次体验不计入训练成绩。 @@ -159,7 +168,7 @@ {{ activeScenarioName }} - 第 {{ Math.min(roundIndex + 1, 3) }}/3 轮 + {{ realtimeActive ? `实时 · ${realtimeStatusLabel}` : `第 ${Math.min(roundIndex + 1, 3)}/3 轮` }} @@ -253,7 +262,18 @@ - + + + 实时陪练 Beta + 直接说话即可;本次内容仅供即时练习,不写入训练成绩或主管复盘。 + + + + + + + +