fix(aihr): clean up orphaned upload objects
This commit is contained in:
+18
@@ -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,6 +54,7 @@ public class AihrCandidateMaterialService {
|
||||
String type = clean(materialType, materialType(fileName));
|
||||
SysOssVo oss = ossService.upload(AihrMultipartFiles.withOriginalFilename(file, fileName));
|
||||
KeyHolder keyHolder = new GeneratedKeyHolder();
|
||||
try {
|
||||
jdbcTemplate.update(connection -> {
|
||||
PreparedStatement ps = connection.prepareStatement("""
|
||||
insert into aihr_candidate_material
|
||||
@@ -69,10 +72,25 @@ public class AihrCandidateMaterialService {
|
||||
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);
|
||||
|
||||
+20
-1
@@ -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) {
|
||||
|
||||
+46
@@ -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");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+30
@@ -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,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
|
||||
|
||||
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user