This commit is contained in:
2026-09-29 17:55:54 +08:00
parent 41d8387cd5
commit 54ad26c607
111 changed files with 1837 additions and 962 deletions
+5 -4
View File
@@ -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、撤销矩阵和切换门槛只维护在新的两份主契约中。
## 契约、数据库与验证入口
+5 -3
View File
@@ -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。内部调用不通过公网,部署及通信规则以新的两份主契约为准。
+4 -4
View File
@@ -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。
@@ -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) {
}
@@ -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));
}
}
@@ -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) { }
@@ -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);
}
}
}
@@ -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) { }
@@ -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<OverseasTokenService> overseasTokens;
@PostMapping("/overseas/token")
@PreAuthorize("isAuthenticated()")
@Operation(summary = "兑换海外尾程访问令牌", description = "使用已通过国内会话校验的身份兑换 15 分钟 RS256 海外令牌。")
public ApiResponse<OverseasTokenService.OverseasTokenResponse> 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 = "修改当前用户密码")
@@ -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<Map<String, Object>> jwks() throws Exception {
return ResponseEntity.ok().cacheControl(CacheControl.maxAge(5, TimeUnit.MINUTES).cachePublic())
.body(JWKSet.parse(properties.rsaJwkSet()).toPublicJWKSet().toJSONObject());
@@ -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<String> roles,
@Schema(description = "Current authority snapshot returned outside JWT / JWT 外返回的当前权限快照") Set<String> authorities
) {
@@ -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<OverseasTokenService> 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<String> roles,
java.util.Set<String> 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() {
@@ -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<String> roles, java.util.Set<String> 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);
}
}
}
+6 -8
View File
@@ -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:
@@ -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);
});
}
}
@@ -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);
}
}
@@ -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);
@@ -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);
}
}
@@ -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<JwtEncoderParameters> 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<JwtEncoderParameters> 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<JwtEncoderParameters> captor = ArgumentCaptor.forClass(JwtEncoderParameters.class);
verify(encoder).encode(captor.capture());
assertThat(captor.getValue().getClaims().getClaims())
.doesNotContainKey("overseas_warehouse_id");
}
}
@@ -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)));
}
}
@@ -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);
}
}
@@ -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());
}
}
}
@@ -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);
};
}
}
@@ -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);
}
}
+4 -4
View File
@@ -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`。
@@ -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<StringRedisTemplate> 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);
}
@@ -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<String> values(Object value){ Set<String> 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; }
}
@@ -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<String> values(String header) {
if (header == null || header.isBlank()) return Set.of();
LinkedHashSet<String> values = new LinkedHashSet<>();
for (String value : header.split(",")) {
String normalized = value.trim();
if (!normalized.isEmpty()) values.add(normalized);
}
return Set.copyOf(values);
}
}
@@ -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;
}
}
@@ -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)
@@ -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,
@@ -1,3 +1,2 @@
com.baiyue.common.security.HmacJwtDecoderAutoConfiguration
com.baiyue.common.security.JwksJwtDecoderAutoConfiguration
com.baiyue.common.security.BaiyueSecurityAutoConfiguration
@@ -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();
}
}
@@ -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);
});
@@ -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);
}
}
}
@@ -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;
}
}
}
@@ -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<Request.Options>();
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);
}
}
+2 -2
View File
@@ -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}
@@ -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}
+1
View File
@@ -25,6 +25,7 @@
<dependency><groupId>com.alibaba.cloud</groupId><artifactId>spring-cloud-starter-alibaba-nacos-config</artifactId></dependency>
<dependency><groupId>org.springframework.boot</groupId><artifactId>spring-boot-starter-actuator</artifactId></dependency>
<dependency><groupId>org.springframework.boot</groupId><artifactId>spring-boot-starter-test</artifactId><scope>test</scope></dependency>
<dependency><groupId>com.h2database</groupId><artifactId>h2</artifactId><scope>test</scope></dependency>
</dependencies>
<build>
<plugins>
@@ -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<Acknowledgement> receive(@RequestHeader("X-Internal-Service-Token") String token,
@RequestBody EventEnvelope envelope);
record Acknowledgement(String eventId, String result, String receivedAt) { }
}
@@ -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<DeliveryAck> receive(@RequestHeader("X-Internal-Service-Token") String token,
@RequestBody EventEnvelope envelope);
}
@@ -25,7 +25,7 @@ public class InternalBusinessFeeSourceController {
@Value("${baiyue.security.internal-service-token}") private String expectedToken;
@PostMapping
public ApiResponse<Map<String, Long>> consume(
public ApiResponse<com.baiyue.common.event.DeliveryAck> 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));
}
}
@@ -24,7 +24,7 @@ public class InternalWalletCommandEventController {
@Value("${baiyue.security.internal-service-token}") private String expectedToken;
@PostMapping
public ApiResponse<Map<String, String>> consume(
public ApiResponse<com.baiyue.common.event.DeliveryAck> 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));
}
}
@@ -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) {
@@ -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<String> OPERATIONS = Set.of("FREEZE", "SETTLE", "RELEASE");
private static final Set<String> 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<String> 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<String> 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);
}
}
@@ -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<String, Object> 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<DATE_SUB(NOW(3),INTERVAL 2 MINUTE)");
var policy = retryPolicy();
LocalDateTime now = LocalDateTime.now();
LocalDateTime cutoff = now.minusSeconds(leaseSeconds);
for (Map<String, Object> row : jdbc.queryForList("SELECT event_id,locked_by,retry_count FROM fin_wallet_event_outbox WHERE status='SENDING' AND locked_at<? ORDER BY locked_at,event_id LIMIT 50", cutoff)) {
int failures = ((Number) row.get("retry_count")).intValue() + 1;
boolean dead = policy.exhausted(failures);
jdbc.update("UPDATE fin_wallet_event_outbox SET status=?,retry_count=?,next_retry_at=?,locked_by=NULL,locked_at=NULL,last_error=?,updated_at=? WHERE event_id=? AND status='SENDING' AND locked_by=? AND locked_at<?",
dead ? "DEAD" : "PENDING", failures, dead ? now : now.plus(policy.delayAfterFailure(failures)),
"投递租约超时,消费结果未知" + (dead ? ";自动次数已耗尽" : ""), now,
row.get("event_id"), row.get("locked_by"), cutoff);
}
}
private BoundedRetryPolicy retryPolicy() {
if (leaseSeconds < 1) throw new IllegalArgumentException("Lease duration must be positive");
return new BoundedRetryPolicy(maxAttempts, baseDelaySeconds, maxDelaySeconds, retryJitter);
}
private static String safe(String value) {
String text = value == null ? "unknown" : value.replaceAll("[\\r\\n\\t]+", " ");
@@ -52,7 +52,7 @@ class BusinessFeeSourceEventServiceTest {
when(jdbc.queryForObject(startsWith("SELECT id FROM fin_fee_source"), eq(Long.class),
eq("LASTLEG"), eq("OVERSEAS_PROCUREMENT_FEE"), eq(701L), eq(1))).thenReturn(901L);
assertThat(service.consume(event)).isEqualTo(901L);
assertThat(service.consume(event).eventId()).isEqualTo(event.eventId());
ArgumentCaptor<Object[]> 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<Object[]> 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));
@@ -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<TransactionStatus> 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"));
@@ -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<com.baiyue.common.event.DeliveryAck> first = executor.submit(() -> service.consume(firstEvent));
assertThat(enteredWallet.await(5, TimeUnit.SECONDS)).isTrue();
Future<com.baiyue.common.event.DeliveryAck> 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);
}
}
@@ -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();
}
}
}
@@ -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); }
}
+2 -2
View File
@@ -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` |
@@ -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<Acknowledgement> receive(
@RequestHeader("X-Internal-Service-Token") String token,
@RequestBody EventEnvelope envelope);
record Acknowledgement(String eventId, String result, String receivedAt) { }
}
@@ -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<DeliveryAck> receive(
@RequestHeader("X-Internal-Service-Token") String token,
@RequestBody EventEnvelope envelope);
}
@@ -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<Object> receive(
public ApiResponse<DeliveryAck> 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);
}
@@ -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)
@@ -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<EventBridgeClient.Acknowledgement> response) {
if(response==null) throw new IllegalStateException("事件桥未返回响应");
ApiResponse<com.baiyue.common.event.DeliveryAck> 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) {
@@ -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) {
@@ -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) {
@@ -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) {
@@ -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);
@@ -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));
@@ -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<InboundHoldRecordEntity> 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);
@@ -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<List<String>>("aud", values -> values != null && values.contains(properties.audience()))));
return decoder;
}
@@ -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<String> 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);
}
+29 -1
View File
@@ -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
@@ -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;
@@ -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
@@ -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
@@ -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();
}
}
@@ -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,
@@ -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()))));
}
@@ -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<Long> 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<Long> batch = Set.copyOf(accounts.subList(start, Math.min(start + 200, accounts.size())));
try {
ApiResponse<Void> 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 = "平台提交企业客户审核")
@@ -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);
}
}
@@ -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");
@@ -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");
+1
View File
@@ -22,6 +22,7 @@
<dependency><groupId>org.springframework.boot</groupId><artifactId>spring-boot-starter-test</artifactId><scope>test</scope></dependency>
<dependency><groupId>com.tngtech.archunit</groupId><artifactId>archunit</artifactId><scope>test</scope></dependency>
<dependency><groupId>com.belerweb</groupId><artifactId>pinyin4j</artifactId><version>2.5.1</version></dependency>
<dependency><groupId>com.h2database</groupId><artifactId>h2</artifactId><scope>test</scope></dependency>
</dependencies>
<build><plugins><plugin><groupId>org.springframework.boot</groupId><artifactId>spring-boot-maven-plugin</artifactId></plugin></plugins></build>
</project>
@@ -0,0 +1,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<DeliveryAck> 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());
};
}
}
@@ -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<Acknowledgement> receive(
@RequestHeader("X-Internal-Service-Token") String token,
@RequestBody EventEnvelope envelope);
record Acknowledgement(String eventId, String result, String receivedAt) { }
}
@@ -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<DeliveryAck> wallet(@RequestHeader("X-Internal-Service-Token") String token,
@RequestBody EventEnvelope envelope);
@PostMapping("/internal/finance/v1/business-fee-source-events")
ApiResponse<DeliveryAck> fee(@RequestHeader("X-Internal-Service-Token") String token,
@RequestBody EventEnvelope envelope);
}
@@ -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<DeliveryAck> receive(@RequestHeader("X-Internal-Service-Token") String token,
@RequestBody EventEnvelope envelope);
}
@@ -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<Integer> receive(
public ApiResponse<DeliveryAck> 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())) {
@@ -27,8 +27,11 @@ public class OverseasFeeOutboxRepository {
public boolean claim(long id, String worker, LocalDateTime now, LocalDateTime stale) {
return mapper.update(null, new LambdaUpdateWrapper<LastlegEventOutbox>()
.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)));
@@ -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());
}
}
}
@@ -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<EventBridgeClient.Acknowledgement> response) {
if (response == null) throw new IllegalStateException("国内事件桥返回空响应");
com.baiyue.common.core.api.ApiResponse<DeliveryAck> 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);
}
}
@@ -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")
@@ -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) {
@@ -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) {
@@ -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<WalletResultHandler> 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<String> existing = jdbc.queryForList("SELECT payload_hash FROM ll_wallet_event_inbox WHERE event_id=?", String.class, envelope.eventId());
List<String> 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<String> 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) {
@@ -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}
@@ -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)));
}
}
@@ -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:}}");
}
}
@@ -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);
}
}
@@ -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);
@@ -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();
}
}
}
@@ -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<LambdaUpdateWrapper> 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() {
@@ -5,8 +5,8 @@ class LoadingCompletedConsumerTest {
private final Map<String,String> 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<InventoryCommandRequests.InTransit> 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<LastlegOrder> 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<LastlegInboundOrder> 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<LastlegOrder> 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<InventoryCommandRequests.InTransit> 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<LastlegOrder> 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<LastlegInboundOrder> 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<LastlegOrder> 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<LastlegOrder> 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<LastlegOrder> 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<LastlegOrder> 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<DropshipRequests.Receive> 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<DropshipRequests.Receive> 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<InventoryCommandRequests.InTransit> 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<LastlegOrderDetail> 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<LastlegOrder> 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<LastlegOrder> 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<com.baiyue.lastleg.inboundplan.pda.entity.ContainerLoadingEvidence> 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()));
@@ -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);
@@ -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));

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