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())"));
}
@@ -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 '租户编号',
@@ -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 '租户编号',
+1 -1
View File
@@ -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 原始文件名与下载响应头。
+6
View File
@@ -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"
+2 -2
View File
@@ -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 (