From 2847ad1b2d1339607be4500756906189b5778024 Mon Sep 17 00:00:00 2001 From: liuyihe <2513762445@qq.com> Date: Thu, 20 Aug 2026 15:58:25 +0800 Subject: [PATCH] =?UTF-8?q?=E6=8F=90=E4=BA=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- config/nacos/cn/event-bridge-service.yml | 2 + config/nacos/overseas/lastleg-service.yml | 6 ++ deploy/sql/头程数据库统一增量-MySQL8.sql | 65 +++++++++++++++ .../eventbridge/mapper/BridgeInboxMapper.java | 5 +- .../service/FirstlegEventClient.java | 7 ++ .../service/HttpFirstlegEventClient.java | 52 ++++++++++++ .../service/OverseasEventDispatcher.java | 10 ++- .../src/main/resources/application.yml | 1 + .../service/OverseasEventDispatcherTest.java | 17 ++++ .../main/resources/db/lastleg-permissions.sql | 83 +++++++++++++++---- .../LastlegPermissionSqlContractTest.java | 37 +++++++++ .../lastleg/BaiyueLastlegApplication.java | 2 + .../controller/LastlegOrderController.java | 6 ++ .../controller/DispatchController.java | 7 ++ .../lastleg/entity/LastlegEventOutbox.java | 2 + .../baiyue/lastleg/entity/LastlegOrder.java | 1 - .../controller/InboundPlanController.java | 6 ++ .../pda/controller/InboundPdaController.java | 12 +++ .../inboundplan/pda/dto/PdaResponses.java | 2 + .../gateway/HttpUnloadPhotoFileGateway.java | 79 ++++++++++++++++++ .../pda/gateway/UnloadPhotoFileGateway.java | 10 +++ .../service/InboundPdaWorkflowService.java | 41 +++++++++ .../InboundUnloadCompletedOutboxService.java | 17 ++-- .../InventoryCommandController.java | 12 ++- .../controller/InventoryQueryController.java | 7 ++ .../mapper/LastlegEventOutboxMapper.java | 40 +++++++++ ...PlatformOverseasProcurementController.java | 8 +- .../service/OverseasProcurementService.java | 4 +- .../RelabelApplicationController.java | 5 ++ .../service/ContainerUnloadOutboxRelay.java | 56 +++++++++++++ .../lastleg/service/EventBridgePublisher.java | 39 +++++++++ .../service/LoadingCompletedConsumer.java | 3 +- .../PlatformUsTruckOrderController.java | 15 +++- .../trucking/service/UsTruckOrderService.java | 2 +- .../src/main/resources/application.yml | 3 + .../src/main/resources/db/lastleg.sql | 7 +- ...LastlegPlatformPermissionContractTest.java | 57 +++++++++++++ .../InboundPdaWorkflowServiceTest.java | 57 ++++++++++++- ...boundUnloadCompletedOutboxServiceTest.java | 25 ++---- .../ContainerUnloadOutboxRelayTest.java | 57 +++++++++++++ .../service/LoadingCompletedConsumerTest.java | 2 +- scripts/db/incremental.sql | 67 +++++++++++++++ .../PlatformWarehouseController.java | 6 ++ .../PlatformWarehouseResourceController.java | 11 +++ ...atformWarehousePermissionContractTest.java | 36 ++++++++ 45 files changed, 921 insertions(+), 68 deletions(-) create mode 100644 event-bridge-service/src/main/java/com/baiyue/eventbridge/service/FirstlegEventClient.java create mode 100644 event-bridge-service/src/main/java/com/baiyue/eventbridge/service/HttpFirstlegEventClient.java create mode 100644 iam/src/test/java/com/baiyue/iam/repository/LastlegPermissionSqlContractTest.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/gateway/HttpUnloadPhotoFileGateway.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/gateway/UnloadPhotoFileGateway.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/service/ContainerUnloadOutboxRelay.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/service/EventBridgePublisher.java create mode 100644 lastleg-service/src/test/java/com/baiyue/lastleg/controller/LastlegPlatformPermissionContractTest.java create mode 100644 lastleg-service/src/test/java/com/baiyue/lastleg/service/ContainerUnloadOutboxRelayTest.java create mode 100644 warehouse-overseas-service/src/test/java/com/baiyue/warehouse/controller/PlatformWarehousePermissionContractTest.java diff --git a/config/nacos/cn/event-bridge-service.yml b/config/nacos/cn/event-bridge-service.yml index a5fbba087..0738b8d61 100644 --- a/config/nacos/cn/event-bridge-service.yml +++ b/config/nacos/cn/event-bridge-service.yml @@ -8,3 +8,5 @@ baiyue: region: ${REGION_CODE:CN} deployment-cell: ${DEPLOYMENT_CELL:prod-cn} data-residency: ${DATA_RESIDENCY:CN} + event-dispatcher: + firstleg-consumer-url: ${FIRSTLEG_CONTAINER_EVENT_CONSUMER_URL:http://firstleg-service:9203/internal/firstleg/v1/container-events} diff --git a/config/nacos/overseas/lastleg-service.yml b/config/nacos/overseas/lastleg-service.yml index 1b88f047a..68fbcefb3 100644 --- a/config/nacos/overseas/lastleg-service.yml +++ b/config/nacos/overseas/lastleg-service.yml @@ -4,3 +4,9 @@ spring: username: ${LASTLEG_DB_USERNAME} password: ${LASTLEG_DB_PASSWORD} driver-class-name: com.mysql.cj.jdbc.Driver +baiyue: + clients: + file: + url: ${FILE_SERVICE_URL} + event-bridge: + url: ${CN_EVENT_BRIDGE_URL} diff --git a/deploy/sql/头程数据库统一增量-MySQL8.sql b/deploy/sql/头程数据库统一增量-MySQL8.sql index a4849b700..c5488e94f 100644 --- a/deploy/sql/头程数据库统一增量-MySQL8.sql +++ b/deploy/sql/头程数据库统一增量-MySQL8.sql @@ -15701,6 +15701,71 @@ ALTER TABLE fl_inbound_outbox MODIFY COLUMN payload_hash VARCHAR(71) NULL COMMENT '事件规范化负载SHA-256;标准跨区事件含sha256:前缀'; ALTER TABLE fl_inbound_outbox MODIFY COLUMN payload_json LONGTEXT NOT NULL COMMENT '事件负载快照;文本类型保留跨区事件摘要所依赖的数值词法'; +-- 2026-08-20 / 海外仓现有业务平台权限目录。 +USE baiyue_iam; +DROP TEMPORARY TABLE IF EXISTS tmp_lastleg_role_grant; +CREATE TEMPORARY TABLE tmp_lastleg_role_grant ( + tenant_id BIGINT NOT NULL, role_id BIGINT NOT NULL, permission_code VARCHAR(128) NOT NULL, + PRIMARY KEY (tenant_id, role_id, permission_code) +); +INSERT IGNORE INTO tmp_lastleg_role_grant (tenant_id, role_id, permission_code) +SELECT role_permission.tenant_id, role_permission.role_id, permission.permission_code +FROM iam_role_permission role_permission +JOIN iam_permission permission ON permission.id=role_permission.permission_id +WHERE permission.id BETWEEN 5106000 AND 5106004 + AND permission.permission_code IN ('platform:lastleg:order:list','platform:lastleg:order:transfer'); +DELETE role_permission FROM iam_role_permission role_permission +JOIN iam_permission permission ON permission.id=role_permission.permission_id +WHERE (permission.id BETWEEN 5106000 AND 5106004 AND permission.permission_code LIKE 'platform:lastleg:%') + OR permission.permission_code IN ('platform:lastleg:order:detail','platform:lastleg:order:assign','platform:lastleg:order:merge-pallet'); +DELETE FROM iam_permission +WHERE (id BETWEEN 5106000 AND 5106004 AND permission_code LIKE 'platform:lastleg:%') + OR (tenant_id=0 AND app_code='PLATFORM' AND permission_code IN + ('platform:lastleg:order:detail','platform:lastleg:order:assign','platform:lastleg:order:merge-pallet')); +INSERT INTO iam_permission + (id,tenant_id,app_code,permission_type,parent_id,permission_code,permission_name,route_path,http_method,sort_no,status,built_in,deleted) +VALUES + (5107000,0,'PLATFORM','MENU',0,'platform:lastleg','海外仓业务','/lastleg',NULL,700,'ACTIVE',1,0), + (5107010,0,'PLATFORM','MENU',5107000,'platform:lastleg:order:list','尾程订单','/lastleg/orders','GET',10,'ACTIVE',1,0), + (5107011,0,'PLATFORM','BUTTON',5107010,'platform:lastleg:order:export','导出尾程订单',NULL,'GET',10,'ACTIVE',1,0), + (5107012,0,'PLATFORM','BUTTON',5107010,'platform:lastleg:order:transfer','尾程订单转仓',NULL,'PUT',20,'ACTIVE',1,0), + (5107020,0,'PLATFORM','MENU',5107000,'platform:lastleg:inbound-plan:list','入库单','/lastleg/inbound-plans','GET',20,'ACTIVE',1,0), + (5107021,0,'PLATFORM','BUTTON',5107020,'platform:lastleg:inbound-plan:create','新建入库单',NULL,'POST',10,'ACTIVE',1,0), + (5107022,0,'PLATFORM','BUTTON',5107020,'platform:lastleg:inbound-plan:edit','维护入库单',NULL,'POST',20,'ACTIVE',1,0), + (5107023,0,'PLATFORM','BUTTON',5107020,'platform:lastleg:inbound-plan:appointment','FBA预约',NULL,'POST',30,'ACTIVE',1,0), + (5107030,0,'PLATFORM','MENU',5107000,'platform:lastleg:relabel:list','换标单','/lastleg/relabels','GET',30,'ACTIVE',1,0), + (5107031,0,'PLATFORM','BUTTON',5107030,'platform:lastleg:relabel:create','新增换标单',NULL,'POST',10,'ACTIVE',1,0), + (5107032,0,'PLATFORM','BUTTON',5107030,'platform:lastleg:relabel:status','操作换标状态',NULL,'PUT',20,'ACTIVE',1,0), + (5107040,0,'PLATFORM','MENU',5107000,'platform:lastleg:inventory:list','库存管理','/lastleg/inventory','GET',40,'ACTIVE',1,0), + (5107041,0,'PLATFORM','BUTTON',5107040,'platform:lastleg:inventory:export','导出库存',NULL,'GET',10,'ACTIVE',1,0), + (5107042,0,'PLATFORM','BUTTON',5107040,'platform:lastleg:inventory:manage','库存业务操作',NULL,'POST',20,'ACTIVE',1,0), + (5107043,0,'PLATFORM','BUTTON',5107040,'platform:lastleg:inventory:adjust','库存盘点调整',NULL,'POST',30,'ACTIVE',1,0), + (5107050,0,'PLATFORM','MENU',5107000,'platform:lastleg:dispatch:list','排车管理','/lastleg/dispatches','GET',50,'ACTIVE',1,0), + (5107051,0,'PLATFORM','BUTTON',5107050,'platform:lastleg:dispatch:create','创建预排车单',NULL,'POST',10,'ACTIVE',1,0), + (5107052,0,'PLATFORM','BUTTON',5107050,'platform:lastleg:dispatch:edit','编辑排车单',NULL,'PUT',20,'ACTIVE',1,0), + (5107053,0,'PLATFORM','BUTTON',5107050,'platform:lastleg:dispatch:delete','取消排车单',NULL,'DELETE',30,'ACTIVE',1,0), + (5107060,0,'PLATFORM','MENU',5107000,'platform:lastleg:overseas-procurement:list','海外采购','/lastleg/overseas-procurements','GET',60,'ACTIVE',1,0), + (5107061,0,'PLATFORM','BUTTON',5107060,'platform:lastleg:overseas-procurement:edit','编辑海外采购单',NULL,'PATCH',10,'ACTIVE',1,0), + (5107062,0,'PLATFORM','BUTTON',5107060,'platform:lastleg:overseas-procurement:review','审核海外采购单',NULL,'POST',20,'ACTIVE',1,0), + (5107063,0,'PLATFORM','BUTTON',5107060,'platform:lastleg:overseas-procurement:outbound','登记采购出库',NULL,'POST',30,'ACTIVE',1,0), + (5107070,0,'PLATFORM','MENU',5107000,'platform:lastleg:us-trucking:list','本土卡车','/lastleg/us-truck-orders','GET',70,'ACTIVE',1,0), + (5107071,0,'PLATFORM','BUTTON',5107070,'platform:lastleg:us-trucking:status','操作卡车订单状态',NULL,'PATCH',10,'ACTIVE',1,0), + (5107072,0,'PLATFORM','BUTTON',5107070,'platform:lastleg:us-trucking:receive','登记卡车收托',NULL,'POST',20,'ACTIVE',1,0) +ON DUPLICATE KEY UPDATE permission_name=VALUES(permission_name),parent_id=VALUES(parent_id),permission_type=VALUES(permission_type), + route_path=VALUES(route_path),http_method=VALUES(http_method),sort_no=VALUES(sort_no),status='ACTIVE',built_in=1,deleted=0; +INSERT IGNORE INTO iam_role_permission (tenant_id,role_id,permission_id,created_at) +SELECT saved.tenant_id,saved.role_id,permission.id,NOW(3) +FROM tmp_lastleg_role_grant saved +JOIN iam_permission permission ON permission.tenant_id=0 AND permission.app_code='PLATFORM' + AND permission.permission_code=saved.permission_code; +DROP TEMPORARY TABLE IF EXISTS tmp_lastleg_role_grant; +INSERT IGNORE INTO iam_role_permission (tenant_id,role_id,permission_id,created_at) +SELECT 0,role.id,permission.id,NOW(3) FROM iam_role role +JOIN iam_permission permission ON permission.tenant_id=0 AND permission.app_code='PLATFORM' +WHERE role.tenant_id=0 AND role.app_code='PLATFORM' AND role.role_code='PLATFORM_ADMIN' + AND role.status='ACTIVE' AND role.deleted=0 + AND (permission.permission_code='platform:lastleg' OR permission.permission_code LIKE 'platform:lastleg:%'); + CALL baiyue_firstleg.assert_unified_final_schema(); DROP PROCEDURE IF EXISTS baiyue_firstleg.assert_unified_final_schema; SELECT 'PASS' AS migration_status, diff --git a/event-bridge-service/src/main/java/com/baiyue/eventbridge/mapper/BridgeInboxMapper.java b/event-bridge-service/src/main/java/com/baiyue/eventbridge/mapper/BridgeInboxMapper.java index f93a34ff2..568192832 100644 --- a/event-bridge-service/src/main/java/com/baiyue/eventbridge/mapper/BridgeInboxMapper.java +++ b/event-bridge-service/src/main/java/com/baiyue/eventbridge/mapper/BridgeInboxMapper.java @@ -16,8 +16,7 @@ import java.util.List; public interface BridgeInboxMapper extends BaseMapper { @Select(""" SELECT event_id FROM bridge_inbox - WHERE target_region = 'OVERSEAS' - AND ((status IN ('RECEIVED','RETRY') AND (next_retry_at IS NULL OR next_retry_at <= CURRENT_TIMESTAMP(3))) + WHERE ((status IN ('RECEIVED','RETRY') AND (next_retry_at IS NULL OR next_retry_at <= CURRENT_TIMESTAMP(3))) OR (status = 'PROCESSING' AND lease_until < CURRENT_TIMESTAMP(3))) ORDER BY received_at LIMIT #{limit} """) @@ -26,7 +25,7 @@ public interface BridgeInboxMapper extends BaseMapper { @Update(""" UPDATE bridge_inbox SET status='PROCESSING', lease_owner=#{owner}, lease_until=DATE_ADD(CURRENT_TIMESTAMP(3), INTERVAL #{leaseSeconds} SECOND) - WHERE event_id=#{eventId} AND target_region='OVERSEAS' + WHERE event_id=#{eventId} AND ((status IN ('RECEIVED','RETRY') AND (next_retry_at IS NULL OR next_retry_at <= CURRENT_TIMESTAMP(3))) OR (status='PROCESSING' AND lease_until < CURRENT_TIMESTAMP(3))) """) diff --git a/event-bridge-service/src/main/java/com/baiyue/eventbridge/service/FirstlegEventClient.java b/event-bridge-service/src/main/java/com/baiyue/eventbridge/service/FirstlegEventClient.java new file mode 100644 index 000000000..a0bb0e33e --- /dev/null +++ b/event-bridge-service/src/main/java/com/baiyue/eventbridge/service/FirstlegEventClient.java @@ -0,0 +1,7 @@ +package com.baiyue.eventbridge.service; + +import com.baiyue.eventbridge.domain.BridgeInbox; + +public interface FirstlegEventClient { + String deliver(BridgeInbox event) throws Exception; +} diff --git a/event-bridge-service/src/main/java/com/baiyue/eventbridge/service/HttpFirstlegEventClient.java b/event-bridge-service/src/main/java/com/baiyue/eventbridge/service/HttpFirstlegEventClient.java new file mode 100644 index 000000000..0aacdc3f3 --- /dev/null +++ b/event-bridge-service/src/main/java/com/baiyue/eventbridge/service/HttpFirstlegEventClient.java @@ -0,0 +1,52 @@ +package com.baiyue.eventbridge.service; + +import com.baiyue.common.event.EventEnvelope; +import com.baiyue.common.event.RegionCode; +import com.baiyue.eventbridge.config.EventDispatcherProperties; +import com.baiyue.eventbridge.domain.BridgeInbox; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import jakarta.annotation.PostConstruct; +import jakarta.annotation.Resource; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Component; + +import java.net.URI; +import java.net.http.HttpClient; +import java.net.http.HttpRequest; +import java.net.http.HttpResponse; + +@Component +public class HttpFirstlegEventClient implements FirstlegEventClient { + @Resource private ObjectMapper objectMapper; + @Resource private EventDispatcherProperties properties; + @Value("${baiyue.event-dispatcher.firstleg-consumer-url:http://127.0.0.1:9203/internal/firstleg/v1/container-events}") + private URI consumerUrl; + @Value("${baiyue.security.internal-service-token:}") private String serviceToken; + private HttpClient httpClient; + + @PostConstruct + void initializeHttpClient() { + httpClient = HttpClient.newBuilder().connectTimeout(properties.connectTimeout()).build(); + } + + @Override + public String deliver(BridgeInbox row) throws Exception { + EventEnvelope envelope = new EventEnvelope(row.getEventId(), row.getEventType(), row.getEventVersion(), + row.getOccurredAt(), RegionCode.valueOf(row.getSourceRegion()), RegionCode.valueOf(row.getTargetRegion()), + row.getCustomerId(), row.getAggregateType(), row.getAggregateId(), row.getAggregateVersion(), + row.getHandoverVersion(), row.getTraceId(), row.getCorrelationId(), row.getCausationId(), + objectMapper.readTree(row.getPayloadJson()), row.getPayloadHash()); + HttpRequest request = HttpRequest.newBuilder(consumerUrl).timeout(properties.requestTimeout()) + .header("Content-Type", "application/json") + .header("X-Internal-Service-Token", serviceToken) + .POST(HttpRequest.BodyPublishers.ofString(objectMapper.writeValueAsString(envelope))).build(); + HttpResponse response = httpClient.send(request, HttpResponse.BodyHandlers.ofString()); + JsonNode responseBody = objectMapper.readTree(response.body()); + if (response.statusCode() / 100 != 2 || responseBody == null + || !"200".equals(responseBody.path("code").asText())) { + throw new IllegalStateException("头程消费失败: HTTP " + response.statusCode()); + } + return String.valueOf(response.statusCode()); + } +} diff --git a/event-bridge-service/src/main/java/com/baiyue/eventbridge/service/OverseasEventDispatcher.java b/event-bridge-service/src/main/java/com/baiyue/eventbridge/service/OverseasEventDispatcher.java index 76c1bf82f..b795a7019 100644 --- a/event-bridge-service/src/main/java/com/baiyue/eventbridge/service/OverseasEventDispatcher.java +++ b/event-bridge-service/src/main/java/com/baiyue/eventbridge/service/OverseasEventDispatcher.java @@ -20,6 +20,7 @@ public class OverseasEventDispatcher { @Resource private BridgeInboxMapper inboxMapper; @Resource private BridgeDeliveryAttemptMapper attemptMapper; @Resource private LastlegEventClient eventClient; + @Resource private FirstlegEventClient firstlegEventClient; @Resource private EventDispatcherProperties properties; private final String leaseOwner = ManagementFactory.getRuntimeMXBean().getName() + ":" + UUID.randomUUID(); @@ -40,7 +41,7 @@ public class OverseasEventDispatcher { int deliveryAttemptNo = inboxMapper.nextAttemptNo(eventId); BridgeDeliveryAttempt attempt = startAttempt(event, deliveryAttemptNo); try { - String responseCode = eventClient.deliver(event); + String responseCode = deliver(event); if (inboxMapper.markProcessed(eventId, leaseOwner) != 1) { throw new IllegalStateException("事件租约已失效,无法确认投递结果"); } @@ -49,11 +50,16 @@ public class OverseasEventDispatcher { String error = abbreviate(exception.getMessage(), 1000); inboxMapper.markFailed(eventId, leaseOwner, properties.maxAttempts(), backoffSeconds(retryAttempt), error); finishAttempt(attempt, retryAttempt >= properties.maxAttempts() ? "DEAD" : "RETRY", null, error); - log.warn("尾程事件投递失败 eventId={}, retryAttempt={}, deliveryAttempt={}, reason={}", + log.warn("跨区事件投递失败 eventId={}, retryAttempt={}, deliveryAttempt={}, reason={}", eventId, retryAttempt, deliveryAttemptNo, error); } } + private String deliver(BridgeInbox event) throws Exception { + return "CN".equals(event.getTargetRegion()) + ? firstlegEventClient.deliver(event) : eventClient.deliver(event); + } + private BridgeDeliveryAttempt startAttempt(BridgeInbox event, int attemptNo) { BridgeDeliveryAttempt attempt = new BridgeDeliveryAttempt(); attempt.setEventId(event.getEventId()); diff --git a/event-bridge-service/src/main/resources/application.yml b/event-bridge-service/src/main/resources/application.yml index 53a1bdc39..de98ed4c6 100644 --- a/event-bridge-service/src/main/resources/application.yml +++ b/event-bridge-service/src/main/resources/application.yml @@ -32,6 +32,7 @@ baiyue: region: ${REGION_CODE:OVERSEAS} event-dispatcher: consumer-url: ${LASTLEG_CONSUMER_URL:http://127.0.0.1:9303/internal/lastleg/events} + firstleg-consumer-url: ${FIRSTLEG_CONTAINER_EVENT_CONSUMER_URL:http://127.0.0.1:9203/internal/firstleg/v1/container-events} fixed-delay: ${EVENT_DISPATCH_FIXED_DELAY:3000} batch-size: ${EVENT_DISPATCH_BATCH_SIZE:50} max-attempts: ${EVENT_DISPATCH_MAX_ATTEMPTS:10} diff --git a/event-bridge-service/src/test/java/com/baiyue/eventbridge/service/OverseasEventDispatcherTest.java b/event-bridge-service/src/test/java/com/baiyue/eventbridge/service/OverseasEventDispatcherTest.java index f3f727fb0..67cb7b3d1 100644 --- a/event-bridge-service/src/test/java/com/baiyue/eventbridge/service/OverseasEventDispatcherTest.java +++ b/event-bridge-service/src/test/java/com/baiyue/eventbridge/service/OverseasEventDispatcherTest.java @@ -21,6 +21,7 @@ class OverseasEventDispatcherTest { private final BridgeInboxMapper inboxMapper = mock(BridgeInboxMapper.class); private final BridgeDeliveryAttemptMapper attemptMapper = mock(BridgeDeliveryAttemptMapper.class); private final LastlegEventClient eventClient = mock(LastlegEventClient.class); + private final FirstlegEventClient firstlegEventClient = mock(FirstlegEventClient.class); private OverseasEventDispatcher dispatcher; private BridgeInbox event; @@ -34,6 +35,7 @@ class OverseasEventDispatcherTest { ReflectionTestUtils.setField(dispatcher, "inboxMapper", inboxMapper); ReflectionTestUtils.setField(dispatcher, "attemptMapper", attemptMapper); ReflectionTestUtils.setField(dispatcher, "eventClient", eventClient); + ReflectionTestUtils.setField(dispatcher, "firstlegEventClient", firstlegEventClient); ReflectionTestUtils.setField(dispatcher, "properties", properties); event = new BridgeInbox(); event.setEventId("event-1"); @@ -83,9 +85,24 @@ class OverseasEventDispatcherTest { dispatcher.dispatchOne("event-1"); verifyNoInteractions(eventClient); + verifyNoInteractions(firstlegEventClient); verify(attemptMapper, never()).insert(any(BridgeDeliveryAttempt.class)); } + @Test + void cnTargetIsDeliveredToFirstleg() throws Exception { + event.setSourceRegion("OVERSEAS"); + event.setTargetRegion("CN"); + when(firstlegEventClient.deliver(event)).thenReturn("200"); + when(inboxMapper.markProcessed(eq("event-1"), anyString())).thenReturn(1); + + dispatcher.dispatchOne("event-1"); + + verify(firstlegEventClient).deliver(event); + verifyNoInteractions(eventClient); + assertThat(updatedAttempt().getStatus()).isEqualTo("DELIVERED"); + } + private BridgeDeliveryAttempt updatedAttempt() { ArgumentCaptor captor = ArgumentCaptor.forClass(BridgeDeliveryAttempt.class); verify(attemptMapper).updateById(captor.capture()); diff --git a/iam/src/main/resources/db/lastleg-permissions.sql b/iam/src/main/resources/db/lastleg-permissions.sql index 98348b610..6d8bd5033 100644 --- a/iam/src/main/resources/db/lastleg-permissions.sql +++ b/iam/src/main/resources/db/lastleg-permissions.sql @@ -1,25 +1,78 @@ USE baiyue_iam; --- 尾程订单权限属于平台端;列表权限使用 MENU,其余操作使用 BUTTON。 +-- 海外业务权限使用独立 5107000 号段,避免与消息中心 5106000 号段冲突。 +DROP TEMPORARY TABLE IF EXISTS tmp_lastleg_role_grant; +CREATE TEMPORARY TABLE tmp_lastleg_role_grant ( + tenant_id BIGINT NOT NULL, role_id BIGINT NOT NULL, permission_code VARCHAR(128) NOT NULL, + PRIMARY KEY (tenant_id, role_id, permission_code) +); +INSERT IGNORE INTO tmp_lastleg_role_grant (tenant_id, role_id, permission_code) +SELECT role_permission.tenant_id, role_permission.role_id, permission.permission_code +FROM iam_role_permission role_permission +JOIN iam_permission permission ON permission.id = role_permission.permission_id +WHERE permission.id BETWEEN 5106000 AND 5106004 + AND permission.permission_code IN ('platform:lastleg:order:list', 'platform:lastleg:order:transfer'); + +DELETE role_permission FROM iam_role_permission role_permission +JOIN iam_permission permission ON permission.id = role_permission.permission_id +WHERE (permission.id BETWEEN 5106000 AND 5106004 AND permission.permission_code LIKE 'platform:lastleg:%') + OR permission.permission_code IN ( + 'platform:lastleg:order:detail', 'platform:lastleg:order:assign', + 'platform:lastleg:order:merge-pallet'); +DELETE FROM iam_permission +WHERE (id BETWEEN 5106000 AND 5106004 AND permission_code LIKE 'platform:lastleg:%') + OR (tenant_id = 0 AND app_code = 'PLATFORM' AND permission_code IN ( + 'platform:lastleg:order:detail', 'platform:lastleg:order:assign', + 'platform:lastleg:order:merge-pallet')); + INSERT INTO iam_permission (id, tenant_id, app_code, permission_type, parent_id, permission_code, permission_name, route_path, http_method, sort_no, status, built_in, deleted) VALUES - (5106000, 0, 'PLATFORM', 'MENU', 0, 'platform:lastleg:order:list', '尾程订单', '/lastleg/orders', 'GET', 60, 'ACTIVE', 1, 0), - (5106001, 0, 'PLATFORM', 'BUTTON', 5106000, 'platform:lastleg:order:detail', '查看尾程订单详情', NULL, 'GET', 10, 'ACTIVE', 1, 0), - (5106002, 0, 'PLATFORM', 'BUTTON', 5106000, 'platform:lastleg:order:assign', '分配尾程订单', NULL, 'POST', 20, 'ACTIVE', 1, 0), - (5106003, 0, 'PLATFORM', 'BUTTON', 5106000, 'platform:lastleg:order:transfer', '转派尾程订单', NULL, 'POST', 30, 'ACTIVE', 1, 0), - (5106004, 0, 'PLATFORM', 'BUTTON', 5106000, 'platform:lastleg:order:merge-pallet', '合并尾程托盘', NULL, 'POST', 40, 'ACTIVE', 1, 0) -ON DUPLICATE KEY UPDATE permission_name=VALUES(permission_name), route_path=VALUES(route_path), - http_method=VALUES(http_method), sort_no=VALUES(sort_no), status='ACTIVE', built_in=1, deleted=0; + (5107000, 0, 'PLATFORM', 'MENU', 0, 'platform:lastleg', '海外仓业务', '/lastleg', NULL, 700, 'ACTIVE', 1, 0), + (5107010, 0, 'PLATFORM', 'MENU', 5107000, 'platform:lastleg:order:list', '尾程订单', '/lastleg/orders', 'GET', 10, 'ACTIVE', 1, 0), + (5107011, 0, 'PLATFORM', 'BUTTON', 5107010, 'platform:lastleg:order:export', '导出尾程订单', NULL, 'GET', 10, 'ACTIVE', 1, 0), + (5107012, 0, 'PLATFORM', 'BUTTON', 5107010, 'platform:lastleg:order:transfer', '尾程订单转仓', NULL, 'PUT', 20, 'ACTIVE', 1, 0), + (5107020, 0, 'PLATFORM', 'MENU', 5107000, 'platform:lastleg:inbound-plan:list', '入库单', '/lastleg/inbound-plans', 'GET', 20, 'ACTIVE', 1, 0), + (5107021, 0, 'PLATFORM', 'BUTTON', 5107020, 'platform:lastleg:inbound-plan:create', '新建入库单', NULL, 'POST', 10, 'ACTIVE', 1, 0), + (5107022, 0, 'PLATFORM', 'BUTTON', 5107020, 'platform:lastleg:inbound-plan:edit', '维护入库单', NULL, 'POST', 20, 'ACTIVE', 1, 0), + (5107023, 0, 'PLATFORM', 'BUTTON', 5107020, 'platform:lastleg:inbound-plan:appointment', 'FBA预约', NULL, 'POST', 30, 'ACTIVE', 1, 0), + (5107030, 0, 'PLATFORM', 'MENU', 5107000, 'platform:lastleg:relabel:list', '换标单', '/lastleg/relabels', 'GET', 30, 'ACTIVE', 1, 0), + (5107031, 0, 'PLATFORM', 'BUTTON', 5107030, 'platform:lastleg:relabel:create', '新增换标单', NULL, 'POST', 10, 'ACTIVE', 1, 0), + (5107032, 0, 'PLATFORM', 'BUTTON', 5107030, 'platform:lastleg:relabel:status', '操作换标状态', NULL, 'PUT', 20, 'ACTIVE', 1, 0), + (5107040, 0, 'PLATFORM', 'MENU', 5107000, 'platform:lastleg:inventory:list', '库存管理', '/lastleg/inventory', 'GET', 40, 'ACTIVE', 1, 0), + (5107041, 0, 'PLATFORM', 'BUTTON', 5107040, 'platform:lastleg:inventory:export', '导出库存', NULL, 'GET', 10, 'ACTIVE', 1, 0), + (5107042, 0, 'PLATFORM', 'BUTTON', 5107040, 'platform:lastleg:inventory:manage', '库存业务操作', NULL, 'POST', 20, 'ACTIVE', 1, 0), + (5107043, 0, 'PLATFORM', 'BUTTON', 5107040, 'platform:lastleg:inventory:adjust', '库存盘点调整', NULL, 'POST', 30, 'ACTIVE', 1, 0), + (5107050, 0, 'PLATFORM', 'MENU', 5107000, 'platform:lastleg:dispatch:list', '排车管理', '/lastleg/dispatches', 'GET', 50, 'ACTIVE', 1, 0), + (5107051, 0, 'PLATFORM', 'BUTTON', 5107050, 'platform:lastleg:dispatch:create', '创建预排车单', NULL, 'POST', 10, 'ACTIVE', 1, 0), + (5107052, 0, 'PLATFORM', 'BUTTON', 5107050, 'platform:lastleg:dispatch:edit', '编辑排车单', NULL, 'PUT', 20, 'ACTIVE', 1, 0), + (5107053, 0, 'PLATFORM', 'BUTTON', 5107050, 'platform:lastleg:dispatch:delete', '取消排车单', NULL, 'DELETE', 30, 'ACTIVE', 1, 0), + (5107060, 0, 'PLATFORM', 'MENU', 5107000, 'platform:lastleg:overseas-procurement:list', '海外采购', '/lastleg/overseas-procurements', 'GET', 60, 'ACTIVE', 1, 0), + (5107061, 0, 'PLATFORM', 'BUTTON', 5107060, 'platform:lastleg:overseas-procurement:edit', '编辑海外采购单', NULL, 'PATCH', 10, 'ACTIVE', 1, 0), + (5107062, 0, 'PLATFORM', 'BUTTON', 5107060, 'platform:lastleg:overseas-procurement:review', '审核海外采购单', NULL, 'POST', 20, 'ACTIVE', 1, 0), + (5107063, 0, 'PLATFORM', 'BUTTON', 5107060, 'platform:lastleg:overseas-procurement:outbound', '登记采购出库', NULL, 'POST', 30, 'ACTIVE', 1, 0), + (5107070, 0, 'PLATFORM', 'MENU', 5107000, 'platform:lastleg:us-trucking:list', '本土卡车', '/lastleg/us-truck-orders', 'GET', 70, 'ACTIVE', 1, 0), + (5107071, 0, 'PLATFORM', 'BUTTON', 5107070, 'platform:lastleg:us-trucking:status', '操作卡车订单状态', NULL, 'PATCH', 10, 'ACTIVE', 1, 0), + (5107072, 0, 'PLATFORM', 'BUTTON', 5107070, 'platform:lastleg:us-trucking:receive', '登记卡车收托', NULL, 'POST', 20, 'ACTIVE', 1, 0) +ON DUPLICATE KEY UPDATE + permission_name = VALUES(permission_name), parent_id = VALUES(parent_id), + permission_type = VALUES(permission_type), route_path = VALUES(route_path), + http_method = VALUES(http_method), sort_no = VALUES(sort_no), + status = 'ACTIVE', built_in = 1, deleted = 0; + +INSERT IGNORE INTO iam_role_permission (tenant_id, role_id, permission_id, created_at) +SELECT saved.tenant_id, saved.role_id, permission.id, NOW(3) +FROM tmp_lastleg_role_grant saved +JOIN iam_permission permission ON permission.tenant_id = 0 + AND permission.app_code = 'PLATFORM' AND permission.permission_code = saved.permission_code; +DROP TEMPORARY TABLE IF EXISTS tmp_lastleg_role_grant; INSERT IGNORE INTO iam_role_permission (tenant_id, role_id, permission_id, created_at) SELECT 0, role.id, permission.id, NOW(3) FROM iam_role role -JOIN iam_permission permission ON permission.tenant_id=0 AND permission.app_code='PLATFORM' -WHERE role.tenant_id=0 AND role.app_code='PLATFORM' AND role.role_code='PLATFORM_ADMIN' - AND role.status='ACTIVE' AND role.deleted=0 - AND permission.permission_code IN ( - 'platform:lastleg:order:list', 'platform:lastleg:order:detail', - 'platform:lastleg:order:assign', 'platform:lastleg:order:transfer', - 'platform:lastleg:order:merge-pallet'); +JOIN iam_permission permission ON permission.tenant_id = 0 AND permission.app_code = 'PLATFORM' +WHERE role.tenant_id = 0 AND role.app_code = 'PLATFORM' AND role.role_code = 'PLATFORM_ADMIN' + AND role.status = 'ACTIVE' AND role.deleted = 0 + AND (permission.permission_code = 'platform:lastleg' + OR permission.permission_code LIKE 'platform:lastleg:%'); diff --git a/iam/src/test/java/com/baiyue/iam/repository/LastlegPermissionSqlContractTest.java b/iam/src/test/java/com/baiyue/iam/repository/LastlegPermissionSqlContractTest.java new file mode 100644 index 000000000..bd47285d5 --- /dev/null +++ b/iam/src/test/java/com/baiyue/iam/repository/LastlegPermissionSqlContractTest.java @@ -0,0 +1,37 @@ +package com.baiyue.iam.repository; + +import org.junit.jupiter.api.Test; +import org.springframework.core.io.ClassPathResource; + +import java.nio.charset.StandardCharsets; + +import static org.assertj.core.api.Assertions.assertThat; + +class LastlegPermissionSqlContractTest { + @Test + void registersExistingOverseasBusinessesAndMigratesLegacyIds() throws Exception { + String sql = new ClassPathResource("db/lastleg-permissions.sql") + .getContentAsString(StandardCharsets.UTF_8); + + assertThat(sql).contains( + "tmp_lastleg_role_grant", "id BETWEEN 5106000 AND 5106004", + "(5107000, 0, 'PLATFORM', 'MENU'", + "platform:lastleg:order:list", "platform:lastleg:inbound-plan:list", + "platform:lastleg:relabel:list", "platform:lastleg:inventory:list", + "platform:lastleg:dispatch:list", "platform:lastleg:overseas-procurement:list", + "platform:lastleg:us-trucking:list", + "INSERT IGNORE INTO iam_role_permission", "role.role_code = 'PLATFORM_ADMIN'"); + assertThat(sql).doesNotContain("(5106000, 0, 'PLATFORM'", "platform:lastleg:order:view"); + } + + @Test + void listPermissionsOwnDetailsAndRetiredDetailPermissionIsDeleted() throws Exception { + String sql = new ClassPathResource("db/lastleg-permissions.sql") + .getContentAsString(StandardCharsets.UTF_8); + + assertThat(sql).contains( + "platform:lastleg:order:detail", "DELETE FROM iam_permission", + "(5107010, 0, 'PLATFORM', 'MENU', 5107000, 'platform:lastleg:order:list'", + "(5107020, 0, 'PLATFORM', 'MENU', 5107000, 'platform:lastleg:inbound-plan:list'"); + } +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/BaiyueLastlegApplication.java b/lastleg-service/src/main/java/com/baiyue/lastleg/BaiyueLastlegApplication.java index 3b63ff22f..7cc6f7ea0 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/BaiyueLastlegApplication.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/BaiyueLastlegApplication.java @@ -3,12 +3,14 @@ package com.baiyue.lastleg; import org.mybatis.spring.annotation.MapperScan; import org.springframework.boot.SpringApplication; import org.springframework.boot.autoconfigure.SpringBootApplication; +import org.springframework.scheduling.annotation.EnableScheduling; /** * 尾程微服务预留模块启动入口,初始化 Spring Boot 应用并扫描尾程 Mapper。 */ @MapperScan("com.baiyue.lastleg.mapper") @SpringBootApplication(scanBasePackages = "com.baiyue") +@EnableScheduling public class BaiyueLastlegApplication { public static void main(String[] args) { SpringApplication.run(BaiyueLastlegApplication.class, args); } } diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/controller/LastlegOrderController.java b/lastleg-service/src/main/java/com/baiyue/lastleg/controller/LastlegOrderController.java index 5543eae2e..0f52f0900 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/controller/LastlegOrderController.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/controller/LastlegOrderController.java @@ -19,6 +19,7 @@ import org.springframework.http.HttpStatus; import org.springframework.http.MediaType; import org.springframework.http.ResponseEntity; import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.security.access.prepost.PreAuthorize; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.PathVariable; import org.springframework.web.bind.annotation.PostMapping; @@ -49,6 +50,7 @@ public class LastlegOrderController { } @GetMapping("/api/platform/v1/last-leg/orders") + @PreAuthorize("hasAuthority('platform:lastleg:order:list')") /** 分页查询尾程订单列表。 */ @Operation(summary = "分页查询尾程订单") public ApiResponse> list( @@ -59,6 +61,7 @@ public class LastlegOrderController { } @GetMapping("/api/platform/v1/last-leg/orders/{id}") + @PreAuthorize("hasAuthority('platform:lastleg:order:list')") /** 查询指定入仓单维度的尾程订单详情。 */ @Operation(summary = "查询尾程订单详情") public ApiResponse detail( @@ -69,6 +72,7 @@ public class LastlegOrderController { } @GetMapping(value = "/api/platform/v1/last-leg/orders/export", produces = "text/csv") + @PreAuthorize("hasAuthority('platform:lastleg:order:export')") /** 按筛选条件导出尾程订单 CSV 文件。 */ @Operation(summary = "导出尾程订单") public ResponseEntity export( @@ -83,6 +87,7 @@ public class LastlegOrderController { } @PutMapping("/api/platform/v1/last-leg/orders/{id}/destination") + @PreAuthorize("hasAuthority('platform:lastleg:order:transfer')") /** 将单个尾程订单转移到目标仓库。 */ @Operation(summary = "单个订单转移仓库") public ApiResponse transferOne( @@ -94,6 +99,7 @@ public class LastlegOrderController { } @PutMapping("/api/platform/v1/last-leg/orders/destination") + @PreAuthorize("hasAuthority('platform:lastleg:order:transfer')") /** 将多个尾程订单批量转移到目标仓库。 */ @Operation(summary = "批量订单转移仓库") public ApiResponse transferBatch( diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/controller/DispatchController.java b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/controller/DispatchController.java index dad601018..378b6adb4 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/controller/DispatchController.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/controller/DispatchController.java @@ -17,6 +17,7 @@ import jakarta.annotation.Resource; import jakarta.validation.Valid; import org.springframework.http.HttpStatus; import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.security.access.prepost.PreAuthorize; import org.springframework.web.bind.annotation.DeleteMapping; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.PathVariable; @@ -38,6 +39,7 @@ public class DispatchController { @Resource private DispatchPdaWorkflowService pdaWorkflow; @GetMapping("/candidates") + @PreAuthorize("hasAuthority('platform:lastleg:dispatch:list')") /** 查询可用于智能预派车的未锁定托盘。 */ @Operation(summary = "查询可派车托盘") public ApiResponse> candidates( @@ -50,6 +52,7 @@ public class DispatchController { } @PostMapping("/pre-arrange") + @PreAuthorize("hasAuthority('platform:lastleg:dispatch:create')") /** 创建待出发派车单并锁定选中的托盘。 */ @Operation(summary = "创建预派车单") public ApiResponse preArrange( @@ -62,6 +65,7 @@ public class DispatchController { } @GetMapping + @PreAuthorize("hasAuthority('platform:lastleg:dispatch:list')") /** 分页查询派车单。 */ @Operation(summary = "分页查询派车单") public ApiResponse> page( @@ -72,6 +76,7 @@ public class DispatchController { } @GetMapping("/{dispatchNo}") + @PreAuthorize("hasAuthority('platform:lastleg:dispatch:list')") /** 查询派车单详情及托盘目的地。 */ @Operation(summary = "查询派车单详情") public ApiResponse detail( @@ -82,6 +87,7 @@ public class DispatchController { } @PutMapping("/{dispatchNo}") + @PreAuthorize("hasAuthority('platform:lastleg:dispatch:edit')") /** 编辑车辆、司机、预约、路线和出发信息。 */ @Operation(summary = "编辑派车单") public ApiResponse update( @@ -93,6 +99,7 @@ public class DispatchController { } @DeleteMapping("/{dispatchNo}") + @PreAuthorize("hasAuthority('platform:lastleg:dispatch:delete')") /** 取消待处理派车单并释放托盘锁定。 */ @Operation(summary = "取消派车单") public ApiResponse cancel( diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/entity/LastlegEventOutbox.java b/lastleg-service/src/main/java/com/baiyue/lastleg/entity/LastlegEventOutbox.java index a1179f95d..109ec9c40 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/entity/LastlegEventOutbox.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/entity/LastlegEventOutbox.java @@ -24,5 +24,7 @@ public class LastlegEventOutbox { private LocalDateTime nextRetryAt; private LocalDateTime publishedAt; private String lastError; + private String lockedBy; + private LocalDateTime lockedAt; private LocalDateTime createdAt; } diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/entity/LastlegOrder.java b/lastleg-service/src/main/java/com/baiyue/lastleg/entity/LastlegOrder.java index 903ab3afb..4337ed4e0 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/entity/LastlegOrder.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/entity/LastlegOrder.java @@ -56,7 +56,6 @@ public class LastlegOrder { private String warehouseRemark; private String sourceEventId; private String sourceLoadingPlanId; - private String sourceContainerBookingId; private Long sourceLoadingPlanVersion; private Long sourceHandoverVersion; private String sourcePayloadHash; diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/controller/InboundPlanController.java b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/controller/InboundPlanController.java index c5ac307ac..949799eb2 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/controller/InboundPlanController.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/controller/InboundPlanController.java @@ -15,6 +15,7 @@ import jakarta.validation.Valid; import jakarta.annotation.Resource; import org.springframework.http.HttpStatus; import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.security.access.prepost.PreAuthorize; import org.springframework.web.bind.annotation.*; import org.springframework.web.server.ResponseStatusException; @@ -26,6 +27,7 @@ public class InboundPlanController { @Resource private InboundFbaAppointmentService appointmentService; @PostMapping("/convert") + @PreAuthorize("hasAuthority('platform:lastleg:inbound-plan:create')") /** 将入仓单转换为入仓计划。 */ @Operation(summary = "入仓单转入仓计划") public ApiResponse convert(@AuthenticationPrincipal AuthenticatedUser user, @@ -34,6 +36,7 @@ public class InboundPlanController { } @GetMapping("/{planNo}") + @PreAuthorize("hasAuthority('platform:lastleg:inbound-plan:list')") /** 查询入仓计划及其明细、托盘和分配信息。 */ @Operation(summary = "查询入仓计划详情") public ApiResponse detail(@AuthenticationPrincipal AuthenticatedUser user, @@ -43,6 +46,7 @@ public class InboundPlanController { } @PostMapping("/{planNo}/pallets/supplement") + @PreAuthorize("hasAuthority('platform:lastleg:inbound-plan:edit')") /** 为计划目标仓新增一个可打印的补充托盘。 */ @Operation(summary = "新增补充托盘") public ApiResponse supplementPallet(@AuthenticationPrincipal AuthenticatedUser user, @@ -52,6 +56,7 @@ public class InboundPlanController { } @PostMapping("/fba-appointments/batch") + @PreAuthorize("hasAuthority('platform:lastleg:inbound-plan:appointment')") /** 为选中的入仓明细创建 FBA 预约。 */ @Operation(summary = "创建 FBA 预约") public ApiResponse batchFbaAppointment( @@ -63,6 +68,7 @@ public class InboundPlanController { } @GetMapping("/fba-appointments/{appointmentNo}") + @PreAuthorize("hasAuthority('platform:lastleg:inbound-plan:list')") /** 查询 FBA 预约及其选中的入仓明细。 */ @Operation(summary = "查询 FBA 预约详情") public ApiResponse fbaAppointmentDetail( diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/controller/InboundPdaController.java b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/controller/InboundPdaController.java index 9cc116ea1..566c38e07 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/controller/InboundPdaController.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/controller/InboundPdaController.java @@ -16,6 +16,9 @@ import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RequestParam; import org.springframework.web.bind.annotation.RestController; +import org.springframework.web.bind.annotation.RequestPart; +import org.springframework.web.multipart.MultipartFile; +import org.springframework.http.MediaType; import org.springframework.web.server.ResponseStatusException; import java.util.List; @@ -114,6 +117,15 @@ public class InboundPdaController { return ApiResponse.success(service.uploadUnloadPhotos(scope(user, customerId), planNo, request)); } + @PostMapping(value = "/unload-photos/files", consumes = MediaType.MULTIPART_FORM_DATA_VALUE) + public ApiResponse uploadUnloadPhotoFile( + @AuthenticationPrincipal AuthenticatedUser user, @PathVariable String planNo, + @RequestParam(required = false) String customerId, @RequestPart("file") MultipartFile file) { + requireOperate(user); + return ApiResponse.success(service.uploadUnloadPhotoFile( + scope(user, customerId), planNo, user.userId(), file)); + } + private String scope(AuthenticatedUser user, String requestedCustomer) { if (user == null || user.tenantId() == null) throw new ResponseStatusException(HttpStatus.UNAUTHORIZED); if ("PLATFORM".equals(user.appCode()) && user.tenantId() == 0L) { diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/dto/PdaResponses.java b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/dto/PdaResponses.java index 0c6a2bb40..fa261c613 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/dto/PdaResponses.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/dto/PdaResponses.java @@ -12,6 +12,8 @@ public final class PdaResponses { List unloadPhotoFileIds, LocalDateTime unloadPhotoUploadedAt, LocalDateTime estimatedEmptyReturnAt) {} + public record UnloadPhotoFile(long fileId, String fileName, String contentType, long sizeBytes) {} + public record Operation(String result, long allocationId, int requiredPackageCount, int countedPackageCount, int pickedPackageCount, BigDecimal actualVolumeCbm, Integer remainingPackageCount, diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/gateway/HttpUnloadPhotoFileGateway.java b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/gateway/HttpUnloadPhotoFileGateway.java new file mode 100644 index 000000000..debcb721b --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/gateway/HttpUnloadPhotoFileGateway.java @@ -0,0 +1,79 @@ +package com.baiyue.lastleg.inboundplan.pda.gateway; + +import jakarta.annotation.PostConstruct; +import jakarta.annotation.Resource; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.http.HttpStatusCode; +import org.springframework.http.MediaType; +import org.springframework.http.client.MultipartBodyBuilder; +import org.springframework.core.io.ByteArrayResource; +import org.springframework.stereotype.Component; +import org.springframework.web.client.RestClient; +import org.springframework.web.multipart.MultipartFile; + +import java.io.IOException; + +@Component +public class HttpUnloadPhotoFileGateway implements UnloadPhotoFileGateway { + private static final String BUSINESS_TYPE = "OVERSEAS_UNLOAD_PHOTO"; + @Resource private RestClient.Builder restClientBuilder; + @Value("${baiyue.clients.file.url:http://127.0.0.1:9205}") private String baseUrl; + @Value("${baiyue.security.internal-service-token:}") private String internalToken; + private RestClient client; + + @PostConstruct + void initializeClient() { client = restClientBuilder.baseUrl(baseUrl).build(); } + + @Override + public UploadedFile upload(String customerId, long taskId, long operatorId, MultipartFile file) { + long tenantId = tenantId(customerId); + MultipartBodyBuilder body = new MultipartBodyBuilder(); + try { + body.part("file", new ByteArrayResource(file.getBytes()) { + @Override public String getFilename() { return file.getOriginalFilename(); } + }).contentType(file.getContentType() == null ? MediaType.APPLICATION_OCTET_STREAM + : MediaType.parseMediaType(file.getContentType())); + } catch (IOException exception) { + throw new IllegalArgumentException("Cannot read unload photo", exception); + } + MetadataEnvelope response = client.post() + .uri("/internal/file/v1/business-files/{tenantId}/{businessType}/{businessId}", + tenantId, BUSINESS_TYPE, taskId) + .header("X-Internal-Service-Token", internalToken).header("X-Operator-Id", String.valueOf(operatorId)) + .contentType(MediaType.MULTIPART_FORM_DATA).body(body.build()).retrieve() + .onStatus(HttpStatusCode::isError, (request, result) -> { + throw new IllegalArgumentException("Unload photo upload failed"); + }).body(MetadataEnvelope.class); + Metadata data = response == null ? null : response.data(); + if (data == null || data.fileId() == null) throw new IllegalArgumentException("File service returned no photo metadata"); + return new UploadedFile(data.fileId(), data.fileName(), data.contentType(), + data.sizeBytes() == null ? 0 : data.sizeBytes()); + } + + @Override + public void validate(String customerId, long taskId, long fileId) { + long tenantId = tenantId(customerId); + MetadataEnvelope response = client.get() + .uri("/internal/file/v1/business-files/{tenantId}/{businessType}/{businessId}/{fileId}", + tenantId, BUSINESS_TYPE, taskId, fileId) + .header("X-Internal-Service-Token", internalToken) + .retrieve().onStatus(HttpStatusCode::isError, (request, result) -> { + throw new IllegalArgumentException("Unload photo does not exist or its ownership is invalid"); + }).body(MetadataEnvelope.class); + Metadata data = response == null ? null : response.data(); + if (data == null || data.fileId() == null || data.fileId() != fileId + || data.tenantId() == null || data.tenantId() != tenantId + || !BUSINESS_TYPE.equals(data.businessType()) || !String.valueOf(taskId).equals(data.businessId())) { + throw new IllegalArgumentException("Unload photo metadata does not match the PDA task"); + } + } + + private long tenantId(String customerId) { + try { return Long.parseLong(customerId); } + catch (NumberFormatException exception) { throw new IllegalArgumentException("Customer ID must be numeric", exception); } + } + + private record MetadataEnvelope(Metadata data) {} + private record Metadata(Long fileId, Long tenantId, String businessType, String businessId, + String fileName, String contentType, Long sizeBytes) {} +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/gateway/UnloadPhotoFileGateway.java b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/gateway/UnloadPhotoFileGateway.java new file mode 100644 index 000000000..b1abf3e64 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/gateway/UnloadPhotoFileGateway.java @@ -0,0 +1,10 @@ +package com.baiyue.lastleg.inboundplan.pda.gateway; + +import org.springframework.web.multipart.MultipartFile; + +public interface UnloadPhotoFileGateway { + UploadedFile upload(String customerId, long taskId, long operatorId, MultipartFile file); + void validate(String customerId, long taskId, long fileId); + + record UploadedFile(long fileId, String fileName, String contentType, long sizeBytes) {} +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/service/InboundPdaWorkflowService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/service/InboundPdaWorkflowService.java index d77ec080a..4c8b09a2a 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/service/InboundPdaWorkflowService.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/service/InboundPdaWorkflowService.java @@ -12,6 +12,7 @@ import com.baiyue.lastleg.inboundplan.pda.entity.InboundPalletMerge; import com.baiyue.lastleg.inboundplan.pda.entity.InboundPdaOperation; import com.baiyue.lastleg.inboundplan.pda.entity.InboundPdaTask; import com.baiyue.lastleg.inboundplan.pda.gateway.WarehouseResourceGateway; +import com.baiyue.lastleg.inboundplan.pda.gateway.UnloadPhotoFileGateway; import com.baiyue.lastleg.inboundplan.pda.repository.PdaWorkflowRepository; import com.fasterxml.jackson.core.JsonProcessingException; import com.fasterxml.jackson.databind.MapperFeature; @@ -21,6 +22,7 @@ import com.fasterxml.jackson.core.type.TypeReference; import org.springframework.http.HttpStatus; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; +import org.springframework.web.multipart.MultipartFile; import org.springframework.web.server.ResponseStatusException; import jakarta.annotation.PostConstruct; import jakarta.annotation.Resource; @@ -40,12 +42,14 @@ import java.util.Objects; @Service public class InboundPdaWorkflowService { + private static final long MAX_UNLOAD_PHOTO_SIZE = 10L * 1024 * 1024; private static final BigDecimal ZERO_VOLUME = new BigDecimal("0.0000"); @Resource private PdaWorkflowRepository repository; @Resource private WarehouseResourceGateway warehouse; @Resource private InboundStockReceiptService stockReceipts; @Resource private InboundUnloadCompletedOutboxService unloadCompletedOutbox; + @Resource private UnloadPhotoFileGateway unloadPhotoFiles; @Resource private ObjectMapper sourceObjectMapper; private ObjectMapper objectMapper; @@ -421,6 +425,7 @@ public class InboundPdaWorkflowService { InboundPdaTask task = requireActiveTask(customerId, plan); List fileIds = request.fileIds().stream().distinct().sorted().toList(); if (fileIds.size() != request.fileIds().size()) throw conflict("Unload photo file IDs cannot be duplicated"); + fileIds.forEach(fileId -> validateUnloadPhoto(customerId, task.getId(), fileId)); LocalDateTime uploadedAt = LocalDateTime.now(); task.setUnloadPhotoFileIds(write(fileIds)); task.setUnloadPhotoUploadedAt(uploadedAt); @@ -433,6 +438,21 @@ public class InboundPdaWorkflowService { return result; } + public PdaResponses.UnloadPhotoFile uploadUnloadPhotoFile(String customerId, String planNo, + long operatorId, MultipartFile file) { + validateUnloadPhotoFile(file); + InboundPlan plan = requirePlan(customerId, planNo); + InboundPdaTask task = requireActiveTask(customerId, plan); + final UnloadPhotoFileGateway.UploadedFile uploaded; + try { + uploaded = unloadPhotoFiles.upload(customerId, task.getId(), operatorId, file); + } catch (IllegalArgumentException exception) { + throw new ResponseStatusException(HttpStatus.BAD_REQUEST, exception.getMessage(), exception); + } + return new PdaResponses.UnloadPhotoFile(uploaded.fileId(), uploaded.fileName(), + uploaded.contentType(), uploaded.sizeBytes()); + } + private void moveSourceAllocation(Long planId, InboundPlanPalletAllocation source, Long targetPalletId, int packageCount, BigDecimal movedVolume) { source.setPickedPackageCount(value(source.getPickedPackageCount()) - packageCount); @@ -623,6 +643,27 @@ public class InboundPdaWorkflowService { } } + private void validateUnloadPhoto(String customerId, long taskId, long fileId) { + try { + unloadPhotoFiles.validate(customerId, taskId, fileId); + } catch (IllegalArgumentException exception) { + throw conflict("Unload photo does not exist or does not belong to this PDA task"); + } + } + + private static void validateUnloadPhotoFile(MultipartFile file) { + if (file == null || file.isEmpty()) { + throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Unload photo cannot be empty"); + } + if (file.getSize() > MAX_UNLOAD_PHOTO_SIZE) { + throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Unload photo cannot exceed 10 MB"); + } + String contentType = file.getContentType(); + if (!"image/jpeg".equalsIgnoreCase(contentType) && !"image/png".equalsIgnoreCase(contentType)) { + throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Unload photo must be JPG or PNG"); + } + } + private BigDecimal perPackageVolume(InboundPlanLine line) { int required = value(line.getRequiredPackageCount()); if (required <= 0) throw conflict("Inbound line required package count must be positive"); diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/service/InboundUnloadCompletedOutboxService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/service/InboundUnloadCompletedOutboxService.java index a4a95a21c..6ec1c4f2a 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/service/InboundUnloadCompletedOutboxService.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/service/InboundUnloadCompletedOutboxService.java @@ -9,11 +9,9 @@ import com.baiyue.common.event.EventTypes; import com.baiyue.common.event.OverseasContainerUnloadedV1; import com.baiyue.common.event.RegionCode; import com.baiyue.lastleg.entity.LastlegEventOutbox; -import com.baiyue.lastleg.entity.LastlegOrder; import com.baiyue.lastleg.inboundplan.entity.InboundPlan; import com.baiyue.lastleg.inboundplan.pda.entity.InboundPdaTask; import com.baiyue.lastleg.mapper.LastlegEventOutboxMapper; -import com.baiyue.lastleg.mapper.LastlegOrderMapper; import com.fasterxml.jackson.core.JsonProcessingException; import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.ObjectMapper; @@ -29,26 +27,21 @@ import java.util.List; @Service public class InboundUnloadCompletedOutboxService { @Resource private LastlegEventOutboxMapper mapper; - @Resource private LastlegOrderMapper orders; @Resource private ObjectMapper objectMapper; public void publish(InboundPlan plan, InboundPdaTask task, List photoFileIds) { - LastlegOrder sourceOrder = orders.selectById(plan.getSourceOrderId()); - if (sourceOrder == null || sourceOrder.getSourceContainerBookingId() == null - || sourceOrder.getSourceContainerBookingId().isBlank()) { - throw new IllegalStateException("Inbound plan has no source container booking ID"); - } Instant photoUploadedAt = task.getUnloadPhotoUploadedAt().toInstant(ZoneOffset.UTC); - var payload = new OverseasContainerUnloadedV1(plan.getContainerNo(), photoUploadedAt, + Instant unloadingCompletedAt = task.getCompletedAt().toInstant(ZoneOffset.UTC); + var payload = new OverseasContainerUnloadedV1(plan.getContainerNo(), unloadingCompletedAt, photoUploadedAt, null); JsonNode payloadNode = objectMapper.valueToTree(payload); - // Multiple inbound plans can share one container; upload epoch keeps their container events ordered. - long aggregateVersion = photoUploadedAt.toEpochMilli(); + // Multiple inbound plans can share one container; completion epoch keeps their container events ordered. + long aggregateVersion = unloadingCompletedAt.toEpochMilli(); String eventKey = EventTypes.OVERSEAS_CONTAINER_UNLOADED + ':' + task.getId(); String traceId = EventIdentifiers.newUuid(); EventEnvelope envelope = new EventEnvelope(EventIdentifiers.newUuid(), EventTypes.OVERSEAS_CONTAINER_UNLOADED, 1, Instant.now(), RegionCode.OVERSEAS, RegionCode.CN, task.getCustomerId(), "CONTAINER_BOOKING", - sourceOrder.getSourceContainerBookingId(), aggregateVersion, 1L, traceId, eventKey, null, payloadNode, + plan.getContainerNo(), aggregateVersion, 1L, traceId, eventKey, null, payloadNode, EventPayloadHash.sha256(EventPayloadCanonicalizer.canonicalize(payloadNode))).requireValid(); LastlegEventOutbox event = new LastlegEventOutbox(); diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/inventory/command/controller/InventoryCommandController.java b/lastleg-service/src/main/java/com/baiyue/lastleg/inventory/command/controller/InventoryCommandController.java index 4ae6bc836..ba98083a6 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/inventory/command/controller/InventoryCommandController.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/inventory/command/controller/InventoryCommandController.java @@ -11,6 +11,7 @@ import jakarta.annotation.Resource; import jakarta.validation.Valid; import org.springframework.http.HttpStatus; import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.security.access.prepost.PreAuthorize; import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RequestMapping; @@ -21,11 +22,12 @@ import org.springframework.web.server.ResponseStatusException; @RequestMapping("/api/platform/v1/last-leg/inventory") @Tag(name = "尾程入仓单库存操作") public class InventoryCommandController { - public static final String MANAGE_PERMISSION = "lastleg:inventory:manage"; - public static final String ADJUST_PERMISSION = "lastleg:inventory:adjust"; + public static final String MANAGE_PERMISSION = "platform:lastleg:inventory:manage"; + public static final String ADJUST_PERMISSION = "platform:lastleg:inventory:adjust"; @Resource private InventoryCommandService service; @PostMapping("/hold") + @PreAuthorize("hasAuthority('platform:lastleg:inventory:manage')") /** 将可用入仓单箱数转为冻结。 */ @Operation(summary = "冻结入仓单库存") public ApiResponse hold(@AuthenticationPrincipal AuthenticatedUser user, @@ -35,6 +37,7 @@ public class InventoryCommandController { } @PostMapping("/release") + @PreAuthorize("hasAuthority('platform:lastleg:inventory:manage')") /** 释放已冻结的入仓单箱数。 */ @Operation(summary = "释放入仓单库存") public ApiResponse release(@AuthenticationPrincipal AuthenticatedUser user, @@ -44,6 +47,7 @@ public class InventoryCommandController { } @PostMapping("/allocate") + @PreAuthorize("hasAuthority('platform:lastleg:inventory:manage')") /** 将可用入仓单箱数分配给出库单。 */ @Operation(summary = "分配出库库存") public ApiResponse allocate(@AuthenticationPrincipal AuthenticatedUser user, @@ -53,6 +57,7 @@ public class InventoryCommandController { } @PostMapping("/unallocate") + @PreAuthorize("hasAuthority('platform:lastleg:inventory:manage')") /** 取消出库库存分配。 */ @Operation(summary = "取消出库分配") public ApiResponse unallocate(@AuthenticationPrincipal AuthenticatedUser user, @@ -62,6 +67,7 @@ public class InventoryCommandController { } @PostMapping("/pick") + @PreAuthorize("hasAuthority('platform:lastleg:inventory:manage')") /** 确认入仓单箱数已拣货。 */ @Operation(summary = "确认拣货") public ApiResponse pick(@AuthenticationPrincipal AuthenticatedUser user, @@ -71,6 +77,7 @@ public class InventoryCommandController { } @PostMapping("/ship") + @PreAuthorize("hasAuthority('platform:lastleg:inventory:manage')") /** 出库已拣货箱数并返回托盘是否为空。 */ @Operation(summary = "出库拣货库存") public ApiResponse ship(@AuthenticationPrincipal AuthenticatedUser user, @@ -80,6 +87,7 @@ public class InventoryCommandController { } @PostMapping("/adjust") + @PreAuthorize("hasAuthority('platform:lastleg:inventory:adjust')") /** 根据实物盘点结果调整入仓单库存。 */ @Operation(summary = "盘点调整库存") public ApiResponse adjust(@AuthenticationPrincipal AuthenticatedUser user, diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/inventory/query/controller/InventoryQueryController.java b/lastleg-service/src/main/java/com/baiyue/lastleg/inventory/query/controller/InventoryQueryController.java index 3b5bc49e9..d683bfb04 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/inventory/query/controller/InventoryQueryController.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/inventory/query/controller/InventoryQueryController.java @@ -18,6 +18,7 @@ import org.springframework.http.HttpStatus; import org.springframework.http.MediaType; import org.springframework.http.ResponseEntity; import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.security.access.prepost.PreAuthorize; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.PathVariable; import org.springframework.web.bind.annotation.RequestParam; @@ -35,6 +36,7 @@ public class InventoryQueryController { @Resource private InventoryQueryService service; @GetMapping(BASE_PATH + "/summary") + @PreAuthorize("hasAuthority('platform:lastleg:inventory:list')") /** 查询入仓单库存汇总。 */ @Operation(summary = "查询库存汇总") public ApiResponse summary( @@ -45,6 +47,7 @@ public class InventoryQueryController { } @GetMapping(BASE_PATH + "/balances") + @PreAuthorize("hasAuthority('platform:lastleg:inventory:list')") /** 按客户、仓库和入仓单分页查询库存。 */ @Operation(summary = "分页查询库存余额") public ApiResponse> balances( @@ -55,6 +58,7 @@ public class InventoryQueryController { } @GetMapping(BASE_PATH + "/balances/{balanceId}/positions") + @PreAuthorize("hasAuthority('platform:lastleg:inventory:list')") /** 查询库存余额对应的托盘和批次位置。 */ @Operation(summary = "查询库存位置") public ApiResponse> positions( @@ -66,6 +70,7 @@ public class InventoryQueryController { } @GetMapping(BASE_PATH + "/ledger") + @PreAuthorize("hasAuthority('platform:lastleg:inventory:list')") /** 分页查询只增不改的库存流水。 */ @Operation(summary = "分页查询库存流水") public ApiResponse> ledgers( @@ -76,6 +81,7 @@ public class InventoryQueryController { } @GetMapping(value = BASE_PATH + "/balances/export", produces = "text/csv") + @PreAuthorize("hasAuthority('platform:lastleg:inventory:export')") /** 导出筛选后的入仓单库存 CSV 文件。 */ @Operation(summary = "导出库存余额") public ResponseEntity exportBalances( @@ -86,6 +92,7 @@ public class InventoryQueryController { } @GetMapping(value = BASE_PATH + "/ledger/export", produces = "text/csv") + @PreAuthorize("hasAuthority('platform:lastleg:inventory:export')") /** 导出筛选后的库存流水 CSV 文件。 */ @Operation(summary = "导出库存流水") public ResponseEntity exportLedgers( diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/mapper/LastlegEventOutboxMapper.java b/lastleg-service/src/main/java/com/baiyue/lastleg/mapper/LastlegEventOutboxMapper.java index c7871d177..4af644f86 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/mapper/LastlegEventOutboxMapper.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/mapper/LastlegEventOutboxMapper.java @@ -3,7 +3,47 @@ package com.baiyue.lastleg.mapper; import com.baomidou.mybatisplus.core.mapper.BaseMapper; import com.baiyue.lastleg.entity.LastlegEventOutbox; import org.apache.ibatis.annotations.Mapper; +import org.apache.ibatis.annotations.Param; +import org.apache.ibatis.annotations.Select; +import org.apache.ibatis.annotations.Update; + +import java.time.LocalDateTime; +import java.util.List; @Mapper public interface LastlegEventOutboxMapper extends BaseMapper { + @Select(""" + SELECT id FROM ll_event_outbox + WHERE event_type='OverseasContainerUnloaded' AND retry_count < #{maxAttempts} + AND (status='PENDING' OR (status='PROCESSING' AND locked_at <= #{staleBefore})) + AND (next_retry_at IS NULL OR next_retry_at <= #{now}) + ORDER BY created_at,id LIMIT #{limit} + """) + List relayCandidates(@Param("limit") int limit, @Param("maxAttempts") int maxAttempts, + @Param("now") LocalDateTime now, @Param("staleBefore") LocalDateTime staleBefore); + + @Update(""" + UPDATE ll_event_outbox SET status='PROCESSING',locked_by=#{worker},locked_at=#{now} + WHERE id=#{id} AND (status='PENDING' OR (status='PROCESSING' AND locked_at <= #{staleBefore})) + """) + int claim(@Param("id") long id, @Param("worker") String worker, @Param("now") LocalDateTime now, + @Param("staleBefore") LocalDateTime staleBefore); + + @Select("SELECT * FROM ll_event_outbox WHERE id=#{id} AND status='PROCESSING' AND locked_by=#{worker}") + LastlegEventOutbox claimed(@Param("id") long id, @Param("worker") String worker); + + @Update(""" + UPDATE ll_event_outbox SET status='PUBLISHED',published_at=#{now},next_retry_at=NULL, + last_error=NULL,locked_by=NULL,locked_at=NULL + WHERE id=#{id} AND status='PROCESSING' AND locked_by=#{worker} + """) + int markPublished(@Param("id") long id, @Param("worker") String worker, @Param("now") LocalDateTime now); + + @Update(""" + UPDATE ll_event_outbox SET status=#{status},retry_count=retry_count+1,next_retry_at=#{retryAt}, + last_error=#{error},locked_by=NULL,locked_at=NULL + WHERE id=#{id} AND status='PROCESSING' AND locked_by=#{worker} + """) + int markFailed(@Param("id") long id, @Param("worker") String worker, @Param("status") String status, + @Param("retryAt") LocalDateTime retryAt, @Param("error") String error); } diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/procurement/controller/PlatformOverseasProcurementController.java b/lastleg-service/src/main/java/com/baiyue/lastleg/procurement/controller/PlatformOverseasProcurementController.java index 2d0e71a37..0feef3409 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/procurement/controller/PlatformOverseasProcurementController.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/procurement/controller/PlatformOverseasProcurementController.java @@ -10,6 +10,7 @@ import jakarta.annotation.Resource; import jakarta.validation.Valid; import org.springframework.http.HttpStatus; import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.security.access.prepost.PreAuthorize; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.PatchMapping; import org.springframework.web.bind.annotation.PathVariable; @@ -26,6 +27,7 @@ public class PlatformOverseasProcurementController { @Resource private OverseasProcurementService service; @GetMapping + @PreAuthorize("hasAuthority('platform:lastleg:overseas-procurement:list')") public ApiResponse> page( @AuthenticationPrincipal AuthenticatedUser user, @RequestParam(required = false) String customerId, @@ -35,6 +37,7 @@ public class PlatformOverseasProcurementController { } @GetMapping("/{orderNo}") + @PreAuthorize("hasAuthority('platform:lastleg:overseas-procurement:list')") public ApiResponse detail( @AuthenticationPrincipal AuthenticatedUser user, @RequestParam String customerId, @PathVariable String orderNo) { @@ -43,6 +46,7 @@ public class PlatformOverseasProcurementController { } @PostMapping("/{orderNo}/review") + @PreAuthorize("hasAuthority('platform:lastleg:overseas-procurement:review')") public ApiResponse review( @AuthenticationPrincipal AuthenticatedUser user, @RequestParam String customerId, @PathVariable String orderNo, @Valid @RequestBody OverseasProcurementRequests.Review request) { @@ -51,6 +55,7 @@ public class PlatformOverseasProcurementController { } @PatchMapping("/{orderNo}") + @PreAuthorize("hasAuthority('platform:lastleg:overseas-procurement:edit')") public ApiResponse update( @AuthenticationPrincipal AuthenticatedUser user, @RequestParam String customerId, @PathVariable String orderNo, @Valid @RequestBody OverseasProcurementRequests.Update request) { @@ -59,10 +64,11 @@ public class PlatformOverseasProcurementController { } @PostMapping("/{orderNo}/outbounds") + @PreAuthorize("hasAuthority('platform:lastleg:overseas-procurement:outbound')") public ApiResponse addOutbound( @AuthenticationPrincipal AuthenticatedUser user, @RequestParam String customerId, @PathVariable String orderNo, @Valid @RequestBody OverseasProcurementRequests.AddOutbound request) { - requirePermission(user, OverseasProcurementService.MANAGE_PERMISSION); + requirePermission(user, "platform:lastleg:overseas-procurement:outbound"); return ApiResponse.success(service.addOutbound(requiredCustomer(customerId), orderNo, operator(user), request)); } diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/procurement/service/OverseasProcurementService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/procurement/service/OverseasProcurementService.java index d955c88d8..c9353182a 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/procurement/service/OverseasProcurementService.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/procurement/service/OverseasProcurementService.java @@ -31,8 +31,8 @@ import java.util.Set; @Service public class OverseasProcurementService { - public static final String REVIEW_PERMISSION = "lastleg:overseas-procurement:review"; - public static final String MANAGE_PERMISSION = "lastleg:overseas-procurement:manage"; + public static final String REVIEW_PERMISSION = "platform:lastleg:overseas-procurement:review"; + public static final String MANAGE_PERMISSION = "platform:lastleg:overseas-procurement:edit"; public static final String PENDING_REVIEW = "PENDING_REVIEW"; public static final String APPROVED = "APPROVED"; public static final String REJECTED = "REJECTED"; diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/relabel/controller/RelabelApplicationController.java b/lastleg-service/src/main/java/com/baiyue/lastleg/relabel/controller/RelabelApplicationController.java index 349c89f69..de7f70612 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/relabel/controller/RelabelApplicationController.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/relabel/controller/RelabelApplicationController.java @@ -10,6 +10,7 @@ import jakarta.annotation.Resource; import jakarta.validation.Valid; import org.springframework.http.HttpStatus; import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.security.access.prepost.PreAuthorize; import org.springframework.web.bind.annotation.*; import org.springframework.web.server.ResponseStatusException; @@ -19,21 +20,25 @@ public class RelabelApplicationController { @Resource private RelabelApplicationService service; @PostMapping + @PreAuthorize("hasAuthority('platform:lastleg:relabel:create')") public ApiResponse create(@AuthenticationPrincipal AuthenticatedUser user, @RequestParam(required = false) String customerId, @Valid @RequestBody RelabelRequests.Create request) { return ApiResponse.success(service.create(scope(user, customerId), operator(user), request)); } @GetMapping + @PreAuthorize("hasAuthority('platform:lastleg:relabel:list')") public ApiResponse> page(@AuthenticationPrincipal AuthenticatedUser user, @RequestParam(required = false) String customerId, @Valid RelabelRequests.Query query) { return ApiResponse.success(service.page(scope(user, customerId), query)); } @GetMapping("/{relabelNo}") + @PreAuthorize("hasAuthority('platform:lastleg:relabel:list')") public ApiResponse detail(@AuthenticationPrincipal AuthenticatedUser user, @RequestParam(required = false) String customerId, @PathVariable String relabelNo) { return ApiResponse.success(service.detail(scope(user, customerId), relabelNo)); } @PutMapping("/{relabelNo}/status") + @PreAuthorize("hasAuthority('platform:lastleg:relabel:status')") public ApiResponse status(@AuthenticationPrincipal AuthenticatedUser user, @RequestParam(required = false) String customerId, @PathVariable String relabelNo, @Valid @RequestBody RelabelRequests.ChangeStatus request) { diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/service/ContainerUnloadOutboxRelay.java b/lastleg-service/src/main/java/com/baiyue/lastleg/service/ContainerUnloadOutboxRelay.java new file mode 100644 index 000000000..bd90f94c7 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/service/ContainerUnloadOutboxRelay.java @@ -0,0 +1,56 @@ +package com.baiyue.lastleg.service; + +import com.baiyue.lastleg.entity.LastlegEventOutbox; +import com.baiyue.lastleg.mapper.LastlegEventOutboxMapper; +import jakarta.annotation.Resource; +import lombok.extern.slf4j.Slf4j; +import org.springframework.scheduling.annotation.Scheduled; +import org.springframework.stereotype.Component; + +import java.lang.management.ManagementFactory; +import java.time.LocalDateTime; +import java.util.UUID; + +@Component +@Slf4j +public class ContainerUnloadOutboxRelay { + private static final int MAX_ATTEMPTS = 12; + @Resource private LastlegEventOutboxMapper outbox; + @Resource private EventBridgePublisher publisher; + private final String worker = ManagementFactory.getRuntimeMXBean().getName() + ':' + UUID.randomUUID(); + + @Scheduled(fixedDelayString = "${baiyue.container-unload-relay.fixed-delay:3000}") + public void relay() { + LocalDateTime now = LocalDateTime.now(); + LocalDateTime staleBefore = now.minusMinutes(2); + for (Long id : outbox.relayCandidates(50, MAX_ATTEMPTS, now, staleBefore)) { + relayOne(id, now, staleBefore); + } + } + + void relayOne(long id, LocalDateTime now, LocalDateTime staleBefore) { + if (outbox.claim(id, worker, now, staleBefore) != 1) return; + LastlegEventOutbox row = outbox.claimed(id, worker); + if (row == null) return; + try { + publisher.publish(row.getPayloadJson()); + outbox.markPublished(id, worker, LocalDateTime.now()); + } catch (Exception exception) { + int attempt = row.getRetryCount() == null ? 1 : row.getRetryCount() + 1; + boolean exhausted = attempt >= MAX_ATTEMPTS; + LocalDateTime retryAt = exhausted ? null : LocalDateTime.now().plusSeconds(backoffSeconds(attempt)); + outbox.markFailed(id, worker, exhausted ? "FAILED" : "PENDING", retryAt, + abbreviate(exception.getMessage())); + log.warn("海外卸柜事件投递失败 id={}, attempt={}, reason={}", id, attempt, exception.getMessage()); + } + } + + private static long backoffSeconds(int attempt) { + return Math.min(900L, 5L << Math.min(Math.max(attempt - 1, 0), 8)); + } + + private static String abbreviate(String value) { + String safe = value == null || value.isBlank() ? "未知投递错误" : value; + return safe.length() <= 1000 ? safe : safe.substring(0, 1000); + } +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/service/EventBridgePublisher.java b/lastleg-service/src/main/java/com/baiyue/lastleg/service/EventBridgePublisher.java new file mode 100644 index 000000000..95dd15ba3 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/service/EventBridgePublisher.java @@ -0,0 +1,39 @@ +package com.baiyue.lastleg.service; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import jakarta.annotation.PostConstruct; +import jakarta.annotation.Resource; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Component; + +import java.net.URI; +import java.net.http.HttpClient; +import java.net.http.HttpRequest; +import java.net.http.HttpResponse; +import java.time.Duration; + +@Component +public class EventBridgePublisher { + @Resource private ObjectMapper objectMapper; + @Value("${baiyue.clients.event-bridge.url:http://127.0.0.1:9312}") private String baseUrl; + @Value("${baiyue.security.internal-service-token:}") private String serviceToken; + private HttpClient httpClient; + + @PostConstruct + void initialize() { + httpClient = HttpClient.newBuilder().connectTimeout(Duration.ofSeconds(3)).build(); + } + + public void publish(String envelopeJson) throws Exception { + HttpRequest request = HttpRequest.newBuilder(URI.create(baseUrl + "/internal/event-bridge/events")) + .timeout(Duration.ofSeconds(10)).header("Content-Type", "application/json") + .header("X-Internal-Service-Token", serviceToken) + .POST(HttpRequest.BodyPublishers.ofString(envelopeJson)).build(); + HttpResponse response = httpClient.send(request, HttpResponse.BodyHandlers.ofString()); + JsonNode body = objectMapper.readTree(response.body()); + if (response.statusCode() / 100 != 2 || body == null || !"200".equals(body.path("code").asText())) { + throw new IllegalStateException("事件桥接收失败: HTTP " + response.statusCode()); + } + } +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/service/LoadingCompletedConsumer.java b/lastleg-service/src/main/java/com/baiyue/lastleg/service/LoadingCompletedConsumer.java index cf841602a..3d076a2b3 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/service/LoadingCompletedConsumer.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/service/LoadingCompletedConsumer.java @@ -120,8 +120,7 @@ public class LoadingCompletedConsumer { order.setVolumeWeightKg(source.items().stream().map(FirstLegLoadingPlanCompletedV1.Item::dimensionalWeightKg) .filter(Objects::nonNull).reduce(BigDecimal.ZERO,BigDecimal::add)); order.setCurrentStatus("PENDING_ASSIGNMENT"); order.setSourceEventId(event.eventId()); - order.setSourceLoadingPlanId(payload.loadingPlanId()); order.setSourceContainerBookingId(payload.containerBookingId()); - order.setSourceLoadingPlanVersion(event.aggregateVersion()); + order.setSourceLoadingPlanId(payload.loadingPlanId()); order.setSourceLoadingPlanVersion(event.aggregateVersion()); order.setSourceHandoverVersion(event.handoverVersion()); order.setSourcePayloadHash(orderHash); order.setCreatedAt(LocalDateTime.now()); order.setUpdatedAt(LocalDateTime.now()); try { diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/trucking/controller/PlatformUsTruckOrderController.java b/lastleg-service/src/main/java/com/baiyue/lastleg/trucking/controller/PlatformUsTruckOrderController.java index 3dba2f252..d156e77b0 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/trucking/controller/PlatformUsTruckOrderController.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/trucking/controller/PlatformUsTruckOrderController.java @@ -10,6 +10,7 @@ import jakarta.annotation.Resource; import jakarta.validation.Valid; import org.springframework.http.HttpStatus; import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.security.access.prepost.PreAuthorize; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.PatchMapping; import org.springframework.web.bind.annotation.PathVariable; @@ -26,6 +27,7 @@ public class PlatformUsTruckOrderController { @Resource private UsTruckOrderService service; @GetMapping + @PreAuthorize("hasAuthority('platform:lastleg:us-trucking:list')") public ApiResponse> page(@AuthenticationPrincipal AuthenticatedUser user, @Valid UsTruckOrderRequests.Query query) { requirePlatform(user); @@ -33,6 +35,7 @@ public class PlatformUsTruckOrderController { } @GetMapping("/{orderNo}") + @PreAuthorize("hasAuthority('platform:lastleg:us-trucking:list')") public ApiResponse detail(@AuthenticationPrincipal AuthenticatedUser user, @RequestParam String customerId, @PathVariable String orderNo) { requirePlatform(user); @@ -40,6 +43,7 @@ public class PlatformUsTruckOrderController { } @PatchMapping("/{orderNo}/status") + @PreAuthorize("hasAuthority('platform:lastleg:us-trucking:status')") public ApiResponse changeStatus(@AuthenticationPrincipal AuthenticatedUser user, @RequestParam String customerId, @PathVariable String orderNo, @Valid @RequestBody UsTruckOrderRequests.ChangeStatus request) { @@ -48,10 +52,11 @@ public class PlatformUsTruckOrderController { } @PostMapping("/{orderNo}/receive-pallets") + @PreAuthorize("hasAuthority('platform:lastleg:us-trucking:receive')") public ApiResponse receivePallets(@AuthenticationPrincipal AuthenticatedUser user, @RequestParam String customerId, @PathVariable String orderNo, @Valid @RequestBody UsTruckOrderRequests.ReceivePallets request) { - requirePermission(user); + requirePermission(user, "platform:lastleg:us-trucking:receive"); return ApiResponse.success(service.receivePallets(requiredCustomer(customerId), orderNo, operator(user), request)); } @@ -63,10 +68,14 @@ public class PlatformUsTruckOrderController { } private void requirePermission(AuthenticatedUser user) { + requirePermission(user, UsTruckOrderService.MANAGE_PERMISSION); + } + + private void requirePermission(AuthenticatedUser user, String permission) { requirePlatform(user); - if (user.permissions() == null || !user.permissions().contains(UsTruckOrderService.MANAGE_PERMISSION)) { + if (user.permissions() == null || !user.permissions().contains(permission)) { throw new ResponseStatusException(HttpStatus.FORBIDDEN, - UsTruckOrderService.MANAGE_PERMISSION + " permission is required"); + permission + " permission is required"); } } diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/trucking/service/UsTruckOrderService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/trucking/service/UsTruckOrderService.java index 4c6453f2d..72eae0ca2 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/trucking/service/UsTruckOrderService.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/trucking/service/UsTruckOrderService.java @@ -35,7 +35,7 @@ import java.util.function.Consumer; @Service public class UsTruckOrderService { - public static final String MANAGE_PERMISSION = "lastleg:us-trucking:manage"; + public static final String MANAGE_PERMISSION = "platform:lastleg:us-trucking:status"; public static final String PENDING_REVIEW = "PENDING_REVIEW"; /** Kept for source compatibility; new orders use PENDING_REVIEW. */ @Deprecated public static final String PENDING_CONFIRMATION = PENDING_REVIEW; diff --git a/lastleg-service/src/main/resources/application.yml b/lastleg-service/src/main/resources/application.yml index 250cdcb8d..907738ba3 100644 --- a/lastleg-service/src/main/resources/application.yml +++ b/lastleg-service/src/main/resources/application.yml @@ -35,6 +35,9 @@ spring: management.endpoints.web.exposure.include: health,info,prometheus baiyue: + clients: + event-bridge: + url: ${CN_EVENT_BRIDGE_URL:http://127.0.0.1:9312} security: internal-service-token: ${INTERNAL_SERVICE_TOKEN:phase1-local-internal-token-at-least-32-bytes} jwt-mode: jwks diff --git a/lastleg-service/src/main/resources/db/lastleg.sql b/lastleg-service/src/main/resources/db/lastleg.sql index 8e3682ab8..049591454 100644 --- a/lastleg-service/src/main/resources/db/lastleg.sql +++ b/lastleg-service/src/main/resources/db/lastleg.sql @@ -62,7 +62,6 @@ CREATE TABLE IF NOT EXISTS ll_order ( warehouse_remark VARCHAR(1000) COMMENT '备注(仓库)', source_event_id VARCHAR(128) NOT NULL COMMENT '来源事件 ID', source_loading_plan_id VARCHAR(128) NOT NULL COMMENT '来源装柜计划 ID', - source_container_booking_id VARCHAR(128) NULL COMMENT '来源头程货柜预订ID', source_loading_plan_version BIGINT COMMENT '来源装柜计划版本', source_handover_version BIGINT COMMENT '交接版本', source_payload_hash VARCHAR(71) NOT NULL COMMENT '来源事件负载摘要', @@ -164,8 +163,6 @@ CREATE TABLE IF NOT EXISTS ll_order_status_history ( -- 幂等注释迁移:CREATE TABLE IF NOT EXISTS 对已有表不会更新表/字段注释。 ALTER TABLE ll_loading_event_inbox COMMENT='头程装柜完成事件接收幂等箱'; ALTER TABLE ll_order COMMENT='尾程订单主表'; -SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_order' AND column_name='source_container_booking_id'), 'SELECT 1', 'ALTER TABLE ll_order ADD COLUMN source_container_booking_id VARCHAR(128) NULL COMMENT ''来源头程货柜预订ID'' AFTER source_loading_plan_id')); -PREPARE lastleg_order_container_booking_stmt FROM @ddl; EXECUTE lastleg_order_container_booking_stmt; DEALLOCATE PREPARE lastleg_order_container_booking_stmt; ALTER TABLE ll_order_detail COMMENT='尾程订单实装商品冻结明细'; ALTER TABLE ll_loading_order_status_history COMMENT='尾程订单状态变更历史'; ALTER TABLE ll_event_conflict COMMENT='事件冲突记录'; @@ -240,7 +237,6 @@ ALTER TABLE ll_order MODIFY COLUMN warehouse_remark VARCHAR(1000) NULL COMMENT '备注(仓库)', MODIFY COLUMN source_event_id VARCHAR(128) NOT NULL COMMENT '来源事件 ID', MODIFY COLUMN source_loading_plan_id VARCHAR(128) NOT NULL COMMENT '来源装柜计划 ID', - MODIFY COLUMN source_container_booking_id VARCHAR(128) NULL COMMENT '来源头程货柜预订ID', MODIFY COLUMN source_loading_plan_version BIGINT NULL COMMENT '来源装柜计划版本', MODIFY COLUMN source_handover_version BIGINT NULL COMMENT '交接版本', MODIFY COLUMN source_payload_hash VARCHAR(71) NOT NULL COMMENT '来源事件负载摘要', @@ -519,7 +515,8 @@ CREATE TABLE IF NOT EXISTS ll_event_outbox ( event_key VARCHAR(255) NOT NULL COMMENT '业务幂等键', payload_json LONGTEXT NOT NULL COMMENT '事件载荷JSON', status VARCHAR(32) NOT NULL DEFAULT 'PENDING' COMMENT '发布状态', retry_count INT NOT NULL DEFAULT 0 COMMENT '重试次数', next_retry_at DATETIME(3) NULL COMMENT '下次重试时间', published_at DATETIME(3) NULL COMMENT '发布时间', - last_error VARCHAR(1000) NULL COMMENT '最后一次发布错误', created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间', + last_error VARCHAR(1000) NULL COMMENT '最后一次发布错误', locked_by VARCHAR(128) NULL COMMENT '当前投递租约持有者', + locked_at DATETIME(3) NULL COMMENT '当前投递租约获取时间', created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间', PRIMARY KEY (id), UNIQUE KEY uk_ll_event_outbox_key (event_key), KEY idx_ll_event_outbox_publish (status, next_retry_at, created_at) ) ENGINE=InnoDB COMMENT='尾程跨区业务事件发件箱'; diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/controller/LastlegPlatformPermissionContractTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/controller/LastlegPlatformPermissionContractTest.java new file mode 100644 index 000000000..4fe041548 --- /dev/null +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/controller/LastlegPlatformPermissionContractTest.java @@ -0,0 +1,57 @@ +package com.baiyue.lastleg.controller; + +import com.baiyue.lastleg.dispatch.controller.DispatchController; +import com.baiyue.lastleg.inboundplan.controller.InboundPlanController; +import com.baiyue.lastleg.inventory.query.controller.InventoryQueryController; +import com.baiyue.lastleg.inventory.command.controller.InventoryCommandController; +import com.baiyue.lastleg.procurement.controller.PlatformOverseasProcurementController; +import com.baiyue.lastleg.relabel.controller.RelabelApplicationController; +import com.baiyue.lastleg.trucking.controller.PlatformUsTruckOrderController; +import org.junit.jupiter.api.Test; +import org.springframework.security.access.prepost.PreAuthorize; + +import java.lang.reflect.Method; +import java.util.Arrays; + +import static org.assertj.core.api.Assertions.assertThat; + +class LastlegPlatformPermissionContractTest { + @Test + void readEndpointsReuseListPermissionsAndActionsStayIndependent() { + assertPermission(LastlegOrderController.class, "list", "platform:lastleg:order:list"); + assertPermission(LastlegOrderController.class, "detail", "platform:lastleg:order:list"); + assertPermission(LastlegOrderController.class, "export", "platform:lastleg:order:export"); + assertPermission(LastlegOrderController.class, "transferOne", "platform:lastleg:order:transfer"); + + assertPermission(InboundPlanController.class, "detail", "platform:lastleg:inbound-plan:list"); + assertPermission(InboundPlanController.class, "fbaAppointmentDetail", "platform:lastleg:inbound-plan:list"); + assertPermission(InboundPlanController.class, "convert", "platform:lastleg:inbound-plan:create"); + assertPermission(InboundPlanController.class, "batchFbaAppointment", "platform:lastleg:inbound-plan:appointment"); + + assertPermission(RelabelApplicationController.class, "page", "platform:lastleg:relabel:list"); + assertPermission(RelabelApplicationController.class, "detail", "platform:lastleg:relabel:list"); + assertPermission(InventoryQueryController.class, "balances", "platform:lastleg:inventory:list"); + assertPermission(InventoryQueryController.class, "positions", "platform:lastleg:inventory:list"); + assertPermission(InventoryQueryController.class, "exportBalances", "platform:lastleg:inventory:export"); + assertPermission(InventoryCommandController.class, "hold", "platform:lastleg:inventory:manage"); + assertPermission(InventoryCommandController.class, "adjust", "platform:lastleg:inventory:adjust"); + + assertPermission(DispatchController.class, "page", "platform:lastleg:dispatch:list"); + assertPermission(DispatchController.class, "detail", "platform:lastleg:dispatch:list"); + assertPermission(DispatchController.class, "preArrange", "platform:lastleg:dispatch:create"); + + assertPermission(PlatformOverseasProcurementController.class, "page", "platform:lastleg:overseas-procurement:list"); + assertPermission(PlatformOverseasProcurementController.class, "detail", "platform:lastleg:overseas-procurement:list"); + assertPermission(PlatformUsTruckOrderController.class, "page", "platform:lastleg:us-trucking:list"); + assertPermission(PlatformUsTruckOrderController.class, "detail", "platform:lastleg:us-trucking:list"); + } + + private static void assertPermission(Class type, String methodName, String permission) { + Method method = Arrays.stream(type.getDeclaredMethods()) + .filter(candidate -> candidate.getName().equals(methodName)) + .findFirst().orElseThrow(); + assertThat(method.getAnnotation(PreAuthorize.class)).isNotNull(); + assertThat(method.getAnnotation(PreAuthorize.class).value()) + .isEqualTo("hasAuthority('" + permission + "')"); + } +} diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/pda/service/InboundPdaWorkflowServiceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/pda/service/InboundPdaWorkflowServiceTest.java index 6caff0876..e1353af91 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/pda/service/InboundPdaWorkflowServiceTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/pda/service/InboundPdaWorkflowServiceTest.java @@ -10,6 +10,7 @@ import com.baiyue.lastleg.inboundplan.pda.entity.InboundPalletMerge; import com.baiyue.lastleg.inboundplan.pda.entity.InboundPdaOperation; import com.baiyue.lastleg.inboundplan.pda.entity.InboundPdaTask; import com.baiyue.lastleg.inboundplan.pda.gateway.WarehouseResourceGateway; +import com.baiyue.lastleg.inboundplan.pda.gateway.UnloadPhotoFileGateway; import com.baiyue.lastleg.inboundplan.pda.repository.PdaWorkflowRepository; import com.fasterxml.jackson.databind.ObjectMapper; import org.junit.jupiter.api.BeforeEach; @@ -20,6 +21,7 @@ import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; import org.springframework.web.server.ResponseStatusException; import org.springframework.test.util.ReflectionTestUtils; +import org.springframework.mock.web.MockMultipartFile; import java.math.BigDecimal; import java.time.LocalDateTime; @@ -33,6 +35,7 @@ import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.anyLong; import static org.mockito.ArgumentMatchers.eq; import static org.mockito.Mockito.atLeastOnce; +import static org.mockito.Mockito.lenient; import static org.mockito.Mockito.never; import static org.mockito.Mockito.times; import static org.mockito.Mockito.verify; @@ -44,6 +47,7 @@ class InboundPdaWorkflowServiceTest { @Mock WarehouseResourceGateway warehouse; @Mock InboundStockReceiptService stockReceipts; @Mock InboundUnloadCompletedOutboxService unloadCompletedOutbox; + @Mock UnloadPhotoFileGateway unloadPhotoFiles; private InboundPdaWorkflowService service; private InboundPlan plan; @@ -64,6 +68,7 @@ class InboundPdaWorkflowServiceTest { ReflectionTestUtils.setField(service, "warehouse", warehouse); ReflectionTestUtils.setField(service, "stockReceipts", stockReceipts); ReflectionTestUtils.setField(service, "unloadCompletedOutbox", unloadCompletedOutbox); + ReflectionTestUtils.setField(service, "unloadPhotoFiles", unloadPhotoFiles); ReflectionTestUtils.setField(service, "sourceObjectMapper", new ObjectMapper().findAndRegisterModules()); service.initializeObjectMapper(); plan = plan(); @@ -76,7 +81,7 @@ class InboundPdaWorkflowServiceTest { target = allocation(31L, targetLine.getId(), targetPallet.getId(), 3, 3, 0); source = allocation(32L, sourceLine.getId(), sourcePallet.getId(), 2, 2, 2); otherWarehouse = allocation(33L, sourceLine.getId(), otherWarehousePallet.getId(), 2, 2, 2); - when(repository.lockPlan("100", "LIP1")).thenReturn(plan); + lenient().when(repository.lockPlan("100", "LIP1")).thenReturn(plan); } @Test @@ -326,6 +331,8 @@ class InboundPdaWorkflowServiceTest { assertEquals(List.of(101L, 103L), result.unloadPhotoFileIds()); assertEquals(result.unloadPhotoUploadedAt().plusDays(2), result.estimatedEmptyReturnAt()); + verify(unloadPhotoFiles).validate("100", task.getId(), 101L); + verify(unloadPhotoFiles).validate("100", task.getId(), 103L); verify(repository).update(task); ArgumentCaptor operation = ArgumentCaptor.forClass(InboundPdaOperation.class); verify(repository).insert(operation.capture()); @@ -344,6 +351,54 @@ class InboundPdaWorkflowServiceTest { verify(repository, never()).update(task); } + @Test + void uploadUnloadPhotosRejectsAFileOwnedByAnotherBusinessTask() { + activeTask(); + org.mockito.Mockito.doThrow(new IllegalArgumentException("ownership mismatch")) + .when(unloadPhotoFiles).validate("100", task.getId(), 101L); + + assertThrows(ResponseStatusException.class, () -> service.uploadUnloadPhotos("100", "LIP1", + new PdaRequests.UnloadPhotos(List.of(101L), "unload-photo-wrong-owner", "7", "worker"))); + + verify(repository, never()).update(task); + } + + @Test + void uploadUnloadPhotoFileBindsTheFileToTheActivePdaTask() { + activeTask(); + var file = new MockMultipartFile("file", "unload.jpg", "image/jpeg", new byte[] {1, 2, 3}); + when(unloadPhotoFiles.upload("100", task.getId(), 7L, file)) + .thenReturn(new UnloadPhotoFileGateway.UploadedFile(101L, "unload.jpg", "image/jpeg", 3L)); + + var result = service.uploadUnloadPhotoFile("100", "LIP1", 7L, file); + + assertEquals(101L, result.fileId()); + assertEquals("unload.jpg", result.fileName()); + assertEquals("image/jpeg", result.contentType()); + assertEquals(3L, result.sizeBytes()); + verify(unloadPhotoFiles).upload("100", task.getId(), 7L, file); + } + + @Test + void uploadUnloadPhotoFileRejectsEmptyFileBeforeCallingFileService() { + var file = new MockMultipartFile("file", "unload.jpg", "image/jpeg", new byte[0]); + + assertThrows(ResponseStatusException.class, + () -> service.uploadUnloadPhotoFile("100", "LIP1", 7L, file)); + + verify(unloadPhotoFiles, never()).upload(any(), anyLong(), anyLong(), any()); + } + + @Test + void uploadUnloadPhotoFileRejectsNonImageBeforeCallingFileService() { + var file = new MockMultipartFile("file", "unload.pdf", "application/pdf", new byte[] {1}); + + assertThrows(ResponseStatusException.class, + () -> service.uploadUnloadPhotoFile("100", "LIP1", 7L, file)); + + verify(unloadPhotoFiles, never()).upload(any(), anyLong(), anyLong(), any()); + } + @Test void completesWhenMergedAllocationsPreserveExactLineTotals() { activeTask(); diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/pda/service/InboundUnloadCompletedOutboxServiceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/pda/service/InboundUnloadCompletedOutboxServiceTest.java index 3720afd97..53c6b0d6f 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/pda/service/InboundUnloadCompletedOutboxServiceTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/pda/service/InboundUnloadCompletedOutboxServiceTest.java @@ -2,13 +2,12 @@ package com.baiyue.lastleg.inboundplan.pda.service; import com.baiyue.common.event.EventEnvelope; import com.baiyue.common.event.EventTypes; +import com.baiyue.common.event.OverseasContainerUnloadedV1; import com.baiyue.common.event.RegionCode; import com.baiyue.lastleg.entity.LastlegEventOutbox; -import com.baiyue.lastleg.entity.LastlegOrder; import com.baiyue.lastleg.inboundplan.entity.InboundPlan; import com.baiyue.lastleg.inboundplan.pda.entity.InboundPdaTask; import com.baiyue.lastleg.mapper.LastlegEventOutboxMapper; -import com.baiyue.lastleg.mapper.LastlegOrderMapper; import com.fasterxml.jackson.databind.ObjectMapper; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; @@ -29,7 +28,6 @@ import static org.mockito.Mockito.when; @ExtendWith(MockitoExtension.class) class InboundUnloadCompletedOutboxServiceTest { @Mock LastlegEventOutboxMapper outbox; - @Mock LastlegOrderMapper orders; private final ObjectMapper objectMapper = new ObjectMapper().findAndRegisterModules(); private InboundUnloadCompletedOutboxService service; @@ -37,17 +35,15 @@ class InboundUnloadCompletedOutboxServiceTest { void setUp() { service = new InboundUnloadCompletedOutboxService(); ReflectionTestUtils.setField(service, "mapper", outbox); - ReflectionTestUtils.setField(service, "orders", orders); ReflectionTestUtils.setField(service, "objectMapper", objectMapper); } @Test - void writesAValidOverseasToCnEnvelopeUsingTheSourceBookingId() throws Exception { - InboundPlan plan = new InboundPlan(); plan.setSourceOrderId(11L); plan.setContainerNo("MSCU1"); + void writesAValidOverseasToCnEnvelopeUsingTheContainerNo() throws Exception { + InboundPlan plan = new InboundPlan(); plan.setContainerNo("MSCU1"); InboundPdaTask task = new InboundPdaTask(); task.setId(22L); task.setCustomerId("100"); - task.setUnloadPhotoUploadedAt(LocalDateTime.of(2026, 8, 20, 10, 0)); task.setVersion(3); - LastlegOrder order = new LastlegOrder(); order.setSourceContainerBookingId("9001"); - when(orders.selectById(11L)).thenReturn(order); + task.setUnloadPhotoUploadedAt(LocalDateTime.of(2026, 8, 20, 10, 0)); + task.setCompletedAt(LocalDateTime.of(2026, 8, 20, 10, 5)); task.setVersion(3); service.publish(plan, task, List.of(101L)); @@ -57,15 +53,10 @@ class InboundUnloadCompletedOutboxServiceTest { assertEquals(EventTypes.OVERSEAS_CONTAINER_UNLOADED, envelope.eventType()); assertEquals(RegionCode.OVERSEAS, envelope.sourceRegion()); assertEquals(RegionCode.CN, envelope.targetRegion()); - assertEquals("9001", envelope.aggregateId()); + assertEquals("MSCU1", envelope.aggregateId()); assertEquals("MSCU1", envelope.payload().path("containerNo").asText()); + OverseasContainerUnloadedV1 payload = objectMapper.treeToValue(envelope.payload(), OverseasContainerUnloadedV1.class); + assertEquals("2026-08-20T10:05:00Z", payload.unloadingCompletedAt().toString()); } - @Test - void rejectsPlansWhoseSourceOrderLostTheContainerBookingId() { - InboundPlan plan = new InboundPlan(); plan.setSourceOrderId(11L); - InboundPdaTask task = new InboundPdaTask(); task.setId(22L); task.setCustomerId("100"); - when(orders.selectById(11L)).thenReturn(new LastlegOrder()); - assertThrows(IllegalStateException.class, () -> service.publish(plan, task, List.of(101L))); - } } diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/service/ContainerUnloadOutboxRelayTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/service/ContainerUnloadOutboxRelayTest.java new file mode 100644 index 000000000..03f6cff6b --- /dev/null +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/service/ContainerUnloadOutboxRelayTest.java @@ -0,0 +1,57 @@ +package com.baiyue.lastleg.service; + +import com.baiyue.lastleg.entity.LastlegEventOutbox; +import com.baiyue.lastleg.mapper.LastlegEventOutboxMapper; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.test.util.ReflectionTestUtils; + +import java.time.LocalDateTime; + +import static org.mockito.ArgumentMatchers.*; +import static org.mockito.Mockito.*; + +class ContainerUnloadOutboxRelayTest { + private final LastlegEventOutboxMapper outbox = mock(LastlegEventOutboxMapper.class); + private final EventBridgePublisher publisher = mock(EventBridgePublisher.class); + private ContainerUnloadOutboxRelay relay; + + @BeforeEach + void setUp() { + relay = new ContainerUnloadOutboxRelay(); + ReflectionTestUtils.setField(relay, "outbox", outbox); + ReflectionTestUtils.setField(relay, "publisher", publisher); + } + + @Test + void publishesClaimedEnvelopeAndMarksItDone() throws Exception { + LastlegEventOutbox row = row(0); + when(outbox.claim(eq(11L), anyString(), any(), any())).thenReturn(1); + when(outbox.claimed(eq(11L), anyString())).thenReturn(row); + + relay.relayOne(11L, LocalDateTime.now(), LocalDateTime.now().minusMinutes(2)); + + verify(publisher).publish("{\"eventId\":\"event-1\"}"); + verify(outbox).markPublished(eq(11L), anyString(), any()); + verify(outbox, never()).markFailed(anyLong(), anyString(), anyString(), any(), anyString()); + } + + @Test + void schedulesRetryWhenBridgeIsUnavailable() throws Exception { + LastlegEventOutbox row = row(2); + when(outbox.claim(eq(11L), anyString(), any(), any())).thenReturn(1); + when(outbox.claimed(eq(11L), anyString())).thenReturn(row); + doThrow(new IllegalStateException("bridge down")).when(publisher).publish(anyString()); + + relay.relayOne(11L, LocalDateTime.now(), LocalDateTime.now().minusMinutes(2)); + + verify(outbox).markFailed(eq(11L), anyString(), eq("PENDING"), any(), eq("bridge down")); + verify(outbox, never()).markPublished(anyLong(), anyString(), any()); + } + + private LastlegEventOutbox row(int retries) { + LastlegEventOutbox value = new LastlegEventOutbox(); + value.setId(11L); value.setPayloadJson("{\"eventId\":\"event-1\"}"); value.setRetryCount(retries); + return value; + } +} diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/service/LoadingCompletedConsumerTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/service/LoadingCompletedConsumerTest.java index cab178bdb..4f3142a49 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/service/LoadingCompletedConsumerTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/service/LoadingCompletedConsumerTest.java @@ -4,7 +4,7 @@ import com.baiyue.common.event.*; import com.baiyue.lastleg.entity.*; import com class LoadingCompletedConsumerTest { @Mock LastlegEventInboxMapper inbox; @Mock LastlegOrderMapper orders; @Mock LastlegOrderDetailMapper details; @Mock LastlegOrderStatusHistoryMapper histories; @Mock DropshipOrderService dropshipOrders; @Mock InboundPlanService inboundPlans; ObjectMapper json=new ObjectMapper().findAndRegisterModules(); LoadingCompletedConsumer service; @BeforeEach void setup(){ service=new LoadingCompletedConsumer(); ReflectionTestUtils.setField(service,"inboxMapper",inbox); ReflectionTestUtils.setField(service,"orderMapper",orders); ReflectionTestUtils.setField(service,"detailMapper",details); ReflectionTestUtils.setField(service,"historyMapper",histories); ReflectionTestUtils.setField(service,"dropshipOrders",dropshipOrders); ReflectionTestUtils.setField(service,"inboundPlans",inboundPlans); ReflectionTestUtils.setField(service,"objectMapper",json); ReflectionTestUtils.setField(service,"orderNoGenerator",new LastlegOrderNoGenerator()); lenient().doAnswer(i->{ LastlegOrder o=i.getArgument(0); o.setId(Math.abs(o.getSourceInboundId().hashCode())+1L); return 1; }).when(orders).insert(any(LastlegOrder.class)); } - @Test void createsOneOrderAndOneInboundPlanPerInbound(){ EventEnvelope event=event(); assertEquals(2,service.consume(event)); verify(orders,times(2)).insert(any(LastlegOrder.class)); verify(inboundPlans,times(2)).convert(eq("100"),anyLong()); verify(details,times(2)).insert(any(LastlegOrderDetail.class)); verify(histories,times(2)).insert(any(LastlegOrderStatusHistory.class)); ArgumentCaptor c=ArgumentCaptor.forClass(LastlegOrder.class); verify(orders,times(2)).insert(c.capture()); assertEquals(Set.of("in-1","in-2"),c.getAllValues().stream().map(LastlegOrder::getSourceInboundId).collect(java.util.stream.Collectors.toSet())); assertTrue(c.getAllValues().stream().allMatch(o->"PENDING_ASSIGNMENT".equals(o.getCurrentStatus()))); assertTrue(c.getAllValues().stream().allMatch(o->o.getId()!=null)); assertTrue(c.getAllValues().stream().allMatch(o->"MSCU1".equals(o.getContainerNo()))); assertTrue(c.getAllValues().stream().allMatch(o->"book-1".equals(o.getSourceContainerBookingId()))); assertEquals(1,c.getAllValues().stream().map(LastlegOrder::getOrderNo).distinct().count()); assertEquals(new LastlegOrderNoGenerator().forContainerNo("MSCU1"),c.getAllValues().get(0).getOrderNo()); } + @Test void createsOneOrderAndOneInboundPlanPerInbound(){ EventEnvelope event=event(); assertEquals(2,service.consume(event)); verify(orders,times(2)).insert(any(LastlegOrder.class)); verify(inboundPlans,times(2)).convert(eq("100"),anyLong()); verify(details,times(2)).insert(any(LastlegOrderDetail.class)); verify(histories,times(2)).insert(any(LastlegOrderStatusHistory.class)); ArgumentCaptor c=ArgumentCaptor.forClass(LastlegOrder.class); verify(orders,times(2)).insert(c.capture()); assertEquals(Set.of("in-1","in-2"),c.getAllValues().stream().map(LastlegOrder::getSourceInboundId).collect(java.util.stream.Collectors.toSet())); assertTrue(c.getAllValues().stream().allMatch(o->"PENDING_ASSIGNMENT".equals(o.getCurrentStatus()))); assertTrue(c.getAllValues().stream().allMatch(o->o.getId()!=null)); assertTrue(c.getAllValues().stream().allMatch(o->"MSCU1".equals(o.getContainerNo()))); assertEquals(1,c.getAllValues().stream().map(LastlegOrder::getOrderNo).distinct().count()); assertEquals(new LastlegOrderNoGenerator().forContainerNo("MSCU1"),c.getAllValues().get(0).getOrderNo()); } @Test void repeatedEventReturnsExistingEffect(){ EventEnvelope event=event(); LastlegEventInbox row=new LastlegEventInbox(); row.setPayloadHash(event.payloadHash()); when(inbox.selectById(event.eventId())).thenReturn(row); when(orders.countContainer("100","MSCU1")).thenReturn(2L); assertEquals(2,service.consume(event)); verify(orders).countContainer("100","MSCU1"); verify(orders,never()).insert(any(LastlegOrder.class)); verify(details,never()).insert(any(LastlegOrderDetail.class)); } @Test void sameEventWithDifferentHashIsRejected(){ EventEnvelope event=event(); LastlegEventInbox row=new LastlegEventInbox(); row.setPayloadHash("sha256:"+"0".repeat(64)); when(inbox.selectById(event.eventId())).thenReturn(row); assertThrows(IllegalArgumentException.class,()->service.consume(event)); } @Test void rejectsEventFromWrongRegion(){ EventEnvelope source=event(); EventEnvelope invalid=new EventEnvelope(source.eventId(),source.eventType(),source.eventVersion(),source.occurredAt(),RegionCode.OVERSEAS,RegionCode.CN,source.tenantId(),source.aggregateType(),source.aggregateId(),source.aggregateVersion(),source.handoverVersion(),source.traceId(),source.correlationId(),source.causationId(),source.payload(),source.payloadHash()); assertThrows(IllegalArgumentException.class,()->service.consume(invalid)); } diff --git a/scripts/db/incremental.sql b/scripts/db/incremental.sql index 298df8d32..efd95eb35 100644 --- a/scripts/db/incremental.sql +++ b/scripts/db/incremental.sql @@ -3577,3 +3577,70 @@ SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.statistics 'SELECT 1', 'ALTER TABLE ll_inbound_plan ADD UNIQUE KEY uk_ll_inbound_plan_source_order (customer_id,source_order_id)')); PREPARE ll_inbound_plan_source_order_unique_027 FROM @ddl; EXECUTE ll_inbound_plan_source_order_unique_027; DEALLOCATE PREPARE ll_inbound_plan_source_order_unique_027; + +-- ============================================================================ +-- 2026-08-20 / 028 +-- Overseas PDA unload photos and container empty-return event outbox. +-- ============================================================================ +USE baiyue_lastleg; + +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inbound_pda_task' AND column_name='unload_photo_file_ids'), 'SELECT 1', 'ALTER TABLE ll_inbound_pda_task ADD COLUMN unload_photo_file_ids JSON NULL COMMENT ''卸柜照片文件ID列表'' AFTER completed_at')); +PREPARE lastleg_unload_028 FROM @ddl; EXECUTE lastleg_unload_028; DEALLOCATE PREPARE lastleg_unload_028; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inbound_pda_task' AND column_name='unload_photo_uploaded_at'), 'SELECT 1', 'ALTER TABLE ll_inbound_pda_task ADD COLUMN unload_photo_uploaded_at DATETIME(3) NULL COMMENT ''卸柜照片上传成功时间'' AFTER unload_photo_file_ids')); +PREPARE lastleg_unload_028 FROM @ddl; EXECUTE lastleg_unload_028; DEALLOCATE PREPARE lastleg_unload_028; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inbound_pda_task' AND column_name='estimated_empty_return_at'), 'SELECT 1', 'ALTER TABLE ll_inbound_pda_task ADD COLUMN estimated_empty_return_at DATETIME(3) NULL COMMENT ''预计报空时间(卸柜照片上传成功后两天)'' AFTER unload_photo_uploaded_at')); +PREPARE lastleg_unload_028 FROM @ddl; EXECUTE lastleg_unload_028; DEALLOCATE PREPARE lastleg_unload_028; + +CREATE TABLE IF NOT EXISTS ll_event_outbox ( + id BIGINT NOT NULL COMMENT '事件主键ID', customer_id VARCHAR(128) NOT NULL COMMENT '客户租户ID', + aggregate_type VARCHAR(64) NOT NULL COMMENT '聚合类型', aggregate_id VARCHAR(128) NOT NULL COMMENT '聚合标识', + event_type VARCHAR(64) NOT NULL COMMENT '事件类型', event_version INT NOT NULL COMMENT '事件版本', + event_key VARCHAR(255) NOT NULL COMMENT '业务幂等键', payload_json LONGTEXT NOT NULL COMMENT '事件载荷JSON', + status VARCHAR(32) NOT NULL DEFAULT 'PENDING' COMMENT '发布状态', retry_count INT NOT NULL DEFAULT 0 COMMENT '重试次数', + next_retry_at DATETIME(3) NULL COMMENT '下次重试时间', published_at DATETIME(3) NULL COMMENT '发布时间', + last_error VARCHAR(1000) NULL COMMENT '最后一次发布错误', locked_by VARCHAR(128) NULL COMMENT '当前投递租约持有者', + locked_at DATETIME(3) NULL COMMENT '当前投递租约获取时间', created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间', + PRIMARY KEY (id), UNIQUE KEY uk_ll_event_outbox_key (event_key), KEY idx_ll_event_outbox_publish (status,next_retry_at,created_at) +) ENGINE=InnoDB COMMENT='尾程跨区业务事件发件箱'; + +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.tables WHERE table_schema=DATABASE() AND table_name='ll_event_outbox'), 'ALTER TABLE ll_event_outbox COMMENT=''尾程跨区业务事件发件箱''', 'SELECT 1')); +PREPARE lastleg_unload_028 FROM @ddl; EXECUTE lastleg_unload_028; DEALLOCATE PREPARE lastleg_unload_028; + +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_event_outbox' AND column_name='id'), 'ALTER TABLE ll_event_outbox MODIFY COLUMN id BIGINT NOT NULL COMMENT ''事件主键ID''', 'SELECT 1')); +PREPARE lastleg_unload_028 FROM @ddl; EXECUTE lastleg_unload_028; DEALLOCATE PREPARE lastleg_unload_028; + +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_event_outbox' AND column_name='customer_id'), 'ALTER TABLE ll_event_outbox MODIFY COLUMN customer_id VARCHAR(128) NOT NULL COMMENT ''客户租户ID''', 'SELECT 1')); +PREPARE lastleg_unload_028 FROM @ddl; EXECUTE lastleg_unload_028; DEALLOCATE PREPARE lastleg_unload_028; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_event_outbox' AND column_name='aggregate_type'), 'ALTER TABLE ll_event_outbox MODIFY COLUMN aggregate_type VARCHAR(64) NOT NULL COMMENT ''聚合类型''', 'SELECT 1')); +PREPARE lastleg_unload_028 FROM @ddl; EXECUTE lastleg_unload_028; DEALLOCATE PREPARE lastleg_unload_028; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_event_outbox' AND column_name='aggregate_id'), 'ALTER TABLE ll_event_outbox MODIFY COLUMN aggregate_id VARCHAR(128) NOT NULL COMMENT ''聚合标识''', 'SELECT 1')); +PREPARE lastleg_unload_028 FROM @ddl; EXECUTE lastleg_unload_028; DEALLOCATE PREPARE lastleg_unload_028; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_event_outbox' AND column_name='event_type'), 'ALTER TABLE ll_event_outbox MODIFY COLUMN event_type VARCHAR(64) NOT NULL COMMENT ''事件类型''', 'SELECT 1')); +PREPARE lastleg_unload_028 FROM @ddl; EXECUTE lastleg_unload_028; DEALLOCATE PREPARE lastleg_unload_028; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_event_outbox' AND column_name='event_version'), 'ALTER TABLE ll_event_outbox MODIFY COLUMN event_version INT NOT NULL COMMENT ''事件版本''', 'SELECT 1')); +PREPARE lastleg_unload_028 FROM @ddl; EXECUTE lastleg_unload_028; DEALLOCATE PREPARE lastleg_unload_028; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_event_outbox' AND column_name='event_key'), 'ALTER TABLE ll_event_outbox MODIFY COLUMN event_key VARCHAR(255) NOT NULL COMMENT ''业务幂等键''', 'SELECT 1')); +PREPARE lastleg_unload_028 FROM @ddl; EXECUTE lastleg_unload_028; DEALLOCATE PREPARE lastleg_unload_028; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_event_outbox' AND column_name='payload_json'), 'ALTER TABLE ll_event_outbox MODIFY COLUMN payload_json LONGTEXT NOT NULL COMMENT ''事件载荷JSON''', 'SELECT 1')); +PREPARE lastleg_unload_028 FROM @ddl; EXECUTE lastleg_unload_028; DEALLOCATE PREPARE lastleg_unload_028; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_event_outbox' AND column_name='status'), 'ALTER TABLE ll_event_outbox MODIFY COLUMN status VARCHAR(32) NOT NULL DEFAULT ''PENDING'' COMMENT ''发布状态''', 'SELECT 1')); +PREPARE lastleg_unload_028 FROM @ddl; EXECUTE lastleg_unload_028; DEALLOCATE PREPARE lastleg_unload_028; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_event_outbox' AND column_name='retry_count'), 'ALTER TABLE ll_event_outbox MODIFY COLUMN retry_count INT NOT NULL DEFAULT 0 COMMENT ''重试次数''', 'SELECT 1')); +PREPARE lastleg_unload_028 FROM @ddl; EXECUTE lastleg_unload_028; DEALLOCATE PREPARE lastleg_unload_028; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_event_outbox' AND column_name='next_retry_at'), 'ALTER TABLE ll_event_outbox MODIFY COLUMN next_retry_at DATETIME(3) NULL COMMENT ''下次重试时间''', 'SELECT 1')); +PREPARE lastleg_unload_028 FROM @ddl; EXECUTE lastleg_unload_028; DEALLOCATE PREPARE lastleg_unload_028; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_event_outbox' AND column_name='published_at'), 'ALTER TABLE ll_event_outbox MODIFY COLUMN published_at DATETIME(3) NULL COMMENT ''发布时间''', 'SELECT 1')); +PREPARE lastleg_unload_028 FROM @ddl; EXECUTE lastleg_unload_028; DEALLOCATE PREPARE lastleg_unload_028; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_event_outbox' AND column_name='last_error'), 'ALTER TABLE ll_event_outbox MODIFY COLUMN last_error VARCHAR(1000) NULL COMMENT ''最后一次发布错误''', 'SELECT 1')); +PREPARE lastleg_unload_028 FROM @ddl; EXECUTE lastleg_unload_028; DEALLOCATE PREPARE lastleg_unload_028; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_event_outbox' AND column_name='created_at'), 'ALTER TABLE ll_event_outbox MODIFY COLUMN created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT ''创建时间''', 'SELECT 1')); +PREPARE lastleg_unload_028 FROM @ddl; EXECUTE lastleg_unload_028; DEALLOCATE PREPARE lastleg_unload_028; + +-- ============================================================================ +-- 2026-08-20 / 029 +-- Reliable lease for the overseas container-unload event relay. +-- ============================================================================ +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_event_outbox' AND column_name='locked_by'), 'SELECT 1', 'ALTER TABLE ll_event_outbox ADD COLUMN locked_by VARCHAR(128) NULL COMMENT ''当前投递租约持有者'' AFTER last_error')); +PREPARE lastleg_unload_relay_029 FROM @ddl; EXECUTE lastleg_unload_relay_029; DEALLOCATE PREPARE lastleg_unload_relay_029; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_event_outbox' AND column_name='locked_at'), 'SELECT 1', 'ALTER TABLE ll_event_outbox ADD COLUMN locked_at DATETIME(3) NULL COMMENT ''当前投递租约获取时间'' AFTER locked_by')); +PREPARE lastleg_unload_relay_029 FROM @ddl; EXECUTE lastleg_unload_relay_029; DEALLOCATE PREPARE lastleg_unload_relay_029; diff --git a/warehouse-overseas-service/src/main/java/com/baiyue/warehouse/controller/PlatformWarehouseController.java b/warehouse-overseas-service/src/main/java/com/baiyue/warehouse/controller/PlatformWarehouseController.java index e8bcfb10f..9ae72fe0f 100644 --- a/warehouse-overseas-service/src/main/java/com/baiyue/warehouse/controller/PlatformWarehouseController.java +++ b/warehouse-overseas-service/src/main/java/com/baiyue/warehouse/controller/PlatformWarehouseController.java @@ -17,6 +17,7 @@ import jakarta.annotation.Resource; import jakarta.validation.Valid; import jakarta.validation.constraints.NotNull; import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.security.access.prepost.PreAuthorize; import org.springframework.validation.annotation.Validated; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.PatchMapping; @@ -53,6 +54,7 @@ public class PlatformWarehouseController { @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "409", description = "Warehouse code already exists / 仓库编码已存在") }) @PostMapping + @PreAuthorize("hasAuthority('platform:warehouse:create')") public ApiResponse create(@AuthenticationPrincipal AuthenticatedUser user, @Valid @RequestBody WarehouseRequests.SaveRequest request) { requirePlatform(user); @@ -72,6 +74,7 @@ public class PlatformWarehouseController { @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "409", description = "Warehouse code already exists / 仓库编码已存在") }) @PutMapping("/{id}") + @PreAuthorize("hasAuthority('platform:warehouse:edit')") public ApiResponse update( @AuthenticationPrincipal AuthenticatedUser user, @Parameter(description = "Target tenant ID / 目标租户 ID", example = "10001", required = true) @@ -95,6 +98,7 @@ public class PlatformWarehouseController { @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "403", description = "Platform access required / 需要平台端权限") }) @GetMapping + @PreAuthorize("hasAuthority('platform:warehouse:list')") public ApiResponse> page( @AuthenticationPrincipal AuthenticatedUser user, @Parameter(description = "Target tenant ID / 目标租户 ID", example = "10001", required = true) @@ -115,6 +119,7 @@ public class PlatformWarehouseController { @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "404", description = "Warehouse not found / 仓库不存在") }) @GetMapping("/{id}") + @PreAuthorize("hasAuthority('platform:warehouse:list')") public ApiResponse detail( @AuthenticationPrincipal AuthenticatedUser user, @Parameter(description = "Target tenant ID / 目标租户 ID", example = "10001", required = true) @@ -137,6 +142,7 @@ public class PlatformWarehouseController { @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "404", description = "Warehouse not found / 仓库不存在") }) @PatchMapping("/{id}/status") + @PreAuthorize("hasAuthority('platform:warehouse:status')") public ApiResponse status( @AuthenticationPrincipal AuthenticatedUser user, @Parameter(description = "Target tenant ID / 目标租户 ID", example = "10001", required = true) diff --git a/warehouse-overseas-service/src/main/java/com/baiyue/warehouse/controller/PlatformWarehouseResourceController.java b/warehouse-overseas-service/src/main/java/com/baiyue/warehouse/controller/PlatformWarehouseResourceController.java index 7e714246b..fca6be895 100644 --- a/warehouse-overseas-service/src/main/java/com/baiyue/warehouse/controller/PlatformWarehouseResourceController.java +++ b/warehouse-overseas-service/src/main/java/com/baiyue/warehouse/controller/PlatformWarehouseResourceController.java @@ -14,6 +14,7 @@ import jakarta.validation.Valid; import jakarta.annotation.Resource; import jakarta.validation.constraints.NotNull; import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.security.access.prepost.PreAuthorize; import org.springframework.validation.annotation.Validated; import org.springframework.web.bind.annotation.DeleteMapping; import org.springframework.web.bind.annotation.GetMapping; @@ -32,6 +33,7 @@ public class PlatformWarehouseResourceController { @Resource private WarehouseResourceService service; @PostMapping("/locations") + @PreAuthorize("hasAuthority('platform:location:create')") public ApiResponse createLocation(@AuthenticationPrincipal AuthenticatedUser user, @RequestParam @NotNull Long customerId, @PathVariable long warehouseId, @@ -41,6 +43,7 @@ public class PlatformWarehouseResourceController { } @PutMapping("/locations/{id}") + @PreAuthorize("hasAuthority('platform:location:edit')") public ApiResponse updateLocation(@AuthenticationPrincipal AuthenticatedUser user, @RequestParam @NotNull Long customerId, @PathVariable long warehouseId, @PathVariable long id, @@ -51,6 +54,7 @@ public class PlatformWarehouseResourceController { } @GetMapping("/locations") + @PreAuthorize("hasAuthority('platform:location:list')") public ApiResponse> locations( @AuthenticationPrincipal AuthenticatedUser user, @RequestParam @NotNull Long customerId, @PathVariable long warehouseId, @Valid LocationRequests.QueryRequest query) { @@ -59,6 +63,7 @@ public class PlatformWarehouseResourceController { } @GetMapping("/locations/{id}") + @PreAuthorize("hasAuthority('platform:location:list')") public ApiResponse location(@AuthenticationPrincipal AuthenticatedUser user, @RequestParam @NotNull Long customerId, @PathVariable long warehouseId, @@ -68,6 +73,7 @@ public class PlatformWarehouseResourceController { } @DeleteMapping("/locations/{id}") + @PreAuthorize("hasAuthority('platform:location:delete')") public ApiResponse deleteLocation(@AuthenticationPrincipal AuthenticatedUser user, @RequestParam @NotNull Long customerId, @PathVariable long warehouseId, @PathVariable long id) { @@ -77,6 +83,7 @@ public class PlatformWarehouseResourceController { } @PostMapping("/pallets") + @PreAuthorize("hasAuthority('platform:pallet:create')") public ApiResponse createPallet(@AuthenticationPrincipal AuthenticatedUser user, @RequestParam @NotNull Long customerId, @PathVariable long warehouseId, @@ -86,6 +93,7 @@ public class PlatformWarehouseResourceController { } @PutMapping("/pallets/{id}") + @PreAuthorize("hasAuthority('platform:pallet:edit')") public ApiResponse updatePallet(@AuthenticationPrincipal AuthenticatedUser user, @RequestParam @NotNull Long customerId, @PathVariable long warehouseId, @PathVariable long id, @@ -96,6 +104,7 @@ public class PlatformWarehouseResourceController { } @GetMapping("/pallets") + @PreAuthorize("hasAuthority('platform:pallet:list')") public ApiResponse> pallets( @AuthenticationPrincipal AuthenticatedUser user, @RequestParam @NotNull Long customerId, @PathVariable long warehouseId, @Valid PalletRequests.QueryRequest query) { @@ -104,6 +113,7 @@ public class PlatformWarehouseResourceController { } @GetMapping("/pallets/{id}") + @PreAuthorize("hasAuthority('platform:pallet:list')") public ApiResponse pallet(@AuthenticationPrincipal AuthenticatedUser user, @RequestParam @NotNull Long customerId, @PathVariable long warehouseId, @PathVariable long id) { @@ -112,6 +122,7 @@ public class PlatformWarehouseResourceController { } @DeleteMapping("/pallets/{id}") + @PreAuthorize("hasAuthority('platform:pallet:delete')") public ApiResponse deletePallet(@AuthenticationPrincipal AuthenticatedUser user, @RequestParam @NotNull Long customerId, @PathVariable long warehouseId, @PathVariable long id) { diff --git a/warehouse-overseas-service/src/test/java/com/baiyue/warehouse/controller/PlatformWarehousePermissionContractTest.java b/warehouse-overseas-service/src/test/java/com/baiyue/warehouse/controller/PlatformWarehousePermissionContractTest.java new file mode 100644 index 000000000..61bc96c5a --- /dev/null +++ b/warehouse-overseas-service/src/test/java/com/baiyue/warehouse/controller/PlatformWarehousePermissionContractTest.java @@ -0,0 +1,36 @@ +package com.baiyue.warehouse.controller; + +import org.junit.jupiter.api.Test; +import org.springframework.security.access.prepost.PreAuthorize; + +import java.lang.reflect.Method; +import java.util.Arrays; + +import static org.assertj.core.api.Assertions.assertThat; + +class PlatformWarehousePermissionContractTest { + @Test + void platformWarehouseReadsUseListAndWritesUseIndependentPermissions() { + assertPermission(PlatformWarehouseController.class, "page", "platform:warehouse:list"); + assertPermission(PlatformWarehouseController.class, "detail", "platform:warehouse:list"); + assertPermission(PlatformWarehouseController.class, "create", "platform:warehouse:create"); + assertPermission(PlatformWarehouseController.class, "update", "platform:warehouse:edit"); + assertPermission(PlatformWarehouseController.class, "status", "platform:warehouse:status"); + + assertPermission(PlatformWarehouseResourceController.class, "locations", "platform:location:list"); + assertPermission(PlatformWarehouseResourceController.class, "location", "platform:location:list"); + assertPermission(PlatformWarehouseResourceController.class, "createLocation", "platform:location:create"); + assertPermission(PlatformWarehouseResourceController.class, "pallets", "platform:pallet:list"); + assertPermission(PlatformWarehouseResourceController.class, "pallet", "platform:pallet:list"); + assertPermission(PlatformWarehouseResourceController.class, "createPallet", "platform:pallet:create"); + } + + private static void assertPermission(Class type, String methodName, String permission) { + Method method = Arrays.stream(type.getDeclaredMethods()) + .filter(candidate -> candidate.getName().equals(methodName)) + .findFirst().orElseThrow(); + assertThat(method.getAnnotation(PreAuthorize.class)).isNotNull(); + assertThat(method.getAnnotation(PreAuthorize.class).value()) + .isEqualTo("hasAuthority('" + permission + "')"); + } +}