feat: add qwen realtime practice beta
This commit is contained in:
+16
@@ -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()));
|
||||
|
||||
+6
@@ -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) {
|
||||
}
|
||||
|
||||
|
||||
+134
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
+29
@@ -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"
|
||||
));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user