fix(aihr): tighten upload and rag evidence boundaries

This commit is contained in:
2026-07-14 09:28:22 +08:00
parent dc8d4840bf
commit 896495dddf
9 changed files with 97 additions and 5 deletions
@@ -16,6 +16,8 @@ import java.sql.Statement;
import java.time.LocalDateTime;
import java.time.format.DateTimeFormatter;
import java.util.List;
import java.util.Locale;
import java.util.Set;
@Service
@RequiredArgsConstructor
@@ -24,6 +26,9 @@ public class AihrCandidateMaterialService {
private static final String TENANT_ID = "000000";
private static final DateTimeFormatter TIME_FORMAT = DateTimeFormatter.ofPattern("MM-dd HH:mm");
private static final long MAX_SIZE = 20L * 1024 * 1024;
private static final Set<String> SUPPORTED_MATERIAL_EXTENSIONS = Set.of(
".pdf", ".doc", ".docx", ".jpg", ".jpeg", ".png", ".webp"
);
private final JdbcTemplate jdbcTemplate;
private final ISysOssService ossService;
@@ -39,7 +44,10 @@ public class AihrCandidateMaterialService {
throw new IllegalArgumentException("资料不能超过20MB");
}
String boundCandidateId = requireCandidateId(candidateId);
String fileName = clean(file.getOriginalFilename(), "candidate-material");
String fileName = sanitizeMaterialFileName(file.getOriginalFilename());
if (!isSupportedMaterialFile(fileName)) {
throw new IllegalArgumentException("候选人资料仅支持 PDF、Word 或 JPG/PNG/WebP 图片");
}
String type = clean(materialType, materialType(fileName));
SysOssVo oss = ossService.upload(file);
KeyHolder keyHolder = new GeneratedKeyHolder();
@@ -205,6 +213,20 @@ public class AihrCandidateMaterialService {
return text;
}
static boolean isSupportedMaterialFile(String fileName) {
String lower = clean(fileName, "").toLowerCase(Locale.ROOT);
return SUPPORTED_MATERIAL_EXTENSIONS.stream().anyMatch(lower::endsWith);
}
static String sanitizeMaterialFileName(String original) {
String name = clean(original, "candidate-material");
name = name.replaceAll("[\\\\/\\r\\n\\t]", "_");
if (name.length() > 200) {
name = name.substring(name.length() - 200);
}
return name;
}
private static String normalizeReviewStatus(String status) {
String text = clean(status, "待审核");
if ("已通过".equals(text) || "已驳回".equals(text) || "待审核".equals(text)) {
@@ -26,8 +26,10 @@ import java.time.format.DateTimeFormatter;
import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.Optional;
import java.util.Set;
import java.util.UUID;
import java.util.concurrent.ConcurrentHashMap;
import java.util.stream.Collectors;
@@ -38,6 +40,9 @@ import java.util.stream.Collectors;
public class AihrCaseService {
private static final DateTimeFormatter TIME_FORMAT = DateTimeFormatter.ofPattern("MM-dd HH:mm");
private static final Set<String> SUPPORTED_AUDIO_EXTENSIONS = Set.of(
".mp3", ".wav", ".m4a", ".webm", ".ogg", ".aac", ".flac"
);
private final AihrSpeechService speechService;
private final AihrModelSeedService modelService;
@@ -51,8 +56,11 @@ public class AihrCaseService {
if (file == null || file.isEmpty()) {
throw new IllegalArgumentException("请上传语音文件");
}
String fileName = sanitizeAudioFileName(file.getOriginalFilename());
if (!isSupportedAudioFile(fileName)) {
throw new IllegalArgumentException("案例素材仅支持 MP3、WAV、M4A、WebM、OGG、AAC 或 FLAC 音频");
}
String scopedProject = resolveUploadProject(projectExtOrgId, projectScopes);
String fileName = firstNonBlank(file.getOriginalFilename(), "case-audio.webm");
String contentType = firstNonBlank(file.getContentType(), "application/octet-stream");
try {
String transcript = speechService.transcribe(file.getBytes(), fileName, contentType)
@@ -527,6 +535,20 @@ public class AihrCaseService {
return clean(AihrSensitiveText.forModel(text));
}
static boolean isSupportedAudioFile(String fileName) {
String lower = clean(fileName).toLowerCase(Locale.ROOT);
return SUPPORTED_AUDIO_EXTENSIONS.stream().anyMatch(lower::endsWith);
}
static String sanitizeAudioFileName(String original) {
String name = firstNonBlank(original, "case-audio.webm");
name = name.replaceAll("[\\\\/\\r\\n\\t]", "_");
if (name.length() > 200) {
name = name.substring(name.length() - 200);
}
return name;
}
private record CaseState(
String id,
String fileName,
@@ -1832,7 +1832,7 @@ public class AihrSopSeedService {
}
private SearchResponse withReviewId(SearchResponse response, String source) {
Long reviewId = skipSopReview(source) ? null : createSopReview(response, source);
Long reviewId = shouldCreateSopReview(response.queryText(), source) ? createSopReview(response, source) : null;
return new SearchResponse(
response.queryText(),
response.category(),
@@ -1853,6 +1853,9 @@ public class AihrSopSeedService {
return "verify_demo_questions".equals(source);
}
static boolean shouldCreateSopReview(String queryText, String source) {
return !isBlank(queryText) && !skipSopReview(source);
}
private Long createSopReview(SearchResponse response, String source) {
try {
ensureSopReviewTable();
@@ -5,7 +5,9 @@ import org.junit.jupiter.api.Tag;
import org.junit.jupiter.api.Test;
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;
class AihrCandidateMaterialServiceTest {
@@ -16,4 +18,20 @@ class AihrCandidateMaterialServiceTest {
assertThrows(ServiceException.class, () -> AihrCandidateMaterialService.requireCandidateId(""));
assertThrows(ServiceException.class, () -> AihrCandidateMaterialService.requireCandidateId(null));
}
@Test
@Tag("dev")
void candidateMaterialFileMustUseTheSameAllowlistAsTheMobilePicker() {
assertTrue(AihrCandidateMaterialService.isSupportedMaterialFile("resume.PDF"));
assertTrue(AihrCandidateMaterialService.isSupportedMaterialFile("certificate.webp"));
assertFalse(AihrCandidateMaterialService.isSupportedMaterialFile("payload.exe"));
assertFalse(AihrCandidateMaterialService.isSupportedMaterialFile("archive.zip"));
}
@Test
@Tag("dev")
void candidateMaterialFileNameCannotCarryPathOrControlCharacters() {
assertEquals(".._.._candidate.pdf", AihrCandidateMaterialService.sanitizeMaterialFileName("../../candidate.pdf"));
assertEquals("candidate_ name.pdf", AihrCandidateMaterialService.sanitizeMaterialFileName("candidate\t name.pdf"));
}
}
@@ -129,6 +129,16 @@ class AihrCaseServiceTest {
));
}
@Test
@Tag("dev")
void caseAudioAllowlistRejectsNonAudioExtensions() {
assertTrue(AihrCaseService.isSupportedAudioFile("现场录音.WEBM"));
assertTrue(AihrCaseService.isSupportedAudioFile("follow-up.m4a"));
assertFalse(AihrCaseService.isSupportedAudioFile("payload.exe"));
assertFalse(AihrCaseService.isSupportedAudioFile("notes.pdf"));
assertEquals(".._.._case.webm", AihrCaseService.sanitizeAudioFileName("../../case.webm"));
}
@Test
@Tag("dev")
void caseWriteControllerRequiresHrRoleForSystemUsers() throws Exception {
@@ -36,6 +36,14 @@ public class AihrSopSeedServiceTest {
assertFalse(AihrSopSeedService.skipSopReview("knowledge_search"));
}
@Test
@Tag("dev")
public void emptySopQuestionCannotCreateReviewSample() {
assertFalse(AihrSopSeedService.shouldCreateSopReview(" ", "knowledge_search"));
assertTrue(AihrSopSeedService.shouldCreateSopReview("物业费怎么交", "knowledge_search"));
assertFalse(AihrSopSeedService.shouldCreateSopReview("物业费怎么交", "verify_demo_questions"));
}
@Test
@Tag("dev")
public void evidenceHitsKeepsNamedDocumentOnly() {