fix(aihr): clean up orphaned upload objects

This commit is contained in:
2026-07-14 21:41:22 +08:00
parent dd5c0dadff
commit 30a473b365
6 changed files with 134 additions and 18 deletions
@@ -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<MaterialResponse> list(String candidateId) {
ensureTable();
String boundCandidateId = requireCandidateId(candidateId);
@@ -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) {
@@ -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> T queryForObject(String sql, Class<T> requiredType, Object... args) {
return requiredType.cast(1);
}
@Override
public int update(PreparedStatementCreator psc, KeyHolder generatedKeyHolder) {
throw new IllegalStateException("simulated candidate material insert failure");
}
}
}
@@ -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<String> 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;
}
+1
View File
@@ -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
+2
View File
@@ -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"