暂存提交
This commit is contained in:
@@ -0,0 +1,5 @@
|
|||||||
|
# 海外 Nacos 配置
|
||||||
|
|
||||||
|
将本目录文件按原文件名作为 Data ID 发布到 `prod-overseas` 命名空间和 `BAIYUE` 分组。配置只引用环境变量,不保存密码或访问密钥。Gateway 必须同时启用 `overseas` Profile。
|
||||||
|
|
||||||
|
海外入口与仓储 Data ID 分别为 `gateway-overseas.yml`、`warehouse-overseas-service.yml`。`print-service.yml` 对应头程仓库保留的共用打印服务,不包含尾程旧 Print 模块。
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
spring:
|
||||||
|
datasource:
|
||||||
|
url: ${AUDIT_DB_URL}
|
||||||
|
username: ${AUDIT_DB_USERNAME}
|
||||||
|
password: ${AUDIT_DB_PASSWORD}
|
||||||
|
driver-class-name: com.mysql.cj.jdbc.Driver
|
||||||
@@ -0,0 +1,62 @@
|
|||||||
|
baiyue:
|
||||||
|
deployment:
|
||||||
|
region-code: ${REGION_CODE:OVERSEAS}
|
||||||
|
cell: ${DEPLOYMENT_CELL:prod-overseas}
|
||||||
|
data-residency: ${DATA_RESIDENCY:OVERSEAS}
|
||||||
|
security:
|
||||||
|
issuer: ${JWT_ISSUER:https://auth.example.com}
|
||||||
|
audience: ${JWT_AUDIENCE:lastleg-api}
|
||||||
|
jwk-set-uri: ${JWT_JWK_SET_URI:https://auth.example.com/.well-known/jwks.json}
|
||||||
|
internal-service-token: ${INTERNAL_SERVICE_TOKEN}
|
||||||
|
permission-snapshot-key-prefix: ${PERMISSION_SNAPSHOT_KEY_PREFIX:baiyue:permission-snapshot}
|
||||||
|
openapi.enabled: ${SWAGGER_ENABLED:false}
|
||||||
|
audit.enabled: true
|
||||||
|
spring:
|
||||||
|
jackson:
|
||||||
|
default-property-inclusion: non_null
|
||||||
|
deserialization:
|
||||||
|
fail-on-unknown-properties: false
|
||||||
|
serialization:
|
||||||
|
write-dates-as-timestamps: false
|
||||||
|
data:
|
||||||
|
redis:
|
||||||
|
host: ${REDIS_HOST:redis-overseas.internal}
|
||||||
|
port: ${REDIS_PORT:6379}
|
||||||
|
password: ${REDIS_PASSWORD:}
|
||||||
|
database: ${REDIS_DATABASE:0}
|
||||||
|
timeout: ${REDIS_TIMEOUT:3s}
|
||||||
|
cloud:
|
||||||
|
nacos:
|
||||||
|
discovery:
|
||||||
|
group: ${NACOS_GROUP:BAIYUE}
|
||||||
|
namespace: ${NACOS_NAMESPACE:prod-overseas}
|
||||||
|
openfeign:
|
||||||
|
circuitbreaker.enabled: true
|
||||||
|
client.config.default:
|
||||||
|
connect-timeout: 3000
|
||||||
|
read-timeout: 5000
|
||||||
|
server:
|
||||||
|
shutdown: graceful
|
||||||
|
management:
|
||||||
|
endpoints:
|
||||||
|
web:
|
||||||
|
exposure:
|
||||||
|
include: health,info,prometheus
|
||||||
|
endpoint:
|
||||||
|
health:
|
||||||
|
probes.enabled: true
|
||||||
|
show-details: never
|
||||||
|
mybatis-plus:
|
||||||
|
configuration:
|
||||||
|
map-underscore-to-camel-case: true
|
||||||
|
call-setters-on-nulls: true
|
||||||
|
global-config:
|
||||||
|
banner: false
|
||||||
|
db-config:
|
||||||
|
id-type: assign_id
|
||||||
|
logic-delete-field: deleted
|
||||||
|
logic-delete-value: 1
|
||||||
|
logic-not-delete-value: 0
|
||||||
|
springdoc:
|
||||||
|
api-docs.enabled: ${SWAGGER_ENABLED:false}
|
||||||
|
swagger-ui.enabled: ${SWAGGER_ENABLED:false}
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
spring:
|
||||||
|
datasource:
|
||||||
|
url: ${EVENT_BRIDGE_DB_URL}
|
||||||
|
username: ${EVENT_BRIDGE_DB_USERNAME}
|
||||||
|
password: ${EVENT_BRIDGE_DB_PASSWORD}
|
||||||
|
driver-class-name: com.mysql.cj.jdbc.Driver
|
||||||
|
baiyue:
|
||||||
|
region: ${REGION_CODE:OVERSEAS}
|
||||||
|
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}
|
||||||
|
fixed-delay: ${EVENT_DISPATCH_FIXED_DELAY:3s}
|
||||||
|
batch-size: ${EVENT_DISPATCH_BATCH_SIZE:50}
|
||||||
|
max-attempts: ${EVENT_DISPATCH_MAX_ATTEMPTS:10}
|
||||||
|
lease-duration: ${EVENT_DISPATCH_LEASE_DURATION:30s}
|
||||||
|
initial-backoff: ${EVENT_DISPATCH_INITIAL_BACKOFF:5s}
|
||||||
|
max-backoff: ${EVENT_DISPATCH_MAX_BACKOFF:15m}
|
||||||
|
connect-timeout: ${EVENT_DISPATCH_CONNECT_TIMEOUT:3s}
|
||||||
|
request-timeout: ${EVENT_DISPATCH_REQUEST_TIMEOUT:10s}
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
spring:
|
||||||
|
datasource:
|
||||||
|
url: ${FILE_DB_URL}
|
||||||
|
username: ${FILE_DB_USERNAME}
|
||||||
|
password: ${FILE_DB_PASSWORD}
|
||||||
|
driver-class-name: com.mysql.cj.jdbc.Driver
|
||||||
|
file:
|
||||||
|
storage:
|
||||||
|
provider: ${FILE_STORAGE_PROVIDER:s3}
|
||||||
|
endpoint: ${FILE_STORAGE_ENDPOINT:}
|
||||||
|
bucket: ${FILE_STORAGE_BUCKET:}
|
||||||
|
access-key: ${FILE_STORAGE_ACCESS_KEY:}
|
||||||
|
secret-key: ${FILE_STORAGE_SECRET_KEY:}
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
springdoc:
|
||||||
|
api-docs.enabled: ${SWAGGER_ENABLED:false}
|
||||||
|
swagger-ui.enabled: ${SWAGGER_ENABLED:false}
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
baiyue:
|
||||||
|
integration:
|
||||||
|
baidu-ocr:
|
||||||
|
enabled: ${BAIDU_OCR_ENABLED:false}
|
||||||
|
amazon-sp-api:
|
||||||
|
enabled: ${AMAZON_SP_API_ENABLED:true}
|
||||||
|
client-id: ${AMAZON_SP_API_CLIENT_ID:}
|
||||||
|
client-secret: ${AMAZON_SP_API_CLIENT_SECRET:}
|
||||||
|
refresh-token: ${AMAZON_SP_API_REFRESH_TOKEN:}
|
||||||
|
aws-access-key: ${AMAZON_SP_API_AWS_ACCESS_KEY:}
|
||||||
|
aws-secret-key: ${AMAZON_SP_API_AWS_SECRET_KEY:}
|
||||||
|
region: ${AMAZON_SP_API_REGION:}
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
spring:
|
||||||
|
datasource:
|
||||||
|
url: ${LASTLEG_DB_URL}
|
||||||
|
username: ${LASTLEG_DB_USERNAME}
|
||||||
|
password: ${LASTLEG_DB_PASSWORD}
|
||||||
|
driver-class-name: com.mysql.cj.jdbc.Driver
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
spring:
|
||||||
|
datasource:
|
||||||
|
url: ${PRINT_DB_URL}
|
||||||
|
username: ${PRINT_DB_USERNAME}
|
||||||
|
password: ${PRINT_DB_PASSWORD}
|
||||||
|
driver-class-name: com.mysql.cj.jdbc.Driver
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
spring:
|
||||||
|
datasource:
|
||||||
|
url: ${WAREHOUSE_DB_URL}
|
||||||
|
username: ${WAREHOUSE_DB_USERNAME}
|
||||||
|
password: ${WAREHOUSE_DB_PASSWORD}
|
||||||
|
driver-class-name: com.mysql.cj.jdbc.Driver
|
||||||
@@ -2,33 +2,32 @@
|
|||||||
|
|
||||||
## 职责边界
|
## 职责边界
|
||||||
|
|
||||||
- 负责跨区域事件接收、内部令牌保护、契约校验和 Inbox 幂等持久化。
|
- 负责跨区事件接收、Inbox 持久化、向海外尾程投递、失败重试、投递记录和人工补投。
|
||||||
- 事件模型来自 `common-event-contract/`;本服务不共享国内/国外业务数据库,也不承载头程或尾程领域逻辑。
|
- 只承载可靠传输,不执行尾程建单逻辑,不查询头程或尾程业务表。
|
||||||
- 真实跨区发送、重试调度和死信补投仍需按需求继续完善,不把当前接收骨架描述为完整消息平台。
|
- 事件结构和 Payload 哈希以 `common-event-contract` 为准。
|
||||||
|
|
||||||
## 快速索引
|
## 快速索引
|
||||||
|
|
||||||
| 功能 | 入口 |
|
| 功能 | 入口 |
|
||||||
|---|---|
|
|---|---|
|
||||||
| 应用入口 | `src/main/java/com/baiyue/eventbridge/EventBridgeApplication.java` |
|
| 接收、状态、人工补投 API | `src/main/java/com/baiyue/eventbridge/controller/EventBridgeController.java` |
|
||||||
| 事件接收与状态 API | `controller/EventBridgeController.java`(`/internal/event-bridge`) |
|
| Inbox 接收与幂等 | `service/EventInboxService.java` |
|
||||||
| 接收结果与状态 DTO | `dto/EventReceiveResult.java`、`BridgeStatus.java` |
|
| 海外调度与重试 | `service/OverseasEventDispatcher.java` |
|
||||||
| 校验与 Inbox 编排 | `service/EventInboxService.java` |
|
| 尾程 HTTP 客户端 | `service/LastlegEventClient.java`、`HttpLastlegEventClient.java` |
|
||||||
| Inbox 抽象与 MyBatis 实现 | `service/InboxStore.java`、`MybatisInboxStore.java` |
|
| 人工补投 | `service/EventReplayService.java` |
|
||||||
| Inbox 数据模型 | `domain/BridgeInbox.java`、`mapper/BridgeInboxMapper.java` |
|
| Inbox 与投递记录 | `domain/BridgeInbox.java`、`BridgeDeliveryAttempt.java` 及对应 Mapper |
|
||||||
| 内部服务令牌 | `security/InternalServiceTokenGuard.java` |
|
| 内部服务令牌 | `security/InternalServiceTokenGuard.java` |
|
||||||
| 国内装柜完成生产链 | `../firstleg/service/LoadingCompletedOutboxService.java`、`LoadingCompletedOutboxRelay.java`、`client/EventBridgeClient.java`;Payload 为 `../common-event-contract/src/main/java/com/baiyue/common/event/FirstLegLoadingPlanCompletedV1.java` |
|
| 数据库 | `src/main/resources/db/event-bridge.sql` |
|
||||||
| 表结构 | `src/main/resources/db/event-bridge.sql` |
|
| 测试 | `src/test/java/com/baiyue/eventbridge/` |
|
||||||
| 测试 | `src/test/java/com/baiyue/eventbridge/security/`、`service/` |
|
|
||||||
| 架构边界 | `../AGENTS.md` 的仓库边界、`../docs/头程项目目录结构与服务说明.md` 的主要调用关系与开发拆分约束 |
|
## 可靠投递规则
|
||||||
|
|
||||||
|
- `RECEIVED` 和到期的 `RETRY` 都必须可被调度;只有尾程确认成功后才能标记 `PROCESSED`。
|
||||||
|
- 每次投递保存 attempt、错误和时间;达到上限进入 `DEAD`,经授权人工补投后重新进入重试队列。
|
||||||
|
- 领取任务必须有数据库租约/并发保护,避免多实例重复发送;尾程幂等仍是最终保障。
|
||||||
|
- 内部接口使用 `X-Internal-Service-Token`,生产还应置于受信网络或 mTLS 后。
|
||||||
|
|
||||||
## 修改与验证
|
## 修改与验证
|
||||||
|
|
||||||
- 先使用 `common-event-contract` 校验事件 ID、区域、版本、业务键、时间与 Payload 哈希,再执行幂等入库。
|
- 定向验证:`mvn -pl event-bridge-service -am test`。
|
||||||
- 重复事件应返回稳定结果且不重复产生副作用;数据库唯一约束与应用幂等逻辑要同步。
|
- 重试相关修改至少覆盖 `RECEIVED`、`RETRY`、成功、失败、租约、最大次数和人工补投。
|
||||||
- 跨区域接口必须鉴权、限流并最小化载荷,禁止携带密钥或非必要客户敏感数据。
|
|
||||||
- Event Bridge 内部令牌失败和接收基础设施不可用属于统一 HTTP 200 业务契约的明确例外,保留真实 401/503;已经通过 Controller 进入公共异常处理的普通业务失败才使用 HTTP 200 + body code。
|
|
||||||
- 本服务只扫描 `com.baiyue.eventbridge`,统一异常和 Correlation 能力依赖 `common-web/CommonWebAutoConfiguration` 自动装配;调整公共 Web 配置后追加本模块上下文测试。
|
|
||||||
- 契约变化按 `../common-event-contract/AGENTS.md` 直接同步当前模型、国内生产者和国外消费者,不保留旧事件兼容分支。
|
|
||||||
- Event Bridge 只负责事件接收、校验和 Inbox 幂等,不负责用户登录或 JWT 转发;MQ 投递链路的服务身份与用户 Token 分离。
|
|
||||||
- 从父目录运行 `mvn -pl event-bridge-service -am test`。
|
|
||||||
|
|||||||
@@ -1,14 +1,19 @@
|
|||||||
package com.baiyue.eventbridge;
|
package com.baiyue.eventbridge;
|
||||||
|
|
||||||
|
import com.baiyue.eventbridge.config.EventDispatcherProperties;
|
||||||
import org.mybatis.spring.annotation.MapperScan;
|
import org.mybatis.spring.annotation.MapperScan;
|
||||||
import org.springframework.boot.SpringApplication;
|
import org.springframework.boot.SpringApplication;
|
||||||
import org.springframework.boot.autoconfigure.SpringBootApplication;
|
import org.springframework.boot.autoconfigure.SpringBootApplication;
|
||||||
|
import org.springframework.boot.context.properties.EnableConfigurationProperties;
|
||||||
|
import org.springframework.scheduling.annotation.EnableScheduling;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 跨区域事件桥启动入口。
|
* 跨区域事件桥启动入口。
|
||||||
*/
|
*/
|
||||||
@MapperScan("com.baiyue.eventbridge.mapper")
|
@MapperScan("com.baiyue.eventbridge.mapper")
|
||||||
@SpringBootApplication
|
@SpringBootApplication
|
||||||
|
@EnableScheduling
|
||||||
|
@EnableConfigurationProperties(EventDispatcherProperties.class)
|
||||||
public class EventBridgeApplication {
|
public class EventBridgeApplication {
|
||||||
public static void main(String[] args) {
|
public static void main(String[] args) {
|
||||||
SpringApplication.run(EventBridgeApplication.class, args);
|
SpringApplication.run(EventBridgeApplication.class, args);
|
||||||
|
|||||||
+34
@@ -0,0 +1,34 @@
|
|||||||
|
package com.baiyue.eventbridge.config;
|
||||||
|
|
||||||
|
import org.springframework.boot.context.properties.ConfigurationProperties;
|
||||||
|
|
||||||
|
import java.net.URI;
|
||||||
|
import java.time.Duration;
|
||||||
|
|
||||||
|
@ConfigurationProperties(prefix = "baiyue.event-dispatcher")
|
||||||
|
public record EventDispatcherProperties(
|
||||||
|
URI consumerUrl,
|
||||||
|
int batchSize,
|
||||||
|
int maxAttempts,
|
||||||
|
Duration leaseDuration,
|
||||||
|
Duration initialBackoff,
|
||||||
|
Duration maxBackoff,
|
||||||
|
Duration connectTimeout,
|
||||||
|
Duration requestTimeout) {
|
||||||
|
|
||||||
|
public EventDispatcherProperties {
|
||||||
|
consumerUrl = consumerUrl == null
|
||||||
|
? URI.create("http://127.0.0.1:9303/internal/lastleg/events") : consumerUrl;
|
||||||
|
batchSize = batchSize <= 0 ? 50 : batchSize;
|
||||||
|
maxAttempts = maxAttempts <= 0 ? 10 : maxAttempts;
|
||||||
|
leaseDuration = positiveOrDefault(leaseDuration, Duration.ofSeconds(30));
|
||||||
|
initialBackoff = positiveOrDefault(initialBackoff, Duration.ofSeconds(5));
|
||||||
|
maxBackoff = positiveOrDefault(maxBackoff, Duration.ofMinutes(15));
|
||||||
|
connectTimeout = positiveOrDefault(connectTimeout, Duration.ofSeconds(3));
|
||||||
|
requestTimeout = positiveOrDefault(requestTimeout, Duration.ofSeconds(10));
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Duration positiveOrDefault(Duration value, Duration fallback) {
|
||||||
|
return value == null || value.isNegative() || value.isZero() ? fallback : value;
|
||||||
|
}
|
||||||
|
}
|
||||||
+19
-6
@@ -4,7 +4,9 @@ import com.baiyue.common.core.api.ApiResponse;
|
|||||||
import com.baiyue.common.event.EventEnvelope;
|
import com.baiyue.common.event.EventEnvelope;
|
||||||
import com.baiyue.eventbridge.dto.BridgeStatus;
|
import com.baiyue.eventbridge.dto.BridgeStatus;
|
||||||
import com.baiyue.eventbridge.dto.EventReceiveResult;
|
import com.baiyue.eventbridge.dto.EventReceiveResult;
|
||||||
|
import com.baiyue.eventbridge.dto.EventReplayRequest;
|
||||||
import com.baiyue.eventbridge.service.EventInboxService;
|
import com.baiyue.eventbridge.service.EventInboxService;
|
||||||
|
import com.baiyue.eventbridge.service.EventReplayService;
|
||||||
import com.baiyue.eventbridge.security.InternalServiceTokenGuard;
|
import com.baiyue.eventbridge.security.InternalServiceTokenGuard;
|
||||||
import io.swagger.v3.oas.annotations.Operation;
|
import io.swagger.v3.oas.annotations.Operation;
|
||||||
import io.swagger.v3.oas.annotations.Parameter;
|
import io.swagger.v3.oas.annotations.Parameter;
|
||||||
@@ -18,6 +20,7 @@ import org.springframework.web.bind.annotation.RequestBody;
|
|||||||
import org.springframework.web.bind.annotation.RequestHeader;
|
import org.springframework.web.bind.annotation.RequestHeader;
|
||||||
import org.springframework.web.bind.annotation.RequestMapping;
|
import org.springframework.web.bind.annotation.RequestMapping;
|
||||||
import org.springframework.web.bind.annotation.RestController;
|
import org.springframework.web.bind.annotation.RestController;
|
||||||
|
import org.springframework.web.bind.annotation.PathVariable;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 事件桥内部接口。生产环境必须在网关或基础设施层增加 mTLS 与来源白名单。
|
* 事件桥内部接口。生产环境必须在网关或基础设施层增加 mTLS 与来源白名单。
|
||||||
@@ -26,13 +29,11 @@ import org.springframework.web.bind.annotation.RestController;
|
|||||||
@RestController
|
@RestController
|
||||||
@RequestMapping("/internal/event-bridge")
|
@RequestMapping("/internal/event-bridge")
|
||||||
public class EventBridgeController {
|
public class EventBridgeController {
|
||||||
@Resource
|
@Resource private EventInboxService eventInboxService;
|
||||||
private EventInboxService eventInboxService;
|
@Resource private EventReplayService eventReplayService;
|
||||||
|
@Resource private InternalServiceTokenGuard internalServiceTokenGuard;
|
||||||
|
|
||||||
@Resource
|
@Value("${baiyue.region:OVERSEAS}")
|
||||||
private InternalServiceTokenGuard internalServiceTokenGuard;
|
|
||||||
|
|
||||||
@Value("${baiyue.region:CN}")
|
|
||||||
private String region;
|
private String region;
|
||||||
|
|
||||||
@Operation(summary = "幂等接收事件", description = "当前仅完成数据库接收骨架,不代表生产跨区传输和 mTLS 已开通")
|
@Operation(summary = "幂等接收事件", description = "当前仅完成数据库接收骨架,不代表生产跨区传输和 mTLS 已开通")
|
||||||
@@ -50,6 +51,18 @@ public class EventBridgeController {
|
|||||||
return ApiResponse.success(eventInboxService.receive(envelope));
|
return ApiResponse.success(eventInboxService.receive(envelope));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Operation(summary = "人工补投死信事件")
|
||||||
|
@PostMapping("/events/{eventId}/replay")
|
||||||
|
public ApiResponse<Void> replay(
|
||||||
|
@RequestHeader(value = "X-Internal-Service-Token", required = false) String token,
|
||||||
|
@RequestHeader("X-Operator-Id") String operatorId,
|
||||||
|
@PathVariable String eventId,
|
||||||
|
@RequestBody EventReplayRequest request) {
|
||||||
|
internalServiceTokenGuard.verify(token);
|
||||||
|
eventReplayService.replayDead(eventId, operatorId, request.reason());
|
||||||
|
return ApiResponse.success(null);
|
||||||
|
}
|
||||||
|
|
||||||
@Operation(summary = "查询事件桥能力状态")
|
@Operation(summary = "查询事件桥能力状态")
|
||||||
@ApiResponses(@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "200", description = "查询成功"))
|
@ApiResponses(@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "200", description = "查询成功"))
|
||||||
@GetMapping("/status")
|
@GetMapping("/status")
|
||||||
|
|||||||
+24
@@ -0,0 +1,24 @@
|
|||||||
|
package com.baiyue.eventbridge.domain;
|
||||||
|
|
||||||
|
import com.baomidou.mybatisplus.annotation.IdType;
|
||||||
|
import com.baomidou.mybatisplus.annotation.TableId;
|
||||||
|
import com.baomidou.mybatisplus.annotation.TableName;
|
||||||
|
import lombok.Data;
|
||||||
|
|
||||||
|
import java.time.LocalDateTime;
|
||||||
|
|
||||||
|
@Data
|
||||||
|
@TableName("bridge_delivery_attempt")
|
||||||
|
public class BridgeDeliveryAttempt {
|
||||||
|
@TableId(type = IdType.AUTO)
|
||||||
|
private Long id;
|
||||||
|
private String eventId;
|
||||||
|
private String sourceRegion;
|
||||||
|
private String targetRegion;
|
||||||
|
private Integer attemptNo;
|
||||||
|
private String status;
|
||||||
|
private String responseCode;
|
||||||
|
private String errorMessage;
|
||||||
|
private LocalDateTime startedAt;
|
||||||
|
private LocalDateTime finishedAt;
|
||||||
|
}
|
||||||
@@ -20,7 +20,7 @@ public class BridgeInbox {
|
|||||||
private Integer eventVersion;
|
private Integer eventVersion;
|
||||||
private String sourceRegion;
|
private String sourceRegion;
|
||||||
private String targetRegion;
|
private String targetRegion;
|
||||||
private String tenantId;
|
private String customerId;
|
||||||
private String aggregateType;
|
private String aggregateType;
|
||||||
private String aggregateId;
|
private String aggregateId;
|
||||||
private Long aggregateVersion;
|
private Long aggregateVersion;
|
||||||
@@ -32,5 +32,11 @@ public class BridgeInbox {
|
|||||||
private String payloadHash;
|
private String payloadHash;
|
||||||
private String payloadJson;
|
private String payloadJson;
|
||||||
private String status;
|
private String status;
|
||||||
|
private String leaseOwner;
|
||||||
|
private LocalDateTime leaseUntil;
|
||||||
|
private Integer retryCount;
|
||||||
|
private LocalDateTime nextRetryAt;
|
||||||
|
private String lastError;
|
||||||
private LocalDateTime receivedAt;
|
private LocalDateTime receivedAt;
|
||||||
|
private LocalDateTime processedAt;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,4 @@
|
|||||||
|
package com.baiyue.eventbridge.dto;
|
||||||
|
|
||||||
|
public record EventReplayRequest(String reason) {
|
||||||
|
}
|
||||||
+9
@@ -0,0 +1,9 @@
|
|||||||
|
package com.baiyue.eventbridge.mapper;
|
||||||
|
|
||||||
|
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
|
||||||
|
import com.baiyue.eventbridge.domain.BridgeDeliveryAttempt;
|
||||||
|
import org.apache.ibatis.annotations.Mapper;
|
||||||
|
|
||||||
|
@Mapper
|
||||||
|
public interface BridgeDeliveryAttemptMapper extends BaseMapper<BridgeDeliveryAttempt> {
|
||||||
|
}
|
||||||
+52
@@ -3,10 +3,62 @@ package com.baiyue.eventbridge.mapper;
|
|||||||
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
|
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
|
||||||
import com.baiyue.eventbridge.domain.BridgeInbox;
|
import com.baiyue.eventbridge.domain.BridgeInbox;
|
||||||
import org.apache.ibatis.annotations.Mapper;
|
import org.apache.ibatis.annotations.Mapper;
|
||||||
|
import org.apache.ibatis.annotations.Param;
|
||||||
|
import org.apache.ibatis.annotations.Select;
|
||||||
|
import org.apache.ibatis.annotations.Update;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 入站事件持久化映射。
|
* 入站事件持久化映射。
|
||||||
*/
|
*/
|
||||||
@Mapper
|
@Mapper
|
||||||
public interface BridgeInboxMapper extends BaseMapper<BridgeInbox> {
|
public interface BridgeInboxMapper extends BaseMapper<BridgeInbox> {
|
||||||
|
@Select("""
|
||||||
|
SELECT event_id FROM bridge_inbox
|
||||||
|
WHERE target_region = 'OVERSEAS'
|
||||||
|
AND ((status IN ('RECEIVED','RETRY') AND (next_retry_at IS NULL OR next_retry_at <= CURRENT_TIMESTAMP(3)))
|
||||||
|
OR (status = 'PROCESSING' AND lease_until < CURRENT_TIMESTAMP(3)))
|
||||||
|
ORDER BY received_at LIMIT #{limit}
|
||||||
|
""")
|
||||||
|
List<String> dispatchCandidates(@Param("limit") int limit);
|
||||||
|
|
||||||
|
@Update("""
|
||||||
|
UPDATE bridge_inbox SET status='PROCESSING', lease_owner=#{owner},
|
||||||
|
lease_until=DATE_ADD(CURRENT_TIMESTAMP(3), INTERVAL #{leaseSeconds} SECOND)
|
||||||
|
WHERE event_id=#{eventId} AND target_region='OVERSEAS'
|
||||||
|
AND ((status IN ('RECEIVED','RETRY') AND (next_retry_at IS NULL OR next_retry_at <= CURRENT_TIMESTAMP(3)))
|
||||||
|
OR (status='PROCESSING' AND lease_until < CURRENT_TIMESTAMP(3)))
|
||||||
|
""")
|
||||||
|
int claim(@Param("eventId") String eventId, @Param("owner") String owner,
|
||||||
|
@Param("leaseSeconds") long leaseSeconds);
|
||||||
|
|
||||||
|
@Update("""
|
||||||
|
UPDATE bridge_inbox SET status='PROCESSED', processed_at=CURRENT_TIMESTAMP(3),
|
||||||
|
lease_owner=NULL, lease_until=NULL, next_retry_at=NULL, last_error=NULL
|
||||||
|
WHERE event_id=#{eventId} AND status='PROCESSING' AND lease_owner=#{owner}
|
||||||
|
""")
|
||||||
|
int markProcessed(@Param("eventId") String eventId, @Param("owner") String owner);
|
||||||
|
|
||||||
|
@Update("""
|
||||||
|
UPDATE bridge_inbox SET retry_count=retry_count+1,
|
||||||
|
status=CASE WHEN retry_count+1 >= #{maxAttempts} THEN 'DEAD' ELSE 'RETRY' END,
|
||||||
|
next_retry_at=CASE WHEN retry_count+1 >= #{maxAttempts} THEN NULL
|
||||||
|
ELSE DATE_ADD(CURRENT_TIMESTAMP(3), INTERVAL #{backoffSeconds} SECOND) END,
|
||||||
|
lease_owner=NULL, lease_until=NULL, last_error=#{error}
|
||||||
|
WHERE event_id=#{eventId} AND status='PROCESSING' AND lease_owner=#{owner}
|
||||||
|
""")
|
||||||
|
int markFailed(@Param("eventId") String eventId, @Param("owner") String owner,
|
||||||
|
@Param("maxAttempts") int maxAttempts,
|
||||||
|
@Param("backoffSeconds") long backoffSeconds, @Param("error") String error);
|
||||||
|
|
||||||
|
@Select("SELECT COALESCE(MAX(attempt_no), 0) + 1 FROM bridge_delivery_attempt WHERE event_id=#{eventId}")
|
||||||
|
int nextAttemptNo(@Param("eventId") String eventId);
|
||||||
|
|
||||||
|
@Update("""
|
||||||
|
UPDATE bridge_inbox SET status='RETRY', retry_count=0, next_retry_at=CURRENT_TIMESTAMP(3),
|
||||||
|
lease_owner=NULL, lease_until=NULL, last_error=#{reason}
|
||||||
|
WHERE event_id=#{eventId} AND status='DEAD'
|
||||||
|
""")
|
||||||
|
int replayDead(@Param("eventId") String eventId, @Param("reason") String reason);
|
||||||
}
|
}
|
||||||
|
|||||||
+5
-2
@@ -13,8 +13,11 @@ import java.security.MessageDigest;
|
|||||||
*/
|
*/
|
||||||
@Component
|
@Component
|
||||||
public class InternalServiceTokenGuard {
|
public class InternalServiceTokenGuard {
|
||||||
@Value("${baiyue.security.internal-service-token:}")
|
private final String expectedToken;
|
||||||
private String expectedToken;
|
|
||||||
|
public InternalServiceTokenGuard(@Value("${baiyue.security.internal-service-token:}") String expectedToken) {
|
||||||
|
this.expectedToken = expectedToken;
|
||||||
|
}
|
||||||
|
|
||||||
public void verify(String actualToken) {
|
public void verify(String actualToken) {
|
||||||
if (expectedToken == null || expectedToken.isBlank()) {
|
if (expectedToken == null || expectedToken.isBlank()) {
|
||||||
|
|||||||
+6
-24
@@ -1,7 +1,5 @@
|
|||||||
package com.baiyue.eventbridge.service;
|
package com.baiyue.eventbridge.service;
|
||||||
|
|
||||||
import jakarta.annotation.Resource;
|
|
||||||
|
|
||||||
import com.baiyue.common.event.EventEnvelope;
|
import com.baiyue.common.event.EventEnvelope;
|
||||||
import com.baiyue.common.event.EventPayloadCanonicalizer;
|
import com.baiyue.common.event.EventPayloadCanonicalizer;
|
||||||
import com.baiyue.common.event.EventPayloadHash;
|
import com.baiyue.common.event.EventPayloadHash;
|
||||||
@@ -13,21 +11,20 @@ import org.springframework.beans.factory.annotation.Value;
|
|||||||
import org.springframework.dao.DuplicateKeyException;
|
import org.springframework.dao.DuplicateKeyException;
|
||||||
import org.springframework.stereotype.Service;
|
import org.springframework.stereotype.Service;
|
||||||
import org.springframework.transaction.annotation.Transactional;
|
import org.springframework.transaction.annotation.Transactional;
|
||||||
|
import jakarta.annotation.Resource;
|
||||||
|
|
||||||
import java.time.LocalDateTime;
|
import java.time.LocalDateTime;
|
||||||
import java.util.Objects;
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 入站事件校验与幂等落库服务。
|
* 入站事件校验与幂等落库服务。
|
||||||
*/
|
*/
|
||||||
@Service
|
@Service
|
||||||
public class EventInboxService {
|
public class EventInboxService {
|
||||||
@Resource
|
@Resource private InboxStore inboxStore;
|
||||||
private InboxStore inboxStore;
|
@Resource private ObjectMapper objectMapper;
|
||||||
@Resource
|
|
||||||
private ObjectMapper objectMapper;
|
|
||||||
@Value("${baiyue.region:CN}")
|
@Value("${baiyue.region:CN}")
|
||||||
private String localRegion;
|
private String localRegion;
|
||||||
|
|
||||||
@Transactional
|
@Transactional
|
||||||
public EventReceiveResult receive(EventEnvelope envelope) {
|
public EventReceiveResult receive(EventEnvelope envelope) {
|
||||||
@@ -61,24 +58,9 @@ public class EventInboxService {
|
|||||||
if (!existing.getPayloadHash().equalsIgnoreCase(envelope.payloadHash())) {
|
if (!existing.getPayloadHash().equalsIgnoreCase(envelope.payloadHash())) {
|
||||||
throw new IllegalArgumentException("相同 eventId 对应的 payloadHash 不一致,拒绝覆盖既有事件");
|
throw new IllegalArgumentException("相同 eventId 对应的 payloadHash 不一致,拒绝覆盖既有事件");
|
||||||
}
|
}
|
||||||
if (!hasSameEnvelopeIdentity(existing, envelope)) {
|
|
||||||
throw new IllegalArgumentException("相同 eventId 对应的客户或事件信封身份不一致,拒绝覆盖既有事件");
|
|
||||||
}
|
|
||||||
return new EventReceiveResult(envelope.eventId(), "重复", existing.getReceivedAt());
|
return new EventReceiveResult(envelope.eventId(), "重复", existing.getReceivedAt());
|
||||||
}
|
}
|
||||||
|
|
||||||
private boolean hasSameEnvelopeIdentity(BridgeInbox existing, EventEnvelope envelope) {
|
|
||||||
return Objects.equals(existing.getTenantId(), envelope.tenantId())
|
|
||||||
&& 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.getAggregateType(), envelope.aggregateType())
|
|
||||||
&& Objects.equals(existing.getAggregateId(), envelope.aggregateId())
|
|
||||||
&& Objects.equals(existing.getAggregateVersion(), envelope.aggregateVersion())
|
|
||||||
&& Objects.equals(existing.getHandoverVersion(), envelope.handoverVersion());
|
|
||||||
}
|
|
||||||
|
|
||||||
private BridgeInbox toInbox(EventEnvelope envelope) {
|
private BridgeInbox toInbox(EventEnvelope envelope) {
|
||||||
BridgeInbox inbox = new BridgeInbox();
|
BridgeInbox inbox = new BridgeInbox();
|
||||||
inbox.setEventId(envelope.eventId());
|
inbox.setEventId(envelope.eventId());
|
||||||
@@ -87,7 +69,7 @@ public class EventInboxService {
|
|||||||
inbox.setEventVersion(envelope.eventVersion());
|
inbox.setEventVersion(envelope.eventVersion());
|
||||||
inbox.setSourceRegion(envelope.sourceRegion().name());
|
inbox.setSourceRegion(envelope.sourceRegion().name());
|
||||||
inbox.setTargetRegion(envelope.targetRegion().name());
|
inbox.setTargetRegion(envelope.targetRegion().name());
|
||||||
inbox.setTenantId(envelope.tenantId());
|
inbox.setCustomerId(envelope.tenantId());
|
||||||
inbox.setAggregateType(envelope.aggregateType());
|
inbox.setAggregateType(envelope.aggregateType());
|
||||||
inbox.setAggregateId(envelope.aggregateId());
|
inbox.setAggregateId(envelope.aggregateId());
|
||||||
inbox.setAggregateVersion(envelope.aggregateVersion());
|
inbox.setAggregateVersion(envelope.aggregateVersion());
|
||||||
|
|||||||
+27
@@ -0,0 +1,27 @@
|
|||||||
|
package com.baiyue.eventbridge.service;
|
||||||
|
|
||||||
|
import com.baiyue.eventbridge.mapper.BridgeInboxMapper;
|
||||||
|
import jakarta.annotation.Resource;
|
||||||
|
import org.springframework.stereotype.Service;
|
||||||
|
|
||||||
|
@Service
|
||||||
|
public class EventReplayService {
|
||||||
|
@Resource private BridgeInboxMapper inboxMapper;
|
||||||
|
|
||||||
|
public void replayDead(String eventId, String operatorId, String reason) {
|
||||||
|
if (operatorId == null || operatorId.isBlank()) {
|
||||||
|
throw new IllegalArgumentException("人工补投必须提供操作人");
|
||||||
|
}
|
||||||
|
if (reason == null || reason.isBlank()) {
|
||||||
|
throw new IllegalArgumentException("人工补投必须提供原因");
|
||||||
|
}
|
||||||
|
String auditReason = abbreviate("manual replay by " + operatorId + ": " + reason, 1000);
|
||||||
|
if (inboxMapper.replayDead(eventId, auditReason) != 1) {
|
||||||
|
throw new IllegalStateException("事件不存在或未处于 DEAD 状态");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static String abbreviate(String value, int maxLength) {
|
||||||
|
return value.length() <= maxLength ? value : value.substring(0, maxLength);
|
||||||
|
}
|
||||||
|
}
|
||||||
+55
@@ -0,0 +1,55 @@
|
|||||||
|
package com.baiyue.eventbridge.service;
|
||||||
|
|
||||||
|
import com.baiyue.common.event.EventEnvelope;
|
||||||
|
import com.baiyue.common.event.RegionCode;
|
||||||
|
import com.baiyue.eventbridge.config.EventDispatcherProperties;
|
||||||
|
import com.baiyue.eventbridge.domain.BridgeInbox;
|
||||||
|
import com.fasterxml.jackson.databind.JsonNode;
|
||||||
|
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||||
|
import org.springframework.beans.factory.annotation.Value;
|
||||||
|
import org.springframework.stereotype.Component;
|
||||||
|
import jakarta.annotation.PostConstruct;
|
||||||
|
import jakarta.annotation.Resource;
|
||||||
|
|
||||||
|
import java.net.http.HttpClient;
|
||||||
|
import java.net.http.HttpRequest;
|
||||||
|
import java.net.http.HttpResponse;
|
||||||
|
|
||||||
|
@Component
|
||||||
|
public class HttpLastlegEventClient implements LastlegEventClient {
|
||||||
|
@Resource private ObjectMapper objectMapper;
|
||||||
|
@Resource private EventDispatcherProperties properties;
|
||||||
|
|
||||||
|
@Value("${baiyue.security.internal-service-token:}")
|
||||||
|
private String serviceToken;
|
||||||
|
|
||||||
|
private HttpClient httpClient;
|
||||||
|
|
||||||
|
@PostConstruct
|
||||||
|
void initializeHttpClient() {
|
||||||
|
this.httpClient = HttpClient.newBuilder().connectTimeout(properties.connectTimeout()).build();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public String deliver(BridgeInbox row) throws Exception {
|
||||||
|
EventEnvelope envelope = new EventEnvelope(row.getEventId(), row.getEventType(), row.getEventVersion(),
|
||||||
|
row.getOccurredAt(), RegionCode.valueOf(row.getSourceRegion()), RegionCode.valueOf(row.getTargetRegion()),
|
||||||
|
row.getCustomerId(), row.getAggregateType(), row.getAggregateId(), row.getAggregateVersion(),
|
||||||
|
row.getHandoverVersion(), row.getTraceId(), row.getCorrelationId(), row.getCausationId(),
|
||||||
|
objectMapper.readTree(row.getPayloadJson()), row.getPayloadHash());
|
||||||
|
String body = objectMapper.writeValueAsString(envelope);
|
||||||
|
HttpRequest request = HttpRequest.newBuilder(properties.consumerUrl())
|
||||||
|
.timeout(properties.requestTimeout())
|
||||||
|
.header("Content-Type", "application/json")
|
||||||
|
.header("X-Internal-Service-Token", serviceToken)
|
||||||
|
.POST(HttpRequest.BodyPublishers.ofString(body))
|
||||||
|
.build();
|
||||||
|
HttpResponse<String> response = httpClient.send(request, HttpResponse.BodyHandlers.ofString());
|
||||||
|
JsonNode responseBody = objectMapper.readTree(response.body());
|
||||||
|
if (response.statusCode() / 100 != 2 || responseBody == null
|
||||||
|
|| !"200".equals(responseBody.path("code").asText())) {
|
||||||
|
throw new IllegalStateException("尾程消费失败: HTTP " + response.statusCode());
|
||||||
|
}
|
||||||
|
return String.valueOf(response.statusCode());
|
||||||
|
}
|
||||||
|
}
|
||||||
+7
@@ -0,0 +1,7 @@
|
|||||||
|
package com.baiyue.eventbridge.service;
|
||||||
|
|
||||||
|
import com.baiyue.eventbridge.domain.BridgeInbox;
|
||||||
|
|
||||||
|
public interface LastlegEventClient {
|
||||||
|
String deliver(BridgeInbox event) throws Exception;
|
||||||
|
}
|
||||||
+2
-3
@@ -2,16 +2,15 @@ package com.baiyue.eventbridge.service;
|
|||||||
|
|
||||||
import com.baiyue.eventbridge.domain.BridgeInbox;
|
import com.baiyue.eventbridge.domain.BridgeInbox;
|
||||||
import com.baiyue.eventbridge.mapper.BridgeInboxMapper;
|
import com.baiyue.eventbridge.mapper.BridgeInboxMapper;
|
||||||
import lombok.RequiredArgsConstructor;
|
import jakarta.annotation.Resource;
|
||||||
import org.springframework.stereotype.Repository;
|
import org.springframework.stereotype.Repository;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 基于 MyBatis-Plus 的入站幂等存储。
|
* 基于 MyBatis-Plus 的入站幂等存储。
|
||||||
*/
|
*/
|
||||||
@Repository
|
@Repository
|
||||||
@RequiredArgsConstructor
|
|
||||||
public class MybatisInboxStore implements InboxStore {
|
public class MybatisInboxStore implements InboxStore {
|
||||||
private final BridgeInboxMapper mapper;
|
@Resource private BridgeInboxMapper mapper;
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public BridgeInbox findByEventId(String eventId) {
|
public BridgeInbox findByEventId(String eventId) {
|
||||||
|
|||||||
+88
@@ -0,0 +1,88 @@
|
|||||||
|
package com.baiyue.eventbridge.service;
|
||||||
|
|
||||||
|
import com.baiyue.eventbridge.config.EventDispatcherProperties;
|
||||||
|
import com.baiyue.eventbridge.domain.BridgeDeliveryAttempt;
|
||||||
|
import com.baiyue.eventbridge.domain.BridgeInbox;
|
||||||
|
import com.baiyue.eventbridge.mapper.BridgeDeliveryAttemptMapper;
|
||||||
|
import com.baiyue.eventbridge.mapper.BridgeInboxMapper;
|
||||||
|
import jakarta.annotation.Resource;
|
||||||
|
import lombok.extern.slf4j.Slf4j;
|
||||||
|
import org.springframework.scheduling.annotation.Scheduled;
|
||||||
|
import org.springframework.stereotype.Component;
|
||||||
|
|
||||||
|
import java.lang.management.ManagementFactory;
|
||||||
|
import java.time.LocalDateTime;
|
||||||
|
import java.util.UUID;
|
||||||
|
|
||||||
|
@Component
|
||||||
|
@Slf4j
|
||||||
|
public class OverseasEventDispatcher {
|
||||||
|
@Resource private BridgeInboxMapper inboxMapper;
|
||||||
|
@Resource private BridgeDeliveryAttemptMapper attemptMapper;
|
||||||
|
@Resource private LastlegEventClient eventClient;
|
||||||
|
@Resource private EventDispatcherProperties properties;
|
||||||
|
private final String leaseOwner = ManagementFactory.getRuntimeMXBean().getName() + ":" + UUID.randomUUID();
|
||||||
|
|
||||||
|
@Scheduled(fixedDelayString = "${baiyue.event-dispatcher.fixed-delay:3s}")
|
||||||
|
public void dispatch() {
|
||||||
|
for (String eventId : inboxMapper.dispatchCandidates(properties.batchSize())) {
|
||||||
|
dispatchOne(eventId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void dispatchOne(String eventId) {
|
||||||
|
if (inboxMapper.claim(eventId, leaseOwner, properties.leaseDuration().toSeconds()) != 1) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
BridgeInbox event = inboxMapper.selectById(eventId);
|
||||||
|
int retryAttempt = event.getRetryCount() == null ? 1 : event.getRetryCount() + 1;
|
||||||
|
int deliveryAttemptNo = inboxMapper.nextAttemptNo(eventId);
|
||||||
|
BridgeDeliveryAttempt attempt = startAttempt(event, deliveryAttemptNo);
|
||||||
|
try {
|
||||||
|
String responseCode = eventClient.deliver(event);
|
||||||
|
if (inboxMapper.markProcessed(eventId, leaseOwner) != 1) {
|
||||||
|
throw new IllegalStateException("事件租约已失效,无法确认投递结果");
|
||||||
|
}
|
||||||
|
finishAttempt(attempt, "DELIVERED", responseCode, null);
|
||||||
|
} catch (Exception exception) {
|
||||||
|
String error = abbreviate(exception.getMessage(), 1000);
|
||||||
|
inboxMapper.markFailed(eventId, leaseOwner, properties.maxAttempts(), backoffSeconds(retryAttempt), error);
|
||||||
|
finishAttempt(attempt, retryAttempt >= properties.maxAttempts() ? "DEAD" : "RETRY", null, error);
|
||||||
|
log.warn("尾程事件投递失败 eventId={}, retryAttempt={}, deliveryAttempt={}, reason={}",
|
||||||
|
eventId, retryAttempt, deliveryAttemptNo, error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private BridgeDeliveryAttempt startAttempt(BridgeInbox event, int attemptNo) {
|
||||||
|
BridgeDeliveryAttempt attempt = new BridgeDeliveryAttempt();
|
||||||
|
attempt.setEventId(event.getEventId());
|
||||||
|
attempt.setSourceRegion(event.getSourceRegion());
|
||||||
|
attempt.setTargetRegion(event.getTargetRegion());
|
||||||
|
attempt.setAttemptNo(attemptNo);
|
||||||
|
attempt.setStatus("PROCESSING");
|
||||||
|
attempt.setStartedAt(LocalDateTime.now());
|
||||||
|
attemptMapper.insert(attempt);
|
||||||
|
return attempt;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void finishAttempt(BridgeDeliveryAttempt attempt, String status, String responseCode, String error) {
|
||||||
|
attempt.setStatus(status);
|
||||||
|
attempt.setResponseCode(responseCode);
|
||||||
|
attempt.setErrorMessage(error);
|
||||||
|
attempt.setFinishedAt(LocalDateTime.now());
|
||||||
|
attemptMapper.updateById(attempt);
|
||||||
|
}
|
||||||
|
|
||||||
|
private long backoffSeconds(int attemptNo) {
|
||||||
|
long initial = properties.initialBackoff().toSeconds();
|
||||||
|
int shift = Math.min(Math.max(attemptNo - 1, 0), 20);
|
||||||
|
long delay = initial > Long.MAX_VALUE >> shift ? Long.MAX_VALUE : initial << shift;
|
||||||
|
return Math.min(delay, properties.maxBackoff().toSeconds());
|
||||||
|
}
|
||||||
|
|
||||||
|
private static String abbreviate(String value, int maxLength) {
|
||||||
|
String safe = value == null || value.isBlank() ? "未知投递错误" : value;
|
||||||
|
return safe.length() <= maxLength ? safe : safe.substring(0, maxLength);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,6 +0,0 @@
|
|||||||
spring:
|
|
||||||
datasource:
|
|
||||||
driver-class-name: com.mysql.cj.jdbc.Driver
|
|
||||||
url: ${EVENT_BRIDGE_DB_URL:jdbc:mysql://127.0.0.1:3306/baiyue_event_bridge?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai&useSSL=false&allowPublicKeyRetrieval=true}
|
|
||||||
username: ${EVENT_BRIDGE_DB_USERNAME:root}
|
|
||||||
password: ${EVENT_BRIDGE_DB_PASSWORD:123456}
|
|
||||||
@@ -1,6 +0,0 @@
|
|||||||
spring:
|
|
||||||
datasource:
|
|
||||||
driver-class-name: com.mysql.cj.jdbc.Driver
|
|
||||||
url: ${EVENT_BRIDGE_TEST_DB_URL:jdbc:mysql://${TEST_DB_HOST:8.163.30.128}:${TEST_DB_PORT:3306}/baiyue_event_bridge?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai&useSSL=false&allowPublicKeyRetrieval=true&rewriteBatchedStatements=true}
|
|
||||||
username: ${EVENT_BRIDGE_TEST_DB_USERNAME:${TEST_DB_USERNAME:root}}
|
|
||||||
password: ${EVENT_BRIDGE_TEST_DB_PASSWORD:${TEST_DB_PASSWORD}}
|
|
||||||
@@ -1,18 +1,25 @@
|
|||||||
server:
|
server:
|
||||||
port: ${EVENT_BRIDGE_PORT:9300}
|
port: ${EVENT_BRIDGE_PORT:9312}
|
||||||
|
|
||||||
spring:
|
spring:
|
||||||
profiles:
|
data:
|
||||||
active: ${SPRING_PROFILES_ACTIVE:dev}
|
redis:
|
||||||
|
host: ${REDIS_HOST:127.0.0.1}
|
||||||
|
port: ${REDIS_PORT:6380}
|
||||||
application:
|
application:
|
||||||
name: event-bridge-service
|
name: event-bridge-service
|
||||||
config:
|
config:
|
||||||
import:
|
import:
|
||||||
- optional:nacos:baiyue-common.yml?group=BAIYUE&refreshEnabled=true
|
- optional:nacos:baiyue-common.yml?group=BAIYUE&refreshEnabled=true
|
||||||
- optional:nacos:${spring.application.name}.yml?group=BAIYUE&refreshEnabled=true
|
- optional:nacos:${spring.application.name}.yml?group=BAIYUE&refreshEnabled=true
|
||||||
|
datasource:
|
||||||
|
driver-class-name: com.mysql.cj.jdbc.Driver
|
||||||
|
url: ${EVENT_BRIDGE_DB_URL:jdbc:mysql://127.0.0.1:3306/baiyue_event_bridge?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai&useSSL=false&allowPublicKeyRetrieval=true}
|
||||||
|
username: ${EVENT_BRIDGE_DB_USERNAME:root}
|
||||||
|
password: ${EVENT_BRIDGE_DB_PASSWORD:123456}
|
||||||
cloud:
|
cloud:
|
||||||
nacos:
|
nacos:
|
||||||
server-addr: ${NACOS_SERVER_ADDR:127.0.0.1:8848}
|
server-addr: ${NACOS_SERVER_ADDR:127.0.0.1:8858}
|
||||||
username: ${NACOS_USERNAME:nacos}
|
username: ${NACOS_USERNAME:nacos}
|
||||||
password: ${NACOS_PASSWORD:nacos}
|
password: ${NACOS_PASSWORD:nacos}
|
||||||
discovery:
|
discovery:
|
||||||
@@ -22,7 +29,17 @@ spring:
|
|||||||
namespace: ${NACOS_NAMESPACE:}
|
namespace: ${NACOS_NAMESPACE:}
|
||||||
|
|
||||||
baiyue:
|
baiyue:
|
||||||
region: ${REGION_CODE:CN}
|
region: ${REGION_CODE:OVERSEAS}
|
||||||
|
event-dispatcher:
|
||||||
|
consumer-url: ${LASTLEG_CONSUMER_URL:http://127.0.0.1:9303/internal/lastleg/events}
|
||||||
|
fixed-delay: ${EVENT_DISPATCH_FIXED_DELAY:3s}
|
||||||
|
batch-size: ${EVENT_DISPATCH_BATCH_SIZE:50}
|
||||||
|
max-attempts: ${EVENT_DISPATCH_MAX_ATTEMPTS:10}
|
||||||
|
lease-duration: ${EVENT_DISPATCH_LEASE_DURATION:30s}
|
||||||
|
initial-backoff: ${EVENT_DISPATCH_INITIAL_BACKOFF:5s}
|
||||||
|
max-backoff: ${EVENT_DISPATCH_MAX_BACKOFF:15m}
|
||||||
|
connect-timeout: ${EVENT_DISPATCH_CONNECT_TIMEOUT:3s}
|
||||||
|
request-timeout: ${EVENT_DISPATCH_REQUEST_TIMEOUT:10s}
|
||||||
security:
|
security:
|
||||||
internal-service-token: ${INTERNAL_SERVICE_TOKEN:}
|
internal-service-token: ${INTERNAL_SERVICE_TOKEN:}
|
||||||
|
|
||||||
@@ -35,14 +52,3 @@ management:
|
|||||||
springdoc:
|
springdoc:
|
||||||
api-docs:
|
api-docs:
|
||||||
path: /v3/api-docs
|
path: /v3/api-docs
|
||||||
|
|
||||||
logging:
|
|
||||||
file:
|
|
||||||
name: "${LOGGING_FILE_NAME:${LOG_PATH:./logs}/${spring.application.name}.log}"
|
|
||||||
logback:
|
|
||||||
rollingpolicy:
|
|
||||||
file-name-pattern: "${LOGGING_ROLLING_FILE_NAME_PATTERN:${LOG_PATH:./logs}/${spring.application.name}.%d{yyyy-MM-dd}.%i.log.gz}"
|
|
||||||
max-file-size: "${LOGGING_ROLLING_MAX_FILE_SIZE:100MB}"
|
|
||||||
max-history: "${LOGGING_ROLLING_MAX_HISTORY:7}"
|
|
||||||
total-size-cap: "${LOGGING_ROLLING_TOTAL_SIZE_CAP:5GB}"
|
|
||||||
clean-history-on-start: "${LOGGING_ROLLING_CLEAN_HISTORY_ON_START:false}"
|
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ CREATE TABLE IF NOT EXISTS bridge_outbox (
|
|||||||
event_version INT NOT NULL COMMENT '事件契约版本',
|
event_version INT NOT NULL COMMENT '事件契约版本',
|
||||||
source_region VARCHAR(32) NOT NULL COMMENT '来源区域',
|
source_region VARCHAR(32) NOT NULL COMMENT '来源区域',
|
||||||
target_region VARCHAR(32) NOT NULL COMMENT '目标区域',
|
target_region VARCHAR(32) NOT NULL COMMENT '目标区域',
|
||||||
tenant_id VARCHAR(128) NOT NULL COMMENT '客户或业务隔离标识;跨区域事件沿用来源系统口径',
|
customer_id VARCHAR(128) NOT NULL COMMENT '租户标识',
|
||||||
aggregate_type VARCHAR(64) NOT NULL COMMENT '聚合类型',
|
aggregate_type VARCHAR(64) NOT NULL COMMENT '聚合类型',
|
||||||
aggregate_id VARCHAR(128) NOT NULL COMMENT '聚合标识',
|
aggregate_id VARCHAR(128) NOT NULL COMMENT '聚合标识',
|
||||||
aggregate_version BIGINT NOT NULL COMMENT '聚合版本',
|
aggregate_version BIGINT NOT NULL COMMENT '聚合版本',
|
||||||
@@ -39,7 +39,7 @@ CREATE TABLE IF NOT EXISTS bridge_inbox (
|
|||||||
event_version INT NOT NULL COMMENT '事件契约版本',
|
event_version INT NOT NULL COMMENT '事件契约版本',
|
||||||
source_region VARCHAR(32) NOT NULL COMMENT '来源区域',
|
source_region VARCHAR(32) NOT NULL COMMENT '来源区域',
|
||||||
target_region VARCHAR(32) NOT NULL COMMENT '目标区域',
|
target_region VARCHAR(32) NOT NULL COMMENT '目标区域',
|
||||||
tenant_id VARCHAR(128) NOT NULL COMMENT '客户或业务隔离标识;跨区域事件沿用来源系统口径',
|
customer_id VARCHAR(128) NOT NULL COMMENT '租户标识',
|
||||||
aggregate_type VARCHAR(64) NOT NULL COMMENT '聚合类型',
|
aggregate_type VARCHAR(64) NOT NULL COMMENT '聚合类型',
|
||||||
aggregate_id VARCHAR(128) NOT NULL COMMENT '聚合标识',
|
aggregate_id VARCHAR(128) NOT NULL COMMENT '聚合标识',
|
||||||
aggregate_version BIGINT NOT NULL COMMENT '聚合版本',
|
aggregate_version BIGINT NOT NULL COMMENT '聚合版本',
|
||||||
|
|||||||
+6
-20
@@ -1,43 +1,29 @@
|
|||||||
package com.baiyue.eventbridge.security;
|
package com.baiyue.eventbridge.security;
|
||||||
|
|
||||||
import org.junit.jupiter.api.DisplayName;
|
|
||||||
import org.junit.jupiter.api.Test;
|
import org.junit.jupiter.api.Test;
|
||||||
import jakarta.annotation.Resource;
|
|
||||||
import org.junit.jupiter.api.AfterEach;
|
|
||||||
import org.springframework.test.context.TestPropertySource;
|
|
||||||
import org.springframework.test.context.junit.jupiter.SpringJUnitConfig;
|
|
||||||
import org.springframework.test.util.ReflectionTestUtils;
|
|
||||||
import org.springframework.web.server.ResponseStatusException;
|
import org.springframework.web.server.ResponseStatusException;
|
||||||
|
|
||||||
import static org.assertj.core.api.Assertions.assertThatCode;
|
import static org.assertj.core.api.Assertions.assertThatCode;
|
||||||
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||||||
|
|
||||||
@DisplayName("内部服务令牌校验")
|
|
||||||
@SpringJUnitConfig(InternalServiceTokenGuard.class)
|
|
||||||
@TestPropertySource(properties = "baiyue.security.internal-service-token=a-secure-internal-token")
|
|
||||||
class InternalServiceTokenGuardTest {
|
class InternalServiceTokenGuardTest {
|
||||||
@Resource private InternalServiceTokenGuard guard;
|
|
||||||
|
|
||||||
@AfterEach
|
|
||||||
void resetToken() {
|
|
||||||
ReflectionTestUtils.setField(guard, "expectedToken", "a-secure-internal-token");
|
|
||||||
}
|
|
||||||
@Test
|
@Test
|
||||||
void validTokenAllowsAccess() {
|
void 正确令牌允许访问() {
|
||||||
|
InternalServiceTokenGuard guard = new InternalServiceTokenGuard("a-secure-internal-token");
|
||||||
assertThatCode(() -> guard.verify("a-secure-internal-token")).doesNotThrowAnyException();
|
assertThatCode(() -> guard.verify("a-secure-internal-token")).doesNotThrowAnyException();
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void unconfiguredTokenDisablesReceiveEndpoint() {
|
void 未配置令牌时默认关闭接收接口() {
|
||||||
ReflectionTestUtils.setField(guard, "expectedToken", "");
|
InternalServiceTokenGuard guard = new InternalServiceTokenGuard("");
|
||||||
assertThatThrownBy(() -> guard.verify("anything"))
|
assertThatThrownBy(() -> guard.verify("anything"))
|
||||||
.isInstanceOf(ResponseStatusException.class)
|
.isInstanceOf(ResponseStatusException.class)
|
||||||
.hasMessageContaining("接口已关闭");
|
.hasMessageContaining("接口已关闭");
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void invalidTokenIsRejected() {
|
void 错误令牌被拒绝() {
|
||||||
ReflectionTestUtils.setField(guard, "expectedToken", "expected");
|
InternalServiceTokenGuard guard = new InternalServiceTokenGuard("expected");
|
||||||
assertThatThrownBy(() -> guard.verify("wrong"))
|
assertThatThrownBy(() -> guard.verify("wrong"))
|
||||||
.isInstanceOf(ResponseStatusException.class)
|
.isInstanceOf(ResponseStatusException.class)
|
||||||
.hasMessageContaining("令牌无效");
|
.hasMessageContaining("令牌无效");
|
||||||
|
|||||||
+37
-91
@@ -6,16 +6,8 @@ import com.baiyue.common.event.EventPayloadHash;
|
|||||||
import com.baiyue.common.event.RegionCode;
|
import com.baiyue.common.event.RegionCode;
|
||||||
import com.baiyue.eventbridge.domain.BridgeInbox;
|
import com.baiyue.eventbridge.domain.BridgeInbox;
|
||||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||||
import jakarta.annotation.Resource;
|
|
||||||
import org.junit.jupiter.api.DisplayName;
|
|
||||||
import org.junit.jupiter.api.AfterEach;
|
|
||||||
import org.junit.jupiter.api.Test;
|
import org.junit.jupiter.api.Test;
|
||||||
import org.springframework.boot.test.mock.mockito.MockBean;
|
|
||||||
import org.springframework.context.annotation.Bean;
|
|
||||||
import org.springframework.context.annotation.Configuration;
|
|
||||||
import org.springframework.dao.DuplicateKeyException;
|
import org.springframework.dao.DuplicateKeyException;
|
||||||
import org.springframework.test.context.TestPropertySource;
|
|
||||||
import org.springframework.test.context.junit.jupiter.SpringJUnitConfig;
|
|
||||||
import org.springframework.test.util.ReflectionTestUtils;
|
import org.springframework.test.util.ReflectionTestUtils;
|
||||||
|
|
||||||
import java.time.Instant;
|
import java.time.Instant;
|
||||||
@@ -25,35 +17,17 @@ import static org.assertj.core.api.Assertions.assertThat;
|
|||||||
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||||||
import static org.mockito.ArgumentMatchers.any;
|
import static org.mockito.ArgumentMatchers.any;
|
||||||
import static org.mockito.Mockito.doThrow;
|
import static org.mockito.Mockito.doThrow;
|
||||||
|
import static org.mockito.Mockito.mock;
|
||||||
import static org.mockito.Mockito.verify;
|
import static org.mockito.Mockito.verify;
|
||||||
import static org.mockito.Mockito.when;
|
import static org.mockito.Mockito.when;
|
||||||
|
|
||||||
@DisplayName("跨区域事件收件箱")
|
|
||||||
@SpringJUnitConfig(classes = {EventInboxService.class, EventInboxServiceTest.TestConfig.class})
|
|
||||||
@TestPropertySource(properties = "baiyue.region=OVERSEAS")
|
|
||||||
class EventInboxServiceTest {
|
class EventInboxServiceTest {
|
||||||
@Resource
|
private final ObjectMapper objectMapper = new ObjectMapper();
|
||||||
private ObjectMapper objectMapper;
|
private final InboxStore store = mock(InboxStore.class);
|
||||||
@MockBean
|
private final EventInboxService service = service("OVERSEAS");
|
||||||
private InboxStore store;
|
|
||||||
@Resource
|
|
||||||
private EventInboxService service;
|
|
||||||
|
|
||||||
@Configuration(proxyBeanMethods = false)
|
|
||||||
static class TestConfig {
|
|
||||||
@Bean
|
|
||||||
ObjectMapper objectMapper() {
|
|
||||||
return new ObjectMapper();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@AfterEach
|
|
||||||
void resetRegion() {
|
|
||||||
ReflectionTestUtils.setField(service, "localRegion", "OVERSEAS");
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void firstEventPersistsAndReturnsReceived() {
|
void 首次事件落库并返回已接收() {
|
||||||
EventEnvelope envelope = envelope("sha256:placeholder");
|
EventEnvelope envelope = envelope("sha256:placeholder");
|
||||||
envelope = withHash(envelope);
|
envelope = withHash(envelope);
|
||||||
|
|
||||||
@@ -62,19 +36,31 @@ class EventInboxServiceTest {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void identicalEventAndDigestDoesNotPersistAgain() {
|
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()));
|
||||||
|
EventEnvelope envelope = new EventEnvelope(source.eventId(), source.eventType(), source.eventVersion(),
|
||||||
|
source.occurredAt(), source.sourceRegion(), source.targetRegion(), source.tenantId(),
|
||||||
|
source.aggregateType(), source.aggregateId(), source.aggregateVersion(), source.handoverVersion(),
|
||||||
|
source.traceId(), source.correlationId(), source.causationId(), source.payload(), legacyHash);
|
||||||
|
|
||||||
|
assertThat(service.receive(envelope).result()).isEqualTo("已接收");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void 相同事件和摘要重复接收不再次写入() {
|
||||||
EventEnvelope envelope = withHash(envelope("sha256:placeholder"));
|
EventEnvelope envelope = withHash(envelope("sha256:placeholder"));
|
||||||
BridgeInbox existing = existing(envelope, envelope.payloadHash());
|
BridgeInbox existing = existing(envelope.payloadHash());
|
||||||
when(store.findByEventId(envelope.eventId())).thenReturn(existing);
|
when(store.findByEventId(envelope.eventId())).thenReturn(existing);
|
||||||
|
|
||||||
assertThat(service.receive(envelope).result()).isEqualTo("重复");
|
assertThat(service.receive(envelope).result()).isEqualTo("重复");
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void sameEventWithDifferentDigestRejectsOverwrite() {
|
void 相同事件但摘要不同时拒绝覆盖() {
|
||||||
EventEnvelope envelope = withHash(envelope("sha256:placeholder"));
|
EventEnvelope envelope = withHash(envelope("sha256:placeholder"));
|
||||||
when(store.findByEventId(envelope.eventId()))
|
when(store.findByEventId(envelope.eventId())).thenReturn(existing("sha256:" + "0".repeat(64)));
|
||||||
.thenReturn(existing(envelope, "sha256:" + "0".repeat(64)));
|
|
||||||
|
|
||||||
assertThatThrownBy(() -> service.receive(envelope))
|
assertThatThrownBy(() -> service.receive(envelope))
|
||||||
.isInstanceOf(IllegalArgumentException.class)
|
.isInstanceOf(IllegalArgumentException.class)
|
||||||
@@ -82,45 +68,20 @@ class EventInboxServiceTest {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void sameEventAndPayloadWithDifferentTenantRejectsOverwrite() {
|
void 并发插入冲突后按既有记录判定为重复() {
|
||||||
EventEnvelope original = withHash(envelope("sha256:placeholder"));
|
|
||||||
EventEnvelope otherTenant = withTenant(original, "20002");
|
|
||||||
when(store.findByEventId(otherTenant.eventId()))
|
|
||||||
.thenReturn(existing(original, original.payloadHash()));
|
|
||||||
|
|
||||||
assertThatThrownBy(() -> service.receive(otherTenant))
|
|
||||||
.isInstanceOf(IllegalArgumentException.class)
|
|
||||||
.hasMessageContaining("信封身份不一致");
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
void sameEventAndPayloadWithDifferentAggregateVersionRejectsOverwrite() {
|
|
||||||
EventEnvelope original = withHash(envelope("sha256:placeholder"));
|
|
||||||
EventEnvelope otherEnvelope = withAggregateVersion(original, 2L);
|
|
||||||
when(store.findByEventId(otherEnvelope.eventId()))
|
|
||||||
.thenReturn(existing(original, original.payloadHash()));
|
|
||||||
|
|
||||||
assertThatThrownBy(() -> service.receive(otherEnvelope))
|
|
||||||
.isInstanceOf(IllegalArgumentException.class)
|
|
||||||
.hasMessageContaining("信封身份不一致");
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
void duplicateInsertAfterRaceIsTreatedAsDuplicate() {
|
|
||||||
EventEnvelope envelope = withHash(envelope("sha256:placeholder"));
|
EventEnvelope envelope = withHash(envelope("sha256:placeholder"));
|
||||||
when(store.findByEventId(envelope.eventId()))
|
when(store.findByEventId(envelope.eventId())).thenReturn(null, existing(envelope.payloadHash()));
|
||||||
.thenReturn(null, existing(envelope, envelope.payloadHash()));
|
|
||||||
doThrow(new DuplicateKeyException("并发重复")).when(store).insert(any());
|
doThrow(new DuplicateKeyException("并发重复")).when(store).insert(any());
|
||||||
|
|
||||||
assertThat(service.receive(envelope).result()).isEqualTo("重复");
|
assertThat(service.receive(envelope).result()).isEqualTo("重复");
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void targetRegionDifferentFromCurrentDeploymentIsRejected() {
|
void 目标区域与当前部署区域不一致时拒绝接收() {
|
||||||
ReflectionTestUtils.setField(service, "localRegion", "CN");
|
EventInboxService cnService = service("CN");
|
||||||
EventEnvelope envelope = withHash(envelope("sha256:placeholder"));
|
EventEnvelope envelope = withHash(envelope("sha256:placeholder"));
|
||||||
|
|
||||||
assertThatThrownBy(() -> service.receive(envelope))
|
assertThatThrownBy(() -> cnService.receive(envelope))
|
||||||
.isInstanceOf(IllegalArgumentException.class)
|
.isInstanceOf(IllegalArgumentException.class)
|
||||||
.hasMessageContaining("目标区域");
|
.hasMessageContaining("目标区域");
|
||||||
}
|
}
|
||||||
@@ -132,6 +93,14 @@ class EventInboxServiceTest {
|
|||||||
objectMapper.createObjectNode().put("orderId", "90001"), hash);
|
objectMapper.createObjectNode().put("orderId", "90001"), hash);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private EventInboxService service(String region) {
|
||||||
|
EventInboxService service = new EventInboxService();
|
||||||
|
ReflectionTestUtils.setField(service, "inboxStore", store);
|
||||||
|
ReflectionTestUtils.setField(service, "objectMapper", objectMapper);
|
||||||
|
ReflectionTestUtils.setField(service, "localRegion", region);
|
||||||
|
return service;
|
||||||
|
}
|
||||||
|
|
||||||
private EventEnvelope withHash(EventEnvelope source) {
|
private EventEnvelope withHash(EventEnvelope source) {
|
||||||
String hash = EventPayloadHash.sha256(EventPayloadCanonicalizer.canonicalize(source.payload()));
|
String hash = EventPayloadHash.sha256(EventPayloadCanonicalizer.canonicalize(source.payload()));
|
||||||
return new EventEnvelope(source.eventId(), source.eventType(), source.eventVersion(), source.occurredAt(),
|
return new EventEnvelope(source.eventId(), source.eventType(), source.eventVersion(), source.occurredAt(),
|
||||||
@@ -140,32 +109,9 @@ class EventInboxServiceTest {
|
|||||||
source.correlationId(), source.causationId(), source.payload(), hash);
|
source.correlationId(), source.causationId(), source.payload(), hash);
|
||||||
}
|
}
|
||||||
|
|
||||||
private EventEnvelope withTenant(EventEnvelope source, String tenantId) {
|
private BridgeInbox existing(String hash) {
|
||||||
return new EventEnvelope(source.eventId(), source.eventType(), source.eventVersion(), source.occurredAt(),
|
|
||||||
source.sourceRegion(), source.targetRegion(), tenantId, source.aggregateType(),
|
|
||||||
source.aggregateId(), source.aggregateVersion(), source.handoverVersion(), source.traceId(),
|
|
||||||
source.correlationId(), source.causationId(), source.payload(), source.payloadHash());
|
|
||||||
}
|
|
||||||
|
|
||||||
private EventEnvelope withAggregateVersion(EventEnvelope source, Long aggregateVersion) {
|
|
||||||
return new EventEnvelope(source.eventId(), source.eventType(), source.eventVersion(), source.occurredAt(),
|
|
||||||
source.sourceRegion(), source.targetRegion(), source.tenantId(), source.aggregateType(),
|
|
||||||
source.aggregateId(), aggregateVersion, source.handoverVersion(), source.traceId(),
|
|
||||||
source.correlationId(), source.causationId(), source.payload(), source.payloadHash());
|
|
||||||
}
|
|
||||||
|
|
||||||
private BridgeInbox existing(EventEnvelope envelope, String hash) {
|
|
||||||
BridgeInbox inbox = new BridgeInbox();
|
BridgeInbox inbox = new BridgeInbox();
|
||||||
inbox.setEventId(envelope.eventId());
|
inbox.setEventId("550e8400-e29b-41d4-a716-446655440000");
|
||||||
inbox.setTenantId(envelope.tenantId());
|
|
||||||
inbox.setEventType(envelope.eventType());
|
|
||||||
inbox.setEventVersion(envelope.eventVersion());
|
|
||||||
inbox.setSourceRegion(envelope.sourceRegion().name());
|
|
||||||
inbox.setTargetRegion(envelope.targetRegion().name());
|
|
||||||
inbox.setAggregateType(envelope.aggregateType());
|
|
||||||
inbox.setAggregateId(envelope.aggregateId());
|
|
||||||
inbox.setAggregateVersion(envelope.aggregateVersion());
|
|
||||||
inbox.setHandoverVersion(envelope.handoverVersion());
|
|
||||||
inbox.setPayloadHash(hash);
|
inbox.setPayloadHash(hash);
|
||||||
inbox.setReceivedAt(LocalDateTime.of(2026, 7, 29, 18, 0));
|
inbox.setReceivedAt(LocalDateTime.of(2026, 7, 29, 18, 0));
|
||||||
return inbox;
|
return inbox;
|
||||||
|
|||||||
+36
@@ -0,0 +1,36 @@
|
|||||||
|
package com.baiyue.eventbridge.service;
|
||||||
|
|
||||||
|
import com.baiyue.eventbridge.mapper.BridgeInboxMapper;
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
import org.springframework.test.util.ReflectionTestUtils;
|
||||||
|
|
||||||
|
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||||||
|
import static org.mockito.Mockito.*;
|
||||||
|
|
||||||
|
class EventReplayServiceTest {
|
||||||
|
private final BridgeInboxMapper mapper = mock(BridgeInboxMapper.class);
|
||||||
|
private final EventReplayService service = service();
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void requeuesDeadEventWithAuditReason() {
|
||||||
|
when(mapper.replayDead(eq("event-1"), contains("incident-42"))).thenReturn(1);
|
||||||
|
|
||||||
|
service.replayDead("event-1", "operator-7", "incident-42 approved");
|
||||||
|
|
||||||
|
verify(mapper).replayDead(eq("event-1"), contains("operator-7"));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void rejectsEventThatIsNotDead() {
|
||||||
|
when(mapper.replayDead(anyString(), anyString())).thenReturn(0);
|
||||||
|
|
||||||
|
assertThatThrownBy(() -> service.replayDead("event-1", "operator-7", "retry"))
|
||||||
|
.isInstanceOf(IllegalStateException.class);
|
||||||
|
}
|
||||||
|
|
||||||
|
private EventReplayService service() {
|
||||||
|
EventReplayService service = new EventReplayService();
|
||||||
|
ReflectionTestUtils.setField(service, "inboxMapper", mapper);
|
||||||
|
return service;
|
||||||
|
}
|
||||||
|
}
|
||||||
+94
@@ -0,0 +1,94 @@
|
|||||||
|
package com.baiyue.eventbridge.service;
|
||||||
|
|
||||||
|
import com.baiyue.eventbridge.config.EventDispatcherProperties;
|
||||||
|
import com.baiyue.eventbridge.domain.BridgeDeliveryAttempt;
|
||||||
|
import com.baiyue.eventbridge.domain.BridgeInbox;
|
||||||
|
import com.baiyue.eventbridge.mapper.BridgeDeliveryAttemptMapper;
|
||||||
|
import com.baiyue.eventbridge.mapper.BridgeInboxMapper;
|
||||||
|
import org.junit.jupiter.api.BeforeEach;
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
import org.mockito.ArgumentCaptor;
|
||||||
|
import org.springframework.test.util.ReflectionTestUtils;
|
||||||
|
|
||||||
|
import java.net.URI;
|
||||||
|
import java.time.Duration;
|
||||||
|
|
||||||
|
import static org.assertj.core.api.Assertions.assertThat;
|
||||||
|
import static org.mockito.ArgumentMatchers.*;
|
||||||
|
import static org.mockito.Mockito.*;
|
||||||
|
|
||||||
|
class OverseasEventDispatcherTest {
|
||||||
|
private final BridgeInboxMapper inboxMapper = mock(BridgeInboxMapper.class);
|
||||||
|
private final BridgeDeliveryAttemptMapper attemptMapper = mock(BridgeDeliveryAttemptMapper.class);
|
||||||
|
private final LastlegEventClient eventClient = mock(LastlegEventClient.class);
|
||||||
|
private OverseasEventDispatcher dispatcher;
|
||||||
|
private BridgeInbox event;
|
||||||
|
|
||||||
|
@BeforeEach
|
||||||
|
void setUp() {
|
||||||
|
EventDispatcherProperties properties = new EventDispatcherProperties(
|
||||||
|
URI.create("https://lastleg.internal/events"), 50, 3,
|
||||||
|
Duration.ofSeconds(30), Duration.ofSeconds(5), Duration.ofMinutes(1),
|
||||||
|
Duration.ofSeconds(3), Duration.ofSeconds(10));
|
||||||
|
dispatcher = new OverseasEventDispatcher();
|
||||||
|
ReflectionTestUtils.setField(dispatcher, "inboxMapper", inboxMapper);
|
||||||
|
ReflectionTestUtils.setField(dispatcher, "attemptMapper", attemptMapper);
|
||||||
|
ReflectionTestUtils.setField(dispatcher, "eventClient", eventClient);
|
||||||
|
ReflectionTestUtils.setField(dispatcher, "properties", properties);
|
||||||
|
event = new BridgeInbox();
|
||||||
|
event.setEventId("event-1");
|
||||||
|
event.setSourceRegion("CN");
|
||||||
|
event.setTargetRegion("OVERSEAS");
|
||||||
|
event.setRetryCount(0);
|
||||||
|
when(inboxMapper.claim(eq("event-1"), anyString(), anyLong())).thenReturn(1);
|
||||||
|
when(inboxMapper.selectById("event-1")).thenReturn(event);
|
||||||
|
when(inboxMapper.nextAttemptNo("event-1")).thenReturn(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void successfulDeliveryIsConfirmedAndAudited() throws Exception {
|
||||||
|
when(eventClient.deliver(event)).thenReturn("200");
|
||||||
|
when(inboxMapper.markProcessed(eq("event-1"), anyString())).thenReturn(1);
|
||||||
|
|
||||||
|
dispatcher.dispatchOne("event-1");
|
||||||
|
|
||||||
|
verify(inboxMapper).markProcessed(eq("event-1"), anyString());
|
||||||
|
assertThat(updatedAttempt().getStatus()).isEqualTo("DELIVERED");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void failedDeliveryIsScheduledForRetry() throws Exception {
|
||||||
|
when(eventClient.deliver(event)).thenThrow(new IllegalStateException("network down"));
|
||||||
|
|
||||||
|
dispatcher.dispatchOne("event-1");
|
||||||
|
|
||||||
|
verify(inboxMapper).markFailed(eq("event-1"), anyString(), eq(3), anyLong(), eq("network down"));
|
||||||
|
assertThat(updatedAttempt().getStatus()).isEqualTo("RETRY");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void finalFailureIsMovedToDeadState() throws Exception {
|
||||||
|
event.setRetryCount(2);
|
||||||
|
when(eventClient.deliver(event)).thenThrow(new IllegalStateException("still down"));
|
||||||
|
|
||||||
|
dispatcher.dispatchOne("event-1");
|
||||||
|
|
||||||
|
assertThat(updatedAttempt().getStatus()).isEqualTo("DEAD");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void eventClaimedByAnotherInstanceIsSkipped() {
|
||||||
|
when(inboxMapper.claim(eq("event-1"), anyString(), anyLong())).thenReturn(0);
|
||||||
|
|
||||||
|
dispatcher.dispatchOne("event-1");
|
||||||
|
|
||||||
|
verifyNoInteractions(eventClient);
|
||||||
|
verify(attemptMapper, never()).insert(any(BridgeDeliveryAttempt.class));
|
||||||
|
}
|
||||||
|
|
||||||
|
private BridgeDeliveryAttempt updatedAttempt() {
|
||||||
|
ArgumentCaptor<BridgeDeliveryAttempt> captor = ArgumentCaptor.forClass(BridgeDeliveryAttempt.class);
|
||||||
|
verify(attemptMapper).updateById(captor.capture());
|
||||||
|
return captor.getValue();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
# Gateway 模块指引
|
||||||
|
|
||||||
|
## 职责边界
|
||||||
|
|
||||||
|
- 负责海外 API 统一入口、路由、RS256/JWKS 验签、Audience 和必要 Claims 校验。
|
||||||
|
- 不保存签名私钥,不依赖国内 Redis 会话,不在网关实现尾程业务逻辑。
|
||||||
|
|
||||||
|
## 快速索引
|
||||||
|
|
||||||
|
| 功能 | 入口 |
|
||||||
|
|---|---|
|
||||||
|
| 安全链配置 | `src/main/java/com/baiyue/gateway/config/GatewaySecurityConfig.java` |
|
||||||
|
| Audience 校验 | `config/AudienceJwtValidator.java` |
|
||||||
|
| 必要 Claims 校验 | `config/RequiredClaimsJwtValidator.java` |
|
||||||
|
| 安全配置项 | `config/GatewaySecurityProperties.java` |
|
||||||
|
| 可信身份头传递 | `filter/TrustedIdentityHeaderFilter.java` |
|
||||||
|
| 环境配置 | `src/main/resources/application.yml`、`../config/nacos/` |
|
||||||
|
| 测试 | `src/test/java/com/baiyue/gateway/config/` |
|
||||||
|
|
||||||
|
## 安全规则
|
||||||
|
|
||||||
|
- 生产令牌 `aud` 必须包含 `lastleg-api`,并包含配置要求的身份声明。
|
||||||
|
- 网关必须清除外部请求伪造的可信身份头,仅传递验签后生成的身份信息。
|
||||||
|
- JWKS 地址、Issuer 和 Audience 必须外部配置;不得在源码写死生产密钥。
|
||||||
|
|
||||||
|
## 修改与验证
|
||||||
|
|
||||||
|
- 定向验证:`mvn -pl gateway -am test`。
|
||||||
|
- 安全变更覆盖合法令牌、错误 Audience、缺失 Claims、过期/错误签名和身份头伪造测试。
|
||||||
|
|
||||||
@@ -0,0 +1,69 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<project xmlns="http://maven.apache.org/POM/4.0.0"
|
||||||
|
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
|
||||||
|
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
|
||||||
|
<modelVersion>4.0.0</modelVersion>
|
||||||
|
<parent>
|
||||||
|
<groupId>com.baiyue.firstleg</groupId>
|
||||||
|
<artifactId>baiyue-firstleg-platform</artifactId>
|
||||||
|
<version>1.0.0-SNAPSHOT</version>
|
||||||
|
<relativePath>../pom.xml</relativePath>
|
||||||
|
</parent>
|
||||||
|
<artifactId>gateway-overseas</artifactId>
|
||||||
|
<name>gateway-overseas</name>
|
||||||
|
|
||||||
|
<dependencies>
|
||||||
|
<dependency>
|
||||||
|
<groupId>org.springdoc</groupId>
|
||||||
|
<artifactId>springdoc-openapi-starter-webflux-ui</artifactId>
|
||||||
|
</dependency>
|
||||||
|
<dependency>
|
||||||
|
<groupId>com.baiyue.shared</groupId>
|
||||||
|
<artifactId>baiyue-common-core</artifactId>
|
||||||
|
<version>${project.version}</version>
|
||||||
|
</dependency>
|
||||||
|
<dependency>
|
||||||
|
<groupId>org.springframework.cloud</groupId>
|
||||||
|
<artifactId>spring-cloud-starter-gateway</artifactId>
|
||||||
|
</dependency>
|
||||||
|
<dependency>
|
||||||
|
<groupId>org.springframework.boot</groupId>
|
||||||
|
<artifactId>spring-boot-starter-security</artifactId>
|
||||||
|
</dependency>
|
||||||
|
<dependency>
|
||||||
|
<groupId>org.springframework.boot</groupId>
|
||||||
|
<artifactId>spring-boot-starter-oauth2-resource-server</artifactId>
|
||||||
|
</dependency>
|
||||||
|
<dependency>
|
||||||
|
<groupId>com.alibaba.cloud</groupId>
|
||||||
|
<artifactId>spring-cloud-starter-alibaba-nacos-discovery</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-alibaba-nacos-config</artifactId>
|
||||||
|
</dependency>
|
||||||
|
<dependency>
|
||||||
|
<groupId>com.alibaba.cloud</groupId>
|
||||||
|
<artifactId>spring-cloud-starter-alibaba-sentinel</artifactId>
|
||||||
|
</dependency>
|
||||||
|
<dependency>
|
||||||
|
<groupId>org.springframework.boot</groupId>
|
||||||
|
<artifactId>spring-boot-starter-actuator</artifactId>
|
||||||
|
</dependency>
|
||||||
|
<dependency>
|
||||||
|
<groupId>org.springframework.boot</groupId>
|
||||||
|
<artifactId>spring-boot-starter-test</artifactId>
|
||||||
|
<scope>test</scope>
|
||||||
|
</dependency>
|
||||||
|
<dependency>
|
||||||
|
<groupId>io.projectreactor</groupId>
|
||||||
|
<artifactId>reactor-test</artifactId>
|
||||||
|
<scope>test</scope>
|
||||||
|
</dependency>
|
||||||
|
</dependencies>
|
||||||
|
<build><plugins><plugin><groupId>org.springframework.boot</groupId><artifactId>spring-boot-maven-plugin</artifactId></plugin></plugins></build>
|
||||||
|
</project>
|
||||||
+14
@@ -0,0 +1,14 @@
|
|||||||
|
package com.baiyue.gateway;
|
||||||
|
|
||||||
|
import org.springframework.boot.SpringApplication;
|
||||||
|
import org.springframework.boot.autoconfigure.SpringBootApplication;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* API 网关启动入口,承载统一路由、认证校验和可信身份头转发。
|
||||||
|
*/
|
||||||
|
@SpringBootApplication
|
||||||
|
public class BaiyueGatewayOverseasApplication {
|
||||||
|
public static void main(String[] args) {
|
||||||
|
SpringApplication.run(BaiyueGatewayOverseasApplication.class, args);
|
||||||
|
}
|
||||||
|
}
|
||||||
+32
@@ -0,0 +1,32 @@
|
|||||||
|
package com.baiyue.gateway.config;
|
||||||
|
|
||||||
|
import org.springframework.security.authorization.AuthorizationDecision;
|
||||||
|
import org.springframework.security.authorization.ReactiveAuthorizationManager;
|
||||||
|
import org.springframework.security.core.Authentication;
|
||||||
|
import org.springframework.security.oauth2.server.resource.authentication.JwtAuthenticationToken;
|
||||||
|
import org.springframework.security.web.server.authorization.AuthorizationContext;
|
||||||
|
import reactor.core.publisher.Mono;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 网关应用范围授权器,根据 JWT 的 {@code app_code} 声明隔离商户端与平台端接口。
|
||||||
|
*/
|
||||||
|
final class AppScopeAuthorizationManager implements ReactiveAuthorizationManager<AuthorizationContext> {
|
||||||
|
private final String expectedAppCode;
|
||||||
|
|
||||||
|
AppScopeAuthorizationManager(String expectedAppCode) {
|
||||||
|
this.expectedAppCode = expectedAppCode;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public Mono<AuthorizationDecision> check(Mono<Authentication> authentication,
|
||||||
|
AuthorizationContext context) {
|
||||||
|
return authentication.filter(Authentication::isAuthenticated)
|
||||||
|
.map(this::hasExpectedScope).map(AuthorizationDecision::new)
|
||||||
|
.defaultIfEmpty(new AuthorizationDecision(false));
|
||||||
|
}
|
||||||
|
|
||||||
|
private boolean hasExpectedScope(Authentication authentication) {
|
||||||
|
return authentication instanceof JwtAuthenticationToken jwt
|
||||||
|
&& expectedAppCode.equals(jwt.getToken().getClaimAsString("app_code"));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
package com.baiyue.gateway.config;
|
||||||
|
|
||||||
|
import org.springframework.security.oauth2.core.OAuth2Error;
|
||||||
|
import org.springframework.security.oauth2.core.OAuth2TokenValidator;
|
||||||
|
import org.springframework.security.oauth2.core.OAuth2TokenValidatorResult;
|
||||||
|
import org.springframework.security.oauth2.jwt.Jwt;
|
||||||
|
|
||||||
|
final class AudienceJwtValidator implements OAuth2TokenValidator<Jwt> {
|
||||||
|
private final String requiredAudience;
|
||||||
|
|
||||||
|
AudienceJwtValidator(String requiredAudience) {
|
||||||
|
this.requiredAudience = requiredAudience;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public OAuth2TokenValidatorResult validate(Jwt jwt) {
|
||||||
|
if (jwt.getAudience().contains(requiredAudience)) {
|
||||||
|
return OAuth2TokenValidatorResult.success();
|
||||||
|
}
|
||||||
|
return OAuth2TokenValidatorResult.failure(new OAuth2Error(
|
||||||
|
"invalid_token", "JWT audience 不允许访问海外 API", null));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,84 @@
|
|||||||
|
package com.baiyue.gateway.config;
|
||||||
|
|
||||||
|
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||||
|
import org.springframework.boot.context.properties.EnableConfigurationProperties;
|
||||||
|
import org.springframework.context.annotation.Bean;
|
||||||
|
import org.springframework.context.annotation.Configuration;
|
||||||
|
import org.springframework.http.HttpStatus;
|
||||||
|
import org.springframework.http.MediaType;
|
||||||
|
import org.springframework.security.config.web.server.ServerHttpSecurity;
|
||||||
|
import org.springframework.security.oauth2.core.DelegatingOAuth2TokenValidator;
|
||||||
|
import org.springframework.security.oauth2.jose.jws.SignatureAlgorithm;
|
||||||
|
import org.springframework.security.oauth2.jwt.JwtValidators;
|
||||||
|
import org.springframework.security.oauth2.jwt.NimbusReactiveJwtDecoder;
|
||||||
|
import org.springframework.security.oauth2.jwt.ReactiveJwtDecoder;
|
||||||
|
import org.springframework.security.web.server.SecurityWebFilterChain;
|
||||||
|
import reactor.core.publisher.Mono;
|
||||||
|
|
||||||
|
import java.time.Instant;
|
||||||
|
import java.util.LinkedHashMap;
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 海外网关响应式安全配置,使用国内认证中心公开的 JWKS 验证 JWT。
|
||||||
|
*/
|
||||||
|
@Configuration
|
||||||
|
@EnableConfigurationProperties(GatewaySecurityProperties.class)
|
||||||
|
public class GatewaySecurityConfig {
|
||||||
|
@Bean
|
||||||
|
SecurityWebFilterChain securityWebFilterChain(ServerHttpSecurity http,
|
||||||
|
GatewaySecurityProperties properties,
|
||||||
|
ObjectMapper objectMapper) {
|
||||||
|
var exchanges = http.authorizeExchange();
|
||||||
|
properties.permitPaths().forEach(path -> exchanges.pathMatchers(path).permitAll());
|
||||||
|
exchanges.pathMatchers("/api/merchant/**")
|
||||||
|
.access(new AppScopeAuthorizationManager("MERCHANT"));
|
||||||
|
exchanges.pathMatchers("/api/platform/**")
|
||||||
|
.access(new AppScopeAuthorizationManager("PLATFORM"));
|
||||||
|
exchanges.anyExchange().authenticated();
|
||||||
|
return http.csrf(ServerHttpSecurity.CsrfSpec::disable)
|
||||||
|
.httpBasic(ServerHttpSecurity.HttpBasicSpec::disable)
|
||||||
|
.formLogin(ServerHttpSecurity.FormLoginSpec::disable)
|
||||||
|
.oauth2ResourceServer(oauth -> oauth.jwt(jwt -> { })
|
||||||
|
.authenticationEntryPoint((exchange, exception) -> writeError(exchange, objectMapper,
|
||||||
|
HttpStatus.UNAUTHORIZED, "A0401", "未登录或令牌已失效")))
|
||||||
|
.exceptionHandling(errors -> errors.accessDeniedHandler((exchange, exception) ->
|
||||||
|
writeError(exchange, objectMapper, HttpStatus.FORBIDDEN, "A0403", "无权访问该资源")))
|
||||||
|
.build();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Bean
|
||||||
|
ReactiveJwtDecoder reactiveJwtDecoder(GatewaySecurityProperties properties) {
|
||||||
|
NimbusReactiveJwtDecoder decoder = NimbusReactiveJwtDecoder
|
||||||
|
.withJwkSetUri(properties.jwkSetUri())
|
||||||
|
.jwsAlgorithms(algorithms -> {
|
||||||
|
algorithms.add(SignatureAlgorithm.RS256);
|
||||||
|
algorithms.add(SignatureAlgorithm.ES256);
|
||||||
|
})
|
||||||
|
.build();
|
||||||
|
decoder.setJwtValidator(new DelegatingOAuth2TokenValidator<>(
|
||||||
|
JwtValidators.createDefaultWithIssuer(properties.issuer()),
|
||||||
|
new AudienceJwtValidator(properties.audience()),
|
||||||
|
new RequiredClaimsJwtValidator()));
|
||||||
|
return decoder;
|
||||||
|
}
|
||||||
|
|
||||||
|
static Mono<Void> writeError(org.springframework.web.server.ServerWebExchange exchange,
|
||||||
|
ObjectMapper mapper, HttpStatus status, String code, String message) {
|
||||||
|
exchange.getResponse().setStatusCode(status);
|
||||||
|
exchange.getResponse().getHeaders().setContentType(MediaType.APPLICATION_JSON);
|
||||||
|
Map<String, Object> body = new LinkedHashMap<>();
|
||||||
|
body.put("code", code);
|
||||||
|
body.put("message", message);
|
||||||
|
body.put("data", null);
|
||||||
|
body.put("requestId", exchange.getRequest().getId());
|
||||||
|
body.put("timestamp", Instant.now().toString());
|
||||||
|
try {
|
||||||
|
byte[] bytes = mapper.writeValueAsBytes(body);
|
||||||
|
return exchange.getResponse().writeWith(
|
||||||
|
Mono.just(exchange.getResponse().bufferFactory().wrap(bytes)));
|
||||||
|
} catch (Exception exception) {
|
||||||
|
return exchange.getResponse().setComplete();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+27
@@ -0,0 +1,27 @@
|
|||||||
|
package com.baiyue.gateway.config;
|
||||||
|
|
||||||
|
import org.springframework.boot.context.properties.ConfigurationProperties;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 海外网关安全配置,只保存验签所需的签发方和公钥集地址。
|
||||||
|
*/
|
||||||
|
@ConfigurationProperties(prefix = "baiyue.security")
|
||||||
|
public record GatewaySecurityProperties(String jwkSetUri,
|
||||||
|
String issuer,
|
||||||
|
String audience,
|
||||||
|
List<String> permitPaths) {
|
||||||
|
public GatewaySecurityProperties {
|
||||||
|
if (jwkSetUri == null || jwkSetUri.isBlank()) {
|
||||||
|
throw new IllegalArgumentException("必须配置 JWT_JWK_SET_URI");
|
||||||
|
}
|
||||||
|
if (issuer == null || issuer.isBlank()) {
|
||||||
|
throw new IllegalArgumentException("必须配置 JWT_ISSUER");
|
||||||
|
}
|
||||||
|
if (audience == null || audience.isBlank()) {
|
||||||
|
throw new IllegalArgumentException("必须配置 JWT_AUDIENCE");
|
||||||
|
}
|
||||||
|
permitPaths = permitPaths == null ? List.of() : List.copyOf(permitPaths);
|
||||||
|
}
|
||||||
|
}
|
||||||
+26
@@ -0,0 +1,26 @@
|
|||||||
|
package com.baiyue.gateway.config;
|
||||||
|
|
||||||
|
import org.springframework.security.oauth2.core.OAuth2Error;
|
||||||
|
import org.springframework.security.oauth2.core.OAuth2TokenValidator;
|
||||||
|
import org.springframework.security.oauth2.core.OAuth2TokenValidatorResult;
|
||||||
|
import org.springframework.security.oauth2.jwt.Jwt;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
/** 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");
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public OAuth2TokenValidatorResult validate(Jwt jwt) {
|
||||||
|
for (String claim : REQUIRED_CLAIMS) {
|
||||||
|
Object value = jwt.getClaims().get(claim);
|
||||||
|
if (value == null || value.toString().isBlank()) {
|
||||||
|
return OAuth2TokenValidatorResult.failure(new OAuth2Error(
|
||||||
|
"invalid_token", "JWT 缺少必要声明: " + claim, null));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return OAuth2TokenValidatorResult.success();
|
||||||
|
}
|
||||||
|
}
|
||||||
+73
@@ -0,0 +1,73 @@
|
|||||||
|
package com.baiyue.gateway.filter;
|
||||||
|
|
||||||
|
import org.springframework.cloud.gateway.filter.GatewayFilterChain;
|
||||||
|
import org.springframework.cloud.gateway.filter.GlobalFilter;
|
||||||
|
import org.springframework.core.Ordered;
|
||||||
|
import org.springframework.security.core.Authentication;
|
||||||
|
import org.springframework.security.oauth2.server.resource.authentication.JwtAuthenticationToken;
|
||||||
|
import org.springframework.stereotype.Component;
|
||||||
|
import org.springframework.web.server.ServerWebExchange;
|
||||||
|
import reactor.core.publisher.Mono;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 网关可信身份头过滤器,清除外部伪造身份头,并从已认证 JWT 注入用户、租户和应用范围信息。
|
||||||
|
*/
|
||||||
|
@Component
|
||||||
|
public class TrustedIdentityHeaderFilter implements GlobalFilter, Ordered {
|
||||||
|
private static final List<String> TRUSTED_HEADERS = List.of(
|
||||||
|
"X-User-Id", "X-Membership-Id", "X-Tenant-Id", "X-Operator-Tenant-Id",
|
||||||
|
"X-App-Code", "X-Client-Type"
|
||||||
|
);
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public Mono<Void> filter(ServerWebExchange exchange, GatewayFilterChain chain) {
|
||||||
|
return exchange.getPrincipal()
|
||||||
|
.cast(Authentication.class)
|
||||||
|
.defaultIfEmpty(new AnonymousAuthentication())
|
||||||
|
.flatMap(authentication -> {
|
||||||
|
var requestBuilder = exchange.getRequest().mutate();
|
||||||
|
requestBuilder.headers(headers -> TRUSTED_HEADERS.forEach(headers::remove));
|
||||||
|
|
||||||
|
if (authentication instanceof JwtAuthenticationToken jwt && authentication.isAuthenticated()) {
|
||||||
|
put(requestBuilder, "X-User-Id", jwt.getToken().getSubject());
|
||||||
|
put(requestBuilder, "X-Membership-Id", claim(jwt, "membership_id"));
|
||||||
|
put(requestBuilder, "X-Tenant-Id", claim(jwt, "tenant_id"));
|
||||||
|
put(requestBuilder, "X-Operator-Tenant-Id", claim(jwt, "operator_tenant_id"));
|
||||||
|
put(requestBuilder, "X-App-Code", claim(jwt, "app_code"));
|
||||||
|
put(requestBuilder, "X-Client-Type", claim(jwt, "client_type"));
|
||||||
|
}
|
||||||
|
return chain.filter(exchange.mutate().request(requestBuilder.build()).build());
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private static String claim(JwtAuthenticationToken jwt, String name) {
|
||||||
|
Object value = jwt.getToken().getClaims().get(name);
|
||||||
|
return value == null ? null : value.toString();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void put(org.springframework.http.server.reactive.ServerHttpRequest.Builder builder,
|
||||||
|
String name,
|
||||||
|
String value) {
|
||||||
|
if (value != null && !value.isBlank()) {
|
||||||
|
builder.header(name, value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public int getOrder() {
|
||||||
|
return -100;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static final class AnonymousAuthentication implements Authentication {
|
||||||
|
@Override public java.util.Collection<? extends org.springframework.security.core.GrantedAuthority> getAuthorities() { return List.of(); }
|
||||||
|
@Override public Object getCredentials() { return ""; }
|
||||||
|
@Override public Object getDetails() { return null; }
|
||||||
|
@Override public Object getPrincipal() { return "anonymous"; }
|
||||||
|
@Override public boolean isAuthenticated() { return false; }
|
||||||
|
@Override public void setAuthenticated(boolean isAuthenticated) { }
|
||||||
|
@Override public String getName() { return "anonymous"; }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@@ -0,0 +1,69 @@
|
|||||||
|
spring:
|
||||||
|
config:
|
||||||
|
activate:
|
||||||
|
on-profile: overseas
|
||||||
|
cloud:
|
||||||
|
gateway:
|
||||||
|
discovery:
|
||||||
|
locator:
|
||||||
|
enabled: false
|
||||||
|
routes:
|
||||||
|
- id: lastleg-service
|
||||||
|
uri: lb://lastleg-service
|
||||||
|
predicates: ["Path=/api/merchant/v1/last-leg/**,/api/platform/v1/last-leg/**"]
|
||||||
|
- id: warehouse-overseas-service
|
||||||
|
uri: lb://warehouse-overseas-service
|
||||||
|
predicates: ["Path=/api/merchant/v1/warehouses/**,/api/platform/v1/warehouses/**,/api/platform/v1/warehouse-admin/**"]
|
||||||
|
- id: file-service
|
||||||
|
uri: lb://file-service
|
||||||
|
predicates: ["Path=/api/merchant/v1/files/**,/api/platform/v1/files/**"]
|
||||||
|
- id: print-service
|
||||||
|
uri: lb://print-service
|
||||||
|
predicates: ["Path=/api/merchant/v1/print/**,/api/platform/v1/print/**"]
|
||||||
|
- id: lastleg-openapi
|
||||||
|
uri: lb://lastleg-service
|
||||||
|
predicates: [Path=/lastleg-service/v3/api-docs/**]
|
||||||
|
filters: [StripPrefix=1]
|
||||||
|
- id: warehouse-openapi
|
||||||
|
uri: lb://warehouse-overseas-service
|
||||||
|
predicates: [Path=/warehouse-overseas-service/v3/api-docs/**]
|
||||||
|
filters: [StripPrefix=1]
|
||||||
|
- id: file-openapi
|
||||||
|
uri: lb://file-service
|
||||||
|
predicates: [Path=/file-service/v3/api-docs/**]
|
||||||
|
filters: [StripPrefix=1]
|
||||||
|
- id: print-openapi
|
||||||
|
uri: lb://print-service
|
||||||
|
predicates: [Path=/print-service/v3/api-docs/**]
|
||||||
|
filters: [StripPrefix=1]
|
||||||
|
- id: audit-openapi
|
||||||
|
uri: lb://audit-service
|
||||||
|
predicates: [Path=/audit-service/v3/api-docs/**]
|
||||||
|
filters: [StripPrefix=1]
|
||||||
|
- id: integration-openapi
|
||||||
|
uri: lb://integration-service
|
||||||
|
predicates: [Path=/integration-service/v3/api-docs/**]
|
||||||
|
filters: [StripPrefix=1]
|
||||||
|
- id: event-bridge-openapi
|
||||||
|
uri: lb://event-bridge-service
|
||||||
|
predicates: [Path=/event-bridge-service/v3/api-docs/**]
|
||||||
|
filters: [StripPrefix=1]
|
||||||
|
|
||||||
|
baiyue:
|
||||||
|
deployment:
|
||||||
|
region-code: ${REGION_CODE:OVERSEAS}
|
||||||
|
cell: ${DEPLOYMENT_CELL:prod-overseas}
|
||||||
|
data-residency: ${DATA_RESIDENCY:OVERSEAS}
|
||||||
|
|
||||||
|
springdoc:
|
||||||
|
swagger-ui:
|
||||||
|
urls:
|
||||||
|
- { name: lastleg-merchant, url: /lastleg-service/v3/api-docs/merchant }
|
||||||
|
- { name: warehouse-merchant, url: /warehouse-overseas-service/v3/api-docs/merchant }
|
||||||
|
- { 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 }
|
||||||
|
- { name: print-merchant, url: /print-service/v3/api-docs/merchant }
|
||||||
|
- { name: audit, url: /audit-service/v3/api-docs }
|
||||||
|
- { name: integration, url: /integration-service/v3/api-docs }
|
||||||
|
- { name: event-bridge, url: /event-bridge-service/v3/api-docs }
|
||||||
@@ -0,0 +1,67 @@
|
|||||||
|
server:
|
||||||
|
port: ${GATEWAY_PORT:9300}
|
||||||
|
|
||||||
|
spring:
|
||||||
|
data:
|
||||||
|
redis:
|
||||||
|
host: ${REDIS_HOST:127.0.0.1}
|
||||||
|
port: ${REDIS_PORT:6380}
|
||||||
|
application:
|
||||||
|
name: gateway-overseas
|
||||||
|
profiles:
|
||||||
|
default: overseas
|
||||||
|
autoconfigure:
|
||||||
|
exclude:
|
||||||
|
- org.springframework.boot.autoconfigure.jdbc.DataSourceAutoConfiguration
|
||||||
|
config:
|
||||||
|
import:
|
||||||
|
- optional:nacos:baiyue-common.yml?group=BAIYUE&refreshEnabled=true
|
||||||
|
- optional:nacos:${spring.application.name}.yml?group=BAIYUE&refreshEnabled=true
|
||||||
|
cloud:
|
||||||
|
nacos:
|
||||||
|
server-addr: ${NACOS_SERVER_ADDR:127.0.0.1:8858}
|
||||||
|
username: ${NACOS_USERNAME:nacos}
|
||||||
|
password: ${NACOS_PASSWORD:nacos}
|
||||||
|
discovery:
|
||||||
|
enabled: ${NACOS_DISCOVERY_ENABLED:true}
|
||||||
|
register-enabled: ${NACOS_REGISTER_ENABLED:true}
|
||||||
|
group: ${NACOS_GROUP:BAIYUE}
|
||||||
|
namespace: ${NACOS_NAMESPACE:public}
|
||||||
|
config:
|
||||||
|
namespace: ${NACOS_NAMESPACE:public}
|
||||||
|
gateway:
|
||||||
|
discovery:
|
||||||
|
locator:
|
||||||
|
enabled: false
|
||||||
|
|
||||||
|
baiyue:
|
||||||
|
security:
|
||||||
|
issuer: ${JWT_ISSUER:https://auth.example.com}
|
||||||
|
audience: ${JWT_AUDIENCE:lastleg-api}
|
||||||
|
jwk-set-uri: ${JWT_JWK_SET_URI:https://auth.example.com/.well-known/jwks.json}
|
||||||
|
permit-paths:
|
||||||
|
- /actuator/health
|
||||||
|
- /swagger-ui/**
|
||||||
|
- /webjars/**
|
||||||
|
- /v3/api-docs/**
|
||||||
|
- /lastleg-service/v3/api-docs/**
|
||||||
|
- /warehouse-overseas-service/v3/api-docs/**
|
||||||
|
- /file-service/v3/api-docs/**
|
||||||
|
- /print-service/v3/api-docs/**
|
||||||
|
- /audit-service/v3/api-docs/**
|
||||||
|
- /integration-service/v3/api-docs/**
|
||||||
|
- /event-bridge-service/v3/api-docs/**
|
||||||
|
|
||||||
|
management:
|
||||||
|
endpoints:
|
||||||
|
web:
|
||||||
|
exposure:
|
||||||
|
include: health,info,prometheus
|
||||||
|
endpoint:
|
||||||
|
health:
|
||||||
|
probes:
|
||||||
|
enabled: true
|
||||||
|
|
||||||
|
springdoc:
|
||||||
|
api-docs.enabled: ${SWAGGER_ENABLED:false}
|
||||||
|
swagger-ui.enabled: ${SWAGGER_ENABLED:false}
|
||||||
+55
@@ -0,0 +1,55 @@
|
|||||||
|
package com.baiyue.gateway.config;
|
||||||
|
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
import org.springframework.security.core.authority.SimpleGrantedAuthority;
|
||||||
|
import org.springframework.security.oauth2.jwt.Jwt;
|
||||||
|
import org.springframework.security.oauth2.server.resource.authentication.JwtAuthenticationToken;
|
||||||
|
import reactor.core.publisher.Mono;
|
||||||
|
import reactor.test.StepVerifier;
|
||||||
|
|
||||||
|
import java.time.Instant;
|
||||||
|
|
||||||
|
class AppScopeAuthorizationManagerTest {
|
||||||
|
@Test
|
||||||
|
void acceptsMatchingApplicationScope() {
|
||||||
|
var manager = new AppScopeAuthorizationManager("MERCHANT");
|
||||||
|
StepVerifier.create(manager.check(Mono.just(authentication("MERCHANT")), null))
|
||||||
|
.expectNextMatches(decision -> decision.isGranted())
|
||||||
|
.verifyComplete();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void rejectsDifferentApplicationScope() {
|
||||||
|
var manager = new AppScopeAuthorizationManager("PLATFORM");
|
||||||
|
StepVerifier.create(manager.check(Mono.just(authentication("MERCHANT")), null))
|
||||||
|
.expectNextMatches(decision -> !decision.isGranted())
|
||||||
|
.verifyComplete();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void rejectsMissingApplicationScope() {
|
||||||
|
var manager = new AppScopeAuthorizationManager("PLATFORM");
|
||||||
|
Jwt jwt = Jwt.withTokenValue("token")
|
||||||
|
.header("alg", "none")
|
||||||
|
.subject("1")
|
||||||
|
.issuedAt(Instant.now())
|
||||||
|
.expiresAt(Instant.now().plusSeconds(60))
|
||||||
|
.build();
|
||||||
|
StepVerifier.create(manager.check(Mono.just(new JwtAuthenticationToken(
|
||||||
|
jwt, java.util.List.of(new SimpleGrantedAuthority("authenticated")))), null))
|
||||||
|
.expectNextMatches(decision -> !decision.isGranted())
|
||||||
|
.verifyComplete();
|
||||||
|
}
|
||||||
|
|
||||||
|
private JwtAuthenticationToken authentication(String appCode) {
|
||||||
|
Jwt jwt = Jwt.withTokenValue("token")
|
||||||
|
.header("alg", "none")
|
||||||
|
.subject("1")
|
||||||
|
.issuedAt(Instant.now())
|
||||||
|
.expiresAt(Instant.now().plusSeconds(60))
|
||||||
|
.claim("app_code", appCode)
|
||||||
|
.build();
|
||||||
|
return new JwtAuthenticationToken(jwt,
|
||||||
|
java.util.List.of(new SimpleGrantedAuthority("authenticated")));
|
||||||
|
}
|
||||||
|
}
|
||||||
+33
@@ -0,0 +1,33 @@
|
|||||||
|
package com.baiyue.gateway.config;
|
||||||
|
|
||||||
|
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;
|
||||||
|
|
||||||
|
class AudienceJwtValidatorTest {
|
||||||
|
private final AudienceJwtValidator validator = new AudienceJwtValidator("lastleg-api");
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void acceptsTokenForLastlegApi() {
|
||||||
|
assertThat(validator.validate(jwt(List.of("cn-api", "lastleg-api"))).hasErrors()).isFalse();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void rejectsTokenForAnotherApi() {
|
||||||
|
assertThat(validator.validate(jwt(List.of("cn-api"))).hasErrors()).isTrue();
|
||||||
|
}
|
||||||
|
|
||||||
|
private Jwt jwt(List<String> audiences) {
|
||||||
|
return Jwt.withTokenValue("token")
|
||||||
|
.header("alg", "RS256")
|
||||||
|
.subject("1001")
|
||||||
|
.issuedAt(Instant.now())
|
||||||
|
.expiresAt(Instant.now().plusSeconds(300))
|
||||||
|
.audience(audiences)
|
||||||
|
.build();
|
||||||
|
}
|
||||||
|
}
|
||||||
+36
@@ -0,0 +1,36 @@
|
|||||||
|
package com.baiyue.gateway.config;
|
||||||
|
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
import org.springframework.security.oauth2.jwt.Jwt;
|
||||||
|
|
||||||
|
import java.time.Instant;
|
||||||
|
|
||||||
|
import static org.assertj.core.api.Assertions.assertThat;
|
||||||
|
|
||||||
|
class RequiredClaimsJwtValidatorTest {
|
||||||
|
private final RequiredClaimsJwtValidator validator = new RequiredClaimsJwtValidator();
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void acceptsCompleteIdentityClaims() {
|
||||||
|
assertThat(validator.validate(jwt(true)).hasErrors()).isFalse();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void rejectsMissingPermissionVersion() {
|
||||||
|
assertThat(validator.validate(jwt(false)).hasErrors()).isTrue();
|
||||||
|
}
|
||||||
|
|
||||||
|
private Jwt jwt(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");
|
||||||
|
if (includePermissionVersion) {
|
||||||
|
builder.claim("permission_version", 3L);
|
||||||
|
}
|
||||||
|
return builder.build();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,72 @@
|
|||||||
|
# Lastleg Service 模块指引
|
||||||
|
|
||||||
|
## 职责与边界
|
||||||
|
|
||||||
|
- 负责消费头程装柜完成事件,保存业务 Inbox,并按最终实装入仓单建立尾程订单、明细冻结快照和初始状态历史。
|
||||||
|
- 负责尾程订单列表、详情、导出和目的仓调整,以及海外入仓计划、托盘分配与 PDA 入仓作业。
|
||||||
|
- 不查询头程数据库,不调用头程接口补齐建单字段;事件快照缺失时应拒绝消费并保留可诊断错误。
|
||||||
|
|
||||||
|
## 快速索引
|
||||||
|
|
||||||
|
| 功能 | 入口 |
|
||||||
|
|---|---|
|
||||||
|
| 事件接收、订单列表/详情/导出、目的仓调整 | `src/main/java/com/baiyue/lastleg/controller/LastlegOrderController.java` |
|
||||||
|
| 装柜完成事件消费 | `service/LoadingCompletedConsumer.java` |
|
||||||
|
| 尾程单号生成 | `service/LastlegOrderNoGenerator.java` |
|
||||||
|
| 订单查询与更新 | `service/LastlegOrderService.java` |
|
||||||
|
| 业务 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/service/PalletAllocator.java` |
|
||||||
|
| PDA 入仓、实收、上架、合托、完成 | `inboundplan/pda/controller/`、`inboundplan/pda/service/InboundPdaWorkflowService.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` |
|
||||||
|
| 数据库基线、表索引与中文注释契约 | `src/main/resources/db/lastleg.sql`、`src/test/java/com/baiyue/lastleg/schema/SchemaChineseCommentContractTest.java` |
|
||||||
|
| 测试 | `src/test/java/com/baiyue/lastleg/` |
|
||||||
|
|
||||||
|
## 数据库表索引与注释约束
|
||||||
|
|
||||||
|
| 业务域 | 表名 | 中文含义 |
|
||||||
|
|---|---|---|
|
||||||
|
| 核心订单与事件 | `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_*` | 海外采购及美国卡车运输 |
|
||||||
|
|
||||||
|
- 新增或修改表时,表 `COMMENT` 和每个普通字段的 `COMMENT` 必须填写准确的中文业务含义,不得只写英文、字段名或类型名。
|
||||||
|
- `SchemaChineseCommentContractTest` 自动扫描全部 `ll_` 表,无需维护表名白名单;新增字段必须同步更新基线 SQL、建表增量和存量备注迁移,增量脚本的 `ADD/MODIFY COLUMN` 不得丢失中文备注。
|
||||||
|
- 表备注、字段备注和索引变更必须可重复执行;存量库的备注修订通过统一增量脚本落地,不依赖基线建表语句。
|
||||||
|
|
||||||
|
## 事件建单规则
|
||||||
|
|
||||||
|
- 只接受冻结契约支持的事件类型和版本;校验事件 ID、租户、区域、Payload 哈希及必要字段。
|
||||||
|
- 同一实际集装箱 `containerNo` 生成一个共享业务尾程单号;`payload.orders` 每项按 `sourceInboundId` 独立落一条雪花 ID `ll_order`,并保存对应明细和初始状态历史。整柜操作不使用 `cabinetNo`。
|
||||||
|
- `ONE_CLICK_DROPSHIP` 每个来源入仓单必须携带一个或多个不可变来源托盘快照;每托按 SKU 携带箱数、数量和箱唛。校验订单汇总一致、托盘号/SKU/箱唛唯一后,按托建立档案及 SKU 库存,并建立箱唛和流水。仓库实物托盘由 Warehouse API 分配,不得覆盖来源托盘快照。
|
||||||
|
- 其他订单类型只建立入仓单维度的尾程订单与冻结明细,来源 `palletCount` 强制为 0;后续 PDA 托盘属于海外仓作业资源。
|
||||||
|
- 整个事件消费在一个事务内完成;任意订单失败时不得留下部分订单。
|
||||||
|
- 相同事件重复投递返回既有结果,不重新生成单号或插入明细。来源入仓单唯一约束是最终防线。
|
||||||
|
- 内部接口先执行常量时间令牌校验;缺失或错误令牌不得进入消费事务。
|
||||||
|
|
||||||
|
## 查询与租户约束
|
||||||
|
|
||||||
|
- 平台查询范围由可信身份/平台权限决定;商户查询只能使用认证身份中的客户 ID。
|
||||||
|
- 列表、详情、导出和更新必须使用同一租户边界,不能仅在列表过滤而让详情或更新越权。
|
||||||
|
- 目的仓变更必须写状态/操作历史或审计证据,并使用版本控制防止并发覆盖。
|
||||||
|
- 货物废弃只使用可核对 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 入可用库存。
|
||||||
|
- 派车状态只允许 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`。
|
||||||
|
|
||||||
|
## 修改与验证
|
||||||
|
|
||||||
|
- 表字段、注释和索引同步维护基线 SQL 与统一增量 SQL;生产/存量库只执行增量脚本。
|
||||||
|
- 事件契约变化同时检查 `common-event-contract` 版本、Event Bridge 消费方和旧 Payload 哈希兼容。
|
||||||
|
- 定向验证:`mvn -pl lastleg -am test`。
|
||||||
|
- 事件链路变更至少覆盖重复事件、相同来源入仓单、事务回滚、并发单号、内部令牌和租户越权测试。
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
|
||||||
|
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
|
||||||
|
<modelVersion>4.0.0</modelVersion>
|
||||||
|
<parent><groupId>com.baiyue.firstleg</groupId><artifactId>baiyue-firstleg-platform</artifactId><version>1.0.0-SNAPSHOT</version><relativePath>../pom.xml</relativePath></parent>
|
||||||
|
<artifactId>lastleg-service</artifactId>
|
||||||
|
<dependencies>
|
||||||
|
<dependency><groupId>com.baiyue.shared</groupId><artifactId>common-event-contract</artifactId><version>${project.version}</version></dependency>
|
||||||
|
<dependency><groupId>com.baiyue.shared</groupId><artifactId>baiyue-common-openapi</artifactId><version>${project.version}</version></dependency>
|
||||||
|
<dependency><groupId>com.baomidou</groupId><artifactId>mybatis-plus-spring-boot3-starter</artifactId></dependency>
|
||||||
|
<dependency><groupId>com.baiyue.shared</groupId><artifactId>baiyue-common-web</artifactId><version>${project.version}</version></dependency>
|
||||||
|
<dependency><groupId>com.baiyue.shared</groupId><artifactId>baiyue-common-security</artifactId><version>${project.version}</version></dependency>
|
||||||
|
<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>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>
|
||||||
|
<dependency><groupId>org.springframework.boot</groupId><artifactId>spring-boot-starter-test</artifactId><scope>test</scope></dependency>
|
||||||
|
</dependencies>
|
||||||
|
<build><plugins><plugin><groupId>org.springframework.boot</groupId><artifactId>spring-boot-maven-plugin</artifactId></plugin></plugins></build>
|
||||||
|
</project>
|
||||||
|
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
package com.baiyue.lastleg;
|
||||||
|
|
||||||
|
import org.mybatis.spring.annotation.MapperScan;
|
||||||
|
import org.springframework.boot.SpringApplication;
|
||||||
|
import org.springframework.boot.autoconfigure.SpringBootApplication;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 尾程微服务预留模块启动入口,初始化 Spring Boot 应用并扫描尾程 Mapper。
|
||||||
|
*/
|
||||||
|
@MapperScan("com.baiyue.lastleg.mapper")
|
||||||
|
@SpringBootApplication(scanBasePackages = "com.baiyue")
|
||||||
|
public class BaiyueLastlegApplication {
|
||||||
|
public static void main(String[] args) { SpringApplication.run(BaiyueLastlegApplication.class, args); }
|
||||||
|
}
|
||||||
+34
@@ -0,0 +1,34 @@
|
|||||||
|
package com.baiyue.lastleg.controller;
|
||||||
|
|
||||||
|
import com.baiyue.common.core.api.ApiResponse;
|
||||||
|
import io.swagger.v3.oas.annotations.Operation;
|
||||||
|
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||||
|
import org.springframework.web.bind.annotation.GetMapping;
|
||||||
|
import org.springframework.web.bind.annotation.RequestMapping;
|
||||||
|
import org.springframework.web.bind.annotation.RestController;
|
||||||
|
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 尾程预留服务状态接口,用于验证尾程服务边界及基础可用性。
|
||||||
|
*/
|
||||||
|
@Tag(
|
||||||
|
name = "Last-leg service status / 尾程服务状态",
|
||||||
|
description = "Last-leg reserved service availability API. / 尾程预留服务的可用性接口。"
|
||||||
|
)
|
||||||
|
@RestController
|
||||||
|
@RequestMapping("/api/platform/v1/last-leg")
|
||||||
|
public class LastlegHealthController {
|
||||||
|
@Operation(
|
||||||
|
summary = "Get service status / 查询服务状态",
|
||||||
|
description = "Returns the logical status of the reserved lastleg-service boundary. / 返回预留 lastleg-service 边界的逻辑运行状态。"
|
||||||
|
)
|
||||||
|
@io.swagger.v3.oas.annotations.responses.ApiResponse(
|
||||||
|
responseCode = "200",
|
||||||
|
description = "Service is available / 服务可用"
|
||||||
|
)
|
||||||
|
@GetMapping("/status")
|
||||||
|
public ApiResponse<Map<String, String>> status() {
|
||||||
|
return ApiResponse.success(Map.of("service", "lastleg-service", "status", "UP"));
|
||||||
|
}
|
||||||
|
}
|
||||||
+113
@@ -0,0 +1,113 @@
|
|||||||
|
package com.baiyue.lastleg.controller;
|
||||||
|
|
||||||
|
import com.baiyue.common.core.api.ApiResponse;
|
||||||
|
import com.baiyue.common.core.api.PageResult;
|
||||||
|
import com.baiyue.common.event.EventEnvelope;
|
||||||
|
import com.baiyue.common.security.AuthenticatedUser;
|
||||||
|
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.service.LastlegOrderService;
|
||||||
|
import com.baiyue.lastleg.service.LoadingCompletedConsumer;
|
||||||
|
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.HttpHeaders;
|
||||||
|
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.web.bind.annotation.GetMapping;
|
||||||
|
import org.springframework.web.bind.annotation.PathVariable;
|
||||||
|
import org.springframework.web.bind.annotation.PostMapping;
|
||||||
|
import org.springframework.web.bind.annotation.PutMapping;
|
||||||
|
import org.springframework.web.bind.annotation.RequestBody;
|
||||||
|
import org.springframework.web.bind.annotation.RequestHeader;
|
||||||
|
import org.springframework.web.bind.annotation.RequestParam;
|
||||||
|
import org.springframework.web.bind.annotation.RestController;
|
||||||
|
import org.springframework.web.server.ResponseStatusException;
|
||||||
|
|
||||||
|
import java.nio.charset.StandardCharsets;
|
||||||
|
|
||||||
|
@RestController
|
||||||
|
@Tag(name = "Tail-leg orders")
|
||||||
|
public class LastlegOrderController {
|
||||||
|
@Resource private LoadingCompletedConsumer consumer;
|
||||||
|
@Resource private LastlegOrderService orderService;
|
||||||
|
@Resource private InternalServiceTokenGuard tokenGuard;
|
||||||
|
|
||||||
|
@PostMapping("/internal/lastleg/events")
|
||||||
|
@Operation(summary = "Receive a first-leg loading-completed event")
|
||||||
|
public ApiResponse<Integer> receive(
|
||||||
|
@RequestHeader(value = "X-Internal-Service-Token", required = false) String token,
|
||||||
|
@RequestBody EventEnvelope event) {
|
||||||
|
tokenGuard.verify(token);
|
||||||
|
return ApiResponse.success(consumer.consume(event));
|
||||||
|
}
|
||||||
|
|
||||||
|
@GetMapping("/api/platform/v1/last-leg/orders")
|
||||||
|
@Operation(summary = "Page tail-leg order rows")
|
||||||
|
public ApiResponse<PageResult<LastlegOrderSummary>> list(
|
||||||
|
@AuthenticationPrincipal AuthenticatedUser user,
|
||||||
|
@RequestParam(required = false) String customerId,
|
||||||
|
@Valid LastlegOrderRequests.Query query) {
|
||||||
|
return ApiResponse.success(orderService.page(scope(user, customerId), query));
|
||||||
|
}
|
||||||
|
|
||||||
|
@GetMapping("/api/platform/v1/last-leg/orders/{id}")
|
||||||
|
@Operation(summary = "Get one inbound-code-level tail-leg order")
|
||||||
|
public ApiResponse<LastlegOrderDetailResponse> detail(
|
||||||
|
@AuthenticationPrincipal AuthenticatedUser user,
|
||||||
|
@RequestParam(required = false) String customerId,
|
||||||
|
@PathVariable long id) {
|
||||||
|
return ApiResponse.success(orderService.detail(scope(user, customerId), id));
|
||||||
|
}
|
||||||
|
|
||||||
|
@GetMapping(value = "/api/platform/v1/last-leg/orders/export", produces = "text/csv")
|
||||||
|
@Operation(summary = "Export filtered tail-leg orders as UTF-8 CSV")
|
||||||
|
public ResponseEntity<byte[]> export(
|
||||||
|
@AuthenticationPrincipal AuthenticatedUser user,
|
||||||
|
@RequestParam(required = false) String customerId,
|
||||||
|
@Valid LastlegOrderRequests.Query query) {
|
||||||
|
byte[] body = orderService.exportCsv(scope(user, customerId), query);
|
||||||
|
return ResponseEntity.ok()
|
||||||
|
.contentType(new MediaType("text", "csv", StandardCharsets.UTF_8))
|
||||||
|
.header(HttpHeaders.CONTENT_DISPOSITION, "attachment; filename=lastleg-orders.csv")
|
||||||
|
.body(body);
|
||||||
|
}
|
||||||
|
|
||||||
|
@PutMapping("/api/platform/v1/last-leg/orders/{id}/destination")
|
||||||
|
@Operation(summary = "Transfer one order to a destination warehouse")
|
||||||
|
public ApiResponse<Integer> transferOne(
|
||||||
|
@AuthenticationPrincipal AuthenticatedUser user,
|
||||||
|
@RequestParam(required = false) String customerId,
|
||||||
|
@PathVariable long id,
|
||||||
|
@Valid @RequestBody LastlegOrderRequests.SingleTransferDestination request) {
|
||||||
|
return ApiResponse.success(orderService.transferDestination(scope(user, customerId), request.withOrderId(id)));
|
||||||
|
}
|
||||||
|
|
||||||
|
@PutMapping("/api/platform/v1/last-leg/orders/destination")
|
||||||
|
@Operation(summary = "Transfer multiple orders to a destination warehouse")
|
||||||
|
public ApiResponse<Integer> transferBatch(
|
||||||
|
@AuthenticationPrincipal AuthenticatedUser user,
|
||||||
|
@RequestParam(required = false) String customerId,
|
||||||
|
@Valid @RequestBody LastlegOrderRequests.TransferDestination request) {
|
||||||
|
return ApiResponse.success(orderService.transferDestination(scope(user, customerId), request));
|
||||||
|
}
|
||||||
|
|
||||||
|
private String scope(AuthenticatedUser user, String requestedCustomer) {
|
||||||
|
if (user == null || user.tenantId() == null) {
|
||||||
|
throw new ResponseStatusException(HttpStatus.UNAUTHORIZED);
|
||||||
|
}
|
||||||
|
if ("PLATFORM".equals(user.appCode()) && user.tenantId() == 0L) {
|
||||||
|
if (requestedCustomer == null || requestedCustomer.isBlank() || "0".equals(requestedCustomer)) {
|
||||||
|
throw new ResponseStatusException(HttpStatus.BAD_REQUEST,
|
||||||
|
"Platform queries must specify a customer");
|
||||||
|
}
|
||||||
|
return requestedCustomer;
|
||||||
|
}
|
||||||
|
return String.valueOf(user.tenantId());
|
||||||
|
}
|
||||||
|
}
|
||||||
+9
@@ -0,0 +1,9 @@
|
|||||||
|
package com.baiyue.lastleg.delivery;
|
||||||
|
|
||||||
|
import org.mybatis.spring.annotation.MapperScan;
|
||||||
|
import org.springframework.context.annotation.Configuration;
|
||||||
|
|
||||||
|
@Configuration
|
||||||
|
@MapperScan("com.baiyue.lastleg.delivery.mapper")
|
||||||
|
public class DeliveryMapperConfiguration {
|
||||||
|
}
|
||||||
+74
@@ -0,0 +1,74 @@
|
|||||||
|
package com.baiyue.lastleg.delivery.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.delivery.dto.DeliveryRequests;
|
||||||
|
import com.baiyue.lastleg.delivery.dto.DeliveryResponses;
|
||||||
|
import com.baiyue.lastleg.delivery.service.DeliveryTransferService;
|
||||||
|
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.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")
|
||||||
|
@Tag(name = "Outbound and delivery")
|
||||||
|
public class DeliveryTransferController {
|
||||||
|
@Resource private DeliveryTransferService service;
|
||||||
|
|
||||||
|
@PostMapping("/dispatches/{dispatchNo}/transfer-delivery")
|
||||||
|
@Operation(summary = "Create one pending outbound order and one pending delivery order")
|
||||||
|
public ApiResponse<DeliveryResponses.TransferResult> transfer(
|
||||||
|
@AuthenticationPrincipal AuthenticatedUser user, @PathVariable String dispatchNo,
|
||||||
|
@RequestParam(required = false) String customerId,
|
||||||
|
@Valid @RequestBody DeliveryRequests.Transfer request) {
|
||||||
|
return ApiResponse.success(service.transfer(scope(user, customerId), dispatchNo, operator(user), request));
|
||||||
|
}
|
||||||
|
|
||||||
|
@GetMapping("/outbound-orders")
|
||||||
|
public ApiResponse<PageResult<DeliveryResponses.OutboundRow>> outboundPage(
|
||||||
|
@AuthenticationPrincipal AuthenticatedUser user, @RequestParam(required = false) String customerId,
|
||||||
|
@Valid DeliveryRequests.Query query) {
|
||||||
|
return ApiResponse.success(service.outboundPage(scope(user, customerId), query));
|
||||||
|
}
|
||||||
|
|
||||||
|
@GetMapping("/outbound-orders/{outboundNo}")
|
||||||
|
public ApiResponse<DeliveryResponses.OutboundDetail> outboundDetail(
|
||||||
|
@AuthenticationPrincipal AuthenticatedUser user, @RequestParam(required = false) String customerId,
|
||||||
|
@PathVariable String outboundNo, @Valid DeliveryRequests.PalletQuery query) {
|
||||||
|
return ApiResponse.success(service.outboundDetail(scope(user, customerId), outboundNo, query));
|
||||||
|
}
|
||||||
|
|
||||||
|
@GetMapping("/delivery-orders")
|
||||||
|
public ApiResponse<PageResult<DeliveryResponses.DeliveryRow>> deliveryPage(
|
||||||
|
@AuthenticationPrincipal AuthenticatedUser user, @RequestParam(required = false) String customerId,
|
||||||
|
@Valid DeliveryRequests.Query query) {
|
||||||
|
return ApiResponse.success(service.deliveryPage(scope(user, customerId), query));
|
||||||
|
}
|
||||||
|
|
||||||
|
@GetMapping("/delivery-orders/{deliveryNo}")
|
||||||
|
public ApiResponse<DeliveryResponses.OutboundDetail> deliveryDetail(
|
||||||
|
@AuthenticationPrincipal AuthenticatedUser user, @RequestParam(required = false) String customerId,
|
||||||
|
@PathVariable String deliveryNo, @Valid DeliveryRequests.PalletQuery query) {
|
||||||
|
return ApiResponse.success(service.deliveryDetail(scope(user, customerId), deliveryNo, query));
|
||||||
|
}
|
||||||
|
|
||||||
|
private String scope(AuthenticatedUser user, String requestedCustomer) {
|
||||||
|
if (user == null || user.tenantId() == null) throw new ResponseStatusException(HttpStatus.UNAUTHORIZED);
|
||||||
|
if ("PLATFORM".equals(user.appCode()) && user.tenantId() == 0L) {
|
||||||
|
if (requestedCustomer == null || requestedCustomer.isBlank() || "0".equals(requestedCustomer)) {
|
||||||
|
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Platform request must specify target tenant");
|
||||||
|
}
|
||||||
|
return requestedCustomer.trim();
|
||||||
|
}
|
||||||
|
return String.valueOf(user.tenantId());
|
||||||
|
}
|
||||||
|
private String operator(AuthenticatedUser user) {
|
||||||
|
return user.userId() == null ? user.username() : String.valueOf(user.userId());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
package com.baiyue.lastleg.delivery.dto;
|
||||||
|
|
||||||
|
import jakarta.validation.constraints.Max;
|
||||||
|
import jakarta.validation.constraints.Min;
|
||||||
|
import jakarta.validation.constraints.NotBlank;
|
||||||
|
import jakarta.validation.constraints.Size;
|
||||||
|
|
||||||
|
public final class DeliveryRequests {
|
||||||
|
private DeliveryRequests() {}
|
||||||
|
|
||||||
|
public record Transfer(@NotBlank @Size(max = 255) String idempotencyKey,
|
||||||
|
@Size(max = 1000) String remark) {}
|
||||||
|
|
||||||
|
public record Query(@Min(1) long page, @Min(1) @Max(200) long size,
|
||||||
|
String documentNo, String warehouseId, String status) {
|
||||||
|
public Query { if (page == 0) page = 1; if (size == 0) size = 20; }
|
||||||
|
}
|
||||||
|
|
||||||
|
public record PalletQuery(@Min(1) long page, @Min(1) @Max(200) long size,
|
||||||
|
String destinationWarehouseCode) {
|
||||||
|
public PalletQuery { if (page == 0) page = 1; if (size == 0) size = 20; }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
package com.baiyue.lastleg.delivery.dto;
|
||||||
|
|
||||||
|
import com.baiyue.common.core.api.PageResult;
|
||||||
|
import java.time.LocalDateTime;
|
||||||
|
|
||||||
|
public final class DeliveryResponses {
|
||||||
|
private DeliveryResponses() {}
|
||||||
|
|
||||||
|
public record TransferResult(String dispatchNo, String outboundNo, String outboundStatus,
|
||||||
|
String deliveryNo, String deliveryStatus, int palletCount,
|
||||||
|
int packageCount, boolean replayed) {}
|
||||||
|
|
||||||
|
public record OutboundRow(Long id, String outboundNo, String dispatchNo, String overseasWarehouseId,
|
||||||
|
String status, int palletCount, int packageCount, LocalDateTime createdAt) {}
|
||||||
|
|
||||||
|
public record DeliveryRow(Long id, String deliveryNo, String outboundNo, String dispatchNo,
|
||||||
|
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) {}
|
||||||
|
}
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
package com.baiyue.lastleg.delivery.entity;
|
||||||
|
|
||||||
|
import com.baomidou.mybatisplus.annotation.IdType;
|
||||||
|
import com.baomidou.mybatisplus.annotation.TableId;
|
||||||
|
import com.baomidou.mybatisplus.annotation.TableName;
|
||||||
|
import lombok.Data;
|
||||||
|
|
||||||
|
import java.time.LocalDateTime;
|
||||||
|
|
||||||
|
/** Delivery document linked one-to-one to its outbound document and dispatch. */
|
||||||
|
@Data
|
||||||
|
@TableName("ll_delivery_order")
|
||||||
|
public class DeliveryOrder {
|
||||||
|
@TableId(type = IdType.ASSIGN_ID)
|
||||||
|
private Long id;
|
||||||
|
private String customerId;
|
||||||
|
private String deliveryNo;
|
||||||
|
private Long outboundOrderId;
|
||||||
|
private String outboundNo;
|
||||||
|
private Long dispatchOrderId;
|
||||||
|
private String dispatchNo;
|
||||||
|
private String overseasWarehouseId;
|
||||||
|
private String status;
|
||||||
|
private Integer packageCount;
|
||||||
|
private Integer palletCount;
|
||||||
|
private String idempotencyKey;
|
||||||
|
private String createdBy;
|
||||||
|
private LocalDateTime createdAt;
|
||||||
|
private LocalDateTime updatedAt;
|
||||||
|
}
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
package com.baiyue.lastleg.delivery.entity;
|
||||||
|
|
||||||
|
import com.baomidou.mybatisplus.annotation.IdType;
|
||||||
|
import com.baomidou.mybatisplus.annotation.TableId;
|
||||||
|
import com.baomidou.mybatisplus.annotation.TableName;
|
||||||
|
import lombok.Data;
|
||||||
|
|
||||||
|
import java.time.LocalDateTime;
|
||||||
|
|
||||||
|
/** Outbound document created when a dispatch is transferred to delivery. */
|
||||||
|
@Data
|
||||||
|
@TableName("ll_outbound_order")
|
||||||
|
public class OutboundOrder {
|
||||||
|
@TableId(type = IdType.ASSIGN_ID)
|
||||||
|
private Long id;
|
||||||
|
private String customerId;
|
||||||
|
private String outboundNo;
|
||||||
|
private Long dispatchOrderId;
|
||||||
|
private String dispatchNo;
|
||||||
|
private String overseasWarehouseId;
|
||||||
|
private String status;
|
||||||
|
private Integer packageCount;
|
||||||
|
private Integer palletCount;
|
||||||
|
private String idempotencyKey;
|
||||||
|
private String createdBy;
|
||||||
|
private LocalDateTime createdAt;
|
||||||
|
private LocalDateTime updatedAt;
|
||||||
|
}
|
||||||
+27
@@ -0,0 +1,27 @@
|
|||||||
|
package com.baiyue.lastleg.delivery.entity;
|
||||||
|
|
||||||
|
import com.baomidou.mybatisplus.annotation.IdType;
|
||||||
|
import com.baomidou.mybatisplus.annotation.TableId;
|
||||||
|
import com.baomidou.mybatisplus.annotation.TableName;
|
||||||
|
import lombok.Data;
|
||||||
|
|
||||||
|
import java.time.LocalDateTime;
|
||||||
|
|
||||||
|
/** Pallet-level immutable snapshot belonging to one outbound document. */
|
||||||
|
@Data
|
||||||
|
@TableName("ll_outbound_pallet_item")
|
||||||
|
public class OutboundPalletItem {
|
||||||
|
@TableId(type = IdType.ASSIGN_ID)
|
||||||
|
private Long id;
|
||||||
|
private String customerId;
|
||||||
|
private Long outboundOrderId;
|
||||||
|
private Long warehousePalletId;
|
||||||
|
private String palletNo;
|
||||||
|
private String destinationWarehouseId;
|
||||||
|
private String destinationWarehouseCode;
|
||||||
|
private String destinationWarehouseName;
|
||||||
|
private Integer packageCount;
|
||||||
|
private String orderRefs;
|
||||||
|
private String inboundCode;
|
||||||
|
private LocalDateTime createdAt;
|
||||||
|
}
|
||||||
+17
@@ -0,0 +1,17 @@
|
|||||||
|
package com.baiyue.lastleg.delivery.mapper;
|
||||||
|
|
||||||
|
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
|
||||||
|
import com.baiyue.lastleg.delivery.entity.DeliveryOrder;
|
||||||
|
import org.apache.ibatis.annotations.Mapper;
|
||||||
|
import org.apache.ibatis.annotations.Param;
|
||||||
|
import org.apache.ibatis.annotations.Select;
|
||||||
|
|
||||||
|
@Mapper
|
||||||
|
public interface DeliveryOrderMapper extends BaseMapper<DeliveryOrder> {
|
||||||
|
@Select("SELECT * FROM ll_delivery_order WHERE customer_id=#{customerId} AND delivery_no=#{deliveryNo} LIMIT 1")
|
||||||
|
DeliveryOrder findByNo(@Param("customerId") String customerId, @Param("deliveryNo") String deliveryNo);
|
||||||
|
@Select("SELECT * FROM ll_delivery_order WHERE customer_id=#{customerId} AND idempotency_key=#{key} LIMIT 1")
|
||||||
|
DeliveryOrder findByIdempotency(@Param("customerId") String customerId, @Param("key") String key);
|
||||||
|
@Select("SELECT * FROM ll_delivery_order WHERE customer_id=#{customerId} AND outbound_order_id=#{outboundOrderId} LIMIT 1 FOR UPDATE")
|
||||||
|
DeliveryOrder lockByOutbound(@Param("customerId") String customerId, @Param("outboundOrderId") Long outboundOrderId);
|
||||||
|
}
|
||||||
+17
@@ -0,0 +1,17 @@
|
|||||||
|
package com.baiyue.lastleg.delivery.mapper;
|
||||||
|
|
||||||
|
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
|
||||||
|
import com.baiyue.lastleg.delivery.entity.OutboundOrder;
|
||||||
|
import org.apache.ibatis.annotations.Mapper;
|
||||||
|
import org.apache.ibatis.annotations.Param;
|
||||||
|
import org.apache.ibatis.annotations.Select;
|
||||||
|
|
||||||
|
@Mapper
|
||||||
|
public interface OutboundOrderMapper extends BaseMapper<OutboundOrder> {
|
||||||
|
@Select("SELECT * FROM ll_outbound_order WHERE customer_id=#{customerId} AND outbound_no=#{outboundNo} LIMIT 1")
|
||||||
|
OutboundOrder findByNo(@Param("customerId") String customerId, @Param("outboundNo") String outboundNo);
|
||||||
|
@Select("SELECT * FROM ll_outbound_order WHERE customer_id=#{customerId} AND idempotency_key=#{key} LIMIT 1")
|
||||||
|
OutboundOrder findByIdempotency(@Param("customerId") String customerId, @Param("key") String key);
|
||||||
|
@Select("SELECT * FROM ll_outbound_order WHERE customer_id=#{customerId} AND dispatch_order_id=#{dispatchOrderId} LIMIT 1 FOR UPDATE")
|
||||||
|
OutboundOrder lockByDispatch(@Param("customerId") String customerId, @Param("dispatchOrderId") Long dispatchOrderId);
|
||||||
|
}
|
||||||
+14
@@ -0,0 +1,14 @@
|
|||||||
|
package com.baiyue.lastleg.delivery.mapper;
|
||||||
|
|
||||||
|
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
|
||||||
|
import com.baiyue.lastleg.delivery.entity.OutboundPalletItem;
|
||||||
|
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 OutboundPalletItemMapper extends BaseMapper<OutboundPalletItem> {
|
||||||
|
@Select("SELECT * FROM ll_outbound_pallet_item WHERE customer_id=#{customerId} AND outbound_order_id=#{outboundOrderId} ORDER BY id")
|
||||||
|
List<OutboundPalletItem> findByOutbound(@Param("customerId") String customerId, @Param("outboundOrderId") Long outboundOrderId);
|
||||||
|
}
|
||||||
+213
@@ -0,0 +1,213 @@
|
|||||||
|
package com.baiyue.lastleg.delivery.service;
|
||||||
|
|
||||||
|
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
|
||||||
|
import com.baomidou.mybatisplus.core.toolkit.IdWorker;
|
||||||
|
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
|
||||||
|
import com.baiyue.common.core.api.PageResult;
|
||||||
|
import com.baiyue.lastleg.delivery.dto.DeliveryRequests;
|
||||||
|
import com.baiyue.lastleg.delivery.dto.DeliveryResponses;
|
||||||
|
import com.baiyue.lastleg.delivery.entity.DeliveryOrder;
|
||||||
|
import com.baiyue.lastleg.delivery.entity.OutboundOrder;
|
||||||
|
import com.baiyue.lastleg.delivery.entity.OutboundPalletItem;
|
||||||
|
import com.baiyue.lastleg.delivery.mapper.DeliveryOrderMapper;
|
||||||
|
import com.baiyue.lastleg.delivery.mapper.OutboundOrderMapper;
|
||||||
|
import com.baiyue.lastleg.delivery.mapper.OutboundPalletItemMapper;
|
||||||
|
import com.baiyue.lastleg.dispatch.entity.DispatchItem;
|
||||||
|
import com.baiyue.lastleg.dispatch.entity.DispatchOrder;
|
||||||
|
import com.baiyue.lastleg.dispatch.mapper.DispatchItemMapper;
|
||||||
|
import com.baiyue.lastleg.dispatch.mapper.DispatchOrderMapper;
|
||||||
|
import com.baiyue.lastleg.entity.LastlegOrder;
|
||||||
|
import com.baiyue.lastleg.entity.LastlegOrderStatusHistory;
|
||||||
|
import com.baiyue.lastleg.inboundplan.entity.InboundPlanLine;
|
||||||
|
import com.baiyue.lastleg.inboundplan.entity.InboundPlanPalletAllocation;
|
||||||
|
import com.baiyue.lastleg.inboundplan.mapper.InboundPlanLineMapper;
|
||||||
|
import com.baiyue.lastleg.inboundplan.mapper.InboundPlanPalletAllocationMapper;
|
||||||
|
import com.baiyue.lastleg.mapper.LastlegOrderMapper;
|
||||||
|
import com.baiyue.lastleg.mapper.LastlegOrderStatusHistoryMapper;
|
||||||
|
import jakarta.annotation.Resource;
|
||||||
|
import org.springframework.dao.DuplicateKeyException;
|
||||||
|
import org.springframework.http.HttpStatus;
|
||||||
|
import org.springframework.stereotype.Service;
|
||||||
|
import org.springframework.transaction.annotation.Transactional;
|
||||||
|
import org.springframework.util.StringUtils;
|
||||||
|
import org.springframework.web.server.ResponseStatusException;
|
||||||
|
|
||||||
|
import java.time.LocalDateTime;
|
||||||
|
import java.time.format.DateTimeFormatter;
|
||||||
|
import java.util.*;
|
||||||
|
|
||||||
|
@Service
|
||||||
|
public class DeliveryTransferService {
|
||||||
|
@Resource private DispatchOrderMapper dispatchOrders;
|
||||||
|
@Resource private DispatchItemMapper dispatchItems;
|
||||||
|
@Resource private OutboundOrderMapper outboundOrders;
|
||||||
|
@Resource private OutboundPalletItemMapper outboundPallets;
|
||||||
|
@Resource private DeliveryOrderMapper deliveryOrders;
|
||||||
|
@Resource private InboundPlanPalletAllocationMapper planAllocations;
|
||||||
|
@Resource private InboundPlanLineMapper planLines;
|
||||||
|
@Resource private LastlegOrderMapper tailOrders;
|
||||||
|
@Resource private LastlegOrderStatusHistoryMapper histories;
|
||||||
|
|
||||||
|
@Transactional
|
||||||
|
public DeliveryResponses.TransferResult transfer(String customerId, String dispatchNo, String operatorId,
|
||||||
|
DeliveryRequests.Transfer request) {
|
||||||
|
DispatchOrder found = dispatchOrders.findByDispatchNo(customerId, dispatchNo);
|
||||||
|
if (found == null) throw notFound("Dispatch order not found");
|
||||||
|
DispatchOrder dispatch = dispatchOrders.lockById(customerId, found.getId());
|
||||||
|
OutboundOrder replay = outboundOrders.findByIdempotency(customerId, request.idempotencyKey().trim());
|
||||||
|
if (replay != null) {
|
||||||
|
if (!dispatch.getId().equals(replay.getDispatchOrderId())) throw conflict("Idempotency key belongs to another dispatch");
|
||||||
|
return result(replay, requireDelivery(customerId, replay.getId()), true);
|
||||||
|
}
|
||||||
|
OutboundOrder existing = outboundOrders.lockByDispatch(customerId, dispatch.getId());
|
||||||
|
if (existing != null) return result(existing, requireDelivery(customerId, existing.getId()), true);
|
||||||
|
if (Set.of("CANCELLED", "DEPARTED", "PARTIAL_DEPARTED", "COMPLETED").contains(dispatch.getStatus())) {
|
||||||
|
throw conflict("Dispatch cannot be transferred to delivery in its current status");
|
||||||
|
}
|
||||||
|
List<DispatchItem> items = dispatchItems.findByDispatchOrder(customerId, dispatch.getId());
|
||||||
|
if (items.isEmpty()) throw conflict("Dispatch has no pallets");
|
||||||
|
if (items.stream().anyMatch(item -> item.getWarehousePalletId() == null)) {
|
||||||
|
throw conflict("All dispatch pallets must be received before transfer to delivery");
|
||||||
|
}
|
||||||
|
if (!StringUtils.hasText(dispatch.getOverseasWarehouseId())) throw conflict("Dispatch warehouse is required");
|
||||||
|
|
||||||
|
LocalDateTime now = LocalDateTime.now();
|
||||||
|
OutboundOrder outbound = new OutboundOrder();
|
||||||
|
outbound.setId(IdWorker.getId()); outbound.setCustomerId(customerId);
|
||||||
|
outbound.setOutboundNo("OUT" + now.format(DateTimeFormatter.BASIC_ISO_DATE) + outbound.getId());
|
||||||
|
outbound.setDispatchOrderId(dispatch.getId()); outbound.setDispatchNo(dispatch.getDispatchNo());
|
||||||
|
outbound.setOverseasWarehouseId(dispatch.getOverseasWarehouseId()); outbound.setStatus("WAIT_OUTBOUND");
|
||||||
|
outbound.setPackageCount(items.stream().map(DispatchItem::getPackageCount).filter(Objects::nonNull).mapToInt(Integer::intValue).sum());
|
||||||
|
outbound.setPalletCount(items.size()); outbound.setIdempotencyKey(request.idempotencyKey().trim());
|
||||||
|
outbound.setCreatedBy(operatorId); outbound.setCreatedAt(now); outbound.setUpdatedAt(now);
|
||||||
|
try {
|
||||||
|
outboundOrders.insert(outbound);
|
||||||
|
} catch (DuplicateKeyException e) {
|
||||||
|
// The unique keys are the final concurrent guard. A client retry must observe
|
||||||
|
// the document created by the winning transaction rather than receive a false failure.
|
||||||
|
OutboundOrder concurrent = outboundOrders.findByIdempotency(customerId, request.idempotencyKey().trim());
|
||||||
|
if (concurrent != null && dispatch.getId().equals(concurrent.getDispatchOrderId())) {
|
||||||
|
return result(concurrent, requireDelivery(customerId, concurrent.getId()), true);
|
||||||
|
}
|
||||||
|
concurrent = outboundOrders.lockByDispatch(customerId, dispatch.getId());
|
||||||
|
if (concurrent != null) {
|
||||||
|
return result(concurrent, requireDelivery(customerId, concurrent.getId()), true);
|
||||||
|
}
|
||||||
|
throw conflict("Dispatch was concurrently transferred to delivery");
|
||||||
|
}
|
||||||
|
|
||||||
|
for (DispatchItem source : items) {
|
||||||
|
OutboundPalletItem item = new OutboundPalletItem();
|
||||||
|
item.setId(IdWorker.getId()); item.setCustomerId(customerId); item.setOutboundOrderId(outbound.getId());
|
||||||
|
item.setWarehousePalletId(source.getWarehousePalletId()); item.setPalletNo(source.getPalletNo());
|
||||||
|
item.setDestinationWarehouseId(source.getDestinationWarehouseId());
|
||||||
|
item.setDestinationWarehouseCode(source.getDestinationWarehouseCode());
|
||||||
|
item.setDestinationWarehouseName(source.getDestinationWarehouseName());
|
||||||
|
item.setPackageCount(Objects.requireNonNullElse(source.getPackageCount(), 0));
|
||||||
|
item.setOrderRefs(source.getOrderRefs()); item.setInboundCode(source.getInboundCode()); item.setCreatedAt(now);
|
||||||
|
outboundPallets.insert(item);
|
||||||
|
}
|
||||||
|
|
||||||
|
DeliveryOrder delivery = new DeliveryOrder();
|
||||||
|
delivery.setId(IdWorker.getId()); delivery.setCustomerId(customerId);
|
||||||
|
delivery.setDeliveryNo("DEL" + now.format(DateTimeFormatter.BASIC_ISO_DATE) + delivery.getId());
|
||||||
|
delivery.setOutboundOrderId(outbound.getId()); delivery.setOutboundNo(outbound.getOutboundNo());
|
||||||
|
delivery.setDispatchOrderId(dispatch.getId()); delivery.setDispatchNo(dispatch.getDispatchNo());
|
||||||
|
delivery.setOverseasWarehouseId(dispatch.getOverseasWarehouseId()); delivery.setStatus("WAIT_DELIVERY");
|
||||||
|
delivery.setPackageCount(outbound.getPackageCount()); delivery.setPalletCount(outbound.getPalletCount());
|
||||||
|
delivery.setIdempotencyKey(request.idempotencyKey().trim()); delivery.setCreatedBy(operatorId);
|
||||||
|
delivery.setCreatedAt(now); delivery.setUpdatedAt(now); deliveryOrders.insert(delivery);
|
||||||
|
bindTailOrders(customerId, items, outbound.getOutboundNo(), delivery.getDeliveryNo(), request.remark(), now);
|
||||||
|
return result(outbound, delivery, false);
|
||||||
|
}
|
||||||
|
|
||||||
|
public PageResult<DeliveryResponses.OutboundRow> outboundPage(String customerId, DeliveryRequests.Query query) {
|
||||||
|
Page<OutboundOrder> page = outboundOrders.selectPage(new Page<>(query.page(), query.size()),
|
||||||
|
new LambdaQueryWrapper<OutboundOrder>().eq(OutboundOrder::getCustomerId, customerId)
|
||||||
|
.like(StringUtils.hasText(query.documentNo()), OutboundOrder::getOutboundNo, query.documentNo())
|
||||||
|
.eq(StringUtils.hasText(query.warehouseId()), OutboundOrder::getOverseasWarehouseId, query.warehouseId())
|
||||||
|
.eq(StringUtils.hasText(query.status()), OutboundOrder::getStatus, query.status())
|
||||||
|
.orderByDesc(OutboundOrder::getCreatedAt));
|
||||||
|
return page(page, page.getRecords().stream().map(this::outboundRow).toList());
|
||||||
|
}
|
||||||
|
|
||||||
|
public DeliveryResponses.OutboundDetail outboundDetail(String customerId, String outboundNo,
|
||||||
|
DeliveryRequests.PalletQuery query) {
|
||||||
|
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()));
|
||||||
|
}
|
||||||
|
|
||||||
|
public PageResult<DeliveryResponses.DeliveryRow> deliveryPage(String customerId, DeliveryRequests.Query query) {
|
||||||
|
Page<DeliveryOrder> page = deliveryOrders.selectPage(new Page<>(query.page(), query.size()),
|
||||||
|
new LambdaQueryWrapper<DeliveryOrder>().eq(DeliveryOrder::getCustomerId, customerId)
|
||||||
|
.like(StringUtils.hasText(query.documentNo()), DeliveryOrder::getDeliveryNo, query.documentNo())
|
||||||
|
.eq(StringUtils.hasText(query.warehouseId()), DeliveryOrder::getOverseasWarehouseId, query.warehouseId())
|
||||||
|
.eq(StringUtils.hasText(query.status()), DeliveryOrder::getStatus, query.status())
|
||||||
|
.orderByDesc(DeliveryOrder::getCreatedAt));
|
||||||
|
return page(page, page.getRecords().stream().map(this::deliveryRow).toList());
|
||||||
|
}
|
||||||
|
|
||||||
|
public DeliveryResponses.OutboundDetail deliveryDetail(String customerId, String deliveryNo,
|
||||||
|
DeliveryRequests.PalletQuery query) {
|
||||||
|
DeliveryOrder delivery = deliveryOrders.findByNo(customerId, deliveryNo);
|
||||||
|
if (delivery == null) throw notFound("Delivery order not found");
|
||||||
|
OutboundOrder outbound = outboundOrders.selectById(delivery.getOutboundOrderId());
|
||||||
|
if (outbound == null || !customerId.equals(outbound.getCustomerId())) throw notFound("Outbound order not found");
|
||||||
|
return outboundDetail(customerId, outbound.getOutboundNo(), query);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void bindTailOrders(String customerId, List<DispatchItem> items, String outboundNo, String deliveryNo,
|
||||||
|
String remark, LocalDateTime now) {
|
||||||
|
Set<Long> orderIds = new LinkedHashSet<>();
|
||||||
|
items.stream().map(DispatchItem::getOrderId).filter(Objects::nonNull).forEach(orderIds::add);
|
||||||
|
List<Long> palletIds = items.stream().map(DispatchItem::getPlanPalletId).filter(Objects::nonNull).distinct().toList();
|
||||||
|
if (!palletIds.isEmpty()) {
|
||||||
|
List<InboundPlanPalletAllocation> allocations = planAllocations.selectList(
|
||||||
|
new LambdaQueryWrapper<InboundPlanPalletAllocation>().in(InboundPlanPalletAllocation::getPalletId, palletIds));
|
||||||
|
List<Long> lineIds = allocations.stream().map(InboundPlanPalletAllocation::getLineId)
|
||||||
|
.filter(Objects::nonNull).distinct().toList();
|
||||||
|
if (!lineIds.isEmpty()) {
|
||||||
|
planLines.selectBatchIds(lineIds).stream().map(InboundPlanLine::getOrderId)
|
||||||
|
.filter(Objects::nonNull).forEach(orderIds::add);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (orderIds.isEmpty()) return;
|
||||||
|
for (LastlegOrder order : tailOrders.selectList(new LambdaQueryWrapper<LastlegOrder>()
|
||||||
|
.eq(LastlegOrder::getCustomerId, customerId).in(LastlegOrder::getId, orderIds))) {
|
||||||
|
String from = order.getCurrentStatus(); order.setOutboundOrderNo(outboundNo);
|
||||||
|
order.setDeliveryOrderNo(deliveryNo); order.setCurrentStatus("WAIT_OUTBOUND"); order.setUpdatedAt(now);
|
||||||
|
order.setSubOrderStatus("OUTBOUND:WAIT_OUTBOUND;DELIVERY:WAIT_DELIVERY");
|
||||||
|
tailOrders.updateById(order);
|
||||||
|
LastlegOrderStatusHistory history = new LastlegOrderStatusHistory(); history.setOrderId(order.getId());
|
||||||
|
history.setFromStatus(from); history.setToStatus("WAIT_OUTBOUND");
|
||||||
|
history.setEventId("TRANSFER_DELIVERY:" + deliveryNo);
|
||||||
|
history.setRemark(StringUtils.hasText(remark) ? remark.trim() : "Transferred to outbound and delivery documents");
|
||||||
|
history.setCreatedAt(now); histories.insert(history);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private DeliveryOrder requireDelivery(String customerId, Long outboundId) {
|
||||||
|
DeliveryOrder value = deliveryOrders.lockByOutbound(customerId, outboundId);
|
||||||
|
if (value == null) throw new IllegalStateException("Delivery document is missing for outbound order");
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
private DeliveryResponses.TransferResult result(OutboundOrder o, DeliveryOrder d, boolean replayed) {
|
||||||
|
return new DeliveryResponses.TransferResult(o.getDispatchNo(), o.getOutboundNo(), o.getStatus(),
|
||||||
|
d.getDeliveryNo(), d.getStatus(), o.getPalletCount(), o.getPackageCount(), replayed);
|
||||||
|
}
|
||||||
|
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); }
|
||||||
|
}
|
||||||
+9
@@ -0,0 +1,9 @@
|
|||||||
|
package com.baiyue.lastleg.discard;
|
||||||
|
|
||||||
|
import org.mybatis.spring.annotation.MapperScan;
|
||||||
|
import org.springframework.context.annotation.Configuration;
|
||||||
|
|
||||||
|
@Configuration
|
||||||
|
@MapperScan("com.baiyue.lastleg.discard.mapper")
|
||||||
|
public class DiscardMapperConfiguration {
|
||||||
|
}
|
||||||
+84
@@ -0,0 +1,84 @@
|
|||||||
|
package com.baiyue.lastleg.discard.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.discard.dto.CargoDiscardRequests;
|
||||||
|
import com.baiyue.lastleg.discard.dto.CargoDiscardResponses;
|
||||||
|
import com.baiyue.lastleg.discard.service.CargoDiscardService;
|
||||||
|
import jakarta.annotation.Resource;
|
||||||
|
import jakarta.validation.Valid;
|
||||||
|
import org.springframework.http.HttpStatus;
|
||||||
|
import org.springframework.security.core.annotation.AuthenticationPrincipal;
|
||||||
|
import org.springframework.web.bind.annotation.GetMapping;
|
||||||
|
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.RequestMapping;
|
||||||
|
import org.springframework.web.bind.annotation.RequestParam;
|
||||||
|
import org.springframework.web.bind.annotation.RestController;
|
||||||
|
import org.springframework.web.server.ResponseStatusException;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
@RestController
|
||||||
|
@RequestMapping("/api/platform/v1/last-leg/cargo-discards")
|
||||||
|
public class CargoDiscardController {
|
||||||
|
@Resource private CargoDiscardService service;
|
||||||
|
|
||||||
|
@GetMapping("/inventory-candidates")
|
||||||
|
public ApiResponse<List<CargoDiscardResponses.Candidate>> candidates(
|
||||||
|
@AuthenticationPrincipal AuthenticatedUser user,
|
||||||
|
@RequestParam(required = false) String customerId,
|
||||||
|
@RequestParam(required = false) String inboundCode) {
|
||||||
|
return ApiResponse.success(service.candidates(scope(user, customerId), inboundCode));
|
||||||
|
}
|
||||||
|
|
||||||
|
@PostMapping
|
||||||
|
public ApiResponse<CargoDiscardResponses.Detail> create(@AuthenticationPrincipal AuthenticatedUser user,
|
||||||
|
@RequestParam(required = false) String customerId, @Valid @RequestBody CargoDiscardRequests.Create request) {
|
||||||
|
return ApiResponse.success(service.create(scope(user, customerId), operator(user), request));
|
||||||
|
}
|
||||||
|
|
||||||
|
@GetMapping
|
||||||
|
public ApiResponse<PageResult<CargoDiscardResponses.Summary>> page(@AuthenticationPrincipal AuthenticatedUser user,
|
||||||
|
@RequestParam(required = false) String customerId, @Valid CargoDiscardRequests.Query query) {
|
||||||
|
return ApiResponse.success(service.page(scope(user, customerId), query));
|
||||||
|
}
|
||||||
|
|
||||||
|
@GetMapping("/{discardNo}")
|
||||||
|
public ApiResponse<CargoDiscardResponses.Detail> detail(@AuthenticationPrincipal AuthenticatedUser user,
|
||||||
|
@RequestParam(required = false) String customerId, @PathVariable String discardNo) {
|
||||||
|
return ApiResponse.success(service.detail(scope(user, customerId), discardNo));
|
||||||
|
}
|
||||||
|
|
||||||
|
@PostMapping("/{discardNo}/review")
|
||||||
|
public ApiResponse<CargoDiscardResponses.Detail> review(@AuthenticationPrincipal AuthenticatedUser user,
|
||||||
|
@RequestParam(required = false) String customerId, @PathVariable String discardNo,
|
||||||
|
@Valid @RequestBody CargoDiscardRequests.Review request) {
|
||||||
|
requireApprovalPermission(user);
|
||||||
|
return ApiResponse.success(service.review(scope(user, customerId), discardNo, operator(user), request));
|
||||||
|
}
|
||||||
|
|
||||||
|
private String scope(AuthenticatedUser user, String requested) {
|
||||||
|
if (user == null || user.tenantId() == null) throw new ResponseStatusException(HttpStatus.UNAUTHORIZED);
|
||||||
|
if ("PLATFORM".equals(user.appCode()) && user.tenantId() == 0L) {
|
||||||
|
if (requested == null || requested.isBlank() || "0".equals(requested)) {
|
||||||
|
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Platform request must specify target tenant");
|
||||||
|
}
|
||||||
|
return requested.trim();
|
||||||
|
}
|
||||||
|
return String.valueOf(user.tenantId());
|
||||||
|
}
|
||||||
|
|
||||||
|
private void requireApprovalPermission(AuthenticatedUser user) {
|
||||||
|
if (user == null || user.permissions() == null
|
||||||
|
|| !user.permissions().contains(CargoDiscardService.APPROVAL_PERMISSION)) {
|
||||||
|
throw new ResponseStatusException(HttpStatus.FORBIDDEN, "Cargo discard approval permission is required");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private String operator(AuthenticatedUser user) {
|
||||||
|
return user.userId() == null ? user.username() : String.valueOf(user.userId());
|
||||||
|
}
|
||||||
|
}
|
||||||
+30
@@ -0,0 +1,30 @@
|
|||||||
|
package com.baiyue.lastleg.discard.dto;
|
||||||
|
|
||||||
|
import jakarta.validation.Valid;
|
||||||
|
import jakarta.validation.constraints.Max;
|
||||||
|
import jakarta.validation.constraints.Min;
|
||||||
|
import jakarta.validation.constraints.NotBlank;
|
||||||
|
import jakarta.validation.constraints.NotEmpty;
|
||||||
|
import jakarta.validation.constraints.NotNull;
|
||||||
|
import jakarta.validation.constraints.Positive;
|
||||||
|
import jakarta.validation.constraints.Size;
|
||||||
|
|
||||||
|
import java.time.LocalDateTime;
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
public final class CargoDiscardRequests {
|
||||||
|
private CargoDiscardRequests() {}
|
||||||
|
|
||||||
|
public record Create(@NotEmpty @Size(max = 200) List<@Valid Item> items,
|
||||||
|
@NotBlank @Size(max = 255) String idempotencyKey,
|
||||||
|
@Size(max = 1000) String remark) {}
|
||||||
|
|
||||||
|
public record Item(@NotNull Long orderId, @NotBlank @Size(max = 128) String skuCode,
|
||||||
|
@Positive int discardPackages) {}
|
||||||
|
|
||||||
|
public record Review(@NotBlank String decision, @Size(max = 1000) String remark) {}
|
||||||
|
|
||||||
|
public record Query(@Min(1) long page, @Min(1) @Max(200) long size,
|
||||||
|
String discardNo, String inboundCode, String overseasWarehouseId,
|
||||||
|
String status, LocalDateTime createdFrom, LocalDateTime createdTo) {}
|
||||||
|
}
|
||||||
+30
@@ -0,0 +1,30 @@
|
|||||||
|
package com.baiyue.lastleg.discard.dto;
|
||||||
|
|
||||||
|
import java.math.BigDecimal;
|
||||||
|
import java.time.LocalDateTime;
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
public final class CargoDiscardResponses {
|
||||||
|
private CargoDiscardResponses() {}
|
||||||
|
|
||||||
|
public record Candidate(Long orderId, String inboundCode, String inboundOrderNo,
|
||||||
|
String overseasWarehouseId, String overseasWarehouseName,
|
||||||
|
LocalDateTime inboundCreatedAt, long availablePackages,
|
||||||
|
List<CandidateSku> skus) {}
|
||||||
|
public record CandidateSku(String skuCode, String productName, long availablePackages) {}
|
||||||
|
|
||||||
|
public record Summary(Long id, String discardNo, String inboundCodes,
|
||||||
|
String overseasWarehouseId, String overseasWarehouseName,
|
||||||
|
int totalPackages, BigDecimal totalFee, String currency, String status,
|
||||||
|
String createdBy, LocalDateTime createdAt, LocalDateTime reviewedAt) {}
|
||||||
|
|
||||||
|
public record Item(Long id, Long orderId, String inboundCode, String skuCode,
|
||||||
|
String productName, int availablePackagesSnapshot, int discardPackages) {}
|
||||||
|
|
||||||
|
public record History(String fromStatus, String toStatus, String operatorId,
|
||||||
|
String remark, LocalDateTime createdAt) {}
|
||||||
|
|
||||||
|
public record Detail(Summary summary, BigDecimal feePerPackage, String remark,
|
||||||
|
String reviewedBy, String reviewRemark, List<Item> items,
|
||||||
|
List<History> histories) {}
|
||||||
|
}
|
||||||
+34
@@ -0,0 +1,34 @@
|
|||||||
|
package com.baiyue.lastleg.discard.entity;
|
||||||
|
|
||||||
|
import com.baomidou.mybatisplus.annotation.IdType;
|
||||||
|
import com.baomidou.mybatisplus.annotation.TableId;
|
||||||
|
import com.baomidou.mybatisplus.annotation.TableName;
|
||||||
|
import lombok.Data;
|
||||||
|
|
||||||
|
import java.math.BigDecimal;
|
||||||
|
import java.time.LocalDateTime;
|
||||||
|
|
||||||
|
@Data
|
||||||
|
@TableName("ll_cargo_discard_application")
|
||||||
|
public class CargoDiscardApplication {
|
||||||
|
@TableId(type = IdType.INPUT) private Long id;
|
||||||
|
private String customerId;
|
||||||
|
private String discardNo;
|
||||||
|
private String overseasWarehouseId;
|
||||||
|
private String overseasWarehouseName;
|
||||||
|
private String inboundCodes;
|
||||||
|
private Integer totalPackages;
|
||||||
|
private BigDecimal feePerPackage;
|
||||||
|
private BigDecimal totalFee;
|
||||||
|
private String currency;
|
||||||
|
private String status;
|
||||||
|
private String remark;
|
||||||
|
private String idempotencyKey;
|
||||||
|
private String requestHash;
|
||||||
|
private String createdBy;
|
||||||
|
private String reviewedBy;
|
||||||
|
private String reviewRemark;
|
||||||
|
private LocalDateTime reviewedAt;
|
||||||
|
private LocalDateTime createdAt;
|
||||||
|
private LocalDateTime updatedAt;
|
||||||
|
}
|
||||||
+20
@@ -0,0 +1,20 @@
|
|||||||
|
package com.baiyue.lastleg.discard.entity;
|
||||||
|
|
||||||
|
import com.baomidou.mybatisplus.annotation.IdType;
|
||||||
|
import com.baomidou.mybatisplus.annotation.TableId;
|
||||||
|
import com.baomidou.mybatisplus.annotation.TableName;
|
||||||
|
import lombok.Data;
|
||||||
|
|
||||||
|
import java.time.LocalDateTime;
|
||||||
|
|
||||||
|
@Data
|
||||||
|
@TableName("ll_cargo_discard_carton")
|
||||||
|
public class CargoDiscardCarton {
|
||||||
|
@TableId(type = IdType.INPUT) private Long id;
|
||||||
|
private Long applicationId;
|
||||||
|
private Long itemId;
|
||||||
|
private String customerId;
|
||||||
|
private Long cartonId;
|
||||||
|
private String cartonMark;
|
||||||
|
private LocalDateTime createdAt;
|
||||||
|
}
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
package com.baiyue.lastleg.discard.entity;
|
||||||
|
|
||||||
|
import com.baomidou.mybatisplus.annotation.IdType;
|
||||||
|
import com.baomidou.mybatisplus.annotation.TableId;
|
||||||
|
import com.baomidou.mybatisplus.annotation.TableName;
|
||||||
|
import lombok.Data;
|
||||||
|
|
||||||
|
import java.time.LocalDateTime;
|
||||||
|
|
||||||
|
@Data
|
||||||
|
@TableName("ll_cargo_discard_item")
|
||||||
|
public class CargoDiscardItem {
|
||||||
|
@TableId(type = IdType.INPUT) private Long id;
|
||||||
|
private Long applicationId;
|
||||||
|
private String customerId;
|
||||||
|
private Long orderId;
|
||||||
|
private String inboundCode;
|
||||||
|
private Long profileId;
|
||||||
|
private Long skuInventoryId;
|
||||||
|
private String skuCode;
|
||||||
|
private String productName;
|
||||||
|
private Integer availablePackagesSnapshot;
|
||||||
|
private Integer discardPackages;
|
||||||
|
private LocalDateTime createdAt;
|
||||||
|
}
|
||||||
+21
@@ -0,0 +1,21 @@
|
|||||||
|
package com.baiyue.lastleg.discard.entity;
|
||||||
|
|
||||||
|
import com.baomidou.mybatisplus.annotation.IdType;
|
||||||
|
import com.baomidou.mybatisplus.annotation.TableId;
|
||||||
|
import com.baomidou.mybatisplus.annotation.TableName;
|
||||||
|
import lombok.Data;
|
||||||
|
|
||||||
|
import java.time.LocalDateTime;
|
||||||
|
|
||||||
|
@Data
|
||||||
|
@TableName("ll_cargo_discard_status_history")
|
||||||
|
public class CargoDiscardStatusHistory {
|
||||||
|
@TableId(type = IdType.INPUT) private Long id;
|
||||||
|
private Long applicationId;
|
||||||
|
private String customerId;
|
||||||
|
private String fromStatus;
|
||||||
|
private String toStatus;
|
||||||
|
private String operatorId;
|
||||||
|
private String remark;
|
||||||
|
private LocalDateTime createdAt;
|
||||||
|
}
|
||||||
+19
@@ -0,0 +1,19 @@
|
|||||||
|
package com.baiyue.lastleg.discard.mapper;
|
||||||
|
|
||||||
|
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
|
||||||
|
import com.baiyue.lastleg.discard.entity.CargoDiscardApplication;
|
||||||
|
import org.apache.ibatis.annotations.Mapper;
|
||||||
|
import org.apache.ibatis.annotations.Param;
|
||||||
|
import org.apache.ibatis.annotations.Select;
|
||||||
|
|
||||||
|
@Mapper
|
||||||
|
public interface CargoDiscardApplicationMapper extends BaseMapper<CargoDiscardApplication> {
|
||||||
|
@Select("SELECT * FROM ll_cargo_discard_application WHERE customer_id=#{customerId} AND discard_no=#{discardNo} LIMIT 1")
|
||||||
|
CargoDiscardApplication findNo(@Param("customerId") String customerId, @Param("discardNo") String discardNo);
|
||||||
|
|
||||||
|
@Select("SELECT * FROM ll_cargo_discard_application WHERE customer_id=#{customerId} AND idempotency_key=#{key} LIMIT 1")
|
||||||
|
CargoDiscardApplication findKey(@Param("customerId") String customerId, @Param("key") String key);
|
||||||
|
|
||||||
|
@Select("SELECT * FROM ll_cargo_discard_application WHERE customer_id=#{customerId} AND discard_no=#{discardNo} LIMIT 1 FOR UPDATE")
|
||||||
|
CargoDiscardApplication lockNo(@Param("customerId") String customerId, @Param("discardNo") String discardNo);
|
||||||
|
}
|
||||||
+16
@@ -0,0 +1,16 @@
|
|||||||
|
package com.baiyue.lastleg.discard.mapper;
|
||||||
|
|
||||||
|
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
|
||||||
|
import com.baiyue.lastleg.discard.entity.CargoDiscardCarton;
|
||||||
|
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 CargoDiscardCartonMapper extends BaseMapper<CargoDiscardCarton> {
|
||||||
|
@Select("SELECT * FROM ll_cargo_discard_carton WHERE customer_id=#{customerId} AND application_id=#{applicationId} ORDER BY id")
|
||||||
|
List<CargoDiscardCarton> listApplication(@Param("customerId") String customerId,
|
||||||
|
@Param("applicationId") Long applicationId);
|
||||||
|
}
|
||||||
+69
@@ -0,0 +1,69 @@
|
|||||||
|
package com.baiyue.lastleg.discard.mapper;
|
||||||
|
|
||||||
|
import com.baiyue.lastleg.discard.model.CargoDiscardCandidateRow;
|
||||||
|
import com.baiyue.lastleg.dropship.entity.DropshipCarton;
|
||||||
|
import com.baiyue.lastleg.dropship.entity.DropshipSkuInventory;
|
||||||
|
import org.apache.ibatis.annotations.Mapper;
|
||||||
|
import org.apache.ibatis.annotations.Param;
|
||||||
|
import org.apache.ibatis.annotations.Select;
|
||||||
|
import org.apache.ibatis.annotations.Update;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
@Mapper
|
||||||
|
public interface CargoDiscardInventoryMapper {
|
||||||
|
@Select("<script>SELECT o.id order_id,o.inbound_code,o.inbound_order_no,o.overseas_warehouse_id," +
|
||||||
|
"o.overseas_warehouse_name,o.created_at,p.id profile_id,s.id sku_inventory_id,s.sku_code,s.product_name," +
|
||||||
|
"COUNT(c.id) available_packages FROM ll_order o " +
|
||||||
|
"JOIN ll_dropship_order_profile p ON p.customer_id=o.customer_id AND p.order_id=o.id " +
|
||||||
|
"JOIN ll_dropship_sku_inventory s ON s.customer_id=o.customer_id AND s.order_id=o.id AND s.profile_id=p.id " +
|
||||||
|
"LEFT JOIN ll_dropship_carton c ON c.customer_id=o.customer_id AND c.order_id=o.id " +
|
||||||
|
"AND c.profile_id=p.id AND c.sku_code=s.sku_code AND c.status='AVAILABLE' " +
|
||||||
|
"WHERE o.customer_id=#{customerId} AND o.order_type='ONE_CLICK_DROPSHIP' " +
|
||||||
|
"<if test='inboundCode != null and inboundCode != \"\"'>AND o.inbound_code LIKE CONCAT('%',#{inboundCode},'%') </if>" +
|
||||||
|
"GROUP BY o.id,o.inbound_code,o.inbound_order_no,o.overseas_warehouse_id,o.overseas_warehouse_name,o.created_at," +
|
||||||
|
"p.id,s.id,s.sku_code,s.product_name HAVING COUNT(c.id)>0 ORDER BY o.created_at DESC,o.id DESC,p.id,s.sku_code LIMIT 500</script>")
|
||||||
|
List<CargoDiscardCandidateRow> candidates(@Param("customerId") String customerId,
|
||||||
|
@Param("inboundCode") String inboundCode);
|
||||||
|
|
||||||
|
@Select("SELECT COUNT(*) FROM ll_dropship_carton WHERE customer_id=#{customerId} AND order_id=#{orderId} " +
|
||||||
|
"AND sku_code=#{skuCode} AND status='AVAILABLE'")
|
||||||
|
long countAvailable(@Param("customerId") String customerId, @Param("orderId") Long orderId,
|
||||||
|
@Param("skuCode") String skuCode);
|
||||||
|
|
||||||
|
@Select("SELECT COUNT(*) FROM ll_dropship_carton WHERE customer_id=#{customerId} AND order_id=#{orderId} " +
|
||||||
|
"AND profile_id=#{profileId} AND sku_code=#{skuCode} AND status='AVAILABLE'")
|
||||||
|
long countAvailableProfile(@Param("customerId") String customerId, @Param("orderId") Long orderId,
|
||||||
|
@Param("profileId") Long profileId, @Param("skuCode") String skuCode);
|
||||||
|
|
||||||
|
@Select("SELECT * FROM ll_dropship_carton WHERE customer_id=#{customerId} AND order_id=#{orderId} " +
|
||||||
|
"AND sku_code=#{skuCode} AND status='AVAILABLE' ORDER BY profile_id,carton_sequence,id LIMIT #{limit} FOR UPDATE")
|
||||||
|
List<DropshipCarton> lockAvailable(@Param("customerId") String customerId, @Param("orderId") Long orderId,
|
||||||
|
@Param("skuCode") String skuCode, @Param("limit") int limit);
|
||||||
|
|
||||||
|
@Update("UPDATE ll_dropship_carton SET status=#{toStatus},version=version+1,updated_at=NOW(3) " +
|
||||||
|
"WHERE customer_id=#{customerId} AND id=#{cartonId} AND status=#{fromStatus}")
|
||||||
|
int changeCartonStatus(@Param("customerId") String customerId, @Param("cartonId") Long cartonId,
|
||||||
|
@Param("fromStatus") String fromStatus, @Param("toStatus") String toStatus);
|
||||||
|
|
||||||
|
@Select("SELECT * FROM ll_dropship_carton WHERE customer_id=#{customerId} AND id=#{cartonId} LIMIT 1 FOR UPDATE")
|
||||||
|
DropshipCarton lockCarton(@Param("customerId") String customerId, @Param("cartonId") Long cartonId);
|
||||||
|
|
||||||
|
@Select("SELECT * FROM ll_dropship_sku_inventory WHERE customer_id=#{customerId} AND id=#{id} LIMIT 1 FOR UPDATE")
|
||||||
|
DropshipSkuInventory lockSkuInventory(@Param("customerId") String customerId, @Param("id") Long id);
|
||||||
|
|
||||||
|
@Select("SELECT * FROM ll_dropship_sku_inventory WHERE customer_id=#{customerId} AND order_id=#{orderId} " +
|
||||||
|
"AND profile_id=#{profileId} AND sku_code=#{skuCode} LIMIT 1 FOR UPDATE")
|
||||||
|
DropshipSkuInventory lockSkuInventoryDimension(@Param("customerId") String customerId,
|
||||||
|
@Param("orderId") Long orderId,
|
||||||
|
@Param("profileId") Long profileId,
|
||||||
|
@Param("skuCode") String skuCode);
|
||||||
|
|
||||||
|
@Update("UPDATE ll_dropship_sku_inventory SET package_count=package_count-#{packages},updated_at=NOW(3) " +
|
||||||
|
"WHERE customer_id=#{customerId} AND id=#{id} AND package_count>=#{packages}")
|
||||||
|
int deductSkuInventory(@Param("customerId") String customerId, @Param("id") Long id,
|
||||||
|
@Param("packages") int packages);
|
||||||
|
|
||||||
|
@Select("SELECT package_count FROM ll_dropship_sku_inventory WHERE customer_id=#{customerId} AND id=#{id}")
|
||||||
|
Long skuPackages(@Param("customerId") String customerId, @Param("id") Long id);
|
||||||
|
}
|
||||||
+16
@@ -0,0 +1,16 @@
|
|||||||
|
package com.baiyue.lastleg.discard.mapper;
|
||||||
|
|
||||||
|
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
|
||||||
|
import com.baiyue.lastleg.discard.entity.CargoDiscardItem;
|
||||||
|
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 CargoDiscardItemMapper extends BaseMapper<CargoDiscardItem> {
|
||||||
|
@Select("SELECT * FROM ll_cargo_discard_item WHERE customer_id=#{customerId} AND application_id=#{applicationId} ORDER BY inbound_code,sku_code,profile_id")
|
||||||
|
List<CargoDiscardItem> listApplication(@Param("customerId") String customerId,
|
||||||
|
@Param("applicationId") Long applicationId);
|
||||||
|
}
|
||||||
+16
@@ -0,0 +1,16 @@
|
|||||||
|
package com.baiyue.lastleg.discard.mapper;
|
||||||
|
|
||||||
|
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
|
||||||
|
import com.baiyue.lastleg.discard.entity.CargoDiscardStatusHistory;
|
||||||
|
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 CargoDiscardStatusHistoryMapper extends BaseMapper<CargoDiscardStatusHistory> {
|
||||||
|
@Select("SELECT * FROM ll_cargo_discard_status_history WHERE customer_id=#{customerId} AND application_id=#{applicationId} ORDER BY created_at,id")
|
||||||
|
List<CargoDiscardStatusHistory> listApplication(@Param("customerId") String customerId,
|
||||||
|
@Param("applicationId") Long applicationId);
|
||||||
|
}
|
||||||
+20
@@ -0,0 +1,20 @@
|
|||||||
|
package com.baiyue.lastleg.discard.model;
|
||||||
|
|
||||||
|
import lombok.Data;
|
||||||
|
|
||||||
|
import java.time.LocalDateTime;
|
||||||
|
|
||||||
|
@Data
|
||||||
|
public class CargoDiscardCandidateRow {
|
||||||
|
private Long orderId;
|
||||||
|
private String inboundCode;
|
||||||
|
private String inboundOrderNo;
|
||||||
|
private String overseasWarehouseId;
|
||||||
|
private String overseasWarehouseName;
|
||||||
|
private LocalDateTime createdAt;
|
||||||
|
private Long profileId;
|
||||||
|
private Long skuInventoryId;
|
||||||
|
private String skuCode;
|
||||||
|
private String productName;
|
||||||
|
private Long availablePackages;
|
||||||
|
}
|
||||||
+98
@@ -0,0 +1,98 @@
|
|||||||
|
package com.baiyue.lastleg.discard.service;
|
||||||
|
|
||||||
|
import com.baomidou.mybatisplus.core.toolkit.IdWorker;
|
||||||
|
import com.baiyue.lastleg.discard.entity.CargoDiscardApplication;
|
||||||
|
import com.baiyue.lastleg.discard.entity.CargoDiscardCarton;
|
||||||
|
import com.baiyue.lastleg.discard.entity.CargoDiscardItem;
|
||||||
|
import com.baiyue.lastleg.discard.mapper.CargoDiscardInventoryMapper;
|
||||||
|
import com.baiyue.lastleg.dropship.entity.DropshipCarton;
|
||||||
|
import com.baiyue.lastleg.dropship.entity.DropshipCartonLedger;
|
||||||
|
import com.baiyue.lastleg.dropship.entity.DropshipSkuInventory;
|
||||||
|
import com.baiyue.lastleg.dropship.mapper.DropshipCartonLedgerMapper;
|
||||||
|
import jakarta.annotation.Resource;
|
||||||
|
import org.springframework.http.HttpStatus;
|
||||||
|
import org.springframework.stereotype.Service;
|
||||||
|
import org.springframework.transaction.annotation.Propagation;
|
||||||
|
import org.springframework.transaction.annotation.Transactional;
|
||||||
|
import org.springframework.web.server.ResponseStatusException;
|
||||||
|
|
||||||
|
import java.time.LocalDateTime;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
|
/** Final inventory mutation for a warehouse-approved cargo discard. */
|
||||||
|
@Service
|
||||||
|
public class CargoDiscardInventoryAdjustmentService {
|
||||||
|
@Resource private CargoDiscardInventoryMapper inventory;
|
||||||
|
@Resource private DropshipCartonLedgerMapper cartonLedgers;
|
||||||
|
|
||||||
|
@Transactional(propagation = Propagation.MANDATORY)
|
||||||
|
public AdjustmentResult discard(String customerId, CargoDiscardApplication application,
|
||||||
|
List<CargoDiscardItem> items,
|
||||||
|
Map<Long, List<CargoDiscardCarton>> linksByItem,
|
||||||
|
String operatorId, LocalDateTime occurredAt) {
|
||||||
|
long beforePackages = 0;
|
||||||
|
long discardedPackages = 0;
|
||||||
|
long afterPackages = 0;
|
||||||
|
for (CargoDiscardItem item : items) {
|
||||||
|
DropshipSkuInventory sku = inventory.lockSkuInventory(customerId, item.getSkuInventoryId());
|
||||||
|
if (sku == null) throw conflict("SKU inventory is missing during discard approval: " + item.getSkuCode());
|
||||||
|
long before = value(sku.getPackageCount());
|
||||||
|
int discarded = item.getDiscardPackages();
|
||||||
|
if (before < discarded || inventory.deductSkuInventory(customerId, sku.getId(), discarded) != 1) {
|
||||||
|
throw conflict("SKU inventory is insufficient during discard approval: " + item.getSkuCode());
|
||||||
|
}
|
||||||
|
List<CargoDiscardCarton> links = linksByItem.getOrDefault(item.getId(), List.of());
|
||||||
|
if (links.size() != discarded) throw conflict("Reserved discard cartons are incomplete");
|
||||||
|
for (CargoDiscardCarton link : links) {
|
||||||
|
DropshipCarton carton = inventory.lockCarton(customerId, link.getCartonId());
|
||||||
|
if (carton == null || inventory.changeCartonStatus(customerId, carton.getId(),
|
||||||
|
"DISCARD_PENDING", "DISCARDED") != 1) throw conflict("Reserved cargo status changed before approval");
|
||||||
|
cartonLedgers.insert(discardFact(customerId, application, item, carton, operatorId, occurredAt));
|
||||||
|
}
|
||||||
|
Long persistedAfter = inventory.skuPackages(customerId, sku.getId());
|
||||||
|
long expectedAfter = before - discarded;
|
||||||
|
if (persistedAfter == null || persistedAfter != expectedAfter) {
|
||||||
|
throw conflict("Cargo discard package conservation check failed for SKU " + item.getSkuCode());
|
||||||
|
}
|
||||||
|
beforePackages = Math.addExact(beforePackages, before);
|
||||||
|
discardedPackages = Math.addExact(discardedPackages, discarded);
|
||||||
|
afterPackages = Math.addExact(afterPackages, persistedAfter);
|
||||||
|
}
|
||||||
|
if (beforePackages - discardedPackages != afterPackages
|
||||||
|
|| discardedPackages != application.getTotalPackages()) {
|
||||||
|
throw conflict("Cargo discard application package conservation check failed");
|
||||||
|
}
|
||||||
|
return new AdjustmentResult(beforePackages, discardedPackages, afterPackages);
|
||||||
|
}
|
||||||
|
|
||||||
|
private DropshipCartonLedger discardFact(String customerId, CargoDiscardApplication application,
|
||||||
|
CargoDiscardItem item, DropshipCarton carton,
|
||||||
|
String operatorId, LocalDateTime occurredAt) {
|
||||||
|
DropshipCartonLedger fact = new DropshipCartonLedger();
|
||||||
|
fact.setId(IdWorker.getId()); fact.setCustomerId(customerId); fact.setOrderId(item.getOrderId());
|
||||||
|
fact.setCartonId(carton.getId()); fact.setCartonMark(carton.getCartonMark()); fact.setSkuCode(item.getSkuCode());
|
||||||
|
fact.setOperationType("DISCARD"); fact.setBeforePackages(1L); fact.setDeltaPackages(-1L); fact.setAfterPackages(0L);
|
||||||
|
fact.setBusinessOrderNo(application.getDiscardNo());
|
||||||
|
fact.setIdempotencyKey("CARGO_DISCARD:" + application.getId() + ":" + carton.getId());
|
||||||
|
fact.setRemark("Warehouse discard approved by " + operatorId + remarkSuffix(application.getRemark()));
|
||||||
|
fact.setOccurredAt(occurredAt); fact.setCreatedAt(occurredAt);
|
||||||
|
return fact;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static long value(Long value) { return value == null ? 0L : value; }
|
||||||
|
private static String remarkSuffix(String value) {
|
||||||
|
return value == null || value.isBlank() ? "" : "; " + value.trim();
|
||||||
|
}
|
||||||
|
private static ResponseStatusException conflict(String message) {
|
||||||
|
return new ResponseStatusException(HttpStatus.CONFLICT, message);
|
||||||
|
}
|
||||||
|
|
||||||
|
public record AdjustmentResult(long beforePackages, long discardedPackages, long afterPackages) {
|
||||||
|
public String auditRemark(String reviewRemark) {
|
||||||
|
String prefix = reviewRemark == null || reviewRemark.isBlank() ? "" : reviewRemark.trim() + "; ";
|
||||||
|
return prefix + "inventoryBefore=" + beforePackages + ", discarded=" + discardedPackages
|
||||||
|
+ ", inventoryAfter=" + afterPackages;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+303
@@ -0,0 +1,303 @@
|
|||||||
|
package com.baiyue.lastleg.discard.service;
|
||||||
|
|
||||||
|
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
|
||||||
|
import com.baomidou.mybatisplus.core.toolkit.IdWorker;
|
||||||
|
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
|
||||||
|
import com.baiyue.common.core.api.PageResult;
|
||||||
|
import com.baiyue.lastleg.discard.dto.CargoDiscardRequests;
|
||||||
|
import com.baiyue.lastleg.discard.dto.CargoDiscardResponses;
|
||||||
|
import com.baiyue.lastleg.discard.entity.CargoDiscardApplication;
|
||||||
|
import com.baiyue.lastleg.discard.entity.CargoDiscardCarton;
|
||||||
|
import com.baiyue.lastleg.discard.entity.CargoDiscardItem;
|
||||||
|
import com.baiyue.lastleg.discard.entity.CargoDiscardStatusHistory;
|
||||||
|
import com.baiyue.lastleg.discard.mapper.CargoDiscardApplicationMapper;
|
||||||
|
import com.baiyue.lastleg.discard.mapper.CargoDiscardCartonMapper;
|
||||||
|
import com.baiyue.lastleg.discard.mapper.CargoDiscardInventoryMapper;
|
||||||
|
import com.baiyue.lastleg.discard.mapper.CargoDiscardItemMapper;
|
||||||
|
import com.baiyue.lastleg.discard.mapper.CargoDiscardStatusHistoryMapper;
|
||||||
|
import com.baiyue.lastleg.discard.model.CargoDiscardCandidateRow;
|
||||||
|
import com.baiyue.lastleg.dropship.entity.DropshipCarton;
|
||||||
|
import com.baiyue.lastleg.dropship.entity.DropshipSkuInventory;
|
||||||
|
import com.baiyue.lastleg.entity.LastlegOrder;
|
||||||
|
import com.baiyue.lastleg.mapper.LastlegOrderMapper;
|
||||||
|
import com.fasterxml.jackson.core.JsonProcessingException;
|
||||||
|
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||||
|
import jakarta.annotation.Resource;
|
||||||
|
import org.springframework.beans.factory.annotation.Value;
|
||||||
|
import org.springframework.http.HttpStatus;
|
||||||
|
import org.springframework.stereotype.Service;
|
||||||
|
import org.springframework.transaction.annotation.Transactional;
|
||||||
|
import org.springframework.util.StringUtils;
|
||||||
|
import org.springframework.web.server.ResponseStatusException;
|
||||||
|
|
||||||
|
import java.math.BigDecimal;
|
||||||
|
import java.security.MessageDigest;
|
||||||
|
import java.security.NoSuchAlgorithmException;
|
||||||
|
import java.time.LocalDateTime;
|
||||||
|
import java.time.format.DateTimeFormatter;
|
||||||
|
import java.util.HashMap;
|
||||||
|
import java.util.LinkedHashMap;
|
||||||
|
import java.util.LinkedHashSet;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
import java.util.Set;
|
||||||
|
|
||||||
|
@Service
|
||||||
|
public class CargoDiscardService {
|
||||||
|
public static final String APPROVAL_PERMISSION = "lastleg:cargo-discard:approve";
|
||||||
|
public static final String PENDING_REVIEW = "PENDING_REVIEW";
|
||||||
|
public static final String APPROVED = "APPROVED";
|
||||||
|
public static final String REJECTED = "REJECTED";
|
||||||
|
private static final Set<String> STATUSES = Set.of(PENDING_REVIEW, APPROVED, REJECTED);
|
||||||
|
|
||||||
|
@Resource private CargoDiscardApplicationMapper applications;
|
||||||
|
@Resource private CargoDiscardItemMapper items;
|
||||||
|
@Resource private CargoDiscardCartonMapper discardCartons;
|
||||||
|
@Resource private CargoDiscardStatusHistoryMapper histories;
|
||||||
|
@Resource private CargoDiscardInventoryMapper inventory;
|
||||||
|
@Resource private CargoDiscardInventoryAdjustmentService adjustments;
|
||||||
|
@Resource private LastlegOrderMapper orders;
|
||||||
|
@Resource private ObjectMapper objectMapper;
|
||||||
|
@Value("${baiyue.lastleg.cargo-discard.fee-per-package-usd:5.00}")
|
||||||
|
private BigDecimal feePerPackage = new BigDecimal("5.00");
|
||||||
|
|
||||||
|
public List<CargoDiscardResponses.Candidate> candidates(String customerId, String inboundCode) {
|
||||||
|
List<CargoDiscardCandidateRow> rows = inventory.candidates(customerId, trim(inboundCode));
|
||||||
|
Map<Long, CandidateAccumulator> grouped = new LinkedHashMap<>();
|
||||||
|
for (CargoDiscardCandidateRow row : rows) {
|
||||||
|
CandidateAccumulator value = grouped.computeIfAbsent(row.getOrderId(), ignored -> new CandidateAccumulator(row));
|
||||||
|
value.add(row);
|
||||||
|
}
|
||||||
|
return grouped.values().stream().map(CandidateAccumulator::response).toList();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Transactional
|
||||||
|
public CargoDiscardResponses.Detail create(String customerId, String operatorId,
|
||||||
|
CargoDiscardRequests.Create request) {
|
||||||
|
String key = request.idempotencyKey().trim();
|
||||||
|
String requestHash = hash(request);
|
||||||
|
CargoDiscardApplication replay = applications.findKey(customerId, key);
|
||||||
|
if (replay != null) {
|
||||||
|
if (!requestHash.equals(replay.getRequestHash())) {
|
||||||
|
throw conflict("Idempotency key was reused with different cargo discard data");
|
||||||
|
}
|
||||||
|
return detail(customerId, replay.getDiscardNo());
|
||||||
|
}
|
||||||
|
Set<String> requestedDimensions = new LinkedHashSet<>();
|
||||||
|
Map<Long, LastlegOrder> orderCache = new HashMap<>();
|
||||||
|
String warehouseId = null;
|
||||||
|
String warehouseName = null;
|
||||||
|
int total = 0;
|
||||||
|
for (CargoDiscardRequests.Item input : request.items()) {
|
||||||
|
String dimension = input.orderId() + ":" + input.skuCode().trim();
|
||||||
|
if (!requestedDimensions.add(dimension)) throw badRequest("Duplicate order/SKU discard item: " + dimension);
|
||||||
|
LastlegOrder order = requireDropshipOrder(customerId, input.orderId());
|
||||||
|
orderCache.put(order.getId(), order);
|
||||||
|
if (warehouseId == null) {
|
||||||
|
warehouseId = required(order.getOverseasWarehouseId(), "Order has no operating overseas warehouse");
|
||||||
|
warehouseName = trim(order.getOverseasWarehouseName());
|
||||||
|
} else if (!warehouseId.equals(order.getOverseasWarehouseId())) {
|
||||||
|
throw conflict("One cargo discard application cannot span operating warehouses");
|
||||||
|
}
|
||||||
|
total = Math.addExact(total, input.discardPackages());
|
||||||
|
}
|
||||||
|
|
||||||
|
LocalDateTime now = LocalDateTime.now();
|
||||||
|
CargoDiscardApplication application = new CargoDiscardApplication();
|
||||||
|
application.setId(IdWorker.getId()); application.setCustomerId(customerId);
|
||||||
|
application.setDiscardNo("FH" + now.format(DateTimeFormatter.BASIC_ISO_DATE) + application.getId());
|
||||||
|
application.setOverseasWarehouseId(warehouseId); application.setOverseasWarehouseName(warehouseName);
|
||||||
|
application.setInboundCodes(orderCache.values().stream().map(LastlegOrder::getInboundCode)
|
||||||
|
.distinct().sorted().collect(java.util.stream.Collectors.joining(",")));
|
||||||
|
application.setTotalPackages(total); application.setFeePerPackage(feePerPackage);
|
||||||
|
application.setTotalFee(feePerPackage.multiply(BigDecimal.valueOf(total)));
|
||||||
|
application.setCurrency("USD"); application.setStatus(PENDING_REVIEW);
|
||||||
|
application.setRemark(trim(request.remark())); application.setIdempotencyKey(key);
|
||||||
|
application.setRequestHash(requestHash); application.setCreatedBy(operatorId);
|
||||||
|
application.setCreatedAt(now); application.setUpdatedAt(now);
|
||||||
|
applications.insert(application);
|
||||||
|
|
||||||
|
for (CargoDiscardRequests.Item input : request.items()) {
|
||||||
|
reserve(customerId, application, orderCache.get(input.orderId()), input, now);
|
||||||
|
}
|
||||||
|
history(customerId, application.getId(), null, PENDING_REVIEW, operatorId, request.remark(), now);
|
||||||
|
return detail(customerId, application.getDiscardNo());
|
||||||
|
}
|
||||||
|
|
||||||
|
public PageResult<CargoDiscardResponses.Summary> page(String customerId, CargoDiscardRequests.Query query) {
|
||||||
|
validateQuery(query);
|
||||||
|
LambdaQueryWrapper<CargoDiscardApplication> wrapper = new LambdaQueryWrapper<CargoDiscardApplication>()
|
||||||
|
.eq(CargoDiscardApplication::getCustomerId, customerId)
|
||||||
|
.like(StringUtils.hasText(query.discardNo()), CargoDiscardApplication::getDiscardNo, query.discardNo())
|
||||||
|
.like(StringUtils.hasText(query.inboundCode()), CargoDiscardApplication::getInboundCodes, query.inboundCode())
|
||||||
|
.eq(StringUtils.hasText(query.overseasWarehouseId()), CargoDiscardApplication::getOverseasWarehouseId,
|
||||||
|
query.overseasWarehouseId())
|
||||||
|
.eq(StringUtils.hasText(query.status()), CargoDiscardApplication::getStatus, query.status())
|
||||||
|
.ge(query.createdFrom() != null, CargoDiscardApplication::getCreatedAt, query.createdFrom())
|
||||||
|
.le(query.createdTo() != null, CargoDiscardApplication::getCreatedAt, query.createdTo())
|
||||||
|
.orderByDesc(CargoDiscardApplication::getCreatedAt).orderByDesc(CargoDiscardApplication::getId);
|
||||||
|
Page<CargoDiscardApplication> result = applications.selectPage(Page.of(query.page(), query.size()), wrapper);
|
||||||
|
List<CargoDiscardResponses.Summary> records = result.getRecords().stream().map(this::summary).toList();
|
||||||
|
return new PageResult<>(records, query.page(), query.size(), result.getTotal(), result.getPages());
|
||||||
|
}
|
||||||
|
|
||||||
|
public CargoDiscardResponses.Detail detail(String customerId, String discardNo) {
|
||||||
|
CargoDiscardApplication application = applications.findNo(customerId, discardNo);
|
||||||
|
if (application == null) throw notFound("Cargo discard application not found");
|
||||||
|
List<CargoDiscardResponses.Item> itemViews = items.listApplication(customerId, application.getId()).stream()
|
||||||
|
.map(value -> new CargoDiscardResponses.Item(value.getId(), value.getOrderId(), value.getInboundCode(),
|
||||||
|
value.getSkuCode(), value.getProductName(), value.getAvailablePackagesSnapshot(), value.getDiscardPackages()))
|
||||||
|
.toList();
|
||||||
|
List<CargoDiscardResponses.History> historyViews = histories.listApplication(customerId, application.getId()).stream()
|
||||||
|
.map(value -> new CargoDiscardResponses.History(value.getFromStatus(), value.getToStatus(),
|
||||||
|
value.getOperatorId(), value.getRemark(), value.getCreatedAt())).toList();
|
||||||
|
return new CargoDiscardResponses.Detail(summary(application), application.getFeePerPackage(),
|
||||||
|
application.getRemark(), application.getReviewedBy(), application.getReviewRemark(), itemViews, historyViews);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Transactional
|
||||||
|
public CargoDiscardResponses.Detail review(String customerId, String discardNo, String operatorId,
|
||||||
|
CargoDiscardRequests.Review request) {
|
||||||
|
String decision = request.decision().trim().toUpperCase();
|
||||||
|
String next = switch (decision) {
|
||||||
|
case "APPROVE", APPROVED -> APPROVED;
|
||||||
|
case "REJECT", REJECTED -> REJECTED;
|
||||||
|
default -> throw badRequest("Review decision must be APPROVE or REJECT");
|
||||||
|
};
|
||||||
|
CargoDiscardApplication application = applications.lockNo(customerId, discardNo);
|
||||||
|
if (application == null) throw notFound("Cargo discard application not found");
|
||||||
|
if (next.equals(application.getStatus())) return detail(customerId, discardNo);
|
||||||
|
if (!PENDING_REVIEW.equals(application.getStatus())) throw conflict("Cargo discard application was already reviewed");
|
||||||
|
|
||||||
|
List<CargoDiscardItem> applicationItems = items.listApplication(customerId, application.getId());
|
||||||
|
List<CargoDiscardCarton> links = discardCartons.listApplication(customerId, application.getId());
|
||||||
|
Map<Long, List<CargoDiscardCarton>> linksByItem = links.stream()
|
||||||
|
.collect(java.util.stream.Collectors.groupingBy(CargoDiscardCarton::getItemId));
|
||||||
|
String historyRemark = request.remark();
|
||||||
|
if (APPROVED.equals(next)) {
|
||||||
|
CargoDiscardInventoryAdjustmentService.AdjustmentResult result = adjustments.discard(
|
||||||
|
customerId, application, applicationItems, linksByItem, operatorId, now());
|
||||||
|
historyRemark = result.auditRemark(request.remark());
|
||||||
|
} else {
|
||||||
|
release(customerId, links);
|
||||||
|
}
|
||||||
|
|
||||||
|
LocalDateTime reviewedAt = now();
|
||||||
|
application.setStatus(next); application.setReviewedBy(operatorId);
|
||||||
|
application.setReviewRemark(trim(request.remark())); application.setReviewedAt(reviewedAt);
|
||||||
|
application.setUpdatedAt(reviewedAt); applications.updateById(application);
|
||||||
|
history(customerId, application.getId(), PENDING_REVIEW, next, operatorId, historyRemark, reviewedAt);
|
||||||
|
return detail(customerId, discardNo);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void reserve(String customerId, CargoDiscardApplication application, LastlegOrder order,
|
||||||
|
CargoDiscardRequests.Item input, LocalDateTime now) {
|
||||||
|
String skuCode = input.skuCode().trim();
|
||||||
|
if (inventory.countAvailable(customerId, order.getId(), skuCode) < input.discardPackages()) {
|
||||||
|
throw conflict("Insufficient available packages for SKU " + skuCode + " on inbound " + order.getInboundCode());
|
||||||
|
}
|
||||||
|
List<DropshipCarton> selected = inventory.lockAvailable(customerId, order.getId(), skuCode, input.discardPackages());
|
||||||
|
if (selected.size() != input.discardPackages()) throw conflict("Available cargo changed; retry the discard request");
|
||||||
|
Map<Long, List<DropshipCarton>> byProfile = selected.stream()
|
||||||
|
.collect(java.util.stream.Collectors.groupingBy(DropshipCarton::getProfileId, LinkedHashMap::new,
|
||||||
|
java.util.stream.Collectors.toList()));
|
||||||
|
for (Map.Entry<Long, List<DropshipCarton>> entry : byProfile.entrySet()) {
|
||||||
|
DropshipSkuInventory sku = inventory.lockSkuInventoryDimension(customerId, order.getId(), entry.getKey(), skuCode);
|
||||||
|
if (sku == null) throw conflict("SKU inventory disappeared while reserving cargo");
|
||||||
|
CargoDiscardItem item = new CargoDiscardItem();
|
||||||
|
item.setId(IdWorker.getId()); item.setApplicationId(application.getId()); item.setCustomerId(customerId);
|
||||||
|
item.setOrderId(order.getId()); item.setInboundCode(order.getInboundCode()); item.setProfileId(entry.getKey());
|
||||||
|
item.setSkuInventoryId(sku.getId()); item.setSkuCode(skuCode); item.setProductName(sku.getProductName());
|
||||||
|
item.setAvailablePackagesSnapshot(Math.toIntExact(inventory.countAvailableProfile(
|
||||||
|
customerId, order.getId(), entry.getKey(), skuCode)));
|
||||||
|
item.setDiscardPackages(entry.getValue().size()); item.setCreatedAt(now); items.insert(item);
|
||||||
|
for (DropshipCarton carton : entry.getValue()) {
|
||||||
|
if (inventory.changeCartonStatus(customerId, carton.getId(), "AVAILABLE", "DISCARD_PENDING") != 1) {
|
||||||
|
throw conflict("Cargo was reserved concurrently; retry the discard request");
|
||||||
|
}
|
||||||
|
CargoDiscardCarton link = new CargoDiscardCarton();
|
||||||
|
link.setId(IdWorker.getId()); link.setApplicationId(application.getId()); link.setItemId(item.getId());
|
||||||
|
link.setCustomerId(customerId); link.setCartonId(carton.getId()); link.setCartonMark(carton.getCartonMark());
|
||||||
|
link.setCreatedAt(now); discardCartons.insert(link);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void release(String customerId, List<CargoDiscardCarton> links) {
|
||||||
|
for (CargoDiscardCarton link : links) {
|
||||||
|
if (inventory.changeCartonStatus(customerId, link.getCartonId(), "DISCARD_PENDING", "AVAILABLE") != 1) {
|
||||||
|
throw conflict("Reserved cargo status changed before rejection");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private LastlegOrder requireDropshipOrder(String customerId, Long orderId) {
|
||||||
|
LastlegOrder order = orders.selectById(orderId);
|
||||||
|
if (order == null || !customerId.equals(order.getCustomerId())) throw notFound("Inbound order not found");
|
||||||
|
if (!"ONE_CLICK_DROPSHIP".equals(order.getOrderType())) {
|
||||||
|
throw conflict("Cargo discard currently requires SKU/carton inventory from a one-click dropship order");
|
||||||
|
}
|
||||||
|
return order;
|
||||||
|
}
|
||||||
|
|
||||||
|
private CargoDiscardResponses.Summary summary(CargoDiscardApplication value) {
|
||||||
|
return new CargoDiscardResponses.Summary(value.getId(), value.getDiscardNo(), value.getInboundCodes(),
|
||||||
|
value.getOverseasWarehouseId(), value.getOverseasWarehouseName(), value.getTotalPackages(),
|
||||||
|
value.getTotalFee(), value.getCurrency(), value.getStatus(), value.getCreatedBy(), value.getCreatedAt(),
|
||||||
|
value.getReviewedAt());
|
||||||
|
}
|
||||||
|
|
||||||
|
private void history(String customerId, Long applicationId, String from, String to, String operatorId,
|
||||||
|
String remark, LocalDateTime createdAt) {
|
||||||
|
CargoDiscardStatusHistory value = new CargoDiscardStatusHistory();
|
||||||
|
value.setId(IdWorker.getId()); value.setApplicationId(applicationId); value.setCustomerId(customerId);
|
||||||
|
value.setFromStatus(from); value.setToStatus(to); value.setOperatorId(operatorId);
|
||||||
|
value.setRemark(trim(remark)); value.setCreatedAt(createdAt); histories.insert(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void validateQuery(CargoDiscardRequests.Query query) {
|
||||||
|
if (StringUtils.hasText(query.status()) && !STATUSES.contains(query.status())) throw badRequest("Unsupported discard status");
|
||||||
|
if (query.createdFrom() != null && query.createdTo() != null && query.createdFrom().isAfter(query.createdTo())) {
|
||||||
|
throw badRequest("createdFrom must not be after createdTo");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private String hash(Object value) {
|
||||||
|
try {
|
||||||
|
byte[] json = objectMapper.writeValueAsBytes(value);
|
||||||
|
return java.util.HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256").digest(json));
|
||||||
|
} catch (JsonProcessingException | NoSuchAlgorithmException exception) {
|
||||||
|
throw new IllegalStateException("Cannot hash cargo discard request", exception);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static LocalDateTime now() { return LocalDateTime.now(); }
|
||||||
|
private static String trim(String value) { return value == null || value.isBlank() ? null : value.trim(); }
|
||||||
|
private static String required(String value, String message) { String result = trim(value); if (result == null) throw conflict(message); return result; }
|
||||||
|
private static ResponseStatusException badRequest(String message) { return new ResponseStatusException(HttpStatus.BAD_REQUEST, message); }
|
||||||
|
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); }
|
||||||
|
|
||||||
|
private static final class CandidateAccumulator {
|
||||||
|
private final CargoDiscardCandidateRow first;
|
||||||
|
private final Map<String, CandidateSkuAccumulator> skus = new LinkedHashMap<>();
|
||||||
|
private CandidateAccumulator(CargoDiscardCandidateRow first) { this.first = first; }
|
||||||
|
private void add(CargoDiscardCandidateRow row) {
|
||||||
|
skus.computeIfAbsent(row.getSkuCode(), ignored -> new CandidateSkuAccumulator(row.getSkuCode(), row.getProductName()))
|
||||||
|
.packages += row.getAvailablePackages() == null ? 0 : row.getAvailablePackages();
|
||||||
|
}
|
||||||
|
private CargoDiscardResponses.Candidate response() {
|
||||||
|
List<CargoDiscardResponses.CandidateSku> skuViews = skus.values().stream()
|
||||||
|
.map(value -> new CargoDiscardResponses.CandidateSku(value.skuCode, value.productName, value.packages)).toList();
|
||||||
|
long packages = skuViews.stream().mapToLong(CargoDiscardResponses.CandidateSku::availablePackages).sum();
|
||||||
|
return new CargoDiscardResponses.Candidate(first.getOrderId(), first.getInboundCode(), first.getInboundOrderNo(),
|
||||||
|
first.getOverseasWarehouseId(), first.getOverseasWarehouseName(), first.getCreatedAt(), packages, skuViews);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
private static final class CandidateSkuAccumulator {
|
||||||
|
private final String skuCode;
|
||||||
|
private final String productName;
|
||||||
|
private long packages;
|
||||||
|
private CandidateSkuAccumulator(String skuCode, String productName) { this.skuCode = skuCode; this.productName = productName; }
|
||||||
|
}
|
||||||
|
}
|
||||||
+9
@@ -0,0 +1,9 @@
|
|||||||
|
package com.baiyue.lastleg.dispatch;
|
||||||
|
|
||||||
|
import org.mybatis.spring.annotation.MapperScan;
|
||||||
|
import org.springframework.context.annotation.Configuration;
|
||||||
|
|
||||||
|
@Configuration
|
||||||
|
@MapperScan("com.baiyue.lastleg.dispatch.mapper")
|
||||||
|
public class DispatchMapperConfiguration {
|
||||||
|
}
|
||||||
+134
@@ -0,0 +1,134 @@
|
|||||||
|
package com.baiyue.lastleg.dispatch.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.dispatch.dto.DispatchRequests;
|
||||||
|
import com.baiyue.lastleg.dispatch.dto.DispatchResponse;
|
||||||
|
import com.baiyue.lastleg.dispatch.model.DispatchPalletCandidate;
|
||||||
|
import com.baiyue.lastleg.dispatch.pda.dto.DispatchPdaRequests;
|
||||||
|
import com.baiyue.lastleg.dispatch.pda.dto.DispatchPdaResponses;
|
||||||
|
import com.baiyue.lastleg.dispatch.pda.service.DispatchPdaWorkflowService;
|
||||||
|
import com.baiyue.lastleg.dispatch.pda.controller.DispatchPdaController;
|
||||||
|
import com.baiyue.lastleg.dispatch.service.DispatchService;
|
||||||
|
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.http.HttpStatus;
|
||||||
|
import org.springframework.security.core.annotation.AuthenticationPrincipal;
|
||||||
|
import org.springframework.web.bind.annotation.DeleteMapping;
|
||||||
|
import org.springframework.web.bind.annotation.GetMapping;
|
||||||
|
import org.springframework.web.bind.annotation.PathVariable;
|
||||||
|
import org.springframework.web.bind.annotation.PostMapping;
|
||||||
|
import org.springframework.web.bind.annotation.PutMapping;
|
||||||
|
import org.springframework.web.bind.annotation.RequestBody;
|
||||||
|
import org.springframework.web.bind.annotation.RequestMapping;
|
||||||
|
import org.springframework.web.bind.annotation.RequestParam;
|
||||||
|
import org.springframework.web.bind.annotation.RestController;
|
||||||
|
import org.springframework.web.server.ResponseStatusException;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
@RestController
|
||||||
|
@RequestMapping("/api/platform/v1/last-leg/dispatches")
|
||||||
|
@Tag(name = "Tail-leg vehicle dispatch")
|
||||||
|
public class DispatchController {
|
||||||
|
@Resource private DispatchService service;
|
||||||
|
@Resource private DispatchPdaWorkflowService pdaWorkflow;
|
||||||
|
|
||||||
|
@GetMapping("/candidates")
|
||||||
|
@Operation(summary = "List unlocked planned or received pallets for intelligent pre-dispatch")
|
||||||
|
public ApiResponse<List<DispatchPalletCandidate>> candidates(
|
||||||
|
@AuthenticationPrincipal AuthenticatedUser user,
|
||||||
|
@RequestParam(required = false) String customerId,
|
||||||
|
@RequestParam(required = false) String orderNo,
|
||||||
|
@RequestParam(required = false) String fbaWarehouseCode,
|
||||||
|
@RequestParam(required = false) String sourceStage) {
|
||||||
|
return ApiResponse.success(service.candidates(scope(user, customerId), orderNo, fbaWarehouseCode, sourceStage));
|
||||||
|
}
|
||||||
|
|
||||||
|
@PostMapping("/pre-arrange")
|
||||||
|
@Operation(summary = "Create a pending-departure dispatch and lock all selected pallets")
|
||||||
|
public ApiResponse<DispatchResponse> preArrange(
|
||||||
|
@AuthenticationPrincipal AuthenticatedUser user,
|
||||||
|
@RequestParam(required = false) String customerId,
|
||||||
|
@Valid @RequestBody DispatchRequests.PreArrange request) {
|
||||||
|
String scopedTenant = scope(user, customerId);
|
||||||
|
String operatorId = user.userId() == null ? user.username() : String.valueOf(user.userId());
|
||||||
|
return ApiResponse.success(service.preArrange(scopedTenant, operatorId, request));
|
||||||
|
}
|
||||||
|
|
||||||
|
@GetMapping
|
||||||
|
@Operation(summary = "Page dispatch orders")
|
||||||
|
public ApiResponse<PageResult<DispatchResponse>> page(
|
||||||
|
@AuthenticationPrincipal AuthenticatedUser user,
|
||||||
|
@RequestParam(required = false) String customerId,
|
||||||
|
@Valid DispatchRequests.Query query) {
|
||||||
|
return ApiResponse.success(service.page(scope(user, customerId), query));
|
||||||
|
}
|
||||||
|
|
||||||
|
@GetMapping("/{dispatchNo}")
|
||||||
|
@Operation(summary = "Get dispatch details and all pallet destinations")
|
||||||
|
public ApiResponse<DispatchResponse> detail(
|
||||||
|
@AuthenticationPrincipal AuthenticatedUser user,
|
||||||
|
@RequestParam(required = false) String customerId,
|
||||||
|
@PathVariable String dispatchNo) {
|
||||||
|
return ApiResponse.success(service.detail(scope(user, customerId), dispatchNo));
|
||||||
|
}
|
||||||
|
|
||||||
|
@PutMapping("/{dispatchNo}")
|
||||||
|
@Operation(summary = "Edit vehicle, driver, appointment, route and departure data")
|
||||||
|
public ApiResponse<DispatchResponse> update(
|
||||||
|
@AuthenticationPrincipal AuthenticatedUser user,
|
||||||
|
@RequestParam(required = false) String customerId,
|
||||||
|
@PathVariable String dispatchNo,
|
||||||
|
@Valid @RequestBody DispatchRequests.Update request) {
|
||||||
|
return ApiResponse.success(service.update(scope(user, customerId), dispatchNo, request));
|
||||||
|
}
|
||||||
|
|
||||||
|
@DeleteMapping("/{dispatchNo}")
|
||||||
|
@Operation(summary = "Cancel a pending dispatch and release its pallet locks")
|
||||||
|
public ApiResponse<Void> cancel(
|
||||||
|
@AuthenticationPrincipal AuthenticatedUser user,
|
||||||
|
@RequestParam(required = false) String customerId,
|
||||||
|
@PathVariable String dispatchNo) {
|
||||||
|
service.cancel(scope(user, customerId), dispatchNo);
|
||||||
|
return ApiResponse.success(null);
|
||||||
|
}
|
||||||
|
|
||||||
|
@PostMapping("/{dispatchNo}/depart")
|
||||||
|
@Operation(summary = "Ship all PDA-picked pallets and mark the dispatch departed")
|
||||||
|
public ApiResponse<DispatchPdaResponses.Departed> depart(
|
||||||
|
@AuthenticationPrincipal AuthenticatedUser user,
|
||||||
|
@RequestParam(required = false) String customerId,
|
||||||
|
@PathVariable String dispatchNo,
|
||||||
|
@Valid @RequestBody DispatchPdaRequests.Depart request) {
|
||||||
|
requirePdaOperate(user);
|
||||||
|
String scopedTenant = scope(user, customerId);
|
||||||
|
String operatorId = user.userId() == null ? user.username() : String.valueOf(user.userId());
|
||||||
|
return ApiResponse.success(pdaWorkflow.depart(scopedTenant, dispatchNo, operatorId, request));
|
||||||
|
}
|
||||||
|
|
||||||
|
private String scope(AuthenticatedUser user, String requestedCustomer) {
|
||||||
|
if (user == null || user.tenantId() == null) {
|
||||||
|
throw new ResponseStatusException(HttpStatus.UNAUTHORIZED);
|
||||||
|
}
|
||||||
|
if ("PLATFORM".equals(user.appCode()) && user.tenantId() == 0L) {
|
||||||
|
if (requestedCustomer == null || requestedCustomer.isBlank() || "0".equals(requestedCustomer)) {
|
||||||
|
throw new ResponseStatusException(HttpStatus.BAD_REQUEST,
|
||||||
|
"Platform request must specify target tenant");
|
||||||
|
}
|
||||||
|
return requestedCustomer;
|
||||||
|
}
|
||||||
|
return String.valueOf(user.tenantId());
|
||||||
|
}
|
||||||
|
|
||||||
|
private void requirePdaOperate(AuthenticatedUser user) {
|
||||||
|
if (user == null || user.tenantId() == null) throw new ResponseStatusException(HttpStatus.UNAUTHORIZED);
|
||||||
|
if (user.permissions() == null || !user.permissions().contains(DispatchPdaController.OPERATE_PERMISSION)) {
|
||||||
|
throw new ResponseStatusException(HttpStatus.FORBIDDEN,
|
||||||
|
DispatchPdaController.OPERATE_PERMISSION + " permission is required");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
package com.baiyue.lastleg.dispatch.dto;
|
||||||
|
|
||||||
|
import jakarta.validation.constraints.Max;
|
||||||
|
import jakarta.validation.constraints.Min;
|
||||||
|
import jakarta.validation.constraints.NotEmpty;
|
||||||
|
import jakarta.validation.constraints.NotNull;
|
||||||
|
import jakarta.validation.constraints.Size;
|
||||||
|
|
||||||
|
import java.time.LocalDateTime;
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
public final class DispatchRequests {
|
||||||
|
private DispatchRequests() {
|
||||||
|
}
|
||||||
|
|
||||||
|
public record Query(@Min(1) long page, @Min(1) @Max(200) long size, String dispatchNo,
|
||||||
|
String vehiclePlate, String status, String fbaAppointmentNo) {
|
||||||
|
public Query { if (page == 0) page = 1; if (size == 0) size = 20; }
|
||||||
|
}
|
||||||
|
|
||||||
|
public record PreArrange(@NotEmpty @Size(max = 500) List<@NotNull Long> planPalletIds,
|
||||||
|
@Size(max = 255) String route, @Size(max = 64) String fbaAppointmentNo,
|
||||||
|
@Size(max = 32) String fbaAppointmentStatus,
|
||||||
|
@Size(max = 32) String vehicleAppointmentStatus) {
|
||||||
|
}
|
||||||
|
|
||||||
|
public record Update(@Size(max = 64) String vehiclePlate, @Size(max = 128) String driverName,
|
||||||
|
@Size(max = 64) String driverPhone, @Size(max = 255) String carrierName,
|
||||||
|
@Size(max = 255) String route, LocalDateTime departAt,
|
||||||
|
@Size(max = 64) String fbaAppointmentNo,
|
||||||
|
@Size(max = 32) String fbaAppointmentStatus,
|
||||||
|
@Size(max = 32) String vehicleAppointmentStatus,
|
||||||
|
LocalDateTime estimatedArrivalAt, @Size(max = 32) String status) {
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
package com.baiyue.lastleg.dispatch.dto;
|
||||||
|
|
||||||
|
import java.math.BigDecimal;
|
||||||
|
import java.time.LocalDateTime;
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
public record DispatchResponse(Long id, String dispatchNo, String vehiclePlate, String driverName,
|
||||||
|
String driverPhone, String carrierName, String orderNos, String route,
|
||||||
|
String overseasWarehouseId, String overseasWarehouseName, LocalDateTime departAt,
|
||||||
|
String fbaAppointmentNo, String fbaAppointmentStatus,
|
||||||
|
String vehicleAppointmentStatus, LocalDateTime estimatedArrivalAt, String status,
|
||||||
|
int itemCount, int palletCount, int packageCount, int pickedPackageCount,
|
||||||
|
int shippedPackageCount, int remainingPackageCount, String partialCompletionReason,
|
||||||
|
LocalDateTime createdAt,
|
||||||
|
List<Item> items) {
|
||||||
|
public record Item(Long id, Long planPalletId, Long warehousePalletId, String palletNo,
|
||||||
|
String overseasWarehouseId, String overseasWarehouseName,
|
||||||
|
String orderNo, String orderRefs, String inboundCode, String destinationWarehouseCode,
|
||||||
|
String destinationWarehouseName, String fbaWarehouseCode, int packageCount,
|
||||||
|
BigDecimal volumeCbm, String sourceStage) {
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
package com.baiyue.lastleg.dispatch.entity;
|
||||||
|
|
||||||
|
import com.baomidou.mybatisplus.annotation.IdType;
|
||||||
|
import com.baomidou.mybatisplus.annotation.TableId;
|
||||||
|
import com.baomidou.mybatisplus.annotation.TableName;
|
||||||
|
import lombok.Data;
|
||||||
|
|
||||||
|
import java.math.BigDecimal;
|
||||||
|
import java.time.LocalDateTime;
|
||||||
|
|
||||||
|
/** Immutable pallet/cargo snapshot selected into a dispatch order. */
|
||||||
|
@Data
|
||||||
|
@TableName("ll_dispatch_item")
|
||||||
|
public class DispatchItem {
|
||||||
|
@TableId(type = IdType.INPUT)
|
||||||
|
private Long id;
|
||||||
|
private Long dispatchOrderId;
|
||||||
|
private String customerId;
|
||||||
|
private Long planId;
|
||||||
|
private Long planPalletId;
|
||||||
|
private Long warehousePalletId;
|
||||||
|
private Long orderId;
|
||||||
|
private String overseasWarehouseId;
|
||||||
|
private String overseasWarehouseName;
|
||||||
|
private String orderNo;
|
||||||
|
private String orderRefs;
|
||||||
|
private String inboundCode;
|
||||||
|
private String palletNo;
|
||||||
|
private String destinationWarehouseId;
|
||||||
|
private String destinationWarehouseCode;
|
||||||
|
private String destinationWarehouseName;
|
||||||
|
private String fbaWarehouseCode;
|
||||||
|
private Integer packageCount;
|
||||||
|
private BigDecimal volumeCbm;
|
||||||
|
/** PLAN for planned pallets; RECEIVED for warehouse/entity pallets. */
|
||||||
|
private String sourceStage;
|
||||||
|
private LocalDateTime createdAt;
|
||||||
|
}
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
package com.baiyue.lastleg.dispatch.entity;
|
||||||
|
|
||||||
|
import com.baomidou.mybatisplus.annotation.IdType;
|
||||||
|
import com.baomidou.mybatisplus.annotation.TableId;
|
||||||
|
import com.baomidou.mybatisplus.annotation.TableName;
|
||||||
|
import lombok.Data;
|
||||||
|
|
||||||
|
import java.time.LocalDateTime;
|
||||||
|
|
||||||
|
/** Header for a pre-dispatched vehicle load. Vehicle information remains editable before departure. */
|
||||||
|
@Data
|
||||||
|
@TableName("ll_dispatch_order")
|
||||||
|
public class DispatchOrder {
|
||||||
|
@TableId(type = IdType.INPUT)
|
||||||
|
private Long id;
|
||||||
|
private String customerId;
|
||||||
|
private String dispatchNo;
|
||||||
|
private String overseasWarehouseId;
|
||||||
|
private String overseasWarehouseName;
|
||||||
|
private String vehiclePlate;
|
||||||
|
private String driverName;
|
||||||
|
private String driverPhone;
|
||||||
|
private String carrierName;
|
||||||
|
private String route;
|
||||||
|
private LocalDateTime departAt;
|
||||||
|
private String fbaAppointmentNo;
|
||||||
|
private String fbaAppointmentStatus;
|
||||||
|
private String vehicleAppointmentStatus;
|
||||||
|
private LocalDateTime estimatedArrivalAt;
|
||||||
|
private String status;
|
||||||
|
private Integer itemCount;
|
||||||
|
private Integer palletCount;
|
||||||
|
private Integer packageCount;
|
||||||
|
private Integer pickedPackageCount;
|
||||||
|
private Integer shippedPackageCount;
|
||||||
|
private String partialCompletionReason;
|
||||||
|
private String createdBy;
|
||||||
|
private LocalDateTime createdAt;
|
||||||
|
private LocalDateTime updatedAt;
|
||||||
|
}
|
||||||
+29
@@ -0,0 +1,29 @@
|
|||||||
|
package com.baiyue.lastleg.dispatch.entity;
|
||||||
|
|
||||||
|
import com.baomidou.mybatisplus.annotation.IdType;
|
||||||
|
import com.baomidou.mybatisplus.annotation.TableId;
|
||||||
|
import com.baomidou.mybatisplus.annotation.TableName;
|
||||||
|
import lombok.Data;
|
||||||
|
|
||||||
|
import java.time.LocalDateTime;
|
||||||
|
|
||||||
|
/** Prevents a source pallet from being added to more than one active dispatch order. */
|
||||||
|
@Data
|
||||||
|
@TableName("ll_dispatch_pallet_lock")
|
||||||
|
public class DispatchPalletLock {
|
||||||
|
@TableId(type = IdType.INPUT)
|
||||||
|
private Long id;
|
||||||
|
private String customerId;
|
||||||
|
private String sourcePalletKey;
|
||||||
|
/** Equals sourcePalletKey while ACTIVE and is null after release; unique index enforces exclusivity. */
|
||||||
|
private String activeKey;
|
||||||
|
private Long planId;
|
||||||
|
private Long planPalletId;
|
||||||
|
private Long warehousePalletId;
|
||||||
|
private Long dispatchOrderId;
|
||||||
|
private String status;
|
||||||
|
private LocalDateTime lockedAt;
|
||||||
|
private LocalDateTime releasedAt;
|
||||||
|
private LocalDateTime createdAt;
|
||||||
|
private LocalDateTime updatedAt;
|
||||||
|
}
|
||||||
+78
@@ -0,0 +1,78 @@
|
|||||||
|
package com.baiyue.lastleg.dispatch.mapper;
|
||||||
|
|
||||||
|
import com.baiyue.lastleg.dispatch.model.DispatchPalletCandidate;
|
||||||
|
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 DispatchCandidateMapper {
|
||||||
|
@Select("<script>" +
|
||||||
|
"SELECT p.plan_id AS planId,p.id AS planPalletId,p.warehouse_pallet_id AS warehousePalletId," +
|
||||||
|
"MIN(l.order_id) AS orderId,MIN(o.overseas_warehouse_id) AS overseasWarehouseId,MIN(o.overseas_warehouse_name) AS overseasWarehouseName,MIN(o.order_no) AS orderNo," +
|
||||||
|
"GROUP_CONCAT(DISTINCT o.order_no ORDER BY o.order_no SEPARATOR ',') AS orderRefs,MIN(l.inbound_code) AS inboundCode," +
|
||||||
|
"p.pallet_no AS palletNo,p.destination_warehouse_id AS destinationWarehouseId," +
|
||||||
|
"p.destination_warehouse_code AS destinationWarehouseCode,p.destination_warehouse_name AS destinationWarehouseName," +
|
||||||
|
"MIN(l.fba_warehouse_code) AS fbaWarehouseCode," +
|
||||||
|
"CASE WHEN p.warehouse_pallet_id IS NOT NULL THEN COALESCE((SELECT SUM(pos.available_packages) " +
|
||||||
|
"FROM ll_inventory_position pos WHERE pos.customer_id=#{customerId} AND pos.pallet_id=p.warehouse_pallet_id),0) " +
|
||||||
|
"ELSE COALESCE(SUM(CASE WHEN a.picked_package_count > 0 THEN a.picked_package_count " +
|
||||||
|
"ELSE a.planned_package_count END),0) END AS packageCount,p.allocated_volume_cbm AS volumeCbm," +
|
||||||
|
"CASE WHEN p.warehouse_pallet_id IS NULL THEN 'PLAN' ELSE 'RECEIVED' END AS sourceStage " +
|
||||||
|
"FROM ll_inbound_plan_pallet p JOIN ll_inbound_plan ip ON ip.id=p.plan_id " +
|
||||||
|
"LEFT JOIN ll_inbound_plan_pallet_allocation a ON a.pallet_id=p.id " +
|
||||||
|
"LEFT JOIN ll_inbound_plan_line l ON l.id=a.line_id " +
|
||||||
|
"LEFT JOIN ll_order o ON o.id=l.order_id " +
|
||||||
|
"WHERE ip.customer_id=#{customerId} " +
|
||||||
|
"<if test='orderNo != null and orderNo != \"\"'> AND o.order_no LIKE CONCAT('%',#{orderNo},'%')</if> " +
|
||||||
|
"<if test='fbaWarehouseCode != null and fbaWarehouseCode != \"\"'> AND l.fba_warehouse_code=#{fbaWarehouseCode}</if> " +
|
||||||
|
"<if test='sourceStage != null and sourceStage != \"\"'> AND " +
|
||||||
|
"CASE WHEN p.warehouse_pallet_id IS NULL THEN 'PLAN' ELSE 'RECEIVED' END=#{sourceStage}</if> " +
|
||||||
|
"AND (p.warehouse_pallet_id IS NULL OR EXISTS (SELECT 1 FROM ll_inventory_position pos " +
|
||||||
|
"WHERE pos.customer_id=#{customerId} AND pos.pallet_id=p.warehouse_pallet_id AND pos.available_packages > 0)) " +
|
||||||
|
"AND NOT EXISTS (SELECT 1 FROM ll_dispatch_pallet_lock d " +
|
||||||
|
"WHERE d.customer_id=ip.customer_id AND d.status='ACTIVE' AND (" +
|
||||||
|
"d.source_pallet_key=CONCAT('PLAN:',p.id) OR (p.warehouse_pallet_id IS NOT NULL AND " +
|
||||||
|
"d.source_pallet_key=CONCAT('RECEIVED:',p.warehouse_pallet_id)))) " +
|
||||||
|
"AND NOT EXISTS (SELECT 1 FROM ll_warehouse_transfer_inventory_allocation ta " +
|
||||||
|
"JOIN ll_inventory_position tp ON tp.id=ta.inventory_position_id " +
|
||||||
|
"WHERE ta.customer_id=#{customerId} AND ta.status='RESERVED' AND tp.pallet_id=p.warehouse_pallet_id) " +
|
||||||
|
"GROUP BY p.plan_id,p.id,p.warehouse_pallet_id,p.pallet_no,p.destination_warehouse_id," +
|
||||||
|
"p.destination_warehouse_code,p.destination_warehouse_name,p.allocated_volume_cbm " +
|
||||||
|
"ORDER BY p.plan_id,p.sequence_no" +
|
||||||
|
"</script>")
|
||||||
|
List<DispatchPalletCandidate> available(@Param("customerId") String customerId,
|
||||||
|
@Param("orderNo") String orderNo,
|
||||||
|
@Param("fbaWarehouseCode") String fbaWarehouseCode,
|
||||||
|
@Param("sourceStage") String sourceStage);
|
||||||
|
|
||||||
|
@Select("<script>" +
|
||||||
|
"SELECT p.plan_id AS planId,p.id AS planPalletId,p.warehouse_pallet_id AS warehousePalletId," +
|
||||||
|
"MIN(l.order_id) AS orderId,MIN(o.overseas_warehouse_id) AS overseasWarehouseId,MIN(o.overseas_warehouse_name) AS overseasWarehouseName,MIN(o.order_no) AS orderNo," +
|
||||||
|
"GROUP_CONCAT(DISTINCT o.order_no ORDER BY o.order_no SEPARATOR ',') AS orderRefs,MIN(l.inbound_code) AS inboundCode," +
|
||||||
|
"p.pallet_no AS palletNo,p.destination_warehouse_id AS destinationWarehouseId," +
|
||||||
|
"p.destination_warehouse_code AS destinationWarehouseCode,p.destination_warehouse_name AS destinationWarehouseName," +
|
||||||
|
"MIN(l.fba_warehouse_code) AS fbaWarehouseCode," +
|
||||||
|
"CASE WHEN p.warehouse_pallet_id IS NOT NULL THEN COALESCE((SELECT SUM(pos.available_packages) " +
|
||||||
|
"FROM ll_inventory_position pos WHERE pos.customer_id=#{customerId} AND pos.pallet_id=p.warehouse_pallet_id),0) " +
|
||||||
|
"ELSE COALESCE(SUM(CASE WHEN a.picked_package_count > 0 THEN a.picked_package_count " +
|
||||||
|
"ELSE a.planned_package_count END),0) END AS packageCount,p.allocated_volume_cbm AS volumeCbm," +
|
||||||
|
"CASE WHEN p.warehouse_pallet_id IS NULL THEN 'PLAN' ELSE 'RECEIVED' END AS sourceStage " +
|
||||||
|
"FROM ll_inbound_plan_pallet p JOIN ll_inbound_plan ip ON ip.id=p.plan_id " +
|
||||||
|
"LEFT JOIN ll_inbound_plan_pallet_allocation a ON a.pallet_id=p.id " +
|
||||||
|
"LEFT JOIN ll_inbound_plan_line l ON l.id=a.line_id " +
|
||||||
|
"LEFT JOIN ll_order o ON o.id=l.order_id " +
|
||||||
|
"WHERE ip.customer_id=#{customerId} AND p.id IN " +
|
||||||
|
"<foreach collection='planPalletIds' item='id' open='(' separator=',' close=')'>#{id}</foreach> " +
|
||||||
|
"AND NOT EXISTS (SELECT 1 FROM ll_warehouse_transfer_inventory_allocation ta " +
|
||||||
|
"JOIN ll_inventory_position tp ON tp.id=ta.inventory_position_id " +
|
||||||
|
"WHERE ta.customer_id=#{customerId} AND ta.status='RESERVED' AND tp.pallet_id=p.warehouse_pallet_id) " +
|
||||||
|
"GROUP BY p.plan_id,p.id,p.warehouse_pallet_id,p.pallet_no,p.destination_warehouse_id," +
|
||||||
|
"p.destination_warehouse_code,p.destination_warehouse_name,p.allocated_volume_cbm " +
|
||||||
|
"ORDER BY p.id FOR UPDATE" +
|
||||||
|
"</script>")
|
||||||
|
List<DispatchPalletCandidate> lockSelected(@Param("customerId") String customerId,
|
||||||
|
@Param("planPalletIds") List<Long> planPalletIds);
|
||||||
|
}
|
||||||
+25
@@ -0,0 +1,25 @@
|
|||||||
|
package com.baiyue.lastleg.dispatch.mapper;
|
||||||
|
|
||||||
|
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
|
||||||
|
import com.baiyue.lastleg.dispatch.entity.DispatchItem;
|
||||||
|
import org.apache.ibatis.annotations.Mapper;
|
||||||
|
import org.apache.ibatis.annotations.Param;
|
||||||
|
import org.apache.ibatis.annotations.Select;
|
||||||
|
import org.apache.ibatis.annotations.Update;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
@Mapper
|
||||||
|
public interface DispatchItemMapper extends BaseMapper<DispatchItem> {
|
||||||
|
@Select("SELECT * FROM ll_dispatch_item WHERE customer_id=#{customerId} AND dispatch_order_id=#{dispatchOrderId} ORDER BY id")
|
||||||
|
List<DispatchItem> findByDispatchOrder(@Param("customerId") String customerId,
|
||||||
|
@Param("dispatchOrderId") Long dispatchOrderId);
|
||||||
|
|
||||||
|
@Update("UPDATE ll_dispatch_item i JOIN ll_inbound_plan_pallet p ON p.id=i.plan_pallet_id " +
|
||||||
|
"JOIN ll_inbound_plan ip ON ip.id=p.plan_id AND ip.customer_id=i.customer_id " +
|
||||||
|
"SET i.warehouse_pallet_id=p.warehouse_pallet_id,i.source_stage='RECEIVED' " +
|
||||||
|
"WHERE i.customer_id=#{customerId} AND i.dispatch_order_id=#{dispatchOrderId} " +
|
||||||
|
"AND i.warehouse_pallet_id IS NULL AND p.warehouse_pallet_id IS NOT NULL")
|
||||||
|
int bindReceivedPallets(@Param("customerId") String customerId,
|
||||||
|
@Param("dispatchOrderId") Long dispatchOrderId);
|
||||||
|
}
|
||||||
+16
@@ -0,0 +1,16 @@
|
|||||||
|
package com.baiyue.lastleg.dispatch.mapper;
|
||||||
|
|
||||||
|
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
|
||||||
|
import com.baiyue.lastleg.dispatch.entity.DispatchOrder;
|
||||||
|
import org.apache.ibatis.annotations.Mapper;
|
||||||
|
import org.apache.ibatis.annotations.Param;
|
||||||
|
import org.apache.ibatis.annotations.Select;
|
||||||
|
|
||||||
|
@Mapper
|
||||||
|
public interface DispatchOrderMapper extends BaseMapper<DispatchOrder> {
|
||||||
|
@Select("SELECT * FROM ll_dispatch_order WHERE customer_id=#{customerId} AND dispatch_no=#{dispatchNo} LIMIT 1")
|
||||||
|
DispatchOrder findByDispatchNo(@Param("customerId") String customerId, @Param("dispatchNo") String dispatchNo);
|
||||||
|
|
||||||
|
@Select("SELECT * FROM ll_dispatch_order WHERE customer_id=#{customerId} AND id=#{id} LIMIT 1 FOR UPDATE")
|
||||||
|
DispatchOrder lockById(@Param("customerId") String customerId, @Param("id") Long id);
|
||||||
|
}
|
||||||
+15
@@ -0,0 +1,15 @@
|
|||||||
|
package com.baiyue.lastleg.dispatch.mapper;
|
||||||
|
|
||||||
|
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
|
||||||
|
import com.baiyue.lastleg.dispatch.entity.DispatchPalletLock;
|
||||||
|
import org.apache.ibatis.annotations.Mapper;
|
||||||
|
import org.apache.ibatis.annotations.Param;
|
||||||
|
import org.apache.ibatis.annotations.Select;
|
||||||
|
|
||||||
|
@Mapper
|
||||||
|
public interface DispatchPalletLockMapper extends BaseMapper<DispatchPalletLock> {
|
||||||
|
@Select("SELECT * FROM ll_dispatch_pallet_lock WHERE customer_id=#{customerId} AND source_pallet_key=#{sourcePalletKey} " +
|
||||||
|
"AND status='ACTIVE' LIMIT 1 FOR UPDATE")
|
||||||
|
DispatchPalletLock lockActive(@Param("customerId") String customerId,
|
||||||
|
@Param("sourcePalletKey") String sourcePalletKey);
|
||||||
|
}
|
||||||
+37
@@ -0,0 +1,37 @@
|
|||||||
|
package com.baiyue.lastleg.dispatch.model;
|
||||||
|
|
||||||
|
import java.math.BigDecimal;
|
||||||
|
|
||||||
|
public record DispatchPalletCandidate(
|
||||||
|
Long planId,
|
||||||
|
Long planPalletId,
|
||||||
|
Long warehousePalletId,
|
||||||
|
Long orderId,
|
||||||
|
String overseasWarehouseId,
|
||||||
|
String overseasWarehouseName,
|
||||||
|
String orderNo,
|
||||||
|
String orderRefs,
|
||||||
|
String inboundCode,
|
||||||
|
String palletNo,
|
||||||
|
String destinationWarehouseId,
|
||||||
|
String destinationWarehouseCode,
|
||||||
|
String destinationWarehouseName,
|
||||||
|
String fbaWarehouseCode,
|
||||||
|
Integer packageCount,
|
||||||
|
BigDecimal volumeCbm,
|
||||||
|
String sourceStage
|
||||||
|
) {
|
||||||
|
public DispatchPalletCandidate(Long planId, Long planPalletId, Long warehousePalletId, Long orderId,
|
||||||
|
String orderNo, String orderRefs, String inboundCode, String palletNo,
|
||||||
|
String destinationWarehouseId, String destinationWarehouseCode,
|
||||||
|
String destinationWarehouseName, String fbaWarehouseCode, Integer packageCount,
|
||||||
|
BigDecimal volumeCbm, String sourceStage) {
|
||||||
|
this(planId, planPalletId, warehousePalletId, orderId, null, null, orderNo, orderRefs, inboundCode,
|
||||||
|
palletNo, destinationWarehouseId, destinationWarehouseCode, destinationWarehouseName,
|
||||||
|
fbaWarehouseCode, packageCount, volumeCbm, sourceStage);
|
||||||
|
}
|
||||||
|
public String sourcePalletKey() {
|
||||||
|
// A plan pallet keeps the same lock identity before and after the physical pallet is received.
|
||||||
|
return planPalletId != null ? "PLAN:" + planPalletId : "RECEIVED:" + warehousePalletId;
|
||||||
|
}
|
||||||
|
}
|
||||||
+8
@@ -0,0 +1,8 @@
|
|||||||
|
package com.baiyue.lastleg.dispatch.pda;
|
||||||
|
|
||||||
|
import org.mybatis.spring.annotation.MapperScan;
|
||||||
|
import org.springframework.context.annotation.Configuration;
|
||||||
|
|
||||||
|
@Configuration
|
||||||
|
@MapperScan("com.baiyue.lastleg.dispatch.pda.mapper")
|
||||||
|
public class DispatchPdaMapperConfiguration {}
|
||||||
+69
@@ -0,0 +1,69 @@
|
|||||||
|
package com.baiyue.lastleg.dispatch.pda.controller;
|
||||||
|
|
||||||
|
import com.baiyue.common.core.api.ApiResponse;
|
||||||
|
import com.baiyue.common.security.AuthenticatedUser;
|
||||||
|
import com.baiyue.lastleg.dispatch.pda.dto.DispatchPdaRequests;
|
||||||
|
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.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")
|
||||||
|
public class DispatchPdaController {
|
||||||
|
public static final String OPERATE_PERMISSION = "lastleg:pda:operate";
|
||||||
|
@Resource private DispatchPdaWorkflowService service;
|
||||||
|
|
||||||
|
@PostMapping("/push")
|
||||||
|
public ApiResponse<DispatchPdaResponses.Task> push(@AuthenticationPrincipal AuthenticatedUser user,
|
||||||
|
@PathVariable String dispatchNo,
|
||||||
|
@RequestParam(required = false) String customerId,
|
||||||
|
@Valid @RequestBody DispatchPdaRequests.Push request) {
|
||||||
|
requireOperate(user);
|
||||||
|
return ApiResponse.success(service.push(scope(user, customerId), dispatchNo, request));
|
||||||
|
}
|
||||||
|
@GetMapping
|
||||||
|
public ApiResponse<DispatchPdaResponses.Task> task(@AuthenticationPrincipal AuthenticatedUser user,
|
||||||
|
@PathVariable String dispatchNo,
|
||||||
|
@RequestParam(required = false) String customerId) {
|
||||||
|
return ApiResponse.success(service.task(scope(user, customerId), dispatchNo));
|
||||||
|
}
|
||||||
|
@PostMapping("/items/{itemId}/pick")
|
||||||
|
public ApiResponse<DispatchPdaResponses.Picked> pick(@AuthenticationPrincipal AuthenticatedUser user,
|
||||||
|
@PathVariable String dispatchNo, @PathVariable long itemId,
|
||||||
|
@RequestParam(required = false) String customerId,
|
||||||
|
@Valid @RequestBody DispatchPdaRequests.Pick request) {
|
||||||
|
requireOperate(user);
|
||||||
|
return ApiResponse.success(service.pick(scope(user, customerId), dispatchNo, itemId, operator(user), request));
|
||||||
|
}
|
||||||
|
@PostMapping("/complete")
|
||||||
|
public ApiResponse<DispatchPdaResponses.PickingCompleted> completePicking(
|
||||||
|
@AuthenticationPrincipal AuthenticatedUser user,
|
||||||
|
@PathVariable String dispatchNo,
|
||||||
|
@RequestParam(required = false) String customerId,
|
||||||
|
@Valid @RequestBody DispatchPdaRequests.CompletePicking request) {
|
||||||
|
requireOperate(user);
|
||||||
|
return ApiResponse.success(service.completePicking(scope(user, customerId), dispatchNo, request));
|
||||||
|
}
|
||||||
|
private String scope(AuthenticatedUser user, String requestedCustomer) {
|
||||||
|
if (user == null || user.tenantId() == null) throw new ResponseStatusException(HttpStatus.UNAUTHORIZED);
|
||||||
|
if ("PLATFORM".equals(user.appCode()) && user.tenantId() == 0L) {
|
||||||
|
if (requestedCustomer == null || requestedCustomer.isBlank() || "0".equals(requestedCustomer)) {
|
||||||
|
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Platform request must specify target tenant");
|
||||||
|
}
|
||||||
|
return requestedCustomer.trim();
|
||||||
|
}
|
||||||
|
return String.valueOf(user.tenantId());
|
||||||
|
}
|
||||||
|
private String operator(AuthenticatedUser user) { return user.userId() == null ? user.username() : String.valueOf(user.userId()); }
|
||||||
|
private void requireOperate(AuthenticatedUser user) {
|
||||||
|
if (user == null || user.tenantId() == null) throw new ResponseStatusException(HttpStatus.UNAUTHORIZED);
|
||||||
|
if (user.permissions() == null || !user.permissions().contains(OPERATE_PERMISSION)) {
|
||||||
|
throw new ResponseStatusException(HttpStatus.FORBIDDEN, OPERATE_PERMISSION + " permission is required");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+26
@@ -0,0 +1,26 @@
|
|||||||
|
package com.baiyue.lastleg.dispatch.pda.dto;
|
||||||
|
|
||||||
|
import jakarta.validation.constraints.NotBlank;
|
||||||
|
import jakarta.validation.constraints.NotNull;
|
||||||
|
import jakarta.validation.constraints.Positive;
|
||||||
|
import jakarta.validation.constraints.PositiveOrZero;
|
||||||
|
import jakarta.validation.constraints.Size;
|
||||||
|
|
||||||
|
public final class DispatchPdaRequests {
|
||||||
|
private DispatchPdaRequests() {}
|
||||||
|
public record Push(@NotBlank String idempotencyKey, String operatorId, String operatorName,
|
||||||
|
String warehouseId) {
|
||||||
|
public Push(String idempotencyKey, String operatorId, String operatorName) {
|
||||||
|
this(idempotencyKey, operatorId, operatorName, null);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
public record Pick(@NotNull Long positionId, @Positive long packages, @PositiveOrZero long productQuantity,
|
||||||
|
@NotBlank String idempotencyKey, String remark, String warehouseId) {
|
||||||
|
public Pick(Long positionId, long packages, long productQuantity, String idempotencyKey, String remark) {
|
||||||
|
this(positionId, packages, productQuantity, idempotencyKey, remark, null);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
public record Depart(@NotBlank String idempotencyKey, String remark) {}
|
||||||
|
public record CompletePicking(@NotBlank String idempotencyKey,
|
||||||
|
@NotBlank @Size(max = 1000) String reason) {}
|
||||||
|
}
|
||||||
+27
@@ -0,0 +1,27 @@
|
|||||||
|
package com.baiyue.lastleg.dispatch.pda.dto;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
public final class DispatchPdaResponses {
|
||||||
|
private DispatchPdaResponses() {}
|
||||||
|
public record Task(Long id, String taskNo, String dispatchNo, String vehiclePlate, String warehouseId, String status,
|
||||||
|
List<Item> items) {}
|
||||||
|
public record Item(Long dispatchItemId, String palletNo, String destinationWarehouseCode,
|
||||||
|
String destinationWarehouseName, String fbaWarehouseCode, int scheduledPackages,
|
||||||
|
long pickedPackages, List<Position> positions) {}
|
||||||
|
public record Position(Long id, Long balanceId, long availablePackages, long allocatedPackages,
|
||||||
|
long pickedPackages) {}
|
||||||
|
public record Picked(Long dispatchItemId, Long positionId, long packages, long pickedPackages,
|
||||||
|
boolean palletScrapped, boolean replayed) {}
|
||||||
|
public record Departed(String dispatchNo, String status, long shippedPackages, boolean replayed) {
|
||||||
|
public Departed withReplay() { return new Departed(dispatchNo, status, shippedPackages, true); }
|
||||||
|
}
|
||||||
|
public record PickingCompleted(String dispatchNo, String status, long plannedPackages,
|
||||||
|
long pickedPackages, long remainingPackages, boolean partial,
|
||||||
|
boolean replayed) {
|
||||||
|
public PickingCompleted withReplay() {
|
||||||
|
return new PickingCompleted(dispatchNo, status, plannedPackages, pickedPackages,
|
||||||
|
remainingPackages, partial, true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+23
@@ -0,0 +1,23 @@
|
|||||||
|
package com.baiyue.lastleg.dispatch.pda.entity;
|
||||||
|
|
||||||
|
import com.baomidou.mybatisplus.annotation.IdType;
|
||||||
|
import com.baomidou.mybatisplus.annotation.TableId;
|
||||||
|
import com.baomidou.mybatisplus.annotation.TableName;
|
||||||
|
import lombok.Data;
|
||||||
|
|
||||||
|
import java.time.LocalDateTime;
|
||||||
|
|
||||||
|
@Data
|
||||||
|
@TableName("ll_dispatch_pda_operation")
|
||||||
|
public class DispatchPdaOperation {
|
||||||
|
@TableId(type = IdType.INPUT) private Long id;
|
||||||
|
private String customerId;
|
||||||
|
private Long taskId;
|
||||||
|
private String operationType;
|
||||||
|
private String idempotencyKey;
|
||||||
|
private String requestHash;
|
||||||
|
private String resultJson;
|
||||||
|
private String status;
|
||||||
|
private LocalDateTime createdAt;
|
||||||
|
private LocalDateTime updatedAt;
|
||||||
|
}
|
||||||
+23
@@ -0,0 +1,23 @@
|
|||||||
|
package com.baiyue.lastleg.dispatch.pda.entity;
|
||||||
|
|
||||||
|
import com.baomidou.mybatisplus.annotation.IdType;
|
||||||
|
import com.baomidou.mybatisplus.annotation.TableId;
|
||||||
|
import com.baomidou.mybatisplus.annotation.TableName;
|
||||||
|
import lombok.Data;
|
||||||
|
|
||||||
|
import java.time.LocalDateTime;
|
||||||
|
|
||||||
|
@Data
|
||||||
|
@TableName("ll_dispatch_pda_pick")
|
||||||
|
public class DispatchPdaPick {
|
||||||
|
@TableId(type = IdType.INPUT) private Long id;
|
||||||
|
private String customerId;
|
||||||
|
private Long taskId;
|
||||||
|
private Long dispatchItemId;
|
||||||
|
private Long inventoryPositionId;
|
||||||
|
private Long packages;
|
||||||
|
private Long productQuantity;
|
||||||
|
private String idempotencyKey;
|
||||||
|
private LocalDateTime pickedAt;
|
||||||
|
private LocalDateTime createdAt;
|
||||||
|
}
|
||||||
+22
@@ -0,0 +1,22 @@
|
|||||||
|
package com.baiyue.lastleg.dispatch.pda.entity;
|
||||||
|
|
||||||
|
import com.baomidou.mybatisplus.annotation.IdType;
|
||||||
|
import com.baomidou.mybatisplus.annotation.TableId;
|
||||||
|
import com.baomidou.mybatisplus.annotation.TableName;
|
||||||
|
import lombok.Data;
|
||||||
|
|
||||||
|
import java.time.LocalDateTime;
|
||||||
|
|
||||||
|
/** Immutable FIFO attribution of a PDA pick to its inbound receipt and tail-leg order. */
|
||||||
|
@Data
|
||||||
|
@TableName("ll_dispatch_pda_pick_order")
|
||||||
|
public class DispatchPdaPickOrder {
|
||||||
|
@TableId(type = IdType.INPUT) private Long id;
|
||||||
|
private String customerId;
|
||||||
|
private Long pickId;
|
||||||
|
private Long receiptId;
|
||||||
|
private Long orderId;
|
||||||
|
private Long packages;
|
||||||
|
private Long productQuantity;
|
||||||
|
private LocalDateTime createdAt;
|
||||||
|
}
|
||||||
+25
@@ -0,0 +1,25 @@
|
|||||||
|
package com.baiyue.lastleg.dispatch.pda.entity;
|
||||||
|
|
||||||
|
import com.baomidou.mybatisplus.annotation.IdType;
|
||||||
|
import com.baomidou.mybatisplus.annotation.TableId;
|
||||||
|
import com.baomidou.mybatisplus.annotation.TableName;
|
||||||
|
import lombok.Data;
|
||||||
|
|
||||||
|
import java.time.LocalDateTime;
|
||||||
|
|
||||||
|
@Data
|
||||||
|
@TableName("ll_dispatch_pda_task")
|
||||||
|
public class DispatchPdaTask {
|
||||||
|
@TableId(type = IdType.INPUT) private Long id;
|
||||||
|
private String customerId;
|
||||||
|
private Long dispatchOrderId;
|
||||||
|
private String taskNo;
|
||||||
|
private String status;
|
||||||
|
private String operatorId;
|
||||||
|
private String operatorName;
|
||||||
|
private String warehouseId;
|
||||||
|
private LocalDateTime pushedAt;
|
||||||
|
private LocalDateTime completedAt;
|
||||||
|
private LocalDateTime createdAt;
|
||||||
|
private LocalDateTime updatedAt;
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user