From 30a473b365e71016c6644536ffc874db21e1b7e4 Mon Sep 17 00:00:00 2001 From: let5sne Date: Tue, 14 Jul 2026 21:41:22 +0800 Subject: [PATCH] fix(aihr): clean up orphaned upload objects --- .../service/AihrCandidateMaterialService.java | 52 +++++++++++++------ .../dromara/aihr/service/AihrCaseService.java | 21 +++++++- .../AihrCandidateMaterialServiceTest.java | 46 ++++++++++++++++ .../aihr/service/AihrCaseServiceTest.java | 30 +++++++++++ docs/BRD_IMPLEMENTATION_AUDIT.md | 1 + scripts/demo-check.sh | 2 + 6 files changed, 134 insertions(+), 18 deletions(-) diff --git a/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/service/AihrCandidateMaterialService.java b/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/service/AihrCandidateMaterialService.java index ac898d31..bd6acad1 100644 --- a/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/service/AihrCandidateMaterialService.java +++ b/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/service/AihrCandidateMaterialService.java @@ -1,6 +1,7 @@ package org.dromara.aihr.service; import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; import org.dromara.common.tenant.helper.TenantHelper; import org.dromara.common.core.exception.ServiceException; import org.dromara.system.domain.vo.SysOssVo; @@ -22,6 +23,7 @@ import java.util.Set; @Service @RequiredArgsConstructor +@Slf4j public class AihrCandidateMaterialService { private static final String TENANT_ID = "000000"; @@ -52,27 +54,43 @@ public class AihrCandidateMaterialService { String type = clean(materialType, materialType(fileName)); SysOssVo oss = ossService.upload(AihrMultipartFiles.withOriginalFilename(file, fileName)); KeyHolder keyHolder = new GeneratedKeyHolder(); - jdbcTemplate.update(connection -> { - PreparedStatement ps = connection.prepareStatement(""" - insert into aihr_candidate_material - (tenant_id, candidate_id, candidate_name, material_type, file_name, file_size, content_type, oss_id, status, create_time, update_time) - values (?, ?, ?, ?, ?, ?, ?, ?, ?, now(), now()) - """, Statement.RETURN_GENERATED_KEYS); - ps.setString(1, tenantId()); - ps.setString(2, boundCandidateId); - ps.setString(3, clean(candidateName, "候选人")); - ps.setString(4, type); - ps.setString(5, fileName); - ps.setLong(6, file.getSize()); - ps.setString(7, clean(file.getContentType(), "application/octet-stream")); - ps.setLong(8, oss.getOssId()); - ps.setString(9, "待审核"); - return ps; - }, keyHolder); + try { + jdbcTemplate.update(connection -> { + PreparedStatement ps = connection.prepareStatement(""" + insert into aihr_candidate_material + (tenant_id, candidate_id, candidate_name, material_type, file_name, file_size, content_type, oss_id, status, create_time, update_time) + values (?, ?, ?, ?, ?, ?, ?, ?, ?, now(), now()) + """, Statement.RETURN_GENERATED_KEYS); + ps.setString(1, tenantId()); + ps.setString(2, boundCandidateId); + ps.setString(3, clean(candidateName, "候选人")); + ps.setString(4, type); + ps.setString(5, fileName); + ps.setLong(6, file.getSize()); + ps.setString(7, clean(file.getContentType(), "application/octet-stream")); + ps.setLong(8, oss.getOssId()); + ps.setString(9, "待审核"); + return ps; + }, keyHolder); + } catch (RuntimeException error) { + deleteUploadedOssQuietly(oss); + throw error; + } Number id = keyHolder.getKey(); return new MaterialResponse(id == null ? null : id.longValue(), oss.getOssId(), fileName, type, file.getSize(), "待审核", LocalDateTime.now().format(TIME_FORMAT)); } + private void deleteUploadedOssQuietly(SysOssVo oss) { + if (oss == null || oss.getOssId() == null || ossService == null) { + return; + } + try { + ossService.deleteWithValidByIds(List.of(oss.getOssId()), false); + } catch (Exception cleanupError) { + log.warn("candidate material OSS cleanup failed ossId={}", oss.getOssId(), cleanupError); + } + } + public List list(String candidateId) { ensureTable(); String boundCandidateId = requireCandidateId(candidateId); diff --git a/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/service/AihrCaseService.java b/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/service/AihrCaseService.java index 083d2263..dc111449 100644 --- a/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/service/AihrCaseService.java +++ b/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/service/AihrCaseService.java @@ -63,20 +63,25 @@ public class AihrCaseService { } String scopedProject = resolveUploadProject(projectExtOrgId, projectScopes); String contentType = firstNonBlank(file.getContentType(), "application/octet-stream"); + String caseId = null; + Long mediaOssId = null; try { String transcript = speechService.transcribe(file.getBytes(), fileName, contentType) .orElseThrow(() -> new IllegalStateException("ASR 未配置或转写失败")); transcript = maskSensitiveText(transcript); MediaRef media = storeSourceAudio(file, fileName); - String caseId = "case-" + UUID.randomUUID(); + mediaOssId = media.ossId(); + caseId = "case-" + UUID.randomUUID(); CaseState state = new CaseState(caseId, fileName, scopedProject, transcript, tagsFromText(transcript), null, media.ossId(), media.url(), currentCaseOwner()); cases.put(caseId, state); saveCase(state, "已转写", caseTitle(List.of(), fileName), transcript); return new UploadResponse(caseId, fileName, transcript, state.tags(), "real-asr", media.url(), media.ossId()); } catch (IllegalStateException e) { + cleanupFailedUpload(caseId, mediaOssId); throw e; } catch (Exception e) { + cleanupFailedUpload(caseId, mediaOssId); log.warn("case audio upload failed(处理错误已隐藏)"); throw new IllegalStateException("语音上传或转写失败"); } @@ -449,6 +454,20 @@ public class AihrCaseService { return new MediaRef(oss.getOssId(), firstNonBlank(oss.getUrl(), "")); } + private void cleanupFailedUpload(String caseId, Long mediaOssId) { + if (caseId != null) { + cases.remove(caseId); + } + if (mediaOssId == null || ossService == null) { + return; + } + try { + ossService.deleteWithValidByIds(List.of(mediaOssId), false); + } catch (Exception cleanupError) { + log.warn("case audio OSS cleanup failed ossId={}", mediaOssId, cleanupError); + } + } + private String resolveMediaUrl(Long mediaOssId, String persistedUrl) { String fallback = firstNonBlank(persistedUrl, ""); if (mediaOssId == null || ossService == null) { diff --git a/backend/ruoyi-modules/ruoyi-aihr/src/test/java/org/dromara/aihr/service/AihrCandidateMaterialServiceTest.java b/backend/ruoyi-modules/ruoyi-aihr/src/test/java/org/dromara/aihr/service/AihrCandidateMaterialServiceTest.java index e2059029..f7d98e26 100644 --- a/backend/ruoyi-modules/ruoyi-aihr/src/test/java/org/dromara/aihr/service/AihrCandidateMaterialServiceTest.java +++ b/backend/ruoyi-modules/ruoyi-aihr/src/test/java/org/dromara/aihr/service/AihrCandidateMaterialServiceTest.java @@ -1,16 +1,27 @@ package org.dromara.aihr.service; import org.dromara.common.core.exception.ServiceException; +import org.dromara.system.domain.vo.SysOssVo; +import org.dromara.system.service.ISysOssService; import org.junit.jupiter.api.Tag; import org.junit.jupiter.api.Test; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.jdbc.core.PreparedStatementCreator; +import org.springframework.jdbc.support.KeyHolder; +import org.springframework.mock.web.MockMultipartFile; import java.nio.file.Files; import java.nio.file.Path; +import java.util.List; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertFalse; import static org.junit.jupiter.api.Assertions.assertThrows; import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; class AihrCandidateMaterialServiceTest { @@ -59,4 +70,39 @@ class AihrCandidateMaterialServiceTest { assertTrue(code.contains("review(id, request, operator)")); } + + @Test + @Tag("dev") + void uploadDeletesOssWhenCandidateMaterialInsertFails() { + SysOssVo oss = new SysOssVo(); + oss.setOssId(321L); + ISysOssService ossService = mock(ISysOssService.class); + when(ossService.upload(any(org.springframework.web.multipart.MultipartFile.class))).thenReturn(oss); + FailingCandidateJdbcTemplate jdbcTemplate = new FailingCandidateJdbcTemplate(); + AihrCandidateMaterialService service = new AihrCandidateMaterialService(jdbcTemplate, ossService); + + assertThrows(IllegalStateException.class, () -> service.upload( + new MockMultipartFile("file", "resume.pdf", "application/pdf", new byte[]{1}), + "candidate-1", "候选人", "简历/证书" + )); + + verify(ossService).deleteWithValidByIds(List.of(321L), false); + } + + private static final class FailingCandidateJdbcTemplate extends JdbcTemplate { + @Override + public void execute(String sql) { + // Table DDL is not relevant for this unit test. + } + + @Override + public T queryForObject(String sql, Class requiredType, Object... args) { + return requiredType.cast(1); + } + + @Override + public int update(PreparedStatementCreator psc, KeyHolder generatedKeyHolder) { + throw new IllegalStateException("simulated candidate material insert failure"); + } + } } diff --git a/backend/ruoyi-modules/ruoyi-aihr/src/test/java/org/dromara/aihr/service/AihrCaseServiceTest.java b/backend/ruoyi-modules/ruoyi-aihr/src/test/java/org/dromara/aihr/service/AihrCaseServiceTest.java index 2194352f..42a922e7 100644 --- a/backend/ruoyi-modules/ruoyi-aihr/src/test/java/org/dromara/aihr/service/AihrCaseServiceTest.java +++ b/backend/ruoyi-modules/ruoyi-aihr/src/test/java/org/dromara/aihr/service/AihrCaseServiceTest.java @@ -22,6 +22,7 @@ import static org.junit.jupiter.api.Assertions.assertFalse; import static org.junit.jupiter.api.Assertions.assertThrows; import static org.junit.jupiter.api.Assertions.assertTrue; import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; class AihrCaseServiceTest { @@ -73,6 +74,31 @@ class AihrCaseServiceTest { assertEquals("https://oss.local/cases/case.webm", jdbcTemplate.updateArgs[10]); } + @Test + @Tag("dev") + void uploadDeletesOssWhenCaseRecordInsertFails() { + AihrSpeechService speechService = new AihrSpeechService(null, new ObjectMapper()) { + @Override + public Optional transcribe(byte[] audio, String filename, String contentType) { + return Optional.of("现场投诉已完成跟进"); + } + }; + SysOssVo oss = new SysOssVo(); + oss.setOssId(789L); + oss.setUrl("https://oss.local/cases/failed-case.webm"); + ISysOssService ossService = mock(ISysOssService.class); + when(ossService.upload(org.mockito.ArgumentMatchers.any(org.springframework.web.multipart.MultipartFile.class))).thenReturn(oss); + CaseJdbcTemplate jdbcTemplate = new CaseJdbcTemplate(); + jdbcTemplate.failOnUpdate = true; + AihrCaseService service = new AihrCaseService(speechService, null, new ObjectMapper(), jdbcTemplate, ossService); + + assertThrows(IllegalStateException.class, () -> service.upload( + new MockMultipartFile("file", "case.webm", "audio/webm", new byte[]{1}), "P1", null + )); + + verify(ossService).deleteWithValidByIds(List.of(789L), false); + } + @Test @Tag("dev") void curateCanReloadCaseAfterServiceRestart() { @@ -186,6 +212,7 @@ class AihrCaseServiceTest { private static class CaseJdbcTemplate extends JdbcTemplate { private Object[] updateArgs = new Object[0]; + private boolean failOnUpdate; @Override public void execute(String sql) { @@ -194,6 +221,9 @@ class AihrCaseServiceTest { @Override public int update(String sql, Object... args) { + if (failOnUpdate) { + throw new IllegalStateException("simulated case record insert failure"); + } this.updateArgs = args; return 1; } diff --git a/docs/BRD_IMPLEMENTATION_AUDIT.md b/docs/BRD_IMPLEMENTATION_AUDIT.md index 6ecba0b5..f11deb00 100644 --- a/docs/BRD_IMPLEMENTATION_AUDIT.md +++ b/docs/BRD_IMPLEMENTATION_AUDIT.md @@ -1,5 +1,6 @@ # BRD 功能审查与未完成项记录 +- 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 原始文件名与下载响应头。 更新时间:2026-07-14 diff --git a/scripts/demo-check.sh b/scripts/demo-check.sh index 38577a10..89996d2c 100755 --- a/scripts/demo-check.sh +++ b/scripts/demo-check.sh @@ -627,7 +627,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/AihrSopSeedService.java "aihr_knowledge_gap" contains backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/service/AihrMultipartFiles.java "withOriginalFilename" contains backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/service/AihrCandidateMaterialService.java "AihrMultipartFiles.withOriginalFilename" +contains backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/service/AihrCandidateMaterialService.java "deleteUploadedOssQuietly" contains backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/service/AihrCaseService.java "AihrMultipartFiles.withOriginalFilename" +contains backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/service/AihrCaseService.java "cleanupFailedUpload" contains backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/service/AihrSopSeedService.java "AihrMultipartFiles.withOriginalFilename" contains backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/controller/AihrSpeechController.java "AihrMultipartFiles.withOriginalFilename" contains backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/service/AihrSopSeedService.java "o.tenant_id = a.tenant_id"