Compare commits

...
13 Commits
Author SHA1 Message Date
liuyihe ce5144d27e 功能提交 2026-08-19 15:52:47 +08:00
liuyihe 8bf775f885 功能提交 2026-08-19 09:24:56 +08:00
liuyihe 31cf37c528 功能提交 2026-08-18 16:31:55 +08:00
liuyihe d2c532f807 提交 2026-08-18 11:39:40 +08:00
liuyihe ac6c0d0395 提交 2026-08-17 19:04:58 +08:00
liuyihe 3082167411 派车提交 2026-08-17 13:38:48 +08:00
liuyihe d6fe79b0f2 提交 2026-08-17 09:18:03 +08:00
liuyihe 8b768125c6 Merge remote-tracking branch 'origin/main' 2026-08-13 17:18:27 +08:00
liuyihe b1772bce42 1 2026-08-13 17:10:02 +08:00
liuyihe e543765763 Merge remote-tracking branch 'origin/main' 2026-08-13 16:03:29 +08:00
liuyihe 5778d48f34 1 2026-08-13 16:00:19 +08:00
liuyihe 7c4cf6c040 上传文件至「/」 2026-08-13 15:58:07 +08:00
liuyihe eb4ab93ab2 Initial commit 2026-08-13 15:57:51 +08:00
400 changed files with 29423 additions and 0 deletions
File diff suppressed because it is too large Load Diff
+34
View File
@@ -0,0 +1,34 @@
# 海外尾程后端项目指引
## 适用范围
- 本文件适用于 `baiyue-lastleg/` Maven 多模块仓库。修改具体服务前,继续读取该模块的 `AGENTS.md`。
- 本层维护服务边界、跨服务协作、数据库变更入口和通用验证;类、接口和表的入口以模块级指引为准。
- 海外服务不得跨库查询国内头程数据。跨区事实必须通过版本化事件和不可变快照传递。
## 服务路由
| 任务关键词 | 首选模块 | 模块指引 |
|---|---|---|
| RS256、JWKS、Audience、Claims、海外统一入口 | `gateway/` | `gateway/AGENTS.md` |
| 尾程订单、装柜完成事件、Inbox、订单明细、状态历史 | `lastleg/` | `lastleg/AGENTS.md` |
| 入仓计划、托盘分配、PDA 入仓作业、合托 | `lastleg/` | `lastleg/AGENTS.md` 的入仓计划索引 |
| 跨区事件接收、重试、死信、人工补投、投递记录 | `event-bridge-service/` | `event-bridge-service/AGENTS.md` |
| 海外仓、库位、托盘、资源锁定与释放 | `warehouse/` | `warehouse/AGENTS.md` |
| 文件、打印、审计、第三方集成 | `file/`、`print/`、`audit-service/`、`integration-service/` | 对应模块源码;补充业务能力时先新增模块指引 |
## 固定契约与边界
- 头程装柜完成事件固定为 `FirstLegLoadingPlanCompleted` V1,`CN -> OVERSEAS`,聚合类型为 `LOADING_PLAN`。
- 一个事件只承载一个 `tenantId/accountId`;`payload.orders` 中每个最终实装 `sourceInboundId` 独立生成一条尾程订单记录,不按 FBA 仓、渠道或海外仓再次合并。
- 尾程消费者不得回查头程。订单、明细、重量、体积、目的仓、SKU、FBA tracking 和 shipment 等均使用事件冻结快照。
- 事件幂等和业务幂等必须同时成立:相同 `eventId + payloadHash` 重投返回已有结果;`(tenant_id, source_region, source_inbound_id)` 防止重复建单。
- 内部事件接口必须校验 `X-Internal-Service-Token`。外部用户接口由海外网关校验 RS256/JWKS、`aud=lastleg-api` 和必要身份声明。
- 调用方不得通过请求参数任意扩大 `tenantId` 范围;租户范围必须来自可信认证身份或平台权限。
## 数据库与验证
- 模块基线 SQL 位于 `<module>/src/main/resources/db/`。已部署环境的后续字段、注释和索引变更统一追加到 `scripts/db/incremental.sql`,不得依赖修改 `CREATE TABLE IF NOT EXISTS` 自动更新旧表。
- SQL 必须可重复执行,新增索引前检查 `information_schema.statistics`,新增/修改字段前检查 `information_schema.columns`,并保留已有业务数据原文。
- 单模块优先运行 `mvn -pl <module> -am test`;跨事件链路至少运行 `lastleg` 与 `event-bridge-service` 的定向测试。
- 不提交 `target/`、运行日志、临时联调脚本或本地密钥。
+85
View File
@@ -0,0 +1,85 @@
# 百悦物流尾程业务平台
本项目是海外尾程业务的独立 Maven 聚合工程草案,根坐标为 `com.baiyue.lastleg:baiyue-lastleg-platform:1.0.0-SNAPSHOT`。项目不包含登录、租户、用户、角色、菜单、按钮权限、国内订单和头程履约源码,也不允许直接查询国内数据库。
## 项目模块
| 模块 | 职责 |
|---|---|
| `gateway` | 海外统一入口、JWKS 验签、双端访问范围校验和显式路由 |
| `lastleg` | 到港、清关、拆柜、海外仓入库、派送和签收 |
| `warehouse` | 海外仓库存、库位和出入库作业 |
| `file` | 海外文件元数据和对象存储适配骨架 |
| `print` | 海外标签、面单和打印任务 |
| `audit-service` | 海外本地操作审计 |
| `integration-service` | 海外仓、承运商、清关和平台渠道适配 |
| `event-bridge-service` | 国内与海外之间的可靠事件收发、幂等和重试 |
## 登录与权限边界
登录、账号、租户、角色以及按钮权限的唯一写入口在头程项目。尾程项目不部署这类服务,按以下方式完成认证和授权:
1. 用户在国内统一入口登录,获得使用非对称算法签名的短时效 JWT。
2. JWT 包含用户、租户、应用端、角色、按钮权限和权限版本等快照信息。
3. 海外网关通过 `JWT_JWK_SET_URI` 获取并缓存公钥,校验 RS256/ES256 签名、签发方、有效期、`lastleg-api` Audience、应用端和必要身份声明,不保存签名私钥。
4. 尾程业务服务通过公共安全组件读取权限快照并执行接口、按钮和租户边界校验,不在每次请求时跨区查询国内权限数据。
5. 用户停用、租户冻结和权限版本变化通过跨区事件同步到海外本地投影;高风险操作可依据本地冻结投影立即拒绝。
海外服务不得依赖国内 Redis 中的登录会话。正式上线前,头程公共安全制品必须完成 JWKS 解码器和本地权限快照校验改造。
生产令牌必须包含 `sub`、`tenant_id`、`app_code`、`permission_version`,且 `aud` 必须包含配置的 `JWT_AUDIENCE`。认证中心应将访问令牌有效期控制在 5 至 15 分钟,并通过权限事件及时同步用户停用、租户冻结和权限版本变化。
## 公共制品依赖
本仓库不复制 `common-*` 源码。公共组件由头程仓发布到 Maven 私有仓库,本项目通过以下制品导入版本:
```text
com.baiyue.shared:baiyue-platform-bom:1.0.0-SNAPSHOT
com.baiyue.shared:baiyue-common-core
com.baiyue.shared:baiyue-common-web
com.baiyue.shared:baiyue-common-security
com.baiyue.shared:baiyue-common-openapi
com.baiyue.shared:baiyue-common-audit
com.baiyue.shared:common-event-contract
```
公共制品必须先发布或安装,本仓库才能进行完整 Maven 构建。
## 数据边界
| 服务 | 独立数据库 |
|---|---|
| `lastleg-service` | `baiyue_lastleg` |
| `warehouse-service` | `baiyue_lastlag_warehouse` |
| `file-service` | `baiyue_file` |
| `print-service` | `baiyue_print` |
| `audit-service` | `baiyue_audit` |
| `event-bridge-service` | `baiyue_event_bridge` |
服务之间不得跨库查询。国内订单和头程事实通过版本化事件进入尾程本地投影,尾程状态和签收事实也通过事件回传国内。
事件桥的海外本地投递使用数据库租约避免多实例重复领取,失败后指数退避并在达到上限后进入 `DEAD`。每次投递写入 `bridge_delivery_attempt`;经审批后可调用内部补投接口重新进入重试队列。生产环境必须将事件桥内部接口置于来源白名单和 mTLS 之后,并把 `LASTLEG_CONSUMER_URL` 配置为受信任的 HTTPS 内部地址。
## 文件服务说明
原工程中企业资质上传后同步调用国内权限服务的链路已从尾程仓移除。海外文件中心当前只保留基础骨架;提单、装箱单、清关文件和签收证明的对象存储、授权下载、病毒扫描及保留期限策略属于后续必做能力。
## 配置与构建
海外 Nacos 配置位于 `config/nacos`,环境变量模板位于 `env.example`。网关默认启用 `overseas` 配置,服务发现自动路由关闭,只允许 `application-overseas.yml` 中声明的尾程服务路由。
公共制品安装完成后执行:
```powershell
.\scripts\build.ps1
```
仅检查仓库边界和 POM 结构时执行:
```powershell
.\scripts\check-boundary.ps1
```
详细仓库边界见 [仓库边界说明](docs/repository-boundary.md)。
头程装柜完成后按实装入仓单批量生成尾程订单的事件契约、幂等规则和开发窗口拆分,见 [头程装柜完成生成尾程订单实施基线](docs/firstleg-loading-to-lastleg-orders.md)。
+59
View File
@@ -0,0 +1,59 @@
<?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.lastleg</groupId>
<artifactId>baiyue-lastleg-platform</artifactId>
<version>1.0.0-SNAPSHOT</version>
<relativePath>../pom.xml</relativePath>
</parent>
<artifactId>audit-service</artifactId>
<dependencies>
<dependency>
<groupId>com.baiyue.shared</groupId>
<artifactId>baiyue-common-audit</artifactId>
</dependency>
<dependency>
<groupId>com.baiyue.shared</groupId>
<artifactId>baiyue-common-web</artifactId>
</dependency>
<dependency>
<groupId>com.baiyue.shared</groupId>
<artifactId>baiyue-common-openapi</artifactId>
</dependency>
<dependency>
<groupId>com.baomidou</groupId>
<artifactId>mybatis-plus-spring-boot3-starter</artifactId>
</dependency>
<dependency>
<groupId>com.mysql</groupId>
<artifactId>mysql-connector-j</artifactId>
<scope>runtime</scope>
</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>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,16 @@
package com.baiyue.audit;
import org.mybatis.spring.annotation.MapperScan;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
/**
* 审计微服务启动入口,初始化 Spring Boot 应用并扫描审计日志 Mapper。
*/
@MapperScan("com.baiyue.audit.mapper")
@SpringBootApplication
public class BaiyueAuditApplication {
public static void main(String[] args) {
SpringApplication.run(BaiyueAuditApplication.class, args);
}
}
@@ -0,0 +1,45 @@
package com.baiyue.audit.controller;
import com.baiyue.audit.dto.AuditIngestRequest;
import com.baiyue.audit.service.AuditIngestService;
import com.baiyue.common.core.api.ApiResponse;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.responses.ApiResponses;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.annotation.Resource;
import jakarta.validation.Valid;
import org.springframework.http.HttpStatus;
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.ResponseStatus;
import org.springframework.web.bind.annotation.RestController;
/**
* 内部审计事件接收接口,校验服务间提交的审计数据并交由审计服务持久化。
*/
@Tag(
name = "Internal audit ingestion / 内部审计写入",
description = "Internal service-to-service API for immutable business audit events. / 服务间写入不可变业务审计事件的内部接口。"
)
@RestController
@RequestMapping("/internal/audit/events")
public class AuditIngestController {
@Resource
private AuditIngestService service;
@Operation(
summary = "Ingest business audit event / 写入业务审计事件",
description = "Validates and stores one sanitized event in baiyue_audit.sys_audit_log. / 校验并将一条脱敏事件写入 baiyue_audit.sys_audit_log。"
)
@ApiResponses({
@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "202", description = "Event accepted and stored / 事件已接收并存储"),
@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "400", description = "Invalid event payload / 事件载荷无效"),
@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "500", description = "Audit storage failed / 审计存储失败")
})
@PostMapping
@ResponseStatus(HttpStatus.ACCEPTED)
public ApiResponse<Void> ingest(@Valid @RequestBody AuditIngestRequest event) {
service.ingest(event);
return ApiResponse.success(null);
}
}
@@ -0,0 +1,63 @@
package com.baiyue.audit.dto;
import io.swagger.v3.oas.annotations.media.Schema;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotNull;
import jakarta.validation.constraints.PositiveOrZero;
import jakarta.validation.constraints.Size;
import java.time.Instant;
/**
* 内部审计写入请求契约,承载操作对象、租户与用户、请求链路、执行结果及脱敏快照。
*/
@Schema(name = "AuditIngestRequest", description = "Internal business audit event / 内部业务审计事件")
public record AuditIngestRequest(
@Schema(description = "Event code / 事件编码", example = "WAREHOUSE_UPDATE", requiredMode = Schema.RequiredMode.REQUIRED)
@NotBlank @Size(max = 64) String eventType,
@Schema(description = "Resource type / 资源类型", example = "WAREHOUSE", requiredMode = Schema.RequiredMode.REQUIRED)
@NotBlank @Size(max = 64) String resourceType,
@Schema(description = "Resource identifier / 资源标识", example = "190000000000000001")
@Size(max = 128) String resourceId,
@Schema(description = "Bilingual event description / 中英文事件描述", example = "Update warehouse / 更新仓库")
@Size(max = 255) String description,
@Schema(description = "Request ID / 请求 ID", example = "req-20260728-001")
@Size(max = 128) String requestId,
@Schema(description = "Distributed trace ID / 分布式链路 ID", example = "trace-20260728-001")
@Size(max = 128) String traceId,
@Schema(description = "Business data tenant ID / 业务数据租户 ID", example = "10001")
Long tenantId,
@Schema(description = "Operator tenant ID / 操作人所属租户 ID", example = "0")
Long operatorTenantId,
@Schema(description = "Operator user ID / 操作人用户 ID", example = "90001")
Long userId,
@Schema(description = "Operator username / 操作人用户名", example = "platform.admin")
@Size(max = 128) String username,
@Schema(description = "Application scope / 应用权限域", example = "PLATFORM")
@Size(max = 64) String appCode,
@Schema(description = "Client type / 客户端类型", example = "WEB")
@Size(max = 32) String clientType,
@Schema(description = "HTTP method / HTTP 方法", example = "PUT")
@Size(max = 16) String httpMethod,
@Schema(description = "Request URI / 请求 URI", example = "/api/platform/v1/warehouse-admin/190000000000000001")
@Size(max = 512) String requestUri,
@Schema(description = "Client IP / 客户端 IP", example = "10.0.0.10")
@Size(max = 64) String clientIp,
@Schema(description = "Request duration in milliseconds / 请求耗时(毫秒)", example = "35")
@PositiveOrZero long durationMs,
@Schema(description = "Whether the business operation succeeded / 业务操作是否成功", example = "true")
boolean success,
@Schema(description = "Error code when failed / 失败时错误码", example = "WAREHOUSE_NOT_FOUND")
@Size(max = 64) String errorCode,
@Schema(description = "Sanitized error message / 脱敏后的错误信息", example = "Warehouse not found / 仓库不存在")
@Size(max = 512) String errorMessage,
@Schema(description = "Sanitized JSON snapshot before change / 变更前脱敏 JSON 快照", example = "{\"status\":\"INACTIVE\"}")
String beforeData,
@Schema(description = "Sanitized JSON snapshot after change / 变更后脱敏 JSON 快照", example = "{\"status\":\"ACTIVE\"}")
String afterData,
@Schema(description = "Event occurrence time in UTC / 事件发生时间(UTC)", example = "2026-07-28T07:30:00Z", requiredMode = Schema.RequiredMode.REQUIRED)
@NotNull Instant occurredAt,
@Schema(description = "Source service name / 来源服务名称", example = "warehouse-service")
@Size(max = 64) String serviceName
) {
}
@@ -0,0 +1,101 @@
package com.baiyue.audit.entity;
import lombok.Getter;
import lombok.Setter;
import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import io.swagger.v3.oas.annotations.media.Schema;
import java.time.Instant;
import java.time.LocalDateTime;
/**
* 业务审计日志持久化实体,对应 {@code sys_audit_log} 表。
* 用于保存跨租户敏感操作的不可变审计证据,不用于领域事件投递。
*/
@Schema(name = "AuditLog", description = "Stored business audit log / 已存储业务审计日志")
@Getter
@Setter
@TableName("sys_audit_log")
public class AuditLogEntity {
@Schema(description = "Audit log ID / 审计日志 ID", example = "190000000000000201")
@TableId(type = IdType.AUTO)
private Long id;
private String eventType;
private String resourceType;
private String resourceId;
private String description;
private String requestId;
private String traceId;
private Long tenantId;
private Long operatorTenantId;
private Long userId;
private String username;
private String appCode;
private String clientType;
private String httpMethod;
private String requestUri;
private String clientIp;
private Long durationMs;
private Boolean success;
private String errorCode;
private String errorMessage;
private String beforeData;
private String afterData;
private String serviceName;
private Instant occurredAt;
private LocalDateTime createdAt;
}
@@ -0,0 +1,10 @@
package com.baiyue.audit.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.baiyue.audit.entity.AuditLogEntity;
/**
* 审计日志 MyBatis-Plus Mapper,提供 {@code sys_audit_log} 表的单表写入和查询能力。
*/
public interface AuditLogMapper extends BaseMapper<AuditLogEntity> {
}
@@ -0,0 +1,47 @@
package com.baiyue.audit.repository;
import com.baiyue.audit.dto.AuditIngestRequest;
import com.baiyue.audit.entity.AuditLogEntity;
import com.baiyue.audit.mapper.AuditLogMapper;
import org.springframework.stereotype.Repository;
import jakarta.annotation.Resource;
/**
* 审计事件持久化仓储,负责将内部审计请求映射并追加写入审计日志表。
*/
@Repository
public class AuditEventRepository {
@Resource private AuditLogMapper mapper;
public void save(AuditIngestRequest event) {
AuditLogEntity entity = new AuditLogEntity();
entity.setEventType(event.eventType());
entity.setResourceType(event.resourceType());
entity.setResourceId(event.resourceId());
entity.setDescription(event.description());
entity.setRequestId(event.requestId());
entity.setTraceId(event.traceId());
entity.setTenantId(event.tenantId());
entity.setOperatorTenantId(event.operatorTenantId());
entity.setUserId(event.userId());
entity.setUsername(event.username());
entity.setAppCode(event.appCode());
entity.setClientType(event.clientType());
entity.setHttpMethod(event.httpMethod());
entity.setRequestUri(event.requestUri());
entity.setClientIp(event.clientIp());
entity.setDurationMs(event.durationMs());
entity.setSuccess(event.success());
entity.setErrorCode(event.errorCode());
entity.setErrorMessage(event.errorMessage());
entity.setBeforeData(jsonOrNull(event.beforeData()));
entity.setAfterData(jsonOrNull(event.afterData()));
entity.setOccurredAt(event.occurredAt());
entity.setServiceName(event.serviceName());
mapper.insert(entity);
}
private static String jsonOrNull(String value) {
return value == null ? "null" : value;
}
}
@@ -0,0 +1,22 @@
package com.baiyue.audit.service;
import jakarta.annotation.Resource;
import com.baiyue.audit.dto.AuditIngestRequest;
import com.baiyue.audit.repository.AuditEventRepository;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
/**
* 审计事件写入应用服务,在事务内接收并持久化不可变业务审计记录。
*/
@Service
public class AuditIngestService {
@Resource
private AuditEventRepository repository;
@Transactional
public void ingest(AuditIngestRequest event) {
repository.save(event);
}
}
@@ -0,0 +1,28 @@
server:
port: ${AUDIT_PORT:9310}
spring:
data:
redis:
host: ${REDIS_HOST:127.0.0.1}
port: ${REDIS_PORT:6380}
application:
name: audit-service
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}
baiyue.audit.enabled: false
management.endpoints.web.exposure.include: health,info,prometheus
@@ -0,0 +1,36 @@
CREATE DATABASE IF NOT EXISTS baiyue_audit
DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci;
USE baiyue_audit;
CREATE TABLE IF NOT EXISTS sys_audit_log (
id BIGINT NOT NULL AUTO_INCREMENT COMMENT '审计日志自增ID',
event_type VARCHAR(64) NOT NULL COMMENT '审计事件编码',
resource_type VARCHAR(64) NOT NULL COMMENT '被操作资源类型',
resource_id VARCHAR(128) DEFAULT NULL COMMENT '被操作资源ID',
description VARCHAR(255) DEFAULT NULL COMMENT '操作说明',
request_id VARCHAR(128) DEFAULT NULL COMMENT '请求ID,用于单次请求追踪',
trace_id VARCHAR(128) DEFAULT NULL COMMENT '分布式链路追踪ID',
tenant_id BIGINT DEFAULT NULL COMMENT '业务数据所属租户ID',
operator_tenant_id BIGINT DEFAULT NULL COMMENT '操作主体所属租户ID',
user_id BIGINT DEFAULT NULL COMMENT '操作用户ID',
username VARCHAR(128) DEFAULT NULL COMMENT '操作用户名快照',
app_code VARCHAR(64) DEFAULT NULL COMMENT '操作来源应用端',
client_type VARCHAR(32) DEFAULT NULL COMMENT '客户端类型',
http_method VARCHAR(16) DEFAULT NULL COMMENT 'HTTP请求方法',
request_uri VARCHAR(512) DEFAULT NULL COMMENT '请求URI',
client_ip VARCHAR(64) DEFAULT NULL COMMENT '客户端IP',
duration_ms BIGINT NOT NULL DEFAULT 0 COMMENT '请求耗时(毫秒)',
success TINYINT NOT NULL DEFAULT 0 COMMENT '操作是否成功',
error_code VARCHAR(64) DEFAULT NULL COMMENT '失败时的错误码',
error_message VARCHAR(512) DEFAULT NULL COMMENT '脱敏后的失败信息',
before_data JSON DEFAULT NULL COMMENT '变更前数据快照(已脱敏)',
after_data JSON DEFAULT NULL COMMENT '变更后数据快照(已脱敏)',
service_name VARCHAR(64) DEFAULT NULL COMMENT '产生日志的服务名',
occurred_at DATETIME(3) NOT NULL COMMENT '业务操作发生时间',
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '审计服务落库时间',
PRIMARY KEY (id),
KEY idx_audit_tenant_time (tenant_id, occurred_at),
KEY idx_audit_event_time (event_type, occurred_at),
KEY idx_audit_user_time (user_id, occurred_at),
KEY idx_audit_request (request_id)
) ENGINE=InnoDB COMMENT='统一业务审计日志表:追加记录敏感操作的主体、租户、资源、结果及前后快照;不同于消息Outbox和消费幂等表';
+3
View File
@@ -0,0 +1,3 @@
# 海外 Nacos 配置
将本目录文件按原文件名作为 Data ID 发布到 `prod-overseas` 命名空间和 `BAIYUE` 分组。配置只引用环境变量,不保存密码或访问密钥。Gateway 必须同时启用 `overseas` Profile。
+6
View File
@@ -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
+62
View File
@@ -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}
+20
View File
@@ -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}
+13
View File
@@ -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:}
+3
View File
@@ -0,0 +1,3 @@
springdoc:
api-docs.enabled: ${SWAGGER_ENABLED:false}
swagger-ui.enabled: ${SWAGGER_ENABLED:false}
+12
View File
@@ -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:}
+6
View File
@@ -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
+6
View File
@@ -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
+6
View File
@@ -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
+121
View File
@@ -0,0 +1,121 @@
# 尾程平台已完成功能报告(2026-08-15)
## 1. 交付结论
头程装柜完成到尾程订单的真实链路已打通。头程按客户发布
`FirstLegLoadingPlanCompleted V1` 事件,经 Event Bridge 可靠投递后,尾程按事件中
每一笔最终实装入仓单分别落一条订单记录。同一柜的订单记录共享尾程单号,
`sourceInboundId` 作为来源建单唯一标识。
本报告仅描述当前代码和已经取得证据的能力,不把规划中的功能列为已完成。
## 2. 已完成功能
### 2.1 跨区域事件桥
- 接收并持久化版本化事件信封,保存幂等键、负载摘要和冻结 JSON。
- 使用数据库租约领取待投递事件,支持多实例竞争和超时租约恢复。
- `RECEIVED` 与 `RETRY` 都可重新调度,失败采用指数退避,达到上限进入 `DEAD`。
- 每次投递写入 `bridge_delivery_attempt`,记录投递序号、结果、响应码和错误。
- 支持经过内部令牌保护的死信人工补投;成功后才把 Inbox 标记为 `PROCESSED`。
- Event Bridge 与尾程内部接口使用统一的 `X-Internal-Service-Token`。
### 2.2 装柜完成事件消费与尾程建单
- 只接受 `CN -> OVERSEAS`、`LOADING_PLAN`、版本为 1 的装柜完成事件。
- 强制校验实际 `containerNo` 和非空 `orders`,尾程不回查头程数据。
- 事件 Inbox 以 `eventId` 防重复,并校验重复事件的 `payloadHash`。
- 按 `orders` 逐笔创建尾程订单;同柜入仓单共享一个不可碰撞的尾程单号。
- 以 `(tenant_id, source_region, source_inbound_id)` 保证来源入仓单建单幂等。
- 保存根入仓单、来源订单、装柜计划版本、交接版本和来源摘要等追溯字段。
- 保存商品、箱数、数量、托数、重量、体积、目的仓、SKU、FBA 跟踪号、
shipmentNo 和唛头等实装冻结明细。
- 新订单初始化为 `PENDING_ASSIGNMENT`,同步写入状态历史。
- 同一来源入仓单摘要不一致时拒绝静默覆盖。
### 2.3 尾程订单查询与转仓
- 按租户分页查询,支持尾程单号、入仓码、头程订单号、柜号、状态、转仓、
异常、暂扣和创建时间筛选。
- 查询订单详情、实装明细和状态历史。
- 导出 UTF-8 CSV,并处理公式注入风险。
- 支持单笔和批量转仓,更新订单及明细目的仓快照并写状态历史。
- 已生成入库计划的订单禁止再次修改目的仓。
- 普通客户只能查询认证身份对应租户;平台身份必须显式指定客户租户,
不允许通过请求参数任意跨客户查询。
### 2.4 尾程入库计划与 PDA 作业
- 按租户和实际集装箱生成唯一入库计划。
- 保存入仓码明细、目的仓快照、计划托盘和托盘分配关系。
- 按目的仓隔离合托,支持固定容量托盘分配。
- 提供 PDA 任务、操作幂等记录、托盘合并记录及仓库资源锁关联字段。
### 2.5 仓库资源和海外认证边界
- 仓库维护库位、托盘及资源锁,资源锁使用租户幂等键并记录业务归属。
- 仓库数据源启动保护避免 schema 接错后误执行建表脚本。
- 海外 Gateway 使用 JWKS 验证非对称 JWT,校验 `lastleg-api` Audience 及必需 claims。
- 尾程内部事件入口使用常量时间令牌比较;未配置或令牌错误时拒绝消费。
### 2.6 工程规范与数据库交付
- 生产 Spring Bean 的依赖注入已统一为 `jakarta.annotation.Resource`;仅保留配置值
`@Value` 和普通值对象/校验器构造器参数,不把不可变业务值误当作 Bean 注入。
- 新增统一增量入口 `scripts/db/incremental.sql`,按 `001-006` 归档 Event Bridge、尾程订单与入库计划/PDA、仓库资源及仓库主数据的字段、中文注释和索引变更。
- 增量脚本使用 `information_schema` 守卫,45 个动态 DDL 均成对释放,且不包含业务数据写入、删除或清理语句。
## 3. 真实链路验收证据
| 项目 | 结果 |
|---|---|
| loadingPlanId | `2091000814000009002` |
| containerNo | `LLSYNC-CNTR-20260814` |
| tenantId/accountId | `10001` |
| eventId | `88cd0f63-dc9e-4c16-bdb7-c32b20a83c3b` |
| payloadHash | `sha256:23011feff2ff91203fb94ebab7cab8306f58e3e128f61d17913d2f74af5c87f4` |
| Event Bridge 状态 | `PROCESSED` |
| 尾程单号 | `LMO43831679558828067566368238939` |
同一事件实际生成两条入仓单维度订单记录:
| sourceInboundId | 入仓码 | 箱数 | 托数 | 明细数 |
|---|---|---:|---:|---:|
| `2091000814000000301` | `LLSYNC-20260814-IN-01` | 2 | 1 | 1 |
| `2091000814000001301` | `LLSYNC-20260814-IN-02` | 2 | 1 | 1 |
重复投递相同 `eventId` 后接口仍返回成功,数据库订单记录总数保持为 2,未重复建单。
## 4. 数据库交付约定
全量初始化脚本继续由各服务维护:
- `lastleg/src/main/resources/db/lastleg.sql`
- `event-bridge-service/src/main/resources/db/event-bridge.sql`
- `warehouse/src/main/resources/db/warehouse.sql`
从本次交付开始,所有字段和索引变更同时追加到
`scripts/db/incremental.sql`。该文件按日期和序号只追加,已发布段落不得修改;
新增字段和索引必须查询 `information_schema` 后再执行,禁止放入测试数据、
业务数据修复、`DELETE` 或 `TRUNCATE`。
当前归档段为 `001` Event Bridge、`002` 仓库资源、`003` 入库计划/PDA、
`004` Event Bridge 存量字段升级、`005` 尾程订单投影和 `006` 仓库主数据注释/索引。
脚本静态契约校验结果:UTF-8、614 行、45 个 `PREPARE` 与 45 个释放操作,
未包含业务 DML,也未检测到乱码字符。
## 5. 验证结果
- `mvn -pl lastleg -am test`:34 tests passed。
- `mvn -pl event-bridge-service -am test`:15 tests passed。
- `mvn -pl warehouse -am test`:26 tests passed。
- `mvn -pl gateway -am test`:7 tests passed。
- 注入改造额外验证:`integration-service` 1 test passed,`audit-service` 编译通过。
- 生产代码扫描无 `@Autowired`、`@Inject` 或 Lombok 构造器注入残留;文档和 SQL UTF-8 乱码扫描通过。
## 6. 当前未声明完成的范围
- 到港、清关、拆柜、正式入库、出库、派送和签收的完整状态命令链路。
- 前端尾程订单页面。
- 权限冻结事件的海外本地投影和完整生产密钥轮换演练。
- 生产级 mTLS、来源网络白名单、监控告警和灾备演练。
+275
View File
@@ -0,0 +1,275 @@
# 头程装柜完成生成尾程订单实施基线
## 1. 已确认范围
- 头程一个柜次可以包含多个客户、多个入仓单和每个入仓单下的多条商品明细。
- 尾程订单以“本柜最终实装入仓单”为建单维度:一笔实装入仓单生成一笔尾程订单。
- 一个柜次因此可以生成多笔尾程订单。
- 同一客户在同一柜次中的多笔入仓单不得按海外仓、FBA 仓、渠道或地址再次合并。
- 头程与尾程保持独立数据库和运行环境,尾程不得回查头程数据库或调用头程业务接口补齐建单数据。
## 2. 业务触发点
事件只能在 PDA 人工确认装柜完成的本地事务中产生。头程当前链路为:
```text
LoadingPickService.complete
-> PdaPickSettlementService.settle
-> LoadingListService.finishFromPda
-> 装柜计划 LOADING -> COMPLETED
-> 同一事务写头程业务 Outbox
```
不能以下列动作作为尾程建单触发点:
- 页面进入“装柜完成”页签;
- 单条拣货完成;
- 入仓单状态投影更新;
- Event Bridge 收到同步 HTTP 请求后再反查头程数据。
## 3. 租户隔离与事件数量
装柜计划明细允许包含多个 `account_id`,而事件信封只有一个 `tenantId`。因此一个柜次不能发布一个包含所有客户数据的事件。
发布规则:
```text
一个已完成柜次
-> 按 account_id 分组
-> 每个 account_id 发布一个 FirstLegLoadingPlanCompleted 事件
-> 每个事件的 orders 包含该客户在本柜中的全部实装入仓单
-> 尾程对 orders 中每一项分别建单
```
事件级幂等键:
```text
FIRSTLEG_LOADING_COMPLETED:{loadingPlanId}:{tenantId}:{handoverVersion}
```
## 4. 入仓单标识规则
PDA 部分拣货会生成本柜实装 `LOADED` 分片以及可继续分柜的 `REMAINDER` 分片。事件中的 `sourceInboundId` 必须取本柜最终实装入仓单 ID,不能固定使用拆分前的父入仓单 ID。
每个订单单元同时保存:
| 字段 | 语义 |
|---|---|
| `sourceInboundId` | 本柜最终实装入仓单 ID,尾程建单唯一来源 |
| `sourceInboundCode` | 本柜最终实装入仓码快照 |
| `rootInboundId` | 头程根入仓单 ID,用于追溯 |
| `sourceOrderId` | 本柜最终实装订单 ID |
| `sourceOrderNo` | 本柜最终实装订单号快照 |
| `rootOrderId` | 根订单 ID,用于追溯 |
未发生部分拆分时,`sourceInboundId` 可以等于原入仓单 ID。发生拆分时,必须使用 `LOADED` 分片 ID。
## 5. 事件契约
事件信封:
| 属性 | 值 |
|---|---|
| `eventType` | `FirstLegLoadingPlanCompleted` |
| `eventVersion` | `1` |
| `sourceRegion` | `CN` |
| `targetRegion` | `OVERSEAS` |
| `tenantId` | 当前分组的客户账号 ID |
| `aggregateType` | `LOADING_PLAN` |
| `aggregateId` | 装柜计划 ID |
| `aggregateVersion` | 装柜计划完成后的版本 |
| `handoverVersion` | 当前租户在该柜次的交接版本,首版为 1 |
建议的 V1 payload:
```json
{
"loadingPlanId": "208600001",
"loadingPlanNo": "LP202608130001",
"containerBookingId": "208500001",
"containerNo": "MSCU1234567",
"containerType": "40HQ",
"sealNo": "SEAL001",
"voyageNo": "V001",
"originWarehouseId": "1001",
"completedAt": "2026-08-13T08:30:00Z",
"orders": [
{
"sourceInboundId": "208700001",
"sourceInboundCode": "RK-202608130001",
"rootInboundId": "208600101",
"sourceOrderId": "208800001",
"sourceOrderNo": "FL202608130001",
"rootOrderId": "208800001",
"customerName": "示例客户",
"channelCode": "SEA",
"channelName": "海运",
"destinationWarehouseId": "3001",
"destinationWarehouseCode": "ONT8",
"destinationWarehouseName": "FBA ONT8",
"packageCount": 10,
"palletCount": 2,
"grossWeightKg": 1200.000,
"volumeCbm": 8.5000,
"items": [
{
"sourceInboundDetailId": "208710001",
"skuCode": "SKU-001",
"productNameZh": "示例商品",
"productNameEn": "Sample product",
"packageCount": 10,
"productQuantity": 200,
"unitsPerCarton": 20,
"grossWeightKg": 1200.000,
"volumeCbm": 8.5000,
"fbaTrackingNo": "FBA123",
"shipmentNo": "SHIP123",
"shippingMark": "MARK-001"
}
]
}
]
}
```
约束:
- `orders` 非空,并按 `sourceInboundId` 升序输出,保证 payload 摘要稳定。
- 同一个事件内 `sourceInboundId` 唯一。
- `items` 非空,并按 `sourceInboundDetailId` 升序输出。
- 订单汇总箱数、重量和体积必须由该订单的实装明细计算,不使用拆分前总量。
- 文件只传不可变文件引用、区域和摘要,不传二进制内容。
- V1 消费者忽略未知字段;已有字段不改变含义或类型。
### 5.1 订单类型分支
`orderType`、`pallets`、`palletNo`、`skuPackages` 和 `cartonMarks` 必须正式加入头程公共事件契约及发送端,不能长期依赖尾程读取原始 JSON 扩展字段。三个服务升级到同一 `common-event-contract` 版本后再启用生产流量。
`ONE_CLICK_DROPSHIP` 的一笔最终实装入仓单可包含一个或多个来源托盘快照。消费时必须满足:所有托盘的 SKU 箱数之和等于订单箱数、SKU 数量之和等于订单数量、每个 SKU 的箱唛数等于其箱数、同一托盘内 SKU 不重复、同一订单内来源托盘号和箱唛不重复。通过后按来源托盘创建一件代发档案及托盘级 SKU 库存,并创建箱唛记录和不可变箱唛流水。`source_pallet_no` 是头程冻结快照;`warehouse_pallet_id/pallet_no` 是 Warehouse API 分配的海外仓实物托盘,两者不得混用。
非 `ONE_CLICK_DROPSHIP` 类型只创建 `ll_order`、冻结明细和初始状态历史,来源 `palletCount` 按 0 入库,不创建一件代发 SKU/箱唛库存。后续入仓 PDA 分配的托盘属于海外仓作业资源。
## 6. 尾程建单幂等
尾程订单表必须有以下唯一约束:
```text
UNIQUE (customer_id, source_region, source_inbound_id)
```
建议同时保存:
- `source_event_id`
- `source_loading_plan_id`
- `source_loading_plan_version`
- `source_handover_version`
- `source_payload_hash`
消费事务必须原子完成:
1. 锁定或领取尾程业务 Inbox;
2. 校验事件类型、版本、租户和 payload;
3. 对 `orders` 中每一笔执行幂等建单;
4. 保存商品明细和来源快照;
5. 将业务 Inbox 标记为 `PROCESSED`。
相同 `eventId + payloadHash` 重复到达直接返回既有结果。相同来源入仓单且快照相同视为幂等;相同来源入仓单但关键快照不同必须进入冲突状态并告警,不能静默覆盖已开始履约的尾程订单。
事件内任意一笔订单建单失败时,整个事件消费事务回滚,不允许出现同一客户同一柜次只生成部分尾程订单。
同一实际集装箱的订单共享 `order_no`、`container_no` 和 `source_loading_plan_id`;`ll_order.id` 仍是每条订单独立的雪花 ID。整柜查询、整柜操作和转入库计划统一以 `containerNo` 聚合,不使用虚拟柜号 `cabinetNo`。数据库必须保留 `KEY idx_ll_order_container (customer_id, container_no)`。
## 7. 尾程订单初始状态
事件自动生成的订单建议使用:
```text
PENDING_ASSIGNMENT(待分配)
```
首期状态机建议:
```text
PENDING_ASSIGNMENT -> ASSIGNED -> ARRIVED -> CUSTOMS_CLEARED
-> DEVANNED -> WAREHOUSED -> DISPATCHED -> DELIVERED
```
异常使用独立异常标记和历史记录,不用一个 `ABNORMAL` 主状态覆盖实际履约节点。原型里的“是否异常”和“订单状态”应分别存储。
## 8. IAM 接入边界
- 账号、登录、租户、角色、菜单和按钮权限仍由头程 Auth/IAM 唯一写入。
- 头程需要从 HS256 迁移到 RS256 或 ES256,并发布可轮换 JWKS。
- 尾程网关和业务服务只验证公钥签名及本地权限投影,不读取国内 Redis,不保存签名私钥。
- 用户停用、租户冻结和权限版本变化通过跨区事件更新尾程本地投影。
- 尾程权限建议使用独立命名空间,例如 `platform:lastleg:order:list`、`platform:lastleg:order:assign`、`platform:lastleg:order:transfer` 和 `platform:lastleg:order:merge-pallet`,权限主数据仍维护在头程 IAM。
## 9. 独立窗口拆分
### W0:契约基线
仓库:头程公共事件契约和本文档。
交付:事件常量、V1 payload DTO/JSON Schema、兼容性测试和样例。W1-W4 开始编码前必须冻结字段语义。
### W1:头程事件生产
仓库:`baiyue-firstleg` 的 `firstleg` 模块。
交付:按租户/最终实装入仓单构建快照;在装柜完成事务中写业务 Outbox;发布器、重试和测试。
不得修改尾程代码。必须覆盖多租户同柜、多入仓单、部分拆分、重复完成和事务回滚测试。
### W2:跨区 Event Bridge
仓库:两仓的 `event-bridge-service`,两端分别提交。
交付:Outbox 扫描、租约、HTTPS/mTLS 发送、Inbox 调度、指数退避、死信、人工补投、指标和告警。
不得在 Event Bridge 中执行尾程领域建单。
### W3:尾程订单领域
仓库:`baiyue-lastleg/lastleg`。
交付:尾程订单、明细、状态历史、业务 Inbox、事件消费者、列表/详情 API 和 OpenAPI;一笔 `sourceInboundId` 对应一笔尾程订单。
### W4:统一 IAM
仓库:`baiyue-firstleg` 的 `auth`、`iam`、`common-security`、`gateway`,以及尾程 `gateway` 的契约测试。
交付:非对称 JWT、JWKS、密钥轮换、尾程权限资源、权限/冻结事件和尾程本地拒绝策略。
### W5:尾程作业能力
仓库:`baiyue-lastleg/lastleg`,在 W3 数据模型冻结后开始。
交付:分配、批量转仓、批量合托、到港、清关、拆柜、入仓、派送和签收命令及审计。
### W6:前端原型
仓库:待确认的前端仓库。
交付:尾程订单列表、待分配页签、筛选、详情、批量操作和权限按钮。当前尾程仓没有前端工程,不应将页面代码放入 Maven 后端仓。
### W7:双仓联调验收
在 W1-W5 完成后执行。覆盖重复、乱序、跨区断网、Event Bridge 重启、数据库事务回滚、毒消息、人工补投、权限冻结和 JWKS 轮换。
## 10. 并行规则
- W0 先完成;W1、W2、W3、W4 可在契约冻结后并行。
- W5 等 W3 的表结构和状态机冻结后开始。
- W6 等 OpenAPI 冻结且前端仓确认后开始。
- 每个窗口独立分支,禁止两个窗口同时修改同一模块。
- 两仓分别提交和评审,不能用一个提交跨越两个 Git 仓库。
## 11. 完成定义
- 一个柜次、一个租户、三笔实装入仓单,稳定生成三笔尾程订单。
- 一个柜次包含两个租户时,产生两个隔离事件,任何一方 payload 不包含另一租户数据。
- 部分拣货使用 `LOADED` 分片建单,余量分到后续柜次时能生成另一笔尾程订单且不冲突。
- 同一事件投递 10 次只产生一次业务效果。
- 消费到第 N 笔失败时不保留前 N-1 笔半成品订单。
- 跨区中断后恢复能够自动追平,无需重新点击头程“装柜完成”。
- 尾程完全断开国内 MySQL、Redis、Nacos 和业务 Feign 后仍能完成验签、查询和后续履约。
+31
View File
@@ -0,0 +1,31 @@
# 尾程本地运行环境
运行目录为 `D:\baiyue-lastleg-runtime`。
| 组件或服务 | 端口 |
|---|---:|
| Redis | 6380 |
| Nacos | 8858 |
| Gateway | 9300 |
| Lastleg | 9303 |
| Warehouse | 9305 |
| File | 9306 |
| Print | 9307 |
| Audit | 9310 |
| Integration | 9311 |
| Event Bridge | 9312 |
先启动基础设施:
```powershell
powershell -ExecutionPolicy Bypass -File D:\baiyue-lastleg-runtime\start-infra.ps1
```
在项目 PowerShell 中加载本地变量:
```powershell
cd D:\baiyue-lastleg
. .\scripts\local-env.ps1
```
尾程不会连接头程 Redis 或 Nacos;文件上传目录为 `D:\baiyue-lastleg-runtime\uploads`。
+70
View File
@@ -0,0 +1,70 @@
# 尾程仓库边界说明
## 一、拆分目标
尾程仓库是可独立构建、发布和部署的海外业务单元。其生命周期、数据库、Nacos 命名空间、Redis、对象存储和发布流水线均与头程仓库隔离。两个仓库只通过已发布的 Maven 公共制品和版本化跨区事件建立依赖。
## 二、允许进入本仓库的能力
- 海外网关及尾程显式路由。
- 尾程履约、海外仓储、文件、打印、审计和外部渠道适配服务。
- 跨区事件桥及其 Outbox、Inbox、投递尝试数据。
- 海外 Nacos 模板、环境变量模板和部署脚本。
- 尾程专用数据库脚本、测试和中文文档。
## 三、禁止进入本仓库的能力
- 登录、注册、刷新令牌和账号凭据管理。
- 租户、企业、成员、角色、菜单、页面、按钮和接口权限主数据管理。
- 国内订单主档、头程订舱、装柜、报关和国内仓作业源码。
- `common-*` 公共源码以及头程仓父工程源码。
- 对国内 MySQL、Redis、Nacos 和内部服务的公网同步调用。
- 国内服务的网关路由、Swagger 聚合地址和服务发现自动路由。
## 四、依赖方向
```text
头程公共源码 -> Maven 私有仓库 -> 尾程项目
国内业务事实 -> 国内 Outbox -> 跨区事件桥 -> 海外 Inbox -> 尾程本地投影
尾程业务事实 -> 海外 Outbox -> 跨区事件桥 -> 国内 Inbox -> 国内轨迹投影
```
尾程业务模块不得反向成为公共制品的依赖,公共制品也不得引用任一区域的业务模块。
## 五、认证授权落地规则
1. 国内统一认证中心使用 RS256 或 ES256 签发 JWT,并发布可轮换的 JWKS。
2. 海外只缓存公钥,禁止存放国内签名私钥或共享对称密钥。
3. JWT 至少携带 `userId`、`tenantId`、`appCode`、`roles`、`authorities`、`permissionVersion`、`jti`、`iat` 和 `exp`。
4. 海外网关先验证签名、签发方、有效期和应用端,再由业务服务校验按钮权限、租户和资源归属。
5. 权限变更、账号停用、租户冻结通过事件更新海外本地权限投影,不允许请求时跨区查询。
6. 权限事件延迟期间通过短时效令牌、权限版本比较和高风险操作冻结规则控制风险。
## 六、服务和数据库所有权
| 服务 | 所有事实 | 禁止事项 |
|---|---|---|
| 尾程服务 | 到港、清关、拆柜、入海外仓、派送、签收 | 修改国内订单主档 |
| 海外仓储服务 | 海外库位、库存和作业流水 | 查询国内仓储库 |
| 海外文件服务 | 海外文件元数据和对象存储引用 | 承担企业资质审核流程 |
| 海外打印服务 | 海外标签模板、打印任务和状态 | 跨库读取文件表 |
| 海外审计服务 | 海外操作审计 | 写入国内审计库 |
| 海外集成服务 | 海外承运商、清关和平台适配 | 直接修改尾程核心表 |
| 海外事件桥 | 收发状态、幂等键和重试记录 | 代替业务服务处理领域规则 |
## 七、跨区事件最低规范
事件信封必须包含事件编号、事件类型、契约版本、来源区域、目标区域、聚合编号、聚合版本、租户编号、发生时间、链路编号和负载摘要。消费者使用事件编号保证幂等,使用聚合版本拒绝乱序覆盖,契约只允许向后兼容演进。
禁止通过跨区同步 HTTP 调用创建或修改尾程聚合。事件桥必须具备持久化 Outbox、持久化 Inbox、指数退避重试、死信、人工补投、摘要冲突检测和可观测指标;生产链路使用 HTTPS 双向证书或私网连接。
## 八、当前草案状态
- 已完成独立根 POM、业务模块抽取、公共依赖坐标调整和海外配置收敛。
- 已移除文件服务中依赖国内权限服务的企业资质链路。
- 海外网关已改为 JWKS 验签,强制校验 Audience 和必要身份声明,并默认启用海外显式路由。
- 公共安全组件的 JWKS 解码、本地权限快照和冻结事件处理仍需在公共制品中完成。
- 海外提单、装箱单、清关文件和签收证明能力仍待实现。
- 海外事件桥到尾程消费者的本地调度已具备租约、指数退避、投递审计、死信和人工补投;头程跨区发送端、尾程反向回传和生产 mTLS 仍需完成联调验收。
只有上述待办完成并通过故障注入、跨区断网、重复事件、乱序事件和密钥轮换验收后,尾程服务才允许迁移到海外生产环境。
+400
View File
@@ -0,0 +1,400 @@
# 头程与尾程双仓拆分审核方案
## 一、拆分结论
项目拆分为两个完全独立的 Git 仓库:
- 头程仓负责国内业务、统一登录、租户、用户、角色、菜单、按钮权限、订单主数据和头程履约。
- 尾程仓负责海外尾程履约以及海外本地仓储、文件、打印、审计、外部渠道适配。
- 登录和权限数据只有头程仓可以写入,尾程仓不得包含 `auth`、`iam`、`order`、`firstleg` 源码。
- 两仓不得通过源码依赖、跨库查询或跨区域 Feign 调用协作,只能通过版本化事件契约交换业务事实。
- 公共组件由头程仓维护并发布到 Maven 私有仓库,尾程仓只引用明确版本的制品。
当前目录是待审核的拆分暂存稿,审核通过后再分别落到正式仓库目录并初始化独立 Git 仓库。
## 二、拆分后的目录树
```text
baiyue-firstleg
├─ pom.xml
├─ platform-bom
├─ common-core
├─ common-web
├─ common-security
├─ common-openapi
├─ common-audit
├─ common-event-contract
├─ gateway
├─ auth
├─ iam
├─ order
├─ firstleg
├─ warehouse
├─ file
├─ print
├─ audit-service
├─ integration-service
├─ event-bridge-service
├─ config
│ └─ nacos
│ └─ cn
├─ docs
├─ scripts
└─ docker-compose-infra.yml
baiyue-lastleg
├─ pom.xml
├─ gateway
├─ lastleg
├─ warehouse
├─ file
├─ print
├─ audit-service
├─ integration-service
├─ event-bridge-service
├─ config
│ └─ nacos
├─ docs
├─ scripts
└─ env.example
```
## 三、模块归属和职责
### 3.1 头程仓
| 模块 | 职责 | 数据归属 |
|---|---|---|
| `gateway` | 国内统一入口、鉴权、路由和接口文档聚合 | 不保存业务数据 |
| `auth` | 统一登录、令牌签发、刷新、注销和登录会话 | 国内认证库和国内 Redis |
| `iam` | 租户、用户、成员、角色、菜单、按钮权限和数据范围 | 国内权限库 |
| `order` | 订单主数据、状态编排和跨区订单事实发布 | 国内订单库 |
| `firstleg` | 揽收、入仓、装载、发运和跨区交接 | 国内头程库 |
| `warehouse` | 国内仓库、库位和出入库能力 | 国内仓储库 |
| `file` | 国内文件元数据和对象存储适配 | 国内文件库和国内对象存储 |
| `print` | 国内标签、单据和打印任务 | 国内打印库 |
| `audit-service` | 国内审计记录 | 国内审计库 |
| `integration-service` | 国内外部渠道适配 | 配置和必要的本地状态 |
| `event-bridge-service` | 国内跨区事件发送、接收、重试和幂等 | 国内事件桥库 |
| `common-*` | 公共基础能力 | 发布为 Maven 制品 |
| `common-event-contract` | 跨区事件信封、事件类型和校验规则 | 发布为 Maven 制品 |
| `platform-bom` | 统一公共制品版本 | 发布为 Maven 物料清单 |
### 3.2 尾程仓
| 模块 | 职责 | 数据归属 |
|---|---|---|
| `gateway` | 海外统一入口、本地验签、路由和访问控制 | 不保存业务数据 |
| `lastleg` | 到港、清关、拆柜、海外仓入库、派送和签收 | 海外尾程库 |
| `warehouse` | 海外仓库、库位和出入库作业 | 海外仓储库 |
| `file` | 海外业务文件、面单和签收证明 | 海外文件库和海外对象存储 |
| `print` | 海外标签、面单和打印任务 | 海外打印库 |
| `audit-service` | 海外本地操作审计 | 海外审计库 |
| `integration-service` | 海外仓、承运商、清关和平台渠道适配 | 配置和必要的本地状态 |
| `event-bridge-service` | 海外跨区事件接收、发送、重试和幂等 | 海外事件桥库 |
尾程仓明确禁止出现以下源码目录和服务制品依赖:
```text
auth
iam
order
firstleg
common-core
common-web
common-security
common-openapi
common-audit
common-event-contract
```
其中公共组件不是禁止使用,而是禁止复制源码;尾程仓必须通过 Maven 私有仓库引用。
## 四、登录和权限边界
1. 账号、密码、租户、用户、角色、菜单、按钮权限和数据范围只允许由头程仓的 `auth` 与 `iam` 写入。
2. 用户通过国内统一入口登录,由 `auth` 使用非对称算法签发短时效令牌。
3. 海外网关只缓存公开密钥并在本地验签,不保存签名私钥,不跨区读取国内 Redis 登录会话。
4. 令牌携带用户、租户、应用端、权限快照版本和必要的授权声明;海外服务仍需校验租户边界。
5. 用户停用、租户冻结、权限版本变化和高风险封禁通过事件同步为海外本地投影。
6. 正式迁往海外前,公共安全组件必须完成公开密钥验签和本地权限投影校验改造;当前基于共享密钥和 Redis 会话的实现不能直接作为跨区域生产方案。
## 五、公共制品发布清单
头程仓发布以下公共制品,尾程仓只消费,不得再次发布同一坐标:
| 制品坐标 | 类型 | 用途 |
|---|---|---|
| `com.baiyue.shared:baiyue-platform-bom:1.0.0-SNAPSHOT` | `pom` | 统一公共组件版本 |
| `com.baiyue.shared:baiyue-common-core:1.0.0-SNAPSHOT` | `jar` | 返回结构、异常、基础实体和数据库公共配置 |
| `com.baiyue.shared:baiyue-common-web:1.0.0-SNAPSHOT` | `jar` | 统一异常、链路标识和网络层公共能力 |
| `com.baiyue.shared:baiyue-common-security:1.0.0-SNAPSHOT` | `jar` | 认证信息解析和接口权限校验 |
| `com.baiyue.shared:baiyue-common-openapi:1.0.0-SNAPSHOT` | `jar` | 接口文档公共配置 |
| `com.baiyue.shared:baiyue-common-audit:1.0.0-SNAPSHOT` | `jar` | 业务审计切面和审计发送能力 |
| `com.baiyue.shared:common-event-contract:1.0.0-SNAPSHOT` | `jar` | 跨区事件信封、类型、摘要和校验规则 |
两个聚合父工程使用不同坐标:
```text
com.baiyue.firstleg:baiyue-firstleg-platform:1.0.0-SNAPSHOT
com.baiyue.lastleg:baiyue-lastleg-platform:1.0.0-SNAPSHOT
```
正式环境不得长期引用快照版本。审核和联调阶段可以使用快照版本,上线时应发布不可覆盖的正式版本,例如 `1.0.0`,并保留制品校验值和变更记录。
## 六、构建和发布顺序
### 6.1 本地构建
第一次构建或公共版本发生变化时,必须先构建头程仓:
```powershell
cd D:\baiyue-firstleg
mvn clean install
```
确认公共制品已经安装到本地 Maven 仓库后,再构建尾程仓:
```powershell
cd D:\baiyue-lastleg
.\scripts\check-boundary.ps1
mvn clean verify
```
### 6.2 私有仓库发布
用于团队开发、流水线或服务器构建时,头程仓先发布公共制品:
```powershell
cd D:\baiyue-firstleg
mvn clean deploy
```
尾程流水线确认指定的 `baiyue.shared.version` 已经存在且校验通过,然后执行自身构建。尾程仓不得通过相对路径访问头程仓,也不得依赖开发人员本地残留的公共源码。
### 6.3 正确的拓扑顺序
```text
头程父工程
→ 公共核心
→ 公共网络层
→ 公共安全
→ 公共接口文档
→ 公共审计
→ 公共事件契约
→ 公共版本清单发布
→ 头程业务服务构建
→ 尾程边界检查
→ 尾程业务服务构建
```
## 七、跨仓业务协作
### 7.1 强制规则
- 跨仓只传递业务事件,不共享源码模型。
- 不通过公网 Feign 直接调用对方内部服务。
- 不跨区域查询对方数据库、Redis、Nacos 或对象存储。
- 事件发送采用本地事务与发件箱,接收采用收件箱幂等。
- 同一事件重复到达只能产生一次业务效果。
- 失败事件必须支持指数退避、死信、告警和人工补投。
### 7.2 推荐事件链路
```text
业务服务本地事务
→ 本地发件箱
→ 本区域事件桥
→ 加密的跨区传输
→ 对方区域事件桥
→ 本地收件箱去重
→ 本地业务消费者
→ 本地投影或状态机更新
```
### 7.3 首批事件
| 方向 | 事件 | 用途 |
|---|---|---|
| 国内到海外 | 订单已创建 | 建立尾程订单投影 |
| 国内到海外 | 订单已取消 | 阻止未开始的尾程履约 |
| 国内到海外 | 头程已交接 | 建立尾程接货任务 |
| 国内到海外 | 货物已发运 | 提供预计到港和装载信息 |
| 国内到海外 | 权限版本已变化 | 刷新海外授权投影 |
| 国内到海外 | 租户已冻结 | 海外本地立即拒绝访问 |
| 海外到国内 | 尾程已接货 | 更新国内订单轨迹 |
| 海外到国内 | 已到港或已清关 | 更新节点并触发通知 |
| 海外到国内 | 已派送 | 更新订单状态和客户通知 |
| 海外到国内 | 已签收 | 完成尾程并回传签收证明引用 |
| 海外到国内 | 尾程异常 | 触发国内客服和订单异常流程 |
文件二进制不放入事件,只传文件标识、校验值、区域和受控下载引用。
## 八、数据库、Nacos 和 Redis 边界
### 8.1 数据库
- 每个微服务拥有独立数据库账号和独立逻辑库。
- 国内数据库只允许国内服务网络访问,海外数据库只允许海外服务网络访问。
- 同名逻辑库也必须部署在不同实例或不同区域资源中,不能把同一个 MySQL 实例暴露给两区。
- 服务之间不建立跨库外键,不使用跨库联表,不复制对方业务表。
- 跨区所需数据建立最小化本地投影,并记录来源事件、聚合版本和最后更新时间。
- 订单主数据归国内,尾程状态主数据归海外;国内只保存用于查询和通知的尾程轨迹投影。
### 8.2 Nacos
```text
国内命名空间:prod-cn
海外命名空间:prod-overseas
```
- 两区使用独立 Nacos 集群、独立地址和独立凭据。
- 服务发现仅在本区域内生效。
- 配置项可以使用相同的数据标识,但配置内容、密钥和资源地址必须按区域隔离。
- 网关关闭自动服务路由,只开放区域清单中明确声明的服务。
### 8.3 Redis
- 国内 Redis 保存国内登录会话、限流和国内业务缓存。
- 海外 Redis 只保存海外缓存、幂等辅助状态和本地权限投影,不保存或远程读取国内登录会话。
- 两区 Redis 不做公网主从复制,不复用密码和数据库编号作为隔离手段。
- 需要跨区同步的冻结或撤销事实通过事件传递,并在海外形成可过期的本地拒绝投影。
## 九、持续集成和发布流程
### 9.1 头程仓流水线
1. 检查仓库边界、敏感信息和禁止文件。
2. 校验全部 POM、代码格式、单元测试和契约测试。
3. 构建公共制品和头程业务服务。
4. 公共组件变更时先发布候选版本到 Maven 私有仓库。
5. 使用候选公共版本触发尾程兼容流水线。
6. 两仓都通过后,将公共候选版本提升为不可覆盖的正式版本。
7. 构建带提交号和版本号的国内服务镜像。
8. 部署国内测试环境,完成数据库迁移、健康检查和一期验收。
9. 通过审批后灰度发布国内生产环境。
### 9.2 尾程仓流水线
1. 执行仓库边界脚本,禁止头程业务源码和公共组件源码进入仓库。
2. 从私有仓库解析锁定的公共版本,不使用动态版本范围。
3. 在干净 Maven 缓存中执行编译、单元测试、契约测试和集成测试。
4. 校验海外配置中不存在国内数据库、Redis、Nacos 和内部服务地址。
5. 构建带提交号、尾程版本和公共组件版本标签的镜像。
6. 部署海外测试环境并执行事件往返、幂等、重试和断网恢复测试。
7. 通过审批后按租户或新订单灰度,不迁移在途订单的写入归属。
公共制品发布失败或尾程兼容流水线失败时,禁止头程公共版本进入正式仓库。
## 十、版本兼容规则
1. 公共制品和事件契约使用语义化版本。
2. 补丁版本只允许缺陷修复;次版本允许向后兼容的字段和能力增加;主版本用于不兼容变更。
3. 事件必须携带事件类型、事件标识、契约版本、生产区域、目标区域、聚合标识、聚合版本、时间和负载摘要。
4. 新增字段必须可选或具有默认语义,消费者必须忽略不认识的附加字段。
5. 不允许在同一主版本中删除字段、改变字段含义或复用旧事件类型表达新语义。
6. 生产者至少兼容当前和上一版消费者;消费者至少支持当前和上一版事件契约。
7. 公共版本升级必须同时通过头程与尾程测试,尾程仓在自己的父 POM 中明确锁定版本。
8. 每个服务镜像记录业务版本、公共制品版本、契约版本和源代码提交号,便于回滚和审计。
## 十一、迁移步骤
1. 审核并冻结本方案中的模块归属、数据主权和首批事件清单。
2. 在原仓库冻结结构性新增,只允许必要缺陷修复,避免拆分期间双向漂移。
3. 完成暂存仓的中文乱码检查、父 POM、模块坐标和区域配置校验。
4. 建立 Maven 私有仓库的正式版、候选版和快照版发布策略。
5. 先发布头程公共物料清单、公共组件和事件契约。
6. 在干净环境构建尾程仓,证明其不依赖原单体目录和本地源码。
7. 分别创建 `D:\baiyue-firstleg` 与 `D:\baiyue-lastleg`,各自初始化独立 Git 仓库和远程仓库。
8. 先在国内部署头程仓全部一期服务,尾程一期仍可在国内独立环境运行,但使用尾程仓代码和独立资源。
9. 完成发件箱、事件桥、收件箱、业务消费者和轨迹通知闭环。
10. 事件链路先影子运行,对比原链路结果,不立即切换业务主写。
11. 海外基础设施准备完成后,仅将新创建、未履约的尾程聚合灰度到海外。
12. 已在途订单保持原区域完成,不在履约过程中迁移数据库写入权。
13. 灰度稳定后扩大流量,保留事件回放、数据核对和版本回滚能力。
14. 双仓稳定运行并完成审计后,再归档原 `D:\baiyue`,不得提前删除。
## 十二、验收清单
### 12.1 仓库和构建
- [ ] 两个目录分别存在且各自只有一个独立 `.git`。
- [ ] 尾程仓不存在 `auth`、`iam`、`order`、`firstleg` 和公共组件源码目录。
- [ ] 两个聚合父工程及重复区域服务不存在相同可发布坐标冲突。
- [ ] 头程仓可执行 `mvn clean verify`。
- [ ] 头程公共制品可执行 `mvn deploy` 并从私有仓库重新下载。
- [ ] 清空本地相关制品后,尾程仓仍可独立执行 `mvn clean verify`。
- [ ] 两仓均未提交 `target`、密钥、密码、日志和开发工具缓存。
### 12.2 登录权限
- [ ] 登录、租户、用户、角色、菜单、按钮权限只有国内写入口。
- [ ] 海外网关使用公开密钥本地验签,不保存国内签名私钥。
- [ ] 海外服务不连接国内 Redis 会话。
- [ ] 租户冻结、用户停用和权限版本变化可在规定时间内同步到海外。
- [ ] 平台端与商户端权限、按钮权限和租户数据边界均有自动化测试。
### 12.3 跨区事件
- [ ] 订单、头程交接、尾程节点和异常事件均有明确契约版本。
- [ ] 发件箱与业务写入属于同一本地事务。
- [ ] 收件箱按事件标识幂等,重复事件不重复推进状态。
- [ ] 乱序、延迟、重复、摘要冲突和非法目标区域均有测试。
- [ ] 断网恢复、指数退避、死信告警和人工补投可用。
- [ ] 跨区链路启用传输加密、双向认证或等效的服务身份校验。
- [ ] 两仓不存在跨区 Feign 地址和对方内部数据库连接。
### 12.4 数据和基础设施
- [ ] 国内与海外 MySQL、Redis、Nacos、消息系统和对象存储物理隔离。
- [ ] 每个服务使用独立数据库账号且权限最小化。
- [ ] 没有跨库查询、跨库外键和公网数据库访问。
- [ ] 国内与海外网关仅暴露本区域允许的路由。
- [ ] 备份、恢复、数据保留、审计和敏感信息脱敏策略按区域验证。
### 12.5 发布迁移
- [ ] 镜像可追溯到代码提交、业务版本、公共版本和契约版本。
- [ ] 数据库脚本支持升级校验和回滚预案。
- [ ] 新订单灰度、在途订单保持原区域和失败回切规则已演练。
- [ ] 国内订单轨迹与海外尾程事实完成自动核对。
- [ ] 原仓库只在双仓稳定验收后归档,归档前保留只读备份。
## 十三、当前完成情况
### 13.1 已完成
- 已在可写暂存目录形成头程和尾程两个独立 Maven 聚合工程草案。
- 已将 `auth`、`iam`、`order`、`firstleg` 和全部公共组件源码仅放入头程草案。
- 已确认尾程草案不包含上述头程业务源码目录和编译期业务包引用。
- 已为两个父工程使用不同的组织坐标,降低聚合制品冲突风险。
- 已建立公共物料清单,尾程通过 `com.baiyue.shared` 公共制品引用公共能力。
- 已按国内和海外分别整理 Gateway 与 Nacos 配置草案。
- 已为尾程草案提供仓库边界检查脚本和构建脚本。
- 已从尾程文件服务草案中移除同步调用国内权限服务的资格上传链路。
- 已保留跨区事件桥和公共事件契约骨架。
### 13.2 待完成
- 待用户审核本方案后,才创建两个正式 Git 仓库;当前暂存目录不是最终交付仓库。
- 待修复暂存文件中已有的中文编码显示问题,并统一以无字节顺序标记的 UTF-8 保存。
- 待配置 Maven 私有仓库地址、凭据、发布权限和不可覆盖的正式版本策略。
- 待检查并消除两个仓中重复区域服务的最终发布坐标冲突。
- 待将公共安全组件从共享密钥与国内 Redis 会话校验改造为公开密钥验签和海外本地授权投影。
- 待实现真实的发件箱扫描、跨区发送、收件箱消费、重试、死信和人工补投。
- 待为订单、头程、尾程、权限冻结和轨迹通知接入真实事件生产者与消费者。
- 待补齐尾程业务表、仓储作业、文件对象存储、打印和渠道适配的实际业务能力。
- 待在干净 Maven 仓库中验证“头程发布公共制品后,尾程完全独立构建”。
- 待执行两仓全部单元测试、契约测试、集成测试、安全测试和区域故障演练。
- 待初始化独立 Git 历史、远程仓库保护规则和双仓持续集成流水线。
## 十四、审核决策点
本次审核需要确认以下内容:
1. 登录、租户、用户、角色、菜单和按钮权限永久归头程仓唯一写入。
2. 订单主数据归头程,尾程只维护事件投影和尾程状态主数据。
3. 公共组件由头程仓发布,尾程不复制公共源码。
4. 两仓跨区只使用事件,不开放跨区内部 Feign 和数据库访问。
5. 一期先在国内完成独立双仓部署验证,后期只迁移新尾程聚合到海外。
6. 当前 `D:\baiyue` 保留为拆分来源和回滚备份,双仓验收前不删除。
+68
View File
@@ -0,0 +1,68 @@
# 海外部署单元标识
REGION_CODE=OVERSEAS
DEPLOYMENT_CELL=prod-overseas
DATA_RESIDENCY=OVERSEAS
# 海外注册中心和配置中心
NACOS_SERVER_ADDR=nacos-overseas.internal:8858
NACOS_NAMESPACE=prod-overseas
NACOS_GROUP=BAIYUE
NACOS_USERNAME=
NACOS_PASSWORD=
# 海外本地缓存,不得连接国内 Redis
REDIS_HOST=redis-overseas.internal
REDIS_PORT=6380
REDIS_PASSWORD=
REDIS_DATABASE=0
PERMISSION_SNAPSHOT_KEY_PREFIX=baiyue:permission-snapshot
# 国内认证中心只公开非对称签名公钥,海外不得保存签名私钥
JWT_ISSUER=https://auth.example.com
JWT_AUDIENCE=lastleg-api
JWT_JWK_SET_URI=https://auth.example.com/.well-known/jwks.json
INTERNAL_SERVICE_TOKEN=
# 海外各服务独立数据库
LASTLEG_DB_URL=jdbc:mysql://mysql-overseas.internal:3306/baiyue_lastleg?useUnicode=true&characterEncoding=utf8&serverTimezone=UTC
LASTLEG_DB_USERNAME=
LASTLEG_DB_PASSWORD=
WAREHOUSE_DB_URL=jdbc:mysql://mysql-overseas.internal:3306/baiyue_lastlag_warehouse?useUnicode=true&characterEncoding=utf8&serverTimezone=UTC
WAREHOUSE_DB_USERNAME=
WAREHOUSE_DB_PASSWORD=
FILE_DB_URL=jdbc:mysql://mysql-overseas.internal:3306/baiyue_file?useUnicode=true&characterEncoding=utf8&serverTimezone=UTC
FILE_DB_USERNAME=
FILE_DB_PASSWORD=
PRINT_DB_URL=jdbc:mysql://mysql-overseas.internal:3306/baiyue_print?useUnicode=true&characterEncoding=utf8&serverTimezone=UTC
PRINT_DB_USERNAME=
PRINT_DB_PASSWORD=
AUDIT_DB_URL=jdbc:mysql://mysql-overseas.internal:3306/baiyue_audit?useUnicode=true&characterEncoding=utf8&serverTimezone=UTC
AUDIT_DB_USERNAME=
AUDIT_DB_PASSWORD=
EVENT_BRIDGE_DB_URL=jdbc:mysql://mysql-overseas.internal:3306/baiyue_event_bridge?useUnicode=true&characterEncoding=utf8&serverTimezone=UTC
EVENT_BRIDGE_DB_USERNAME=
EVENT_BRIDGE_DB_PASSWORD=
EVENT_BRIDGE_PORT=9300
LASTLEG_CONSUMER_URL=https://lastleg-service.internal/internal/lastleg/events
EVENT_DISPATCH_FIXED_DELAY=3s
EVENT_DISPATCH_BATCH_SIZE=50
EVENT_DISPATCH_MAX_ATTEMPTS=10
EVENT_DISPATCH_LEASE_DURATION=30s
EVENT_DISPATCH_INITIAL_BACKOFF=5s
EVENT_DISPATCH_MAX_BACKOFF=15m
EVENT_DISPATCH_CONNECT_TIMEOUT=3s
EVENT_DISPATCH_REQUEST_TIMEOUT=10s
# 海外对象存储和外部渠道适配
FILE_STORAGE_PROVIDER=s3
FILE_STORAGE_ENDPOINT=
FILE_STORAGE_BUCKET=
FILE_STORAGE_ACCESS_KEY=
FILE_STORAGE_SECRET_KEY=
AMAZON_SP_API_ENABLED=true
AMAZON_SP_API_CLIENT_ID=
AMAZON_SP_API_CLIENT_SECRET=
AMAZON_SP_API_REFRESH_TOKEN=
AMAZON_SP_API_AWS_ACCESS_KEY=
AMAZON_SP_API_AWS_SECRET_KEY=
AMAZON_SP_API_REGION=
+34
View File
@@ -0,0 +1,34 @@
# Event Bridge Service 模块指引
## 职责边界
- 负责跨区事件接收、Inbox 持久化、向海外尾程投递、失败重试、投递记录和人工补投。
- 只承载可靠传输,不执行尾程建单逻辑,不查询头程或尾程业务表。
- 事件结构和 Payload 哈希以 `common-event-contract` 为准。
## 快速索引
| 功能 | 入口 |
|---|---|
| 接收、状态、人工补投 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` |
| 数据库 | `src/main/resources/db/event-bridge.sql` |
| 测试 | `src/test/java/com/baiyue/eventbridge/` |
## 可靠投递规则
- `RECEIVED` 和到期的 `RETRY` 都必须可被调度;只有尾程确认成功后才能标记 `PROCESSED`。
- 每次投递保存 attempt、错误和时间;达到上限进入 `DEAD`,经授权人工补投后重新进入重试队列。
- 领取任务必须有数据库租约/并发保护,避免多实例重复发送;尾程幂等仍是最终保障。
- 内部接口使用 `X-Internal-Service-Token`,生产还应置于受信网络或 mTLS 后。
## 修改与验证
- 定向验证:`mvn -pl event-bridge-service -am test`。
- 重试相关修改至少覆盖 `RECEIVED`、`RETRY`、成功、失败、租约、最大次数和人工补投。
+23
View File
@@ -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.lastleg</groupId><artifactId>baiyue-lastleg-platform</artifactId>
<version>1.0.0-SNAPSHOT</version><relativePath>../pom.xml</relativePath>
</parent>
<artifactId>event-bridge-service</artifactId>
<name>event-bridge-service</name>
<dependencies>
<dependency><groupId>com.baiyue.shared</groupId><artifactId>baiyue-common-web</artifactId></dependency>
<dependency><groupId>com.baiyue.shared</groupId><artifactId>baiyue-common-openapi</artifactId></dependency>
<dependency><groupId>com.baiyue.shared</groupId><artifactId>common-event-contract</artifactId></dependency>
<dependency><groupId>com.baomidou</groupId><artifactId>mybatis-plus-spring-boot3-starter</artifactId></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>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,21 @@
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);
}
}
@@ -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;
}
}
@@ -0,0 +1,72 @@
package com.baiyue.eventbridge.controller;
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;
import io.swagger.v3.oas.annotations.responses.ApiResponses;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.annotation.Resource;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestHeader;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.bind.annotation.PathVariable;
/**
* 事件桥内部接口。生产环境必须在网关或基础设施层增加 mTLS 与来源白名单。
*/
@Tag(name = "事件桥内部接口", description = "跨区域事件的幂等接收与能力状态")
@RestController
@RequestMapping("/internal/event-bridge")
public class EventBridgeController {
@Resource private EventInboxService eventInboxService;
@Resource private EventReplayService eventReplayService;
@Resource private InternalServiceTokenGuard internalServiceTokenGuard;
@Value("${baiyue.region:OVERSEAS}")
private String region;
@Operation(summary = "幂等接收事件", description = "当前仅完成数据库接收骨架,不代表生产跨区传输和 mTLS 已开通")
@ApiResponses({
@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "200", description = "事件已接收或判定为重复"),
@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "401", description = "内部服务令牌无效"),
@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "503", description = "内部服务令牌未配置,接收接口关闭")
})
@PostMapping("/events")
public ApiResponse<EventReceiveResult> receive(
@Parameter(description = "内部服务共享令牌")
@RequestHeader(value = "X-Internal-Service-Token", required = false) String token,
@RequestBody EventEnvelope envelope) {
internalServiceTokenGuard.verify(token);
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")
public ApiResponse<BridgeStatus> status() {
return ApiResponse.success(new BridgeStatus("运行中", region, true, false));
}
}
@@ -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;
}
@@ -0,0 +1,42 @@
package com.baiyue.eventbridge.domain;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import lombok.Data;
import java.time.LocalDateTime;
import java.time.Instant;
/**
* 事件桥入站幂等记录。
*/
@Data
@TableName("bridge_inbox")
public class BridgeInbox {
@TableId
private String eventId;
private String idempotencyKey;
private String eventType;
private Integer eventVersion;
private String sourceRegion;
private String targetRegion;
private String customerId;
private String aggregateType;
private String aggregateId;
private Long aggregateVersion;
private Long handoverVersion;
private String traceId;
private String correlationId;
private String causationId;
private Instant occurredAt;
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,14 @@
package com.baiyue.eventbridge.dto;
import io.swagger.v3.oas.annotations.media.Schema;
/**
* 事件桥能力状态,不代表生产网络链路已经开通。
*/
@Schema(description = "事件桥能力状态")
public record BridgeStatus(
@Schema(description = "服务状态") String status,
@Schema(description = "当前区域") String region,
@Schema(description = "数据库幂等接收是否启用") boolean inboxEnabled,
@Schema(description = "生产级跨区传输是否启用") boolean productionTransportEnabled) {
}
@@ -0,0 +1,15 @@
package com.baiyue.eventbridge.dto;
import io.swagger.v3.oas.annotations.media.Schema;
import java.time.LocalDateTime;
/**
* 事件接收结果。
*/
@Schema(description = "事件接收结果")
public record EventReceiveResult(
@Schema(description = "事件标识") String eventId,
@Schema(description = "接收结果:已接收或重复") String result,
@Schema(description = "接收时间") LocalDateTime receivedAt) {
}
@@ -0,0 +1,4 @@
package com.baiyue.eventbridge.dto;
public record EventReplayRequest(String reason) {
}
@@ -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> {
}
@@ -0,0 +1,64 @@
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);
}
@@ -0,0 +1,32 @@
package com.baiyue.eventbridge.security;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.http.HttpStatus;
import org.springframework.stereotype.Component;
import org.springframework.web.server.ResponseStatusException;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
/**
* 内部事件接收接口的共享令牌校验器;未配置令牌时默认关闭接收。
*/
@Component
public class InternalServiceTokenGuard {
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()) {
throw new ResponseStatusException(HttpStatus.SERVICE_UNAVAILABLE, "内部事件接收令牌未配置,接口已关闭");
}
byte[] expected = expectedToken.getBytes(StandardCharsets.UTF_8);
byte[] actual = actualToken == null ? new byte[0] : actualToken.getBytes(StandardCharsets.UTF_8);
if (!MessageDigest.isEqual(expected, actual)) {
throw new ResponseStatusException(HttpStatus.UNAUTHORIZED, "内部服务令牌无效");
}
}
}
@@ -0,0 +1,91 @@
package com.baiyue.eventbridge.service;
import com.baiyue.common.event.EventEnvelope;
import com.baiyue.common.event.EventPayloadCanonicalizer;
import com.baiyue.common.event.EventPayloadHash;
import com.baiyue.eventbridge.domain.BridgeInbox;
import com.baiyue.eventbridge.dto.EventReceiveResult;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.ObjectMapper;
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;
/**
* 入站事件校验与幂等落库服务。
*/
@Service
public class EventInboxService {
@Resource private InboxStore inboxStore;
@Resource private ObjectMapper objectMapper;
@Value("${baiyue.region:CN}")
private String localRegion;
@Transactional
public EventReceiveResult receive(EventEnvelope envelope) {
envelope.requireValid();
if (!envelope.targetRegion().name().equalsIgnoreCase(localRegion)) {
throw new IllegalArgumentException("事件目标区域与当前事件桥区域不一致");
}
boolean currentHash = EventPayloadHash.matches(EventPayloadCanonicalizer.canonicalize(envelope.payload()), envelope.payloadHash());
boolean legacyHash = EventPayloadHash.matches(EventPayloadCanonicalizer.canonicalizeLegacy(envelope.payload()), envelope.payloadHash());
if (!currentHash && !legacyHash) {
throw new IllegalArgumentException("payloadHash 与规范化事件负载不一致");
}
BridgeInbox existing = inboxStore.findByEventId(envelope.eventId());
if (existing != null) {
return duplicateOrConflict(existing, envelope);
}
BridgeInbox inbox = toInbox(envelope);
try {
inboxStore.insert(inbox);
return new EventReceiveResult(envelope.eventId(), "已接收", inbox.getReceivedAt());
} catch (DuplicateKeyException exception) {
BridgeInbox concurrent = inboxStore.findByEventId(envelope.eventId());
if (concurrent == null) {
throw exception;
}
return duplicateOrConflict(concurrent, envelope);
}
}
private EventReceiveResult duplicateOrConflict(BridgeInbox existing, EventEnvelope envelope) {
if (!existing.getPayloadHash().equalsIgnoreCase(envelope.payloadHash())) {
throw new IllegalArgumentException("相同 eventId 对应的 payloadHash 不一致,拒绝覆盖既有事件");
}
return new EventReceiveResult(envelope.eventId(), "重复", existing.getReceivedAt());
}
private BridgeInbox toInbox(EventEnvelope envelope) {
BridgeInbox inbox = new BridgeInbox();
inbox.setEventId(envelope.eventId());
inbox.setIdempotencyKey(envelope.sourceRegion() + ":" + envelope.eventId());
inbox.setEventType(envelope.eventType());
inbox.setEventVersion(envelope.eventVersion());
inbox.setSourceRegion(envelope.sourceRegion().name());
inbox.setTargetRegion(envelope.targetRegion().name());
inbox.setCustomerId(envelope.tenantId());
inbox.setAggregateType(envelope.aggregateType());
inbox.setAggregateId(envelope.aggregateId());
inbox.setAggregateVersion(envelope.aggregateVersion());
inbox.setHandoverVersion(envelope.handoverVersion());
inbox.setTraceId(envelope.traceId());
inbox.setCorrelationId(envelope.correlationId());
inbox.setCausationId(envelope.causationId());
inbox.setOccurredAt(envelope.occurredAt());
inbox.setPayloadHash(envelope.payloadHash().toLowerCase(java.util.Locale.ROOT));
try {
inbox.setPayloadJson(objectMapper.writeValueAsString(envelope.payload()));
} catch (JsonProcessingException exception) {
throw new IllegalArgumentException("事件负载无法序列化", exception);
}
inbox.setStatus("RECEIVED");
inbox.setReceivedAt(LocalDateTime.now());
return inbox;
}
}
@@ -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);
}
}
@@ -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());
}
}
@@ -0,0 +1,11 @@
package com.baiyue.eventbridge.service;
import com.baiyue.eventbridge.domain.BridgeInbox;
/**
* 入站幂等存储边界,便于隔离数据库实现并进行单元测试。
*/
public interface InboxStore {
BridgeInbox findByEventId(String eventId);
void insert(BridgeInbox inbox);
}
@@ -0,0 +1,7 @@
package com.baiyue.eventbridge.service;
import com.baiyue.eventbridge.domain.BridgeInbox;
public interface LastlegEventClient {
String deliver(BridgeInbox event) throws Exception;
}
@@ -0,0 +1,24 @@
package com.baiyue.eventbridge.service;
import com.baiyue.eventbridge.domain.BridgeInbox;
import com.baiyue.eventbridge.mapper.BridgeInboxMapper;
import jakarta.annotation.Resource;
import org.springframework.stereotype.Repository;
/**
* 基于 MyBatis-Plus 的入站幂等存储。
*/
@Repository
public class MybatisInboxStore implements InboxStore {
@Resource private BridgeInboxMapper mapper;
@Override
public BridgeInbox findByEventId(String eventId) {
return mapper.selectById(eventId);
}
@Override
public void insert(BridgeInbox inbox) {
mapper.insert(inbox);
}
}
@@ -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);
}
}
@@ -0,0 +1,54 @@
server:
port: ${EVENT_BRIDGE_PORT:9312}
spring:
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:8858}
username: ${NACOS_USERNAME:nacos}
password: ${NACOS_PASSWORD:nacos}
discovery:
group: ${NACOS_GROUP:BAIYUE}
namespace: ${NACOS_NAMESPACE:}
config:
namespace: ${NACOS_NAMESPACE:}
baiyue:
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:}
management:
endpoints:
web:
exposure:
include: health,info,prometheus
springdoc:
api-docs:
path: /v3/api-docs
@@ -0,0 +1,80 @@
CREATE DATABASE IF NOT EXISTS baiyue_event_bridge DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
USE baiyue_event_bridge;
CREATE TABLE IF NOT EXISTS bridge_outbox (
event_id VARCHAR(128) NOT NULL COMMENT '事件全局标识',
idempotency_key VARCHAR(191) NOT NULL COMMENT '发送幂等键',
event_type VARCHAR(64) NOT NULL COMMENT '事件类型',
event_version INT NOT NULL COMMENT '事件契约版本',
source_region VARCHAR(32) NOT NULL COMMENT '来源区域',
target_region VARCHAR(32) 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 '聚合版本',
handover_version BIGINT NULL COMMENT '跨区交接版本',
trace_id VARCHAR(128) NOT NULL COMMENT '链路标识',
correlation_id VARCHAR(128) NOT NULL COMMENT '业务关联标识',
causation_id VARCHAR(128) NULL COMMENT '上游事件标识',
occurred_at DATETIME(3) NOT NULL COMMENT '业务事实发生时间',
payload_hash VARCHAR(71) NOT NULL COMMENT '规范化负载摘要',
payload_json JSON NOT NULL COMMENT '事件负载',
status VARCHAR(32) NOT NULL DEFAULT 'PENDING' COMMENT '投递状态',
retry_count INT NOT NULL DEFAULT 0 COMMENT '已重试次数',
next_retry_at DATETIME(3) NULL COMMENT '下次重试时间',
lease_owner VARCHAR(128) NULL COMMENT '租约持有者',
lease_until DATETIME(3) NULL COMMENT '租约截止时间',
last_error VARCHAR(1000) NULL COMMENT '最后错误',
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间',
delivered_at DATETIME(3) NULL COMMENT '投递成功时间',
PRIMARY KEY (event_id),
UNIQUE KEY uk_bridge_outbox_idempotency (idempotency_key),
KEY idx_bridge_outbox_schedule (target_region, status, next_retry_at, lease_until)
) ENGINE=InnoDB COMMENT='跨区域事件待投递箱';
CREATE TABLE IF NOT EXISTS bridge_inbox (
event_id VARCHAR(128) NOT NULL COMMENT '事件全局标识',
idempotency_key VARCHAR(191) NOT NULL COMMENT '接收幂等键',
event_type VARCHAR(64) NOT NULL COMMENT '事件类型',
event_version INT NOT NULL COMMENT '事件契约版本',
source_region VARCHAR(32) NOT NULL COMMENT '来源区域',
target_region VARCHAR(32) 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 '聚合版本',
handover_version BIGINT NULL COMMENT '跨区交接版本',
trace_id VARCHAR(128) NOT NULL COMMENT '链路标识',
correlation_id VARCHAR(128) NOT NULL COMMENT '业务关联标识',
causation_id VARCHAR(128) NULL COMMENT '上游事件标识',
occurred_at DATETIME(3) NOT NULL COMMENT '业务事实发生时间',
payload_hash VARCHAR(71) NOT NULL COMMENT '规范化负载摘要',
payload_json JSON NOT NULL COMMENT '事件负载',
status VARCHAR(32) NOT NULL DEFAULT 'RECEIVED' COMMENT '消费状态',
lease_owner VARCHAR(128) NULL COMMENT '消费租约持有者',
lease_until DATETIME(3) NULL COMMENT '消费租约截止时间',
retry_count INT NOT NULL DEFAULT 0 COMMENT '消费重试次数',
next_retry_at DATETIME(3) NULL COMMENT '下次消费时间',
last_error VARCHAR(1000) NULL COMMENT '最后错误',
received_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '接收时间',
processed_at DATETIME(3) NULL COMMENT '消费完成时间',
PRIMARY KEY (event_id),
UNIQUE KEY uk_bridge_inbox_idempotency (idempotency_key),
KEY idx_bridge_inbox_consume (status, next_retry_at, lease_until)
) ENGINE=InnoDB COMMENT='跨区域事件已接收箱';
CREATE TABLE IF NOT EXISTS bridge_delivery_attempt (
id BIGINT NOT NULL AUTO_INCREMENT COMMENT '主键',
event_id VARCHAR(128) NOT NULL COMMENT '事件全局标识',
source_region VARCHAR(32) NOT NULL COMMENT '来源区域',
target_region VARCHAR(32) NOT NULL COMMENT '目标区域',
attempt_no INT NOT NULL COMMENT '投递序号',
status VARCHAR(32) NOT NULL COMMENT '投递结果',
response_code VARCHAR(64) NULL COMMENT '对端响应码',
error_message VARCHAR(1000) NULL COMMENT '错误信息',
started_at DATETIME(3) NOT NULL COMMENT '开始时间',
finished_at DATETIME(3) NULL COMMENT '完成时间',
PRIMARY KEY (id),
UNIQUE KEY uk_bridge_attempt (event_id, attempt_no),
KEY idx_bridge_attempt_event (event_id, started_at)
) ENGINE=InnoDB COMMENT='跨区域事件投递尝试记录';
@@ -0,0 +1,31 @@
package com.baiyue.eventbridge.security;
import org.junit.jupiter.api.Test;
import org.springframework.web.server.ResponseStatusException;
import static org.assertj.core.api.Assertions.assertThatCode;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
class InternalServiceTokenGuardTest {
@Test
void 正确令牌允许访问() {
InternalServiceTokenGuard guard = new InternalServiceTokenGuard("a-secure-internal-token");
assertThatCode(() -> guard.verify("a-secure-internal-token")).doesNotThrowAnyException();
}
@Test
void 未配置令牌时默认关闭接收接口() {
InternalServiceTokenGuard guard = new InternalServiceTokenGuard("");
assertThatThrownBy(() -> guard.verify("anything"))
.isInstanceOf(ResponseStatusException.class)
.hasMessageContaining("接口已关闭");
}
@Test
void 错误令牌被拒绝() {
InternalServiceTokenGuard guard = new InternalServiceTokenGuard("expected");
assertThatThrownBy(() -> guard.verify("wrong"))
.isInstanceOf(ResponseStatusException.class)
.hasMessageContaining("令牌无效");
}
}
@@ -0,0 +1,119 @@
package com.baiyue.eventbridge.service;
import com.baiyue.common.event.EventEnvelope;
import com.baiyue.common.event.EventPayloadCanonicalizer;
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 org.junit.jupiter.api.Test;
import org.springframework.dao.DuplicateKeyException;
import org.springframework.test.util.ReflectionTestUtils;
import java.time.Instant;
import java.time.LocalDateTime;
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;
class EventInboxServiceTest {
private final ObjectMapper objectMapper = new ObjectMapper();
private final InboxStore store = mock(InboxStore.class);
private final EventInboxService service = service("OVERSEAS");
@Test
void 首次事件落库并返回已接收() {
EventEnvelope envelope = envelope("sha256:placeholder");
envelope = withHash(envelope);
assertThat(service.receive(envelope).result()).isEqualTo("已接收");
verify(store).insert(any(BridgeInbox.class));
}
@Test
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.payloadHash());
when(store.findByEventId(envelope.eventId())).thenReturn(existing);
assertThat(service.receive(envelope).result()).isEqualTo("重复");
}
@Test
void 相同事件但摘要不同时拒绝覆盖() {
EventEnvelope envelope = withHash(envelope("sha256:placeholder"));
when(store.findByEventId(envelope.eventId())).thenReturn(existing("sha256:" + "0".repeat(64)));
assertThatThrownBy(() -> service.receive(envelope))
.isInstanceOf(IllegalArgumentException.class)
.hasMessageContaining("拒绝覆盖");
}
@Test
void 并发插入冲突后按既有记录判定为重复() {
EventEnvelope envelope = withHash(envelope("sha256:placeholder"));
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 目标区域与当前部署区域不一致时拒绝接收() {
EventInboxService cnService = service("CN");
EventEnvelope envelope = withHash(envelope("sha256:placeholder"));
assertThatThrownBy(() -> cnService.receive(envelope))
.isInstanceOf(IllegalArgumentException.class)
.hasMessageContaining("目标区域");
}
private EventEnvelope envelope(String hash) {
return new EventEnvelope("550e8400-e29b-41d4-a716-446655440000", "OrderApproved", 1,
Instant.parse("2026-07-29T08:30:00Z"), RegionCode.CN, RegionCode.OVERSEAS,
"20001", "ORDER", "90001", 1, null, "trace-1", "flow-1", null,
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(),
source.sourceRegion(), source.targetRegion(), source.tenantId(), source.aggregateType(),
source.aggregateId(), source.aggregateVersion(), source.handoverVersion(), source.traceId(),
source.correlationId(), source.causationId(), source.payload(), hash);
}
private BridgeInbox existing(String hash) {
BridgeInbox inbox = new BridgeInbox();
inbox.setEventId("550e8400-e29b-41d4-a716-446655440000");
inbox.setPayloadHash(hash);
inbox.setReceivedAt(LocalDateTime.of(2026, 7, 29, 18, 0));
return inbox;
}
}
@@ -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;
}
}
@@ -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();
}
}
+21
View File
@@ -0,0 +1,21 @@
<?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.lastleg</groupId><artifactId>baiyue-lastleg-platform</artifactId><version>1.0.0-SNAPSHOT</version><relativePath>../pom.xml</relativePath></parent>
<artifactId>file-service</artifactId>
<dependencies>
<dependency><groupId>com.baiyue.shared</groupId><artifactId>baiyue-common-audit</artifactId></dependency>
<dependency><groupId>com.baiyue.shared</groupId><artifactId>baiyue-common-openapi</artifactId></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></dependency>
<dependency><groupId>com.baiyue.shared</groupId><artifactId>baiyue-common-security</artifactId></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>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.file;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
/**
* 文件中心服务启动入口。
*/
@SpringBootApplication(scanBasePackages = "com.baiyue")
public class FileServiceApplication {
public static void main(String[] args) {
SpringApplication.run(FileServiceApplication.class, args);
}
}
@@ -0,0 +1,28 @@
package com.baiyue.file.controller;
import com.baiyue.common.core.api.ApiResponse;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.responses.ApiResponses;
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;
/**
* 文件中心状态检查接口。
*/
@RestController
@RequestMapping("/api/platform/v1/files")
@Tag(name = "文件中心状态", description = "文件中心服务可用性接口")
public class FileHealthController {
@GetMapping("/status")
@Operation(summary = "查询服务状态", description = "返回文件中心服务的逻辑运行状态")
@ApiResponses({
@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "200", description = "查询成功")
})
public ApiResponse<Map<String, String>> status() {
return ApiResponse.success(Map.of("service", "file-service", "status", "UP"));
}
}
+39
View File
@@ -0,0 +1,39 @@
server:
port: ${FILE_PORT:9306}
spring:
data:
redis:
host: ${REDIS_HOST:127.0.0.1}
port: ${REDIS_PORT:6380}
application:
name: file-service
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}
datasource:
driver-class-name: com.mysql.cj.jdbc.Driver
url: ${FILE_DB_URL:jdbc:mysql://127.0.0.1:3306/baiyue_file?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai&rewriteBatchedStatements=true}
username: ${FILE_DB_USERNAME:root}
password: ${FILE_DB_PASSWORD:}
servlet:
multipart:
max-file-size: 5MB
max-request-size: 6MB
baiyue:
security:
internal-service-token: ${INTERNAL_SERVICE_TOKEN:change-me-to-an-internal-token-at-least-32-bytes-long}
file:
storage-root: ${FILE_STORAGE_ROOT:D:/baiyue-lastleg-runtime/uploads}
management.endpoints.web.exposure.include: health,info,prometheus
+61
View File
@@ -0,0 +1,61 @@
-- 文件中心独占数据库,其他服务只保存文件逻辑ID。
CREATE DATABASE IF NOT EXISTS baiyue_file
DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci;
USE baiyue_file;
CREATE TABLE IF NOT EXISTS file_object (
id BIGINT UNSIGNED NOT NULL COMMENT '文件ID(雪花ID)',
tenant_id BIGINT UNSIGNED NOT NULL COMMENT '数据所属租户ID',
business_type VARCHAR(32) NULL COMMENT '关联业务类型',
business_id VARCHAR(64) NULL COMMENT '关联业务逻辑ID',
original_name VARCHAR(255) NOT NULL COMMENT '原始文件名',
object_key VARCHAR(512) NOT NULL COMMENT '对象存储键',
storage_provider VARCHAR(32) NOT NULL COMMENT '存储提供方',
bucket_name VARCHAR(128) NOT NULL COMMENT '存储桶名称',
content_type VARCHAR(128) NOT NULL COMMENT '文件内容类型',
size_bytes BIGINT UNSIGNED NOT NULL COMMENT '文件字节数',
sha256 CHAR(64) NULL COMMENT '文件SHA-256摘要',
status VARCHAR(24) NOT NULL DEFAULT 'ACTIVE' COMMENT '文件状态',
created_by BIGINT UNSIGNED NOT NULL COMMENT '创建人ID',
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间',
updated_by BIGINT UNSIGNED NOT NULL COMMENT '最后修改人ID',
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '更新时间',
deleted TINYINT(1) NOT NULL DEFAULT 0 COMMENT '逻辑删除标记',
version INT NOT NULL DEFAULT 0 COMMENT '乐观锁版本号',
PRIMARY KEY (id),
UNIQUE KEY uk_file_tenant_object (tenant_id, object_key),
KEY idx_file_tenant_business (tenant_id, business_type, business_id),
KEY idx_file_tenant_created (tenant_id, created_at)
) ENGINE=InnoDB COMMENT='文件元数据表';
CREATE TABLE IF NOT EXISTS file_download_token (
id BIGINT UNSIGNED NOT NULL COMMENT '下载令牌ID',
tenant_id BIGINT UNSIGNED NOT NULL COMMENT '数据所属租户ID',
file_id BIGINT UNSIGNED NOT NULL COMMENT '文件逻辑ID',
token_hash CHAR(64) NOT NULL COMMENT '下载令牌SHA-256摘要',
expires_at DATETIME(3) NOT NULL COMMENT '过期时间',
max_downloads INT UNSIGNED NOT NULL DEFAULT 1 COMMENT '最大下载次数',
download_count INT UNSIGNED NOT NULL DEFAULT 0 COMMENT '已下载次数',
revoked_at DATETIME(3) NULL COMMENT '撤销时间',
created_by BIGINT UNSIGNED NOT NULL COMMENT '创建人ID',
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间',
PRIMARY KEY (id),
UNIQUE KEY uk_download_token_hash (token_hash),
KEY idx_download_file (tenant_id, file_id),
KEY idx_download_expiry (expires_at)
) ENGINE=InnoDB COMMENT='临时下载授权令牌表';
CREATE TABLE IF NOT EXISTS file_download_log (
id BIGINT UNSIGNED NOT NULL COMMENT '下载日志ID',
tenant_id BIGINT UNSIGNED NOT NULL COMMENT '数据所属租户ID',
file_id BIGINT UNSIGNED NOT NULL COMMENT '文件逻辑ID',
user_id BIGINT UNSIGNED NULL COMMENT '下载用户ID',
request_id VARCHAR(64) NULL COMMENT '请求ID',
client_ip VARCHAR(64) NULL COMMENT '客户端IP',
result VARCHAR(16) NOT NULL COMMENT '下载结果',
failure_reason VARCHAR(500) NULL COMMENT '失败原因',
downloaded_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '下载时间',
PRIMARY KEY (id),
KEY idx_download_log_file (tenant_id, file_id, downloaded_at),
KEY idx_download_log_user (tenant_id, user_id, downloaded_at)
) ENGINE=InnoDB COMMENT='文件下载审计日志表';
+30
View File
@@ -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、过期/错误签名和身份头伪造测试。
+67
View File
@@ -0,0 +1,67 @@
<?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.lastleg</groupId>
<artifactId>baiyue-lastleg-platform</artifactId>
<version>1.0.0-SNAPSHOT</version>
</parent>
<artifactId>gateway</artifactId>
<name>gateway</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>
</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>
@@ -0,0 +1,14 @@
package com.baiyue.gateway;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
/**
* API 网关启动入口,承载统一路由、认证校验和可信身份头转发。
*/
@SpringBootApplication
public class BaiyueGatewayApplication {
public static void main(String[] args) {
SpringApplication.run(BaiyueGatewayApplication.class, args);
}
}
@@ -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();
}
}
}
@@ -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);
}
}
@@ -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();
}
}
@@ -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-service
uri: lb://warehouse-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-service
predicates: [Path=/warehouse-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-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
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-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}
@@ -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")));
}
}
@@ -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();
}
}
@@ -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();
}
}
+47
View File
@@ -0,0 +1,47 @@
<?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.lastleg</groupId>
<artifactId>baiyue-lastleg-platform</artifactId>
<version>1.0.0-SNAPSHOT</version>
<relativePath>../pom.xml</relativePath>
</parent>
<artifactId>integration-service</artifactId>
<name>integration-service</name>
<dependencies>
<dependency>
<groupId>com.baiyue.shared</groupId>
<artifactId>baiyue-common-web</artifactId>
</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>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>
</dependencies>
<build>
<plugins>
<plugin>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
</plugin>
</plugins>
</build>
</project>
@@ -0,0 +1,13 @@
package com.baiyue.integration;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.boot.context.properties.ConfigurationPropertiesScan;
@SpringBootApplication
@ConfigurationPropertiesScan
public class IntegrationServiceApplication {
public static void main(String[] args) {
SpringApplication.run(IntegrationServiceApplication.class, args);
}
}
@@ -0,0 +1,34 @@
package com.baiyue.integration.config;
import org.springframework.boot.context.properties.ConfigurationProperties;
@ConfigurationProperties(prefix = "baiyue.integration")
public record IntegrationProperties(BaiduOcr baiduOcr, AmazonSpApi amazonSpApi) {
public record BaiduOcr(boolean enabled, String apiKey, String secretKey) {
public boolean configured() {
return enabled && hasText(apiKey) && hasText(secretKey);
}
}
public record AmazonSpApi(boolean enabled,
String clientId,
String clientSecret,
String refreshToken,
String awsAccessKey,
String awsSecretKey,
String region) {
public boolean configured() {
return enabled
&& hasText(clientId)
&& hasText(clientSecret)
&& hasText(refreshToken)
&& hasText(awsAccessKey)
&& hasText(awsSecretKey)
&& hasText(region);
}
}
private static boolean hasText(String value) {
return value != null && !value.isBlank();
}
}
@@ -0,0 +1,29 @@
package com.baiyue.integration.controller;
import com.baiyue.common.core.api.ApiResponse;
import com.baiyue.integration.dto.ProviderStatus;
import com.baiyue.integration.security.InternalServiceTokenGuard;
import com.baiyue.integration.service.ProviderStatusService;
import jakarta.annotation.Resource;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestHeader;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import java.util.List;
@RestController
@RequestMapping("/internal/integrations")
public class IntegrationProviderController {
@Resource
private ProviderStatusService providerStatusService;
@Resource
private InternalServiceTokenGuard internalServiceTokenGuard;
@GetMapping("/providers")
public ApiResponse<List<ProviderStatus>> providers(
@RequestHeader(value = "X-Internal-Service-Token", required = false) String token) {
internalServiceTokenGuard.verify(token);
return ApiResponse.success(providerStatusService.providers());
}
}
@@ -0,0 +1,17 @@
package com.baiyue.integration.dto;
public record ProviderStatus(String provider,
boolean enabled,
boolean configured,
String status,
String message) {
public static ProviderStatus of(String provider, boolean enabled, boolean configured) {
if (!enabled) {
return new ProviderStatus(provider, false, false, "DISABLED", "第三方能力未启用");
}
if (!configured) {
return new ProviderStatus(provider, true, false, "NOT_CONFIGURED", "第三方密钥配置不完整");
}
return new ProviderStatus(provider, true, true, "READY", "配置已就绪");
}
}
@@ -0,0 +1,17 @@
package com.baiyue.integration.provider;
import com.baiyue.integration.config.IntegrationProperties;
import com.baiyue.integration.dto.ProviderStatus;
import org.springframework.stereotype.Component;
import jakarta.annotation.Resource;
@Component
public class AmazonLogisticsAdapter implements IntegrationProviderAdapter {
@Resource private IntegrationProperties properties;
@Override
public ProviderStatus status() {
IntegrationProperties.AmazonSpApi config = properties.amazonSpApi();
return ProviderStatus.of("AMAZON_SP_API", config.enabled(), config.configured());
}
}
@@ -0,0 +1,17 @@
package com.baiyue.integration.provider;
import com.baiyue.integration.config.IntegrationProperties;
import com.baiyue.integration.dto.ProviderStatus;
import org.springframework.stereotype.Component;
import jakarta.annotation.Resource;
@Component
public class BaiduOcrAdapter implements IntegrationProviderAdapter {
@Resource private IntegrationProperties properties;
@Override
public ProviderStatus status() {
IntegrationProperties.BaiduOcr config = properties.baiduOcr();
return ProviderStatus.of("BAIDU_OCR", config.enabled(), config.configured());
}
}
@@ -0,0 +1,7 @@
package com.baiyue.integration.provider;
import com.baiyue.integration.dto.ProviderStatus;
public interface IntegrationProviderAdapter {
ProviderStatus status();
}
@@ -0,0 +1,28 @@
package com.baiyue.integration.security;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.http.HttpStatus;
import org.springframework.stereotype.Component;
import org.springframework.web.server.ResponseStatusException;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
@Component
public class InternalServiceTokenGuard {
private final byte[] expectedToken;
public InternalServiceTokenGuard(@Value("${baiyue.security.internal-service-token}") String token) {
if (token == null || token.getBytes(StandardCharsets.UTF_8).length < 32) {
throw new IllegalStateException("INTERNAL_SERVICE_TOKEN 至少需要 32 字节");
}
this.expectedToken = token.getBytes(StandardCharsets.UTF_8);
}
public void verify(String actualToken) {
byte[] actual = actualToken == null ? new byte[0] : actualToken.getBytes(StandardCharsets.UTF_8);
if (!MessageDigest.isEqual(expectedToken, actual)) {
throw new ResponseStatusException(HttpStatus.UNAUTHORIZED, "内部服务令牌无效");
}
}
}
@@ -0,0 +1,17 @@
package com.baiyue.integration.service;
import com.baiyue.integration.dto.ProviderStatus;
import com.baiyue.integration.provider.IntegrationProviderAdapter;
import org.springframework.stereotype.Service;
import jakarta.annotation.Resource;
import java.util.List;
@Service
public class ProviderStatusService {
@Resource private List<IntegrationProviderAdapter> adapters;
public List<ProviderStatus> providers() {
return adapters.stream().map(IntegrationProviderAdapter::status).toList();
}
}
@@ -0,0 +1,57 @@
server:
port: ${INTEGRATION_PORT:9311}
spring:
data:
redis:
host: ${REDIS_HOST:127.0.0.1}
port: ${REDIS_PORT:6380}
application:
name: integration-service
autoconfigure:
exclude:
- org.springframework.boot.autoconfigure.jdbc.DataSourceAutoConfiguration
- com.baomidou.mybatisplus.autoconfigure.MybatisPlusAutoConfiguration
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}
baiyue:
security:
internal-service-token: ${INTERNAL_SERVICE_TOKEN:change-me-to-an-internal-token-at-least-32-bytes-long}
integration:
baidu-ocr:
enabled: ${BAIDU_OCR_ENABLED:false}
api-key: ${BAIDU_OCR_API_KEY:}
secret-key: ${BAIDU_OCR_SECRET_KEY:}
amazon-sp-api:
enabled: ${AMAZON_SP_API_ENABLED:false}
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:}
management:
endpoints:
web:
exposure:
include: health,info,prometheus
endpoint:
health:
probes:
enabled: true
@@ -0,0 +1,20 @@
package com.baiyue.integration.config;
import org.junit.jupiter.api.Test;
import static org.assertj.core.api.Assertions.assertThat;
class IntegrationPropertiesTest {
@Test
void providerIsConfiguredOnlyWhenEnabledAndAllSecretsExist() {
var baidu = new IntegrationProperties.BaiduOcr(true, "key", "secret");
var amazon = new IntegrationProperties.AmazonSpApi(
true, "client", "secret", "refresh", "access", "aws-secret", "us-east-1");
assertThat(baidu.configured()).isTrue();
assertThat(amazon.configured()).isTrue();
assertThat(new IntegrationProperties.BaiduOcr(false, "key", "secret").configured()).isFalse();
assertThat(new IntegrationProperties.AmazonSpApi(
true, "client", "secret", "", "access", "aws-secret", "us-east-1").configured()).isFalse();
}
}
+72
View File
@@ -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`。
- 事件链路变更至少覆盖重复事件、相同来源入仓单、事务回滚、并发单号、内部令牌和租户越权测试。
+23
View File
@@ -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.lastleg</groupId><artifactId>baiyue-lastleg-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></dependency>
<dependency><groupId>com.baiyue.shared</groupId><artifactId>baiyue-common-openapi</artifactId></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></dependency>
<dependency><groupId>com.baiyue.shared</groupId><artifactId>baiyue-common-security</artifactId></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); }
}
@@ -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"));
}
}
@@ -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());
}
}
@@ -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 {
}
@@ -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;
}
@@ -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;
}
@@ -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);
}
@@ -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);
}

Some files were not shown because too many files have changed in this diff Show More