diff --git a/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/controller/AihrSpeechController.java b/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/controller/AihrSpeechController.java index 5d2e6a81..94848150 100644 --- a/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/controller/AihrSpeechController.java +++ b/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/controller/AihrSpeechController.java @@ -6,6 +6,7 @@ import lombok.extern.slf4j.Slf4j; 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.system.domain.vo.SysOssVo; @@ -52,10 +53,12 @@ public class AihrSpeechController { if (file.getSize() > MAX_ASR_BYTES) { return R.fail("音频过大(上限 5MB),请缩短录音时长"); } - return speechService.transcribe(file.getBytes(), file.getOriginalFilename(), file.getContentType()) + String fileName = AihrMultipartFiles.sanitizeFileName(file.getOriginalFilename(), "audio.webm"); + MultipartFile sanitizedFile = AihrMultipartFiles.withOriginalFilename(file, fileName); + return speechService.transcribe(sanitizedFile.getBytes(), fileName, sanitizedFile.getContentType()) .map(text -> { try { - SysOssVo oss = ossService.upload(file); + SysOssVo oss = ossService.upload(sanitizedFile); return R.ok(new AsrResponse(text, "openai-compatible", oss.getOssId(), oss.getUrl())); } catch (Exception e) { log.warn("asr audio upload failed(处理错误已隐藏)"); 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 b6f11ac0..ac898d31 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 @@ -50,7 +50,7 @@ public class AihrCandidateMaterialService { throw new IllegalArgumentException("候选人资料仅支持 PDF、Word 或 JPG/PNG/WebP 图片"); } String type = clean(materialType, materialType(fileName)); - SysOssVo oss = ossService.upload(file); + SysOssVo oss = ossService.upload(AihrMultipartFiles.withOriginalFilename(file, fileName)); KeyHolder keyHolder = new GeneratedKeyHolder(); jdbcTemplate.update(connection -> { PreparedStatement ps = connection.prepareStatement(""" 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 a46f6f0a..083d2263 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 @@ -67,7 +67,7 @@ public class AihrCaseService { String transcript = speechService.transcribe(file.getBytes(), fileName, contentType) .orElseThrow(() -> new IllegalStateException("ASR 未配置或转写失败")); transcript = maskSensitiveText(transcript); - MediaRef media = storeSourceAudio(file); + MediaRef media = storeSourceAudio(file, fileName); String caseId = "case-" + UUID.randomUUID(); CaseState state = new CaseState(caseId, fileName, scopedProject, transcript, tagsFromText(transcript), null, media.ossId(), media.url(), currentCaseOwner()); @@ -440,12 +440,12 @@ public class AihrCaseService { return String.join(",", Collections.nCopies(size, "?")); } - private MediaRef storeSourceAudio(MultipartFile file) { + private MediaRef storeSourceAudio(MultipartFile file, String fileName) { // Unit tests can construct this service without the system OSS bean; production always wires it. if (ossService == null) { return new MediaRef(null, ""); } - SysOssVo oss = ossService.upload(file); + SysOssVo oss = ossService.upload(AihrMultipartFiles.withOriginalFilename(file, fileName)); return new MediaRef(oss.getOssId(), firstNonBlank(oss.getUrl(), "")); } diff --git a/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/service/AihrMultipartFiles.java b/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/service/AihrMultipartFiles.java new file mode 100644 index 00000000..f25a3e02 --- /dev/null +++ b/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/service/AihrMultipartFiles.java @@ -0,0 +1,75 @@ +package org.dromara.aihr.service; + +import org.springframework.web.multipart.MultipartFile; + +import java.io.File; +import java.io.IOException; +import java.io.InputStream; + +/** + * Multipart 文件边界工具:清理后的文件名必须继续贯穿到 OSS 元数据和外部请求。 + */ +public final class AihrMultipartFiles { + + private AihrMultipartFiles() { + } + + public static MultipartFile withOriginalFilename(MultipartFile delegate, String originalFilename) { + if (delegate == null) { + throw new IllegalArgumentException("文件不能为空"); + } + String safeName = sanitizeFileName(originalFilename, "upload.bin"); + return new MultipartFile() { + @Override + public String getName() { + return delegate.getName(); + } + + @Override + public String getOriginalFilename() { + return safeName; + } + + @Override + public String getContentType() { + return delegate.getContentType(); + } + + @Override + public boolean isEmpty() { + return delegate.isEmpty(); + } + + @Override + public long getSize() { + return delegate.getSize(); + } + + @Override + public byte[] getBytes() throws IOException { + return delegate.getBytes(); + } + + @Override + public InputStream getInputStream() throws IOException { + return delegate.getInputStream(); + } + + @Override + public void transferTo(File dest) throws IOException, IllegalStateException { + delegate.transferTo(dest); + } + }; + } + + public static String sanitizeFileName(String originalFilename, String fallback) { + String name = originalFilename == null || originalFilename.isBlank() + ? fallback + : originalFilename.trim(); + name = name.replaceAll("[\\\\/\\r\\n\\t]", "_"); + if (name.length() > 200) { + name = name.substring(name.length() - 200); + } + return name.isBlank() ? fallback : name; + } +} diff --git a/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/service/AihrSopSeedService.java b/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/service/AihrSopSeedService.java index 17a5ec0d..f3e0551e 100644 --- a/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/service/AihrSopSeedService.java +++ b/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/service/AihrSopSeedService.java @@ -389,7 +389,7 @@ public class AihrSopSeedService { if (file == null || file.isEmpty()) { throw new ServiceException("上传文件不能为空"); } - String fileName = Optional.ofNullable(file.getOriginalFilename()).orElse("knowledge.txt").trim(); + String fileName = AihrMultipartFiles.sanitizeFileName(file.getOriginalFilename(), "knowledge.txt"); if (!supportedFile(fileName)) { throw new ServiceException("仅支持 txt/md/markdown/pdf/doc/docx/xls/xlsx/ppt/pptx/图片文件"); } @@ -397,7 +397,8 @@ public class AihrSopSeedService { throw new ServiceException("文件不能超过 100MB"); } - return saveDocument(fileName, category, () -> ossService.upload(file), () -> readContent(file, fileName), () -> fileFingerprint(file)); + MultipartFile sanitizedFile = AihrMultipartFiles.withOriginalFilename(file, fileName); + return saveDocument(fileName, category, () -> ossService.upload(sanitizedFile), () -> readContent(sanitizedFile, fileName), () -> fileFingerprint(sanitizedFile)); } /** diff --git a/backend/ruoyi-modules/ruoyi-aihr/src/test/java/org/dromara/aihr/service/AihrMultipartFilesTest.java b/backend/ruoyi-modules/ruoyi-aihr/src/test/java/org/dromara/aihr/service/AihrMultipartFilesTest.java new file mode 100644 index 00000000..fc1b7480 --- /dev/null +++ b/backend/ruoyi-modules/ruoyi-aihr/src/test/java/org/dromara/aihr/service/AihrMultipartFilesTest.java @@ -0,0 +1,32 @@ +package org.dromara.aihr.service; + +import org.junit.jupiter.api.Tag; +import org.junit.jupiter.api.Test; +import org.springframework.mock.web.MockMultipartFile; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +class AihrMultipartFilesTest { + + @Test + @Tag("dev") + void sanitizedNameMustReachTheMultipartPassedToStorage() { + MockMultipartFile source = new MockMultipartFile( + "file", "../../case\r\n.webm", "audio/webm", new byte[]{1, 2, 3} + ); + + var sanitized = AihrMultipartFiles.withOriginalFilename(source, AihrMultipartFiles.sanitizeFileName( + source.getOriginalFilename(), "audio.webm" + )); + + assertEquals(".._.._case__.webm", sanitized.getOriginalFilename()); + assertEquals(source.getSize(), sanitized.getSize()); + assertEquals("audio/webm", sanitized.getContentType()); + } + + @Test + @Tag("dev") + void blankNameUsesSafeFallback() { + assertEquals("upload.bin", AihrMultipartFiles.sanitizeFileName(" \t", "upload.bin")); + } +} diff --git a/docs/BRD_IMPLEMENTATION_AUDIT.md b/docs/BRD_IMPLEMENTATION_AUDIT.md index dd83fa74..6ecba0b5 100644 --- a/docs/BRD_IMPLEMENTATION_AUDIT.md +++ b/docs/BRD_IMPLEMENTATION_AUDIT.md @@ -1,5 +1,7 @@ # BRD 功能审查与未完成项记录 +- 2026-07-14 BRD G3 文件名边界复核:候选资料、案例音频、同步 SOP 文档和 ASR 音频此前虽然分别校验或清理文件名,但仍有路径把原始 `MultipartFile` 直接交给 `sys_oss`,清理后的名字没有贯通到 OSS 元数据;现新增共用 `AihrMultipartFiles` 包装器,四条链路均以清理后的文件名上传/外发,并补单测与 `demo-check` marker。该修复不改变文件类型、大小、权限和业务状态,仍需正式环境抽查 OSS 原始文件名与下载响应头。 + 更新时间:2026-07-14 - 2026-07-14 21:28 BRD 发布只读复核:生产根站、`/h5/` 和 `/prod-api/auth/tenant/list` 均返回 `200`;线上管理端仍加载 `assets/index-CJZ3Ax3Z.js`(SHA-256 `8b9278a26ccb760abce489b12a20b221748c64d4c4153613d2511777bb9677bc`),H5 仍加载 `assets/index-D4-NrEpb.js`(SHA-256 `ab15bfd17cabe58e2f34b0ac61ee198a837128ed85dc6bb0ddf77bc3e22dcd9c`)。本地最新提交 `d2b0035e` 的租户隔离修复尚未发布;本轮仅做 GET/hash 核验,未执行生产静态同步、后端重启或业务数据写入。 diff --git a/scripts/demo-check.sh b/scripts/demo-check.sh index bfa217b4..38577a10 100755 --- a/scripts/demo-check.sh +++ b/scripts/demo-check.sh @@ -625,6 +625,11 @@ contains backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/control contains backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/service/AihrSopSeedService.java "仅支持 txt/md/markdown/pdf/doc/docx/xls/xlsx/ppt/pptx/图片文件" contains backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/service/AihrSopSeedService.java "/embeddings" 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/AihrCaseService.java "AihrMultipartFiles.withOriginalFilename" +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" contains backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/service/AihrSopSeedService.java "where tenant_id = ? and oss_id = ? limit 1" contains backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/service/AihrSopSeedService.java "deleteQdrantTenantPoints()"