feat: add qwen realtime practice beta

This commit is contained in:
2026-07-22 09:29:39 +08:00
parent b7dd1bca27
commit 87772cc64c
7 changed files with 712 additions and 4 deletions
@@ -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<List<ScenarioResponse>> 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<RealtimeSdpResponse> realtimeSdp(@RequestBody RealtimeSdpRequest request) {
String username = currentAppUsername();
if (username.isBlank()) {
return R.fail("请使用员工端账号开启实时陪练");
}
return R.ok(realtimePracticeService.exchangeSdp(request, username));
}
@PostMapping("/turn")
public R<TurnResponse> turn(@RequestBody TurnRequest request) {
return R.ok(practiceSeedService.turn(request, currentAppUsername(), currentAppUserId()));
@@ -107,6 +107,12 @@ public final class AihrPracticeDto {
public record PrepCardResponse(List<String> keyPoints, List<String> redLines, List<String> 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) {
}
@@ -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<String> 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);
}
}
}
@@ -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"
));
}
}