fix(aihr): expire unbound practice audio

This commit is contained in:
2026-07-14 22:00:12 +08:00
parent d9b609b1cc
commit ab10e56523
8 changed files with 211 additions and 6 deletions
@@ -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()) {
@@ -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;
@@ -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())"));
}