优化
This commit is contained in:
@@ -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) { }
|
||||
}
|
||||
|
||||
+50
-4
@@ -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");
|
||||
|
||||
@@ -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());
|
||||
|
||||
Reference in New Issue
Block a user