From eb4ab93ab2503591169cd7ae84dbd63bb097fd4d Mon Sep 17 00:00:00 2001
From: liuyihe <2513762445@qq.com>
Date: Thu, 13 Aug 2026 15:57:51 +0800
Subject: [PATCH] Initial commit
---
.gitignore | 8 +
README.md | 80 ++++
audit-service/pom.xml | 59 +++
.../baiyue/audit/BaiyueAuditApplication.java | 16 +
.../controller/AuditIngestController.java | 45 ++
.../baiyue/audit/dto/AuditIngestRequest.java | 63 +++
.../baiyue/audit/entity/AuditLogEntity.java | 101 +++++
.../baiyue/audit/mapper/AuditLogMapper.java | 10 +
.../repository/AuditEventRepository.java | 50 +++
.../audit/service/AuditIngestService.java | 22 +
.../src/main/resources/application.yml | 28 ++
audit-service/src/main/resources/db/audit.sql | 36 ++
config/nacos/README.md | 3 +
config/nacos/audit-service.yml | 6 +
config/nacos/baiyue-common.yml | 61 +++
config/nacos/event-bridge-service.yml | 10 +
config/nacos/file-service.yml | 13 +
config/nacos/gateway.yml | 3 +
config/nacos/integration-service.yml | 12 +
config/nacos/lastleg-service.yml | 6 +
config/nacos/print-service.yml | 6 +
config/nacos/warehouse-service.yml | 6 +
docs/local-runtime.md | 31 ++
docs/repository-boundary.md | 71 ++++
docs/two-repository-split.md | 400 ++++++++++++++++++
env.example | 58 +++
event-bridge-service/pom.xml | 23 +
.../eventbridge/EventBridgeApplication.java | 16 +
.../controller/EventBridgeController.java | 57 +++
.../eventbridge/domain/BridgeInbox.java | 36 ++
.../baiyue/eventbridge/dto/BridgeStatus.java | 14 +
.../eventbridge/dto/EventReceiveResult.java | 15 +
.../eventbridge/mapper/BridgeInboxMapper.java | 12 +
.../security/InternalServiceTokenGuard.java | 32 ++
.../service/EventInboxService.java | 93 ++++
.../eventbridge/service/InboxStore.java | 11 +
.../service/MybatisInboxStore.java | 25 ++
.../src/main/resources/application.yml | 44 ++
.../src/main/resources/db/event-bridge.sql | 80 ++++
.../InternalServiceTokenGuardTest.java | 31 ++
.../service/EventInboxServiceTest.java | 97 +++++
file/pom.xml | 21 +
.../baiyue/file/FileServiceApplication.java | 14 +
.../file/controller/FileHealthController.java | 28 ++
file/src/main/resources/application.yml | 39 ++
file/src/main/resources/db/file.sql | 61 +++
gateway/pom.xml | 67 +++
.../gateway/BaiyueGatewayApplication.java | 14 +
.../config/AppScopeAuthorizationManager.java | 32 ++
.../gateway/config/GatewaySecurityConfig.java | 74 ++++
.../config/GatewaySecurityProperties.java | 21 +
.../filter/TrustedIdentityHeaderFilter.java | 73 ++++
.../main/resources/application-overseas.yml | 69 +++
gateway/src/main/resources/application.yml | 66 +++
.../AppScopeAuthorizationManagerTest.java | 55 +++
integration-service/pom.xml | 47 ++
.../IntegrationServiceApplication.java | 13 +
.../config/IntegrationProperties.java | 34 ++
.../IntegrationProviderController.java | 29 ++
.../integration/dto/ProviderStatus.java | 17 +
.../provider/AmazonLogisticsAdapter.java | 20 +
.../integration/provider/BaiduOcrAdapter.java | 20 +
.../provider/IntegrationProviderAdapter.java | 7 +
.../security/InternalServiceTokenGuard.java | 28 ++
.../service/ProviderStatusService.java | 20 +
.../src/main/resources/application.yml | 57 +++
.../config/IntegrationPropertiesTest.java | 20 +
lastleg/pom.xml | 22 +
.../lastleg/BaiyueLastlegApplication.java | 14 +
.../controller/LastlegHealthController.java | 34 ++
.../com/baiyue/lastleg/dto/package-info.java | 5 +
.../baiyue/lastleg/entity/package-info.java | 5 +
.../baiyue/lastleg/mapper/package-info.java | 5 +
.../lastleg/repository/package-info.java | 5 +
.../baiyue/lastleg/service/package-info.java | 5 +
lastleg/src/main/resources/application.yml | 30 ++
pom.xml | 135 ++++++
print/pom.xml | 21 +
.../baiyue/print/PrintServiceApplication.java | 14 +
.../controller/PrintHealthController.java | 28 ++
print/src/main/resources/application.yml | 30 ++
print/src/main/resources/db/print.sql | 62 +++
scripts/build.ps1 | 13 +
scripts/check-boundary.ps1 | 24 ++
scripts/local-env.ps1 | 18 +
warehouse/pom.xml | 71 ++++
.../warehouse/BaiyueWarehouseApplication.java | 14 +
.../MerchantWarehouseController.java | 67 +++
.../PlatformWarehouseController.java | 166 ++++++++
.../controller/WarehouseHealthController.java | 26 ++
.../dto/WarehouseDetailResponse.java | 19 +
.../warehouse/dto/WarehouseRequests.java | 117 +++++
.../entity/ServiceCapabilityEntity.java | 57 +++
.../entity/WarehouseAddressEntity.java | 77 ++++
.../warehouse/entity/WarehouseEntity.java | 71 ++++
.../mapper/ServiceCapabilityMapper.java | 16 +
.../mapper/WarehouseAddressMapper.java | 16 +
.../warehouse/mapper/WarehouseMapper.java | 6 +
.../repository/WarehouseRepository.java | 105 +++++
.../service/WarehouseApplicationService.java | 156 +++++++
warehouse/src/main/resources/application.yml | 30 ++
warehouse/src/main/resources/db/warehouse.sql | 75 ++++
102 files changed, 4295 insertions(+)
create mode 100644 .gitignore
create mode 100644 README.md
create mode 100644 audit-service/pom.xml
create mode 100644 audit-service/src/main/java/com/baiyue/audit/BaiyueAuditApplication.java
create mode 100644 audit-service/src/main/java/com/baiyue/audit/controller/AuditIngestController.java
create mode 100644 audit-service/src/main/java/com/baiyue/audit/dto/AuditIngestRequest.java
create mode 100644 audit-service/src/main/java/com/baiyue/audit/entity/AuditLogEntity.java
create mode 100644 audit-service/src/main/java/com/baiyue/audit/mapper/AuditLogMapper.java
create mode 100644 audit-service/src/main/java/com/baiyue/audit/repository/AuditEventRepository.java
create mode 100644 audit-service/src/main/java/com/baiyue/audit/service/AuditIngestService.java
create mode 100644 audit-service/src/main/resources/application.yml
create mode 100644 audit-service/src/main/resources/db/audit.sql
create mode 100644 config/nacos/README.md
create mode 100644 config/nacos/audit-service.yml
create mode 100644 config/nacos/baiyue-common.yml
create mode 100644 config/nacos/event-bridge-service.yml
create mode 100644 config/nacos/file-service.yml
create mode 100644 config/nacos/gateway.yml
create mode 100644 config/nacos/integration-service.yml
create mode 100644 config/nacos/lastleg-service.yml
create mode 100644 config/nacos/print-service.yml
create mode 100644 config/nacos/warehouse-service.yml
create mode 100644 docs/local-runtime.md
create mode 100644 docs/repository-boundary.md
create mode 100644 docs/two-repository-split.md
create mode 100644 env.example
create mode 100644 event-bridge-service/pom.xml
create mode 100644 event-bridge-service/src/main/java/com/baiyue/eventbridge/EventBridgeApplication.java
create mode 100644 event-bridge-service/src/main/java/com/baiyue/eventbridge/controller/EventBridgeController.java
create mode 100644 event-bridge-service/src/main/java/com/baiyue/eventbridge/domain/BridgeInbox.java
create mode 100644 event-bridge-service/src/main/java/com/baiyue/eventbridge/dto/BridgeStatus.java
create mode 100644 event-bridge-service/src/main/java/com/baiyue/eventbridge/dto/EventReceiveResult.java
create mode 100644 event-bridge-service/src/main/java/com/baiyue/eventbridge/mapper/BridgeInboxMapper.java
create mode 100644 event-bridge-service/src/main/java/com/baiyue/eventbridge/security/InternalServiceTokenGuard.java
create mode 100644 event-bridge-service/src/main/java/com/baiyue/eventbridge/service/EventInboxService.java
create mode 100644 event-bridge-service/src/main/java/com/baiyue/eventbridge/service/InboxStore.java
create mode 100644 event-bridge-service/src/main/java/com/baiyue/eventbridge/service/MybatisInboxStore.java
create mode 100644 event-bridge-service/src/main/resources/application.yml
create mode 100644 event-bridge-service/src/main/resources/db/event-bridge.sql
create mode 100644 event-bridge-service/src/test/java/com/baiyue/eventbridge/security/InternalServiceTokenGuardTest.java
create mode 100644 event-bridge-service/src/test/java/com/baiyue/eventbridge/service/EventInboxServiceTest.java
create mode 100644 file/pom.xml
create mode 100644 file/src/main/java/com/baiyue/file/FileServiceApplication.java
create mode 100644 file/src/main/java/com/baiyue/file/controller/FileHealthController.java
create mode 100644 file/src/main/resources/application.yml
create mode 100644 file/src/main/resources/db/file.sql
create mode 100644 gateway/pom.xml
create mode 100644 gateway/src/main/java/com/baiyue/gateway/BaiyueGatewayApplication.java
create mode 100644 gateway/src/main/java/com/baiyue/gateway/config/AppScopeAuthorizationManager.java
create mode 100644 gateway/src/main/java/com/baiyue/gateway/config/GatewaySecurityConfig.java
create mode 100644 gateway/src/main/java/com/baiyue/gateway/config/GatewaySecurityProperties.java
create mode 100644 gateway/src/main/java/com/baiyue/gateway/filter/TrustedIdentityHeaderFilter.java
create mode 100644 gateway/src/main/resources/application-overseas.yml
create mode 100644 gateway/src/main/resources/application.yml
create mode 100644 gateway/src/test/java/com/baiyue/gateway/config/AppScopeAuthorizationManagerTest.java
create mode 100644 integration-service/pom.xml
create mode 100644 integration-service/src/main/java/com/baiyue/integration/IntegrationServiceApplication.java
create mode 100644 integration-service/src/main/java/com/baiyue/integration/config/IntegrationProperties.java
create mode 100644 integration-service/src/main/java/com/baiyue/integration/controller/IntegrationProviderController.java
create mode 100644 integration-service/src/main/java/com/baiyue/integration/dto/ProviderStatus.java
create mode 100644 integration-service/src/main/java/com/baiyue/integration/provider/AmazonLogisticsAdapter.java
create mode 100644 integration-service/src/main/java/com/baiyue/integration/provider/BaiduOcrAdapter.java
create mode 100644 integration-service/src/main/java/com/baiyue/integration/provider/IntegrationProviderAdapter.java
create mode 100644 integration-service/src/main/java/com/baiyue/integration/security/InternalServiceTokenGuard.java
create mode 100644 integration-service/src/main/java/com/baiyue/integration/service/ProviderStatusService.java
create mode 100644 integration-service/src/main/resources/application.yml
create mode 100644 integration-service/src/test/java/com/baiyue/integration/config/IntegrationPropertiesTest.java
create mode 100644 lastleg/pom.xml
create mode 100644 lastleg/src/main/java/com/baiyue/lastleg/BaiyueLastlegApplication.java
create mode 100644 lastleg/src/main/java/com/baiyue/lastleg/controller/LastlegHealthController.java
create mode 100644 lastleg/src/main/java/com/baiyue/lastleg/dto/package-info.java
create mode 100644 lastleg/src/main/java/com/baiyue/lastleg/entity/package-info.java
create mode 100644 lastleg/src/main/java/com/baiyue/lastleg/mapper/package-info.java
create mode 100644 lastleg/src/main/java/com/baiyue/lastleg/repository/package-info.java
create mode 100644 lastleg/src/main/java/com/baiyue/lastleg/service/package-info.java
create mode 100644 lastleg/src/main/resources/application.yml
create mode 100644 pom.xml
create mode 100644 print/pom.xml
create mode 100644 print/src/main/java/com/baiyue/print/PrintServiceApplication.java
create mode 100644 print/src/main/java/com/baiyue/print/controller/PrintHealthController.java
create mode 100644 print/src/main/resources/application.yml
create mode 100644 print/src/main/resources/db/print.sql
create mode 100644 scripts/build.ps1
create mode 100644 scripts/check-boundary.ps1
create mode 100644 scripts/local-env.ps1
create mode 100644 warehouse/pom.xml
create mode 100644 warehouse/src/main/java/com/baiyue/warehouse/BaiyueWarehouseApplication.java
create mode 100644 warehouse/src/main/java/com/baiyue/warehouse/controller/MerchantWarehouseController.java
create mode 100644 warehouse/src/main/java/com/baiyue/warehouse/controller/PlatformWarehouseController.java
create mode 100644 warehouse/src/main/java/com/baiyue/warehouse/controller/WarehouseHealthController.java
create mode 100644 warehouse/src/main/java/com/baiyue/warehouse/dto/WarehouseDetailResponse.java
create mode 100644 warehouse/src/main/java/com/baiyue/warehouse/dto/WarehouseRequests.java
create mode 100644 warehouse/src/main/java/com/baiyue/warehouse/entity/ServiceCapabilityEntity.java
create mode 100644 warehouse/src/main/java/com/baiyue/warehouse/entity/WarehouseAddressEntity.java
create mode 100644 warehouse/src/main/java/com/baiyue/warehouse/entity/WarehouseEntity.java
create mode 100644 warehouse/src/main/java/com/baiyue/warehouse/mapper/ServiceCapabilityMapper.java
create mode 100644 warehouse/src/main/java/com/baiyue/warehouse/mapper/WarehouseAddressMapper.java
create mode 100644 warehouse/src/main/java/com/baiyue/warehouse/mapper/WarehouseMapper.java
create mode 100644 warehouse/src/main/java/com/baiyue/warehouse/repository/WarehouseRepository.java
create mode 100644 warehouse/src/main/java/com/baiyue/warehouse/service/WarehouseApplicationService.java
create mode 100644 warehouse/src/main/resources/application.yml
create mode 100644 warehouse/src/main/resources/db/warehouse.sql
diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..271ea6e
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,8 @@
+target/
+.idea/
+*.iml
+*.log
+.env
+logs/
+data/
+uploads/
diff --git a/README.md b/README.md
new file mode 100644
index 0000000..7544a2c
--- /dev/null
+++ b/README.md
@@ -0,0 +1,80 @@
+# 百悦物流尾程业务平台
+
+本项目是海外尾程业务的独立 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` 获取并缓存公钥,只负责验签、签发方、有效期和应用端校验,不保存签名私钥。
+4. 尾程业务服务通过公共安全组件读取权限快照并执行接口、按钮和租户边界校验,不在每次请求时跨区查询国内权限数据。
+5. 用户停用、租户冻结和权限版本变化通过跨区事件同步到海外本地投影;高风险操作可依据本地冻结投影立即拒绝。
+
+海外服务不得依赖国内 Redis 中的登录会话。正式上线前,头程公共安全制品必须完成 JWKS 解码器和本地权限快照校验改造。
+
+## 公共制品依赖
+
+本仓库不复制 `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_warehouse` |
+| `file-service` | `baiyue_file` |
+| `print-service` | `baiyue_print` |
+| `audit-service` | `baiyue_audit` |
+| `event-bridge-service` | `baiyue_event_bridge` |
+
+服务之间不得跨库查询。国内订单和头程事实通过版本化事件进入尾程本地投影,尾程状态和签收事实也通过事件回传国内。
+
+## 文件服务说明
+
+原工程中企业资质上传后同步调用国内权限服务的链路已从尾程仓移除。海外文件中心当前只保留基础骨架;提单、装箱单、清关文件和签收证明的对象存储、授权下载、病毒扫描及保留期限策略属于后续必做能力。
+
+## 配置与构建
+
+海外 Nacos 配置位于 `config/nacos`,环境变量模板位于 `env.example`。网关默认启用 `overseas` 配置,服务发现自动路由关闭,只允许 `application-overseas.yml` 中声明的尾程服务路由。
+
+公共制品安装完成后执行:
+
+```powershell
+.\scripts\build.ps1
+```
+
+仅检查仓库边界和 POM 结构时执行:
+
+```powershell
+.\scripts\check-boundary.ps1
+```
+
+详细仓库边界见 [仓库边界说明](docs/repository-boundary.md)。
+
diff --git a/audit-service/pom.xml b/audit-service/pom.xml
new file mode 100644
index 0000000..8d326c9
--- /dev/null
+++ b/audit-service/pom.xml
@@ -0,0 +1,59 @@
+
+
+ 4.0.0
+
+ com.baiyue.lastleg
+ baiyue-lastleg-platform
+ 1.0.0-SNAPSHOT
+ ../pom.xml
+
+ audit-service
+
+
+
+ com.baiyue.shared
+ baiyue-common-audit
+
+
+ com.baiyue.shared
+ baiyue-common-web
+
+
+ com.baiyue.shared
+ baiyue-common-openapi
+
+
+ com.baomidou
+ mybatis-plus-spring-boot3-starter
+
+
+ com.mysql
+ mysql-connector-j
+ runtime
+
+
+ com.alibaba.cloud
+ spring-cloud-starter-alibaba-nacos-discovery
+
+
+ com.alibaba.cloud
+ spring-cloud-starter-alibaba-nacos-config
+
+
+ org.springframework.boot
+ spring-boot-starter-test
+ test
+
+
+
+
+
+
+ org.springframework.boot
+ spring-boot-maven-plugin
+
+
+
+
diff --git a/audit-service/src/main/java/com/baiyue/audit/BaiyueAuditApplication.java b/audit-service/src/main/java/com/baiyue/audit/BaiyueAuditApplication.java
new file mode 100644
index 0000000..3a9bd5c
--- /dev/null
+++ b/audit-service/src/main/java/com/baiyue/audit/BaiyueAuditApplication.java
@@ -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);
+ }
+}
diff --git a/audit-service/src/main/java/com/baiyue/audit/controller/AuditIngestController.java b/audit-service/src/main/java/com/baiyue/audit/controller/AuditIngestController.java
new file mode 100644
index 0000000..ff361f6
--- /dev/null
+++ b/audit-service/src/main/java/com/baiyue/audit/controller/AuditIngestController.java
@@ -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 ingest(@Valid @RequestBody AuditIngestRequest event) {
+ service.ingest(event);
+ return ApiResponse.success(null);
+ }
+}
diff --git a/audit-service/src/main/java/com/baiyue/audit/dto/AuditIngestRequest.java b/audit-service/src/main/java/com/baiyue/audit/dto/AuditIngestRequest.java
new file mode 100644
index 0000000..7948394
--- /dev/null
+++ b/audit-service/src/main/java/com/baiyue/audit/dto/AuditIngestRequest.java
@@ -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
+) {
+}
diff --git a/audit-service/src/main/java/com/baiyue/audit/entity/AuditLogEntity.java b/audit-service/src/main/java/com/baiyue/audit/entity/AuditLogEntity.java
new file mode 100644
index 0000000..ba85a91
--- /dev/null
+++ b/audit-service/src/main/java/com/baiyue/audit/entity/AuditLogEntity.java
@@ -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;
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+}
diff --git a/audit-service/src/main/java/com/baiyue/audit/mapper/AuditLogMapper.java b/audit-service/src/main/java/com/baiyue/audit/mapper/AuditLogMapper.java
new file mode 100644
index 0000000..612c0e5
--- /dev/null
+++ b/audit-service/src/main/java/com/baiyue/audit/mapper/AuditLogMapper.java
@@ -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 {
+}
diff --git a/audit-service/src/main/java/com/baiyue/audit/repository/AuditEventRepository.java b/audit-service/src/main/java/com/baiyue/audit/repository/AuditEventRepository.java
new file mode 100644
index 0000000..acc5669
--- /dev/null
+++ b/audit-service/src/main/java/com/baiyue/audit/repository/AuditEventRepository.java
@@ -0,0 +1,50 @@
+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;
+
+/**
+ * 审计事件持久化仓储,负责将内部审计请求映射并追加写入审计日志表。
+ */
+@Repository
+public class AuditEventRepository {
+ private final AuditLogMapper mapper;
+
+ public AuditEventRepository(AuditLogMapper mapper) {
+ this.mapper = 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;
+ }
+}
diff --git a/audit-service/src/main/java/com/baiyue/audit/service/AuditIngestService.java b/audit-service/src/main/java/com/baiyue/audit/service/AuditIngestService.java
new file mode 100644
index 0000000..a75e417
--- /dev/null
+++ b/audit-service/src/main/java/com/baiyue/audit/service/AuditIngestService.java
@@ -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);
+ }
+}
diff --git a/audit-service/src/main/resources/application.yml b/audit-service/src/main/resources/application.yml
new file mode 100644
index 0000000..369f46d
--- /dev/null
+++ b/audit-service/src/main/resources/application.yml
@@ -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
diff --git a/audit-service/src/main/resources/db/audit.sql b/audit-service/src/main/resources/db/audit.sql
new file mode 100644
index 0000000..985b89b
--- /dev/null
+++ b/audit-service/src/main/resources/db/audit.sql
@@ -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和消费幂等表';
diff --git a/config/nacos/README.md b/config/nacos/README.md
new file mode 100644
index 0000000..e617941
--- /dev/null
+++ b/config/nacos/README.md
@@ -0,0 +1,3 @@
+# 海外 Nacos 配置
+
+将本目录文件按原文件名作为 Data ID 发布到 `prod-overseas` 命名空间和 `BAIYUE` 分组。配置只引用环境变量,不保存密码或访问密钥。Gateway 必须同时启用 `overseas` Profile。
diff --git a/config/nacos/audit-service.yml b/config/nacos/audit-service.yml
new file mode 100644
index 0000000..1ac49f2
--- /dev/null
+++ b/config/nacos/audit-service.yml
@@ -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
diff --git a/config/nacos/baiyue-common.yml b/config/nacos/baiyue-common.yml
new file mode 100644
index 0000000..e1db9a8
--- /dev/null
+++ b/config/nacos/baiyue-common.yml
@@ -0,0 +1,61 @@
+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}
+ 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}
diff --git a/config/nacos/event-bridge-service.yml b/config/nacos/event-bridge-service.yml
new file mode 100644
index 0000000..f50a01e
--- /dev/null
+++ b/config/nacos/event-bridge-service.yml
@@ -0,0 +1,10 @@
+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}
diff --git a/config/nacos/file-service.yml b/config/nacos/file-service.yml
new file mode 100644
index 0000000..9714746
--- /dev/null
+++ b/config/nacos/file-service.yml
@@ -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:}
diff --git a/config/nacos/gateway.yml b/config/nacos/gateway.yml
new file mode 100644
index 0000000..e16f76e
--- /dev/null
+++ b/config/nacos/gateway.yml
@@ -0,0 +1,3 @@
+springdoc:
+ api-docs.enabled: ${SWAGGER_ENABLED:false}
+ swagger-ui.enabled: ${SWAGGER_ENABLED:false}
diff --git a/config/nacos/integration-service.yml b/config/nacos/integration-service.yml
new file mode 100644
index 0000000..25617fa
--- /dev/null
+++ b/config/nacos/integration-service.yml
@@ -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:}
diff --git a/config/nacos/lastleg-service.yml b/config/nacos/lastleg-service.yml
new file mode 100644
index 0000000..1b88f04
--- /dev/null
+++ b/config/nacos/lastleg-service.yml
@@ -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
diff --git a/config/nacos/print-service.yml b/config/nacos/print-service.yml
new file mode 100644
index 0000000..613433e
--- /dev/null
+++ b/config/nacos/print-service.yml
@@ -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
diff --git a/config/nacos/warehouse-service.yml b/config/nacos/warehouse-service.yml
new file mode 100644
index 0000000..a05d57b
--- /dev/null
+++ b/config/nacos/warehouse-service.yml
@@ -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
diff --git a/docs/local-runtime.md b/docs/local-runtime.md
new file mode 100644
index 0000000..e75ebb9
--- /dev/null
+++ b/docs/local-runtime.md
@@ -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`。
diff --git a/docs/repository-boundary.md b/docs/repository-boundary.md
new file mode 100644
index 0000000..ca555a0
--- /dev/null
+++ b/docs/repository-boundary.md
@@ -0,0 +1,71 @@
+# 尾程仓库边界说明
+
+## 一、拆分目标
+
+尾程仓库是可独立构建、发布和部署的海外业务单元。其生命周期、数据库、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 验签并默认启用海外显式路由。
+- 公共安全组件的 JWKS 解码、本地权限快照和冻结事件处理仍需在公共制品中完成。
+- 海外提单、装箱单、清关文件和签收证明能力仍待实现。
+- 跨区发送器、消息中间件接入、重试调度和死信补投仍待实现。
+
+只有上述待办完成并通过故障注入、跨区断网、重复事件、乱序事件和密钥轮换验收后,尾程服务才允许迁移到海外生产环境。
+
diff --git a/docs/two-repository-split.md b/docs/two-repository-split.md
new file mode 100644
index 0000000..f7b6320
--- /dev/null
+++ b/docs/two-repository-split.md
@@ -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` 保留为拆分来源和回滚备份,双仓验收前不删除。
diff --git a/env.example b/env.example
new file mode 100644
index 0000000..3d2a233
--- /dev/null
+++ b/env.example
@@ -0,0 +1,58 @@
+# 海外部署单元标识
+REGION_CODE=OVERSEAS
+DEPLOYMENT_CELL=prod-overseas
+DATA_RESIDENCY=OVERSEAS
+
+# 海外注册中心和配置中心
+NACOS_SERVER_ADDR=nacos-overseas.internal:8848
+NACOS_NAMESPACE=prod-overseas
+NACOS_GROUP=BAIYUE
+NACOS_USERNAME=
+NACOS_PASSWORD=
+
+# 海外本地缓存,不得连接国内 Redis
+REDIS_HOST=redis-overseas.internal
+REDIS_PORT=6379
+REDIS_PASSWORD=
+REDIS_DATABASE=0
+PERMISSION_SNAPSHOT_KEY_PREFIX=baiyue:permission-snapshot
+
+# 国内认证中心只公开非对称签名公钥,海外不得保存签名私钥
+JWT_ISSUER=https://auth.example.com
+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_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
+
+# 海外对象存储和外部渠道适配
+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=
diff --git a/event-bridge-service/pom.xml b/event-bridge-service/pom.xml
new file mode 100644
index 0000000..ba5d4f8
--- /dev/null
+++ b/event-bridge-service/pom.xml
@@ -0,0 +1,23 @@
+
+
+ 4.0.0
+
+ com.baiyue.lastlegbaiyue-lastleg-platform
+ 1.0.0-SNAPSHOT../pom.xml
+
+ event-bridge-service
+ event-bridge-service
+
+ com.baiyue.sharedbaiyue-common-web
+ com.baiyue.sharedbaiyue-common-openapi
+ com.baiyue.sharedcommon-event-contract
+ com.baomidoumybatis-plus-spring-boot3-starter
+ com.alibaba.cloudspring-cloud-starter-alibaba-nacos-discovery
+ com.alibaba.cloudspring-cloud-starter-alibaba-nacos-config
+ org.springframework.bootspring-boot-starter-actuator
+ com.mysqlmysql-connector-jruntime
+ org.springframework.bootspring-boot-starter-testtest
+
+ org.springframework.bootspring-boot-maven-plugin
+
diff --git a/event-bridge-service/src/main/java/com/baiyue/eventbridge/EventBridgeApplication.java b/event-bridge-service/src/main/java/com/baiyue/eventbridge/EventBridgeApplication.java
new file mode 100644
index 0000000..dd0e0b0
--- /dev/null
+++ b/event-bridge-service/src/main/java/com/baiyue/eventbridge/EventBridgeApplication.java
@@ -0,0 +1,16 @@
+package com.baiyue.eventbridge;
+
+import org.mybatis.spring.annotation.MapperScan;
+import org.springframework.boot.SpringApplication;
+import org.springframework.boot.autoconfigure.SpringBootApplication;
+
+/**
+ * 跨区域事件桥启动入口。
+ */
+@MapperScan("com.baiyue.eventbridge.mapper")
+@SpringBootApplication
+public class EventBridgeApplication {
+ public static void main(String[] args) {
+ SpringApplication.run(EventBridgeApplication.class, args);
+ }
+}
diff --git a/event-bridge-service/src/main/java/com/baiyue/eventbridge/controller/EventBridgeController.java b/event-bridge-service/src/main/java/com/baiyue/eventbridge/controller/EventBridgeController.java
new file mode 100644
index 0000000..4b4f4fe
--- /dev/null
+++ b/event-bridge-service/src/main/java/com/baiyue/eventbridge/controller/EventBridgeController.java
@@ -0,0 +1,57 @@
+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.service.EventInboxService;
+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 lombok.RequiredArgsConstructor;
+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;
+
+/**
+ * 事件桥内部接口。生产环境必须在网关或基础设施层增加 mTLS 与来源白名单。
+ */
+@Tag(name = "事件桥内部接口", description = "跨区域事件的幂等接收与能力状态")
+@RestController
+@RequestMapping("/internal/event-bridge")
+@RequiredArgsConstructor
+public class EventBridgeController {
+ private final EventInboxService eventInboxService;
+ private final InternalServiceTokenGuard internalServiceTokenGuard;
+
+ @Value("${baiyue.region:CN}")
+ 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 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 = "查询事件桥能力状态")
+ @ApiResponses(@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "200", description = "查询成功"))
+ @GetMapping("/status")
+ public ApiResponse status() {
+ return ApiResponse.success(new BridgeStatus("运行中", region, true, false));
+ }
+}
diff --git a/event-bridge-service/src/main/java/com/baiyue/eventbridge/domain/BridgeInbox.java b/event-bridge-service/src/main/java/com/baiyue/eventbridge/domain/BridgeInbox.java
new file mode 100644
index 0000000..4094be1
--- /dev/null
+++ b/event-bridge-service/src/main/java/com/baiyue/eventbridge/domain/BridgeInbox.java
@@ -0,0 +1,36 @@
+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 tenantId;
+ 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 LocalDateTime receivedAt;
+}
diff --git a/event-bridge-service/src/main/java/com/baiyue/eventbridge/dto/BridgeStatus.java b/event-bridge-service/src/main/java/com/baiyue/eventbridge/dto/BridgeStatus.java
new file mode 100644
index 0000000..ff9d7ef
--- /dev/null
+++ b/event-bridge-service/src/main/java/com/baiyue/eventbridge/dto/BridgeStatus.java
@@ -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) {
+}
diff --git a/event-bridge-service/src/main/java/com/baiyue/eventbridge/dto/EventReceiveResult.java b/event-bridge-service/src/main/java/com/baiyue/eventbridge/dto/EventReceiveResult.java
new file mode 100644
index 0000000..5d84a09
--- /dev/null
+++ b/event-bridge-service/src/main/java/com/baiyue/eventbridge/dto/EventReceiveResult.java
@@ -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) {
+}
diff --git a/event-bridge-service/src/main/java/com/baiyue/eventbridge/mapper/BridgeInboxMapper.java b/event-bridge-service/src/main/java/com/baiyue/eventbridge/mapper/BridgeInboxMapper.java
new file mode 100644
index 0000000..5ec5360
--- /dev/null
+++ b/event-bridge-service/src/main/java/com/baiyue/eventbridge/mapper/BridgeInboxMapper.java
@@ -0,0 +1,12 @@
+package com.baiyue.eventbridge.mapper;
+
+import com.baomidou.mybatisplus.core.mapper.BaseMapper;
+import com.baiyue.eventbridge.domain.BridgeInbox;
+import org.apache.ibatis.annotations.Mapper;
+
+/**
+ * 入站事件持久化映射。
+ */
+@Mapper
+public interface BridgeInboxMapper extends BaseMapper {
+}
diff --git a/event-bridge-service/src/main/java/com/baiyue/eventbridge/security/InternalServiceTokenGuard.java b/event-bridge-service/src/main/java/com/baiyue/eventbridge/security/InternalServiceTokenGuard.java
new file mode 100644
index 0000000..ba47174
--- /dev/null
+++ b/event-bridge-service/src/main/java/com/baiyue/eventbridge/security/InternalServiceTokenGuard.java
@@ -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, "内部服务令牌无效");
+ }
+ }
+}
diff --git a/event-bridge-service/src/main/java/com/baiyue/eventbridge/service/EventInboxService.java b/event-bridge-service/src/main/java/com/baiyue/eventbridge/service/EventInboxService.java
new file mode 100644
index 0000000..8803d0a
--- /dev/null
+++ b/event-bridge-service/src/main/java/com/baiyue/eventbridge/service/EventInboxService.java
@@ -0,0 +1,93 @@
+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 java.time.LocalDateTime;
+
+/**
+ * 入站事件校验与幂等落库服务。
+ */
+@Service
+public class EventInboxService {
+ private final InboxStore inboxStore;
+ private final ObjectMapper objectMapper;
+ private final String localRegion;
+
+ public EventInboxService(InboxStore inboxStore, ObjectMapper objectMapper,
+ @Value("${baiyue.region:CN}") String localRegion) {
+ this.inboxStore = inboxStore;
+ this.objectMapper = objectMapper;
+ this.localRegion = localRegion;
+ }
+
+ @Transactional
+ public EventReceiveResult receive(EventEnvelope envelope) {
+ envelope.requireValid();
+ if (!envelope.targetRegion().name().equalsIgnoreCase(localRegion)) {
+ throw new IllegalArgumentException("事件目标区域与当前事件桥区域不一致");
+ }
+ if (!EventPayloadHash.matches(EventPayloadCanonicalizer.canonicalize(envelope.payload()), envelope.payloadHash())) {
+ 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.setTenantId(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;
+ }
+}
diff --git a/event-bridge-service/src/main/java/com/baiyue/eventbridge/service/InboxStore.java b/event-bridge-service/src/main/java/com/baiyue/eventbridge/service/InboxStore.java
new file mode 100644
index 0000000..e96519c
--- /dev/null
+++ b/event-bridge-service/src/main/java/com/baiyue/eventbridge/service/InboxStore.java
@@ -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);
+}
diff --git a/event-bridge-service/src/main/java/com/baiyue/eventbridge/service/MybatisInboxStore.java b/event-bridge-service/src/main/java/com/baiyue/eventbridge/service/MybatisInboxStore.java
new file mode 100644
index 0000000..b397cae
--- /dev/null
+++ b/event-bridge-service/src/main/java/com/baiyue/eventbridge/service/MybatisInboxStore.java
@@ -0,0 +1,25 @@
+package com.baiyue.eventbridge.service;
+
+import com.baiyue.eventbridge.domain.BridgeInbox;
+import com.baiyue.eventbridge.mapper.BridgeInboxMapper;
+import lombok.RequiredArgsConstructor;
+import org.springframework.stereotype.Repository;
+
+/**
+ * 基于 MyBatis-Plus 的入站幂等存储。
+ */
+@Repository
+@RequiredArgsConstructor
+public class MybatisInboxStore implements InboxStore {
+ private final BridgeInboxMapper mapper;
+
+ @Override
+ public BridgeInbox findByEventId(String eventId) {
+ return mapper.selectById(eventId);
+ }
+
+ @Override
+ public void insert(BridgeInbox inbox) {
+ mapper.insert(inbox);
+ }
+}
diff --git a/event-bridge-service/src/main/resources/application.yml b/event-bridge-service/src/main/resources/application.yml
new file mode 100644
index 0000000..03c7162
--- /dev/null
+++ b/event-bridge-service/src/main/resources/application.yml
@@ -0,0 +1,44 @@
+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:}
+ 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:CN}
+ security:
+ internal-service-token: ${INTERNAL_SERVICE_TOKEN:}
+
+management:
+ endpoints:
+ web:
+ exposure:
+ include: health,info,prometheus
+
+springdoc:
+ api-docs:
+ path: /v3/api-docs
diff --git a/event-bridge-service/src/main/resources/db/event-bridge.sql b/event-bridge-service/src/main/resources/db/event-bridge.sql
new file mode 100644
index 0000000..3fd4368
--- /dev/null
+++ b/event-bridge-service/src/main/resources/db/event-bridge.sql
@@ -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 '目标区域',
+ tenant_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 '目标区域',
+ tenant_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='跨区域事件投递尝试记录';
diff --git a/event-bridge-service/src/test/java/com/baiyue/eventbridge/security/InternalServiceTokenGuardTest.java b/event-bridge-service/src/test/java/com/baiyue/eventbridge/security/InternalServiceTokenGuardTest.java
new file mode 100644
index 0000000..1b8dfb9
--- /dev/null
+++ b/event-bridge-service/src/test/java/com/baiyue/eventbridge/security/InternalServiceTokenGuardTest.java
@@ -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("令牌无效");
+ }
+}
diff --git a/event-bridge-service/src/test/java/com/baiyue/eventbridge/service/EventInboxServiceTest.java b/event-bridge-service/src/test/java/com/baiyue/eventbridge/service/EventInboxServiceTest.java
new file mode 100644
index 0000000..97fc883
--- /dev/null
+++ b/event-bridge-service/src/test/java/com/baiyue/eventbridge/service/EventInboxServiceTest.java
@@ -0,0 +1,97 @@
+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 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 = new EventInboxService(store, objectMapper, "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 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 = new EventInboxService(store, objectMapper, "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 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;
+ }
+}
diff --git a/file/pom.xml b/file/pom.xml
new file mode 100644
index 0000000..4f1085c
--- /dev/null
+++ b/file/pom.xml
@@ -0,0 +1,21 @@
+
+
+ 4.0.0
+ com.baiyue.lastlegbaiyue-lastleg-platform1.0.0-SNAPSHOT../pom.xml
+ file-service
+
+ com.baiyue.sharedbaiyue-common-audit
+ com.baiyue.sharedbaiyue-common-openapi
+ com.baomidoumybatis-plus-spring-boot3-starter
+ com.baiyue.sharedbaiyue-common-web
+ com.baiyue.sharedbaiyue-common-security
+ com.alibaba.cloudspring-cloud-starter-alibaba-nacos-discovery
+ com.alibaba.cloudspring-cloud-starter-alibaba-nacos-config
+ com.alibaba.cloudspring-cloud-starter-alibaba-sentinel
+ org.springframework.bootspring-boot-starter-actuator
+ com.mysqlmysql-connector-jruntime
+ org.springframework.bootspring-boot-starter-testtest
+
+ org.springframework.bootspring-boot-maven-plugin
+
diff --git a/file/src/main/java/com/baiyue/file/FileServiceApplication.java b/file/src/main/java/com/baiyue/file/FileServiceApplication.java
new file mode 100644
index 0000000..ee0dfc7
--- /dev/null
+++ b/file/src/main/java/com/baiyue/file/FileServiceApplication.java
@@ -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);
+ }
+}
diff --git a/file/src/main/java/com/baiyue/file/controller/FileHealthController.java b/file/src/main/java/com/baiyue/file/controller/FileHealthController.java
new file mode 100644
index 0000000..3c5db74
--- /dev/null
+++ b/file/src/main/java/com/baiyue/file/controller/FileHealthController.java
@@ -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