feat(firstleg): implement container customs workflow
This commit is contained in:
@@ -0,0 +1,46 @@
|
||||
package com.baiyue.file.config;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
class CustomsFileExportContractTest {
|
||||
private static final String TYPE = "LOADING_LIST_BUY_DECLARATION_ITEMS";
|
||||
|
||||
@Test void applicationAndNacosRegisterTheSameFirstlegProviderContract() throws Exception {
|
||||
for (String file : new String[]{
|
||||
"file/src/main/resources/application.yml",
|
||||
"config/nacos/local/file-service.yml",
|
||||
"config/nacos/cn/file-service.yml"}) {
|
||||
String source = Files.readString(repositoryRoot().resolve(file));
|
||||
assertThat(source).contains(TYPE + ":")
|
||||
.contains("provider-service: firstleg-service")
|
||||
.contains("provider-path: /internal/exports/v1/" + TYPE + "/batches")
|
||||
.contains("required-permission: platform:firstleg:customs:manage")
|
||||
.contains("app-scope: PLATFORM")
|
||||
.doesNotContain("loading-list:export");
|
||||
}
|
||||
}
|
||||
|
||||
@Test void openApiFreezesOwnerScopeSizeVisibilityAndColumns() throws Exception {
|
||||
String source = Files.readString(repositoryRoot()
|
||||
.resolve("file/src/main/resources/openapi/customs-file-and-export-v1.yaml"));
|
||||
|
||||
assertThat(source).contains("LOADING_LIST_CUSTOMS_SOURCE", "LOADING_LIST_CUSTOMS_RESULT")
|
||||
.contains("maximum: 52428800", "no-store", "CUSTOMER owner scope", "loadingPlanId")
|
||||
.contains("platform:firstleg:customs:manage", "platform:firstleg:loading-list:list",
|
||||
"merchant:order:edit", "merchant:order:view")
|
||||
.contains("customerName", "orderNo", "inboundCode", "sku", "chineseName", "englishName",
|
||||
"cartonCount", "productQuantity", "grossWeight", "netWeight", "volume", "mark",
|
||||
"customerRemark")
|
||||
.doesNotContain("productNameZh", "productNameEn", "shippingMark", "loading-list:export");
|
||||
}
|
||||
|
||||
private static Path repositoryRoot() {
|
||||
Path current = Path.of("").toAbsolutePath();
|
||||
return Files.isDirectory(current.resolve("file")) ? current : current.getParent();
|
||||
}
|
||||
}
|
||||
@@ -45,6 +45,18 @@ class InternalBusinessFileControllerTest {
|
||||
verify(service).download(0L, "LOADING_CUSTOMS_DECLARATION", "51", 91L);
|
||||
}
|
||||
|
||||
@Test void arbitraryResultDownloadUsesAttachmentAndFallsBackToBinaryContentType() {
|
||||
when(service.download(10001L, "LOADING_LIST_CUSTOMS_RESULT", "51", 92L))
|
||||
.thenReturn(new InternalBusinessFileService.Download("完成文件.unknown", "invalid content type", new byte[]{3}));
|
||||
|
||||
var response = controller.download("internal-token", 10001L,
|
||||
"LOADING_LIST_CUSTOMS_RESULT", "51", 92L);
|
||||
|
||||
assertThat(response.getHeaders().getCacheControl()).contains("no-store");
|
||||
assertThat(response.getHeaders().getContentDisposition().getType()).isEqualTo("attachment");
|
||||
assertThat(response.getHeaders().getContentType()).isEqualTo(org.springframework.http.MediaType.APPLICATION_OCTET_STREAM);
|
||||
}
|
||||
|
||||
@Test void rejectsInvalidTokenAndNegativeTenantBeforeServiceAccess() {
|
||||
var file = new MockMultipartFile("file", "x.txt", "text/plain", "x".getBytes());
|
||||
assertThatThrownBy(() -> controller.upload("wrong", 7L, 0L,
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
package com.baiyue.file.service;
|
||||
|
||||
import com.baiyue.common.core.exception.BizException;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.io.TempDir;
|
||||
import org.springframework.mock.web.MockMultipartFile;
|
||||
import org.springframework.test.util.ReflectionTestUtils;
|
||||
import org.springframework.web.multipart.MultipartFile;
|
||||
|
||||
import java.nio.file.Path;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||||
import static org.mockito.Mockito.mock;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
class BusinessFileStorageTest {
|
||||
@TempDir Path root;
|
||||
|
||||
@Test void arbitraryCustomsFormatIsStoredAsAttachmentObject() {
|
||||
BusinessFileStorage storage = storage();
|
||||
var file = new MockMultipartFile("file", "folder/manifest.custom", "application/x-custom", new byte[]{1, 2, 3});
|
||||
|
||||
var stored = storage.store(10001L, 51L, file, BusinessFileCategory.CUSTOMS_MAX_SIZE, true);
|
||||
|
||||
assertThat(stored.originalName()).isEqualTo("manifest.custom");
|
||||
assertThat(stored.contentType()).isEqualTo("application/x-custom");
|
||||
assertThat(storage.read(stored.objectKey())).containsExactly(1, 2, 3);
|
||||
}
|
||||
|
||||
@Test void customsFileAboveFiftyMegabytesIsRejectedBeforeRead() {
|
||||
BusinessFileStorage storage = storage();
|
||||
MultipartFile file = mock(MultipartFile.class);
|
||||
when(file.isEmpty()).thenReturn(false);
|
||||
when(file.getSize()).thenReturn(BusinessFileCategory.CUSTOMS_MAX_SIZE + 1);
|
||||
|
||||
assertThatThrownBy(() -> storage.store(10001L, 51L, file,
|
||||
BusinessFileCategory.CUSTOMS_MAX_SIZE, true))
|
||||
.isInstanceOf(BizException.class).hasMessageContaining("50MB");
|
||||
}
|
||||
|
||||
@Test void legacyBusinessFilesRetainRestrictedFormatsAndTenMegabyteLimit() {
|
||||
BusinessFileStorage storage = storage();
|
||||
var file = new MockMultipartFile("file", "archive.zip", "application/zip", new byte[]{1});
|
||||
|
||||
assertThatThrownBy(() -> storage.store(10001L, 51L, file,
|
||||
BusinessFileCategory.STANDARD_MAX_SIZE, false))
|
||||
.isInstanceOf(BizException.class).hasMessageContaining("仅支持");
|
||||
}
|
||||
|
||||
private BusinessFileStorage storage() {
|
||||
BusinessFileStorage storage = new BusinessFileStorage();
|
||||
ReflectionTestUtils.setField(storage, "configuredRoot", root);
|
||||
storage.initializeRoot();
|
||||
return storage;
|
||||
}
|
||||
}
|
||||
@@ -84,6 +84,34 @@ class ExportTaskServiceTest {
|
||||
.getResultCode());
|
||||
}
|
||||
|
||||
@Test
|
||||
void buyDeclarationExportRequiresOnePositiveLoadingPlanId() {
|
||||
var definition = definition();
|
||||
definition.setProviderService("firstleg-service");
|
||||
definition.setProviderPath("/internal/exports/v1/LOADING_LIST_BUY_DECLARATION_ITEMS/batches");
|
||||
definition.setRequiredPermission("platform:firstleg:customs:manage");
|
||||
when(registry.require("LOADING_LIST_BUY_DECLARATION_ITEMS")).thenReturn(definition);
|
||||
when(states.createConstrained(any(FileExportTask.class))).thenAnswer(invocation ->
|
||||
new ExportStateService.CreationResult(invocation.getArgument(0), true));
|
||||
var user = user(1L, Set.of("platform:firstleg:customs:manage"));
|
||||
|
||||
var response = service.create(user, new CreateExportTaskRequest(
|
||||
"LOADING_LIST_BUY_DECLARATION_ITEMS", "XLSX", "买单报关明细", Map.of("loadingPlanId", "51")));
|
||||
|
||||
assertEquals("LOADING_LIST_BUY_DECLARATION_ITEMS", response.exportType());
|
||||
assertEquals(ResultCode.VALIDATION_FAILED, assertThrows(BizException.class, () -> service.create(user,
|
||||
new CreateExportTaskRequest("LOADING_LIST_BUY_DECLARATION_ITEMS", "XLSX", "买单报关明细", Map.of())))
|
||||
.getResultCode());
|
||||
assertEquals(ResultCode.VALIDATION_FAILED, assertThrows(BizException.class, () -> service.create(user,
|
||||
new CreateExportTaskRequest("LOADING_LIST_BUY_DECLARATION_ITEMS", "XLSX", "买单报关明细",
|
||||
Map.of("loadingPlanId", 0))))
|
||||
.getResultCode());
|
||||
assertEquals(ResultCode.VALIDATION_FAILED, assertThrows(BizException.class, () -> service.create(user,
|
||||
new CreateExportTaskRequest("LOADING_LIST_BUY_DECLARATION_ITEMS", "XLSX", "买单报关明细",
|
||||
Map.of("loadingPlanId", 51L))))
|
||||
.getResultCode());
|
||||
}
|
||||
|
||||
private AuthenticatedUser user(long id, Set<String> permissions) {
|
||||
return new AuthenticatedUser(id, 0L, 0L, id, "user" + id, "PLATFORM", "WEB", permissions);
|
||||
}
|
||||
|
||||
@@ -86,6 +86,56 @@ class GenericXlsxExportRunnerTest {
|
||||
}
|
||||
}
|
||||
|
||||
@Test void buyDeclarationProviderMustReturnTheFrozenThirteenColumnsInOrder() {
|
||||
List<ExportColumn> invalid = ExportSchemaContracts.BUY_DECLARATION_COLUMNS.stream()
|
||||
.map(column -> new ExportColumn(column.key(), column.title(), column.type(), 18)).toList();
|
||||
invalid = new java.util.ArrayList<>(invalid);
|
||||
invalid.set(4, new ExportColumn("productNameZh", "中文品名", ExportColumnType.STRING, 18));
|
||||
var batch = new ExportBatchResponse(1, "sha256:buy-declaration", "snapshot-51",
|
||||
invalid, List.of(), null, false, 0L);
|
||||
when(provider.batch(anyString(), eq(ExportSchemaContracts.BUY_DECLARATION_ITEMS), any(), any()))
|
||||
.thenReturn(ApiResponse.success(batch));
|
||||
FileExportTask task = new FileExportTask(); task.setId(51L); task.setTenantId(0L);
|
||||
task.setAppCode("PLATFORM"); task.setRequestedBy(7L); task.setDispatchVersion(1);
|
||||
task.setExportType(ExportSchemaContracts.BUY_DECLARATION_ITEMS); task.setTaskName("买单报关明细");
|
||||
task.setParametersJson("{\"loadingPlanId\":\"51\"}");
|
||||
var definition = new ExportDefinitionRegistry.Definition(); definition.setMaxRows(200000); definition.setBatchSize(500);
|
||||
|
||||
assertThrows(ExportFailure.class,
|
||||
() -> runner.run(task, definition, new AtomicBoolean(), ignored -> { }));
|
||||
}
|
||||
|
||||
@Test void frozenBuyDeclarationProviderColumnsGenerateWorkbookDirectly() throws Exception {
|
||||
List<ExportColumn> columns = ExportSchemaContracts.BUY_DECLARATION_COLUMNS.stream()
|
||||
.map(column -> new ExportColumn(column.key(), column.title(), column.type(), 18)).toList();
|
||||
Map<String, Object> row = new java.util.LinkedHashMap<>();
|
||||
row.put("customerName", "客户A"); row.put("orderNo", "O-1"); row.put("inboundCode", "IN-1");
|
||||
row.put("sku", "SKU-1"); row.put("chineseName", "中文品名"); row.put("englishName", "English name");
|
||||
row.put("cartonCount", 2); row.put("productQuantity", 20); row.put("grossWeight", "12.5");
|
||||
row.put("netWeight", "11.5"); row.put("volume", "1.25"); row.put("mark", "MARK-1");
|
||||
row.put("customerRemark", "备注");
|
||||
var batch = new ExportBatchResponse(1, "sha256:buy-declaration", "snapshot-51",
|
||||
columns, List.of(row), null, false, 1L);
|
||||
when(provider.batch(anyString(), eq(ExportSchemaContracts.BUY_DECLARATION_ITEMS), any(), any()))
|
||||
.thenReturn(ApiResponse.success(batch));
|
||||
FileExportTask task = new FileExportTask(); task.setId(52L); task.setTenantId(0L);
|
||||
task.setAppCode("PLATFORM"); task.setRequestedBy(7L); task.setDispatchVersion(1);
|
||||
task.setExportType(ExportSchemaContracts.BUY_DECLARATION_ITEMS); task.setTaskName("买单报关明细");
|
||||
task.setParametersJson("{\"loadingPlanId\":\"51\"}");
|
||||
var definition = new ExportDefinitionRegistry.Definition(); definition.setMaxRows(200000); definition.setBatchSize(500);
|
||||
|
||||
var result = runner.run(task, definition, new AtomicBoolean(), ignored -> { });
|
||||
|
||||
assertEquals(1L, result.rows());
|
||||
try (var workbook = new XSSFWorkbook(storage.load(result.objectKey()).getInputStream())) {
|
||||
var sheet = workbook.getSheetAt(0);
|
||||
assertEquals("客户名称", sheet.getRow(0).getCell(0).getStringCellValue());
|
||||
assertEquals("客户A", sheet.getRow(1).getCell(0).getStringCellValue());
|
||||
assertEquals("中文品名", sheet.getRow(0).getCell(4).getStringCellValue());
|
||||
assertEquals("唛头", sheet.getRow(0).getCell(11).getStringCellValue());
|
||||
}
|
||||
}
|
||||
|
||||
@Configuration
|
||||
static class TestConfig {
|
||||
@Bean ObjectMapper objectMapper() { return new ObjectMapper(); }
|
||||
|
||||
@@ -21,7 +21,7 @@ class InternalBusinessFileServiceTest {
|
||||
|
||||
@Test void customsDocumentUploadPersistsExactBusinessOwnership() {
|
||||
MockMultipartFile file = new MockMultipartFile("file", "invoice.txt", "text/plain", "invoice".getBytes());
|
||||
when(storage.storeLoading(10001L, 41L, file)).thenReturn(new BusinessFileStorage.Stored(
|
||||
when(storage.store(10001L, 41L, file, BusinessFileCategory.STANDARD_MAX_SIZE, false)).thenReturn(new BusinessFileStorage.Stored(
|
||||
"business-files/10001/41/key.txt", "text/plain", 7, "a".repeat(64), "invoice.txt"));
|
||||
when(files.insert(any(FileObjectEntity.class))).thenAnswer(invocation -> {
|
||||
((FileObjectEntity) invocation.getArgument(0)).setId(71L); return 1;
|
||||
@@ -37,10 +37,10 @@ class InternalBusinessFileServiceTest {
|
||||
|
||||
@Test void platformContainerAndLoadingDocumentsSupportZeroTenantScope() {
|
||||
MockMultipartFile file = new MockMultipartFile("file", "customs.pdf", "application/pdf", "%PDF-1.4".getBytes());
|
||||
when(storage.storeLoading(eq(0L), anyLong(), same(file))).thenAnswer(invocation ->
|
||||
when(storage.store(eq(0L), anyLong(), same(file), eq(BusinessFileCategory.STANDARD_MAX_SIZE), eq(false))).thenAnswer(invocation ->
|
||||
new BusinessFileStorage.Stored("business-files/0/" + invocation.getArgument(1) + "/customs.pdf",
|
||||
"application/pdf", file.getSize(), "b".repeat(64), "customs.pdf"));
|
||||
when(storage.storeLoading(11L, 51L, file)).thenReturn(new BusinessFileStorage.Stored(
|
||||
when(storage.store(11L, 51L, file, BusinessFileCategory.STANDARD_MAX_SIZE, false)).thenReturn(new BusinessFileStorage.Stored(
|
||||
"business-files/11/51/unload.jpg", "image/jpeg", file.getSize(), "c".repeat(64), "unload.jpg"));
|
||||
when(files.insert(any(FileObjectEntity.class))).thenAnswer(invocation -> {
|
||||
((FileObjectEntity) invocation.getArgument(0)).setId(81L); return 1;
|
||||
@@ -64,9 +64,9 @@ class InternalBusinessFileServiceTest {
|
||||
InternalBusinessFileService.Metadata::businessType,
|
||||
InternalBusinessFileService.Metadata::businessId)
|
||||
.containsExactly(11L, "OVERSEAS_UNLOAD_PHOTO", "51");
|
||||
verify(storage, times(3)).storeLoading(0L, 41L, file);
|
||||
verify(storage).storeLoading(0L, 51L, file);
|
||||
verify(storage).storeLoading(11L, 51L, file);
|
||||
verify(storage, times(3)).store(0L, 41L, file, BusinessFileCategory.STANDARD_MAX_SIZE, false);
|
||||
verify(storage).store(0L, 51L, file, BusinessFileCategory.STANDARD_MAX_SIZE, false);
|
||||
verify(storage).store(11L, 51L, file, BusinessFileCategory.STANDARD_MAX_SIZE, false);
|
||||
}
|
||||
|
||||
@Test void rejectsNegativeTenantAndInvalidOwnershipInputs() {
|
||||
@@ -82,7 +82,7 @@ class InternalBusinessFileServiceTest {
|
||||
|
||||
@Test void overseasRelabelPhotoPersistsExactTaskOwnership() {
|
||||
MockMultipartFile file = new MockMultipartFile("file", "relabel.jpg", "image/jpeg", "photo".getBytes());
|
||||
when(storage.storeLoading(100L, 81L, file)).thenReturn(new BusinessFileStorage.Stored(
|
||||
when(storage.store(100L, 81L, file, BusinessFileCategory.STANDARD_MAX_SIZE, false)).thenReturn(new BusinessFileStorage.Stored(
|
||||
"business-files/100/81/relabel.jpg", "image/jpeg", file.getSize(), "d".repeat(64), "relabel.jpg"));
|
||||
when(files.insert(any(FileObjectEntity.class))).thenAnswer(invocation -> {
|
||||
((FileObjectEntity) invocation.getArgument(0)).setId(91L); return 1;
|
||||
@@ -96,6 +96,36 @@ class InternalBusinessFileServiceTest {
|
||||
.containsExactly(100L, "OVERSEAS_RELABEL_PHOTO", "81");
|
||||
}
|
||||
|
||||
@Test void loadingCustomsSourceAndResultAreCustomerOwnedArbitraryFormatObjects() {
|
||||
MockMultipartFile file = new MockMultipartFile("file", "资料包.zip", "application/zip", "zip".getBytes());
|
||||
when(storage.store(10001L, 51L, file, BusinessFileCategory.CUSTOMS_MAX_SIZE, true))
|
||||
.thenReturn(new BusinessFileStorage.Stored("business-files/10001/51/key", "application/zip",
|
||||
file.getSize(), "e".repeat(64), "资料包.zip"));
|
||||
when(files.insert(any(FileObjectEntity.class))).thenAnswer(invocation -> {
|
||||
((FileObjectEntity) invocation.getArgument(0)).setId(101L); return 1;
|
||||
});
|
||||
|
||||
var source = service.upload(10001L, "LOADING_LIST_CUSTOMS_SOURCE", "51", 7L, file);
|
||||
var result = service.upload(10001L, "LOADING_LIST_CUSTOMS_RESULT", "51", 7L, file);
|
||||
|
||||
assertThat(source).extracting(InternalBusinessFileService.Metadata::tenantId,
|
||||
InternalBusinessFileService.Metadata::businessType,
|
||||
InternalBusinessFileService.Metadata::businessId)
|
||||
.containsExactly(10001L, "LOADING_LIST_CUSTOMS_SOURCE", "51");
|
||||
assertThat(result.businessType()).isEqualTo("LOADING_LIST_CUSTOMS_RESULT");
|
||||
verify(storage, times(2)).store(10001L, 51L, file, BusinessFileCategory.CUSTOMS_MAX_SIZE, true);
|
||||
}
|
||||
|
||||
@Test void loadingCustomsCategoriesRejectPlatformOwnerScope() {
|
||||
var file = new MockMultipartFile("file", "source.bin", "application/octet-stream", new byte[]{1});
|
||||
|
||||
assertThatThrownBy(() -> service.upload(0L, "LOADING_LIST_CUSTOMS_SOURCE", "51", 7L, file))
|
||||
.isInstanceOf(BizException.class).hasMessageContaining("明确客户");
|
||||
assertThatThrownBy(() -> service.metadata(0L, "LOADING_LIST_CUSTOMS_RESULT", "51", 91L))
|
||||
.isInstanceOf(BizException.class).hasMessageContaining("明确客户");
|
||||
verifyNoInteractions(files, storage);
|
||||
}
|
||||
|
||||
@Test void unsupportedBusinessTypeFailsClosed() {
|
||||
assertThatThrownBy(() -> service.upload(10001L, "ARBITRARY", "41", 7L,
|
||||
new MockMultipartFile("file", "a.txt", "text/plain", "x".getBytes())))
|
||||
|
||||
Reference in New Issue
Block a user