fix(aihr): expire unbound practice audio
This commit is contained in:
+43
@@ -3,12 +3,16 @@ package org.dromara.aihr.controller;
|
||||
import cn.dev33.satoken.annotation.SaCheckLogin;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.dromara.aihr.service.AihrPracticeSeedService;
|
||||
import org.dromara.aihr.domain.AihrSpeechDto.AsrResponse;
|
||||
import org.dromara.aihr.domain.AihrSpeechDto.TtsRequest;
|
||||
import org.dromara.aihr.domain.AihrSpeechDto.TtsResponse;
|
||||
import org.dromara.aihr.service.AihrMultipartFiles;
|
||||
import org.dromara.aihr.service.AihrSpeechService;
|
||||
import org.dromara.common.core.domain.R;
|
||||
import org.dromara.common.core.domain.model.LoginUser;
|
||||
import org.dromara.common.core.enums.UserType;
|
||||
import org.dromara.common.satoken.utils.LoginHelper;
|
||||
import org.dromara.system.domain.vo.SysOssVo;
|
||||
import org.dromara.system.service.ISysOssService;
|
||||
import org.springframework.http.MediaType;
|
||||
@@ -24,6 +28,7 @@ import java.io.IOException;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.util.Base64;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* 对练语音 API:ASR 语音输入 + TTS 客户语音播报。未配置语音模型时返回失败,前端降级为文本。
|
||||
@@ -40,6 +45,7 @@ public class AihrSpeechController {
|
||||
private static final int MAX_TTS_CHARS = 300;
|
||||
|
||||
private final AihrSpeechService speechService;
|
||||
private final AihrPracticeSeedService practiceSeedService;
|
||||
private final ISysOssService ossService;
|
||||
|
||||
@PostMapping(value = "/asr", consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
|
||||
@@ -59,6 +65,16 @@ public class AihrSpeechController {
|
||||
.map(text -> {
|
||||
try {
|
||||
SysOssVo oss = ossService.upload(sanitizedFile);
|
||||
Long ownerUserId = currentAppUserId();
|
||||
if (ownerUserId != null) {
|
||||
try {
|
||||
practiceSeedService.stagePracticeAudio(oss.getOssId(), oss.getUrl(), currentAppUsername(), ownerUserId);
|
||||
} catch (RuntimeException ex) {
|
||||
deleteOssQuietly(oss.getOssId());
|
||||
log.warn("stage asr audio failed(处理错误已隐藏)");
|
||||
return R.ok(new AsrResponse(text, "openai-compatible"));
|
||||
}
|
||||
}
|
||||
return R.ok(new AsrResponse(text, "openai-compatible", oss.getOssId(), oss.getUrl()));
|
||||
} catch (Exception e) {
|
||||
log.warn("asr audio upload failed(处理错误已隐藏)");
|
||||
@@ -68,6 +84,33 @@ public class AihrSpeechController {
|
||||
.orElseGet(() -> R.fail("语音识别失败,请改用文字输入"));
|
||||
}
|
||||
|
||||
private void deleteOssQuietly(Long ossId) {
|
||||
if (ossId == null) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
ossService.deleteWithValidByIds(List.of(ossId), false);
|
||||
} catch (RuntimeException ex) {
|
||||
log.warn("delete staged asr audio failed(处理错误已隐藏)");
|
||||
}
|
||||
}
|
||||
|
||||
private static String currentAppUsername() {
|
||||
LoginUser loginUser = LoginHelper.getLoginUser();
|
||||
if (loginUser == null || !UserType.APP_USER.getUserType().equals(loginUser.getUserType())) {
|
||||
return "";
|
||||
}
|
||||
return loginUser.getUsername() == null ? "" : loginUser.getUsername().trim();
|
||||
}
|
||||
|
||||
private static Long currentAppUserId() {
|
||||
LoginUser loginUser = LoginHelper.getLoginUser();
|
||||
if (loginUser == null || !UserType.APP_USER.getUserType().equals(loginUser.getUserType())) {
|
||||
return null;
|
||||
}
|
||||
return loginUser.getUserId();
|
||||
}
|
||||
|
||||
@PostMapping("/tts")
|
||||
public R<TtsResponse> tts(@RequestBody TtsRequest request) {
|
||||
if (!speechService.ttsConfigured()) {
|
||||
|
||||
+125
-3
@@ -1,8 +1,10 @@
|
||||
package org.dromara.aihr.service;
|
||||
|
||||
import jakarta.annotation.PostConstruct;
|
||||
import com.fasterxml.jackson.core.JsonProcessingException;
|
||||
import com.fasterxml.jackson.databind.JsonNode;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.dromara.aihr.domain.AihrPracticeDto.AnnotationResponse;
|
||||
import org.dromara.aihr.domain.AihrPracticeDto.AnnotationSaveRequest;
|
||||
import org.dromara.aihr.domain.AihrPracticeDto.CalibrationRequest;
|
||||
@@ -48,6 +50,7 @@ import org.dromara.aihr.service.AihrPracticeLlmService.PracticeTurn;
|
||||
import org.dromara.aihr.service.AihrPracticeLlmService.DailyDrillAssessment;
|
||||
import org.dromara.common.core.exception.ServiceException;
|
||||
import org.dromara.common.tenant.helper.TenantHelper;
|
||||
import org.dromara.system.service.ISysOssService;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.dao.DataAccessException;
|
||||
@@ -73,12 +76,15 @@ import java.util.Optional;
|
||||
import java.util.Set;
|
||||
import java.util.concurrent.ConcurrentHashMap;
|
||||
import java.util.concurrent.ConcurrentMap;
|
||||
import java.util.concurrent.ScheduledExecutorService;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
|
||||
/**
|
||||
* 三角色对练编排器。seed 剧本承担剧情锚点与兜底;配置了 chat 模型时,
|
||||
* 客户回复与 /finish 评分走真 LLM(AihrPracticeLlmService),失败自动回退 seed。
|
||||
*/
|
||||
@Service
|
||||
@Slf4j
|
||||
public class AihrPracticeSeedService {
|
||||
|
||||
private static final Set<String> LEGACY_DAILY_DRILL_PROFILES = Set.of("dev", "local");
|
||||
@@ -224,6 +230,8 @@ public class AihrPracticeSeedService {
|
||||
private final AihrPracticeLlmService practiceLlmService;
|
||||
private final AihrSopSeedService sopSeedService;
|
||||
private final TransactionTemplate pilotExportTransaction;
|
||||
private final ISysOssService ossService;
|
||||
private final ScheduledExecutorService scheduledExecutorService;
|
||||
@Value("${aihr.practice.allow-legacy-daily-drill-fallback:${AIHR_PRACTICE_ALLOW_LEGACY_DAILY_DRILL_FALLBACK:false}}")
|
||||
private boolean allowLegacyDailyDrillFallback;
|
||||
@Value("${spring.profiles.active:}")
|
||||
@@ -240,22 +248,39 @@ public class AihrPracticeSeedService {
|
||||
|
||||
@Autowired
|
||||
public AihrPracticeSeedService(ObjectMapper objectMapper, JdbcTemplate jdbcTemplate, AihrPracticeLlmService practiceLlmService,
|
||||
AihrSopSeedService sopSeedService, PlatformTransactionManager transactionManager) {
|
||||
this(objectMapper, jdbcTemplate, practiceLlmService, sopSeedService, new TransactionTemplate(transactionManager));
|
||||
AihrSopSeedService sopSeedService, PlatformTransactionManager transactionManager,
|
||||
ISysOssService ossService, ScheduledExecutorService scheduledExecutorService) {
|
||||
this(objectMapper, jdbcTemplate, practiceLlmService, sopSeedService, new TransactionTemplate(transactionManager),
|
||||
ossService, scheduledExecutorService);
|
||||
}
|
||||
|
||||
public AihrPracticeSeedService(ObjectMapper objectMapper, JdbcTemplate jdbcTemplate, AihrPracticeLlmService practiceLlmService,
|
||||
AihrSopSeedService sopSeedService) {
|
||||
this(objectMapper, jdbcTemplate, practiceLlmService, sopSeedService, (TransactionTemplate) null);
|
||||
this(objectMapper, jdbcTemplate, practiceLlmService, sopSeedService, (TransactionTemplate) null, null, null);
|
||||
}
|
||||
|
||||
AihrPracticeSeedService(ObjectMapper objectMapper, JdbcTemplate jdbcTemplate, AihrPracticeLlmService practiceLlmService,
|
||||
AihrSopSeedService sopSeedService, TransactionTemplate pilotExportTransaction) {
|
||||
this(objectMapper, jdbcTemplate, practiceLlmService, sopSeedService, pilotExportTransaction, null, null);
|
||||
}
|
||||
|
||||
AihrPracticeSeedService(ObjectMapper objectMapper, JdbcTemplate jdbcTemplate, AihrPracticeLlmService practiceLlmService,
|
||||
AihrSopSeedService sopSeedService, TransactionTemplate pilotExportTransaction,
|
||||
ISysOssService ossService, ScheduledExecutorService scheduledExecutorService) {
|
||||
this.objectMapper = objectMapper;
|
||||
this.jdbcTemplate = jdbcTemplate;
|
||||
this.practiceLlmService = practiceLlmService;
|
||||
this.sopSeedService = sopSeedService;
|
||||
this.pilotExportTransaction = configurePilotExportTransaction(pilotExportTransaction);
|
||||
this.ossService = ossService;
|
||||
this.scheduledExecutorService = scheduledExecutorService;
|
||||
}
|
||||
|
||||
@PostConstruct
|
||||
void schedulePracticeAudioCleanup() {
|
||||
if (scheduledExecutorService != null) {
|
||||
scheduledExecutorService.scheduleWithFixedDelay(this::cleanupExpiredPracticeAudioUploads, 10, 15, TimeUnit.MINUTES);
|
||||
}
|
||||
}
|
||||
|
||||
public List<ScenarioResponse> scenarios(String position, String scenarioType) {
|
||||
@@ -1497,6 +1522,81 @@ public class AihrPracticeSeedService {
|
||||
return count != null && count > 0;
|
||||
}
|
||||
|
||||
public void stagePracticeAudio(Long ossId, String audioUrl, String ownerIdentity, Long ownerUserId) {
|
||||
if (ossId == null || isBlank(ownerIdentity) || ownerUserId == null || ownerUserId <= 0) {
|
||||
return;
|
||||
}
|
||||
ensureAudioUploadTable();
|
||||
cleanupExpiredPracticeAudioUploads();
|
||||
LocalDateTime now = LocalDateTime.now();
|
||||
jdbcTemplate.update("""
|
||||
INSERT INTO aihr_practice_audio_upload
|
||||
(tenant_id, oss_id, owner_user_id, owner_identity, audio_url, status, expires_time, create_time, update_time)
|
||||
VALUES (?, ?, ?, ?, ?, 'staged', ?, ?, ?)
|
||||
ON DUPLICATE KEY UPDATE
|
||||
owner_user_id = VALUES(owner_user_id),
|
||||
owner_identity = VALUES(owner_identity),
|
||||
audio_url = VALUES(audio_url),
|
||||
status = 'staged',
|
||||
expires_time = VALUES(expires_time),
|
||||
update_time = VALUES(update_time)
|
||||
""",
|
||||
tenantId(),
|
||||
ossId,
|
||||
ownerUserId,
|
||||
ownerIdentity.trim(),
|
||||
firstNonBlank(audioUrl, ""),
|
||||
Timestamp.valueOf(now.plusHours(2)),
|
||||
Timestamp.valueOf(now),
|
||||
Timestamp.valueOf(now)
|
||||
);
|
||||
}
|
||||
|
||||
private void bindPracticeAudio(Long ossId) {
|
||||
if (ossId == null) {
|
||||
return;
|
||||
}
|
||||
ensureAudioUploadTable();
|
||||
jdbcTemplate.update("""
|
||||
UPDATE aihr_practice_audio_upload
|
||||
SET status = 'bound', update_time = ?
|
||||
WHERE tenant_id = ? AND oss_id = ? AND status = 'staged'
|
||||
""", Timestamp.valueOf(LocalDateTime.now()), tenantId(), ossId);
|
||||
}
|
||||
|
||||
private void cleanupExpiredPracticeAudioUploads() {
|
||||
if (jdbcTemplate == null || ossService == null) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
ensureAudioUploadTable();
|
||||
List<Long> expiredIds = jdbcTemplate.query("""
|
||||
SELECT u.oss_id
|
||||
FROM aihr_practice_audio_upload u
|
||||
WHERE u.tenant_id = ? AND u.status = 'staged' AND u.expires_time < ?
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM aihr_practice_audio a
|
||||
WHERE a.tenant_id = u.tenant_id AND a.oss_id = u.oss_id
|
||||
)
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM aihr_practice_assignment d
|
||||
WHERE d.tenant_id = u.tenant_id AND d.answer_audio_oss_id = u.oss_id
|
||||
)
|
||||
""", (rs, rowNum) -> rs.getLong("oss_id"), tenantId(), Timestamp.valueOf(LocalDateTime.now()));
|
||||
for (Long ossId : expiredIds) {
|
||||
try {
|
||||
ossService.deleteWithValidByIds(List.of(ossId), false);
|
||||
jdbcTemplate.update("DELETE FROM aihr_practice_audio_upload WHERE tenant_id = ? AND oss_id = ? AND status = 'staged'", tenantId(), ossId);
|
||||
} catch (RuntimeException ex) {
|
||||
// Keep the staging row so the next sweep can retry object deletion.
|
||||
log.warn("practice audio cleanup failed(处理错误已隐藏)");
|
||||
}
|
||||
}
|
||||
} catch (RuntimeException ex) {
|
||||
log.warn("practice audio cleanup sweep failed(处理错误已隐藏)");
|
||||
}
|
||||
}
|
||||
|
||||
private void validateAudioOwnership(Long ossId, String ownerIdentity, Long ownerUserId) {
|
||||
if (ossId == null) {
|
||||
return;
|
||||
@@ -2226,6 +2326,7 @@ public class AihrPracticeSeedService {
|
||||
if (updated == 0) {
|
||||
return null;
|
||||
}
|
||||
bindPracticeAudio(audioOssId);
|
||||
return new DailyDrillAnswerResponse(id, "已完成", score.correct(), score.score(), score.feedback(), parts.referenceAnswer(), score.mode(), audioUrl, audioOssId);
|
||||
}
|
||||
|
||||
@@ -3476,6 +3577,7 @@ public class AihrPracticeSeedService {
|
||||
Timestamp.valueOf(now),
|
||||
Timestamp.valueOf(now)
|
||||
);
|
||||
bindPracticeAudio(i < ossIds.size() ? ossIds.get(i) : null);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3938,6 +4040,26 @@ public class AihrPracticeSeedService {
|
||||
}
|
||||
}
|
||||
|
||||
private void ensureAudioUploadTable() {
|
||||
jdbcTemplate.execute("""
|
||||
CREATE TABLE IF NOT EXISTS `aihr_practice_audio_upload` (
|
||||
`id` bigint NOT NULL AUTO_INCREMENT COMMENT '主键',
|
||||
`tenant_id` varchar(20) DEFAULT '000000' COMMENT '租户编号',
|
||||
`oss_id` bigint NOT NULL COMMENT 'OSS文件ID',
|
||||
`owner_user_id` bigint NOT NULL COMMENT '上传用户ID',
|
||||
`owner_identity` varchar(100) NOT NULL COMMENT '上传主体',
|
||||
`audio_url` varchar(1000) DEFAULT NULL COMMENT '音频URL',
|
||||
`status` varchar(20) NOT NULL DEFAULT 'staged' COMMENT 'staged/bound',
|
||||
`expires_time` datetime NOT NULL COMMENT '过期时间',
|
||||
`create_time` datetime DEFAULT NULL COMMENT '创建时间',
|
||||
`update_time` datetime DEFAULT NULL COMMENT '更新时间',
|
||||
PRIMARY KEY (`id`),
|
||||
UNIQUE KEY `uk_aihr_practice_audio_upload` (`tenant_id`, `oss_id`),
|
||||
KEY `idx_aihr_practice_audio_upload_expire` (`tenant_id`, `status`, `expires_time`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='AI HR 对练音频上传暂存';
|
||||
""");
|
||||
}
|
||||
|
||||
private void ensurePromptTemplateTable() {
|
||||
if (promptTemplateTableReady) {
|
||||
return;
|
||||
|
||||
+2
@@ -315,6 +315,8 @@ public class AihrPracticeSeedServiceTest {
|
||||
assertTrue(service.contains("validateAudioOwnership(request == null ? null : request.traineeAudioOssId(), ownerIdentity, ownerUserId)"));
|
||||
assertTrue(service.contains("validateAudioOwnership(audioOssId, extPartyId, ownerUserId)"));
|
||||
assertTrue(service.contains("d.answer_audio_oss_id = ?"));
|
||||
assertTrue(service.contains("aihr_practice_audio_upload"));
|
||||
assertTrue(service.contains("cleanupExpiredPracticeAudioUploads"));
|
||||
assertTrue(controller.contains("currentAppUserId()"));
|
||||
assertTrue(mobileController.contains("answerDailyDrill(id, request, ownMobileExtPartyId(null), currentAppUserId())"));
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user