优化
This commit is contained in:
@@ -47,7 +47,7 @@
|
||||
|
||||
- 国内头程 Auth 是唯一用户账号和 Token 签发方;Admin/Client 共用一套前端和用户体系,海外仓不得另建登录或权限模型。
|
||||
- 统一使用 RS256/JWKS。国内登录/刷新时由 Auth 同时签发国内 Token 和短时海外 Token;前端统一认证层按国内/海外 API Base 自动选择。国内服务叠加 Redis 会话校验,海外服务不得访问国内 Redis,只按限定 Audience 的海外 Token Claims 完成本地认证和授权;海外业务请求不得经国内 Gateway 中转。
|
||||
- 头程与海外仓的入库单、装柜结果、费用来源和财务回传走 MQ Outbox/Inbox 事件链;事件不携带用户 JWT,服务身份由 MQ TLS/ACL 或内部服务令牌负责。跨区事件契约、幂等和死信分别查 `common-event-contract/`、`event-bridge-service/` 与目标消费者。
|
||||
- 头程装柜完成向海外建尾程单、海外 PDA 卸柜完成向国内回写货柜报空事实,以及后续费用来源和财务回传,统一走 MQ Outbox/Inbox 事件链;事件不携带用户 JWT,服务身份由 MQ TLS/ACL 或内部服务令牌负责。跨区事件契约、幂等和死信分别查 `common-event-contract/`、`event-bridge-service/` 与目标消费者。
|
||||
- 海外浏览器请求只进入 `gateway-overseas/`,由其路由到 `lastleg-service/`、`warehouse-overseas-service/` 及海外部署的公共服务;不得经国内 `gateway/` 中转。`lastleg-service` 不读取头程数据库,`warehouse-overseas-service` 不读取尾程订单库。
|
||||
|
||||
## 契约、数据库与验证入口
|
||||
|
||||
Vendored
+9
-3
@@ -1,10 +1,11 @@
|
||||
def serviceModules = [
|
||||
'gateway', 'auth', 'iam', 'firstleg', 'finance', 'order', 'warehouse', 'file', 'print',
|
||||
'integration-service', 'event-bridge-service', 'audit-service'
|
||||
'gateway', 'gateway-overseas', 'auth', 'iam', 'firstleg', 'finance', 'order', 'warehouse', 'file', 'print',
|
||||
'integration-service', 'lastleg-service', 'warehouse-overseas-service', 'event-bridge-service', 'audit-service'
|
||||
]
|
||||
|
||||
def serviceCheckboxes = [
|
||||
'gateway': 'SERVICE_GATEWAY',
|
||||
'gateway-overseas': 'SERVICE_GATEWAY_OVERSEAS',
|
||||
'auth': 'SERVICE_AUTH',
|
||||
'iam': 'SERVICE_IAM',
|
||||
'firstleg': 'SERVICE_FIRSTLEG',
|
||||
@@ -14,6 +15,8 @@ def serviceCheckboxes = [
|
||||
'file': 'SERVICE_FILE',
|
||||
'print': 'SERVICE_PRINT',
|
||||
'integration-service': 'SERVICE_INTEGRATION',
|
||||
'lastleg-service': 'SERVICE_LASTLEG',
|
||||
'warehouse-overseas-service': 'SERVICE_WAREHOUSE_OVERSEAS',
|
||||
'event-bridge-service': 'SERVICE_EVENT_BRIDGE',
|
||||
'audit-service': 'SERVICE_AUDIT'
|
||||
]
|
||||
@@ -47,6 +50,7 @@ pipeline {
|
||||
parameters {
|
||||
choice(name: 'BUILD_MODE', choices: ['AUTO', 'SELECT', 'ALL'], description: 'AUTO 按 Git 变更识别;SELECT 勾选服务;ALL 构建全部服务')
|
||||
booleanParam(name: 'SERVICE_GATEWAY', defaultValue: false, description: 'Gateway 网关服务(gateway)')
|
||||
booleanParam(name: 'SERVICE_GATEWAY_OVERSEAS', defaultValue: false, description: '海外 Gateway 网关服务(gateway-overseas)')
|
||||
booleanParam(name: 'SERVICE_AUTH', defaultValue: false, description: '统一认证服务(auth)')
|
||||
booleanParam(name: 'SERVICE_IAM', defaultValue: false, description: '租户与权限服务(iam)')
|
||||
booleanParam(name: 'SERVICE_FIRSTLEG', defaultValue: false, description: '头程履约服务(firstleg)')
|
||||
@@ -56,7 +60,9 @@ pipeline {
|
||||
booleanParam(name: 'SERVICE_FILE', defaultValue: false, description: '文件中心(file)')
|
||||
booleanParam(name: 'SERVICE_PRINT', defaultValue: false, description: '打印服务(print)')
|
||||
booleanParam(name: 'SERVICE_INTEGRATION', defaultValue: false, description: '第三方集成服务(integration-service)')
|
||||
booleanParam(name: 'SERVICE_EVENT_BRIDGE', defaultValue: false, description: '跨区域事件桥(event-bridge-service)')
|
||||
booleanParam(name: 'SERVICE_LASTLEG', defaultValue: false, description: '海外尾程与入仓服务(lastleg-service)')
|
||||
booleanParam(name: 'SERVICE_WAREHOUSE_OVERSEAS', defaultValue: false, description: '海外仓资源服务(warehouse-overseas-service)')
|
||||
booleanParam(name: 'SERVICE_EVENT_BRIDGE', defaultValue: false, description: '跨区域事件桥(event-bridge-service;由环境凭据 REGION_CODE 显式选择 CN 或 OVERSEAS 实例)')
|
||||
booleanParam(name: 'SERVICE_AUDIT', defaultValue: false, description: '审计服务(audit-service)')
|
||||
choice(name: 'ENVIRONMENT', choices: environmentChoicesForJob(env.JOB_BASE_NAME), description: '部署环境')
|
||||
booleanParam(name: 'DEPLOY', defaultValue: false, description: '各服务构建成功后是否独立部署')
|
||||
|
||||
+5
-2
@@ -1,10 +1,11 @@
|
||||
def serviceModules = [
|
||||
'gateway', 'auth', 'iam', 'firstleg', 'finance', 'order', 'warehouse', 'file', 'print',
|
||||
'integration-service', 'event-bridge-service', 'audit-service'
|
||||
'gateway', 'gateway-overseas', 'auth', 'iam', 'firstleg', 'finance', 'order', 'warehouse', 'file', 'print',
|
||||
'integration-service', 'lastleg-service', 'warehouse-overseas-service', 'event-bridge-service', 'audit-service'
|
||||
]
|
||||
|
||||
def serviceJars = [
|
||||
'gateway': 'gateway/target/gateway-1.0.0-SNAPSHOT.jar',
|
||||
'gateway-overseas': 'gateway-overseas/target/gateway-overseas-1.0.0-SNAPSHOT.jar',
|
||||
'auth': 'auth/target/auth-service-1.0.0-SNAPSHOT.jar',
|
||||
'iam': 'iam/target/iam-service-1.0.0-SNAPSHOT.jar',
|
||||
'firstleg': 'firstleg/target/firstleg-service-1.0.0-SNAPSHOT.jar',
|
||||
@@ -14,6 +15,8 @@ def serviceJars = [
|
||||
'file': 'file/target/file-service-1.0.0-SNAPSHOT.jar',
|
||||
'print': 'print/target/print-service-1.0.0-SNAPSHOT.jar',
|
||||
'integration-service': 'integration-service/target/integration-service-1.0.0-SNAPSHOT.jar',
|
||||
'lastleg-service': 'lastleg-service/target/lastleg-service-1.0.0-SNAPSHOT.jar',
|
||||
'warehouse-overseas-service': 'warehouse-overseas-service/target/warehouse-overseas-service-1.0.0-SNAPSHOT.jar',
|
||||
'event-bridge-service': 'event-bridge-service/target/event-bridge-service-1.0.0-SNAPSHOT.jar',
|
||||
'audit-service': 'audit-service/target/audit-service-1.0.0-SNAPSHOT.jar'
|
||||
]
|
||||
|
||||
@@ -46,9 +46,9 @@ public class OverseasTokenService {
|
||||
.issuer(properties.issuer()).audience(List.of(properties.audience()))
|
||||
.subject(account.getId().toString()).id(UUID.randomUUID().toString())
|
||||
.issuedAt(issuedAt).expiresAt(expiresAt)
|
||||
.claim("user_id", account.getId()).claim("tenant_id", account.getDefaultTenantId())
|
||||
.claim("user_id", account.getId()).claim("tenant_id", authorization.getTenantId())
|
||||
.claim("membership_id", authorization.getMembershipId())
|
||||
.claim("app_code", account.getDefaultAppCode()).claim("client_type", authorization.getClientType())
|
||||
.claim("app_code", authorization.getAppCode()).claim("client_type", authorization.getClientType())
|
||||
.claim("roles", authorization.getRoles())
|
||||
.claim("authorities", authorization.getPermissions())
|
||||
.claim("permission_version", authorization.getPermissionVersion() == null ? 0L : authorization.getPermissionVersion())
|
||||
|
||||
@@ -45,10 +45,11 @@ class OverseasTokenServiceTest {
|
||||
@Test
|
||||
void signsShortRs256TokenWithCrossRegionClaims() {
|
||||
AuthAccount account = new AuthAccount();
|
||||
account.setId(42L); account.setAccountStatus("ACTIVE"); account.setDefaultTenantId(100L);
|
||||
account.setId(42L); account.setAccountStatus("ACTIVE"); account.setDefaultTenantId(999L);
|
||||
account.setDefaultMembershipId(84L); account.setDefaultAppCode("MERCHANT");
|
||||
AuthorizationSnapshot snapshot = new AuthorizationSnapshot(42L, 100L, 84L, "MERCHANT", "WEB",
|
||||
Set.of("MERCHANT_ADMIN"), Set.of("merchant:order:list"), 7L);
|
||||
String authority = "platform:overseas-master-data:quote:list";
|
||||
AuthorizationSnapshot snapshot = new AuthorizationSnapshot(42L, 0L, 84L, "PLATFORM", "WEB",
|
||||
Set.of("PLATFORM_ADMIN"), Set.of(authority), 7L);
|
||||
Jwt encoded = org.mockito.Mockito.mock(Jwt.class);
|
||||
when(accounts.findById(42L)).thenReturn(account);
|
||||
when(authorizationLoader.load(account)).thenReturn(snapshot);
|
||||
@@ -64,12 +65,12 @@ class OverseasTokenServiceTest {
|
||||
assertThat(parameters.getJwsHeader().getKeyId()).isEqualTo("current-kid");
|
||||
assertThat(parameters.getClaims().getAudience()).containsExactly("lastleg-api");
|
||||
assertThat(parameters.getClaims().getClaims())
|
||||
.containsEntry("user_id", 42L).containsEntry("tenant_id", 100L)
|
||||
.containsEntry("user_id", 42L).containsEntry("tenant_id", 0L)
|
||||
.containsEntry("membership_id", 84L)
|
||||
.containsEntry("app_code", "MERCHANT").containsEntry("client_type", "WEB")
|
||||
.containsEntry("app_code", "PLATFORM").containsEntry("client_type", "WEB")
|
||||
.containsEntry("permission_version", 7L)
|
||||
.containsEntry("roles", Set.of("MERCHANT_ADMIN"))
|
||||
.containsEntry("authorities", Set.of("merchant:order:list"))
|
||||
.containsEntry("roles", Set.of("PLATFORM_ADMIN"))
|
||||
.containsEntry("authorities", Set.of(authority))
|
||||
.doesNotContainKeys("userId", "tenantId", "appCode", "permissionVersion");
|
||||
assertThat(response.expiresIn()).isEqualTo(900);
|
||||
}
|
||||
|
||||
@@ -14,13 +14,15 @@
|
||||
| 事件 ID 与业务键 | `EventIdentifiers.java` |
|
||||
| Payload 规范化与哈希 | `EventPayloadCanonicalizer.java`、`EventPayloadHash.java` |
|
||||
| 事件类型与区域 | `EventTypes.java`、`RegionCode.java` |
|
||||
| 头程装柜完成 V1 | `FirstLegLoadingPlanCompletedV1.java`;国内生产者见 `../firstleg/service/LoadingCompletedOutboxService.java` |
|
||||
| 头程装柜完成 V1 | `FirstLegLoadingPlanCompletedV1.java`;国内生产者见 `../firstleg/src/main/java/com/baiyue/firstleg/service/LoadingCompletedOutboxService.java` |
|
||||
| 海外卸柜完成 V1 | `OverseasContainerUnloadedV1.java`;海外生产者见 `../lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/service/InboundUnloadCompletedOutboxService.java`,国内消费者见 `../firstleg/src/main/java/com/baiyue/firstleg/service/OverseasContainerEventConsumer.java` |
|
||||
| 契约说明 | `package-info.java` |
|
||||
| 契约测试 | `src/test/java/com/baiyue/common/event/EventEnvelopeTest.java`、`FirstLegLoadingPlanCompletedV1Test.java` |
|
||||
| 契约测试 | `src/test/java/com/baiyue/common/event/EventEnvelopeTest.java`、`FirstLegLoadingPlanCompletedV1Test.java`、`OverseasContainerUnloadedV1Test.java` |
|
||||
|
||||
## 修改与验证
|
||||
|
||||
- 所有字段与事件类型都视为跨仓公共契约;当前开发阶段不保留旧事件字段、旧版本或兼容解析,目标契约变化时直接修改当前模型,并同步更新 `event-bridge-service/`、国内生产者、尾程消费者和全部契约测试。
|
||||
- 哈希算法或规范化规则变化必须同步更新全部生产者和消费者,并保留跨语言确定性测试。
|
||||
- MQ 事件不携带用户 Access Token;生产者和消费者使用 MQ TLS/ACL 或内部服务身份,事件信封负责事件 ID、业务键、来源/目标区域、版本和 Payload 哈希。
|
||||
- `OVERSEAS_CONTAINER_UNLOADED` 固定从 `OVERSEAS` 发往 `CN`,用于回传海外 PDA 卸柜完成及卸柜照片上传时间;预计报空时间由国内头程按照片上传时间加两天计算,实际报空时间允许后续补充。
|
||||
- 从父目录运行 `mvn -pl common-event-contract -am test`。
|
||||
|
||||
+2
-1
@@ -2,8 +2,9 @@ package com.baiyue.common.event;
|
||||
|
||||
import java.time.Instant;
|
||||
|
||||
/** 海外仓卸柜完成事实;预计报空时间由头程按照片上传时间加两天计算。 */
|
||||
/** 海外仓卸柜完成事实;国内订舱 ID 是稳定聚合标识,containerNo 仅用于核对物理柜号。 */
|
||||
public record OverseasContainerUnloadedV1(
|
||||
String sourceContainerBookingId,
|
||||
String containerNo,
|
||||
Instant unloadingCompletedAt,
|
||||
Instant unloadingPhotoUploadedAt,
|
||||
|
||||
+2
-2
@@ -13,12 +13,12 @@ class OverseasContainerUnloadedV1Test {
|
||||
|
||||
@Test
|
||||
void payloadRoundTripsWithStableContainerIdAndUtcFacts() throws Exception {
|
||||
var payload = new OverseasContainerUnloadedV1("MSCU1234567",
|
||||
var payload = new OverseasContainerUnloadedV1("1001", "MSCU1234567",
|
||||
Instant.parse("2026-08-20T03:00:00Z"), Instant.parse("2026-08-20T02:30:00Z"), null);
|
||||
|
||||
String json = mapper.writeValueAsString(payload);
|
||||
|
||||
assertThat(mapper.readValue(json, OverseasContainerUnloadedV1.class)).isEqualTo(payload);
|
||||
assertThat(json).contains("MSCU1234567", "unloadingPhotoUploadedAt");
|
||||
assertThat(json).contains("sourceContainerBookingId", "1001", "MSCU1234567", "unloadingPhotoUploadedAt");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,3 +8,5 @@ baiyue:
|
||||
region: ${REGION_CODE:CN}
|
||||
deployment-cell: ${DEPLOYMENT_CELL:prod-cn}
|
||||
data-residency: ${DATA_RESIDENCY:CN}
|
||||
event-dispatcher:
|
||||
consumer-url: ${EVENT_CONSUMER_URL:http://firstleg:9202/internal/firstleg/v1/container-events}
|
||||
|
||||
@@ -7,6 +7,13 @@ spring:
|
||||
baiyue.clients.warehouse.url: ${WAREHOUSE_SERVICE_URL:}
|
||||
baiyue.clients.order.url: ${ORDER_SERVICE_URL:}
|
||||
baiyue.clients.file.url: ${FILE_SERVICE_URL:}
|
||||
baiyue:
|
||||
lastleg:
|
||||
event-bridge-url: ${LASTLEG_EVENT_BRIDGE_URL}
|
||||
outbox-relay:
|
||||
fixed-delay-ms: ${LASTLEG_OUTBOX_RELAY_FIXED_DELAY_MS:5000}
|
||||
lease-seconds: ${LASTLEG_OUTBOX_RELAY_LEASE_SECONDS:60}
|
||||
retry-seconds: ${LASTLEG_OUTBOX_RELAY_RETRY_SECONDS:30}
|
||||
firstleg:
|
||||
inbound:
|
||||
allow-rejected-measurement: false
|
||||
|
||||
@@ -9,7 +9,7 @@ baiyue:
|
||||
deployment-cell: ${DEPLOYMENT_CELL:prod-overseas}
|
||||
data-residency: ${DATA_RESIDENCY:OVERSEAS}
|
||||
event-dispatcher:
|
||||
consumer-url: ${LASTLEG_CONSUMER_URL:http://lastleg-service:9303/internal/lastleg/events}
|
||||
consumer-url: ${EVENT_CONSUMER_URL:http://lastleg-service:9303/internal/lastleg/events}
|
||||
fixed-delay: ${EVENT_DISPATCH_FIXED_DELAY:3000}
|
||||
batch-size: ${EVENT_DISPATCH_BATCH_SIZE:50}
|
||||
max-attempts: ${EVENT_DISPATCH_MAX_ATTEMPTS:10}
|
||||
|
||||
@@ -4,3 +4,16 @@ spring:
|
||||
username: ${LASTLEG_DB_USERNAME}
|
||||
password: ${LASTLEG_DB_PASSWORD}
|
||||
driver-class-name: com.mysql.cj.jdbc.Driver
|
||||
baiyue:
|
||||
clients:
|
||||
domestic-warehouse:
|
||||
url: ${DOMESTIC_WAREHOUSE_URL}
|
||||
cache-ttl: ${DOMESTIC_WAREHOUSE_CACHE_TTL:30m}
|
||||
warehouse:
|
||||
base-url: ${WAREHOUSE_OVERSEAS_SERVICE_URL:http://warehouse-overseas-service:9305}
|
||||
firstleg:
|
||||
event-bridge-url: ${FIRSTLEG_EVENT_BRIDGE_URL}
|
||||
outbox-relay:
|
||||
fixed-delay-ms: ${FIRSTLEG_OUTBOX_RELAY_FIXED_DELAY_MS:5000}
|
||||
lease-seconds: ${FIRSTLEG_OUTBOX_RELAY_LEASE_SECONDS:60}
|
||||
retry-seconds: ${FIRSTLEG_OUTBOX_RELAY_RETRY_SECONDS:30}
|
||||
|
||||
@@ -0,0 +1,97 @@
|
||||
name: firstleg-${DEPLOY_ENV:-test}-overseas
|
||||
|
||||
x-overseas-common: &overseas-common
|
||||
restart: unless-stopped
|
||||
networks: [firstleg-overseas-network, infra-network]
|
||||
extra_hosts:
|
||||
- "host.docker.internal:host-gateway"
|
||||
environment: &overseas-environment
|
||||
SPRING_PROFILES_ACTIVE: overseas
|
||||
NACOS_SERVER_ADDR: ${NACOS_SERVER_ADDR}
|
||||
NACOS_USERNAME: ${NACOS_USERNAME}
|
||||
NACOS_PASSWORD: ${NACOS_PASSWORD}
|
||||
NACOS_GROUP: ${NACOS_GROUP:-BAIYUE}
|
||||
NACOS_NAMESPACE: ${OVERSEAS_NACOS_NAMESPACE:-prod-overseas}
|
||||
REDIS_HOST: ${OVERSEAS_REDIS_HOST:-host.docker.internal}
|
||||
REDIS_PORT: ${OVERSEAS_REDIS_PORT:-6379}
|
||||
REDIS_PASSWORD: ${OVERSEAS_REDIS_PASSWORD:-}
|
||||
REDIS_DATABASE: ${OVERSEAS_REDIS_DATABASE:-0}
|
||||
INTERNAL_SERVICE_TOKEN: ${INTERNAL_SERVICE_TOKEN}
|
||||
JWT_ISSUER: ${JWT_ISSUER}
|
||||
JWT_AUDIENCE: ${OVERSEAS_JWT_AUDIENCE:-lastleg-api}
|
||||
JWT_JWK_SET_URI: ${JWT_JWK_SET_URI}
|
||||
REGION_CODE: OVERSEAS
|
||||
DEPLOYMENT_CELL: ${OVERSEAS_DEPLOYMENT_CELL:-test-overseas}
|
||||
DATA_RESIDENCY: OVERSEAS
|
||||
SPRING_SQL_INIT_MODE: never
|
||||
LOG_PATH: /app/logs
|
||||
TZ: Asia/Shanghai
|
||||
logging:
|
||||
driver: json-file
|
||||
options:
|
||||
max-size: 100m
|
||||
max-file: "5"
|
||||
|
||||
services:
|
||||
gateway-overseas:
|
||||
<<: *overseas-common
|
||||
image: ${IMAGE_REPOSITORY}/gateway-overseas:${IMAGE_TAG}
|
||||
container_name: firstleg-${DEPLOY_ENV:-test}-gateway-overseas
|
||||
ports: ["${GATEWAY_OVERSEAS_PORT:-9300}:9300"]
|
||||
volumes: ["${LOG_ROOT:-/data/logs/baiyue-firstleg}/gateway-overseas:/app/logs"]
|
||||
environment:
|
||||
<<: *overseas-environment
|
||||
SERVER_PORT: 9300
|
||||
SWAGGER_ENABLED: ${SWAGGER_ENABLED:-false}
|
||||
CORS_ALLOWED_ORIGINS: ${OVERSEAS_CORS_ALLOWED_ORIGINS:-}
|
||||
CORS_ALLOW_CREDENTIALS: ${CORS_ALLOW_CREDENTIALS:-false}
|
||||
|
||||
lastleg-service:
|
||||
<<: *overseas-common
|
||||
image: ${IMAGE_REPOSITORY}/lastleg-service:${IMAGE_TAG}
|
||||
container_name: firstleg-${DEPLOY_ENV:-test}-lastleg
|
||||
ports: ["${LASTLEG_PORT:-9303}:9303"]
|
||||
volumes: ["${LOG_ROOT:-/data/logs/baiyue-firstleg}/lastleg-service:/app/logs"]
|
||||
environment:
|
||||
<<: *overseas-environment
|
||||
SERVER_PORT: 9303
|
||||
LASTLEG_DB_URL: ${LASTLEG_DB_URL}
|
||||
LASTLEG_DB_USERNAME: ${LASTLEG_DB_USERNAME}
|
||||
LASTLEG_DB_PASSWORD: ${LASTLEG_DB_PASSWORD}
|
||||
FIRSTLEG_EVENT_BRIDGE_URL: ${FIRSTLEG_EVENT_BRIDGE_URL}
|
||||
DOMESTIC_WAREHOUSE_URL: ${DOMESTIC_WAREHOUSE_URL}
|
||||
WAREHOUSE_OVERSEAS_SERVICE_URL: ${WAREHOUSE_OVERSEAS_SERVICE_URL:-http://warehouse-overseas-service:9305}
|
||||
|
||||
warehouse-overseas-service:
|
||||
<<: *overseas-common
|
||||
image: ${IMAGE_REPOSITORY}/warehouse-overseas-service:${IMAGE_TAG}
|
||||
container_name: firstleg-${DEPLOY_ENV:-test}-warehouse-overseas
|
||||
ports: ["${WAREHOUSE_OVERSEAS_PORT:-9305}:9305"]
|
||||
volumes: ["${LOG_ROOT:-/data/logs/baiyue-firstleg}/warehouse-overseas-service:/app/logs"]
|
||||
environment:
|
||||
<<: *overseas-environment
|
||||
SERVER_PORT: 9305
|
||||
WAREHOUSE_DB_URL: ${WAREHOUSE_OVERSEAS_DB_URL}
|
||||
WAREHOUSE_DB_USERNAME: ${WAREHOUSE_OVERSEAS_DB_USERNAME}
|
||||
WAREHOUSE_DB_PASSWORD: ${WAREHOUSE_OVERSEAS_DB_PASSWORD}
|
||||
|
||||
event-bridge-service:
|
||||
<<: *overseas-common
|
||||
image: ${IMAGE_REPOSITORY}/event-bridge-service:${IMAGE_TAG}
|
||||
container_name: firstleg-${DEPLOY_ENV:-test}-event-bridge-overseas
|
||||
ports: ["${EVENT_BRIDGE_OVERSEAS_PORT:-9313}:9312"]
|
||||
volumes: ["${LOG_ROOT:-/data/logs/baiyue-firstleg}/event-bridge-service-overseas:/app/logs"]
|
||||
environment:
|
||||
<<: *overseas-environment
|
||||
SERVER_PORT: 9312
|
||||
EVENT_BRIDGE_DB_URL: ${EVENT_BRIDGE_OVERSEAS_DB_URL}
|
||||
EVENT_BRIDGE_DB_USERNAME: ${EVENT_BRIDGE_OVERSEAS_DB_USERNAME}
|
||||
EVENT_BRIDGE_DB_PASSWORD: ${EVENT_BRIDGE_OVERSEAS_DB_PASSWORD}
|
||||
EVENT_CONSUMER_URL: ${EVENT_CONSUMER_URL:-http://lastleg-service:9303/internal/lastleg/events}
|
||||
|
||||
networks:
|
||||
firstleg-overseas-network:
|
||||
name: firstleg-${DEPLOY_ENV:-test}-overseas-network
|
||||
infra-network:
|
||||
external: true
|
||||
name: ${INFRA_NETWORK:-firstleg-test-infra-network}
|
||||
@@ -86,6 +86,7 @@ services:
|
||||
FIRSTLEG_DB_URL: ${FIRSTLEG_DB_URL}
|
||||
FIRSTLEG_DB_USERNAME: ${FIRSTLEG_DB_USERNAME}
|
||||
FIRSTLEG_DB_PASSWORD: ${FIRSTLEG_DB_PASSWORD}
|
||||
LASTLEG_EVENT_BRIDGE_URL: ${LASTLEG_EVENT_BRIDGE_URL}
|
||||
|
||||
finance:
|
||||
<<: *common
|
||||
@@ -185,14 +186,16 @@ services:
|
||||
<<: *common
|
||||
image: ${IMAGE_REPOSITORY}/event-bridge-service:${IMAGE_TAG}
|
||||
container_name: firstleg-${DEPLOY_ENV:-test}-event-bridge
|
||||
ports: ["${EVENT_BRIDGE_PORT:-9300}:9300"]
|
||||
ports: ["${EVENT_BRIDGE_PORT:-9312}:9312"]
|
||||
volumes: ["${LOG_ROOT:-/data/logs/baiyue-firstleg}/event-bridge-service:/app/logs"]
|
||||
environment:
|
||||
<<: *common-environment
|
||||
SERVER_PORT: 9300
|
||||
SERVER_PORT: 9312
|
||||
EVENT_BRIDGE_DB_URL: ${EVENT_BRIDGE_DB_URL}
|
||||
EVENT_BRIDGE_DB_USERNAME: ${EVENT_BRIDGE_DB_USERNAME}
|
||||
EVENT_BRIDGE_DB_PASSWORD: ${EVENT_BRIDGE_DB_PASSWORD}
|
||||
EVENT_CONSUMER_URL: ${EVENT_CONSUMER_URL:-http://firstleg:9202/internal/firstleg/v1/container-events}
|
||||
SPRING_SQL_INIT_MODE: never
|
||||
|
||||
audit-service:
|
||||
<<: *common
|
||||
|
||||
@@ -30,12 +30,10 @@ case "$SERVICE" in
|
||||
;;
|
||||
esac
|
||||
|
||||
for file in "$ENV_FILE" "$COMPOSE_FILE"; do
|
||||
if [ ! -f "$file" ]; then
|
||||
echo "必需文件不存在: $file" >&2
|
||||
exit 2
|
||||
fi
|
||||
done
|
||||
if [ ! -f "$ENV_FILE" ]; then
|
||||
echo "必需文件不存在: $ENV_FILE" >&2
|
||||
exit 2
|
||||
fi
|
||||
|
||||
env_file_has_key() {
|
||||
grep -q "^$1=" "$ENV_FILE"
|
||||
@@ -45,6 +43,37 @@ env_file_value() {
|
||||
sed -n "s/^$1=//p" "$ENV_FILE" | tail -n 1 | tr -d '\r'
|
||||
}
|
||||
|
||||
case "$SERVICE" in
|
||||
gateway-overseas|lastleg-service|warehouse-overseas-service)
|
||||
COMPOSE_FILE="deploy/docker-compose.overseas.yml"
|
||||
PROJECT_NAME="firstleg-$DEPLOY_ENV-overseas"
|
||||
;;
|
||||
event-bridge-service)
|
||||
if ! env_file_has_key REGION_CODE; then
|
||||
echo "部署 event-bridge-service 时,环境文件必须显式包含 REGION_CODE=CN 或 REGION_CODE=OVERSEAS" >&2
|
||||
exit 2
|
||||
fi
|
||||
case "$(env_file_value REGION_CODE)" in
|
||||
CN) ;;
|
||||
OVERSEAS)
|
||||
COMPOSE_FILE="deploy/docker-compose.overseas.yml"
|
||||
PROJECT_NAME="firstleg-$DEPLOY_ENV-overseas"
|
||||
;;
|
||||
*)
|
||||
echo "部署 event-bridge-service 时,REGION_CODE 只能为 CN 或 OVERSEAS" >&2
|
||||
exit 2
|
||||
;;
|
||||
esac
|
||||
;;
|
||||
esac
|
||||
|
||||
for file in "$ENV_FILE" "$COMPOSE_FILE"; do
|
||||
if [ ! -f "$file" ]; then
|
||||
echo "必需文件不存在: $file" >&2
|
||||
exit 2
|
||||
fi
|
||||
done
|
||||
|
||||
if [ "$SERVICE" = "finance" ]; then
|
||||
for key in FINANCE_DB_URL FINANCE_DB_USERNAME FINANCE_DB_PASSWORD; do
|
||||
if ! env_file_has_key "$key" || [ -z "$(env_file_value "$key")" ]; then
|
||||
@@ -54,6 +83,56 @@ if [ "$SERVICE" = "finance" ]; then
|
||||
done
|
||||
fi
|
||||
|
||||
case "$SERVICE" in
|
||||
gateway-overseas|lastleg-service|warehouse-overseas-service)
|
||||
for key in NACOS_SERVER_ADDR OVERSEAS_NACOS_NAMESPACE INTERNAL_SERVICE_TOKEN JWT_ISSUER OVERSEAS_JWT_AUDIENCE JWT_JWK_SET_URI; do
|
||||
if ! env_file_has_key "$key" || [ -z "$(env_file_value "$key")" ]; then
|
||||
echo "部署海外服务时,环境文件必须包含非空 $key" >&2
|
||||
exit 2
|
||||
fi
|
||||
done
|
||||
;;
|
||||
esac
|
||||
|
||||
if [ "$SERVICE" = "firstleg" ]; then
|
||||
if ! env_file_has_key LASTLEG_EVENT_BRIDGE_URL || [ -z "$(env_file_value LASTLEG_EVENT_BRIDGE_URL)" ]; then
|
||||
echo "部署 firstleg 时,环境文件必须包含非空 LASTLEG_EVENT_BRIDGE_URL" >&2
|
||||
exit 2
|
||||
fi
|
||||
fi
|
||||
|
||||
if [ "$SERVICE" = "lastleg-service" ]; then
|
||||
for key in LASTLEG_DB_URL LASTLEG_DB_USERNAME LASTLEG_DB_PASSWORD FIRSTLEG_EVENT_BRIDGE_URL DOMESTIC_WAREHOUSE_URL; do
|
||||
if ! env_file_has_key "$key" || [ -z "$(env_file_value "$key")" ]; then
|
||||
echo "部署 lastleg-service 时,环境文件必须包含非空 $key" >&2
|
||||
exit 2
|
||||
fi
|
||||
done
|
||||
fi
|
||||
|
||||
if [ "$SERVICE" = "warehouse-overseas-service" ]; then
|
||||
for key in WAREHOUSE_OVERSEAS_DB_URL WAREHOUSE_OVERSEAS_DB_USERNAME WAREHOUSE_OVERSEAS_DB_PASSWORD; do
|
||||
if ! env_file_has_key "$key" || [ -z "$(env_file_value "$key")" ]; then
|
||||
echo "部署 warehouse-overseas-service 时,环境文件必须包含非空 $key" >&2
|
||||
exit 2
|
||||
fi
|
||||
done
|
||||
fi
|
||||
|
||||
if [ "$SERVICE" = "event-bridge-service" ]; then
|
||||
if [ "$PROJECT_NAME" = "firstleg-$DEPLOY_ENV-overseas" ]; then
|
||||
EVENT_BRIDGE_KEYS="EVENT_BRIDGE_OVERSEAS_DB_URL EVENT_BRIDGE_OVERSEAS_DB_USERNAME EVENT_BRIDGE_OVERSEAS_DB_PASSWORD EVENT_CONSUMER_URL"
|
||||
else
|
||||
EVENT_BRIDGE_KEYS="EVENT_BRIDGE_DB_URL EVENT_BRIDGE_DB_USERNAME EVENT_BRIDGE_DB_PASSWORD EVENT_CONSUMER_URL"
|
||||
fi
|
||||
for key in $EVENT_BRIDGE_KEYS; do
|
||||
if ! env_file_has_key "$key" || [ -z "$(env_file_value "$key")" ]; then
|
||||
echo "部署 event-bridge-service 时,环境文件必须包含非空 $key" >&2
|
||||
exit 2
|
||||
fi
|
||||
done
|
||||
fi
|
||||
|
||||
if [ "$SERVICE" = "file" ]; then
|
||||
if ! env_file_has_key ROCKETMQ_NAME_SERVER || [ -z "$(env_file_value ROCKETMQ_NAME_SERVER)" ]; then
|
||||
echo "部署 file 服务时,环境文件必须包含非空 ROCKETMQ_NAME_SERVER" >&2
|
||||
@@ -149,24 +228,25 @@ REMOTE_PREPARE
|
||||
echo "传输镜像到 $SSH_TARGET: $IMAGE"
|
||||
docker save "$IMAGE" | gzip -1 | run_ssh 'gzip -dc | docker load'
|
||||
|
||||
run_ssh sh -s -- "$REMOTE_DIR" "$DEPLOY_ENV" "$IMAGE_REPOSITORY" "$IMAGE_TAG" "$SERVICE" <<'REMOTE_DEPLOY'
|
||||
run_ssh sh -s -- "$REMOTE_DIR" "$DEPLOY_ENV" "$IMAGE_REPOSITORY" "$IMAGE_TAG" "$SERVICE" "$PROJECT_NAME" <<'REMOTE_DEPLOY'
|
||||
set -eu
|
||||
remote_dir="$1"
|
||||
deploy_env="$2"
|
||||
image_repository="$3"
|
||||
image_tag="$4"
|
||||
service="$5"
|
||||
project_name="$6"
|
||||
cd "$remote_dir"
|
||||
export DEPLOY_ENV="$deploy_env"
|
||||
export IMAGE_REPOSITORY="$image_repository"
|
||||
export IMAGE_TAG="$image_tag"
|
||||
docker compose \
|
||||
--project-name "firstleg-$deploy_env" \
|
||||
--project-name "$project_name" \
|
||||
--env-file .env \
|
||||
-f docker-compose.yml \
|
||||
up -d --remove-orphans --pull never "$service"
|
||||
docker compose \
|
||||
--project-name "firstleg-$deploy_env" \
|
||||
--project-name "$project_name" \
|
||||
--env-file .env \
|
||||
-f docker-compose.yml \
|
||||
ps "$service"
|
||||
|
||||
@@ -15,7 +15,8 @@ SSH_TARGET="${DEVELOPMENT_SSH_TARGET:-deploy@172.31.198.8}"
|
||||
SSH_KEY="${DEVELOPMENT_SSH_KEY:-/var/jenkins_home/.ssh/dev_deploy}"
|
||||
SSH_KNOWN_HOSTS="${DEVELOPMENT_SSH_KNOWN_HOSTS:-/var/jenkins_home/.ssh/known_hosts}"
|
||||
REMOTE_DIR="${DEVELOPMENT_DEPLOY_DIR:-/opt/firstleg/application}"
|
||||
COMPOSE_FILE="deploy/docker-compose.yml"
|
||||
COMPOSE_FILE="${DEPLOY_COMPOSE_FILE:-deploy/docker-compose.yml}"
|
||||
PROJECT_NAME="${DEPLOY_PROJECT_NAME:-firstleg-development}"
|
||||
|
||||
if [ ! -f "$SSH_KEY" ]; then
|
||||
echo "开发环境 SSH 私钥不存在: $SSH_KEY" >&2
|
||||
@@ -65,19 +66,19 @@ if [ "${DEVELOPMENT_DEPLOY_LOCAL:-false}" = "true" ]; then
|
||||
export IMAGE_REPOSITORY IMAGE_TAG
|
||||
|
||||
docker compose \
|
||||
--project-name firstleg-development \
|
||||
--project-name "$PROJECT_NAME" \
|
||||
--env-file .env \
|
||||
-f docker-compose.yml \
|
||||
pull $SERVICES
|
||||
|
||||
docker compose \
|
||||
--project-name firstleg-development \
|
||||
--project-name "$PROJECT_NAME" \
|
||||
--env-file .env \
|
||||
-f docker-compose.yml \
|
||||
up -d --remove-orphans --pull never $SERVICES
|
||||
|
||||
docker compose \
|
||||
--project-name firstleg-development \
|
||||
--project-name "$PROJECT_NAME" \
|
||||
--env-file .env \
|
||||
-f docker-compose.yml \
|
||||
ps $SERVICES
|
||||
@@ -129,12 +130,13 @@ for service in $SERVICES; do
|
||||
docker save "$image" | gzip -1 | run_ssh 'gzip -dc | docker load'
|
||||
done
|
||||
|
||||
run_ssh sh -s -- "$REMOTE_DIR" "$IMAGE_REPOSITORY" "$IMAGE_TAG" "$SERVICE_CSV" <<'REMOTE_DEPLOY'
|
||||
run_ssh sh -s -- "$REMOTE_DIR" "$IMAGE_REPOSITORY" "$IMAGE_TAG" "$SERVICE_CSV" "$PROJECT_NAME" <<'REMOTE_DEPLOY'
|
||||
set -eu
|
||||
remote_dir="$1"
|
||||
image_repository="$2"
|
||||
image_tag="$3"
|
||||
service_csv="$4"
|
||||
project_name="$5"
|
||||
services=$(printf '%s' "$service_csv" | tr ',' ' ')
|
||||
|
||||
cd "$remote_dir"
|
||||
@@ -143,13 +145,13 @@ export IMAGE_REPOSITORY="$image_repository"
|
||||
export IMAGE_TAG="$image_tag"
|
||||
|
||||
docker compose \
|
||||
--project-name firstleg-development \
|
||||
--project-name "$project_name" \
|
||||
--env-file .env \
|
||||
-f docker-compose.yml \
|
||||
up -d --remove-orphans --pull never $services
|
||||
|
||||
docker compose \
|
||||
--project-name firstleg-development \
|
||||
--project-name "$project_name" \
|
||||
--env-file .env \
|
||||
-f docker-compose.yml \
|
||||
ps $services
|
||||
|
||||
@@ -19,6 +19,15 @@ fi
|
||||
|
||||
SERVICES=$(printf '%s' "$SERVICE_CSV" | tr ',' ' ')
|
||||
|
||||
for service in $SERVICES; do
|
||||
case "$service" in
|
||||
*[!A-Za-z0-9_-]*|'')
|
||||
echo "非法服务名: $service" >&2
|
||||
exit 2
|
||||
;;
|
||||
esac
|
||||
done
|
||||
|
||||
env_file_has_key() {
|
||||
grep -q "^$1=" "$ENV_FILE"
|
||||
}
|
||||
@@ -27,6 +36,46 @@ env_file_value() {
|
||||
sed -n "s/^$1=//p" "$ENV_FILE" | tail -n 1 | tr -d '\r'
|
||||
}
|
||||
|
||||
DEPLOY_REGION=CN
|
||||
for service in $SERVICES; do
|
||||
case "$service" in
|
||||
gateway-overseas|lastleg-service|warehouse-overseas-service) DEPLOY_REGION=OVERSEAS ;;
|
||||
event-bridge-service)
|
||||
if ! env_file_has_key REGION_CODE; then
|
||||
echo "部署 event-bridge-service 时,环境文件必须显式包含 REGION_CODE=CN 或 REGION_CODE=OVERSEAS" >&2
|
||||
exit 2
|
||||
fi
|
||||
case "$(env_file_value REGION_CODE)" in
|
||||
CN) ;;
|
||||
OVERSEAS) DEPLOY_REGION=OVERSEAS ;;
|
||||
*) echo "部署 event-bridge-service 时,REGION_CODE 只能为 CN 或 OVERSEAS" >&2; exit 2 ;;
|
||||
esac
|
||||
;;
|
||||
esac
|
||||
done
|
||||
if [ "$DEPLOY_REGION" = "OVERSEAS" ]; then
|
||||
for service in $SERVICES; do
|
||||
case "$service" in
|
||||
gateway-overseas|lastleg-service|warehouse-overseas-service|event-bridge-service) ;;
|
||||
*) echo "一次部署不得混合国内与海外服务: $SERVICE_CSV" >&2; exit 2 ;;
|
||||
esac
|
||||
done
|
||||
COMPOSE_FILE="deploy/docker-compose.overseas.yml"
|
||||
PROJECT_NAME="firstleg-${DEPLOY_ENV}-overseas"
|
||||
else
|
||||
COMPOSE_FILE="deploy/docker-compose.yml"
|
||||
PROJECT_NAME="firstleg-${DEPLOY_ENV}"
|
||||
fi
|
||||
|
||||
if [ "$DEPLOY_REGION" = "OVERSEAS" ]; then
|
||||
for key in NACOS_SERVER_ADDR OVERSEAS_NACOS_NAMESPACE INTERNAL_SERVICE_TOKEN JWT_ISSUER OVERSEAS_JWT_AUDIENCE JWT_JWK_SET_URI; do
|
||||
if ! env_file_has_key "$key" || [ -z "$(env_file_value "$key")" ]; then
|
||||
echo "部署海外服务时,环境文件必须包含非空 $key" >&2
|
||||
exit 2
|
||||
fi
|
||||
done
|
||||
fi
|
||||
|
||||
case " $SERVICES " in
|
||||
*" finance "*)
|
||||
for key in FINANCE_DB_URL FINANCE_DB_USERNAME FINANCE_DB_PASSWORD; do
|
||||
@@ -38,6 +87,53 @@ case " $SERVICES " in
|
||||
;;
|
||||
esac
|
||||
|
||||
case " $SERVICES " in
|
||||
*" firstleg "*)
|
||||
if ! env_file_has_key LASTLEG_EVENT_BRIDGE_URL || [ -z "$(env_file_value LASTLEG_EVENT_BRIDGE_URL)" ]; then
|
||||
echo "部署 firstleg 时,环境文件必须包含非空 LASTLEG_EVENT_BRIDGE_URL" >&2
|
||||
exit 2
|
||||
fi
|
||||
;;
|
||||
esac
|
||||
|
||||
case " $SERVICES " in
|
||||
*" lastleg-service "*)
|
||||
for key in LASTLEG_DB_URL LASTLEG_DB_USERNAME LASTLEG_DB_PASSWORD FIRSTLEG_EVENT_BRIDGE_URL DOMESTIC_WAREHOUSE_URL; do
|
||||
if ! env_file_has_key "$key" || [ -z "$(env_file_value "$key")" ]; then
|
||||
echo "部署 lastleg-service 时,环境文件必须包含非空 $key" >&2
|
||||
exit 2
|
||||
fi
|
||||
done
|
||||
;;
|
||||
esac
|
||||
|
||||
case " $SERVICES " in
|
||||
*" warehouse-overseas-service "*)
|
||||
for key in WAREHOUSE_OVERSEAS_DB_URL WAREHOUSE_OVERSEAS_DB_USERNAME WAREHOUSE_OVERSEAS_DB_PASSWORD; do
|
||||
if ! env_file_has_key "$key" || [ -z "$(env_file_value "$key")" ]; then
|
||||
echo "部署 warehouse-overseas-service 时,环境文件必须包含非空 $key" >&2
|
||||
exit 2
|
||||
fi
|
||||
done
|
||||
;;
|
||||
esac
|
||||
|
||||
case " $SERVICES " in
|
||||
*" event-bridge-service "*)
|
||||
if [ "$DEPLOY_REGION" = "OVERSEAS" ]; then
|
||||
EVENT_BRIDGE_KEYS="EVENT_BRIDGE_OVERSEAS_DB_URL EVENT_BRIDGE_OVERSEAS_DB_USERNAME EVENT_BRIDGE_OVERSEAS_DB_PASSWORD EVENT_CONSUMER_URL"
|
||||
else
|
||||
EVENT_BRIDGE_KEYS="EVENT_BRIDGE_DB_URL EVENT_BRIDGE_DB_USERNAME EVENT_BRIDGE_DB_PASSWORD EVENT_CONSUMER_URL"
|
||||
fi
|
||||
for key in $EVENT_BRIDGE_KEYS; do
|
||||
if ! env_file_has_key "$key" || [ -z "$(env_file_value "$key")" ]; then
|
||||
echo "部署 event-bridge-service 时,环境文件必须包含非空 $key" >&2
|
||||
exit 2
|
||||
fi
|
||||
done
|
||||
;;
|
||||
esac
|
||||
|
||||
case " $SERVICES " in
|
||||
*" file "*)
|
||||
if ! env_file_has_key ROCKETMQ_NAME_SERVER || [ -z "$(env_file_value ROCKETMQ_NAME_SERVER)" ]; then
|
||||
@@ -75,24 +171,24 @@ esac
|
||||
export DEPLOY_ENV IMAGE_REPOSITORY IMAGE_TAG
|
||||
|
||||
if [ "$DEPLOY_ENV" = "development" ]; then
|
||||
exec sh deploy/scripts/deploy-development-service.sh \
|
||||
DEPLOY_COMPOSE_FILE="$COMPOSE_FILE" DEPLOY_PROJECT_NAME="$PROJECT_NAME" exec sh deploy/scripts/deploy-development-service.sh \
|
||||
"$IMAGE_REPOSITORY" "$IMAGE_TAG" "$SERVICE_CSV" "$ENV_FILE"
|
||||
fi
|
||||
|
||||
docker compose \
|
||||
--project-name "firstleg-${DEPLOY_ENV}" \
|
||||
--project-name "$PROJECT_NAME" \
|
||||
--env-file "$ENV_FILE" \
|
||||
-f deploy/docker-compose.yml \
|
||||
-f "$COMPOSE_FILE" \
|
||||
pull $SERVICES
|
||||
|
||||
docker compose \
|
||||
--project-name "firstleg-${DEPLOY_ENV}" \
|
||||
--project-name "$PROJECT_NAME" \
|
||||
--env-file "$ENV_FILE" \
|
||||
-f deploy/docker-compose.yml \
|
||||
-f "$COMPOSE_FILE" \
|
||||
up -d --remove-orphans --pull never $SERVICES
|
||||
|
||||
docker compose \
|
||||
--project-name "firstleg-${DEPLOY_ENV}" \
|
||||
--project-name "$PROJECT_NAME" \
|
||||
--env-file "$ENV_FILE" \
|
||||
-f deploy/docker-compose.yml \
|
||||
-f "$COMPOSE_FILE" \
|
||||
ps $SERVICES
|
||||
|
||||
+17
-1
@@ -3,6 +3,11 @@ services:
|
||||
artifactId: gateway
|
||||
jar: gateway/target/gateway-1.0.0-SNAPSHOT.jar
|
||||
port: 9000
|
||||
gateway-overseas:
|
||||
artifactId: gateway-overseas
|
||||
jar: gateway-overseas/target/gateway-overseas-1.0.0-SNAPSHOT.jar
|
||||
port: 9300
|
||||
regions: [OVERSEAS]
|
||||
auth:
|
||||
artifactId: auth-service
|
||||
jar: auth/target/auth-service-1.0.0-SNAPSHOT.jar
|
||||
@@ -35,10 +40,21 @@ services:
|
||||
artifactId: integration-service
|
||||
jar: integration-service/target/integration-service-1.0.0-SNAPSHOT.jar
|
||||
port: 9210
|
||||
lastleg-service:
|
||||
artifactId: lastleg-service
|
||||
jar: lastleg-service/target/lastleg-service-1.0.0-SNAPSHOT.jar
|
||||
port: 9303
|
||||
regions: [OVERSEAS]
|
||||
warehouse-overseas-service:
|
||||
artifactId: warehouse-overseas-service
|
||||
jar: warehouse-overseas-service/target/warehouse-overseas-service-1.0.0-SNAPSHOT.jar
|
||||
port: 9305
|
||||
regions: [OVERSEAS]
|
||||
event-bridge-service:
|
||||
artifactId: event-bridge-service
|
||||
jar: event-bridge-service/target/event-bridge-service-1.0.0-SNAPSHOT.jar
|
||||
port: 9300
|
||||
port: 9312
|
||||
regions: [CN, OVERSEAS]
|
||||
audit-service:
|
||||
artifactId: audit-service
|
||||
jar: audit-service/target/audit-service-1.0.0-SNAPSHOT.jar
|
||||
|
||||
@@ -2,7 +2,9 @@
|
||||
|
||||
## 唯一数据库初始化与发布入口
|
||||
|
||||
- 固定入口:`头程数据库统一增量-MySQL8.sql`。文件名保留历史兼容,内容维护为当前完整数据库基线和存量环境兼容升级,不再按日期拆分发布脚本。
|
||||
- 国内区域固定入口:`头程数据库统一增量-MySQL8.sql`。文件名保留历史兼容,内容维护为当前完整数据库基线和存量环境兼容升级,不再按日期拆分发布脚本。
|
||||
- 海外区域固定入口:`海外区域统一增量-MySQL8.sql`;当前包含海外仓报价结构,后续海外主数据继续追加到同一入口,不按日期拆分。
|
||||
- 国内、海外 Compose 部署默认设置 `SPRING_SQL_INIT_MODE=never`,执行任一 SQL 均需用户单独授权。
|
||||
- 该脚本汇总一期、二期及已实现三期出仓能力的数据库基线、存量升级、IAM 权限资源和最终结构校验,适用于 MySQL 8.0。
|
||||
- 正式上线前的数据库变更直接维护该文件中的当前基线、存量兼容处理和最终校验,不再创建按日期拆分的发布 SQL;正式上线后的迁移策略在生产发布规范中另行冻结。
|
||||
- 当前测试环境尚未上线,船期价格表相关表、字段和索引只维护在现有初始化建表定义及本固定入口中,不向 `phase1-order-extension.sql`、`phase2-fba-pricing-migration.sql` 或新的增量脚本追加同一结构。
|
||||
@@ -215,3 +217,9 @@ SELECT
|
||||
- Warehouse 新增 `wh_overseas_storage_fee_rule` 和 `wh_overseas_storage_fee_tier`,包含客户唯一索引、分页索引、阶梯起始天唯一索引和规则明细查询索引。
|
||||
- IAM 新增 `platform:overseas-storage-fee:list/create/edit/delete` 四项权限,默认授予平台系统管理员。
|
||||
- Warehouse Service 启动前必须先执行固定入口;末尾只有两张表、五个索引和四项权限均存在才返回 `PASS`。
|
||||
|
||||
## 四期海外权限目录
|
||||
|
||||
- 固定入口已同步 `iam/src/main/resources/db/lastleg-permissions.sql` 的 `platform:overseas-*` 权限,并默认授予平台系统管理员。
|
||||
- 旧 `platform:lastleg:*` 权限会撤销角色关联并停用;不保留旧码兼容映射。
|
||||
- 本批不创建箱号、产品库、海外出库、退货出库或海外派送权限。
|
||||
|
||||
+168
-47
@@ -9190,6 +9190,8 @@ BEGIN SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='订舱状态历史不可删除';
|
||||
DELIMITER ;
|
||||
|
||||
USE baiyue_iam;
|
||||
DELETE rp FROM iam_role_permission rp WHERE rp.permission_id IN (5102141,5102143,5102144,5102145);
|
||||
DELETE FROM iam_permission WHERE id IN (5102141,5102143,5102144,5102145);
|
||||
DROP PROCEDURE IF EXISTS assert_ocean_booking_permission_ids_available;
|
||||
DELIMITER $$
|
||||
CREATE PROCEDURE assert_ocean_booking_permission_ids_available()
|
||||
@@ -9197,23 +9199,14 @@ BEGIN
|
||||
IF EXISTS (
|
||||
SELECT 1 FROM iam_permission
|
||||
WHERE (id = 5102140 AND permission_code <> 'platform:firstleg:ocean-booking:list')
|
||||
OR (id = 5102141 AND permission_code NOT IN (
|
||||
'platform:firstleg:ocean-booking:list',
|
||||
'platform:firstleg:ocean-booking:view'))
|
||||
OR (id = 5102142 AND permission_code <> 'platform:firstleg:ocean-booking:create')
|
||||
OR (id = 5102143 AND permission_code <> 'platform:firstleg:ocean-booking:edit')
|
||||
OR (id = 5102144 AND permission_code <> 'platform:firstleg:ocean-booking:submit')
|
||||
OR (id = 5102145 AND permission_code <> 'platform:firstleg:ocean-booking:confirm')
|
||||
OR (id = 5102146 AND permission_code <> 'platform:firstleg:ocean-booking:release')
|
||||
OR (id = 5102147 AND permission_code NOT IN (
|
||||
'platform:firstleg:ocean-booking:cancel',
|
||||
'platform:firstleg:ocean-booking:edit'))
|
||||
OR (id = 5102148 AND permission_code NOT IN (
|
||||
'platform:firstleg:ocean-booking:change',
|
||||
'platform:firstleg:ocean-booking:delete'))
|
||||
OR (id = 5102147 AND permission_code <> 'platform:firstleg:ocean-booking:edit')
|
||||
OR (id = 5102148 AND permission_code <> 'platform:firstleg:ocean-booking:delete')
|
||||
OR (id = 5102149 AND permission_code <> 'platform:firstleg:ocean-booking:allocate')
|
||||
OR (permission_code LIKE 'platform:firstleg:ocean-booking:%'
|
||||
AND (tenant_id <> 0 OR app_code <> 'PLATFORM' OR id NOT BETWEEN 5102140 AND 5102149))
|
||||
AND (tenant_id <> 0 OR app_code <> 'PLATFORM'
|
||||
OR id NOT IN (5102140,5102142,5102146,5102147,5102148,5102149)))
|
||||
) THEN
|
||||
SIGNAL SQLSTATE '45000'
|
||||
SET MESSAGE_TEXT = '三期订舱权限迁移失败:5102140-5102149 ID 或权限码被其他语义占用';
|
||||
@@ -9223,13 +9216,6 @@ DELIMITER ;
|
||||
CALL assert_ocean_booking_permission_ids_available();
|
||||
DROP PROCEDURE IF EXISTS assert_ocean_booking_permission_ids_available;
|
||||
|
||||
UPDATE iam_permission
|
||||
SET permission_code='platform:firstleg:ocean-booking:view',status='INACTIVE',deleted=1
|
||||
WHERE id=5102141 AND permission_code='platform:firstleg:ocean-booking:list';
|
||||
|
||||
DELETE rp FROM iam_role_permission rp WHERE rp.permission_id IN (5102143,5102144,5102145);
|
||||
DELETE FROM iam_permission WHERE id IN (5102143,5102144,5102145);
|
||||
|
||||
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)
|
||||
@@ -9249,10 +9235,6 @@ SELECT role.id,permission.id,0 FROM iam_role role JOIN iam_permission permission
|
||||
ON permission.tenant_id=0 AND permission.permission_code LIKE 'platform:firstleg:ocean-booking:%'
|
||||
WHERE role.tenant_id=0 AND role.role_code IN ('PLATFORM_ADMIN','ORDER_OPERATOR');
|
||||
|
||||
DELETE rp FROM iam_role_permission rp JOIN iam_permission p ON p.tenant_id=rp.tenant_id AND p.id=rp.permission_id
|
||||
WHERE p.id=5102141;
|
||||
UPDATE iam_permission SET status='INACTIVE',deleted=1 WHERE id=5102141;
|
||||
|
||||
USE baiyue_firstleg;
|
||||
|
||||
|
||||
@@ -9338,7 +9320,7 @@ BEGIN
|
||||
WHERE (id = 5102150 AND permission_code <> 'platform:firstleg:truck-appointment:list')
|
||||
OR (id = 5102151 AND permission_code NOT IN (
|
||||
'platform:firstleg:truck-appointment:list',
|
||||
'platform:firstleg:truck-appointment:view'))
|
||||
CONCAT('platform:firstleg:truck-appointment:', 'view')))
|
||||
OR (id = 5102152 AND permission_code <> 'platform:firstleg:truck-appointment:create')
|
||||
OR (id = 5102153 AND permission_code <> 'platform:firstleg:truck-appointment:edit')
|
||||
OR (id = 5102154 AND permission_code <> 'platform:firstleg:truck-appointment:operate')
|
||||
@@ -9353,10 +9335,6 @@ DELIMITER ;
|
||||
CALL assert_truck_appointment_permission_ids_available();
|
||||
DROP PROCEDURE IF EXISTS assert_truck_appointment_permission_ids_available;
|
||||
|
||||
UPDATE iam_permission
|
||||
SET permission_code='platform:firstleg:truck-appointment:view',status='INACTIVE',deleted=1
|
||||
WHERE id=5102151 AND permission_code='platform:firstleg:truck-appointment:list';
|
||||
|
||||
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)
|
||||
@@ -9377,7 +9355,7 @@ WHERE role.tenant_id=0 AND role.role_code IN ('PLATFORM_ADMIN','ORDER_OPERATOR')
|
||||
|
||||
DELETE rp FROM iam_role_permission rp JOIN iam_permission p ON p.tenant_id=rp.tenant_id AND p.id=rp.permission_id
|
||||
WHERE p.id=5102151;
|
||||
UPDATE iam_permission SET status='INACTIVE',deleted=1 WHERE id=5102151;
|
||||
DELETE FROM iam_permission WHERE id=5102151;
|
||||
|
||||
USE baiyue_firstleg;
|
||||
|
||||
@@ -10267,10 +10245,18 @@ CREATE TABLE IF NOT EXISTS fl_carrier_schedule_query (
|
||||
tracking_no VARCHAR(128) NOT NULL COMMENT '提交至船公司官网的跟踪号码',
|
||||
official_url VARCHAR(500) NOT NULL COMMENT '船公司官网查询入口地址快照',
|
||||
access_mode VARCHAR(32) NOT NULL COMMENT '官网访问方式:PUBLIC_FORM公开表单、HCAPTCHA人机校验、DYNAMIC_IFRAME动态页面',
|
||||
status VARCHAR(24) NOT NULL COMMENT '任务状态:READY可查询、MANUAL_REQUIRED需人工处理、SUCCESS已取得有效结果',
|
||||
status VARCHAR(24) NOT NULL COMMENT '任务状态:READY、MANUAL_REQUIRED、ARRIVED、PICKUP_READY、STOPPED',
|
||||
trigger_source VARCHAR(32) NOT NULL DEFAULT 'MANUAL' COMMENT '任务触发来源:OUTBOUND_CONFIRM或MANUAL',
|
||||
latest_standard_node VARCHAR(32) NULL COMMENT '最近标准船节点',
|
||||
latest_node_observed_at DATETIME(3) NULL COMMENT '最近标准节点观测时间',
|
||||
stopped_at DATETIME(3) NULL COMMENT '任务停止时间',
|
||||
stop_reason VARCHAR(500) NULL COMMENT '任务停止原因',
|
||||
lease_owner VARCHAR(64) NULL COMMENT '当前调度租约持有者',
|
||||
lease_until DATETIME(3) NULL COMMENT '当前调度租约过期时间',
|
||||
consecutive_failures INT UNSIGNED NOT NULL DEFAULT 0 COMMENT '连续失败次数,用于指数退避',
|
||||
window_started_at DATETIME(3) NOT NULL COMMENT '当前三天查询窗口起始时间',
|
||||
attempt_count INT UNSIGNED NOT NULL DEFAULT 0 COMMENT '当前查询窗口已执行次数;最多3次',
|
||||
next_allowed_at DATETIME(3) NOT NULL COMMENT '频控规则允许的下次查询时间',
|
||||
next_allowed_at DATETIME(3) NULL COMMENT '频控规则允许的下次查询时间;停止后为空',
|
||||
last_attempt_at DATETIME(3) NULL COMMENT '最近一次查询尝试时间',
|
||||
last_success_at DATETIME(3) NULL COMMENT '最近一次取得有效结果时间',
|
||||
source_updated_at DATETIME(3) NULL COMMENT '船公司官网展示的结果更新时间',
|
||||
@@ -10291,7 +10277,7 @@ CREATE TABLE IF NOT EXISTS fl_carrier_schedule_query (
|
||||
PRIMARY KEY (id),
|
||||
UNIQUE KEY uk_carrier_schedule_query_no (query_no),
|
||||
UNIQUE KEY uk_carrier_schedule_query_scope
|
||||
(customer_id,ocean_booking_id,container_scope_id,carrier_code,tracking_type,tracking_no),
|
||||
(customer_id,ocean_booking_id,container_scope_id),
|
||||
KEY idx_carrier_schedule_query_due (status,next_allowed_at,id),
|
||||
KEY idx_carrier_schedule_query_customer (customer_id,updated_at,id),
|
||||
KEY idx_carrier_schedule_query_expiry (expires_at,id),
|
||||
@@ -10306,7 +10292,10 @@ CREATE TABLE IF NOT EXISTS fl_carrier_schedule_query (
|
||||
CONSTRAINT chk_carrier_schedule_query_access CHECK
|
||||
(access_mode IN ('PUBLIC_FORM','HCAPTCHA','DYNAMIC_IFRAME')),
|
||||
CONSTRAINT chk_carrier_schedule_query_status CHECK
|
||||
(status IN ('READY','MANUAL_REQUIRED','SUCCESS')),
|
||||
(status IN ('READY','MANUAL_REQUIRED','ARRIVED','PICKUP_READY','STOPPED')),
|
||||
CONSTRAINT chk_carrier_schedule_query_node CHECK
|
||||
(latest_standard_node IS NULL OR latest_standard_node IN
|
||||
('DEPARTED_WAREHOUSE','AT_ORIGIN_PORT','DEPARTED_ORIGIN_PORT','IN_TRANSIT','ARRIVED','PICKUP_READY')),
|
||||
CONSTRAINT chk_carrier_schedule_query_attempt CHECK (attempt_count BETWEEN 0 AND 3)
|
||||
) ENGINE=InnoDB COMMENT='六家船公司官网查询任务与三天窗口频控';
|
||||
|
||||
@@ -10349,9 +10338,35 @@ CREATE TABLE IF NOT EXISTS fl_carrier_schedule_query_history (
|
||||
PRIMARY KEY (id),
|
||||
KEY idx_carrier_schedule_history_query (query_id,created_at,id),
|
||||
CONSTRAINT fk_carrier_schedule_history_query FOREIGN KEY (query_id) REFERENCES fl_carrier_schedule_query(id),
|
||||
CONSTRAINT chk_carrier_schedule_history_action CHECK (action IN ('CREATE','ATTEMPT','MANUAL_RESULT'))
|
||||
CONSTRAINT chk_carrier_schedule_history_action CHECK
|
||||
(action IN ('CREATE','AUTO_CREATE','MANUAL_RESULT','NODE_OBSERVED','PROVIDER_QUERY','STOP'))
|
||||
) ENGINE=InnoDB COMMENT='船司查询任务不可变历史';
|
||||
|
||||
CREATE TABLE IF NOT EXISTS fl_carrier_schedule_node_fact (
|
||||
id BIGINT UNSIGNED NOT NULL COMMENT '标准船节点事实ID',
|
||||
query_id BIGINT UNSIGNED NOT NULL COMMENT '所属查询任务ID',
|
||||
event_key VARCHAR(192) NOT NULL COMMENT '供应商编码与供应商事件ID组成的幂等键',
|
||||
provider_code VARCHAR(32) NOT NULL COMMENT '轨迹供应商编码',
|
||||
provider_node_code VARCHAR(64) NOT NULL COMMENT '供应商原始节点编码',
|
||||
standard_node VARCHAR(32) NOT NULL COMMENT '标准船节点',
|
||||
node_sequence INT UNSIGNED NOT NULL COMMENT '标准节点单调序号',
|
||||
observed_at DATETIME(3) NOT NULL COMMENT '供应商观测时间',
|
||||
estimated_at DATETIME(3) NULL COMMENT '供应商预计发生时间',
|
||||
actual_at DATETIME(3) NULL COMMENT '供应商确认实际发生时间',
|
||||
location_code VARCHAR(32) NULL COMMENT '节点地点编码',
|
||||
location_name VARCHAR(128) NULL COMMENT '节点地点名称',
|
||||
source_reference VARCHAR(500) NULL COMMENT '可审计来源引用',
|
||||
raw_payload_hash CHAR(64) NOT NULL COMMENT '原始响应SHA-256;不保存敏感原文',
|
||||
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '事实入库时间',
|
||||
PRIMARY KEY (id),
|
||||
UNIQUE KEY uk_carrier_schedule_node_event (query_id,event_key),
|
||||
KEY idx_carrier_schedule_node_timeline (query_id,node_sequence,observed_at,id),
|
||||
CONSTRAINT fk_carrier_schedule_node_query FOREIGN KEY (query_id) REFERENCES fl_carrier_schedule_query(id),
|
||||
CONSTRAINT chk_carrier_schedule_node_standard CHECK
|
||||
(standard_node IN ('DEPARTED_WAREHOUSE','AT_ORIGIN_PORT','DEPARTED_ORIGIN_PORT','IN_TRANSIT','ARRIVED','PICKUP_READY')),
|
||||
CONSTRAINT chk_carrier_schedule_node_sequence CHECK (node_sequence IN (10,20,30,40,50,60))
|
||||
) ENGINE=InnoDB COMMENT='供应商标准船节点不可变事实';
|
||||
|
||||
CREATE TABLE IF NOT EXISTS fl_carrier_schedule_query_operation (
|
||||
id BIGINT UNSIGNED NOT NULL COMMENT '船司查询写操作幂等记录ID',
|
||||
query_id BIGINT UNSIGNED NULL COMMENT '关联查询任务ID;创建任务落库前允许为空',
|
||||
@@ -10392,6 +10407,8 @@ DROP TRIGGER IF EXISTS trg_carrier_schedule_attempt_before_update;
|
||||
DROP TRIGGER IF EXISTS trg_carrier_schedule_attempt_before_delete;
|
||||
DROP TRIGGER IF EXISTS trg_carrier_schedule_history_before_update;
|
||||
DROP TRIGGER IF EXISTS trg_carrier_schedule_history_before_delete;
|
||||
DROP TRIGGER IF EXISTS trg_carrier_schedule_node_before_update;
|
||||
DROP TRIGGER IF EXISTS trg_carrier_schedule_node_before_delete;
|
||||
DELIMITER $$
|
||||
CREATE TRIGGER trg_carrier_schedule_attempt_before_update BEFORE UPDATE ON fl_carrier_schedule_query_attempt
|
||||
FOR EACH ROW BEGIN SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='船司查询尝试不可修改'; END$$
|
||||
@@ -10401,15 +10418,21 @@ CREATE TRIGGER trg_carrier_schedule_history_before_update BEFORE UPDATE ON fl_ca
|
||||
FOR EACH ROW BEGIN SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='船司查询历史不可修改'; END$$
|
||||
CREATE TRIGGER trg_carrier_schedule_history_before_delete BEFORE DELETE ON fl_carrier_schedule_query_history
|
||||
FOR EACH ROW BEGIN SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='船司查询历史不可删除'; END$$
|
||||
CREATE TRIGGER trg_carrier_schedule_node_before_update BEFORE UPDATE ON fl_carrier_schedule_node_fact
|
||||
FOR EACH ROW BEGIN SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='标准船节点事实不可修改'; END$$
|
||||
CREATE TRIGGER trg_carrier_schedule_node_before_delete BEFORE DELETE ON fl_carrier_schedule_node_fact
|
||||
FOR EACH ROW BEGIN SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='标准船节点事实不可删除'; END$$
|
||||
DELIMITER ;
|
||||
|
||||
USE baiyue_iam;
|
||||
DELETE role_permission FROM iam_role_permission role_permission
|
||||
JOIN iam_permission permission ON permission.id=role_permission.permission_id
|
||||
WHERE permission.tenant_id=0
|
||||
AND permission.permission_code='platform:firstleg:port-schedule-query:view';
|
||||
AND permission.permission_code IN ('platform:firstleg:port-schedule-query:view',
|
||||
'platform:firstleg:port-schedule-query:attempt');
|
||||
DELETE FROM iam_permission
|
||||
WHERE tenant_id=0 AND permission_code='platform:firstleg:port-schedule-query:view';
|
||||
WHERE tenant_id=0 AND permission_code IN ('platform:firstleg:port-schedule-query:view',
|
||||
'platform:firstleg:port-schedule-query:attempt');
|
||||
|
||||
DROP PROCEDURE IF EXISTS assert_carrier_schedule_query_permission_ids;
|
||||
DELIMITER $$
|
||||
@@ -10417,16 +10440,15 @@ CREATE PROCEDURE assert_carrier_schedule_query_permission_ids()
|
||||
BEGIN
|
||||
IF EXISTS (
|
||||
SELECT 1 FROM iam_permission
|
||||
WHERE (id IN (5102184,5102186,5102187,5102188)
|
||||
WHERE (id IN (5102184,5102186,5102188)
|
||||
AND permission_code <> CASE id
|
||||
WHEN 5102184 THEN 'platform:firstleg:port-schedule-query:list'
|
||||
WHEN 5102186 THEN 'platform:firstleg:port-schedule-query:create'
|
||||
WHEN 5102187 THEN 'platform:firstleg:port-schedule-query:attempt'
|
||||
WHEN 5102188 THEN 'platform:firstleg:port-schedule-query:manual'
|
||||
END)
|
||||
OR id=5102185
|
||||
OR id IN (5102185,5102187)
|
||||
OR (permission_code LIKE 'platform:firstleg:port-schedule-query:%'
|
||||
AND (tenant_id<>0 OR app_code<>'PLATFORM' OR id NOT IN (5102184,5102186,5102187,5102188)))
|
||||
AND (tenant_id<>0 OR app_code<>'PLATFORM' OR id NOT IN (5102184,5102186,5102188)))
|
||||
) THEN
|
||||
SIGNAL SQLSTATE '45000'
|
||||
SET MESSAGE_TEXT='三期船司查询权限迁移失败:5102184-5102188 ID或权限码被占用';
|
||||
@@ -10442,7 +10464,6 @@ INSERT INTO iam_permission
|
||||
VALUES
|
||||
(5102184,0,'PLATFORM','BUTTON',5102180,'platform:firstleg:port-schedule-query:list','查询船司任务',NULL,'GET',40,'ACTIVE',1,0),
|
||||
(5102186,0,'PLATFORM','BUTTON',5102180,'platform:firstleg:port-schedule-query:create','创建船司任务',NULL,'POST',60,'ACTIVE',1,0),
|
||||
(5102187,0,'PLATFORM','BUTTON',5102180,'platform:firstleg:port-schedule-query:attempt','执行船司查询',NULL,'POST',70,'ACTIVE',1,0),
|
||||
(5102188,0,'PLATFORM','BUTTON',5102180,'platform:firstleg:port-schedule-query:manual','录入人工结果',NULL,'POST',80,'ACTIVE',1,0)
|
||||
ON DUPLICATE KEY UPDATE tenant_id=0,app_code='PLATFORM',permission_type=VALUES(permission_type),
|
||||
permission_code=VALUES(permission_code),permission_name=VALUES(permission_name),parent_id=VALUES(parent_id),
|
||||
@@ -10463,12 +10484,13 @@ BEGIN
|
||||
IF (SELECT COUNT(*) FROM information_schema.tables
|
||||
WHERE table_schema='baiyue_firstleg' AND table_name IN
|
||||
('fl_carrier_schedule_query','fl_carrier_schedule_query_attempt',
|
||||
'fl_carrier_schedule_query_history','fl_carrier_schedule_query_operation')) <> 4 THEN
|
||||
'fl_carrier_schedule_query_history','fl_carrier_schedule_query_operation',
|
||||
'fl_carrier_schedule_node_fact')) <> 5 THEN
|
||||
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='船司查询任务表未完整创建';
|
||||
END IF;
|
||||
IF (SELECT COUNT(*) FROM baiyue_iam.iam_permission
|
||||
WHERE tenant_id=0 AND id BETWEEN 5102184 AND 5102188
|
||||
AND permission_code LIKE 'platform:firstleg:port-schedule-query:%') <> 4 THEN
|
||||
WHERE tenant_id=0 AND id IN (5102184,5102186,5102188)
|
||||
AND permission_code LIKE 'platform:firstleg:port-schedule-query:%') <> 3 THEN
|
||||
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='船司查询任务权限未完整创建';
|
||||
END IF;
|
||||
IF (SELECT COUNT(*)
|
||||
@@ -10477,7 +10499,7 @@ BEGIN
|
||||
JOIN baiyue_iam.iam_permission permission ON permission.id=role_permission.permission_id
|
||||
WHERE role.tenant_id=0 AND role.role_code IN ('PLATFORM_ADMIN','ORDER_OPERATOR')
|
||||
AND permission.tenant_id=0
|
||||
AND permission.permission_code LIKE 'platform:firstleg:port-schedule-query:%') <> 8 THEN
|
||||
AND permission.permission_code LIKE 'platform:firstleg:port-schedule-query:%') <> 6 THEN
|
||||
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='船司查询任务角色授权未完整创建';
|
||||
END IF;
|
||||
END$$
|
||||
@@ -11727,6 +11749,7 @@ CALL queue_table_comment('baiyue_firstleg', 'fl_carrier_schedule_query', '六家
|
||||
CALL queue_table_comment('baiyue_firstleg', 'fl_carrier_schedule_query_attempt', '船司官网查询尝试不可变审计');
|
||||
CALL queue_table_comment('baiyue_firstleg', 'fl_carrier_schedule_query_history', '船司查询任务不可变历史');
|
||||
CALL queue_table_comment('baiyue_firstleg', 'fl_carrier_schedule_query_operation', '船司查询写操作幂等与请求摘要');
|
||||
CALL queue_table_comment('baiyue_firstleg', 'fl_carrier_schedule_node_fact', '供应商标准船节点不可变事实');
|
||||
CALL queue_table_comment('baiyue_firstleg', 'fl_consumed_event', '头程消息消费幂等表:记录已处理事件并按事件ID去重,防止RocketMQ重复投递造成重复建单;不是消息发布表');
|
||||
CALL queue_table_comment('baiyue_firstleg', 'fl_container_booking', '平台货柜预订资料;预装状态唯一来源为装柜计划');
|
||||
CALL queue_table_comment('baiyue_firstleg', 'fl_container_document', '平台货柜清关、DO与PU文件关联');
|
||||
@@ -12331,7 +12354,15 @@ CALL queue_column_comment('baiyue_firstleg', 'fl_carrier_schedule_query', 'query
|
||||
CALL queue_column_comment('baiyue_firstleg', 'fl_carrier_schedule_query', 'remark', '平台内部备注');
|
||||
CALL queue_column_comment('baiyue_firstleg', 'fl_carrier_schedule_query', 'result_port_schedule_id', '最近有效结果对应的船期记录ID');
|
||||
CALL queue_column_comment('baiyue_firstleg', 'fl_carrier_schedule_query', 'source_updated_at', '船公司官网展示的结果更新时间');
|
||||
CALL queue_column_comment('baiyue_firstleg', 'fl_carrier_schedule_query', 'status', '任务状态:READY可查询、MANUAL_REQUIRED需人工处理、SUCCESS已取得有效结果');
|
||||
CALL queue_column_comment('baiyue_firstleg', 'fl_carrier_schedule_query', 'status', '任务状态:READY、MANUAL_REQUIRED、ARRIVED、PICKUP_READY、STOPPED');
|
||||
CALL queue_column_comment('baiyue_firstleg', 'fl_carrier_schedule_query', 'trigger_source', '任务触发来源:OUTBOUND_CONFIRM或MANUAL');
|
||||
CALL queue_column_comment('baiyue_firstleg', 'fl_carrier_schedule_query', 'latest_standard_node', '最近标准船节点');
|
||||
CALL queue_column_comment('baiyue_firstleg', 'fl_carrier_schedule_query', 'latest_node_observed_at', '最近标准节点观测时间');
|
||||
CALL queue_column_comment('baiyue_firstleg', 'fl_carrier_schedule_query', 'stopped_at', '任务停止时间');
|
||||
CALL queue_column_comment('baiyue_firstleg', 'fl_carrier_schedule_query', 'stop_reason', '任务停止原因');
|
||||
CALL queue_column_comment('baiyue_firstleg', 'fl_carrier_schedule_query', 'lease_owner', '当前调度租约持有者');
|
||||
CALL queue_column_comment('baiyue_firstleg', 'fl_carrier_schedule_query', 'lease_until', '当前调度租约过期时间');
|
||||
CALL queue_column_comment('baiyue_firstleg', 'fl_carrier_schedule_query', 'consecutive_failures', '连续失败次数,用于指数退避');
|
||||
CALL queue_column_comment('baiyue_firstleg', 'fl_carrier_schedule_query', 'tracking_no', '提交至船公司官网的跟踪号码');
|
||||
CALL queue_column_comment('baiyue_firstleg', 'fl_carrier_schedule_query', 'tracking_type', '官网跟踪类型:BOOKING_NO订舱号、BILL_OF_LADING提单号、CONTAINER_NO箱号');
|
||||
CALL queue_column_comment('baiyue_firstleg', 'fl_carrier_schedule_query', 'updated_at', '任务最后更新时间');
|
||||
@@ -12375,6 +12406,21 @@ CALL queue_column_comment('baiyue_firstleg', 'fl_carrier_schedule_query_operatio
|
||||
CALL queue_column_comment('baiyue_firstleg', 'fl_carrier_schedule_query_operation', 'request_hash', '规范化请求内容SHA-256摘要;用于识别同键不同请求');
|
||||
CALL queue_column_comment('baiyue_firstleg', 'fl_carrier_schedule_query_operation', 'result_json', '成功结果JSON快照;供幂等重放返回');
|
||||
CALL queue_column_comment('baiyue_firstleg', 'fl_carrier_schedule_query_operation', 'status', '处理状态:PROCESSING处理中、SUCCESS成功');
|
||||
CALL queue_column_comment('baiyue_firstleg', 'fl_carrier_schedule_node_fact', 'id', '标准船节点事实ID');
|
||||
CALL queue_column_comment('baiyue_firstleg', 'fl_carrier_schedule_node_fact', 'query_id', '所属查询任务ID');
|
||||
CALL queue_column_comment('baiyue_firstleg', 'fl_carrier_schedule_node_fact', 'event_key', '供应商编码与供应商事件ID组成的幂等键');
|
||||
CALL queue_column_comment('baiyue_firstleg', 'fl_carrier_schedule_node_fact', 'provider_code', '轨迹供应商编码');
|
||||
CALL queue_column_comment('baiyue_firstleg', 'fl_carrier_schedule_node_fact', 'provider_node_code', '供应商原始节点编码');
|
||||
CALL queue_column_comment('baiyue_firstleg', 'fl_carrier_schedule_node_fact', 'standard_node', '标准船节点');
|
||||
CALL queue_column_comment('baiyue_firstleg', 'fl_carrier_schedule_node_fact', 'node_sequence', '标准节点单调序号');
|
||||
CALL queue_column_comment('baiyue_firstleg', 'fl_carrier_schedule_node_fact', 'observed_at', '供应商观测时间');
|
||||
CALL queue_column_comment('baiyue_firstleg', 'fl_carrier_schedule_node_fact', 'estimated_at', '供应商预计发生时间');
|
||||
CALL queue_column_comment('baiyue_firstleg', 'fl_carrier_schedule_node_fact', 'actual_at', '供应商确认实际发生时间');
|
||||
CALL queue_column_comment('baiyue_firstleg', 'fl_carrier_schedule_node_fact', 'location_code', '节点地点编码');
|
||||
CALL queue_column_comment('baiyue_firstleg', 'fl_carrier_schedule_node_fact', 'location_name', '节点地点名称');
|
||||
CALL queue_column_comment('baiyue_firstleg', 'fl_carrier_schedule_node_fact', 'source_reference', '可审计来源引用');
|
||||
CALL queue_column_comment('baiyue_firstleg', 'fl_carrier_schedule_node_fact', 'raw_payload_hash', '原始响应SHA-256;不保存敏感原文');
|
||||
CALL queue_column_comment('baiyue_firstleg', 'fl_carrier_schedule_node_fact', 'created_at', '事实入库时间');
|
||||
CALL queue_column_comment('baiyue_firstleg', 'fl_consumed_event', 'consumed_at', '首次成功消费时间');
|
||||
CALL queue_column_comment('baiyue_firstleg', 'fl_consumed_event', 'event_id', '已消费领域事件ID,与生产端Outbox事件ID一致');
|
||||
CALL queue_column_comment('baiyue_firstleg', 'fl_consumed_event', 'event_type', '已消费事件类型');
|
||||
@@ -15701,6 +15747,81 @@ ALTER TABLE fl_inbound_outbox
|
||||
MODIFY COLUMN payload_hash VARCHAR(71) NULL COMMENT '事件规范化负载SHA-256;标准跨区事件含sha256:前缀';
|
||||
ALTER TABLE fl_inbound_outbox
|
||||
MODIFY COLUMN payload_json LONGTEXT NOT NULL COMMENT '事件负载快照;文本类型保留跨区事件摘要所依赖的数值词法';
|
||||
+-- 四期海外权限统一目录(来源:iam/db/lastleg-permissions.sql)
|
||||
-- 四期 Admin 海外权限只使用 platform:overseas-*;列表权限包含详情只读。
|
||||
DELETE rp FROM baiyue_iam.iam_role_permission rp
|
||||
JOIN baiyue_iam.iam_permission p ON p.id=rp.permission_id
|
||||
WHERE p.permission_code LIKE 'platform:lastleg:%';
|
||||
UPDATE baiyue_iam.iam_permission SET status='INACTIVE', deleted=1
|
||||
WHERE permission_code LIKE 'platform:lastleg:%';
|
||||
|
||||
INSERT INTO baiyue_iam.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:overseas-warehouse:last-mile-order:list','海外尾程订单','/overseas-warehouse/last-mile-orders','GET',700,'ACTIVE',1,0),
|
||||
(5107001,0,'PLATFORM','BUTTON',5107000,'platform:overseas-warehouse:last-mile-order:edit','编辑海外尾程订单',NULL,'PUT',701,'ACTIVE',1,0),
|
||||
(5107002,0,'PLATFORM','BUTTON',5107000,'platform:overseas-warehouse:last-mile-order:export','导出海外尾程订单',NULL,'GET',702,'ACTIVE',1,0),
|
||||
(5107010,0,'PLATFORM','MENU',0,'platform:overseas-warehouse:inbound-order:list','海外入仓单','/overseas-warehouse/inbound-orders','GET',710,'ACTIVE',1,0),
|
||||
(5107011,0,'PLATFORM','BUTTON',5107010,'platform:overseas-warehouse:inbound-order:create','创建海外入仓计划',NULL,'POST',711,'ACTIVE',1,0),
|
||||
(5107012,0,'PLATFORM','BUTTON',5107010,'platform:overseas-warehouse:inbound-order:edit','编辑海外入仓计划',NULL,'POST',712,'ACTIVE',1,0),
|
||||
(5107020,0,'PLATFORM','MENU',0,'platform:overseas-warehouse:relabel-order:list','海外换标单','/overseas-warehouse/relabel-orders','GET',720,'ACTIVE',1,0),
|
||||
(5107021,0,'PLATFORM','BUTTON',5107020,'platform:overseas-warehouse:relabel-order:create','创建海外换标单',NULL,'POST',721,'ACTIVE',1,0),
|
||||
(5107022,0,'PLATFORM','BUTTON',5107020,'platform:overseas-warehouse:relabel-order:status','变更海外换标单状态',NULL,'PUT',722,'ACTIVE',1,0),
|
||||
(5107030,0,'PLATFORM','MENU',0,'platform:overseas-warehouse:pallet:list','海外托盘','/overseas-warehouse/pallets','GET',730,'ACTIVE',1,0),
|
||||
(5107031,0,'PLATFORM','BUTTON',5107030,'platform:overseas-warehouse:pallet:create','创建海外托盘',NULL,'POST',731,'ACTIVE',1,0),
|
||||
(5107032,0,'PLATFORM','BUTTON',5107030,'platform:overseas-warehouse:pallet:edit','编辑海外托盘',NULL,'PUT',732,'ACTIVE',1,0),
|
||||
(5107033,0,'PLATFORM','BUTTON',5107030,'platform:overseas-warehouse:pallet:delete','删除海外托盘',NULL,'DELETE',733,'ACTIVE',1,0),
|
||||
(5107040,0,'PLATFORM','MENU',0,'platform:overseas-warehouse:inventory:list','海外库存','/overseas-warehouse/inventory','GET',740,'ACTIVE',1,0),
|
||||
(5107041,0,'PLATFORM','BUTTON',5107040,'platform:overseas-warehouse:inventory:export','导出海外库存',NULL,'GET',741,'ACTIVE',1,0),
|
||||
(5107042,0,'PLATFORM','BUTTON',5107040,'platform:overseas-warehouse:inventory:edit','编辑海外库存状态',NULL,'POST',742,'ACTIVE',1,0),
|
||||
(5107043,0,'PLATFORM','BUTTON',5107040,'platform:overseas-warehouse:inventory:adjust','调整海外库存',NULL,'POST',743,'ACTIVE',1,0),
|
||||
(5107050,0,'PLATFORM','MENU',0,'platform:overseas-warehouse:warehouse:list','海外仓资源','/overseas-warehouse/warehouses','GET',750,'ACTIVE',1,0),
|
||||
(5107051,0,'PLATFORM','BUTTON',5107050,'platform:overseas-warehouse:warehouse:create','创建海外仓资源',NULL,'POST',751,'ACTIVE',1,0),
|
||||
(5107052,0,'PLATFORM','BUTTON',5107050,'platform:overseas-warehouse:warehouse:edit','编辑海外仓资源',NULL,'PUT',752,'ACTIVE',1,0),
|
||||
(5107053,0,'PLATFORM','BUTTON',5107050,'platform:overseas-warehouse:warehouse:status','变更海外仓状态',NULL,'PATCH',753,'ACTIVE',1,0),
|
||||
(5107060,0,'PLATFORM','MENU',0,'platform:overseas-warehouse:location:list','海外库位','/overseas-warehouse/locations','GET',760,'ACTIVE',1,0),
|
||||
(5107061,0,'PLATFORM','BUTTON',5107060,'platform:overseas-warehouse:location:create','创建海外库位',NULL,'POST',761,'ACTIVE',1,0),
|
||||
(5107062,0,'PLATFORM','BUTTON',5107060,'platform:overseas-warehouse:location:edit','编辑海外库位',NULL,'PUT',762,'ACTIVE',1,0),
|
||||
(5107063,0,'PLATFORM','BUTTON',5107060,'platform:overseas-warehouse:location:delete','删除海外库位',NULL,'DELETE',763,'ACTIVE',1,0),
|
||||
(5107100,0,'PLATFORM','MENU',0,'platform:overseas-business:purchase:list','海外采购订单','/overseas-business/purchases','GET',800,'ACTIVE',1,0),
|
||||
(5107101,0,'PLATFORM','BUTTON',5107100,'platform:overseas-business:purchase:edit','编辑海外采购订单',NULL,'PATCH',801,'ACTIVE',1,0),
|
||||
(5107102,0,'PLATFORM','BUTTON',5107100,'platform:overseas-business:purchase:review','审核海外采购订单',NULL,'POST',802,'ACTIVE',1,0),
|
||||
(5107110,0,'PLATFORM','MENU',0,'platform:overseas-business:truck:list','本土卡车','/overseas-business/trucks','GET',810,'ACTIVE',1,0),
|
||||
(5107111,0,'PLATFORM','BUTTON',5107110,'platform:overseas-business:truck:create','创建本土卡车单',NULL,'POST',811,'ACTIVE',1,0),
|
||||
(5107112,0,'PLATFORM','BUTTON',5107110,'platform:overseas-business:truck:edit','编辑本土卡车单',NULL,'PUT',812,'ACTIVE',1,0),
|
||||
(5107113,0,'PLATFORM','BUTTON',5107110,'platform:overseas-business:truck:status','变更本土卡车状态',NULL,'PATCH',813,'ACTIVE',1,0),
|
||||
(5107120,0,'PLATFORM','MENU',0,'platform:overseas-business:looseboard:list','本土散板','/overseas-business/looseboards','GET',820,'ACTIVE',1,0),
|
||||
(5107121,0,'PLATFORM','BUTTON',5107120,'platform:overseas-business:looseboard:create','创建本土散板单',NULL,'POST',821,'ACTIVE',1,0),
|
||||
(5107122,0,'PLATFORM','BUTTON',5107120,'platform:overseas-business:looseboard:edit','编辑本土散板单',NULL,'PUT',822,'ACTIVE',1,0),
|
||||
(5107123,0,'PLATFORM','BUTTON',5107120,'platform:overseas-business:looseboard:status','变更本土散板状态',NULL,'PATCH',823,'ACTIVE',1,0),
|
||||
(5107200,0,'PLATFORM','MENU',0,'platform:overseas-master-data:quote:list','海外仓报价','/overseas-master-data/quotes','GET',900,'ACTIVE',1,0),
|
||||
(5107201,0,'PLATFORM','BUTTON',5107200,'platform:overseas-master-data:quote:create','创建海外仓报价',NULL,'POST',901,'ACTIVE',1,0),
|
||||
(5107202,0,'PLATFORM','BUTTON',5107200,'platform:overseas-master-data:quote:edit','编辑海外仓报价',NULL,'PUT',902,'ACTIVE',1,0),
|
||||
(5107203,0,'PLATFORM','BUTTON',5107200,'platform:overseas-master-data:quote:status','变更海外仓报价状态',NULL,'PATCH',903,'ACTIVE',1,0),
|
||||
(5107204,0,'PLATFORM','BUTTON',5107200,'platform:overseas-master-data:quote:export','导出海外仓报价',NULL,'GET',904,'ACTIVE',1,0),
|
||||
(5107210,0,'PLATFORM','MENU',0,'platform:overseas-master-data:exchange-rate:list','月度汇率','/overseas-master-data/exchange-rates','GET',910,'ACTIVE',1,0),
|
||||
(5107211,0,'PLATFORM','BUTTON',5107210,'platform:overseas-master-data:exchange-rate:create','创建月度汇率',NULL,'POST',911,'ACTIVE',1,0),
|
||||
(5107212,0,'PLATFORM','BUTTON',5107210,'platform:overseas-master-data:exchange-rate:edit','编辑月度汇率',NULL,'PUT',912,'ACTIVE',1,0),
|
||||
(5107213,0,'PLATFORM','BUTTON',5107210,'platform:overseas-master-data:exchange-rate:status','变更月度汇率状态',NULL,'PATCH',913,'ACTIVE',1,0),
|
||||
(5107214,0,'PLATFORM','BUTTON',5107210,'platform:overseas-master-data:exchange-rate:export','导出月度汇率',NULL,'GET',914,'ACTIVE',1,0),
|
||||
(5107220,0,'PLATFORM','MENU',0,'platform:overseas-master-data:vehicle:list','车辆主数据','/overseas-master-data/vehicles','GET',920,'ACTIVE',1,0),
|
||||
(5107221,0,'PLATFORM','BUTTON',5107220,'platform:overseas-master-data:vehicle:create','创建车辆主数据',NULL,'POST',921,'ACTIVE',1,0),
|
||||
(5107222,0,'PLATFORM','BUTTON',5107220,'platform:overseas-master-data:vehicle:edit','编辑车辆主数据',NULL,'PUT',922,'ACTIVE',1,0),
|
||||
(5107223,0,'PLATFORM','BUTTON',5107220,'platform:overseas-master-data:vehicle:status','变更车辆状态',NULL,'PATCH',923,'ACTIVE',1,0),
|
||||
(5107224,0,'PLATFORM','BUTTON',5107220,'platform:overseas-master-data:vehicle:export','导出车辆主数据',NULL,'GET',924,'ACTIVE',1,0)
|
||||
ON DUPLICATE KEY UPDATE permission_name=VALUES(permission_name), route_path=VALUES(route_path),
|
||||
http_method=VALUES(http_method), sort_no=VALUES(sort_no), status='ACTIVE', built_in=1, deleted=0;
|
||||
|
||||
INSERT IGNORE INTO baiyue_iam.iam_role_permission (tenant_id, role_id, permission_id, created_at)
|
||||
SELECT 0, role.id, permission.id, NOW(3)
|
||||
FROM baiyue_iam.iam_role role
|
||||
JOIN baiyue_iam.iam_permission permission ON permission.tenant_id=0 AND permission.app_code='PLATFORM'
|
||||
AND permission.id BETWEEN 5107000 AND 5107299
|
||||
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.status='ACTIVE' AND permission.deleted=0;
|
||||
|
||||
|
||||
CALL baiyue_firstleg.assert_unified_final_schema();
|
||||
DROP PROCEDURE IF EXISTS baiyue_firstleg.assert_unified_final_schema;
|
||||
SELECT 'PASS' AS migration_status,
|
||||
|
||||
@@ -0,0 +1,254 @@
|
||||
-- 海外区域固定 SQL 入口。只由人工审核执行;服务部署默认不自动执行 SQL。
|
||||
USE baiyue_lastleg;
|
||||
|
||||
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 '发布时间',
|
||||
locked_at DATETIME(3) NULL COMMENT '投递租约获取时间', locked_by VARCHAR(128) NULL COMMENT '投递租约持有者',
|
||||
last_error VARCHAR(1000) 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='尾程跨区业务事件发件箱';
|
||||
|
||||
CREATE TABLE IF NOT EXISTS ll_overseas_quote (
|
||||
id BIGINT NOT NULL COMMENT '报价ID', name VARCHAR(128) NOT NULL COMMENT '报价名称',
|
||||
warehouse_id BIGINT NOT NULL COMMENT '国内平台海外合作仓ID', warehouse_code VARCHAR(64) NOT NULL COMMENT '合作仓编码快照',
|
||||
warehouse_name VARCHAR(128) NOT NULL COMMENT '合作仓名称快照', country_code VARCHAR(2) NOT NULL COMMENT '国家二字码快照',
|
||||
provider_name VARCHAR(128) NOT NULL COMMENT '报价方', currency CHAR(3) NOT NULL COMMENT '币种',
|
||||
effective_start DATE NOT NULL COMMENT '生效开始日期', effective_end DATE NOT NULL COMMENT '生效结束日期',
|
||||
version_code VARCHAR(64) NOT NULL COMMENT '业务版本号', status VARCHAR(16) NOT NULL DEFAULT 'INACTIVE' COMMENT '状态:ACTIVE/INACTIVE',
|
||||
remark VARCHAR(1000) NULL COMMENT '备注', record_version BIGINT NOT NULL DEFAULT 0 COMMENT '乐观锁版本',
|
||||
created_by VARCHAR(128) NOT NULL COMMENT '创建人', updated_by VARCHAR(128) NOT NULL COMMENT '更新人',
|
||||
created_at DATETIME(3) NOT NULL COMMENT '创建时间', updated_at DATETIME(3) NOT NULL COMMENT '更新时间',
|
||||
PRIMARY KEY (id), UNIQUE KEY uk_ll_quote_warehouse_provider_version (warehouse_id,provider_name,version_code),
|
||||
KEY idx_ll_quote_page (status,updated_at,id), KEY idx_ll_quote_effective (warehouse_id,provider_name,currency,effective_start,effective_end)
|
||||
) ENGINE=InnoDB COMMENT='海外仓报价主表';
|
||||
|
||||
CREATE TABLE IF NOT EXISTS ll_overseas_quote_fee (
|
||||
id BIGINT NOT NULL COMMENT '费用明细ID', quote_id BIGINT NOT NULL COMMENT '报价ID', sequence_no INT NOT NULL COMMENT '显示顺序',
|
||||
item VARCHAR(128) NOT NULL COMMENT '费用项目', billing_unit VARCHAR(64) NOT NULL COMMENT '计费单位',
|
||||
unit_price DECIMAL(18,4) NOT NULL COMMENT '单价', minimum_charge DECIMAL(18,4) NOT NULL DEFAULT 0 COMMENT '最低收费',
|
||||
remark VARCHAR(500) NULL COMMENT '备注', PRIMARY KEY (id), UNIQUE KEY uk_ll_quote_fee_sequence (quote_id,sequence_no),
|
||||
KEY idx_ll_quote_fee_quote (quote_id)
|
||||
) ENGINE=InnoDB COMMENT='海外仓报价仓库费用';
|
||||
|
||||
CREATE TABLE IF NOT EXISTS ll_overseas_quote_transit_price (
|
||||
id BIGINT NOT NULL COMMENT '中转价格ID', quote_id BIGINT NOT NULL COMMENT '报价ID', sequence_no INT NOT NULL COMMENT '显示顺序',
|
||||
fba_warehouse_code VARCHAR(64) NOT NULL COMMENT 'FBA仓编码', delivery_method VARCHAR(64) NOT NULL COMMENT '报价运输方式',
|
||||
estimated_miles DECIMAL(18,4) NOT NULL DEFAULT 0 COMMENT '预估英里数', price DECIMAL(18,4) NOT NULL COMMENT '报价金额',
|
||||
minimum_charge DECIMAL(18,4) NOT NULL DEFAULT 0 COMMENT '最低收费', remark VARCHAR(500) NULL COMMENT '备注',
|
||||
PRIMARY KEY (id), UNIQUE KEY uk_ll_quote_transit_sequence (quote_id,sequence_no),
|
||||
KEY idx_ll_quote_transit_quote (quote_id)
|
||||
) ENGINE=InnoDB COMMENT='海外仓报价中转价格明细';
|
||||
|
||||
CREATE TABLE IF NOT EXISTS ll_monthly_exchange_rate (
|
||||
id BIGINT NOT NULL COMMENT '月度汇率ID', rate_month CHAR(7) NOT NULL COMMENT '汇率月份,格式yyyy-MM',
|
||||
base_currency CHAR(3) NOT NULL DEFAULT 'USD' COMMENT '基准币种', quote_currency CHAR(3) NOT NULL DEFAULT 'CNY' COMMENT '报价币种',
|
||||
usd_to_cny_average DECIMAL(18,8) NOT NULL COMMENT '美元兑人民币月平均汇率', status VARCHAR(16) NOT NULL DEFAULT 'ACTIVE' COMMENT '状态:ACTIVE/INACTIVE',
|
||||
remark VARCHAR(1000) NULL COMMENT '备注', record_version BIGINT NOT NULL DEFAULT 0 COMMENT '乐观锁版本',
|
||||
created_by VARCHAR(128) NOT NULL COMMENT '创建人', updated_by VARCHAR(128) NOT NULL COMMENT '更新人',
|
||||
created_at DATETIME(3) NOT NULL COMMENT '创建时间', updated_at DATETIME(3) NOT NULL COMMENT '更新时间',
|
||||
PRIMARY KEY (id), UNIQUE KEY uk_ll_exchange_rate_month (rate_month), KEY idx_ll_exchange_rate_page (status,rate_month)
|
||||
) ENGINE=InnoDB COMMENT='美元兑人民币月度平均汇率';
|
||||
|
||||
CREATE TABLE IF NOT EXISTS ll_monthly_exchange_rate_history (
|
||||
id BIGINT NOT NULL COMMENT '汇率变更历史ID', exchange_rate_id BIGINT NOT NULL COMMENT '月度汇率ID',
|
||||
before_rate DECIMAL(18,8) NULL COMMENT '变更前汇率', after_rate DECIMAL(18,8) NOT NULL COMMENT '变更后汇率',
|
||||
before_status VARCHAR(16) NULL COMMENT '变更前状态', after_status VARCHAR(16) NOT NULL COMMENT '变更后状态',
|
||||
operator_id VARCHAR(128) NOT NULL COMMENT '操作人', remark VARCHAR(1000) NULL COMMENT '变更说明',
|
||||
created_at DATETIME(3) NOT NULL COMMENT '创建时间', PRIMARY KEY (id), KEY idx_ll_exchange_rate_history (exchange_rate_id,created_at)
|
||||
) ENGINE=InnoDB COMMENT='月度汇率变更历史';
|
||||
|
||||
CREATE TABLE IF NOT EXISTS ll_vehicle (
|
||||
id BIGINT NOT NULL COMMENT '车辆档案ID', plate_number VARCHAR(64) NOT NULL COMMENT '车牌号',
|
||||
model VARCHAR(128) NOT NULL COMMENT '车型', fuel_consumption_per100_km DECIMAL(10,2) NOT NULL COMMENT '百公里油耗,单位升',
|
||||
mileage_km BIGINT NOT NULL COMMENT '当前公里码,单位公里', driver_or_dispatch_company VARCHAR(128) NULL COMMENT '司机姓名或外派单位',
|
||||
phone VARCHAR(64) NULL COMMENT '司机或外派单位联系电话', region VARCHAR(16) NOT NULL COMMENT '区域:DOMESTIC/OVERSEAS',
|
||||
ownership VARCHAR(16) NOT NULL COMMENT '车辆归属:OWNED/DISPATCHED',
|
||||
status VARCHAR(32) NOT NULL COMMENT '状态:IN_SERVICE/DISPATCHED/MAINTENANCE/INACTIVE/RETURNED',
|
||||
remark VARCHAR(1000) NULL COMMENT '备注', record_version BIGINT NOT NULL DEFAULT 0 COMMENT '乐观锁版本',
|
||||
created_by VARCHAR(128) NOT NULL COMMENT '创建人', updated_by VARCHAR(128) NOT NULL COMMENT '更新人',
|
||||
created_at DATETIME(3) NOT NULL COMMENT '创建时间', updated_at DATETIME(3) NOT NULL COMMENT '更新时间',
|
||||
PRIMARY KEY (id), UNIQUE KEY uk_ll_vehicle_plate (plate_number), KEY idx_ll_vehicle_page (ownership,region,status,updated_at,id)
|
||||
) ENGINE=InnoDB COMMENT='平台车辆主数据';
|
||||
|
||||
CREATE TABLE IF NOT EXISTS ll_overseas_procurement_order (
|
||||
id BIGINT NOT NULL COMMENT '主键ID', customer_id VARCHAR(128) NOT NULL COMMENT '客户租户ID',
|
||||
customer_name VARCHAR(255) NULL COMMENT '客户名称快照', order_no VARCHAR(64) NOT NULL COMMENT '订单号',
|
||||
purchase_category VARCHAR(128) NOT NULL COMMENT '采购品类', product_name VARCHAR(255) NOT NULL COMMENT '产品名称',
|
||||
product_specification VARCHAR(500) NULL COMMENT '产品规格', product_url VARCHAR(2000) NULL COMMENT '产品链接',
|
||||
purchase_quantity BIGINT NOT NULL COMMENT '采购数量', budget_unit_price DECIMAL(18,2) NULL COMMENT '预算单价',
|
||||
currency CHAR(3) NOT NULL DEFAULT 'USD' COMMENT '币种', receiving_address VARCHAR(1000) NULL COMMENT '收货地址',
|
||||
contact_name VARCHAR(128) NOT NULL COMMENT '联系人姓名', contact_phone VARCHAR(64) NOT NULL COMMENT '联系人电话',
|
||||
purchase_warehouse_id VARCHAR(128) NULL COMMENT '采购入仓仓库ID', purchase_warehouse_name VARCHAR(255) NULL COMMENT '采购入仓仓库名称',
|
||||
inbound_quantity BIGINT NOT NULL DEFAULT 0 COMMENT '采购单据累计入库数量', domestic_carrier VARCHAR(128) NULL COMMENT '境内承运商',
|
||||
domestic_tracking_no VARCHAR(128) NULL COMMENT '境内物流单号', overseas_carrier VARCHAR(128) NULL COMMENT '海外承运商',
|
||||
overseas_tracking_no VARCHAR(128) NULL COMMENT '海外物流单号', estimated_arrival_at DATETIME(3) NULL COMMENT '预计到达时间',
|
||||
status VARCHAR(32) NOT NULL DEFAULT 'PENDING_REVIEW' COMMENT '业务状态', remark VARCHAR(1000) NULL COMMENT '备注',
|
||||
idempotency_key VARCHAR(255) NOT NULL COMMENT '业务幂等键', request_hash CHAR(64) NOT NULL COMMENT '请求内容哈希',
|
||||
created_by VARCHAR(128) NULL COMMENT '创建人ID', reviewed_by VARCHAR(128) NULL COMMENT '审核人ID',
|
||||
review_remark VARCHAR(1000) NULL COMMENT '审核备注', reviewed_at DATETIME(3) NULL COMMENT '审核时间',
|
||||
version BIGINT NOT NULL DEFAULT 0 COMMENT '乐观锁版本', created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间',
|
||||
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '更新时间',
|
||||
PRIMARY KEY(id), UNIQUE KEY uk_ll_overseas_procurement_order_no(customer_id,order_no),
|
||||
UNIQUE KEY uk_ll_overseas_procurement_order_key(customer_id,idempotency_key),
|
||||
KEY idx_ll_overseas_procurement_list(customer_id,status,created_at),
|
||||
KEY idx_ll_overseas_procurement_warehouse(customer_id,purchase_warehouse_id,created_at)
|
||||
) ENGINE=InnoDB COMMENT='海外采购订单';
|
||||
|
||||
CREATE TABLE IF NOT EXISTS ll_overseas_procurement_status_history (
|
||||
id BIGINT NOT NULL COMMENT '主键ID', order_id BIGINT NOT NULL COMMENT '订单ID', customer_id VARCHAR(128) NOT NULL COMMENT '客户租户ID',
|
||||
operation_type VARCHAR(32) NOT NULL COMMENT '操作类型:CREATE、REVIEW或UPDATE', from_status VARCHAR(32) NULL COMMENT '变更前状态',
|
||||
to_status VARCHAR(32) NOT NULL COMMENT '变更后状态', operator_id VARCHAR(128) NULL COMMENT '操作人ID',
|
||||
change_detail JSON NULL COMMENT '字段变更前后值JSON', remark VARCHAR(1000) NULL COMMENT '备注',
|
||||
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间', PRIMARY KEY(id),
|
||||
KEY idx_ll_overseas_procurement_history(customer_id,order_id,created_at)
|
||||
) ENGINE=InnoDB COMMENT='海外采购操作流水';
|
||||
|
||||
CREATE TABLE IF NOT EXISTS ll_us_truck_order (
|
||||
id BIGINT NOT NULL COMMENT '主键ID', customer_id VARCHAR(128) NOT NULL COMMENT '客户租户ID', order_no VARCHAR(64) NOT NULL COMMENT '订单号',
|
||||
transport_type VARCHAR(32) NOT NULL COMMENT '运输类型', origin VARCHAR(255) NOT NULL COMMENT '起运地', origin_address VARCHAR(500) NULL COMMENT '起运地址',
|
||||
destination VARCHAR(255) NOT NULL COMMENT '目的地', destination_address VARCHAR(500) NULL COMMENT '目的地址',
|
||||
container_type VARCHAR(32) NULL COMMENT '历史兼容设备类型,零担托盘业务不采集', pallet_count INT NOT NULL DEFAULT 1 COMMENT '客户申报托盘数',
|
||||
package_count INT NULL COMMENT '可选箱数,零或空值均有效', received_pallet_count INT NOT NULL DEFAULT 0 COMMENT 'PDA实收托盘数',
|
||||
allocated_pallet_count INT NOT NULL DEFAULT 0 COMMENT '已分配到合车行程的托盘数', shipped_pallet_count INT NOT NULL DEFAULT 0 COMMENT '已出库托盘数',
|
||||
total_weight_kg DECIMAL(18,3) NOT NULL DEFAULT 0 COMMENT '总重量(千克)', total_volume_cbm DECIMAL(18,6) NOT NULL DEFAULT 0 COMMENT '总体积(立方米)',
|
||||
budget_amount DECIMAL(18,2) NULL COMMENT '预算金额', currency VARCHAR(3) NOT NULL DEFAULT 'USD' COMMENT '币种',
|
||||
contact_name VARCHAR(128) NOT NULL COMMENT '联系人姓名', contact_info VARCHAR(128) NOT NULL COMMENT '联系人联系方式',
|
||||
requested_date DATE NOT NULL COMMENT '要求服务日期', status VARCHAR(32) NOT NULL COMMENT '美国卡车订单状态', remark VARCHAR(1000) NULL COMMENT '备注',
|
||||
receipt_idempotency_key VARCHAR(255) NULL COMMENT '实物收货幂等键', receipt_request_hash CHAR(64) NULL COMMENT '实物收货请求哈希',
|
||||
idempotency_key VARCHAR(255) NOT NULL COMMENT '业务幂等键', request_hash CHAR(64) NOT NULL COMMENT '请求内容哈希', created_by VARCHAR(128) NULL COMMENT '创建人ID',
|
||||
version BIGINT NOT NULL DEFAULT 0 COMMENT '乐观锁版本', created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间',
|
||||
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '更新时间', PRIMARY KEY(id),
|
||||
UNIQUE KEY uk_ll_us_truck_order_no(customer_id,order_no), UNIQUE KEY uk_ll_us_truck_order_key(customer_id,idempotency_key),
|
||||
UNIQUE KEY uk_ll_us_truck_receipt_key(customer_id,receipt_idempotency_key), KEY idx_ll_us_truck_order_list(customer_id,status,created_at),
|
||||
KEY idx_ll_us_truck_order_date(customer_id,requested_date,created_at)
|
||||
) ENGINE=InnoDB COMMENT='美国卡车运输订单';
|
||||
|
||||
CREATE TABLE IF NOT EXISTS ll_us_truck_order_pallet (
|
||||
id BIGINT NOT NULL COMMENT '托盘记录ID', order_id BIGINT NOT NULL COMMENT '美国卡车订单ID', customer_id VARCHAR(128) NOT NULL COMMENT '客户租户ID',
|
||||
pallet_no VARCHAR(128) NOT NULL COMMENT '业务托盘号', sequence_no INT NOT NULL COMMENT '订单内托盘序号',
|
||||
source VARCHAR(32) NOT NULL COMMENT '托盘来源:订单计划或PDA超收', status VARCHAR(32) NOT NULL COMMENT '托盘状态:待收、已收或超收待审核',
|
||||
warehouse_pallet_id BIGINT NULL COMMENT '仓库实物托盘资源ID', package_count INT NULL COMMENT '托盘箱数', received_at DATETIME(3) NULL COMMENT '实收时间',
|
||||
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间',
|
||||
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '更新时间', PRIMARY KEY(id),
|
||||
UNIQUE KEY uk_ll_us_truck_order_pallet_no(customer_id,order_id,pallet_no), UNIQUE KEY uk_ll_us_truck_order_pallet_seq(customer_id,order_id,sequence_no),
|
||||
KEY idx_ll_us_truck_order_pallet_list(customer_id,order_id,status)
|
||||
) ENGINE=InnoDB COMMENT='美国卡车订单托盘实收记录';
|
||||
|
||||
CREATE TABLE IF NOT EXISTS ll_us_truck_order_status_history (
|
||||
id BIGINT NOT NULL COMMENT '主键ID', order_id BIGINT NOT NULL COMMENT '订单ID', customer_id VARCHAR(128) NOT NULL COMMENT '客户租户ID',
|
||||
from_status VARCHAR(32) NULL COMMENT '变更前状态', to_status VARCHAR(32) NOT NULL COMMENT '变更后状态', operator_id VARCHAR(128) NULL COMMENT '操作人ID',
|
||||
remark VARCHAR(1000) NULL COMMENT '备注', created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间', PRIMARY KEY(id),
|
||||
KEY idx_ll_us_truck_history(customer_id,order_id,created_at)
|
||||
) ENGINE=InnoDB COMMENT='美国卡车订单状态历史';
|
||||
|
||||
CREATE TABLE IF NOT EXISTS ll_local_looseboard_order (
|
||||
id BIGINT NOT NULL COMMENT '主键ID', customer_id VARCHAR(128) NOT NULL COMMENT '客户租户ID', order_no VARCHAR(64) NOT NULL COMMENT '本土散板订单号',
|
||||
transport_mode VARCHAR(32) NOT NULL COMMENT '运输方式:拼车或优先拼载', pallet_required VARCHAR(8) NOT NULL COMMENT '是否需要打盘:YES或NO',
|
||||
transport_type VARCHAR(32) NOT NULL COMMENT '运输类型', origin VARCHAR(255) NOT NULL COMMENT '起运地', origin_address VARCHAR(500) NULL COMMENT '起运详细地址',
|
||||
destination VARCHAR(255) NOT NULL COMMENT '目的地', destination_address VARCHAR(500) NULL COMMENT '目的详细地址、码头或仓号',
|
||||
container_type VARCHAR(32) NULL COMMENT '柜型', package_count INT NOT NULL DEFAULT 0 COMMENT '货物件数',
|
||||
total_weight_kg DECIMAL(18,3) NOT NULL DEFAULT 0 COMMENT '总重量(千克)', total_volume_cbm DECIMAL(18,6) NOT NULL DEFAULT 0 COMMENT '总体积(立方米)',
|
||||
budget_amount DECIMAL(18,2) NULL COMMENT '预算总价', currency CHAR(3) NOT NULL DEFAULT 'USD' COMMENT '预算币种',
|
||||
contact_name VARCHAR(128) NULL COMMENT '联系人姓名', contact_info VARCHAR(128) NULL COMMENT '联系人联系方式',
|
||||
requested_date DATE NULL COMMENT '要求服务日期', status VARCHAR(32) NOT NULL DEFAULT 'PENDING_ACCEPTANCE' COMMENT '本土散板订单状态',
|
||||
remark VARCHAR(1000) NULL COMMENT '备注', idempotency_key VARCHAR(255) NOT NULL COMMENT '创建幂等键', request_hash CHAR(64) NOT NULL COMMENT '创建请求内容哈希',
|
||||
created_by VARCHAR(128) NULL COMMENT '创建人ID', version BIGINT NOT NULL DEFAULT 0 COMMENT '乐观锁版本',
|
||||
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间',
|
||||
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '更新时间', PRIMARY KEY(id),
|
||||
UNIQUE KEY uk_ll_local_looseboard_no(customer_id,order_no), UNIQUE KEY uk_ll_local_looseboard_key(customer_id,idempotency_key),
|
||||
KEY idx_ll_local_looseboard_list(customer_id,status,created_at), KEY idx_ll_local_looseboard_date(customer_id,requested_date,created_at)
|
||||
) ENGINE=InnoDB COMMENT='本土散板订单';
|
||||
|
||||
CREATE TABLE IF NOT EXISTS ll_local_looseboard_history (
|
||||
id BIGINT NOT NULL COMMENT '主键ID', order_id BIGINT NOT NULL COMMENT '本土散板订单ID', customer_id VARCHAR(128) NOT NULL COMMENT '客户租户ID',
|
||||
operation_type VARCHAR(32) NOT NULL COMMENT '操作类型:CREATE、UPDATE或STATUS', from_status VARCHAR(32) NULL COMMENT '变更前状态',
|
||||
to_status VARCHAR(32) NOT NULL COMMENT '变更后状态', operator_id VARCHAR(128) NULL COMMENT '操作人ID',
|
||||
change_detail JSON NULL COMMENT '字段变更前后值JSON', remark VARCHAR(1000) NULL COMMENT '操作备注',
|
||||
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间', PRIMARY KEY(id),
|
||||
KEY idx_ll_local_looseboard_history(customer_id,order_id,created_at)
|
||||
) ENGINE=InnoDB COMMENT='本土散板操作流水';
|
||||
|
||||
-- 入仓库存按 SKU 冻结维度;旧环境只补结构,不由部署自动执行。
|
||||
CREATE TABLE IF NOT EXISTS ll_inbound_plan_carton (
|
||||
id BIGINT NOT NULL COMMENT '箱唛作业主键', customer_id VARCHAR(128) NOT NULL COMMENT '客户租户ID',
|
||||
order_id BIGINT NULL COMMENT '海外首扫确权后的尾程订单ID', source_inbound_detail_id VARCHAR(128) NOT NULL COMMENT '来源入库明细ID',
|
||||
sku_code VARCHAR(128) NULL COMMENT 'SKU编码快照', carton_mark VARCHAR(128) NOT NULL COMMENT '国内冻结箱唛',
|
||||
destination_warehouse_id VARCHAR(128) NULL COMMENT '目的仓ID快照', destination_warehouse_code VARCHAR(128) NULL COMMENT '目的仓编码快照',
|
||||
destination_warehouse_name VARCHAR(255) NULL COMMENT '目的仓名称快照', plan_id BIGINT NULL COMMENT '海外入库计划ID',
|
||||
line_id BIGINT NULL COMMENT '入库计划明细ID', plan_pallet_id BIGINT NULL COMMENT '海外计划托盘ID', warehouse_pallet_id BIGINT NULL COMMENT '海外实物托盘ID',
|
||||
warehouse_location_id BIGINT NULL COMMENT '海外库位ID', pda_task_id BIGINT NULL COMMENT '首次扫码PDA任务ID',
|
||||
status VARCHAR(32) NOT NULL COMMENT '箱唛状态:UNASSIGNED、ON_PALLET、AVAILABLE', version INT NOT NULL DEFAULT 0 COMMENT '乐观锁版本',
|
||||
bound_at DATETIME(3) NULL COMMENT '绑定托盘时间', received_at DATETIME(3) NULL COMMENT '完成入库时间',
|
||||
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '更新时间',
|
||||
PRIMARY KEY(id), UNIQUE KEY uk_ll_inbound_carton_mark(customer_id,carton_mark),
|
||||
KEY idx_ll_inbound_carton_plan(plan_id,status), KEY idx_ll_inbound_carton_pallet(plan_pallet_id,status), KEY idx_ll_inbound_carton_order(order_id,status)
|
||||
) ENGINE=InnoDB 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'),
|
||||
'ALTER TABLE ll_order MODIFY COLUMN source_container_booking_id VARCHAR(128) NULL COMMENT ''来源头程货柜预订ID''',
|
||||
'ALTER TABLE ll_order ADD COLUMN source_container_booking_id VARCHAR(128) NULL COMMENT ''来源头程货柜预订ID'' AFTER source_loading_plan_id'));
|
||||
PREPARE phase4_event_stmt FROM @ddl; EXECUTE phase4_event_stmt; DEALLOCATE PREPARE phase4_event_stmt;
|
||||
|
||||
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'),
|
||||
'ALTER TABLE ll_inbound_pda_task MODIFY COLUMN unload_photo_file_ids JSON NULL COMMENT ''卸柜照片文件ID列表''',
|
||||
'ALTER TABLE ll_inbound_pda_task ADD COLUMN unload_photo_file_ids JSON NULL COMMENT ''卸柜照片文件ID列表'' AFTER completed_at'));
|
||||
PREPARE phase4_event_stmt FROM @ddl; EXECUTE phase4_event_stmt; DEALLOCATE PREPARE phase4_event_stmt;
|
||||
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'),
|
||||
'ALTER TABLE ll_inbound_pda_task MODIFY COLUMN unload_photo_uploaded_at DATETIME(3) NULL COMMENT ''卸柜照片上传成功时间''',
|
||||
'ALTER TABLE ll_inbound_pda_task ADD COLUMN unload_photo_uploaded_at DATETIME(3) NULL COMMENT ''卸柜照片上传成功时间'' AFTER unload_photo_file_ids'));
|
||||
PREPARE phase4_event_stmt FROM @ddl; EXECUTE phase4_event_stmt; DEALLOCATE PREPARE phase4_event_stmt;
|
||||
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'),
|
||||
'ALTER TABLE ll_inbound_pda_task MODIFY COLUMN estimated_empty_return_at DATETIME(3) NULL COMMENT ''预计报空时间(卸柜照片上传成功后两天)''',
|
||||
'ALTER TABLE ll_inbound_pda_task ADD COLUMN estimated_empty_return_at DATETIME(3) NULL COMMENT ''预计报空时间(卸柜照片上传成功后两天)'' AFTER unload_photo_uploaded_at'));
|
||||
PREPARE phase4_event_stmt FROM @ddl; EXECUTE phase4_event_stmt; DEALLOCATE PREPARE phase4_event_stmt;
|
||||
|
||||
SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.statistics WHERE table_schema=DATABASE() AND table_name='ll_inbound_plan' AND index_name='uk_ll_inbound_plan_container'),
|
||||
'ALTER TABLE ll_inbound_plan DROP INDEX uk_ll_inbound_plan_container', 'SELECT 1'));
|
||||
PREPARE phase4_inbound_stmt FROM @ddl; EXECUTE phase4_inbound_stmt; DEALLOCATE PREPARE phase4_inbound_stmt;
|
||||
SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.statistics WHERE table_schema=DATABASE() AND table_name='ll_inbound_plan' AND index_name='idx_ll_inbound_plan_container'),
|
||||
'SELECT 1', 'ALTER TABLE ll_inbound_plan ADD KEY idx_ll_inbound_plan_container (customer_id,container_no)'));
|
||||
PREPARE phase4_inbound_stmt FROM @ddl; EXECUTE phase4_inbound_stmt; DEALLOCATE PREPARE phase4_inbound_stmt;
|
||||
|
||||
SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inventory_balance' AND column_name='sku_code'),
|
||||
'SELECT 1', 'ALTER TABLE ll_inventory_balance ADD COLUMN sku_code VARCHAR(128) NOT NULL DEFAULT ''UNSPECIFIED'' COMMENT ''SKU编码;无SKU使用UNSPECIFIED'' AFTER inbound_code'));
|
||||
PREPARE phase4_inventory_stmt FROM @ddl; EXECUTE phase4_inventory_stmt; DEALLOCATE PREPARE phase4_inventory_stmt;
|
||||
SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inventory_balance' AND column_name='product_name_zh'),
|
||||
'SELECT 1', 'ALTER TABLE ll_inventory_balance ADD COLUMN product_name_zh VARCHAR(255) NULL COMMENT ''中文品名快照'' AFTER sku_code'));
|
||||
PREPARE phase4_inventory_stmt FROM @ddl; EXECUTE phase4_inventory_stmt; DEALLOCATE PREPARE phase4_inventory_stmt;
|
||||
SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inventory_balance' AND column_name='product_name_en'),
|
||||
'SELECT 1', 'ALTER TABLE ll_inventory_balance ADD COLUMN product_name_en VARCHAR(255) NULL COMMENT ''英文品名快照'' AFTER product_name_zh'));
|
||||
PREPARE phase4_inventory_stmt FROM @ddl; EXECUTE phase4_inventory_stmt; DEALLOCATE PREPARE phase4_inventory_stmt;
|
||||
|
||||
SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.statistics WHERE table_schema=DATABASE() AND table_name='ll_inventory_balance' AND index_name='uk_ll_inventory_dimension'),
|
||||
'ALTER TABLE ll_inventory_balance DROP INDEX uk_ll_inventory_dimension', 'SELECT 1'));
|
||||
PREPARE phase4_inventory_stmt FROM @ddl; EXECUTE phase4_inventory_stmt; DEALLOCATE PREPARE phase4_inventory_stmt;
|
||||
SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.statistics WHERE table_schema=DATABASE() AND table_name='ll_inventory_balance' AND index_name='uk_ll_inventory_dimension'),
|
||||
'SELECT 1', 'ALTER TABLE ll_inventory_balance ADD UNIQUE KEY uk_ll_inventory_dimension (customer_id,overseas_warehouse_id,destination_warehouse_code,inbound_order_id,inbound_code,sku_code)'));
|
||||
PREPARE phase4_inventory_stmt FROM @ddl; EXECUTE phase4_inventory_stmt; DEALLOCATE PREPARE phase4_inventory_stmt;
|
||||
SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.statistics WHERE table_schema=DATABASE() AND table_name='ll_inventory_balance' AND index_name='idx_ll_inventory_sku'),
|
||||
'SELECT 1', 'ALTER TABLE ll_inventory_balance ADD KEY idx_ll_inventory_sku (customer_id,sku_code,updated_at)'));
|
||||
PREPARE phase4_inventory_stmt FROM @ddl; EXECUTE phase4_inventory_stmt; DEALLOCATE PREPARE phase4_inventory_stmt;
|
||||
|
||||
SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inbound_stock_receipt' AND column_name='sku_code'),
|
||||
'SELECT 1', 'ALTER TABLE ll_inbound_stock_receipt ADD COLUMN sku_code VARCHAR(128) NOT NULL DEFAULT ''UNSPECIFIED'' COMMENT ''SKU编码快照'' AFTER inbound_code'));
|
||||
PREPARE phase4_inventory_stmt FROM @ddl; EXECUTE phase4_inventory_stmt; DEALLOCATE PREPARE phase4_inventory_stmt;
|
||||
SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inbound_stock_receipt' AND column_name='product_name_zh'),
|
||||
'SELECT 1', 'ALTER TABLE ll_inbound_stock_receipt ADD COLUMN product_name_zh VARCHAR(255) NULL COMMENT ''中文品名快照'' AFTER sku_code'));
|
||||
PREPARE phase4_inventory_stmt FROM @ddl; EXECUTE phase4_inventory_stmt; DEALLOCATE PREPARE phase4_inventory_stmt;
|
||||
SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inbound_stock_receipt' AND column_name='product_name_en'),
|
||||
'SELECT 1', 'ALTER TABLE ll_inbound_stock_receipt ADD COLUMN product_name_en VARCHAR(255) NULL COMMENT ''英文品名快照'' AFTER product_name_zh'));
|
||||
PREPARE phase4_inventory_stmt FROM @ddl; EXECUTE phase4_inventory_stmt; DEALLOCATE PREPARE phase4_inventory_stmt;
|
||||
SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.statistics WHERE table_schema=DATABASE() AND table_name='ll_inbound_stock_receipt' AND index_name='uk_ll_stock_receipt_task_allocation'),
|
||||
'ALTER TABLE ll_inbound_stock_receipt DROP INDEX uk_ll_stock_receipt_task_allocation', 'SELECT 1'));
|
||||
PREPARE phase4_inventory_stmt FROM @ddl; EXECUTE phase4_inventory_stmt; DEALLOCATE PREPARE phase4_inventory_stmt;
|
||||
SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.statistics WHERE table_schema=DATABASE() AND table_name='ll_inbound_stock_receipt' AND index_name='uk_ll_stock_receipt_task_allocation_sku'),
|
||||
'SELECT 1', 'ALTER TABLE ll_inbound_stock_receipt ADD UNIQUE KEY uk_ll_stock_receipt_task_allocation_sku (customer_id,task_id,allocation_id,sku_code)'));
|
||||
PREPARE phase4_inventory_stmt FROM @ddl; EXECUTE phase4_inventory_stmt; DEALLOCATE PREPARE phase4_inventory_stmt;
|
||||
|
||||
SELECT 'PASS' AS migration_status, '海外仓报价、月度汇率、车辆主数据、海外业务与SKU库存结构已就绪' AS message;
|
||||
@@ -86,6 +86,7 @@ baiyue-firstleg/
|
||||
| 文件夹 | 注册服务名 | 端口 | 数据库 | 当前职责与实现情况 |
|
||||
| --- | --- | ---: | --- | --- |
|
||||
| `gateway` | `gateway` | 9000 | 无 | 统一路由、CORS、JWT/Redis 会话校验、商户端与平台端访问域隔离、Swagger 聚合 |
|
||||
| `gateway-overseas` | `gateway-overseas` | 9300 | 无 | 海外区域 API 入口、海外 Audience/JWKS 校验及海外服务路由 |
|
||||
| `auth` | `auth-service` | 9100 | `baiyue_auth` | 登录、刷新 Token、退出、商户注册、账号密码和账号内部管理 |
|
||||
| `iam` | `iam-service` | 9201 | `baiyue_iam` | 租户、企业资料、企业审核、成员、角色、权限、按钮权限、个人资料和授权快照 |
|
||||
| `firstleg` | `firstleg-service` | 9202 | `baiyue_firstleg` | 履约任务与分配;二期入库单、测量审核、FBA 计费、费用发布和完成入库编排;不是订单主数据服务 |
|
||||
@@ -94,10 +95,12 @@ baiyue-firstleg/
|
||||
| `file` | `file-service` | 9206 | `baiyue_file` | 企业资质文件、公共异步导出下载、入库测量图片及重量 OCR 调用协作 |
|
||||
| `print` | `print-service` | 9207 | 无 | 无状态 PDF 渲染服务;调用方传入完整标签数据,服务在内存中生成一次性多页 PDF |
|
||||
| `integration-service` | `integration-service` | 9210 | 无 | 百度 OCR、Amazon SP-API 等第三方适配器和启停状态;默认关闭外部调用 |
|
||||
| `event-bridge-service` | `event-bridge-service` | 9300 | `baiyue_event_bridge` | 国内外事件接收、签名/内部令牌保护、幂等 Inbox 和接收结果 |
|
||||
| `lastleg-service` | `lastleg-service` | 9303 | `baiyue_lastleg` | 海外尾程订单、接柜入仓、库存与海外本土业务;不含海外出库和派送 |
|
||||
| `warehouse-overseas-service` | `warehouse-overseas-service` | 9305 | 海外仓资源库 | 海外仓库、库位、托盘和资源锁 |
|
||||
| `event-bridge-service` | `event-bridge-service` | 9312 | `baiyue_event_bridge` | 按 CN/OVERSEAS 双实例部署的跨区事件 Inbox、重试和目标服务投递 |
|
||||
| `audit-service` | `audit-service` | 9700 | `baiyue_audit` | 接收公共审计事件并持久化;平台端可按权限查询已脱敏日志,业务审计发送默认开启 |
|
||||
|
||||
MySQL、Redis、Nacos 是外部基础设施,不属于上述 11 个业务服务,也不应打包进业务镜像。
|
||||
MySQL、Redis、Nacos 是外部基础设施,不属于上述业务服务,也不应打包进业务镜像。
|
||||
|
||||
## 5. 各服务文件夹说明
|
||||
|
||||
|
||||
+1
-1
@@ -36,7 +36,7 @@ public class EventBridgeController {
|
||||
@Value("${baiyue.region:OVERSEAS}")
|
||||
private String region;
|
||||
|
||||
@Operation(summary = "幂等接收事件", description = "当前仅完成数据库接收骨架,不代表生产跨区传输和 mTLS 已开通")
|
||||
@Operation(summary = "幂等接收事件", description = "按当前部署区域校验目标区域、事件信封与 payloadHash,幂等持久化后进入租约保护的重试投递队列")
|
||||
@ApiResponses({
|
||||
@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "200", description = "事件已接收或判定为重复"),
|
||||
@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "401", description = "内部服务令牌无效"),
|
||||
|
||||
+23
-5
@@ -14,6 +14,7 @@ import org.springframework.transaction.annotation.Transactional;
|
||||
import jakarta.annotation.Resource;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.Objects;
|
||||
|
||||
/**
|
||||
* 入站事件校验与幂等落库服务。
|
||||
@@ -32,9 +33,8 @@ public class EventInboxService {
|
||||
if (!envelope.targetRegion().name().equalsIgnoreCase(localRegion)) {
|
||||
throw new IllegalArgumentException("事件目标区域与当前事件桥区域不一致");
|
||||
}
|
||||
boolean currentHash = EventPayloadHash.matches(EventPayloadCanonicalizer.canonicalize(envelope.payload()), envelope.payloadHash());
|
||||
boolean legacyHash = EventPayloadHash.matches(EventPayloadCanonicalizer.canonicalizeLegacy(envelope.payload()), envelope.payloadHash());
|
||||
if (!currentHash && !legacyHash) {
|
||||
if (!EventPayloadHash.matches(
|
||||
EventPayloadCanonicalizer.canonicalize(envelope.payload()), envelope.payloadHash())) {
|
||||
throw new IllegalArgumentException("payloadHash 与规范化事件负载不一致");
|
||||
}
|
||||
BridgeInbox existing = inboxStore.findByEventId(envelope.eventId());
|
||||
@@ -55,12 +55,30 @@ public class EventInboxService {
|
||||
}
|
||||
|
||||
private EventReceiveResult duplicateOrConflict(BridgeInbox existing, EventEnvelope envelope) {
|
||||
if (!existing.getPayloadHash().equalsIgnoreCase(envelope.payloadHash())) {
|
||||
throw new IllegalArgumentException("相同 eventId 对应的 payloadHash 不一致,拒绝覆盖既有事件");
|
||||
if (!sameEnvelope(existing, envelope)) {
|
||||
throw new IllegalArgumentException("相同 eventId 对应的事件信封或 payloadHash 不一致,拒绝覆盖既有事件");
|
||||
}
|
||||
return new EventReceiveResult(envelope.eventId(), "重复", existing.getReceivedAt());
|
||||
}
|
||||
|
||||
private boolean sameEnvelope(BridgeInbox existing, EventEnvelope envelope) {
|
||||
return existing.getPayloadHash() != null
|
||||
&& existing.getPayloadHash().equalsIgnoreCase(envelope.payloadHash())
|
||||
&& Objects.equals(existing.getEventType(), envelope.eventType())
|
||||
&& Objects.equals(existing.getEventVersion(), envelope.eventVersion())
|
||||
&& Objects.equals(existing.getSourceRegion(), envelope.sourceRegion().name())
|
||||
&& Objects.equals(existing.getTargetRegion(), envelope.targetRegion().name())
|
||||
&& Objects.equals(existing.getCustomerId(), envelope.tenantId())
|
||||
&& Objects.equals(existing.getAggregateType(), envelope.aggregateType())
|
||||
&& Objects.equals(existing.getAggregateId(), envelope.aggregateId())
|
||||
&& Objects.equals(existing.getAggregateVersion(), envelope.aggregateVersion())
|
||||
&& Objects.equals(existing.getHandoverVersion(), envelope.handoverVersion())
|
||||
&& Objects.equals(existing.getOccurredAt(), envelope.occurredAt())
|
||||
&& Objects.equals(existing.getTraceId(), envelope.traceId())
|
||||
&& Objects.equals(existing.getCorrelationId(), envelope.correlationId())
|
||||
&& Objects.equals(existing.getCausationId(), envelope.causationId());
|
||||
}
|
||||
|
||||
private BridgeInbox toInbox(EventEnvelope envelope) {
|
||||
BridgeInbox inbox = new BridgeInbox();
|
||||
inbox.setEventId(envelope.eventId());
|
||||
|
||||
@@ -19,7 +19,7 @@ spring:
|
||||
password: ${EVENT_BRIDGE_DB_PASSWORD:123456}
|
||||
cloud:
|
||||
nacos:
|
||||
server-addr: ${NACOS_SERVER_ADDR:127.0.0.1:8858}
|
||||
server-addr: ${NACOS_SERVER_ADDR:127.0.0.1:8848}
|
||||
username: ${NACOS_USERNAME:nacos}
|
||||
password: ${NACOS_PASSWORD:nacos}
|
||||
discovery:
|
||||
@@ -31,7 +31,7 @@ spring:
|
||||
baiyue:
|
||||
region: ${REGION_CODE:OVERSEAS}
|
||||
event-dispatcher:
|
||||
consumer-url: ${LASTLEG_CONSUMER_URL:http://127.0.0.1:9303/internal/lastleg/events}
|
||||
consumer-url: ${EVENT_CONSUMER_URL:${LASTLEG_CONSUMER_URL:http://127.0.0.1:9303/internal/lastleg/events}}
|
||||
fixed-delay: ${EVENT_DISPATCH_FIXED_DELAY:3000}
|
||||
batch-size: ${EVENT_DISPATCH_BATCH_SIZE:50}
|
||||
max-attempts: ${EVENT_DISPATCH_MAX_ATTEMPTS:10}
|
||||
|
||||
+36
-7
@@ -36,7 +36,7 @@ class EventInboxServiceTest {
|
||||
}
|
||||
|
||||
@Test
|
||||
void 数字规范化上线前的历史摘要仍可接收() {
|
||||
void 拒绝旧数字规范化摘要() {
|
||||
EventEnvelope source = envelope("sha256:placeholder");
|
||||
source.payload().withObject("/snapshot").put("weight", 30.0).put("volume", 0.3150);
|
||||
String legacyHash = EventPayloadHash.sha256(EventPayloadCanonicalizer.canonicalizeLegacy(source.payload()));
|
||||
@@ -45,13 +45,15 @@ class EventInboxServiceTest {
|
||||
source.aggregateType(), source.aggregateId(), source.aggregateVersion(), source.handoverVersion(),
|
||||
source.traceId(), source.correlationId(), source.causationId(), source.payload(), legacyHash);
|
||||
|
||||
assertThat(service.receive(envelope).result()).isEqualTo("已接收");
|
||||
assertThatThrownBy(() -> service.receive(envelope))
|
||||
.isInstanceOf(IllegalArgumentException.class)
|
||||
.hasMessageContaining("payloadHash");
|
||||
}
|
||||
|
||||
@Test
|
||||
void 相同事件和摘要重复接收不再次写入() {
|
||||
EventEnvelope envelope = withHash(envelope("sha256:placeholder"));
|
||||
BridgeInbox existing = existing(envelope.payloadHash());
|
||||
BridgeInbox existing = existing(envelope, envelope.payloadHash());
|
||||
when(store.findByEventId(envelope.eventId())).thenReturn(existing);
|
||||
|
||||
assertThat(service.receive(envelope).result()).isEqualTo("重复");
|
||||
@@ -60,17 +62,31 @@ class EventInboxServiceTest {
|
||||
@Test
|
||||
void 相同事件但摘要不同时拒绝覆盖() {
|
||||
EventEnvelope envelope = withHash(envelope("sha256:placeholder"));
|
||||
when(store.findByEventId(envelope.eventId())).thenReturn(existing("sha256:" + "0".repeat(64)));
|
||||
when(store.findByEventId(envelope.eventId())).thenReturn(existing(envelope, "sha256:" + "0".repeat(64)));
|
||||
|
||||
assertThatThrownBy(() -> service.receive(envelope))
|
||||
.isInstanceOf(IllegalArgumentException.class)
|
||||
.hasMessageContaining("拒绝覆盖");
|
||||
}
|
||||
|
||||
@Test
|
||||
void 相同事件和摘要但聚合标识不同时拒绝覆盖() {
|
||||
EventEnvelope envelope = withHash(envelope("sha256:placeholder"));
|
||||
EventEnvelope conflicting = new EventEnvelope(envelope.eventId(), envelope.eventType(), envelope.eventVersion(),
|
||||
envelope.occurredAt(), envelope.sourceRegion(), envelope.targetRegion(), envelope.tenantId(),
|
||||
envelope.aggregateType(), "DIFFERENT", envelope.aggregateVersion(), envelope.handoverVersion(),
|
||||
envelope.traceId(), envelope.correlationId(), envelope.causationId(), envelope.payload(), envelope.payloadHash());
|
||||
when(store.findByEventId(conflicting.eventId())).thenReturn(existing(envelope, envelope.payloadHash()));
|
||||
|
||||
assertThatThrownBy(() -> service.receive(conflicting))
|
||||
.isInstanceOf(IllegalArgumentException.class)
|
||||
.hasMessageContaining("事件信封");
|
||||
}
|
||||
|
||||
@Test
|
||||
void 并发插入冲突后按既有记录判定为重复() {
|
||||
EventEnvelope envelope = withHash(envelope("sha256:placeholder"));
|
||||
when(store.findByEventId(envelope.eventId())).thenReturn(null, existing(envelope.payloadHash()));
|
||||
when(store.findByEventId(envelope.eventId())).thenReturn(null, existing(envelope, envelope.payloadHash()));
|
||||
doThrow(new DuplicateKeyException("并发重复")).when(store).insert(any());
|
||||
|
||||
assertThat(service.receive(envelope).result()).isEqualTo("重复");
|
||||
@@ -109,9 +125,22 @@ class EventInboxServiceTest {
|
||||
source.correlationId(), source.causationId(), source.payload(), hash);
|
||||
}
|
||||
|
||||
private BridgeInbox existing(String hash) {
|
||||
private BridgeInbox existing(EventEnvelope envelope, String hash) {
|
||||
BridgeInbox inbox = new BridgeInbox();
|
||||
inbox.setEventId("550e8400-e29b-41d4-a716-446655440000");
|
||||
inbox.setEventId(envelope.eventId());
|
||||
inbox.setEventType(envelope.eventType());
|
||||
inbox.setEventVersion(envelope.eventVersion());
|
||||
inbox.setSourceRegion(envelope.sourceRegion().name());
|
||||
inbox.setTargetRegion(envelope.targetRegion().name());
|
||||
inbox.setCustomerId(envelope.tenantId());
|
||||
inbox.setAggregateType(envelope.aggregateType());
|
||||
inbox.setAggregateId(envelope.aggregateId());
|
||||
inbox.setAggregateVersion(envelope.aggregateVersion());
|
||||
inbox.setHandoverVersion(envelope.handoverVersion());
|
||||
inbox.setOccurredAt(envelope.occurredAt());
|
||||
inbox.setTraceId(envelope.traceId());
|
||||
inbox.setCorrelationId(envelope.correlationId());
|
||||
inbox.setCausationId(envelope.causationId());
|
||||
inbox.setPayloadHash(hash);
|
||||
inbox.setReceivedAt(LocalDateTime.of(2026, 7, 29, 18, 0));
|
||||
return inbox;
|
||||
|
||||
+4
-2
@@ -28,8 +28,9 @@
|
||||
| 货柜与智能排柜 | `controller/PlatformContainerController.java`、`PlatformSmartLoadingController.java`、`loading/` |
|
||||
| PDA 拣货与装柜 | `controller/PlatformLoadingPdaController.java`、`loading/controller/PlatformLoadingDeliveryController.java` |
|
||||
| 装柜完成跨区事件 | `service/LoadingCompletedOutboxService.java`、`LoadingCompletedOutboxRelay.java`、`repository/LoadingCompletedOutboxRepository.java`、`client/EventBridgeClient.java`;事件模型见 `../common-event-contract/AGENTS.md` |
|
||||
| 海外卸柜完成事件回写 | 内部入口 `controller/InternalOverseasContainerEventController.java`、消费者 `service/OverseasContainerEventConsumer.java`、Inbox `entity/ContainerEventInboxEntity.java` 与 `mapper/ContainerEventInboxMapper.java`;事件模型见 `../common-event-contract/AGENTS.md` |
|
||||
| 三期海运订舱与出仓前船期资料 | `oceanbooking/controller/PlatformOceanBookingController.java`、`oceanbooking/service/OceanBookingService.java`、`portschedule/controller/PlatformPortScheduleController.java`;订舱契约见 `src/main/resources/openapi/ocean-bookings-v1.yaml`,表结构见 `src/main/resources/db/phase3-ocean-booking-schema.sql` |
|
||||
| 四期国内出仓后船节点定时查询 | 现有 `portschedule/controller/PlatformCarrierScheduleQueryController.java`、`service/CarrierScheduleQueryService.java` 及查询任务、尝试、历史整体归四期;新增定时编排、节点事实或到港衔接继续归该子域,外部船司/轨迹调用经 `integration-service/`;范围见 `../../docs/四期/README.md` |
|
||||
| 四期国内出仓后船节点定时查询 | `outbound/dispatch/service/OutboundDispatchService.java` 在确认出仓事务内幂等创建现有 `CarrierScheduleQuery`;编排与租约 `portschedule/service/CarrierScheduleQueryScheduler.java`,任务/标准节点事实/历史 `CarrierScheduleQueryService.java`,内部停止入口 `controller/InternalCarrierScheduleQueryController.java`,平台只读入口 `PlatformCarrierScheduleQueryController.java`;外部船司/轨迹调用经 `integration-service/`;范围见 `../../docs/四期/README.md` |
|
||||
| Firstleg 业务单据编号 | `service/FirstlegDocumentNumberGenerator.java`;当前支持 `OB`、`TA`、`CB`、`CAB`、`LP` 前缀,按北京时间每日独立编号;具体业务归属以各调用服务为准 |
|
||||
| 拖车与出仓调度 | `controller/PlatformTruckAppointmentController.java`、`outbound/dispatch/controller/PlatformOutboundDispatchController.java` |
|
||||
| 报关协同 | `customs/controller/PlatformCustomsController.java`、`MerchantCustomsController.java` |
|
||||
@@ -49,7 +50,8 @@
|
||||
- 订舱状态固定为 `DRAFT → RELEASED`:草稿允许资料不完整并可替换或删除,放舱时校验完整持久化聚合,放舱后永久只读。创建草稿和放舱都只维护订舱聚合、三类参与方及柜型需求,不创建真实 `containerNo`、货柜行或分配关系;真实货柜只在后续提柜关联时占用已放舱柜型需求。
|
||||
- 订舱柜型需求至少一项、最多 20 项,数量必须为正整数,容量和限重必须匹配平台柜型规格。起运仓必须是启用的国内中转仓;物流渠道必须来自该仓当前 ACTIVE 价格版本,服务端保存渠道名称快照,渠道失效时失败关闭。
|
||||
- 订舱号统一使用 `OB-yyMMdd-####`,按北京时间创建日每日独立递增且单日最多 9999;不得以实体 ID 拼接生成。放舱不要求真实柜号,也不得为满足放舱校验伪造柜号。
|
||||
- 四期船节点任务从确认柜子离开国内仓库后启用,必须绑定真实订舱和柜次,保存来源、节点观测时间、最近成功结果、失败尝试及完整历史,并以幂等方式把确认到港/可提柜结果衔接给海外区域。现有 `CarrierScheduleQuery` 及“每 3 天一次、窗口最多 3 次”约束属于四期;外部失败不得推进业务节点,不得在三期另建查询任务。
|
||||
- 四期船节点任务从确认柜子离开国内仓库后启用,必须绑定真实订舱和柜次;标准节点固定为 `DEPARTED_WAREHOUSE → AT_ORIGIN_PORT → DEPARTED_ORIGIN_PORT → IN_TRANSIT → ARRIVED → PICKUP_READY`,保存来源、节点观测时间、最近成功结果、失败尝试及完整历史。现有 `CarrierScheduleQuery` 及“正常每 3 天一次、窗口最多 3 次、失败指数退避”约束属于四期;租约、调度尝试和内部停止入口不得暴露为平台页面 API,外部失败不得推进业务节点,不得在三期另建查询任务。
|
||||
- 海外卸柜完成事件只接受 `OVERSEAS -> CN` 的 `OVERSEAS_CONTAINER_UNLOADED` V1,并通过内部服务令牌进入。消费者必须校验信封、Payload 哈希、来源货柜和时间顺序,以 event ID + Payload 哈希幂等,使用事件聚合版本阻止乱序覆盖;预计报空时间固定按卸柜照片上传时间加两天计算,实际报空时间可为空。
|
||||
- Firstleg 只拥有业务费用及履约事实;Finance 拥有费用入池、账期、账单、收款、成本分摊结果和财务状态。修改费用来源版本、发布条件、柜次归属或体积快照时同步检查 `../finance/src/main/java/com/baiyue/finance/client/FirstlegFeeSourceClient.java`、同目录 `FirstlegCostSnapshotClient.java` 及双方契约测试,不在 Firstleg 复制财务状态机。
|
||||
|
||||
## 三期装柜与 PDA 资源结算
|
||||
|
||||
+28
@@ -0,0 +1,28 @@
|
||||
package com.baiyue.firstleg.client;
|
||||
|
||||
import org.springframework.cloud.openfeign.FeignClient;
|
||||
import org.springframework.web.bind.annotation.PostMapping;
|
||||
import org.springframework.web.bind.annotation.RequestBody;
|
||||
import org.springframework.web.bind.annotation.RequestHeader;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.List;
|
||||
|
||||
@FeignClient(name = "integration-service", contextId = "carrierScheduleIntegrationClient",
|
||||
url = "${baiyue.integration-service.url:}")
|
||||
public interface CarrierScheduleIntegrationClient {
|
||||
@PostMapping("/internal/integrations/carrier-schedules/query")
|
||||
Envelope<QueryResult> query(@RequestHeader("X-Internal-Service-Token") String token,
|
||||
@RequestBody QueryRequest request);
|
||||
|
||||
record Envelope<T>(String code, String message, T data) {
|
||||
public boolean successful() { return "200".equals(code); }
|
||||
}
|
||||
record QueryRequest(String carrierCode, String trackingType, String trackingNo) { }
|
||||
record QueryResult(String providerCode, String outcome, List<Observation> observations,
|
||||
String errorCode, String errorMessage, LocalDateTime providerObservedAt) { }
|
||||
record Observation(String providerEventId, String providerNodeCode, String standardNode,
|
||||
LocalDateTime observedAt, LocalDateTime estimatedAt, LocalDateTime actualAt,
|
||||
String locationCode, String locationName, String sourceReference,
|
||||
String rawPayloadHash) { }
|
||||
}
|
||||
@@ -14,6 +14,14 @@ public final class ContainerOverviewDtos {
|
||||
List<TimelineEntry> timeline) {}
|
||||
public record ContainerBookingEntityView(Long id, String bookingNo, String cabinetNo, String containerNo,
|
||||
String status, Long warehouseId, String warehouseName, String voyageNo, String vesselName,
|
||||
String billOfLadingNo, Long shippingCompanyId, String shippingCompanyName,
|
||||
String departurePortCode, String departurePortName, String destinationPortCode,
|
||||
String destinationPortName, LocalDateTime estimatedSailingAt, LocalDateTime actualSailingAt,
|
||||
LocalDateTime estimatedPortArrivalAt, LocalDateTime actualPortArrivalAt,
|
||||
LocalDateTime estimatedWarehouseArrivalAt, LocalDateTime actualWarehouseArrivalAt,
|
||||
LocalDateTime unloadingCompletedAt, LocalDateTime unloadingPhotoUploadedAt,
|
||||
LocalDateTime estimatedEmptyReportedAt, LocalDateTime actualEmptyReportedAt,
|
||||
Long emptyReportSourceVersion,
|
||||
Integer version) {}
|
||||
public record PdaSummary(Long taskId, Long loadingPlanId, Long containerBookingId, String taskStatus,
|
||||
String workflowStatus, Integer taskVersion, Integer itemCount, Integer completedItemCount, Integer unresolvedItemCount,
|
||||
|
||||
@@ -116,7 +116,7 @@ public interface ContainerBookingMapper extends BaseMapper<ContainerBookingEntit
|
||||
actual_empty_reported_at=#{actualEmptyReportedAt}, empty_report_source_version=#{sourceVersion},
|
||||
updated_by=0, updated_by_name='OVERSEAS_EVENT', updated_at=CURRENT_TIMESTAMP(3), version=version+1
|
||||
WHERE id=#{id} AND record_status='ACTIVE'
|
||||
AND (empty_report_source_version IS NULL OR empty_report_source_version < #{sourceVersion})
|
||||
AND (empty_report_source_version IS NULL OR empty_report_source_version < #{sourceVersion})
|
||||
""")
|
||||
int applyEmptyReport(@Param("id") long id,
|
||||
@Param("unloadingCompletedAt") java.time.LocalDateTime unloadingCompletedAt,
|
||||
|
||||
+16
-1
@@ -10,6 +10,8 @@ import com.baiyue.firstleg.outbound.dispatch.entity.OutboundDispatchHistoryEntit
|
||||
import com.baiyue.firstleg.outbound.dispatch.gate.OutboundDispatchGateContributor;
|
||||
import com.baiyue.firstleg.outbound.dispatch.mapper.OutboundDispatchReadMapper;
|
||||
import com.baiyue.firstleg.outbound.dispatch.repository.OutboundDispatchRepository;
|
||||
import com.baiyue.firstleg.portschedule.service.CarrierScheduleQueryService;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.dao.DuplicateKeyException;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
@@ -26,10 +28,14 @@ public class OutboundDispatchService {
|
||||
|
||||
private final OutboundDispatchRepository repository;
|
||||
private final Map<String, OutboundDispatchGateContributor> contributors;
|
||||
private final CarrierScheduleQueryService carrierSchedules;
|
||||
|
||||
@Autowired
|
||||
public OutboundDispatchService(OutboundDispatchRepository repository,
|
||||
List<OutboundDispatchGateContributor> contributors) {
|
||||
List<OutboundDispatchGateContributor> contributors,
|
||||
CarrierScheduleQueryService carrierSchedules) {
|
||||
this.repository = repository;
|
||||
this.carrierSchedules = carrierSchedules;
|
||||
Map<String, OutboundDispatchGateContributor> indexed = new HashMap<>();
|
||||
for (OutboundDispatchGateContributor contributor : contributors) {
|
||||
if (indexed.put(contributor.code(), contributor) != null) {
|
||||
@@ -39,6 +45,11 @@ public class OutboundDispatchService {
|
||||
this.contributors = Map.copyOf(indexed);
|
||||
}
|
||||
|
||||
public OutboundDispatchService(OutboundDispatchRepository repository,
|
||||
List<OutboundDispatchGateContributor> contributors) {
|
||||
this(repository, contributors, null);
|
||||
}
|
||||
|
||||
public List<Aggregate> list(Query query) {
|
||||
requireSelector(query);
|
||||
return repository.bindings(query).stream().map(this::aggregate).toList();
|
||||
@@ -73,6 +84,10 @@ public class OutboundDispatchService {
|
||||
}
|
||||
history(state, "CONFIRM_DISPATCH", OutboundDispatchStatus.READY_FOR_DISPATCH.name(),
|
||||
OutboundDispatchStatus.WAITING_FOR_LOADING.name(), request.remark(), operatorId, operatorName);
|
||||
if (carrierSchedules != null) {
|
||||
carrierSchedules.ensureFromOutboundConfirm(binding.tenantId(), before.oceanBookingId(),
|
||||
binding.containerBookingId(), operatorId, operatorName);
|
||||
}
|
||||
if (repository.completeOperation(state.getId(), operatorId, "CONFIRM_DISPATCH", key) != 1) throw concurrent();
|
||||
return detail(query);
|
||||
}
|
||||
|
||||
+48
@@ -0,0 +1,48 @@
|
||||
package com.baiyue.firstleg.portschedule.controller;
|
||||
|
||||
import com.baiyue.common.core.api.ApiResponse;
|
||||
import com.baiyue.common.core.api.ResultCode;
|
||||
import com.baiyue.common.core.exception.BizException;
|
||||
import com.baiyue.firstleg.portschedule.dto.CarrierScheduleQueryDtos.Detail;
|
||||
import com.baiyue.firstleg.portschedule.service.CarrierScheduleQueryService;
|
||||
import jakarta.validation.Valid;
|
||||
import jakarta.validation.constraints.NotBlank;
|
||||
import jakarta.validation.constraints.Positive;
|
||||
import jakarta.validation.constraints.Size;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.web.bind.annotation.PathVariable;
|
||||
import org.springframework.web.bind.annotation.PostMapping;
|
||||
import org.springframework.web.bind.annotation.RequestBody;
|
||||
import org.springframework.web.bind.annotation.RequestHeader;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.security.MessageDigest;
|
||||
|
||||
@RestController
|
||||
@RequestMapping("/internal/firstleg/v1/carrier-schedule-queries")
|
||||
public class InternalCarrierScheduleQueryController {
|
||||
private final CarrierScheduleQueryService service;
|
||||
@Value("${baiyue.security.internal-service-token}") private String expectedToken;
|
||||
|
||||
public InternalCarrierScheduleQueryController(CarrierScheduleQueryService service) { this.service = service; }
|
||||
|
||||
@PostMapping("/{queryId}/stop")
|
||||
public ApiResponse<Detail> stop(@RequestHeader(value = "X-Internal-Service-Token", required = false) String token,
|
||||
@PathVariable @Positive long queryId,
|
||||
@Valid @RequestBody StopRequest request) {
|
||||
authenticate(token);
|
||||
return ApiResponse.success(service.stop(queryId, request.reason()));
|
||||
}
|
||||
|
||||
private void authenticate(String token) {
|
||||
if (token == null || expectedToken == null || expectedToken.isBlank()
|
||||
|| !MessageDigest.isEqual(token.getBytes(StandardCharsets.UTF_8),
|
||||
expectedToken.getBytes(StandardCharsets.UTF_8))) {
|
||||
throw new BizException(ResultCode.FORBIDDEN, "内部服务令牌无效");
|
||||
}
|
||||
}
|
||||
|
||||
public record StopRequest(@NotBlank @Size(max = 500) String reason) { }
|
||||
}
|
||||
+2
-15
@@ -4,14 +4,13 @@ import com.baiyue.common.audit.BusinessAudit;
|
||||
import com.baiyue.common.core.api.ApiResponse;
|
||||
import com.baiyue.common.core.api.PageResult;
|
||||
import com.baiyue.common.security.AuthenticatedUser;
|
||||
import com.baiyue.firstleg.portschedule.dto.CarrierScheduleQueryDtos.AttemptRequest;
|
||||
import com.baiyue.firstleg.portschedule.dto.CarrierScheduleQueryDtos.CarrierDescriptor;
|
||||
import com.baiyue.firstleg.portschedule.dto.CarrierScheduleQueryDtos.CreateRequest;
|
||||
import com.baiyue.firstleg.portschedule.dto.CarrierScheduleQueryDtos.Detail;
|
||||
import com.baiyue.firstleg.portschedule.dto.CarrierScheduleQueryDtos.ManualResultRequest;
|
||||
import com.baiyue.firstleg.portschedule.dto.CarrierScheduleQueryDtos.Query;
|
||||
import com.baiyue.firstleg.portschedule.dto.CarrierScheduleQueryDtos.QueryView;
|
||||
import com.baiyue.firstleg.portschedule.entity.CarrierScheduleQueryHistoryEntity;
|
||||
import com.baiyue.firstleg.portschedule.dto.CarrierScheduleQueryDtos.History;
|
||||
import com.baiyue.firstleg.portschedule.service.CarrierScheduleQueryService;
|
||||
import com.baiyue.firstleg.service.FirstlegScopeGuard;
|
||||
import io.swagger.v3.oas.annotations.Operation;
|
||||
@@ -66,7 +65,7 @@ public class PlatformCarrierScheduleQueryController {
|
||||
|
||||
@GetMapping("/{queryId}/history")
|
||||
@PreAuthorize("hasAuthority('platform:firstleg:port-schedule-query:list')")
|
||||
public ApiResponse<List<CarrierScheduleQueryHistoryEntity>> history(
|
||||
public ApiResponse<List<History>> history(
|
||||
@AuthenticationPrincipal AuthenticatedUser user, @PathVariable @Positive long queryId) {
|
||||
FirstlegScopeGuard.platform(user, service.customerId(queryId));
|
||||
return ApiResponse.success(service.history(queryId));
|
||||
@@ -83,18 +82,6 @@ public class PlatformCarrierScheduleQueryController {
|
||||
return ApiResponse.success(service.create(user.userId(), user.username(), key, request));
|
||||
}
|
||||
|
||||
@PostMapping("/{queryId}/attempts")
|
||||
@PreAuthorize("hasAuthority('platform:firstleg:port-schedule-query:attempt')")
|
||||
@BusinessAudit(event = "CARRIER_SCHEDULE_QUERY_ATTEMPT", resourceType = "CARRIER_SCHEDULE_QUERY",
|
||||
resourceId = "#queryId", description = "执行船司官网查询策略")
|
||||
public ApiResponse<Detail> attempt(@AuthenticationPrincipal AuthenticatedUser user,
|
||||
@PathVariable @Positive long queryId,
|
||||
@RequestHeader("Idempotency-Key") String key,
|
||||
@Valid @RequestBody AttemptRequest request) {
|
||||
FirstlegScopeGuard.platform(user, service.customerId(queryId));
|
||||
return ApiResponse.success(service.attempt(queryId, user.userId(), user.username(), key, request));
|
||||
}
|
||||
|
||||
@PostMapping("/{queryId}/manual-results")
|
||||
@PreAuthorize("hasAuthority('platform:firstleg:port-schedule-query:manual')")
|
||||
@BusinessAudit(event = "CARRIER_SCHEDULE_QUERY_MANUAL_RESULT", resourceType = "CARRIER_SCHEDULE_QUERY",
|
||||
|
||||
+29
@@ -0,0 +1,29 @@
|
||||
package com.baiyue.firstleg.portschedule.domain;
|
||||
|
||||
import com.baiyue.common.core.api.ResultCode;
|
||||
import com.baiyue.common.core.exception.BizException;
|
||||
|
||||
import java.util.Locale;
|
||||
|
||||
/** Provider-independent maritime milestones in monotonic business order. */
|
||||
public enum CarrierScheduleNode {
|
||||
DEPARTED_WAREHOUSE(10),
|
||||
AT_ORIGIN_PORT(20),
|
||||
DEPARTED_ORIGIN_PORT(30),
|
||||
IN_TRANSIT(40),
|
||||
ARRIVED(50),
|
||||
PICKUP_READY(60);
|
||||
|
||||
private final int sequence;
|
||||
|
||||
CarrierScheduleNode(int sequence) { this.sequence = sequence; }
|
||||
|
||||
public int sequence() { return sequence; }
|
||||
|
||||
public static CarrierScheduleNode require(String value) {
|
||||
try { return valueOf(value == null ? "" : value.trim().toUpperCase(Locale.ROOT)); }
|
||||
catch (IllegalArgumentException exception) {
|
||||
throw new BizException(ResultCode.BAD_REQUEST, "不支持的船节点:" + value);
|
||||
}
|
||||
}
|
||||
}
|
||||
+31
-10
@@ -1,8 +1,5 @@
|
||||
package com.baiyue.firstleg.portschedule.dto;
|
||||
|
||||
import com.baiyue.firstleg.portschedule.entity.CarrierScheduleQueryAttemptEntity;
|
||||
import com.baiyue.firstleg.portschedule.entity.CarrierScheduleQueryEntity;
|
||||
import com.baiyue.firstleg.portschedule.entity.CarrierScheduleQueryHistoryEntity;
|
||||
import jakarta.validation.constraints.Max;
|
||||
import jakarta.validation.constraints.Min;
|
||||
import jakarta.validation.constraints.NotBlank;
|
||||
@@ -33,20 +30,44 @@ public final class CarrierScheduleQueryDtos {
|
||||
@NotBlank @Size(max = 128) String trackingNo,
|
||||
@Size(max = 1000) String remark) {}
|
||||
|
||||
public record AttemptRequest(@NotNull @Min(0) Integer expectedVersion,
|
||||
@Size(max = 1000) String reason) {}
|
||||
|
||||
public record ManualResultRequest(@NotNull @Min(0) Integer expectedVersion,
|
||||
@NotNull @Positive Long portScheduleId,
|
||||
@NotBlank @Size(max = 1000) String reason) {}
|
||||
|
||||
public record ProviderObservationRequest(@NotBlank @Size(max = 32) String providerCode,
|
||||
@NotBlank @Size(max = 128) String providerEventId,
|
||||
@NotBlank @Size(max = 64) String providerNodeCode,
|
||||
@NotBlank @Size(max = 32) String standardNode,
|
||||
@NotNull LocalDateTime observedAt,
|
||||
LocalDateTime estimatedAt,
|
||||
LocalDateTime actualAt,
|
||||
@Size(max = 32) String locationCode,
|
||||
@Size(max = 128) String locationName,
|
||||
@Size(max = 500) String sourceReference,
|
||||
@NotBlank @Size(min = 64, max = 64) String rawPayloadHash) {}
|
||||
|
||||
public record CarrierDescriptor(String carrierCode, String carrierName, String officialUrl,
|
||||
String accessMode, boolean automated, boolean coverageGap,
|
||||
String coverageGapCode, String manualReason) {}
|
||||
|
||||
public record QueryView(CarrierScheduleQueryEntity query, boolean expired,
|
||||
int maxAttempts, LocalDateTime nextAllowedAt) {}
|
||||
public record Task(Long id, String queryNo, Long customerId, Long oceanBookingId,
|
||||
Long containerBookingId, String carrierCode, String trackingType, String trackingNo,
|
||||
String officialUrl, String accessMode, String status, String triggerSource,
|
||||
String latestStandardNode, LocalDateTime latestNodeObservedAt,
|
||||
LocalDateTime lastSuccessAt, LocalDateTime sourceUpdatedAt, LocalDateTime expiresAt,
|
||||
Long resultPortScheduleId, String manualReason, String lastErrorCode,
|
||||
String lastErrorMessage, LocalDateTime stoppedAt, String stopReason, Integer version) {}
|
||||
|
||||
public record Detail(QueryView query, List<CarrierScheduleQueryAttemptEntity> attempts,
|
||||
List<CarrierScheduleQueryHistoryEntity> histories) {}
|
||||
public record QueryView(Task query, boolean expired) {}
|
||||
|
||||
public record NodeFact(Long id, String providerCode, String providerNodeCode, String standardNode,
|
||||
int nodeSequence, LocalDateTime observedAt, LocalDateTime estimatedAt,
|
||||
LocalDateTime actualAt, String locationCode, String locationName,
|
||||
String sourceReference, String rawPayloadHash, LocalDateTime createdAt) {}
|
||||
|
||||
public record History(Long id, String action, Integer fromVersion, Integer toVersion, String reason,
|
||||
Long operatorId, String operatorName, LocalDateTime createdAt) {}
|
||||
|
||||
/** Frontend detail intentionally excludes scheduler leases and raw query attempts. */
|
||||
public record Detail(QueryView query, List<NodeFact> nodes, List<History> histories) {}
|
||||
}
|
||||
|
||||
+32
@@ -0,0 +1,32 @@
|
||||
package com.baiyue.firstleg.portschedule.entity;
|
||||
|
||||
import com.baomidou.mybatisplus.annotation.FieldFill;
|
||||
import com.baomidou.mybatisplus.annotation.IdType;
|
||||
import com.baomidou.mybatisplus.annotation.TableField;
|
||||
import com.baomidou.mybatisplus.annotation.TableId;
|
||||
import com.baomidou.mybatisplus.annotation.TableName;
|
||||
import lombok.Getter;
|
||||
import lombok.Setter;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
@Getter
|
||||
@Setter
|
||||
@TableName("fl_carrier_schedule_node_fact")
|
||||
public class CarrierScheduleNodeFactEntity {
|
||||
@TableId(type = IdType.ASSIGN_ID) private Long id;
|
||||
private Long queryId;
|
||||
private String eventKey;
|
||||
private String providerCode;
|
||||
private String providerNodeCode;
|
||||
private String standardNode;
|
||||
private Integer nodeSequence;
|
||||
private LocalDateTime observedAt;
|
||||
private LocalDateTime estimatedAt;
|
||||
private LocalDateTime actualAt;
|
||||
private String locationCode;
|
||||
private String locationName;
|
||||
private String sourceReference;
|
||||
private String rawPayloadHash;
|
||||
@TableField(fill = FieldFill.INSERT) private LocalDateTime createdAt;
|
||||
}
|
||||
+8
@@ -26,6 +26,14 @@ public class CarrierScheduleQueryEntity {
|
||||
private String officialUrl;
|
||||
private String accessMode;
|
||||
private String status;
|
||||
private String triggerSource;
|
||||
private String latestStandardNode;
|
||||
private LocalDateTime latestNodeObservedAt;
|
||||
private LocalDateTime stoppedAt;
|
||||
private String stopReason;
|
||||
private String leaseOwner;
|
||||
private LocalDateTime leaseUntil;
|
||||
private Integer consecutiveFailures;
|
||||
private LocalDateTime windowStartedAt;
|
||||
private Integer attemptCount;
|
||||
private LocalDateTime nextAllowedAt;
|
||||
|
||||
+23
@@ -0,0 +1,23 @@
|
||||
package com.baiyue.firstleg.portschedule.mapper;
|
||||
|
||||
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
|
||||
import com.baiyue.firstleg.portschedule.entity.CarrierScheduleNodeFactEntity;
|
||||
import org.apache.ibatis.annotations.Mapper;
|
||||
import org.apache.ibatis.annotations.Insert;
|
||||
import org.apache.ibatis.annotations.Param;
|
||||
|
||||
@Mapper
|
||||
public interface CarrierScheduleNodeFactMapper extends BaseMapper<CarrierScheduleNodeFactEntity> {
|
||||
@Insert("""
|
||||
INSERT INTO fl_carrier_schedule_node_fact
|
||||
(id,query_id,event_key,provider_code,provider_node_code,standard_node,node_sequence,observed_at,
|
||||
estimated_at,actual_at,location_code,location_name,source_reference,raw_payload_hash,created_at)
|
||||
VALUES
|
||||
(#{value.id},#{value.queryId},#{value.eventKey},#{value.providerCode},#{value.providerNodeCode},
|
||||
#{value.standardNode},#{value.nodeSequence},#{value.observedAt},#{value.estimatedAt},#{value.actualAt},
|
||||
#{value.locationCode},#{value.locationName},#{value.sourceReference},#{value.rawPayloadHash},
|
||||
CURRENT_TIMESTAMP(3))
|
||||
ON DUPLICATE KEY UPDATE id=id
|
||||
""")
|
||||
int insertIgnore(@Param("value") CarrierScheduleNodeFactEntity value);
|
||||
}
|
||||
+36
@@ -9,6 +9,9 @@ import org.apache.ibatis.annotations.Param;
|
||||
import org.apache.ibatis.annotations.Select;
|
||||
import org.apache.ibatis.annotations.Update;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.List;
|
||||
|
||||
@Mapper
|
||||
public interface CarrierScheduleQueryMapper extends BaseMapper<CarrierScheduleQueryEntity> {
|
||||
@Select("""
|
||||
@@ -33,9 +36,42 @@ public interface CarrierScheduleQueryMapper extends BaseMapper<CarrierScheduleQu
|
||||
@Select("SELECT * FROM fl_carrier_schedule_query WHERE id=#{id} FOR UPDATE")
|
||||
CarrierScheduleQueryEntity lockById(@Param("id") long id);
|
||||
|
||||
@Select("""
|
||||
SELECT * FROM fl_carrier_schedule_query
|
||||
WHERE customer_id=#{customerId} AND ocean_booking_id=#{oceanBookingId}
|
||||
AND container_booking_id=#{containerBookingId}
|
||||
ORDER BY id DESC LIMIT 1
|
||||
""")
|
||||
CarrierScheduleQueryEntity findByBusinessScope(@Param("customerId") long customerId,
|
||||
@Param("oceanBookingId") long oceanBookingId,
|
||||
@Param("containerBookingId") long containerBookingId);
|
||||
|
||||
@Select("""
|
||||
SELECT * FROM fl_carrier_schedule_query
|
||||
WHERE status IN ('READY','MANUAL_REQUIRED','ARRIVED','PICKUP_READY')
|
||||
AND next_allowed_at IS NOT NULL AND next_allowed_at<=#{now}
|
||||
AND (lease_until IS NULL OR lease_until<#{now})
|
||||
ORDER BY next_allowed_at,id LIMIT #{limit}
|
||||
""")
|
||||
List<CarrierScheduleQueryEntity> selectDue(@Param("now") LocalDateTime now, @Param("limit") int limit);
|
||||
|
||||
@Update("""
|
||||
UPDATE fl_carrier_schedule_query SET lease_owner=#{owner},lease_until=#{leaseUntil},
|
||||
updated_at=CURRENT_TIMESTAMP(3)
|
||||
WHERE id=#{id} AND status IN ('READY','MANUAL_REQUIRED','ARRIVED','PICKUP_READY')
|
||||
AND next_allowed_at IS NOT NULL AND next_allowed_at<=#{now}
|
||||
AND (lease_until IS NULL OR lease_until<#{now})
|
||||
""")
|
||||
int claimLease(@Param("id") long id, @Param("owner") String owner,
|
||||
@Param("now") LocalDateTime now, @Param("leaseUntil") LocalDateTime leaseUntil);
|
||||
|
||||
@Update("""
|
||||
UPDATE fl_carrier_schedule_query
|
||||
SET official_url=#{value.officialUrl},access_mode=#{value.accessMode},status=#{value.status},
|
||||
trigger_source=#{value.triggerSource},latest_standard_node=#{value.latestStandardNode},
|
||||
latest_node_observed_at=#{value.latestNodeObservedAt},stopped_at=#{value.stoppedAt},
|
||||
stop_reason=#{value.stopReason},lease_owner=#{value.leaseOwner},lease_until=#{value.leaseUntil},
|
||||
consecutive_failures=#{value.consecutiveFailures},
|
||||
window_started_at=#{value.windowStartedAt},attempt_count=#{value.attemptCount},
|
||||
next_allowed_at=#{value.nextAllowedAt},last_attempt_at=#{value.lastAttemptAt},
|
||||
last_success_at=#{value.lastSuccessAt},source_updated_at=#{value.sourceUpdatedAt},
|
||||
|
||||
+19
@@ -9,14 +9,17 @@ import com.baiyue.firstleg.portschedule.entity.CarrierScheduleQueryAttemptEntity
|
||||
import com.baiyue.firstleg.portschedule.entity.CarrierScheduleQueryEntity;
|
||||
import com.baiyue.firstleg.portschedule.entity.CarrierScheduleQueryHistoryEntity;
|
||||
import com.baiyue.firstleg.portschedule.entity.CarrierScheduleQueryOperationEntity;
|
||||
import com.baiyue.firstleg.portschedule.entity.CarrierScheduleNodeFactEntity;
|
||||
import com.baiyue.firstleg.portschedule.mapper.CarrierScheduleQueryAttemptMapper;
|
||||
import com.baiyue.firstleg.portschedule.mapper.CarrierScheduleQueryHistoryMapper;
|
||||
import com.baiyue.firstleg.portschedule.mapper.CarrierScheduleQueryMapper;
|
||||
import com.baiyue.firstleg.portschedule.mapper.CarrierScheduleQueryOperationMapper;
|
||||
import com.baiyue.firstleg.portschedule.mapper.CarrierScheduleNodeFactMapper;
|
||||
import jakarta.annotation.Resource;
|
||||
import org.springframework.stereotype.Repository;
|
||||
|
||||
import java.util.List;
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
@Repository
|
||||
public class CarrierScheduleQueryRepository {
|
||||
@@ -24,12 +27,20 @@ public class CarrierScheduleQueryRepository {
|
||||
@Resource private CarrierScheduleQueryAttemptMapper attempts;
|
||||
@Resource private CarrierScheduleQueryHistoryMapper histories;
|
||||
@Resource private CarrierScheduleQueryOperationMapper operations;
|
||||
@Resource private CarrierScheduleNodeFactMapper nodes;
|
||||
|
||||
public PageResult<CarrierScheduleQueryEntity> page(Query query) {
|
||||
return PageResult.from(queries.selectPage(new Page<>(query.page(), query.size()), query));
|
||||
}
|
||||
public CarrierScheduleQueryEntity find(long id) { return queries.selectById(id); }
|
||||
public CarrierScheduleQueryEntity lock(long id) { return queries.lockById(id); }
|
||||
public CarrierScheduleQueryEntity findByBusinessScope(long customerId, long oceanBookingId, long containerBookingId) {
|
||||
return queries.findByBusinessScope(customerId, oceanBookingId, containerBookingId);
|
||||
}
|
||||
public List<CarrierScheduleQueryEntity> due(LocalDateTime now, int limit) { return queries.selectDue(now, limit); }
|
||||
public boolean claimLease(long id, String owner, LocalDateTime now, LocalDateTime leaseUntil) {
|
||||
return queries.claimLease(id, owner, now, leaseUntil) == 1;
|
||||
}
|
||||
public void insert(CarrierScheduleQueryEntity value) { queries.insert(value); }
|
||||
public int updateState(CarrierScheduleQueryEntity value, int version, long operatorId, String operatorName) {
|
||||
return queries.updateState(value, version, operatorId, operatorName);
|
||||
@@ -48,6 +59,14 @@ public class CarrierScheduleQueryRepository {
|
||||
.orderByAsc(CarrierScheduleQueryHistoryEntity::getId));
|
||||
}
|
||||
public void insert(CarrierScheduleQueryHistoryEntity value) { histories.insert(value); }
|
||||
public List<CarrierScheduleNodeFactEntity> nodes(long id) {
|
||||
return nodes.selectList(new LambdaQueryWrapper<CarrierScheduleNodeFactEntity>()
|
||||
.eq(CarrierScheduleNodeFactEntity::getQueryId, id)
|
||||
.orderByAsc(CarrierScheduleNodeFactEntity::getNodeSequence)
|
||||
.orderByAsc(CarrierScheduleNodeFactEntity::getObservedAt)
|
||||
.orderByAsc(CarrierScheduleNodeFactEntity::getId));
|
||||
}
|
||||
public boolean insertIfAbsent(CarrierScheduleNodeFactEntity value) { return nodes.insertIgnore(value) == 1; }
|
||||
public boolean claimOperation(Long queryId, long operatorId, String operation, String key, String hash) {
|
||||
return operations.claim(IdWorker.getId(), queryId, operatorId, operation, key, hash) == 1;
|
||||
}
|
||||
|
||||
+17
@@ -32,6 +32,14 @@ public class CarrierScheduleAdapterRegistry {
|
||||
return required(carrierCode).code();
|
||||
}
|
||||
|
||||
public String resolve(String carrierName) {
|
||||
String normalized = carrierName == null ? "" : carrierName.trim().toUpperCase(Locale.ROOT);
|
||||
return DEFINITIONS.values().stream().filter(value -> value.matches(normalized))
|
||||
.findFirst().map(Definition::code)
|
||||
.orElseThrow(() -> new BizException(ResultCode.BAD_REQUEST,
|
||||
"出仓订舱船司无法映射至已支持的船节点供应商:" + carrierName));
|
||||
}
|
||||
|
||||
private static Definition required(String carrierCode) {
|
||||
String normalized = carrierCode == null ? "" : carrierCode.trim().toUpperCase(Locale.ROOT);
|
||||
Definition value = DEFINITIONS.get(normalized);
|
||||
@@ -79,6 +87,15 @@ public class CarrierScheduleAdapterRegistry {
|
||||
return new CarrierDescriptor(code, name, officialUrl, accessMode, false, true,
|
||||
coverageGapCode, manualReason);
|
||||
}
|
||||
boolean matches(String value) {
|
||||
return value.equals(code) || value.contains(code) || value.contains(name.toUpperCase(Locale.ROOT))
|
||||
|| "MATSON".equals(code) && value.contains("美森")
|
||||
|| "SML".equals(code) && (value.contains("森罗") || value.contains("SM LINE"))
|
||||
|| "ZIM".equals(code) && value.contains("以星")
|
||||
|| "COSCO".equals(code) && (value.contains("中远") || value.contains("COSCO SHIPPING"))
|
||||
|| "EMC".equals(code) && (value.contains("长荣") || value.contains("EVERGREEN"))
|
||||
|| "HEDE".equals(code) && value.contains("合德");
|
||||
}
|
||||
}
|
||||
|
||||
public record PreparedAttempt(String carrierCode, String accessMode, String officialUrl,
|
||||
|
||||
+71
@@ -0,0 +1,71 @@
|
||||
package com.baiyue.firstleg.portschedule.service;
|
||||
|
||||
import com.baiyue.firstleg.client.CarrierScheduleIntegrationClient;
|
||||
import com.baiyue.firstleg.client.CarrierScheduleIntegrationClient.QueryRequest;
|
||||
import com.baiyue.firstleg.portschedule.dto.CarrierScheduleQueryDtos.ProviderObservationRequest;
|
||||
import com.baiyue.firstleg.portschedule.entity.CarrierScheduleQueryEntity;
|
||||
import com.baiyue.firstleg.portschedule.repository.CarrierScheduleQueryRepository;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.scheduling.annotation.Scheduled;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
import java.time.Clock;
|
||||
import java.time.Duration;
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.List;
|
||||
import java.util.UUID;
|
||||
|
||||
@Component
|
||||
public class CarrierScheduleQueryScheduler {
|
||||
private final CarrierScheduleQueryRepository repository;
|
||||
private final CarrierScheduleQueryService service;
|
||||
private final CarrierScheduleIntegrationClient integration;
|
||||
private final String workerId = UUID.randomUUID().toString();
|
||||
private Clock clock = Clock.systemDefaultZone();
|
||||
|
||||
@Value("${baiyue.security.internal-service-token:}") private String internalToken;
|
||||
@Value("${baiyue.carrier-schedule.scheduler.batch-size:20}") private int batchSize = 20;
|
||||
@Value("${baiyue.carrier-schedule.scheduler.lease-duration:PT5M}")
|
||||
private Duration leaseDuration = Duration.ofMinutes(5);
|
||||
|
||||
public CarrierScheduleQueryScheduler(CarrierScheduleQueryRepository repository,
|
||||
CarrierScheduleQueryService service,
|
||||
CarrierScheduleIntegrationClient integration) {
|
||||
this.repository = repository; this.service = service; this.integration = integration;
|
||||
}
|
||||
|
||||
@Scheduled(fixedDelayString = "${baiyue.carrier-schedule.scheduler.fixed-delay:PT1M}")
|
||||
public void poll() {
|
||||
LocalDateTime now = LocalDateTime.now(clock);
|
||||
repository.due(now, Math.max(1, Math.min(batchSize, 100))).forEach(candidate -> execute(candidate, now));
|
||||
}
|
||||
|
||||
/** Executes only after this worker wins the database lease. */
|
||||
void execute(CarrierScheduleQueryEntity candidate, LocalDateTime now) {
|
||||
if (!repository.claimLease(candidate.getId(), workerId, now, now.plus(leaseDuration))) return;
|
||||
try {
|
||||
var response = integration.query(internalToken,
|
||||
new QueryRequest(candidate.getCarrierCode(), candidate.getTrackingType(), candidate.getTrackingNo()));
|
||||
if (response == null || !response.successful() || response.data() == null) {
|
||||
service.completeScheduledAttempt(candidate.getId(), "RETRYABLE_FAILURE", "EMPTY_PROVIDER_RESPONSE",
|
||||
"轨迹供应商返回空响应");
|
||||
return;
|
||||
}
|
||||
var result = response.data();
|
||||
List<CarrierScheduleIntegrationClient.Observation> observations = result.observations() == null
|
||||
? List.of() : result.observations();
|
||||
for (var observation : observations) {
|
||||
service.applyProviderObservation(candidate.getId(), new ProviderObservationRequest(
|
||||
result.providerCode(), observation.providerEventId(), observation.providerNodeCode(),
|
||||
observation.standardNode(), observation.observedAt(), observation.estimatedAt(),
|
||||
observation.actualAt(), observation.locationCode(), observation.locationName(),
|
||||
observation.sourceReference(), observation.rawPayloadHash()));
|
||||
}
|
||||
service.completeScheduledAttempt(candidate.getId(), result.outcome(), result.errorCode(),
|
||||
result.errorMessage());
|
||||
} catch (RuntimeException exception) {
|
||||
service.completeScheduledAttempt(candidate.getId(), "RETRYABLE_FAILURE", "PROVIDER_CALL_FAILED",
|
||||
exception.getClass().getSimpleName());
|
||||
}
|
||||
}
|
||||
}
|
||||
+200
-53
@@ -5,17 +5,22 @@ import com.baiyue.common.core.api.PageResult;
|
||||
import com.baiyue.common.core.api.ResultCode;
|
||||
import com.baiyue.common.core.exception.BizException;
|
||||
import com.baiyue.firstleg.oceanbooking.entity.OceanBookingEntity;
|
||||
import com.baiyue.firstleg.portschedule.dto.CarrierScheduleQueryDtos.AttemptRequest;
|
||||
import com.baiyue.firstleg.portschedule.dto.CarrierScheduleQueryDtos.CarrierDescriptor;
|
||||
import com.baiyue.firstleg.portschedule.dto.CarrierScheduleQueryDtos.CreateRequest;
|
||||
import com.baiyue.firstleg.portschedule.dto.CarrierScheduleQueryDtos.Detail;
|
||||
import com.baiyue.firstleg.portschedule.dto.CarrierScheduleQueryDtos.ManualResultRequest;
|
||||
import com.baiyue.firstleg.portschedule.dto.CarrierScheduleQueryDtos.Query;
|
||||
import com.baiyue.firstleg.portschedule.dto.CarrierScheduleQueryDtos.QueryView;
|
||||
import com.baiyue.firstleg.portschedule.entity.CarrierScheduleQueryAttemptEntity;
|
||||
import com.baiyue.firstleg.portschedule.dto.CarrierScheduleQueryDtos.ProviderObservationRequest;
|
||||
import com.baiyue.firstleg.portschedule.dto.CarrierScheduleQueryDtos.NodeFact;
|
||||
import com.baiyue.firstleg.portschedule.dto.CarrierScheduleQueryDtos.History;
|
||||
import com.baiyue.firstleg.portschedule.dto.CarrierScheduleQueryDtos.Task;
|
||||
import com.baiyue.firstleg.portschedule.domain.CarrierScheduleNode;
|
||||
import com.baiyue.firstleg.portschedule.entity.CarrierScheduleQueryEntity;
|
||||
import com.baiyue.firstleg.portschedule.entity.CarrierScheduleQueryHistoryEntity;
|
||||
import com.baiyue.firstleg.portschedule.entity.CarrierScheduleQueryOperationEntity;
|
||||
import com.baiyue.firstleg.portschedule.entity.CarrierScheduleNodeFactEntity;
|
||||
import com.baiyue.firstleg.entity.ContainerBookingEntity;
|
||||
import com.baiyue.firstleg.portschedule.entity.PortScheduleEntity;
|
||||
import com.baiyue.firstleg.portschedule.repository.CarrierScheduleQueryRepository;
|
||||
import com.baiyue.firstleg.portschedule.repository.PortScheduleRepository;
|
||||
@@ -61,13 +66,148 @@ public class CarrierScheduleQueryService {
|
||||
|
||||
public Detail detail(long id) { return detail(required(id)); }
|
||||
|
||||
public List<CarrierScheduleQueryHistoryEntity> history(long id) {
|
||||
public List<History> history(long id) {
|
||||
required(id);
|
||||
return repository.histories(id);
|
||||
return repository.histories(id).stream().map(this::history).toList();
|
||||
}
|
||||
|
||||
public long customerId(long id) { return required(id).getCustomerId(); }
|
||||
|
||||
/** Idempotently starts the canonical tracking task after domestic outbound confirmation. */
|
||||
@Transactional
|
||||
public CarrierScheduleQueryEntity ensureFromOutboundConfirm(long customerId, long oceanBookingId,
|
||||
long containerBookingId,
|
||||
long operatorId, String operatorName) {
|
||||
CarrierScheduleQueryEntity existing = repository.findByBusinessScope(customerId, oceanBookingId,
|
||||
containerBookingId);
|
||||
if (existing != null) return existing;
|
||||
OceanBookingEntity booking = portSchedules.oceanBooking(oceanBookingId);
|
||||
ContainerBookingEntity container = portSchedules.containerBooking(containerBookingId);
|
||||
if (booking == null || container == null) throw new BizException(ResultCode.NOT_FOUND, "出仓订舱或柜次不存在");
|
||||
validateLinks(customerId, oceanBookingId, containerBookingId);
|
||||
String carrierCode = adapters.resolve(booking.getShippingCompanyName());
|
||||
String trackingType;
|
||||
String trackingNo;
|
||||
if (StringUtils.hasText(container.getContainerNo())) {
|
||||
trackingType = "CONTAINER_NO"; trackingNo = container.getContainerNo().trim();
|
||||
} else if (StringUtils.hasText(booking.getBillOfLadingNo())) {
|
||||
trackingType = "BILL_OF_LADING"; trackingNo = booking.getBillOfLadingNo().trim();
|
||||
} else if (StringUtils.hasText(booking.getBookingNo())) {
|
||||
trackingType = "BOOKING_NO"; trackingNo = booking.getBookingNo().trim();
|
||||
} else {
|
||||
throw badRequest("确认国内出仓前必须具备柜号、提单号或订舱号之一");
|
||||
}
|
||||
PreparedAttempt strategy = adapters.prepare(carrierCode, trackingType, trackingNo);
|
||||
LocalDateTime now = now();
|
||||
CarrierScheduleQueryEntity value = new CarrierScheduleQueryEntity();
|
||||
value.setId(IdWorker.getId()); value.setQueryNo("CSQ" + value.getId());
|
||||
value.setCustomerId(customerId); value.setOceanBookingId(oceanBookingId);
|
||||
value.setContainerBookingId(containerBookingId); value.setCarrierCode(carrierCode);
|
||||
value.setTrackingType(trackingType); value.setTrackingNo(trackingNo);
|
||||
value.setOfficialUrl(strategy.officialUrl()); value.setAccessMode(strategy.accessMode());
|
||||
value.setStatus("READY"); value.setTriggerSource("OUTBOUND_CONFIRM");
|
||||
value.setLatestStandardNode(CarrierScheduleNode.DEPARTED_WAREHOUSE.name());
|
||||
value.setLatestNodeObservedAt(now); value.setWindowStartedAt(now); value.setAttemptCount(0);
|
||||
value.setConsecutiveFailures(0); value.setNextAllowedAt(now); value.setCreatedBy(operatorId); value.setCreatedByName(operatorName);
|
||||
value.setUpdatedBy(operatorId); value.setUpdatedByName(operatorName); value.setVersion(0);
|
||||
try { repository.insert(value); }
|
||||
catch (DuplicateKeyException exception) {
|
||||
CarrierScheduleQueryEntity concurrent = repository.findByBusinessScope(customerId, oceanBookingId,
|
||||
containerBookingId);
|
||||
if (concurrent != null) return concurrent;
|
||||
throw exception;
|
||||
}
|
||||
addNode(value.getId(), "FIRSTLEG", "OUTBOUND_CONFIRM", "OUTBOUND_CONFIRM",
|
||||
CarrierScheduleNode.DEPARTED_WAREHOUSE, now, null, now, null, null, "OUTBOUND_CONFIRM",
|
||||
sha256("OUTBOUND_CONFIRM|" + containerBookingId));
|
||||
addHistory(value.getId(), "AUTO_CREATE", null, 0, "国内确认出仓自动创建", null, value,
|
||||
operatorId, operatorName);
|
||||
return value;
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public Detail applyProviderObservation(long id, ProviderObservationRequest request) {
|
||||
CarrierScheduleQueryEntity value = requiredLocked(id);
|
||||
if ("STOPPED".equals(value.getStatus())) return detail(value);
|
||||
CarrierScheduleNode node = CarrierScheduleNode.require(request.standardNode());
|
||||
CarrierScheduleQueryEntity before = copy(value);
|
||||
if (!addNode(id, request.providerCode(), request.providerEventId(), request.providerNodeCode(), node,
|
||||
request.observedAt(), request.estimatedAt(), request.actualAt(), request.locationCode(),
|
||||
request.locationName(), request.sourceReference(), request.rawPayloadHash())) {
|
||||
return detail(value);
|
||||
}
|
||||
CarrierScheduleNode current = value.getLatestStandardNode() == null ? null
|
||||
: CarrierScheduleNode.require(value.getLatestStandardNode());
|
||||
if (current == null || node.sequence() >= current.sequence()) {
|
||||
value.setLatestStandardNode(node.name()); value.setLatestNodeObservedAt(request.observedAt());
|
||||
value.setLastSuccessAt(now()); value.setSourceUpdatedAt(request.observedAt());
|
||||
value.setLastAttemptStatus("SUCCESS"); value.setLastErrorCode(null); value.setLastErrorMessage(null);
|
||||
value.setStatus(node == CarrierScheduleNode.ARRIVED ? "ARRIVED"
|
||||
: node == CarrierScheduleNode.PICKUP_READY ? "PICKUP_READY" : "READY");
|
||||
value.setNextAllowedAt(now().plusDays(WINDOW_DAYS));
|
||||
if (repository.updateState(value, value.getVersion(), 0L, "carrier-scheduler") != 1) throw concurrent();
|
||||
}
|
||||
CarrierScheduleQueryEntity after = required(id);
|
||||
addHistory(id, "NODE_OBSERVED", before.getVersion(), after.getVersion(), request.standardNode(), before,
|
||||
after, 0L, "carrier-scheduler");
|
||||
return detail(after);
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public Detail stop(long id, String reason) {
|
||||
CarrierScheduleQueryEntity value = requiredLocked(id);
|
||||
if ("STOPPED".equals(value.getStatus())) return detail(value);
|
||||
CarrierScheduleQueryEntity before = copy(value);
|
||||
value.setStatus("STOPPED"); value.setStoppedAt(now()); value.setStopReason(trim(reason));
|
||||
value.setLeaseOwner(null); value.setLeaseUntil(null); value.setNextAllowedAt(null);
|
||||
if (repository.updateState(value, value.getVersion(), 0L, "carrier-scheduler") != 1) throw concurrent();
|
||||
CarrierScheduleQueryEntity after = required(id);
|
||||
addHistory(id, "STOP", before.getVersion(), after.getVersion(), reason, before, after, 0L,
|
||||
"carrier-scheduler");
|
||||
return detail(after);
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public void completeScheduledAttempt(long id, String outcome, String errorCode, String errorMessage) {
|
||||
CarrierScheduleQueryEntity value = requiredLocked(id);
|
||||
if ("STOPPED".equals(value.getStatus())) return;
|
||||
CarrierScheduleQueryEntity before = copy(value);
|
||||
LocalDateTime now = now();
|
||||
value.setLeaseOwner(null); value.setLeaseUntil(null); value.setLastAttemptAt(now);
|
||||
value.setLastAttemptStatus(outcome); value.setLastErrorCode(trim(errorCode));
|
||||
value.setLastErrorMessage(trim(errorMessage));
|
||||
if ("SUCCESS".equals(outcome) || "NO_CHANGE".equals(outcome)) {
|
||||
value.setConsecutiveFailures(0); value.setAttemptCount(0); value.setWindowStartedAt(now);
|
||||
value.setNextAllowedAt(now.plusDays(WINDOW_DAYS));
|
||||
} else {
|
||||
int failures = count(value.getConsecutiveFailures()) + 1;
|
||||
LocalDateTime windowStart = value.getWindowStartedAt();
|
||||
if (windowStart == null || !now.isBefore(windowStart.plusDays(WINDOW_DAYS))) {
|
||||
windowStart = now; value.setAttemptCount(0);
|
||||
}
|
||||
int attempts = count(value.getAttemptCount()) + 1;
|
||||
value.setWindowStartedAt(windowStart); value.setAttemptCount(Math.min(MAX_ATTEMPTS, attempts));
|
||||
value.setConsecutiveFailures(failures);
|
||||
if ("MANUAL_REQUIRED".equals(outcome) || "PERMANENT_FAILURE".equals(outcome)) {
|
||||
if (!"ARRIVED".equals(value.getStatus()) && !"PICKUP_READY".equals(value.getStatus())) {
|
||||
value.setStatus("MANUAL_REQUIRED");
|
||||
}
|
||||
value.setNextAllowedAt(now.plusDays(WINDOW_DAYS));
|
||||
} else {
|
||||
long minutes = Math.min(24L * 60L, 15L << Math.min(failures - 1, 6));
|
||||
LocalDateTime retryAt = now.plusMinutes(minutes);
|
||||
if (attempts >= MAX_ATTEMPTS && retryAt.isBefore(windowStart.plusDays(WINDOW_DAYS))) {
|
||||
retryAt = windowStart.plusDays(WINDOW_DAYS);
|
||||
}
|
||||
value.setNextAllowedAt(retryAt);
|
||||
}
|
||||
}
|
||||
if (repository.updateState(value, value.getVersion(), 0L, "carrier-scheduler") != 1) throw concurrent();
|
||||
CarrierScheduleQueryEntity after = required(id);
|
||||
addHistory(id, "PROVIDER_QUERY", before.getVersion(), after.getVersion(), outcome, before, after, 0L,
|
||||
"carrier-scheduler");
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public Detail create(long operatorId, String operatorName, String key, CreateRequest request) {
|
||||
String operation = "CREATE";
|
||||
@@ -84,7 +224,8 @@ public class CarrierScheduleQueryService {
|
||||
value.setContainerBookingId(request.containerBookingId()); value.setCarrierCode(carrierCode);
|
||||
value.setTrackingType(trackingType); value.setTrackingNo(request.trackingNo().trim());
|
||||
value.setOfficialUrl(strategy.officialUrl()); value.setAccessMode(strategy.accessMode());
|
||||
value.setStatus("READY"); value.setWindowStartedAt(now); value.setAttemptCount(0);
|
||||
value.setStatus("READY"); value.setTriggerSource("MANUAL");
|
||||
value.setWindowStartedAt(now); value.setAttemptCount(0); value.setConsecutiveFailures(0);
|
||||
value.setNextAllowedAt(now); value.setManualReason(strategy.manualReason()); value.setRemark(trim(request.remark()));
|
||||
value.setCreatedBy(operatorId); value.setCreatedByName(operatorName);
|
||||
value.setUpdatedBy(operatorId); value.setUpdatedByName(operatorName); value.setVersion(0);
|
||||
@@ -94,51 +235,6 @@ public class CarrierScheduleQueryService {
|
||||
return complete(value.getId(), operatorId, key, detail(value));
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public Detail attempt(long id, long operatorId, String operatorName, String key, AttemptRequest request) {
|
||||
String operation = "ATTEMPT";
|
||||
Detail replay = claimOrReplay(id, operatorId, operation, key, hash(operation, id, request));
|
||||
if (replay != null) return replay;
|
||||
CarrierScheduleQueryEntity before = requiredLockedVersion(id, request.expectedVersion());
|
||||
LocalDateTime now = now();
|
||||
LocalDateTime windowStart = before.getWindowStartedAt();
|
||||
if (windowStart == null || !now.isBefore(windowStart.plusDays(WINDOW_DAYS))) {
|
||||
windowStart = now;
|
||||
before.setAttemptCount(0);
|
||||
}
|
||||
if (before.getLastSuccessAt() != null && before.getNextAllowedAt() != null
|
||||
&& now.isBefore(before.getNextAllowedAt())) {
|
||||
throw conflict("船期查询每3天执行一次,下次允许时间:" + before.getNextAllowedAt());
|
||||
}
|
||||
int attemptCount = count(before.getAttemptCount());
|
||||
LocalDateTime windowEnd = windowStart.plusDays(WINDOW_DAYS);
|
||||
if (attemptCount >= MAX_ATTEMPTS && now.isBefore(windowEnd)) {
|
||||
throw conflict("当前3天窗口最多查询3次,下次允许时间:" + windowEnd);
|
||||
}
|
||||
PreparedAttempt strategy = adapters.prepare(before.getCarrierCode(), before.getTrackingType(), before.getTrackingNo());
|
||||
CarrierScheduleQueryEntity snapshot = copy(before);
|
||||
int nextCount = attemptCount + 1;
|
||||
before.setOfficialUrl(strategy.officialUrl()); before.setAccessMode(strategy.accessMode());
|
||||
before.setStatus(strategy.status()); before.setWindowStartedAt(windowStart); before.setAttemptCount(nextCount);
|
||||
before.setLastAttemptAt(now); before.setLastAttemptStatus(strategy.status());
|
||||
before.setManualReason(strategy.manualReason()); before.setLastErrorCode(strategy.coverageGapCode());
|
||||
before.setLastErrorMessage(strategy.manualReason());
|
||||
before.setNextAllowedAt(nextCount >= MAX_ATTEMPTS ? windowEnd : now);
|
||||
if (repository.updateState(before, request.expectedVersion(), operatorId, operatorName) != 1) throw concurrent();
|
||||
CarrierScheduleQueryEntity after = required(id);
|
||||
CarrierScheduleQueryAttemptEntity attempt = new CarrierScheduleQueryAttemptEntity();
|
||||
attempt.setId(IdWorker.getId()); attempt.setQueryId(id); attempt.setWindowAttemptNo(nextCount);
|
||||
attempt.setCarrierCode(after.getCarrierCode()); attempt.setAccessMode(strategy.accessMode());
|
||||
attempt.setOfficialUrl(strategy.officialUrl()); attempt.setAttemptStatus(strategy.status());
|
||||
attempt.setCoverageGap(true); attempt.setCoverageGapCode(strategy.coverageGapCode());
|
||||
attempt.setManualReason(strategy.manualReason()); attempt.setErrorCode(strategy.coverageGapCode());
|
||||
attempt.setErrorMessage(strategy.manualReason()); attempt.setOperatorId(operatorId); attempt.setOperatorName(operatorName);
|
||||
repository.insert(attempt);
|
||||
addHistory(id, operation, snapshot.getVersion(), after.getVersion(), request.reason(), snapshot, after,
|
||||
operatorId, operatorName);
|
||||
return complete(id, operatorId, key, detail(after));
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public Detail attachManualResult(long id, long operatorId, String operatorName, String key,
|
||||
ManualResultRequest request) {
|
||||
@@ -155,7 +251,7 @@ public class CarrierScheduleQueryService {
|
||||
}
|
||||
CarrierScheduleQueryEntity before = copy(value);
|
||||
LocalDateTime now = now();
|
||||
value.setStatus("SUCCESS"); value.setResultPortScheduleId(schedule.getId());
|
||||
value.setStatus("READY"); value.setResultPortScheduleId(schedule.getId());
|
||||
value.setLastSuccessAt(now); value.setSourceUpdatedAt(schedule.getObservedAt());
|
||||
value.setExpiresAt(schedule.getExpiresAt()); value.setNextAllowedAt(now.plusDays(WINDOW_DAYS));
|
||||
value.setWindowStartedAt(now); value.setLastAttemptStatus("MANUAL_COMPLETED");
|
||||
@@ -212,13 +308,48 @@ public class CarrierScheduleQueryService {
|
||||
}
|
||||
|
||||
private Detail detail(CarrierScheduleQueryEntity value) {
|
||||
return new Detail(view(value, now()), repository.attempts(value.getId()), repository.histories(value.getId()));
|
||||
return new Detail(view(value, now()), repository.nodes(value.getId()).stream().map(this::node).toList(),
|
||||
repository.histories(value.getId()).stream().map(this::history).toList());
|
||||
}
|
||||
|
||||
private NodeFact node(CarrierScheduleNodeFactEntity value) {
|
||||
return new NodeFact(value.getId(), value.getProviderCode(), value.getProviderNodeCode(),
|
||||
value.getStandardNode(), value.getNodeSequence(), value.getObservedAt(), value.getEstimatedAt(),
|
||||
value.getActualAt(), value.getLocationCode(), value.getLocationName(), value.getSourceReference(),
|
||||
value.getRawPayloadHash(), value.getCreatedAt());
|
||||
}
|
||||
|
||||
private History history(CarrierScheduleQueryHistoryEntity value) {
|
||||
return new History(value.getId(), value.getAction(), value.getFromVersion(), value.getToVersion(),
|
||||
value.getReason(), value.getOperatorId(), value.getOperatorName(), value.getCreatedAt());
|
||||
}
|
||||
|
||||
private boolean addNode(long queryId, String providerCode, String providerEventId, String providerNodeCode,
|
||||
CarrierScheduleNode node, LocalDateTime observedAt, LocalDateTime estimatedAt,
|
||||
LocalDateTime actualAt, String locationCode, String locationName,
|
||||
String sourceReference, String rawPayloadHash) {
|
||||
CarrierScheduleNodeFactEntity fact = new CarrierScheduleNodeFactEntity();
|
||||
fact.setId(IdWorker.getId()); fact.setQueryId(queryId);
|
||||
fact.setEventKey(providerCode.trim().toUpperCase(Locale.ROOT) + ':' + providerEventId.trim());
|
||||
fact.setProviderCode(providerCode.trim().toUpperCase(Locale.ROOT));
|
||||
fact.setProviderNodeCode(providerNodeCode.trim()); fact.setStandardNode(node.name());
|
||||
fact.setNodeSequence(node.sequence()); fact.setObservedAt(observedAt); fact.setEstimatedAt(estimatedAt);
|
||||
fact.setActualAt(actualAt); fact.setLocationCode(trim(locationCode)); fact.setLocationName(trim(locationName));
|
||||
fact.setSourceReference(trim(sourceReference)); fact.setRawPayloadHash(rawPayloadHash);
|
||||
return repository.insertIfAbsent(fact);
|
||||
}
|
||||
|
||||
private static QueryView view(CarrierScheduleQueryEntity value, LocalDateTime now) {
|
||||
boolean expired = value.getLastSuccessAt() == null || value.getExpiresAt() == null
|
||||
|| !value.getExpiresAt().isAfter(now);
|
||||
return new QueryView(value, expired, MAX_ATTEMPTS, value.getNextAllowedAt());
|
||||
Task task = new Task(value.getId(), value.getQueryNo(), value.getCustomerId(), value.getOceanBookingId(),
|
||||
value.getContainerBookingId(), value.getCarrierCode(), value.getTrackingType(), value.getTrackingNo(),
|
||||
value.getOfficialUrl(), value.getAccessMode(), value.getStatus(), value.getTriggerSource(),
|
||||
value.getLatestStandardNode(), value.getLatestNodeObservedAt(), value.getLastSuccessAt(),
|
||||
value.getSourceUpdatedAt(), value.getExpiresAt(), value.getResultPortScheduleId(), value.getManualReason(),
|
||||
value.getLastErrorCode(), value.getLastErrorMessage(), value.getStoppedAt(), value.getStopReason(),
|
||||
value.getVersion());
|
||||
return new QueryView(task, expired);
|
||||
}
|
||||
|
||||
private CarrierScheduleQueryEntity required(long id) {
|
||||
@@ -234,6 +365,12 @@ public class CarrierScheduleQueryService {
|
||||
return value;
|
||||
}
|
||||
|
||||
private CarrierScheduleQueryEntity requiredLocked(long id) {
|
||||
CarrierScheduleQueryEntity value = repository.lock(id);
|
||||
if (value == null) throw new BizException(ResultCode.NOT_FOUND, "船司查询任务不存在");
|
||||
return value;
|
||||
}
|
||||
|
||||
private static CarrierScheduleQueryEntity copy(CarrierScheduleQueryEntity source) {
|
||||
CarrierScheduleQueryEntity value = new CarrierScheduleQueryEntity();
|
||||
value.setId(source.getId()); value.setQueryNo(source.getQueryNo()); value.setCustomerId(source.getCustomerId());
|
||||
@@ -241,6 +378,10 @@ public class CarrierScheduleQueryService {
|
||||
value.setCarrierCode(source.getCarrierCode()); value.setTrackingType(source.getTrackingType());
|
||||
value.setTrackingNo(source.getTrackingNo()); value.setOfficialUrl(source.getOfficialUrl());
|
||||
value.setAccessMode(source.getAccessMode()); value.setStatus(source.getStatus());
|
||||
value.setTriggerSource(source.getTriggerSource()); value.setLatestStandardNode(source.getLatestStandardNode());
|
||||
value.setLatestNodeObservedAt(source.getLatestNodeObservedAt()); value.setStoppedAt(source.getStoppedAt());
|
||||
value.setStopReason(source.getStopReason()); value.setLeaseOwner(source.getLeaseOwner());
|
||||
value.setLeaseUntil(source.getLeaseUntil()); value.setConsecutiveFailures(source.getConsecutiveFailures());
|
||||
value.setWindowStartedAt(source.getWindowStartedAt()); value.setAttemptCount(source.getAttemptCount());
|
||||
value.setNextAllowedAt(source.getNextAllowedAt()); value.setLastAttemptAt(source.getLastAttemptAt());
|
||||
value.setLastSuccessAt(source.getLastSuccessAt()); value.setSourceUpdatedAt(source.getSourceUpdatedAt());
|
||||
@@ -259,6 +400,12 @@ public class CarrierScheduleQueryService {
|
||||
} catch (Exception ex) { throw new IllegalStateException("无法计算船司查询请求摘要", ex); }
|
||||
}
|
||||
|
||||
private static String sha256(String value) {
|
||||
try { return HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256")
|
||||
.digest(value.getBytes(StandardCharsets.UTF_8))); }
|
||||
catch (Exception exception) { throw new IllegalStateException(exception); }
|
||||
}
|
||||
|
||||
private String json(Object value) {
|
||||
if (value == null) return null;
|
||||
try { return objectMapper.writeValueAsString(value); }
|
||||
|
||||
+15
-5
@@ -15,17 +15,27 @@ import java.util.List;
|
||||
public class LoadingCompletedOutboxRepository {
|
||||
@Resource private InboundOutboxMapper mapper;
|
||||
|
||||
public List<InboundOutboxEntity> pending(int limit, LocalDateTime now) {
|
||||
public List<InboundOutboxEntity> pending(int limit, LocalDateTime now, LocalDateTime expiredLeaseBefore) {
|
||||
return mapper.selectList(new LambdaQueryWrapper<InboundOutboxEntity>()
|
||||
.eq(InboundOutboxEntity::getEventType, EventTypes.FIRST_LEG_LOADING_PLAN_COMPLETED)
|
||||
.eq(InboundOutboxEntity::getPublishStatus, "PENDING")
|
||||
.and(v -> v.isNull(InboundOutboxEntity::getNextRetryAt).or().le(InboundOutboxEntity::getNextRetryAt, now))
|
||||
.and(status -> status.eq(InboundOutboxEntity::getPublishStatus, "PENDING")
|
||||
.and(retry -> retry.isNull(InboundOutboxEntity::getNextRetryAt)
|
||||
.or().le(InboundOutboxEntity::getNextRetryAt, now))
|
||||
.or(stale -> stale.eq(InboundOutboxEntity::getPublishStatus, "PROCESSING")
|
||||
.le(InboundOutboxEntity::getLockedAt, expiredLeaseBefore)))
|
||||
.orderByAsc(InboundOutboxEntity::getCreatedAt).orderByAsc(InboundOutboxEntity::getId)
|
||||
.last("LIMIT " + Math.min(Math.max(limit, 1), 100)));
|
||||
}
|
||||
public boolean claim(long id, String worker, LocalDateTime now) { return mapper.update(null, base(id, "PENDING")
|
||||
public boolean claim(long id, String worker, LocalDateTime now, LocalDateTime expiredLeaseBefore) {
|
||||
return mapper.update(null, new LambdaUpdateWrapper<InboundOutboxEntity>()
|
||||
.eq(InboundOutboxEntity::getId, id)
|
||||
.eq(InboundOutboxEntity::getEventType, EventTypes.FIRST_LEG_LOADING_PLAN_COMPLETED)
|
||||
.and(status -> status.eq(InboundOutboxEntity::getPublishStatus, "PENDING")
|
||||
.or(stale -> stale.eq(InboundOutboxEntity::getPublishStatus, "PROCESSING")
|
||||
.le(InboundOutboxEntity::getLockedAt, expiredLeaseBefore)))
|
||||
.set(InboundOutboxEntity::getPublishStatus, "PROCESSING").set(InboundOutboxEntity::getLockedBy, worker)
|
||||
.set(InboundOutboxEntity::getLockedAt, now)) == 1; }
|
||||
.set(InboundOutboxEntity::getLockedAt, now)) == 1;
|
||||
}
|
||||
public InboundOutboxEntity claimed(long id, String worker) { return mapper.selectOne(new LambdaQueryWrapper<InboundOutboxEntity>()
|
||||
.eq(InboundOutboxEntity::getId, id).eq(InboundOutboxEntity::getEventType, EventTypes.FIRST_LEG_LOADING_PLAN_COMPLETED)
|
||||
.eq(InboundOutboxEntity::getPublishStatus, "PROCESSING").eq(InboundOutboxEntity::getLockedBy, worker)); }
|
||||
|
||||
@@ -44,7 +44,14 @@ public class ContainerOverviewService {
|
||||
List<TimelineEntry> timeline = timeline(id, task == null ? null : task.id(), exceptionHistory);
|
||||
return new Overview(new ContainerBookingEntityView(booking.getId(), booking.getBookingNo(),
|
||||
booking.getCabinetNo(), booking.getContainerNo(), booking.getPlanStatus(), booking.getWarehouseId(),
|
||||
booking.getWarehouseName(), booking.getVoyageNo(), booking.getVesselName(), booking.getVersion()),
|
||||
booking.getWarehouseName(), booking.getVoyageNo(), booking.getVesselName(), booking.getBillOfLadingNo(),
|
||||
booking.getShippingCompanyId(), booking.getShippingCompanyName(), booking.getDeparturePortCode(),
|
||||
booking.getDeparturePortName(), booking.getDestinationPortCode(), booking.getDestinationPortName(),
|
||||
booking.getEstimatedSailingAt(), booking.getActualSailingAt(), booking.getEstimatedPortArrivalAt(),
|
||||
booking.getActualPortArrivalAt(), booking.getEstimatedWarehouseArrivalAt(),
|
||||
booking.getActualWarehouseArrivalAt(), booking.getUnloadingCompletedAt(),
|
||||
booking.getUnloadingPhotoUploadedAt(), booking.getEstimatedEmptyReportedAt(),
|
||||
booking.getActualEmptyReportedAt(), booking.getEmptyReportSourceVersion(), booking.getVersion()),
|
||||
task == null ? booking.getPlanId() : task.loadingPlanId(), pda, documents,
|
||||
new ExceptionState(latestException != null && "MARK".equals(latestException.action()),
|
||||
latestException == null ? null : latestException.reason(),
|
||||
|
||||
+16
-4
@@ -22,13 +22,16 @@ public class LoadingCompletedOutboxRelay {
|
||||
@Resource private EventBridgeClient client;
|
||||
@Resource private ObjectMapper objectMapper;
|
||||
@Value("${baiyue.security.internal-service-token}") private String internalToken;
|
||||
@Value("${baiyue.lastleg.outbox-relay.lease-seconds:60}") private long leaseSeconds;
|
||||
@Value("${baiyue.lastleg.outbox-relay.retry-seconds:30}") private long retrySeconds;
|
||||
private final String workerId = UUID.randomUUID().toString();
|
||||
|
||||
@Scheduled(fixedDelayString = "${baiyue.lastleg.outbox-relay.fixed-delay-ms:5000}")
|
||||
public void relay() {
|
||||
LocalDateTime now=LocalDateTime.now();
|
||||
for (InboundOutboxEntity candidate:repository.pending(50,now)) {
|
||||
if(!repository.claim(candidate.getId(),workerId,now)) continue;
|
||||
LocalDateTime expiredLeaseBefore=now.minusSeconds(Math.max(leaseSeconds,10));
|
||||
for (InboundOutboxEntity candidate:repository.pending(50,now,expiredLeaseBefore)) {
|
||||
if(!repository.claim(candidate.getId(),workerId,now,expiredLeaseBefore)) continue;
|
||||
InboundOutboxEntity row=repository.claimed(candidate.getId(),workerId);
|
||||
if(row==null) continue;
|
||||
try {
|
||||
@@ -39,9 +42,18 @@ public class LoadingCompletedOutboxRelay {
|
||||
|| !event.eventId().equals(response.data().eventId())) throw new IllegalStateException("事件桥未确认装柜完成事件");
|
||||
repository.published(row.getId(),workerId,LocalDateTime.now());
|
||||
} catch(Exception exception) {
|
||||
repository.retry(row.getId(),workerId,LocalDateTime.now().plusSeconds(30),exception.getMessage());
|
||||
log.warn("装柜完成事件投递失败: eventKey={}, reason={}",row.getEventKey(),exception.getMessage());
|
||||
String error=safeError(exception);
|
||||
repository.retry(row.getId(),workerId,LocalDateTime.now().plusSeconds(Math.max(retrySeconds,1)),error);
|
||||
log.warn("装柜完成事件投递失败: eventKey={}, reason={}",row.getEventKey(),error);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private String safeError(Exception exception) {
|
||||
String value=exception.getMessage();
|
||||
if(value==null || value.isBlank()) value=exception.getClass().getSimpleName();
|
||||
if(internalToken!=null && !internalToken.isBlank()) value=value.replace(internalToken,"***");
|
||||
value=value.replaceAll("[\\r\\n\\t]+"," ");
|
||||
return value.length()>500?value.substring(0,500):value;
|
||||
}
|
||||
}
|
||||
|
||||
+21
-9
@@ -30,9 +30,12 @@ public class OverseasContainerEventConsumer {
|
||||
public ContainerBookingEntity consume(EventEnvelope event) {
|
||||
validateEnvelope(event);
|
||||
OverseasContainerUnloadedV1 payload = payload(event);
|
||||
if (payload.containerNo() == null || payload.containerNo().isBlank()
|
||||
|| !payload.containerNo().equals(event.aggregateId())) {
|
||||
throw new IllegalArgumentException("事件 aggregateId 与 containerNo 不一致");
|
||||
if (payload.sourceContainerBookingId() == null || payload.sourceContainerBookingId().isBlank()
|
||||
|| !payload.sourceContainerBookingId().equals(event.aggregateId())) {
|
||||
throw new IllegalArgumentException("事件 aggregateId 与 sourceContainerBookingId 不一致");
|
||||
}
|
||||
if (payload.containerNo() == null || payload.containerNo().isBlank()) {
|
||||
throw new IllegalArgumentException("货柜号不能为空");
|
||||
}
|
||||
if (payload.unloadingCompletedAt() == null || payload.unloadingPhotoUploadedAt() == null) {
|
||||
throw new IllegalArgumentException("卸柜完成时间和卸柜照片上传时间不能为空");
|
||||
@@ -47,7 +50,7 @@ public class OverseasContainerEventConsumer {
|
||||
if (!existing.getPayloadHash().equalsIgnoreCase(event.payloadHash())) {
|
||||
throw new IllegalArgumentException("相同 eventId 对应的事件摘要冲突");
|
||||
}
|
||||
return requiredContainer(payload.containerNo());
|
||||
return requiredContainer(payload.sourceContainerBookingId(), payload.containerNo());
|
||||
}
|
||||
ContainerEventInboxEntity row = inbox(event);
|
||||
try {
|
||||
@@ -55,17 +58,17 @@ public class OverseasContainerEventConsumer {
|
||||
} catch (DuplicateKeyException duplicate) {
|
||||
ContainerEventInboxEntity raced = inbox.selectById(event.eventId());
|
||||
if (raced == null || !raced.getPayloadHash().equalsIgnoreCase(event.payloadHash())) throw duplicate;
|
||||
return requiredContainer(payload.containerNo());
|
||||
return requiredContainer(payload.sourceContainerBookingId(), payload.containerNo());
|
||||
}
|
||||
|
||||
ContainerBookingEntity container = requiredContainer(payload.containerNo());
|
||||
ContainerBookingEntity container = requiredContainer(payload.sourceContainerBookingId(), payload.containerNo());
|
||||
LocalDateTime uploadedAt = utc(payload.unloadingPhotoUploadedAt());
|
||||
containers.applyEmptyReport(container.getId(), utc(payload.unloadingCompletedAt()), uploadedAt,
|
||||
uploadedAt.plusDays(2), utc(payload.actualEmptyReportedAt()), event.aggregateVersion());
|
||||
row.setStatus("PROCESSED");
|
||||
row.setProcessedAt(LocalDateTime.now(ZoneOffset.UTC));
|
||||
inbox.updateById(row);
|
||||
return requiredContainer(payload.containerNo());
|
||||
return requiredContainer(payload.sourceContainerBookingId(), payload.containerNo());
|
||||
}
|
||||
|
||||
private void validateEnvelope(EventEnvelope event) {
|
||||
@@ -97,9 +100,18 @@ public class OverseasContainerEventConsumer {
|
||||
return row;
|
||||
}
|
||||
|
||||
private ContainerBookingEntity requiredContainer(String containerNo) {
|
||||
ContainerBookingEntity value = containers.selectActiveByContainerNo(containerNo);
|
||||
private ContainerBookingEntity requiredContainer(String sourceContainerBookingId, String containerNo) {
|
||||
long bookingId;
|
||||
try {
|
||||
bookingId = Long.parseLong(sourceContainerBookingId);
|
||||
} catch (NumberFormatException exception) {
|
||||
throw new IllegalArgumentException("国内货柜订舱 ID 无效", exception);
|
||||
}
|
||||
ContainerBookingEntity value = containers.selectActiveBookingForUpdate(bookingId);
|
||||
if (value == null) throw new IllegalArgumentException("头程货柜不存在或不可用");
|
||||
if (value.getContainerNo() == null || !value.getContainerNo().equals(containerNo)) {
|
||||
throw new IllegalArgumentException("事件货柜号与国内订舱记录不一致");
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
|
||||
@@ -13,10 +13,18 @@ CREATE TABLE IF NOT EXISTS fl_carrier_schedule_query (
|
||||
tracking_no VARCHAR(128) NOT NULL COMMENT '提交至船公司官网的跟踪号码',
|
||||
official_url VARCHAR(500) NOT NULL COMMENT '船公司官网查询入口地址快照',
|
||||
access_mode VARCHAR(32) NOT NULL COMMENT '官网访问方式:PUBLIC_FORM公开表单、HCAPTCHA人机校验、DYNAMIC_IFRAME动态页面',
|
||||
status VARCHAR(24) NOT NULL COMMENT '任务状态:READY可查询、MANUAL_REQUIRED需人工处理、SUCCESS已取得有效结果',
|
||||
status VARCHAR(24) NOT NULL COMMENT '任务状态:READY、MANUAL_REQUIRED、ARRIVED、PICKUP_READY、STOPPED',
|
||||
trigger_source VARCHAR(32) NOT NULL DEFAULT 'MANUAL' COMMENT '任务触发来源:OUTBOUND_CONFIRM或MANUAL',
|
||||
latest_standard_node VARCHAR(32) NULL COMMENT '最近标准船节点',
|
||||
latest_node_observed_at DATETIME(3) NULL COMMENT '最近标准节点观测时间',
|
||||
stopped_at DATETIME(3) NULL COMMENT '任务停止时间',
|
||||
stop_reason VARCHAR(500) NULL COMMENT '任务停止原因',
|
||||
lease_owner VARCHAR(64) NULL COMMENT '当前调度租约持有者',
|
||||
lease_until DATETIME(3) NULL COMMENT '当前调度租约过期时间',
|
||||
consecutive_failures INT UNSIGNED NOT NULL DEFAULT 0 COMMENT '连续失败次数,用于指数退避',
|
||||
window_started_at DATETIME(3) NOT NULL COMMENT '当前三天查询窗口起始时间',
|
||||
attempt_count INT UNSIGNED NOT NULL DEFAULT 0 COMMENT '当前查询窗口已执行次数;最多3次',
|
||||
next_allowed_at DATETIME(3) NOT NULL COMMENT '频控规则允许的下次查询时间',
|
||||
next_allowed_at DATETIME(3) NULL COMMENT '频控规则允许的下次查询时间;停止后为空',
|
||||
last_attempt_at DATETIME(3) NULL COMMENT '最近一次查询尝试时间',
|
||||
last_success_at DATETIME(3) NULL COMMENT '最近一次取得有效结果时间',
|
||||
source_updated_at DATETIME(3) NULL COMMENT '船公司官网展示的结果更新时间',
|
||||
@@ -37,7 +45,7 @@ CREATE TABLE IF NOT EXISTS fl_carrier_schedule_query (
|
||||
PRIMARY KEY (id),
|
||||
UNIQUE KEY uk_carrier_schedule_query_no (query_no),
|
||||
UNIQUE KEY uk_carrier_schedule_query_scope
|
||||
(customer_id,ocean_booking_id,container_scope_id,carrier_code,tracking_type,tracking_no),
|
||||
(customer_id,ocean_booking_id,container_scope_id),
|
||||
KEY idx_carrier_schedule_query_due (status,next_allowed_at,id),
|
||||
KEY idx_carrier_schedule_query_customer (customer_id,updated_at,id),
|
||||
KEY idx_carrier_schedule_query_expiry (expires_at,id),
|
||||
@@ -52,7 +60,10 @@ CREATE TABLE IF NOT EXISTS fl_carrier_schedule_query (
|
||||
CONSTRAINT chk_carrier_schedule_query_access CHECK
|
||||
(access_mode IN ('PUBLIC_FORM','HCAPTCHA','DYNAMIC_IFRAME')),
|
||||
CONSTRAINT chk_carrier_schedule_query_status CHECK
|
||||
(status IN ('READY','MANUAL_REQUIRED','SUCCESS')),
|
||||
(status IN ('READY','MANUAL_REQUIRED','ARRIVED','PICKUP_READY','STOPPED')),
|
||||
CONSTRAINT chk_carrier_schedule_query_node CHECK
|
||||
(latest_standard_node IS NULL OR latest_standard_node IN
|
||||
('DEPARTED_WAREHOUSE','AT_ORIGIN_PORT','DEPARTED_ORIGIN_PORT','IN_TRANSIT','ARRIVED','PICKUP_READY')),
|
||||
CONSTRAINT chk_carrier_schedule_query_attempt CHECK (attempt_count BETWEEN 0 AND 3)
|
||||
) ENGINE=InnoDB COMMENT='六家船公司官网查询任务与三天窗口频控';
|
||||
|
||||
@@ -95,9 +106,35 @@ CREATE TABLE IF NOT EXISTS fl_carrier_schedule_query_history (
|
||||
PRIMARY KEY (id),
|
||||
KEY idx_carrier_schedule_history_query (query_id,created_at,id),
|
||||
CONSTRAINT fk_carrier_schedule_history_query FOREIGN KEY (query_id) REFERENCES fl_carrier_schedule_query(id),
|
||||
CONSTRAINT chk_carrier_schedule_history_action CHECK (action IN ('CREATE','ATTEMPT','MANUAL_RESULT'))
|
||||
CONSTRAINT chk_carrier_schedule_history_action CHECK
|
||||
(action IN ('CREATE','AUTO_CREATE','MANUAL_RESULT','NODE_OBSERVED','PROVIDER_QUERY','STOP'))
|
||||
) ENGINE=InnoDB COMMENT='船司查询任务不可变历史';
|
||||
|
||||
CREATE TABLE IF NOT EXISTS fl_carrier_schedule_node_fact (
|
||||
id BIGINT UNSIGNED NOT NULL COMMENT '标准船节点事实ID',
|
||||
query_id BIGINT UNSIGNED NOT NULL COMMENT '所属查询任务ID',
|
||||
event_key VARCHAR(192) NOT NULL COMMENT '供应商编码与供应商事件ID组成的幂等键',
|
||||
provider_code VARCHAR(32) NOT NULL COMMENT '轨迹供应商编码',
|
||||
provider_node_code VARCHAR(64) NOT NULL COMMENT '供应商原始节点编码',
|
||||
standard_node VARCHAR(32) NOT NULL COMMENT '标准船节点',
|
||||
node_sequence INT UNSIGNED NOT NULL COMMENT '标准节点单调序号',
|
||||
observed_at DATETIME(3) NOT NULL COMMENT '供应商观测时间',
|
||||
estimated_at DATETIME(3) NULL COMMENT '供应商预计发生时间',
|
||||
actual_at DATETIME(3) NULL COMMENT '供应商确认实际发生时间',
|
||||
location_code VARCHAR(32) NULL COMMENT '节点地点编码',
|
||||
location_name VARCHAR(128) NULL COMMENT '节点地点名称',
|
||||
source_reference VARCHAR(500) NULL COMMENT '可审计来源引用',
|
||||
raw_payload_hash CHAR(64) NOT NULL COMMENT '原始响应SHA-256;不保存敏感原文',
|
||||
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '事实入库时间',
|
||||
PRIMARY KEY (id),
|
||||
UNIQUE KEY uk_carrier_schedule_node_event (query_id,event_key),
|
||||
KEY idx_carrier_schedule_node_timeline (query_id,node_sequence,observed_at,id),
|
||||
CONSTRAINT fk_carrier_schedule_node_query FOREIGN KEY (query_id) REFERENCES fl_carrier_schedule_query(id),
|
||||
CONSTRAINT chk_carrier_schedule_node_standard CHECK
|
||||
(standard_node IN ('DEPARTED_WAREHOUSE','AT_ORIGIN_PORT','DEPARTED_ORIGIN_PORT','IN_TRANSIT','ARRIVED','PICKUP_READY')),
|
||||
CONSTRAINT chk_carrier_schedule_node_sequence CHECK (node_sequence IN (10,20,30,40,50,60))
|
||||
) ENGINE=InnoDB COMMENT='供应商标准船节点不可变事实';
|
||||
|
||||
CREATE TABLE IF NOT EXISTS fl_carrier_schedule_query_operation (
|
||||
id BIGINT UNSIGNED NOT NULL COMMENT '船司查询写操作幂等记录ID',
|
||||
query_id BIGINT UNSIGNED NULL COMMENT '关联查询任务ID;创建任务落库前允许为空',
|
||||
@@ -138,6 +175,8 @@ DROP TRIGGER IF EXISTS trg_carrier_schedule_attempt_before_update;
|
||||
DROP TRIGGER IF EXISTS trg_carrier_schedule_attempt_before_delete;
|
||||
DROP TRIGGER IF EXISTS trg_carrier_schedule_history_before_update;
|
||||
DROP TRIGGER IF EXISTS trg_carrier_schedule_history_before_delete;
|
||||
DROP TRIGGER IF EXISTS trg_carrier_schedule_node_before_update;
|
||||
DROP TRIGGER IF EXISTS trg_carrier_schedule_node_before_delete;
|
||||
DELIMITER $$
|
||||
CREATE TRIGGER trg_carrier_schedule_attempt_before_update BEFORE UPDATE ON fl_carrier_schedule_query_attempt
|
||||
FOR EACH ROW BEGIN SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='船司查询尝试不可修改'; END$$
|
||||
@@ -147,15 +186,21 @@ CREATE TRIGGER trg_carrier_schedule_history_before_update BEFORE UPDATE ON fl_ca
|
||||
FOR EACH ROW BEGIN SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='船司查询历史不可修改'; END$$
|
||||
CREATE TRIGGER trg_carrier_schedule_history_before_delete BEFORE DELETE ON fl_carrier_schedule_query_history
|
||||
FOR EACH ROW BEGIN SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='船司查询历史不可删除'; END$$
|
||||
CREATE TRIGGER trg_carrier_schedule_node_before_update BEFORE UPDATE ON fl_carrier_schedule_node_fact
|
||||
FOR EACH ROW BEGIN SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='标准船节点事实不可修改'; END$$
|
||||
CREATE TRIGGER trg_carrier_schedule_node_before_delete BEFORE DELETE ON fl_carrier_schedule_node_fact
|
||||
FOR EACH ROW BEGIN SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='标准船节点事实不可删除'; END$$
|
||||
DELIMITER ;
|
||||
|
||||
USE baiyue_iam;
|
||||
DELETE role_permission FROM iam_role_permission role_permission
|
||||
JOIN iam_permission permission ON permission.id=role_permission.permission_id
|
||||
WHERE permission.tenant_id=0
|
||||
AND permission.permission_code='platform:firstleg:port-schedule-query:view';
|
||||
AND permission.permission_code IN ('platform:firstleg:port-schedule-query:view',
|
||||
'platform:firstleg:port-schedule-query:attempt');
|
||||
DELETE FROM iam_permission
|
||||
WHERE tenant_id=0 AND permission_code='platform:firstleg:port-schedule-query:view';
|
||||
WHERE tenant_id=0 AND permission_code IN ('platform:firstleg:port-schedule-query:view',
|
||||
'platform:firstleg:port-schedule-query:attempt');
|
||||
|
||||
DROP PROCEDURE IF EXISTS assert_carrier_schedule_query_permission_ids;
|
||||
DELIMITER $$
|
||||
@@ -163,16 +208,15 @@ CREATE PROCEDURE assert_carrier_schedule_query_permission_ids()
|
||||
BEGIN
|
||||
IF EXISTS (
|
||||
SELECT 1 FROM iam_permission
|
||||
WHERE (id IN (5102184,5102186,5102187,5102188)
|
||||
WHERE (id IN (5102184,5102186,5102188)
|
||||
AND permission_code <> CASE id
|
||||
WHEN 5102184 THEN 'platform:firstleg:port-schedule-query:list'
|
||||
WHEN 5102186 THEN 'platform:firstleg:port-schedule-query:create'
|
||||
WHEN 5102187 THEN 'platform:firstleg:port-schedule-query:attempt'
|
||||
WHEN 5102188 THEN 'platform:firstleg:port-schedule-query:manual'
|
||||
END)
|
||||
OR id=5102185
|
||||
OR id IN (5102185,5102187)
|
||||
OR (permission_code LIKE 'platform:firstleg:port-schedule-query:%'
|
||||
AND (tenant_id<>0 OR app_code<>'PLATFORM' OR id NOT IN (5102184,5102186,5102187,5102188)))
|
||||
AND (tenant_id<>0 OR app_code<>'PLATFORM' OR id NOT IN (5102184,5102186,5102188)))
|
||||
) THEN
|
||||
SIGNAL SQLSTATE '45000'
|
||||
SET MESSAGE_TEXT='三期船司查询权限迁移失败:5102184-5102188 ID或权限码被占用';
|
||||
@@ -188,7 +232,6 @@ INSERT INTO iam_permission
|
||||
VALUES
|
||||
(5102184,0,'PLATFORM','BUTTON',5102180,'platform:firstleg:port-schedule-query:list','查询船司任务',NULL,'GET',40,'ACTIVE',1,0),
|
||||
(5102186,0,'PLATFORM','BUTTON',5102180,'platform:firstleg:port-schedule-query:create','创建船司任务',NULL,'POST',60,'ACTIVE',1,0),
|
||||
(5102187,0,'PLATFORM','BUTTON',5102180,'platform:firstleg:port-schedule-query:attempt','执行船司查询',NULL,'POST',70,'ACTIVE',1,0),
|
||||
(5102188,0,'PLATFORM','BUTTON',5102180,'platform:firstleg:port-schedule-query:manual','录入人工结果',NULL,'POST',80,'ACTIVE',1,0)
|
||||
ON DUPLICATE KEY UPDATE tenant_id=0,app_code='PLATFORM',permission_type=VALUES(permission_type),
|
||||
permission_code=VALUES(permission_code),permission_name=VALUES(permission_name),parent_id=VALUES(parent_id),
|
||||
@@ -209,12 +252,13 @@ BEGIN
|
||||
IF (SELECT COUNT(*) FROM information_schema.tables
|
||||
WHERE table_schema='baiyue_firstleg' AND table_name IN
|
||||
('fl_carrier_schedule_query','fl_carrier_schedule_query_attempt',
|
||||
'fl_carrier_schedule_query_history','fl_carrier_schedule_query_operation')) <> 4 THEN
|
||||
'fl_carrier_schedule_query_history','fl_carrier_schedule_query_operation',
|
||||
'fl_carrier_schedule_node_fact')) <> 5 THEN
|
||||
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='船司查询任务表未完整创建';
|
||||
END IF;
|
||||
IF (SELECT COUNT(*) FROM baiyue_iam.iam_permission
|
||||
WHERE tenant_id=0 AND id BETWEEN 5102184 AND 5102188
|
||||
AND permission_code LIKE 'platform:firstleg:port-schedule-query:%') <> 4 THEN
|
||||
WHERE tenant_id=0 AND id IN (5102184,5102186,5102188)
|
||||
AND permission_code LIKE 'platform:firstleg:port-schedule-query:%') <> 3 THEN
|
||||
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='船司查询任务权限未完整创建';
|
||||
END IF;
|
||||
IF (SELECT COUNT(*)
|
||||
@@ -223,7 +267,7 @@ BEGIN
|
||||
JOIN baiyue_iam.iam_permission permission ON permission.id=role_permission.permission_id
|
||||
WHERE role.tenant_id=0 AND role.role_code IN ('PLATFORM_ADMIN','ORDER_OPERATOR')
|
||||
AND permission.tenant_id=0
|
||||
AND permission.permission_code LIKE 'platform:firstleg:port-schedule-query:%') <> 8 THEN
|
||||
AND permission.permission_code LIKE 'platform:firstleg:port-schedule-query:%') <> 6 THEN
|
||||
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='船司查询任务角色授权未完整创建';
|
||||
END IF;
|
||||
END$$
|
||||
|
||||
@@ -232,6 +232,8 @@ BEGIN SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='订舱状态历史不可删除';
|
||||
DELIMITER ;
|
||||
|
||||
USE baiyue_iam;
|
||||
DELETE rp FROM iam_role_permission rp WHERE rp.permission_id IN (5102141,5102143,5102144,5102145);
|
||||
DELETE FROM iam_permission WHERE id IN (5102141,5102143,5102144,5102145);
|
||||
DROP PROCEDURE IF EXISTS assert_ocean_booking_permission_ids_available;
|
||||
DELIMITER $$
|
||||
CREATE PROCEDURE assert_ocean_booking_permission_ids_available()
|
||||
@@ -239,23 +241,14 @@ BEGIN
|
||||
IF EXISTS (
|
||||
SELECT 1 FROM iam_permission
|
||||
WHERE (id = 5102140 AND permission_code <> 'platform:firstleg:ocean-booking:list')
|
||||
OR (id = 5102141 AND permission_code NOT IN (
|
||||
'platform:firstleg:ocean-booking:list',
|
||||
'platform:firstleg:ocean-booking:view'))
|
||||
OR (id = 5102142 AND permission_code <> 'platform:firstleg:ocean-booking:create')
|
||||
OR (id = 5102143 AND permission_code <> 'platform:firstleg:ocean-booking:edit')
|
||||
OR (id = 5102144 AND permission_code <> 'platform:firstleg:ocean-booking:submit')
|
||||
OR (id = 5102145 AND permission_code <> 'platform:firstleg:ocean-booking:confirm')
|
||||
OR (id = 5102146 AND permission_code <> 'platform:firstleg:ocean-booking:release')
|
||||
OR (id = 5102147 AND permission_code NOT IN (
|
||||
'platform:firstleg:ocean-booking:cancel',
|
||||
'platform:firstleg:ocean-booking:edit'))
|
||||
OR (id = 5102148 AND permission_code NOT IN (
|
||||
'platform:firstleg:ocean-booking:change',
|
||||
'platform:firstleg:ocean-booking:delete'))
|
||||
OR (id = 5102147 AND permission_code <> 'platform:firstleg:ocean-booking:edit')
|
||||
OR (id = 5102148 AND permission_code <> 'platform:firstleg:ocean-booking:delete')
|
||||
OR (id = 5102149 AND permission_code <> 'platform:firstleg:ocean-booking:allocate')
|
||||
OR (permission_code LIKE 'platform:firstleg:ocean-booking:%'
|
||||
AND (tenant_id <> 0 OR app_code <> 'PLATFORM' OR id NOT BETWEEN 5102140 AND 5102149))
|
||||
AND (tenant_id <> 0 OR app_code <> 'PLATFORM'
|
||||
OR id NOT IN (5102140,5102142,5102146,5102147,5102148,5102149)))
|
||||
) THEN
|
||||
SIGNAL SQLSTATE '45000'
|
||||
SET MESSAGE_TEXT = '三期订舱权限迁移失败:5102140-5102149 ID 或权限码被其他语义占用';
|
||||
@@ -265,13 +258,6 @@ DELIMITER ;
|
||||
CALL assert_ocean_booking_permission_ids_available();
|
||||
DROP PROCEDURE IF EXISTS assert_ocean_booking_permission_ids_available;
|
||||
|
||||
UPDATE iam_permission
|
||||
SET permission_code='platform:firstleg:ocean-booking:view',status='INACTIVE',deleted=1
|
||||
WHERE id=5102141 AND permission_code='platform:firstleg:ocean-booking:list';
|
||||
|
||||
DELETE rp FROM iam_role_permission rp WHERE rp.permission_id IN (5102143,5102144,5102145);
|
||||
DELETE FROM iam_permission WHERE id IN (5102143,5102144,5102145);
|
||||
|
||||
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)
|
||||
@@ -291,8 +277,4 @@ SELECT role.id,permission.id,0 FROM iam_role role JOIN iam_permission permission
|
||||
ON permission.tenant_id=0 AND permission.permission_code LIKE 'platform:firstleg:ocean-booking:%'
|
||||
WHERE role.tenant_id=0 AND role.role_code IN ('PLATFORM_ADMIN','ORDER_OPERATOR');
|
||||
|
||||
DELETE rp FROM iam_role_permission rp JOIN iam_permission p ON p.tenant_id=rp.tenant_id AND p.id=rp.permission_id
|
||||
WHERE p.id=5102141;
|
||||
UPDATE iam_permission SET status='INACTIVE',deleted=1 WHERE id=5102141;
|
||||
|
||||
USE baiyue_firstleg;
|
||||
|
||||
@@ -112,7 +112,7 @@ BEGIN
|
||||
WHERE (id = 5102150 AND permission_code <> 'platform:firstleg:truck-appointment:list')
|
||||
OR (id = 5102151 AND permission_code NOT IN (
|
||||
'platform:firstleg:truck-appointment:list',
|
||||
'platform:firstleg:truck-appointment:view'))
|
||||
CONCAT('platform:firstleg:truck-appointment:', 'view')))
|
||||
OR (id = 5102152 AND permission_code <> 'platform:firstleg:truck-appointment:create')
|
||||
OR (id = 5102153 AND permission_code <> 'platform:firstleg:truck-appointment:edit')
|
||||
OR (id = 5102154 AND permission_code <> 'platform:firstleg:truck-appointment:operate')
|
||||
@@ -127,10 +127,6 @@ DELIMITER ;
|
||||
CALL assert_truck_appointment_permission_ids_available();
|
||||
DROP PROCEDURE IF EXISTS assert_truck_appointment_permission_ids_available;
|
||||
|
||||
UPDATE iam_permission
|
||||
SET permission_code='platform:firstleg:truck-appointment:view',status='INACTIVE',deleted=1
|
||||
WHERE id=5102151 AND permission_code='platform:firstleg:truck-appointment:list';
|
||||
|
||||
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)
|
||||
@@ -151,6 +147,6 @@ WHERE role.tenant_id=0 AND role.role_code IN ('PLATFORM_ADMIN','ORDER_OPERATOR')
|
||||
|
||||
DELETE rp FROM iam_role_permission rp JOIN iam_permission p ON p.tenant_id=rp.tenant_id AND p.id=rp.permission_id
|
||||
WHERE p.id=5102151;
|
||||
UPDATE iam_permission SET status='INACTIVE',deleted=1 WHERE id=5102151;
|
||||
DELETE FROM iam_permission WHERE id=5102151;
|
||||
|
||||
USE baiyue_firstleg;
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
openapi: 3.0.3
|
||||
info:
|
||||
title: Carrier Schedule Internal Handoff API
|
||||
version: 1.0.0
|
||||
description: Internal-only handoff contract; it is not a platform or merchant frontend API.
|
||||
paths:
|
||||
/internal/firstleg/v1/carrier-schedule-queries/{queryId}/stop:
|
||||
post:
|
||||
summary: Stop tracking after the overseas pickup handoff has completed
|
||||
parameters:
|
||||
- {name: queryId, in: path, required: true, schema: {type: integer, format: int64, minimum: 1}}
|
||||
- {name: X-Internal-Service-Token, in: header, required: true, schema: {type: string}}
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
required: [reason]
|
||||
properties:
|
||||
reason: {type: string, minLength: 1, maxLength: 500}
|
||||
responses:
|
||||
'200': {description: Idempotent STOPPED task detail}
|
||||
@@ -2,7 +2,7 @@ openapi: 3.0.3
|
||||
info:
|
||||
title: Carrier Schedule Query API
|
||||
version: 1.0.0
|
||||
description: Six-carrier query tasks with a three-day window, at most three attempts, and explicit manual fallback.
|
||||
description: Phase-4 carrier tracking from domestic outbound confirmation through destination pickup readiness.
|
||||
paths:
|
||||
/api/platform/v1/carrier-schedule-queries/carriers:
|
||||
get:
|
||||
@@ -40,7 +40,7 @@ paths:
|
||||
/api/platform/v1/carrier-schedule-queries/{queryId}:
|
||||
parameters: [{$ref: '#/components/parameters/QueryId'}]
|
||||
get:
|
||||
summary: Get task, immutable attempts and history
|
||||
summary: Get task, standardized node facts and immutable business history
|
||||
x-permission: platform:firstleg:port-schedule-query:list
|
||||
responses:
|
||||
'200': {description: Stable task detail}
|
||||
@@ -51,19 +51,6 @@ paths:
|
||||
x-permission: platform:firstleg:port-schedule-query:list
|
||||
responses:
|
||||
'200': {description: CREATE, ATTEMPT and MANUAL_RESULT history}
|
||||
/api/platform/v1/carrier-schedule-queries/{queryId}/attempts:
|
||||
parameters: [{$ref: '#/components/parameters/QueryId'}]
|
||||
post:
|
||||
summary: Execute the compliant carrier strategy; never bypass captcha or dynamic pages
|
||||
x-permission: platform:firstleg:port-schedule-query:attempt
|
||||
parameters: [{$ref: '#/components/parameters/IdempotencyKey'}]
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema: {$ref: '#/components/schemas/AttemptRequest'}
|
||||
responses:
|
||||
'200': {description: MANUAL_REQUIRED with coverageGap when no public structured API is available}
|
||||
/api/platform/v1/carrier-schedule-queries/{queryId}/manual-results:
|
||||
parameters: [{$ref: '#/components/parameters/QueryId'}]
|
||||
post:
|
||||
@@ -84,7 +71,8 @@ components:
|
||||
schemas:
|
||||
CarrierCode: {type: string, enum: [MATSON, SML, ZIM, COSCO, EMC, HEDE]}
|
||||
TrackingType: {type: string, enum: [BOOKING_NO, BILL_OF_LADING, CONTAINER_NO]}
|
||||
QueryStatus: {type: string, enum: [READY, MANUAL_REQUIRED, SUCCESS]}
|
||||
QueryStatus: {type: string, enum: [READY, MANUAL_REQUIRED, ARRIVED, PICKUP_READY, STOPPED]}
|
||||
StandardNode: {type: string, enum: [DEPARTED_WAREHOUSE, AT_ORIGIN_PORT, DEPARTED_ORIGIN_PORT, IN_TRANSIT, ARRIVED, PICKUP_READY]}
|
||||
CreateRequest:
|
||||
type: object
|
||||
required: [expectedVersion, customerId, oceanBookingId, carrierCode, trackingType, trackingNo]
|
||||
@@ -97,12 +85,6 @@ components:
|
||||
trackingType: {$ref: '#/components/schemas/TrackingType'}
|
||||
trackingNo: {type: string, minLength: 1, maxLength: 128}
|
||||
remark: {type: string, nullable: true, maxLength: 1000}
|
||||
AttemptRequest:
|
||||
type: object
|
||||
required: [expectedVersion]
|
||||
properties:
|
||||
expectedVersion: {type: integer, minimum: 0}
|
||||
reason: {type: string, nullable: true, maxLength: 1000}
|
||||
ManualResultRequest:
|
||||
type: object
|
||||
required: [expectedVersion, portScheduleId, reason]
|
||||
@@ -112,9 +94,7 @@ components:
|
||||
reason: {type: string, minLength: 1, maxLength: 1000}
|
||||
QueryView:
|
||||
type: object
|
||||
required: [query, expired, maxAttempts, nextAllowedAt]
|
||||
required: [query, expired]
|
||||
properties:
|
||||
query: {type: object, description: Includes source, attemptCount, lastAttemptAt, lastSuccessAt, sourceUpdatedAt and manualReason}
|
||||
query: {type: object, description: Includes triggerSource, latestStandardNode, latestNodeObservedAt, source freshness and stop fields; scheduler leases and raw attempts are never exposed}
|
||||
expired: {type: boolean}
|
||||
maxAttempts: {type: integer, enum: [3]}
|
||||
nextAllowedAt: {type: string, format: date-time}
|
||||
|
||||
+21
@@ -0,0 +1,21 @@
|
||||
package com.baiyue.firstleg.controller;
|
||||
|
||||
import com.baiyue.firstleg.dto.ContainerOverviewDtos.ContainerBookingEntityView;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import java.util.Arrays;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
class ContainerOverviewTransportContractTest {
|
||||
@Test
|
||||
void overviewPublishesMaritimeAndEmptyReportFacts() {
|
||||
assertThat(Arrays.stream(ContainerBookingEntityView.class.getRecordComponents()).map(value -> value.getName()))
|
||||
.contains("billOfLadingNo", "shippingCompanyId", "shippingCompanyName", "departurePortCode",
|
||||
"departurePortName", "destinationPortCode", "destinationPortName", "estimatedSailingAt",
|
||||
"actualSailingAt", "estimatedPortArrivalAt", "actualPortArrivalAt",
|
||||
"estimatedWarehouseArrivalAt", "actualWarehouseArrivalAt", "unloadingCompletedAt",
|
||||
"unloadingPhotoUploadedAt", "estimatedEmptyReportedAt", "actualEmptyReportedAt",
|
||||
"emptyReportSourceVersion");
|
||||
}
|
||||
}
|
||||
+21
@@ -8,6 +8,7 @@ import com.baiyue.firstleg.outbound.dispatch.gate.OutboundDispatchGateContributo
|
||||
import com.baiyue.firstleg.outbound.dispatch.mapper.OutboundDispatchReadMapper;
|
||||
import com.baiyue.firstleg.outbound.dispatch.repository.OutboundDispatchRepository;
|
||||
import com.baiyue.firstleg.outbound.dispatch.service.OutboundDispatchService;
|
||||
import com.baiyue.firstleg.portschedule.service.CarrierScheduleQueryService;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import java.util.List;
|
||||
@@ -67,6 +68,26 @@ class OutboundDispatchServiceTest {
|
||||
verify(repository).completeOperation(33L, 9L, "CONFIRM_DISPATCH", "confirm-1");
|
||||
}
|
||||
|
||||
@Test
|
||||
void confirmCreatesCanonicalCarrierScheduleTaskInTheSameTransaction() {
|
||||
CarrierScheduleQueryService carrierSchedules = mock(CarrierScheduleQueryService.class);
|
||||
OutboundDispatchService service = new OutboundDispatchService(repository,
|
||||
List.of(passing("CUSTOMS_CLEARANCE"), passing("SAILING_SCHEDULE")), carrierSchedules);
|
||||
stubReadyChain();
|
||||
OutboundDispatchEntity state = new OutboundDispatchEntity();
|
||||
state.setId(33L); state.setTenantId(7L); state.setInboundId(11L);
|
||||
state.setContainerBookingId(22L); state.setStatus("READY_FOR_DISPATCH"); state.setVersion(0);
|
||||
when(repository.dispatch(7L, 11L, 22L)).thenReturn(state);
|
||||
when(repository.claim(eq(9L), eq("CONFIRM_DISPATCH"), eq("confirm-carrier"), anyString())).thenReturn(true);
|
||||
when(repository.confirm(33L, 0, 9L, "admin", null)).thenReturn(1);
|
||||
when(repository.completeOperation(33L, 9L, "CONFIRM_DISPATCH", "confirm-carrier")).thenReturn(1);
|
||||
when(repository.timeline(any(), any(), any())).thenReturn(List.of());
|
||||
|
||||
service.confirm(9L, "admin", "confirm-carrier", new ActionRequest(11L, 22L, 0, null));
|
||||
|
||||
verify(carrierSchedules).ensureFromOutboundConfirm(7L, 66L, 22L, 9L, "admin");
|
||||
}
|
||||
|
||||
@Test
|
||||
void confirmNeverWritesStateWhenAnyRealGateFails() {
|
||||
OutboundDispatchService service = new OutboundDispatchService(repository,
|
||||
|
||||
+5
-7
@@ -9,19 +9,17 @@ import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
class CarrierScheduleQueryOpenApiContractTest {
|
||||
@Test
|
||||
void publishesFrequencyFreshnessAndManualFallbackContract() throws Exception {
|
||||
void publishesBusinessNodesWithoutSchedulerOrAttemptEndpoints() throws Exception {
|
||||
String yaml = new ClassPathResource("openapi/carrier-schedule-query-v1.yaml")
|
||||
.getContentAsString(StandardCharsets.UTF_8);
|
||||
assertThat(yaml)
|
||||
.contains("/api/platform/v1/carrier-schedule-queries:")
|
||||
.contains("/api/platform/v1/carrier-schedule-queries/{queryId}/attempts:")
|
||||
.contains("/api/platform/v1/carrier-schedule-queries/{queryId}/manual-results:")
|
||||
.contains("enum: [MATSON, SML, ZIM, COSCO, EMC, HEDE]")
|
||||
.contains("DEPARTED_WAREHOUSE, AT_ORIGIN_PORT, DEPARTED_ORIGIN_PORT, IN_TRANSIT, ARRIVED, PICKUP_READY")
|
||||
.contains("MANUAL_REQUIRED")
|
||||
.contains("coverageGap")
|
||||
.contains("lastAttemptAt")
|
||||
.contains("lastSuccessAt")
|
||||
.contains("sourceUpdatedAt")
|
||||
.contains("nextAllowedAt");
|
||||
.contains("scheduler leases and raw attempts are never exposed")
|
||||
.doesNotContain("/{queryId}/attempts:")
|
||||
.doesNotContain("port-schedule-query:attempt");
|
||||
}
|
||||
}
|
||||
|
||||
+3
-2
@@ -22,16 +22,17 @@ class PlatformCarrierScheduleQueryControllerContractTest {
|
||||
"detail", "platform:firstleg:port-schedule-query:list",
|
||||
"history", "platform:firstleg:port-schedule-query:list",
|
||||
"create", "platform:firstleg:port-schedule-query:create",
|
||||
"attempt", "platform:firstleg:port-schedule-query:attempt",
|
||||
"manualResult", "platform:firstleg:port-schedule-query:manual");
|
||||
permissions.forEach((name, permission) -> {
|
||||
Method method = Arrays.stream(PlatformCarrierScheduleQueryController.class.getDeclaredMethods())
|
||||
.filter(candidate -> candidate.getName().equals(name)).findFirst().orElseThrow();
|
||||
assertThat(method.getAnnotation(PreAuthorize.class).value())
|
||||
.isEqualTo("hasAuthority('" + permission + "')");
|
||||
if (name.equals("create") || name.equals("attempt") || name.equals("manualResult")) {
|
||||
if (name.equals("create") || name.equals("manualResult")) {
|
||||
assertThat(method.getAnnotation(BusinessAudit.class)).isNotNull();
|
||||
}
|
||||
});
|
||||
assertThat(Arrays.stream(PlatformCarrierScheduleQueryController.class.getDeclaredMethods())
|
||||
.map(Method::getName)).doesNotContain("attempt");
|
||||
}
|
||||
}
|
||||
|
||||
+9
-4
@@ -17,11 +17,16 @@ class CarrierScheduleQuerySqlContractTest {
|
||||
.contains("CREATE TABLE IF NOT EXISTS fl_carrier_schedule_query_attempt")
|
||||
.contains("CREATE TABLE IF NOT EXISTS fl_carrier_schedule_query_history")
|
||||
.contains("CREATE TABLE IF NOT EXISTS fl_carrier_schedule_query_operation")
|
||||
.contains("CREATE TABLE IF NOT EXISTS fl_carrier_schedule_node_fact")
|
||||
.contains("attempt_count BETWEEN 0 AND 3")
|
||||
.contains("window_attempt_no BETWEEN 1 AND 3")
|
||||
.contains("UNIQUE KEY uk_carrier_schedule_operation (operator_id,idempotency_key)")
|
||||
.contains("船司查询尝试不可修改")
|
||||
.contains("船司查询历史不可删除");
|
||||
.contains("船司查询历史不可删除")
|
||||
.contains("标准船节点事实不可修改")
|
||||
.contains("ARRIVED','PICKUP_READY','STOPPED")
|
||||
.contains("lease_owner")
|
||||
.contains("consecutive_failures");
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -29,9 +34,9 @@ class CarrierScheduleQuerySqlContractTest {
|
||||
assertThat(moduleSql())
|
||||
.contains("booking.id=NEW.ocean_booking_id AND booking.customer_id=NEW.customer_id")
|
||||
.contains("allocation.container_booking_id=NEW.container_booking_id")
|
||||
.contains("5102184 AND 5102188")
|
||||
.contains("platform:firstleg:port-schedule-query:attempt")
|
||||
.contains("id IN (5102184,5102186,5102188)")
|
||||
.contains("platform:firstleg:port-schedule-query:manual")
|
||||
.contains("'platform:firstleg:port-schedule-query:attempt'")
|
||||
.contains("role.role_code IN ('PLATFORM_ADMIN','ORDER_OPERATOR')")
|
||||
.contains("船司查询任务角色授权未完整创建");
|
||||
}
|
||||
@@ -43,7 +48,7 @@ class CarrierScheduleQuerySqlContractTest {
|
||||
assertThat(unified)
|
||||
.contains("fl_carrier_schedule_query")
|
||||
.contains("uk_carrier_schedule_query_scope")
|
||||
.contains("5102184 AND 5102188")
|
||||
.contains("id IN (5102184,5102186,5102188)")
|
||||
.contains("assert_carrier_schedule_query_schema")
|
||||
.contains("船司查询任务角色授权未完整创建");
|
||||
}
|
||||
|
||||
+41
@@ -0,0 +1,41 @@
|
||||
package com.baiyue.firstleg.portschedule.service;
|
||||
|
||||
import com.baiyue.firstleg.client.CarrierScheduleIntegrationClient;
|
||||
import com.baiyue.firstleg.portschedule.entity.CarrierScheduleQueryEntity;
|
||||
import com.baiyue.firstleg.portschedule.repository.CarrierScheduleQueryRepository;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.List;
|
||||
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.ArgumentMatchers.anyString;
|
||||
import static org.mockito.ArgumentMatchers.anyLong;
|
||||
import static org.mockito.Mockito.mock;
|
||||
import static org.mockito.Mockito.verify;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
class CarrierScheduleQuerySchedulerTest {
|
||||
@Test
|
||||
void leaseWinnerPersistsStandardNodesAndCompletesAttempt() {
|
||||
CarrierScheduleQueryRepository repository = mock(CarrierScheduleQueryRepository.class);
|
||||
CarrierScheduleQueryService service = mock(CarrierScheduleQueryService.class);
|
||||
CarrierScheduleIntegrationClient integration = mock(CarrierScheduleIntegrationClient.class);
|
||||
CarrierScheduleQueryScheduler scheduler = new CarrierScheduleQueryScheduler(repository, service, integration);
|
||||
CarrierScheduleQueryEntity task = new CarrierScheduleQueryEntity(); task.setId(1L);
|
||||
task.setCarrierCode("COSCO"); task.setTrackingType("CONTAINER_NO"); task.setTrackingNo("CSNU1234567");
|
||||
LocalDateTime now = LocalDateTime.of(2026, 8, 20, 12, 0);
|
||||
when(repository.claimLease(anyLong(), anyString(), any(), any())).thenReturn(true);
|
||||
var observation = new CarrierScheduleIntegrationClient.Observation("evt-1", "DISCHARGED", "ARRIVED",
|
||||
now, null, now, "USLAX", "Los Angeles", "provider:evt-1",
|
||||
"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa");
|
||||
when(integration.query(any(), any())).thenReturn(new CarrierScheduleIntegrationClient.Envelope<>("200", null,
|
||||
new CarrierScheduleIntegrationClient.QueryResult("TRACK_API", "SUCCESS", List.of(observation),
|
||||
null, null, now)));
|
||||
|
||||
scheduler.execute(task, now);
|
||||
|
||||
verify(service).applyProviderObservation(anyLong(), any());
|
||||
verify(service).completeScheduledAttempt(1L, "SUCCESS", null, null);
|
||||
}
|
||||
}
|
||||
+31
-27
@@ -3,10 +3,9 @@ package com.baiyue.firstleg.portschedule.service;
|
||||
import com.baiyue.common.core.exception.BizException;
|
||||
import com.baiyue.firstleg.oceanbooking.entity.OceanBookingContainerAllocationEntity;
|
||||
import com.baiyue.firstleg.oceanbooking.entity.OceanBookingEntity;
|
||||
import com.baiyue.firstleg.portschedule.dto.CarrierScheduleQueryDtos.AttemptRequest;
|
||||
import com.baiyue.firstleg.portschedule.dto.CarrierScheduleQueryDtos.CreateRequest;
|
||||
import com.baiyue.firstleg.portschedule.dto.CarrierScheduleQueryDtos.ManualResultRequest;
|
||||
import com.baiyue.firstleg.portschedule.entity.CarrierScheduleQueryAttemptEntity;
|
||||
import com.baiyue.firstleg.portschedule.dto.CarrierScheduleQueryDtos.ProviderObservationRequest;
|
||||
import com.baiyue.firstleg.portschedule.entity.CarrierScheduleQueryEntity;
|
||||
import com.baiyue.firstleg.portschedule.entity.PortScheduleEntity;
|
||||
import com.baiyue.firstleg.portschedule.repository.CarrierScheduleQueryRepository;
|
||||
@@ -14,7 +13,6 @@ import com.baiyue.firstleg.portschedule.repository.PortScheduleRepository;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.mockito.ArgumentCaptor;
|
||||
import org.mockito.InjectMocks;
|
||||
import org.mockito.Mock;
|
||||
import org.mockito.MockitoAnnotations;
|
||||
@@ -48,7 +46,7 @@ class CarrierScheduleQueryServiceTest {
|
||||
set("clock", Clock.fixed(Instant.parse("2026-08-10T04:00:00Z"), ZoneOffset.UTC));
|
||||
when(repository.claimOperation(any(), anyLong(), anyString(), anyString(), anyString())).thenReturn(true);
|
||||
when(repository.completeOperation(anyLong(), anyLong(), anyString(), anyString())).thenReturn(1);
|
||||
when(repository.attempts(anyLong())).thenReturn(List.of());
|
||||
when(repository.nodes(anyLong())).thenReturn(List.of());
|
||||
when(repository.histories(anyLong())).thenReturn(List.of());
|
||||
}
|
||||
|
||||
@@ -62,42 +60,48 @@ class CarrierScheduleQueryServiceTest {
|
||||
var result = service.create(7L, "admin", "carrier-create",
|
||||
new CreateRequest(0, 10001L, 10L, 20L, "ZIM", "BILL_OF_LADING", "GAOU6898053", null));
|
||||
|
||||
assertThat(result.query().query().getCarrierCode()).isEqualTo("ZIM");
|
||||
assertThat(result.query().query().getStatus()).isEqualTo("READY");
|
||||
assertThat(result.query().query().carrierCode()).isEqualTo("ZIM");
|
||||
assertThat(result.query().query().status()).isEqualTo("READY");
|
||||
assertThat(result.query().expired()).isTrue();
|
||||
verify(repository).insert(any(CarrierScheduleQueryEntity.class));
|
||||
}
|
||||
|
||||
@Test
|
||||
void recordsManualRequiredAttemptWithoutBypassingCaptcha() {
|
||||
CarrierScheduleQueryEntity task = task(0, 0);
|
||||
when(repository.lock(1L)).thenReturn(task);
|
||||
when(repository.updateState(any(), any(Integer.class), anyLong(), anyString())).thenReturn(1);
|
||||
when(repository.find(1L)).thenAnswer(invocation -> task);
|
||||
void outboundConfirmationCreatesTaskWithContainerTrackingAndDepartureNode() {
|
||||
OceanBookingEntity booking = new OceanBookingEntity(); booking.setCustomerId(10001L);
|
||||
booking.setShippingCompanyName("中远海运"); booking.setBookingNo("OB-260810-0001");
|
||||
var container = new com.baiyue.firstleg.entity.ContainerBookingEntity();
|
||||
container.setContainerNo("CSNU1234567");
|
||||
when(portSchedules.oceanBooking(10L)).thenReturn(booking);
|
||||
when(portSchedules.containerBooking(20L)).thenReturn(container);
|
||||
when(portSchedules.allocation(10L, 20L)).thenReturn(new OceanBookingContainerAllocationEntity());
|
||||
|
||||
var result = service.attempt(1L, 7L, "admin", "carrier-attempt",
|
||||
new AttemptRequest(0, "官网查询"));
|
||||
CarrierScheduleQueryEntity result = service.ensureFromOutboundConfirm(10001L, 10L, 20L, 7L, "admin");
|
||||
|
||||
assertThat(result.query().query().getStatus()).isEqualTo("MANUAL_REQUIRED");
|
||||
assertThat(result.query().query().getAttemptCount()).isEqualTo(1);
|
||||
assertThat(result.query().query().getManualReason()).contains("hCaptcha");
|
||||
ArgumentCaptor<CarrierScheduleQueryAttemptEntity> attempt =
|
||||
ArgumentCaptor.forClass(CarrierScheduleQueryAttemptEntity.class);
|
||||
verify(repository).insert(attempt.capture());
|
||||
assertThat(attempt.getValue().getCoverageGap()).isTrue();
|
||||
assertThat(attempt.getValue().getCoverageGapCode()).isEqualTo("HCAPTCHA_REQUIRED");
|
||||
assertThat(result.getTriggerSource()).isEqualTo("OUTBOUND_CONFIRM");
|
||||
assertThat(result.getCarrierCode()).isEqualTo("COSCO");
|
||||
assertThat(result.getTrackingType()).isEqualTo("CONTAINER_NO");
|
||||
assertThat(result.getLatestStandardNode()).isEqualTo("DEPARTED_WAREHOUSE");
|
||||
verify(repository).insertIfAbsent(any(com.baiyue.firstleg.portschedule.entity.CarrierScheduleNodeFactEntity.class));
|
||||
}
|
||||
|
||||
@Test
|
||||
void blocksFourthAttemptInsideSameThreeDayWindow() {
|
||||
CarrierScheduleQueryEntity task = task(3, 4);
|
||||
void providerObservationAdvancesToArrivedWithoutExposingAttempts() {
|
||||
CarrierScheduleQueryEntity task = task(0, 0);
|
||||
task.setLatestStandardNode("IN_TRANSIT");
|
||||
when(repository.lock(1L)).thenReturn(task);
|
||||
when(repository.find(1L)).thenReturn(task);
|
||||
when(repository.insertIfAbsent(any(com.baiyue.firstleg.portschedule.entity.CarrierScheduleNodeFactEntity.class)))
|
||||
.thenReturn(true);
|
||||
when(repository.updateState(any(), any(Integer.class), anyLong(), anyString())).thenReturn(1);
|
||||
|
||||
assertThatThrownBy(() -> service.attempt(1L, 7L, "admin", "carrier-fourth",
|
||||
new AttemptRequest(4, null)))
|
||||
.isInstanceOf(BizException.class).hasMessageContaining("最多查询3次");
|
||||
var result = service.applyProviderObservation(1L, new ProviderObservationRequest("TRACK_API", "evt-1",
|
||||
"DISCHARGED", "ARRIVED", NOW, null, NOW, "USLAX", "Los Angeles", "provider:evt-1",
|
||||
"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"));
|
||||
|
||||
verify(repository, never()).updateState(any(), any(Integer.class), anyLong(), anyString());
|
||||
assertThat(result.query().query().status()).isEqualTo("ARRIVED");
|
||||
assertThat(result.query().query().latestStandardNode()).isEqualTo("ARRIVED");
|
||||
assertThat(result.nodes()).isEmpty();
|
||||
}
|
||||
|
||||
@Test
|
||||
|
||||
+1
-1
@@ -149,7 +149,7 @@ class Phase3FinalUnifiedSqlContractTest {
|
||||
.contains("存在缺失中文注释的业务表")
|
||||
.contains("存在缺失中文注释的业务字段")
|
||||
.doesNotContain("CREATE PROCEDURE upgrade_column_comment");
|
||||
assertThat(count(sql, "CALL queue_table_comment(")).isEqualTo(150);
|
||||
assertThat(count(sql, "CALL queue_table_comment(")).isEqualTo(151);
|
||||
assertThat(count(sql, "CALL queue_column_comment(")).isGreaterThanOrEqualTo(2459);
|
||||
assertThat(sql)
|
||||
.contains("CALL queue_column_comment('baiyue_firstleg', 'fl_inbound_fee_detail', 'supplier_id'")
|
||||
|
||||
+96
@@ -0,0 +1,96 @@
|
||||
package com.baiyue.firstleg.service;
|
||||
|
||||
import com.baiyue.common.core.api.ApiResponse;
|
||||
import com.baiyue.common.event.EventEnvelope;
|
||||
import com.baiyue.common.event.EventPayloadCanonicalizer;
|
||||
import com.baiyue.common.event.EventPayloadHash;
|
||||
import com.baiyue.common.event.FirstLegLoadingPlanCompletedV1;
|
||||
import com.baiyue.common.event.EventTypes;
|
||||
import com.baiyue.common.event.RegionCode;
|
||||
import com.baiyue.firstleg.client.EventBridgeClient;
|
||||
import com.baiyue.firstleg.entity.InboundOutboxEntity;
|
||||
import com.baiyue.firstleg.repository.LoadingCompletedOutboxRepository;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.extension.ExtendWith;
|
||||
import org.mockito.Mock;
|
||||
import org.mockito.junit.jupiter.MockitoExtension;
|
||||
import org.springframework.test.util.ReflectionTestUtils;
|
||||
|
||||
import java.time.Instant;
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.List;
|
||||
|
||||
import static org.mockito.ArgumentMatchers.*;
|
||||
import static org.mockito.Mockito.*;
|
||||
|
||||
@ExtendWith(MockitoExtension.class)
|
||||
class LoadingCompletedOutboxRelayTest {
|
||||
@Mock private LoadingCompletedOutboxRepository repository;
|
||||
@Mock private EventBridgeClient client;
|
||||
private ObjectMapper objectMapper;
|
||||
private LoadingCompletedOutboxRelay relay;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
objectMapper = new ObjectMapper().registerModule(new JavaTimeModule());
|
||||
relay = new LoadingCompletedOutboxRelay();
|
||||
ReflectionTestUtils.setField(relay, "repository", repository);
|
||||
ReflectionTestUtils.setField(relay, "client", client);
|
||||
ReflectionTestUtils.setField(relay, "objectMapper", objectMapper);
|
||||
ReflectionTestUtils.setField(relay, "internalToken", "internal-token");
|
||||
ReflectionTestUtils.setField(relay, "leaseSeconds", 60L);
|
||||
ReflectionTestUtils.setField(relay, "retrySeconds", 30L);
|
||||
}
|
||||
|
||||
@Test
|
||||
void claimsExpiredLeasesAndPublishesAfterOverseasBridgeAcknowledgement() throws Exception {
|
||||
EventEnvelope event = event();
|
||||
InboundOutboxEntity row = row(event);
|
||||
when(repository.pending(eq(50), any(), any())).thenReturn(List.of(row));
|
||||
when(repository.claim(eq(9001L), anyString(), any(), any())).thenReturn(true);
|
||||
when(repository.claimed(eq(9001L), anyString())).thenReturn(row);
|
||||
when(client.receive(eq("internal-token"), any(EventEnvelope.class))).thenReturn(ApiResponse.success(
|
||||
new EventBridgeClient.Acknowledgement(event.eventId(), "已接收", "2026-08-20T03:01:00")));
|
||||
|
||||
relay.relay();
|
||||
|
||||
verify(repository).published(eq(9001L), anyString(), any(LocalDateTime.class));
|
||||
verify(repository, never()).retry(anyLong(), anyString(), any(), anyString());
|
||||
}
|
||||
|
||||
@Test
|
||||
void redactsInternalTokenBeforePersistingRetryError() throws Exception {
|
||||
EventEnvelope event = event();
|
||||
InboundOutboxEntity row = row(event);
|
||||
when(repository.pending(eq(50), any(), any())).thenReturn(List.of(row));
|
||||
when(repository.claim(eq(9001L), anyString(), any(), any())).thenReturn(true);
|
||||
when(repository.claimed(eq(9001L), anyString())).thenReturn(row);
|
||||
when(client.receive(eq("internal-token"), any(EventEnvelope.class)))
|
||||
.thenThrow(new IllegalStateException("failed internal-token\nremote"));
|
||||
|
||||
relay.relay();
|
||||
|
||||
verify(repository).retry(eq(9001L), anyString(), any(LocalDateTime.class), eq("failed *** remote"));
|
||||
}
|
||||
|
||||
private InboundOutboxEntity row(EventEnvelope event) throws Exception {
|
||||
InboundOutboxEntity row = new InboundOutboxEntity();
|
||||
row.setId(9001L);
|
||||
row.setEventKey(event.eventId());
|
||||
row.setPayloadJson(objectMapper.writeValueAsString(event));
|
||||
return row;
|
||||
}
|
||||
|
||||
private EventEnvelope event() {
|
||||
var payload = objectMapper.valueToTree(new FirstLegLoadingPlanCompletedV1(
|
||||
"LOAD-1", "PLAN-1", "BOOK-1", "MSCU1234567", null,
|
||||
"WH-1", "Warehouse", List.of()));
|
||||
return new EventEnvelope("00000000-0000-4000-8000-000000000019", EventTypes.FIRST_LEG_LOADING_PLAN_COMPLETED, 1,
|
||||
Instant.parse("2026-08-20T03:00:01Z"), RegionCode.CN, RegionCode.OVERSEAS, "1",
|
||||
"LOADING_PLAN", "LOAD-1", 7L, 1L, "trace-12345678", "correlation-12345678", null,
|
||||
payload, EventPayloadHash.sha256(EventPayloadCanonicalizer.canonicalize(payload)));
|
||||
}
|
||||
}
|
||||
+6
-6
@@ -43,12 +43,12 @@ class OverseasContainerEventConsumerTest {
|
||||
@Test
|
||||
void derivesEstimatedEmptyReportedAtFromPhotoUploadAndPersistsInbox() {
|
||||
ContainerBookingEntity container = container();
|
||||
when(containers.selectActiveByContainerNo("MSCU1234567")).thenReturn(container);
|
||||
when(containers.selectActiveBookingForUpdate(1001L)).thenReturn(container);
|
||||
Instant completedAt = Instant.parse("2026-08-20T03:00:00Z");
|
||||
Instant uploadedAt = Instant.parse("2026-08-20T02:30:00Z");
|
||||
|
||||
consumer.consume(event("00000000-0000-4000-8000-000000000001", 7,
|
||||
new OverseasContainerUnloadedV1("MSCU1234567", completedAt, uploadedAt, null)));
|
||||
new OverseasContainerUnloadedV1("1001", "MSCU1234567", completedAt, uploadedAt, null)));
|
||||
|
||||
verify(containers).applyEmptyReport(eq(1001L), eq(LocalDateTime.parse("2026-08-20T03:00:00")),
|
||||
eq(LocalDateTime.parse("2026-08-20T02:30:00")),
|
||||
@@ -62,13 +62,13 @@ class OverseasContainerEventConsumerTest {
|
||||
|
||||
@Test
|
||||
void repeatedEventWithSameHashDoesNotApplyAgain() {
|
||||
OverseasContainerUnloadedV1 payload = new OverseasContainerUnloadedV1("MSCU1234567",
|
||||
OverseasContainerUnloadedV1 payload = new OverseasContainerUnloadedV1("1001", "MSCU1234567",
|
||||
Instant.parse("2026-08-20T03:00:00Z"), Instant.parse("2026-08-20T02:30:00Z"), null);
|
||||
EventEnvelope event = event("00000000-0000-4000-8000-000000000002", 7, payload);
|
||||
ContainerEventInboxEntity existing = new ContainerEventInboxEntity();
|
||||
existing.setPayloadHash(event.payloadHash());
|
||||
when(inbox.selectById(event.eventId())).thenReturn(existing);
|
||||
when(containers.selectActiveByContainerNo("MSCU1234567")).thenReturn(container());
|
||||
when(containers.selectActiveBookingForUpdate(1001L)).thenReturn(container());
|
||||
|
||||
consumer.consume(event);
|
||||
|
||||
@@ -79,7 +79,7 @@ class OverseasContainerEventConsumerTest {
|
||||
@Test
|
||||
void rejectsTamperedPayloadHash() {
|
||||
EventEnvelope valid = event("00000000-0000-4000-8000-000000000003", 7,
|
||||
new OverseasContainerUnloadedV1("MSCU1234567", Instant.parse("2026-08-20T03:00:00Z"),
|
||||
new OverseasContainerUnloadedV1("1001", "MSCU1234567", Instant.parse("2026-08-20T03:00:00Z"),
|
||||
Instant.parse("2026-08-20T02:30:00Z"), null));
|
||||
EventEnvelope tampered = new EventEnvelope(valid.eventId(), valid.eventType(), valid.eventVersion(),
|
||||
valid.occurredAt(), valid.sourceRegion(), valid.targetRegion(), valid.tenantId(), valid.aggregateType(),
|
||||
@@ -96,7 +96,7 @@ class OverseasContainerEventConsumerTest {
|
||||
String hash = EventPayloadHash.sha256(EventPayloadCanonicalizer.canonicalize(node));
|
||||
return new EventEnvelope(eventId, EventTypes.OVERSEAS_CONTAINER_UNLOADED, 1,
|
||||
Instant.parse("2026-08-20T02:31:00Z"), RegionCode.OVERSEAS, RegionCode.CN, "1",
|
||||
"CONTAINER_BOOKING", payload.containerNo(), aggregateVersion, null,
|
||||
"CONTAINER_BOOKING", payload.sourceContainerBookingId(), aggregateVersion, null,
|
||||
"trace-12345678", "correlation-12345678", null, node, hash);
|
||||
}
|
||||
|
||||
|
||||
@@ -53,7 +53,6 @@ public class GatewaySecurityConfig {
|
||||
.withJwkSetUri(properties.jwkSetUri())
|
||||
.jwsAlgorithms(algorithms -> {
|
||||
algorithms.add(SignatureAlgorithm.RS256);
|
||||
algorithms.add(SignatureAlgorithm.ES256);
|
||||
})
|
||||
.build();
|
||||
decoder.setJwtValidator(new DelegatingOAuth2TokenValidator<>(
|
||||
|
||||
+39
-1
@@ -5,12 +5,14 @@ import org.springframework.security.oauth2.core.OAuth2TokenValidator;
|
||||
import org.springframework.security.oauth2.core.OAuth2TokenValidatorResult;
|
||||
import org.springframework.security.oauth2.jwt.Jwt;
|
||||
|
||||
import java.util.Collection;
|
||||
import java.util.List;
|
||||
import java.util.Locale;
|
||||
|
||||
/** Validates the identity and authorization claims required by overseas APIs. */
|
||||
final class RequiredClaimsJwtValidator implements OAuth2TokenValidator<Jwt> {
|
||||
private static final List<String> REQUIRED_CLAIMS = List.of(
|
||||
"sub", "tenant_id", "app_code", "permission_version");
|
||||
"sub", "tenant_id", "app_code", "permission_version", "authorities");
|
||||
|
||||
@Override
|
||||
public OAuth2TokenValidatorResult validate(Jwt jwt) {
|
||||
@@ -21,6 +23,42 @@ final class RequiredClaimsJwtValidator implements OAuth2TokenValidator<Jwt> {
|
||||
"invalid_token", "JWT 缺少必要声明: " + claim, null));
|
||||
}
|
||||
}
|
||||
Long subject = longValue(jwt.getSubject());
|
||||
Long tenantId = longValue(jwt.getClaims().get("tenant_id"));
|
||||
Long permissionVersion = longValue(jwt.getClaims().get("permission_version"));
|
||||
String appCode = String.valueOf(jwt.getClaims().get("app_code")).toUpperCase(Locale.ROOT);
|
||||
if (subject == null || subject <= 0) {
|
||||
return failure("JWT sub 必须是正整数用户 ID");
|
||||
}
|
||||
if (tenantId == null || tenantId < 0) {
|
||||
return failure("JWT tenant_id 必须是非负整数客户范围");
|
||||
}
|
||||
if (permissionVersion == null || permissionVersion < 0) {
|
||||
return failure("JWT permission_version 必须是非负整数");
|
||||
}
|
||||
if (!("PLATFORM".equals(appCode) && tenantId == 0L)
|
||||
&& !("MERCHANT".equals(appCode) && tenantId > 0L)) {
|
||||
return failure("JWT app_code 与 tenant_id 访问域不一致");
|
||||
}
|
||||
Object authorities = jwt.getClaims().get("authorities");
|
||||
if (!(authorities instanceof Collection<?> values)
|
||||
|| values.stream().anyMatch(value -> value == null || value.toString().isBlank())) {
|
||||
return failure("JWT authorities 必须是权限列表");
|
||||
}
|
||||
return OAuth2TokenValidatorResult.success();
|
||||
}
|
||||
|
||||
private static Long longValue(Object value) {
|
||||
if (value instanceof Number number) return number.longValue();
|
||||
if (value == null) return null;
|
||||
try {
|
||||
return Long.valueOf(value.toString());
|
||||
} catch (NumberFormatException ignored) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private static OAuth2TokenValidatorResult failure(String message) {
|
||||
return OAuth2TokenValidatorResult.failure(new OAuth2Error("invalid_token", message, null));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -59,7 +59,9 @@ springdoc:
|
||||
swagger-ui:
|
||||
urls:
|
||||
- { name: lastleg-merchant, url: /lastleg-service/v3/api-docs/merchant }
|
||||
- { name: lastleg-platform, url: /lastleg-service/v3/api-docs/platform }
|
||||
- { name: warehouse-merchant, url: /warehouse-overseas-service/v3/api-docs/merchant }
|
||||
- { name: warehouse-platform, url: /warehouse-overseas-service/v3/api-docs/platform }
|
||||
- { name: file-platform, url: /file-service/v3/api-docs/platform }
|
||||
- { name: file-merchant, url: /file-service/v3/api-docs/merchant }
|
||||
- { name: print-platform, url: /print-service/v3/api-docs/platform }
|
||||
|
||||
+47
-2
@@ -4,6 +4,7 @@ import org.junit.jupiter.api.Test;
|
||||
import org.springframework.security.oauth2.jwt.Jwt;
|
||||
|
||||
import java.time.Instant;
|
||||
import java.util.List;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
@@ -20,14 +21,58 @@ class RequiredClaimsJwtValidatorTest {
|
||||
assertThat(validator.validate(jwt(false)).hasErrors()).isTrue();
|
||||
}
|
||||
|
||||
@Test
|
||||
void rejectsMissingAuthorities() {
|
||||
Jwt value = Jwt.withTokenValue("token").header("alg", "RS256").subject("1001")
|
||||
.issuedAt(Instant.now()).expiresAt(Instant.now().plusSeconds(300))
|
||||
.claim("tenant_id", "0").claim("app_code", "PLATFORM").claim("permission_version", 3L)
|
||||
.build();
|
||||
assertThat(validator.validate(value).hasErrors()).isTrue();
|
||||
}
|
||||
|
||||
@Test
|
||||
void acceptsEmptyAuthoritySnapshotAndLeavesAuthorizationToControllers() {
|
||||
assertThat(validator.validate(jwt("0", "PLATFORM", List.of(), true)).hasErrors()).isFalse();
|
||||
}
|
||||
|
||||
@Test
|
||||
void acceptsMerchantIdentityOnlyWithPositiveCustomerScope() {
|
||||
assertThat(validator.validate(jwt("2001", "MERCHANT", List.of("merchant:order:list"), true)).hasErrors())
|
||||
.isFalse();
|
||||
}
|
||||
|
||||
@Test
|
||||
void rejectsApplicationAndCustomerScopeMismatch() {
|
||||
assertThat(validator.validate(jwt("2001", "PLATFORM", List.of("platform:test:list"), true)).hasErrors())
|
||||
.isTrue();
|
||||
assertThat(validator.validate(jwt("0", "MERCHANT", List.of("merchant:test:list"), true)).hasErrors())
|
||||
.isTrue();
|
||||
}
|
||||
|
||||
@Test
|
||||
void rejectsMalformedIdentityAndPermissionVersion() {
|
||||
Jwt value = Jwt.withTokenValue("token").header("alg", "RS256").subject("not-a-user")
|
||||
.issuedAt(Instant.now()).expiresAt(Instant.now().plusSeconds(300))
|
||||
.claim("tenant_id", "0").claim("app_code", "PLATFORM").claim("permission_version", -1L)
|
||||
.claim("authorities", List.of()).build();
|
||||
assertThat(validator.validate(value).hasErrors()).isTrue();
|
||||
}
|
||||
|
||||
private Jwt jwt(boolean includePermissionVersion) {
|
||||
return jwt("0", "PLATFORM",
|
||||
List.of("platform:overseas-warehouse:inventory:list"), includePermissionVersion);
|
||||
}
|
||||
|
||||
private Jwt jwt(String tenantId, String appCode, List<String> authorities,
|
||||
boolean includePermissionVersion) {
|
||||
Jwt.Builder builder = Jwt.withTokenValue("token")
|
||||
.header("alg", "RS256")
|
||||
.subject("1001")
|
||||
.issuedAt(Instant.now())
|
||||
.expiresAt(Instant.now().plusSeconds(300))
|
||||
.claim("tenant_id", "2001")
|
||||
.claim("app_code", "PLATFORM");
|
||||
.claim("tenant_id", tenantId)
|
||||
.claim("app_code", appCode)
|
||||
.claim("authorities", authorities);
|
||||
if (includePermissionVersion) {
|
||||
builder.claim("permission_version", 3L);
|
||||
}
|
||||
|
||||
@@ -1,15 +1,67 @@
|
||||
USE baiyue_iam;
|
||||
|
||||
-- 尾程订单权限属于平台端;列表权限使用 MENU,其余操作使用 BUTTON。
|
||||
-- 四期 Admin 海外权限只使用 platform:overseas-*;列表权限包含详情只读。
|
||||
DELETE rp FROM iam_role_permission rp
|
||||
JOIN iam_permission p ON p.id=rp.permission_id
|
||||
WHERE p.permission_code LIKE 'platform:lastleg:%';
|
||||
UPDATE iam_permission SET status='INACTIVE', deleted=1
|
||||
WHERE permission_code LIKE 'platform:lastleg:%';
|
||||
|
||||
INSERT INTO iam_permission
|
||||
(id, tenant_id, app_code, permission_type, parent_id, permission_code,
|
||||
permission_name, route_path, http_method, sort_no, status, built_in, deleted)
|
||||
VALUES
|
||||
(5106000, 0, 'PLATFORM', 'MENU', 0, 'platform:lastleg:order:list', '尾程订单', '/lastleg/orders', 'GET', 60, 'ACTIVE', 1, 0),
|
||||
(5106001, 0, 'PLATFORM', 'BUTTON', 5106000, 'platform:lastleg:order:detail', '查看尾程订单详情', NULL, 'GET', 10, 'ACTIVE', 1, 0),
|
||||
(5106002, 0, 'PLATFORM', 'BUTTON', 5106000, 'platform:lastleg:order:assign', '分配尾程订单', NULL, 'POST', 20, 'ACTIVE', 1, 0),
|
||||
(5106003, 0, 'PLATFORM', 'BUTTON', 5106000, 'platform:lastleg:order:transfer', '转派尾程订单', NULL, 'POST', 30, 'ACTIVE', 1, 0),
|
||||
(5106004, 0, 'PLATFORM', 'BUTTON', 5106000, 'platform:lastleg:order:merge-pallet', '合并尾程托盘', NULL, 'POST', 40, 'ACTIVE', 1, 0)
|
||||
(5107000,0,'PLATFORM','MENU',0,'platform:overseas-warehouse:last-mile-order:list','海外尾程订单','/overseas-warehouse/last-mile-orders','GET',700,'ACTIVE',1,0),
|
||||
(5107001,0,'PLATFORM','BUTTON',5107000,'platform:overseas-warehouse:last-mile-order:edit','编辑海外尾程订单',NULL,'PUT',701,'ACTIVE',1,0),
|
||||
(5107002,0,'PLATFORM','BUTTON',5107000,'platform:overseas-warehouse:last-mile-order:export','导出海外尾程订单',NULL,'GET',702,'ACTIVE',1,0),
|
||||
(5107010,0,'PLATFORM','MENU',0,'platform:overseas-warehouse:inbound-order:list','海外入仓单','/overseas-warehouse/inbound-orders','GET',710,'ACTIVE',1,0),
|
||||
(5107011,0,'PLATFORM','BUTTON',5107010,'platform:overseas-warehouse:inbound-order:create','创建海外入仓计划',NULL,'POST',711,'ACTIVE',1,0),
|
||||
(5107012,0,'PLATFORM','BUTTON',5107010,'platform:overseas-warehouse:inbound-order:edit','编辑海外入仓计划',NULL,'POST',712,'ACTIVE',1,0),
|
||||
(5107020,0,'PLATFORM','MENU',0,'platform:overseas-warehouse:relabel-order:list','海外换标单','/overseas-warehouse/relabel-orders','GET',720,'ACTIVE',1,0),
|
||||
(5107021,0,'PLATFORM','BUTTON',5107020,'platform:overseas-warehouse:relabel-order:create','创建海外换标单',NULL,'POST',721,'ACTIVE',1,0),
|
||||
(5107022,0,'PLATFORM','BUTTON',5107020,'platform:overseas-warehouse:relabel-order:status','变更海外换标单状态',NULL,'PUT',722,'ACTIVE',1,0),
|
||||
(5107030,0,'PLATFORM','MENU',0,'platform:overseas-warehouse:pallet:list','海外托盘','/overseas-warehouse/pallets','GET',730,'ACTIVE',1,0),
|
||||
(5107031,0,'PLATFORM','BUTTON',5107030,'platform:overseas-warehouse:pallet:create','创建海外托盘',NULL,'POST',731,'ACTIVE',1,0),
|
||||
(5107032,0,'PLATFORM','BUTTON',5107030,'platform:overseas-warehouse:pallet:edit','编辑海外托盘',NULL,'PUT',732,'ACTIVE',1,0),
|
||||
(5107033,0,'PLATFORM','BUTTON',5107030,'platform:overseas-warehouse:pallet:delete','删除海外托盘',NULL,'DELETE',733,'ACTIVE',1,0),
|
||||
(5107040,0,'PLATFORM','MENU',0,'platform:overseas-warehouse:inventory:list','海外库存','/overseas-warehouse/inventory','GET',740,'ACTIVE',1,0),
|
||||
(5107041,0,'PLATFORM','BUTTON',5107040,'platform:overseas-warehouse:inventory:export','导出海外库存',NULL,'GET',741,'ACTIVE',1,0),
|
||||
(5107042,0,'PLATFORM','BUTTON',5107040,'platform:overseas-warehouse:inventory:edit','编辑海外库存状态',NULL,'POST',742,'ACTIVE',1,0),
|
||||
(5107043,0,'PLATFORM','BUTTON',5107040,'platform:overseas-warehouse:inventory:adjust','调整海外库存',NULL,'POST',743,'ACTIVE',1,0),
|
||||
(5107050,0,'PLATFORM','MENU',0,'platform:overseas-warehouse:warehouse:list','海外仓资源','/overseas-warehouse/warehouses','GET',750,'ACTIVE',1,0),
|
||||
(5107051,0,'PLATFORM','BUTTON',5107050,'platform:overseas-warehouse:warehouse:create','创建海外仓资源',NULL,'POST',751,'ACTIVE',1,0),
|
||||
(5107052,0,'PLATFORM','BUTTON',5107050,'platform:overseas-warehouse:warehouse:edit','编辑海外仓资源',NULL,'PUT',752,'ACTIVE',1,0),
|
||||
(5107053,0,'PLATFORM','BUTTON',5107050,'platform:overseas-warehouse:warehouse:status','变更海外仓状态',NULL,'PATCH',753,'ACTIVE',1,0),
|
||||
(5107060,0,'PLATFORM','MENU',0,'platform:overseas-warehouse:location:list','海外库位','/overseas-warehouse/locations','GET',760,'ACTIVE',1,0),
|
||||
(5107061,0,'PLATFORM','BUTTON',5107060,'platform:overseas-warehouse:location:create','创建海外库位',NULL,'POST',761,'ACTIVE',1,0),
|
||||
(5107062,0,'PLATFORM','BUTTON',5107060,'platform:overseas-warehouse:location:edit','编辑海外库位',NULL,'PUT',762,'ACTIVE',1,0),
|
||||
(5107063,0,'PLATFORM','BUTTON',5107060,'platform:overseas-warehouse:location:delete','删除海外库位',NULL,'DELETE',763,'ACTIVE',1,0),
|
||||
(5107100,0,'PLATFORM','MENU',0,'platform:overseas-business:purchase:list','海外采购订单','/overseas-business/purchases','GET',800,'ACTIVE',1,0),
|
||||
(5107101,0,'PLATFORM','BUTTON',5107100,'platform:overseas-business:purchase:edit','编辑海外采购订单',NULL,'PATCH',801,'ACTIVE',1,0),
|
||||
(5107102,0,'PLATFORM','BUTTON',5107100,'platform:overseas-business:purchase:review','审核海外采购订单',NULL,'POST',802,'ACTIVE',1,0),
|
||||
(5107110,0,'PLATFORM','MENU',0,'platform:overseas-business:truck:list','本土卡车','/overseas-business/trucks','GET',810,'ACTIVE',1,0),
|
||||
(5107111,0,'PLATFORM','BUTTON',5107110,'platform:overseas-business:truck:create','创建本土卡车单',NULL,'POST',811,'ACTIVE',1,0),
|
||||
(5107112,0,'PLATFORM','BUTTON',5107110,'platform:overseas-business:truck:edit','编辑本土卡车单',NULL,'PUT',812,'ACTIVE',1,0),
|
||||
(5107113,0,'PLATFORM','BUTTON',5107110,'platform:overseas-business:truck:status','变更本土卡车状态',NULL,'PATCH',813,'ACTIVE',1,0),
|
||||
(5107120,0,'PLATFORM','MENU',0,'platform:overseas-business:looseboard:list','本土散板','/overseas-business/looseboards','GET',820,'ACTIVE',1,0),
|
||||
(5107121,0,'PLATFORM','BUTTON',5107120,'platform:overseas-business:looseboard:create','创建本土散板单',NULL,'POST',821,'ACTIVE',1,0),
|
||||
(5107122,0,'PLATFORM','BUTTON',5107120,'platform:overseas-business:looseboard:edit','编辑本土散板单',NULL,'PUT',822,'ACTIVE',1,0),
|
||||
(5107123,0,'PLATFORM','BUTTON',5107120,'platform:overseas-business:looseboard:status','变更本土散板状态',NULL,'PATCH',823,'ACTIVE',1,0),
|
||||
(5107200,0,'PLATFORM','MENU',0,'platform:overseas-master-data:quote:list','海外仓报价','/overseas-master-data/quotes','GET',900,'ACTIVE',1,0),
|
||||
(5107201,0,'PLATFORM','BUTTON',5107200,'platform:overseas-master-data:quote:create','创建海外仓报价',NULL,'POST',901,'ACTIVE',1,0),
|
||||
(5107202,0,'PLATFORM','BUTTON',5107200,'platform:overseas-master-data:quote:edit','编辑海外仓报价',NULL,'PUT',902,'ACTIVE',1,0),
|
||||
(5107203,0,'PLATFORM','BUTTON',5107200,'platform:overseas-master-data:quote:status','变更海外仓报价状态',NULL,'PATCH',903,'ACTIVE',1,0),
|
||||
(5107204,0,'PLATFORM','BUTTON',5107200,'platform:overseas-master-data:quote:export','导出海外仓报价',NULL,'GET',904,'ACTIVE',1,0),
|
||||
(5107210,0,'PLATFORM','MENU',0,'platform:overseas-master-data:exchange-rate:list','月度汇率','/overseas-master-data/exchange-rates','GET',910,'ACTIVE',1,0),
|
||||
(5107211,0,'PLATFORM','BUTTON',5107210,'platform:overseas-master-data:exchange-rate:create','创建月度汇率',NULL,'POST',911,'ACTIVE',1,0),
|
||||
(5107212,0,'PLATFORM','BUTTON',5107210,'platform:overseas-master-data:exchange-rate:edit','编辑月度汇率',NULL,'PUT',912,'ACTIVE',1,0),
|
||||
(5107213,0,'PLATFORM','BUTTON',5107210,'platform:overseas-master-data:exchange-rate:status','变更月度汇率状态',NULL,'PATCH',913,'ACTIVE',1,0),
|
||||
(5107214,0,'PLATFORM','BUTTON',5107210,'platform:overseas-master-data:exchange-rate:export','导出月度汇率',NULL,'GET',914,'ACTIVE',1,0),
|
||||
(5107220,0,'PLATFORM','MENU',0,'platform:overseas-master-data:vehicle:list','车辆主数据','/overseas-master-data/vehicles','GET',920,'ACTIVE',1,0),
|
||||
(5107221,0,'PLATFORM','BUTTON',5107220,'platform:overseas-master-data:vehicle:create','创建车辆主数据',NULL,'POST',921,'ACTIVE',1,0),
|
||||
(5107222,0,'PLATFORM','BUTTON',5107220,'platform:overseas-master-data:vehicle:edit','编辑车辆主数据',NULL,'PUT',922,'ACTIVE',1,0),
|
||||
(5107223,0,'PLATFORM','BUTTON',5107220,'platform:overseas-master-data:vehicle:status','变更车辆状态',NULL,'PATCH',923,'ACTIVE',1,0),
|
||||
(5107224,0,'PLATFORM','BUTTON',5107220,'platform:overseas-master-data:vehicle:export','导出车辆主数据',NULL,'GET',924,'ACTIVE',1,0)
|
||||
ON DUPLICATE KEY UPDATE permission_name=VALUES(permission_name), route_path=VALUES(route_path),
|
||||
http_method=VALUES(http_method), sort_no=VALUES(sort_no), status='ACTIVE', built_in=1, deleted=0;
|
||||
|
||||
@@ -17,9 +69,6 @@ INSERT IGNORE INTO iam_role_permission (tenant_id, role_id, permission_id, creat
|
||||
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'
|
||||
AND permission.id BETWEEN 5107000 AND 5107299
|
||||
WHERE role.tenant_id=0 AND role.app_code='PLATFORM' AND role.role_code='PLATFORM_ADMIN'
|
||||
AND role.status='ACTIVE' AND role.deleted=0
|
||||
AND permission.permission_code IN (
|
||||
'platform:lastleg:order:list', 'platform:lastleg:order:detail',
|
||||
'platform:lastleg:order:assign', 'platform:lastleg:order:transfer',
|
||||
'platform:lastleg:order:merge-pallet');
|
||||
AND role.status='ACTIVE' AND role.deleted=0 AND permission.status='ACTIVE' AND permission.deleted=0;
|
||||
|
||||
+77
@@ -0,0 +1,77 @@
|
||||
package com.baiyue.iam.repository;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.core.io.ClassPathResource;
|
||||
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.util.LinkedHashSet;
|
||||
import java.util.List;
|
||||
import java.util.Set;
|
||||
import java.util.regex.Pattern;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
class OverseasPhase4PermissionSqlContractTest {
|
||||
private static final Pattern CANONICAL = Pattern.compile("platform:overseas-[a-z0-9:-]+");
|
||||
private static final List<String> REQUIRED = List.of(
|
||||
"platform:overseas-warehouse:last-mile-order:list",
|
||||
"platform:overseas-warehouse:inbound-order:list",
|
||||
"platform:overseas-warehouse:relabel-order:status",
|
||||
"platform:overseas-warehouse:pallet:list",
|
||||
"platform:overseas-warehouse:inventory:adjust",
|
||||
"platform:overseas-business:purchase:review",
|
||||
"platform:overseas-business:truck:create",
|
||||
"platform:overseas-business:looseboard:list",
|
||||
"platform:overseas-master-data:quote:list",
|
||||
"platform:overseas-master-data:exchange-rate:list",
|
||||
"platform:overseas-master-data:vehicle:list");
|
||||
|
||||
@Test
|
||||
void canonicalCatalogDisablesLegacyCodesAndExcludesFrozenDomains() throws Exception {
|
||||
String sql = new ClassPathResource("db/lastleg-permissions.sql")
|
||||
.getContentAsString(StandardCharsets.UTF_8);
|
||||
Set<String> catalog = codes(sql);
|
||||
assertThat(sql).contains(REQUIRED.toArray(String[]::new));
|
||||
assertThat(catalog).hasSize(51).noneMatch(code -> code.endsWith(":view"));
|
||||
assertThat(sql).contains("permission_code LIKE 'platform:lastleg:%'", "status='INACTIVE'", "deleted=1");
|
||||
assertThat(sql).doesNotContain(":carton:", ":product:", ":outbound:", ":delivery:");
|
||||
}
|
||||
|
||||
@Test
|
||||
void unifiedSqlContainsTheSameCanonicalPermissionCatalog() throws Exception {
|
||||
Path path = Path.of("deploy/sql/头程数据库统一增量-MySQL8.sql");
|
||||
if (!Files.exists(path)) path = Path.of("../deploy/sql/头程数据库统一增量-MySQL8.sql");
|
||||
String sql = Files.readString(path, StandardCharsets.UTF_8);
|
||||
Set<String> moduleCatalog = codes(new ClassPathResource("db/lastleg-permissions.sql")
|
||||
.getContentAsString(StandardCharsets.UTF_8));
|
||||
assertThat(codes(sql)).containsAll(moduleCatalog);
|
||||
assertThat(sql).contains("baiyue_iam.iam_permission", "baiyue_iam.iam_role_permission");
|
||||
}
|
||||
|
||||
@Test
|
||||
void servicePermissionConstantsExactlyMatchTheIamCatalog() throws Exception {
|
||||
Set<String> moduleCatalog = codes(new ClassPathResource("db/lastleg-permissions.sql")
|
||||
.getContentAsString(StandardCharsets.UTF_8));
|
||||
Set<String> serviceCatalog = new LinkedHashSet<>();
|
||||
serviceCatalog.addAll(codes(repositoryFile(
|
||||
"lastleg-service/src/main/java/com/baiyue/lastleg/security/OverseasPermissions.java")));
|
||||
serviceCatalog.addAll(codes(repositoryFile(
|
||||
"warehouse-overseas-service/src/main/java/com/baiyue/warehouse/security/OverseasWarehousePermissions.java")));
|
||||
|
||||
assertThat(serviceCatalog).containsExactlyInAnyOrderElementsOf(moduleCatalog);
|
||||
}
|
||||
|
||||
private static Set<String> codes(String source) {
|
||||
Set<String> values = new LinkedHashSet<>();
|
||||
CANONICAL.matcher(source).results().map(result -> result.group()).forEach(values::add);
|
||||
return values;
|
||||
}
|
||||
|
||||
private static String repositoryFile(String relativePath) throws Exception {
|
||||
Path path = Path.of(relativePath);
|
||||
if (!Files.exists(path)) path = Path.of("..").resolve(relativePath);
|
||||
return Files.readString(path, StandardCharsets.UTF_8);
|
||||
}
|
||||
}
|
||||
@@ -11,7 +11,7 @@
|
||||
- 内部接口:`controller/IntegrationProviderController.java`、`WeightOcrController.java`
|
||||
- OCR 编排:`service/ocr/WeightOcrBatchService.java`、`ImageCropper.java`、`WeightCandidateSelector.java`
|
||||
- 供应商适配:`provider/IntegrationProviderAdapter.java`、`BaiduOcrAdapter.java`、`AmazonLogisticsAdapter.java`
|
||||
- 四期船节点外部适配:继续进入 `provider/` 和内部接口层;轮询任务、订舱/柜次关联、最新节点和节点历史归 `firstleg/portschedule/`,范围见 `../../docs/四期/README.md`
|
||||
- 四期船节点外部适配:内部契约 `controller/IntegrationProviderController.java` 的 `/internal/integrations/carrier-schedules/query`,统一 DTO `dto/CarrierScheduleDtos.java`,provider SPI `provider/CarrierScheduleProviderAdapter.java`,标准节点 `domain/CarrierScheduleStandardNode.java`;轮询任务、租约、订舱/柜次关联、最新节点和节点历史归 `firstleg/portschedule/`,范围见 `../../docs/四期/README.md`
|
||||
- 安全:`security/InternalServiceTokenGuard.java`;配置 `application.yml`、`../config/nacos/{local,cn}/integration-service.yml`
|
||||
- 测试:`src/test/java/com/baiyue/integration/`;`mvn -pl integration-service -am test`
|
||||
|
||||
|
||||
+16
@@ -4,10 +4,16 @@ import com.baiyue.common.core.api.ApiResponse;
|
||||
import com.baiyue.integration.dto.ProviderStatus;
|
||||
import com.baiyue.integration.security.InternalServiceTokenGuard;
|
||||
import com.baiyue.integration.service.ProviderStatusService;
|
||||
import com.baiyue.integration.dto.CarrierScheduleDtos.QueryRequest;
|
||||
import com.baiyue.integration.dto.CarrierScheduleDtos.QueryResult;
|
||||
import com.baiyue.integration.service.CarrierScheduleProviderService;
|
||||
import jakarta.validation.Valid;
|
||||
import jakarta.annotation.Resource;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.RequestHeader;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.PostMapping;
|
||||
import org.springframework.web.bind.annotation.RequestBody;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
|
||||
import java.util.List;
|
||||
@@ -19,6 +25,8 @@ public class IntegrationProviderController {
|
||||
private ProviderStatusService providerStatusService;
|
||||
@Resource
|
||||
private InternalServiceTokenGuard internalServiceTokenGuard;
|
||||
@Resource
|
||||
private CarrierScheduleProviderService carrierScheduleProviderService;
|
||||
|
||||
@GetMapping("/providers")
|
||||
public ApiResponse<List<ProviderStatus>> providers(
|
||||
@@ -26,4 +34,12 @@ public class IntegrationProviderController {
|
||||
internalServiceTokenGuard.verify(token);
|
||||
return ApiResponse.success(providerStatusService.providers());
|
||||
}
|
||||
|
||||
@PostMapping("/carrier-schedules/query")
|
||||
public ApiResponse<QueryResult> queryCarrierSchedule(
|
||||
@RequestHeader(value = "X-Internal-Service-Token", required = false) String token,
|
||||
@Valid @RequestBody QueryRequest request) {
|
||||
internalServiceTokenGuard.verify(token);
|
||||
return ApiResponse.success(carrierScheduleProviderService.query(request));
|
||||
}
|
||||
}
|
||||
|
||||
+17
@@ -0,0 +1,17 @@
|
||||
package com.baiyue.integration.domain;
|
||||
|
||||
import java.util.Locale;
|
||||
|
||||
public enum CarrierScheduleStandardNode {
|
||||
DEPARTED_WAREHOUSE,
|
||||
AT_ORIGIN_PORT,
|
||||
DEPARTED_ORIGIN_PORT,
|
||||
IN_TRANSIT,
|
||||
ARRIVED,
|
||||
PICKUP_READY;
|
||||
|
||||
public static boolean supports(String value) {
|
||||
try { valueOf(value == null ? "" : value.trim().toUpperCase(Locale.ROOT)); return true; }
|
||||
catch (IllegalArgumentException exception) { return false; }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
package com.baiyue.integration.dto;
|
||||
|
||||
import jakarta.validation.constraints.NotBlank;
|
||||
import jakarta.validation.constraints.Size;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.List;
|
||||
|
||||
public final class CarrierScheduleDtos {
|
||||
private CarrierScheduleDtos() { }
|
||||
|
||||
public record QueryRequest(@NotBlank @Size(max = 16) String carrierCode,
|
||||
@NotBlank @Size(max = 32) String trackingType,
|
||||
@NotBlank @Size(max = 128) String trackingNo) { }
|
||||
|
||||
public record QueryResult(String providerCode, String outcome, List<Observation> observations,
|
||||
String errorCode, String errorMessage, LocalDateTime providerObservedAt) { }
|
||||
|
||||
public record Observation(String providerEventId, String providerNodeCode, String standardNode,
|
||||
LocalDateTime observedAt, LocalDateTime estimatedAt, LocalDateTime actualAt,
|
||||
String locationCode, String locationName, String sourceReference,
|
||||
String rawPayloadHash) { }
|
||||
}
|
||||
+9
@@ -0,0 +1,9 @@
|
||||
package com.baiyue.integration.provider;
|
||||
|
||||
import com.baiyue.integration.dto.CarrierScheduleDtos.QueryRequest;
|
||||
import com.baiyue.integration.dto.CarrierScheduleDtos.QueryResult;
|
||||
|
||||
public interface CarrierScheduleProviderAdapter {
|
||||
boolean supports(String carrierCode);
|
||||
QueryResult query(QueryRequest request);
|
||||
}
|
||||
+35
@@ -0,0 +1,35 @@
|
||||
package com.baiyue.integration.provider;
|
||||
|
||||
import com.baiyue.integration.dto.CarrierScheduleDtos.QueryRequest;
|
||||
import com.baiyue.integration.dto.CarrierScheduleDtos.QueryResult;
|
||||
import com.baiyue.integration.dto.ProviderStatus;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.List;
|
||||
import java.util.Locale;
|
||||
import java.util.Set;
|
||||
|
||||
/**
|
||||
* Safe fallback for the six confirmed carriers. It never scrapes captcha/dynamic pages and returns
|
||||
* an explicit provider-unavailable outcome until a documented provider API adapter is configured.
|
||||
*/
|
||||
@Component
|
||||
public class CompliantCarrierScheduleProviderAdapter implements CarrierScheduleProviderAdapter, IntegrationProviderAdapter {
|
||||
private static final Set<String> CARRIERS = Set.of("MATSON", "SML", "ZIM", "COSCO", "EMC", "HEDE");
|
||||
|
||||
@Override
|
||||
public boolean supports(String carrierCode) {
|
||||
return carrierCode != null && CARRIERS.contains(carrierCode.trim().toUpperCase(Locale.ROOT));
|
||||
}
|
||||
|
||||
@Override
|
||||
public QueryResult query(QueryRequest request) {
|
||||
return new QueryResult("COMPLIANT_MANUAL_FALLBACK", "MANUAL_REQUIRED", List.of(),
|
||||
"PROVIDER_API_NOT_CONFIGURED", "未配置可审计的结构化轨迹供应商接口,不执行网页抓取",
|
||||
LocalDateTime.now());
|
||||
}
|
||||
|
||||
@Override
|
||||
public ProviderStatus status() { return ProviderStatus.of("CARRIER_SCHEDULE", true, false); }
|
||||
}
|
||||
+39
@@ -0,0 +1,39 @@
|
||||
package com.baiyue.integration.service;
|
||||
|
||||
import com.baiyue.integration.dto.CarrierScheduleDtos.QueryRequest;
|
||||
import com.baiyue.integration.dto.CarrierScheduleDtos.QueryResult;
|
||||
import com.baiyue.integration.provider.CarrierScheduleProviderAdapter;
|
||||
import com.baiyue.integration.domain.CarrierScheduleStandardNode;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Set;
|
||||
|
||||
@Service
|
||||
public class CarrierScheduleProviderService {
|
||||
private static final Set<String> OUTCOMES = Set.of("SUCCESS", "NO_CHANGE", "RETRYABLE_FAILURE",
|
||||
"PERMANENT_FAILURE", "MANUAL_REQUIRED");
|
||||
private final List<CarrierScheduleProviderAdapter> adapters;
|
||||
|
||||
public CarrierScheduleProviderService(List<CarrierScheduleProviderAdapter> adapters) {
|
||||
this.adapters = List.copyOf(adapters);
|
||||
}
|
||||
|
||||
public QueryResult query(QueryRequest request) {
|
||||
QueryResult result = adapters.stream().filter(adapter -> adapter.supports(request.carrierCode())).findFirst()
|
||||
.orElseThrow(() -> new IllegalArgumentException(
|
||||
"没有支持该船司的轨迹供应商适配器:" + request.carrierCode()))
|
||||
.query(request);
|
||||
if (result == null || result.observations() == null || !OUTCOMES.contains(result.outcome())) {
|
||||
throw new IllegalStateException("轨迹供应商返回空结果");
|
||||
}
|
||||
if (result.observations().stream().anyMatch(value -> value == null
|
||||
|| !CarrierScheduleStandardNode.supports(value.standardNode())
|
||||
|| value.providerEventId() == null || value.providerEventId().isBlank()
|
||||
|| value.observedAt() == null || value.rawPayloadHash() == null
|
||||
|| !value.rawPayloadHash().matches("[0-9a-f]{64}"))) {
|
||||
throw new IllegalStateException("轨迹供应商返回了无效标准节点");
|
||||
}
|
||||
return result;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
openapi: 3.0.3
|
||||
info:
|
||||
title: Carrier Schedule Provider Internal API
|
||||
version: 1.0.0
|
||||
description: Internal provider adapter contract. It owns normalization, timeout, rate-limit and provider errors, not business tasks.
|
||||
paths:
|
||||
/internal/integrations/carrier-schedules/query:
|
||||
post:
|
||||
summary: Query one carrier tracking identifier through the configured provider adapter
|
||||
parameters:
|
||||
- name: X-Internal-Service-Token
|
||||
in: header
|
||||
required: true
|
||||
schema: {type: string}
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema: {$ref: '#/components/schemas/QueryRequest'}
|
||||
responses:
|
||||
'200': {description: Standard observations or an explicit normalized failure/manual outcome}
|
||||
components:
|
||||
schemas:
|
||||
QueryRequest:
|
||||
type: object
|
||||
required: [carrierCode, trackingType, trackingNo]
|
||||
properties:
|
||||
carrierCode: {type: string, enum: [MATSON, SML, ZIM, COSCO, EMC, HEDE]}
|
||||
trackingType: {type: string, enum: [BOOKING_NO, BILL_OF_LADING, CONTAINER_NO]}
|
||||
trackingNo: {type: string, minLength: 1, maxLength: 128}
|
||||
StandardNode:
|
||||
type: string
|
||||
enum: [DEPARTED_WAREHOUSE, AT_ORIGIN_PORT, DEPARTED_ORIGIN_PORT, IN_TRANSIT, ARRIVED, PICKUP_READY]
|
||||
QueryResult:
|
||||
type: object
|
||||
required: [providerCode, outcome, observations]
|
||||
properties:
|
||||
providerCode: {type: string}
|
||||
outcome: {type: string, enum: [SUCCESS, NO_CHANGE, RETRYABLE_FAILURE, PERMANENT_FAILURE, MANUAL_REQUIRED]}
|
||||
observations:
|
||||
type: array
|
||||
items:
|
||||
type: object
|
||||
required: [providerEventId, providerNodeCode, standardNode, observedAt, rawPayloadHash]
|
||||
properties:
|
||||
providerEventId: {type: string}
|
||||
providerNodeCode: {type: string}
|
||||
standardNode: {$ref: '#/components/schemas/StandardNode'}
|
||||
observedAt: {type: string, format: date-time}
|
||||
estimatedAt: {type: string, format: date-time, nullable: true}
|
||||
actualAt: {type: string, format: date-time, nullable: true}
|
||||
locationCode: {type: string, nullable: true}
|
||||
locationName: {type: string, nullable: true}
|
||||
sourceReference: {type: string, nullable: true}
|
||||
rawPayloadHash: {type: string, pattern: '^[0-9a-f]{64}$'}
|
||||
+41
@@ -0,0 +1,41 @@
|
||||
package com.baiyue.integration.service;
|
||||
|
||||
import com.baiyue.integration.dto.CarrierScheduleDtos.Observation;
|
||||
import com.baiyue.integration.dto.CarrierScheduleDtos.QueryRequest;
|
||||
import com.baiyue.integration.dto.CarrierScheduleDtos.QueryResult;
|
||||
import com.baiyue.integration.provider.CarrierScheduleProviderAdapter;
|
||||
import com.baiyue.integration.provider.CompliantCarrierScheduleProviderAdapter;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.List;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||||
|
||||
class CarrierScheduleProviderServiceTest {
|
||||
@Test
|
||||
void fallbackNeverPretendsThatWebsiteScrapingSucceeded() {
|
||||
var service = new CarrierScheduleProviderService(List.of(new CompliantCarrierScheduleProviderAdapter()));
|
||||
QueryResult result = service.query(new QueryRequest("COSCO", "CONTAINER_NO", "CSNU1234567"));
|
||||
assertThat(result.outcome()).isEqualTo("MANUAL_REQUIRED");
|
||||
assertThat(result.errorCode()).isEqualTo("PROVIDER_API_NOT_CONFIGURED");
|
||||
assertThat(result.observations()).isEmpty();
|
||||
}
|
||||
|
||||
@Test
|
||||
void rejectsProviderSpecificNodesThatWereNotNormalized() {
|
||||
CarrierScheduleProviderAdapter invalid = new CarrierScheduleProviderAdapter() {
|
||||
@Override public boolean supports(String carrierCode) { return true; }
|
||||
@Override public QueryResult query(QueryRequest request) {
|
||||
return new QueryResult("TEST", "SUCCESS", List.of(new Observation("evt", "RAW", "UNKNOWN",
|
||||
LocalDateTime.now(), null, null, null, null, null,
|
||||
"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa")), null, null,
|
||||
LocalDateTime.now());
|
||||
}
|
||||
};
|
||||
var service = new CarrierScheduleProviderService(List.of(invalid));
|
||||
assertThatThrownBy(() -> service.query(new QueryRequest("COSCO", "CONTAINER_NO", "CSNU1234567")))
|
||||
.hasMessageContaining("无效标准节点");
|
||||
}
|
||||
}
|
||||
@@ -3,7 +3,7 @@
|
||||
## 职责与边界
|
||||
|
||||
- 负责消费头程装柜完成事件,保存业务 Inbox,并按最终实装入仓单建立尾程订单、明细冻结快照和初始状态历史。
|
||||
- 负责尾程订单列表、详情、导出和目的仓调整,以及海外入仓计划、托盘分配与 PDA 入仓作业。
|
||||
- 负责尾程订单列表、详情、导出和目的仓调整,以及海外入仓计划、托盘分配与 PDA 入仓作业;PDA 卸柜完成后通过本地 Outbox 向国内头程回传货柜报空事实。
|
||||
- 不查询头程数据库,不调用头程接口补齐建单字段;事件快照缺失时应拒绝消费并保留可诊断错误。
|
||||
|
||||
## 快速索引
|
||||
@@ -17,16 +17,19 @@
|
||||
| 业务 Inbox | `entity/LastlegEventInbox.java`、`mapper/LastlegEventInboxMapper.java` |
|
||||
| 尾程订单、明细、状态历史 | `entity/LastlegOrder.java`、`LastlegOrderDetail.java`、`LastlegOrderStatusHistory.java` 及对应 Mapper |
|
||||
| 内部令牌保护 | `security/InternalServiceTokenGuard.java` |
|
||||
| 入仓计划转换/查询 | `inboundplan/controller/InboundPlanController.java`、`inboundplan/service/InboundPlanService.java` |
|
||||
| 入仓计划分页、转换与详情 | `inboundplan/controller/InboundPlanController.java`、`inboundplan/dto/InboundPlanRequests.java`、`InboundPlanSummary.java`、`inboundplan/service/InboundPlanService.java` |
|
||||
| 托盘分配算法 | `inboundplan/service/PalletAllocator.java` |
|
||||
| PDA 入仓、实收、上架、合托、完成 | `inboundplan/pda/controller/`、`inboundplan/pda/service/InboundPdaWorkflowService.java` |
|
||||
| 入仓后库存余额、库位和流水 | `inventory/`;平台库存命令从 `inventory/command/controller/InventoryCommandController.java` 进入 |
|
||||
| PDA 入仓、实收、上架、合托、完成 | `inboundplan/pda/controller/`、`inboundplan/pda/service/InboundPdaWorkflowService.java`;入口固定为 `/api/pda/v1/last-leg/**`,不得挂入平台浏览器 API |
|
||||
| 卸柜照片与海外卸柜完成 Outbox | `inboundplan/pda/controller/InboundPdaController.java`、`inboundplan/pda/service/InboundUnloadCompletedOutboxService.java`、`entity/LastlegEventOutbox.java`、`mapper/LastlegEventOutboxMapper.java`;事件模型见 `../common-event-contract/AGENTS.md` |
|
||||
| 入仓后 SKU 库存余额、库位和流水 | `inventory/`;平台只读查询与非出库库存命令分别从 `inventory/query/controller/InventoryQueryController.java`、`inventory/command/controller/InventoryCommandController.java` 进入 |
|
||||
| 仓储资源调用 | `inboundplan/pda/gateway/WarehouseResourceGateway.java`、`HttpWarehouseResourceGateway.java` |
|
||||
| 仓储转运、历史订单/入仓绑定、可选换标联动 | `transfer/controller/WarehouseTransferController.java`、`transfer/service/WarehouseTransferService.java` |
|
||||
| 一件代发接收、SKU/箱唛库存 | `dropship/service/DropshipOrderService.java`、`dropship/entity/`、`dropship/mapper/` |
|
||||
| 货物废弃、待审锁箱、审批扣减 | `discard/controller/CargoDiscardController.java`、`discard/service/CargoDiscardService.java` |
|
||||
| 海外采购、客户下单、采购审核、手工出库与采购库存 | `procurement/controller/`、`procurement/service/OverseasProcurementService.java` |
|
||||
| 海外采购、客户下单、采购审核、客户/采购仓候选与操作流水 | `procurement/controller/`、`procurement/service/OverseasProcurementService.java`;OpenAPI `src/main/resources/openapi/overseas-business-v1.yaml` |
|
||||
| 美国/本土卡车订单 | `trucking/controller/PlatformUsTruckOrderController.java`、`MerchantUsTruckOrderController.java` 及同子域 Service/DTO |
|
||||
| 本土散板订单 | `looseboard/controller/PlatformLocalLooseboardController.java` 及同子域 Service/DTO/Entity/Mapper;OpenAPI `src/main/resources/openapi/overseas-business-v1.yaml` |
|
||||
| 海外仓报价、月度汇率、车辆主数据 | `masterdata/quote/`、`masterdata/exchange/`、`masterdata/vehicle/`;OpenAPI `src/main/resources/openapi/overseas-master-data-v1.yaml`;车辆档案不含派送履约历史 |
|
||||
| 数据库基线、表索引与中文注释契约 | `src/main/resources/db/lastleg.sql`、`src/test/java/com/baiyue/lastleg/schema/SchemaChineseCommentContractTest.java` |
|
||||
| 测试 | `src/test/java/com/baiyue/lastleg/` |
|
||||
|
||||
@@ -34,19 +37,21 @@
|
||||
|
||||
- 四期在本服务内只进入尾程订单、入仓计划/PDA 入仓、换标、产品与箱唛快照、库存、海外采购和本土卡车;本土散板新增实现也归本服务的独立稳定子域。
|
||||
- `dispatch/`、`delivery/`、出库单、PDA 出库拣货、派送和其他库存离仓能力虽然已存在于服务代码或表结构中,但不属于当前四期开发与验收范围,不得因代码已存在而自动纳入。
|
||||
- 历史 `dispatch/`、`delivery/` Controller 默认不注册;只有后续阶段明确启用 `lastleg.future-outbound-api.enabled=true` 才可恢复。四期平台库存 Controller 不映射 allocate、unallocate、pick、ship。
|
||||
- 海外仓库、库位、实物托盘和资源锁归 `warehouse-overseas-service/`;浏览器 API 统一经 `gateway-overseas/`,本服务不得复制仓储资源主数据或另建公网认证入口。
|
||||
|
||||
## 数据库表索引与注释约束
|
||||
|
||||
| 业务域 | 表名 | 中文含义 |
|
||||
|---|---|---|
|
||||
| 核心订单与事件 | `ll_loading_*`、`ll_event_*`、`ll_order*`、`ll_inbound_order*` | 事件收件、尾程订单、明细及状态历史 |
|
||||
| 核心订单与事件 | `ll_loading_*`、`ll_event_*`、`ll_order*`、`ll_inbound_order*` | 事件收件/发件、尾程订单、明细及状态历史 |
|
||||
| 入仓计划与作业 | `ll_inbound_plan*`、`ll_inbound_pda*`、`ll_inbound_pallet_merge`、`ll_inbound_fba_appointment*` | 入仓计划、托盘分配、PDA 作业、合托及 FBA 预约 |
|
||||
| 库存 | `ll_inventory_*`、`ll_inbound_stock_receipt` | 库存余额、仓位、流水、命令幂等及实收快照 |
|
||||
| 派车与出库 | `ll_dispatch_*`、`ll_outbound_*`、`ll_delivery_order` | 派车、PDA 拣货、出库及派送 |
|
||||
| 转仓与换标 | `ll_warehouse_transfer_*`、`ll_relabel_*` | 转仓单、库存转移、目标收货及换标 |
|
||||
| 一件代发与废弃 | `ll_dropship_*`、`ll_cargo_discard_*` | 托盘/箱唛库存、流水和废弃审批 |
|
||||
| 采购与卡车 | `ll_overseas_procurement_*`、`ll_us_truck_*` | 海外采购及美国卡车运输 |
|
||||
| 采购、卡车与本土散板 | `ll_overseas_procurement_*`、`ll_us_truck_*`、`ll_local_looseboard_*` | 海外采购、美国卡车运输及本土散板订单 |
|
||||
| 通用资料 | `ll_overseas_quote*`、`ll_monthly_exchange_rate*`、`ll_vehicle` | 海外仓报价、月度汇率及车辆档案;不保存车辆派送履约历史 |
|
||||
|
||||
- 新增或修改表时,表 `COMMENT` 和每个普通字段的 `COMMENT` 必须填写准确的中文业务含义,不得只写英文、字段名或类型名。
|
||||
- `SchemaChineseCommentContractTest` 自动扫描全部 `ll_` 表,无需维护表名白名单;新增字段必须同步更新基线 SQL、建表增量和存量备注迁移,增量脚本的 `ADD/MODIFY COLUMN` 不得丢失中文备注。
|
||||
@@ -61,14 +66,16 @@
|
||||
- 整个事件消费在一个事务内完成;任意订单失败时不得留下部分订单。
|
||||
- 相同事件重复投递返回既有结果,不重新生成单号或插入明细。来源入仓单唯一约束是最终防线。
|
||||
- 内部接口先执行常量时间令牌校验;缺失或错误令牌不得进入消费事务。
|
||||
- PDA 入仓任务完成前必须先通过 `/unload-photos` 保存非空且不重复的卸柜照片文件 ID;照片上传时间由服务端记录,预计报空时间固定为照片上传时间加两天。完成入仓时,库存入账、计划/任务状态推进与 `OVERSEAS_CONTAINER_UNLOADED` V1 Outbox 必须处于同一事务;事件绑定来源头程货柜身份,不得从前端请求拼装或回查头程数据库。
|
||||
|
||||
## 查询与租户约束
|
||||
|
||||
- 平台查询范围由可信身份/平台权限决定;商户查询只能使用认证身份中的客户 ID。
|
||||
- 常规入仓库存余额以客户、海外仓、目的仓、入仓单和 SKU 为唯一业务维度;PDA 完成入库时按冻结商品明细精确拆分 SKU 箱数,转仓收货继承来源库存 SKU,不得回退为仅按入仓单汇总。
|
||||
- 列表、详情、导出和更新必须使用同一租户边界,不能仅在列表过滤而让详情或更新越权。
|
||||
- 目的仓变更必须写状态/操作历史或审计证据,并使用版本控制防止并发覆盖。
|
||||
- 货物废弃只使用可核对 SKU 和箱唛的一件代发库存。创建申请只能将箱从 `AVAILABLE` 锁定为 `DISCARD_PENDING`,不得扣减 SKU 箱数;实际扣库存节点固定为具备 `lastleg:cargo-discard:approve` 权限的仓库/PDA 审核人确认废弃时,并且必须在审核事务内调用专用 discard adjustment。该 adjustment 必须将箱改为 `DISCARDED`、写逐箱业务流水及唯一幂等键,并校验 `扣减前箱数 - 废弃箱数 = 扣减后箱数` 且逐箱数等于申请总箱数;任一环节失败须整体回滚。拒绝申请只释放锁箱,不得扣库存。
|
||||
- 海外采购客户下单固定进入 `PENDING_REVIEW`;采购表的 `inbound_quantity - outbound_quantity` 只表示采购业务单据余量,不得写入或冒充 `available_packages`。审核/下单只能停留在采购单据(或明确的 `procurement_packages` 桶);只有海外仓 PDA 完成实收后,`InboundStockReceiptService` 才能以 `PDA_STOCK_IN:{taskId}:{allocationId}` 幂等键调用库存 `stockIn` 转入可用库存。禁止通过通用平台接口绕过 PDA 入可用库存。
|
||||
- 海外采购客户下单固定进入 `PENDING_REVIEW`;四期采购只维护采购订单、审核、入库数量、候选快照和操作流水,不提供采购出库端点、出库记录或出库数量。采购单据不得写入或冒充 `available_packages`;只有海外仓 PDA 完成实收后,`InboundStockReceiptService` 才能以 `PDA_STOCK_IN:{taskId}:{allocationId}` 幂等键调用库存 `stockIn` 转入可用库存。禁止通过通用平台接口绕过 PDA 入可用库存。
|
||||
- 派车状态只允许 PDA 拣货、完成和出库动作推进,普通派车编辑接口不得修改状态。PDA 写操作要求 `lastleg:pda:operate`;后台库存表单操作要求 `lastleg:inventory:manage`,盘点调整另要求 `lastleg:inventory:adjust`,转仓库存操作要求 `lastleg:warehouse-transfer:manage`。
|
||||
- `ll_order.current_status` 只保存尾程主流程状态;换标、转仓、出库和派送等子单精确状态保存到各自单据并汇总到 `sub_order_status`,不得再用子单状态覆盖主流程状态。PDA 入仓及库存落账完成后,主流程状态固定推进为 `INBOUND_COMPLETED`。
|
||||
|
||||
|
||||
@@ -13,6 +13,8 @@
|
||||
<dependency><groupId>com.alibaba.cloud</groupId><artifactId>spring-cloud-starter-alibaba-nacos-discovery</artifactId></dependency>
|
||||
<dependency><groupId>com.alibaba.cloud</groupId><artifactId>spring-cloud-starter-alibaba-nacos-config</artifactId></dependency>
|
||||
<dependency><groupId>com.alibaba.cloud</groupId><artifactId>spring-cloud-starter-alibaba-sentinel</artifactId></dependency>
|
||||
<dependency><groupId>org.springframework.cloud</groupId><artifactId>spring-cloud-starter-openfeign</artifactId></dependency>
|
||||
<dependency><groupId>org.springframework.cloud</groupId><artifactId>spring-cloud-starter-loadbalancer</artifactId></dependency>
|
||||
<dependency><groupId>com.alibaba.cloud</groupId><artifactId>spring-cloud-starter-stream-rocketmq</artifactId></dependency>
|
||||
<dependency><groupId>org.springframework.boot</groupId><artifactId>spring-boot-starter-actuator</artifactId></dependency>
|
||||
<dependency><groupId>com.mysql</groupId><artifactId>mysql-connector-j</artifactId><scope>runtime</scope></dependency>
|
||||
@@ -20,4 +22,3 @@
|
||||
</dependencies>
|
||||
<build><plugins><plugin><groupId>org.springframework.boot</groupId><artifactId>spring-boot-maven-plugin</artifactId></plugin></plugins></build>
|
||||
</project>
|
||||
|
||||
|
||||
@@ -3,11 +3,15 @@ package com.baiyue.lastleg;
|
||||
import org.mybatis.spring.annotation.MapperScan;
|
||||
import org.springframework.boot.SpringApplication;
|
||||
import org.springframework.boot.autoconfigure.SpringBootApplication;
|
||||
import org.springframework.cloud.openfeign.EnableFeignClients;
|
||||
import org.springframework.scheduling.annotation.EnableScheduling;
|
||||
|
||||
/**
|
||||
* 尾程微服务预留模块启动入口,初始化 Spring Boot 应用并扫描尾程 Mapper。
|
||||
*/
|
||||
@MapperScan("com.baiyue.lastleg.mapper")
|
||||
@EnableFeignClients(basePackages = "com.baiyue.lastleg")
|
||||
@EnableScheduling
|
||||
@SpringBootApplication(scanBasePackages = "com.baiyue")
|
||||
public class BaiyueLastlegApplication {
|
||||
public static void main(String[] args) { SpringApplication.run(BaiyueLastlegApplication.class, args); }
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
package com.baiyue.lastleg.client;
|
||||
|
||||
import com.baiyue.common.core.api.ApiResponse;
|
||||
import com.baiyue.common.event.EventEnvelope;
|
||||
import org.springframework.cloud.openfeign.FeignClient;
|
||||
import org.springframework.web.bind.annotation.PostMapping;
|
||||
import org.springframework.web.bind.annotation.RequestBody;
|
||||
import org.springframework.web.bind.annotation.RequestHeader;
|
||||
|
||||
/** 将海外业务 Outbox 投递到国内区域事件桥。 */
|
||||
@FeignClient(name = "event-bridge-service", contextId = "overseasContainerEventBridgeClient",
|
||||
url = "${baiyue.firstleg.event-bridge-url:}")
|
||||
public interface EventBridgeClient {
|
||||
@PostMapping("/internal/event-bridge/events")
|
||||
ApiResponse<Acknowledgement> receive(
|
||||
@RequestHeader("X-Internal-Service-Token") String token,
|
||||
@RequestBody EventEnvelope envelope);
|
||||
|
||||
record Acknowledgement(String eventId, String result, String receivedAt) { }
|
||||
}
|
||||
+12
-5
@@ -8,6 +8,7 @@ import com.baiyue.lastleg.dto.LastlegOrderDetailResponse;
|
||||
import com.baiyue.lastleg.dto.LastlegOrderRequests;
|
||||
import com.baiyue.lastleg.dto.LastlegOrderSummary;
|
||||
import com.baiyue.lastleg.security.InternalServiceTokenGuard;
|
||||
import com.baiyue.lastleg.security.OverseasPermissions;
|
||||
import com.baiyue.lastleg.service.LastlegOrderService;
|
||||
import com.baiyue.lastleg.service.LoadingCompletedConsumer;
|
||||
import io.swagger.v3.oas.annotations.Operation;
|
||||
@@ -19,6 +20,7 @@ import org.springframework.http.HttpStatus;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.security.core.annotation.AuthenticationPrincipal;
|
||||
import org.springframework.security.access.prepost.PreAuthorize;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.PathVariable;
|
||||
import org.springframework.web.bind.annotation.PostMapping;
|
||||
@@ -48,7 +50,8 @@ public class LastlegOrderController {
|
||||
}
|
||||
|
||||
@GetMapping("/api/platform/v1/last-leg/orders")
|
||||
@Operation(summary = "Page tail-leg order rows")
|
||||
@PreAuthorize("hasAuthority('" + OverseasPermissions.LAST_MILE_ORDER_LIST + "')")
|
||||
@Operation(summary = "Page tail-leg order rows", description = "Required authority: " + OverseasPermissions.LAST_MILE_ORDER_LIST)
|
||||
public ApiResponse<PageResult<LastlegOrderSummary>> list(
|
||||
@AuthenticationPrincipal AuthenticatedUser user,
|
||||
@RequestParam(required = false) String customerId,
|
||||
@@ -57,7 +60,8 @@ public class LastlegOrderController {
|
||||
}
|
||||
|
||||
@GetMapping("/api/platform/v1/last-leg/orders/{id}")
|
||||
@Operation(summary = "Get one inbound-code-level tail-leg order")
|
||||
@PreAuthorize("hasAuthority('" + OverseasPermissions.LAST_MILE_ORDER_LIST + "')")
|
||||
@Operation(summary = "Get one inbound-code-level tail-leg order", description = "Required authority: " + OverseasPermissions.LAST_MILE_ORDER_LIST)
|
||||
public ApiResponse<LastlegOrderDetailResponse> detail(
|
||||
@AuthenticationPrincipal AuthenticatedUser user,
|
||||
@RequestParam(required = false) String customerId,
|
||||
@@ -66,7 +70,8 @@ public class LastlegOrderController {
|
||||
}
|
||||
|
||||
@GetMapping(value = "/api/platform/v1/last-leg/orders/export", produces = "text/csv")
|
||||
@Operation(summary = "Export filtered tail-leg orders as UTF-8 CSV")
|
||||
@PreAuthorize("hasAuthority('" + OverseasPermissions.LAST_MILE_ORDER_EXPORT + "')")
|
||||
@Operation(summary = "Export filtered tail-leg orders as UTF-8 CSV", description = "Required authority: " + OverseasPermissions.LAST_MILE_ORDER_EXPORT)
|
||||
public ResponseEntity<byte[]> export(
|
||||
@AuthenticationPrincipal AuthenticatedUser user,
|
||||
@RequestParam(required = false) String customerId,
|
||||
@@ -79,7 +84,8 @@ public class LastlegOrderController {
|
||||
}
|
||||
|
||||
@PutMapping("/api/platform/v1/last-leg/orders/{id}/destination")
|
||||
@Operation(summary = "Transfer one order to a destination warehouse")
|
||||
@PreAuthorize("hasAuthority('" + OverseasPermissions.LAST_MILE_ORDER_EDIT + "')")
|
||||
@Operation(summary = "Transfer one order to a destination warehouse", description = "Required authority: " + OverseasPermissions.LAST_MILE_ORDER_EDIT)
|
||||
public ApiResponse<Integer> transferOne(
|
||||
@AuthenticationPrincipal AuthenticatedUser user,
|
||||
@RequestParam(required = false) String customerId,
|
||||
@@ -89,7 +95,8 @@ public class LastlegOrderController {
|
||||
}
|
||||
|
||||
@PutMapping("/api/platform/v1/last-leg/orders/destination")
|
||||
@Operation(summary = "Transfer multiple orders to a destination warehouse")
|
||||
@PreAuthorize("hasAuthority('" + OverseasPermissions.LAST_MILE_ORDER_EDIT + "')")
|
||||
@Operation(summary = "Transfer multiple orders to a destination warehouse", description = "Required authority: " + OverseasPermissions.LAST_MILE_ORDER_EDIT)
|
||||
public ApiResponse<Integer> transferBatch(
|
||||
@AuthenticationPrincipal AuthenticatedUser user,
|
||||
@RequestParam(required = false) String customerId,
|
||||
|
||||
+2
@@ -10,12 +10,14 @@ import io.swagger.v3.oas.annotations.Operation;
|
||||
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||
import jakarta.annotation.Resource;
|
||||
import jakarta.validation.Valid;
|
||||
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.security.core.annotation.AuthenticationPrincipal;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
import org.springframework.web.server.ResponseStatusException;
|
||||
|
||||
@RestController
|
||||
@ConditionalOnProperty(name = "lastleg.future-outbound-api.enabled", havingValue = "true")
|
||||
@RequestMapping("/api/platform/v1/last-leg")
|
||||
@Tag(name = "Outbound and delivery")
|
||||
public class DeliveryTransferController {
|
||||
|
||||
+1
-8
@@ -1,6 +1,5 @@
|
||||
package com.baiyue.lastleg.delivery.dto;
|
||||
|
||||
import com.baiyue.common.core.api.PageResult;
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
public final class DeliveryResponses {
|
||||
@@ -17,11 +16,5 @@ public final class DeliveryResponses {
|
||||
String overseasWarehouseId, String status, int palletCount,
|
||||
int packageCount, LocalDateTime createdAt) {}
|
||||
|
||||
public record PalletRow(Long id, Long warehousePalletId, String palletNo, String inboundCode,
|
||||
String orderRefs, String destinationWarehouseId,
|
||||
String destinationWarehouseCode, String destinationWarehouseName,
|
||||
int packageCount) {}
|
||||
|
||||
public record OutboundDetail(OutboundRow outbound, DeliveryRow delivery,
|
||||
PageResult<PalletRow> pallets) {}
|
||||
public record OutboundDetail(OutboundRow outbound, DeliveryRow delivery) {}
|
||||
}
|
||||
|
||||
+1
-9
@@ -136,14 +136,7 @@ public class DeliveryTransferService {
|
||||
OutboundOrder outbound = outboundOrders.findByNo(customerId, outboundNo);
|
||||
if (outbound == null) throw notFound("Outbound order not found");
|
||||
DeliveryOrder delivery = requireDelivery(customerId, outbound.getId());
|
||||
Page<OutboundPalletItem> page = outboundPallets.selectPage(new Page<>(query.page(), query.size()),
|
||||
new LambdaQueryWrapper<OutboundPalletItem>().eq(OutboundPalletItem::getCustomerId, customerId)
|
||||
.eq(OutboundPalletItem::getOutboundOrderId, outbound.getId())
|
||||
.eq(StringUtils.hasText(query.destinationWarehouseCode()),
|
||||
OutboundPalletItem::getDestinationWarehouseCode, query.destinationWarehouseCode())
|
||||
.orderByAsc(OutboundPalletItem::getId));
|
||||
return new DeliveryResponses.OutboundDetail(outboundRow(outbound), deliveryRow(delivery),
|
||||
page(page, page.getRecords().stream().map(this::palletRow).toList()));
|
||||
return new DeliveryResponses.OutboundDetail(outboundRow(outbound), deliveryRow(delivery));
|
||||
}
|
||||
|
||||
public PageResult<DeliveryResponses.DeliveryRow> deliveryPage(String customerId, DeliveryRequests.Query query) {
|
||||
@@ -206,7 +199,6 @@ public class DeliveryTransferService {
|
||||
}
|
||||
private DeliveryResponses.OutboundRow outboundRow(OutboundOrder o) { return new DeliveryResponses.OutboundRow(o.getId(), o.getOutboundNo(), o.getDispatchNo(), o.getOverseasWarehouseId(), o.getStatus(), o.getPalletCount(), o.getPackageCount(), o.getCreatedAt()); }
|
||||
private DeliveryResponses.DeliveryRow deliveryRow(DeliveryOrder d) { return new DeliveryResponses.DeliveryRow(d.getId(), d.getDeliveryNo(), d.getOutboundNo(), d.getDispatchNo(), d.getOverseasWarehouseId(), d.getStatus(), d.getPalletCount(), d.getPackageCount(), d.getCreatedAt()); }
|
||||
private DeliveryResponses.PalletRow palletRow(OutboundPalletItem p) { return new DeliveryResponses.PalletRow(p.getId(), p.getWarehousePalletId(), p.getPalletNo(), p.getInboundCode(), p.getOrderRefs(), p.getDestinationWarehouseId(), p.getDestinationWarehouseCode(), p.getDestinationWarehouseName(), p.getPackageCount()); }
|
||||
private static <T> PageResult<T> page(Page<?> page, List<T> records) { return new PageResult<>(records, page.getCurrent(), page.getSize(), page.getTotal(), page.getPages()); }
|
||||
private static ResponseStatusException conflict(String message) { return new ResponseStatusException(HttpStatus.CONFLICT, message); }
|
||||
private static ResponseStatusException notFound(String message) { return new ResponseStatusException(HttpStatus.NOT_FOUND, message); }
|
||||
|
||||
+2
@@ -15,6 +15,7 @@ import io.swagger.v3.oas.annotations.Operation;
|
||||
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||
import jakarta.annotation.Resource;
|
||||
import jakarta.validation.Valid;
|
||||
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.security.core.annotation.AuthenticationPrincipal;
|
||||
import org.springframework.web.bind.annotation.DeleteMapping;
|
||||
@@ -31,6 +32,7 @@ import org.springframework.web.server.ResponseStatusException;
|
||||
import java.util.List;
|
||||
|
||||
@RestController
|
||||
@ConditionalOnProperty(name = "lastleg.future-outbound-api.enabled", havingValue = "true")
|
||||
@RequestMapping("/api/platform/v1/last-leg/dispatches")
|
||||
@Tag(name = "Tail-leg vehicle dispatch")
|
||||
public class DispatchController {
|
||||
|
||||
+3
-1
@@ -7,13 +7,15 @@ import com.baiyue.lastleg.dispatch.pda.dto.DispatchPdaResponses;
|
||||
import com.baiyue.lastleg.dispatch.pda.service.DispatchPdaWorkflowService;
|
||||
import jakarta.annotation.Resource;
|
||||
import jakarta.validation.Valid;
|
||||
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.security.core.annotation.AuthenticationPrincipal;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
import org.springframework.web.server.ResponseStatusException;
|
||||
|
||||
@RestController
|
||||
@RequestMapping("/api/platform/v1/last-leg/dispatches/{dispatchNo}/pda")
|
||||
@ConditionalOnProperty(name = "lastleg.future-outbound-api.enabled", havingValue = "true")
|
||||
@RequestMapping("/api/pda/v1/last-leg/dispatches/{dispatchNo}")
|
||||
public class DispatchPdaController {
|
||||
public static final String OPERATE_PERMISSION = "lastleg:pda:operate";
|
||||
@Resource private DispatchPdaWorkflowService service;
|
||||
|
||||
+5
-17
@@ -1,27 +1,15 @@
|
||||
package com.baiyue.lastleg.dto;
|
||||
|
||||
import com.baiyue.lastleg.entity.LastlegOrder;
|
||||
import com.baiyue.lastleg.entity.LastlegOrderDetail;
|
||||
import com.baiyue.lastleg.entity.LastlegOrderStatusHistory;
|
||||
import com.baiyue.lastleg.dropship.entity.DropshipCarton;
|
||||
import com.baiyue.lastleg.dropship.entity.DropshipSkuInventory;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
public record LastlegOrderDetailResponse(
|
||||
LastlegOrder order,
|
||||
LastlegOrderSummary order,
|
||||
List<LastlegOrderDetail> cargo,
|
||||
List<LastlegOrderStatusHistory> statusHistory,
|
||||
List<DropshipPallet> dropshipPallets
|
||||
List<History> statusHistory
|
||||
) {
|
||||
public record DropshipPallet(
|
||||
Long profileId,
|
||||
String sourcePalletNo,
|
||||
Long warehousePalletId,
|
||||
String warehousePalletNo,
|
||||
Long inventoryPositionId,
|
||||
String status,
|
||||
List<DropshipSkuInventory> skuInventory,
|
||||
List<DropshipCarton> cartons
|
||||
) {}
|
||||
public record History(Long id, String fromStatus, String toStatus, String remark, LocalDateTime createdAt) {}
|
||||
}
|
||||
|
||||
@@ -26,9 +26,6 @@ public record LastlegOrderSummary(
|
||||
Boolean detained,
|
||||
Integer palletCount,
|
||||
Integer packageCount,
|
||||
Integer pickedPackageCount,
|
||||
Integer shippedPackageCount,
|
||||
Integer remainingPackageCount,
|
||||
BigDecimal volumeCbm,
|
||||
Integer productQuantity,
|
||||
BigDecimal volumeWeightKg,
|
||||
@@ -36,8 +33,6 @@ public record LastlegOrderSummary(
|
||||
LocalDateTime actualArrivalAt,
|
||||
LocalDateTime estimatedWarehouseAt,
|
||||
String inboundOrderNo,
|
||||
String outboundOrderNo,
|
||||
String deliveryOrderNo,
|
||||
String relabelOrderNo,
|
||||
String relabelStatus,
|
||||
String palletMergeFlag,
|
||||
@@ -46,6 +41,7 @@ public record LastlegOrderSummary(
|
||||
String privateRemark,
|
||||
String customerRemark,
|
||||
String warehouseRemark,
|
||||
LocalDateTime createdAt
|
||||
LocalDateTime createdAt,
|
||||
LocalDateTime updatedAt
|
||||
) {
|
||||
}
|
||||
|
||||
@@ -23,6 +23,8 @@ public class LastlegEventOutbox {
|
||||
private Integer retryCount;
|
||||
private LocalDateTime nextRetryAt;
|
||||
private LocalDateTime publishedAt;
|
||||
private LocalDateTime lockedAt;
|
||||
private String lockedBy;
|
||||
private String lastError;
|
||||
private LocalDateTime createdAt;
|
||||
}
|
||||
|
||||
+25
-5
@@ -1,20 +1,25 @@
|
||||
package com.baiyue.lastleg.inboundplan.controller;
|
||||
|
||||
import com.baiyue.common.core.api.ApiResponse;
|
||||
import com.baiyue.common.core.api.PageResult;
|
||||
import com.baiyue.common.security.AuthenticatedUser;
|
||||
import com.baiyue.lastleg.inboundplan.dto.ConvertInboundPlanRequest;
|
||||
import com.baiyue.lastleg.inboundplan.dto.BatchFbaAppointmentRequest;
|
||||
import com.baiyue.lastleg.inboundplan.dto.FbaAppointmentResponse;
|
||||
import com.baiyue.lastleg.inboundplan.dto.InboundPlanResponse;
|
||||
import com.baiyue.lastleg.inboundplan.dto.InboundPlanRequests;
|
||||
import com.baiyue.lastleg.inboundplan.dto.InboundPlanSummary;
|
||||
import com.baiyue.lastleg.inboundplan.dto.SupplementPalletRequest;
|
||||
import com.baiyue.lastleg.inboundplan.service.InboundFbaAppointmentService;
|
||||
import com.baiyue.lastleg.inboundplan.service.InboundPlanService;
|
||||
import com.baiyue.lastleg.security.OverseasPermissions;
|
||||
import io.swagger.v3.oas.annotations.Operation;
|
||||
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||
import jakarta.validation.Valid;
|
||||
import jakarta.annotation.Resource;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.security.core.annotation.AuthenticationPrincipal;
|
||||
import org.springframework.security.access.prepost.PreAuthorize;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
import org.springframework.web.server.ResponseStatusException;
|
||||
|
||||
@@ -25,15 +30,27 @@ public class InboundPlanController {
|
||||
@Resource private InboundPlanService service;
|
||||
@Resource private InboundFbaAppointmentService appointmentService;
|
||||
|
||||
@GetMapping
|
||||
@PreAuthorize("hasAuthority('" + OverseasPermissions.INBOUND_ORDER_LIST + "')")
|
||||
@Operation(summary = "Page inbound plans without PDA, internal-event or outbound workflow fields", description = "Required authority: " + OverseasPermissions.INBOUND_ORDER_LIST)
|
||||
public ApiResponse<PageResult<InboundPlanSummary>> page(
|
||||
@AuthenticationPrincipal AuthenticatedUser user,
|
||||
@RequestParam(required = false) String customerId,
|
||||
@Valid InboundPlanRequests.Query query) {
|
||||
return ApiResponse.success(service.page(scope(user, customerId), query));
|
||||
}
|
||||
|
||||
@PostMapping("/convert")
|
||||
@Operation(summary = "Convert one inbound order to one inbound plan")
|
||||
@PreAuthorize("hasAuthority('" + OverseasPermissions.INBOUND_ORDER_CREATE + "')")
|
||||
@Operation(summary = "Convert one inbound order to one inbound plan", description = "Required authority: " + OverseasPermissions.INBOUND_ORDER_CREATE)
|
||||
public ApiResponse<InboundPlanResponse> convert(@AuthenticationPrincipal AuthenticatedUser user,
|
||||
@Valid @RequestBody ConvertInboundPlanRequest request) {
|
||||
return ApiResponse.success(service.convert(scope(user, request.customerId()), request.orderId()));
|
||||
}
|
||||
|
||||
@GetMapping("/{planNo}")
|
||||
@Operation(summary = "Get an inbound plan with lines, pallets and allocations")
|
||||
@PreAuthorize("hasAuthority('" + OverseasPermissions.INBOUND_ORDER_LIST + "')")
|
||||
@Operation(summary = "Get an inbound plan with lines, pallets and allocations", description = "Required authority: " + OverseasPermissions.INBOUND_ORDER_LIST)
|
||||
public ApiResponse<InboundPlanResponse> detail(@AuthenticationPrincipal AuthenticatedUser user,
|
||||
@PathVariable String planNo,
|
||||
@RequestParam(required = false) String customerId) {
|
||||
@@ -41,7 +58,8 @@ public class InboundPlanController {
|
||||
}
|
||||
|
||||
@PostMapping("/{planNo}/pallets/supplement")
|
||||
@Operation(summary = "Add one printable supplemental pallet for a plan destination warehouse")
|
||||
@PreAuthorize("hasAuthority('" + OverseasPermissions.INBOUND_ORDER_EDIT + "')")
|
||||
@Operation(summary = "Add one printable supplemental pallet for a plan destination warehouse", description = "Required authority: " + OverseasPermissions.INBOUND_ORDER_EDIT)
|
||||
public ApiResponse<InboundPlanResponse.Pallet> supplementPallet(@AuthenticationPrincipal AuthenticatedUser user,
|
||||
@PathVariable String planNo,
|
||||
@Valid @RequestBody SupplementPalletRequest request) {
|
||||
@@ -49,7 +67,8 @@ public class InboundPlanController {
|
||||
}
|
||||
|
||||
@PostMapping("/fba-appointments/batch")
|
||||
@Operation(summary = "Create one FBA appointment for selected inbound lines")
|
||||
@PreAuthorize("hasAuthority('" + OverseasPermissions.INBOUND_ORDER_EDIT + "')")
|
||||
@Operation(summary = "Create one FBA appointment for selected inbound lines", description = "Required authority: " + OverseasPermissions.INBOUND_ORDER_EDIT)
|
||||
public ApiResponse<FbaAppointmentResponse> batchFbaAppointment(
|
||||
@AuthenticationPrincipal AuthenticatedUser user,
|
||||
@Valid @RequestBody BatchFbaAppointmentRequest request) {
|
||||
@@ -59,7 +78,8 @@ public class InboundPlanController {
|
||||
}
|
||||
|
||||
@GetMapping("/fba-appointments/{appointmentNo}")
|
||||
@Operation(summary = "Get an FBA appointment and its selected inbound lines")
|
||||
@PreAuthorize("hasAuthority('" + OverseasPermissions.INBOUND_ORDER_LIST + "')")
|
||||
@Operation(summary = "Get an FBA appointment and its selected inbound lines", description = "Required authority: " + OverseasPermissions.INBOUND_ORDER_LIST)
|
||||
public ApiResponse<FbaAppointmentResponse> fbaAppointmentDetail(
|
||||
@AuthenticationPrincipal AuthenticatedUser user,
|
||||
@PathVariable String appointmentNo,
|
||||
|
||||
+30
@@ -0,0 +1,30 @@
|
||||
package com.baiyue.lastleg.inboundplan.dto;
|
||||
|
||||
import jakarta.validation.constraints.Max;
|
||||
import jakarta.validation.constraints.Min;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
public final class InboundPlanRequests {
|
||||
private InboundPlanRequests() {
|
||||
}
|
||||
|
||||
public record Query(
|
||||
@Min(1) long page,
|
||||
@Min(1) @Max(200) long size,
|
||||
String planNo,
|
||||
String inboundCode,
|
||||
String status,
|
||||
String overseasWarehouseId,
|
||||
String destinationWarehouseCode,
|
||||
String fbaWarehouseCode,
|
||||
String appointmentStatus,
|
||||
LocalDateTime createdFrom,
|
||||
LocalDateTime createdTo
|
||||
) {
|
||||
public Query {
|
||||
if (page == 0) page = 1;
|
||||
if (size == 0) size = 20;
|
||||
}
|
||||
}
|
||||
}
|
||||
+32
@@ -0,0 +1,32 @@
|
||||
package com.baiyue.lastleg.inboundplan.dto;
|
||||
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
@Schema(description = "Inbound plan row for the platform browser API; outbound workflow fields are intentionally excluded")
|
||||
public record InboundPlanSummary(
|
||||
Long id,
|
||||
String planNo,
|
||||
Long sourceOrderId,
|
||||
String customerId,
|
||||
String customerName,
|
||||
String orderType,
|
||||
String inboundCode,
|
||||
String containerNo,
|
||||
String overseasWarehouseId,
|
||||
String overseasWarehouseName,
|
||||
String destinationWarehouseId,
|
||||
String destinationWarehouseCode,
|
||||
String destinationWarehouseName,
|
||||
String fbaWarehouseCode,
|
||||
Integer packageCount,
|
||||
BigDecimal totalVolumeCbm,
|
||||
Integer palletCount,
|
||||
String status,
|
||||
String appointmentStatus,
|
||||
LocalDateTime createdAt,
|
||||
LocalDateTime updatedAt
|
||||
) {
|
||||
}
|
||||
+37
@@ -1,13 +1,50 @@
|
||||
package com.baiyue.lastleg.inboundplan.mapper;
|
||||
|
||||
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
|
||||
import com.baiyue.lastleg.inboundplan.dto.InboundPlanRequests;
|
||||
import com.baiyue.lastleg.inboundplan.dto.InboundPlanSummary;
|
||||
import com.baiyue.lastleg.inboundplan.entity.InboundPlan;
|
||||
import org.apache.ibatis.annotations.Mapper;
|
||||
import org.apache.ibatis.annotations.Param;
|
||||
import org.apache.ibatis.annotations.Select;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
@Mapper
|
||||
public interface InboundPlanMapper extends BaseMapper<InboundPlan> {
|
||||
String PAGE_FROM = " FROM ll_inbound_plan p JOIN ll_order o ON o.id=p.source_order_id AND o.customer_id=p.customer_id " +
|
||||
"LEFT JOIN ll_inbound_plan_line l ON l.plan_id=p.id AND l.order_id=p.source_order_id " +
|
||||
"WHERE p.customer_id=#{customerId}" +
|
||||
"<if test='q.planNo != null and q.planNo != \"\"'> AND p.plan_no LIKE CONCAT('%',#{q.planNo},'%')</if>" +
|
||||
"<if test='q.inboundCode != null and q.inboundCode != \"\"'> AND l.inbound_code LIKE CONCAT('%',#{q.inboundCode},'%')</if>" +
|
||||
"<if test='q.status != null and q.status != \"\"'> AND p.status=#{q.status}</if>" +
|
||||
"<if test='q.overseasWarehouseId != null and q.overseasWarehouseId != \"\"'> AND o.overseas_warehouse_id=#{q.overseasWarehouseId}</if>" +
|
||||
"<if test='q.destinationWarehouseCode != null and q.destinationWarehouseCode != \"\"'> AND l.destination_warehouse_code=#{q.destinationWarehouseCode}</if>" +
|
||||
"<if test='q.fbaWarehouseCode != null and q.fbaWarehouseCode != \"\"'> AND l.fba_warehouse_code=#{q.fbaWarehouseCode}</if>" +
|
||||
"<if test='q.appointmentStatus != null and q.appointmentStatus != \"\" and q.appointmentStatus != \"NOT_APPOINTED\"'> " +
|
||||
"AND EXISTS (SELECT 1 FROM ll_inbound_fba_appointment_item ai JOIN ll_inbound_fba_appointment a ON a.id=ai.appointment_id " +
|
||||
"WHERE ai.plan_id=p.id AND a.customer_id=#{customerId} AND a.appointment_status=#{q.appointmentStatus})</if>" +
|
||||
"<if test='q.appointmentStatus == \"NOT_APPOINTED\"'> AND NOT EXISTS (SELECT 1 FROM ll_inbound_fba_appointment_item ai " +
|
||||
"WHERE ai.plan_id=p.id)</if>" +
|
||||
"<if test='q.createdFrom != null'> AND p.created_at >=#{q.createdFrom}</if>" +
|
||||
"<if test='q.createdTo != null'> AND p.created_at <=#{q.createdTo}</if>";
|
||||
|
||||
@Select("<script>SELECT p.id,p.plan_no,p.source_order_id,p.customer_id,o.customer_name,o.order_type," +
|
||||
"l.inbound_code,p.container_no,o.overseas_warehouse_id,o.overseas_warehouse_name," +
|
||||
"l.destination_warehouse_id,l.destination_warehouse_code,l.destination_warehouse_name,l.fba_warehouse_code," +
|
||||
"COALESCE(l.required_package_count,o.package_count,0) package_count,p.total_volume_cbm,p.pallet_count,p.status," +
|
||||
"COALESCE((SELECT a.appointment_status FROM ll_inbound_fba_appointment_item ai " +
|
||||
"JOIN ll_inbound_fba_appointment a ON a.id=ai.appointment_id WHERE ai.plan_id=p.id " +
|
||||
"ORDER BY a.created_at DESC,a.id DESC LIMIT 1),'NOT_APPOINTED') appointment_status,p.created_at,p.updated_at" +
|
||||
PAGE_FROM + " ORDER BY p.created_at DESC,p.id DESC LIMIT #{limit} OFFSET #{offset}</script>")
|
||||
List<InboundPlanSummary> pageRows(@Param("customerId") String customerId,
|
||||
@Param("q") InboundPlanRequests.Query query,
|
||||
@Param("offset") long offset,
|
||||
@Param("limit") long limit);
|
||||
|
||||
@Select("<script>SELECT COUNT(*)" + PAGE_FROM + "</script>")
|
||||
long countRows(@Param("customerId") String customerId, @Param("q") InboundPlanRequests.Query query);
|
||||
|
||||
@Select("SELECT * FROM ll_inbound_plan WHERE customer_id=#{customerId} AND source_order_id=#{sourceOrderId} LIMIT 1")
|
||||
InboundPlan findBySourceOrder(@Param("customerId") String customerId, @Param("sourceOrderId") Long sourceOrderId);
|
||||
}
|
||||
|
||||
+1
-1
@@ -17,7 +17,7 @@ import org.springframework.web.bind.annotation.RestController;
|
||||
import org.springframework.web.server.ResponseStatusException;
|
||||
|
||||
@RestController
|
||||
@RequestMapping("/api/platform/v1/last-leg/inbound-plans/{planNo}/pallets")
|
||||
@RequestMapping("/api/pda/v1/last-leg/inbound-plans/{planNo}/pallets")
|
||||
public class InboundPalletAssignmentController {
|
||||
@Resource private InboundPdaWorkflowService service;
|
||||
|
||||
|
||||
+1
-1
@@ -21,7 +21,7 @@ import org.springframework.web.server.ResponseStatusException;
|
||||
import java.util.List;
|
||||
|
||||
@RestController
|
||||
@RequestMapping("/api/platform/v1/last-leg/inbound-plans/{planNo}/pda")
|
||||
@RequestMapping("/api/pda/v1/last-leg/inbound-plans/{planNo}")
|
||||
public class InboundPdaController {
|
||||
public static final String OPERATE_PERMISSION = "lastleg:pda:operate";
|
||||
@Resource private InboundPdaWorkflowService service;
|
||||
|
||||
+3
@@ -20,6 +20,9 @@ public class InboundStockReceipt {
|
||||
private Long lineId;
|
||||
private Long orderId;
|
||||
private String inboundCode;
|
||||
private String skuCode;
|
||||
private String productNameZh;
|
||||
private String productNameEn;
|
||||
private Long warehousePalletId;
|
||||
private String palletNo;
|
||||
private Long warehouseLocationId;
|
||||
|
||||
+85
-5
@@ -2,6 +2,7 @@ package com.baiyue.lastleg.inboundplan.pda.service;
|
||||
|
||||
import com.baomidou.mybatisplus.core.toolkit.IdWorker;
|
||||
import com.baiyue.lastleg.entity.LastlegOrder;
|
||||
import com.baiyue.lastleg.entity.LastlegOrderDetail;
|
||||
import com.baiyue.lastleg.inboundplan.entity.InboundPlan;
|
||||
import com.baiyue.lastleg.inboundplan.entity.InboundPlanLine;
|
||||
import com.baiyue.lastleg.inboundplan.entity.InboundPlanPallet;
|
||||
@@ -13,6 +14,7 @@ import com.baiyue.lastleg.inventory.command.dto.InventoryCommandRequests;
|
||||
import com.baiyue.lastleg.inventory.command.dto.InventoryCommandResponse;
|
||||
import com.baiyue.lastleg.inventory.command.service.InventoryCommandService;
|
||||
import com.baiyue.lastleg.mapper.LastlegOrderMapper;
|
||||
import com.baiyue.lastleg.mapper.LastlegOrderDetailMapper;
|
||||
import com.baiyue.lastleg.mapper.LastlegOrderStatusHistoryMapper;
|
||||
import com.baiyue.lastleg.entity.LastlegOrderStatusHistory;
|
||||
import com.baiyue.lastleg.dropship.service.DropshipOrderService;
|
||||
@@ -34,6 +36,7 @@ import java.util.stream.Collectors;
|
||||
@Service
|
||||
public class InboundStockReceiptService {
|
||||
@Resource private LastlegOrderMapper orders;
|
||||
@Resource private LastlegOrderDetailMapper orderDetails;
|
||||
@Resource private InboundStockReceiptMapper receipts;
|
||||
@Resource private InventoryCommandService inventory;
|
||||
@Resource private DropshipOrderService dropshipOrders;
|
||||
@@ -79,9 +82,22 @@ public class InboundStockReceiptService {
|
||||
if (pickedPackages != value(line.getRequiredPackageCount())) {
|
||||
throw conflict("PDA picked packages must equal the inbound order package total");
|
||||
}
|
||||
List<SkuRemainder> skuRemainders = "ONE_CLICK_DROPSHIP".equals(order.getOrderType())
|
||||
? List.of() : skuRemainders(order.getId(), pickedPackages);
|
||||
for (InboundPlanPalletAllocation allocation : lineAllocations) {
|
||||
created.add(persist(customerId, plan, task, line, allocation,
|
||||
requirePallet(palletById, allocation), order, receivedAt));
|
||||
InboundPlanPallet pallet = requirePallet(palletById, allocation);
|
||||
if ("ONE_CLICK_DROPSHIP".equals(order.getOrderType())) {
|
||||
created.add(persist(customerId, plan, task, line, allocation, pallet, order,
|
||||
SkuSnapshot.unspecified(value(allocation.getPickedPackageCount())), receivedAt));
|
||||
} else {
|
||||
long allocationPackages = value(allocation.getPickedPackageCount());
|
||||
for (SkuSnapshot sku : consume(skuRemainders, allocationPackages)) {
|
||||
created.add(persist(customerId, plan, task, line, allocation, pallet, order, sku, receivedAt));
|
||||
}
|
||||
}
|
||||
}
|
||||
if (skuRemainders.stream().anyMatch(value -> value.remaining > 0)) {
|
||||
throw conflict("Inbound SKU package snapshots were not fully received");
|
||||
}
|
||||
}
|
||||
activateDropship(customerId, task.getId(), orderById, created);
|
||||
@@ -112,20 +128,21 @@ public class InboundStockReceiptService {
|
||||
|
||||
private InboundStockReceipt persist(String customerId, InboundPlan plan, InboundPdaTask task, InboundPlanLine line,
|
||||
InboundPlanPalletAllocation allocation, InboundPlanPallet pallet,
|
||||
LastlegOrder order, LocalDateTime receivedAt) {
|
||||
long packages = value(allocation.getPickedPackageCount());
|
||||
LastlegOrder order, SkuSnapshot sku, LocalDateTime receivedAt) {
|
||||
long packages = sku.packages();
|
||||
if (packages <= 0 || pallet.getWarehousePalletId() == null) {
|
||||
throw conflict("Stock-in requires picked packages and an assigned warehouse pallet");
|
||||
}
|
||||
String destinationCode = required(first(line.getDestinationWarehouseCode(), order.getFbaWarehouseCode()),
|
||||
"Destination warehouse code is required for stock-in");
|
||||
String operationKey = "PDA_STOCK_IN:" + task.getId() + ":" + allocation.getId();
|
||||
String operationKey = "PDA_STOCK_IN:" + task.getId() + ":" + allocation.getId() + ":" + sku.skuCode();
|
||||
String overseasWarehouseId = required(order.getOverseasWarehouseId(),
|
||||
"Actual overseas warehouse is required for stock-in");
|
||||
InventoryCommandResponse result = inventory.stockIn(customerId, task.getOperatorId(),
|
||||
new InventoryCommandRequests.StockIn(null, order.getCustomerName(), overseasWarehouseId,
|
||||
null, order.getOverseasWarehouseName(), line.getDestinationWarehouseId(), destinationCode,
|
||||
line.getDestinationWarehouseName(), order.getId(), plan.getPlanNo(), line.getInboundCode(),
|
||||
sku.skuCode(), sku.productNameZh(), sku.productNameEn(),
|
||||
pallet.getWarehousePalletId(), pallet.getPalletNo(), plan.getPlanNo(),
|
||||
pallet.getWarehouseLocationId(), null, packages, order.getOrderNo(),
|
||||
order.getSourceEventId(), operationKey,
|
||||
@@ -140,6 +157,9 @@ public class InboundStockReceiptService {
|
||||
receipt.setLineId(line.getId());
|
||||
receipt.setOrderId(order.getId());
|
||||
receipt.setInboundCode(line.getInboundCode());
|
||||
receipt.setSkuCode(sku.skuCode());
|
||||
receipt.setProductNameZh(sku.productNameZh());
|
||||
receipt.setProductNameEn(sku.productNameEn());
|
||||
receipt.setWarehousePalletId(pallet.getWarehousePalletId());
|
||||
receipt.setPalletNo(pallet.getPalletNo());
|
||||
receipt.setWarehouseLocationId(pallet.getWarehouseLocationId());
|
||||
@@ -154,6 +174,46 @@ public class InboundStockReceiptService {
|
||||
return receipt;
|
||||
}
|
||||
|
||||
private List<SkuRemainder> skuRemainders(Long orderId, long expectedPackages) {
|
||||
List<LastlegOrderDetail> details = orderDetails.selectList(
|
||||
new com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper<LastlegOrderDetail>()
|
||||
.eq(LastlegOrderDetail::getOrderId, orderId).orderByAsc(LastlegOrderDetail::getId));
|
||||
Map<String, SkuRemainder> grouped = new java.util.LinkedHashMap<>();
|
||||
for (LastlegOrderDetail detail : details) {
|
||||
String skuCode = trim(detail.getSkuCode());
|
||||
long packages = value(detail.getPackageCount());
|
||||
if (skuCode == null || packages <= 0) continue;
|
||||
SkuRemainder existing = grouped.get(skuCode);
|
||||
if (existing == null) {
|
||||
grouped.put(skuCode, new SkuRemainder(skuCode, trim(detail.getProductNameZh()),
|
||||
trim(detail.getProductNameEn()), packages));
|
||||
} else {
|
||||
existing.remaining += packages;
|
||||
}
|
||||
}
|
||||
long actualPackages = grouped.values().stream().mapToLong(value -> value.remaining).sum();
|
||||
if (grouped.isEmpty() || actualPackages != expectedPackages) {
|
||||
throw conflict("Inbound SKU package snapshots must exactly match the received package total");
|
||||
}
|
||||
return new java.util.ArrayList<>(grouped.values());
|
||||
}
|
||||
|
||||
private static List<SkuSnapshot> consume(List<SkuRemainder> remainders, long packages) {
|
||||
List<SkuSnapshot> result = new java.util.ArrayList<>();
|
||||
long pending = packages;
|
||||
for (SkuRemainder remainder : remainders) {
|
||||
if (pending == 0) break;
|
||||
if (remainder.remaining == 0) continue;
|
||||
long accepted = Math.min(pending, remainder.remaining);
|
||||
result.add(new SkuSnapshot(remainder.skuCode, remainder.productNameZh,
|
||||
remainder.productNameEn, accepted));
|
||||
remainder.remaining -= accepted;
|
||||
pending -= accepted;
|
||||
}
|
||||
if (pending != 0) throw conflict("PDA received packages exceed the frozen SKU package snapshots");
|
||||
return result;
|
||||
}
|
||||
|
||||
private void activateDropship(String customerId, Long taskId, Map<Long, LastlegOrder> orderById,
|
||||
List<InboundStockReceipt> stockReceipts) {
|
||||
Map<Long, List<InboundStockReceipt>> receiptsByOrder = stockReceipts.stream()
|
||||
@@ -190,4 +250,24 @@ public class InboundStockReceiptService {
|
||||
private static ResponseStatusException conflict(String message) {
|
||||
return new ResponseStatusException(HttpStatus.CONFLICT, message);
|
||||
}
|
||||
|
||||
private static final class SkuRemainder {
|
||||
private final String skuCode;
|
||||
private final String productNameZh;
|
||||
private final String productNameEn;
|
||||
private long remaining;
|
||||
|
||||
private SkuRemainder(String skuCode, String productNameZh, String productNameEn, long remaining) {
|
||||
this.skuCode = skuCode;
|
||||
this.productNameZh = productNameZh;
|
||||
this.productNameEn = productNameEn;
|
||||
this.remaining = remaining;
|
||||
}
|
||||
}
|
||||
|
||||
private record SkuSnapshot(String skuCode, String productNameZh, String productNameEn, long packages) {
|
||||
private static SkuSnapshot unspecified(long packages) {
|
||||
return new SkuSnapshot("UNSPECIFIED", null, null, packages);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+54
@@ -0,0 +1,54 @@
|
||||
package com.baiyue.lastleg.inboundplan.pda.service;
|
||||
|
||||
import com.baiyue.common.event.EventEnvelope;
|
||||
import com.baiyue.common.event.EventEnvelopeValidator;
|
||||
import com.baiyue.lastleg.client.EventBridgeClient;
|
||||
import com.baiyue.lastleg.entity.LastlegEventOutbox;
|
||||
import com.baiyue.lastleg.repository.UnloadCompletedOutboxRepository;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import jakarta.annotation.Resource;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.scheduling.annotation.Scheduled;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.UUID;
|
||||
|
||||
/** 至少一次投递海外卸柜完成事件;国内事件桥按 eventId 幂等接收。 */
|
||||
@Component
|
||||
@Slf4j
|
||||
public class InboundUnloadCompletedOutboxRelay {
|
||||
@Resource private UnloadCompletedOutboxRepository repository;
|
||||
@Resource private EventBridgeClient client;
|
||||
@Resource private ObjectMapper objectMapper;
|
||||
@Value("${baiyue.security.internal-service-token}") private String internalToken;
|
||||
@Value("${baiyue.firstleg.outbox-relay.lease-seconds:60}") private long leaseSeconds;
|
||||
@Value("${baiyue.firstleg.outbox-relay.retry-seconds:30}") private long retrySeconds;
|
||||
private final String workerId = UUID.randomUUID().toString();
|
||||
|
||||
@Scheduled(fixedDelayString = "${baiyue.firstleg.outbox-relay.fixed-delay-ms:5000}")
|
||||
public void relay() {
|
||||
LocalDateTime now = LocalDateTime.now();
|
||||
LocalDateTime expiredLeaseBefore = now.minusSeconds(Math.max(leaseSeconds, 10));
|
||||
for (LastlegEventOutbox candidate : repository.pending(50, now, expiredLeaseBefore)) {
|
||||
if (!repository.claim(candidate.getId(), workerId, now, expiredLeaseBefore)) continue;
|
||||
LastlegEventOutbox row = repository.claimed(candidate.getId(), workerId);
|
||||
if (row == null) continue;
|
||||
try {
|
||||
EventEnvelope event = objectMapper.readValue(row.getPayloadJson(), EventEnvelope.class);
|
||||
EventEnvelopeValidator.requireValid(event);
|
||||
var response = client.receive(internalToken, event);
|
||||
if (response == null || !response.successful() || response.data() == null
|
||||
|| !event.eventId().equals(response.data().eventId())) {
|
||||
throw new IllegalStateException("国内事件桥未确认海外卸柜完成事件");
|
||||
}
|
||||
repository.published(row.getId(), workerId, LocalDateTime.now());
|
||||
} catch (Exception exception) {
|
||||
repository.retry(row.getId(), workerId, LocalDateTime.now().plusSeconds(Math.max(retrySeconds, 1)),
|
||||
exception.getMessage());
|
||||
log.warn("海外卸柜完成事件投递失败: eventKey={}, reason={}", row.getEventKey(), exception.getMessage());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+16
@@ -2,7 +2,10 @@ package com.baiyue.lastleg.inboundplan.service;
|
||||
|
||||
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
|
||||
import com.baomidou.mybatisplus.core.toolkit.IdWorker;
|
||||
import com.baiyue.common.core.api.PageResult;
|
||||
import com.baiyue.lastleg.inboundplan.dto.InboundPlanRequests;
|
||||
import com.baiyue.lastleg.inboundplan.dto.InboundPlanResponse;
|
||||
import com.baiyue.lastleg.inboundplan.dto.InboundPlanSummary;
|
||||
import com.baiyue.lastleg.inboundplan.dto.SupplementPalletRequest;
|
||||
import com.baiyue.lastleg.inboundplan.entity.InboundPlan;
|
||||
import com.baiyue.lastleg.inboundplan.entity.InboundPlanLine;
|
||||
@@ -36,6 +39,19 @@ public class InboundPlanService {
|
||||
@Resource private InboundPlanPalletAllocationMapper allocationMapper;
|
||||
@Resource private InboundPlanOrderMapper orderMapper;
|
||||
|
||||
@Transactional(readOnly = true)
|
||||
public PageResult<InboundPlanSummary> page(String customerId, InboundPlanRequests.Query query) {
|
||||
if (query.createdFrom() != null && query.createdTo() != null
|
||||
&& query.createdFrom().isAfter(query.createdTo())) {
|
||||
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "createdFrom must not be after createdTo");
|
||||
}
|
||||
long total = planMapper.countRows(customerId, query);
|
||||
List<InboundPlanSummary> records = total == 0 ? List.of()
|
||||
: planMapper.pageRows(customerId, query, Math.multiplyExact(query.page() - 1, query.size()), query.size());
|
||||
long pages = total == 0 ? 0 : (total + query.size() - 1) / query.size();
|
||||
return new PageResult<>(records, query.page(), query.size(), total, pages);
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public InboundPlanResponse convert(String customerId, Long selectedOrderId) {
|
||||
List<SourceOrderSnapshot> orders = orderMapper.lockOrder(customerId, selectedOrderId);
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user