提交代码

This commit is contained in:
2026-08-03 15:46:50 +08:00
parent 8f5201112d
commit 9e69eff1c5
26 changed files with 383 additions and 58 deletions
@@ -15,14 +15,16 @@ public interface IamQualificationClient {
@RequestBody QualificationRequest request);
@PostMapping("/platform-qualifications")
ApiResponse<QualificationResponse> submitPlatform(
ApiResponse<PlatformQualificationResponse> 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 PlatformQualificationResponse(Long qualificationId, Long reviewId, String tenantStatus,
String replacedDocumentUrl) { }
record PlatformQualificationRequest(Long tenantId, String qualificationType,
String certificateNo, String documentUrl,
java.time.LocalDate validTo) { }
java.time.LocalDate validTo, boolean replace) { }
}
@@ -7,6 +7,8 @@ import com.baiyue.common.security.AuthenticatedUser;
import com.baiyue.file.client.IamQualificationClient;
import com.baiyue.file.service.LocalQualificationStorage;
import jakarta.annotation.Resource;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.http.CacheControl;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
@@ -27,6 +29,7 @@ import java.time.LocalDate;
@RestController
@RequestMapping("/api/platform/v1/files/qualifications")
public class PlatformQualificationFileController {
private static final Logger LOGGER = LoggerFactory.getLogger(PlatformQualificationFileController.class);
@Resource private LocalQualificationStorage storage;
@Resource private IamQualificationClient iamClient;
@Value("${baiyue.security.internal-service-token}") private String internalToken;
@@ -39,30 +42,44 @@ public class PlatformQualificationFileController {
@RequestPart("file") MultipartFile file,
@RequestParam String qualificationType,
@RequestParam(required = false) String certificateNo,
@RequestParam(required = false) LocalDate validTo) {
@RequestParam(required = false) LocalDate validTo,
@RequestParam(defaultValue = "false") boolean replace) {
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);
tenantId, qualificationType, certificateNo, stored.documentUrl(), validTo, replace));
if (response == null) {
throw new IllegalStateException("IAM qualification response is empty");
}
if (!response.successful()) {
ResultCode code = ResultCode.of(response.code());
String message = response.message() == null || response.message().isBlank()
? code.message() : response.message();
storage.delete(stored);
throw new BizException(code, message);
}
if (response.data() == null) {
throw new IllegalStateException("IAM qualification response data is empty");
}
var data = response.data();
if (!stored.documentUrl().equals(data.replacedDocumentUrl())) {
storage.deleteByDocumentUrl(tenantId, data.replacedDocumentUrl());
}
return ApiResponse.success(new QualificationFileController.UploadResponse(
data.qualificationId(), data.reviewId(), data.tenantStatus(), stored.documentUrl()));
} catch (BizException exception) {
throw exception;
} catch (RuntimeException exception) {
storage.delete(stored);
if (exception instanceof BizException business) throw business;
LOGGER.error("IAM 平台资质登记结果未知,保留已写入文件等待重试或对账,tenantId={},documentUrl={}",
tenantId, stored.documentUrl(), exception);
throw new BizException(ResultCode.SERVICE_UNAVAILABLE, "IAM qualification service is unavailable");
}
}
@GetMapping("/{tenantId}/{fileName}")
@PreAuthorize("hasAuthority('platform:customer:review')")
@PreAuthorize("hasAuthority('platform:customer:view')")
public ResponseEntity<org.springframework.core.io.Resource> download(
@AuthenticationPrincipal AuthenticatedUser user,
@PathVariable long tenantId, @PathVariable String fileName) {
@@ -39,24 +39,26 @@ public class QualificationFileController {
new IamQualificationClient.QualificationRequest(tenantId, "BUSINESS_LICENSE",
certificateNo, stored.documentUrl()));
if (response == null) {
throw new BizException(ResultCode.SERVICE_UNAVAILABLE, "Qualification workflow failed");
throw new IllegalStateException("IAM qualification response is empty");
}
if (!response.successful()) {
ResultCode code = ResultCode.of(response.code());
String message = response.message() == null || response.message().isBlank()
? code.message() : response.message();
storage.delete(stored);
throw new BizException(code, message);
}
if (response.data() == null) {
throw new BizException(ResultCode.SERVICE_UNAVAILABLE, "Qualification workflow failed");
throw new IllegalStateException("IAM qualification response data is empty");
}
var data = response.data();
return ApiResponse.success(new UploadResponse(data.qualificationId(), data.reviewId(),
data.tenantStatus(), stored.documentUrl()));
} catch (BizException exception) {
throw exception;
} catch (RuntimeException exception) {
storage.delete(stored);
if (exception instanceof BizException business) throw business;
LOGGER.error("IAM 资质登记调用失败", exception);
LOGGER.error("IAM 资质登记结果未知,保留已写入文件等待重试或对账,tenantId={},documentUrl={}",
tenantId, stored.documentUrl(), exception);
throw new BizException(ResultCode.SERVICE_UNAVAILABLE, "IAM qualification service is unavailable");
}
}
@@ -115,6 +115,20 @@ public class LocalQualificationStorage {
try { Files.deleteIfExists(file.path()); } catch (IOException ignored) { }
}
public void deleteByDocumentUrl(long tenantId, String documentUrl) {
if (documentUrl == null || documentUrl.isBlank()) return;
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;
Path directory = tenantDirectory(tenantId);
Path target = directory.resolve(fileName).normalize();
if (!target.startsWith(directory)) return;
try { Files.deleteIfExists(target); } catch (IOException exception) {
LOGGER.warn("旧资质文件清理失败,tenantId={},fileName={}", tenantId, fileName, exception);
}
}
private Path tenantDirectory(long tenantId) {
Path directory = qualificationRoot.resolve(Long.toString(tenantId)).normalize();
if (!directory.startsWith(qualificationRoot)) {
@@ -1,14 +1,35 @@
package com.baiyue.file.controller;
import com.baiyue.common.core.api.ApiResponse;
import com.baiyue.common.core.api.ResultCode;
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 org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.springframework.http.MediaType;
import org.springframework.mock.web.MockMultipartFile;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.test.util.ReflectionTestUtils;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import java.nio.file.Path;
import java.time.LocalDate;
import java.util.Arrays;
import java.util.Set;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.inOrder;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
@DisplayName("企业资质文件接口契约功能")
class QualificationFileContractTest {
@@ -39,4 +60,139 @@ class QualificationFileContractTest {
.anyMatch(MediaType.MULTIPART_FORM_DATA_VALUE::equals);
assertTrue(hasMultipartPost);
}
@Test
void platformQualificationPreviewUsesTheCustomerViewPermission() throws Exception {
var method = PlatformQualificationFileController.class.getDeclaredMethod(
"download", AuthenticatedUser.class, long.class, String.class);
assertEquals("hasAuthority('platform:customer:view')", method.getAnnotation(PreAuthorize.class).value());
}
@Test
void platformReplacementIsForwardedAndCleansThePreviousFileAfterIamSucceeds() {
var storage = mock(LocalQualificationStorage.class);
var iamClient = mock(IamQualificationClient.class);
var controller = platformController(storage, iamClient);
var file = new MockMultipartFile("file", "license.pdf", "application/pdf", "%PDF-1.4".getBytes());
var stored = new LocalQualificationStorage.StoredFile(Path.of("new.pdf"), "new.pdf",
"/api/merchant/v1/files/qualifications/11111111-1111-1111-1111-111111111111.pdf");
var oldUrl = "/api/merchant/v1/files/qualifications/22222222-2222-2222-2222-222222222222.pdf";
when(storage.store(10001L, file)).thenReturn(stored);
when(iamClient.submitPlatform(eq("internal-token"), any())).thenReturn(ApiResponse.success(
new IamQualificationClient.PlatformQualificationResponse(31L, null, "PROFILE_REQUIRED", oldUrl)));
var response = controller.upload(platformUser(), 10001L, file, "LEGAL_PERSON_ID", null,
LocalDate.of(2030, 12, 31), true);
var ordered = inOrder(storage, iamClient);
ordered.verify(storage).store(10001L, file);
var requestCaptor = org.mockito.ArgumentCaptor.forClass(
IamQualificationClient.PlatformQualificationRequest.class);
ordered.verify(iamClient).submitPlatform(eq("internal-token"), requestCaptor.capture());
ordered.verify(storage).deleteByDocumentUrl(10001L, oldUrl);
assertTrue(requestCaptor.getValue().replace());
assertEquals(stored.documentUrl(), requestCaptor.getValue().documentUrl());
assertEquals(stored.documentUrl(), response.data().documentUrl());
}
@Test
void platformReplacementNeverDeletesTheCurrentUpload() {
var storage = mock(LocalQualificationStorage.class);
var iamClient = mock(IamQualificationClient.class);
var controller = platformController(storage, iamClient);
var file = new MockMultipartFile("file", "license.pdf", "application/pdf", "%PDF-1.4".getBytes());
var currentUrl = "/api/merchant/v1/files/qualifications/11111111-1111-1111-1111-111111111111.pdf";
var stored = new LocalQualificationStorage.StoredFile(Path.of("new.pdf"), "new.pdf", currentUrl);
when(storage.store(10001L, file)).thenReturn(stored);
when(iamClient.submitPlatform(eq("internal-token"), any())).thenReturn(ApiResponse.success(
new IamQualificationClient.PlatformQualificationResponse(31L, null, "PROFILE_REQUIRED", currentUrl)));
controller.upload(platformUser(), 10001L, file, "LEGAL_PERSON_ID", null, null, true);
verify(storage, never()).deleteByDocumentUrl(10001L, currentUrl);
}
@Test
void platformTransportFailurePreservesTheFileBecauseTheIamOutcomeIsUnknown() {
var storage = mock(LocalQualificationStorage.class);
var iamClient = mock(IamQualificationClient.class);
var controller = platformController(storage, iamClient);
var file = new MockMultipartFile("file", "license.pdf", "application/pdf", "%PDF-1.4".getBytes());
var stored = storedFile();
when(storage.store(10001L, file)).thenReturn(stored);
when(iamClient.submitPlatform(eq("internal-token"), any())).thenThrow(new RuntimeException("timeout"));
var exception = assertThrows(BizException.class,
() -> controller.upload(platformUser(), 10001L, file, "LEGAL_PERSON_ID", null, null, true));
assertEquals(ResultCode.SERVICE_UNAVAILABLE, exception.getResultCode());
verify(storage, never()).delete(stored);
}
@Test
void explicitPlatformBusinessRejectionDeletesTheUnreferencedFile() {
var storage = mock(LocalQualificationStorage.class);
var iamClient = mock(IamQualificationClient.class);
var controller = platformController(storage, iamClient);
var file = new MockMultipartFile("file", "license.pdf", "application/pdf", "%PDF-1.4".getBytes());
var stored = storedFile();
when(storage.store(10001L, file)).thenReturn(stored);
when(iamClient.submitPlatform(eq("internal-token"), any())).thenReturn(
ApiResponse.failure(ResultCode.CONFLICT, "duplicate", null, null));
var exception = assertThrows(BizException.class,
() -> controller.upload(platformUser(), 10001L, file, "LEGAL_PERSON_ID", null, null, false));
assertEquals(ResultCode.CONFLICT, exception.getResultCode());
verify(storage).delete(stored);
}
@Test
void merchantTransportFailureAlsoPreservesTheFileForReconciliation() {
var storage = mock(LocalQualificationStorage.class);
var iamClient = mock(IamQualificationClient.class);
var controller = merchantController(storage, iamClient);
var file = new MockMultipartFile("file", "license.pdf", "application/pdf", "%PDF-1.4".getBytes());
var stored = storedFile();
when(storage.store(10001L, file)).thenReturn(stored);
when(iamClient.submit(eq("internal-token"), any())).thenThrow(new RuntimeException("timeout"));
var exception = assertThrows(BizException.class,
() -> controller.upload(merchantUser(), file, null));
assertEquals(ResultCode.SERVICE_UNAVAILABLE, exception.getResultCode());
verify(storage, never()).delete(stored);
}
private static PlatformQualificationFileController platformController(
LocalQualificationStorage storage, IamQualificationClient iamClient) {
var controller = new PlatformQualificationFileController();
ReflectionTestUtils.setField(controller, "storage", storage);
ReflectionTestUtils.setField(controller, "iamClient", iamClient);
ReflectionTestUtils.setField(controller, "internalToken", "internal-token");
return controller;
}
private static QualificationFileController merchantController(
LocalQualificationStorage storage, IamQualificationClient iamClient) {
var controller = new QualificationFileController();
ReflectionTestUtils.setField(controller, "storage", storage);
ReflectionTestUtils.setField(controller, "iamClient", iamClient);
ReflectionTestUtils.setField(controller, "internalToken", "internal-token");
return controller;
}
private static LocalQualificationStorage.StoredFile storedFile() {
return new LocalQualificationStorage.StoredFile(Path.of("new.pdf"), "new.pdf",
"/api/merchant/v1/files/qualifications/11111111-1111-1111-1111-111111111111.pdf");
}
private static AuthenticatedUser platformUser() {
return new AuthenticatedUser(1L, 0L, 0L, 1L, "admin", "PLATFORM", "WEB", Set.of());
}
private static AuthenticatedUser merchantUser() {
return new AuthenticatedUser(2L, 10001L, 10001L, 2L, "merchant", "MERCHANT", "WEB", Set.of());
}
}
@@ -11,6 +11,7 @@ import java.nio.file.Files;
import java.nio.file.Path;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.junit.jupiter.api.Assertions.assertTrue;
@@ -63,6 +64,30 @@ class LocalQualificationStorageTest {
storage.load(10001L, stored.fileName()).getFile().toPath().toAbsolutePath());
}
@Test
void deletesTheReplacedQualificationByItsValidatedDocumentUrl() {
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);
storage.deleteByDocumentUrl(10001L, stored.documentUrl());
assertFalse(Files.exists(stored.path()));
}
@Test
void doesNotDeleteAQualificationForAnUntrustedDocumentUrl() {
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);
storage.deleteByDocumentUrl(10001L, "/untrusted/" + stored.fileName());
assertTrue(Files.exists(stored.path()));
}
@Test
void legacySeedPathCannotBePretendedToBeAStoredFile() {
var storage = new LocalQualificationStorage(storageRoot.toString());