diff --git a/AGENTS.md b/AGENTS.md index 57fb7d324..df742e218 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,5 +1,7 @@ # 头程与海外仓后端项目指引 +> 当前只有单一测试环境,所有服务同机部署。统一 Gateway/会话/Nacos 与去事件桥目标见 `../docs/测试环境单机部署与配置契约.md`、`../docs/统一认证与服务通信契约.md`;整体迁移未完成,下面现有服务入口用于定位,不能视为继续维持双区域部署的要求。 + ## 适用范围与定位 - 本文件适用于 `baiyue-firstleg/` Maven 多模块仓库,包含国内头程与海外区域服务;进入具体服务后继续读取该服务的 `AGENTS.md`。 @@ -95,10 +97,9 @@ ## 国内头程与海外仓协同 -- 国内头程 Auth 是唯一用户账号和 Token 签发方;Admin/Client 共用一套前端和用户体系,海外仓不得另建登录或权限模型。 -- 统一使用 RS256/JWKS。国内登录/刷新时由 Auth 同时签发国内 Token 和短时海外 Token;前端统一认证层按国内/海外 API Base 自动选择。国内服务叠加 Redis 会话校验;海外 Token 只携带稳定身份、客户/仓库范围和版本声明,不携带角色或权限集。海外 Gateway 本地验签后必须通过 Lastleg 安全投影取得当前角色/权限并失败关闭,再清除外部伪造头、写入受内部服务令牌保护的可信身份上下文;海外服务不得访问国内 Redis,海外业务请求不得经国内 Gateway 中转。 -- 头程装柜完成向海外建尾程单、海外 PDA 卸柜完成向国内回写货柜报空事实,以及后续费用来源和财务回传,统一走 MQ Outbox/Inbox 事件链;事件不携带用户 JWT,服务身份由 MQ TLS/ACL 或内部服务令牌负责。跨区事件契约、幂等和死信分别查 `common-event-contract/`、`event-bridge-service/` 与目标消费者。 -- 海外浏览器请求只进入 `gateway-overseas/`,由其路由到 `lastleg-service/`、`warehouse-overseas-service/` 及海外部署的公共服务;不得经国内 `gateway/` 中转。`lastleg-service` 不读取头程数据库,`warehouse-overseas-service` 不读取尾程订单库。 +- Auth 是唯一用户账号与 Token 签发方;目标为各端共用 Gateway 和 Redis 会话,保留 RS256/JWKS、客户隔离、权限撤销、PDA 仓绑定及服务身份校验。原双 Token 与 Lastleg 安全投影在替代撤销链验证后移除。 +- 装柜、船节点、卸柜、暂扣、派送、费用与资金按统一通信契约的逐事件表直接投递。保留本地 Outbox、消费幂等、重试与失败处置,不携带用户 JWT;现有 Bridge 尚未退役,不得先停服务后补替代链。 +- `lastleg-service` 不读取头程数据库,`warehouse-overseas-service` 不读取尾程订单库。共用测试 MySQL 实例不改变服务数据归属。准确路由、ACK、撤销矩阵和切换门槛只维护在新的两份主契约中。 ## 契约、数据库与验证入口 diff --git a/README.md b/README.md index aaebd9eea..cac61cb47 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,8 @@ # 柏悦头程平台 -本仓库承载国内头程与海外仓后端,包含统一认证、国内订单与履约、国内外仓储资源、海外尾程与入仓业务,以及跨区域事件桥和公共能力。国内、海外仍保持独立数据库、Nacos 命名空间与部署边界,通过版本化跨区域事件协作。 +本仓库承载头程与海外仓后端。当前只有一个测试环境,目标为所有服务同机部署,统一 Gateway、Auth 会话及 Nacos 命名空间,业务服务继续按职责拥有独立 Schema。部署与切换见[测试环境单机部署与配置契约](../docs/测试环境单机部署与配置契约.md),路由、认证和事件投递见[统一认证与服务通信契约](../docs/统一认证与服务通信契约.md)。 + +整体迁移尚未完成:下面模块清单与旧启动配置描述的是现有源码,海外 Gateway、事件桥和双 Token 仍待替代;不代表需要继续规划双区域或其他环境。 > 当前主体模型:业务单租户,产品只包含“平台—客户”两级。现有 `tenant_id > 0`、`tenantId` 和 `Tenant*` 是历史兼容命名,当前承载客户归属与隔离;`tenant_id = 0` 是平台账号、平台 RBAC 和平台公共主数据的技术作用域,不是可管理租户。平台公共仓库、往来单位、快递公司、托盘、库位等归属 `0`,客户订单、文件和费用归属客户 ID。未经过独立迁移设计,不得新增真实租户层或把现有客户隔离字段改义为上级租户。 @@ -29,7 +31,7 @@ GitLab 在独立 Node 镜像中自动执行拓扑合同,随后才允许服务 2026-07-30 订单与客户前端联调缺口已完成真实回归:19 个 Reactor 项目构建成功,75 项单元及组件测试、68 项认证与 IAM 真实 API、37 项订单与仓库真实 API 全部通过。详细证据见 `docs/phase1-business-maturity-regression-report.md`。 -国内本地联调启动顺序为:IAM、Auth、Firstleg、Order、Warehouse、File、Print,最后启动 Gateway。往来单位不是独立服务,由 `warehouse-service` 承载并保存到 `baiyue_warehouse.wh_partner`。海外服务通过独立 Compose 与 Nacos 配置启动,不与国内服务共享数据库或配置命名空间。 +现有本地启动入口包含 IAM、Auth、Firstleg、Order、Warehouse、File、Print 和 Gateway。往来单位不是独立服务,由 `warehouse-service` 承载并保存到 `baiyue_warehouse.wh_partner`。旧海外 Compose/Nacos 仍存在;新的测试启动依赖和统一配置按部署主契约收敛,本地编译不代表另有开发环境。 国内 Nacos 配置位于 `config/nacos/cn`。数据库、Redis、Nacos 和 RocketMQ 的生产密码必须通过环境变量或配置中心密文注入,禁止提交明文密码。 @@ -61,4 +63,4 @@ CORS_ALLOWED_ORIGINS=https://merchant.example.com,https://admin.example.com ## 仓库边界 -仓库边界、服务调用和拆分规则见 `docs/头程项目目录结构与服务说明.md` 的“项目定位”“主要调用关系”和“开发和拆分约束”。国内与海外服务不允许跨区域共享数据库、Redis 或 Nacos,也不允许通过公网直接调用对方内部服务。 +仓库结构入口见 `docs/头程项目目录结构与服务说明.md`。服务保持数据归属、禁止跨库直接查询其他服务业务表;同机可共用 MySQL 实例、Redis 和 Nacos。内部调用不通过公网,部署及通信规则以新的两份主契约为准。 diff --git a/auth/AGENTS.md b/auth/AGENTS.md index 0cb3466c8..195b1036b 100644 --- a/auth/AGENTS.md +++ b/auth/AGENTS.md @@ -5,7 +5,7 @@ - Mapper 只由 `BaiyueAuthApplication.java` 对 `com.baiyue.auth` 根包按 `@Mapper` 过滤扫描;新增或移动 Mapper 后从后端根运行 `node scripts/mapper-scan-contract.mjs`,不得增加子域扫描配置。 - 负责登录、注册、账号凭据、Access/Refresh Token、退出、密码和账号归属范围。 - 客户主档、成员、角色权限归 `iam/`;Auth 通过 IAM 客户端获取授权快照,不跨库写 IAM。 -- 修改国内/海外 Token、Audience、JWKS 或跨区域身份边界前,先读 `../../docs/跨区域认证与服务通信契约.md`。 +- 修改国内/海外 Token、Audience、JWKS 或跨区域身份边界前,先读 `../../docs/统一认证与服务通信契约.md`。 - `tenant_id=0` 为平台作用域,`tenant_id>0` 为客户 ID;不得引入租户管理或切换。 ## 模块入口 @@ -16,8 +16,8 @@ | 内部账号管理与 scope | `controller/InternalAccountController.java`、`service/AccountManagementService.java` | | 登录标识与企业联系人解耦 | `service/AccountManagementService.java`、`dto/AccountManagementDtos.java`、`service/MerchantRegistrationService.java`;注册手机号只在创建时同时初始化首位管理员登录手机号和公司联系电话,创建后二者独立维护;账号手机号变更需撤销该账号全部会话 | | 统一 JWT 签发与 JWKS | `config/AuthSecurityConfig.java`、`config/AuthSecurityProperties.java`、`service/AuthService.java`;Auth 是国内与海外仓共用的唯一用户 Token 签发方,JWKS 公钥发布按认证接口契约实现 | -| 国内小 JWT 与会话 | `service/AuthService.java`、`service/TokenSessionStore.java`;浏览器登录 Token 只承载稳定身份、应用域和版本声明,国内服务结合 Redis 会话校验 | -| 海外访问 Token 签发 | `service/OverseasTokenService.java`;登录/刷新编排使用同一用户身份同时签发短时 RS256 海外 Token,限定海外 Audience,只携带稳定身份、客户范围和版本声明,不携带角色或权限集;前端统一认证层负责按区域 API 自动选择 | +| 统一小 JWT 与会话 | `service/AuthService.java`、`service/TokenSessionStore.java`;浏览器登录 Token 只承载稳定身份、应用域和版本声明,各业务服务结合 Redis 会话校验 | +| 统一 RS256 签发 | `config/UnifiedJwtConfiguration.java`、`config/UnifiedJwtProperties.java`;登录、刷新、代登录使用唯一 Access Token,`JWT_RSA_JWK_SET` 和 `JWT_CURRENT_KID` 仅配置在 Auth;不再提供海外 Token 兑换 | | 海外 PDA 实时会话校验 | `controller/InternalAccountController.java`、`service/OverseasSessionValidationService.java`;只接受海外 Gateway 已验签后提取的 userId/tokenVersion/overseasWarehouseId,以内部服务令牌保护并实时联查 IAM 当前仓绑定 | | Token 会话与权限快照 | `service/TokenSessionStore.java`、`client/AuthorizationLoader.java`、`client/IamAuthorizationClient.java`;消费端映射见 `../common-security/src/main/java/com/baiyue/common/security/RedisAccessTokenSessionValidator.java`、`JwtClaimsMapper.java` | | 注册协作 | `service/MerchantRegistrationService.java` | @@ -28,7 +28,7 @@ ## 修改约束 -- 密码、Token、敏感 Claim 不得写日志;统一 JWT 使用 RS256,私钥只留在 Auth,JWKS 只发布公钥。国内登录 Token 的实时权限来自 Redis 会话快照;短时海外 Token 不携带角色或权限集,海外授权由 IAM 事件投影到 Lastleg,再由海外 Gateway 验证并以可信内网上下文传递,海外服务不访问国内 Redis。修改登录响应、Claim、Audience、JWKS、权限快照或过期策略时联查 Admin/Client 统一认证层、`common-security/`、`gateway/`、`iam/` 和海外服务消费者。 +- 密码、Token、敏感 Claim 不得写日志;统一 JWT 使用 RS256,私钥只留在 Auth,JWKS 只发布公钥。统一 Token 不携带角色或权限集,权限来自共享 Redis 会话;用户、客户、权限和仓绑定变更必须撤销受影响会话。独立海外网关与安全投影仍处于迁移阶段,不能将本地签发改造视为整体已部署。修改登录响应、Claim、Audience、JWKS、权限快照或过期策略时联查 Admin/Client 统一认证层、`common-security/`、`gateway/`、`iam/` 和海外服务消费者。 - 注册跨 Auth/IAM 必须保持幂等和失败补偿;内部接口仅接受服务令牌。 - 登录账号的 username、phone、email 唯一性只约束 Auth 账号;IAM 公司联系电话/联系邮箱是企业资料,不得反向覆盖账号登录标识,也不得因其他企业使用相同联系电话而拒绝。成员登录手机号变更只能由 IAM 账号协作入口同步并撤销该账号全部会话。 - 业务错误保持 HTTP 200 + body code;Security 认证失败保留真实 401/403。 diff --git a/auth/src/main/java/com/baiyue/auth/config/AuthSecurityProperties.java b/auth/src/main/java/com/baiyue/auth/config/AuthSecurityProperties.java index 5b9f00a1c..32ad3cbb0 100644 --- a/auth/src/main/java/com/baiyue/auth/config/AuthSecurityProperties.java +++ b/auth/src/main/java/com/baiyue/auth/config/AuthSecurityProperties.java @@ -8,7 +8,7 @@ import java.time.Duration; * 认证安全配置属性,承载 JWT 密钥、签发方及访问令牌、刷新令牌有效期。 */ @ConfigurationProperties(prefix = "baiyue.security") -public record AuthSecurityProperties(String jwtSecret, String issuer, String audience, +public record AuthSecurityProperties(String issuer, String audience, Duration accessTokenTtl, Duration refreshTokenTtl, int maxLoginFailures, Duration lockDuration) { } diff --git a/auth/src/main/java/com/baiyue/auth/config/DomesticJwtConfiguration.java b/auth/src/main/java/com/baiyue/auth/config/DomesticJwtConfiguration.java deleted file mode 100644 index acae75153..000000000 --- a/auth/src/main/java/com/baiyue/auth/config/DomesticJwtConfiguration.java +++ /dev/null @@ -1,22 +0,0 @@ -package com.baiyue.auth.config; - -import com.nimbusds.jose.jwk.source.ImmutableSecret; -import org.springframework.context.annotation.Bean; -import org.springframework.context.annotation.Configuration; -import org.springframework.context.annotation.Primary; -import org.springframework.security.oauth2.jwt.JwtEncoder; -import org.springframework.security.oauth2.jwt.NimbusJwtEncoder; - -import java.nio.charset.StandardCharsets; - -/** 国内现有 HS256 编码器,始终为认证服务主编码器。 */ -@Configuration(proxyBeanMethods = false) -public class DomesticJwtConfiguration { - @Bean("jwtEncoder") - @Primary - JwtEncoder jwtEncoder(AuthSecurityProperties properties) { - byte[] secret = properties.jwtSecret().getBytes(StandardCharsets.UTF_8); - if (secret.length < 32) throw new IllegalStateException("JWT_SECRET 至少需要 32 字节"); - return new NimbusJwtEncoder(new ImmutableSecret<>(secret)); - } -} diff --git a/auth/src/main/java/com/baiyue/auth/config/OverseasJwtProperties.java b/auth/src/main/java/com/baiyue/auth/config/OverseasJwtProperties.java deleted file mode 100644 index 62e173c7f..000000000 --- a/auth/src/main/java/com/baiyue/auth/config/OverseasJwtProperties.java +++ /dev/null @@ -1,10 +0,0 @@ -package com.baiyue.auth.config; - -import org.springframework.boot.context.properties.ConfigurationProperties; - -import java.time.Duration; - -/** 海外尾程 RS256 短时令牌配置,默认关闭。 */ -@ConfigurationProperties(prefix = "baiyue.security.overseas-jwt") -public record OverseasJwtProperties(boolean enabled, String issuer, String audience, - String rsaJwkSet, String currentKid, Duration accessTokenTtl) { } diff --git a/auth/src/main/java/com/baiyue/auth/config/OverseasJwtConfiguration.java b/auth/src/main/java/com/baiyue/auth/config/UnifiedJwtConfiguration.java similarity index 67% rename from auth/src/main/java/com/baiyue/auth/config/OverseasJwtConfiguration.java rename to auth/src/main/java/com/baiyue/auth/config/UnifiedJwtConfiguration.java index ed7d60faa..37c346b4d 100644 --- a/auth/src/main/java/com/baiyue/auth/config/OverseasJwtConfiguration.java +++ b/auth/src/main/java/com/baiyue/auth/config/UnifiedJwtConfiguration.java @@ -3,7 +3,6 @@ package com.baiyue.auth.config; import com.nimbusds.jose.jwk.JWKSet; import com.nimbusds.jose.jwk.RSAKey; import com.nimbusds.jose.jwk.source.ImmutableJWKSet; -import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; import org.springframework.boot.context.properties.EnableConfigurationProperties; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; @@ -11,27 +10,26 @@ import org.springframework.security.oauth2.jwt.JwtEncoder; import org.springframework.security.oauth2.jwt.NimbusJwtEncoder; @Configuration(proxyBeanMethods = false) -@EnableConfigurationProperties(OverseasJwtProperties.class) -@ConditionalOnProperty(name = "baiyue.security.overseas-jwt.enabled", havingValue = "true") -public class OverseasJwtConfiguration { - @Bean("overseasJwtEncoder") - JwtEncoder overseasJwtEncoder(OverseasJwtProperties properties) { +@EnableConfigurationProperties(UnifiedJwtProperties.class) +public class UnifiedJwtConfiguration { + @Bean("jwtEncoder") + JwtEncoder jwtEncoder(UnifiedJwtProperties properties) { try { JWKSet set = JWKSet.parse(properties.rsaJwkSet()); RSAKey current = set.getKeyByKeyId(properties.currentKid()) instanceof RSAKey rsa ? rsa : null; if (current == null || !current.isPrivate()) { - throw new IllegalStateException("海外 current-kid 必须指向包含私钥的 RSA 密钥"); + throw new IllegalStateException("统一 current-kid 必须指向包含私钥的 RSA 密钥"); } if (set.getKeys().stream().anyMatch(jwk -> !(jwk instanceof RSAKey))) { - throw new IllegalStateException("海外 JWK Set 只能包含 RSA 密钥"); + throw new IllegalStateException("统一 JWK Set 只能包含 RSA 密钥"); } if (set.getKeys().stream().anyMatch(jwk -> !properties.currentKid().equals(jwk.getKeyID()) && jwk instanceof RSAKey rsa && rsa.isPrivate())) { - throw new IllegalStateException("海外非当前密钥只能配置公钥"); + throw new IllegalStateException("统一非当前密钥只能配置公钥"); } return new NimbusJwtEncoder(new ImmutableJWKSet<>(set)); } catch (java.text.ParseException exception) { - throw new IllegalStateException("海外 RSA JWK Set 配置无效", exception); + throw new IllegalStateException("统一 RSA JWK Set 配置无效", exception); } } } diff --git a/auth/src/main/java/com/baiyue/auth/config/UnifiedJwtProperties.java b/auth/src/main/java/com/baiyue/auth/config/UnifiedJwtProperties.java new file mode 100644 index 000000000..265f9d4a2 --- /dev/null +++ b/auth/src/main/java/com/baiyue/auth/config/UnifiedJwtProperties.java @@ -0,0 +1,7 @@ +package com.baiyue.auth.config; + +import org.springframework.boot.context.properties.ConfigurationProperties; + +/** Unified RS256 signing key configuration. Private keys remain in Auth. */ +@ConfigurationProperties(prefix = "baiyue.security.jwt") +public record UnifiedJwtProperties(String rsaJwkSet, String currentKid) { } diff --git a/auth/src/main/java/com/baiyue/auth/controller/AuthController.java b/auth/src/main/java/com/baiyue/auth/controller/AuthController.java index 6130bf029..a4fd65b75 100644 --- a/auth/src/main/java/com/baiyue/auth/controller/AuthController.java +++ b/auth/src/main/java/com/baiyue/auth/controller/AuthController.java @@ -23,8 +23,6 @@ import com.baiyue.auth.dto.AccountManagementDtos.ChangePasswordRequest; import com.baiyue.auth.dto.AccountManagementDtos.ForcedPasswordChangeRequest; import com.baiyue.auth.dto.AccountManagementDtos; import com.baiyue.auth.service.AccountManagementService; -import com.baiyue.auth.service.OverseasTokenService; -import org.springframework.beans.factory.ObjectProvider; import org.springframework.security.access.prepost.PreAuthorize; /** @@ -40,19 +38,6 @@ public class AuthController { private MerchantRegistrationService registrationService; @Resource private AccountManagementService accountManagementService; - @Resource - private ObjectProvider overseasTokens; - - @PostMapping("/overseas/token") - @PreAuthorize("isAuthenticated()") - @Operation(summary = "兑换海外尾程访问令牌", description = "使用已通过国内会话校验的身份兑换 15 分钟 RS256 海外令牌。") - public ApiResponse exchangeOverseasToken( - @AuthenticationPrincipal AuthenticatedUser user) { - OverseasTokenService service = overseasTokens.getIfAvailable(); - if (service == null) throw new BizException(ResultCode.SERVICE_UNAVAILABLE, "海外认证未启用"); - return ApiResponse.success(service.exchange(user.userId())); - } - @PutMapping("/password") @PreAuthorize("isAuthenticated()") @Operation(summary = "修改当前用户密码") diff --git a/auth/src/main/java/com/baiyue/auth/controller/JwksController.java b/auth/src/main/java/com/baiyue/auth/controller/JwksController.java index f8fa9487e..136712e26 100644 --- a/auth/src/main/java/com/baiyue/auth/controller/JwksController.java +++ b/auth/src/main/java/com/baiyue/auth/controller/JwksController.java @@ -1,10 +1,9 @@ package com.baiyue.auth.controller; -import com.baiyue.auth.config.OverseasJwtProperties; +import com.baiyue.auth.config.UnifiedJwtProperties; import com.nimbusds.jose.jwk.JWKSet; import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.security.SecurityRequirements; -import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; import org.springframework.http.CacheControl; import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.GetMapping; @@ -13,17 +12,16 @@ import org.springframework.web.bind.annotation.RestController; import java.util.Map; import java.util.concurrent.TimeUnit; -/** 仅在海外 JWT 显式启用后发布 RSA 公钥。 */ +/** 发布统一 JWT 的 RSA 公钥。 */ @RestController -@ConditionalOnProperty(name = "baiyue.security.overseas-jwt.enabled", havingValue = "true") public class JwksController { - private final OverseasJwtProperties properties; + private final UnifiedJwtProperties properties; - public JwksController(OverseasJwtProperties properties) { this.properties = properties; } + public JwksController(UnifiedJwtProperties properties) { this.properties = properties; } @GetMapping("/api/auth/.well-known/jwks.json") @SecurityRequirements - @Operation(summary = "获取海外 JWT 公钥集", description = "仅发布当前和轮换期上一把 RSA 公钥,不包含私钥。") + @Operation(summary = "获取统一 JWT 公钥集", description = "仅发布当前和轮换期上一把 RSA 公钥,不包含私钥。") public ResponseEntity> jwks() throws Exception { return ResponseEntity.ok().cacheControl(CacheControl.maxAge(5, TimeUnit.MINUTES).cachePublic()) .body(JWKSet.parse(properties.rsaJwkSet()).toPublicJWKSet().toJSONObject()); diff --git a/auth/src/main/java/com/baiyue/auth/dto/TokenResponse.java b/auth/src/main/java/com/baiyue/auth/dto/TokenResponse.java index aa86d983c..9d5af3cc9 100644 --- a/auth/src/main/java/com/baiyue/auth/dto/TokenResponse.java +++ b/auth/src/main/java/com/baiyue/auth/dto/TokenResponse.java @@ -14,10 +14,6 @@ public record TokenResponse( @Schema(description = "Access token lifetime in seconds / 访问令牌有效秒数", example = "1800") long expiresIn, @Schema(description = "Refresh token / 刷新令牌", format = "password") String refreshToken, @Schema(description = "Refresh token lifetime in seconds / 刷新令牌有效秒数", example = "604800") long refreshExpiresIn, - @Schema(description = "Short-lived overseas API access token; null only when overseas JWT is disabled / 海外 API 短时访问令牌;仅海外 JWT 未启用时为空", - format = "password", nullable = true) String overseasAccessToken, - @Schema(description = "Overseas access token lifetime in seconds; zero only when overseas JWT is disabled / 海外访问令牌有效秒数;仅海外 JWT 未启用时为 0", - example = "900", minimum = "0") long overseasExpiresIn, @Schema(description = "Current role snapshot returned outside JWT / JWT 外返回的当前角色快照") Set roles, @Schema(description = "Current authority snapshot returned outside JWT / JWT 外返回的当前权限快照") Set authorities ) { diff --git a/auth/src/main/java/com/baiyue/auth/service/AuthService.java b/auth/src/main/java/com/baiyue/auth/service/AuthService.java index 15144bf9f..d3b76d480 100644 --- a/auth/src/main/java/com/baiyue/auth/service/AuthService.java +++ b/auth/src/main/java/com/baiyue/auth/service/AuthService.java @@ -16,14 +16,14 @@ import com.baiyue.common.core.exception.BizException; import jakarta.annotation.Resource; import org.springframework.security.crypto.password.PasswordEncoder; -import org.springframework.security.oauth2.jose.jws.MacAlgorithm; +import org.springframework.security.oauth2.jose.jws.SignatureAlgorithm; import org.springframework.security.oauth2.jwt.JwtClaimsSet; import org.springframework.security.oauth2.jwt.JwtEncoder; import org.springframework.security.oauth2.jwt.JwtEncoderParameters; import org.springframework.security.oauth2.jwt.JwsHeader; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; -import org.springframework.beans.factory.ObjectProvider; +import com.baiyue.auth.config.UnifiedJwtProperties; import java.security.SecureRandom; import java.time.Instant; @@ -51,7 +51,7 @@ public class AuthService { @Resource private TokenSessionStore tokenSessionStore; @Resource - private ObjectProvider overseasTokens; + private UnifiedJwtProperties signingProperties; private final SecureRandom secureRandom = new SecureRandom(); @@ -147,13 +147,8 @@ public class AuthService { String rawRefresh = Base64.getUrlEncoder().withoutPadding().encodeToString(random); tokenSessionStore.store(jwtId, account.getId(), authorization.getRoles(), authorization.getPermissions(), rawRefresh, properties.accessTokenTtl(), properties.refreshTokenTtl()); - OverseasTokenService service = overseasTokens.getIfAvailable(); - OverseasTokenService.OverseasTokenResponse overseas = service == null - ? null : service.issue(account, authorization); return new TokenResponse("Bearer", accessToken, properties.accessTokenTtl().toSeconds(), rawRefresh, properties.refreshTokenTtl().toSeconds(), - overseas == null ? null : overseas.accessToken(), - overseas == null ? 0L : overseas.expiresIn(), authorization.getRoles(), authorization.getPermissions()); } @@ -208,17 +203,11 @@ public class AuthService { String accessToken = jwtEncoder.encode(JwtEncoderParameters.from(signingHeader(), claims)).getTokenValue(); tokenSessionStore.storeAccessOnly(jwtId, account.getId(), authorization.getRoles(), authorization.getPermissions(), Duration.ofMinutes(15)); - OverseasTokenService service = overseasTokens.getIfAvailable(); - OverseasTokenService.OverseasTokenResponse overseas = service == null - ? null : service.issue(account, authorization); return new ImpersonationTokenResponse("Bearer", accessToken, 900, - overseas == null ? null : overseas.accessToken(), - overseas == null ? 0L : overseas.expiresIn(), authorization.getRoles(), authorization.getPermissions()); } public record ImpersonationTokenResponse(String tokenType, String accessToken, long expiresIn, - String overseasAccessToken, long overseasExpiresIn, java.util.Set roles, java.util.Set authorities) { public ImpersonationTokenResponse { @@ -228,7 +217,7 @@ public class AuthService { } private JwsHeader signingHeader() { - return JwsHeader.with(MacAlgorithm.HS256).build(); + return JwsHeader.with(SignatureAlgorithm.RS256).keyId(signingProperties.currentKid()).build(); } private static BizException invalidCredentials() { diff --git a/auth/src/main/java/com/baiyue/auth/service/OverseasTokenService.java b/auth/src/main/java/com/baiyue/auth/service/OverseasTokenService.java deleted file mode 100644 index 5c90d612e..000000000 --- a/auth/src/main/java/com/baiyue/auth/service/OverseasTokenService.java +++ /dev/null @@ -1,71 +0,0 @@ -package com.baiyue.auth.service; - -import com.baiyue.auth.client.AuthorizationLoader; -import com.baiyue.auth.client.AuthorizationSnapshot; -import com.baiyue.auth.config.OverseasJwtProperties; -import com.baiyue.auth.entity.AuthAccount; -import com.baiyue.auth.repository.AuthAccountRepository; -import com.baiyue.common.core.api.ResultCode; -import com.baiyue.common.core.exception.BizException; -import jakarta.annotation.Resource; -import org.springframework.beans.factory.annotation.Qualifier; -import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; -import org.springframework.security.oauth2.jose.jws.SignatureAlgorithm; -import org.springframework.security.oauth2.jwt.JwtClaimsSet; -import org.springframework.security.oauth2.jwt.JwtEncoder; -import org.springframework.security.oauth2.jwt.JwtEncoderParameters; -import org.springframework.security.oauth2.jwt.JwsHeader; -import org.springframework.stereotype.Service; - -import java.time.Instant; -import java.util.List; -import java.util.UUID; - -/** 将已经通过国内 HMAC 与 Redis 校验的会话兑换为海外 RS256 短时令牌。 */ -@Service -@ConditionalOnProperty(name = "baiyue.security.overseas-jwt.enabled", havingValue = "true") -public class OverseasTokenService { - @Resource private AuthAccountRepository accounts; - @Resource private AuthorizationLoader authorizationLoader; - @Resource private OverseasJwtProperties properties; - @Resource @Qualifier("overseasJwtEncoder") private JwtEncoder encoder; - - public OverseasTokenResponse exchange(long authenticatedUserId) { - AuthAccount account = accounts.findById(authenticatedUserId); - if (account == null || !"ACTIVE".equals(account.getAccountStatus())) { - throw new BizException(ResultCode.UNAUTHORIZED, "账号不可用"); - } - AuthorizationSnapshot authorization = authorizationLoader.load(account); - return issue(account, authorization); - } - - public OverseasTokenResponse issue(AuthAccount account, AuthorizationSnapshot authorization) { - Instant issuedAt = Instant.now(); - Instant expiresAt = issuedAt.plus(properties.accessTokenTtl()); - JwtClaimsSet.Builder claimsBuilder = JwtClaimsSet.builder() - .issuer(properties.issuer()).audience(List.of(properties.audience())) - .subject(account.getId().toString()).id(UUID.randomUUID().toString()) - .issuedAt(issuedAt).expiresAt(expiresAt) - .claim("user_id", account.getId()).claim("tenant_id", authorization.getTenantId()) - .claim("membership_id", authorization.getMembershipId()) - .claim("app_code", authorization.getAppCode()).claim("client_type", authorization.getClientType()) - .claim("token_version", account.getTokenVersion() == null ? 0 : account.getTokenVersion()) - .claim("permission_version", authorization.getPermissionVersion() == null ? 0L : authorization.getPermissionVersion()); - if (authorization.getOverseasWarehouseId() != null) { - claimsBuilder.claim("overseas_warehouse_id", authorization.getOverseasWarehouseId()); - } - JwtClaimsSet claims = claimsBuilder.build(); - String token = encoder.encode(JwtEncoderParameters.from( - JwsHeader.with(SignatureAlgorithm.RS256).keyId(properties.currentKid()).build(), claims)).getTokenValue(); - return new OverseasTokenResponse("Bearer", token, properties.accessTokenTtl().toSeconds(), - authorization.getRoles(), authorization.getPermissions()); - } - - public record OverseasTokenResponse(String tokenType, String accessToken, long expiresIn, - java.util.Set roles, java.util.Set authorities) { - public OverseasTokenResponse { - roles = roles == null ? java.util.Set.of() : java.util.Set.copyOf(roles); - authorities = authorities == null ? java.util.Set.of() : java.util.Set.copyOf(authorities); - } - } -} diff --git a/auth/src/main/resources/application.yml b/auth/src/main/resources/application.yml index fd346398e..ad9451d45 100644 --- a/auth/src/main/resources/application.yml +++ b/auth/src/main/resources/application.yml @@ -30,7 +30,6 @@ spring: namespace: ${NACOS_NAMESPACE:public} encode: UTF-8 baiyue.security: - jwt-secret: ${JWT_SECRET:change-me-to-at-least-32-bytes-long-before-start} internal-service-token: ${INTERNAL_SERVICE_TOKEN:change-me-to-an-internal-token-at-least-32-bytes-long} token-key-prefix: ${REDIS_TOKEN_KEY_PREFIX:baiyue:auth} issuer: ${JWT_ISSUER:auth-service} @@ -39,13 +38,12 @@ baiyue.security: refresh-token-ttl: ${REFRESH_TOKEN_TTL:7d} max-login-failures: ${MAX_LOGIN_FAILURES:5} lock-duration: ${LOGIN_LOCK_DURATION:15m} - overseas-jwt: - enabled: ${OVERSEAS_JWT_ENABLED:false} - issuer: ${OVERSEAS_JWT_ISSUER:auth-service} - audience: ${OVERSEAS_JWT_AUDIENCE:lastleg-api} - rsa-jwk-set: ${OVERSEAS_JWT_RSA_JWK_SET:} - current-kid: ${OVERSEAS_JWT_CURRENT_KID:} - access-token-ttl: ${OVERSEAS_ACCESS_TOKEN_TTL:15m} + jwt-mode: jwks + session-mode: redis + jwk-set-uri: ${JWT_JWKS_URI:http://auth-service:9100/api/auth/.well-known/jwks.json} + jwt: + rsa-jwk-set: ${JWT_RSA_JWK_SET:} + current-kid: ${JWT_CURRENT_KID:} baiyue.clients: iam: diff --git a/auth/src/test/java/com/baiyue/auth/config/DualTrackJwtConfigurationTest.java b/auth/src/test/java/com/baiyue/auth/config/DualTrackJwtConfigurationTest.java deleted file mode 100644 index 65c871721..000000000 --- a/auth/src/test/java/com/baiyue/auth/config/DualTrackJwtConfigurationTest.java +++ /dev/null @@ -1,29 +0,0 @@ -package com.baiyue.auth.config; - -import org.junit.jupiter.api.Test; -import org.springframework.boot.autoconfigure.AutoConfigurations; -import org.springframework.boot.test.context.runner.ApplicationContextRunner; -import org.springframework.security.oauth2.jwt.JwtEncoder; - -import static org.assertj.core.api.Assertions.assertThat; - -class DualTrackJwtConfigurationTest { - private final ApplicationContextRunner runner = new ApplicationContextRunner() - .withConfiguration(AutoConfigurations.of(DomesticJwtConfiguration.class, OverseasJwtConfiguration.class)) - .withBean(AuthSecurityProperties.class, () -> new AuthSecurityProperties( - "domestic-secret-at-least-32-bytes-long", "auth-service", "lastleg-api", - java.time.Duration.ofMinutes(24), java.time.Duration.ofDays(7), 5, - java.time.Duration.ofMinutes(15))) - .withPropertyValues( - "baiyue.security.overseas-jwt.enabled=false"); - - @Test - void startsDomesticEncoderWithoutRsaConfiguration() { - runner.run(context -> { - assertThat(context).hasNotFailed(); - assertThat(context).hasBean("jwtEncoder"); - assertThat(context).doesNotHaveBean("overseasJwtEncoder"); - assertThat(context).hasSingleBean(JwtEncoder.class); - }); - } -} diff --git a/auth/src/test/java/com/baiyue/auth/config/UnifiedJwtConfigurationTest.java b/auth/src/test/java/com/baiyue/auth/config/UnifiedJwtConfigurationTest.java new file mode 100644 index 000000000..8c4f1c416 --- /dev/null +++ b/auth/src/test/java/com/baiyue/auth/config/UnifiedJwtConfigurationTest.java @@ -0,0 +1,43 @@ +package com.baiyue.auth.config; + +import com.nimbusds.jose.JWSAlgorithm; +import com.nimbusds.jose.jwk.JWKSet; +import com.nimbusds.jose.jwk.gen.RSAKeyGenerator; +import org.junit.jupiter.api.Test; +import org.springframework.security.oauth2.jose.jws.SignatureAlgorithm; +import org.springframework.security.oauth2.jwt.*; +import java.time.Instant; +import java.util.List; +import static org.assertj.core.api.Assertions.*; + +class UnifiedJwtConfigurationTest { + @Test + void signsWithCurrentKeyAndRetainsPreviousPublicKeyForRotation() throws Exception { + var current = new RSAKeyGenerator(2048).keyID("current").algorithm(JWSAlgorithm.RS256).generate(); + var previous = new RSAKeyGenerator(2048).keyID("previous").algorithm(JWSAlgorithm.RS256).generate(); + var properties = new UnifiedJwtProperties(new JWKSet(List.of(current, previous.toPublicJWK())).toString(false), "current"); + var encoder = new UnifiedJwtConfiguration().jwtEncoder(properties); + var now = Instant.now(); + var token = encoder.encode(JwtEncoderParameters.from( + JwsHeader.with(SignatureAlgorithm.RS256).keyId("current").build(), + JwtClaimsSet.builder().subject("42").issuedAt(now).expiresAt(now.plusSeconds(60)).build())); + var decoded = NimbusJwtDecoder.withPublicKey(current.toRSAPublicKey()).build().decode(token.getTokenValue()); + assertThat(decoded.getSubject()).isEqualTo("42"); + assertThat(decoded.getHeaders()).containsEntry("kid", "current").containsEntry("alg", "RS256"); + assertThatThrownBy(() -> NimbusJwtDecoder.withPublicKey(previous.toRSAPublicKey()).build().decode(token.getTokenValue())) + .isInstanceOf(JwtException.class); + } + + @Test + void rejectsMissingPrivateKeyAndPreviousPrivateKey() throws Exception { + var current = new RSAKeyGenerator(2048).keyID("current").generate(); + var previous = new RSAKeyGenerator(2048).keyID("previous").generate(); + var config = new UnifiedJwtConfiguration(); + assertThatThrownBy(() -> config.jwtEncoder(new UnifiedJwtProperties(new JWKSet(current.toPublicJWK()).toString(), "current"))) + .isInstanceOf(IllegalStateException.class); + assertThatThrownBy(() -> config.jwtEncoder(new UnifiedJwtProperties(new JWKSet(List.of(current, previous)).toString(false), "current"))) + .isInstanceOf(IllegalStateException.class); + assertThatThrownBy(() -> config.jwtEncoder(new UnifiedJwtProperties("", "current"))) + .isInstanceOf(IllegalStateException.class); + } +} diff --git a/auth/src/test/java/com/baiyue/auth/controller/JwksControllerTest.java b/auth/src/test/java/com/baiyue/auth/controller/JwksControllerTest.java index 53a673c72..9feacbcc7 100644 --- a/auth/src/test/java/com/baiyue/auth/controller/JwksControllerTest.java +++ b/auth/src/test/java/com/baiyue/auth/controller/JwksControllerTest.java @@ -1,6 +1,6 @@ package com.baiyue.auth.controller; -import com.baiyue.auth.config.OverseasJwtProperties; +import com.baiyue.auth.config.UnifiedJwtProperties; import com.nimbusds.jose.JWSAlgorithm; import com.nimbusds.jose.jwk.JWKSet; import com.nimbusds.jose.jwk.KeyUse; @@ -20,8 +20,7 @@ class JwksControllerTest { RSAKey current = key("current", true); RSAKey previous = key("previous", false); String json = new JWKSet(List.of(current, previous)).toString(false); - var properties = new OverseasJwtProperties(true, "auth-service", "lastleg-api", json, - "current", Duration.ofMinutes(15)); + var properties = new UnifiedJwtProperties(json, "current"); var body = new JwksController(properties).jwks().getBody(); String published = new com.fasterxml.jackson.databind.ObjectMapper().writeValueAsString(body); diff --git a/auth/src/test/java/com/baiyue/auth/service/AuthServiceSmallJwtTest.java b/auth/src/test/java/com/baiyue/auth/service/AuthServiceSmallJwtTest.java index 1ea5b0459..f908f1ab3 100644 --- a/auth/src/test/java/com/baiyue/auth/service/AuthServiceSmallJwtTest.java +++ b/auth/src/test/java/com/baiyue/auth/service/AuthServiceSmallJwtTest.java @@ -12,7 +12,7 @@ import org.springframework.boot.test.mock.mockito.MockBean; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; import org.springframework.security.crypto.password.PasswordEncoder; -import org.springframework.security.oauth2.jose.jws.MacAlgorithm; +import org.springframework.security.oauth2.jose.jws.SignatureAlgorithm; import org.springframework.security.oauth2.jwt.Jwt; import org.springframework.security.oauth2.jwt.JwtEncoder; import org.springframework.security.oauth2.jwt.JwtEncoderParameters; @@ -37,21 +37,24 @@ class AuthServiceSmallJwtTest { @MockBean private JwtEncoder jwtEncoder; @MockBean private AuthorizationLoader authorizationLoader; @MockBean private TokenSessionStore tokenSessionStore; - @MockBean private OverseasTokenService overseasTokenService; @Resource private AuthService service; @Configuration(proxyBeanMethods = false) static class TestConfig { + @Bean + com.baiyue.auth.config.UnifiedJwtProperties signingProperties() { + return new com.baiyue.auth.config.UnifiedJwtProperties("unused", "current"); + } @Bean AuthSecurityProperties properties() { - return new AuthSecurityProperties("unused", "auth-service", "lastleg-api", + return new AuthSecurityProperties("auth-service", "lastleg-api", Duration.ofMinutes(30), Duration.ofDays(7), 5, Duration.ofMinutes(10)); } } @Test - void signsCrossRegionClaimsAndStoresSession() { + void signsUnifiedClaimsAndStoresSession() { AuthAccount account = new AuthAccount(); account.setId(42L); account.setUsername("operator"); @@ -71,9 +74,6 @@ class AuthServiceSmallJwtTest { when(authorizationLoader.load(account)).thenReturn(snapshot); when(jwtEncoder.encode(any())).thenReturn(encoded); when(encoded.getTokenValue()).thenReturn("small-token"); - when(overseasTokenService.issue(account, snapshot)).thenReturn( - new OverseasTokenService.OverseasTokenResponse("Bearer", "overseas-token", 900, - snapshot.getRoles(), snapshot.getPermissions())); var response = service.login(new LoginRequest("operator", "secret")); @@ -89,16 +89,13 @@ class AuthServiceSmallJwtTest { .containsEntry("overseas_warehouse_id", 900L) .doesNotContainKeys("roles", "authorities", "permissions", "scope"); assertThat(jwt.getValue().getClaims().getAudience()).containsExactly("lastleg-api"); - assertThat(jwt.getValue().getJwsHeader().getAlgorithm()).isEqualTo(MacAlgorithm.HS256); - assertThat(jwt.getValue().getJwsHeader().getKeyId()).isNull(); + assertThat(jwt.getValue().getJwsHeader().getAlgorithm()).isEqualTo(SignatureAlgorithm.RS256); + assertThat(jwt.getValue().getJwsHeader().getKeyId()).isEqualTo("current"); verify(tokenSessionStore).store(any(), org.mockito.ArgumentMatchers.eq(42L), org.mockito.ArgumentMatchers.eq(Set.of("OVERSEAS_WAREHOUSE_PDA_OPERATOR")), org.mockito.ArgumentMatchers.eq(Set.of("platform:order:list")), any(), org.mockito.ArgumentMatchers.eq(Duration.ofMinutes(30)), org.mockito.ArgumentMatchers.eq(Duration.ofDays(7))); - verify(overseasTokenService).issue(account, snapshot); - assertThat(response.overseasAccessToken()).isEqualTo("overseas-token"); - assertThat(response.overseasExpiresIn()).isEqualTo(900); assertThat(response.roles()).containsExactly("OVERSEAS_WAREHOUSE_PDA_OPERATOR"); assertThat(response.authorities()).containsExactly("platform:order:list"); } @@ -144,7 +141,7 @@ class AuthServiceSmallJwtTest { } @Test - void refreshRotatesDomesticAndOverseasTokensFromOneAuthorizationSnapshot() { + void refreshRotatesUnifiedTokenFromCurrentAuthorizationSnapshot() { AuthAccount account = new AuthAccount(); account.setId(52L); account.setUsername("merchant"); @@ -164,9 +161,6 @@ class AuthServiceSmallJwtTest { when(authorizationLoader.load(account)).thenReturn(snapshot); when(jwtEncoder.encode(any())).thenReturn(encoded); when(encoded.getTokenValue()).thenReturn("domestic-refreshed"); - when(overseasTokenService.issue(account, snapshot)).thenReturn( - new OverseasTokenService.OverseasTokenResponse("Bearer", "overseas-refreshed", 900, - snapshot.getRoles(), snapshot.getPermissions())); var response = service.refresh("refresh-old"); @@ -175,8 +169,6 @@ class AuthServiceSmallJwtTest { assertThat(response.expiresIn()).isEqualTo(1800); assertThat(response.refreshToken()).isNotBlank().isNotEqualTo("refresh-old"); assertThat(response.refreshExpiresIn()).isEqualTo(604800); - assertThat(response.overseasAccessToken()).isEqualTo("overseas-refreshed"); - assertThat(response.overseasExpiresIn()).isEqualTo(900); assertThat(response.roles()).containsExactly("MERCHANT_ADMIN"); assertThat(response.authorities()).containsExactly("merchant:order:list"); verify(tokenSessionStore).consume("refresh-old"); @@ -187,7 +179,6 @@ class AuthServiceSmallJwtTest { org.mockito.ArgumentMatchers.eq(Duration.ofMinutes(30)), org.mockito.ArgumentMatchers.eq(Duration.ofDays(7))); verify(authorizationLoader).load(account); - verify(overseasTokenService).issue(account, snapshot); } @Test @@ -200,7 +191,6 @@ class AuthServiceSmallJwtTest { verify(repository, never()).findById(any()); verify(jwtEncoder, never()).encode(any()); - verify(overseasTokenService, never()).issue(any(), any()); } @Test @@ -225,14 +215,9 @@ class AuthServiceSmallJwtTest { when(authorizationLoader.load(account)).thenReturn(snapshot); when(jwtEncoder.encode(any())).thenReturn(encoded); when(encoded.getTokenValue()).thenReturn("small-impersonation-token"); - when(overseasTokenService.issue(account, snapshot)).thenReturn( - new OverseasTokenService.OverseasTokenResponse("Bearer", "overseas-impersonation-token", 900, - Set.of("MERCHANT_OWNER"), Set.of("merchant:order:list"))); AuthService.ImpersonationTokenResponse response = service.exchangeImpersonationTicket("one-time-code"); - assertThat(response.overseasAccessToken()).isEqualTo("overseas-impersonation-token"); - assertThat(response.overseasExpiresIn()).isEqualTo(900); assertThat(response.roles()).containsExactly("MERCHANT_OWNER"); assertThat(response.authorities()).containsExactly("merchant:order:list"); @@ -247,6 +232,5 @@ class AuthServiceSmallJwtTest { org.mockito.ArgumentMatchers.eq(Set.of("MERCHANT_OWNER")), org.mockito.ArgumentMatchers.eq(Set.of("merchant:order:list")), org.mockito.ArgumentMatchers.eq(Duration.ofMinutes(15))); - verify(overseasTokenService).issue(account, snapshot); } } diff --git a/auth/src/test/java/com/baiyue/auth/service/OverseasTokenServiceTest.java b/auth/src/test/java/com/baiyue/auth/service/OverseasTokenServiceTest.java deleted file mode 100644 index cda05aa12..000000000 --- a/auth/src/test/java/com/baiyue/auth/service/OverseasTokenServiceTest.java +++ /dev/null @@ -1,139 +0,0 @@ -package com.baiyue.auth.service; - -import com.baiyue.auth.client.AuthorizationLoader; -import com.baiyue.auth.client.AuthorizationSnapshot; -import com.baiyue.auth.config.OverseasJwtProperties; -import com.baiyue.auth.entity.AuthAccount; -import com.baiyue.auth.repository.AuthAccountRepository; -import jakarta.annotation.Resource; -import org.junit.jupiter.api.Test; -import org.mockito.ArgumentCaptor; -import org.springframework.boot.test.mock.mockito.MockBean; -import org.springframework.context.annotation.Bean; -import org.springframework.context.annotation.Configuration; -import org.springframework.security.oauth2.jose.jws.SignatureAlgorithm; -import org.springframework.security.oauth2.jwt.Jwt; -import org.springframework.security.oauth2.jwt.JwtEncoder; -import org.springframework.security.oauth2.jwt.JwtEncoderParameters; -import org.springframework.test.context.junit.jupiter.SpringJUnitConfig; -import org.springframework.test.context.TestPropertySource; - -import java.time.Duration; -import java.util.Set; - -import static org.assertj.core.api.Assertions.assertThat; -import static org.mockito.ArgumentMatchers.any; -import static org.mockito.Mockito.verify; -import static org.mockito.Mockito.times; -import static org.mockito.Mockito.when; - -@SpringJUnitConfig(classes = {OverseasTokenService.class, OverseasTokenServiceTest.Config.class}) -@TestPropertySource(properties = "baiyue.security.overseas-jwt.enabled=true") -class OverseasTokenServiceTest { - @MockBean private AuthAccountRepository accounts; - @MockBean private AuthorizationLoader authorizationLoader; - @MockBean(name = "overseasJwtEncoder") private JwtEncoder encoder; - @Resource private OverseasTokenService service; - - @Configuration(proxyBeanMethods = false) - static class Config { - @Bean OverseasJwtProperties properties() { - return new OverseasJwtProperties(true, "auth-service", "lastleg-api", "{}", "current-kid", - Duration.ofMinutes(15)); - } - } - - @Test - void signsShortRs256TokenWithCrossRegionClaims() { - AuthAccount account = new AuthAccount(); - account.setId(42L); account.setAccountStatus("ACTIVE"); account.setDefaultTenantId(999L); - account.setDefaultMembershipId(84L); account.setDefaultAppCode("MERCHANT"); - String authority = "platform:overseas-master-data:quote:list"; - String merchantAuthority = "merchant:warehouse:delivery:list"; - String merchantCrossBorderAuthority = "merchant:cross-border:procurement:list"; - String lastlegAuthority = "lastleg:pda:operate"; - String financeAuthority = "platform:finance:fee-source:edit"; - String domesticAuthority = "platform:firstleg:order:list"; - AuthorizationSnapshot snapshot = new AuthorizationSnapshot(42L, 0L, 84L, "PLATFORM", "WEB", - Set.of("OVERSEAS_WAREHOUSE_PDA_OPERATOR"), - Set.of(authority, merchantAuthority, merchantCrossBorderAuthority, lastlegAuthority, - financeAuthority, domesticAuthority), 7L, 900L); - Jwt encoded = org.mockito.Mockito.mock(Jwt.class); - when(accounts.findById(42L)).thenReturn(account); - when(authorizationLoader.load(account)).thenReturn(snapshot); - when(encoder.encode(any())).thenReturn(encoded); - when(encoded.getTokenValue()).thenReturn("overseas-token"); - - var response = service.exchange(42L); - - ArgumentCaptor captor = ArgumentCaptor.forClass(JwtEncoderParameters.class); - verify(encoder).encode(captor.capture()); - var parameters = captor.getValue(); - assertThat(parameters.getJwsHeader().getAlgorithm()).isEqualTo(SignatureAlgorithm.RS256); - assertThat(parameters.getJwsHeader().getKeyId()).isEqualTo("current-kid"); - assertThat(parameters.getClaims().getAudience()).containsExactly("lastleg-api"); - assertThat(parameters.getClaims().getClaims()) - .containsEntry("user_id", 42L).containsEntry("tenant_id", 0L) - .containsEntry("membership_id", 84L) - .containsEntry("app_code", "PLATFORM").containsEntry("client_type", "WEB") - .containsEntry("permission_version", 7L) - .containsEntry("token_version", 0) - .containsEntry("overseas_warehouse_id", 900L) - .doesNotContainKeys("roles", "authorities", "permissions", "userId", "tenantId", "appCode", - "permissionVersion"); - assertThat(response.expiresIn()).isEqualTo(900); - assertThat(response.roles()).containsExactly("OVERSEAS_WAREHOUSE_PDA_OPERATOR"); - assertThat(response.authorities()).containsExactlyInAnyOrder(authority, merchantAuthority, - merchantCrossBorderAuthority, lastlegAuthority, financeAuthority, domesticAuthority); - } - - @Test - void refreshedAuthorizationSnapshotReplacesWarehouseBindingAndTokenIdentity() { - AuthAccount account = new AuthAccount(); - account.setId(43L); account.setAccountStatus("ACTIVE"); account.setDefaultTenantId(0L); - account.setDefaultMembershipId(85L); account.setDefaultAppCode("PLATFORM"); - account.setTokenVersion(8); - AuthorizationSnapshot oldBinding = new AuthorizationSnapshot(43L, 0L, 85L, "PLATFORM", "PDA", - Set.of("OVERSEAS_WAREHOUSE_PDA_OPERATOR"), Set.of("lastleg:pda:operate"), 8L, 900L); - AuthorizationSnapshot newBinding = new AuthorizationSnapshot(43L, 0L, 85L, "PLATFORM", "PDA", - Set.of("OVERSEAS_WAREHOUSE_PDA_OPERATOR"), Set.of("lastleg:pda:operate"), 9L, 901L); - Jwt first = org.mockito.Mockito.mock(Jwt.class); - Jwt second = org.mockito.Mockito.mock(Jwt.class); - when(encoder.encode(any())).thenReturn(first, second); - when(first.getTokenValue()).thenReturn("overseas-old-binding"); - when(second.getTokenValue()).thenReturn("overseas-new-binding"); - - var oldToken = service.issue(account, oldBinding); - var newToken = service.issue(account, newBinding); - - ArgumentCaptor captor = ArgumentCaptor.forClass(JwtEncoderParameters.class); - verify(encoder, times(2)).encode(captor.capture()); - var oldClaims = captor.getAllValues().get(0).getClaims(); - var newClaims = captor.getAllValues().get(1).getClaims(); - assertThat(oldClaims.getId()).isNotEqualTo(newClaims.getId()); - assertThat(oldClaims.getClaims()).containsEntry("overseas_warehouse_id", 900L) - .containsEntry("permission_version", 8L); - assertThat(newClaims.getClaims()).containsEntry("overseas_warehouse_id", 901L) - .containsEntry("permission_version", 9L); - assertThat(newClaims.getClaims()).containsEntry("token_version", 8); - assertThat(oldToken.accessToken()).isNotEqualTo(newToken.accessToken()); - } - - @Test - void unboundWarehouseIsOmittedSoPdaIdentityMustFailClosed() { - AuthAccount account = new AuthAccount(); - account.setId(44L); account.setAccountStatus("ACTIVE"); - AuthorizationSnapshot unbound = new AuthorizationSnapshot(44L, 0L, 86L, "PLATFORM", "PDA", - Set.of("OVERSEAS_WAREHOUSE_PDA_OPERATOR"), Set.of("lastleg:pda:operate"), 10L, null); - Jwt encoded = org.mockito.Mockito.mock(Jwt.class); - when(encoder.encode(any())).thenReturn(encoded); - when(encoded.getTokenValue()).thenReturn("overseas-unbound"); - - service.issue(account, unbound); - - ArgumentCaptor captor = ArgumentCaptor.forClass(JwtEncoderParameters.class); - verify(encoder).encode(captor.capture()); - assertThat(captor.getValue().getClaims().getClaims()) - .doesNotContainKey("overseas_warehouse_id"); - } -} diff --git a/common-core/src/main/java/com/baiyue/common/core/retry/BoundedRetryPolicy.java b/common-core/src/main/java/com/baiyue/common/core/retry/BoundedRetryPolicy.java new file mode 100644 index 000000000..2ac3c9895 --- /dev/null +++ b/common-core/src/main/java/com/baiyue/common/core/retry/BoundedRetryPolicy.java @@ -0,0 +1,31 @@ +package com.baiyue.common.core.retry; + +import java.time.Duration; +import java.util.concurrent.ThreadLocalRandom; + +/** Bounded exponential retry schedule; callers persist failed or expired attempts. */ +public record BoundedRetryPolicy(int maxAttempts, long baseDelaySeconds, long maxDelaySeconds, double jitter) { + public BoundedRetryPolicy { + if (maxAttempts < 1 || baseDelaySeconds < 1 || maxDelaySeconds < baseDelaySeconds + || maxDelaySeconds > 86400 || !Double.isFinite(jitter) || jitter < 0 || jitter > 0.5) { + throw new IllegalArgumentException("Invalid bounded retry policy"); + } + } + public boolean exhausted(int failedAttempts) { + return failedAttempts >= maxAttempts; + } + public Duration delayAfterFailure(int failedAttempts) { + return delayAfterFailure(failedAttempts, ThreadLocalRandom.current().nextDouble()); + } + public Duration delayAfterFailure(int failedAttempts, double random) { + if (failedAttempts < 1 || !Double.isFinite(random) || random < 0 || random > 1) { + throw new IllegalArgumentException("Invalid retry attempt or jitter sample"); + } + long seconds = baseDelaySeconds; + for (int i = 1; i < failedAttempts && seconds < maxDelaySeconds; i++) { + seconds = Math.min(maxDelaySeconds, seconds * 2); + } + long millis = Math.round(seconds * 1000 * (1 - jitter + 2 * jitter * random)); + return Duration.ofMillis(Math.min(maxDelaySeconds * 1000, Math.max(1, millis))); + } +} diff --git a/common-core/src/test/java/com/baiyue/common/core/BoundedRetryPolicyTest.java b/common-core/src/test/java/com/baiyue/common/core/BoundedRetryPolicyTest.java new file mode 100644 index 000000000..95f055a0a --- /dev/null +++ b/common-core/src/test/java/com/baiyue/common/core/BoundedRetryPolicyTest.java @@ -0,0 +1,31 @@ +package com.baiyue.common.core; + +import com.baiyue.common.core.retry.BoundedRetryPolicy; +import org.junit.jupiter.api.Test; +import static org.assertj.core.api.Assertions.*; + +class BoundedRetryPolicyTest { + @Test void startsAtFiveSecondsDoublesAndCapsAtThirtyMinutes() { + var policy = new BoundedRetryPolicy(12, 5, 1800, 0); + assertThat(policy.delayAfterFailure(1).toSeconds()).isEqualTo(5); + assertThat(policy.delayAfterFailure(2).toSeconds()).isEqualTo(10); + assertThat(policy.delayAfterFailure(9).toSeconds()).isEqualTo(1280); + assertThat(policy.delayAfterFailure(10).toSeconds()).isEqualTo(1800); + assertThat(policy.delayAfterFailure(Integer.MAX_VALUE).toSeconds()).isEqualTo(1800); + assertThat(policy.exhausted(11)).isFalse(); + assertThat(policy.exhausted(12)).isTrue(); + } + @Test void jitterStaysBoundedEvenAtMaximumDelay() { + var policy = new BoundedRetryPolicy(12, 5, 1800, 0.1); + assertThat(policy.delayAfterFailure(1, 0).toMillis()).isEqualTo(4500); + assertThat(policy.delayAfterFailure(1, 1).toMillis()).isEqualTo(5500); + assertThat(policy.delayAfterFailure(20, 1).toSeconds()).isEqualTo(1800); + assertThat(policy.delayAfterFailure(20, 0).toSeconds()).isEqualTo(1620); + } + @Test void invalidConfigurationFailsClosed() { + assertThatThrownBy(() -> new BoundedRetryPolicy(0, 5, 1800, 0.1)).isInstanceOf(IllegalArgumentException.class); + assertThatThrownBy(() -> new BoundedRetryPolicy(12, 0, 1800, 0.1)).isInstanceOf(IllegalArgumentException.class); + assertThatThrownBy(() -> new BoundedRetryPolicy(12, 5, 3, 0.1)).isInstanceOf(IllegalArgumentException.class); + assertThatThrownBy(() -> new BoundedRetryPolicy(12, 5, 1800, Double.NaN)).isInstanceOf(IllegalArgumentException.class); + } +} diff --git a/common-event-contract/src/main/java/com/baiyue/common/event/DeliveryAck.java b/common-event-contract/src/main/java/com/baiyue/common/event/DeliveryAck.java new file mode 100644 index 000000000..803416c07 --- /dev/null +++ b/common-event-contract/src/main/java/com/baiyue/common/event/DeliveryAck.java @@ -0,0 +1,31 @@ +package com.baiyue.common.event; + +import java.util.Objects; + +/** A consumer returns this only after its durable processing transaction commits. */ +public record DeliveryAck(String eventId, String payloadHash, Outcome outcome) { + public enum Outcome { APPLIED, DUPLICATE, STALE } + + public DeliveryAck { + if (!EventIdentifiers.isSupported(eventId)) throw new IllegalArgumentException("Invalid ACK eventId"); + if (payloadHash == null || !payloadHash.matches("sha256:[0-9a-fA-F]{64}")) { + throw new IllegalArgumentException("Invalid ACK payloadHash"); + } + Objects.requireNonNull(outcome, "ACK outcome is required"); + } + + public static DeliveryAck of(EventEnvelope event, Outcome outcome) { + return new DeliveryAck(event.eventId(), event.payloadHash(), outcome); + } + + /** HTTP and ApiResponse status must also be checked by the transport. */ + public void requireMatches(EventEnvelope sent) { + if (!eventId.equals(sent.eventId()) || !payloadHash.equalsIgnoreCase(sent.payloadHash())) { + throw new IllegalArgumentException("Consumer ACK does not match the sent event identity and payload"); + } + EventDeliveryRoute route = EventDeliveryRoute.forEventType(sent.eventType()); + if (outcome == Outcome.STALE && !route.acceptsStale()) { + throw new IllegalArgumentException("STALE is not an allowed result for " + sent.eventType()); + } + } +} diff --git a/common-event-contract/src/main/java/com/baiyue/common/event/EventDeliveryRoute.java b/common-event-contract/src/main/java/com/baiyue/common/event/EventDeliveryRoute.java new file mode 100644 index 000000000..17d68e3a6 --- /dev/null +++ b/common-event-contract/src/main/java/com/baiyue/common/event/EventDeliveryRoute.java @@ -0,0 +1,23 @@ +package com.baiyue.common.event; + +/** Explicit business-event destinations. There is no regional or default destination. */ +public record EventDeliveryRoute(String serviceName, String path, boolean acceptsStale) { + public static EventDeliveryRoute forEventType(String eventType) { + if (eventType == null) throw new IllegalArgumentException("Event type is required"); + return switch (eventType) { + case EventTypes.FIRST_LEG_LOADING_PLAN_COMPLETED, EventTypes.WALLET_COMMAND_COMPLETED -> + new EventDeliveryRoute("lastleg-service", "/internal/lastleg/events", false); + case EventTypes.FIRST_LEG_CONTAINER_SHIPPING_NODE_RECORDED -> + new EventDeliveryRoute("lastleg-service", "/internal/lastleg/events", true); + case EventTypes.OVERSEAS_CONTAINER_UNLOADED, EventTypes.OVERSEAS_DELIVERY_COMPLETED -> + new EventDeliveryRoute("firstleg-service", "/internal/firstleg/v1/container-events", false); + case EventTypes.OVERSEAS_INBOUND_ORDER_DETAINED_CHANGED -> + new EventDeliveryRoute("firstleg-service", "/internal/firstleg/v1/container-events", true); + case EventTypes.WALLET_COMMAND_REQUESTED -> + new EventDeliveryRoute("finance-service", "/internal/finance/v1/wallet-command-events", false); + case EventTypes.BUSINESS_FEE_SOURCE_CHANGED -> + new EventDeliveryRoute("finance-service", "/internal/finance/v1/business-fee-source-events", false); + default -> throw new IllegalArgumentException("Unsupported direct-delivery event type: " + eventType); + }; + } +} diff --git a/common-event-contract/src/test/java/com/baiyue/common/event/DeliveryAckTest.java b/common-event-contract/src/test/java/com/baiyue/common/event/DeliveryAckTest.java new file mode 100644 index 000000000..e21ce9ed2 --- /dev/null +++ b/common-event-contract/src/test/java/com/baiyue/common/event/DeliveryAckTest.java @@ -0,0 +1,50 @@ +package com.baiyue.common.event; + +import com.fasterxml.jackson.databind.ObjectMapper; +import org.junit.jupiter.api.Test; +import java.time.Instant; +import java.util.UUID; +import static org.assertj.core.api.Assertions.*; + +class DeliveryAckTest { + private EventEnvelope event(String type) { + return new EventEnvelope(UUID.randomUUID().toString(), type, 1, Instant.now(), + RegionCode.CN, RegionCode.OVERSEAS, "0", "TEST", "1", 1, null, + null, null, null, new ObjectMapper().createObjectNode(), "sha256:" + "a".repeat(64)); + } + + @Test + void roundTripsAndAcceptsOnlyTheSentIdentityAndHash() throws Exception { + var event = event(EventTypes.WALLET_COMMAND_REQUESTED); + var ack = DeliveryAck.of(event, DeliveryAck.Outcome.APPLIED); + var mapper = new ObjectMapper(); + assertThat(mapper.readValue(mapper.writeValueAsString(ack), DeliveryAck.class)).isEqualTo(ack); + assertThatCode(() -> ack.requireMatches(event)).doesNotThrowAnyException(); + assertThatThrownBy(() -> ack.requireMatches(event(EventTypes.WALLET_COMMAND_REQUESTED))).isInstanceOf(IllegalArgumentException.class); + assertThatThrownBy(() -> new DeliveryAck(event.eventId(), "sha256:" + "b".repeat(64), DeliveryAck.Outcome.APPLIED) + .requireMatches(event)).isInstanceOf(IllegalArgumentException.class); + assertThatThrownBy(() -> mapper.readValue("{\"eventId\":\"" + event.eventId() + "\",\"payloadHash\":\"" + event.payloadHash() + "\",\"outcome\":\"RECEIVED\"}", DeliveryAck.class)) + .isInstanceOf(Exception.class); + } + + @Test + void staleCannotAcknowledgeWalletCommandsResultsOrIndependentFees() { + for (String type : new String[]{EventTypes.WALLET_COMMAND_REQUESTED, EventTypes.WALLET_COMMAND_COMPLETED, + EventTypes.BUSINESS_FEE_SOURCE_CHANGED, EventTypes.OVERSEAS_DELIVERY_COMPLETED}) { + var event = event(type); + assertThatThrownBy(() -> DeliveryAck.of(event, DeliveryAck.Outcome.STALE).requireMatches(event)) + .isInstanceOf(IllegalArgumentException.class); + assertThatCode(() -> DeliveryAck.of(event, DeliveryAck.Outcome.DUPLICATE).requireMatches(event)).doesNotThrowAnyException(); + } + } + + @Test + void mixedLastlegOutboxRoutesWalletToFinanceAndUnloadingToFirstleg() { + assertThat(EventDeliveryRoute.forEventType(EventTypes.WALLET_COMMAND_REQUESTED)) + .isEqualTo(new EventDeliveryRoute("finance-service", "/internal/finance/v1/wallet-command-events", false)); + assertThat(EventDeliveryRoute.forEventType(EventTypes.OVERSEAS_CONTAINER_UNLOADED).serviceName()).isEqualTo("firstleg-service"); + assertThat(EventDeliveryRoute.forEventType(EventTypes.WALLET_COMMAND_COMPLETED).serviceName()).isEqualTo("lastleg-service"); + assertThatThrownBy(() -> EventDeliveryRoute.forEventType("UnknownEvent")).isInstanceOf(IllegalArgumentException.class); + assertThatThrownBy(() -> EventDeliveryRoute.forEventType(EventTypes.IAM_USER_STATUS_CHANGED)).isInstanceOf(IllegalArgumentException.class); + } +} diff --git a/common-security/AGENTS.md b/common-security/AGENTS.md index f53733015..db8bb210f 100644 --- a/common-security/AGENTS.md +++ b/common-security/AGENTS.md @@ -10,19 +10,19 @@ | 功能 | 入口 | |---|---| | 安全链自动配置 | `src/main/java/com/baiyue/common/security/BaiyueSecurityAutoConfiguration.java` | -| JWT 解码模式 | `JwksJwtDecoderAutoConfiguration.java`(国内与海外统一 RS256/JWKS);`HmacJwtDecoderAutoConfiguration.java` 仅作清理前的历史实现定位,不得作为跨区域共享密钥方案 | +| JWT 解码模式 | `JwksJwtDecoderAutoConfiguration.java`(国内与海外统一 RS256/JWKS) | | JWT 过滤与声明映射 | `JwtAuthenticationFilter.java`、`JwtClaimsMapper.java` | | 当前用户与认证对象 | `AuthenticatedUser.java`、`BaiyueJwtAuthenticationToken.java`;海外 PDA 仓范围由 `JwtClaimsMapper.java` 将 `overseas_warehouse_id` 映射为 `overseasWarehouseId` | -| 会话与授权来源 | `AccessTokenSessionValidator.java`、`RedisAccessTokenSessionValidator.java`(国内登录 Token 即时失效)、`ClaimsAccessTokenSessionValidator.java`(海外访问 Token 本地短时 Claims)、`RedisTokenKeys.java` | +| 会话与授权来源 | `AccessTokenSessionValidator.java`、`RedisAccessTokenSessionValidator.java`(所有服务共享会话和权限快照)、`RedisTokenKeys.java` | | 401/403 响应 | `JsonAuthenticationEntryPoint.java`、`JsonAccessDeniedHandler.java` | | 自动配置注册 | `src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports` | | 安全回归测试 | `src/test/java/com/baiyue/common/security/` | ## 修改与验证 -- 所有资源服务统一使用 `jwt-mode=jwks`、RS256、Issuer 和 JWKS;国内服务验证国内 Audience 并使用 `session-mode=redis`,海外服务只接受登录/刷新时签发的海外 Audience Token 并使用 `session-mode=claims`,不得访问国内 Redis。海外服务应缓存 JWKS 并在密钥轮换时刷新,不得每个请求访问国内 Auth;不得重新引入跨区域共享 HS256 密钥,也不得让海外服务接受国内 Token。 +- 所有资源服务只接受 RS256/JWKS,统一 Issuer、Audience 和 `session-mode=redis`。私钥仅在 Auth,业务服务使用 JWKS 公钥缓存与共享 Redis 会话;不再支持 HMAC、claims 或 gateway 会话模式,不信任请求头携带的角色权限。 - Token Claim、Redis Key、Issuer、Audience、JWKS、会话模式或认证对象变化必须同步检查 `auth/`、`gateway/`、Nacos/部署配置和所有实际业务服务。 -- `overseas_warehouse_id` 只承载 IAM 已确认的海外仓绑定;缺失、格式非法或与实时安全投影不一致时不得放宽 PDA 仓范围,Gateway 与业务服务必须 fail-closed。 +- `overseas_warehouse_id` 只承载 IAM 已确认的海外仓绑定;缺失、格式非法或会话已撤销时不得放宽 PDA 仓范围,Gateway 与业务服务必须 fail-closed。 - `JsonAuthenticationEntryPoint` 与 `JsonAccessDeniedHandler` 属于统一 HTTP 200 业务契约的例外:认证入口仍返回真实 401/403,供前端刷新 Token 和区分授权失败。 - 不降低服务端租户、会话或权限校验;密钥只从配置中心或环境变量读取。 - 从父目录运行 `mvn -pl common-security -am test`。 diff --git a/common-security/src/main/java/com/baiyue/common/security/BaiyueSecurityAutoConfiguration.java b/common-security/src/main/java/com/baiyue/common/security/BaiyueSecurityAutoConfiguration.java index f27f21ff9..25fe60b58 100644 --- a/common-security/src/main/java/com/baiyue/common/security/BaiyueSecurityAutoConfiguration.java +++ b/common-security/src/main/java/com/baiyue/common/security/BaiyueSecurityAutoConfiguration.java @@ -23,7 +23,7 @@ import org.springframework.security.web.authentication.UsernamePasswordAuthentic * Servlet 服务安全自动配置,建立无状态 JWT 认证链、统一异常响应和审计操作者解析。 */ @AutoConfiguration( - after = {HmacJwtDecoderAutoConfiguration.class, JwksJwtDecoderAutoConfiguration.class}, + after = JwksJwtDecoderAutoConfiguration.class, before = SecurityAutoConfiguration.class) @EnableMethodSecurity public class BaiyueSecurityAutoConfiguration { @@ -50,16 +50,13 @@ public class BaiyueSecurityAutoConfiguration { org.springframework.beans.factory.ObjectProvider redis, ObjectMapper objectMapper, @Value("${baiyue.security.token-key-prefix:baiyue:auth}") String keyPrefix, - @Value("${baiyue.security.gateway-internal-service-token:${baiyue.security.internal-service-token:${INTERNAL_SERVICE_TOKEN:}}}") String gatewayToken, @Value("${baiyue.security.session-mode:redis}") String sessionMode) { - AccessTokenSessionValidator validator; - if ("claims".equalsIgnoreCase(sessionMode)) validator = new ClaimsAccessTokenSessionValidator(); - else if ("gateway".equalsIgnoreCase(sessionMode)) validator = new GatewayAccessTokenSessionValidator(gatewayToken); - else { - StringRedisTemplate template=redis.getIfAvailable(); - if(template==null) throw new IllegalStateException("session-mode=redis requires StringRedisTemplate"); - validator=new RedisAccessTokenSessionValidator(template,keyPrefix,objectMapper); + if (!"redis".equalsIgnoreCase(sessionMode)) { + throw new IllegalStateException("Only shared Redis access sessions are supported"); } + StringRedisTemplate template = redis.getIfAvailable(); + if (template == null) throw new IllegalStateException("Redis access sessions require StringRedisTemplate"); + AccessTokenSessionValidator validator = new RedisAccessTokenSessionValidator(template, keyPrefix, objectMapper); return new JwtAuthenticationFilter(jwtDecoder, claimsMapper, entryPoint, validator); } diff --git a/common-security/src/main/java/com/baiyue/common/security/ClaimsAccessTokenSessionValidator.java b/common-security/src/main/java/com/baiyue/common/security/ClaimsAccessTokenSessionValidator.java deleted file mode 100644 index c32b30317..000000000 --- a/common-security/src/main/java/com/baiyue/common/security/ClaimsAccessTokenSessionValidator.java +++ /dev/null @@ -1,16 +0,0 @@ -package com.baiyue.common.security; - -import org.springframework.security.oauth2.jwt.Jwt; -import java.util.LinkedHashSet; -import java.util.Set; - -/** 海外模式直接使用已由 RS256/JWKS 验证的短时令牌授权快照。 */ -public class ClaimsAccessTokenSessionValidator implements AccessTokenSessionValidator { - @Override public AccessTokenSession validate(Jwt jwt) { - Long userId=number(jwt.getClaims().getOrDefault("userId",jwt.getClaims().get("user_id"))); - return new AccessTokenSession(1,userId,values(jwt.getClaims().get("roles")), - values(jwt.getClaims().getOrDefault("authorities",jwt.getClaims().get("permissions")))); - } - private static Long number(Object value){ if(value instanceof Number n)return n.longValue(); try{return Long.valueOf(String.valueOf(value));}catch(Exception e){return null;} } - private static Set values(Object value){ Set result=new LinkedHashSet<>(); if(value instanceof Iterable it)it.forEach(v->result.add(String.valueOf(v))); else if(value!=null)for(String v:String.valueOf(value).split("[ ,]+"))if(!v.isBlank())result.add(v); return result; } -} diff --git a/common-security/src/main/java/com/baiyue/common/security/GatewayAccessTokenSessionValidator.java b/common-security/src/main/java/com/baiyue/common/security/GatewayAccessTokenSessionValidator.java deleted file mode 100644 index 062ff2fa1..000000000 --- a/common-security/src/main/java/com/baiyue/common/security/GatewayAccessTokenSessionValidator.java +++ /dev/null @@ -1,65 +0,0 @@ -package com.baiyue.common.security; - -import jakarta.servlet.http.HttpServletRequest; -import org.springframework.security.oauth2.jwt.Jwt; - -import java.nio.charset.StandardCharsets; -import java.security.MessageDigest; -import java.util.LinkedHashSet; -import java.util.Set; - -/** Uses the overseas gateway's authenticated authorization snapshot instead of large JWT claims. */ -public final class GatewayAccessTokenSessionValidator implements AccessTokenSessionValidator { - public static final String TOKEN_HEADER = "X-Internal-Service-Token"; - public static final String ROLES_HEADER = "X-Overseas-Roles"; - public static final String AUTHORITIES_HEADER = "X-Overseas-Authorities"; - - private final byte[] expectedToken; - - public GatewayAccessTokenSessionValidator(String expectedToken) { - if (expectedToken == null || expectedToken.isBlank()) { - throw new IllegalStateException("session-mode=gateway requires an internal service token"); - } - this.expectedToken = expectedToken.getBytes(StandardCharsets.UTF_8); - } - - @Override - public AccessTokenSession validate(Jwt jwt) { - throw new IllegalArgumentException("Gateway authorization headers are required"); - } - - @Override - public AccessTokenSession validate(Jwt jwt, HttpServletRequest request) { - String supplied = request.getHeader(TOKEN_HEADER); - if (supplied == null || supplied.isBlank()) { - throw new IllegalArgumentException("Missing overseas gateway authorization context"); - } - byte[] suppliedToken = supplied.getBytes(StandardCharsets.UTF_8); - if (!MessageDigest.isEqual(expectedToken, suppliedToken)) { - throw new IllegalArgumentException("Invalid overseas gateway authorization context (expectedBytes=" - + expectedToken.length + ", suppliedBytes=" + suppliedToken.length + ")"); - } - Long userId = number(jwt.getClaims().getOrDefault("userId", jwt.getClaims().get("user_id"))); - if (userId == null) { - userId = number(jwt.getSubject()); - } - return new AccessTokenSession(1, userId, values(request.getHeader(ROLES_HEADER)), - values(request.getHeader(AUTHORITIES_HEADER))); - } - - private static Long number(Object value) { - if (value instanceof Number number) return number.longValue(); - try { return value == null ? null : Long.valueOf(value.toString()); } - catch (NumberFormatException exception) { return null; } - } - - private static Set values(String header) { - if (header == null || header.isBlank()) return Set.of(); - LinkedHashSet values = new LinkedHashSet<>(); - for (String value : header.split(",")) { - String normalized = value.trim(); - if (!normalized.isEmpty()) values.add(normalized); - } - return Set.copyOf(values); - } -} diff --git a/common-security/src/main/java/com/baiyue/common/security/HmacJwtDecoderAutoConfiguration.java b/common-security/src/main/java/com/baiyue/common/security/HmacJwtDecoderAutoConfiguration.java deleted file mode 100644 index b4b8261ed..000000000 --- a/common-security/src/main/java/com/baiyue/common/security/HmacJwtDecoderAutoConfiguration.java +++ /dev/null @@ -1,33 +0,0 @@ -package com.baiyue.common.security; - -import org.springframework.beans.factory.annotation.Value; -import org.springframework.boot.autoconfigure.AutoConfiguration; -import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean; -import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; -import org.springframework.context.annotation.Bean; -import org.springframework.security.oauth2.jose.jws.MacAlgorithm; -import org.springframework.security.oauth2.jwt.JwtDecoder; -import org.springframework.security.oauth2.jwt.JwtValidators; -import org.springframework.security.oauth2.jwt.NimbusJwtDecoder; - -import javax.crypto.spec.SecretKeySpec; -import java.nio.charset.StandardCharsets; - -/** - * HMAC JWT 解码器自动配置,按共享密钥和签发方构建统一令牌验证器。 - */ -@AutoConfiguration(before = BaiyueSecurityAutoConfiguration.class) -@ConditionalOnProperty(name = "baiyue.security.jwt-mode", havingValue = "hmac", matchIfMissing = true) -public class HmacJwtDecoderAutoConfiguration { - @Bean - @ConditionalOnMissingBean(JwtDecoder.class) - JwtDecoder jwtDecoder(@Value("${baiyue.security.jwt-secret}") String jwtSecret, - @Value("${baiyue.security.issuer:auth-service}") String issuer) { - byte[] secret = jwtSecret.getBytes(StandardCharsets.UTF_8); - if (secret.length < 32) throw new IllegalStateException("JWT_SECRET 至少需要 32 字节"); - NimbusJwtDecoder decoder = NimbusJwtDecoder.withSecretKey(new SecretKeySpec(secret, "HmacSHA256")) - .macAlgorithm(MacAlgorithm.HS256).build(); - decoder.setJwtValidator(JwtValidators.createDefaultWithIssuer(issuer)); - return decoder; - } -} diff --git a/common-security/src/main/java/com/baiyue/common/security/JwksJwtDecoderAutoConfiguration.java b/common-security/src/main/java/com/baiyue/common/security/JwksJwtDecoderAutoConfiguration.java index b73770483..7859c7a44 100644 --- a/common-security/src/main/java/com/baiyue/common/security/JwksJwtDecoderAutoConfiguration.java +++ b/common-security/src/main/java/com/baiyue/common/security/JwksJwtDecoderAutoConfiguration.java @@ -3,7 +3,6 @@ package com.baiyue.common.security; import org.springframework.beans.factory.annotation.Value; import org.springframework.boot.autoconfigure.AutoConfiguration; import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean; -import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; import org.springframework.context.annotation.Bean; import org.springframework.security.oauth2.jwt.JwtDecoder; import org.springframework.security.oauth2.jwt.JwtValidators; @@ -14,12 +13,11 @@ import org.springframework.security.oauth2.jose.jws.SignatureAlgorithm; import java.util.List; -/** 海外服务仅通过可轮换 JWKS 验证 RS256,不访问国内 Redis。 */ +/** 所有业务服务通过可轮换 JWKS 验证 RS256,会话由共享 Redis 校验。 */ @AutoConfiguration(before = BaiyueSecurityAutoConfiguration.class) -@ConditionalOnProperty(name="baiyue.security.jwt-mode",havingValue="jwks") public class JwksJwtDecoderAutoConfiguration { @Bean @ConditionalOnMissingBean(JwtDecoder.class) - JwtDecoder jwksJwtDecoder(@Value("${baiyue.security.jwk-set-uri}") String uri, + JwtDecoder jwksJwtDecoder(@Value("${baiyue.security.jwk-set-uri:${JWT_JWKS_URI:http://auth-service:9100/api/auth/.well-known/jwks.json}}") String uri, @Value("${baiyue.security.issuer:auth-service}") String issuer, @Value("${baiyue.security.audience:lastleg-api}") String audience) { NimbusJwtDecoder decoder = NimbusJwtDecoder.withJwkSetUri(uri) diff --git a/common-security/src/main/java/com/baiyue/common/security/JwtAuthenticationFilter.java b/common-security/src/main/java/com/baiyue/common/security/JwtAuthenticationFilter.java index 3ea44844c..ea96d2ab9 100644 --- a/common-security/src/main/java/com/baiyue/common/security/JwtAuthenticationFilter.java +++ b/common-security/src/main/java/com/baiyue/common/security/JwtAuthenticationFilter.java @@ -28,10 +28,6 @@ public class JwtAuthenticationFilter extends OncePerRequestFilter { private final AuthenticationEntryPoint authenticationEntryPoint; private final AccessTokenSessionValidator sessionValidator; - public JwtAuthenticationFilter(JwtDecoder jwtDecoder, JwtClaimsMapper claimsMapper, - AuthenticationEntryPoint authenticationEntryPoint) { - this(jwtDecoder, claimsMapper, authenticationEntryPoint, jwt -> null); - } public JwtAuthenticationFilter(JwtDecoder jwtDecoder, JwtClaimsMapper claimsMapper, AuthenticationEntryPoint authenticationEntryPoint, diff --git a/common-security/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports b/common-security/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports index d4d261650..6f4cadda1 100644 --- a/common-security/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports +++ b/common-security/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports @@ -1,3 +1,2 @@ -com.baiyue.common.security.HmacJwtDecoderAutoConfiguration com.baiyue.common.security.JwksJwtDecoderAutoConfiguration com.baiyue.common.security.BaiyueSecurityAutoConfiguration diff --git a/common-security/src/test/java/com/baiyue/common/security/GatewayAccessTokenSessionValidatorTest.java b/common-security/src/test/java/com/baiyue/common/security/GatewayAccessTokenSessionValidatorTest.java deleted file mode 100644 index d9c86a28f..000000000 --- a/common-security/src/test/java/com/baiyue/common/security/GatewayAccessTokenSessionValidatorTest.java +++ /dev/null @@ -1,49 +0,0 @@ -package com.baiyue.common.security; - -import org.junit.jupiter.api.Test; -import org.springframework.mock.web.MockHttpServletRequest; -import org.springframework.security.oauth2.jwt.Jwt; - -import java.time.Instant; - -import static org.assertj.core.api.Assertions.assertThat; -import static org.assertj.core.api.Assertions.assertThatThrownBy; - -class GatewayAccessTokenSessionValidatorTest { - private final GatewayAccessTokenSessionValidator validator = - new GatewayAccessTokenSessionValidator("gateway-secret"); - - @Test void acceptsOnlyGatewayAuthenticatedAuthorizationSnapshot() { - MockHttpServletRequest request = new MockHttpServletRequest(); - request.addHeader(GatewayAccessTokenSessionValidator.TOKEN_HEADER, "gateway-secret"); - request.addHeader(GatewayAccessTokenSessionValidator.ROLES_HEADER, "PLATFORM_ADMIN"); - request.addHeader(GatewayAccessTokenSessionValidator.AUTHORITIES_HEADER, - "platform:overseas-warehouse:last-mile-order:list,platform:finance:fee-source:view"); - - AccessTokenSession session = validator.validate(jwt(), request); - - assertThat(session.userId()).isEqualTo(42L); - assertThat(session.roles()).containsExactly("PLATFORM_ADMIN"); - assertThat(session.permissions()).containsExactlyInAnyOrder( - "platform:overseas-warehouse:last-mile-order:list", "platform:finance:fee-source:view"); - } - - @Test void rejectsForgedOrMissingGatewayToken() { - MockHttpServletRequest forged = new MockHttpServletRequest(); - forged.addHeader(GatewayAccessTokenSessionValidator.TOKEN_HEADER, "forged"); - forged.addHeader(GatewayAccessTokenSessionValidator.AUTHORITIES_HEADER, - "platform:overseas-warehouse:last-mile-order:list"); - assertThatThrownBy(() -> validator.validate(jwt(), forged)) - .isInstanceOf(IllegalArgumentException.class).hasMessageContaining("Invalid overseas gateway"); - - assertThatThrownBy(() -> validator.validate(jwt(), new MockHttpServletRequest())) - .isInstanceOf(IllegalArgumentException.class).hasMessageContaining("Missing overseas gateway"); - } - - private Jwt jwt() { - return Jwt.withTokenValue("small-overseas-token").header("alg", "RS256").subject("42") - .issuedAt(Instant.now()).expiresAt(Instant.now().plusSeconds(300)) - .claim("tenant_id", 0L).claim("app_code", "PLATFORM") - .claim("permission_version", 7L).claim("token_version", 1).build(); - } -} diff --git a/common-security/src/test/java/com/baiyue/common/security/JwtAuthenticationFilterTest.java b/common-security/src/test/java/com/baiyue/common/security/JwtAuthenticationFilterTest.java index 9ea0f4fa1..307a44d56 100644 --- a/common-security/src/test/java/com/baiyue/common/security/JwtAuthenticationFilterTest.java +++ b/common-security/src/test/java/com/baiyue/common/security/JwtAuthenticationFilterTest.java @@ -29,7 +29,8 @@ class JwtAuthenticationFilterTest { .claim("user_id", 1L).build(); JwtDecoder decoder = token -> jwt; JwtAuthenticationFilter filter = new JwtAuthenticationFilter(decoder, new JwtClaimsMapper(), - new JsonAuthenticationEntryPoint(new ObjectMapper())); + new JsonAuthenticationEntryPoint(new ObjectMapper()), + token -> new AccessTokenSession(1, 1L, java.util.Set.of(), java.util.Set.of())); MockHttpServletRequest request = new MockHttpServletRequest(); request.addHeader(HttpHeaders.AUTHORIZATION, "Bearer valid"); MockHttpServletResponse response = new MockHttpServletResponse(); @@ -43,20 +44,25 @@ class JwtAuthenticationFilterTest { } @Test - void authenticatesMinimalOverseasTokenWithGatewaySnapshot() throws Exception { + void authenticatesUnifiedTokenWithServerSessionIgnoringGatewayHeaders() throws Exception { Jwt jwt = Jwt.withTokenValue("overseas").header("alg", "RS256").subject("42") .issuedAt(Instant.now()).expiresAt(Instant.now().plusSeconds(60)) - .claim("tenant_id", 0L).claim("app_code", "PLATFORM") + .claim("jti", "session-42").claim("user_id", 42L).claim("tenant_id", 0L).claim("app_code", "PLATFORM") .claim("permission_version", 7L).claim("token_version", 1).build(); + var redis = org.mockito.Mockito.mock(org.springframework.data.redis.core.StringRedisTemplate.class); + var values = org.mockito.Mockito.mock(org.springframework.data.redis.core.ValueOperations.class); + org.mockito.Mockito.when(redis.opsForValue()).thenReturn(values); + org.mockito.Mockito.when(values.get("baiyue:auth:access:session-42")) + .thenReturn("{\"version\":1,\"userId\":42,\"roles\":[\"PLATFORM_ADMIN\"],\"permissions\":[\"platform:lastleg:view\"]}"); JwtAuthenticationFilter filter = new JwtAuthenticationFilter(token -> jwt, new JwtClaimsMapper(), new JsonAuthenticationEntryPoint(new ObjectMapper()), - new GatewayAccessTokenSessionValidator("gateway-secret")); + new RedisAccessTokenSessionValidator(redis, "baiyue:auth")); MockHttpServletRequest request = new MockHttpServletRequest(); request.setRequestURI("/api/platform/v1/last-leg/orders"); request.addHeader(HttpHeaders.AUTHORIZATION, "Bearer overseas"); - request.addHeader(GatewayAccessTokenSessionValidator.TOKEN_HEADER, "gateway-secret"); - request.addHeader(GatewayAccessTokenSessionValidator.ROLES_HEADER, "PLATFORM_ADMIN"); - request.addHeader(GatewayAccessTokenSessionValidator.AUTHORITIES_HEADER, "platform:lastleg:view"); + request.addHeader("X-Internal-Service-Token", "gateway-secret"); + request.addHeader("X-Overseas-Roles", "FORGED_ADMIN"); + request.addHeader("X-Overseas-Authorities", "platform:wallet:write"); MockHttpServletResponse response = new MockHttpServletResponse(); AtomicBoolean invoked = new AtomicBoolean(); @@ -64,7 +70,8 @@ class JwtAuthenticationFilterTest { var authentication = SecurityContextHolder.getContext().getAuthentication(); assertThat(authentication).isNotNull(); assertThat(authentication.getAuthorities()).extracting("authority") - .contains("APP_PLATFORM", "ROLE_PLATFORM_ADMIN", "platform:lastleg:view"); + .contains("APP_PLATFORM", "ROLE_PLATFORM_ADMIN", "platform:lastleg:view") + .doesNotContain("ROLE_FORGED_ADMIN", "platform:wallet:write"); invoked.set(true); }); diff --git a/common-security/src/test/java/com/baiyue/common/security/UnifiedSessionModeTest.java b/common-security/src/test/java/com/baiyue/common/security/UnifiedSessionModeTest.java new file mode 100644 index 000000000..4280bbe8e --- /dev/null +++ b/common-security/src/test/java/com/baiyue/common/security/UnifiedSessionModeTest.java @@ -0,0 +1,22 @@ +package com.baiyue.common.security; + +import com.fasterxml.jackson.databind.ObjectMapper; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.support.StaticListableBeanFactory; +import org.springframework.data.redis.core.StringRedisTemplate; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +class UnifiedSessionModeTest { + @Test + void rejectsLegacySessionModesAndMissingRedisInsteadOfTrustingClaimsOrHeaders() { + var configuration = new BaiyueSecurityAutoConfiguration(); + var beans = new StaticListableBeanFactory(); + var redis = beans.getBeanProvider(StringRedisTemplate.class); + for (String mode : new String[]{"claims", "gateway", "unknown", "redis"}) { + assertThatThrownBy(() -> configuration.jwtAuthenticationFilter(token -> null, + new JwtClaimsMapper(), new JsonAuthenticationEntryPoint(new ObjectMapper()), + redis, new ObjectMapper(), "baiyue:auth", mode)) + .isInstanceOf(IllegalStateException.class); + } + } +} diff --git a/common-web/src/main/java/com/baiyue/common/web/EventDeliveryFeignConfiguration.java b/common-web/src/main/java/com/baiyue/common/web/EventDeliveryFeignConfiguration.java new file mode 100644 index 000000000..25768312a --- /dev/null +++ b/common-web/src/main/java/com/baiyue/common/web/EventDeliveryFeignConfiguration.java @@ -0,0 +1,33 @@ +package com.baiyue.common.web; + +import feign.Request; +import feign.Capability; +import feign.Retryer; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.context.annotation.Bean; +import java.util.concurrent.TimeUnit; + +/** Explicitly selected by reliable event clients; never registered as a global configuration. */ +public class EventDeliveryFeignConfiguration { + @Bean + public DeliveryTransportCapability eventDeliveryCapability( + @Value("${baiyue.event-delivery.connect-timeout-ms:3000}") int connectTimeout, + @Value("${baiyue.event-delivery.read-timeout-ms:15000}") int readTimeout, + @Value("${baiyue.event-delivery.lease-seconds:60}") long leaseSeconds) { + if (connectTimeout < 1 || readTimeout < 1 || leaseSeconds < 1 + || leaseSeconds > Integer.MAX_VALUE || (long) connectTimeout + readTimeout >= leaseSeconds * 1000) { + throw new IllegalArgumentException("Event delivery timeouts must fit inside the lease"); + } + return new DeliveryTransportCapability(new Request.Options(connectTimeout, TimeUnit.MILLISECONDS, readTimeout, TimeUnit.MILLISECONDS, false)); + } + /** Applied at Feign build time after global/client properties, without changing other clients. */ + public static final class DeliveryTransportCapability implements Capability { + private final Request.Options options; + public DeliveryTransportCapability(Request.Options options) { this.options = options; } + @Override public Request.Options enrich(Request.Options ignored) { return options; } + @Override public Retryer enrich(Retryer ignored) { + // The persisted Outbox owns the retry budget. + return Retryer.NEVER_RETRY; + } + } +} diff --git a/common-web/src/test/java/com/baiyue/common/web/EventDeliveryFeignConfigurationTest.java b/common-web/src/test/java/com/baiyue/common/web/EventDeliveryFeignConfigurationTest.java new file mode 100644 index 000000000..dd94c9ff5 --- /dev/null +++ b/common-web/src/test/java/com/baiyue/common/web/EventDeliveryFeignConfigurationTest.java @@ -0,0 +1,49 @@ +package com.baiyue.common.web; + +import feign.*; +import org.junit.jupiter.api.Test; +import java.nio.charset.StandardCharsets; +import java.util.List; +import java.util.Map; +import java.util.concurrent.atomic.AtomicInteger; +import java.util.concurrent.atomic.AtomicReference; +import static org.assertj.core.api.Assertions.*; + +class EventDeliveryFeignConfigurationTest { + interface Endpoint { @RequestLine("POST /events") String send(); } + + @Test void builtClientOverridesLegacyTimeoutAndRedirectSettings() { + var capability = new EventDeliveryFeignConfiguration().eventDeliveryCapability(3000, 15000, 60); + var observed = new AtomicReference(); + Endpoint endpoint = Feign.builder().options(new Request.Options(1, 5000)) + .addCapability(capability).client((request, options) -> { + observed.set(options); + return Response.builder().request(request).status(200).reason("OK") + .headers(Map.of()).body("ok", StandardCharsets.UTF_8).build(); + }).target(Endpoint.class, "http://event-consumer.invalid"); + assertThat(endpoint.send()).isEqualTo("ok"); + assertThat(observed.get().connectTimeoutMillis()).isEqualTo(3000); + assertThat(observed.get().readTimeoutMillis()).isEqualTo(15000); + assertThat(observed.get().isFollowRedirects()).isFalse(); + } + + @Test void retryAfterResponseCannotTriggerHiddenHttpRetries() { + var calls = new AtomicInteger(); + Endpoint endpoint = Feign.builder().retryer(new Retryer.Default(1, 1, 3)) + .addCapability(new EventDeliveryFeignConfiguration().eventDeliveryCapability(3000, 15000, 60)) + .client((request, options) -> { + calls.incrementAndGet(); + return Response.builder().request(request).status(503).reason("Unavailable") + .headers(Map.of("Retry-After", List.of("0"))).build(); + }).target(Endpoint.class, "http://event-consumer.invalid"); + assertThatThrownBy(endpoint::send).isInstanceOf(RetryableException.class); + assertThat(calls.get()).isEqualTo(1); + } + + @Test void invalidTimeoutsAndTooShortLeaseAreRejected() { + var configuration = new EventDeliveryFeignConfiguration(); + assertThatThrownBy(() -> configuration.eventDeliveryCapability(0, 15000, 60)).isInstanceOf(IllegalArgumentException.class); + assertThatThrownBy(() -> configuration.eventDeliveryCapability(3000, 15000, 18)).isInstanceOf(IllegalArgumentException.class); + assertThatThrownBy(() -> configuration.eventDeliveryCapability(3000, -1, 60)).isInstanceOf(IllegalArgumentException.class); + } +} diff --git a/config/nacos/local/lastleg-service.yml b/config/nacos/local/lastleg-service.yml index 77ec1e8b2..7b8f3bd01 100644 --- a/config/nacos/local/lastleg-service.yml +++ b/config/nacos/local/lastleg-service.yml @@ -23,7 +23,7 @@ baiyue: security: internal-service-token: ${INTERNAL_SERVICE_TOKEN:phase1-local-internal-token-at-least-32-bytes} jwt-mode: jwks - session-mode: gateway + session-mode: redis issuer: ${JWT_ISSUER:auth-service} audience: ${JWT_AUDIENCE:lastleg-api} - jwk-set-uri: ${JWT_JWK_SET_URI:http://127.0.0.1:9100/api/auth/.well-known/jwks.json} + jwk-set-uri: ${JWT_JWKS_URI:http://auth-service:9100/api/auth/.well-known/jwks.json} diff --git a/config/nacos/local/warehouse-overseas-service.yml b/config/nacos/local/warehouse-overseas-service.yml index 7edeaec76..a3b8cf9ee 100644 --- a/config/nacos/local/warehouse-overseas-service.yml +++ b/config/nacos/local/warehouse-overseas-service.yml @@ -13,7 +13,7 @@ baiyue: token: ${INTERNAL_SERVICE_TOKEN:phase1-local-internal-token-at-least-32-bytes} security: jwt-mode: jwks - session-mode: gateway + session-mode: redis issuer: ${JWT_ISSUER:auth-service} audience: ${JWT_AUDIENCE:lastleg-api} - jwk-set-uri: ${JWT_JWK_SET_URI:http://127.0.0.1:9100/api/auth/.well-known/jwks.json} + jwk-set-uri: ${JWT_JWKS_URI:http://auth-service:9100/api/auth/.well-known/jwks.json} diff --git a/finance/pom.xml b/finance/pom.xml index 8da2c4701..8fce50d06 100644 --- a/finance/pom.xml +++ b/finance/pom.xml @@ -25,6 +25,7 @@ com.alibaba.cloudspring-cloud-starter-alibaba-nacos-config org.springframework.bootspring-boot-starter-actuator org.springframework.bootspring-boot-starter-testtest + com.h2databaseh2test diff --git a/finance/src/main/java/com/baiyue/finance/client/WalletResultEventBridgeClient.java b/finance/src/main/java/com/baiyue/finance/client/WalletResultEventBridgeClient.java deleted file mode 100644 index 1aab9c099..000000000 --- a/finance/src/main/java/com/baiyue/finance/client/WalletResultEventBridgeClient.java +++ /dev/null @@ -1,18 +0,0 @@ -package com.baiyue.finance.client; - -import com.baiyue.common.core.api.ApiResponse; -import com.baiyue.common.event.EventEnvelope; -import org.springframework.cloud.openfeign.FeignClient; -import org.springframework.web.bind.annotation.PostMapping; -import org.springframework.web.bind.annotation.RequestBody; -import org.springframework.web.bind.annotation.RequestHeader; - -@FeignClient(name = "event-bridge-service", contextId = "financeWalletResultEventBridgeClient", - url = "${OVERSEAS_EVENT_BRIDGE_URL:${baiyue.wallet-event.event-bridge-url:}}") -public interface WalletResultEventBridgeClient { - @PostMapping("/internal/event-bridge/events") - ApiResponse receive(@RequestHeader("X-Internal-Service-Token") String token, - @RequestBody EventEnvelope envelope); - - record Acknowledgement(String eventId, String result, String receivedAt) { } -} diff --git a/finance/src/main/java/com/baiyue/finance/client/WalletResultLastlegClient.java b/finance/src/main/java/com/baiyue/finance/client/WalletResultLastlegClient.java new file mode 100644 index 000000000..793e75493 --- /dev/null +++ b/finance/src/main/java/com/baiyue/finance/client/WalletResultLastlegClient.java @@ -0,0 +1,19 @@ +package com.baiyue.finance.client; + +import com.baiyue.common.core.api.ApiResponse; +import com.baiyue.common.web.EventDeliveryFeignConfiguration; +import com.baiyue.common.event.EventEnvelope; +import com.baiyue.common.event.DeliveryAck; +import org.springframework.cloud.openfeign.FeignClient; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestHeader; + +@FeignClient(configuration = EventDeliveryFeignConfiguration.class, name = "lastleg-service", contextId = "financeWalletResultLastlegClient", + url = "${LASTLEG_SERVICE_URL:${baiyue.clients.lastleg.url:}}") +public interface WalletResultLastlegClient { + @PostMapping("/internal/lastleg/events") + ApiResponse receive(@RequestHeader("X-Internal-Service-Token") String token, + @RequestBody EventEnvelope envelope); + +} diff --git a/finance/src/main/java/com/baiyue/finance/controller/InternalBusinessFeeSourceController.java b/finance/src/main/java/com/baiyue/finance/controller/InternalBusinessFeeSourceController.java index 88f9dc71e..cc21914d0 100644 --- a/finance/src/main/java/com/baiyue/finance/controller/InternalBusinessFeeSourceController.java +++ b/finance/src/main/java/com/baiyue/finance/controller/InternalBusinessFeeSourceController.java @@ -25,7 +25,7 @@ public class InternalBusinessFeeSourceController { @Value("${baiyue.security.internal-service-token}") private String expectedToken; @PostMapping - public ApiResponse> consume( + public ApiResponse consume( @RequestHeader(value = "X-Internal-Service-Token", required = false) String token, @RequestBody EventEnvelope event) { if (token == null || expectedToken == null || expectedToken.isBlank() @@ -33,6 +33,6 @@ public class InternalBusinessFeeSourceController { expectedToken.getBytes(StandardCharsets.UTF_8))) { throw new FinanceEventException("FIN-EVT-401", "内部服务令牌无效"); } - return FinanceResponses.success(Map.of("feeSourceId", service.consume(event))); + return FinanceResponses.success(service.consume(event)); } } diff --git a/finance/src/main/java/com/baiyue/finance/controller/InternalWalletCommandEventController.java b/finance/src/main/java/com/baiyue/finance/controller/InternalWalletCommandEventController.java index 51a94c358..ea45923b9 100644 --- a/finance/src/main/java/com/baiyue/finance/controller/InternalWalletCommandEventController.java +++ b/finance/src/main/java/com/baiyue/finance/controller/InternalWalletCommandEventController.java @@ -24,7 +24,7 @@ public class InternalWalletCommandEventController { @Value("${baiyue.security.internal-service-token}") private String expectedToken; @PostMapping - public ApiResponse> consume( + public ApiResponse consume( @RequestHeader(value = "X-Internal-Service-Token", required = false) String token, @RequestBody EventEnvelope event) { if (token == null || expectedToken == null || expectedToken.isBlank() @@ -32,6 +32,6 @@ public class InternalWalletCommandEventController { expectedToken.getBytes(StandardCharsets.UTF_8))) { throw new FinanceEventException("FIN-EVT-401", "内部服务令牌无效"); } - return FinanceResponses.success(Map.of("eventId", service.consume(event))); + return FinanceResponses.success(service.consume(event)); } } diff --git a/finance/src/main/java/com/baiyue/finance/service/BusinessFeeSourceEventService.java b/finance/src/main/java/com/baiyue/finance/service/BusinessFeeSourceEventService.java index 2089ae768..ee71a1255 100644 --- a/finance/src/main/java/com/baiyue/finance/service/BusinessFeeSourceEventService.java +++ b/finance/src/main/java/com/baiyue/finance/service/BusinessFeeSourceEventService.java @@ -2,6 +2,7 @@ package com.baiyue.finance.service; import com.baiyue.common.event.BusinessFeeSourceChangedV1; import com.baiyue.common.event.EventEnvelope; +import com.baiyue.common.event.DeliveryAck; import com.baiyue.common.event.EventPayloadCanonicalizer; import com.baiyue.common.event.EventPayloadHash; import com.baiyue.common.event.EventTypes; @@ -28,7 +29,7 @@ public class BusinessFeeSourceEventService { private final FinanceSupplierValidator supplierValidator; @Transactional - public long consume(EventEnvelope envelope) { + public DeliveryAck consume(EventEnvelope envelope) { try { envelope.requireValid(); } catch (RuntimeException exception) { @@ -51,7 +52,7 @@ public class BusinessFeeSourceEventService { if (!envelope.payloadHash().equals(row.get("payload_hash"))) throw error("FIN-EVT-002", "同一事件ID对应不同费用快照"); if ("PROCESSED".equals(row.get("status")) && row.get("result_resource_id") instanceof Number id) - return id.longValue(); + return DeliveryAck.of(envelope, DeliveryAck.Outcome.DUPLICATE); throw error("FIN-EVT-003", "相同事件正在处理或上次处理失败"); } jdbc.update("INSERT INTO fin_inbox_event(event_id,source_system,event_type,payload,payload_hash,status) VALUES (?,?,?,?,?,'RECEIVED')", @@ -59,7 +60,7 @@ public class BusinessFeeSourceEventService { long result = apply(sourceSystem, source); jdbc.update("UPDATE fin_inbox_event SET status='PROCESSED',result_resource_id=?,processed_at=CURRENT_TIMESTAMP(3),error_code=NULL WHERE source_system=? AND event_id=?", result, sourceSystem, envelope.eventId()); - return result; + return DeliveryAck.of(envelope, DeliveryAck.Outcome.APPLIED); } private long apply(String system, BusinessFeeSourceChangedV1 source) { diff --git a/finance/src/main/java/com/baiyue/finance/service/WalletCommandEventService.java b/finance/src/main/java/com/baiyue/finance/service/WalletCommandEventService.java index 843b3d644..a386fe13e 100644 --- a/finance/src/main/java/com/baiyue/finance/service/WalletCommandEventService.java +++ b/finance/src/main/java/com/baiyue/finance/service/WalletCommandEventService.java @@ -1,6 +1,7 @@ package com.baiyue.finance.service; import com.baiyue.common.event.EventEnvelope; +import com.baiyue.common.event.DeliveryAck; import com.baiyue.common.event.EventEnvelopeValidator; import com.baiyue.common.event.EventIdentifiers; import com.baiyue.common.event.EventPayloadCanonicalizer; @@ -14,10 +15,11 @@ import com.baiyue.finance.dto.FinanceWalletDtos.WalletHold; import com.baiyue.finance.dto.FinanceWalletDtos.WalletHoldActionRequest; import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.ObjectMapper; -import lombok.RequiredArgsConstructor; import org.springframework.dao.DuplicateKeyException; import org.springframework.jdbc.core.JdbcTemplate; import org.springframework.stereotype.Service; +import org.springframework.transaction.PlatformTransactionManager; +import org.springframework.transaction.TransactionDefinition; import org.springframework.transaction.support.TransactionTemplate; import java.math.BigDecimal; @@ -27,35 +29,62 @@ import java.util.Locale; import java.util.Set; @Service -@RequiredArgsConstructor public class WalletCommandEventService { private static final Set OPERATIONS = Set.of("FREEZE", "SETTLE", "RELEASE"); + private static final Set FINAL_REJECTIONS = Set.of( + "FIN-WALLET-INSUFFICIENT", "FIN-WALLET-HOLD-409", "FIN-IDEMPOTENCY-CONFLICT", "FIN-REQ-400"); private final JdbcTemplate jdbc; private final ObjectMapper objectMapper; private final FinanceWalletService walletService; private final TransactionTemplate transactions; - public String consume(EventEnvelope envelope) { + public WalletCommandEventService(JdbcTemplate jdbc, ObjectMapper objectMapper, + FinanceWalletService walletService, PlatformTransactionManager transactionManager) { + this.jdbc = jdbc; + this.objectMapper = objectMapper; + this.walletService = walletService; + this.transactions = new TransactionTemplate(transactionManager); + this.transactions.setPropagationBehavior(TransactionDefinition.PROPAGATION_REQUIRES_NEW); + } + + public DeliveryAck consume(EventEnvelope envelope) { WalletCommandRequestedV1 command = validate(envelope); - String existing = existingHash(envelope.eventId()); - if (existing != null && !existing.equals(envelope.payloadHash())) { + // Keep event identity durable even if processing rolls back. + transactions.executeWithoutResult(status -> reserve(envelope)); + try { + return transactions.execute(status -> { + requireLockedIdentity(envelope); + if (resultExists(envelope.eventId())) return DeliveryAck.of(envelope, DeliveryAck.Outcome.DUPLICATE); + // Wallet methods join this transaction: money, the Inbox outcome and + // the result Outbox commit together, or all roll back. + WalletHold hold = execute(command); + if (hold == null) throw new IllegalStateException("资金操作未返回处理结果"); + finalizeResult(envelope, completed(envelope.eventId(), command, "SUCCEEDED", + hold.status(), hold.amount(), null, null)); + return DeliveryAck.of(envelope, DeliveryAck.Outcome.APPLIED); + }); + } catch (FinanceEventException exception) { + if (!FINAL_REJECTIONS.contains(exception.code())) throw exception; + // A joined wallet transaction may have been marked rollback-only. Persist + // a final business rejection only AFTER that transaction has rolled back. + return transactions.execute(status -> { + requireLockedIdentity(envelope); + if (resultExists(envelope.eventId())) return DeliveryAck.of(envelope, DeliveryAck.Outcome.DUPLICATE); + finalizeResult(envelope, completed(envelope.eventId(), command, "FAILED", + null, command.amount(), exception.code(), exception.getMessage())); + return DeliveryAck.of(envelope, DeliveryAck.Outcome.APPLIED); + }); + } + } + + private void requireLockedIdentity(EventEnvelope envelope) { + List hashes = jdbc.queryForList( + "SELECT payload_hash FROM fin_wallet_command_inbox WHERE event_id=? FOR UPDATE", + String.class, envelope.eventId()); + if (hashes.size() != 1) throw new IllegalStateException("资金指令身份记录不存在"); + if (!hashes.get(0).equals(envelope.payloadHash())) { throw new FinanceEventException("FIN-EVT-002", "同一事件ID对应不同资金指令"); } - if (existing == null) reserve(envelope); - if (resultExists(envelope.eventId())) return envelope.eventId(); - - WalletCommandCompletedV1 result; - try { - WalletHold hold = transactions.execute(status -> execute(command)); - result = completed(envelope.eventId(), command, "SUCCEEDED", hold.status(), hold.amount(), null, null); - } catch (FinanceEventException exception) { - result = completed(envelope.eventId(), command, "FAILED", null, command.amount(), exception.code(), exception.getMessage()); - } catch (RuntimeException exception) { - result = completed(envelope.eventId(), command, "FAILED", null, command.amount(), "FIN-WALLET-COMMAND-FAILED", safe(exception.getMessage())); - } - WalletCommandCompletedV1 finalResult = result; - transactions.executeWithoutResult(status -> finalizeResult(envelope, finalResult)); - return envelope.eventId(); } private WalletHold execute(WalletCommandRequestedV1 command) { @@ -104,7 +133,10 @@ public class WalletCommandEventService { try { jdbc.update("INSERT INTO fin_wallet_command_inbox(event_id,event_type,payload_hash,payload_json,status,received_at) VALUES(?,?,?,CAST(? AS JSON),'PROCESSING',NOW(3))", envelope.eventId(), envelope.eventType(), envelope.payloadHash(), json(envelope)); - } catch (DuplicateKeyException ignored) { } + } catch (DuplicateKeyException duplicate) { + // The durable winner is verified again under SELECT FOR UPDATE before + // any wallet call, including when both requests initially saw no record. + } } private void finalizeResult(EventEnvelope commandEnvelope, WalletCommandCompletedV1 result) { @@ -114,16 +146,12 @@ public class WalletCommandEventService { WalletCommandCompletedV1.VERSION, Instant.now(), RegionCode.CN, RegionCode.OVERSEAS, String.valueOf(result.customerId()), "WALLET_HOLD", result.businessNo(), 1L, null, commandEnvelope.traceId(), commandEnvelope.correlationId(), commandEnvelope.eventId(), payload, payloadHash).requireValid(); - jdbc.update("INSERT IGNORE INTO fin_wallet_event_outbox(event_id,event_key,payload_json,status,next_retry_at,created_at,updated_at) VALUES(?,?,CAST(? AS JSON),'PENDING',NOW(3),NOW(3),NOW(3))", + jdbc.update("INSERT INTO fin_wallet_event_outbox(event_id,event_key,payload_json,status,next_retry_at,created_at,updated_at) VALUES(?,?,CAST(? AS JSON),'PENDING',NOW(3),NOW(3),NOW(3))", completed.eventId(), "WALLET_RESULT:" + commandEnvelope.eventId(), json(completed)); jdbc.update("UPDATE fin_wallet_command_inbox SET status=?,result_json=CAST(? AS JSON),processed_at=NOW(3) WHERE event_id=?", result.result(), json(result), commandEnvelope.eventId()); } - private String existingHash(String eventId) { - List rows = jdbc.queryForList("SELECT payload_hash FROM fin_wallet_command_inbox WHERE event_id=?", String.class, eventId); - return rows.isEmpty() ? null : rows.get(0); - } private boolean resultExists(String eventId) { Integer count = jdbc.queryForObject("SELECT COUNT(*) FROM fin_wallet_event_outbox WHERE event_key=?", Integer.class, "WALLET_RESULT:" + eventId); @@ -139,9 +167,4 @@ public class WalletCommandEventService { catch (Exception exception) { throw new FinanceEventException("FIN-EVT-001", "资金事件序列化失败"); } } private static boolean blank(String value) { return value == null || value.isBlank(); } - private static String safe(String value) { - if (value == null || value.isBlank()) return "资金指令执行失败"; - String normalized = value.replaceAll("[\\r\\n\\t]+", " "); - return normalized.length() <= 500 ? normalized : normalized.substring(0, 500); - } } diff --git a/finance/src/main/java/com/baiyue/finance/service/WalletResultOutboxRelay.java b/finance/src/main/java/com/baiyue/finance/service/WalletResultOutboxRelay.java index 2a57aaed1..2c787ddf8 100644 --- a/finance/src/main/java/com/baiyue/finance/service/WalletResultOutboxRelay.java +++ b/finance/src/main/java/com/baiyue/finance/service/WalletResultOutboxRelay.java @@ -1,7 +1,8 @@ package com.baiyue.finance.service; import com.baiyue.common.event.EventEnvelope; -import com.baiyue.finance.client.WalletResultEventBridgeClient; +import com.baiyue.common.core.retry.BoundedRetryPolicy; +import com.baiyue.finance.client.WalletResultLastlegClient; import com.fasterxml.jackson.databind.ObjectMapper; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; @@ -21,10 +22,13 @@ import java.util.UUID; public class WalletResultOutboxRelay { private final JdbcTemplate jdbc; private final ObjectMapper objectMapper; - private final WalletResultEventBridgeClient client; + private final WalletResultLastlegClient client; @Value("${baiyue.security.internal-service-token}") private String internalToken; - @Value("${baiyue.wallet-event.max-attempts:12}") private int maxAttempts; - private final String worker = UUID.randomUUID().toString(); + @Value("${baiyue.event-delivery.max-attempts:12}") private int maxAttempts = 12; + @Value("${baiyue.event-delivery.base-delay-seconds:5}") private long baseDelaySeconds = 5; + @Value("${baiyue.event-delivery.max-delay-seconds:1800}") private long maxDelaySeconds = 1800; + @Value("${baiyue.event-delivery.jitter:0.1}") private double retryJitter = 0.1; + @Value("${baiyue.event-delivery.lease-seconds:60}") private long leaseSeconds = 60; @Scheduled(fixedDelayString = "${baiyue.wallet-event.fixed-delay-ms:5000}") public void relay() { @@ -33,24 +37,33 @@ public class WalletResultOutboxRelay { } public void deliver(String eventId) { + var policy = retryPolicy(); + String worker = UUID.randomUUID().toString(); LocalDateTime now = LocalDateTime.now(); int claimed = jdbc.update("UPDATE fin_wallet_event_outbox SET status='SENDING',locked_by=?,locked_at=?,updated_at=? WHERE event_id=? AND status='PENDING' AND next_retry_at<=?", worker, now, now, eventId, now); if (claimed != 1) return; Map row = jdbc.queryForMap("SELECT payload_json,retry_count FROM fin_wallet_event_outbox WHERE event_id=?", eventId); + int failures = ((Number) row.get("retry_count")).intValue(); + if (policy.exhausted(failures)) { + jdbc.update("UPDATE fin_wallet_event_outbox SET status='DEAD',locked_by=NULL,locked_at=NULL,last_error=?,updated_at=NOW(3) WHERE event_id=? AND status='SENDING' AND locked_by=?", + "自动投递次数已耗尽(含租约超时)", eventId, worker); + return; + } try { EventEnvelope envelope = objectMapper.readValue(String.valueOf(row.get("payload_json")), EventEnvelope.class).requireValid(); var response = client.receive(internalToken, envelope); - if (response == null || !response.successful() || response.data() == null - || !eventId.equals(response.data().eventId())) throw new IllegalStateException("海外事件桥未确认资金结果事件"); - jdbc.update("UPDATE fin_wallet_event_outbox SET status='PUBLISHED',published_at=NOW(3),locked_by=NULL,locked_at=NULL,last_error=NULL,updated_at=NOW(3) WHERE event_id=? AND locked_by=?", + if (response == null || !response.successful() || response.data() == null) + throw new IllegalStateException("Lastleg 未确认资金结果事件"); + response.data().requireMatches(envelope); + jdbc.update("UPDATE fin_wallet_event_outbox SET status='PUBLISHED',published_at=NOW(3),locked_by=NULL,locked_at=NULL,last_error=NULL,updated_at=NOW(3) WHERE event_id=? AND status='SENDING' AND locked_by=?", eventId, worker); } catch (Exception exception) { - int retries = ((Number) row.get("retry_count")).intValue() + 1; - String status = retries >= maxAttempts ? "DEAD" : "PENDING"; - long delay = Math.min(1800, 5L << Math.min(retries, 8)); - jdbc.update("UPDATE fin_wallet_event_outbox SET status=?,retry_count=?,next_retry_at=?,locked_by=NULL,locked_at=NULL,last_error=?,updated_at=NOW(3) WHERE event_id=? AND locked_by=?", - status, retries, now.plusSeconds(delay), safe(exception.getMessage()), eventId, worker); + int retries = failures + 1; + String status = policy.exhausted(retries) ? "DEAD" : "PENDING"; + LocalDateTime next = policy.exhausted(retries) ? LocalDateTime.now() : LocalDateTime.now().plus(policy.delayAfterFailure(retries)); + jdbc.update("UPDATE fin_wallet_event_outbox SET status=?,retry_count=?,next_retry_at=?,locked_by=NULL,locked_at=NULL,last_error=?,updated_at=NOW(3) WHERE event_id=? AND status='SENDING' AND locked_by=?", + status, retries, next, safe(exception.getMessage()), eventId, worker); log.warn("资金结果事件投递失败 eventId={}, reason={}", eventId, exception.getMessage()); } } @@ -59,7 +72,21 @@ public class WalletResultOutboxRelay { return jdbc.queryForList("SELECT event_id FROM fin_wallet_event_outbox WHERE status='PENDING' AND next_retry_at<=NOW(3) ORDER BY created_at,event_id LIMIT 50", String.class); } private void recoverExpired() { - jdbc.update("UPDATE fin_wallet_event_outbox SET status='PENDING',locked_by=NULL,locked_at=NULL,next_retry_at=NOW(3),updated_at=NOW(3) WHERE status='SENDING' AND locked_at row : jdbc.queryForList("SELECT event_id,locked_by,retry_count FROM fin_wallet_event_outbox WHERE status='SENDING' AND locked_at values = ArgumentCaptor.forClass(Object[].class); verify(jdbc).update(argThat(sql -> sql.startsWith("INSERT INTO fin_fee_source") @@ -97,7 +97,7 @@ class BusinessFeeSourceEventServiceTest { new com.baiyue.finance.client.WarehouseSupplierClient.SupplierSnapshot(71L, "供应商", null, "ACTIVE")); when(jdbc.queryForObject(startsWith("SELECT id FROM fin_fee_source"), eq(Long.class), eq("LASTLEG"), eq("OVERSEAS_CONTAINER_FEE"), eq(701L), eq(1))).thenReturn(901L); - assertThat(service.consume(event)).isEqualTo(901L); + assertThat(service.consume(event).eventId()).isEqualTo(event.eventId()); ArgumentCaptor source = ArgumentCaptor.forClass(Object[].class); verify(jdbc).update(startsWith("INSERT INTO fin_fee_source"), source.capture()); assertThat(source.getValue()[0]).isEqualTo("LASTLEG"); @@ -138,7 +138,7 @@ class BusinessFeeSourceEventServiceTest { new com.baiyue.finance.client.WarehouseSupplierClient.SupplierSnapshot(71L, "供应商", null, "ACTIVE")); when(jdbc.queryForList(startsWith("SELECT payload_hash,status"), eq("LASTLEG"), eq(event.eventId()))) .thenReturn(List.of(Map.of("payload_hash", event.payloadHash(), "status", "PROCESSED", "result_resource_id", 901L))); - assertThat(service.consume(event)).isEqualTo(901L); + assertThat(service.consume(event).eventId()).isEqualTo(event.eventId()); verify(jdbc, never()).update(anyString(), any(Object[].class)); } @@ -189,7 +189,7 @@ class BusinessFeeSourceEventServiceTest { void voidedSourceCannotBeRevivedByPublicationReplay() { var event = payableEvent("PUBLISHED", 1); existingVersion(event.payload().get("sourceHash").asText(), "VOIDED"); - assertThat(service.consume(event)).isEqualTo(901L); + assertThat(service.consume(event).eventId()).isEqualTo(event.eventId()); noFinancialWrites(); } @@ -197,7 +197,7 @@ class BusinessFeeSourceEventServiceTest { void voidTargetsOnlyMatchingOverseasCostVersion() { var event = payableEvent("VOIDED", 1); existingVersion(event.payload().get("sourceHash").asText(), "AVAILABLE"); - assertThat(service.consume(event)).isEqualTo(901L); + assertThat(service.consume(event).eventId()).isEqualTo(event.eventId()); verify(jdbc).update(startsWith("UPDATE fin_cost SET status='VOIDED'"), eq("LASTLEG"), eq("OVERSEAS_CONTAINER_FEE"), eq(701L), eq(1)); verify(jdbc, never()).update(startsWith("INSERT INTO fin_cost"), any(Object[].class)); diff --git a/finance/src/test/java/com/baiyue/finance/WalletCommandEventServiceTest.java b/finance/src/test/java/com/baiyue/finance/WalletCommandEventServiceTest.java index 42481247f..05129261d 100644 --- a/finance/src/test/java/com/baiyue/finance/WalletCommandEventServiceTest.java +++ b/finance/src/test/java/com/baiyue/finance/WalletCommandEventServiceTest.java @@ -16,14 +16,12 @@ import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.springframework.jdbc.core.JdbcTemplate; import org.springframework.transaction.TransactionStatus; -import org.springframework.transaction.support.TransactionCallback; -import org.springframework.transaction.support.TransactionTemplate; +import org.springframework.transaction.PlatformTransactionManager; import java.math.BigDecimal; import java.time.Instant; import java.util.List; import java.util.UUID; -import java.util.function.Consumer; import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatThrownBy; @@ -34,39 +32,31 @@ class WalletCommandEventServiceTest { private final JdbcTemplate jdbc = mock(JdbcTemplate.class); private final ObjectMapper mapper = new ObjectMapper().findAndRegisterModules(); private final FinanceWalletService wallet = mock(FinanceWalletService.class); - private final TransactionTemplate transactions = mock(TransactionTemplate.class); + private final PlatformTransactionManager transactions = mock(PlatformTransactionManager.class); private final WalletCommandEventService service = new WalletCommandEventService(jdbc, mapper, wallet, transactions); @BeforeEach void executeTransactionCallbacks() { - when(transactions.execute(any())).thenAnswer(invocation -> { - TransactionCallback callback = invocation.getArgument(0); - return callback.doInTransaction(mock(TransactionStatus.class)); - }); - doAnswer(invocation -> { - Consumer callback = invocation.getArgument(0); - callback.accept(mock(TransactionStatus.class)); - return null; - }).when(transactions).executeWithoutResult(any()); + when(transactions.getTransaction(any())).thenAnswer(invocation -> mock(TransactionStatus.class)); } @Test void freezesWalletAndPublishesCompletedEvent() { EventEnvelope envelope = envelope("FREEZE", 31L, new BigDecimal("88.50")); when(jdbc.queryForList(startsWith("SELECT payload_hash"), eq(String.class), eq(envelope.eventId()))) - .thenReturn(List.of()); + .thenReturn(List.of(envelope.payloadHash())); when(jdbc.queryForObject(startsWith("SELECT COUNT(*)"), eq(Integer.class), anyString())).thenReturn(0); when(wallet.freeze(eq(31L), eq("freeze-procurement-1"), any())).thenReturn( new WalletHold(9L, 31L, "OVERSEAS_PROCUREMENT_FEE", "PO-1", new BigDecimal("88.50"), new BigDecimal("80.00"), new BigDecimal("8.50"), "FROZEN", 71L, null, null, null, null)); - assertThat(service.consume(envelope)).isEqualTo(envelope.eventId()); + assertThat(service.consume(envelope).eventId()).isEqualTo(envelope.eventId()); verify(wallet).freeze(eq(31L), eq("freeze-procurement-1"), argThat(request -> request.amount().compareTo(new BigDecimal("88.50")) == 0 && request.businessNo().equals("PO-1"))); - verify(jdbc).update(startsWith("INSERT IGNORE INTO fin_wallet_event_outbox"), any(Object[].class)); + verify(jdbc).update(startsWith("INSERT INTO fin_wallet_event_outbox"), any(Object[].class)); verify(jdbc).update(startsWith("UPDATE fin_wallet_command_inbox SET status="), eq("SUCCEEDED"), contains("\"holdStatus\":\"FROZEN\""), eq(envelope.eventId())); } @@ -75,7 +65,7 @@ class WalletCommandEventServiceTest { void settledWalletHoldMarksMatchingReceivableSourceSettled() { EventEnvelope envelope = envelope("SETTLE", 31L, new BigDecimal("88.50")); when(jdbc.queryForList(startsWith("SELECT payload_hash"), eq(String.class), eq(envelope.eventId()))) - .thenReturn(List.of()); + .thenReturn(List.of(envelope.payloadHash())); when(jdbc.queryForObject(startsWith("SELECT COUNT(*)"), eq(Integer.class), anyString())).thenReturn(0); when(wallet.settle(eq(31L), eq("OVERSEAS_PROCUREMENT_FEE"), eq("PO-1"), eq("freeze-procurement-1"), any())).thenReturn(new WalletHold(9L, 31L, @@ -87,6 +77,35 @@ class WalletCommandEventServiceTest { verify(jdbc).update(startsWith("UPDATE fin_fee_source f JOIN fin_wallet_hold"), eq(9L)); } + @Test + void transientWalletFailureMustNotBecomeAFinalResult() { + EventEnvelope event = envelope("FREEZE", 31L, new BigDecimal("10.00")); + when(jdbc.queryForList(startsWith("SELECT payload_hash"), eq(String.class), anyString())) + .thenReturn(List.of(event.payloadHash())); + when(jdbc.queryForObject(startsWith("SELECT COUNT(*)"), eq(Integer.class), anyString())).thenReturn(0); + var failure = new org.springframework.dao.TransientDataAccessResourceException("temporary database failure"); + when(wallet.freeze(anyLong(), anyString(), any())).thenThrow(failure); + + assertThatThrownBy(() -> service.consume(event)).isSameAs(failure); + verify(jdbc, never()).update(startsWith("INSERT INTO fin_wallet_event_outbox"), any(Object[].class)); + } + + @Test + void losingInboxInsertMustRecheckThePersistedHash() { + EventEnvelope event = envelope("FREEZE", 31L, new BigDecimal("10.00")); + when(jdbc.queryForList(startsWith("SELECT payload_hash"), eq(String.class), anyString())) + .thenAnswer(invocation -> ((String) invocation.getArgument(0)).contains("FOR UPDATE") + ? List.of("a-different-persisted-hash") : List.of()); + when(jdbc.update(startsWith("INSERT INTO fin_wallet_command_inbox"), any(Object[].class))) + .thenThrow(new org.springframework.dao.DuplicateKeyException("another request won")); + when(jdbc.queryForObject(startsWith("SELECT COUNT(*)"), eq(Integer.class), anyString())).thenReturn(0); + + assertThatThrownBy(() -> service.consume(event)) + .isInstanceOf(FinanceEventException.class) + .extracting(error -> ((FinanceEventException) error).code()).isEqualTo("FIN-EVT-002"); + verifyNoInteractions(wallet); + } + @Test void rejectsCustomerMismatchBeforeTouchingWallet() { EventEnvelope envelope = envelope("FREEZE", 31L, new BigDecimal("10.00")); diff --git a/finance/src/test/java/com/baiyue/finance/WalletCommandTransactionTest.java b/finance/src/test/java/com/baiyue/finance/WalletCommandTransactionTest.java new file mode 100644 index 000000000..bd88b0dbd --- /dev/null +++ b/finance/src/test/java/com/baiyue/finance/WalletCommandTransactionTest.java @@ -0,0 +1,205 @@ +package com.baiyue.finance; + +import com.baiyue.common.event.*; +import com.baiyue.finance.dto.FinanceWalletDtos.WalletHold; +import com.baiyue.finance.service.FinanceEventException; +import com.baiyue.finance.service.FinanceWalletService; +import com.baiyue.finance.service.WalletCommandEventService; +import com.fasterxml.jackson.databind.ObjectMapper; +import org.h2.jdbcx.JdbcDataSource; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.dao.TransientDataAccessResourceException; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.jdbc.datasource.DataSourceTransactionManager; + +import java.math.BigDecimal; +import java.time.Instant; +import java.util.UUID; +import java.util.concurrent.*; +import java.util.concurrent.atomic.AtomicBoolean; + +import static org.assertj.core.api.Assertions.*; +import static org.mockito.ArgumentMatchers.*; +import static org.mockito.Mockito.*; + +/** Real JDBC transactions and row locks; the wallet boundary is a durable idempotent test double. */ +class WalletCommandTransactionTest { + private JdbcTemplate jdbc; + private FinanceWalletService wallet; + private WalletCommandEventService service; + private final ObjectMapper mapper = new ObjectMapper().findAndRegisterModules(); + + @BeforeEach + void setup() { + JdbcDataSource source = new JdbcDataSource(); + source.setURL("jdbc:h2:mem:" + UUID.randomUUID() + ";MODE=MySQL;DB_CLOSE_DELAY=-1;LOCK_TIMEOUT=5000"); + jdbc = spy(new JdbcTemplate(source)); + jdbc.execute("CREATE TABLE fin_wallet_command_inbox(event_id VARCHAR(128) PRIMARY KEY,event_type VARCHAR(100),payload_hash VARCHAR(128),payload_json JSON,status VARCHAR(32),received_at TIMESTAMP,result_json JSON,processed_at TIMESTAMP)"); + jdbc.execute("CREATE TABLE fin_wallet_event_outbox(event_id VARCHAR(128) PRIMARY KEY,event_key VARCHAR(200) UNIQUE,payload_json JSON,status VARCHAR(32),next_retry_at TIMESTAMP,created_at TIMESTAMP,updated_at TIMESTAMP)"); + jdbc.execute("CREATE TABLE wallet_effect(idempotency_key VARCHAR(128) PRIMARY KEY,amount DECIMAL(18,2))"); + wallet = mock(FinanceWalletService.class); + service = new WalletCommandEventService(jdbc, mapper, wallet, new DataSourceTransactionManager(source)); + doAnswer(invocation -> { + String key = invocation.getArgument(1); + // Models the existing FinanceWalletService's durable replay behavior. + if (jdbc.queryForObject("SELECT COUNT(*) FROM wallet_effect WHERE idempotency_key=?", Integer.class, key) == 0) { + jdbc.update("INSERT INTO wallet_effect VALUES(?,?)", key, new BigDecimal("10.00")); + } + return hold(); + }).when(wallet).freeze(anyLong(), anyString(), any()); + } + + @Test + void transientFailureRollsBackMoneyAndCanRetryTheOriginalEvent() { + EventEnvelope event = event(UUID.randomUUID().toString(), "10.00"); + AtomicBoolean first = new AtomicBoolean(true); + doAnswer(invocation -> { + jdbc.update("INSERT INTO wallet_effect VALUES('freeze-1',10.00)"); + if (first.getAndSet(false)) throw new TransientDataAccessResourceException("temporary outage"); + return hold(); + }).when(wallet).freeze(anyLong(), anyString(), any()); + + assertThatThrownBy(() -> service.consume(event)).isInstanceOf(TransientDataAccessResourceException.class); + assertThat(count("wallet_effect")).isZero(); + assertThat(count("fin_wallet_event_outbox")).isZero(); + assertThat(inboxStatus(event)).isEqualTo("PROCESSING"); + assertThat(service.consume(event).eventId()).isEqualTo(event.eventId()); + assertThat(count("wallet_effect")).isEqualTo(1); + assertThat(count("fin_wallet_event_outbox")).isEqualTo(1); + assertThat(inboxStatus(event)).isEqualTo("SUCCEEDED"); + } + + @Test + void businessRejectionRollsBackMoneyAndPublishesExactlyOneFailedResult() { + EventEnvelope event = event(UUID.randomUUID().toString(), "10.00"); + doAnswer(invocation -> { + jdbc.update("INSERT INTO wallet_effect VALUES('freeze-1',10.00)"); + throw new FinanceEventException("FIN-WALLET-INSUFFICIENT", "insufficient balance"); + }).when(wallet).freeze(anyLong(), anyString(), any()); + service.consume(event); + service.consume(event); + assertThat(count("wallet_effect")).isZero(); + assertThat(count("fin_wallet_event_outbox")).isEqualTo(1); + assertThat(inboxStatus(event)).isEqualTo("FAILED"); + verify(wallet, times(1)).freeze(anyLong(), anyString(), any()); + } + + @Test + void retryableFinanceCodeDoesNotPublishATerminalResult() { + EventEnvelope event = event(UUID.randomUUID().toString(), "10.00"); + doThrow(new FinanceEventException("FIN-IDEMPOTENCY-PROCESSING", "in progress")) + .when(wallet).freeze(anyLong(), anyString(), any()); + assertThatThrownBy(() -> service.consume(event)).isInstanceOf(FinanceEventException.class); + assertThat(inboxStatus(event)).isEqualTo("PROCESSING"); + assertThat(count("fin_wallet_event_outbox")).isZero(); + } + + @Test + void failureAfterResultInsertRollsBackMoneyAndResultAndCanRetry() { + EventEnvelope event = event(UUID.randomUUID().toString(), "10.00"); + AtomicBoolean first = new AtomicBoolean(true); + doAnswer(invocation -> { + if (first.getAndSet(false)) throw new TransientDataAccessResourceException("result commit failed"); + return invocation.callRealMethod(); + }).when(jdbc).update(startsWith("UPDATE fin_wallet_command_inbox SET status="), any(Object[].class)); + + assertThatThrownBy(() -> service.consume(event)).isInstanceOf(TransientDataAccessResourceException.class); + assertThat(count("wallet_effect")).isZero(); + assertThat(count("fin_wallet_event_outbox")).isZero(); + assertThat(inboxStatus(event)).isEqualTo("PROCESSING"); + assertThat(service.consume(event).eventId()).isEqualTo(event.eventId()); + assertThat(count("wallet_effect")).isEqualTo(1); + assertThat(count("fin_wallet_event_outbox")).isEqualTo(1); + assertThat(inboxStatus(event)).isEqualTo("SUCCEEDED"); + verify(wallet, times(2)).freeze(eq(31L), eq("freeze-1"), any()); + } + + @Test + void differentPayloadCannotReplaceIdentityAfterAResultFailure() { + EventEnvelope event = event(UUID.randomUUID().toString(), "10.00"); + doThrow(new TransientDataAccessResourceException("outbox unavailable")) + .when(jdbc).update(startsWith("INSERT INTO fin_wallet_event_outbox"), any(Object[].class)); + assertThatThrownBy(() -> service.consume(event)).isInstanceOf(TransientDataAccessResourceException.class); + assertThatThrownBy(() -> service.consume(event(event.eventId(), "20.00"))) + .isInstanceOf(FinanceEventException.class) + .extracting(e -> ((FinanceEventException) e).code()).isEqualTo("FIN-EVT-002"); + assertThat(count("wallet_effect")).isZero(); + verify(wallet, times(1)).freeze(anyLong(), anyString(), any()); + } + + @Test + void concurrentSamePayloadExecutesOnce() throws Exception { + runConcurrent(false); + } + + @Test + void concurrentDifferentPayloadIsRejectedBeforeWalletExecution() throws Exception { + runConcurrent(true); + } + + private void runConcurrent(boolean differentPayload) throws Exception { + EventEnvelope firstEvent = event(UUID.randomUUID().toString(), "10.00"); + EventEnvelope secondEvent = differentPayload ? event(firstEvent.eventId(), "20.00") : firstEvent; + CountDownLatch enteredWallet = new CountDownLatch(1); + CountDownLatch releaseWallet = new CountDownLatch(1); + CountDownLatch secondStarted = new CountDownLatch(1); + doAnswer(invocation -> { + enteredWallet.countDown(); + if (!releaseWallet.await(5, TimeUnit.SECONDS)) throw new AssertionError("test timed out"); + jdbc.update("INSERT INTO wallet_effect VALUES('freeze-1',10.00)"); + return hold(); + }).when(wallet).freeze(anyLong(), anyString(), any()); + ExecutorService executor = Executors.newFixedThreadPool(2); + try { + Future first = executor.submit(() -> service.consume(firstEvent)); + assertThat(enteredWallet.await(5, TimeUnit.SECONDS)).isTrue(); + Future second = executor.submit(() -> { + secondStarted.countDown(); + return service.consume(secondEvent); + }); + assertThat(secondStarted.await(5, TimeUnit.SECONDS)).isTrue(); + assertThatThrownBy(() -> second.get(150, TimeUnit.MILLISECONDS)).isInstanceOf(TimeoutException.class); + releaseWallet.countDown(); + assertThat(first.get(5, TimeUnit.SECONDS).eventId()).isEqualTo(firstEvent.eventId()); + if (differentPayload) { + assertThatThrownBy(() -> second.get(5, TimeUnit.SECONDS)) + .isInstanceOf(ExecutionException.class).hasCauseInstanceOf(FinanceEventException.class) + .satisfies(e -> assertThat(((FinanceEventException) e.getCause()).code()).isEqualTo("FIN-EVT-002")); + } else { + assertThat(second.get(5, TimeUnit.SECONDS).eventId()).isEqualTo(firstEvent.eventId()); + } + verify(wallet, times(1)).freeze(anyLong(), anyString(), any()); + assertThat(count("wallet_effect")).isEqualTo(1); + assertThat(count("fin_wallet_event_outbox")).isEqualTo(1); + assertThat(inboxStatus(firstEvent)).isEqualTo("SUCCEEDED"); + } finally { + releaseWallet.countDown(); + executor.shutdownNow(); + assertThat(executor.awaitTermination(5, TimeUnit.SECONDS)).isTrue(); + } + } + + private int count(String table) { + return jdbc.queryForObject("SELECT COUNT(*) FROM " + table, Integer.class); + } + + private String inboxStatus(EventEnvelope event) { + return jdbc.queryForObject("SELECT status FROM fin_wallet_command_inbox WHERE event_id=?", String.class, event.eventId()); + } + + private EventEnvelope event(String id, String amount) { + var command = new WalletCommandRequestedV1("FREEZE", 31L, "OVERSEAS_PROCUREMENT_FEE", "PO-1", + new BigDecimal(amount), "fee", "freeze-1", Instant.parse("2026-09-05T01:00:00Z")); + var payload = mapper.valueToTree(command); + return new EventEnvelope(id, EventTypes.WALLET_COMMAND_REQUESTED, WalletCommandRequestedV1.VERSION, + Instant.now(), RegionCode.OVERSEAS, RegionCode.CN, "31", "WALLET_HOLD", "PO-1", 1, + null, UUID.randomUUID().toString(), UUID.randomUUID().toString(), null, payload, + EventPayloadHash.sha256(EventPayloadCanonicalizer.canonicalize(payload))); + } + + private WalletHold hold() { + return new WalletHold(9L, 31L, "OVERSEAS_PROCUREMENT_FEE", "PO-1", new BigDecimal("10.00"), + new BigDecimal("10.00"), BigDecimal.ZERO, "FROZEN", 71L, null, null, null, null); + } +} diff --git a/finance/src/test/java/com/baiyue/finance/WalletResultOutboxLeaseTest.java b/finance/src/test/java/com/baiyue/finance/WalletResultOutboxLeaseTest.java new file mode 100644 index 000000000..acb6c036e --- /dev/null +++ b/finance/src/test/java/com/baiyue/finance/WalletResultOutboxLeaseTest.java @@ -0,0 +1,75 @@ +package com.baiyue.finance; + +import com.baiyue.common.core.api.ApiResponse; +import com.baiyue.common.event.*; +import com.baiyue.finance.client.WalletResultLastlegClient; +import com.baiyue.finance.service.WalletResultOutboxRelay; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule; +import org.junit.jupiter.api.Test; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.jdbc.datasource.DriverManagerDataSource; +import org.springframework.test.util.ReflectionTestUtils; +import java.time.Instant; +import java.util.UUID; +import java.util.concurrent.*; +import java.util.concurrent.atomic.AtomicInteger; +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.*; +import static org.mockito.Mockito.*; + +class WalletResultOutboxLeaseTest { + @Test void expiredAttemptCannotOverwriteNewAttemptInSameRelayInstance() throws Exception { + var jdbc = new JdbcTemplate(new DriverManagerDataSource( + "jdbc:h2:mem:" + UUID.randomUUID() + ";MODE=MySQL;DB_CLOSE_DELAY=-1", "sa", "")); + jdbc.execute("CREATE TABLE fin_wallet_event_outbox(event_id VARCHAR(64) PRIMARY KEY, payload_json TEXT, status VARCHAR(32), retry_count INT DEFAULT 0, next_retry_at TIMESTAMP NOT NULL, locked_by VARCHAR(128), locked_at TIMESTAMP, updated_at TIMESTAMP, published_at TIMESTAMP, last_error VARCHAR(1000))"); + var mapper = new ObjectMapper().registerModule(new JavaTimeModule()); + var payload = mapper.createObjectNode().put("result", "SUCCEEDED"); + var event = new EventEnvelope(UUID.randomUUID().toString(), EventTypes.WALLET_COMMAND_COMPLETED, 1, + Instant.now(), RegionCode.CN, RegionCode.OVERSEAS, "1", "WALLET_HOLD", "ORDER-1", 1L, null, + "trace", "correlation", null, payload, EventPayloadHash.sha256(EventPayloadCanonicalizer.canonicalize(payload))); + jdbc.update("INSERT INTO fin_wallet_event_outbox(event_id,payload_json,status,next_retry_at) VALUES(?,?,'PENDING',DATEADD('SECOND',-5,CURRENT_TIMESTAMP))", + event.eventId(), mapper.writeValueAsString(event)); + var client = mock(WalletResultLastlegClient.class); + var relay = new WalletResultOutboxRelay(jdbc, mapper, client); + ReflectionTestUtils.setField(relay, "internalToken", "token"); + ReflectionTestUtils.setField(relay, "maxAttempts", 12); + var firstEntered = new CountDownLatch(1); + var secondEntered = new CountDownLatch(1); + var releaseFirst = new CountDownLatch(1); + var releaseSecond = new CountDownLatch(1); + var calls = new AtomicInteger(); + when(client.receive(eq("token"), any())).thenAnswer(invocation -> { + if (calls.incrementAndGet() == 1) { + firstEntered.countDown(); + if (!releaseFirst.await(10, TimeUnit.SECONDS)) throw new IllegalStateException("test timeout"); + throw new IllegalStateException("late failure from expired request"); + } + secondEntered.countDown(); + if (!releaseSecond.await(10, TimeUnit.SECONDS)) throw new IllegalStateException("test timeout"); + return ApiResponse.success(DeliveryAck.of(event, DeliveryAck.Outcome.APPLIED)); + }); + ExecutorService pool = Executors.newFixedThreadPool(2); + try { + Future first = pool.submit(() -> relay.deliver(event.eventId())); + assertThat(firstEntered.await(10, TimeUnit.SECONDS)).isTrue(); + String oldOwner = jdbc.queryForObject("SELECT locked_by FROM fin_wallet_event_outbox", String.class); + // Simulate lease recovery while the original HTTP request has not returned. + jdbc.update("UPDATE fin_wallet_event_outbox SET status='PENDING',locked_by=NULL,locked_at=NULL"); + Future second = pool.submit(() -> relay.deliver(event.eventId())); + assertThat(secondEntered.await(10, TimeUnit.SECONDS)).isTrue(); + String newOwner = jdbc.queryForObject("SELECT locked_by FROM fin_wallet_event_outbox", String.class); + assertThat(newOwner).isNotEqualTo(oldOwner); + releaseFirst.countDown(); + first.get(10, TimeUnit.SECONDS); + assertThat(jdbc.queryForObject("SELECT status FROM fin_wallet_event_outbox", String.class)).isEqualTo("SENDING"); + assertThat(jdbc.queryForObject("SELECT locked_by FROM fin_wallet_event_outbox", String.class)).isEqualTo(newOwner); + assertThat(jdbc.queryForObject("SELECT retry_count FROM fin_wallet_event_outbox", Integer.class)).isZero(); + releaseSecond.countDown(); + second.get(10, TimeUnit.SECONDS); + assertThat(jdbc.queryForObject("SELECT status FROM fin_wallet_event_outbox", String.class)).isEqualTo("PUBLISHED"); + } finally { + releaseFirst.countDown(); releaseSecond.countDown(); pool.shutdownNow(); + } + } +} diff --git a/finance/src/test/java/com/baiyue/finance/WalletResultRetryTest.java b/finance/src/test/java/com/baiyue/finance/WalletResultRetryTest.java new file mode 100644 index 000000000..dfa0b1046 --- /dev/null +++ b/finance/src/test/java/com/baiyue/finance/WalletResultRetryTest.java @@ -0,0 +1,86 @@ +package com.baiyue.finance; + +import com.baiyue.common.event.*; +import com.baiyue.finance.client.WalletResultLastlegClient; +import com.baiyue.finance.service.WalletResultOutboxRelay; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.jdbc.datasource.DriverManagerDataSource; +import org.springframework.test.util.ReflectionTestUtils; +import java.time.Instant; +import java.time.LocalDateTime; +import java.util.UUID; +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.*; +import static org.mockito.Mockito.*; + +class WalletResultRetryTest { + JdbcTemplate jdbc; + WalletResultLastlegClient client; + WalletResultOutboxRelay relay; + String eventId; + String originalJson; + + @BeforeEach void setup() throws Exception { + jdbc = new JdbcTemplate(new DriverManagerDataSource("jdbc:h2:mem:" + UUID.randomUUID() + + ";MODE=MySQL;DB_CLOSE_DELAY=-1", "sa", "")); + jdbc.execute("CREATE TABLE fin_wallet_event_outbox(event_id VARCHAR(64) PRIMARY KEY, payload_json TEXT, status VARCHAR(32), retry_count INT DEFAULT 0, next_retry_at TIMESTAMP NOT NULL, locked_by VARCHAR(128), locked_at TIMESTAMP, updated_at TIMESTAMP, published_at TIMESTAMP, created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, last_error VARCHAR(1000))"); + var mapper = new ObjectMapper().registerModule(new JavaTimeModule()); + var payload = mapper.createObjectNode().put("result", "SUCCEEDED"); + eventId = UUID.randomUUID().toString(); + var event = new EventEnvelope(eventId, EventTypes.WALLET_COMMAND_COMPLETED, 1, Instant.now(), + RegionCode.CN, RegionCode.OVERSEAS, "1", "WALLET_HOLD", "ORDER-1", 1L, null, + "trace", "correlation", null, payload, EventPayloadHash.sha256(EventPayloadCanonicalizer.canonicalize(payload))); + originalJson = mapper.writeValueAsString(event); + jdbc.update("INSERT INTO fin_wallet_event_outbox(event_id,payload_json,status,next_retry_at) VALUES(?,?,'PENDING',?)", + eventId, originalJson, LocalDateTime.now().minusSeconds(1)); + client = mock(WalletResultLastlegClient.class); + relay = new WalletResultOutboxRelay(jdbc, mapper, client); + ReflectionTestUtils.setField(relay, "internalToken", "token"); + ReflectionTestUtils.setField(relay, "maxAttempts", 3); + ReflectionTestUtils.setField(relay, "retryJitter", 0.0); + } + + @Test void failedDeliveryBacksOffThenStopsAtConfiguredLimitWithoutChangingPayload() { + when(client.receive(anyString(), any())).thenThrow(new IllegalStateException("service unavailable")); + LocalDateTime before = LocalDateTime.now(); + relay.deliver(eventId); + LocalDateTime next = jdbc.queryForObject("SELECT next_retry_at FROM fin_wallet_event_outbox", LocalDateTime.class); + assertThat(next).isAfterOrEqualTo(before.plusSeconds(5)).isBefore(LocalDateTime.now().plusSeconds(6)); + assertThat(status()).isEqualTo("PENDING"); + for (int i = 0; i < 2; i++) { + jdbc.update("UPDATE fin_wallet_event_outbox SET next_retry_at=?", LocalDateTime.now().minusSeconds(1)); + relay.deliver(eventId); + } + assertThat(status()).isEqualTo("DEAD"); + assertThat(jdbc.queryForObject("SELECT retry_count FROM fin_wallet_event_outbox", Integer.class)).isEqualTo(3); + assertThat(jdbc.queryForObject("SELECT locked_by FROM fin_wallet_event_outbox", String.class)).isNull(); + assertThat(jdbc.queryForObject("SELECT payload_json FROM fin_wallet_event_outbox", String.class)).isEqualTo(originalJson); + relay.deliver(eventId); + verify(client, times(3)).receive(anyString(), any()); + } + + @Test void expiredLastAttemptGoesDeadWithoutAnotherHttpRequest() { + jdbc.update("UPDATE fin_wallet_event_outbox SET status='SENDING',retry_count=2,locked_by='expired',locked_at=?", + LocalDateTime.now().minusSeconds(61)); + relay.relay(); + assertThat(status()).isEqualTo("DEAD"); + assertThat(jdbc.queryForObject("SELECT retry_count FROM fin_wallet_event_outbox", Integer.class)).isEqualTo(3); + verifyNoInteractions(client); + } + + @Test void expiredLeaseCountsAsFailureAndWaitsBeforeRetrying() { + jdbc.update("UPDATE fin_wallet_event_outbox SET status='SENDING',locked_by='expired',locked_at=?", + LocalDateTime.now().minusSeconds(61)); + relay.relay(); + assertThat(status()).isEqualTo("PENDING"); + assertThat(jdbc.queryForObject("SELECT retry_count FROM fin_wallet_event_outbox", Integer.class)).isEqualTo(1); + assertThat(jdbc.queryForObject("SELECT next_retry_at FROM fin_wallet_event_outbox", LocalDateTime.class)).isAfter(LocalDateTime.now()); + verifyNoInteractions(client); + } + + private String status() { return jdbc.queryForObject("SELECT status FROM fin_wallet_event_outbox", String.class); } +} diff --git a/firstleg/AGENTS.md b/firstleg/AGENTS.md index 691ae867a..2239efda4 100644 --- a/firstleg/AGENTS.md +++ b/firstleg/AGENTS.md @@ -41,7 +41,7 @@ | 货柜与全局智能排柜 | `controller/PlatformContainerController.java`、`PlatformSmartLoadingController.java`、`service/SmartLoadingService.java`、`repository/SmartLoadingRepository.java`、`client/PendingReviewOrderClient.java`、`loading/`;全局候选合并本服务履约投影与 Order `/internal/order/v1/pending-review-orders` 权威待审核页,工作台柜次从 `/inbound-candidates`、`/containers` 进入;普通箱数、待审核占位、同柜组和全部未拣移除分别走 `/inbound-assignments`、`/pending-review-assignments`、`/group-assignments`、`/unpicked-items`,明细规格拆分留在服务端 | | 货柜资料、PDA 证据与业务总览 | `controller/PlatformContainerController.java` 的 `/{id}/overview`、`/{id}/documents/{documentType}`,聚合 `mapper/ContainerOverviewMapper.java`;契约 `src/main/resources/openapi/phase3-container-overview-v1.yaml`,四类资料固定为目的港清关、国内出口报关、DO、PU 的当前唯一版本 | | PDA 拣货与装柜、证据幂等上传 | `controller/PlatformLoadingPdaController.java`、`loading/controller/PlatformLoadingDeliveryController.java`、`loading/service/CabinetLoadingEvidenceService.java`;证据写入和 multipart 上传均先以任务内幂等键匹配请求哈希,上传哈希覆盖仓库、版本、证据类型、锁码、备注、文件名、媒体类型、大小与文件内容;完全重放返回当前视图且不得再次上传 File,相同键不同内容返回冲突 | -| 装柜完成跨区事件 | `service/LoadingCompletedOutboxService.java`、`LoadingCompletedOutboxRelay.java`、`repository/LoadingCompletedOutboxRepository.java`、`mapper/LoadingCartonManifestMapper.java`、`loading/mapper/CabinetLoadingEvidenceMapper.java`、`client/EventBridgeClient.java`;当前发布 V3,从 `fl_inbound_order.destination_type` 冻结 `orders[].destinationType`(`FBA` 或 `OVERSEAS`),OVERSEAS 按最终实装与稳定箱码冻结,FBA 可仅冻结箱数,并冻结物理柜五类有效装柜照片引用;真实生产验收 `src/test/java/com/baiyue/firstleg/service/TaillegRealProducerAcceptanceTest.java`;事件模型见 `../common-event-contract/AGENTS.md` | +| 装柜完成直连事件 | `service/LoadingCompletedOutboxService.java`、`LoadingCompletedOutboxRelay.java`、`repository/LoadingCompletedOutboxRepository.java`、`mapper/LoadingCartonManifestMapper.java`、`loading/mapper/CabinetLoadingEvidenceMapper.java`、`client/LastlegEventClient.java`;当前发布 V3,从 `fl_inbound_order.destination_type` 冻结 `orders[].destinationType`(`FBA` 或 `OVERSEAS`),OVERSEAS 按最终实装与稳定箱码冻结,FBA 可仅冻结箱数,并冻结物理柜五类有效装柜照片引用;真实生产验收 `src/test/java/com/baiyue/firstleg/service/TaillegRealProducerAcceptanceTest.java`;事件模型见 `../common-event-contract/AGENTS.md` | | 海外卸柜完成事件回写 | 内部入口 `controller/InternalOverseasContainerEventController.java`、消费者 `service/OverseasContainerEventConsumer.java`、Inbox `entity/ContainerEventInboxEntity.java` 与 `mapper/ContainerEventInboxMapper.java`;事件模型见 `../common-event-contract/AGENTS.md` | | 海外暂扣状态回写 | 同一内部事件入口、消费者 `service/OverseasInboundOrderDetainedConsumer.java`;按来源入仓单 ID 更新 `fl_inbound_order.hold_flag/current_hold_reason`,复用跨区 Inbox 并写 `fl_inbound_hold_record` | | 海外派送完成回写 | 同一内部事件入口、消费者 `service/OverseasDeliveryCompletedConsumer.java`;按来源入仓单、订单、入仓码和 FBA 仓校验后推进当前入仓单 `LOADED→DELIVERED`,同一 Order 全部有效入仓单完成后才经 `OrderFulfillmentCompletionClient` 推进 Order `COMPLETED` | @@ -54,7 +54,7 @@ | 整柜出口报关 | `customs/container/controller/PlatformContainerCustomsController.java`、`customs/container/controller/MerchantContainerCustomsController.java`、`customs/container/service/ContainerCustomsService.java`、`customs/container/mapper/ContainerCustomsMapper.java`;装柜完成后按柜收集退税资料,多对多覆盖入仓码,开始报关冻结,完成文件逐资料一对一回传,全买单柜由平台确认并通过导出中心取明细;正式申报批次与海关结果在同一模块独立记录,不改报关四态、PDA 或船期事实;契约 `src/main/resources/openapi/customs-v1.yaml`,基线 `src/main/resources/db/phase3-container-customs-schema.sql`;消费者为 Admin `../../apps/admin-web/src/modules/container-loading/components/ContainerCustomsPanel.vue` 和 Client `../../apps/client-web/src/modules/first-leg/components/CustomsDocumentsDialog.vue` | | 买单订单明细导出 Provider | `customs/container/controller/LoadingListBuyDeclarationExportController.java`、`service/LoadingListBuyDeclarationExportService.java`;只为 File 导出中心提供 `LOADING_LIST_BUY_DECLARATION_ITEMS` 的已完成装柜实际买单行,不在 Firstleg 生成文件 | | 跨模型桥接 | `loading/bridge/controller/PlatformLoadingModelBridgeController.java` | -| 跨服务客户端 | `client/OrderCargoClient.java`、`WarehousePalletLocationClient.java`、`MeasurementImageClient.java`、`LoadingEvidenceFileClient.java`、`EventBridgeClient.java` | +| 跨服务客户端 | `client/OrderCargoClient.java`、`WarehousePalletLocationClient.java`、`MeasurementImageClient.java`、`LoadingEvidenceFileClient.java`、`LastlegEventClient.java` | | 仓库公共供应商快照 | `client/WarehouseSupplierClient.java`、`service/WarehouseSupplierSnapshotService.java`;报关、订舱和拖车分别消费 `customs-v1.yaml`、`ocean-bookings-v1.yaml`、`truck-appointments-v1.yaml` 中的供应商 ID,并由 Warehouse 返回服务端名称快照 | | SQL/OpenAPI | `src/main/resources/db/phase2-*.sql`、`src/main/resources/db/phase3-*.sql`、`src/main/resources/openapi/`;部署统一 `../deploy/sql/头程数据库统一增量-MySQL8.sql` | | 测试/验证 | `src/test/java/com/baiyue/firstleg/`;`mvn -pl firstleg -am test` | diff --git a/firstleg/src/main/java/com/baiyue/firstleg/client/EventBridgeClient.java b/firstleg/src/main/java/com/baiyue/firstleg/client/EventBridgeClient.java deleted file mode 100644 index f48074f63..000000000 --- a/firstleg/src/main/java/com/baiyue/firstleg/client/EventBridgeClient.java +++ /dev/null @@ -1,19 +0,0 @@ -package com.baiyue.firstleg.client; - -import com.baiyue.common.core.api.ApiResponse; -import com.baiyue.common.event.EventEnvelope; -import org.springframework.cloud.openfeign.FeignClient; -import org.springframework.web.bind.annotation.PostMapping; -import org.springframework.web.bind.annotation.RequestBody; -import org.springframework.web.bind.annotation.RequestHeader; - -/** 将本地业务 Outbox 投递到跨区事件桥。 */ -@FeignClient(name = "event-bridge-service", contextId = "loadingCompletedEventBridgeClient", url = "${baiyue.lastleg.event-bridge-url:}") -public interface EventBridgeClient { - @PostMapping("/internal/event-bridge/events") - ApiResponse receive( - @RequestHeader("X-Internal-Service-Token") String token, - @RequestBody EventEnvelope envelope); - - record Acknowledgement(String eventId, String result, String receivedAt) { } -} diff --git a/firstleg/src/main/java/com/baiyue/firstleg/client/LastlegEventClient.java b/firstleg/src/main/java/com/baiyue/firstleg/client/LastlegEventClient.java new file mode 100644 index 000000000..b6ea27a97 --- /dev/null +++ b/firstleg/src/main/java/com/baiyue/firstleg/client/LastlegEventClient.java @@ -0,0 +1,20 @@ +package com.baiyue.firstleg.client; + +import com.baiyue.common.core.api.ApiResponse; +import com.baiyue.common.web.EventDeliveryFeignConfiguration; +import com.baiyue.common.event.EventEnvelope; +import com.baiyue.common.event.DeliveryAck; +import org.springframework.cloud.openfeign.FeignClient; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestHeader; + +/** 将本地业务 Outbox 投递到Lastleg 消费者。 */ +@FeignClient(configuration = EventDeliveryFeignConfiguration.class, name = "lastleg-service", contextId = "loadingCompletedLastlegEventClient", url = "${LASTLEG_SERVICE_URL:${baiyue.clients.lastleg.url:}}") +public interface LastlegEventClient { + @PostMapping("/internal/lastleg/events") + ApiResponse receive( + @RequestHeader("X-Internal-Service-Token") String token, + @RequestBody EventEnvelope envelope); + +} diff --git a/firstleg/src/main/java/com/baiyue/firstleg/controller/InternalOverseasContainerEventController.java b/firstleg/src/main/java/com/baiyue/firstleg/controller/InternalOverseasContainerEventController.java index 039cce166..34fb7eae5 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/controller/InternalOverseasContainerEventController.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/controller/InternalOverseasContainerEventController.java @@ -4,6 +4,7 @@ import com.baiyue.common.core.api.ApiResponse; import com.baiyue.common.core.api.ResultCode; import com.baiyue.common.core.exception.BizException; import com.baiyue.common.event.EventEnvelope; +import com.baiyue.common.event.DeliveryAck; import com.baiyue.common.event.EventTypes; import com.baiyue.firstleg.service.OverseasContainerEventConsumer; import com.baiyue.firstleg.service.OverseasInboundOrderDetainedConsumer; @@ -27,14 +28,17 @@ public class InternalOverseasContainerEventController { @Value("${baiyue.security.internal-service-token}") private String expectedToken; @PostMapping("/internal/firstleg/v1/container-events") - public ApiResponse receive( + public ApiResponse receive( @RequestHeader(value = "X-Internal-Service-Token", required = false) String token, @RequestBody EventEnvelope event) { authenticate(token); - Object result = EventTypes.OVERSEAS_INBOUND_ORDER_DETAINED_CHANGED.equals(event.eventType()) - ? detainedConsumer.consume(event) - : EventTypes.OVERSEAS_DELIVERY_COMPLETED.equals(event.eventType()) - ? deliveryCompletedConsumer.consume(event) : consumer.consume(event); + event.requireValid(); + DeliveryAck result = switch (event.eventType()) { + case EventTypes.OVERSEAS_CONTAINER_UNLOADED -> consumer.consume(event); + case EventTypes.OVERSEAS_INBOUND_ORDER_DETAINED_CHANGED -> detainedConsumer.consume(event); + case EventTypes.OVERSEAS_DELIVERY_COMPLETED -> deliveryCompletedConsumer.consume(event); + default -> throw new IllegalArgumentException("不支持的 Firstleg 业务事件类型"); + }; return ApiResponse.success(result); } diff --git a/firstleg/src/main/java/com/baiyue/firstleg/repository/LoadingCompletedOutboxRepository.java b/firstleg/src/main/java/com/baiyue/firstleg/repository/LoadingCompletedOutboxRepository.java index 403f5a5c1..e5ac78d78 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/repository/LoadingCompletedOutboxRepository.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/repository/LoadingCompletedOutboxRepository.java @@ -38,6 +38,7 @@ public class LoadingCompletedOutboxRepository { .or().le(InboundOutboxEntity::getNextRetryAt, now)) .or(stale -> stale.eq(InboundOutboxEntity::getPublishStatus, "PROCESSING") .le(InboundOutboxEntity::getLockedAt, expiredLeaseBefore))) + .setSql("retry_count = retry_count + CASE WHEN publish_status='PROCESSING' THEN 1 ELSE 0 END") .set(InboundOutboxEntity::getPublishStatus, "PROCESSING").set(InboundOutboxEntity::getLockedBy, worker) .set(InboundOutboxEntity::getLockedAt, now)) == 1; } @@ -48,6 +49,14 @@ public class LoadingCompletedOutboxRepository { .set(InboundOutboxEntity::getPublishStatus, "PUBLISHED").set(InboundOutboxEntity::getPublishedAt, now) .set(InboundOutboxEntity::getLockedBy, null).set(InboundOutboxEntity::getLockedAt, null) .set(InboundOutboxEntity::getLastError, null)); } + public void dead(long id, String worker, int failures, String error) { + mapper.update(null, claimedBase(id, worker) + .set(InboundOutboxEntity::getPublishStatus, "DEAD") + .set(InboundOutboxEntity::getRetryCount, failures) + .set(InboundOutboxEntity::getNextRetryAt, null) + .set(InboundOutboxEntity::getLockedBy, null).set(InboundOutboxEntity::getLockedAt, null) + .set(InboundOutboxEntity::getLastError, truncate(error))); + } public void retry(long id, String worker, LocalDateTime retryAt, String error) { mapper.update(null, claimedBase(id, worker) .setSql("retry_count = retry_count + 1").set(InboundOutboxEntity::getPublishStatus, "PENDING") .set(InboundOutboxEntity::getNextRetryAt, retryAt).set(InboundOutboxEntity::getLockedBy, null) diff --git a/firstleg/src/main/java/com/baiyue/firstleg/service/LoadingCompletedOutboxRelay.java b/firstleg/src/main/java/com/baiyue/firstleg/service/LoadingCompletedOutboxRelay.java index f01597951..d7c99bc08 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/service/LoadingCompletedOutboxRelay.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/service/LoadingCompletedOutboxRelay.java @@ -2,8 +2,9 @@ package com.baiyue.firstleg.service; import com.baiyue.common.core.api.ApiResponse; import com.baiyue.common.event.EventEnvelope; +import com.baiyue.common.core.retry.BoundedRetryPolicy; import com.baiyue.common.event.EventEnvelopeValidator; -import com.baiyue.firstleg.client.EventBridgeClient; +import com.baiyue.firstleg.client.LastlegEventClient; import com.baiyue.firstleg.entity.InboundOutboxEntity; import com.baiyue.firstleg.repository.LoadingCompletedOutboxRepository; import com.fasterxml.jackson.databind.ObjectMapper; @@ -16,25 +17,39 @@ import org.springframework.stereotype.Component; import java.time.LocalDateTime; import java.util.UUID; -/** 至少一次投递国内头程跨区事件;事件桥按 eventId + payloadHash 幂等接收。 */ +/** 至少一次投递国内头程业务事件;Lastleg按 eventId + payloadHash 幂等接收。 */ @Component @Slf4j public class LoadingCompletedOutboxRelay { @Resource private LoadingCompletedOutboxRepository repository; - @Resource private EventBridgeClient client; + @Resource private LastlegEventClient client; @Resource private ObjectMapper objectMapper; @Value("${baiyue.security.internal-service-token}") private String internalToken; - @Value("${baiyue.lastleg.outbox-relay.lease-seconds:60}") private long leaseSeconds; - @Value("${baiyue.lastleg.outbox-relay.retry-seconds:30}") private long retrySeconds; - private final String workerId = UUID.randomUUID().toString(); + @Value("${baiyue.event-delivery.lease-seconds:60}") private long leaseSeconds = 60; + + @Value("${baiyue.event-delivery.max-attempts:12}") private int maxAttempts = 12; + @Value("${baiyue.event-delivery.base-delay-seconds:5}") private long baseDelaySeconds = 5; + @Value("${baiyue.event-delivery.max-delay-seconds:1800}") private long maxDelaySeconds = 1800; + @Value("${baiyue.event-delivery.jitter:0.1}") private double retryJitter = 0.1; @Scheduled(fixedDelayString = "${baiyue.lastleg.outbox-relay.fixed-delay-ms:5000}") public void relay() { + if (leaseSeconds < 1) throw new IllegalArgumentException("Lease duration must be positive"); + var policy = new BoundedRetryPolicy(maxAttempts, baseDelaySeconds, maxDelaySeconds, retryJitter); LocalDateTime now=LocalDateTime.now(); - LocalDateTime expiredLeaseBefore=now.minusSeconds(Math.max(leaseSeconds,10)); + LocalDateTime expiredLeaseBefore=now.minusSeconds(leaseSeconds); for (InboundOutboxEntity candidate:repository.pending(50,now,expiredLeaseBefore)) { + String workerId = UUID.randomUUID().toString(); + now = LocalDateTime.now(); + expiredLeaseBefore = now.minusSeconds(leaseSeconds); + if(!repository.claim(candidate.getId(),workerId,now,expiredLeaseBefore)) continue; InboundOutboxEntity row=repository.claimed(candidate.getId(),workerId); if(row==null) continue; + int failures = row.getRetryCount() == null ? 0 : row.getRetryCount(); + if (policy.exhausted(failures)) { + repository.dead(row.getId(), workerId, failures, "自动投递次数已耗尽(含租约超时)"); + continue; + } try { EventEnvelope event=objectMapper.readValue(row.getPayloadJson(),EventEnvelope.class); EventEnvelopeValidator.requireValid(event); @@ -43,25 +58,24 @@ public class LoadingCompletedOutboxRelay { repository.published(row.getId(),workerId,LocalDateTime.now()); } catch(Exception exception) { String error=safeError(exception); - repository.retry(row.getId(),workerId,LocalDateTime.now().plusSeconds(Math.max(retrySeconds,1)),error); - log.warn("头程跨区事件投递失败: eventType={}, eventKey={}, reason={}", + if (policy.exhausted(failures + 1)) repository.dead(row.getId(), workerId, failures + 1, error); + else repository.retry(row.getId(), workerId, LocalDateTime.now().plus(policy.delayAfterFailure(failures + 1)), error); + log.warn("头程业务事件投递失败: eventType={}, eventKey={}, reason={}", row.getEventType(), row.getEventKey(), error); } } } private void requireAcknowledgement(EventEnvelope event, - ApiResponse response) { - if(response==null) throw new IllegalStateException("事件桥未返回响应"); + ApiResponse response) { + if(response==null) throw new IllegalStateException("Lastleg未返回响应"); if(!response.successful()) { - throw new IllegalStateException("事件桥拒绝头程跨区事件: code=" + response.code() + throw new IllegalStateException("Lastleg拒绝头程业务事件: code=" + response.code() + ", message=" + response.message() + ", requestId=" + response.requestId() + ", traceId=" + response.traceId()); } - if(response.data()==null || !event.eventId().equals(response.data().eventId())) { - throw new IllegalStateException("事件桥 ACK 不匹配: expectedEventId=" + event.eventId() - + ", actualEventId=" + (response.data()==null?null:response.data().eventId())); - } + if (response.data() == null) throw new IllegalStateException("Lastleg ACK 缺失"); + response.data().requireMatches(event); } private String safeError(Exception exception) { diff --git a/firstleg/src/main/java/com/baiyue/firstleg/service/OverseasContainerEventConsumer.java b/firstleg/src/main/java/com/baiyue/firstleg/service/OverseasContainerEventConsumer.java index 5bdd15cc2..1eb91f8c9 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/service/OverseasContainerEventConsumer.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/service/OverseasContainerEventConsumer.java @@ -1,6 +1,7 @@ package com.baiyue.firstleg.service; import com.baiyue.common.event.EventEnvelope; +import com.baiyue.common.event.DeliveryAck; import com.baiyue.common.event.EventPayloadCanonicalizer; import com.baiyue.common.event.EventPayloadHash; import com.baiyue.common.event.EventTypes; @@ -26,8 +27,8 @@ public class OverseasContainerEventConsumer { private final ContainerEventInboxMapper inbox; private final ObjectMapper objectMapper; - @Transactional - public ContainerBookingEntity consume(EventEnvelope event) { + @Transactional(isolation = org.springframework.transaction.annotation.Isolation.READ_COMMITTED) + public DeliveryAck consume(EventEnvelope event) { validateEnvelope(event); OverseasContainerUnloadedV1 payload = payload(event); if (payload.sourceContainerBookingId() == null || payload.sourceContainerBookingId().isBlank() @@ -50,7 +51,8 @@ public class OverseasContainerEventConsumer { if (!existing.getPayloadHash().equalsIgnoreCase(event.payloadHash())) { throw new IllegalArgumentException("相同 eventId 对应的事件摘要冲突"); } - return requiredContainer(payload.sourceContainerBookingId(), payload.containerNo()); + if (!"PROCESSED".equals(existing.getStatus())) throw new IllegalStateException("事件尚未完成消费"); + return DeliveryAck.of(event, DeliveryAck.Outcome.DUPLICATE); } ContainerEventInboxEntity row = inbox(event); try { @@ -58,18 +60,20 @@ public class OverseasContainerEventConsumer { } catch (DuplicateKeyException duplicate) { ContainerEventInboxEntity raced = inbox.selectById(event.eventId()); if (raced == null || !raced.getPayloadHash().equalsIgnoreCase(event.payloadHash())) throw duplicate; - return requiredContainer(payload.sourceContainerBookingId(), payload.containerNo()); + if (!"PROCESSED".equals(raced.getStatus())) throw new IllegalStateException("事件尚未完成消费"); + return DeliveryAck.of(event, DeliveryAck.Outcome.DUPLICATE); } ContainerBookingEntity container = requiredContainer(payload.sourceContainerBookingId(), payload.containerNo()); LocalDateTime uploadedAt = utc(payload.unloadingPhotoUploadedAt()); LocalDateTime unloadingCompletedAt = utc(payload.unloadingCompletedAt()); - containers.applyEmptyReport(container.getId(), unloadingCompletedAt, uploadedAt, + int updated = containers.applyEmptyReport(container.getId(), unloadingCompletedAt, uploadedAt, unloadingCompletedAt.plusHours(72), utc(payload.actualEmptyReportedAt()), event.aggregateVersion()); + if (updated != 1) throw new IllegalStateException("卸柜事实未写入:来源版本冲突或货柜状态已变化"); row.setStatus("PROCESSED"); row.setProcessedAt(LocalDateTime.now(ZoneOffset.UTC)); inbox.updateById(row); - return requiredContainer(payload.sourceContainerBookingId(), payload.containerNo()); + return DeliveryAck.of(event, DeliveryAck.Outcome.APPLIED); } private void validateEnvelope(EventEnvelope event) { diff --git a/firstleg/src/main/java/com/baiyue/firstleg/service/OverseasDeliveryCompletedConsumer.java b/firstleg/src/main/java/com/baiyue/firstleg/service/OverseasDeliveryCompletedConsumer.java index 6fa43e279..2834891dd 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/service/OverseasDeliveryCompletedConsumer.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/service/OverseasDeliveryCompletedConsumer.java @@ -2,6 +2,7 @@ package com.baiyue.firstleg.service; import com.baomidou.mybatisplus.core.toolkit.IdWorker; import com.baiyue.common.event.EventEnvelope; +import com.baiyue.common.event.DeliveryAck; import com.baiyue.common.event.EventPayloadCanonicalizer; import com.baiyue.common.event.EventPayloadHash; import com.baiyue.common.event.EventTypes; @@ -37,8 +38,8 @@ public class OverseasDeliveryCompletedConsumer { private final ObjectMapper objectMapper; @Value("${baiyue.security.internal-service-token}") private String internalToken; - @Transactional - public InboundOrderEntity consume(EventEnvelope event) { + @Transactional(isolation = org.springframework.transaction.annotation.Isolation.READ_COMMITTED) + public DeliveryAck consume(EventEnvelope event) { validate(event); OverseasDeliveryCompletedV1 payload = payload(event); long inboundId = positive(payload.sourceInboundId(), "来源入仓单 ID 无效"); @@ -51,14 +52,16 @@ public class OverseasDeliveryCompletedConsumer { if (!existing.getPayloadHash().equalsIgnoreCase(event.payloadHash())) { throw new IllegalArgumentException("相同 eventId 对应的事件摘要冲突"); } - return required(inboundId, tenantId, payload); + if (!"PROCESSED".equals(existing.getStatus())) throw new IllegalStateException("事件尚未完成消费"); + return DeliveryAck.of(event, DeliveryAck.Outcome.DUPLICATE); } ContainerEventInboxEntity inboxRow = inbox(event); try { inbox.insert(inboxRow); } catch (DuplicateKeyException duplicate) { ContainerEventInboxEntity raced = inbox.selectById(event.eventId()); if (raced == null || !raced.getPayloadHash().equalsIgnoreCase(event.payloadHash())) throw duplicate; - return required(inboundId, tenantId, payload); + if (!"PROCESSED".equals(raced.getStatus())) throw new IllegalStateException("事件尚未完成消费"); + return DeliveryAck.of(event, DeliveryAck.Outcome.DUPLICATE); } InboundOrderEntity inbound = required(inboundId, tenantId, payload); if (!"LOADED".equals(inbound.getProcessStatus()) && !"DELIVERED".equals(inbound.getProcessStatus())) { @@ -91,7 +94,7 @@ public class OverseasDeliveryCompletedConsumer { } inboxRow.setStatus("PROCESSED"); inboxRow.setProcessedAt(LocalDateTime.now(ZoneOffset.UTC)); inbox.updateById(inboxRow); - return required(inboundId, tenantId, payload); + return DeliveryAck.of(event, DeliveryAck.Outcome.APPLIED); } private void validate(EventEnvelope event) { diff --git a/firstleg/src/main/java/com/baiyue/firstleg/service/OverseasInboundOrderDetainedConsumer.java b/firstleg/src/main/java/com/baiyue/firstleg/service/OverseasInboundOrderDetainedConsumer.java index df37c939f..8c9df62d2 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/service/OverseasInboundOrderDetainedConsumer.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/service/OverseasInboundOrderDetainedConsumer.java @@ -2,6 +2,7 @@ package com.baiyue.firstleg.service; import com.baomidou.mybatisplus.core.toolkit.IdWorker; import com.baiyue.common.event.EventEnvelope; +import com.baiyue.common.event.DeliveryAck; import com.baiyue.common.event.EventPayloadCanonicalizer; import com.baiyue.common.event.EventPayloadHash; import com.baiyue.common.event.EventTypes; @@ -33,8 +34,8 @@ public class OverseasInboundOrderDetainedConsumer { private final ContainerEventInboxMapper inbox; private final ObjectMapper objectMapper; - @Transactional - public InboundOrderEntity consume(EventEnvelope event) { + @Transactional(isolation = org.springframework.transaction.annotation.Isolation.READ_COMMITTED) + public DeliveryAck consume(EventEnvelope event) { validateEnvelope(event); OverseasInboundOrderDetainedChangedV1 payload = payload(event); long inboundId = parsePositive(payload.sourceInboundId(), "来源入仓单 ID 无效"); @@ -52,7 +53,8 @@ public class OverseasInboundOrderDetainedConsumer { if (!existing.getPayloadHash().equalsIgnoreCase(event.payloadHash())) { throw new IllegalArgumentException("相同 eventId 对应的事件摘要冲突"); } - return requiredInbound(inboundId, tenantId, payload.inboundCode()); + if (!"PROCESSED".equals(existing.getStatus())) throw new IllegalStateException("事件尚未完成消费"); + return DeliveryAck.of(event, DeliveryAck.Outcome.DUPLICATE); } ContainerEventInboxEntity inboxRow = inbox(event); try { @@ -60,21 +62,24 @@ public class OverseasInboundOrderDetainedConsumer { } catch (DuplicateKeyException duplicate) { ContainerEventInboxEntity raced = inbox.selectById(event.eventId()); if (raced == null || !raced.getPayloadHash().equalsIgnoreCase(event.payloadHash())) throw duplicate; - return requiredInbound(inboundId, tenantId, payload.inboundCode()); + if (!"PROCESSED".equals(raced.getStatus())) throw new IllegalStateException("事件尚未完成消费"); + return DeliveryAck.of(event, DeliveryAck.Outcome.DUPLICATE); } InboundOrderEntity inbound = requiredInbound(inboundId, tenantId, payload.inboundCode()); + boolean applied = false; if (inbound.getLastOverseasHoldVersion() == null || event.aggregateVersion() > inbound.getLastOverseasHoldVersion()) { int updated = inboundOrders.applyOverseasHold(tenantId, inboundId, payload.detained(), payload.detained() ? payload.reason() : null, event.aggregateVersion(), parseOperatorId(payload.operatorId())); - if (updated == 1) insertHistory(event, payload, tenantId, inboundId); + applied = updated == 1; + if (applied) insertHistory(event, payload, tenantId, inboundId); } inboxRow.setStatus("PROCESSED"); inboxRow.setProcessedAt(LocalDateTime.now(ZoneOffset.UTC)); inbox.updateById(inboxRow); - return requiredInbound(inboundId, tenantId, payload.inboundCode()); + return DeliveryAck.of(event, applied ? DeliveryAck.Outcome.APPLIED : DeliveryAck.Outcome.STALE); } private void validateEnvelope(EventEnvelope event) { diff --git a/firstleg/src/test/java/com/baiyue/firstleg/service/LoadingCompletedOutboxRelayTest.java b/firstleg/src/test/java/com/baiyue/firstleg/service/LoadingCompletedOutboxRelayTest.java index 3970763f6..288886bb2 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/service/LoadingCompletedOutboxRelayTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/service/LoadingCompletedOutboxRelayTest.java @@ -8,7 +8,7 @@ import com.baiyue.common.event.EventPayloadHash; import com.baiyue.common.event.FirstLegLoadingPlanCompletedV1; import com.baiyue.common.event.EventTypes; import com.baiyue.common.event.RegionCode; -import com.baiyue.firstleg.client.EventBridgeClient; +import com.baiyue.firstleg.client.LastlegEventClient; import com.baiyue.firstleg.entity.InboundOutboxEntity; import com.baiyue.firstleg.repository.LoadingCompletedOutboxRepository; import com.fasterxml.jackson.databind.ObjectMapper; @@ -31,7 +31,7 @@ import static org.mockito.Mockito.*; @ExtendWith(MockitoExtension.class) class LoadingCompletedOutboxRelayTest { @Mock private LoadingCompletedOutboxRepository repository; - @Mock private EventBridgeClient client; + @Mock private LastlegEventClient client; private ObjectMapper objectMapper; private LoadingCompletedOutboxRelay relay; @@ -44,18 +44,18 @@ class LoadingCompletedOutboxRelayTest { ReflectionTestUtils.setField(relay, "objectMapper", objectMapper); ReflectionTestUtils.setField(relay, "internalToken", "internal-token"); ReflectionTestUtils.setField(relay, "leaseSeconds", 60L); - ReflectionTestUtils.setField(relay, "retrySeconds", 30L); + ReflectionTestUtils.setField(relay, "baseDelaySeconds", 5L); } @Test - void claimsExpiredLeasesAndPublishesAfterOverseasBridgeAcknowledgement() throws Exception { + void claimsExpiredLeasesAndPublishesAfterConsumerAcknowledgement() throws Exception { EventEnvelope event = event(); InboundOutboxEntity row = row(event); when(repository.pending(eq(50), any(), any())).thenReturn(List.of(row)); when(repository.claim(eq(9001L), anyString(), any(), any())).thenReturn(true); when(repository.claimed(eq(9001L), anyString())).thenReturn(row); when(client.receive(eq("internal-token"), any(EventEnvelope.class))).thenReturn(ApiResponse.success( - new EventBridgeClient.Acknowledgement(event.eventId(), "已接收", "2026-08-20T03:01:00"))); + com.baiyue.common.event.DeliveryAck.of(event, com.baiyue.common.event.DeliveryAck.Outcome.APPLIED))); relay.relay(); @@ -110,11 +110,53 @@ class LoadingCompletedOutboxRelayTest { relay.relay(); verify(repository).retry(eq(9001L), anyString(), any(LocalDateTime.class), eq( - "IllegalStateException: 事件桥拒绝头程跨区事件: code=S0503, message=bridge inbox unavailable, " + "IllegalStateException: Lastleg拒绝头程业务事件: code=S0503, message=bridge inbox unavailable, " + "requestId=request-123, traceId=trace-456")); verify(repository, never()).published(anyLong(), anyString(), any()); } + @Test + void matchingEventIdWithWrongPayloadHashDoesNotPublish() throws Exception { + EventEnvelope event = event(); + InboundOutboxEntity row = row(event); + when(repository.pending(eq(50), any(), any())).thenReturn(List.of(row)); + when(repository.claim(eq(9001L), anyString(), any(), any())).thenReturn(true); + when(repository.claimed(eq(9001L), anyString())).thenReturn(row); + when(client.receive(eq("internal-token"), any(EventEnvelope.class))).thenReturn(ApiResponse.success( + new com.baiyue.common.event.DeliveryAck(event.eventId(), "sha256:" + "0".repeat(64), + com.baiyue.common.event.DeliveryAck.Outcome.APPLIED))); + relay.relay(); + verify(repository, never()).published(anyLong(), anyString(), any()); + verify(repository).retry(eq(9001L), anyString(), any(), contains("ACK does not match")); + } + + @Test + void twelfthFailureGoesDeadInsteadOfSchedulingAnotherRetry() throws Exception { + InboundOutboxEntity row = row(event()); + row.setRetryCount(11); + when(repository.pending(eq(50), any(), any())).thenReturn(List.of(row)); + when(repository.claim(eq(9001L), anyString(), any(), any())).thenReturn(true); + when(repository.claimed(eq(9001L), anyString())).thenReturn(row); + when(client.receive(anyString(), any())).thenThrow(new IllegalStateException("consumer unavailable")); + relay.relay(); + verify(repository).dead(eq(9001L), anyString(), eq(12), contains("consumer unavailable")); + verify(repository, never()).retry(anyLong(), anyString(), any(), anyString()); + verify(repository, never()).published(anyLong(), anyString(), any()); + } + + @Test + void recoveredExhaustedLeaseDoesNotSendAgain() throws Exception { + InboundOutboxEntity row = row(event()); + row.setRetryCount(12); + when(repository.pending(eq(50), any(), any())).thenReturn(List.of(row)); + when(repository.claim(eq(9001L), anyString(), any(), any())).thenReturn(true); + when(repository.claimed(eq(9001L), anyString())).thenReturn(row); + relay.relay(); + verifyNoInteractions(client); + verify(repository).dead(eq(9001L), anyString(), eq(12), contains("租约超时")); + verify(repository, never()).retry(anyLong(), anyString(), any(), anyString()); + } + private InboundOutboxEntity row(EventEnvelope event) throws Exception { InboundOutboxEntity row = new InboundOutboxEntity(); row.setId(9001L); diff --git a/firstleg/src/test/java/com/baiyue/firstleg/service/OverseasContainerEventConsumerTest.java b/firstleg/src/test/java/com/baiyue/firstleg/service/OverseasContainerEventConsumerTest.java index c4242c9fc..dbb9a5567 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/service/OverseasContainerEventConsumerTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/service/OverseasContainerEventConsumerTest.java @@ -44,6 +44,7 @@ class OverseasContainerEventConsumerTest { void derivesEstimatedEmptyReportedAtFromUnloadCompletionAndPersistsInbox() { ContainerBookingEntity container = container(); when(containers.selectActiveBookingForUpdate(1001L)).thenReturn(container); + when(containers.applyEmptyReport(anyLong(), any(), any(), any(), any(), anyLong())).thenReturn(1); Instant completedAt = Instant.parse("2026-08-20T03:00:00Z"); Instant uploadedAt = Instant.parse("2026-08-20T02:30:00Z"); @@ -67,10 +68,10 @@ class OverseasContainerEventConsumerTest { EventEnvelope event = event("00000000-0000-4000-8000-000000000002", 7, payload); ContainerEventInboxEntity existing = new ContainerEventInboxEntity(); existing.setPayloadHash(event.payloadHash()); + existing.setStatus("PROCESSED"); when(inbox.selectById(event.eventId())).thenReturn(existing); - when(containers.selectActiveBookingForUpdate(1001L)).thenReturn(container()); - consumer.consume(event); + assertThat(consumer.consume(event).outcome()).isEqualTo(com.baiyue.common.event.DeliveryAck.Outcome.DUPLICATE); verify(containers, never()).applyEmptyReport(anyLong(), any(), any(), any(), any(), anyLong()); verify(inbox, never()).insert(any(ContainerEventInboxEntity.class)); @@ -91,6 +92,17 @@ class OverseasContainerEventConsumerTest { verifyNoInteractions(containers, inbox); } + @Test + void unappliedUnloadCannotBeAcknowledgedAsSuccessful() { + when(containers.selectActiveBookingForUpdate(1001L)).thenReturn(container()); + EventEnvelope event = event("00000000-0000-4000-8000-000000000004", 7, + new OverseasContainerUnloadedV1("1001", "ZCSU7881970", Instant.parse("2026-08-20T03:00:00Z"), + Instant.parse("2026-08-20T02:30:00Z"), null)); + assertThatThrownBy(() -> consumer.consume(event)).isInstanceOf(IllegalStateException.class) + .hasMessageContaining("卸柜事实未写入"); + verify(inbox, never()).updateById(any(ContainerEventInboxEntity.class)); + } + private EventEnvelope event(String eventId, long aggregateVersion, OverseasContainerUnloadedV1 payload) { var node = objectMapper.valueToTree(payload); String hash = EventPayloadHash.sha256(EventPayloadCanonicalizer.canonicalize(node)); diff --git a/firstleg/src/test/java/com/baiyue/firstleg/service/OverseasInboundOrderDetainedConsumerTest.java b/firstleg/src/test/java/com/baiyue/firstleg/service/OverseasInboundOrderDetainedConsumerTest.java index f065a93ec..945887023 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/service/OverseasInboundOrderDetainedConsumerTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/service/OverseasInboundOrderDetainedConsumerTest.java @@ -47,7 +47,8 @@ class OverseasInboundOrderDetainedConsumerTest { when(inboundOrders.selectActiveByIdForUpdate(1001L)).thenReturn(inbound); when(inboundOrders.applyOverseasHold(88L, 1001L, true, "等待查验", 7L, 66L)).thenReturn(1); - consumer.consume(event(7, true, "等待查验")); + assertThat(consumer.consume(event(7, true, "等待查验")).outcome()) + .isEqualTo(com.baiyue.common.event.DeliveryAck.Outcome.APPLIED); verify(inboundOrders).applyOverseasHold(88L, 1001L, true, "等待查验", 7L, 66L); ArgumentCaptor history = ArgumentCaptor.forClass(InboundHoldRecordEntity.class); @@ -61,7 +62,8 @@ class OverseasInboundOrderDetainedConsumerTest { void staleReleaseCannotOverwriteNewerHoldState() { when(inboundOrders.selectActiveByIdForUpdate(1001L)).thenReturn(inbound(9L)); - consumer.consume(event(7, false, "解除")); + assertThat(consumer.consume(event(7, false, "解除")).outcome()) + .isEqualTo(com.baiyue.common.event.DeliveryAck.Outcome.STALE); verify(inboundOrders, never()).applyOverseasHold(anyLong(), anyLong(), anyBoolean(), any(), anyLong(), anyLong()); verifyNoInteractions(holdRecords); diff --git a/gateway/src/main/java/com/baiyue/gateway/config/GatewaySecurityConfig.java b/gateway/src/main/java/com/baiyue/gateway/config/GatewaySecurityConfig.java index 0b0626c50..175a78eb1 100644 --- a/gateway/src/main/java/com/baiyue/gateway/config/GatewaySecurityConfig.java +++ b/gateway/src/main/java/com/baiyue/gateway/config/GatewaySecurityConfig.java @@ -9,7 +9,7 @@ import org.springframework.http.HttpMethod; import org.springframework.http.HttpStatus; import org.springframework.http.MediaType; import org.springframework.security.config.web.server.ServerHttpSecurity; -import org.springframework.security.oauth2.jose.jws.MacAlgorithm; +import org.springframework.security.oauth2.jose.jws.SignatureAlgorithm; import org.springframework.security.oauth2.jwt.NimbusReactiveJwtDecoder; import org.springframework.security.oauth2.jwt.ReactiveJwtDecoder; import org.springframework.security.oauth2.jwt.JwtValidators; @@ -17,8 +17,9 @@ import org.springframework.security.web.server.SecurityWebFilterChain; import org.springframework.web.cors.reactive.CorsConfigurationSource; import reactor.core.publisher.Mono; -import javax.crypto.spec.SecretKeySpec; -import java.nio.charset.StandardCharsets; +import org.springframework.security.oauth2.core.DelegatingOAuth2TokenValidator; +import org.springframework.security.oauth2.jwt.JwtClaimValidator; +import java.util.List; import java.time.Instant; import java.util.LinkedHashMap; import java.util.Map; @@ -44,6 +45,9 @@ public class GatewaySecurityConfig { .access(new AppScopeAuthorizationManager("MERCHANT")); exchanges.pathMatchers("/api/platform/**") .access(new AppScopeAuthorizationManager("PLATFORM")); + exchanges.pathMatchers("/api/pda/**") + .access(new AppScopeAuthorizationManager("PLATFORM")); + exchanges.pathMatchers("/internal/**").denyAll(); exchanges.anyExchange().authenticated(); return http.cors(cors -> cors.configurationSource(corsConfigurationSource)) .csrf(ServerHttpSecurity.CsrfSpec::disable) @@ -59,12 +63,11 @@ public class GatewaySecurityConfig { @Bean ReactiveJwtDecoder reactiveJwtDecoder(GatewaySecurityProperties properties) { - byte[] key = properties.jwtSecret().getBytes(StandardCharsets.UTF_8); - if (key.length < 32) throw new IllegalStateException("JWT_SECRET 至少需要 32 字节"); NimbusReactiveJwtDecoder decoder = NimbusReactiveJwtDecoder - .withSecretKey(new SecretKeySpec(key, "HmacSHA256")) - .macAlgorithm(MacAlgorithm.HS256).build(); - decoder.setJwtValidator(JwtValidators.createDefaultWithIssuer(properties.issuer())); + .withJwkSetUri(properties.jwkSetUri()).jwsAlgorithm(SignatureAlgorithm.RS256).build(); + decoder.setJwtValidator(new DelegatingOAuth2TokenValidator<>( + JwtValidators.createDefaultWithIssuer(properties.issuer()), + new JwtClaimValidator>("aud", values -> values != null && values.contains(properties.audience())))); return decoder; } diff --git a/gateway/src/main/java/com/baiyue/gateway/config/GatewaySecurityProperties.java b/gateway/src/main/java/com/baiyue/gateway/config/GatewaySecurityProperties.java index 01268b5d2..8d357b975 100644 --- a/gateway/src/main/java/com/baiyue/gateway/config/GatewaySecurityProperties.java +++ b/gateway/src/main/java/com/baiyue/gateway/config/GatewaySecurityProperties.java @@ -8,10 +8,11 @@ import java.util.List; * 网关安全配置模型,集中承载 JWT 密钥、签发方、Redis 令牌键前缀和匿名放行路径。 */ @ConfigurationProperties(prefix = "baiyue.security") -public record GatewaySecurityProperties(String jwtSecret, String issuer, String tokenKeyPrefix, +public record GatewaySecurityProperties(String jwkSetUri, String issuer, String audience, String tokenKeyPrefix, List permitPaths) { public GatewaySecurityProperties { issuer = issuer == null || issuer.isBlank() ? "auth-service" : issuer; + audience = audience == null || audience.isBlank() ? "lastleg-api" : audience; tokenKeyPrefix = normalize(tokenKeyPrefix); permitPaths = permitPaths == null ? List.of() : List.copyOf(permitPaths); } diff --git a/gateway/src/main/resources/application.yml b/gateway/src/main/resources/application.yml index 2304723e4..c7df24b36 100644 --- a/gateway/src/main/resources/application.yml +++ b/gateway/src/main/resources/application.yml @@ -42,6 +42,14 @@ spring: enabled: false lower-case-service-id: true routes: + - id: lastleg-service + uri: lb://lastleg-service + predicates: + - Path=/api/merchant/v1/last-leg/**,/api/platform/v1/last-leg/**,/api/pda/v1/last-leg/** + - id: warehouse-overseas-service + uri: lb://warehouse-overseas-service + predicates: + - Path=/api/platform/v1/overseas-warehouse-resources/** - id: auth-service uri: lb://auth-service predicates: @@ -83,6 +91,18 @@ spring: predicates: - Path=/api/platform/finance/v1/**,/api/client/finance/v1/** # Swagger 文档聚合路由。 + - id: lastleg-openapi + uri: lb://lastleg-service + predicates: + - Path=/lastleg-service/v3/api-docs/** + filters: + - StripPrefix=1 + - id: warehouse-overseas-openapi + uri: lb://warehouse-overseas-service + predicates: + - Path=/warehouse-overseas-service/v3/api-docs/** + filters: + - StripPrefix=1 - id: auth-openapi uri: lb://auth-service predicates: @@ -158,7 +178,9 @@ baiyue: allow-credentials: ${CORS_ALLOW_CREDENTIALS:false} max-age: ${CORS_MAX_AGE:3600} security: - jwt-secret: ${JWT_SECRET:change-me-to-at-least-32-bytes-long-before-start} + jwk-set-uri: ${JWT_JWKS_URI:http://auth-service:9100/api/auth/.well-known/jwks.json} + issuer: ${JWT_ISSUER:auth-service} + audience: ${JWT_AUDIENCE:lastleg-api} token-key-prefix: ${REDIS_TOKEN_KEY_PREFIX:baiyue:auth} permit-paths: - /api/pda-updates/** @@ -183,6 +205,8 @@ baiyue: - /integration-service/v3/api-docs/** - /event-bridge-service/v3/api-docs/** - /finance-service/v3/api-docs/** + - /lastleg-service/v3/api-docs/** + - /warehouse-overseas-service/v3/api-docs/** management: endpoints: @@ -231,3 +255,7 @@ springdoc: url: /event-bridge-service/v3/api-docs - name: finance url: /finance-service/v3/api-docs + - name: lastleg + url: /lastleg-service/v3/api-docs + - name: warehouse-overseas + url: /warehouse-overseas-service/v3/api-docs diff --git a/gateway/src/test/java/com/baiyue/gateway/config/CustomerTransitStatisticsRouteContractTest.java b/gateway/src/test/java/com/baiyue/gateway/config/CustomerTransitStatisticsRouteContractTest.java index a6ab80981..bd212811f 100644 --- a/gateway/src/test/java/com/baiyue/gateway/config/CustomerTransitStatisticsRouteContractTest.java +++ b/gateway/src/test/java/com/baiyue/gateway/config/CustomerTransitStatisticsRouteContractTest.java @@ -12,7 +12,7 @@ import static org.assertj.core.api.Assertions.assertThat; "spring.cloud.nacos.discovery.register-enabled=false", "spring.cloud.nacos.config.enabled=false", "spring.cloud.gateway.discovery.locator.enabled=false", - "baiyue.security.jwt-secret=test-jwt-secret-at-least-32-bytes-long" + "baiyue.security.jwk-set-uri=http://127.0.0.1:1/jwks" }) class CustomerTransitStatisticsRouteContractTest { @Autowired private GatewayProperties gatewayProperties; diff --git a/gateway/src/test/java/com/baiyue/gateway/config/GatewayCorsConfigurationTest.java b/gateway/src/test/java/com/baiyue/gateway/config/GatewayCorsConfigurationTest.java index 8b13163fd..09755984a 100644 --- a/gateway/src/test/java/com/baiyue/gateway/config/GatewayCorsConfigurationTest.java +++ b/gateway/src/test/java/com/baiyue/gateway/config/GatewayCorsConfigurationTest.java @@ -18,7 +18,7 @@ import static org.junit.jupiter.api.Assertions.assertThrows; "spring.cloud.nacos.discovery.register-enabled=false", "spring.cloud.nacos.config.enabled=false", "spring.cloud.gateway.discovery.locator.enabled=false", - "baiyue.security.jwt-secret=test-jwt-secret-at-least-32-bytes-long", + "baiyue.security.jwk-set-uri=http://127.0.0.1:1/jwks", "baiyue.cors.allowed-origins=http://localhost:5173,http://localhost:5174" }) @AutoConfigureWebTestClient diff --git a/gateway/src/test/java/com/baiyue/gateway/config/GatewayFileRouteConfigurationTest.java b/gateway/src/test/java/com/baiyue/gateway/config/GatewayFileRouteConfigurationTest.java index c2778a083..e792921e3 100644 --- a/gateway/src/test/java/com/baiyue/gateway/config/GatewayFileRouteConfigurationTest.java +++ b/gateway/src/test/java/com/baiyue/gateway/config/GatewayFileRouteConfigurationTest.java @@ -14,7 +14,7 @@ import static org.assertj.core.api.Assertions.assertThat; "spring.cloud.nacos.discovery.register-enabled=false", "spring.cloud.nacos.config.enabled=false", "spring.cloud.gateway.discovery.locator.enabled=false", - "baiyue.security.jwt-secret=test-jwt-secret-at-least-32-bytes-long" + "baiyue.security.jwk-set-uri=http://127.0.0.1:1/jwks" }) class GatewayFileRouteConfigurationTest { @Autowired diff --git a/gateway/src/test/java/com/baiyue/gateway/config/UnifiedJwtDecoderTest.java b/gateway/src/test/java/com/baiyue/gateway/config/UnifiedJwtDecoderTest.java new file mode 100644 index 000000000..253571ba7 --- /dev/null +++ b/gateway/src/test/java/com/baiyue/gateway/config/UnifiedJwtDecoderTest.java @@ -0,0 +1,55 @@ +package com.baiyue.gateway.config; + +import com.nimbusds.jose.*; +import com.nimbusds.jose.crypto.RSASSASigner; +import com.nimbusds.jose.jwk.JWKSet; +import com.nimbusds.jose.jwk.gen.RSAKeyGenerator; +import com.nimbusds.jwt.JWTClaimsSet; +import com.nimbusds.jwt.SignedJWT; +import com.sun.net.httpserver.HttpServer; +import org.junit.jupiter.api.Test; +import org.springframework.security.oauth2.jwt.JwtException; +import java.net.InetSocketAddress; +import java.nio.charset.StandardCharsets; +import java.time.Duration; +import java.time.Instant; +import java.util.Date; +import java.util.List; +import static org.assertj.core.api.Assertions.*; + +class UnifiedJwtDecoderTest { + @Test + void acceptsUnifiedRsaTokenAndRejectsWrongAudienceIssuerAndExpiry() throws Exception { + var key = new RSAKeyGenerator(2048).keyID("current").generate(); + var server = HttpServer.create(new InetSocketAddress("127.0.0.1", 0), 0); + server.createContext("/jwks", exchange -> { + byte[] body = new JWKSet(key.toPublicJWK()).toString().getBytes(StandardCharsets.UTF_8); + exchange.getResponseHeaders().add("Content-Type", "application/json"); + exchange.sendResponseHeaders(200, body.length); + try (var output = exchange.getResponseBody()) { output.write(body); } + }); + server.start(); + try { + var properties = new GatewaySecurityProperties("http://127.0.0.1:" + server.getAddress().getPort() + "/jwks", + "auth-service", "lastleg-api", "test:auth", List.of()); + var decoder = new GatewaySecurityConfig().reactiveJwtDecoder(properties); + var signer = new RSASSASigner(key); + String valid = token(signer, "auth-service", "lastleg-api", 120); + assertThat(decoder.decode(valid).block(Duration.ofSeconds(5)).getSubject()).isEqualTo("42"); + for (String invalid : List.of(token(signer, "other", "lastleg-api", 120), + token(signer, "auth-service", "other", 120), token(signer, "auth-service", "lastleg-api", -120))) { + assertThatThrownBy(() -> decoder.decode(invalid).block(Duration.ofSeconds(5))) + .isInstanceOf(JwtException.class); + } + } finally { server.stop(0); } + } + + private String token(RSASSASigner signer, String issuer, String audience, int ttl) throws Exception { + var jwt = new SignedJWT(new JWSHeader.Builder(JWSAlgorithm.RS256).keyID("current").build(), + new JWTClaimsSet.Builder().subject("42").issuer(issuer).audience(audience) + .issueTime(Date.from(Instant.now().minusSeconds(300))) + .expirationTime(Date.from(Instant.now().plusSeconds(ttl))).build()); + jwt.sign(signer); + return jwt.serialize(); + } +} diff --git a/iam/src/main/java/com/baiyue/iam/mapper/AuthorizationQueryMapper.java b/iam/src/main/java/com/baiyue/iam/mapper/AuthorizationQueryMapper.java index e1a487122..2f83481d9 100644 --- a/iam/src/main/java/com/baiyue/iam/mapper/AuthorizationQueryMapper.java +++ b/iam/src/main/java/com/baiyue/iam/mapper/AuthorizationQueryMapper.java @@ -19,6 +19,10 @@ public interface AuthorizationQueryMapper { AND u.status = 'ACTIVE' AND u.deleted = 0 AND m.id = #{membershipId} AND m.tenant_id = #{tenantId} AND m.status = 'ACTIVE' AND m.deleted = 0 + AND (m.tenant_id = 0 OR EXISTS ( + SELECT 1 FROM iam_tenant t WHERE t.id = m.tenant_id AND t.deleted = 0 + AND t.status IN ('ACTIVE', 'PROFILE_REQUIRED', 'PENDING', 'REJECTED') + )) """) int countActiveIdentity(@Param("userId") long userId, @Param("tenantId") long tenantId, diff --git a/iam/src/main/java/com/baiyue/iam/service/PlatformUserManagementService.java b/iam/src/main/java/com/baiyue/iam/service/PlatformUserManagementService.java index 9433ffab0..93f0dfe29 100644 --- a/iam/src/main/java/com/baiyue/iam/service/PlatformUserManagementService.java +++ b/iam/src/main/java/com/baiyue/iam/service/PlatformUserManagementService.java @@ -165,7 +165,7 @@ public class PlatformUserManagementService { members.save(membership); if (scopeChanged) members.replaceRoles(oldTenantId, membershipId, List.of()); members.replaceRoles(newTenantId, membershipId, roleIds); - if ((rolesChanged || warehouseChanged) && !profileChanged && !scopeChanged) { + if ((rolesChanged || warehouseChanged) && !scopeChanged) { call(() -> authClient.revokeSessions(internalToken, new AuthAccountClient.RevokeAccountSessionsRequest(Set.of(user.getAuthUserId())))); } diff --git a/iam/src/main/java/com/baiyue/iam/service/TenantService.java b/iam/src/main/java/com/baiyue/iam/service/TenantService.java index 0f0a7809a..f6a849271 100644 --- a/iam/src/main/java/com/baiyue/iam/service/TenantService.java +++ b/iam/src/main/java/com/baiyue/iam/service/TenantService.java @@ -423,10 +423,30 @@ public class TenantService { } tenant.setStatus(request.status()); repository.saveTenant(tenant); + revokeMerchantSessions(tenantId); securityEvents.tenantStatus(tenantId, request.status()); return response(tenant); } + private void revokeMerchantSessions(long tenantId) { + List accounts = memberRepository.findByTenant(tenantId).stream() + .map(member -> memberRepository.findUser(member.getUserId())) + .filter(java.util.Objects::nonNull).map(IamUser::getAuthUserId) + .filter(java.util.Objects::nonNull).distinct().toList(); + for (int start = 0; start < accounts.size(); start += 200) { + Set batch = Set.copyOf(accounts.subList(start, Math.min(start + 200, accounts.size()))); + try { + ApiResponse result = authClient.revokeSessions(internalToken, + new AuthAccountClient.RevokeAccountSessionsRequest(batch)); + if (result == null || !result.successful()) { + throw new BizException(ResultCode.SERVICE_UNAVAILABLE, "客户会话撤销失败,请重试"); + } + } catch (FeignException exception) { + throw new BizException(ResultCode.SERVICE_UNAVAILABLE, "客户会话撤销失败,请重试"); + } + } + } + @Transactional @BusinessAudit(event = "PLATFORM_TENANT_SUBMIT_REVIEW", resourceType = "TENANT", resourceId = "#tenantId", description = "平台提交企业客户审核") diff --git a/iam/src/test/java/com/baiyue/iam/mapper/AuthorizationIdentitySqlTest.java b/iam/src/test/java/com/baiyue/iam/mapper/AuthorizationIdentitySqlTest.java new file mode 100644 index 000000000..d6d8047f4 --- /dev/null +++ b/iam/src/test/java/com/baiyue/iam/mapper/AuthorizationIdentitySqlTest.java @@ -0,0 +1,37 @@ +package com.baiyue.iam.mapper; + +import org.apache.ibatis.annotations.Select; +import org.junit.jupiter.api.Test; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.jdbc.datasource.DriverManagerDataSource; +import java.util.UUID; +import static org.assertj.core.api.Assertions.assertThat; + +class AuthorizationIdentitySqlTest { + @Test + void disabledArchivedDeletedOrMissingCustomerCannotGetNewAuthorizationSnapshot() throws Exception { + var jdbc = new JdbcTemplate(new DriverManagerDataSource("jdbc:h2:mem:" + UUID.randomUUID() + ";DB_CLOSE_DELAY=-1", "sa", "")); + jdbc.execute("CREATE TABLE iam_user(id BIGINT, auth_user_id BIGINT, status VARCHAR(30), deleted INT)"); + jdbc.execute("CREATE TABLE iam_membership(id BIGINT, user_id BIGINT, tenant_id BIGINT, status VARCHAR(30), deleted INT)"); + jdbc.execute("CREATE TABLE iam_tenant(id BIGINT, status VARCHAR(30), deleted INT)"); + jdbc.update("INSERT INTO iam_user VALUES(1,42,'ACTIVE',0)"); + jdbc.update("INSERT INTO iam_membership VALUES(9,1,7,'ACTIVE',0)"); + jdbc.update("INSERT INTO iam_tenant VALUES(7,'ACTIVE',0)"); + String sql = String.join("\n", AuthorizationQueryMapper.class.getMethod("countActiveIdentity", long.class, long.class, long.class) + .getAnnotation(Select.class).value()).replace("#{userId}", "42").replace("#{membershipId}", "9").replace("#{tenantId}", "7"); + for (String status : new String[]{"ACTIVE", "PROFILE_REQUIRED", "PENDING", "REJECTED"}) { + jdbc.update("UPDATE iam_tenant SET status=?", status); + assertThat(jdbc.queryForObject(sql, Integer.class)).as(status).isEqualTo(1); + } + for (String status : new String[]{"DISABLED", "ARCHIVED", "UNKNOWN"}) { + jdbc.update("UPDATE iam_tenant SET status=?", status); + assertThat(jdbc.queryForObject(sql, Integer.class)).as(status).isZero(); + } + jdbc.update("UPDATE iam_tenant SET status='ACTIVE', deleted=1"); + assertThat(jdbc.queryForObject(sql, Integer.class)).isZero(); + jdbc.update("DELETE FROM iam_tenant"); + assertThat(jdbc.queryForObject(sql, Integer.class)).isZero(); + jdbc.update("UPDATE iam_membership SET tenant_id=0"); + assertThat(jdbc.queryForObject(sql.replace("m.tenant_id = 7", "m.tenant_id = 0"), Integer.class)).isEqualTo(1); + } +} diff --git a/iam/src/test/java/com/baiyue/iam/service/PlatformUserManagementServiceTest.java b/iam/src/test/java/com/baiyue/iam/service/PlatformUserManagementServiceTest.java index 67b7cfc39..f9e96fda5 100644 --- a/iam/src/test/java/com/baiyue/iam/service/PlatformUserManagementServiceTest.java +++ b/iam/src/test/java/com/baiyue/iam/service/PlatformUserManagementServiceTest.java @@ -201,6 +201,18 @@ class PlatformUserManagementServiceTest { "PLATFORM_USER_AUTHORIZATION_CHANGED"); } + @Test + void changingNameTogetherWithWarehouseStillRevokesSessions() { + when(auth.profile(eq("internal-token"), eq(10L), any())).thenReturn(ApiResponse.success(null)); + platformRole.setRoleCode("OVERSEAS_WAREHOUSE_PDA_OPERATOR"); + membership.setOverseasWarehouseId(900L); + service.update(30L, new UpdateUserRequest("新姓名", "13800138000", null, + 0L, List.of(40001L), 901L), Set.of("platform:user:grant-role")); + verify(auth).profile(eq("internal-token"), eq(10L), any()); + verify(auth).revokeSessions(eq("internal-token"), + eq(new AuthAccountClient.RevokeAccountSessionsRequest(Set.of(10L)))); + } + @Test void clearingWarehouseBindingAlsoRevokesAllAuthSessions() { platformRole.setRoleCode("WAREHOUSE_OPERATOR"); diff --git a/iam/src/test/java/com/baiyue/iam/service/TenantServiceTest.java b/iam/src/test/java/com/baiyue/iam/service/TenantServiceTest.java index 4b8e6074e..28eb7d4de 100644 --- a/iam/src/test/java/com/baiyue/iam/service/TenantServiceTest.java +++ b/iam/src/test/java/com/baiyue/iam/service/TenantServiceTest.java @@ -181,6 +181,30 @@ class TenantServiceTest { verify(repository, org.mockito.Mockito.times(2)).saveTenant(tenant); } + @Test + void disablingCustomerRevokesEveryMemberAccountAndRejectsFailedRevocation() { + IamTenant tenant = merchant(10001L, "ACTIVE"); + when(repository.findTenant(10001L)).thenReturn(tenant); + IamMembership first = new IamMembership(); first.setUserId(1L); + IamMembership second = new IamMembership(); second.setUserId(2L); + IamUser firstUser = new IamUser(); firstUser.setAuthUserId(101L); + IamUser secondUser = new IamUser(); secondUser.setAuthUserId(102L); + when(members.findByTenant(10001L)).thenReturn(List.of(first, second)); + when(members.findUser(1L)).thenReturn(firstUser); + when(members.findUser(2L)).thenReturn(secondUser); + var request = new AuthAccountClient.RevokeAccountSessionsRequest(Set.of(101L, 102L)); + when(authClient.revokeSessions("internal-token", request)).thenReturn(null); + assertThrows(BizException.class, + () -> service.changeMerchantStatus(10001L, new ChangeTenantStatusRequest("DISABLED"))); + verify(securityEvents, never()).tenantStatus(org.mockito.ArgumentMatchers.anyLong(), any()); + + when(authClient.revokeSessions("internal-token", request)) + .thenReturn(com.baiyue.common.core.api.ApiResponse.success(null)); + assertEquals("DISABLED", service.changeMerchantStatus(10001L, + new ChangeTenantStatusRequest("DISABLED")).status()); + verify(securityEvents).tenantStatus(10001L, "DISABLED"); + } + @Test void activeCustomerCanChangeCompanyContactPhoneWithoutChangingAnyLoginIdentity() { IamTenant tenant = merchant(10001L, "ACTIVE"); diff --git a/lastleg-service/pom.xml b/lastleg-service/pom.xml index 1314eb0d7..4c9968de3 100644 --- a/lastleg-service/pom.xml +++ b/lastleg-service/pom.xml @@ -22,6 +22,7 @@ org.springframework.bootspring-boot-starter-testtest com.tngtech.archunitarchunittest com.belerwebpinyin4j2.5.1 + com.h2databaseh2test org.springframework.bootspring-boot-maven-plugin diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/client/DirectEventClient.java b/lastleg-service/src/main/java/com/baiyue/lastleg/client/DirectEventClient.java new file mode 100644 index 000000000..4aa290029 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/client/DirectEventClient.java @@ -0,0 +1,28 @@ +package com.baiyue.lastleg.client; + +import com.baiyue.common.core.api.ApiResponse; +import com.baiyue.common.event.DeliveryAck; +import com.baiyue.common.event.EventEnvelope; +import com.baiyue.common.event.EventTypes; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; + +/** Dispatches each supported business event directly to its owning service. */ +@Component +@RequiredArgsConstructor +public class DirectEventClient { + private final FirstlegEventClient firstleg; + private final FinanceBusinessEventClient finance; + + public ApiResponse receive(String token, EventEnvelope event) { + event.requireValid(); + return switch (event.eventType()) { + case EventTypes.OVERSEAS_CONTAINER_UNLOADED, + EventTypes.OVERSEAS_INBOUND_ORDER_DETAINED_CHANGED, + EventTypes.OVERSEAS_DELIVERY_COMPLETED -> firstleg.receive(token, event); + case EventTypes.WALLET_COMMAND_REQUESTED -> finance.wallet(token, event); + case EventTypes.BUSINESS_FEE_SOURCE_CHANGED -> finance.fee(token, event); + default -> throw new IllegalArgumentException("Unsupported outbound event type: " + event.eventType()); + }; + } +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/client/EventBridgeClient.java b/lastleg-service/src/main/java/com/baiyue/lastleg/client/EventBridgeClient.java deleted file mode 100644 index 5a09c0bc4..000000000 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/client/EventBridgeClient.java +++ /dev/null @@ -1,20 +0,0 @@ -package com.baiyue.lastleg.client; - -import com.baiyue.common.core.api.ApiResponse; -import com.baiyue.common.event.EventEnvelope; -import org.springframework.cloud.openfeign.FeignClient; -import org.springframework.web.bind.annotation.PostMapping; -import org.springframework.web.bind.annotation.RequestBody; -import org.springframework.web.bind.annotation.RequestHeader; - -/** 将海外业务 Outbox 投递到国内区域事件桥。 */ -@FeignClient(name = "event-bridge-service", contextId = "overseasContainerEventBridgeClient", - url = "${CN_EVENT_BRIDGE_URL:${baiyue.firstleg.event-bridge-url:}}") -public interface EventBridgeClient { - @PostMapping("/internal/event-bridge/events") - ApiResponse receive( - @RequestHeader("X-Internal-Service-Token") String token, - @RequestBody EventEnvelope envelope); - - record Acknowledgement(String eventId, String result, String receivedAt) { } -} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/client/FinanceBusinessEventClient.java b/lastleg-service/src/main/java/com/baiyue/lastleg/client/FinanceBusinessEventClient.java new file mode 100644 index 000000000..96e35de55 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/client/FinanceBusinessEventClient.java @@ -0,0 +1,21 @@ +package com.baiyue.lastleg.client; + +import com.baiyue.common.core.api.ApiResponse; +import com.baiyue.common.web.EventDeliveryFeignConfiguration; +import com.baiyue.common.event.DeliveryAck; +import com.baiyue.common.event.EventEnvelope; +import org.springframework.cloud.openfeign.FeignClient; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestHeader; + +@FeignClient(configuration = EventDeliveryFeignConfiguration.class, name = "finance-service", contextId = "FinanceBusinessEventClient", + url = "${FINANCE_SERVICE_URL:${baiyue.clients.finance.url:}}") +public interface FinanceBusinessEventClient { + @PostMapping("/internal/finance/v1/wallet-command-events") + ApiResponse wallet(@RequestHeader("X-Internal-Service-Token") String token, + @RequestBody EventEnvelope envelope); + @PostMapping("/internal/finance/v1/business-fee-source-events") + ApiResponse fee(@RequestHeader("X-Internal-Service-Token") String token, + @RequestBody EventEnvelope envelope); +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/client/FirstlegEventClient.java b/lastleg-service/src/main/java/com/baiyue/lastleg/client/FirstlegEventClient.java new file mode 100644 index 000000000..d3a07830a --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/client/FirstlegEventClient.java @@ -0,0 +1,18 @@ +package com.baiyue.lastleg.client; + +import com.baiyue.common.core.api.ApiResponse; +import com.baiyue.common.web.EventDeliveryFeignConfiguration; +import com.baiyue.common.event.DeliveryAck; +import com.baiyue.common.event.EventEnvelope; +import org.springframework.cloud.openfeign.FeignClient; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestHeader; + +@FeignClient(configuration = EventDeliveryFeignConfiguration.class, name = "firstleg-service", contextId = "FirstlegEventClient", + url = "${FIRSTLEG_SERVICE_URL:${baiyue.clients.firstleg.url:}}") +public interface FirstlegEventClient { + @PostMapping("/internal/firstleg/v1/container-events") + ApiResponse receive(@RequestHeader("X-Internal-Service-Token") String token, + @RequestBody EventEnvelope envelope); +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/controller/LastlegOrderController.java b/lastleg-service/src/main/java/com/baiyue/lastleg/controller/LastlegOrderController.java index 1b283ce63..e7312a6f9 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/controller/LastlegOrderController.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/controller/LastlegOrderController.java @@ -3,6 +3,7 @@ package com.baiyue.lastleg.controller; import com.baiyue.common.core.api.ApiResponse; import com.baiyue.common.core.api.PageResult; import com.baiyue.common.event.EventEnvelope; +import com.baiyue.common.event.DeliveryAck; import com.baiyue.common.security.AuthenticatedUser; import com.baiyue.lastleg.dto.LastlegOrderDetailResponse; import com.baiyue.lastleg.dto.LastlegOrderRequests; @@ -60,11 +61,11 @@ public class LastlegOrderController { @PostMapping("/internal/lastleg/events") @Operation(summary = "Receive a first-leg loading or actual shipping-node event", description = "Accepts loading-completed V1/V2 and FirstLegContainerShippingNodeRecorded V1 through the internal token boundary; only monotonic actual PICKUP_READY-or-later facts open overseas PDA receiving.") - public ApiResponse receive( + public ApiResponse receive( @RequestHeader(value = "X-Internal-Service-Token", required = false) String token, @RequestBody EventEnvelope event) { tokenGuard.verify(token); - int consumed; + DeliveryAck consumed; if (EventTypes.FIRST_LEG_CONTAINER_SHIPPING_NODE_RECORDED.equals(event.eventType())) { consumed = shippingNodeConsumer.consume(event); } else if (EventTypes.WALLET_COMMAND_COMPLETED.equals(event.eventType())) { diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/financefee/repository/OverseasFeeOutboxRepository.java b/lastleg-service/src/main/java/com/baiyue/lastleg/financefee/repository/OverseasFeeOutboxRepository.java index c1d28ee5a..d37fab88b 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/financefee/repository/OverseasFeeOutboxRepository.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/financefee/repository/OverseasFeeOutboxRepository.java @@ -27,8 +27,11 @@ public class OverseasFeeOutboxRepository { public boolean claim(long id, String worker, LocalDateTime now, LocalDateTime stale) { return mapper.update(null, new LambdaUpdateWrapper() .eq(LastlegEventOutbox::getId, id).eq(LastlegEventOutbox::getEventType, EventTypes.BUSINESS_FEE_SOURCE_CHANGED) - .and(status -> status.eq(LastlegEventOutbox::getStatus, "PENDING").or(processing -> + .and(status -> status.eq(LastlegEventOutbox::getStatus, "PENDING") + .and(retry -> retry.isNull(LastlegEventOutbox::getNextRetryAt).or().le(LastlegEventOutbox::getNextRetryAt, now)) + .or(processing -> processing.eq(LastlegEventOutbox::getStatus, "PROCESSING").le(LastlegEventOutbox::getLockedAt, stale))) + .setSql("retry_count = retry_count + CASE WHEN status='PROCESSING' THEN 1 ELSE 0 END") .set(LastlegEventOutbox::getStatus, "PROCESSING").set(LastlegEventOutbox::getLockedBy, worker) .set(LastlegEventOutbox::getLockedAt, now)) == 1; } @@ -40,6 +43,12 @@ public class OverseasFeeOutboxRepository { mapper.update(null, claimed(id, worker, "PUBLISHED").set(LastlegEventOutbox::getPublishedAt, now) .set(LastlegEventOutbox::getNextRetryAt, null).set(LastlegEventOutbox::getLastError, null)); } + public void dead(long id, String worker, int failures, String error) { + mapper.update(null, claimed(id, worker, "DEAD") + .set(LastlegEventOutbox::getRetryCount, failures) + .set(LastlegEventOutbox::getNextRetryAt, null) + .set(LastlegEventOutbox::getLastError, truncate(error))); + } public void retry(long id, String worker, int retryCount, LocalDateTime next, String error) { mapper.update(null, claimed(id, worker, "PENDING").set(LastlegEventOutbox::getRetryCount, retryCount) .set(LastlegEventOutbox::getNextRetryAt, next).set(LastlegEventOutbox::getLastError, truncate(error))); diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/financefee/service/OverseasFeeOutboxRelay.java b/lastleg-service/src/main/java/com/baiyue/lastleg/financefee/service/OverseasFeeOutboxRelay.java index 5ec65a694..571f6c90d 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/financefee/service/OverseasFeeOutboxRelay.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/financefee/service/OverseasFeeOutboxRelay.java @@ -1,7 +1,8 @@ package com.baiyue.lastleg.financefee.service; import com.baiyue.common.event.EventEnvelope; -import com.baiyue.lastleg.client.EventBridgeClient; +import com.baiyue.common.core.retry.BoundedRetryPolicy; +import com.baiyue.lastleg.client.DirectEventClient; import com.baiyue.lastleg.entity.LastlegEventOutbox; import com.baiyue.lastleg.financefee.repository.OverseasFeeOutboxRepository; import com.fasterxml.jackson.databind.ObjectMapper; @@ -10,7 +11,6 @@ import org.springframework.beans.factory.annotation.Value; import org.springframework.scheduling.annotation.Scheduled; import org.springframework.stereotype.Component; -import java.lang.management.ManagementFactory; import java.time.LocalDateTime; import java.time.ZoneOffset; import java.util.UUID; @@ -18,28 +18,45 @@ import java.util.UUID; @Component public class OverseasFeeOutboxRelay { @Resource private OverseasFeeOutboxRepository repository; - @Resource private EventBridgeClient client; + @Resource private DirectEventClient client; @Resource private ObjectMapper objectMapper; @Value("${baiyue.security.internal-service-token:}") private String token; - private final String worker = ManagementFactory.getRuntimeMXBean().getName() + ':' + UUID.randomUUID(); + + @Value("${baiyue.event-delivery.max-attempts:12}") private int maxAttempts = 12; + @Value("${baiyue.event-delivery.base-delay-seconds:5}") private long baseDelaySeconds = 5; + @Value("${baiyue.event-delivery.max-delay-seconds:1800}") private long maxDelaySeconds = 1800; + @Value("${baiyue.event-delivery.jitter:0.1}") private double retryJitter = 0.1; + + @Value("${baiyue.event-delivery.lease-seconds:60}") private long leaseSeconds = 60; @Scheduled(fixedDelayString = "${baiyue.finance-fee-relay.fixed-delay:3000}") public void relay() { + if (leaseSeconds < 1) throw new IllegalArgumentException("Lease duration must be positive"); + var policy = new BoundedRetryPolicy(maxAttempts, baseDelaySeconds, maxDelaySeconds, retryJitter); LocalDateTime now = LocalDateTime.now(ZoneOffset.UTC); - for (LastlegEventOutbox candidate : repository.pending(50, now, now.minusSeconds(30))) { - if (!repository.claim(candidate.getId(), worker, now, now.minusSeconds(30))) continue; + for (LastlegEventOutbox candidate : repository.pending(50, now, now.minusSeconds(leaseSeconds))) { + String worker = UUID.randomUUID().toString(); + now = LocalDateTime.now(ZoneOffset.UTC); + + if (!repository.claim(candidate.getId(), worker, now, now.minusSeconds(leaseSeconds))) continue; LastlegEventOutbox row = repository.claimed(candidate.getId(), worker); if (row == null) continue; + int failures = row.getRetryCount() == null ? 0 : row.getRetryCount(); + if (policy.exhausted(failures)) { + repository.dead(row.getId(), worker, failures, "自动投递次数已耗尽(含租约超时)"); + continue; + } try { EventEnvelope envelope = objectMapper.readValue(row.getPayloadJson(), EventEnvelope.class).requireValid(); var response = client.receive(token, envelope); - if (response == null || !"200".equals(response.code()) || response.data() == null - || !envelope.eventId().equals(response.data().eventId())) - throw new IllegalStateException("Event Bridge未确认海外费用事件"); + if (response == null || !response.successful() || response.data() == null) + throw new IllegalStateException("Finance未确认费用事件"); + response.data().requireMatches(envelope); repository.published(row.getId(), worker, LocalDateTime.now(ZoneOffset.UTC)); } catch (Exception exception) { - int retries = row.getRetryCount() == null ? 1 : row.getRetryCount() + 1; - repository.retry(row.getId(), worker, retries, now.plusSeconds(Math.min(900, 5L << Math.min(retries, 7))), exception.getMessage()); + int retries = failures + 1; + if (policy.exhausted(retries)) repository.dead(row.getId(), worker, retries, exception.getMessage()); + else repository.retry(row.getId(), worker, retries, LocalDateTime.now(ZoneOffset.UTC).plus(policy.delayAfterFailure(retries)), exception.getMessage()); } } } diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/service/InboundUnloadCompletedOutboxRelay.java b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/service/InboundUnloadCompletedOutboxRelay.java index b7988f67d..2cbf52ffb 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/service/InboundUnloadCompletedOutboxRelay.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/service/InboundUnloadCompletedOutboxRelay.java @@ -1,8 +1,10 @@ package com.baiyue.lastleg.inboundplan.pda.service; import com.baiyue.common.event.EventEnvelope; +import com.baiyue.common.core.retry.BoundedRetryPolicy; +import com.baiyue.common.event.DeliveryAck; import com.baiyue.common.event.EventEnvelopeValidator; -import com.baiyue.lastleg.client.EventBridgeClient; +import com.baiyue.lastleg.client.DirectEventClient; import com.baiyue.lastleg.entity.LastlegEventOutbox; import com.baiyue.lastleg.repository.UnloadCompletedOutboxRepository; import com.fasterxml.jackson.databind.ObjectMapper; @@ -15,26 +17,40 @@ import org.springframework.stereotype.Component; import java.time.LocalDateTime; import java.util.UUID; -/** 至少一次投递海外到国内业务事件;国内事件桥按 eventId 幂等接收。 */ +/** 至少一次投递海外到国内业务事件;消费服务按 eventId 幂等接收。 */ @Component @Slf4j public class InboundUnloadCompletedOutboxRelay { @Resource private UnloadCompletedOutboxRepository repository; - @Resource private EventBridgeClient client; + @Resource private DirectEventClient client; @Resource private ObjectMapper objectMapper; @Value("${baiyue.security.internal-service-token}") private String internalToken; - @Value("${baiyue.firstleg.outbox-relay.lease-seconds:60}") private long leaseSeconds; - @Value("${baiyue.firstleg.outbox-relay.retry-seconds:30}") private long retrySeconds; - private final String workerId = UUID.randomUUID().toString(); + @Value("${baiyue.event-delivery.lease-seconds:60}") private long leaseSeconds = 60; + + @Value("${baiyue.event-delivery.max-attempts:12}") private int maxAttempts = 12; + @Value("${baiyue.event-delivery.base-delay-seconds:5}") private long baseDelaySeconds = 5; + @Value("${baiyue.event-delivery.max-delay-seconds:1800}") private long maxDelaySeconds = 1800; + @Value("${baiyue.event-delivery.jitter:0.1}") private double retryJitter = 0.1; @Scheduled(fixedDelayString = "${baiyue.firstleg.outbox-relay.fixed-delay-ms:5000}") public void relay() { + if (leaseSeconds < 1) throw new IllegalArgumentException("Lease duration must be positive"); + var policy = new BoundedRetryPolicy(maxAttempts, baseDelaySeconds, maxDelaySeconds, retryJitter); LocalDateTime now = LocalDateTime.now(); - LocalDateTime expiredLeaseBefore = now.minusSeconds(Math.max(leaseSeconds, 10)); + LocalDateTime expiredLeaseBefore = now.minusSeconds(leaseSeconds); for (LastlegEventOutbox candidate : repository.pending(50, now, expiredLeaseBefore)) { + String workerId = UUID.randomUUID().toString(); + now = LocalDateTime.now(); + expiredLeaseBefore = now.minusSeconds(leaseSeconds); + if (!repository.claim(candidate.getId(), workerId, now, expiredLeaseBefore)) continue; LastlegEventOutbox row = repository.claimed(candidate.getId(), workerId); if (row == null) continue; + int failures = row.getRetryCount() == null ? 0 : row.getRetryCount(); + if (policy.exhausted(failures)) { + repository.dead(row.getId(), workerId, failures, "自动投递次数已耗尽(含租约超时)"); + continue; + } try { EventEnvelope event = objectMapper.readValue(row.getPayloadJson(), EventEnvelope.class); EventEnvelopeValidator.requireValid(event); @@ -42,7 +58,8 @@ public class InboundUnloadCompletedOutboxRelay { requireAcknowledgement(event, response); repository.published(row.getId(), workerId, LocalDateTime.now()); } catch (Exception exception) { - repository.retry(row.getId(), workerId, LocalDateTime.now().plusSeconds(Math.max(retrySeconds, 1)), + if (policy.exhausted(failures + 1)) repository.dead(row.getId(), workerId, failures + 1, exception.getMessage()); + else repository.retry(row.getId(), workerId, LocalDateTime.now().plus(policy.delayAfterFailure(failures + 1)), exception.getMessage()); log.warn("海外到国内事件投递失败: eventKey={}, reason={}", row.getEventKey(), exception.getMessage()); } @@ -50,16 +67,13 @@ public class InboundUnloadCompletedOutboxRelay { } private void requireAcknowledgement(EventEnvelope event, - com.baiyue.common.core.api.ApiResponse response) { - if (response == null) throw new IllegalStateException("国内事件桥返回空响应"); + com.baiyue.common.core.api.ApiResponse response) { + if (response == null) throw new IllegalStateException("消费服务返回空响应"); if (!response.successful()) { - throw new IllegalStateException("国内事件桥拒绝海外事件: code=" + response.code() + throw new IllegalStateException("消费服务拒绝海外事件: code=" + response.code() + ", message=" + response.message() + ", requestId=" + response.requestId()); } - if (response.data() == null) throw new IllegalStateException("国内事件桥成功响应缺少确认数据"); - if (!event.eventId().equals(response.data().eventId())) { - throw new IllegalStateException("国内事件桥确认eventId不一致: expected=" + event.eventId() - + ", actual=" + response.data().eventId()); - } + if (response.data() == null) throw new IllegalStateException("消费服务成功响应缺少确认数据"); + response.data().requireMatches(event); } } diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/repository/UnloadCompletedOutboxRepository.java b/lastleg-service/src/main/java/com/baiyue/lastleg/repository/UnloadCompletedOutboxRepository.java index 279b9e728..bbce4b4b1 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/repository/UnloadCompletedOutboxRepository.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/repository/UnloadCompletedOutboxRepository.java @@ -41,6 +41,7 @@ public class UnloadCompletedOutboxRepository { .or().le(LastlegEventOutbox::getNextRetryAt, now)) .or(stale -> stale.eq(LastlegEventOutbox::getStatus, "PROCESSING") .le(LastlegEventOutbox::getLockedAt, expiredLeaseBefore))) + .setSql("retry_count = retry_count + CASE WHEN status='PROCESSING' THEN 1 ELSE 0 END") .set(LastlegEventOutbox::getStatus, "PROCESSING") .set(LastlegEventOutbox::getLockedBy, worker) .set(LastlegEventOutbox::getLockedAt, now)) == 1; @@ -64,6 +65,14 @@ public class UnloadCompletedOutboxRepository { .set(LastlegEventOutbox::getLastError, null)); } + public void dead(long id, String worker, int failures, String error) { + mapper.update(null, claimedBase(id, worker) + .set(LastlegEventOutbox::getStatus, "DEAD") + .set(LastlegEventOutbox::getRetryCount, failures) + .set(LastlegEventOutbox::getNextRetryAt, null) + .set(LastlegEventOutbox::getLockedBy, null).set(LastlegEventOutbox::getLockedAt, null) + .set(LastlegEventOutbox::getLastError, truncate(error))); + } public void retry(long id, String worker, LocalDateTime retryAt, String error) { mapper.update(null, claimedBase(id, worker) .setSql("retry_count = retry_count + 1") diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/service/LoadingCompletedConsumer.java b/lastleg-service/src/main/java/com/baiyue/lastleg/service/LoadingCompletedConsumer.java index 15e448879..d3ca0bc7c 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/service/LoadingCompletedConsumer.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/service/LoadingCompletedConsumer.java @@ -51,7 +51,7 @@ public class LoadingCompletedConsumer { @Resource private ContainerLoadingEvidenceMapper loadingEvidence; @Transactional(isolation = Isolation.READ_COMMITTED) - public int consume(EventEnvelope envelope) { + public DeliveryAck consume(EventEnvelope envelope) { envelope.requireValid(); if (!EventTypes.FIRST_LEG_LOADING_PLAN_COMPLETED.equals(envelope.eventType()) || (envelope.eventVersion() != 1 && envelope.eventVersion() != 2 && envelope.eventVersion() != 3)) @@ -72,7 +72,8 @@ public class LoadingCompletedConsumer { LastlegEventInbox existing=inboxMapper.selectById(envelope.eventId()); if(existing!=null) { if(!existing.getPayloadHash().equalsIgnoreCase(envelope.payloadHash())) throw new IllegalArgumentException("事件摘要冲突"); - return countOrders(envelope,containerNo); + if (!"PROCESSED".equals(existing.getStatus())) throw new IllegalStateException("事件尚未完成消费"); + return DeliveryAck.of(envelope, DeliveryAck.Outcome.DUPLICATE); } LastlegEventInbox inbox=new LastlegEventInbox(); @@ -85,7 +86,8 @@ public class LoadingCompletedConsumer { || !raced.getPayloadHash().equalsIgnoreCase(envelope.payloadHash())) { throw new IllegalArgumentException("事件摘要冲突", exception); } - return countOrders(envelope,containerNo); + if (!"PROCESSED".equals(raced.getStatus())) throw new IllegalStateException("事件尚未完成消费"); + return DeliveryAck.of(envelope, DeliveryAck.Outcome.DUPLICATE); } projectLoadingEvidence(envelope, sourceContainerBookingId); @@ -102,7 +104,7 @@ public class LoadingCompletedConsumer { sourceContainerBookingId, owner); } inbox.setStatus("PROCESSED"); inbox.setProcessedAt(LocalDateTime.now()); inboxMapper.updateById(inbox); - return payload.orders().size(); + return DeliveryAck.of(envelope, DeliveryAck.Outcome.APPLIED); } private void projectLoadingEvidence(EventEnvelope envelope, String sourceContainerBookingId) { diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/shipping/service/ContainerShippingNodeConsumer.java b/lastleg-service/src/main/java/com/baiyue/lastleg/shipping/service/ContainerShippingNodeConsumer.java index a0787bdce..e140c2cee 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/shipping/service/ContainerShippingNodeConsumer.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/shipping/service/ContainerShippingNodeConsumer.java @@ -1,6 +1,7 @@ package com.baiyue.lastleg.shipping.service; import com.baiyue.common.event.EventEnvelope; +import com.baiyue.common.event.DeliveryAck; import com.baiyue.common.event.EventPayloadCanonicalizer; import com.baiyue.common.event.EventPayloadHash; import com.baiyue.common.event.EventTypes; @@ -28,7 +29,7 @@ public class ContainerShippingNodeConsumer { @Resource private ObjectMapper objectMapper; @Transactional(isolation = Isolation.READ_COMMITTED) - public int consume(EventEnvelope envelope) { + public DeliveryAck consume(EventEnvelope envelope) { validate(envelope); FirstLegContainerShippingNodeRecordedV1 payload = payload(envelope); validateEnvelopePayload(envelope, payload); @@ -37,7 +38,8 @@ public class ContainerShippingNodeConsumer { if (!existing.getPayloadHash().equalsIgnoreCase(envelope.payloadHash())) { throw new IllegalArgumentException("船节点事件摘要冲突"); } - return 1; + if (!"PROCESSED".equals(existing.getStatus())) throw new IllegalStateException("事件尚未完成消费"); + return DeliveryAck.of(envelope, DeliveryAck.Outcome.DUPLICATE); } LastlegEventInbox inbox = inbox(envelope); try { inboxes.insert(inbox); } @@ -46,7 +48,8 @@ public class ContainerShippingNodeConsumer { if (concurrent == null || !concurrent.getPayloadHash().equalsIgnoreCase(envelope.payloadHash())) { throw new IllegalArgumentException("船节点事件摘要冲突", raced); } - return 1; + if (!"PROCESSED".equals(concurrent.getStatus())) throw new IllegalStateException("事件尚未完成消费"); + return DeliveryAck.of(envelope, DeliveryAck.Outcome.DUPLICATE); } gates.initialize(payload.containerBookingId(), payload.oceanBookingId(), @@ -55,13 +58,13 @@ public class ContainerShippingNodeConsumer { if (gate == null || !payload.containerNo().equals(gate.getContainerNo())) { throw new IllegalArgumentException("船节点柜号快照与装柜交接不一致"); } - apply(gate, envelope, payload); + boolean applied = apply(gate, envelope, payload); gates.updateById(gate); inbox.setStatus("PROCESSED"); inbox.setProcessedAt(LocalDateTime.now()); inboxes.updateById(inbox); - return 1; + return DeliveryAck.of(envelope, applied ? DeliveryAck.Outcome.APPLIED : DeliveryAck.Outcome.STALE); } - private void apply(ContainerShippingGate gate, EventEnvelope envelope, + private boolean apply(ContainerShippingGate gate, EventEnvelope envelope, FirstLegContainerShippingNodeRecordedV1 payload) { long currentVersion = gate.getAggregateVersion() == null ? 0 : gate.getAggregateVersion(); int currentSequence = gate.getNodeSequence() == null ? 0 : gate.getNodeSequence(); @@ -88,6 +91,7 @@ public class ContainerShippingNodeConsumer { gate.setHandoverVersion(Math.max(gate.getHandoverVersion() == null ? 0 : gate.getHandoverVersion(), envelope.handoverVersion())); gate.setUpdatedAt(LocalDateTime.now()); + return accepted; } private void validate(EventEnvelope envelope) { diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/wallet/WalletCommandCompletedConsumer.java b/lastleg-service/src/main/java/com/baiyue/lastleg/wallet/WalletCommandCompletedConsumer.java index a95dd7e06..78a9ad765 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/wallet/WalletCommandCompletedConsumer.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/wallet/WalletCommandCompletedConsumer.java @@ -1,6 +1,7 @@ package com.baiyue.lastleg.wallet; import com.baiyue.common.event.EventEnvelope; +import com.baiyue.common.event.DeliveryAck; import com.baiyue.common.event.EventEnvelopeValidator; import com.baiyue.common.event.EventPayloadCanonicalizer; import com.baiyue.common.event.EventPayloadHash; @@ -26,23 +27,27 @@ public class WalletCommandCompletedConsumer { private final ObjectMapper objectMapper; private final List handlers; - @Transactional - public int consume(EventEnvelope envelope) { + @Transactional(isolation = org.springframework.transaction.annotation.Isolation.READ_COMMITTED) + public DeliveryAck consume(EventEnvelope envelope) { WalletCommandCompletedV1 result = validate(envelope); - List existing = jdbc.queryForList("SELECT payload_hash FROM ll_wallet_event_inbox WHERE event_id=?", String.class, envelope.eventId()); + List existing = jdbc.queryForList("SELECT payload_hash FROM ll_wallet_event_inbox WHERE event_id=? AND status='PROCESSED'", String.class, envelope.eventId()); if (!existing.isEmpty()) { if (!existing.get(0).equals(envelope.payloadHash())) throw bad("同一资金结果事件ID对应不同载荷"); - return 0; + return DeliveryAck.of(envelope, DeliveryAck.Outcome.DUPLICATE); } try { jdbc.update("INSERT INTO ll_wallet_event_inbox(event_id,command_event_id,payload_hash,payload_json,status,received_at) VALUES(?,?,?,CAST(? AS JSON),'RECEIVED',NOW(3))", envelope.eventId(), result.commandEventId(), envelope.payloadHash(), json(envelope)); - } catch (DuplicateKeyException exception) { return 0; } + } catch (DuplicateKeyException exception) { + List raced = jdbc.queryForList("SELECT payload_hash FROM ll_wallet_event_inbox WHERE event_id=? AND status='PROCESSED'", String.class, envelope.eventId()); + if (raced.size() != 1 || !raced.get(0).equals(envelope.payloadHash())) throw bad("资金结果事件身份冲突或尚未完成"); + return DeliveryAck.of(envelope, DeliveryAck.Outcome.DUPLICATE); + } WalletResultHandler handler = handlers.stream().filter(value -> value.supports(result.businessType())).findFirst() .orElseThrow(() -> bad("没有可处理该资金结果的业务模块")); handler.handle(result); jdbc.update("UPDATE ll_wallet_event_inbox SET status='PROCESSED',processed_at=NOW(3) WHERE event_id=?", envelope.eventId()); - return 1; + return DeliveryAck.of(envelope, DeliveryAck.Outcome.APPLIED); } private WalletCommandCompletedV1 validate(EventEnvelope envelope) { diff --git a/lastleg-service/src/main/resources/application.yml b/lastleg-service/src/main/resources/application.yml index 07e47ba18..6164da56a 100644 --- a/lastleg-service/src/main/resources/application.yml +++ b/lastleg-service/src/main/resources/application.yml @@ -88,10 +88,9 @@ baiyue: lease-seconds: ${FIRSTLEG_OUTBOX_RELAY_LEASE_SECONDS:60} retry-seconds: ${FIRSTLEG_OUTBOX_RELAY_RETRY_SECONDS:30} security: - gateway-internal-service-token: ${GATEWAY_INTERNAL_SERVICE_TOKEN:${INTERNAL_SERVICE_TOKEN:}} internal-service-token: ${INTERNAL_SERVICE_TOKEN:} jwt-mode: jwks - session-mode: gateway + session-mode: redis issuer: ${JWT_ISSUER:auth-service} audience: ${JWT_AUDIENCE:lastleg-api} - jwk-set-uri: ${JWT_JWK_SET_URI:http://127.0.0.1:9100/api/auth/.well-known/jwks.json} + jwk-set-uri: ${JWT_JWKS_URI:http://auth-service:9100/api/auth/.well-known/jwks.json} diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/client/DirectEventClientTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/client/DirectEventClientTest.java new file mode 100644 index 000000000..2235aa467 --- /dev/null +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/client/DirectEventClientTest.java @@ -0,0 +1,77 @@ +package com.baiyue.lastleg.client; + +import com.baiyue.common.core.api.ApiResponse; +import com.baiyue.common.event.*; +import com.fasterxml.jackson.databind.ObjectMapper; +import org.junit.jupiter.api.Test; +import org.springframework.cloud.openfeign.FeignClient; +import org.springframework.web.bind.annotation.PostMapping; +import java.time.Instant; +import static org.assertj.core.api.Assertions.*; +import static org.mockito.Mockito.*; + +class DirectEventClientTest { + private final FirstlegEventClient firstleg = mock(FirstlegEventClient.class); + private final FinanceBusinessEventClient finance = mock(FinanceBusinessEventClient.class); + private final DirectEventClient client = new DirectEventClient(firstleg, finance); + + @Test void walletIsSentOnlyToFinance() { + var event = event(EventTypes.WALLET_COMMAND_REQUESTED); + var ack = ApiResponse.success(DeliveryAck.of(event, DeliveryAck.Outcome.APPLIED)); + when(finance.wallet("token", event)).thenReturn(ack); + assertThat(client.receive("token", event)).isSameAs(ack); + verify(finance).wallet("token", event); + verifyNoMoreInteractions(finance); + verifyNoInteractions(firstleg); + } + + @Test void feeIsSentOnlyToFinance() { + var event = event(EventTypes.BUSINESS_FEE_SOURCE_CHANGED); + client.receive("token", event); + verify(finance).fee("token", event); + verifyNoMoreInteractions(finance); + verifyNoInteractions(firstleg); + } + + @Test void fulfillmentEventsAreSentOnlyToFirstleg() { + for (String type : new String[]{EventTypes.OVERSEAS_CONTAINER_UNLOADED, + EventTypes.OVERSEAS_INBOUND_ORDER_DETAINED_CHANGED, EventTypes.OVERSEAS_DELIVERY_COMPLETED}) { + var event = event(type); + client.receive("token", event); + verify(firstleg).receive("token", event); + } + verifyNoInteractions(finance); + } + + @Test void unsupportedEventsHaveNoDefaultDestination() { + assertThatThrownBy(() -> client.receive("token", event(EventTypes.WALLET_COMMAND_COMPLETED))) + .isInstanceOf(IllegalArgumentException.class).hasMessageContaining("Unsupported outbound"); + verifyNoInteractions(firstleg, finance); + } + + @Test void declaredEndpointsMatchTheSharedRouteTable() throws Exception { + assertEndpoint(FirstlegEventClient.class, "receive", "FIRSTLEG_SERVICE_URL", "firstleg", + EventTypes.OVERSEAS_CONTAINER_UNLOADED); + assertEndpoint(FinanceBusinessEventClient.class, "wallet", "FINANCE_SERVICE_URL", "finance", + EventTypes.WALLET_COMMAND_REQUESTED); + assertEndpoint(FinanceBusinessEventClient.class, "fee", "FINANCE_SERVICE_URL", "finance", + EventTypes.BUSINESS_FEE_SOURCE_CHANGED); + } + + private void assertEndpoint(Class type, String method, String env, String property, String eventType) throws Exception { + var route = EventDeliveryRoute.forEventType(eventType); + var annotation = type.getAnnotation(FeignClient.class); + assertThat(annotation.name()).isEqualTo(route.serviceName()); + assertThat(annotation.url()).isEqualTo("${" + env + ":${baiyue.clients." + property + ".url:}}"); + assertThat(type.getMethod(method, String.class, EventEnvelope.class).getAnnotation(PostMapping.class).value()) + .containsExactly(route.path()); + } + + private EventEnvelope event(String type) { + var payload = new ObjectMapper().createObjectNode().put("businessNo", "test"); + return new EventEnvelope("00000000-0000-4000-8000-000000000009", type, 1, Instant.now(), + RegionCode.OVERSEAS, RegionCode.CN, "1", "BUSINESS", "1", 1L, null, + "trace", "correlation", null, payload, + EventPayloadHash.sha256(EventPayloadCanonicalizer.canonicalize(payload))); + } +} diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/client/EventBridgeClientContractTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/client/EventBridgeClientContractTest.java deleted file mode 100644 index 5891459d7..000000000 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/client/EventBridgeClientContractTest.java +++ /dev/null @@ -1,16 +0,0 @@ -package com.baiyue.lastleg.client; - -import org.junit.jupiter.api.Test; -import org.springframework.cloud.openfeign.FeignClient; - -import static org.assertj.core.api.Assertions.assertThat; - -class EventBridgeClientContractTest { - @Test - void canonicalDomesticBridgeEnvironmentOverridesLegacyNacosProperty() { - FeignClient contract = EventBridgeClient.class.getAnnotation(FeignClient.class); - - assertThat(contract.url()) - .isEqualTo("${CN_EVENT_BRIDGE_URL:${baiyue.firstleg.event-bridge-url:}}"); - } -} diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/financefee/OverseasFeeOutboxRetryTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/financefee/OverseasFeeOutboxRetryTest.java new file mode 100644 index 000000000..3291ef092 --- /dev/null +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/financefee/OverseasFeeOutboxRetryTest.java @@ -0,0 +1,35 @@ +package com.baiyue.lastleg.financefee; + +import com.baiyue.lastleg.client.DirectEventClient; +import com.baiyue.lastleg.entity.LastlegEventOutbox; +import com.baiyue.lastleg.financefee.repository.OverseasFeeOutboxRepository; +import com.baiyue.lastleg.financefee.service.OverseasFeeOutboxRelay; +import com.fasterxml.jackson.databind.ObjectMapper; +import org.junit.jupiter.api.Test; +import org.springframework.test.util.ReflectionTestUtils; +import java.util.List; +import static org.mockito.ArgumentMatchers.*; +import static org.mockito.Mockito.*; + +class OverseasFeeOutboxRetryTest { + @Test void failedPayloadAtLimitGoesDeadAndExhaustedLeaseNeverCallsClient() { + var repository = mock(OverseasFeeOutboxRepository.class); + var client = mock(DirectEventClient.class); + var relay = new OverseasFeeOutboxRelay(); + ReflectionTestUtils.setField(relay, "repository", repository); + ReflectionTestUtils.setField(relay, "client", client); + ReflectionTestUtils.setField(relay, "objectMapper", new ObjectMapper()); + var row = new LastlegEventOutbox(); + row.setId(1L); row.setRetryCount(11); row.setPayloadJson("invalid-json"); + when(repository.pending(eq(50), any(), any())).thenReturn(List.of(row)); + when(repository.claim(eq(1L), anyString(), any(), any())).thenReturn(true); + when(repository.claimed(eq(1L), anyString())).thenReturn(row); + relay.relay(); + row.setRetryCount(12); + relay.relay(); + verify(repository, times(2)).dead(eq(1L), anyString(), eq(12), anyString()); + verify(repository, never()).retry(anyLong(), anyString(), anyInt(), any(), anyString()); + verify(repository, never()).published(anyLong(), anyString(), any()); + verifyNoInteractions(client); + } +} diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/pda/service/InboundUnloadCompletedOutboxRelayTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/pda/service/InboundUnloadCompletedOutboxRelayTest.java index 472331057..49d3ee511 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/pda/service/InboundUnloadCompletedOutboxRelayTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/pda/service/InboundUnloadCompletedOutboxRelayTest.java @@ -2,12 +2,13 @@ package com.baiyue.lastleg.inboundplan.pda.service; import com.baiyue.common.core.api.ApiResponse; import com.baiyue.common.event.EventEnvelope; +import com.baiyue.common.event.DeliveryAck; import com.baiyue.common.event.EventPayloadCanonicalizer; import com.baiyue.common.event.EventPayloadHash; import com.baiyue.common.event.EventTypes; import com.baiyue.common.event.OverseasContainerUnloadedV1; import com.baiyue.common.event.RegionCode; -import com.baiyue.lastleg.client.EventBridgeClient; +import com.baiyue.lastleg.client.DirectEventClient; import com.baiyue.lastleg.entity.LastlegEventOutbox; import com.baiyue.lastleg.repository.UnloadCompletedOutboxRepository; import com.fasterxml.jackson.databind.ObjectMapper; @@ -30,7 +31,7 @@ import com.baiyue.common.core.api.ResultCode; @ExtendWith(MockitoExtension.class) class InboundUnloadCompletedOutboxRelayTest { @Mock private UnloadCompletedOutboxRepository repository; - @Mock private EventBridgeClient client; + @Mock private DirectEventClient client; private ObjectMapper objectMapper; private InboundUnloadCompletedOutboxRelay relay; @@ -43,19 +44,18 @@ class InboundUnloadCompletedOutboxRelayTest { ReflectionTestUtils.setField(relay, "objectMapper", objectMapper); ReflectionTestUtils.setField(relay, "internalToken", "internal-token"); ReflectionTestUtils.setField(relay, "leaseSeconds", 60L); - ReflectionTestUtils.setField(relay, "retrySeconds", 30L); + ReflectionTestUtils.setField(relay, "baseDelaySeconds", 5L); } @Test - void marksOutboxPublishedAfterDomesticBridgeAcknowledgesSameEvent() throws Exception { + void marksOutboxPublishedAfterConsumerAcknowledgesSameEvent() throws Exception { EventEnvelope event = event(); LastlegEventOutbox row = row(event); when(repository.pending(eq(50), any(), any())).thenReturn(List.of(row)); when(repository.claim(eq(9001L), anyString(), any(), any())).thenReturn(true); when(repository.claimed(eq(9001L), anyString())).thenReturn(row); when(client.receive(eq("internal-token"), any(EventEnvelope.class))) - .thenReturn(ApiResponse.success(new EventBridgeClient.Acknowledgement( - event.eventId(), "已接收", "2026-08-20T03:01:00"))); + .thenReturn(ApiResponse.success(DeliveryAck.of(event, DeliveryAck.Outcome.APPLIED))); relay.relay(); @@ -64,23 +64,23 @@ class InboundUnloadCompletedOutboxRelayTest { } @Test - void releasesLeaseForRetryWhenBridgeDeliveryFails() throws Exception { + void releasesLeaseForRetryWhenConsumerDeliveryFails() throws Exception { EventEnvelope event = event(); LastlegEventOutbox row = row(event); when(repository.pending(eq(50), any(), any())).thenReturn(List.of(row)); when(repository.claim(eq(9001L), anyString(), any(), any())).thenReturn(true); when(repository.claimed(eq(9001L), anyString())).thenReturn(row); when(client.receive(eq("internal-token"), any(EventEnvelope.class))) - .thenThrow(new IllegalStateException("bridge unavailable")); + .thenThrow(new IllegalStateException("consumer unavailable")); relay.relay(); - verify(repository).retry(eq(9001L), anyString(), any(LocalDateTime.class), eq("bridge unavailable")); + verify(repository).retry(eq(9001L), anyString(), any(LocalDateTime.class), eq("consumer unavailable")); verify(repository, never()).published(anyLong(), anyString(), any()); } @Test - void preservesDomesticBridgeBusinessErrorForDeploymentDiagnosis() throws Exception { + void preservesConsumerBusinessErrorForDeploymentDiagnosis() throws Exception { EventEnvelope event = event(); LastlegEventOutbox row = row(event); when(repository.pending(eq(50), any(), any())).thenReturn(List.of(row)); @@ -88,15 +88,58 @@ class InboundUnloadCompletedOutboxRelayTest { when(repository.claimed(eq(9001L), anyString())).thenReturn(row); when(client.receive(eq("internal-token"), any(EventEnvelope.class))) .thenReturn(ApiResponse.failure(ResultCode.BAD_REQUEST, - "事件目标区域与当前事件桥区域不一致", "request-bridge", "trace-bridge")); + "消费事件类型不支持", "request-bridge", "trace-bridge")); relay.relay(); verify(repository).retry(eq(9001L), anyString(), any(LocalDateTime.class), - contains("事件目标区域与当前事件桥区域不一致")); + contains("消费事件类型不支持")); verify(repository, never()).published(anyLong(), anyString(), any()); } + @Test + void mismatchedHashOrUnallowedStaleCannotMarkPublished() throws Exception { + EventEnvelope event = event(); + LastlegEventOutbox row = row(event); + when(repository.pending(eq(50), any(), any())).thenReturn(List.of(row)); + when(repository.claim(eq(9001L), anyString(), any(), any())).thenReturn(true); + when(repository.claimed(eq(9001L), anyString())).thenReturn(row); + when(client.receive(eq("internal-token"), any(EventEnvelope.class))) + .thenReturn(ApiResponse.success(new DeliveryAck(event.eventId(), "sha256:" + "0".repeat(64), DeliveryAck.Outcome.APPLIED))) + .thenReturn(ApiResponse.success(DeliveryAck.of(event, DeliveryAck.Outcome.STALE))); + relay.relay(); + relay.relay(); + verify(repository, times(2)).retry(eq(9001L), anyString(), any(), anyString()); + verify(repository, never()).published(anyLong(), anyString(), any()); + } + + @Test + void twelfthFailureGoesDeadInsteadOfSchedulingAnotherRetry() throws Exception { + LastlegEventOutbox row = row(event()); + row.setRetryCount(11); + when(repository.pending(eq(50), any(), any())).thenReturn(List.of(row)); + when(repository.claim(eq(9001L), anyString(), any(), any())).thenReturn(true); + when(repository.claimed(eq(9001L), anyString())).thenReturn(row); + when(client.receive(anyString(), any())).thenThrow(new IllegalStateException("consumer unavailable")); + relay.relay(); + verify(repository).dead(eq(9001L), anyString(), eq(12), contains("consumer unavailable")); + verify(repository, never()).retry(anyLong(), anyString(), any(), anyString()); + verify(repository, never()).published(anyLong(), anyString(), any()); + } + + @Test + void recoveredExhaustedLeaseDoesNotSendAgain() throws Exception { + LastlegEventOutbox row = row(event()); + row.setRetryCount(12); + when(repository.pending(eq(50), any(), any())).thenReturn(List.of(row)); + when(repository.claim(eq(9001L), anyString(), any(), any())).thenReturn(true); + when(repository.claimed(eq(9001L), anyString())).thenReturn(row); + relay.relay(); + verifyNoInteractions(client); + verify(repository).dead(eq(9001L), anyString(), eq(12), contains("租约超时")); + verify(repository, never()).retry(anyLong(), anyString(), any(), anyString()); + } + private LastlegEventOutbox row(EventEnvelope event) throws Exception { LastlegEventOutbox row = new LastlegEventOutbox(); row.setId(9001L); diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/repository/OutboxLeaseDatabaseTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/repository/OutboxLeaseDatabaseTest.java new file mode 100644 index 000000000..5c9b6f049 --- /dev/null +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/repository/OutboxLeaseDatabaseTest.java @@ -0,0 +1,56 @@ +package com.baiyue.lastleg.repository; + +import com.baomidou.mybatisplus.core.MybatisConfiguration; +import com.baomidou.mybatisplus.extension.spring.MybatisSqlSessionFactoryBean; +import com.baiyue.common.event.EventTypes; +import com.baiyue.lastleg.entity.LastlegEventOutbox; +import com.baiyue.lastleg.mapper.LastlegEventOutboxMapper; +import com.baiyue.lastleg.financefee.repository.OverseasFeeOutboxRepository; +import org.junit.jupiter.api.Test; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.jdbc.datasource.DriverManagerDataSource; +import org.springframework.test.util.ReflectionTestUtils; +import java.time.LocalDateTime; +import java.util.UUID; +import static org.assertj.core.api.Assertions.assertThat; + +class OutboxLeaseDatabaseTest { + @Test void recoveredLeasesCountFailuresAndOldOwnersCannotChangeNewState() throws Exception { + var dataSource = new DriverManagerDataSource("jdbc:h2:mem:" + UUID.randomUUID() + + ";MODE=MySQL;DB_CLOSE_DELAY=-1", "sa", ""); + var jdbc = new JdbcTemplate(dataSource); + jdbc.execute("CREATE TABLE ll_event_outbox(id BIGINT PRIMARY KEY,customer_id VARCHAR(64),aggregate_type VARCHAR(64),aggregate_id VARCHAR(64),event_type VARCHAR(64),event_version INT,event_key VARCHAR(160),payload_json TEXT,status VARCHAR(32),retry_count INT NOT NULL DEFAULT 0,next_retry_at TIMESTAMP,published_at TIMESTAMP,locked_at TIMESTAMP,locked_by VARCHAR(128),last_error VARCHAR(1000),created_at TIMESTAMP)"); + var configuration = new MybatisConfiguration(); + configuration.addMapper(LastlegEventOutboxMapper.class); + var factory = new MybatisSqlSessionFactoryBean(); + factory.setDataSource(dataSource); + factory.setConfiguration(configuration); + try (var session = factory.getObject().openSession(true)) { + var mapper = session.getMapper(LastlegEventOutboxMapper.class); + var unload = new UnloadCompletedOutboxRepository(); + ReflectionTestUtils.setField(unload, "mapper", mapper); + var fee = new OverseasFeeOutboxRepository(mapper); + LocalDateTime now = LocalDateTime.now(); + for (long id : new long[]{1, 2}) { + jdbc.update("INSERT INTO ll_event_outbox(id,event_type,status,retry_count,next_retry_at,created_at) VALUES(?,?,'PENDING',0,?,?)", + id, id == 1 ? EventTypes.OVERSEAS_CONTAINER_UNLOADED : EventTypes.BUSINESS_FEE_SOURCE_CHANGED, + now.minusSeconds(1), now.minusMinutes(2)); + boolean first = id == 1 ? unload.claim(id, "old", now, now.minusSeconds(60)) : fee.claim(id, "old", now, now.minusSeconds(60)); + assertThat(first).isTrue(); + assertThat(jdbc.queryForObject("SELECT retry_count FROM ll_event_outbox WHERE id=?", Integer.class, id)).isZero(); + jdbc.update("UPDATE ll_event_outbox SET locked_at=? WHERE id=?", now.minusSeconds(61), id); + boolean second = id == 1 ? unload.claim(id, "new", now, now.minusSeconds(60)) : fee.claim(id, "new", now, now.minusSeconds(60)); + assertThat(second).isTrue(); + assertThat(jdbc.queryForObject("SELECT retry_count FROM ll_event_outbox WHERE id=?", Integer.class, id)).isEqualTo(1); + if (id == 1) unload.dead(id, "old", 12, "late failure"); else fee.dead(id, "old", 12, "late failure"); + assertThat(jdbc.queryForObject("SELECT status FROM ll_event_outbox WHERE id=?", String.class, id)).isEqualTo("PROCESSING"); + if (id == 1) unload.dead(id, "new", 12, "exhausted"); else fee.dead(id, "new", 12, "exhausted"); + assertThat(jdbc.queryForObject("SELECT status FROM ll_event_outbox WHERE id=?", String.class, id)).isEqualTo("DEAD"); + boolean third = id == 1 ? unload.claim(id, "third", now, now.minusSeconds(60)) : fee.claim(id, "third", now, now.minusSeconds(60)); + assertThat(third).isFalse(); + } + jdbc.update("UPDATE ll_event_outbox SET status='PENDING',next_retry_at=? WHERE id=2", now.plusMinutes(5)); + assertThat(fee.claim(2L, "early", now, now.minusSeconds(60))).isFalse(); + } + } +} diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/repository/UnloadCompletedOutboxRepositoryTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/repository/UnloadCompletedOutboxRepositoryTest.java index b2123ef9e..24ad3ccf8 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/repository/UnloadCompletedOutboxRepositoryTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/repository/UnloadCompletedOutboxRepositoryTest.java @@ -18,6 +18,19 @@ import static org.mockito.Mockito.mock; import static org.mockito.Mockito.when; class UnloadCompletedOutboxRepositoryTest { + @Test + @SuppressWarnings({"rawtypes", "unchecked"}) + void feeClaimAlsoRechecksRetryWindow() { + TableInfoHelper.initTableInfo(new MapperBuilderAssistant(new MybatisConfiguration(), "test"), LastlegEventOutbox.class); + LastlegEventOutboxMapper mapper = mock(LastlegEventOutboxMapper.class); + var repository = new com.baiyue.lastleg.financefee.repository.OverseasFeeOutboxRepository(mapper); + ArgumentCaptor wrapper = ArgumentCaptor.forClass(LambdaUpdateWrapper.class); + when(mapper.update(isNull(LastlegEventOutbox.class), wrapper.capture())).thenReturn(1); + LocalDateTime now = LocalDateTime.of(2026, 9, 29, 12, 0); + assertThat(repository.claim(1L, "attempt", now, now.minusMinutes(1))).isTrue(); + assertThat(wrapper.getValue().getSqlSegment()).contains("next_retry_at", "<=", "status", "locked_at"); + } + @Test @SuppressWarnings({"rawtypes", "unchecked"}) void claimRechecksRetryWindowAfterCandidateSelection() { diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/service/LoadingCompletedConsumerTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/service/LoadingCompletedConsumerTest.java index 4eb28750f..16dd17461 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/service/LoadingCompletedConsumerTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/service/LoadingCompletedConsumerTest.java @@ -5,8 +5,8 @@ class LoadingCompletedConsumerTest { private final Map frozenContainerNos=new HashMap<>(); @Mock LastlegEventInboxMapper inbox; @Mock LastlegOrderMapper orders; @Mock LastlegInboundOrderMapper inboundOrders; @Mock LastlegOrderDetailMapper details; @Mock LastlegOrderStatusHistoryMapper histories; @Mock InboundPlanCartonMapper cartons; @Mock DropshipOrderService dropshipOrders; @Mock InventoryCommandService inventory; @Mock com.baiyue.lastleg.shipping.mapper.ContainerShippingGateMapper shippingGates; @Mock com.baiyue.lastleg.inboundplan.pda.mapper.ContainerLoadingEvidenceMapper loadingEvidence; @Mock LastlegOrderNoDailySequenceMapper sequences; ObjectMapper json=new ObjectMapper().findAndRegisterModules(); LoadingCompletedConsumer service; @BeforeEach void setup(){ service=new LoadingCompletedConsumer(); ReflectionTestUtils.setField(service,"inboxMapper",inbox); ReflectionTestUtils.setField(service,"orderMapper",orders); ReflectionTestUtils.setField(service,"inboundOrderMapper",inboundOrders); ReflectionTestUtils.setField(service,"detailMapper",details); ReflectionTestUtils.setField(service,"historyMapper",histories); ReflectionTestUtils.setField(service,"cartonMapper",cartons); ReflectionTestUtils.setField(service,"dropshipOrders",dropshipOrders); ReflectionTestUtils.setField(service,"inventory",inventory); ReflectionTestUtils.setField(service,"shippingGates",shippingGates); ReflectionTestUtils.setField(service,"loadingEvidence",loadingEvidence); ReflectionTestUtils.setField(service,"objectMapper",json); LastlegOrderNoGenerator generator=new LastlegOrderNoGenerator(); ReflectionTestUtils.setField(generator,"sequenceMapper",sequences); ReflectionTestUtils.setField(service,"orderNoGenerator",generator); lenient().when(sequences.currentValue()).thenReturn(1L); lenient().doAnswer(i->{ LastlegOrder o=i.getArgument(0); o.setId(Math.abs(o.getSourceInboundId().hashCode())+1L); return 1; }).when(orders).insert(any(LastlegOrder.class)); lenient().when(orders.lockContainerNumbers(anyString())).thenReturn(List.of()); lenient().when(shippingGates.initialize(anyString(),anyString(),anyString(),anyLong(),anyLong())).thenAnswer(i->{ String key=i.getArgument(0); String value=i.getArgument(2); return frozenContainerNos.putIfAbsent(key,value)==null?1:0; }); lenient().when(shippingGates.lock(anyString())).thenAnswer(i->{ String key=i.getArgument(0); String value=frozenContainerNos.get(key); if(value==null)return null; var gate=new com.baiyue.lastleg.shipping.entity.ContainerShippingGate(); gate.setContainerBookingId(key); gate.setContainerNo(value); return gate; }); } - @Test void createsPendingOrdersAndInTransitSnapshotsWithoutInboundPlans(){ EventEnvelope event=event(); assertEquals(2,service.consume(event)); assertThrows(NoSuchFieldException.class,()->LoadingCompletedConsumer.class.getDeclaredField("inboundPlans")); verify(orders,times(2)).insert(any(LastlegOrder.class)); ArgumentCaptor inTransit=ArgumentCaptor.forClass(InventoryCommandRequests.InTransit.class); verify(inventory,times(2)).registerInTransit(eq("100"),eq("SYSTEM"),inTransit.capture()); assertEquals(Set.of("RK1","RK2"),inTransit.getAllValues().stream().map(InventoryCommandRequests.InTransit::inboundOrderNo).collect(java.util.stream.Collectors.toSet())); verify(details,times(2)).insert(any(LastlegOrderDetail.class)); verify(histories,times(2)).insert(any(LastlegOrderStatusHistory.class)); ArgumentCaptor c=ArgumentCaptor.forClass(LastlegOrder.class); verify(orders,times(2)).insert(c.capture()); assertEquals(Set.of("in-1","in-2"),c.getAllValues().stream().map(LastlegOrder::getSourceInboundId).collect(java.util.stream.Collectors.toSet())); assertTrue(c.getAllValues().stream().allMatch(o->"PENDING_ASSIGNMENT".equals(o.getCurrentStatus()))); assertTrue(c.getAllValues().stream().allMatch(o->o.getInboundOrderNo()==null)); assertTrue(c.getAllValues().stream().allMatch(o->o.getId()!=null)); assertTrue(c.getAllValues().stream().allMatch(o->"MSCU6639870".equals(o.getContainerNo()))); assertTrue(c.getAllValues().stream().allMatch(o->"FBA".equals(o.getDestinationType()))); assertTrue(c.getAllValues().stream().allMatch(o->"Customer 100".equals(o.getCustomerName()))); assertTrue(c.getAllValues().stream().allMatch(o->"ow-1".equals(o.getSourceWarehouseId())&&"193000000000200003".equals(o.getOverseasWarehouseId())&&"US-LAX-01".equals(o.getOverseasWarehouseCode())&&"洛杉矶仓".equals(o.getOverseasWarehouseName())&&Integer.valueOf(0).equals(o.getReceivingWarehouseVersion()))); ArgumentCaptor snapshots=ArgumentCaptor.forClass(LastlegInboundOrder.class); verify(inboundOrders,times(2)).insert(snapshots.capture()); assertTrue(snapshots.getAllValues().stream().allMatch(o->"100".equals(o.getCustomerId())&&"FBA".equals(o.getDestinationType()))); assertEquals(1,c.getAllValues().stream().map(LastlegOrder::getOrderNo).distinct().count()); assertTrue(c.getAllValues().get(0).getOrderNo().matches("FL-WH-[0-9]{8}-[0-9]{6}")); } - @Test void oneContainerWith15InboundRowsSharesOneStableContainerOrderNumber(){ EventEnvelope source=event(); com.fasterxml.jackson.databind.node.ObjectNode node=source.payload().deepCopy(); com.fasterxml.jackson.databind.node.ArrayNode rows=json.createArrayNode(); for(int index=1;index<=15;index++){ com.fasterxml.jackson.databind.node.ObjectNode row=node.path("orders").get(0).deepCopy(); row.put("sourceInboundId","in-"+index).put("rootInboundId","root-"+index).put("sourceOrderId","order-"+index).put("sourceOrderNo","FL-"+index).put("rootOrderId","root-order-"+index).put("inboundCode","RK-"+index); rows.add(row); } node.set("orders",rows); String hash=EventPayloadHash.sha256(EventPayloadCanonicalizer.canonicalize(node)); EventEnvelope fifteen=new EventEnvelope("123e4567-e89b-12d3-a456-426614174015",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(),node,hash); assertEquals(15,service.consume(fifteen)); ArgumentCaptor captured=ArgumentCaptor.forClass(LastlegOrder.class); verify(orders,times(15)).insert(captured.capture()); assertEquals(1,captured.getAllValues().stream().map(LastlegOrder::getOrderNo).distinct().count()); assertTrue(captured.getAllValues().get(0).getOrderNo().matches("^FL-WH-\\d{8}-\\d{6}$")); verify(sequences,never()).nextValue(any(java.time.LocalDate.class)); } + @Test void createsPendingOrdersAndInTransitSnapshotsWithoutInboundPlans(){ EventEnvelope event=event(); assertEquals(com.baiyue.common.event.DeliveryAck.Outcome.APPLIED, service.consume(event).outcome()); assertThrows(NoSuchFieldException.class,()->LoadingCompletedConsumer.class.getDeclaredField("inboundPlans")); verify(orders,times(2)).insert(any(LastlegOrder.class)); ArgumentCaptor inTransit=ArgumentCaptor.forClass(InventoryCommandRequests.InTransit.class); verify(inventory,times(2)).registerInTransit(eq("100"),eq("SYSTEM"),inTransit.capture()); assertEquals(Set.of("RK1","RK2"),inTransit.getAllValues().stream().map(InventoryCommandRequests.InTransit::inboundOrderNo).collect(java.util.stream.Collectors.toSet())); verify(details,times(2)).insert(any(LastlegOrderDetail.class)); verify(histories,times(2)).insert(any(LastlegOrderStatusHistory.class)); ArgumentCaptor c=ArgumentCaptor.forClass(LastlegOrder.class); verify(orders,times(2)).insert(c.capture()); assertEquals(Set.of("in-1","in-2"),c.getAllValues().stream().map(LastlegOrder::getSourceInboundId).collect(java.util.stream.Collectors.toSet())); assertTrue(c.getAllValues().stream().allMatch(o->"PENDING_ASSIGNMENT".equals(o.getCurrentStatus()))); assertTrue(c.getAllValues().stream().allMatch(o->o.getInboundOrderNo()==null)); assertTrue(c.getAllValues().stream().allMatch(o->o.getId()!=null)); assertTrue(c.getAllValues().stream().allMatch(o->"MSCU6639870".equals(o.getContainerNo()))); assertTrue(c.getAllValues().stream().allMatch(o->"FBA".equals(o.getDestinationType()))); assertTrue(c.getAllValues().stream().allMatch(o->"Customer 100".equals(o.getCustomerName()))); assertTrue(c.getAllValues().stream().allMatch(o->"ow-1".equals(o.getSourceWarehouseId())&&"193000000000200003".equals(o.getOverseasWarehouseId())&&"US-LAX-01".equals(o.getOverseasWarehouseCode())&&"洛杉矶仓".equals(o.getOverseasWarehouseName())&&Integer.valueOf(0).equals(o.getReceivingWarehouseVersion()))); ArgumentCaptor snapshots=ArgumentCaptor.forClass(LastlegInboundOrder.class); verify(inboundOrders,times(2)).insert(snapshots.capture()); assertTrue(snapshots.getAllValues().stream().allMatch(o->"100".equals(o.getCustomerId())&&"FBA".equals(o.getDestinationType()))); assertEquals(1,c.getAllValues().stream().map(LastlegOrder::getOrderNo).distinct().count()); assertTrue(c.getAllValues().get(0).getOrderNo().matches("FL-WH-[0-9]{8}-[0-9]{6}")); } + @Test void oneContainerWith15InboundRowsSharesOneStableContainerOrderNumber(){ EventEnvelope source=event(); com.fasterxml.jackson.databind.node.ObjectNode node=source.payload().deepCopy(); com.fasterxml.jackson.databind.node.ArrayNode rows=json.createArrayNode(); for(int index=1;index<=15;index++){ com.fasterxml.jackson.databind.node.ObjectNode row=node.path("orders").get(0).deepCopy(); row.put("sourceInboundId","in-"+index).put("rootInboundId","root-"+index).put("sourceOrderId","order-"+index).put("sourceOrderNo","FL-"+index).put("rootOrderId","root-order-"+index).put("inboundCode","RK-"+index); rows.add(row); } node.set("orders",rows); String hash=EventPayloadHash.sha256(EventPayloadCanonicalizer.canonicalize(node)); EventEnvelope fifteen=new EventEnvelope("123e4567-e89b-12d3-a456-426614174015",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(),node,hash); assertEquals(com.baiyue.common.event.DeliveryAck.Outcome.APPLIED, service.consume(fifteen).outcome()); ArgumentCaptor captured=ArgumentCaptor.forClass(LastlegOrder.class); verify(orders,times(15)).insert(captured.capture()); assertEquals(1,captured.getAllValues().stream().map(LastlegOrder::getOrderNo).distinct().count()); assertTrue(captured.getAllValues().get(0).getOrderNo().matches("^FL-WH-\\d{8}-\\d{6}$")); verify(sequences,never()).nextValue(any(java.time.LocalDate.class)); } @Test void rejectsMissingOwnerSnapshotBeforeAnyLastlegWrite(){ EventEnvelope source=event(); com.fasterxml.jackson.databind.node.ObjectNode node=source.payload().deepCopy(); ((com.fasterxml.jackson.databind.node.ObjectNode)node.path("orders").get(0)).put("customerName"," "); String hash=EventPayloadHash.sha256(EventPayloadCanonicalizer.canonicalize(node)); EventEnvelope missing=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(),node,hash); IllegalArgumentException error=assertThrows(IllegalArgumentException.class,()->service.consume(missing)); assertTrue(error.getMessage().contains("尾程事件负载无效")||error.getMessage().contains("货主快照")); verify(inbox,never()).insert(any(LastlegEventInbox.class)); verify(orders,never()).insert(any(LastlegOrder.class)); } @Test void freezesFormalPlannedWarehouseArrivalFromLoadingEvent(){ EventEnvelope source=event(); JsonNode node=source.payload().deepCopy(); ((com.fasterxml.jackson.databind.node.ObjectNode)node).put("estimatedWarehouseArrivalAt","2026-10-03T09:00"); String hash=EventPayloadHash.sha256(EventPayloadCanonicalizer.canonicalize(node)); EventEnvelope changed=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(),node,hash); service.consume(changed); ArgumentCaptor captor=ArgumentCaptor.forClass(LastlegOrder.class); verify(orders,times(2)).insert(captor.capture()); assertTrue(captor.getAllValues().stream().allMatch(order->java.time.LocalDateTime.of(2026,10,3,9,0).equals(order.getEstimatedWarehouseAt()))); } @Test void loadingCreatesAwaitingPickupReadyGateWithoutPollutingShippingVersion(){ service.consume(event()); verify(shippingGates).initialize("book-1","","MSCU6639870",0L,1L); } @@ -14,8 +14,8 @@ class LoadingCompletedConsumerTest { @Test void rejectsBlankActualContainerNumberEvenWhenCabinetNumberExists(){ EventEnvelope source=event(); JsonNode node=source.payload().deepCopy(); ((com.fasterxml.jackson.databind.node.ObjectNode)node).put("containerNo"," ").put("cabinetNo","CABINET-ONLY"); String hash=EventPayloadHash.sha256(EventPayloadCanonicalizer.canonicalize(node)); EventEnvelope changed=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(),node,hash); assertThrows(IllegalArgumentException.class,()->service.consume(changed)); verify(inbox,never()).insert(any(LastlegEventInbox.class)); verify(orders,never()).insert(any(LastlegOrder.class)); } @Test void rejectsDifferentContainerNumberForSameSourceBookingBeforeAnyBusinessWrite(){ EventEnvelope source=event(); frozenContainerNos.put("book-1","CSQU3054383"); IllegalStateException error=assertThrows(IllegalStateException.class,()->service.consume(source)); assertTrue(error.getMessage().contains("sourceContainerBookingId")); verify(inbox,never()).insert(any(LastlegEventInbox.class)); verify(orders,never()).insert(any(LastlegOrder.class)); } @Test void rejectsExistingOrderContainerDriftForSameSourceBooking(){ when(orders.lockContainerNumbers("book-1")).thenReturn(List.of("MSCU6639870","CSQU3054383")); assertThrows(IllegalStateException.class,()->service.consume(event())); verify(inbox,never()).insert(any(LastlegEventInbox.class)); verify(orders,never()).insert(any(LastlegOrder.class)); } - @Test void acceptsSeparateCustomerEventsForTheSameFrozenContainerIdentity(){ EventEnvelope first=event(); assertEquals(2,service.consume(first)); com.fasterxml.jackson.databind.node.ObjectNode node=first.payload().deepCopy(); int index=0; for(JsonNode order:node.path("orders")){ ((com.fasterxml.jackson.databind.node.ObjectNode)order).put("customerId","200").put("customerName","Customer 200").put("sourceInboundId","customer-200-in-"+(++index)); } String hash=EventPayloadHash.sha256(EventPayloadCanonicalizer.canonicalize(node)); EventEnvelope second=new EventEnvelope("123e4567-e89b-12d3-a456-426614174200",first.eventType(),first.eventVersion(),first.occurredAt(),first.sourceRegion(),first.targetRegion(),"200",first.aggregateType(),first.aggregateId(),first.aggregateVersion(),first.handoverVersion(),first.traceId(),first.correlationId(),first.causationId(),node,hash); assertEquals(2,service.consume(second)); verify(shippingGates,times(2)).initialize(eq("book-1"),eq(""),eq("MSCU6639870"),eq(0L),eq(1L)); ArgumentCaptor captured=ArgumentCaptor.forClass(LastlegOrder.class); verify(orders,times(4)).insert(captured.capture()); assertEquals(Set.of("100","200"),captured.getAllValues().stream().map(LastlegOrder::getCustomerId).collect(java.util.stream.Collectors.toSet())); assertTrue(captured.getAllValues().stream().allMatch(order->"MSCU6639870".equals(order.getContainerNo()))); } - @Test void replayPreservesExistingLmoNumberAndAddsNothing(){ EventEnvelope event=event(); LastlegEventInbox row=new LastlegEventInbox(); row.setPayloadHash(event.payloadHash()); when(inbox.selectById(event.eventId())).thenReturn(row); when(orders.countContainer("100","MSCU6639870")).thenReturn(2L); assertEquals(2,service.consume(event)); verify(orders).countContainer("100","MSCU6639870"); verify(sequences,never()).nextValue(any(java.time.LocalDate.class)); verify(orders,never()).insert(any(LastlegOrder.class)); verify(details,never()).insert(any(LastlegOrderDetail.class)); verify(histories,never()).insert(any(LastlegOrderStatusHistory.class)); } + @Test void acceptsSeparateCustomerEventsForTheSameFrozenContainerIdentity(){ EventEnvelope first=event(); assertEquals(com.baiyue.common.event.DeliveryAck.Outcome.APPLIED, service.consume(first).outcome()); com.fasterxml.jackson.databind.node.ObjectNode node=first.payload().deepCopy(); int index=0; for(JsonNode order:node.path("orders")){ ((com.fasterxml.jackson.databind.node.ObjectNode)order).put("customerId","200").put("customerName","Customer 200").put("sourceInboundId","customer-200-in-"+(++index)); } String hash=EventPayloadHash.sha256(EventPayloadCanonicalizer.canonicalize(node)); EventEnvelope second=new EventEnvelope("123e4567-e89b-12d3-a456-426614174200",first.eventType(),first.eventVersion(),first.occurredAt(),first.sourceRegion(),first.targetRegion(),"200",first.aggregateType(),first.aggregateId(),first.aggregateVersion(),first.handoverVersion(),first.traceId(),first.correlationId(),first.causationId(),node,hash); assertEquals(com.baiyue.common.event.DeliveryAck.Outcome.APPLIED, service.consume(second).outcome()); verify(shippingGates,times(2)).initialize(eq("book-1"),eq(""),eq("MSCU6639870"),eq(0L),eq(1L)); ArgumentCaptor captured=ArgumentCaptor.forClass(LastlegOrder.class); verify(orders,times(4)).insert(captured.capture()); assertEquals(Set.of("100","200"),captured.getAllValues().stream().map(LastlegOrder::getCustomerId).collect(java.util.stream.Collectors.toSet())); assertTrue(captured.getAllValues().stream().allMatch(order->"MSCU6639870".equals(order.getContainerNo()))); } + @Test void replayPreservesExistingLmoNumberAndAddsNothing(){ EventEnvelope event=event(); LastlegEventInbox row=new LastlegEventInbox(); row.setPayloadHash(event.payloadHash()); row.setStatus("PROCESSED"); when(inbox.selectById(event.eventId())).thenReturn(row); assertEquals(com.baiyue.common.event.DeliveryAck.Outcome.DUPLICATE, service.consume(event).outcome()); verify(sequences,never()).nextValue(any(java.time.LocalDate.class)); verify(orders,never()).insert(any(LastlegOrder.class)); verify(details,never()).insert(any(LastlegOrderDetail.class)); verify(histories,never()).insert(any(LastlegOrderStatusHistory.class)); } @Test void sameEventWithDifferentHashIsRejected(){ EventEnvelope event=event(); LastlegEventInbox row=new LastlegEventInbox(); row.setPayloadHash("sha256:"+"0".repeat(64)); when(inbox.selectById(event.eventId())).thenReturn(row); assertThrows(IllegalArgumentException.class,()->service.consume(event)); } @Test void tamperedPayloadHashIsRejectedBeforeInboxMutation(){ EventEnvelope source=event(); EventEnvelope tampered=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(),"sha256:"+"0".repeat(64)); IllegalArgumentException error=assertThrows(IllegalArgumentException.class,()->service.consume(tampered)); assertTrue(error.getMessage().contains("payloadHash")); verify(inbox,never()).insert(any(LastlegEventInbox.class)); } @Test void concurrentDuplicateKeyWithDifferentHashIsRejected(){ EventEnvelope event=event(); LastlegEventInbox raced=new LastlegEventInbox(); raced.setPayloadHash("sha256:"+"0".repeat(64)); when(inbox.selectById(event.eventId())).thenReturn(null,raced); doThrow(new org.springframework.dao.DuplicateKeyException("race")).when(inbox).insert(any(LastlegEventInbox.class)); IllegalArgumentException error=assertThrows(IllegalArgumentException.class,()->service.consume(event)); assertTrue(error.getMessage().contains("摘要冲突")); verify(orders,never()).countContainer(anyString(),anyString()); } @@ -26,7 +26,7 @@ class LoadingCompletedConsumerTest { assertTrue(table.contains("customer_id")); assertFalse(table.contains("tenant_id")); } @Test void rejectsEventFromWrongRegion(){ EventEnvelope source=event(); EventEnvelope invalid=new EventEnvelope(source.eventId(),source.eventType(),source.eventVersion(),source.occurredAt(),RegionCode.OVERSEAS,RegionCode.CN,source.tenantId(),source.aggregateType(),source.aggregateId(),source.aggregateVersion(),source.handoverVersion(),source.traceId(),source.correlationId(),source.causationId(),source.payload(),source.payloadHash()); assertThrows(IllegalArgumentException.class,()->service.consume(invalid)); } - @Test void consumesFormalDropshipPalletSnapshots(){ var sku=new FirstLegLoadingPlanCompletedV1.SkuPackageSnapshot("SKU-A","Product A",2,20,List.of("BOX-1","BOX-2")); var pallet=new FirstLegLoadingPlanCompletedV1.PalletSnapshot("PLT-1",List.of(sku)); var item=new FirstLegLoadingPlanCompletedV1.Item("d-1","SKU-A","Product A","Product A",2,20,1,BigDecimal.ONE,BigDecimal.ONE,BigDecimal.ONE,BigDecimal.ONE,"wh-1","ONT8","ONT8",null,null,"MARK",null,List.of()); var order=new FirstLegLoadingPlanCompletedV1.Order("in-d","root-d","ord-d","FL-D","root-od","RK-D","wh-1","ONT8","ONT8",2,20,1,BigDecimal.ONE,BigDecimal.ONE,BigDecimal.ONE,List.of(item),"100","Customer 100","ONE_CLICK_DROPSHIP",List.of(pallet),"FBA"); var payload=new FirstLegLoadingPlanCompletedV1("plan-d","LP-D","book-d","MSCU6639870","CAB-D","92","Warehouse",List.of(order)); JsonNode node=json.valueToTree(payload); addManifest(node.path("orders").get(0).path("pallets").get(0),2); String hash=EventPayloadHash.sha256(EventPayloadCanonicalizer.canonicalize(node)); var event=new EventEnvelope("123e4567-e89b-12d3-a456-426614174010",EventTypes.FIRST_LEG_LOADING_PLAN_COMPLETED,1,Instant.now(),RegionCode.CN,RegionCode.OVERSEAS,"100","LOADING_PLAN","plan-d",2,1L,"123e4567-e89b-12d3-a456-426614174011","FIRSTLEG_LOADING_COMPLETED:plan-d:100:1",null,node,hash); assertEquals(1,service.consume(event)); ArgumentCaptor captor=ArgumentCaptor.forClass(DropshipRequests.Receive.class); verify(dropshipOrders).receive(captor.capture()); verify(inventory,never()).registerInTransit(anyString(),anyString(),any()); assertEquals("100",captor.getValue().customerId()); assertEquals("123e4567-e89b-12d3-a456-426614174010:DROPSHIP:in-d",captor.getValue().idempotencyKey()); assertEquals(1,captor.getValue().pallets().size()); assertEquals("PLT-1",captor.getValue().pallets().get(0).palletNo()); assertEquals(List.of("BOX-1","BOX-2"),captor.getValue().pallets().get(0).skuPackages().get(0).cartonMarks()); } + @Test void consumesFormalDropshipPalletSnapshots(){ var sku=new FirstLegLoadingPlanCompletedV1.SkuPackageSnapshot("SKU-A","Product A",2,20,List.of("BOX-1","BOX-2")); var pallet=new FirstLegLoadingPlanCompletedV1.PalletSnapshot("PLT-1",List.of(sku)); var item=new FirstLegLoadingPlanCompletedV1.Item("d-1","SKU-A","Product A","Product A",2,20,1,BigDecimal.ONE,BigDecimal.ONE,BigDecimal.ONE,BigDecimal.ONE,"wh-1","ONT8","ONT8",null,null,"MARK",null,List.of()); var order=new FirstLegLoadingPlanCompletedV1.Order("in-d","root-d","ord-d","FL-D","root-od","RK-D","wh-1","ONT8","ONT8",2,20,1,BigDecimal.ONE,BigDecimal.ONE,BigDecimal.ONE,List.of(item),"100","Customer 100","ONE_CLICK_DROPSHIP",List.of(pallet),"FBA"); var payload=new FirstLegLoadingPlanCompletedV1("plan-d","LP-D","book-d","MSCU6639870","CAB-D","92","Warehouse",List.of(order)); JsonNode node=json.valueToTree(payload); addManifest(node.path("orders").get(0).path("pallets").get(0),2); String hash=EventPayloadHash.sha256(EventPayloadCanonicalizer.canonicalize(node)); var event=new EventEnvelope("123e4567-e89b-12d3-a456-426614174010",EventTypes.FIRST_LEG_LOADING_PLAN_COMPLETED,1,Instant.now(),RegionCode.CN,RegionCode.OVERSEAS,"100","LOADING_PLAN","plan-d",2,1L,"123e4567-e89b-12d3-a456-426614174011","FIRSTLEG_LOADING_COMPLETED:plan-d:100:1",null,node,hash); assertEquals(com.baiyue.common.event.DeliveryAck.Outcome.APPLIED, service.consume(event).outcome()); ArgumentCaptor captor=ArgumentCaptor.forClass(DropshipRequests.Receive.class); verify(dropshipOrders).receive(captor.capture()); verify(inventory,never()).registerInTransit(anyString(),anyString(),any()); assertEquals("100",captor.getValue().customerId()); assertEquals("123e4567-e89b-12d3-a456-426614174010:DROPSHIP:in-d",captor.getValue().idempotencyKey()); assertEquals(1,captor.getValue().pallets().size()); assertEquals("PLT-1",captor.getValue().pallets().get(0).palletNo()); assertEquals(List.of("BOX-1","BOX-2"),captor.getValue().pallets().get(0).skuPackages().get(0).cartonMarks()); } @Test void freezesReceivingWarehouseInInitialInTransitInventory(){ service.consume(event()); ArgumentCaptor captured=ArgumentCaptor.forClass(InventoryCommandRequests.InTransit.class); @@ -42,7 +42,7 @@ class LoadingCompletedConsumerTest { var order=new FirstLegLoadingPlanCompletedV1.Order("in-multi","root-multi","ord-multi","FL-MULTI","root-ord-multi","RK-MULTI","wh-1","ONT8","ONT8",2,30,1,BigDecimal.ONE,BigDecimal.ONE,BigDecimal.ONE,List.of(itemA,itemB),"100","Customer 100","ONE_CLICK_DROPSHIP",List.of(pallet),"FBA"); EventEnvelope event=eventForPayload(new FirstLegLoadingPlanCompletedV1("plan-multi","LP-MULTI","book-multi","MSCU6639870","CAB-MULTI","92","Warehouse",List.of(order)),2); - assertEquals(1,service.consume(event)); + assertEquals(com.baiyue.common.event.DeliveryAck.Outcome.APPLIED, service.consume(event).outcome()); verify(orders,times(1)).insert(any(LastlegOrder.class)); ArgumentCaptor detailCaptor=ArgumentCaptor.forClass(LastlegOrderDetail.class); verify(details,times(2)).insert(detailCaptor.capture()); @@ -67,7 +67,7 @@ class LoadingCompletedConsumerTest { EventEnvelope v2=new EventEnvelope(source.eventId(),source.eventType(),2,source.occurredAt(),source.sourceRegion(), source.targetRegion(),source.tenantId(),source.aggregateType(),source.aggregateId(),source.aggregateVersion(), 2L,source.traceId(),source.correlationId(),source.causationId(),node,hash); - assertEquals(2,service.consume(v2)); + assertEquals(com.baiyue.common.event.DeliveryAck.Outcome.APPLIED, service.consume(v2).outcome()); ArgumentCaptor orderCaptor=ArgumentCaptor.forClass(LastlegOrder.class); verify(orders,times(2)).insert(orderCaptor.capture()); assertTrue(orderCaptor.getAllValues().stream().allMatch(order->order.getFbaWarehouseCode()==null)); @@ -100,7 +100,7 @@ class LoadingCompletedConsumerTest { EventEnvelope v2=new EventEnvelope(source.eventId(),source.eventType(),2,source.occurredAt(),source.sourceRegion(), source.targetRegion(),source.tenantId(),source.aggregateType(),source.aggregateId(),source.aggregateVersion(), 2L,source.traceId(),source.correlationId(),source.causationId(),node,hash); - assertEquals(2,service.consume(v2)); + assertEquals(com.baiyue.common.event.DeliveryAck.Outcome.APPLIED, service.consume(v2).outcome()); verify(cartons,never()).insert(any(InboundPlanCarton.class)); } @Test void acceptsV2FbaByPackageCountWithoutCartonFacts(){ @@ -111,7 +111,7 @@ class LoadingCompletedConsumerTest { EventEnvelope v2=new EventEnvelope(source.eventId(),source.eventType(),2,source.occurredAt(),source.sourceRegion(), source.targetRegion(),source.tenantId(),source.aggregateType(),source.aggregateId(),source.aggregateVersion(), 2L,source.traceId(),source.correlationId(),source.causationId(),node,hash); - assertEquals(2,service.consume(v2)); + assertEquals(com.baiyue.common.event.DeliveryAck.Outcome.APPLIED, service.consume(v2).outcome()); verify(cartons,never()).insert(any(InboundPlanCarton.class)); } @Test void safelyInfersLegacyOverseasDestinationFromReceivingWarehouseIdentity(){ @@ -125,7 +125,7 @@ class LoadingCompletedConsumerTest { ((com.fasterxml.jackson.databind.node.ObjectNode)node.path("orders").get(1).path("items").get(0)).set("cartonMarks",json.valueToTree(List.of("BOX-6","BOX-7","BOX-8"))); String hash=EventPayloadHash.sha256(EventPayloadCanonicalizer.canonicalize(node)); EventEnvelope v2=new EventEnvelope(source.eventId(),source.eventType(),2,source.occurredAt(),source.sourceRegion(),source.targetRegion(),source.tenantId(),source.aggregateType(),source.aggregateId(),source.aggregateVersion(),2L,source.traceId(),source.correlationId(),source.causationId(),node,hash); - assertEquals(2,service.consume(v2)); + assertEquals(com.baiyue.common.event.DeliveryAck.Outcome.APPLIED, service.consume(v2).outcome()); ArgumentCaptor captured=ArgumentCaptor.forClass(LastlegOrder.class); verify(orders,times(2)).insert(captured.capture()); assertTrue(captured.getAllValues().stream().allMatch(order->"OVERSEAS".equals(order.getDestinationType()))); verify(cartons,times(8)).insert(any(InboundPlanCarton.class)); @@ -248,7 +248,7 @@ class LoadingCompletedConsumerTest { for(String type:List.of("PHOTO_STEP_1","PHOTO_STEP_2","PHOTO_STEP_3","PHOTO_STEP_4","SEAL")) values.addObject().put("evidenceType",type).put("fileId",fileId++).put("fileName",type+".jpg"); String hash=EventPayloadHash.sha256(EventPayloadCanonicalizer.canonicalize(node)); EventEnvelope event=new EventEnvelope(source.eventId(),source.eventType(),3,source.occurredAt(),source.sourceRegion(),source.targetRegion(),source.tenantId(),source.aggregateType(),source.aggregateId(),source.aggregateVersion(),3L,source.traceId(),source.correlationId(),source.causationId(),node,hash); - assertEquals(2,service.consume(event)); + assertEquals(com.baiyue.common.event.DeliveryAck.Outcome.APPLIED, service.consume(event).outcome()); ArgumentCaptor captured=ArgumentCaptor.forClass(com.baiyue.lastleg.inboundplan.pda.entity.ContainerLoadingEvidence.class); verify(loadingEvidence,times(5)).insert(captured.capture()); assertEquals(Set.of("PHOTO_STEP_1","PHOTO_STEP_2","PHOTO_STEP_3","PHOTO_STEP_4","SEAL"),captured.getAllValues().stream().map(com.baiyue.lastleg.inboundplan.pda.entity.ContainerLoadingEvidence::getEvidenceType).collect(java.util.stream.Collectors.toSet())); diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/service/TaillegRealConsumerAcceptanceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/service/TaillegRealConsumerAcceptanceTest.java index 179433b94..99c59b069 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/service/TaillegRealConsumerAcceptanceTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/service/TaillegRealConsumerAcceptanceTest.java @@ -96,7 +96,7 @@ class TaillegRealConsumerAcceptanceTest { String envelopeJson = jdbc.queryForObject( "SELECT payload_json FROM baiyue_firstleg.fl_inbound_outbox WHERE event_key=?", String.class, EVENT_KEY); EventEnvelope envelope = json.readValue(envelopeJson, EventEnvelope.class); - assertEquals(2, consumer.consume(envelope)); + assertEquals(com.baiyue.common.event.DeliveryAck.Outcome.APPLIED, consumer.consume(envelope).outcome()); Long sourceOrderId = jdbc.queryForObject("SELECT id FROM ll_order WHERE customer_id=? AND " + "source_loading_plan_id='990022022' AND order_type='FIRST_LEG'", Long.class, CUSTOMER); diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/shipping/service/ContainerShippingNodeConsumerTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/shipping/service/ContainerShippingNodeConsumerTest.java index 2df7d6fb5..6c73a9d5b 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/shipping/service/ContainerShippingNodeConsumerTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/shipping/service/ContainerShippingNodeConsumerTest.java @@ -50,7 +50,7 @@ class ContainerShippingNodeConsumerTest { EventEnvelope event = event("123e4567-e89b-12d3-a456-426614174101", "PICKUP_READY", 80, 5, Instant.parse("2026-08-21T10:00:00Z")); - assertThat(consumer.consume(event)).isEqualTo(1); + assertThat(consumer.consume(event).outcome()).isEqualTo(com.baiyue.common.event.DeliveryAck.Outcome.APPLIED); assertThat(gate.getStatus()).isEqualTo("RECEIVING_AVAILABLE"); assertThat(gate.getNodeSequence()).isEqualTo(80); @@ -86,9 +86,9 @@ class ContainerShippingNodeConsumerTest { void eventIdIsIdempotentByHashAndRejectsDifferentHash() { EventEnvelope event = event("123e4567-e89b-12d3-a456-426614174104", "PICKUP_READY", 80, 5, Instant.parse("2026-08-21T10:00:00Z")); - LastlegEventInbox existing = new LastlegEventInbox(); existing.setPayloadHash(event.payloadHash()); + LastlegEventInbox existing = new LastlegEventInbox(); existing.setPayloadHash(event.payloadHash()); existing.setStatus("PROCESSED"); when(inboxes.selectById(event.eventId())).thenReturn(existing); - assertThat(consumer.consume(event)).isEqualTo(1); + assertThat(consumer.consume(event).outcome()).isEqualTo(com.baiyue.common.event.DeliveryAck.Outcome.DUPLICATE); verify(gates, never()).updateById(any(ContainerShippingGate.class)); existing.setPayloadHash("sha256:" + "0".repeat(64)); diff --git a/scripts/agents-index-contract.ps1 b/scripts/agents-index-contract.ps1 index 5451d495a..44cbc3fa2 100644 --- a/scripts/agents-index-contract.ps1 +++ b/scripts/agents-index-contract.ps1 @@ -905,8 +905,12 @@ Assert-Contains $backendAgents '客户资金账户、充值、授信、冻结、 Assert-Contains $lastlegAgents '海外业务资金指令与结果' 'Lastleg 跨区钱包入口' Assert-Contains $lastlegAgents '拆柜打托成本与客户应收分摊' 'Lastleg 拆柜打托费用入口' Assert-Contains $clientAgents '换标申请必须提交正数 CNY `chargeAmount`' 'Client 换标钱包金额边界' -Assert-Contains $businessIndex '海外 Token 不携带角色或权限集' '根海外小 Token 边界' -Assert-Contains $backendAgents '海外 Token 只携带稳定身份、客户/仓库范围和版本声明,不携带角色或权限集' '后端海外小 Token 边界' +Assert-Contains $businessIndex '统一认证与服务通信契约.md' '根统一认证契约入口' +Assert-Contains $backendAgents '各端共用 Gateway 和 Redis 会话' '后端统一认证目标' +$unifiedCommunication = Read-RequiredText (Join-Path $workspaceRoot 'docs/统一认证与服务通信契约.md') +Assert-Contains $unifiedCommunication 'JWT 不携带角色或权限集合' '统一 JWT 最小身份边界' +Assert-Contains $unifiedCommunication '平台/客户/PDA' '统一认证访问域隔离' +Assert-Contains $unifiedCommunication '整体迁移尚未完成' '认证目标与落地状态分离' Assert-Contains $financeEventConsumer 'envelope.payloadHash().equals(row.get("payload_hash"))' 'Finance 海外事件 Payload 哈希幂等' Assert-Contains $financeEventConsumer "cost_origin='BUSINESS_DIRECT_PAID'" 'Finance 海外直接已付成本投影' Assert-Contains $financePhase1Service "f.settlement_mode='ON_ACCOUNT'" 'Finance AP 只纳入账期应付' @@ -973,7 +977,7 @@ $criticalPaths = @( 'apps/admin-web/src/modules/iam/pages/InternalUsersView.vue', 'apps/admin-web/src/modules/iam/api/platformIam.ts', 'apps/admin-web/src/modules/unified-orders/api/unifiedOrders.ts', - 'apps/client-web/src/api/overseasHttp.ts', + 'apps/client-web/src/api/http.ts', 'apps/client-web/src/modules/overseas-warehouse/routes.ts', 'apps/client-web/src/modules/overseas-warehouse/api/warehouseServices.ts', 'apps/client-web/src/modules/overseas-warehouse/views/WarehouseServiceView.vue', @@ -1080,7 +1084,7 @@ $criticalPaths = @( 'baiyue-firstleg/firstleg/scripts/analyze-firstleg-dependencies.mjs', 'baiyue-firstleg/firstleg/scripts/analyze-firstleg-dependencies.test.mjs', 'baiyue-firstleg/firstleg/src/test/resources/architecture/firstleg-slice-dependencies.json', - 'baiyue-firstleg/auth/src/main/java/com/baiyue/auth/service/OverseasTokenService.java', + 'baiyue-firstleg/auth/src/main/java/com/baiyue/auth/config/UnifiedJwtConfiguration.java', 'baiyue-firstleg/auth/src/main/java/com/baiyue/auth/service/OverseasSessionValidationService.java', 'baiyue-firstleg/iam/src/main/java/com/baiyue/iam/service/PlatformUserManagementService.java', 'baiyue-firstleg/iam/src/main/java/com/baiyue/iam/service/SecurityEventOutboxService.java', diff --git a/scripts/gateway-route-contract.test.mjs b/scripts/gateway-route-contract.test.mjs index 75b40ca37..73470f076 100644 --- a/scripts/gateway-route-contract.test.mjs +++ b/scripts/gateway-route-contract.test.mjs @@ -80,7 +80,6 @@ function verify(path, service, table, file) { } const domestic = routes(read('gateway/src/main/resources/application.yml')) -const overseas = routes(read('gateway-overseas/src/main/resources/application-overseas.yml')) const modules = [...read('pom.xml').matchAll(/([^<]+)<\/module>/g)].map(m => m[1]) test('local regression and root CI require route checks before Maven/dispatch', () => { @@ -101,8 +100,7 @@ test('Controller prefixes route to their owning service; internal prefixes stay if (!existsSync(resolve(root, app))) continue const service = read(app).match(/application:\s*\r?\n\s+name:\s*([\w-]+)/)?.[1] assert.ok(service, `Missing application name: ${module}`) - const tables = ['lastleg-service', 'warehouse-overseas-service'].includes(module) - ? [overseas] : ['file', 'print'].includes(module) ? [domestic, overseas] : [domestic] + const tables = [domestic] for (const file of javaFiles(resolve(root, module, 'src/main/java'))) { for (const path of controllerPaths(readFileSync(file, 'utf8'), file)) { // PDA release metadata lives in the domestic File control plane. Both diff --git a/warehouse-overseas-service/src/main/java/com/baiyue/warehouse/controller/PlatformWarehouseDirectoryController.java b/warehouse-overseas-service/src/main/java/com/baiyue/warehouse/controller/PlatformWarehouseDirectoryController.java index b930b438e..e2efe6836 100644 --- a/warehouse-overseas-service/src/main/java/com/baiyue/warehouse/controller/PlatformWarehouseDirectoryController.java +++ b/warehouse-overseas-service/src/main/java/com/baiyue/warehouse/controller/PlatformWarehouseDirectoryController.java @@ -9,30 +9,23 @@ import com.baiyue.warehouse.security.OverseasWarehousePermissions; import com.baiyue.warehouse.dto.PalletRequests; import com.baiyue.warehouse.entity.WarehousePalletEntity; import com.baiyue.warehouse.service.WarehouseResourceService; -import com.baiyue.warehouse.service.DomesticWarehouseDirectory; import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.tags.Tag; import org.springframework.security.access.prepost.PreAuthorize; import org.springframework.security.core.annotation.AuthenticationPrincipal; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.RequestMapping; -import org.springframework.web.bind.annotation.RequestParam; import org.springframework.web.bind.annotation.RestController; import jakarta.validation.Valid; -import java.util.List; - -/** Exposes the domestic authoritative directory as read-only overseas resource options. */ +/** Cross-warehouse queries for overseas resources; warehouse masters belong to Warehouse. */ @RestController -@RequestMapping("/api/platform/v1/warehouse-admin") +@RequestMapping("/api/platform/v1/overseas-warehouse-resources") @Tag(name = "Platform overseas warehouse directory") public class PlatformWarehouseDirectoryController { - private final DomesticWarehouseDirectory directory; private final WarehouseResourceService resources; - public PlatformWarehouseDirectoryController(DomesticWarehouseDirectory directory, - WarehouseResourceService resources) { - this.directory = directory; + public PlatformWarehouseDirectoryController(WarehouseResourceService resources) { this.resources = resources; } @@ -56,29 +49,6 @@ public class PlatformWarehouseDirectoryController { return ApiResponse.success(resources.publicPalletSummary(WarehouseResourceService.PUBLIC_RESOURCE_SCOPE, query)); } - @GetMapping - @PreAuthorize("hasAuthority('" + OverseasWarehousePermissions.WAREHOUSE_LIST + "')") - @Operation(summary = "Page shared overseas receiving warehouses", - description = "Required authority: " + OverseasWarehousePermissions.WAREHOUSE_LIST) - public ApiResponse> warehouses( - @AuthenticationPrincipal AuthenticatedUser user, - @RequestParam(defaultValue = "1") long page, - @RequestParam(defaultValue = "200") long size) { - requirePlatform(user); - if (page < 1 || size < 1 || size > 200) { - throw new BizException(ResultCode.BAD_REQUEST, "分页参数无效"); - } - List all = directory.options().stream() - .map(value -> new WarehouseOption(value.warehouseId(), 0L, value.warehouseCode(), - value.warehouseName(), "OVERSEAS", value.status())) - .toList(); - long from = Math.min((page - 1) * size, all.size()); - long to = Math.min(from + size, all.size()); - long pages = all.isEmpty() ? 0 : (all.size() + size - 1) / size; - return ApiResponse.success(new PageResult<>(all.subList((int) from, (int) to), - page, size, all.size(), pages)); - } - private static void requirePlatform(AuthenticatedUser user) { if (user == null || !"PLATFORM".equals(user.appCode()) || user.tenantId() == null || user.tenantId() != 0L) { @@ -86,6 +56,4 @@ public class PlatformWarehouseDirectoryController { } } - public record WarehouseOption(long id, long customerId, String warehouseCode, - String warehouseName, String warehouseType, String status) { } } diff --git a/warehouse-overseas-service/src/main/java/com/baiyue/warehouse/controller/PlatformWarehouseResourceController.java b/warehouse-overseas-service/src/main/java/com/baiyue/warehouse/controller/PlatformWarehouseResourceController.java index f2864e1d9..e081ca4f5 100644 --- a/warehouse-overseas-service/src/main/java/com/baiyue/warehouse/controller/PlatformWarehouseResourceController.java +++ b/warehouse-overseas-service/src/main/java/com/baiyue/warehouse/controller/PlatformWarehouseResourceController.java @@ -33,7 +33,7 @@ import org.springframework.web.bind.annotation.RestController; @Validated @RestController -@RequestMapping("/api/platform/v1/warehouse-admin/{warehouseId}") +@RequestMapping("/api/platform/v1/overseas-warehouse-resources/{warehouseId}") @Tag(name = "Platform overseas warehouse resources") public class PlatformWarehouseResourceController { @Resource private WarehouseResourceService service; diff --git a/warehouse-overseas-service/src/main/java/com/baiyue/warehouse/controller/WarehouseHealthController.java b/warehouse-overseas-service/src/main/java/com/baiyue/warehouse/controller/WarehouseHealthController.java deleted file mode 100644 index 795623301..000000000 --- a/warehouse-overseas-service/src/main/java/com/baiyue/warehouse/controller/WarehouseHealthController.java +++ /dev/null @@ -1,26 +0,0 @@ -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-overseas-service. / 返回 warehouse-overseas-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-overseas-service", "status", "UP")); - } -} - diff --git a/warehouse-overseas-service/src/main/resources/application.yml b/warehouse-overseas-service/src/main/resources/application.yml index 5cf4f5762..077cf099c 100644 --- a/warehouse-overseas-service/src/main/resources/application.yml +++ b/warehouse-overseas-service/src/main/resources/application.yml @@ -51,7 +51,7 @@ baiyue: token: ${INTERNAL_SERVICE_TOKEN:phase1-local-internal-token-at-least-32-bytes} security: jwt-mode: jwks - session-mode: gateway + session-mode: redis issuer: ${JWT_ISSUER:auth-service} audience: ${JWT_AUDIENCE:lastleg-api} - jwk-set-uri: ${JWT_JWK_SET_URI:http://127.0.0.1:9100/api/auth/.well-known/jwks.json} + jwk-set-uri: ${JWT_JWKS_URI:http://auth-service:9100/api/auth/.well-known/jwks.json} diff --git a/warehouse-overseas-service/src/main/resources/openapi/pallet-label-v1.yaml b/warehouse-overseas-service/src/main/resources/openapi/pallet-label-v1.yaml index 8152ade7a..f373d8197 100644 --- a/warehouse-overseas-service/src/main/resources/openapi/pallet-label-v1.yaml +++ b/warehouse-overseas-service/src/main/resources/openapi/pallet-label-v1.yaml @@ -3,7 +3,7 @@ info: title: Overseas pallet label version: 1.0.0 paths: - /api/platform/v1/warehouse-admin/{warehouseId}/pallets/{id}/label: + /api/platform/v1/overseas-warehouse-resources/{warehouseId}/pallets/{id}/label: get: operationId: printOverseasPalletLabel summary: 下载指定托盘的一页标签 PDF diff --git a/warehouse-overseas-service/src/test/java/com/baiyue/warehouse/controller/PlatformWarehouseDirectoryControllerTest.java b/warehouse-overseas-service/src/test/java/com/baiyue/warehouse/controller/PlatformWarehouseDirectoryControllerTest.java deleted file mode 100644 index ae3f2b09e..000000000 --- a/warehouse-overseas-service/src/test/java/com/baiyue/warehouse/controller/PlatformWarehouseDirectoryControllerTest.java +++ /dev/null @@ -1,33 +0,0 @@ -package com.baiyue.warehouse.controller; - -import com.baiyue.common.security.AuthenticatedUser; -import com.baiyue.warehouse.dto.SharedWarehouseReference; -import com.baiyue.warehouse.service.DomesticWarehouseDirectory; -import com.baiyue.warehouse.service.WarehouseResourceService; -import org.junit.jupiter.api.Test; - -import java.util.List; -import java.util.Set; - -import static org.junit.jupiter.api.Assertions.assertEquals; -import static org.mockito.Mockito.mock; -import static org.mockito.Mockito.when; - -class PlatformWarehouseDirectoryControllerTest { - @Test - void pagesPublicSharedWarehouseOptions() { - DomesticWarehouseDirectory directory = mock(DomesticWarehouseDirectory.class); - when(directory.options()).thenReturn(List.of( - new SharedWarehouseReference(21L, "US-LAX", "洛杉矶仓", "ACTIVE"))); - var controller = new PlatformWarehouseDirectoryController(directory, mock(WarehouseResourceService.class)); - var user = new AuthenticatedUser(1L, 0L, 0L, 2L, - "tester", "PLATFORM", "PLATFORM", Set.of()); - - var page = controller.warehouses(user, 1, 200).data(); - - assertEquals(1, page.total()); - assertEquals(21L, page.records().get(0).id()); - assertEquals(0L, page.records().get(0).customerId()); - assertEquals("OVERSEAS", page.records().get(0).warehouseType()); - } -} diff --git a/warehouse-overseas-service/src/test/java/com/baiyue/warehouse/controller/WarehouseResourcePermissionContractTest.java b/warehouse-overseas-service/src/test/java/com/baiyue/warehouse/controller/WarehouseResourcePermissionContractTest.java index 3448dfe3f..a7ecbcb66 100644 --- a/warehouse-overseas-service/src/test/java/com/baiyue/warehouse/controller/WarehouseResourcePermissionContractTest.java +++ b/warehouse-overseas-service/src/test/java/com/baiyue/warehouse/controller/WarehouseResourcePermissionContractTest.java @@ -34,10 +34,10 @@ class WarehouseResourcePermissionContractTest { } @Test - void warehouseDirectoryUsesCanonicalListAuthority() throws Exception { + void crossWarehousePalletDirectoryUsesCanonicalListAuthority() throws Exception { assertPermission(PlatformWarehouseDirectoryController.class.getDeclaredMethod( - "warehouses", com.baiyue.common.security.AuthenticatedUser.class, long.class, long.class), - PREFIX + "warehouse:list"); + "pallets", com.baiyue.common.security.AuthenticatedUser.class, com.baiyue.warehouse.dto.PalletRequests.QueryRequest.class), + PREFIX + "pallet:list"); } private static void assertPermission(Method method, String authority) { diff --git a/warehouse/src/main/java/com/baiyue/warehouse/controller/PlatformOverseasReceivingOptionsController.java b/warehouse/src/main/java/com/baiyue/warehouse/controller/PlatformOverseasReceivingOptionsController.java new file mode 100644 index 000000000..cd6b55f62 --- /dev/null +++ b/warehouse/src/main/java/com/baiyue/warehouse/controller/PlatformOverseasReceivingOptionsController.java @@ -0,0 +1,51 @@ +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.service.WarehouseApplicationService; +import io.swagger.v3.oas.annotations.Operation; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +import java.util.List; + +/** Read-only resource options, without granting warehouse master maintenance permissions. */ +@RestController +public class PlatformOverseasReceivingOptionsController { + private final WarehouseApplicationService service; + + public PlatformOverseasReceivingOptionsController(WarehouseApplicationService service) { + this.service = service; + } + + @GetMapping("/api/platform/v1/warehouse-admin/overseas-receiving-options") + @PreAuthorize("hasAuthority('platform:overseas-warehouse:warehouse:list')") + @Operation(summary = "Page active shared overseas receiving warehouse options", + description = "Requires platform:overseas-warehouse:warehouse:list; only public OVERSEAS/ACTIVE/RECEIVING warehouses.") + public ApiResponse> warehouses( + @AuthenticationPrincipal AuthenticatedUser user, + @RequestParam(defaultValue = "1") long page, + @RequestParam(defaultValue = "200") long size) { + if (user == null || !"PLATFORM".equals(user.appCode()) + || user.tenantId() == null || user.tenantId() != 0L) { + throw new BizException(ResultCode.FORBIDDEN, "仅允许平台端访问"); + } + if (page < 1 || size < 1 || size > 200) throw new BizException(ResultCode.BAD_REQUEST, "分页参数无效"); + List all = service.activeSharedOverseasWarehouses().stream() + .map(value -> new WarehouseOption(value.warehouseId(), 0L, value.warehouseCode(), + value.warehouseName(), "OVERSEAS", value.status())).toList(); + long pages = (all.size() + size - 1) / size; + long from = page > pages ? all.size() : (page - 1) * size; + long to = Math.min(from + size, all.size()); + return ApiResponse.success(new PageResult<>(all.subList((int) from, (int) to), page, size, all.size(), pages)); + } + + public record WarehouseOption(long id, long customerId, String warehouseCode, + String warehouseName, String warehouseType, String status) { } +} diff --git a/warehouse/src/test/java/com/baiyue/warehouse/controller/PlatformOverseasReceivingOptionsControllerTest.java b/warehouse/src/test/java/com/baiyue/warehouse/controller/PlatformOverseasReceivingOptionsControllerTest.java new file mode 100644 index 000000000..e9a7ffc03 --- /dev/null +++ b/warehouse/src/test/java/com/baiyue/warehouse/controller/PlatformOverseasReceivingOptionsControllerTest.java @@ -0,0 +1,46 @@ +package com.baiyue.warehouse.controller; + +import com.baiyue.common.security.AuthenticatedUser; +import com.baiyue.common.core.exception.BizException; +import com.baiyue.warehouse.dto.SharedOverseasWarehouseResponse; +import com.baiyue.warehouse.service.WarehouseApplicationService; +import org.junit.jupiter.api.Test; +import org.springframework.security.access.prepost.PreAuthorize; + +import java.util.List; +import java.util.Set; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.*; + +class PlatformOverseasReceivingOptionsControllerTest { + private final WarehouseApplicationService service = mock(WarehouseApplicationService.class); + private final PlatformOverseasReceivingOptionsController controller = new PlatformOverseasReceivingOptionsController(service); + private final AuthenticatedUser platform = new AuthenticatedUser(1L, 0L, 0L, 2L, + "tester", "PLATFORM", "PLATFORM", Set.of()); + + @Test + void pagesAuthoritativeSharedOptionsWithoutExposingMasterWrites() throws Exception { + when(service.activeSharedOverseasWarehouses()).thenReturn(List.of( + new SharedOverseasWarehouseResponse(21L, "US-LAX", "洛杉矶仓", "US", "ACTIVE"))); + var page = controller.warehouses(platform, 1, 200).data(); + assertEquals(1, page.total()); + assertEquals(21L, page.records().get(0).id()); + assertEquals(0L, page.records().get(0).customerId()); + assertEquals("OVERSEAS", page.records().get(0).warehouseType()); + assertEquals("hasAuthority('platform:overseas-warehouse:warehouse:list')", + controller.getClass().getMethod("warehouses", AuthenticatedUser.class, long.class, long.class) + .getAnnotation(PreAuthorize.class).value()); + assertTrue(controller.warehouses(platform, Long.MAX_VALUE, 200).data().records().isEmpty()); + } + + @Test + void rejectsInvalidScopeAndPaginationBeforeReadingDirectory() { + var merchant = new AuthenticatedUser(2L, 31L, 31L, 3L, "client", "MERCHANT", "MERCHANT", Set.of()); + assertThrows(BizException.class, () -> controller.warehouses(merchant, 1, 200)); + assertThrows(BizException.class, () -> controller.warehouses(null, 1, 200)); + assertThrows(BizException.class, () -> controller.warehouses(platform, 0, 200)); + assertThrows(BizException.class, () -> controller.warehouses(platform, 1, 201)); + verifyNoInteractions(service); + } +}