fix(aihr): sanitize multipart filenames before storage
This commit is contained in:
+5
-2
@@ -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(处理错误已隐藏)");
|
||||
|
||||
+1
-1
@@ -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("""
|
||||
|
||||
+3
-3
@@ -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(), ""));
|
||||
}
|
||||
|
||||
|
||||
+75
@@ -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;
|
||||
}
|
||||
}
|
||||
+3
-2
@@ -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));
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
+32
@@ -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"));
|
||||
}
|
||||
}
|
||||
@@ -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 核验,未执行生产静态同步、后端重启或业务数据写入。
|
||||
|
||||
@@ -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()"
|
||||
|
||||
Reference in New Issue
Block a user