From ab10e56523e2123d3f52d23322ecd368bf7d1d36 Mon Sep 17 00:00:00 2001 From: let5sne Date: Tue, 14 Jul 2026 22:00:12 +0800 Subject: [PATCH] fix(aihr): expire unbound practice audio --- .../aihr/controller/AihrSpeechController.java | 43 ++++++ .../aihr/service/AihrPracticeSeedService.java | 128 +++++++++++++++++- .../service/AihrPracticeSeedServiceTest.java | 2 + backend/script/sql/aihr_practice_mysql8.sql | 16 +++ ...aihr_20260714_practice_evidence_mysql8.sql | 16 +++ docs/BRD_IMPLEMENTATION_AUDIT.md | 2 +- scripts/demo-check.sh | 6 + scripts/tests/aihr-schema-migrations.test.sh | 4 +- 8 files changed, 211 insertions(+), 6 deletions(-) diff --git a/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/controller/AihrSpeechController.java b/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/controller/AihrSpeechController.java index 94848150..9bc29644 100644 --- a/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/controller/AihrSpeechController.java +++ b/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/controller/AihrSpeechController.java @@ -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 tts(@RequestBody TtsRequest request) { if (!speechService.ttsConfigured()) { diff --git a/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/service/AihrPracticeSeedService.java b/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/service/AihrPracticeSeedService.java index f0079f0a..07bd38f1 100644 --- a/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/service/AihrPracticeSeedService.java +++ b/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/service/AihrPracticeSeedService.java @@ -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 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 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 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; diff --git a/backend/ruoyi-modules/ruoyi-aihr/src/test/java/org/dromara/aihr/service/AihrPracticeSeedServiceTest.java b/backend/ruoyi-modules/ruoyi-aihr/src/test/java/org/dromara/aihr/service/AihrPracticeSeedServiceTest.java index 9a877e99..bcaa7f64 100644 --- a/backend/ruoyi-modules/ruoyi-aihr/src/test/java/org/dromara/aihr/service/AihrPracticeSeedServiceTest.java +++ b/backend/ruoyi-modules/ruoyi-aihr/src/test/java/org/dromara/aihr/service/AihrPracticeSeedServiceTest.java @@ -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())")); } diff --git a/backend/script/sql/aihr_practice_mysql8.sql b/backend/script/sql/aihr_practice_mysql8.sql index bd4110b4..b2558d7f 100644 --- a/backend/script/sql/aihr_practice_mysql8.sql +++ b/backend/script/sql/aihr_practice_mysql8.sql @@ -91,6 +91,22 @@ CREATE TABLE IF NOT EXISTS `aihr_practice_audio` ( KEY `idx_aihr_practice_audio_session` (`tenant_id`, `session_id`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='AI HR 对练音频'; +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 对练音频上传暂存'; + CREATE TABLE IF NOT EXISTS `aihr_practice_assignment` ( `id` bigint NOT NULL AUTO_INCREMENT COMMENT '主键', `tenant_id` varchar(20) DEFAULT '000000' COMMENT '租户编号', diff --git a/backend/script/sql/update/aihr_20260714_practice_evidence_mysql8.sql b/backend/script/sql/update/aihr_20260714_practice_evidence_mysql8.sql index 77ad8688..4d03cfba 100644 --- a/backend/script/sql/update/aihr_20260714_practice_evidence_mysql8.sql +++ b/backend/script/sql/update/aihr_20260714_practice_evidence_mysql8.sql @@ -16,6 +16,22 @@ CREATE TABLE IF NOT EXISTS `aihr_practice_audio` ( KEY `idx_aihr_practice_audio_session` (`tenant_id`, `session_id`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='AI HR 对练音频'; +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 对练音频上传暂存'; + CREATE TABLE IF NOT EXISTS `aihr_practice_calibration` ( `id` bigint NOT NULL AUTO_INCREMENT COMMENT '主键', `tenant_id` varchar(20) DEFAULT '000000' COMMENT '租户编号', diff --git a/docs/BRD_IMPLEMENTATION_AUDIT.md b/docs/BRD_IMPLEMENTATION_AUDIT.md index 4f8243ce..8cc34db2 100644 --- a/docs/BRD_IMPLEMENTATION_AUDIT.md +++ b/docs/BRD_IMPLEMENTATION_AUDIT.md @@ -1,6 +1,6 @@ # BRD 功能审查与未完成项记录 -- 2026-07-14 BRD G3 训练音频上传者边界修复:陪练回合和每日三题此前接受客户端直接提交的 `audioOssId`,服务端只在下载时按训练记录判断归属,存在把其他对象绑定到当前训练/作业的风险;现要求移动端提交的 OSS 对象必须由当前 APP 用户创建,且每日三题音频下载授权同时覆盖 `aihr_practice_assignment.answer_audio_oss_id`。ASR 上传后尚未绑定会话的孤儿对象清理仍需独立的短期 staging/过期策略,不用本次归属校验冒充已解决。 +- 2026-07-14 BRD G3 训练音频生命周期修复:陪练回合和每日三题此前接受客户端直接提交的 `audioOssId`,服务端只在下载时按训练记录判断归属,存在把其他对象绑定到当前训练/作业的风险;现要求移动端提交的 OSS 对象必须由当前 APP 用户创建,且每日三题音频下载授权同时覆盖 `aihr_practice_assignment.answer_audio_oss_id`。ASR 成功上传后先写 `aihr_practice_audio_upload` 暂存记录,训练/作业绑定后转为 `bound`,超过两小时且没有业务引用的暂存对象由定时清理删除;清理失败保留暂存记录重试。正式环境仍需用真实 ASR 和 MinIO 对象抽查清理结果。 - 2026-07-14 BRD G3 SOP 附件绑定补偿:同步/待处理图片文档此前在 `sys_oss` 上传成功后再写 `aihr_knowledge_attach`,附件写入失败时可能留下无业务引用对象;现仅对“附件尚未成功绑定”的异常路径做 OSS 补偿删除,解析失败后已存在的失败/待处理附件仍保留用于重试,不改变现有文档处理状态机。 - 2026-07-14 BRD G3 OSS 生命周期补偿:候选资料和案例音频此前先写 `sys_oss`、再写业务记录,业务数据库写入失败时可能留下没有业务引用的对象;现对“本次上传新建且尚未绑定”的 `oss_id` 增加失败补偿删除,案例同时移除内存态,补充两条回归测试和 `demo-check` marker。该修复不触碰已有业务资源;SOP 分片/ASR 等复杂异步链路仍需按各自状态机单独核查,不能用本次补偿覆盖全部媒体生命周期。 - 2026-07-14 BRD G3 文件名边界复核:候选资料、案例音频、同步 SOP 文档和 ASR 音频此前虽然分别校验或清理文件名,但仍有路径把原始 `MultipartFile` 直接交给 `sys_oss`,清理后的名字没有贯通到 OSS 元数据;现新增共用 `AihrMultipartFiles` 包装器,四条链路均以清理后的文件名上传/外发,并补单测与 `demo-check` marker。该修复不改变文件类型、大小、权限和业务状态,仍需正式环境抽查 OSS 原始文件名与下载响应头。 diff --git a/scripts/demo-check.sh b/scripts/demo-check.sh index 138ba4ba..2842abf3 100755 --- a/scripts/demo-check.sh +++ b/scripts/demo-check.sh @@ -140,6 +140,7 @@ check_pilot_samples() { HAVING COUNT(*) = 5 AND ABS(SUM(d.weight) - 1.00) < 0.001 ) ready_rubrics")" audio_table_count="$(pilot_sql_scalar "SELECT COUNT(*) FROM information_schema.tables WHERE table_schema = DATABASE() AND table_name = 'aihr_practice_audio'")" + audio_upload_table_count="$(pilot_sql_scalar "SELECT COUNT(*) FROM information_schema.tables WHERE table_schema = DATABASE() AND table_name = 'aihr_practice_audio_upload'")" prompt_version_schema_count="$(pilot_sql_scalar "SELECT COUNT(*) FROM information_schema.columns WHERE table_schema = DATABASE() AND table_name = 'aihr_sop_answer_review' AND column_name = 'prompt_version'")" requester_identity_schema_count="$(pilot_sql_scalar "SELECT COUNT(*) FROM information_schema.columns WHERE table_schema = DATABASE() AND table_name = 'aihr_sop_answer_review' AND column_name = 'requester_ext_party_id'")" hire_date_schema_count="$(pilot_sql_scalar "SELECT COUNT(*) FROM information_schema.columns WHERE table_schema = DATABASE() AND table_name = 'aihr_org_snapshot' AND column_name = 'hire_date'")" @@ -307,6 +308,7 @@ check_pilot_samples() { (( case_stored_count >= 20 )) || fail "pilot stored cases ${case_stored_count}/20" (( case_unmasked_count == 0 )) || fail "pilot stored cases contain unmasked known PII (${case_unmasked_count})" (( audio_table_count == 1 )) || fail "pilot audio schema unavailable (aihr_practice_audio missing)" + (( audio_upload_table_count == 1 )) || fail "pilot audio staging schema unavailable (aihr_practice_audio_upload missing)" (( prompt_version_schema_count == 1 )) || fail "pilot RAG traceability schema unavailable (aihr_sop_answer_review.prompt_version missing)" (( requester_identity_schema_count == 1 )) || fail "pilot SOP requester identity schema unavailable (aihr_sop_answer_review.requester_ext_party_id missing)" (( hire_date_schema_count == 1 )) || fail "pilot org hire_date schema unavailable" @@ -542,6 +544,7 @@ contains frontend/src/api/aihr/practice.ts "/api/train/practice/export" contains backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/service/AihrPracticeSeedService.java "final_adopted_score" contains backend/script/sql/aihr_practice_mysql8.sql "aihr_practice_calibration" contains backend/script/sql/update/aihr_20260714_practice_evidence_mysql8.sql "aihr_practice_audio" +contains backend/script/sql/update/aihr_20260714_practice_evidence_mysql8.sql "aihr_practice_audio_upload" contains backend/script/sql/update/aihr_20260714_practice_evidence_mysql8.sql "aihr_practice_calibration" contains backend/script/sql/aihr_org_snapshot_mysql8.sql "hire_date" contains backend/script/sql/update/aihr_20260714_org_hire_date_mysql8.sql "hire_date" @@ -602,6 +605,9 @@ contains backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/service contains backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/service/AihrPracticeSeedService.java "maskSensitiveText" contains backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/service/AihrPracticeSeedService.java "validateAudioOwnership" contains backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/service/AihrPracticeSeedService.java "d.answer_audio_oss_id = ?" +contains backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/service/AihrPracticeSeedService.java "stagePracticeAudio" +contains backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/service/AihrPracticeSeedService.java "cleanupExpiredPracticeAudioUploads" +contains backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/controller/AihrSpeechController.java "stagePracticeAudio" contains mobile-uni/src/services/practice.ts "/api/aihr/mobile/practice/assignments" contains mobile-uni/src/services/practice.ts "/api/train/practice/scenarios?position=" contains mobile-uni/src/pages/supervisor/assign/index.vue "scenarioOptions" diff --git a/scripts/tests/aihr-schema-migrations.test.sh b/scripts/tests/aihr-schema-migrations.test.sh index 2fc1809f..51befd53 100755 --- a/scripts/tests/aihr-schema-migrations.test.sh +++ b/scripts/tests/aihr-schema-migrations.test.sh @@ -23,8 +23,8 @@ mysql -e "CREATE DATABASE \`$DB\` CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ mysql "$DB" < "$ROOT_DIR/backend/script/sql/update/aihr_20260714_practice_evidence_mysql8.sql" mysql "$DB" < "$ROOT_DIR/backend/script/sql/update/aihr_20260714_practice_evidence_mysql8.sql" -evidence_tables="$(mysql "$DB" -N -B -e "SELECT GROUP_CONCAT(table_name ORDER BY table_name) FROM information_schema.tables WHERE table_schema='$DB' AND table_name IN ('aihr_practice_audio','aihr_practice_calibration');")" -test "$evidence_tables" = 'aihr_practice_audio,aihr_practice_calibration' +evidence_tables="$(mysql "$DB" -N -B -e "SELECT GROUP_CONCAT(table_name ORDER BY table_name) FROM information_schema.tables WHERE table_schema='$DB' AND table_name IN ('aihr_practice_audio','aihr_practice_audio_upload','aihr_practice_calibration');")" +test "$evidence_tables" = 'aihr_practice_audio,aihr_practice_audio_upload,aihr_practice_calibration' mysql "$DB" <<'SQL' CREATE TABLE aihr_sop_answer_review (