fix(aihr): sanitize multipart filenames before storage

This commit is contained in:
2026-07-14 21:34:45 +08:00
parent 39febf87b2
commit dd5c0dadff
8 changed files with 126 additions and 8 deletions
@@ -6,6 +6,7 @@ import lombok.extern.slf4j.Slf4j;
import org.dromara.aihr.domain.AihrSpeechDto.AsrResponse; import org.dromara.aihr.domain.AihrSpeechDto.AsrResponse;
import org.dromara.aihr.domain.AihrSpeechDto.TtsRequest; import org.dromara.aihr.domain.AihrSpeechDto.TtsRequest;
import org.dromara.aihr.domain.AihrSpeechDto.TtsResponse; import org.dromara.aihr.domain.AihrSpeechDto.TtsResponse;
import org.dromara.aihr.service.AihrMultipartFiles;
import org.dromara.aihr.service.AihrSpeechService; import org.dromara.aihr.service.AihrSpeechService;
import org.dromara.common.core.domain.R; import org.dromara.common.core.domain.R;
import org.dromara.system.domain.vo.SysOssVo; import org.dromara.system.domain.vo.SysOssVo;
@@ -52,10 +53,12 @@ public class AihrSpeechController {
if (file.getSize() > MAX_ASR_BYTES) { if (file.getSize() > MAX_ASR_BYTES) {
return R.fail("音频过大(上限 5MB),请缩短录音时长"); 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 -> { .map(text -> {
try { try {
SysOssVo oss = ossService.upload(file); SysOssVo oss = ossService.upload(sanitizedFile);
return R.ok(new AsrResponse(text, "openai-compatible", oss.getOssId(), oss.getUrl())); return R.ok(new AsrResponse(text, "openai-compatible", oss.getOssId(), oss.getUrl()));
} catch (Exception e) { } catch (Exception e) {
log.warn("asr audio upload failed(处理错误已隐藏)"); log.warn("asr audio upload failed(处理错误已隐藏)");
@@ -50,7 +50,7 @@ public class AihrCandidateMaterialService {
throw new IllegalArgumentException("候选人资料仅支持 PDF、Word 或 JPG/PNG/WebP 图片"); throw new IllegalArgumentException("候选人资料仅支持 PDF、Word 或 JPG/PNG/WebP 图片");
} }
String type = clean(materialType, materialType(fileName)); String type = clean(materialType, materialType(fileName));
SysOssVo oss = ossService.upload(file); SysOssVo oss = ossService.upload(AihrMultipartFiles.withOriginalFilename(file, fileName));
KeyHolder keyHolder = new GeneratedKeyHolder(); KeyHolder keyHolder = new GeneratedKeyHolder();
jdbcTemplate.update(connection -> { jdbcTemplate.update(connection -> {
PreparedStatement ps = connection.prepareStatement(""" PreparedStatement ps = connection.prepareStatement("""
@@ -67,7 +67,7 @@ public class AihrCaseService {
String transcript = speechService.transcribe(file.getBytes(), fileName, contentType) String transcript = speechService.transcribe(file.getBytes(), fileName, contentType)
.orElseThrow(() -> new IllegalStateException("ASR 未配置或转写失败")); .orElseThrow(() -> new IllegalStateException("ASR 未配置或转写失败"));
transcript = maskSensitiveText(transcript); transcript = maskSensitiveText(transcript);
MediaRef media = storeSourceAudio(file); MediaRef media = storeSourceAudio(file, fileName);
String caseId = "case-" + UUID.randomUUID(); String caseId = "case-" + UUID.randomUUID();
CaseState state = new CaseState(caseId, fileName, scopedProject, transcript, tagsFromText(transcript), null, CaseState state = new CaseState(caseId, fileName, scopedProject, transcript, tagsFromText(transcript), null,
media.ossId(), media.url(), currentCaseOwner()); media.ossId(), media.url(), currentCaseOwner());
@@ -440,12 +440,12 @@ public class AihrCaseService {
return String.join(",", Collections.nCopies(size, "?")); 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. // Unit tests can construct this service without the system OSS bean; production always wires it.
if (ossService == null) { if (ossService == null) {
return new MediaRef(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(), "")); return new MediaRef(oss.getOssId(), firstNonBlank(oss.getUrl(), ""));
} }
@@ -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;
}
}
@@ -389,7 +389,7 @@ public class AihrSopSeedService {
if (file == null || file.isEmpty()) { if (file == null || file.isEmpty()) {
throw new ServiceException("上传文件不能为空"); throw new ServiceException("上传文件不能为空");
} }
String fileName = Optional.ofNullable(file.getOriginalFilename()).orElse("knowledge.txt").trim(); String fileName = AihrMultipartFiles.sanitizeFileName(file.getOriginalFilename(), "knowledge.txt");
if (!supportedFile(fileName)) { if (!supportedFile(fileName)) {
throw new ServiceException("仅支持 txt/md/markdown/pdf/doc/docx/xls/xlsx/ppt/pptx/图片文件"); throw new ServiceException("仅支持 txt/md/markdown/pdf/doc/docx/xls/xlsx/ppt/pptx/图片文件");
} }
@@ -397,7 +397,8 @@ public class AihrSopSeedService {
throw new ServiceException("文件不能超过 100MB"); 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));
} }
/** /**
@@ -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"));
}
}
+2
View File
@@ -1,5 +1,7 @@
# BRD 功能审查与未完成项记录 # BRD 功能审查与未完成项记录
- 2026-07-14 BRD G3 文件名边界复核:候选资料、案例音频、同步 SOP 文档和 ASR 音频此前虽然分别校验或清理文件名,但仍有路径把原始 `MultipartFile` 直接交给 `sys_oss`,清理后的名字没有贯通到 OSS 元数据;现新增共用 `AihrMultipartFiles` 包装器,四条链路均以清理后的文件名上传/外发,并补单测与 `demo-check` marker。该修复不改变文件类型、大小、权限和业务状态,仍需正式环境抽查 OSS 原始文件名与下载响应头。
更新时间:2026-07-14 更新时间: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 核验,未执行生产静态同步、后端重启或业务数据写入。 - 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 核验,未执行生产静态同步、后端重启或业务数据写入。
+5
View File
@@ -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 "仅支持 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 "/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/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 "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 "where tenant_id = ? and oss_id = ? limit 1"
contains backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/service/AihrSopSeedService.java "deleteQdrantTenantPoints()" contains backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/service/AihrSopSeedService.java "deleteQdrantTenantPoints()"