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> status() { + return ApiResponse.success(Map.of("service", "file-service", "status", "UP")); + } +} diff --git a/file/src/main/resources/application.yml b/file/src/main/resources/application.yml new file mode 100644 index 0000000..622eac6 --- /dev/null +++ b/file/src/main/resources/application.yml @@ -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 diff --git a/file/src/main/resources/db/file.sql b/file/src/main/resources/db/file.sql new file mode 100644 index 0000000..7e35cf9 --- /dev/null +++ b/file/src/main/resources/db/file.sql @@ -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='文件下载审计日志表'; diff --git a/gateway/pom.xml b/gateway/pom.xml new file mode 100644 index 0000000..894b9ec --- /dev/null +++ b/gateway/pom.xml @@ -0,0 +1,67 @@ + + + 4.0.0 + + com.baiyue.lastleg + baiyue-lastleg-platform + 1.0.0-SNAPSHOT + + gateway + gateway + + + + org.springdoc + springdoc-openapi-starter-webflux-ui + + + com.baiyue.shared + baiyue-common-core + + + org.springframework.cloud + spring-cloud-starter-gateway + + + org.springframework.boot + spring-boot-starter-security + + + org.springframework.boot + spring-boot-starter-oauth2-resource-server + + + com.alibaba.cloud + spring-cloud-starter-alibaba-nacos-discovery + + + org.springframework.cloud + spring-cloud-starter-loadbalancer + + + com.alibaba.cloud + spring-cloud-starter-alibaba-nacos-config + + + com.alibaba.cloud + spring-cloud-starter-alibaba-sentinel + + + org.springframework.boot + spring-boot-starter-actuator + + + org.springframework.boot + spring-boot-starter-test + test + + + io.projectreactor + reactor-test + test + + + org.springframework.bootspring-boot-maven-plugin + diff --git a/gateway/src/main/java/com/baiyue/gateway/BaiyueGatewayApplication.java b/gateway/src/main/java/com/baiyue/gateway/BaiyueGatewayApplication.java new file mode 100644 index 0000000..19f16f3 --- /dev/null +++ b/gateway/src/main/java/com/baiyue/gateway/BaiyueGatewayApplication.java @@ -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); + } +} diff --git a/gateway/src/main/java/com/baiyue/gateway/config/AppScopeAuthorizationManager.java b/gateway/src/main/java/com/baiyue/gateway/config/AppScopeAuthorizationManager.java new file mode 100644 index 0000000..5dae730 --- /dev/null +++ b/gateway/src/main/java/com/baiyue/gateway/config/AppScopeAuthorizationManager.java @@ -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 { + private final String expectedAppCode; + + AppScopeAuthorizationManager(String expectedAppCode) { + this.expectedAppCode = expectedAppCode; + } + + @Override + public Mono check(Mono 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")); + } +} diff --git a/gateway/src/main/java/com/baiyue/gateway/config/GatewaySecurityConfig.java b/gateway/src/main/java/com/baiyue/gateway/config/GatewaySecurityConfig.java new file mode 100644 index 0000000..a26f7c6 --- /dev/null +++ b/gateway/src/main/java/com/baiyue/gateway/config/GatewaySecurityConfig.java @@ -0,0 +1,74 @@ +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.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()).build(); + decoder.setJwtValidator(JwtValidators.createDefaultWithIssuer(properties.issuer())); + return decoder; + } + + static Mono 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 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(); + } + } +} diff --git a/gateway/src/main/java/com/baiyue/gateway/config/GatewaySecurityProperties.java b/gateway/src/main/java/com/baiyue/gateway/config/GatewaySecurityProperties.java new file mode 100644 index 0000000..c8ba9d1 --- /dev/null +++ b/gateway/src/main/java/com/baiyue/gateway/config/GatewaySecurityProperties.java @@ -0,0 +1,21 @@ +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, List 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"); + } + permitPaths = permitPaths == null ? List.of() : List.copyOf(permitPaths); + } +} diff --git a/gateway/src/main/java/com/baiyue/gateway/filter/TrustedIdentityHeaderFilter.java b/gateway/src/main/java/com/baiyue/gateway/filter/TrustedIdentityHeaderFilter.java new file mode 100644 index 0000000..80b2b32 --- /dev/null +++ b/gateway/src/main/java/com/baiyue/gateway/filter/TrustedIdentityHeaderFilter.java @@ -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 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 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 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"; } + } +} + diff --git a/gateway/src/main/resources/application-overseas.yml b/gateway/src/main/resources/application-overseas.yml new file mode 100644 index 0000000..577c64d --- /dev/null +++ b/gateway/src/main/resources/application-overseas.yml @@ -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 } diff --git a/gateway/src/main/resources/application.yml b/gateway/src/main/resources/application.yml new file mode 100644 index 0000000..e0d26c0 --- /dev/null +++ b/gateway/src/main/resources/application.yml @@ -0,0 +1,66 @@ +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} + 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} diff --git a/gateway/src/test/java/com/baiyue/gateway/config/AppScopeAuthorizationManagerTest.java b/gateway/src/test/java/com/baiyue/gateway/config/AppScopeAuthorizationManagerTest.java new file mode 100644 index 0000000..21e2371 --- /dev/null +++ b/gateway/src/test/java/com/baiyue/gateway/config/AppScopeAuthorizationManagerTest.java @@ -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"))); + } +} diff --git a/integration-service/pom.xml b/integration-service/pom.xml new file mode 100644 index 0000000..94c5693 --- /dev/null +++ b/integration-service/pom.xml @@ -0,0 +1,47 @@ + + + 4.0.0 + + com.baiyue.lastleg + baiyue-lastleg-platform + 1.0.0-SNAPSHOT + ../pom.xml + + integration-service + integration-service + + + + com.baiyue.shared + baiyue-common-web + + + com.alibaba.cloud + spring-cloud-starter-alibaba-nacos-discovery + + + com.alibaba.cloud + spring-cloud-starter-alibaba-nacos-config + + + org.springframework.boot + spring-boot-starter-actuator + + + org.springframework.boot + spring-boot-starter-test + test + + + + + + + org.springframework.boot + spring-boot-maven-plugin + + + + diff --git a/integration-service/src/main/java/com/baiyue/integration/IntegrationServiceApplication.java b/integration-service/src/main/java/com/baiyue/integration/IntegrationServiceApplication.java new file mode 100644 index 0000000..670ad36 --- /dev/null +++ b/integration-service/src/main/java/com/baiyue/integration/IntegrationServiceApplication.java @@ -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); + } +} diff --git a/integration-service/src/main/java/com/baiyue/integration/config/IntegrationProperties.java b/integration-service/src/main/java/com/baiyue/integration/config/IntegrationProperties.java new file mode 100644 index 0000000..09c4eec --- /dev/null +++ b/integration-service/src/main/java/com/baiyue/integration/config/IntegrationProperties.java @@ -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(); + } +} diff --git a/integration-service/src/main/java/com/baiyue/integration/controller/IntegrationProviderController.java b/integration-service/src/main/java/com/baiyue/integration/controller/IntegrationProviderController.java new file mode 100644 index 0000000..6205eac --- /dev/null +++ b/integration-service/src/main/java/com/baiyue/integration/controller/IntegrationProviderController.java @@ -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> providers( + @RequestHeader(value = "X-Internal-Service-Token", required = false) String token) { + internalServiceTokenGuard.verify(token); + return ApiResponse.success(providerStatusService.providers()); + } +} diff --git a/integration-service/src/main/java/com/baiyue/integration/dto/ProviderStatus.java b/integration-service/src/main/java/com/baiyue/integration/dto/ProviderStatus.java new file mode 100644 index 0000000..c062fc1 --- /dev/null +++ b/integration-service/src/main/java/com/baiyue/integration/dto/ProviderStatus.java @@ -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", "配置已就绪"); + } +} diff --git a/integration-service/src/main/java/com/baiyue/integration/provider/AmazonLogisticsAdapter.java b/integration-service/src/main/java/com/baiyue/integration/provider/AmazonLogisticsAdapter.java new file mode 100644 index 0000000..759db0b --- /dev/null +++ b/integration-service/src/main/java/com/baiyue/integration/provider/AmazonLogisticsAdapter.java @@ -0,0 +1,20 @@ +package com.baiyue.integration.provider; + +import com.baiyue.integration.config.IntegrationProperties; +import com.baiyue.integration.dto.ProviderStatus; +import org.springframework.stereotype.Component; + +@Component +public class AmazonLogisticsAdapter implements IntegrationProviderAdapter { + private final IntegrationProperties properties; + + public AmazonLogisticsAdapter(IntegrationProperties properties) { + this.properties = properties; + } + + @Override + public ProviderStatus status() { + IntegrationProperties.AmazonSpApi config = properties.amazonSpApi(); + return ProviderStatus.of("AMAZON_SP_API", config.enabled(), config.configured()); + } +} diff --git a/integration-service/src/main/java/com/baiyue/integration/provider/BaiduOcrAdapter.java b/integration-service/src/main/java/com/baiyue/integration/provider/BaiduOcrAdapter.java new file mode 100644 index 0000000..651d5d0 --- /dev/null +++ b/integration-service/src/main/java/com/baiyue/integration/provider/BaiduOcrAdapter.java @@ -0,0 +1,20 @@ +package com.baiyue.integration.provider; + +import com.baiyue.integration.config.IntegrationProperties; +import com.baiyue.integration.dto.ProviderStatus; +import org.springframework.stereotype.Component; + +@Component +public class BaiduOcrAdapter implements IntegrationProviderAdapter { + private final IntegrationProperties properties; + + public BaiduOcrAdapter(IntegrationProperties properties) { + this.properties = properties; + } + + @Override + public ProviderStatus status() { + IntegrationProperties.BaiduOcr config = properties.baiduOcr(); + return ProviderStatus.of("BAIDU_OCR", config.enabled(), config.configured()); + } +} diff --git a/integration-service/src/main/java/com/baiyue/integration/provider/IntegrationProviderAdapter.java b/integration-service/src/main/java/com/baiyue/integration/provider/IntegrationProviderAdapter.java new file mode 100644 index 0000000..70fef0f --- /dev/null +++ b/integration-service/src/main/java/com/baiyue/integration/provider/IntegrationProviderAdapter.java @@ -0,0 +1,7 @@ +package com.baiyue.integration.provider; + +import com.baiyue.integration.dto.ProviderStatus; + +public interface IntegrationProviderAdapter { + ProviderStatus status(); +} diff --git a/integration-service/src/main/java/com/baiyue/integration/security/InternalServiceTokenGuard.java b/integration-service/src/main/java/com/baiyue/integration/security/InternalServiceTokenGuard.java new file mode 100644 index 0000000..dd05194 --- /dev/null +++ b/integration-service/src/main/java/com/baiyue/integration/security/InternalServiceTokenGuard.java @@ -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, "内部服务令牌无效"); + } + } +} diff --git a/integration-service/src/main/java/com/baiyue/integration/service/ProviderStatusService.java b/integration-service/src/main/java/com/baiyue/integration/service/ProviderStatusService.java new file mode 100644 index 0000000..63b23fe --- /dev/null +++ b/integration-service/src/main/java/com/baiyue/integration/service/ProviderStatusService.java @@ -0,0 +1,20 @@ +package com.baiyue.integration.service; + +import com.baiyue.integration.dto.ProviderStatus; +import com.baiyue.integration.provider.IntegrationProviderAdapter; +import org.springframework.stereotype.Service; + +import java.util.List; + +@Service +public class ProviderStatusService { + private final List adapters; + + public ProviderStatusService(List adapters) { + this.adapters = adapters; + } + + public List providers() { + return adapters.stream().map(IntegrationProviderAdapter::status).toList(); + } +} diff --git a/integration-service/src/main/resources/application.yml b/integration-service/src/main/resources/application.yml new file mode 100644 index 0000000..471ea41 --- /dev/null +++ b/integration-service/src/main/resources/application.yml @@ -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 diff --git a/integration-service/src/test/java/com/baiyue/integration/config/IntegrationPropertiesTest.java b/integration-service/src/test/java/com/baiyue/integration/config/IntegrationPropertiesTest.java new file mode 100644 index 0000000..2d23af5 --- /dev/null +++ b/integration-service/src/test/java/com/baiyue/integration/config/IntegrationPropertiesTest.java @@ -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(); + } +} diff --git a/lastleg/pom.xml b/lastleg/pom.xml new file mode 100644 index 0000000..8b9f8f9 --- /dev/null +++ b/lastleg/pom.xml @@ -0,0 +1,22 @@ + + + 4.0.0 + com.baiyue.lastlegbaiyue-lastleg-platform1.0.0-SNAPSHOT../pom.xml + lastleg-service + + 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 + com.alibaba.cloudspring-cloud-starter-stream-rocketmq + 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/lastleg/src/main/java/com/baiyue/lastleg/BaiyueLastlegApplication.java b/lastleg/src/main/java/com/baiyue/lastleg/BaiyueLastlegApplication.java new file mode 100644 index 0000000..3b63ff2 --- /dev/null +++ b/lastleg/src/main/java/com/baiyue/lastleg/BaiyueLastlegApplication.java @@ -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); } +} diff --git a/lastleg/src/main/java/com/baiyue/lastleg/controller/LastlegHealthController.java b/lastleg/src/main/java/com/baiyue/lastleg/controller/LastlegHealthController.java new file mode 100644 index 0000000..abb4a10 --- /dev/null +++ b/lastleg/src/main/java/com/baiyue/lastleg/controller/LastlegHealthController.java @@ -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> status() { + return ApiResponse.success(Map.of("service", "lastleg-service", "status", "UP")); + } +} diff --git a/lastleg/src/main/java/com/baiyue/lastleg/dto/package-info.java b/lastleg/src/main/java/com/baiyue/lastleg/dto/package-info.java new file mode 100644 index 0000000..5f6f3b2 --- /dev/null +++ b/lastleg/src/main/java/com/baiyue/lastleg/dto/package-info.java @@ -0,0 +1,5 @@ +/** + * API request and response models for last-leg delivery. + * / 尾程配送 API 请求与响应模型。 + */ +package com.baiyue.lastleg.dto; diff --git a/lastleg/src/main/java/com/baiyue/lastleg/entity/package-info.java b/lastleg/src/main/java/com/baiyue/lastleg/entity/package-info.java new file mode 100644 index 0000000..95c0971 --- /dev/null +++ b/lastleg/src/main/java/com/baiyue/lastleg/entity/package-info.java @@ -0,0 +1,5 @@ +/** + * Persistence entities mapped to last-leg MySQL tables. + * / 映射尾程 MySQL 数据表的持久化实体。 + */ +package com.baiyue.lastleg.entity; diff --git a/lastleg/src/main/java/com/baiyue/lastleg/mapper/package-info.java b/lastleg/src/main/java/com/baiyue/lastleg/mapper/package-info.java new file mode 100644 index 0000000..5a24a2e --- /dev/null +++ b/lastleg/src/main/java/com/baiyue/lastleg/mapper/package-info.java @@ -0,0 +1,5 @@ +/** + * MyBatis-Plus single-table data access for the last-leg database. + * / 尾程数据库的 MyBatis-Plus 单表访问层。 + */ +package com.baiyue.lastleg.mapper; diff --git a/lastleg/src/main/java/com/baiyue/lastleg/repository/package-info.java b/lastleg/src/main/java/com/baiyue/lastleg/repository/package-info.java new file mode 100644 index 0000000..b3cd17d --- /dev/null +++ b/lastleg/src/main/java/com/baiyue/lastleg/repository/package-info.java @@ -0,0 +1,5 @@ +/** + * Tenant-scoped aggregate queries and multi-mapper persistence composition. + * / 租户范围聚合查询及多 Mapper 持久化组合。 + */ +package com.baiyue.lastleg.repository; diff --git a/lastleg/src/main/java/com/baiyue/lastleg/service/package-info.java b/lastleg/src/main/java/com/baiyue/lastleg/service/package-info.java new file mode 100644 index 0000000..01f4a18 --- /dev/null +++ b/lastleg/src/main/java/com/baiyue/lastleg/service/package-info.java @@ -0,0 +1,5 @@ +/** + * Business orchestration and transaction boundaries for last-leg delivery. + * / 尾程配送业务编排与事务边界。 + */ +package com.baiyue.lastleg.service; diff --git a/lastleg/src/main/resources/application.yml b/lastleg/src/main/resources/application.yml new file mode 100644 index 0000000..1f9abb2 --- /dev/null +++ b/lastleg/src/main/resources/application.yml @@ -0,0 +1,30 @@ +server: + port: ${LASTLEG_PORT:9303} +spring: + data: + redis: + host: ${REDIS_HOST:127.0.0.1} + port: ${REDIS_PORT:6380} + application: + name: lastleg-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: ${LASTLEG_DB_URL:jdbc:mysql://127.0.0.1:3306/baiyue_lastleg?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai&rewriteBatchedStatements=true} + username: ${LASTLEG_DB_USERNAME:root} + password: ${LASTLEG_DB_PASSWORD:} +management.endpoints.web.exposure.include: health,info,prometheus diff --git a/pom.xml b/pom.xml new file mode 100644 index 0000000..144d5c8 --- /dev/null +++ b/pom.xml @@ -0,0 +1,135 @@ + + + 4.0.0 + + + org.springframework.boot + spring-boot-starter-parent + 3.2.9 + + + + com.baiyue.lastleg + baiyue-lastleg-platform + 1.0.0-SNAPSHOT + pom + 百悦尾程业务平台 + 百悦物流海外尾程独立微服务工程 + + + gateway + lastleg + warehouse + file + print + audit-service + integration-service + event-bridge-service + + + + 17 + ${java.version} + UTF-8 + UTF-8 + 1.0.0-SNAPSHOT + 2023.0.3 + 2023.0.3.2 + 3.5.7 + 2.5.0 + 3.13.0 + 3.4.1 + + + + + org.projectlombok + lombok + true + + + + + + + com.baiyue.shared + baiyue-platform-bom + ${baiyue.shared.version} + pom + import + + + org.springframework.cloud + spring-cloud-dependencies + ${spring-cloud.version} + pom + import + + + com.alibaba.cloud + spring-cloud-alibaba-dependencies + ${spring-cloud-alibaba.version} + pom + import + + + com.baomidou + mybatis-plus-bom + ${mybatis-plus.version} + pom + import + + + org.springdoc + springdoc-openapi-starter-webmvc-ui + ${springdoc.version} + + + org.springdoc + springdoc-openapi-starter-webflux-ui + ${springdoc.version} + + + + + + + + + org.springframework.boot + spring-boot-maven-plugin + + + org.apache.maven.plugins + maven-compiler-plugin + ${maven.compiler.plugin.version} + + ${maven.compiler.release} + true + + + + + + + org.apache.maven.plugins + maven-enforcer-plugin + ${maven.enforcer.plugin.version} + + + 校验构建环境 + enforce + + + [17,) + [3.8.8,) + + + + + + + + diff --git a/print/pom.xml b/print/pom.xml new file mode 100644 index 0000000..274fdcd --- /dev/null +++ b/print/pom.xml @@ -0,0 +1,21 @@ + + + 4.0.0 + com.baiyue.lastlegbaiyue-lastleg-platform1.0.0-SNAPSHOT../pom.xml + print-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/print/src/main/java/com/baiyue/print/PrintServiceApplication.java b/print/src/main/java/com/baiyue/print/PrintServiceApplication.java new file mode 100644 index 0000000..6fa3f7c --- /dev/null +++ b/print/src/main/java/com/baiyue/print/PrintServiceApplication.java @@ -0,0 +1,14 @@ +package com.baiyue.print; + +import org.springframework.boot.SpringApplication; +import org.springframework.boot.autoconfigure.SpringBootApplication; + +/** + * 打印服务启动入口。 + */ +@SpringBootApplication(scanBasePackages = "com.baiyue") +public class PrintServiceApplication { + public static void main(String[] args) { + SpringApplication.run(PrintServiceApplication.class, args); + } +} diff --git a/print/src/main/java/com/baiyue/print/controller/PrintHealthController.java b/print/src/main/java/com/baiyue/print/controller/PrintHealthController.java new file mode 100644 index 0000000..62ffc9a --- /dev/null +++ b/print/src/main/java/com/baiyue/print/controller/PrintHealthController.java @@ -0,0 +1,28 @@ +package com.baiyue.print.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/print") +@Tag(name = "打印服务状态", description = "打印服务可用性接口") +public class PrintHealthController { + @GetMapping("/status") + @Operation(summary = "查询服务状态", description = "返回打印服务的逻辑运行状态") + @ApiResponses({ + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "200", description = "查询成功") + }) + public ApiResponse> status() { + return ApiResponse.success(Map.of("service", "print-service", "status", "UP")); + } +} diff --git a/print/src/main/resources/application.yml b/print/src/main/resources/application.yml new file mode 100644 index 0000000..f6b74bf --- /dev/null +++ b/print/src/main/resources/application.yml @@ -0,0 +1,30 @@ +server: + port: ${PRINT_PORT:9307} +spring: + data: + redis: + host: ${REDIS_HOST:127.0.0.1} + port: ${REDIS_PORT:6380} + application: + name: print-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: ${PRINT_DB_URL:jdbc:mysql://127.0.0.1:3306/baiyue_print?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai&rewriteBatchedStatements=true} + username: ${PRINT_DB_USERNAME:root} + password: ${PRINT_DB_PASSWORD:} +management.endpoints.web.exposure.include: health,info,prometheus diff --git a/print/src/main/resources/db/print.sql b/print/src/main/resources/db/print.sql new file mode 100644 index 0000000..959656f --- /dev/null +++ b/print/src/main/resources/db/print.sql @@ -0,0 +1,62 @@ +-- 打印服务独占数据库,业务服务只保存打印任务逻辑ID。 +CREATE DATABASE IF NOT EXISTS baiyue_print + DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci; +USE baiyue_print; + +CREATE TABLE IF NOT EXISTS print_template ( + id BIGINT UNSIGNED NOT NULL COMMENT '模板ID(雪花ID)', + tenant_id BIGINT UNSIGNED NOT NULL COMMENT '数据所属租户ID,平台模板使用平台租户ID', + template_code VARCHAR(64) NOT NULL COMMENT '模板编码', + template_name VARCHAR(128) NOT NULL COMMENT '模板名称', + template_type VARCHAR(32) NOT NULL COMMENT '模板类型', + content TEXT NOT NULL COMMENT '模板内容', + version_no INT UNSIGNED NOT NULL DEFAULT 1 COMMENT '模板版本号', + 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_print_template_code (tenant_id, template_code, version_no), + KEY idx_print_template_status (tenant_id, status) +) ENGINE=InnoDB COMMENT='打印模板表'; + +CREATE TABLE IF NOT EXISTS print_job ( + id BIGINT UNSIGNED NOT NULL COMMENT '打印任务ID(雪花ID)', + tenant_id BIGINT UNSIGNED NOT NULL COMMENT '数据所属租户ID', + job_no VARCHAR(40) NOT NULL COMMENT '打印任务编号', + template_id BIGINT UNSIGNED NOT NULL COMMENT '打印模板逻辑ID', + business_type VARCHAR(32) NULL COMMENT '关联业务类型', + business_id VARCHAR(64) NULL COMMENT '关联业务逻辑ID', + printer_code VARCHAR(64) NULL COMMENT '目标打印机编码', + copies INT UNSIGNED NOT NULL DEFAULT 1 COMMENT '打印份数', + payload_json JSON NOT NULL COMMENT '模板数据快照', + status VARCHAR(24) NOT NULL DEFAULT 'PENDING' COMMENT '任务状态', + output_file_id BIGINT UNSIGNED NULL COMMENT '生成文件的逻辑ID', + error_message VARCHAR(1000) NULL COMMENT '失败原因', + requested_by BIGINT UNSIGNED NOT NULL COMMENT '申请人ID', + requested_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '申请时间', + started_at DATETIME(3) NULL COMMENT '开始时间', + completed_at DATETIME(3) NULL COMMENT '完成时间', + updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '更新时间', + version INT NOT NULL DEFAULT 0 COMMENT '乐观锁版本号', + PRIMARY KEY (id), + UNIQUE KEY uk_print_job_no (tenant_id, job_no), + KEY idx_print_job_status (tenant_id, status, requested_at), + KEY idx_print_job_business (tenant_id, business_type, business_id) +) ENGINE=InnoDB COMMENT='打印任务表'; + +CREATE TABLE IF NOT EXISTS print_job_history ( + id BIGINT UNSIGNED NOT NULL COMMENT '任务历史ID', + tenant_id BIGINT UNSIGNED NOT NULL COMMENT '数据所属租户ID', + print_job_id BIGINT UNSIGNED NOT NULL COMMENT '打印任务逻辑ID', + from_status VARCHAR(24) NULL COMMENT '变更前状态', + to_status VARCHAR(24) NOT NULL COMMENT '变更后状态', + message VARCHAR(500) NULL COMMENT '状态说明', + operator_id BIGINT UNSIGNED NULL COMMENT '操作人ID', + occurred_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '发生时间', + PRIMARY KEY (id), + KEY idx_print_history_job (tenant_id, print_job_id, occurred_at) +) ENGINE=InnoDB COMMENT='打印任务状态历史表'; diff --git a/scripts/build.ps1 b/scripts/build.ps1 new file mode 100644 index 0000000..ccbed67 --- /dev/null +++ b/scripts/build.ps1 @@ -0,0 +1,13 @@ +param([switch]$SkipTests = $true) + +$ErrorActionPreference = 'Stop' +$rootPom = Join-Path (Split-Path $PSScriptRoot -Parent) 'pom.xml' +$arguments = @('-f', $rootPom, 'clean', 'verify') +if ($SkipTests) { + $arguments += '-DskipTests' +} + +& mvn @arguments +if ($LASTEXITCODE -ne 0) { + throw "MAVEN_BUILD_FAILED: $LASTEXITCODE" +} diff --git a/scripts/check-boundary.ps1 b/scripts/check-boundary.ps1 new file mode 100644 index 0000000..8480b1b --- /dev/null +++ b/scripts/check-boundary.ps1 @@ -0,0 +1,24 @@ +$ErrorActionPreference = 'Stop' +$root = Split-Path $PSScriptRoot -Parent +$forbiddenDirectories = @('auth', 'iam', 'order', 'firstleg', 'common-core', 'common-web', + 'common-security', 'common-openapi', 'common-audit', 'common-event-contract') + +foreach ($directory in $forbiddenDirectories) { + if (Test-Path (Join-Path $root $directory)) { + throw "FORBIDDEN_DIRECTORY: $directory" + } +} + +$pomFiles = Get-ChildItem -Path $root -Recurse -Filter pom.xml +foreach ($pom in $pomFiles) { + $document = New-Object System.Xml.XmlDocument + $document.Load($pom.FullName) +} + +$excluded = Get-ChildItem -Path $root -Recurse -Directory | + Where-Object { $_.Name -in @('.git', '.idea', 'target') } +if ($excluded) { + throw "EXCLUDED_DIRECTORY: $($excluded.FullName -join ',')" +} + +Write-Host "BOUNDARY_AND_POM_XML_OK" diff --git a/scripts/local-env.ps1 b/scripts/local-env.ps1 new file mode 100644 index 0000000..d43ca6e --- /dev/null +++ b/scripts/local-env.ps1 @@ -0,0 +1,18 @@ +# 在当前 PowerShell 会话中执行:. .\scripts\local-env.ps1 +$env:NACOS_SERVER_ADDR = '127.0.0.1:8858' +$env:NACOS_NAMESPACE = 'public' +$env:NACOS_GROUP = 'BAIYUE' +$env:REDIS_HOST = '127.0.0.1' +$env:REDIS_PORT = '6380' +$env:SPRING_DATA_REDIS_HOST = '127.0.0.1' +$env:SPRING_DATA_REDIS_PORT = '6380' +$env:FILE_STORAGE_ROOT = 'D:/baiyue-lastleg-runtime/uploads' +$env:GATEWAY_PORT = '9300' +$env:LASTLEG_PORT = '9303' +$env:WAREHOUSE_PORT = '9305' +$env:FILE_PORT = '9306' +$env:PRINT_PORT = '9307' +$env:AUDIT_PORT = '9310' +$env:INTEGRATION_PORT = '9311' +$env:EVENT_BRIDGE_PORT = '9312' +Write-Host '已加载尾程本地环境:Nacos 8858,Redis 6380,业务端口 9300~9312' diff --git a/warehouse/pom.xml b/warehouse/pom.xml new file mode 100644 index 0000000..de72ce7 --- /dev/null +++ b/warehouse/pom.xml @@ -0,0 +1,71 @@ + + + 4.0.0 + + com.baiyue.lastleg + baiyue-lastleg-platform + 1.0.0-SNAPSHOT + ../pom.xml + + warehouse-service + + + + com.baiyue.shared + baiyue-common-openapi + + + com.baiyue.shared + baiyue-common-audit + + + com.baomidou + mybatis-plus-spring-boot3-starter + + + com.baiyue.shared + baiyue-common-web + + + com.baiyue.shared + baiyue-common-security + + + com.alibaba.cloud + spring-cloud-starter-alibaba-nacos-discovery + + + com.alibaba.cloud + spring-cloud-starter-alibaba-nacos-config + + + com.alibaba.cloud + spring-cloud-starter-alibaba-sentinel + + + org.springframework.boot + spring-boot-starter-actuator + + + com.mysql + mysql-connector-j + runtime + + + org.springframework.boot + spring-boot-starter-test + test + + + + + + + org.springframework.boot + spring-boot-maven-plugin + + + + diff --git a/warehouse/src/main/java/com/baiyue/warehouse/BaiyueWarehouseApplication.java b/warehouse/src/main/java/com/baiyue/warehouse/BaiyueWarehouseApplication.java new file mode 100644 index 0000000..93002b5 --- /dev/null +++ b/warehouse/src/main/java/com/baiyue/warehouse/BaiyueWarehouseApplication.java @@ -0,0 +1,14 @@ +package com.baiyue.warehouse; + +import org.mybatis.spring.annotation.MapperScan; +import org.springframework.boot.SpringApplication; +import org.springframework.boot.autoconfigure.SpringBootApplication; + +/** + * 仓库微服务启动入口,启用 Spring Boot 自动配置并扫描仓库服务的 MyBatis Mapper。 + */ +@MapperScan("com.baiyue.warehouse.mapper") +@SpringBootApplication(scanBasePackages = "com.baiyue") +public class BaiyueWarehouseApplication { + public static void main(String[] args) { SpringApplication.run(BaiyueWarehouseApplication.class, args); } +} diff --git a/warehouse/src/main/java/com/baiyue/warehouse/controller/MerchantWarehouseController.java b/warehouse/src/main/java/com/baiyue/warehouse/controller/MerchantWarehouseController.java new file mode 100644 index 0000000..ab4d06e --- /dev/null +++ b/warehouse/src/main/java/com/baiyue/warehouse/controller/MerchantWarehouseController.java @@ -0,0 +1,67 @@ +package com.baiyue.warehouse.controller; + +import com.baiyue.common.core.api.ApiResponse; +import com.baiyue.common.core.api.PageResult; +import com.baiyue.common.security.AuthenticatedUser; +import com.baiyue.warehouse.dto.WarehouseDetailResponse; +import com.baiyue.warehouse.dto.WarehouseRequests; +import com.baiyue.warehouse.entity.WarehouseEntity; +import com.baiyue.warehouse.service.WarehouseApplicationService; +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 jakarta.validation.Valid; +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.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +/** + * 商户端仓库查询接口,仅允许在认证令牌所属租户范围内读取仓库列表和详情。 + */ +@Tag( + name = "Merchant warehouses / 商户仓库", + description = "Merchant read APIs restricted to the tenant in the authenticated JWT. / 商户端只读接口,租户范围取自认证 JWT。" +) +@RestController +@RequestMapping("/api/merchant/v1/warehouses") +public class MerchantWarehouseController { + @Resource + private WarehouseApplicationService service; + + @Operation( + summary = "List available warehouses / 查询可用仓库", + description = "Tenant comes only from the authenticated JWT; clients cannot override it. / 租户仅从认证 JWT 获取,客户端不可覆盖。" + ) + @ApiResponses({ + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "200", description = "Query succeeded / 查询成功"), + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "400", description = "Invalid query / 查询参数无效"), + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "401", description = "Authentication required / 需要登录") + }) + @GetMapping + public ApiResponse> page( + @AuthenticationPrincipal AuthenticatedUser user, + @Valid WarehouseRequests.QueryRequest query) { + return ApiResponse.success(service.page(user.tenantId(), query)); + } + + @Operation( + summary = "Get warehouse details / 查询仓库详情", + description = "Returns warehouse details only within the authenticated merchant tenant. / 仅返回当前认证商户租户内的仓库详情。" + ) + @ApiResponses({ + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "200", description = "Query succeeded / 查询成功"), + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "401", description = "Authentication required / 需要登录"), + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "404", description = "Warehouse not found in current tenant / 当前租户下仓库不存在") + }) + @GetMapping("/{id}") + public ApiResponse detail( + @AuthenticationPrincipal AuthenticatedUser user, + @Parameter(description = "Warehouse ID / 仓库 ID", example = "190000000000000001", required = true) + @PathVariable long id) { + return ApiResponse.success(service.detail(user.tenantId(), id)); + } +} diff --git a/warehouse/src/main/java/com/baiyue/warehouse/controller/PlatformWarehouseController.java b/warehouse/src/main/java/com/baiyue/warehouse/controller/PlatformWarehouseController.java new file mode 100644 index 0000000..5f98848 --- /dev/null +++ b/warehouse/src/main/java/com/baiyue/warehouse/controller/PlatformWarehouseController.java @@ -0,0 +1,166 @@ +package com.baiyue.warehouse.controller; + +import com.baiyue.common.core.api.ApiResponse; +import com.baiyue.common.core.api.PageResult; +import com.baiyue.common.core.api.ResultCode; +import com.baiyue.common.core.exception.BizException; +import com.baiyue.common.security.AuthenticatedUser; +import com.baiyue.warehouse.dto.WarehouseDetailResponse; +import com.baiyue.warehouse.dto.WarehouseRequests; +import com.baiyue.warehouse.entity.WarehouseEntity; +import com.baiyue.warehouse.service.WarehouseApplicationService; +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 jakarta.validation.Valid; +import jakarta.validation.constraints.NotNull; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.validation.annotation.Validated; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PatchMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.PutMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +/** + * 平台端仓库管理接口,提供跨租户管理场景下的仓库创建、修改、查询和状态维护能力。 + */ +@Validated +@Tag( + name = "Platform warehouse administration / 平台仓库管理", + description = "Platform APIs for managing warehouses across tenants. / 平台端按租户管理仓库的接口。" +) +@RestController +@RequestMapping("/api/platform/v1/warehouse-admin") +public class PlatformWarehouseController { + @Resource + private WarehouseApplicationService service; + + @Operation( + summary = "Create warehouse / 新建仓库", + description = "Creates a warehouse, address and service capabilities for the target tenant. / 为目标租户创建仓库、地址和服务能力。" + ) + @ApiResponses({ + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "200", description = "Created successfully / 创建成功"), + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "400", description = "Invalid request / 请求参数无效"), + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "403", description = "Platform access required / 需要平台端权限"), + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "409", description = "Warehouse code already exists / 仓库编码已存在") + }) + @PostMapping + public ApiResponse create(@AuthenticationPrincipal AuthenticatedUser user, + @Valid @RequestBody WarehouseRequests.SaveRequest request) { + requirePlatform(user); + requireTenant(request.tenantId()); + return ApiResponse.success(service.create(request)); + } + + @Operation( + summary = "Update warehouse / 更新仓库", + description = "Updates the warehouse and replaces its address and capabilities. / 更新仓库,并整体替换地址和服务能力。" + ) + @ApiResponses({ + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "200", description = "Updated successfully / 更新成功"), + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "400", description = "Invalid request / 请求参数无效"), + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "403", description = "Platform access or tenant validation failed / 平台权限或租户校验失败"), + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "404", description = "Warehouse not found / 仓库不存在"), + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "409", description = "Warehouse code already exists / 仓库编码已存在") + }) + @PutMapping("/{id}") + public ApiResponse update( + @AuthenticationPrincipal AuthenticatedUser user, + @Parameter(description = "Target tenant ID / 目标租户 ID", example = "10001", required = true) + @RequestParam @NotNull Long tenantId, + @Parameter(description = "Warehouse ID / 仓库 ID", example = "190000000000000001", required = true) + @PathVariable long id, + @Valid @RequestBody WarehouseRequests.SaveRequest request) { + requirePlatform(user); + requireTenant(tenantId); + service.update(tenantId, id, request); + return ApiResponse.success(null); + } + + @Operation( + summary = "List tenant warehouses / 查询租户仓库", + description = "Returns a tenant-scoped warehouse page with optional country, capability and status filters. / 按租户分页查询仓库,可按国家、能力和状态筛选。" + ) + @ApiResponses({ + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "200", description = "Query succeeded / 查询成功"), + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "400", description = "Invalid query / 查询参数无效"), + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "403", description = "Platform access required / 需要平台端权限") + }) + @GetMapping + public ApiResponse> page( + @AuthenticationPrincipal AuthenticatedUser user, + @Parameter(description = "Target tenant ID / 目标租户 ID", example = "10001", required = true) + @RequestParam @NotNull Long tenantId, + @Valid WarehouseRequests.QueryRequest request) { + requirePlatform(user); + requireTenant(tenantId); + return ApiResponse.success(service.page(tenantId, request)); + } + + @Operation( + summary = "Get tenant warehouse details / 查询租户仓库详情", + description = "Returns the warehouse, addresses and service capabilities in the target tenant. / 返回目标租户下的仓库、地址及服务能力。" + ) + @ApiResponses({ + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "200", description = "Query succeeded / 查询成功"), + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "403", description = "Platform access required / 需要平台端权限"), + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "404", description = "Warehouse not found / 仓库不存在") + }) + @GetMapping("/{id}") + public ApiResponse detail( + @AuthenticationPrincipal AuthenticatedUser user, + @Parameter(description = "Target tenant ID / 目标租户 ID", example = "10001", required = true) + @RequestParam @NotNull Long tenantId, + @Parameter(description = "Warehouse ID / 仓库 ID", example = "190000000000000001", required = true) + @PathVariable long id) { + requirePlatform(user); + requireTenant(tenantId); + return ApiResponse.success(service.detail(tenantId, id)); + } + + @Operation( + summary = "Enable or disable warehouse / 启用或停用仓库", + description = "Changes warehouse status to ACTIVE or INACTIVE. / 将仓库状态切换为 ACTIVE 或 INACTIVE。" + ) + @ApiResponses({ + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "200", description = "Status changed / 状态变更成功"), + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "400", description = "Invalid status / 状态无效"), + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "403", description = "Platform access required / 需要平台端权限"), + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "404", description = "Warehouse not found / 仓库不存在") + }) + @PatchMapping("/{id}/status") + public ApiResponse status( + @AuthenticationPrincipal AuthenticatedUser user, + @Parameter(description = "Target tenant ID / 目标租户 ID", example = "10001", required = true) + @RequestParam @NotNull Long tenantId, + @Parameter(description = "Warehouse ID / 仓库 ID", example = "190000000000000001", required = true) + @PathVariable long id, + @Parameter(description = "Target status: ACTIVE or INACTIVE / 目标状态:ACTIVE 或 INACTIVE", example = "ACTIVE", required = true) + @RequestParam String status) { + requirePlatform(user); + requireTenant(tenantId); + service.changeStatus(tenantId, id, status); + return ApiResponse.success(null); + } + + private static void requirePlatform(AuthenticatedUser user) { + if (user == null || !"PLATFORM".equals(user.appCode())) { + throw new BizException(ResultCode.FORBIDDEN, "Platform application required / 仅允许平台端访问"); + } + } + + private static void requireTenant(Long tenantId) { + if (tenantId == null || tenantId <= 0) { + throw new BizException(ResultCode.BAD_REQUEST, "Target tenant is required / 必须指定有效目标租户"); + } + } +} + diff --git a/warehouse/src/main/java/com/baiyue/warehouse/controller/WarehouseHealthController.java b/warehouse/src/main/java/com/baiyue/warehouse/controller/WarehouseHealthController.java new file mode 100644 index 0000000..e519c08 --- /dev/null +++ b/warehouse/src/main/java/com/baiyue/warehouse/controller/WarehouseHealthController.java @@ -0,0 +1,26 @@ +package com.baiyue.warehouse.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 = "Warehouse service status / 仓储服务状态", description = "Warehouse service availability API. / 仓储服务可用性接口。") +@RestController +@RequestMapping("/api/platform/v1/warehouses") +public class WarehouseHealthController { + @Operation(summary = "Get service status / 查询服务状态", description = "Returns the logical status of warehouse-service. / 返回 warehouse-service 的逻辑运行状态。") + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "200", description = "Service is available / 服务可用") + @GetMapping("/status") + public ApiResponse> status() { + return ApiResponse.success(Map.of("service", "warehouse-service", "status", "UP")); + } +} + diff --git a/warehouse/src/main/java/com/baiyue/warehouse/dto/WarehouseDetailResponse.java b/warehouse/src/main/java/com/baiyue/warehouse/dto/WarehouseDetailResponse.java new file mode 100644 index 0000000..f9ec1c8 --- /dev/null +++ b/warehouse/src/main/java/com/baiyue/warehouse/dto/WarehouseDetailResponse.java @@ -0,0 +1,19 @@ +package com.baiyue.warehouse.dto; + +import com.baiyue.warehouse.entity.ServiceCapabilityEntity; +import com.baiyue.warehouse.entity.WarehouseAddressEntity; +import com.baiyue.warehouse.entity.WarehouseEntity; +import io.swagger.v3.oas.annotations.media.Schema; + +import java.util.List; + +/** + * 仓库聚合详情响应,组合仓库主数据、地址列表和服务能力列表。 + */ +@Schema(name = "WarehouseDetailResponse", description = "Warehouse aggregate details / 仓库聚合详情") +public record WarehouseDetailResponse( + @Schema(description = "Warehouse master data / 仓库主数据") WarehouseEntity warehouse, + @Schema(description = "Warehouse addresses / 仓库地址列表") List addresses, + @Schema(description = "Warehouse service capabilities / 仓库服务能力列表") List capabilities +) { +} diff --git a/warehouse/src/main/java/com/baiyue/warehouse/dto/WarehouseRequests.java b/warehouse/src/main/java/com/baiyue/warehouse/dto/WarehouseRequests.java new file mode 100644 index 0000000..079625e --- /dev/null +++ b/warehouse/src/main/java/com/baiyue/warehouse/dto/WarehouseRequests.java @@ -0,0 +1,117 @@ +package com.baiyue.warehouse.dto; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.Valid; +import jakarta.validation.constraints.Email; +import jakarta.validation.constraints.Max; +import jakarta.validation.constraints.Min; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Pattern; +import jakarta.validation.constraints.Size; + +import java.math.BigDecimal; +import java.time.LocalTime; +import java.util.List; + +/** + * 仓库接口请求模型容器,集中定义仓库保存、地址、服务能力、查询和状态变更请求契约。 + */ +public final class WarehouseRequests { + private WarehouseRequests() { + } + + @Schema(name = "WarehouseSaveRequest", description = "Warehouse create or update request / 仓库新增或更新请求") + public record SaveRequest( + @Schema(description = "Target tenant ID / 目标租户 ID", example = "10001", requiredMode = Schema.RequiredMode.REQUIRED) + @NotNull Long tenantId, + @Schema(description = "Tenant-unique warehouse code / 租户内唯一仓库编码", example = "US-LAX-01", maxLength = 50, requiredMode = Schema.RequiredMode.REQUIRED) + @NotBlank @Size(max = 50) String warehouseCode, + @Schema(description = "Warehouse name / 仓库名称", example = "Los Angeles Main Warehouse / 洛杉矶主仓", maxLength = 120, requiredMode = Schema.RequiredMode.REQUIRED) + @NotBlank @Size(max = 120) String warehouseName, + @Schema(description = "Warehouse type / 仓库类型", example = "OVERSEAS", requiredMode = Schema.RequiredMode.REQUIRED) + @NotBlank String warehouseType, + @Schema(description = "ISO 3166-1 alpha-2 country code / ISO 两位国家代码", example = "US", pattern = "[A-Z]{2}", requiredMode = Schema.RequiredMode.REQUIRED) + @NotBlank @Pattern(regexp = "[A-Z]{2}") String countryCode, + @Schema(description = "IANA timezone / IANA 时区", example = "America/Los_Angeles", requiredMode = Schema.RequiredMode.REQUIRED) + @NotBlank String timezone, + @Schema(description = "Contact name / 联系人姓名", example = "Alex Chen", maxLength = 64) + @Size(max = 64) String contactName, + @Schema(description = "Contact phone / 联系电话", example = "+1-213-555-0100", maxLength = 32) + @Size(max = 32) String contactPhone, + @Schema(description = "Contact email / 联系邮箱", example = "warehouse@example.com") + @Email String contactEmail, + @Schema(description = "Warehouse status / 仓库状态", example = "ACTIVE", allowableValues = {"ACTIVE", "INACTIVE"}, requiredMode = Schema.RequiredMode.REQUIRED) + @NotBlank String status, + @Schema(description = "Remark / 备注", example = "Supports appointment receiving / 支持预约入库", maxLength = 500) + @Size(max = 500) String remark, + @Schema(description = "Primary warehouse address / 仓库主地址", requiredMode = Schema.RequiredMode.REQUIRED) + @Valid @NotNull AddressRequest address, + @Schema(description = "Supported service capabilities / 支持的服务能力") + @Valid List capabilities + ) { + } + + @Schema(name = "WarehouseAddressRequest", description = "Warehouse address / 仓库地址") + public record AddressRequest( + @Schema(description = "Address type / 地址类型", example = "WAREHOUSE", requiredMode = Schema.RequiredMode.REQUIRED) + @NotBlank String addressType, + @Schema(description = "ISO two-letter country code / ISO 两位国家代码", example = "US", requiredMode = Schema.RequiredMode.REQUIRED) + @NotBlank @Pattern(regexp = "[A-Z]{2}") String countryCode, + @Schema(description = "State or province / 州或省", example = "California", maxLength = 100) + @Size(max = 100) String stateProvince, + @Schema(description = "City / 城市", example = "Los Angeles", maxLength = 100, requiredMode = Schema.RequiredMode.REQUIRED) + @NotBlank @Size(max = 100) String city, + @Schema(description = "District / 区县", example = "Commerce", maxLength = 100) + @Size(max = 100) String district, + @Schema(description = "Postal code / 邮政编码", example = "90040", maxLength = 20) + @Size(max = 20) String postalCode, + @Schema(description = "Address line 1 / 地址第一行", example = "123 Logistics Ave", maxLength = 255, requiredMode = Schema.RequiredMode.REQUIRED) + @NotBlank @Size(max = 255) String addressLine1, + @Schema(description = "Address line 2 / 地址第二行", example = "Building A", maxLength = 255) + @Size(max = 255) String addressLine2, + @Schema(description = "Latitude / 纬度", example = "34.052235") + BigDecimal latitude, + @Schema(description = "Longitude / 经度", example = "-118.243683") + BigDecimal longitude + ) { + } + + @Schema(name = "WarehouseCapabilityRequest", description = "Warehouse service capability / 仓库服务能力") + public record CapabilityRequest( + @Schema(description = "Capability code / 能力编码", example = "STORAGE", maxLength = 50, requiredMode = Schema.RequiredMode.REQUIRED) + @NotBlank @Size(max = 50) String capabilityCode, + @Schema(description = "Capability name / 能力名称", example = "Storage / 仓储", maxLength = 100, requiredMode = Schema.RequiredMode.REQUIRED) + @NotBlank @Size(max = 100) String capabilityName, + @Schema(description = "Whether enabled / 是否启用", example = "true", defaultValue = "true") + Boolean enabled, + @Schema(description = "Daily cutoff time / 每日截单时间", example = "16:00:00", type = "string", format = "time") + LocalTime cutoffTime, + @Schema(description = "Capability description / 能力说明", example = "Standard pallet storage / 标准托盘仓储", maxLength = 500) + @Size(max = 500) String description + ) { + } + + @Schema(name = "WarehouseQueryRequest", description = "Warehouse page query / 仓库分页查询条件") + public record QueryRequest( + @Schema(description = "Page number, starting at 1 / 页码,从 1 开始", example = "1", defaultValue = "1", minimum = "1") + @Min(1) long page, + @Schema(description = "Page size / 每页条数", example = "20", defaultValue = "20", minimum = "1", maximum = "200") + @Min(1) @Max(200) long size, + @Schema(description = "Country filter / 国家筛选", example = "US") + String countryCode, + @Schema(description = "Enabled capability code filter / 已启用能力编码筛选", example = "STORAGE") + String capabilityCode, + @Schema(description = "Status filter / 状态筛选", example = "ACTIVE", allowableValues = {"ACTIVE", "INACTIVE"}) + String status + ) { + public QueryRequest { + if (page == 0) { + page = 1; + } + if (size == 0) { + size = 20; + } + } + } +} diff --git a/warehouse/src/main/java/com/baiyue/warehouse/entity/ServiceCapabilityEntity.java b/warehouse/src/main/java/com/baiyue/warehouse/entity/ServiceCapabilityEntity.java new file mode 100644 index 0000000..3de6424 --- /dev/null +++ b/warehouse/src/main/java/com/baiyue/warehouse/entity/ServiceCapabilityEntity.java @@ -0,0 +1,57 @@ +package com.baiyue.warehouse.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 com.baiyue.common.core.model.BaseEntity; +import io.swagger.v3.oas.annotations.media.Schema; + +import java.time.LocalTime; + +/** + * 仓库服务能力持久化实体,对应 {@code wh_service_capability} 表。 + * 用于记录租户仓库可提供的能力、启用状态和每日截单时间。 + */ +@Schema(name = "WarehouseServiceCapability", description = "仓库服务能力") +@Getter +@Setter +@TableName("wh_service_capability") +public class ServiceCapabilityEntity extends BaseEntity { + @Schema(description = "能力记录编号", example = "190000000000000021") + @TableId(type = IdType.ASSIGN_ID) + private Long id; + @Schema(description = "租户编号", example = "10001") + private Long tenantId; + @Schema(description = "仓库编号", example = "190000000000000001") + private Long warehouseId; + @Schema(description = "能力编码", example = "STORAGE") + private String capabilityCode; + @Schema(description = "能力名称", example = "仓储") + private String capabilityName; + @Schema(description = "是否启用", example = "true") + private Boolean enabled; + @Schema(description = "每日截单时间", example = "16:00:00", type = "string", format = "time") + private LocalTime cutoffTime; + @Schema(description = "能力说明", example = "标准托盘仓储") + private String description; + + + + + + + + + + + + + + + + + +} diff --git a/warehouse/src/main/java/com/baiyue/warehouse/entity/WarehouseAddressEntity.java b/warehouse/src/main/java/com/baiyue/warehouse/entity/WarehouseAddressEntity.java new file mode 100644 index 0000000..e455725 --- /dev/null +++ b/warehouse/src/main/java/com/baiyue/warehouse/entity/WarehouseAddressEntity.java @@ -0,0 +1,77 @@ +package com.baiyue.warehouse.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 com.baiyue.common.core.model.BaseEntity; +import io.swagger.v3.oas.annotations.media.Schema; + +import java.math.BigDecimal; + +/** + * 仓库地址持久化实体,对应 {@code wh_warehouse_address} 表。 + * 用于保存租户仓库的结构化地址与经纬度信息。 + */ +@Schema(name = "WarehouseAddress", description = "仓库地址数据") +@Getter +@Setter +@TableName("wh_warehouse_address") +public class WarehouseAddressEntity extends BaseEntity { + @Schema(description = "地址编号", example = "190000000000000011") + @TableId(type = IdType.ASSIGN_ID) + private Long id; + @Schema(description = "租户编号", example = "10001") + private Long tenantId; + @Schema(description = "仓库编号", example = "190000000000000001") + private Long warehouseId; + @Schema(description = "地址类型", example = "WAREHOUSE") + private String addressType; + @Schema(description = "ISO 两位国家代码", example = "US") + private String countryCode; + @Schema(description = "州或省", example = "California") + private String stateProvince; + @Schema(description = "城市", example = "Los Angeles") + private String city; + @Schema(description = "区县", example = "Commerce") + private String district; + @Schema(description = "邮政编码", example = "90040") + private String postalCode; + @Schema(description = "地址第一行", example = "123 Logistics Ave") + private String addressLine1; + @Schema(description = "地址第二行", example = "Building A") + private String addressLine2; + @Schema(description = "纬度", example = "34.052235") + private BigDecimal latitude; + @Schema(description = "经度", example = "-118.243683") + private BigDecimal longitude; + + + + + + + + + + + + + + + + + + + + + + + + + + + +} diff --git a/warehouse/src/main/java/com/baiyue/warehouse/entity/WarehouseEntity.java b/warehouse/src/main/java/com/baiyue/warehouse/entity/WarehouseEntity.java new file mode 100644 index 0000000..19cddfc --- /dev/null +++ b/warehouse/src/main/java/com/baiyue/warehouse/entity/WarehouseEntity.java @@ -0,0 +1,71 @@ +package com.baiyue.warehouse.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 com.baiyue.common.core.model.BaseEntity; +import io.swagger.v3.oas.annotations.media.Schema; + +/** + * 仓库主数据持久化实体,对应 {@code wh_warehouse} 表。 + * 用于记录租户下仓库的编码、类型、国家、联系方式和启停状态。 + */ +@Schema(name = "Warehouse", description = "仓库主数据") +@Getter +@Setter +@TableName("wh_warehouse") +public class WarehouseEntity extends BaseEntity { + @Schema(description = "仓库编号", example = "190000000000000001") + @TableId(type = IdType.ASSIGN_ID) + private Long id; + @Schema(description = "租户编号", example = "10001") + private Long tenantId; + @Schema(description = "仓库编码", example = "US-LAX-01") + private String warehouseCode; + @Schema(description = "仓库名称", example = "洛杉矶主仓") + private String warehouseName; + @Schema(description = "仓库类型", example = "OVERSEAS") + private String warehouseType; + @Schema(description = "ISO 两位国家代码", example = "US") + private String countryCode; + @Schema(description = "IANA 时区", example = "America/Los_Angeles") + private String timezone; + @Schema(description = "联系人", example = "陈先生") + private String contactName; + @Schema(description = "联系电话", example = "+1-213-555-0100") + private String contactPhone; + @Schema(description = "联系邮箱", example = "warehouse@example.com") + private String contactEmail; + @Schema(description = "仓库状态", example = "ACTIVE", allowableValues = {"ACTIVE", "INACTIVE"}) + private String status; + @Schema(description = "备注", example = "支持预约入库") + private String remark; + + + + + + + + + + + + + + + + + + + + + + + + + +} diff --git a/warehouse/src/main/java/com/baiyue/warehouse/mapper/ServiceCapabilityMapper.java b/warehouse/src/main/java/com/baiyue/warehouse/mapper/ServiceCapabilityMapper.java new file mode 100644 index 0000000..8f861d1 --- /dev/null +++ b/warehouse/src/main/java/com/baiyue/warehouse/mapper/ServiceCapabilityMapper.java @@ -0,0 +1,16 @@ +package com.baiyue.warehouse.mapper; + +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import com.baiyue.warehouse.entity.ServiceCapabilityEntity; +import org.apache.ibatis.annotations.Delete; +import org.apache.ibatis.annotations.Param; + +/** + * 仓库服务能力 MyBatis-Plus Mapper,负责 {@code wh_service_capability} 表访问及仓库子记录的物理清理。 + */ +public interface ServiceCapabilityMapper extends BaseMapper { + + @Delete("DELETE FROM wh_service_capability WHERE tenant_id = #{tenantId} AND warehouse_id = #{warehouseId}") + int physicallyDeleteByWarehouse(@Param("tenantId") long tenantId, + @Param("warehouseId") long warehouseId); +} diff --git a/warehouse/src/main/java/com/baiyue/warehouse/mapper/WarehouseAddressMapper.java b/warehouse/src/main/java/com/baiyue/warehouse/mapper/WarehouseAddressMapper.java new file mode 100644 index 0000000..90716b5 --- /dev/null +++ b/warehouse/src/main/java/com/baiyue/warehouse/mapper/WarehouseAddressMapper.java @@ -0,0 +1,16 @@ +package com.baiyue.warehouse.mapper; + +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import com.baiyue.warehouse.entity.WarehouseAddressEntity; +import org.apache.ibatis.annotations.Delete; +import org.apache.ibatis.annotations.Param; + +/** + * 仓库地址 MyBatis-Plus Mapper,负责 {@code wh_warehouse_address} 表访问及仓库子记录的物理清理。 + */ +public interface WarehouseAddressMapper extends BaseMapper { + + @Delete("DELETE FROM wh_warehouse_address WHERE tenant_id = #{tenantId} AND warehouse_id = #{warehouseId}") + int physicallyDeleteByWarehouse(@Param("tenantId") long tenantId, + @Param("warehouseId") long warehouseId); +} diff --git a/warehouse/src/main/java/com/baiyue/warehouse/mapper/WarehouseMapper.java b/warehouse/src/main/java/com/baiyue/warehouse/mapper/WarehouseMapper.java new file mode 100644 index 0000000..5c9ef01 --- /dev/null +++ b/warehouse/src/main/java/com/baiyue/warehouse/mapper/WarehouseMapper.java @@ -0,0 +1,6 @@ +package com.baiyue.warehouse.mapper; import com.baomidou.mybatisplus.core.mapper.BaseMapper; import com.baiyue.warehouse.entity.WarehouseEntity; + +/** + * 仓库主数据 MyBatis-Plus Mapper,提供 {@code wh_warehouse} 表的单表持久化访问。 + */ +public interface WarehouseMapper extends BaseMapper {} diff --git a/warehouse/src/main/java/com/baiyue/warehouse/repository/WarehouseRepository.java b/warehouse/src/main/java/com/baiyue/warehouse/repository/WarehouseRepository.java new file mode 100644 index 0000000..904e3f4 --- /dev/null +++ b/warehouse/src/main/java/com/baiyue/warehouse/repository/WarehouseRepository.java @@ -0,0 +1,105 @@ +package com.baiyue.warehouse.repository; + +import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; +import com.baomidou.mybatisplus.extension.plugins.pagination.Page; +import com.baiyue.common.core.api.PageResult; +import com.baiyue.warehouse.dto.WarehouseRequests; +import com.baiyue.warehouse.entity.ServiceCapabilityEntity; +import com.baiyue.warehouse.entity.WarehouseAddressEntity; +import com.baiyue.warehouse.entity.WarehouseEntity; +import com.baiyue.warehouse.mapper.ServiceCapabilityMapper; +import com.baiyue.warehouse.mapper.WarehouseAddressMapper; +import com.baiyue.warehouse.mapper.WarehouseMapper; +import org.springframework.stereotype.Repository; +import org.springframework.util.StringUtils; + +import java.util.List; + +/** + * 仓库聚合持久化仓储,组合仓库、地址和服务能力 Mapper,并强制按租户范围执行查询与写入。 + */ +@Repository +public class WarehouseRepository { + private final WarehouseMapper warehouses; + private final WarehouseAddressMapper addresses; + private final ServiceCapabilityMapper capabilities; + + public WarehouseRepository(WarehouseMapper warehouses, + WarehouseAddressMapper addresses, + ServiceCapabilityMapper capabilities) { + this.warehouses = warehouses; + this.addresses = addresses; + this.capabilities = capabilities; + } + + public void insertWarehouse(WarehouseEntity warehouse) { + warehouses.insert(warehouse); + } + + public void saveWarehouse(WarehouseEntity warehouse) { + warehouses.updateById(warehouse); + } + + public WarehouseEntity findOwned(long tenantId, long id) { + return warehouses.selectOne(new LambdaQueryWrapper() + .eq(WarehouseEntity::getTenantId, tenantId) + .eq(WarehouseEntity::getId, id)); + } + + public boolean existsCode(long tenantId, String code, Long excludedId) { + return warehouses.selectCount(new LambdaQueryWrapper() + .eq(WarehouseEntity::getTenantId, tenantId) + .eq(WarehouseEntity::getWarehouseCode, code) + .ne(excludedId != null, WarehouseEntity::getId, excludedId)) > 0; + } + + public PageResult page(long tenantId, WarehouseRequests.QueryRequest query) { + LambdaQueryWrapper wrapper = new LambdaQueryWrapper() + .eq(WarehouseEntity::getTenantId, tenantId) + .eq(StringUtils.hasText(query.status()), WarehouseEntity::getStatus, query.status()) + .eq(StringUtils.hasText(query.countryCode()), WarehouseEntity::getCountryCode, query.countryCode()) + .orderByDesc(WarehouseEntity::getCreatedAt); + + if (StringUtils.hasText(query.capabilityCode())) { + List ids = capabilities.selectList(new LambdaQueryWrapper() + .select(ServiceCapabilityEntity::getWarehouseId) + .eq(ServiceCapabilityEntity::getTenantId, tenantId) + .eq(ServiceCapabilityEntity::getCapabilityCode, query.capabilityCode()) + .eq(ServiceCapabilityEntity::getEnabled, true)) + .stream() + .map(ServiceCapabilityEntity::getWarehouseId) + .distinct() + .toList(); + if (ids.isEmpty()) { + return new PageResult<>(List.of(), query.page(), query.size(), 0, 0); + } + wrapper.in(WarehouseEntity::getId, ids); + } + return PageResult.from(warehouses.selectPage(new Page<>(query.page(), query.size()), wrapper)); + } + + public List findAddresses(long tenantId, long warehouseId) { + return addresses.selectList(new LambdaQueryWrapper() + .eq(WarehouseAddressEntity::getTenantId, tenantId) + .eq(WarehouseAddressEntity::getWarehouseId, warehouseId)); + } + + public List findCapabilities(long tenantId, long warehouseId) { + return capabilities.selectList(new LambdaQueryWrapper() + .eq(ServiceCapabilityEntity::getTenantId, tenantId) + .eq(ServiceCapabilityEntity::getWarehouseId, warehouseId)); + } + + public void physicallyDeleteChildren(long tenantId, long warehouseId) { + addresses.physicallyDeleteByWarehouse(tenantId, warehouseId); + capabilities.physicallyDeleteByWarehouse(tenantId, warehouseId); + } + + public void insertAddress(WarehouseAddressEntity address) { + addresses.insert(address); + } + + public void insertCapability(ServiceCapabilityEntity capability) { + capabilities.insert(capability); + } +} diff --git a/warehouse/src/main/java/com/baiyue/warehouse/service/WarehouseApplicationService.java b/warehouse/src/main/java/com/baiyue/warehouse/service/WarehouseApplicationService.java new file mode 100644 index 0000000..4ca54de --- /dev/null +++ b/warehouse/src/main/java/com/baiyue/warehouse/service/WarehouseApplicationService.java @@ -0,0 +1,156 @@ +package com.baiyue.warehouse.service; +import jakarta.annotation.Resource; + +import com.baiyue.common.audit.BusinessAudit; +import com.baiyue.common.core.api.PageResult; +import com.baiyue.common.core.api.ResultCode; +import com.baiyue.common.core.exception.BizException; +import com.baiyue.warehouse.dto.WarehouseDetailResponse; +import com.baiyue.warehouse.dto.WarehouseRequests; +import com.baiyue.warehouse.entity.ServiceCapabilityEntity; +import com.baiyue.warehouse.entity.WarehouseAddressEntity; +import com.baiyue.warehouse.entity.WarehouseEntity; +import com.baiyue.warehouse.repository.WarehouseRepository; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.List; + +/** + * 仓库应用服务,负责仓库主数据、地址和服务能力的租户级用例编排、事务控制与业务校验。 + */ +@Service +public class WarehouseApplicationService { + @Resource + private WarehouseRepository repository; + + + @Transactional + @BusinessAudit( + event = "WAREHOUSE_CREATE", + resourceType = "WAREHOUSE", + resourceId = "#r.warehouseCode", + description = "新建仓库", + captureBefore = false + ) + public Long create(WarehouseRequests.SaveRequest request) { + ensureCodeUnique(request.tenantId(), request.warehouseCode(), null); + WarehouseEntity warehouse = toEntity(request); + repository.insertWarehouse(warehouse); + insertChildren(warehouse.getTenantId(), warehouse.getId(), request.address(), request.capabilities()); + return warehouse.getId(); + } + + @Transactional + @BusinessAudit( + event = "WAREHOUSE_UPDATE", + resourceType = "WAREHOUSE", + resourceId = "#id", + description = "更新仓库" + ) + public void update(long tenantId, long id, WarehouseRequests.SaveRequest request) { + if (request.tenantId() == null || tenantId != request.tenantId()) { + throw new BizException(ResultCode.FORBIDDEN, "租户不匹配"); + } + WarehouseEntity old = owned(tenantId, id); + ensureCodeUnique(tenantId, request.warehouseCode(), id); + + WarehouseEntity warehouse = toEntity(request); + warehouse.setId(old.getId()); + repository.saveWarehouse(warehouse); + + repository.physicallyDeleteChildren(tenantId, id); + insertChildren(tenantId, id, request.address(), request.capabilities()); + } + + public PageResult page(long tenantId, WarehouseRequests.QueryRequest query) { + return repository.page(tenantId, query); + } + + public WarehouseDetailResponse detail(long tenantId, long id) { + WarehouseEntity warehouse = owned(tenantId, id); + List addresses = repository.findAddresses(tenantId, id); + List capabilities = repository.findCapabilities(tenantId, id); + return new WarehouseDetailResponse(warehouse, addresses, capabilities); + } + + @Transactional + @BusinessAudit( + event = "WAREHOUSE_STATUS_CHANGE", + resourceType = "WAREHOUSE", + resourceId = "#id", + description = "启用或停用仓库" + ) + public void changeStatus(long tenantId, long id, String status) { + if (!"ACTIVE".equals(status) && !"INACTIVE".equals(status)) { + throw new BizException("无效状态"); + } + WarehouseEntity warehouse = owned(tenantId, id); + warehouse.setStatus(status); + repository.saveWarehouse(warehouse); + } + + private WarehouseEntity owned(long tenantId, long id) { + WarehouseEntity warehouse = repository.findOwned(tenantId, id); + if (warehouse == null) { + throw new BizException(ResultCode.NOT_FOUND, "仓库不存在"); + } + return warehouse; + } + + private void ensureCodeUnique(long tenantId, String code, Long excludedId) { + if (repository.existsCode(tenantId, code, excludedId)) { + throw new BizException(ResultCode.CONFLICT, "仓库编码已存在"); + } + } + + private WarehouseEntity toEntity(WarehouseRequests.SaveRequest request) { + WarehouseEntity warehouse = new WarehouseEntity(); + warehouse.setTenantId(request.tenantId()); + warehouse.setWarehouseCode(request.warehouseCode()); + warehouse.setWarehouseName(request.warehouseName()); + warehouse.setWarehouseType(request.warehouseType()); + warehouse.setCountryCode(request.countryCode()); + warehouse.setTimezone(request.timezone()); + warehouse.setContactName(request.contactName()); + warehouse.setContactPhone(request.contactPhone()); + warehouse.setContactEmail(request.contactEmail()); + warehouse.setStatus(request.status()); + warehouse.setRemark(request.remark()); + return warehouse; + } + + private void insertChildren(long tenantId, long warehouseId, + WarehouseRequests.AddressRequest address, + List requestedCapabilities) { + WarehouseAddressEntity addressEntity = new WarehouseAddressEntity(); + addressEntity.setTenantId(tenantId); + addressEntity.setWarehouseId(warehouseId); + addressEntity.setAddressType(address.addressType()); + addressEntity.setCountryCode(address.countryCode()); + addressEntity.setStateProvince(address.stateProvince()); + addressEntity.setCity(address.city()); + addressEntity.setDistrict(address.district()); + addressEntity.setPostalCode(address.postalCode()); + addressEntity.setAddressLine1(address.addressLine1()); + addressEntity.setAddressLine2(address.addressLine2()); + addressEntity.setLatitude(address.latitude()); + addressEntity.setLongitude(address.longitude()); + repository.insertAddress(addressEntity); + + if (requestedCapabilities == null) { + return; + } + for (WarehouseRequests.CapabilityRequest capability : requestedCapabilities) { + ServiceCapabilityEntity entity = new ServiceCapabilityEntity(); + entity.setTenantId(tenantId); + entity.setWarehouseId(warehouseId); + entity.setCapabilityCode(capability.capabilityCode()); + entity.setCapabilityName(capability.capabilityName()); + entity.setEnabled(capability.enabled() == null || capability.enabled()); + entity.setCutoffTime(capability.cutoffTime()); + entity.setDescription(capability.description()); + repository.insertCapability(entity); + } + } +} diff --git a/warehouse/src/main/resources/application.yml b/warehouse/src/main/resources/application.yml new file mode 100644 index 0000000..763c8b9 --- /dev/null +++ b/warehouse/src/main/resources/application.yml @@ -0,0 +1,30 @@ +server: + port: ${WAREHOUSE_PORT:9305} +spring: + data: + redis: + host: ${REDIS_HOST:127.0.0.1} + port: ${REDIS_PORT:6380} + application: + name: warehouse-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: ${WAREHOUSE_DB_URL:jdbc:mysql://127.0.0.1:3306/baiyue_warehouse?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai&rewriteBatchedStatements=true} + username: ${WAREHOUSE_DB_USERNAME:root} + password: ${WAREHOUSE_DB_PASSWORD:} +management.endpoints.web.exposure.include: health,info,prometheus diff --git a/warehouse/src/main/resources/db/warehouse.sql b/warehouse/src/main/resources/db/warehouse.sql new file mode 100644 index 0000000..e9718cd --- /dev/null +++ b/warehouse/src/main/resources/db/warehouse.sql @@ -0,0 +1,75 @@ +CREATE DATABASE IF NOT EXISTS baiyue_warehouse + DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci; +USE baiyue_warehouse; + +CREATE TABLE IF NOT EXISTS wh_warehouse ( + id BIGINT UNSIGNED NOT NULL COMMENT '仓库ID(雪花ID)', + tenant_id BIGINT UNSIGNED NOT NULL COMMENT '仓库所属租户ID', + warehouse_code VARCHAR(50) NOT NULL COMMENT '租户内唯一仓库编码', + warehouse_name VARCHAR(120) NOT NULL COMMENT '仓库名称', + warehouse_type VARCHAR(32) NOT NULL DEFAULT 'OVERSEAS' COMMENT '仓库类型', + country_code CHAR(2) NOT NULL COMMENT '所在国家二字码', + timezone VARCHAR(64) NOT NULL COMMENT '仓库所在地IANA时区', + contact_name VARCHAR(64) DEFAULT NULL COMMENT '仓库联系人', + contact_phone VARCHAR(32) DEFAULT NULL COMMENT '联系电话', + contact_email VARCHAR(128) DEFAULT NULL COMMENT '联系邮箱', + status VARCHAR(20) NOT NULL DEFAULT 'ACTIVE' COMMENT '仓库状态', + remark VARCHAR(500) DEFAULT NULL COMMENT '仓库备注', + created_by BIGINT UNSIGNED DEFAULT NULL COMMENT '创建人ID', + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间', + updated_by BIGINT UNSIGNED DEFAULT 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_wh_tenant_code (tenant_id, warehouse_code, deleted), + KEY idx_wh_tenant_status (tenant_id, status, deleted), + KEY idx_wh_country (tenant_id, country_code, deleted) +) ENGINE=InnoDB COMMENT='仓库主数据表:以租户为边界维护仓库编码、名称、国家、时区和启停状态'; + +CREATE TABLE IF NOT EXISTS wh_warehouse_address ( + id BIGINT UNSIGNED NOT NULL COMMENT '仓库地址ID', + tenant_id BIGINT UNSIGNED NOT NULL COMMENT '数据所属租户ID', + warehouse_id BIGINT UNSIGNED NOT NULL COMMENT '所属仓库ID', + address_type VARCHAR(20) NOT NULL DEFAULT 'OPERATING' COMMENT '地址类型,例如OPERATING或RETURN', + country_code CHAR(2) NOT NULL COMMENT '国家二字码', + state_province VARCHAR(100) DEFAULT NULL COMMENT '省或州', + city VARCHAR(100) NOT NULL COMMENT '城市', + district VARCHAR(100) DEFAULT NULL COMMENT '区县', + postal_code VARCHAR(20) DEFAULT NULL COMMENT '邮政编码', + address_line1 VARCHAR(255) NOT NULL COMMENT '主地址行', + address_line2 VARCHAR(255) DEFAULT NULL COMMENT '补充地址行', + latitude DECIMAL(10,7) DEFAULT NULL COMMENT '纬度', + longitude DECIMAL(10,7) DEFAULT NULL COMMENT '经度', + created_by BIGINT UNSIGNED DEFAULT NULL COMMENT '创建人ID', + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间', + updated_by BIGINT UNSIGNED DEFAULT 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_address_type (tenant_id, warehouse_id, address_type, deleted), + KEY idx_address_warehouse (tenant_id, warehouse_id, deleted) +) ENGINE=InnoDB COMMENT='仓库地址表:保存运营、退货等不同用途的地址及可选经纬度'; + +CREATE TABLE IF NOT EXISTS wh_service_capability ( + id BIGINT UNSIGNED NOT NULL COMMENT '服务能力ID', + tenant_id BIGINT UNSIGNED NOT NULL COMMENT '数据所属租户ID', + warehouse_id BIGINT UNSIGNED NOT NULL COMMENT '所属仓库ID', + capability_code VARCHAR(50) NOT NULL COMMENT '能力编码:RECEIVING、STORAGE、PICK_PACK、RETURN等', + capability_name VARCHAR(100) NOT NULL COMMENT '能力名称', + enabled TINYINT(1) NOT NULL DEFAULT 1 COMMENT '是否启用', + cutoff_time TIME DEFAULT NULL COMMENT '当日作业截单时间', + description VARCHAR(500) DEFAULT NULL COMMENT '能力说明', + created_by BIGINT UNSIGNED DEFAULT NULL COMMENT '创建人ID', + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间', + updated_by BIGINT UNSIGNED DEFAULT 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_capability (tenant_id, warehouse_id, capability_code, deleted), + KEY idx_capability_query (tenant_id, warehouse_id, enabled, deleted) +) ENGINE=InnoDB COMMENT='仓库服务能力表:声明仓库可提供的收货、仓储、拣包、退货等能力及截单时间'; + +-- 不使用跨服务数据库外键;仓库聚合内的归属由服务代码通过 tenant_id 与 warehouse_id 共同校验。