diff --git a/order/src/main/resources/db/platform-order-export-permission.sql b/order/src/main/resources/db/platform-order-export-permission.sql new file mode 100644 index 000000000..b273a4af6 --- /dev/null +++ b/order/src/main/resources/db/platform-order-export-permission.sql @@ -0,0 +1,17 @@ +-- Order 导出权限初始化源;由统一增量合并后在 IAM 数据库执行。 +USE baiyue_iam; + +INSERT INTO iam_permission + (id, tenant_id, app_code, permission_type, parent_id, permission_code, permission_name, + route_path, http_method, sort_no, status, deleted) +VALUES + (5102026, 0, 'PLATFORM', 'BUTTON', 5102010, 'platform:order:export', '导出订单列表', + NULL, NULL, 120, 'ACTIVE', 0) +ON DUPLICATE KEY UPDATE + permission_name = VALUES(permission_name), parent_id = VALUES(parent_id), + sort_no = VALUES(sort_no), status = 'ACTIVE', deleted = 0; + +INSERT INTO iam_role_permission (role_id, permission_id, tenant_id) +SELECT role_id, 5102026, 0 +FROM (SELECT 4000001 AS role_id UNION ALL SELECT 4000003 AS role_id) roles +ON DUPLICATE KEY UPDATE tenant_id = VALUES(tenant_id); diff --git a/order/src/main/resources/openapi/order-v1.yaml b/order/src/main/resources/openapi/order-v1.yaml index c278f3f0f..543912791 100644 --- a/order/src/main/resources/openapi/order-v1.yaml +++ b/order/src/main/resources/openapi/order-v1.yaml @@ -6,6 +6,28 @@ info: security: - bearerAuth: [] paths: + /internal/exports/v1/DOMESTIC_WAREHOUSE_ORDER_LIST/batches: + post: + security: [] + summary: File 异步导出中心分页读取国内仓订单 + description: >- + 仅接受内部服务令牌。selectedOrderNos 非空时忽略查询筛选并严格按所选业务订单号导出; + selectedOrderNos 为空时按与平台统一订单列表一致的筛选条件导出全部匹配记录,page/size 不属于请求参数。 + Provider 只返回结构化数据,不生成文件;调用方创建 File 任务时 exportType 固定为 + DOMESTIC_WAREHOUSE_ORDER_LIST、format 固定为 XLSX,并需要 platform:order:export。 + parameters: + - name: X-Internal-Service-Token + in: header + required: true + schema: {type: string, minLength: 32} + requestBody: + required: true + content: + application/json: + schema: {$ref: '#/components/schemas/DomesticWarehouseOrderExportBatchRequest'} + responses: + '200': {description: 返回固定列定义、当前批数据、游标与首批总数} + '403': {description: 内部服务令牌无效} /api/merchant/v1/orders/first-leg/addresses: get: summary: 查询当前客户下单地址簿 @@ -486,6 +508,51 @@ components: scheme: bearer bearerFormat: JWT schemas: + DomesticWarehouseOrderExportBatchRequest: + type: object + required: [taskId, tenantId, appCode, requestedBy, dispatchVersion, parameters, limit] + properties: + taskId: {type: string, pattern: '^[1-9][0-9]*$'} + tenantId: {type: string, enum: ['0'], description: 平台导出固定为0} + appCode: {type: string, enum: [PLATFORM]} + requestedBy: {type: string, pattern: '^[1-9][0-9]*$'} + dispatchVersion: {type: integer, minimum: 1} + parameters: {$ref: '#/components/schemas/DomesticWarehouseOrderExportParameters'} + cursor: {type: string, nullable: true} + snapshotToken: {type: string, nullable: true, maxLength: 512} + limit: {type: integer, minimum: 1, maximum: 500} + DomesticWarehouseOrderExportParameters: + type: object + description: >- + selectedOrderNos 非空时仅导出所选订单;为空或省略时使用其余筛选字段导出全量匹配结果。 + 筛选字段与 GET /api/platform/v1/orders 一致,但不得传 page、size。 + properties: + selectedOrderNos: + type: array + maxItems: 5000 + uniqueItems: true + items: {type: string, minLength: 1, maxLength: 64} + tenantId: {type: string, pattern: '^[1-9][0-9]*$'} + keyword: {type: string, maxLength: 64} + orderNo: {type: string, maxLength: 64} + mainStatus: {type: string, enum: [PENDING_REVIEW, WAIT_MEASURE, INBOUNDED, WAIT_SPLIT, LOADING, LOADED, DELIVERED]} + customerId: {type: string, pattern: '^[1-9][0-9]*$'} + receivingWarehouseId: {type: string, pattern: '^[1-9][0-9]*$'} + destinationWarehouseId: {type: string, pattern: '^[1-9][0-9]*$'} + inboundCode: {type: string, maxLength: 64} + internalOrderStatus: {type: string, maxLength: 32} + expectedArrivalDate: {type: string, format: date} + feeReviewStatus: {type: string, enum: [UNRECORDED, PENDING, APPROVED, REJECTED]} + channelName: {type: string, maxLength: 128} + containerNo: {type: string, maxLength: 64} + cabinetNo: {type: string, maxLength: 64} + sailingDate: {type: string, format: date} + loaded: {type: boolean} + fullContainer: {type: boolean} + measurementReviewStatus: {type: string, enum: [UNRECORDED, PENDING, APPROVED, REJECTED]} + fbaCode: {type: string, maxLength: 64} + createdFrom: {type: string, format: date-time} + createdTo: {type: string, format: date-time} TenantOrderStatisticsResponse: type: object required: [tenantId, totalSubmittedOrders, currentMonthSubmittedOrders, monthStart, nextMonthStart, zoneId, asOf] diff --git a/order/src/test/java/com/baiyue/order/controller/DomesticWarehouseOrderExportOpenApiContractTest.java b/order/src/test/java/com/baiyue/order/controller/DomesticWarehouseOrderExportOpenApiContractTest.java new file mode 100644 index 000000000..61191bb3f --- /dev/null +++ b/order/src/test/java/com/baiyue/order/controller/DomesticWarehouseOrderExportOpenApiContractTest.java @@ -0,0 +1,41 @@ +package com.baiyue.order.controller; + +import org.junit.jupiter.api.Test; +import org.springframework.core.io.ClassPathResource; +import org.yaml.snakeyaml.Yaml; + +import java.nio.charset.StandardCharsets; +import java.util.Map; + +import static org.assertj.core.api.Assertions.assertThat; + +class DomesticWarehouseOrderExportOpenApiContractTest { + @Test + void documentsAsyncProviderSelectionPrecedenceAndListFilters() throws Exception { + String source = new ClassPathResource("openapi/order-v1.yaml") + .getContentAsString(StandardCharsets.UTF_8); + Map document = map(new Yaml().load(source)); + Map paths = map(document.get("paths")); + Map operation = map(map(paths.get( + "/internal/exports/v1/DOMESTIC_WAREHOUSE_ORDER_LIST/batches")).get("post")); + assertThat(operation.get("description").toString()) + .contains("selectedOrderNos 非空时忽略查询筛选", "不生成文件", + "DOMESTIC_WAREHOUSE_ORDER_LIST", "platform:order:export"); + + Map schemas = map(map(document.get("components")).get("schemas")); + Map parameters = map(map( + schemas.get("DomesticWarehouseOrderExportParameters")).get("properties")); + assertThat(parameters).containsKeys( + "selectedOrderNos", "tenantId", "keyword", "orderNo", "mainStatus", "customerId", + "receivingWarehouseId", "destinationWarehouseId", "inboundCode", "internalOrderStatus", + "expectedArrivalDate", "feeReviewStatus", "channelName", "containerNo", "cabinetNo", + "sailingDate", "loaded", "fullContainer", "measurementReviewStatus", "fbaCode", + "createdFrom", "createdTo"); + assertThat(parameters).doesNotContainKeys("page", "size", "orderId", "customerInternalId"); + } + + @SuppressWarnings("unchecked") + private Map map(Object value) { + return (Map) value; + } +} diff --git a/order/src/test/java/com/baiyue/order/controller/DomesticWarehouseOrderExportProviderControllerTest.java b/order/src/test/java/com/baiyue/order/controller/DomesticWarehouseOrderExportProviderControllerTest.java new file mode 100644 index 000000000..65ee2cc4a --- /dev/null +++ b/order/src/test/java/com/baiyue/order/controller/DomesticWarehouseOrderExportProviderControllerTest.java @@ -0,0 +1,43 @@ +package com.baiyue.order.controller; + +import com.baiyue.common.core.exception.BizException; +import com.baiyue.common.core.export.ExportContracts.ExportBatchRequest; +import com.baiyue.common.core.export.ExportContracts.ExportBatchResponse; +import com.baiyue.order.service.DomesticWarehouseOrderExportProviderService; +import jakarta.annotation.Resource; +import org.junit.jupiter.api.Test; +import org.springframework.boot.test.mock.mockito.MockBean; +import org.springframework.test.context.TestPropertySource; +import org.springframework.test.context.junit.jupiter.SpringJUnitConfig; + +import java.util.List; +import java.util.Map; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +@SpringJUnitConfig(DomesticWarehouseOrderExportProviderController.class) +@TestPropertySource(properties = "baiyue.security.internal-service-token=0123456789abcdef0123456789abcdef") +class DomesticWarehouseOrderExportProviderControllerTest { + private static final String TOKEN = "0123456789abcdef0123456789abcdef"; + + @MockBean private DomesticWarehouseOrderExportProviderService service; + @Resource private DomesticWarehouseOrderExportProviderController controller; + + @Test + void acceptsOnlyConfiguredInternalServiceToken() { + ExportBatchRequest request = new ExportBatchRequest( + "1001", "0", "PLATFORM", "7001", 1, Map.of(), null, null, 100); + ExportBatchResponse response = new ExportBatchResponse( + 1, "hash", "snapshot", List.of(), List.of(), null, false, 0L); + when(service.exportBatch(request)).thenReturn(response); + + assertThat(controller.batch(TOKEN, request).data()).isSameAs(response); + verify(service).exportBatch(request); + assertThatThrownBy(() -> controller.batch("wrong-token", request)) + .isInstanceOf(BizException.class) + .hasMessageContaining("Invalid internal service token"); + } +} diff --git a/order/src/test/java/com/baiyue/order/service/DomesticWarehouseOrderExportProviderServiceTest.java b/order/src/test/java/com/baiyue/order/service/DomesticWarehouseOrderExportProviderServiceTest.java new file mode 100644 index 000000000..8085ca724 --- /dev/null +++ b/order/src/test/java/com/baiyue/order/service/DomesticWarehouseOrderExportProviderServiceTest.java @@ -0,0 +1,145 @@ +package com.baiyue.order.service; + +import com.baiyue.common.core.api.PageResult; +import com.baiyue.common.core.exception.BizException; +import com.baiyue.common.core.export.ExportContracts.ExportBatchRequest; +import com.baiyue.common.core.export.ExportContracts.ExportBatchResponse; +import com.baiyue.order.dto.UnifiedOrderQuery; +import com.baiyue.order.dto.UnifiedOrderResponse; +import jakarta.annotation.Resource; +import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; +import org.springframework.boot.test.mock.mockito.MockBean; +import org.springframework.test.context.TestPropertySource; +import org.springframework.test.context.junit.jupiter.SpringJUnitConfig; + +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +@SpringJUnitConfig(DomesticWarehouseOrderExportProviderService.class) +@TestPropertySource(properties = "baiyue.security.internal-service-token=0123456789abcdef0123456789abcdef") +class DomesticWarehouseOrderExportProviderServiceTest { + @MockBean private UnifiedOrderQueryService orders; + @Resource private DomesticWarehouseOrderExportProviderService service; + + @Test + void nonEmptySelectionExportsOnlySelectedBusinessOrdersAndIgnoresFilters() { + UnifiedOrderResponse.Row first = row("ORD-2", "RK-2", "客户乙"); + UnifiedOrderResponse.Row second = row("ORD-1", "RK-1", "客户甲"); + when(orders.platformRowsByOrderNos(List.of("ORD-2", "ORD-1"))) + .thenReturn(List.of(first, second)); + + Map parameters = new LinkedHashMap<>(); + parameters.put("selectedOrderNos", List.of("ORD-2", "ORD-1")); + parameters.put("mainStatus", "PENDING_REVIEW"); + ExportBatchResponse response = service.exportBatch(request("1001", parameters, null, null, 500)); + + assertThat(response.totalRows()).isEqualTo(2); + assertThat(response.rows()).extracting(row -> row.get("orderNo")) + .containsExactly("ORD-2", "ORD-1"); + assertThat(response.rows().get(0)).containsEntry("inboundCode", "RK-2") + .containsEntry("customerName", "客户乙") + .doesNotContainKeys("orderId", "tenantId", "customerId", "inboundId", + "receivingWarehouseId", "destinationWarehouseId"); + assertThat(response.columns()).extracting(column -> column.key()) + .doesNotContain("orderId", "tenantId", "customerId", "inboundId"); + verify(orders, never()).platformPage(any(), any()); + } + + @Test + void emptySelectionExportsAllMatchingFiltersWithProviderPaging() { + UnifiedOrderResponse.Row row = row("ORD-3", "RK-3", "客户丙"); + when(orders.platformPage(eq(9001L), any())) + .thenReturn(new PageResult<>(List.of(row), 1, 500, 501, 2)); + + Map parameters = new LinkedHashMap<>(); + parameters.put("selectedOrderNos", List.of()); + parameters.put("tenantId", "9001"); + parameters.put("keyword", "RK-3"); + parameters.put("loaded", true); + parameters.put("createdFrom", "2026-08-01T00:00:00"); + ExportBatchResponse response = service.exportBatch(request("1002", parameters, null, null, 500)); + + ArgumentCaptor query = ArgumentCaptor.forClass(UnifiedOrderQuery.class); + verify(orders).platformPage(eq(9001L), query.capture()); + assertThat(query.getValue().keyword()).isEqualTo("RK-3"); + assertThat(query.getValue().loaded()).isTrue(); + assertThat(query.getValue().page()).isEqualTo(1); + assertThat(query.getValue().size()).isEqualTo(500); + assertThat(response.totalRows()).isEqualTo(501); + assertThat(response.hasMore()).isTrue(); + assertThat(response.nextCursor()).isEqualTo("2"); + } + + @Test + void selectionMustBeUniqueAndEntirelyVisible() { + assertThatThrownBy(() -> service.exportBatch(request("1003", + Map.of("selectedOrderNos", List.of("ORD-1", "ORD-1")), null, null, 100))) + .isInstanceOf(BizException.class) + .hasMessageContaining("不能重复"); + + UnifiedOrderResponse.Row visible = row("ORD-1", "RK-1", "客户甲"); + when(orders.platformRowsByOrderNos(List.of("ORD-1", "ORD-MISSING"))) + .thenReturn(List.of(visible)); + assertThatThrownBy(() -> service.exportBatch(request("1004", + Map.of("selectedOrderNos", List.of("ORD-1", "ORD-MISSING")), null, null, 100))) + .isInstanceOf(BizException.class) + .hasMessageContaining("不存在或不可见"); + } + + @Test + void filteredExportRejectsEmptyAndOverLimitResults() { + when(orders.platformPage(any(), any())) + .thenReturn(new PageResult<>(List.of(), 1, 100, 0, 0)); + assertThatThrownBy(() -> service.exportBatch(request("1005", Map.of(), null, null, 100))) + .isInstanceOf(BizException.class) + .hasMessageContaining("没有符合"); + + when(orders.platformPage(any(), any())) + .thenReturn(new PageResult<>(List.of(), 1, 100, 200_001, 2001)); + assertThatThrownBy(() -> service.exportBatch(request("1006", Map.of(), null, null, 100))) + .isInstanceOf(BizException.class) + .hasMessageContaining("超过最大限制"); + } + + @Test + void continuationSnapshotCannotBeReusedForDifferentTaskOrParameters() { + UnifiedOrderResponse.Row row = row("ORD-1", "RK-1", "客户甲"); + when(orders.platformPage(any(), any())) + .thenReturn(new PageResult<>(List.of(row), 1, 1, 2, 2)); + ExportBatchResponse first = service.exportBatch(request("1007", Map.of("keyword", "A"), null, null, 1)); + + assertThatThrownBy(() -> service.exportBatch( + request("1008", Map.of("keyword", "A"), "2", first.snapshotToken(), 1))) + .isInstanceOf(BizException.class) + .hasMessageContaining("does not match"); + assertThatThrownBy(() -> service.exportBatch( + request("1007", Map.of("keyword", "B"), "2", first.snapshotToken(), 1))) + .isInstanceOf(BizException.class) + .hasMessageContaining("does not match"); + } + + private ExportBatchRequest request(String taskId, Map parameters, + String cursor, String snapshot, int limit) { + return new ExportBatchRequest(taskId, "0", "PLATFORM", "7001", 1, + parameters, cursor, snapshot, limit); + } + + private UnifiedOrderResponse.Row row(String orderNo, String inboundCode, String customerName) { + UnifiedOrderResponse.Row row = mock(UnifiedOrderResponse.Row.class); + when(row.orderNo()).thenReturn(orderNo); + when(row.inboundCode()).thenReturn(inboundCode); + when(row.customerName()).thenReturn(customerName); + return row; + } +}