This commit is contained in:
2026-09-11 16:17:16 +08:00
parent 2d928d667b
commit 51ce609ab1
57 changed files with 942 additions and 139 deletions
@@ -25,7 +25,8 @@ import java.util.UUID;
public class LocalQualificationStorage {
private static final Logger LOGGER = LoggerFactory.getLogger(LocalQualificationStorage.class);
private static final long MAX_SIZE = 5L * 1024 * 1024;
private static final Set<String> TYPES = Set.of("image/jpeg", "image/png", "application/pdf");
private static final Set<String> TYPES = Set.of(
"image/jpeg", "image/png", "image/bmp", "image/x-ms-bmp", "application/pdf");
@Value("${baiyue.file.storage-root}") private String configuredRoot;
private Path root;
private Path qualificationRoot;
@@ -53,11 +54,16 @@ public class LocalQualificationStorage {
if (file == null || file.isEmpty()) throw new BizException("One qualification file is required / 请选择资质文件");
if (file.getSize() > MAX_SIZE) throw new BizException("Qualification file must not exceed 5 MB / 资质文件不能超过 5MB");
String contentType = file.getContentType() == null ? "" : file.getContentType().toLowerCase(Locale.ROOT);
if (!TYPES.contains(contentType)) throw new BizException("Only PDF, JPG and PNG files are supported / 仅支持 PDF、JPG、PNG 文件");
if (!TYPES.contains(contentType)) throw new BizException("Only PDF, JPG, PNG and BMP files are supported / 仅支持 PDF、JPG、PNG、BMP 文件");
if (!matchesSignature(file, contentType)) {
throw new BizException("The uploaded file content does not match its type / 文件内容与格式不匹配");
}
String extension = "image/png".equals(contentType) ? ".png" : ("application/pdf".equals(contentType) ? ".pdf" : ".jpg");
String extension = switch (contentType) {
case "image/png" -> ".png";
case "image/bmp", "image/x-ms-bmp" -> ".bmp";
case "application/pdf" -> ".pdf";
default -> ".jpg";
};
String fileName = UUID.randomUUID() + extension;
Path directory = tenantDirectory(tenantId);
Path target = directory.resolve(fileName).normalize();
@@ -92,6 +98,9 @@ public class LocalQualificationStorage {
return read >= 5 && header[0] == 0x25 && header[1] == 0x50 && header[2] == 0x44
&& header[3] == 0x46 && header[4] == 0x2d;
}
if ("image/bmp".equals(contentType) || "image/x-ms-bmp".equals(contentType)) {
return read >= 2 && header[0] == 0x42 && header[1] == 0x4d;
}
return read >= 8 && (header[0] & 0xff) == 0x89 && header[1] == 0x50
&& header[2] == 0x4e && header[3] == 0x47 && header[4] == 0x0d
&& header[5] == 0x0a && header[6] == 0x1a && header[7] == 0x0a;
@@ -101,7 +110,7 @@ public class LocalQualificationStorage {
}
public Resource load(long tenantId, String fileName) {
if (fileName == null || !fileName.matches("[0-9a-fA-F-]{36}\\.(jpg|png|pdf)")) {
if (fileName == null || !fileName.matches("[0-9a-fA-F-]{36}\\.(jpg|png|bmp|pdf)")) {
throw fileNotFound();
}
Path directory = tenantDirectory(tenantId);
@@ -124,7 +133,7 @@ public class LocalQualificationStorage {
String prefix = "/api/merchant/v1/files/qualifications/";
if (!documentUrl.startsWith(prefix)) return;
String fileName = documentUrl.substring(prefix.length());
if (!fileName.matches("[0-9a-fA-F-]{36}\\.(jpg|png|pdf)")) return;
if (!fileName.matches("[0-9a-fA-F-]{36}\\.(jpg|png|bmp|pdf)")) return;
Path directory = tenantDirectory(tenantId);
Path target = directory.resolve(fileName).normalize();
if (!target.startsWith(directory)) return;
@@ -29,7 +29,7 @@ paths:
type: string
format: binary
maxLength: 5242880
description: PDF/JPG/PNG,最大5MB,声明媒体类型必须与文件签名一致
description: PDF/JPG/PNG/BMP,最大5MB,声明媒体类型必须与文件签名一致
responses:
'200':
description: 成功或业务失败,失败时按 code 判断并保留 requestId/traceId
@@ -139,12 +139,12 @@ components:
businessLicense:
type: string
format: binary
description: 营业执照,必填;PDF/JPG/PNG,最大5MB
description: 营业执照,必填;PDF/JPG/PNG/BMP,最大5MB
legalPersonId:
type: string
format: binary
nullable: true
description: 法人身份证,选填;PDF/JPG/PNG,最大5MB
description: 法人身份证,选填;PDF/JPG/PNG/BMP,最大5MB
responses:
Error:
description: 认证或授权失败,响应包含请求关联元数据
@@ -16,7 +16,7 @@ class QualificationAndOrderFileOpenApiContractTest {
assertThat(yaml)
.contains("/api/merchant/v1/files/qualifications:")
.contains("enum: [LEGAL_PERSON_ID, FREIGHT_FORWARDER_CERTIFICATE, BANK_ACCOUNT_LICENSE]")
.contains("merchant:qualification:upload", "maxLength: 5242880")
.contains("merchant:qualification:upload", "maxLength: 5242880", "PDF/JPG/PNG/BMP")
.contains("name: replace", "requestId:", "traceId:")
.doesNotContain("enum: [BUSINESS_LICENSE, LEGAL_PERSON_ID, FREIGHT_FORWARDER_CERTIFICATE, BANK_ACCOUNT_LICENSE]");
}
@@ -43,7 +43,7 @@ class LocalQualificationStorageTest {
Path qualificationRoot = storageRoot.resolve("qualifications");
assertTrue(Files.isDirectory(qualificationRoot));
try (var files = Files.list(qualificationRoot)) {
assertEquals(0, files.count());
assertFalse(files.anyMatch(path -> path.getFileName().toString().startsWith(".file-service-probe-")));
}
}
@@ -78,6 +78,47 @@ class LocalQualificationStorageTest {
storage.load(10001L, stored.fileName()).getFile().toPath().toAbsolutePath());
}
@Test
void storesBmpQualificationWithEitherStandardMimeTypeAndKeepsTenantOwnership() throws Exception {
storage.initialize();
byte[] bmp = {0x42, 0x4d, 0x01, 0x00, 0x00, 0x00, 0x00, 0x00};
for (String contentType : new String[] {"image/bmp", "image/x-ms-bmp"}) {
var upload = new MockMultipartFile("file", "license.bmp", contentType, bmp);
var stored = storage.store(10001L, upload);
assertTrue(stored.fileName().endsWith(".bmp"));
assertEquals(stored.path().toAbsolutePath(),
storage.load(10001L, stored.fileName()).getFile().toPath().toAbsolutePath());
BizException crossTenant = assertThrows(BizException.class,
() -> storage.load(10002L, stored.fileName()));
assertEquals(ResultCode.NOT_FOUND, crossTenant.getResultCode());
}
}
@Test
void rejectsBmpMimeTypeWhenTheFileSignatureIsNotBmp() {
storage.initialize();
var upload = new MockMultipartFile("file", "license.bmp", "image/bmp", "not-bmp".getBytes());
BizException exception = assertThrows(BizException.class,
() -> storage.store(10001L, upload));
assertTrue(exception.getMessage().contains("文件内容与格式不匹配"));
}
@Test
void deletesBmpQualificationByItsValidatedDocumentUrl() {
storage.initialize();
byte[] bmp = {0x42, 0x4d, 0x01, 0x00, 0x00, 0x00, 0x00, 0x00};
var stored = storage.store(10001L,
new MockMultipartFile("file", "license.bmp", "image/bmp", bmp));
storage.deleteByDocumentUrl(10001L, stored.documentUrl());
assertFalse(Files.exists(stored.path()));
}
@Test
void rejectsQualificationFilesLargerThanTheClientFiveMegabyteLimit() {
storage.initialize();