This commit is contained in:
2026-08-03 14:16:10 +08:00
parent 595a51ab79
commit fe5e3de9b1
20 changed files with 331 additions and 44 deletions
@@ -14,7 +14,15 @@ public interface IamQualificationClient {
@RequestHeader("X-Internal-Service-Token") String internalToken,
@RequestBody QualificationRequest request);
@PostMapping("/platform-qualifications")
ApiResponse<QualificationResponse> submitPlatform(
@RequestHeader("X-Internal-Service-Token") String internalToken,
@RequestBody PlatformQualificationRequest request);
record QualificationRequest(Long tenantId, String qualificationType,
String certificateNo, String documentUrl) { }
record QualificationResponse(Long qualificationId, Long reviewId, String tenantStatus) { }
record PlatformQualificationRequest(Long tenantId, String qualificationType,
String certificateNo, String documentUrl,
java.time.LocalDate validTo) { }
}
@@ -1,8 +1,10 @@
package com.baiyue.file.controller;
import com.baiyue.common.core.api.ResultCode;
import com.baiyue.common.core.api.ApiResponse;
import com.baiyue.common.core.exception.BizException;
import com.baiyue.common.security.AuthenticatedUser;
import com.baiyue.file.client.IamQualificationClient;
import com.baiyue.file.service.LocalQualificationStorage;
import jakarta.annotation.Resource;
import org.springframework.http.CacheControl;
@@ -14,23 +16,67 @@ import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestPart;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.multipart.MultipartFile;
import org.springframework.beans.factory.annotation.Value;
import java.time.LocalDate;
@RestController
@RequestMapping("/api/platform/v1/files/qualifications")
public class PlatformQualificationFileController {
@Resource private LocalQualificationStorage storage;
@Resource private IamQualificationClient iamClient;
@Value("${baiyue.security.internal-service-token}") private String internalToken;
@PostMapping(value = "/{tenantId}", consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
@PreAuthorize("hasAnyAuthority('platform:customer:create','platform:customer:edit')")
public ApiResponse<QualificationFileController.UploadResponse> upload(
@AuthenticationPrincipal AuthenticatedUser user,
@PathVariable long tenantId,
@RequestPart("file") MultipartFile file,
@RequestParam String qualificationType,
@RequestParam(required = false) String certificateNo,
@RequestParam(required = false) LocalDate validTo) {
requirePlatform(user);
var stored = storage.store(tenantId, file);
try {
var response = iamClient.submitPlatform(internalToken,
new IamQualificationClient.PlatformQualificationRequest(
tenantId, qualificationType, certificateNo, stored.documentUrl(), validTo));
if (response == null || !response.successful() || response.data() == null) {
String message = response == null || response.message() == null || response.message().isBlank()
? "IAM qualification service is unavailable" : response.message();
throw new BizException(response == null ? ResultCode.SERVICE_UNAVAILABLE : ResultCode.of(response.code()), message);
}
var data = response.data();
return ApiResponse.success(new QualificationFileController.UploadResponse(
data.qualificationId(), data.reviewId(), data.tenantStatus(), stored.documentUrl()));
} catch (RuntimeException exception) {
storage.delete(stored);
if (exception instanceof BizException business) throw business;
throw new BizException(ResultCode.SERVICE_UNAVAILABLE, "IAM qualification service is unavailable");
}
}
@GetMapping("/{tenantId}/{fileName}")
@PreAuthorize("hasAuthority('platform:customer:review')")
public ResponseEntity<org.springframework.core.io.Resource> download(
@AuthenticationPrincipal AuthenticatedUser user,
@PathVariable long tenantId, @PathVariable String fileName) {
if (user == null || !"PLATFORM".equalsIgnoreCase(user.appCode())) {
throw new BizException(ResultCode.FORBIDDEN, "Platform identity is required");
}
requirePlatform(user);
var resource = storage.load(tenantId, fileName);
String mediaType = fileName.endsWith(".png") ? MediaType.IMAGE_PNG_VALUE : MediaType.IMAGE_JPEG_VALUE;
String mediaType = fileName.endsWith(".png") ? MediaType.IMAGE_PNG_VALUE
: (fileName.endsWith(".pdf") ? MediaType.APPLICATION_PDF_VALUE : MediaType.IMAGE_JPEG_VALUE);
return ResponseEntity.ok().cacheControl(CacheControl.noStore())
.contentType(MediaType.parseMediaType(mediaType)).body(resource);
}
private void requirePlatform(AuthenticatedUser user) {
if (user == null || !"PLATFORM".equalsIgnoreCase(user.appCode())) {
throw new BizException(ResultCode.FORBIDDEN, "Platform identity is required");
}
}
}
@@ -65,7 +65,8 @@ public class QualificationFileController {
public ResponseEntity<org.springframework.core.io.Resource> download(
@AuthenticationPrincipal AuthenticatedUser user, @PathVariable String fileName) {
var resource = storage.load(merchantTenant(user), fileName);
String mediaType = fileName.endsWith(".png") ? MediaType.IMAGE_PNG_VALUE : MediaType.IMAGE_JPEG_VALUE;
String mediaType = fileName.endsWith(".png") ? MediaType.IMAGE_PNG_VALUE
: (fileName.endsWith(".pdf") ? MediaType.APPLICATION_PDF_VALUE : MediaType.IMAGE_JPEG_VALUE);
return ResponseEntity.ok().cacheControl(CacheControl.noStore())
.contentType(MediaType.parseMediaType(mediaType)).body(resource);
}
@@ -24,8 +24,8 @@ import java.util.UUID;
@Service
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");
private static final long MAX_SIZE = 10L * 1024 * 1024;
private static final Set<String> TYPES = Set.of("image/jpeg", "image/png", "application/pdf");
private final Path root;
private final Path qualificationRoot;
@@ -52,14 +52,14 @@ public class LocalQualificationStorage {
}
public StoredFile store(long tenantId, MultipartFile file) {
if (file == null || file.isEmpty()) throw new BizException("One business-license image is required");
if (file.getSize() > MAX_SIZE) throw new BizException("Business-license image must not exceed 5 MB");
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 10 MB / 资质文件不能超过 10MB");
String contentType = file.getContentType() == null ? "" : file.getContentType().toLowerCase(Locale.ROOT);
if (!TYPES.contains(contentType)) throw new BizException("Only JPG and PNG images are supported");
if (!matchesImageSignature(file, contentType)) {
throw new BizException("The uploaded file content is not a valid JPG or PNG image");
if (!TYPES.contains(contentType)) throw new BizException("Only PDF, JPG and PNG files are supported / 仅支持 PDF、JPG、PNG 文件");
if (!matchesSignature(file, contentType)) {
throw new BizException("The uploaded file content does not match its type / 文件内容与格式不匹配");
}
String extension = "image/png".equals(contentType) ? ".png" : ".jpg";
String extension = "image/png".equals(contentType) ? ".png" : ("application/pdf".equals(contentType) ? ".pdf" : ".jpg");
String fileName = UUID.randomUUID() + extension;
Path directory = tenantDirectory(tenantId);
Path target = directory.resolve(fileName).normalize();
@@ -82,7 +82,7 @@ public class LocalQualificationStorage {
}
}
private boolean matchesImageSignature(MultipartFile file, String contentType) {
private boolean matchesSignature(MultipartFile file, String contentType) {
byte[] header = new byte[8];
try (InputStream input = file.getInputStream()) {
int read = input.read(header);
@@ -90,6 +90,10 @@ public class LocalQualificationStorage {
return read >= 3 && (header[0] & 0xff) == 0xff && (header[1] & 0xff) == 0xd8
&& (header[2] & 0xff) == 0xff;
}
if ("application/pdf".equals(contentType)) {
return read >= 5 && header[0] == 0x25 && header[1] == 0x50 && header[2] == 0x44
&& header[3] == 0x46 && header[4] == 0x2d;
}
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;
@@ -99,7 +103,7 @@ public class LocalQualificationStorage {
}
public Resource load(long tenantId, String fileName) {
if (!fileName.matches("[0-9a-fA-F-]{36}\\.(jpg|png)")) throw new BizException("Invalid file name");
if (!fileName.matches("[0-9a-fA-F-]{36}\\.(jpg|png|pdf)")) throw new BizException("Invalid file name");
Path directory = tenantDirectory(tenantId);
Path target = directory.resolve(fileName).normalize();
if (!target.startsWith(directory) || !Files.isRegularFile(target)) throw new BizException("File not found");
+2 -2
View File
@@ -22,8 +22,8 @@ spring:
config.namespace: ${NACOS_NAMESPACE:public}
servlet:
multipart:
max-file-size: 5MB
max-request-size: 6MB
max-file-size: 10MB
max-request-size: 11MB
baiyue:
security:
jwt-secret: ${JWT_SECRET:change-me-to-at-least-32-bytes-long-before-start}
@@ -27,4 +27,16 @@ class QualificationFileContractTest {
assertTrue(hasMultipartPost, "企业资质真实上传入口必须保持 multipart/form-data 契约");
}
@Test
void platformControllerExposesMultipartUploadForCustomerDrafts() {
RequestMapping mapping = PlatformQualificationFileController.class.getAnnotation(RequestMapping.class);
assertTrue(Arrays.asList(mapping.value()).contains("/api/platform/v1/files/qualifications"));
boolean hasMultipartPost = Arrays.stream(PlatformQualificationFileController.class.getDeclaredMethods())
.map(method -> method.getAnnotation(PostMapping.class))
.filter(java.util.Objects::nonNull)
.flatMap(post -> Arrays.stream(post.consumes()))
.anyMatch(MediaType.MULTIPART_FORM_DATA_VALUE::equals);
assertTrue(hasMultipartPost);
}
}
@@ -50,6 +50,19 @@ class LocalQualificationStorageTest {
assertThrows(BizException.class, () -> storage.load(10002L, stored.fileName()));
}
@Test
void storesPdfQualificationAfterVerifyingItsSignature() throws Exception {
var storage = new LocalQualificationStorage(storageRoot.toString());
storage.initialize();
var upload = new MockMultipartFile("file", "license.pdf", "application/pdf", "%PDF-1.4\ncontent".getBytes());
var stored = storage.store(10001L, upload);
assertTrue(stored.fileName().endsWith(".pdf"));
assertEquals(stored.path().toAbsolutePath(),
storage.load(10001L, stored.fileName()).getFile().toPath().toAbsolutePath());
}
@Test
void legacySeedPathCannotBePretendedToBeAStoredFile() {
var storage = new LocalQualificationStorage(storageRoot.toString());