This commit is contained in:
2026-08-20 15:58:25 +08:00
parent cd34cd426e
commit 2847ad1b2d
45 changed files with 921 additions and 68 deletions
+2
View File
@@ -8,3 +8,5 @@ baiyue:
region: ${REGION_CODE:CN} region: ${REGION_CODE:CN}
deployment-cell: ${DEPLOYMENT_CELL:prod-cn} deployment-cell: ${DEPLOYMENT_CELL:prod-cn}
data-residency: ${DATA_RESIDENCY: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}
@@ -4,3 +4,9 @@ spring:
username: ${LASTLEG_DB_USERNAME} username: ${LASTLEG_DB_USERNAME}
password: ${LASTLEG_DB_PASSWORD} password: ${LASTLEG_DB_PASSWORD}
driver-class-name: com.mysql.cj.jdbc.Driver driver-class-name: com.mysql.cj.jdbc.Driver
baiyue:
clients:
file:
url: ${FILE_SERVICE_URL}
event-bridge:
url: ${CN_EVENT_BRIDGE_URL}
@@ -15701,6 +15701,71 @@ ALTER TABLE fl_inbound_outbox
MODIFY COLUMN payload_hash VARCHAR(71) NULL COMMENT '事件规范化负载SHA-256;标准跨区事件含sha256:前缀'; MODIFY COLUMN payload_hash VARCHAR(71) NULL COMMENT '事件规范化负载SHA-256;标准跨区事件含sha256:前缀';
ALTER TABLE fl_inbound_outbox ALTER TABLE fl_inbound_outbox
MODIFY COLUMN payload_json LONGTEXT NOT NULL COMMENT '事件负载快照;文本类型保留跨区事件摘要所依赖的数值词法'; 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(); CALL baiyue_firstleg.assert_unified_final_schema();
DROP PROCEDURE IF EXISTS baiyue_firstleg.assert_unified_final_schema; DROP PROCEDURE IF EXISTS baiyue_firstleg.assert_unified_final_schema;
SELECT 'PASS' AS migration_status, SELECT 'PASS' AS migration_status,
@@ -16,8 +16,7 @@ import java.util.List;
public interface BridgeInboxMapper extends BaseMapper<BridgeInbox> { public interface BridgeInboxMapper extends BaseMapper<BridgeInbox> {
@Select(""" @Select("""
SELECT event_id FROM bridge_inbox SELECT event_id FROM bridge_inbox
WHERE target_region = 'OVERSEAS' WHERE ((status IN ('RECEIVED','RETRY') AND (next_retry_at IS NULL OR next_retry_at <= CURRENT_TIMESTAMP(3)))
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))) OR (status = 'PROCESSING' AND lease_until < CURRENT_TIMESTAMP(3)))
ORDER BY received_at LIMIT #{limit} ORDER BY received_at LIMIT #{limit}
""") """)
@@ -26,7 +25,7 @@ public interface BridgeInboxMapper extends BaseMapper<BridgeInbox> {
@Update(""" @Update("""
UPDATE bridge_inbox SET status='PROCESSING', lease_owner=#{owner}, UPDATE bridge_inbox SET status='PROCESSING', lease_owner=#{owner},
lease_until=DATE_ADD(CURRENT_TIMESTAMP(3), INTERVAL #{leaseSeconds} SECOND) 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))) 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))) OR (status='PROCESSING' AND lease_until < CURRENT_TIMESTAMP(3)))
""") """)
@@ -0,0 +1,7 @@
package com.baiyue.eventbridge.service;
import com.baiyue.eventbridge.domain.BridgeInbox;
public interface FirstlegEventClient {
String deliver(BridgeInbox event) throws Exception;
}
@@ -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<String> 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());
}
}
@@ -20,6 +20,7 @@ public class OverseasEventDispatcher {
@Resource private BridgeInboxMapper inboxMapper; @Resource private BridgeInboxMapper inboxMapper;
@Resource private BridgeDeliveryAttemptMapper attemptMapper; @Resource private BridgeDeliveryAttemptMapper attemptMapper;
@Resource private LastlegEventClient eventClient; @Resource private LastlegEventClient eventClient;
@Resource private FirstlegEventClient firstlegEventClient;
@Resource private EventDispatcherProperties properties; @Resource private EventDispatcherProperties properties;
private final String leaseOwner = ManagementFactory.getRuntimeMXBean().getName() + ":" + UUID.randomUUID(); private final String leaseOwner = ManagementFactory.getRuntimeMXBean().getName() + ":" + UUID.randomUUID();
@@ -40,7 +41,7 @@ public class OverseasEventDispatcher {
int deliveryAttemptNo = inboxMapper.nextAttemptNo(eventId); int deliveryAttemptNo = inboxMapper.nextAttemptNo(eventId);
BridgeDeliveryAttempt attempt = startAttempt(event, deliveryAttemptNo); BridgeDeliveryAttempt attempt = startAttempt(event, deliveryAttemptNo);
try { try {
String responseCode = eventClient.deliver(event); String responseCode = deliver(event);
if (inboxMapper.markProcessed(eventId, leaseOwner) != 1) { if (inboxMapper.markProcessed(eventId, leaseOwner) != 1) {
throw new IllegalStateException("事件租约已失效,无法确认投递结果"); throw new IllegalStateException("事件租约已失效,无法确认投递结果");
} }
@@ -49,11 +50,16 @@ public class OverseasEventDispatcher {
String error = abbreviate(exception.getMessage(), 1000); String error = abbreviate(exception.getMessage(), 1000);
inboxMapper.markFailed(eventId, leaseOwner, properties.maxAttempts(), backoffSeconds(retryAttempt), error); inboxMapper.markFailed(eventId, leaseOwner, properties.maxAttempts(), backoffSeconds(retryAttempt), error);
finishAttempt(attempt, retryAttempt >= properties.maxAttempts() ? "DEAD" : "RETRY", null, 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); 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) { private BridgeDeliveryAttempt startAttempt(BridgeInbox event, int attemptNo) {
BridgeDeliveryAttempt attempt = new BridgeDeliveryAttempt(); BridgeDeliveryAttempt attempt = new BridgeDeliveryAttempt();
attempt.setEventId(event.getEventId()); attempt.setEventId(event.getEventId());
@@ -32,6 +32,7 @@ baiyue:
region: ${REGION_CODE:OVERSEAS} region: ${REGION_CODE:OVERSEAS}
event-dispatcher: event-dispatcher:
consumer-url: ${LASTLEG_CONSUMER_URL:http://127.0.0.1:9303/internal/lastleg/events} 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} fixed-delay: ${EVENT_DISPATCH_FIXED_DELAY:3000}
batch-size: ${EVENT_DISPATCH_BATCH_SIZE:50} batch-size: ${EVENT_DISPATCH_BATCH_SIZE:50}
max-attempts: ${EVENT_DISPATCH_MAX_ATTEMPTS:10} max-attempts: ${EVENT_DISPATCH_MAX_ATTEMPTS:10}
@@ -21,6 +21,7 @@ class OverseasEventDispatcherTest {
private final BridgeInboxMapper inboxMapper = mock(BridgeInboxMapper.class); private final BridgeInboxMapper inboxMapper = mock(BridgeInboxMapper.class);
private final BridgeDeliveryAttemptMapper attemptMapper = mock(BridgeDeliveryAttemptMapper.class); private final BridgeDeliveryAttemptMapper attemptMapper = mock(BridgeDeliveryAttemptMapper.class);
private final LastlegEventClient eventClient = mock(LastlegEventClient.class); private final LastlegEventClient eventClient = mock(LastlegEventClient.class);
private final FirstlegEventClient firstlegEventClient = mock(FirstlegEventClient.class);
private OverseasEventDispatcher dispatcher; private OverseasEventDispatcher dispatcher;
private BridgeInbox event; private BridgeInbox event;
@@ -34,6 +35,7 @@ class OverseasEventDispatcherTest {
ReflectionTestUtils.setField(dispatcher, "inboxMapper", inboxMapper); ReflectionTestUtils.setField(dispatcher, "inboxMapper", inboxMapper);
ReflectionTestUtils.setField(dispatcher, "attemptMapper", attemptMapper); ReflectionTestUtils.setField(dispatcher, "attemptMapper", attemptMapper);
ReflectionTestUtils.setField(dispatcher, "eventClient", eventClient); ReflectionTestUtils.setField(dispatcher, "eventClient", eventClient);
ReflectionTestUtils.setField(dispatcher, "firstlegEventClient", firstlegEventClient);
ReflectionTestUtils.setField(dispatcher, "properties", properties); ReflectionTestUtils.setField(dispatcher, "properties", properties);
event = new BridgeInbox(); event = new BridgeInbox();
event.setEventId("event-1"); event.setEventId("event-1");
@@ -83,9 +85,24 @@ class OverseasEventDispatcherTest {
dispatcher.dispatchOne("event-1"); dispatcher.dispatchOne("event-1");
verifyNoInteractions(eventClient); verifyNoInteractions(eventClient);
verifyNoInteractions(firstlegEventClient);
verify(attemptMapper, never()).insert(any(BridgeDeliveryAttempt.class)); 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() { private BridgeDeliveryAttempt updatedAttempt() {
ArgumentCaptor<BridgeDeliveryAttempt> captor = ArgumentCaptor.forClass(BridgeDeliveryAttempt.class); ArgumentCaptor<BridgeDeliveryAttempt> captor = ArgumentCaptor.forClass(BridgeDeliveryAttempt.class);
verify(attemptMapper).updateById(captor.capture()); verify(attemptMapper).updateById(captor.capture());
@@ -1,25 +1,78 @@
USE baiyue_iam; 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 INSERT INTO iam_permission
(id, tenant_id, app_code, permission_type, parent_id, permission_code, (id, tenant_id, app_code, permission_type, parent_id, permission_code,
permission_name, route_path, http_method, sort_no, status, built_in, deleted) permission_name, route_path, http_method, sort_no, status, built_in, deleted)
VALUES VALUES
(5106000, 0, 'PLATFORM', 'MENU', 0, 'platform:lastleg:order:list', '尾程订单', '/lastleg/orders', 'GET', 60, 'ACTIVE', 1, 0), (5107000, 0, 'PLATFORM', 'MENU', 0, 'platform:lastleg', '海外仓业务', '/lastleg', NULL, 700, 'ACTIVE', 1, 0),
(5106001, 0, 'PLATFORM', 'BUTTON', 5106000, 'platform:lastleg:order:detail', '查看尾程订单详情', NULL, 'GET', 10, 'ACTIVE', 1, 0), (5107010, 0, 'PLATFORM', 'MENU', 5107000, 'platform:lastleg:order:list', '尾程订单', '/lastleg/orders', 'GET', 10, 'ACTIVE', 1, 0),
(5106002, 0, 'PLATFORM', 'BUTTON', 5106000, 'platform:lastleg:order:assign', '分配尾程订单', NULL, 'POST', 20, 'ACTIVE', 1, 0), (5107011, 0, 'PLATFORM', 'BUTTON', 5107010, 'platform:lastleg:order:export', '导出尾程订单', NULL, 'GET', 10, 'ACTIVE', 1, 0),
(5106003, 0, 'PLATFORM', 'BUTTON', 5106000, 'platform:lastleg:order:transfer', '转派尾程订单', NULL, 'POST', 30, 'ACTIVE', 1, 0), (5107012, 0, 'PLATFORM', 'BUTTON', 5107010, 'platform:lastleg:order:transfer', '尾程订单转仓', NULL, 'PUT', 20, 'ACTIVE', 1, 0),
(5106004, 0, 'PLATFORM', 'BUTTON', 5106000, 'platform:lastleg:order:merge-pallet', '合并尾程托盘', NULL, 'POST', 40, 'ACTIVE', 1, 0) (5107020, 0, 'PLATFORM', 'MENU', 5107000, 'platform:lastleg:inbound-plan:list', '入库单', '/lastleg/inbound-plans', 'GET', 20, 'ACTIVE', 1, 0),
ON DUPLICATE KEY UPDATE permission_name=VALUES(permission_name), route_path=VALUES(route_path), (5107021, 0, 'PLATFORM', 'BUTTON', 5107020, 'platform:lastleg:inbound-plan:create', '新建入库单', NULL, 'POST', 10, 'ACTIVE', 1, 0),
http_method=VALUES(http_method), sort_no=VALUES(sort_no), status='ACTIVE', built_in=1, deleted=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) INSERT IGNORE INTO iam_role_permission (tenant_id, role_id, permission_id, created_at)
SELECT 0, role.id, permission.id, NOW(3) SELECT 0, role.id, permission.id, NOW(3)
FROM iam_role role FROM iam_role role
JOIN iam_permission permission ON permission.tenant_id=0 AND permission.app_code='PLATFORM' 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' 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 role.status = 'ACTIVE' AND role.deleted = 0
AND permission.permission_code IN ( AND (permission.permission_code = 'platform:lastleg'
'platform:lastleg:order:list', 'platform:lastleg:order:detail', OR permission.permission_code LIKE 'platform:lastleg:%');
'platform:lastleg:order:assign', 'platform:lastleg:order:transfer',
'platform:lastleg:order:merge-pallet');
@@ -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'");
}
}
@@ -3,12 +3,14 @@ package com.baiyue.lastleg;
import org.mybatis.spring.annotation.MapperScan; import org.mybatis.spring.annotation.MapperScan;
import org.springframework.boot.SpringApplication; import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication; import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.scheduling.annotation.EnableScheduling;
/** /**
* 尾程微服务预留模块启动入口,初始化 Spring Boot 应用并扫描尾程 Mapper。 * 尾程微服务预留模块启动入口,初始化 Spring Boot 应用并扫描尾程 Mapper。
*/ */
@MapperScan("com.baiyue.lastleg.mapper") @MapperScan("com.baiyue.lastleg.mapper")
@SpringBootApplication(scanBasePackages = "com.baiyue") @SpringBootApplication(scanBasePackages = "com.baiyue")
@EnableScheduling
public class BaiyueLastlegApplication { public class BaiyueLastlegApplication {
public static void main(String[] args) { SpringApplication.run(BaiyueLastlegApplication.class, args); } public static void main(String[] args) { SpringApplication.run(BaiyueLastlegApplication.class, args); }
} }
@@ -19,6 +19,7 @@ import org.springframework.http.HttpStatus;
import org.springframework.http.MediaType; import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity; import org.springframework.http.ResponseEntity;
import org.springframework.security.core.annotation.AuthenticationPrincipal; 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.GetMapping;
import org.springframework.web.bind.annotation.PathVariable; import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.PostMapping;
@@ -49,6 +50,7 @@ public class LastlegOrderController {
} }
@GetMapping("/api/platform/v1/last-leg/orders") @GetMapping("/api/platform/v1/last-leg/orders")
@PreAuthorize("hasAuthority('platform:lastleg:order:list')")
/** 分页查询尾程订单列表。 */ /** 分页查询尾程订单列表。 */
@Operation(summary = "分页查询尾程订单") @Operation(summary = "分页查询尾程订单")
public ApiResponse<PageResult<LastlegOrderSummary>> list( public ApiResponse<PageResult<LastlegOrderSummary>> list(
@@ -59,6 +61,7 @@ public class LastlegOrderController {
} }
@GetMapping("/api/platform/v1/last-leg/orders/{id}") @GetMapping("/api/platform/v1/last-leg/orders/{id}")
@PreAuthorize("hasAuthority('platform:lastleg:order:list')")
/** 查询指定入仓单维度的尾程订单详情。 */ /** 查询指定入仓单维度的尾程订单详情。 */
@Operation(summary = "查询尾程订单详情") @Operation(summary = "查询尾程订单详情")
public ApiResponse<LastlegOrderDetailResponse> detail( public ApiResponse<LastlegOrderDetailResponse> detail(
@@ -69,6 +72,7 @@ public class LastlegOrderController {
} }
@GetMapping(value = "/api/platform/v1/last-leg/orders/export", produces = "text/csv") @GetMapping(value = "/api/platform/v1/last-leg/orders/export", produces = "text/csv")
@PreAuthorize("hasAuthority('platform:lastleg:order:export')")
/** 按筛选条件导出尾程订单 CSV 文件。 */ /** 按筛选条件导出尾程订单 CSV 文件。 */
@Operation(summary = "导出尾程订单") @Operation(summary = "导出尾程订单")
public ResponseEntity<byte[]> export( public ResponseEntity<byte[]> export(
@@ -83,6 +87,7 @@ public class LastlegOrderController {
} }
@PutMapping("/api/platform/v1/last-leg/orders/{id}/destination") @PutMapping("/api/platform/v1/last-leg/orders/{id}/destination")
@PreAuthorize("hasAuthority('platform:lastleg:order:transfer')")
/** 将单个尾程订单转移到目标仓库。 */ /** 将单个尾程订单转移到目标仓库。 */
@Operation(summary = "单个订单转移仓库") @Operation(summary = "单个订单转移仓库")
public ApiResponse<Integer> transferOne( public ApiResponse<Integer> transferOne(
@@ -94,6 +99,7 @@ public class LastlegOrderController {
} }
@PutMapping("/api/platform/v1/last-leg/orders/destination") @PutMapping("/api/platform/v1/last-leg/orders/destination")
@PreAuthorize("hasAuthority('platform:lastleg:order:transfer')")
/** 将多个尾程订单批量转移到目标仓库。 */ /** 将多个尾程订单批量转移到目标仓库。 */
@Operation(summary = "批量订单转移仓库") @Operation(summary = "批量订单转移仓库")
public ApiResponse<Integer> transferBatch( public ApiResponse<Integer> transferBatch(
@@ -17,6 +17,7 @@ import jakarta.annotation.Resource;
import jakarta.validation.Valid; import jakarta.validation.Valid;
import org.springframework.http.HttpStatus; import org.springframework.http.HttpStatus;
import org.springframework.security.core.annotation.AuthenticationPrincipal; 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.DeleteMapping;
import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable; import org.springframework.web.bind.annotation.PathVariable;
@@ -38,6 +39,7 @@ public class DispatchController {
@Resource private DispatchPdaWorkflowService pdaWorkflow; @Resource private DispatchPdaWorkflowService pdaWorkflow;
@GetMapping("/candidates") @GetMapping("/candidates")
@PreAuthorize("hasAuthority('platform:lastleg:dispatch:list')")
/** 查询可用于智能预派车的未锁定托盘。 */ /** 查询可用于智能预派车的未锁定托盘。 */
@Operation(summary = "查询可派车托盘") @Operation(summary = "查询可派车托盘")
public ApiResponse<List<DispatchPalletCandidate>> candidates( public ApiResponse<List<DispatchPalletCandidate>> candidates(
@@ -50,6 +52,7 @@ public class DispatchController {
} }
@PostMapping("/pre-arrange") @PostMapping("/pre-arrange")
@PreAuthorize("hasAuthority('platform:lastleg:dispatch:create')")
/** 创建待出发派车单并锁定选中的托盘。 */ /** 创建待出发派车单并锁定选中的托盘。 */
@Operation(summary = "创建预派车单") @Operation(summary = "创建预派车单")
public ApiResponse<DispatchResponse> preArrange( public ApiResponse<DispatchResponse> preArrange(
@@ -62,6 +65,7 @@ public class DispatchController {
} }
@GetMapping @GetMapping
@PreAuthorize("hasAuthority('platform:lastleg:dispatch:list')")
/** 分页查询派车单。 */ /** 分页查询派车单。 */
@Operation(summary = "分页查询派车单") @Operation(summary = "分页查询派车单")
public ApiResponse<PageResult<DispatchResponse>> page( public ApiResponse<PageResult<DispatchResponse>> page(
@@ -72,6 +76,7 @@ public class DispatchController {
} }
@GetMapping("/{dispatchNo}") @GetMapping("/{dispatchNo}")
@PreAuthorize("hasAuthority('platform:lastleg:dispatch:list')")
/** 查询派车单详情及托盘目的地。 */ /** 查询派车单详情及托盘目的地。 */
@Operation(summary = "查询派车单详情") @Operation(summary = "查询派车单详情")
public ApiResponse<DispatchResponse> detail( public ApiResponse<DispatchResponse> detail(
@@ -82,6 +87,7 @@ public class DispatchController {
} }
@PutMapping("/{dispatchNo}") @PutMapping("/{dispatchNo}")
@PreAuthorize("hasAuthority('platform:lastleg:dispatch:edit')")
/** 编辑车辆、司机、预约、路线和出发信息。 */ /** 编辑车辆、司机、预约、路线和出发信息。 */
@Operation(summary = "编辑派车单") @Operation(summary = "编辑派车单")
public ApiResponse<DispatchResponse> update( public ApiResponse<DispatchResponse> update(
@@ -93,6 +99,7 @@ public class DispatchController {
} }
@DeleteMapping("/{dispatchNo}") @DeleteMapping("/{dispatchNo}")
@PreAuthorize("hasAuthority('platform:lastleg:dispatch:delete')")
/** 取消待处理派车单并释放托盘锁定。 */ /** 取消待处理派车单并释放托盘锁定。 */
@Operation(summary = "取消派车单") @Operation(summary = "取消派车单")
public ApiResponse<Void> cancel( public ApiResponse<Void> cancel(
@@ -24,5 +24,7 @@ public class LastlegEventOutbox {
private LocalDateTime nextRetryAt; private LocalDateTime nextRetryAt;
private LocalDateTime publishedAt; private LocalDateTime publishedAt;
private String lastError; private String lastError;
private String lockedBy;
private LocalDateTime lockedAt;
private LocalDateTime createdAt; private LocalDateTime createdAt;
} }
@@ -56,7 +56,6 @@ public class LastlegOrder {
private String warehouseRemark; private String warehouseRemark;
private String sourceEventId; private String sourceEventId;
private String sourceLoadingPlanId; private String sourceLoadingPlanId;
private String sourceContainerBookingId;
private Long sourceLoadingPlanVersion; private Long sourceLoadingPlanVersion;
private Long sourceHandoverVersion; private Long sourceHandoverVersion;
private String sourcePayloadHash; private String sourcePayloadHash;
@@ -15,6 +15,7 @@ import jakarta.validation.Valid;
import jakarta.annotation.Resource; import jakarta.annotation.Resource;
import org.springframework.http.HttpStatus; import org.springframework.http.HttpStatus;
import org.springframework.security.core.annotation.AuthenticationPrincipal; import org.springframework.security.core.annotation.AuthenticationPrincipal;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.web.bind.annotation.*; import org.springframework.web.bind.annotation.*;
import org.springframework.web.server.ResponseStatusException; import org.springframework.web.server.ResponseStatusException;
@@ -26,6 +27,7 @@ public class InboundPlanController {
@Resource private InboundFbaAppointmentService appointmentService; @Resource private InboundFbaAppointmentService appointmentService;
@PostMapping("/convert") @PostMapping("/convert")
@PreAuthorize("hasAuthority('platform:lastleg:inbound-plan:create')")
/** 将入仓单转换为入仓计划。 */ /** 将入仓单转换为入仓计划。 */
@Operation(summary = "入仓单转入仓计划") @Operation(summary = "入仓单转入仓计划")
public ApiResponse<InboundPlanResponse> convert(@AuthenticationPrincipal AuthenticatedUser user, public ApiResponse<InboundPlanResponse> convert(@AuthenticationPrincipal AuthenticatedUser user,
@@ -34,6 +36,7 @@ public class InboundPlanController {
} }
@GetMapping("/{planNo}") @GetMapping("/{planNo}")
@PreAuthorize("hasAuthority('platform:lastleg:inbound-plan:list')")
/** 查询入仓计划及其明细、托盘和分配信息。 */ /** 查询入仓计划及其明细、托盘和分配信息。 */
@Operation(summary = "查询入仓计划详情") @Operation(summary = "查询入仓计划详情")
public ApiResponse<InboundPlanResponse> detail(@AuthenticationPrincipal AuthenticatedUser user, public ApiResponse<InboundPlanResponse> detail(@AuthenticationPrincipal AuthenticatedUser user,
@@ -43,6 +46,7 @@ public class InboundPlanController {
} }
@PostMapping("/{planNo}/pallets/supplement") @PostMapping("/{planNo}/pallets/supplement")
@PreAuthorize("hasAuthority('platform:lastleg:inbound-plan:edit')")
/** 为计划目标仓新增一个可打印的补充托盘。 */ /** 为计划目标仓新增一个可打印的补充托盘。 */
@Operation(summary = "新增补充托盘") @Operation(summary = "新增补充托盘")
public ApiResponse<InboundPlanResponse.Pallet> supplementPallet(@AuthenticationPrincipal AuthenticatedUser user, public ApiResponse<InboundPlanResponse.Pallet> supplementPallet(@AuthenticationPrincipal AuthenticatedUser user,
@@ -52,6 +56,7 @@ public class InboundPlanController {
} }
@PostMapping("/fba-appointments/batch") @PostMapping("/fba-appointments/batch")
@PreAuthorize("hasAuthority('platform:lastleg:inbound-plan:appointment')")
/** 为选中的入仓明细创建 FBA 预约。 */ /** 为选中的入仓明细创建 FBA 预约。 */
@Operation(summary = "创建 FBA 预约") @Operation(summary = "创建 FBA 预约")
public ApiResponse<FbaAppointmentResponse> batchFbaAppointment( public ApiResponse<FbaAppointmentResponse> batchFbaAppointment(
@@ -63,6 +68,7 @@ public class InboundPlanController {
} }
@GetMapping("/fba-appointments/{appointmentNo}") @GetMapping("/fba-appointments/{appointmentNo}")
@PreAuthorize("hasAuthority('platform:lastleg:inbound-plan:list')")
/** 查询 FBA 预约及其选中的入仓明细。 */ /** 查询 FBA 预约及其选中的入仓明细。 */
@Operation(summary = "查询 FBA 预约详情") @Operation(summary = "查询 FBA 预约详情")
public ApiResponse<FbaAppointmentResponse> fbaAppointmentDetail( public ApiResponse<FbaAppointmentResponse> fbaAppointmentDetail(
@@ -16,6 +16,9 @@ import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam; import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController; 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 org.springframework.web.server.ResponseStatusException;
import java.util.List; import java.util.List;
@@ -114,6 +117,15 @@ public class InboundPdaController {
return ApiResponse.success(service.uploadUnloadPhotos(scope(user, customerId), planNo, request)); return ApiResponse.success(service.uploadUnloadPhotos(scope(user, customerId), planNo, request));
} }
@PostMapping(value = "/unload-photos/files", consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
public ApiResponse<PdaResponses.UnloadPhotoFile> 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) { private String scope(AuthenticatedUser user, String requestedCustomer) {
if (user == null || user.tenantId() == null) throw new ResponseStatusException(HttpStatus.UNAUTHORIZED); if (user == null || user.tenantId() == null) throw new ResponseStatusException(HttpStatus.UNAUTHORIZED);
if ("PLATFORM".equals(user.appCode()) && user.tenantId() == 0L) { if ("PLATFORM".equals(user.appCode()) && user.tenantId() == 0L) {
@@ -12,6 +12,8 @@ public final class PdaResponses {
List<Long> unloadPhotoFileIds, LocalDateTime unloadPhotoUploadedAt, List<Long> unloadPhotoFileIds, LocalDateTime unloadPhotoUploadedAt,
LocalDateTime estimatedEmptyReturnAt) {} LocalDateTime estimatedEmptyReturnAt) {}
public record UnloadPhotoFile(long fileId, String fileName, String contentType, long sizeBytes) {}
public record Operation(String result, long allocationId, int requiredPackageCount, public record Operation(String result, long allocationId, int requiredPackageCount,
int countedPackageCount, int pickedPackageCount, int countedPackageCount, int pickedPackageCount,
BigDecimal actualVolumeCbm, Integer remainingPackageCount, BigDecimal actualVolumeCbm, Integer remainingPackageCount,
@@ -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) {}
}
@@ -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) {}
}
@@ -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.InboundPdaOperation;
import com.baiyue.lastleg.inboundplan.pda.entity.InboundPdaTask; import com.baiyue.lastleg.inboundplan.pda.entity.InboundPdaTask;
import com.baiyue.lastleg.inboundplan.pda.gateway.WarehouseResourceGateway; 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.baiyue.lastleg.inboundplan.pda.repository.PdaWorkflowRepository;
import com.fasterxml.jackson.core.JsonProcessingException; import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.MapperFeature; 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.http.HttpStatus;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional; import org.springframework.transaction.annotation.Transactional;
import org.springframework.web.multipart.MultipartFile;
import org.springframework.web.server.ResponseStatusException; import org.springframework.web.server.ResponseStatusException;
import jakarta.annotation.PostConstruct; import jakarta.annotation.PostConstruct;
import jakarta.annotation.Resource; import jakarta.annotation.Resource;
@@ -40,12 +42,14 @@ import java.util.Objects;
@Service @Service
public class InboundPdaWorkflowService { public class InboundPdaWorkflowService {
private static final long MAX_UNLOAD_PHOTO_SIZE = 10L * 1024 * 1024;
private static final BigDecimal ZERO_VOLUME = new BigDecimal("0.0000"); private static final BigDecimal ZERO_VOLUME = new BigDecimal("0.0000");
@Resource private PdaWorkflowRepository repository; @Resource private PdaWorkflowRepository repository;
@Resource private WarehouseResourceGateway warehouse; @Resource private WarehouseResourceGateway warehouse;
@Resource private InboundStockReceiptService stockReceipts; @Resource private InboundStockReceiptService stockReceipts;
@Resource private InboundUnloadCompletedOutboxService unloadCompletedOutbox; @Resource private InboundUnloadCompletedOutboxService unloadCompletedOutbox;
@Resource private UnloadPhotoFileGateway unloadPhotoFiles;
@Resource private ObjectMapper sourceObjectMapper; @Resource private ObjectMapper sourceObjectMapper;
private ObjectMapper objectMapper; private ObjectMapper objectMapper;
@@ -421,6 +425,7 @@ public class InboundPdaWorkflowService {
InboundPdaTask task = requireActiveTask(customerId, plan); InboundPdaTask task = requireActiveTask(customerId, plan);
List<Long> fileIds = request.fileIds().stream().distinct().sorted().toList(); List<Long> fileIds = request.fileIds().stream().distinct().sorted().toList();
if (fileIds.size() != request.fileIds().size()) throw conflict("Unload photo file IDs cannot be duplicated"); 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(); LocalDateTime uploadedAt = LocalDateTime.now();
task.setUnloadPhotoFileIds(write(fileIds)); task.setUnloadPhotoFileIds(write(fileIds));
task.setUnloadPhotoUploadedAt(uploadedAt); task.setUnloadPhotoUploadedAt(uploadedAt);
@@ -433,6 +438,21 @@ public class InboundPdaWorkflowService {
return result; 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, private void moveSourceAllocation(Long planId, InboundPlanPalletAllocation source, Long targetPalletId,
int packageCount, BigDecimal movedVolume) { int packageCount, BigDecimal movedVolume) {
source.setPickedPackageCount(value(source.getPickedPackageCount()) - packageCount); 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) { private BigDecimal perPackageVolume(InboundPlanLine line) {
int required = value(line.getRequiredPackageCount()); int required = value(line.getRequiredPackageCount());
if (required <= 0) throw conflict("Inbound line required package count must be positive"); if (required <= 0) throw conflict("Inbound line required package count must be positive");
@@ -9,11 +9,9 @@ import com.baiyue.common.event.EventTypes;
import com.baiyue.common.event.OverseasContainerUnloadedV1; import com.baiyue.common.event.OverseasContainerUnloadedV1;
import com.baiyue.common.event.RegionCode; import com.baiyue.common.event.RegionCode;
import com.baiyue.lastleg.entity.LastlegEventOutbox; import com.baiyue.lastleg.entity.LastlegEventOutbox;
import com.baiyue.lastleg.entity.LastlegOrder;
import com.baiyue.lastleg.inboundplan.entity.InboundPlan; import com.baiyue.lastleg.inboundplan.entity.InboundPlan;
import com.baiyue.lastleg.inboundplan.pda.entity.InboundPdaTask; import com.baiyue.lastleg.inboundplan.pda.entity.InboundPdaTask;
import com.baiyue.lastleg.mapper.LastlegEventOutboxMapper; import com.baiyue.lastleg.mapper.LastlegEventOutboxMapper;
import com.baiyue.lastleg.mapper.LastlegOrderMapper;
import com.fasterxml.jackson.core.JsonProcessingException; import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper; import com.fasterxml.jackson.databind.ObjectMapper;
@@ -29,26 +27,21 @@ import java.util.List;
@Service @Service
public class InboundUnloadCompletedOutboxService { public class InboundUnloadCompletedOutboxService {
@Resource private LastlegEventOutboxMapper mapper; @Resource private LastlegEventOutboxMapper mapper;
@Resource private LastlegOrderMapper orders;
@Resource private ObjectMapper objectMapper; @Resource private ObjectMapper objectMapper;
public void publish(InboundPlan plan, InboundPdaTask task, List<Long> photoFileIds) { public void publish(InboundPlan plan, InboundPdaTask task, List<Long> 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); 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); photoUploadedAt, null);
JsonNode payloadNode = objectMapper.valueToTree(payload); JsonNode payloadNode = objectMapper.valueToTree(payload);
// Multiple inbound plans can share one container; upload epoch keeps their container events ordered. // Multiple inbound plans can share one container; completion epoch keeps their container events ordered.
long aggregateVersion = photoUploadedAt.toEpochMilli(); long aggregateVersion = unloadingCompletedAt.toEpochMilli();
String eventKey = EventTypes.OVERSEAS_CONTAINER_UNLOADED + ':' + task.getId(); String eventKey = EventTypes.OVERSEAS_CONTAINER_UNLOADED + ':' + task.getId();
String traceId = EventIdentifiers.newUuid(); String traceId = EventIdentifiers.newUuid();
EventEnvelope envelope = new EventEnvelope(EventIdentifiers.newUuid(), EventTypes.OVERSEAS_CONTAINER_UNLOADED, EventEnvelope envelope = new EventEnvelope(EventIdentifiers.newUuid(), EventTypes.OVERSEAS_CONTAINER_UNLOADED,
1, Instant.now(), RegionCode.OVERSEAS, RegionCode.CN, task.getCustomerId(), "CONTAINER_BOOKING", 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(); EventPayloadHash.sha256(EventPayloadCanonicalizer.canonicalize(payloadNode))).requireValid();
LastlegEventOutbox event = new LastlegEventOutbox(); LastlegEventOutbox event = new LastlegEventOutbox();
@@ -11,6 +11,7 @@ import jakarta.annotation.Resource;
import jakarta.validation.Valid; import jakarta.validation.Valid;
import org.springframework.http.HttpStatus; import org.springframework.http.HttpStatus;
import org.springframework.security.core.annotation.AuthenticationPrincipal; 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.PostMapping;
import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RequestMapping;
@@ -21,11 +22,12 @@ import org.springframework.web.server.ResponseStatusException;
@RequestMapping("/api/platform/v1/last-leg/inventory") @RequestMapping("/api/platform/v1/last-leg/inventory")
@Tag(name = "尾程入仓单库存操作") @Tag(name = "尾程入仓单库存操作")
public class InventoryCommandController { public class InventoryCommandController {
public static final String MANAGE_PERMISSION = "lastleg:inventory:manage"; public static final String MANAGE_PERMISSION = "platform:lastleg:inventory:manage";
public static final String ADJUST_PERMISSION = "lastleg:inventory:adjust"; public static final String ADJUST_PERMISSION = "platform:lastleg:inventory:adjust";
@Resource private InventoryCommandService service; @Resource private InventoryCommandService service;
@PostMapping("/hold") @PostMapping("/hold")
@PreAuthorize("hasAuthority('platform:lastleg:inventory:manage')")
/** 将可用入仓单箱数转为冻结。 */ /** 将可用入仓单箱数转为冻结。 */
@Operation(summary = "冻结入仓单库存") @Operation(summary = "冻结入仓单库存")
public ApiResponse<InventoryCommandResponse> hold(@AuthenticationPrincipal AuthenticatedUser user, public ApiResponse<InventoryCommandResponse> hold(@AuthenticationPrincipal AuthenticatedUser user,
@@ -35,6 +37,7 @@ public class InventoryCommandController {
} }
@PostMapping("/release") @PostMapping("/release")
@PreAuthorize("hasAuthority('platform:lastleg:inventory:manage')")
/** 释放已冻结的入仓单箱数。 */ /** 释放已冻结的入仓单箱数。 */
@Operation(summary = "释放入仓单库存") @Operation(summary = "释放入仓单库存")
public ApiResponse<InventoryCommandResponse> release(@AuthenticationPrincipal AuthenticatedUser user, public ApiResponse<InventoryCommandResponse> release(@AuthenticationPrincipal AuthenticatedUser user,
@@ -44,6 +47,7 @@ public class InventoryCommandController {
} }
@PostMapping("/allocate") @PostMapping("/allocate")
@PreAuthorize("hasAuthority('platform:lastleg:inventory:manage')")
/** 将可用入仓单箱数分配给出库单。 */ /** 将可用入仓单箱数分配给出库单。 */
@Operation(summary = "分配出库库存") @Operation(summary = "分配出库库存")
public ApiResponse<InventoryCommandResponse> allocate(@AuthenticationPrincipal AuthenticatedUser user, public ApiResponse<InventoryCommandResponse> allocate(@AuthenticationPrincipal AuthenticatedUser user,
@@ -53,6 +57,7 @@ public class InventoryCommandController {
} }
@PostMapping("/unallocate") @PostMapping("/unallocate")
@PreAuthorize("hasAuthority('platform:lastleg:inventory:manage')")
/** 取消出库库存分配。 */ /** 取消出库库存分配。 */
@Operation(summary = "取消出库分配") @Operation(summary = "取消出库分配")
public ApiResponse<InventoryCommandResponse> unallocate(@AuthenticationPrincipal AuthenticatedUser user, public ApiResponse<InventoryCommandResponse> unallocate(@AuthenticationPrincipal AuthenticatedUser user,
@@ -62,6 +67,7 @@ public class InventoryCommandController {
} }
@PostMapping("/pick") @PostMapping("/pick")
@PreAuthorize("hasAuthority('platform:lastleg:inventory:manage')")
/** 确认入仓单箱数已拣货。 */ /** 确认入仓单箱数已拣货。 */
@Operation(summary = "确认拣货") @Operation(summary = "确认拣货")
public ApiResponse<InventoryCommandResponse> pick(@AuthenticationPrincipal AuthenticatedUser user, public ApiResponse<InventoryCommandResponse> pick(@AuthenticationPrincipal AuthenticatedUser user,
@@ -71,6 +77,7 @@ public class InventoryCommandController {
} }
@PostMapping("/ship") @PostMapping("/ship")
@PreAuthorize("hasAuthority('platform:lastleg:inventory:manage')")
/** 出库已拣货箱数并返回托盘是否为空。 */ /** 出库已拣货箱数并返回托盘是否为空。 */
@Operation(summary = "出库拣货库存") @Operation(summary = "出库拣货库存")
public ApiResponse<InventoryCommandResponse> ship(@AuthenticationPrincipal AuthenticatedUser user, public ApiResponse<InventoryCommandResponse> ship(@AuthenticationPrincipal AuthenticatedUser user,
@@ -80,6 +87,7 @@ public class InventoryCommandController {
} }
@PostMapping("/adjust") @PostMapping("/adjust")
@PreAuthorize("hasAuthority('platform:lastleg:inventory:adjust')")
/** 根据实物盘点结果调整入仓单库存。 */ /** 根据实物盘点结果调整入仓单库存。 */
@Operation(summary = "盘点调整库存") @Operation(summary = "盘点调整库存")
public ApiResponse<InventoryCommandResponse> adjust(@AuthenticationPrincipal AuthenticatedUser user, public ApiResponse<InventoryCommandResponse> adjust(@AuthenticationPrincipal AuthenticatedUser user,
@@ -18,6 +18,7 @@ import org.springframework.http.HttpStatus;
import org.springframework.http.MediaType; import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity; import org.springframework.http.ResponseEntity;
import org.springframework.security.core.annotation.AuthenticationPrincipal; 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.GetMapping;
import org.springframework.web.bind.annotation.PathVariable; import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.RequestParam; import org.springframework.web.bind.annotation.RequestParam;
@@ -35,6 +36,7 @@ public class InventoryQueryController {
@Resource private InventoryQueryService service; @Resource private InventoryQueryService service;
@GetMapping(BASE_PATH + "/summary") @GetMapping(BASE_PATH + "/summary")
@PreAuthorize("hasAuthority('platform:lastleg:inventory:list')")
/** 查询入仓单库存汇总。 */ /** 查询入仓单库存汇总。 */
@Operation(summary = "查询库存汇总") @Operation(summary = "查询库存汇总")
public ApiResponse<InventoryDashboardResponse> summary( public ApiResponse<InventoryDashboardResponse> summary(
@@ -45,6 +47,7 @@ public class InventoryQueryController {
} }
@GetMapping(BASE_PATH + "/balances") @GetMapping(BASE_PATH + "/balances")
@PreAuthorize("hasAuthority('platform:lastleg:inventory:list')")
/** 按客户、仓库和入仓单分页查询库存。 */ /** 按客户、仓库和入仓单分页查询库存。 */
@Operation(summary = "分页查询库存余额") @Operation(summary = "分页查询库存余额")
public ApiResponse<PageResult<InventoryBalanceView>> balances( public ApiResponse<PageResult<InventoryBalanceView>> balances(
@@ -55,6 +58,7 @@ public class InventoryQueryController {
} }
@GetMapping(BASE_PATH + "/balances/{balanceId}/positions") @GetMapping(BASE_PATH + "/balances/{balanceId}/positions")
@PreAuthorize("hasAuthority('platform:lastleg:inventory:list')")
/** 查询库存余额对应的托盘和批次位置。 */ /** 查询库存余额对应的托盘和批次位置。 */
@Operation(summary = "查询库存位置") @Operation(summary = "查询库存位置")
public ApiResponse<List<InventoryPositionView>> positions( public ApiResponse<List<InventoryPositionView>> positions(
@@ -66,6 +70,7 @@ public class InventoryQueryController {
} }
@GetMapping(BASE_PATH + "/ledger") @GetMapping(BASE_PATH + "/ledger")
@PreAuthorize("hasAuthority('platform:lastleg:inventory:list')")
/** 分页查询只增不改的库存流水。 */ /** 分页查询只增不改的库存流水。 */
@Operation(summary = "分页查询库存流水") @Operation(summary = "分页查询库存流水")
public ApiResponse<PageResult<InventoryLedgerView>> ledgers( public ApiResponse<PageResult<InventoryLedgerView>> ledgers(
@@ -76,6 +81,7 @@ public class InventoryQueryController {
} }
@GetMapping(value = BASE_PATH + "/balances/export", produces = "text/csv") @GetMapping(value = BASE_PATH + "/balances/export", produces = "text/csv")
@PreAuthorize("hasAuthority('platform:lastleg:inventory:export')")
/** 导出筛选后的入仓单库存 CSV 文件。 */ /** 导出筛选后的入仓单库存 CSV 文件。 */
@Operation(summary = "导出库存余额") @Operation(summary = "导出库存余额")
public ResponseEntity<byte[]> exportBalances( public ResponseEntity<byte[]> exportBalances(
@@ -86,6 +92,7 @@ public class InventoryQueryController {
} }
@GetMapping(value = BASE_PATH + "/ledger/export", produces = "text/csv") @GetMapping(value = BASE_PATH + "/ledger/export", produces = "text/csv")
@PreAuthorize("hasAuthority('platform:lastleg:inventory:export')")
/** 导出筛选后的库存流水 CSV 文件。 */ /** 导出筛选后的库存流水 CSV 文件。 */
@Operation(summary = "导出库存流水") @Operation(summary = "导出库存流水")
public ResponseEntity<byte[]> exportLedgers( public ResponseEntity<byte[]> exportLedgers(
@@ -3,7 +3,47 @@ package com.baiyue.lastleg.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper; import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.baiyue.lastleg.entity.LastlegEventOutbox; import com.baiyue.lastleg.entity.LastlegEventOutbox;
import org.apache.ibatis.annotations.Mapper; 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 @Mapper
public interface LastlegEventOutboxMapper extends BaseMapper<LastlegEventOutbox> { public interface LastlegEventOutboxMapper extends BaseMapper<LastlegEventOutbox> {
@Select("""
SELECT id FROM ll_event_outbox
WHERE event_type='OverseasContainerUnloaded' AND retry_count &lt; #{maxAttempts}
AND (status='PENDING' OR (status='PROCESSING' AND locked_at &lt;= #{staleBefore}))
AND (next_retry_at IS NULL OR next_retry_at &lt;= #{now})
ORDER BY created_at,id LIMIT #{limit}
""")
List<Long> 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 &lt;= #{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);
} }
@@ -10,6 +10,7 @@ import jakarta.annotation.Resource;
import jakarta.validation.Valid; import jakarta.validation.Valid;
import org.springframework.http.HttpStatus; import org.springframework.http.HttpStatus;
import org.springframework.security.core.annotation.AuthenticationPrincipal; 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.GetMapping;
import org.springframework.web.bind.annotation.PatchMapping; import org.springframework.web.bind.annotation.PatchMapping;
import org.springframework.web.bind.annotation.PathVariable; import org.springframework.web.bind.annotation.PathVariable;
@@ -26,6 +27,7 @@ public class PlatformOverseasProcurementController {
@Resource private OverseasProcurementService service; @Resource private OverseasProcurementService service;
@GetMapping @GetMapping
@PreAuthorize("hasAuthority('platform:lastleg:overseas-procurement:list')")
public ApiResponse<PageResult<OverseasProcurementResponses.Summary>> page( public ApiResponse<PageResult<OverseasProcurementResponses.Summary>> page(
@AuthenticationPrincipal AuthenticatedUser user, @AuthenticationPrincipal AuthenticatedUser user,
@RequestParam(required = false) String customerId, @RequestParam(required = false) String customerId,
@@ -35,6 +37,7 @@ public class PlatformOverseasProcurementController {
} }
@GetMapping("/{orderNo}") @GetMapping("/{orderNo}")
@PreAuthorize("hasAuthority('platform:lastleg:overseas-procurement:list')")
public ApiResponse<OverseasProcurementResponses.Detail> detail( public ApiResponse<OverseasProcurementResponses.Detail> detail(
@AuthenticationPrincipal AuthenticatedUser user, @RequestParam String customerId, @AuthenticationPrincipal AuthenticatedUser user, @RequestParam String customerId,
@PathVariable String orderNo) { @PathVariable String orderNo) {
@@ -43,6 +46,7 @@ public class PlatformOverseasProcurementController {
} }
@PostMapping("/{orderNo}/review") @PostMapping("/{orderNo}/review")
@PreAuthorize("hasAuthority('platform:lastleg:overseas-procurement:review')")
public ApiResponse<OverseasProcurementResponses.Detail> review( public ApiResponse<OverseasProcurementResponses.Detail> review(
@AuthenticationPrincipal AuthenticatedUser user, @RequestParam String customerId, @AuthenticationPrincipal AuthenticatedUser user, @RequestParam String customerId,
@PathVariable String orderNo, @Valid @RequestBody OverseasProcurementRequests.Review request) { @PathVariable String orderNo, @Valid @RequestBody OverseasProcurementRequests.Review request) {
@@ -51,6 +55,7 @@ public class PlatformOverseasProcurementController {
} }
@PatchMapping("/{orderNo}") @PatchMapping("/{orderNo}")
@PreAuthorize("hasAuthority('platform:lastleg:overseas-procurement:edit')")
public ApiResponse<OverseasProcurementResponses.Detail> update( public ApiResponse<OverseasProcurementResponses.Detail> update(
@AuthenticationPrincipal AuthenticatedUser user, @RequestParam String customerId, @AuthenticationPrincipal AuthenticatedUser user, @RequestParam String customerId,
@PathVariable String orderNo, @Valid @RequestBody OverseasProcurementRequests.Update request) { @PathVariable String orderNo, @Valid @RequestBody OverseasProcurementRequests.Update request) {
@@ -59,10 +64,11 @@ public class PlatformOverseasProcurementController {
} }
@PostMapping("/{orderNo}/outbounds") @PostMapping("/{orderNo}/outbounds")
@PreAuthorize("hasAuthority('platform:lastleg:overseas-procurement:outbound')")
public ApiResponse<OverseasProcurementResponses.Detail> addOutbound( public ApiResponse<OverseasProcurementResponses.Detail> addOutbound(
@AuthenticationPrincipal AuthenticatedUser user, @RequestParam String customerId, @AuthenticationPrincipal AuthenticatedUser user, @RequestParam String customerId,
@PathVariable String orderNo, @Valid @RequestBody OverseasProcurementRequests.AddOutbound request) { @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)); return ApiResponse.success(service.addOutbound(requiredCustomer(customerId), orderNo, operator(user), request));
} }
@@ -31,8 +31,8 @@ import java.util.Set;
@Service @Service
public class OverseasProcurementService { public class OverseasProcurementService {
public static final String REVIEW_PERMISSION = "lastleg:overseas-procurement:review"; public static final String REVIEW_PERMISSION = "platform:lastleg:overseas-procurement:review";
public static final String MANAGE_PERMISSION = "lastleg:overseas-procurement:manage"; public static final String MANAGE_PERMISSION = "platform:lastleg:overseas-procurement:edit";
public static final String PENDING_REVIEW = "PENDING_REVIEW"; public static final String PENDING_REVIEW = "PENDING_REVIEW";
public static final String APPROVED = "APPROVED"; public static final String APPROVED = "APPROVED";
public static final String REJECTED = "REJECTED"; public static final String REJECTED = "REJECTED";
@@ -10,6 +10,7 @@ import jakarta.annotation.Resource;
import jakarta.validation.Valid; import jakarta.validation.Valid;
import org.springframework.http.HttpStatus; import org.springframework.http.HttpStatus;
import org.springframework.security.core.annotation.AuthenticationPrincipal; import org.springframework.security.core.annotation.AuthenticationPrincipal;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.web.bind.annotation.*; import org.springframework.web.bind.annotation.*;
import org.springframework.web.server.ResponseStatusException; import org.springframework.web.server.ResponseStatusException;
@@ -19,21 +20,25 @@ public class RelabelApplicationController {
@Resource private RelabelApplicationService service; @Resource private RelabelApplicationService service;
@PostMapping @PostMapping
@PreAuthorize("hasAuthority('platform:lastleg:relabel:create')")
public ApiResponse<RelabelResponses.Detail> create(@AuthenticationPrincipal AuthenticatedUser user, public ApiResponse<RelabelResponses.Detail> create(@AuthenticationPrincipal AuthenticatedUser user,
@RequestParam(required = false) String customerId, @Valid @RequestBody RelabelRequests.Create request) { @RequestParam(required = false) String customerId, @Valid @RequestBody RelabelRequests.Create request) {
return ApiResponse.success(service.create(scope(user, customerId), operator(user), request)); return ApiResponse.success(service.create(scope(user, customerId), operator(user), request));
} }
@GetMapping @GetMapping
@PreAuthorize("hasAuthority('platform:lastleg:relabel:list')")
public ApiResponse<PageResult<RelabelResponses.Summary>> page(@AuthenticationPrincipal AuthenticatedUser user, public ApiResponse<PageResult<RelabelResponses.Summary>> page(@AuthenticationPrincipal AuthenticatedUser user,
@RequestParam(required = false) String customerId, @Valid RelabelRequests.Query query) { @RequestParam(required = false) String customerId, @Valid RelabelRequests.Query query) {
return ApiResponse.success(service.page(scope(user, customerId), query)); return ApiResponse.success(service.page(scope(user, customerId), query));
} }
@GetMapping("/{relabelNo}") @GetMapping("/{relabelNo}")
@PreAuthorize("hasAuthority('platform:lastleg:relabel:list')")
public ApiResponse<RelabelResponses.Detail> detail(@AuthenticationPrincipal AuthenticatedUser user, public ApiResponse<RelabelResponses.Detail> detail(@AuthenticationPrincipal AuthenticatedUser user,
@RequestParam(required = false) String customerId, @PathVariable String relabelNo) { @RequestParam(required = false) String customerId, @PathVariable String relabelNo) {
return ApiResponse.success(service.detail(scope(user, customerId), relabelNo)); return ApiResponse.success(service.detail(scope(user, customerId), relabelNo));
} }
@PutMapping("/{relabelNo}/status") @PutMapping("/{relabelNo}/status")
@PreAuthorize("hasAuthority('platform:lastleg:relabel:status')")
public ApiResponse<RelabelResponses.Detail> status(@AuthenticationPrincipal AuthenticatedUser user, public ApiResponse<RelabelResponses.Detail> status(@AuthenticationPrincipal AuthenticatedUser user,
@RequestParam(required = false) String customerId, @PathVariable String relabelNo, @RequestParam(required = false) String customerId, @PathVariable String relabelNo,
@Valid @RequestBody RelabelRequests.ChangeStatus request) { @Valid @RequestBody RelabelRequests.ChangeStatus request) {
@@ -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);
}
}
@@ -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<String> 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());
}
}
}
@@ -120,8 +120,7 @@ public class LoadingCompletedConsumer {
order.setVolumeWeightKg(source.items().stream().map(FirstLegLoadingPlanCompletedV1.Item::dimensionalWeightKg) order.setVolumeWeightKg(source.items().stream().map(FirstLegLoadingPlanCompletedV1.Item::dimensionalWeightKg)
.filter(Objects::nonNull).reduce(BigDecimal.ZERO,BigDecimal::add)); .filter(Objects::nonNull).reduce(BigDecimal.ZERO,BigDecimal::add));
order.setCurrentStatus("PENDING_ASSIGNMENT"); order.setSourceEventId(event.eventId()); order.setCurrentStatus("PENDING_ASSIGNMENT"); order.setSourceEventId(event.eventId());
order.setSourceLoadingPlanId(payload.loadingPlanId()); order.setSourceContainerBookingId(payload.containerBookingId()); order.setSourceLoadingPlanId(payload.loadingPlanId()); order.setSourceLoadingPlanVersion(event.aggregateVersion());
order.setSourceLoadingPlanVersion(event.aggregateVersion());
order.setSourceHandoverVersion(event.handoverVersion()); order.setSourcePayloadHash(orderHash); order.setSourceHandoverVersion(event.handoverVersion()); order.setSourcePayloadHash(orderHash);
order.setCreatedAt(LocalDateTime.now()); order.setUpdatedAt(LocalDateTime.now()); order.setCreatedAt(LocalDateTime.now()); order.setUpdatedAt(LocalDateTime.now());
try { try {
@@ -10,6 +10,7 @@ import jakarta.annotation.Resource;
import jakarta.validation.Valid; import jakarta.validation.Valid;
import org.springframework.http.HttpStatus; import org.springframework.http.HttpStatus;
import org.springframework.security.core.annotation.AuthenticationPrincipal; 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.GetMapping;
import org.springframework.web.bind.annotation.PatchMapping; import org.springframework.web.bind.annotation.PatchMapping;
import org.springframework.web.bind.annotation.PathVariable; import org.springframework.web.bind.annotation.PathVariable;
@@ -26,6 +27,7 @@ public class PlatformUsTruckOrderController {
@Resource private UsTruckOrderService service; @Resource private UsTruckOrderService service;
@GetMapping @GetMapping
@PreAuthorize("hasAuthority('platform:lastleg:us-trucking:list')")
public ApiResponse<PageResult<UsTruckOrderResponses.Summary>> page(@AuthenticationPrincipal AuthenticatedUser user, public ApiResponse<PageResult<UsTruckOrderResponses.Summary>> page(@AuthenticationPrincipal AuthenticatedUser user,
@Valid UsTruckOrderRequests.Query query) { @Valid UsTruckOrderRequests.Query query) {
requirePlatform(user); requirePlatform(user);
@@ -33,6 +35,7 @@ public class PlatformUsTruckOrderController {
} }
@GetMapping("/{orderNo}") @GetMapping("/{orderNo}")
@PreAuthorize("hasAuthority('platform:lastleg:us-trucking:list')")
public ApiResponse<UsTruckOrderResponses.Detail> detail(@AuthenticationPrincipal AuthenticatedUser user, public ApiResponse<UsTruckOrderResponses.Detail> detail(@AuthenticationPrincipal AuthenticatedUser user,
@RequestParam String customerId, @PathVariable String orderNo) { @RequestParam String customerId, @PathVariable String orderNo) {
requirePlatform(user); requirePlatform(user);
@@ -40,6 +43,7 @@ public class PlatformUsTruckOrderController {
} }
@PatchMapping("/{orderNo}/status") @PatchMapping("/{orderNo}/status")
@PreAuthorize("hasAuthority('platform:lastleg:us-trucking:status')")
public ApiResponse<UsTruckOrderResponses.Detail> changeStatus(@AuthenticationPrincipal AuthenticatedUser user, public ApiResponse<UsTruckOrderResponses.Detail> changeStatus(@AuthenticationPrincipal AuthenticatedUser user,
@RequestParam String customerId, @PathVariable String orderNo, @RequestParam String customerId, @PathVariable String orderNo,
@Valid @RequestBody UsTruckOrderRequests.ChangeStatus request) { @Valid @RequestBody UsTruckOrderRequests.ChangeStatus request) {
@@ -48,10 +52,11 @@ public class PlatformUsTruckOrderController {
} }
@PostMapping("/{orderNo}/receive-pallets") @PostMapping("/{orderNo}/receive-pallets")
@PreAuthorize("hasAuthority('platform:lastleg:us-trucking:receive')")
public ApiResponse<UsTruckOrderResponses.Detail> receivePallets(@AuthenticationPrincipal AuthenticatedUser user, public ApiResponse<UsTruckOrderResponses.Detail> receivePallets(@AuthenticationPrincipal AuthenticatedUser user,
@RequestParam String customerId, @PathVariable String orderNo, @RequestParam String customerId, @PathVariable String orderNo,
@Valid @RequestBody UsTruckOrderRequests.ReceivePallets request) { @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)); return ApiResponse.success(service.receivePallets(requiredCustomer(customerId), orderNo, operator(user), request));
} }
@@ -63,10 +68,14 @@ public class PlatformUsTruckOrderController {
} }
private void requirePermission(AuthenticatedUser user) { private void requirePermission(AuthenticatedUser user) {
requirePermission(user, UsTruckOrderService.MANAGE_PERMISSION);
}
private void requirePermission(AuthenticatedUser user, String permission) {
requirePlatform(user); 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, throw new ResponseStatusException(HttpStatus.FORBIDDEN,
UsTruckOrderService.MANAGE_PERMISSION + " permission is required"); permission + " permission is required");
} }
} }
@@ -35,7 +35,7 @@ import java.util.function.Consumer;
@Service @Service
public class UsTruckOrderService { 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"; public static final String PENDING_REVIEW = "PENDING_REVIEW";
/** Kept for source compatibility; new orders use PENDING_REVIEW. */ /** Kept for source compatibility; new orders use PENDING_REVIEW. */
@Deprecated public static final String PENDING_CONFIRMATION = PENDING_REVIEW; @Deprecated public static final String PENDING_CONFIRMATION = PENDING_REVIEW;
@@ -35,6 +35,9 @@ spring:
management.endpoints.web.exposure.include: health,info,prometheus management.endpoints.web.exposure.include: health,info,prometheus
baiyue: baiyue:
clients:
event-bridge:
url: ${CN_EVENT_BRIDGE_URL:http://127.0.0.1:9312}
security: security:
internal-service-token: ${INTERNAL_SERVICE_TOKEN:phase1-local-internal-token-at-least-32-bytes} internal-service-token: ${INTERNAL_SERVICE_TOKEN:phase1-local-internal-token-at-least-32-bytes}
jwt-mode: jwks jwt-mode: jwks
@@ -62,7 +62,6 @@ CREATE TABLE IF NOT EXISTS ll_order (
warehouse_remark VARCHAR(1000) COMMENT '备注(仓库)', warehouse_remark VARCHAR(1000) COMMENT '备注(仓库)',
source_event_id VARCHAR(128) NOT NULL COMMENT '来源事件 ID', source_event_id VARCHAR(128) NOT NULL COMMENT '来源事件 ID',
source_loading_plan_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_loading_plan_version BIGINT COMMENT '来源装柜计划版本',
source_handover_version BIGINT COMMENT '交接版本', source_handover_version BIGINT COMMENT '交接版本',
source_payload_hash VARCHAR(71) NOT NULL 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 对已有表不会更新表/字段注释。 -- 幂等注释迁移:CREATE TABLE IF NOT EXISTS 对已有表不会更新表/字段注释。
ALTER TABLE ll_loading_event_inbox COMMENT='头程装柜完成事件接收幂等箱'; ALTER TABLE ll_loading_event_inbox COMMENT='头程装柜完成事件接收幂等箱';
ALTER TABLE ll_order 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_order_detail COMMENT='尾程订单实装商品冻结明细';
ALTER TABLE ll_loading_order_status_history COMMENT='尾程订单状态变更历史'; ALTER TABLE ll_loading_order_status_history COMMENT='尾程订单状态变更历史';
ALTER TABLE ll_event_conflict 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 warehouse_remark VARCHAR(1000) NULL COMMENT '备注(仓库)',
MODIFY COLUMN source_event_id VARCHAR(128) NOT NULL COMMENT '来源事件 ID', 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_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_loading_plan_version BIGINT NULL COMMENT '来源装柜计划版本',
MODIFY COLUMN source_handover_version BIGINT NULL COMMENT '交接版本', MODIFY COLUMN source_handover_version BIGINT NULL COMMENT '交接版本',
MODIFY COLUMN source_payload_hash VARCHAR(71) NOT 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', 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 '重试次数', 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 '发布时间', 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), PRIMARY KEY (id), UNIQUE KEY uk_ll_event_outbox_key (event_key),
KEY idx_ll_event_outbox_publish (status, next_retry_at, created_at) KEY idx_ll_event_outbox_publish (status, next_retry_at, created_at)
) ENGINE=InnoDB COMMENT='尾程跨区业务事件发件箱'; ) ENGINE=InnoDB COMMENT='尾程跨区业务事件发件箱';
@@ -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 + "')");
}
}
@@ -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.InboundPdaOperation;
import com.baiyue.lastleg.inboundplan.pda.entity.InboundPdaTask; import com.baiyue.lastleg.inboundplan.pda.entity.InboundPdaTask;
import com.baiyue.lastleg.inboundplan.pda.gateway.WarehouseResourceGateway; 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.baiyue.lastleg.inboundplan.pda.repository.PdaWorkflowRepository;
import com.fasterxml.jackson.databind.ObjectMapper; import com.fasterxml.jackson.databind.ObjectMapper;
import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.BeforeEach;
@@ -20,6 +21,7 @@ import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension; import org.mockito.junit.jupiter.MockitoExtension;
import org.springframework.web.server.ResponseStatusException; import org.springframework.web.server.ResponseStatusException;
import org.springframework.test.util.ReflectionTestUtils; import org.springframework.test.util.ReflectionTestUtils;
import org.springframework.mock.web.MockMultipartFile;
import java.math.BigDecimal; import java.math.BigDecimal;
import java.time.LocalDateTime; 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.anyLong;
import static org.mockito.ArgumentMatchers.eq; import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.atLeastOnce; import static org.mockito.Mockito.atLeastOnce;
import static org.mockito.Mockito.lenient;
import static org.mockito.Mockito.never; import static org.mockito.Mockito.never;
import static org.mockito.Mockito.times; import static org.mockito.Mockito.times;
import static org.mockito.Mockito.verify; import static org.mockito.Mockito.verify;
@@ -44,6 +47,7 @@ class InboundPdaWorkflowServiceTest {
@Mock WarehouseResourceGateway warehouse; @Mock WarehouseResourceGateway warehouse;
@Mock InboundStockReceiptService stockReceipts; @Mock InboundStockReceiptService stockReceipts;
@Mock InboundUnloadCompletedOutboxService unloadCompletedOutbox; @Mock InboundUnloadCompletedOutboxService unloadCompletedOutbox;
@Mock UnloadPhotoFileGateway unloadPhotoFiles;
private InboundPdaWorkflowService service; private InboundPdaWorkflowService service;
private InboundPlan plan; private InboundPlan plan;
@@ -64,6 +68,7 @@ class InboundPdaWorkflowServiceTest {
ReflectionTestUtils.setField(service, "warehouse", warehouse); ReflectionTestUtils.setField(service, "warehouse", warehouse);
ReflectionTestUtils.setField(service, "stockReceipts", stockReceipts); ReflectionTestUtils.setField(service, "stockReceipts", stockReceipts);
ReflectionTestUtils.setField(service, "unloadCompletedOutbox", unloadCompletedOutbox); ReflectionTestUtils.setField(service, "unloadCompletedOutbox", unloadCompletedOutbox);
ReflectionTestUtils.setField(service, "unloadPhotoFiles", unloadPhotoFiles);
ReflectionTestUtils.setField(service, "sourceObjectMapper", new ObjectMapper().findAndRegisterModules()); ReflectionTestUtils.setField(service, "sourceObjectMapper", new ObjectMapper().findAndRegisterModules());
service.initializeObjectMapper(); service.initializeObjectMapper();
plan = plan(); plan = plan();
@@ -76,7 +81,7 @@ class InboundPdaWorkflowServiceTest {
target = allocation(31L, targetLine.getId(), targetPallet.getId(), 3, 3, 0); target = allocation(31L, targetLine.getId(), targetPallet.getId(), 3, 3, 0);
source = allocation(32L, sourceLine.getId(), sourcePallet.getId(), 2, 2, 2); source = allocation(32L, sourceLine.getId(), sourcePallet.getId(), 2, 2, 2);
otherWarehouse = allocation(33L, sourceLine.getId(), otherWarehousePallet.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 @Test
@@ -326,6 +331,8 @@ class InboundPdaWorkflowServiceTest {
assertEquals(List.of(101L, 103L), result.unloadPhotoFileIds()); assertEquals(List.of(101L, 103L), result.unloadPhotoFileIds());
assertEquals(result.unloadPhotoUploadedAt().plusDays(2), result.estimatedEmptyReturnAt()); 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); verify(repository).update(task);
ArgumentCaptor<InboundPdaOperation> operation = ArgumentCaptor.forClass(InboundPdaOperation.class); ArgumentCaptor<InboundPdaOperation> operation = ArgumentCaptor.forClass(InboundPdaOperation.class);
verify(repository).insert(operation.capture()); verify(repository).insert(operation.capture());
@@ -344,6 +351,54 @@ class InboundPdaWorkflowServiceTest {
verify(repository, never()).update(task); 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 @Test
void completesWhenMergedAllocationsPreserveExactLineTotals() { void completesWhenMergedAllocationsPreserveExactLineTotals() {
activeTask(); activeTask();
@@ -2,13 +2,12 @@ package com.baiyue.lastleg.inboundplan.pda.service;
import com.baiyue.common.event.EventEnvelope; import com.baiyue.common.event.EventEnvelope;
import com.baiyue.common.event.EventTypes; import com.baiyue.common.event.EventTypes;
import com.baiyue.common.event.OverseasContainerUnloadedV1;
import com.baiyue.common.event.RegionCode; import com.baiyue.common.event.RegionCode;
import com.baiyue.lastleg.entity.LastlegEventOutbox; import com.baiyue.lastleg.entity.LastlegEventOutbox;
import com.baiyue.lastleg.entity.LastlegOrder;
import com.baiyue.lastleg.inboundplan.entity.InboundPlan; import com.baiyue.lastleg.inboundplan.entity.InboundPlan;
import com.baiyue.lastleg.inboundplan.pda.entity.InboundPdaTask; import com.baiyue.lastleg.inboundplan.pda.entity.InboundPdaTask;
import com.baiyue.lastleg.mapper.LastlegEventOutboxMapper; import com.baiyue.lastleg.mapper.LastlegEventOutboxMapper;
import com.baiyue.lastleg.mapper.LastlegOrderMapper;
import com.fasterxml.jackson.databind.ObjectMapper; import com.fasterxml.jackson.databind.ObjectMapper;
import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test; import org.junit.jupiter.api.Test;
@@ -29,7 +28,6 @@ import static org.mockito.Mockito.when;
@ExtendWith(MockitoExtension.class) @ExtendWith(MockitoExtension.class)
class InboundUnloadCompletedOutboxServiceTest { class InboundUnloadCompletedOutboxServiceTest {
@Mock LastlegEventOutboxMapper outbox; @Mock LastlegEventOutboxMapper outbox;
@Mock LastlegOrderMapper orders;
private final ObjectMapper objectMapper = new ObjectMapper().findAndRegisterModules(); private final ObjectMapper objectMapper = new ObjectMapper().findAndRegisterModules();
private InboundUnloadCompletedOutboxService service; private InboundUnloadCompletedOutboxService service;
@@ -37,17 +35,15 @@ class InboundUnloadCompletedOutboxServiceTest {
void setUp() { void setUp() {
service = new InboundUnloadCompletedOutboxService(); service = new InboundUnloadCompletedOutboxService();
ReflectionTestUtils.setField(service, "mapper", outbox); ReflectionTestUtils.setField(service, "mapper", outbox);
ReflectionTestUtils.setField(service, "orders", orders);
ReflectionTestUtils.setField(service, "objectMapper", objectMapper); ReflectionTestUtils.setField(service, "objectMapper", objectMapper);
} }
@Test @Test
void writesAValidOverseasToCnEnvelopeUsingTheSourceBookingId() throws Exception { void writesAValidOverseasToCnEnvelopeUsingTheContainerNo() throws Exception {
InboundPlan plan = new InboundPlan(); plan.setSourceOrderId(11L); plan.setContainerNo("MSCU1"); InboundPlan plan = new InboundPlan(); plan.setContainerNo("MSCU1");
InboundPdaTask task = new InboundPdaTask(); task.setId(22L); task.setCustomerId("100"); InboundPdaTask task = new InboundPdaTask(); task.setId(22L); task.setCustomerId("100");
task.setUnloadPhotoUploadedAt(LocalDateTime.of(2026, 8, 20, 10, 0)); task.setVersion(3); task.setUnloadPhotoUploadedAt(LocalDateTime.of(2026, 8, 20, 10, 0));
LastlegOrder order = new LastlegOrder(); order.setSourceContainerBookingId("9001"); task.setCompletedAt(LocalDateTime.of(2026, 8, 20, 10, 5)); task.setVersion(3);
when(orders.selectById(11L)).thenReturn(order);
service.publish(plan, task, List.of(101L)); service.publish(plan, task, List.of(101L));
@@ -57,15 +53,10 @@ class InboundUnloadCompletedOutboxServiceTest {
assertEquals(EventTypes.OVERSEAS_CONTAINER_UNLOADED, envelope.eventType()); assertEquals(EventTypes.OVERSEAS_CONTAINER_UNLOADED, envelope.eventType());
assertEquals(RegionCode.OVERSEAS, envelope.sourceRegion()); assertEquals(RegionCode.OVERSEAS, envelope.sourceRegion());
assertEquals(RegionCode.CN, envelope.targetRegion()); assertEquals(RegionCode.CN, envelope.targetRegion());
assertEquals("9001", envelope.aggregateId()); assertEquals("MSCU1", envelope.aggregateId());
assertEquals("MSCU1", envelope.payload().path("containerNo").asText()); 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)));
}
} }
@@ -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;
}
}
@@ -4,7 +4,7 @@ import com.baiyue.common.event.*; import com.baiyue.lastleg.entity.*; import com
class LoadingCompletedConsumerTest { 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; @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)); } @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<LastlegOrder> 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<LastlegOrder> 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 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 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)); } @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)); }
+67
View File
@@ -3577,3 +3577,70 @@ SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.statistics
'SELECT 1', 'SELECT 1',
'ALTER TABLE ll_inbound_plan ADD UNIQUE KEY uk_ll_inbound_plan_source_order (customer_id,source_order_id)')); '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; 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;
@@ -17,6 +17,7 @@ import jakarta.annotation.Resource;
import jakarta.validation.Valid; import jakarta.validation.Valid;
import jakarta.validation.constraints.NotNull; import jakarta.validation.constraints.NotNull;
import org.springframework.security.core.annotation.AuthenticationPrincipal; import org.springframework.security.core.annotation.AuthenticationPrincipal;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.validation.annotation.Validated; import org.springframework.validation.annotation.Validated;
import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PatchMapping; 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 / 仓库编码已存在") @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "409", description = "Warehouse code already exists / 仓库编码已存在")
}) })
@PostMapping @PostMapping
@PreAuthorize("hasAuthority('platform:warehouse:create')")
public ApiResponse<Long> create(@AuthenticationPrincipal AuthenticatedUser user, public ApiResponse<Long> create(@AuthenticationPrincipal AuthenticatedUser user,
@Valid @RequestBody WarehouseRequests.SaveRequest request) { @Valid @RequestBody WarehouseRequests.SaveRequest request) {
requirePlatform(user); requirePlatform(user);
@@ -72,6 +74,7 @@ public class PlatformWarehouseController {
@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "409", description = "Warehouse code already exists / 仓库编码已存在") @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "409", description = "Warehouse code already exists / 仓库编码已存在")
}) })
@PutMapping("/{id}") @PutMapping("/{id}")
@PreAuthorize("hasAuthority('platform:warehouse:edit')")
public ApiResponse<Void> update( public ApiResponse<Void> update(
@AuthenticationPrincipal AuthenticatedUser user, @AuthenticationPrincipal AuthenticatedUser user,
@Parameter(description = "Target tenant ID / 目标租户 ID", example = "10001", required = true) @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 / 需要平台端权限") @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "403", description = "Platform access required / 需要平台端权限")
}) })
@GetMapping @GetMapping
@PreAuthorize("hasAuthority('platform:warehouse:list')")
public ApiResponse<PageResult<WarehouseEntity>> page( public ApiResponse<PageResult<WarehouseEntity>> page(
@AuthenticationPrincipal AuthenticatedUser user, @AuthenticationPrincipal AuthenticatedUser user,
@Parameter(description = "Target tenant ID / 目标租户 ID", example = "10001", required = true) @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 / 仓库不存在") @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "404", description = "Warehouse not found / 仓库不存在")
}) })
@GetMapping("/{id}") @GetMapping("/{id}")
@PreAuthorize("hasAuthority('platform:warehouse:list')")
public ApiResponse<WarehouseDetailResponse> detail( public ApiResponse<WarehouseDetailResponse> detail(
@AuthenticationPrincipal AuthenticatedUser user, @AuthenticationPrincipal AuthenticatedUser user,
@Parameter(description = "Target tenant ID / 目标租户 ID", example = "10001", required = true) @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 / 仓库不存在") @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "404", description = "Warehouse not found / 仓库不存在")
}) })
@PatchMapping("/{id}/status") @PatchMapping("/{id}/status")
@PreAuthorize("hasAuthority('platform:warehouse:status')")
public ApiResponse<Void> status( public ApiResponse<Void> status(
@AuthenticationPrincipal AuthenticatedUser user, @AuthenticationPrincipal AuthenticatedUser user,
@Parameter(description = "Target tenant ID / 目标租户 ID", example = "10001", required = true) @Parameter(description = "Target tenant ID / 目标租户 ID", example = "10001", required = true)
@@ -14,6 +14,7 @@ import jakarta.validation.Valid;
import jakarta.annotation.Resource; import jakarta.annotation.Resource;
import jakarta.validation.constraints.NotNull; import jakarta.validation.constraints.NotNull;
import org.springframework.security.core.annotation.AuthenticationPrincipal; import org.springframework.security.core.annotation.AuthenticationPrincipal;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.validation.annotation.Validated; import org.springframework.validation.annotation.Validated;
import org.springframework.web.bind.annotation.DeleteMapping; import org.springframework.web.bind.annotation.DeleteMapping;
import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.GetMapping;
@@ -32,6 +33,7 @@ public class PlatformWarehouseResourceController {
@Resource private WarehouseResourceService service; @Resource private WarehouseResourceService service;
@PostMapping("/locations") @PostMapping("/locations")
@PreAuthorize("hasAuthority('platform:location:create')")
public ApiResponse<Long> createLocation(@AuthenticationPrincipal AuthenticatedUser user, public ApiResponse<Long> createLocation(@AuthenticationPrincipal AuthenticatedUser user,
@RequestParam @NotNull Long customerId, @RequestParam @NotNull Long customerId,
@PathVariable long warehouseId, @PathVariable long warehouseId,
@@ -41,6 +43,7 @@ public class PlatformWarehouseResourceController {
} }
@PutMapping("/locations/{id}") @PutMapping("/locations/{id}")
@PreAuthorize("hasAuthority('platform:location:edit')")
public ApiResponse<Void> updateLocation(@AuthenticationPrincipal AuthenticatedUser user, public ApiResponse<Void> updateLocation(@AuthenticationPrincipal AuthenticatedUser user,
@RequestParam @NotNull Long customerId, @RequestParam @NotNull Long customerId,
@PathVariable long warehouseId, @PathVariable long id, @PathVariable long warehouseId, @PathVariable long id,
@@ -51,6 +54,7 @@ public class PlatformWarehouseResourceController {
} }
@GetMapping("/locations") @GetMapping("/locations")
@PreAuthorize("hasAuthority('platform:location:list')")
public ApiResponse<PageResult<WarehouseLocationEntity>> locations( public ApiResponse<PageResult<WarehouseLocationEntity>> locations(
@AuthenticationPrincipal AuthenticatedUser user, @RequestParam @NotNull Long customerId, @AuthenticationPrincipal AuthenticatedUser user, @RequestParam @NotNull Long customerId,
@PathVariable long warehouseId, @Valid LocationRequests.QueryRequest query) { @PathVariable long warehouseId, @Valid LocationRequests.QueryRequest query) {
@@ -59,6 +63,7 @@ public class PlatformWarehouseResourceController {
} }
@GetMapping("/locations/{id}") @GetMapping("/locations/{id}")
@PreAuthorize("hasAuthority('platform:location:list')")
public ApiResponse<WarehouseLocationEntity> location(@AuthenticationPrincipal AuthenticatedUser user, public ApiResponse<WarehouseLocationEntity> location(@AuthenticationPrincipal AuthenticatedUser user,
@RequestParam @NotNull Long customerId, @RequestParam @NotNull Long customerId,
@PathVariable long warehouseId, @PathVariable long warehouseId,
@@ -68,6 +73,7 @@ public class PlatformWarehouseResourceController {
} }
@DeleteMapping("/locations/{id}") @DeleteMapping("/locations/{id}")
@PreAuthorize("hasAuthority('platform:location:delete')")
public ApiResponse<Void> deleteLocation(@AuthenticationPrincipal AuthenticatedUser user, public ApiResponse<Void> deleteLocation(@AuthenticationPrincipal AuthenticatedUser user,
@RequestParam @NotNull Long customerId, @RequestParam @NotNull Long customerId,
@PathVariable long warehouseId, @PathVariable long id) { @PathVariable long warehouseId, @PathVariable long id) {
@@ -77,6 +83,7 @@ public class PlatformWarehouseResourceController {
} }
@PostMapping("/pallets") @PostMapping("/pallets")
@PreAuthorize("hasAuthority('platform:pallet:create')")
public ApiResponse<Long> createPallet(@AuthenticationPrincipal AuthenticatedUser user, public ApiResponse<Long> createPallet(@AuthenticationPrincipal AuthenticatedUser user,
@RequestParam @NotNull Long customerId, @RequestParam @NotNull Long customerId,
@PathVariable long warehouseId, @PathVariable long warehouseId,
@@ -86,6 +93,7 @@ public class PlatformWarehouseResourceController {
} }
@PutMapping("/pallets/{id}") @PutMapping("/pallets/{id}")
@PreAuthorize("hasAuthority('platform:pallet:edit')")
public ApiResponse<Void> updatePallet(@AuthenticationPrincipal AuthenticatedUser user, public ApiResponse<Void> updatePallet(@AuthenticationPrincipal AuthenticatedUser user,
@RequestParam @NotNull Long customerId, @RequestParam @NotNull Long customerId,
@PathVariable long warehouseId, @PathVariable long id, @PathVariable long warehouseId, @PathVariable long id,
@@ -96,6 +104,7 @@ public class PlatformWarehouseResourceController {
} }
@GetMapping("/pallets") @GetMapping("/pallets")
@PreAuthorize("hasAuthority('platform:pallet:list')")
public ApiResponse<PageResult<WarehousePalletEntity>> pallets( public ApiResponse<PageResult<WarehousePalletEntity>> pallets(
@AuthenticationPrincipal AuthenticatedUser user, @RequestParam @NotNull Long customerId, @AuthenticationPrincipal AuthenticatedUser user, @RequestParam @NotNull Long customerId,
@PathVariable long warehouseId, @Valid PalletRequests.QueryRequest query) { @PathVariable long warehouseId, @Valid PalletRequests.QueryRequest query) {
@@ -104,6 +113,7 @@ public class PlatformWarehouseResourceController {
} }
@GetMapping("/pallets/{id}") @GetMapping("/pallets/{id}")
@PreAuthorize("hasAuthority('platform:pallet:list')")
public ApiResponse<WarehousePalletEntity> pallet(@AuthenticationPrincipal AuthenticatedUser user, public ApiResponse<WarehousePalletEntity> pallet(@AuthenticationPrincipal AuthenticatedUser user,
@RequestParam @NotNull Long customerId, @RequestParam @NotNull Long customerId,
@PathVariable long warehouseId, @PathVariable long id) { @PathVariable long warehouseId, @PathVariable long id) {
@@ -112,6 +122,7 @@ public class PlatformWarehouseResourceController {
} }
@DeleteMapping("/pallets/{id}") @DeleteMapping("/pallets/{id}")
@PreAuthorize("hasAuthority('platform:pallet:delete')")
public ApiResponse<Void> deletePallet(@AuthenticationPrincipal AuthenticatedUser user, public ApiResponse<Void> deletePallet(@AuthenticationPrincipal AuthenticatedUser user,
@RequestParam @NotNull Long customerId, @RequestParam @NotNull Long customerId,
@PathVariable long warehouseId, @PathVariable long id) { @PathVariable long warehouseId, @PathVariable long id) {
@@ -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 + "')");
}
}