暂存提交
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 幂等持久化。
|
||||
- 事件模型来自 `common-event-contract/`;本服务不共享国内/国外业务数据库,也不承载头程或尾程领域逻辑。
|
||||
- 真实跨区发送、重试调度和死信补投仍需按需求继续完善,不把当前接收骨架描述为完整消息平台。
|
||||
- 负责跨区事件接收、Inbox 持久化、向海外尾程投递、失败重试、投递记录和人工补投。
|
||||
- 只承载可靠传输,不执行尾程建单逻辑,不查询头程或尾程业务表。
|
||||
- 事件结构和 Payload 哈希以 `common-event-contract` 为准。
|
||||
|
||||
## 快速索引
|
||||
|
||||
| 功能 | 入口 |
|
||||
|---|---|
|
||||
| 应用入口 | `src/main/java/com/baiyue/eventbridge/EventBridgeApplication.java` |
|
||||
| 事件接收与状态 API | `controller/EventBridgeController.java`(`/internal/event-bridge`) |
|
||||
| 接收结果与状态 DTO | `dto/EventReceiveResult.java`、`BridgeStatus.java` |
|
||||
| 校验与 Inbox 编排 | `service/EventInboxService.java` |
|
||||
| Inbox 抽象与 MyBatis 实现 | `service/InboxStore.java`、`MybatisInboxStore.java` |
|
||||
| Inbox 数据模型 | `domain/BridgeInbox.java`、`mapper/BridgeInboxMapper.java` |
|
||||
| 接收、状态、人工补投 API | `src/main/java/com/baiyue/eventbridge/controller/EventBridgeController.java` |
|
||||
| Inbox 接收与幂等 | `service/EventInboxService.java` |
|
||||
| 海外调度与重试 | `service/OverseasEventDispatcher.java` |
|
||||
| 尾程 HTTP 客户端 | `service/LastlegEventClient.java`、`HttpLastlegEventClient.java` |
|
||||
| 人工补投 | `service/EventReplayService.java` |
|
||||
| Inbox 与投递记录 | `domain/BridgeInbox.java`、`BridgeDeliveryAttempt.java` 及对应 Mapper |
|
||||
| 内部服务令牌 | `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/test/java/com/baiyue/eventbridge/security/`、`service/` |
|
||||
| 架构边界 | `../AGENTS.md` 的仓库边界、`../docs/头程项目目录结构与服务说明.md` 的主要调用关系与开发拆分约束 |
|
||||
| 数据库 | `src/main/resources/db/event-bridge.sql` |
|
||||
| 测试 | `src/test/java/com/baiyue/eventbridge/` |
|
||||
|
||||
## 可靠投递规则
|
||||
|
||||
- `RECEIVED` 和到期的 `RETRY` 都必须可被调度;只有尾程确认成功后才能标记 `PROCESSED`。
|
||||
- 每次投递保存 attempt、错误和时间;达到上限进入 `DEAD`,经授权人工补投后重新进入重试队列。
|
||||
- 领取任务必须有数据库租约/并发保护,避免多实例重复发送;尾程幂等仍是最终保障。
|
||||
- 内部接口使用 `X-Internal-Service-Token`,生产还应置于受信网络或 mTLS 后。
|
||||
|
||||
## 修改与验证
|
||||
|
||||
- 先使用 `common-event-contract` 校验事件 ID、区域、版本、业务键、时间与 Payload 哈希,再执行幂等入库。
|
||||
- 重复事件应返回稳定结果且不重复产生副作用;数据库唯一约束与应用幂等逻辑要同步。
|
||||
- 跨区域接口必须鉴权、限流并最小化载荷,禁止携带密钥或非必要客户敏感数据。
|
||||
- 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`。
|
||||
- 定向验证:`mvn -pl event-bridge-service -am test`。
|
||||
- 重试相关修改至少覆盖 `RECEIVED`、`RETRY`、成功、失败、租约、最大次数和人工补投。
|
||||
|
||||
@@ -1,14 +1,19 @@
|
||||
package com.baiyue.eventbridge;
|
||||
|
||||
import com.baiyue.eventbridge.config.EventDispatcherProperties;
|
||||
import org.mybatis.spring.annotation.MapperScan;
|
||||
import org.springframework.boot.SpringApplication;
|
||||
import org.springframework.boot.autoconfigure.SpringBootApplication;
|
||||
import org.springframework.boot.context.properties.EnableConfigurationProperties;
|
||||
import org.springframework.scheduling.annotation.EnableScheduling;
|
||||
|
||||
/**
|
||||
* 跨区域事件桥启动入口。
|
||||
*/
|
||||
@MapperScan("com.baiyue.eventbridge.mapper")
|
||||
@SpringBootApplication
|
||||
@EnableScheduling
|
||||
@EnableConfigurationProperties(EventDispatcherProperties.class)
|
||||
public class EventBridgeApplication {
|
||||
public static void main(String[] 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.eventbridge.dto.BridgeStatus;
|
||||
import com.baiyue.eventbridge.dto.EventReceiveResult;
|
||||
import com.baiyue.eventbridge.dto.EventReplayRequest;
|
||||
import com.baiyue.eventbridge.service.EventInboxService;
|
||||
import com.baiyue.eventbridge.service.EventReplayService;
|
||||
import com.baiyue.eventbridge.security.InternalServiceTokenGuard;
|
||||
import io.swagger.v3.oas.annotations.Operation;
|
||||
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.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
import org.springframework.web.bind.annotation.PathVariable;
|
||||
|
||||
/**
|
||||
* 事件桥内部接口。生产环境必须在网关或基础设施层增加 mTLS 与来源白名单。
|
||||
@@ -26,13 +29,11 @@ import org.springframework.web.bind.annotation.RestController;
|
||||
@RestController
|
||||
@RequestMapping("/internal/event-bridge")
|
||||
public class EventBridgeController {
|
||||
@Resource
|
||||
private EventInboxService eventInboxService;
|
||||
@Resource private EventInboxService eventInboxService;
|
||||
@Resource private EventReplayService eventReplayService;
|
||||
@Resource private InternalServiceTokenGuard internalServiceTokenGuard;
|
||||
|
||||
@Resource
|
||||
private InternalServiceTokenGuard internalServiceTokenGuard;
|
||||
|
||||
@Value("${baiyue.region:CN}")
|
||||
@Value("${baiyue.region:OVERSEAS}")
|
||||
private String region;
|
||||
|
||||
@Operation(summary = "幂等接收事件", description = "当前仅完成数据库接收骨架,不代表生产跨区传输和 mTLS 已开通")
|
||||
@@ -50,6 +51,18 @@ public class EventBridgeController {
|
||||
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 = "查询事件桥能力状态")
|
||||
@ApiResponses(@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "200", description = "查询成功"))
|
||||
@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 String sourceRegion;
|
||||
private String targetRegion;
|
||||
private String tenantId;
|
||||
private String customerId;
|
||||
private String aggregateType;
|
||||
private String aggregateId;
|
||||
private Long aggregateVersion;
|
||||
@@ -32,5 +32,11 @@ public class BridgeInbox {
|
||||
private String payloadHash;
|
||||
private String payloadJson;
|
||||
private String status;
|
||||
private String leaseOwner;
|
||||
private LocalDateTime leaseUntil;
|
||||
private Integer retryCount;
|
||||
private LocalDateTime nextRetryAt;
|
||||
private String lastError;
|
||||
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.baiyue.eventbridge.domain.BridgeInbox;
|
||||
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 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
|
||||
public class InternalServiceTokenGuard {
|
||||
@Value("${baiyue.security.internal-service-token:}")
|
||||
private String expectedToken;
|
||||
private final String expectedToken;
|
||||
|
||||
public InternalServiceTokenGuard(@Value("${baiyue.security.internal-service-token:}") String expectedToken) {
|
||||
this.expectedToken = expectedToken;
|
||||
}
|
||||
|
||||
public void verify(String actualToken) {
|
||||
if (expectedToken == null || expectedToken.isBlank()) {
|
||||
|
||||
+6
-24
@@ -1,7 +1,5 @@
|
||||
package com.baiyue.eventbridge.service;
|
||||
|
||||
import jakarta.annotation.Resource;
|
||||
|
||||
import com.baiyue.common.event.EventEnvelope;
|
||||
import com.baiyue.common.event.EventPayloadCanonicalizer;
|
||||
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.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
import jakarta.annotation.Resource;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.Objects;
|
||||
|
||||
/**
|
||||
* 入站事件校验与幂等落库服务。
|
||||
*/
|
||||
@Service
|
||||
public class EventInboxService {
|
||||
@Resource
|
||||
private InboxStore inboxStore;
|
||||
@Resource
|
||||
private ObjectMapper objectMapper;
|
||||
@Resource private InboxStore inboxStore;
|
||||
@Resource private ObjectMapper objectMapper;
|
||||
|
||||
@Value("${baiyue.region:CN}")
|
||||
private String localRegion;
|
||||
private String localRegion;
|
||||
|
||||
@Transactional
|
||||
public EventReceiveResult receive(EventEnvelope envelope) {
|
||||
@@ -61,24 +58,9 @@ public class EventInboxService {
|
||||
if (!existing.getPayloadHash().equalsIgnoreCase(envelope.payloadHash())) {
|
||||
throw new IllegalArgumentException("相同 eventId 对应的 payloadHash 不一致,拒绝覆盖既有事件");
|
||||
}
|
||||
if (!hasSameEnvelopeIdentity(existing, envelope)) {
|
||||
throw new IllegalArgumentException("相同 eventId 对应的客户或事件信封身份不一致,拒绝覆盖既有事件");
|
||||
}
|
||||
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) {
|
||||
BridgeInbox inbox = new BridgeInbox();
|
||||
inbox.setEventId(envelope.eventId());
|
||||
@@ -87,7 +69,7 @@ public class EventInboxService {
|
||||
inbox.setEventVersion(envelope.eventVersion());
|
||||
inbox.setSourceRegion(envelope.sourceRegion().name());
|
||||
inbox.setTargetRegion(envelope.targetRegion().name());
|
||||
inbox.setTenantId(envelope.tenantId());
|
||||
inbox.setCustomerId(envelope.tenantId());
|
||||
inbox.setAggregateType(envelope.aggregateType());
|
||||
inbox.setAggregateId(envelope.aggregateId());
|
||||
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.mapper.BridgeInboxMapper;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import jakarta.annotation.Resource;
|
||||
import org.springframework.stereotype.Repository;
|
||||
|
||||
/**
|
||||
* 基于 MyBatis-Plus 的入站幂等存储。
|
||||
*/
|
||||
@Repository
|
||||
@RequiredArgsConstructor
|
||||
public class MybatisInboxStore implements InboxStore {
|
||||
private final BridgeInboxMapper mapper;
|
||||
@Resource private BridgeInboxMapper mapper;
|
||||
|
||||
@Override
|
||||
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:
|
||||
port: ${EVENT_BRIDGE_PORT:9300}
|
||||
port: ${EVENT_BRIDGE_PORT:9312}
|
||||
|
||||
spring:
|
||||
profiles:
|
||||
active: ${SPRING_PROFILES_ACTIVE:dev}
|
||||
data:
|
||||
redis:
|
||||
host: ${REDIS_HOST:127.0.0.1}
|
||||
port: ${REDIS_PORT:6380}
|
||||
application:
|
||||
name: event-bridge-service
|
||||
config:
|
||||
import:
|
||||
- optional:nacos:baiyue-common.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:
|
||||
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}
|
||||
password: ${NACOS_PASSWORD:nacos}
|
||||
discovery:
|
||||
@@ -22,7 +29,17 @@ spring:
|
||||
namespace: ${NACOS_NAMESPACE:}
|
||||
|
||||
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:
|
||||
internal-service-token: ${INTERNAL_SERVICE_TOKEN:}
|
||||
|
||||
@@ -35,14 +52,3 @@ management:
|
||||
springdoc:
|
||||
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 '事件契约版本',
|
||||
source_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_id VARCHAR(128) 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 '事件契约版本',
|
||||
source_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_id VARCHAR(128) NOT NULL COMMENT '聚合标识',
|
||||
aggregate_version BIGINT NOT NULL COMMENT '聚合版本',
|
||||
|
||||
+6
-20
@@ -1,43 +1,29 @@
|
||||
package com.baiyue.eventbridge.security;
|
||||
|
||||
import org.junit.jupiter.api.DisplayName;
|
||||
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 static org.assertj.core.api.Assertions.assertThatCode;
|
||||
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 {
|
||||
@Resource private InternalServiceTokenGuard guard;
|
||||
|
||||
@AfterEach
|
||||
void resetToken() {
|
||||
ReflectionTestUtils.setField(guard, "expectedToken", "a-secure-internal-token");
|
||||
}
|
||||
@Test
|
||||
void validTokenAllowsAccess() {
|
||||
void 正确令牌允许访问() {
|
||||
InternalServiceTokenGuard guard = new InternalServiceTokenGuard("a-secure-internal-token");
|
||||
assertThatCode(() -> guard.verify("a-secure-internal-token")).doesNotThrowAnyException();
|
||||
}
|
||||
|
||||
@Test
|
||||
void unconfiguredTokenDisablesReceiveEndpoint() {
|
||||
ReflectionTestUtils.setField(guard, "expectedToken", "");
|
||||
void 未配置令牌时默认关闭接收接口() {
|
||||
InternalServiceTokenGuard guard = new InternalServiceTokenGuard("");
|
||||
assertThatThrownBy(() -> guard.verify("anything"))
|
||||
.isInstanceOf(ResponseStatusException.class)
|
||||
.hasMessageContaining("接口已关闭");
|
||||
}
|
||||
|
||||
@Test
|
||||
void invalidTokenIsRejected() {
|
||||
ReflectionTestUtils.setField(guard, "expectedToken", "expected");
|
||||
void 错误令牌被拒绝() {
|
||||
InternalServiceTokenGuard guard = new InternalServiceTokenGuard("expected");
|
||||
assertThatThrownBy(() -> guard.verify("wrong"))
|
||||
.isInstanceOf(ResponseStatusException.class)
|
||||
.hasMessageContaining("令牌无效");
|
||||
|
||||
+37
-91
@@ -6,16 +6,8 @@ import com.baiyue.common.event.EventPayloadHash;
|
||||
import com.baiyue.common.event.RegionCode;
|
||||
import com.baiyue.eventbridge.domain.BridgeInbox;
|
||||
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.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.test.context.TestPropertySource;
|
||||
import org.springframework.test.context.junit.jupiter.SpringJUnitConfig;
|
||||
import org.springframework.test.util.ReflectionTestUtils;
|
||||
|
||||
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.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.Mockito.doThrow;
|
||||
import static org.mockito.Mockito.mock;
|
||||
import static org.mockito.Mockito.verify;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
@DisplayName("跨区域事件收件箱")
|
||||
@SpringJUnitConfig(classes = {EventInboxService.class, EventInboxServiceTest.TestConfig.class})
|
||||
@TestPropertySource(properties = "baiyue.region=OVERSEAS")
|
||||
class EventInboxServiceTest {
|
||||
@Resource
|
||||
private ObjectMapper objectMapper;
|
||||
@MockBean
|
||||
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");
|
||||
}
|
||||
private final ObjectMapper objectMapper = new ObjectMapper();
|
||||
private final InboxStore store = mock(InboxStore.class);
|
||||
private final EventInboxService service = service("OVERSEAS");
|
||||
|
||||
@Test
|
||||
void firstEventPersistsAndReturnsReceived() {
|
||||
void 首次事件落库并返回已接收() {
|
||||
EventEnvelope envelope = envelope("sha256:placeholder");
|
||||
envelope = withHash(envelope);
|
||||
|
||||
@@ -62,19 +36,31 @@ class EventInboxServiceTest {
|
||||
}
|
||||
|
||||
@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"));
|
||||
BridgeInbox existing = existing(envelope, envelope.payloadHash());
|
||||
BridgeInbox existing = existing(envelope.payloadHash());
|
||||
when(store.findByEventId(envelope.eventId())).thenReturn(existing);
|
||||
|
||||
assertThat(service.receive(envelope).result()).isEqualTo("重复");
|
||||
}
|
||||
|
||||
@Test
|
||||
void sameEventWithDifferentDigestRejectsOverwrite() {
|
||||
void 相同事件但摘要不同时拒绝覆盖() {
|
||||
EventEnvelope envelope = withHash(envelope("sha256:placeholder"));
|
||||
when(store.findByEventId(envelope.eventId()))
|
||||
.thenReturn(existing(envelope, "sha256:" + "0".repeat(64)));
|
||||
when(store.findByEventId(envelope.eventId())).thenReturn(existing("sha256:" + "0".repeat(64)));
|
||||
|
||||
assertThatThrownBy(() -> service.receive(envelope))
|
||||
.isInstanceOf(IllegalArgumentException.class)
|
||||
@@ -82,45 +68,20 @@ class EventInboxServiceTest {
|
||||
}
|
||||
|
||||
@Test
|
||||
void sameEventAndPayloadWithDifferentTenantRejectsOverwrite() {
|
||||
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() {
|
||||
void 并发插入冲突后按既有记录判定为重复() {
|
||||
EventEnvelope envelope = withHash(envelope("sha256:placeholder"));
|
||||
when(store.findByEventId(envelope.eventId()))
|
||||
.thenReturn(null, existing(envelope, envelope.payloadHash()));
|
||||
when(store.findByEventId(envelope.eventId())).thenReturn(null, existing(envelope.payloadHash()));
|
||||
doThrow(new DuplicateKeyException("并发重复")).when(store).insert(any());
|
||||
|
||||
assertThat(service.receive(envelope).result()).isEqualTo("重复");
|
||||
}
|
||||
|
||||
@Test
|
||||
void targetRegionDifferentFromCurrentDeploymentIsRejected() {
|
||||
ReflectionTestUtils.setField(service, "localRegion", "CN");
|
||||
void 目标区域与当前部署区域不一致时拒绝接收() {
|
||||
EventInboxService cnService = service("CN");
|
||||
EventEnvelope envelope = withHash(envelope("sha256:placeholder"));
|
||||
|
||||
assertThatThrownBy(() -> service.receive(envelope))
|
||||
assertThatThrownBy(() -> cnService.receive(envelope))
|
||||
.isInstanceOf(IllegalArgumentException.class)
|
||||
.hasMessageContaining("目标区域");
|
||||
}
|
||||
@@ -132,6 +93,14 @@ class EventInboxServiceTest {
|
||||
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) {
|
||||
String hash = EventPayloadHash.sha256(EventPayloadCanonicalizer.canonicalize(source.payload()));
|
||||
return new EventEnvelope(source.eventId(), source.eventType(), source.eventVersion(), source.occurredAt(),
|
||||
@@ -140,32 +109,9 @@ class EventInboxServiceTest {
|
||||
source.correlationId(), source.causationId(), source.payload(), hash);
|
||||
}
|
||||
|
||||
private EventEnvelope withTenant(EventEnvelope source, String tenantId) {
|
||||
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) {
|
||||
private BridgeInbox existing(String hash) {
|
||||
BridgeInbox inbox = new BridgeInbox();
|
||||
inbox.setEventId(envelope.eventId());
|
||||
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.setEventId("550e8400-e29b-41d4-a716-446655440000");
|
||||
inbox.setPayloadHash(hash);
|
||||
inbox.setReceivedAt(LocalDateTime.of(2026, 7, 29, 18, 0));
|
||||
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