暂存提交

This commit is contained in:
2026-08-13 19:01:16 +08:00
parent 8ba04fc87d
commit 073cb6a577
50 changed files with 981 additions and 136 deletions
+76
View File
@@ -0,0 +1,76 @@
# 国内与海外 JWT/JWKS 冻结契约
## 双轨边界
### 国内现有链路
- 登录、刷新、退出和代登录接口保持原契约,不修改 `TokenResponse`。
- 签名算法保持 `HS256`,继续使用 `JWT_SECRET`。
- Access Token 默认有效期保持 24 分钟,Refresh Token 默认有效期保持 7 天。
- 业务服务继续使用国内 HMAC 验签与 Redis 会话校验,账号停用、退出和 Token 版本失效规则不变。
- 国内 Token 保持现有 snake_case 声明,不增加海外 `aud`,也不要求配置 RSA/JWK。
### 海外尾程链路
- 海外能力默认关闭:`OVERSEAS_JWT_ENABLED=false`。
- 已通过国内 HS256 与 Redis 校验的登录用户调用 `POST /api/auth/overseas/token`,兑换独立的海外短时 Token。
- 海外 Token 使用 `RS256`,默认有效期 15 分钟,不签发 Refresh Token。
- 尾程必须配置 `baiyue.security.jwt-mode=jwks` 和 `baiyue.security.session-mode=claims`,只做 JWKS 验签和本地安全投影校验,不创建或访问国内 Redis 会话校验器。
- 海外功能未启用时,兑换接口返回“海外认证未启用”;JWKS Controller、海外签发服务和 `overseasJwtEncoder` 均不创建,Auth 仍可正常启动并签发国内 HS256 Token。
## 海外签发契约
- `issuer`:`auth-service`
- `audience`:`lastleg-api`
- 算法白名单:仅 `RS256`
- Access Token 默认有效期:15 分钟
- 兑换接口:`POST /api/auth/overseas/token`
- JWKS 路径:`/api/auth/.well-known/jwks.json`
- Auth 直连地址:`http://{auth-host}:9100/api/auth/.well-known/jwks.json`
- JWKS HTTP 缓存:5 分钟,响应 `Cache-Control: public, max-age=300`
海外 Token 的 JSON 声明名称和类型固定如下:
| 声明 | JSON 类型 | 说明 |
|---|---|---|
| `sub` | string | 用户 ID 的字符串形式 |
| `jti` | string | Token 唯一标识 |
| `iat` | number | 签发时间 |
| `exp` | number | 过期时间 |
| `aud` | string array | 固定包含 `lastleg-api` |
| `userId` | number | 认证用户 ID |
| `tenantId` | number | 当前客户 ID;平台账号为平台作用域值 |
| `appCode` | string | `PLATFORM` 或 `MERCHANT` |
| `roles` | string array | 当前角色编码快照 |
| `authorities` | string array | 当前权限编码快照 |
| `permissionVersion` | number | IAM 单调权限版本 |
海外 Token 从 V1 起只使用上述 camelCase 自定义声明,不读取国内 snake_case 声明。
## 海外配置
```yaml
baiyue:
security:
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}
```
只有显式启用海外能力时才校验 `OVERSEAS_JWT_RSA_JWK_SET` 和 `OVERSEAS_JWT_CURRENT_KID`。启用后配置不合法必须启动失败,避免错误密钥签发。
## 密钥轮换
- JWK Set 只允许 RSA 密钥。
- `OVERSEAS_JWT_CURRENT_KID` 必须指向唯一的当前签名私钥,JWT Header 写入该 `kid`。
- 非当前密钥只能保留公钥;JWKS 只发布公钥,不得输出私钥参数。
- 轮换时同时发布当前公钥和上一公钥,上一公钥至少保留 20 分钟,即 15 分钟最大 Token 生命周期加 5 分钟 JWKS 缓存。
- 重叠期结束后移除上一公钥,禁止复用历史 `kid`。
## IAM 安全事件
安全事件的 `eventType`、版本、状态枚举和 payload 以 `iam/SECURITY-EVENT-AUDIT.md` 为唯一契约。尾程维护用户状态、客户状态和权限版本的本地投影,请求期间不反向同步访问国内 IAM。
@@ -20,4 +20,5 @@ public class AuthorizationSnapshot {
private String clientType;
private Set<String> roles;
private Set<String> permissions;
private Long permissionVersion;
}
@@ -1,6 +1,5 @@
package com.baiyue.auth.config;
import com.nimbusds.jose.jwk.source.ImmutableSecret;
import com.baiyue.common.security.JwtAuthenticationFilter;
import org.springframework.boot.context.properties.EnableConfigurationProperties;
import org.springframework.context.annotation.Bean;
@@ -10,14 +9,11 @@ import org.springframework.security.config.http.SessionCreationPolicy;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.security.oauth2.jose.jws.MacAlgorithm;
import org.springframework.security.oauth2.jwt.JwtEncoder;
import org.springframework.security.oauth2.jwt.NimbusJwtEncoder;
import org.springframework.security.web.SecurityFilterChain;
import org.springframework.security.web.AuthenticationEntryPoint;
import org.springframework.security.web.access.AccessDeniedHandler;
import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter;
import java.nio.charset.StandardCharsets;
/**
* 认证服务安全配置,集中声明密码编码器、JWT 编码器以及认证接口的安全过滤链。
@@ -31,6 +27,7 @@ public class AuthSecurityConfig {
"/api/auth/refresh",
"/api/auth/logout",
"/api/auth/impersonations/exchange",
"/api/auth/.well-known/jwks.json",
"/actuator/health",
"/v3/api-docs/**",
"/swagger-ui/**",
@@ -40,13 +37,6 @@ public class AuthSecurityConfig {
@Bean
PasswordEncoder passwordEncoder() { return new BCryptPasswordEncoder(12); }
@Bean
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));
}
@Bean
SecurityFilterChain authSecurityFilterChain(HttpSecurity http,
JwtAuthenticationFilter jwtFilter,
@@ -0,0 +1,22 @@
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));
}
}
@@ -0,0 +1,37 @@
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;
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) {
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 密钥");
}
if (set.getKeys().stream().anyMatch(jwk -> !(jwk instanceof RSAKey))) {
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("海外非当前密钥只能配置公钥");
}
return new NimbusJwtEncoder(new ImmutableJWKSet<>(set));
} catch (java.text.ParseException exception) {
throw new IllegalStateException("海外 RSA JWK Set 配置无效", exception);
}
}
}
@@ -0,0 +1,10 @@
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) { }
@@ -23,6 +23,8 @@ 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;
/**
@@ -38,6 +40,18 @@ 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()")
@@ -0,0 +1,31 @@
package com.baiyue.auth.controller;
import com.baiyue.auth.config.OverseasJwtProperties;
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;
import org.springframework.web.bind.annotation.RestController;
import java.util.Map;
import java.util.concurrent.TimeUnit;
/** 仅在海外 JWT 显式启用后发布 RSA 公钥。 */
@RestController
@ConditionalOnProperty(name = "baiyue.security.overseas-jwt.enabled", havingValue = "true")
public class JwksController {
private final OverseasJwtProperties properties;
public JwksController(OverseasJwtProperties properties) { this.properties = properties; }
@GetMapping("/api/auth/.well-known/jwks.json")
@SecurityRequirements
@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());
}
}
@@ -30,7 +30,6 @@ import java.time.LocalDateTime;
import java.util.Base64;
import java.util.UUID;
import java.time.Duration;
import java.util.List;
/**
* 统一认证应用服务,编排密码校验、IAM 权限快照加载、小 JWT 签发、刷新令牌轮换与注销事务。
@@ -109,8 +108,7 @@ public class AuthService {
String jwtId = UUID.randomUUID().toString();
Instant expiresAt = issuedAt.plus(properties.accessTokenTtl());
JwtClaimsSet claims = JwtClaimsSet.builder()
.issuer(properties.issuer()).audience(List.of(properties.audience()))
.id(jwtId).issuedAt(issuedAt).expiresAt(expiresAt)
.issuer(properties.issuer()).id(jwtId).issuedAt(issuedAt).expiresAt(expiresAt)
.subject(account.getId().toString())
.claim("user_id", account.getId())
.claim("username", account.getUsername())
@@ -121,12 +119,6 @@ public class AuthService {
.claim("client_type", authorization.getClientType())
.claim("token_version", account.getTokenVersion())
.claim("password_change_required", Boolean.TRUE.equals(account.getPasswordChangeRequired()))
.claim("userId", account.getId())
.claim("tenantId", account.getDefaultTenantId())
.claim("appCode", account.getDefaultAppCode())
.claim("roles", authorization.getRoles())
.claim("authorities", authorization.getPermissions())
.claim("permissionVersion", account.getTokenVersion())
.build();
String accessToken = jwtEncoder.encode(JwtEncoderParameters.from(
signingHeader(), claims)).getTokenValue();
@@ -175,17 +167,12 @@ public class AuthService {
Instant issuedAt = Instant.now();
Instant expiresAt = issuedAt.plus(Duration.ofMinutes(15));
String jwtId = UUID.randomUUID().toString();
JwtClaimsSet claims = JwtClaimsSet.builder().issuer(properties.issuer())
.audience(List.of(properties.audience())).id(jwtId)
JwtClaimsSet claims = JwtClaimsSet.builder().issuer(properties.issuer()).id(jwtId)
.issuedAt(issuedAt).expiresAt(expiresAt).subject(account.getId().toString())
.claim("user_id", account.getId()).claim("username", account.getUsername())
.claim("tenant_id", account.getDefaultTenantId()).claim("operator_tenant_id", account.getOperatorTenantId())
.claim("membership_id", account.getDefaultMembershipId()).claim("app_code", "MERCHANT")
.claim("client_type", authorization.getClientType()).claim("token_version", account.getTokenVersion())
.claim("userId", account.getId()).claim("tenantId", account.getDefaultTenantId())
.claim("appCode", "MERCHANT").claim("roles", authorization.getRoles())
.claim("authorities", authorization.getPermissions())
.claim("permissionVersion", account.getTokenVersion())
.claim("impersonated", true).claim("impersonation_id", ticket.ticketId())
.claim("actor_user_id", ticket.actorUserId()).claim("actor_membership_id", ticket.actorMembershipId())
.claim("password_change_required", Boolean.TRUE.equals(account.getPasswordChangeRequired())).build();
@@ -0,0 +1,56 @@
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);
Instant issuedAt = Instant.now();
Instant expiresAt = issuedAt.plus(properties.accessTokenTtl());
JwtClaimsSet claims = JwtClaimsSet.builder()
.issuer(properties.issuer()).audience(List.of(properties.audience()))
.subject(account.getId().toString()).id(UUID.randomUUID().toString())
.issuedAt(issuedAt).expiresAt(expiresAt)
.claim("userId", account.getId()).claim("tenantId", account.getDefaultTenantId())
.claim("appCode", account.getDefaultAppCode()).claim("roles", authorization.getRoles())
.claim("authorities", authorization.getPermissions())
.claim("permissionVersion", authorization.getPermissionVersion() == null ? 0L : authorization.getPermissionVersion())
.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());
}
public record OverseasTokenResponse(String tokenType, String accessToken, long expiresIn) { }
}
+7
View File
@@ -39,6 +39,13 @@ 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}
baiyue.clients:
iam:
@@ -72,6 +72,6 @@ class AuthorizationLoaderTest {
private AuthorizationSnapshot snapshot(Long userId, Long tenantId, Long membershipId, String appCode) {
return new AuthorizationSnapshot(userId, tenantId, membershipId, appCode, appCode,
Set.of("MERCHANT_ADMIN"), Set.of("merchant:profile:view"));
Set.of("MERCHANT_ADMIN"), Set.of("merchant:profile:view"), 1L);
}
}
@@ -15,8 +15,9 @@ class AuthTokenTtlConfigurationContractTest {
Path.of("..", "config", "nacos", "cn", "auth-service.yml"), StandardCharsets.UTF_8);
assertThat(configuration)
.contains("access-token-ttl: ${ACCESS_TOKEN_TTL:15m}")
.contains("access-token-ttl: ${ACCESS_TOKEN_TTL:24m}")
.contains("refresh-token-ttl: ${REFRESH_TOKEN_TTL:7d}")
.contains("access-token-ttl: ${OVERSEAS_ACCESS_TOKEN_TTL:15m}")
.doesNotContain("access-token-ttl: ${ACCESS_TOKEN_TTL:7d}");
}
}
@@ -0,0 +1,29 @@
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,40 @@
package com.baiyue.auth.controller;
import com.baiyue.auth.config.OverseasJwtProperties;
import com.nimbusds.jose.JWSAlgorithm;
import com.nimbusds.jose.jwk.JWKSet;
import com.nimbusds.jose.jwk.KeyUse;
import com.nimbusds.jose.jwk.RSAKey;
import com.nimbusds.jose.util.Base64URL;
import org.junit.jupiter.api.Test;
import java.math.BigInteger;
import java.time.Duration;
import java.util.List;
import static org.assertj.core.api.Assertions.assertThat;
class JwksControllerTest {
@Test
void publishesOnlyPublicKeyMaterialForCurrentAndPreviousKeys() throws Exception {
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 body = new JwksController(properties).jwks().getBody();
String published = new com.fasterxml.jackson.databind.ObjectMapper().writeValueAsString(body);
assertThat(published).contains("current", "previous", "\"n\"", "\"e\"")
.doesNotContain("\"d\"", "\"p\"", "\"q\"", "\"dp\"", "\"dq\"", "\"qi\"");
}
private RSAKey key(String kid, boolean privatePart) {
RSAKey.Builder builder = new RSAKey.Builder(
Base64URL.encode(BigInteger.valueOf(3233)), Base64URL.encode(BigInteger.valueOf(17)))
.keyID(kid).keyUse(KeyUse.SIGNATURE).algorithm(JWSAlgorithm.RS256);
if (privatePart) builder.privateExponent(Base64URL.encode(BigInteger.valueOf(2753)));
return builder.build();
}
}
@@ -47,7 +47,7 @@ class AuthServiceSmallJwtTest {
}
@Test
void signsCrossRegionClaimsAndStoresSession() {
void signsDomesticHmacClaimsAndStoresSession() {
AuthAccount account = new AuthAccount();
account.setId(42L);
account.setUsername("operator");
@@ -59,7 +59,7 @@ class AuthServiceSmallJwtTest {
account.setDefaultAppCode("PLATFORM");
account.setTokenVersion(1);
AuthorizationSnapshot snapshot = new AuthorizationSnapshot(42L, 0L, 84L, "PLATFORM",
"WEB", Set.of("PLATFORM_ADMIN"), Set.of("platform:order:list"));
"WEB", Set.of("PLATFORM_ADMIN"), Set.of("platform:order:list"), 7L);
Jwt encoded = org.mockito.Mockito.mock(Jwt.class);
when(repository.findByUsername("operator")).thenReturn(account);
@@ -74,15 +74,10 @@ class AuthServiceSmallJwtTest {
verify(jwtEncoder).encode(jwt.capture());
assertThat(jwt.getValue().getClaims().getClaims())
.containsEntry("user_id", 42L)
.containsEntry("userId", 42L)
.containsEntry("tenantId", 0L)
.containsEntry("appCode", "PLATFORM")
.containsEntry("roles", Set.of("PLATFORM_ADMIN"))
.containsEntry("authorities", Set.of("platform:order:list"))
.containsEntry("permissionVersion", 1)
.containsEntry("membership_id", 84L)
.doesNotContainKeys("permissions", "scope");
assertThat(jwt.getValue().getClaims().getAudience()).containsExactly("lastleg-api");
.doesNotContainKeys("userId", "tenantId", "appCode", "roles", "authorities",
"permissionVersion", "permissions", "scope");
assertThat(jwt.getValue().getClaims().getAudience()).isNull();
assertThat(jwt.getValue().getJwsHeader().getAlgorithm()).isEqualTo(MacAlgorithm.HS256);
assertThat(jwt.getValue().getJwsHeader().getKeyId()).isNull();
verify(tokenSessionStore).store(any(), org.mockito.ArgumentMatchers.eq(42L),
@@ -0,0 +1,74 @@
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.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(100L);
account.setDefaultMembershipId(84L); account.setDefaultAppCode("MERCHANT");
AuthorizationSnapshot snapshot = new AuthorizationSnapshot(42L, 100L, 84L, "MERCHANT", "WEB",
Set.of("MERCHANT_ADMIN"), Set.of("merchant:order:list"), 7L);
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("userId", 42L).containsEntry("tenantId", 100L)
.containsEntry("appCode", "MERCHANT").containsEntry("permissionVersion", 7L)
.containsEntry("roles", Set.of("MERCHANT_ADMIN"))
.containsEntry("authorities", Set.of("merchant:order:list"))
.doesNotContainKeys("user_id", "tenant_id", "app_code");
assertThat(response.expiresIn()).isEqualTo(900);
}
}
@@ -0,0 +1,18 @@
package com.baiyue.common.event;
import java.time.Instant;
/** IAM 向海外服务发布的 V1 安全投影事件负载。 */
public final class IamSecurityEventsV1 {
public static final int VERSION = 1;
private IamSecurityEventsV1() { }
public record UserStatusChanged(long authUserId, long tenantId, String appCode,
String status, long permissionVersion, Instant changedAt) { }
public record TenantStatusChanged(long tenantId, String status, Instant changedAt) { }
public record PermissionVersionChanged(long authUserId, long tenantId, String appCode,
long permissionVersion, String reason, Instant changedAt) { }
}
+8 -2
View File
@@ -6,6 +6,12 @@ spring:
driver-class-name: com.mysql.cj.jdbc.Driver
baiyue:
security:
audience: ${JWT_AUDIENCE:lastleg-api}
access-token-ttl: ${ACCESS_TOKEN_TTL:15m}
access-token-ttl: ${ACCESS_TOKEN_TTL:24m}
refresh-token-ttl: ${REFRESH_TOKEN_TTL:7d}
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}
+30 -12
View File
@@ -4,36 +4,54 @@
xsi:schemaLocation="http://maven.apache.org/SETTINGS/1.2.0 https://maven.apache.org/xsd/settings-1.2.0.xsd">
<mirrors>
<mirror>
<id>aliyun-public</id>
<name>阿里云 Maven 公共仓库</name>
<url>https://maven.aliyun.com/repository/public</url>
<mirrorOf>central</mirrorOf>
<id>baiyue-nexus-public</id>
<name>柏悦 Nexus Maven 聚合仓库</name>
<url>http://8.163.30.128:18081/repository/maven-public/</url>
<mirrorOf>external:*</mirrorOf>
</mirror>
</mirrors>
<servers>
<server>
<id>baiyue-nexus-public</id>
<username>${env.NEXUS_USERNAME}</username>
<password>${env.NEXUS_PASSWORD}</password>
</server>
<server>
<id>baiyue-nexus-releases</id>
<username>${env.NEXUS_USERNAME}</username>
<password>${env.NEXUS_PASSWORD}</password>
</server>
<server>
<id>baiyue-nexus-snapshots</id>
<username>${env.NEXUS_USERNAME}</username>
<password>${env.NEXUS_PASSWORD}</password>
</server>
</servers>
<profiles>
<profile>
<id>国内仓库</id>
<id>柏悦 Nexus</id>
<repositories>
<repository>
<id>central</id>
<url>https://repo.maven.apache.org/maven2</url>
<id>baiyue-nexus-public</id>
<url>http://8.163.30.128:18081/repository/maven-public/</url>
<releases><enabled>true</enabled></releases>
<snapshots><enabled>false</enabled></snapshots>
<snapshots><enabled>true</enabled></snapshots>
</repository>
</repositories>
<pluginRepositories>
<pluginRepository>
<id>central</id>
<url>https://repo.maven.apache.org/maven2</url>
<id>baiyue-nexus-public</id>
<url>http://8.163.30.128:18081/repository/maven-public/</url>
<releases><enabled>true</enabled></releases>
<snapshots><enabled>false</enabled></snapshots>
<snapshots><enabled>true</enabled></snapshots>
</pluginRepository>
</pluginRepositories>
</profile>
</profiles>
<activeProfiles>
<activeProfile>国内仓库</activeProfile>
<activeProfile>柏悦 Nexus</activeProfile>
</activeProfiles>
</settings>
+1
View File
@@ -11,6 +11,7 @@
- `warehouse/phase2-pallet-location-schema-migration.sql` 的有效结构已被当前 `phase2-pallet-location-schema.sql` 和后续共享溢出位迁移完整覆盖,因此不重复内联,避免每次部署无条件删除并重建同名唯一索引。
- 脚本会创建 9 个独立数据库:`baiyue_auth`、`baiyue_iam`、`baiyue_order`、`baiyue_warehouse`、`baiyue_firstleg`、`baiyue_file`、`baiyue_audit`、`baiyue_event_bridge`、`baiyue_finance`。Gateway、Print、Integration Service 无数据库。
- IAM 消息中心基线已内联到固定入口,包括 5 张 `iam_notification_*` 表、任务/事件幂等约束、平台消息管理权限、企业成员收件箱权限及默认角色授权;模块溯源脚本为 `iam/src/main/resources/db/notification-center.sql`。
- IAM 跨区安全事件基线已内联到固定入口,包括主体权限版本、客户状态版本和可靠事件发送箱 3 张表;模块溯源脚本为 `iam/src/main/resources/db/security-event-outbox.sql`。IAM 新版本启动前必须先执行固定入口,避免安全事件 Relay 和授权快照查询因缺表失败。
- 完整执行所有增量区块并通过末段真实结构校验后,脚本最末才会返回唯一的 `PASS`;任一强校验失败会通过 `SIGNAL` 终止。
后续追加增量必须满足:
@@ -14890,6 +14890,61 @@ DELIMITER ;
CALL assert_finance_permissions();
DROP PROCEDURE IF EXISTS assert_finance_permissions;
-- IAM 跨区安全事件可靠发送箱。来源:iam/src/main/resources/db/security-event-outbox.sql
USE baiyue_iam;
CREATE TABLE IF NOT EXISTS iam_security_subject_version (
auth_user_id BIGINT NOT NULL COMMENT '认证账号ID',
permission_version BIGINT NOT NULL DEFAULT 0 COMMENT '权限版本,身份或授权变化时单调递增',
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3),
PRIMARY KEY (auth_user_id)
) ENGINE=InnoDB COMMENT='安全主体权限版本';
CREATE TABLE IF NOT EXISTS iam_security_tenant_version (
tenant_id BIGINT NOT NULL COMMENT '客户ID',
status_version BIGINT NOT NULL DEFAULT 0 COMMENT '客户状态单调版本',
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3),
PRIMARY KEY (tenant_id)
) ENGINE=InnoDB COMMENT='客户状态事件版本';
CREATE TABLE IF NOT EXISTS iam_security_event_outbox (
event_id VARCHAR(64) NOT NULL COMMENT '全局事件ID,消费者幂等键',
event_type VARCHAR(64) NOT NULL COMMENT '安全事件类型',
aggregate_type VARCHAR(32) NOT NULL COMMENT 'USER、TENANT或AUTHORIZATION',
aggregate_id VARCHAR(64) NOT NULL COMMENT '聚合标识',
payload_json JSON NOT NULL COMMENT '标准跨区事件信封,不含密码、令牌和权限全集',
status VARCHAR(16) NOT NULL DEFAULT 'PENDING' COMMENT 'PENDING、SENDING、SENT、DEAD',
attempt_count INT NOT NULL DEFAULT 0,
next_retry_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
lease_owner VARCHAR(128) NULL,
lease_until DATETIME(3) NULL,
last_error VARCHAR(1000) NULL,
sent_at DATETIME(3) NULL,
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3),
PRIMARY KEY (event_id),
KEY idx_security_outbox_publish (status,next_retry_at,lease_until,created_at),
KEY idx_security_outbox_aggregate (aggregate_type,aggregate_id,created_at)
) ENGINE=InnoDB COMMENT='IAM可靠安全事件本地发送箱';
DROP PROCEDURE IF EXISTS assert_iam_security_event_schema;
DELIMITER $$
CREATE PROCEDURE assert_iam_security_event_schema()
BEGIN
IF (SELECT COUNT(*) FROM information_schema.tables
WHERE table_schema='baiyue_iam'
AND table_name IN ('iam_security_subject_version','iam_security_tenant_version','iam_security_event_outbox')) <> 3 THEN
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='统一迁移失败:IAM安全事件表不完整';
END IF;
IF (SELECT COUNT(DISTINCT index_name) FROM information_schema.statistics
WHERE table_schema='baiyue_iam' AND table_name='iam_security_event_outbox'
AND index_name IN ('PRIMARY','idx_security_outbox_publish','idx_security_outbox_aggregate')) <> 3 THEN
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='统一迁移失败:IAM安全事件索引不完整';
END IF;
END$$
DELIMITER ;
CALL assert_iam_security_event_schema();
DROP PROCEDURE IF EXISTS assert_iam_security_event_schema;
USE baiyue_firstleg;
-- 标准跨区事件摘要包含 sha256: 前缀,共 71 字符;兼容既有 Finance 64 位摘要。
ALTER TABLE fl_inbound_outbox
@@ -14897,4 +14952,4 @@ ALTER TABLE fl_inbound_outbox
CALL baiyue_firstleg.assert_unified_final_schema();
DROP PROCEDURE IF EXISTS baiyue_firstleg.assert_unified_final_schema;
SELECT 'PASS' AS migration_status,
'统一增量全部执行完成;父子订单、履约分片、装柜门禁、订舱拖车、报关船期、船司查询、仓库角色、费用、Finance人工财务、公共资源及可靠审计结构校验通过' AS message;
'统一增量全部执行完成;父子订单、履约分片、装柜门禁、订舱拖车、报关船期、船司查询、仓库角色、费用、Finance人工财务、公共资源、可靠审计及IAM安全事件结构校验通过' AS message;
+39
View File
@@ -0,0 +1,39 @@
# IAM 跨区安全事件契约
## 边界
IAM 使用独立的 `iam_security_event_outbox`。业务状态、权限版本与 Outbox 在同一数据库事务内提交;尾程按 `eventId` 幂等消费,并按信封 `aggregateVersion` 拒绝旧事件。事件不包含密码、令牌、联系方式、角色全集或权限全集。
## V1 事件
三个事件均使用标准 `EventEnvelope`,`eventVersion=1`、`sourceRegion=CN`、`targetRegion=OVERSEAS`。
1. `IamUserStatusChanged`
- 聚合:`aggregateType=USER`、`aggregateId=authUserId`
- payload:`authUserId`、`tenantId`、`appCode`、`status`、`permissionVersion`、`changedAt`
- status:`ACTIVE`、`DISABLED`
2. `IamTenantStatusChanged`
- 聚合:`aggregateType=TENANT`、`aggregateId=tenantId`
- payload:`tenantId`、`status`、`changedAt`
- status:`ACTIVE`、`DISABLED`
- `aggregateVersion` 来自 `iam_security_tenant_version.status_version`,每次状态变化单调递增
3. `IamPermissionVersionChanged`
- 聚合:`aggregateType=AUTHORIZATION`、`aggregateId=authUserId`
- payload:`authUserId`、`tenantId`、`appCode`、`permissionVersion`、`reason`、`changedAt`
- `permissionVersion` 来自 `iam_security_subject_version.permission_version`,按账号单调递增
## 当前接入
- 企业成员、平台内部用户和平台用户启停均写 `IamUserStatusChanged`。
- 客户启停写 `IamTenantStatusChanged`。
- 成员角色、平台内部用户角色及角色权限变更写 `IamPermissionVersionChanged`。
## 尾程权限
`lastleg-permissions.sql` 初始化以下平台权限,并授予 `PLATFORM_ADMIN`:
- `platform:lastleg:order:list`
- `platform:lastleg:order:detail`
- `platform:lastleg:order:assign`
- `platform:lastleg:order:transfer`
- `platform:lastleg:order:merge-pallet`
+1
View File
@@ -5,6 +5,7 @@
<parent><groupId>com.baiyue.firstleg</groupId><artifactId>baiyue-firstleg-platform</artifactId><version>1.0.0-SNAPSHOT</version><relativePath>../pom.xml</relativePath></parent>
<artifactId>iam-service</artifactId>
<dependencies>
<dependency><groupId>com.baiyue.shared</groupId><artifactId>common-event-contract</artifactId><version>${project.version}</version></dependency>
<dependency><groupId>com.baiyue.shared</groupId><artifactId>baiyue-common-audit</artifactId><version>${project.version}</version></dependency>
<dependency><groupId>org.apache.rocketmq</groupId><artifactId>rocketmq-spring-boot-starter</artifactId></dependency>
<dependency><groupId>com.baiyue.shared</groupId><artifactId>baiyue-common-openapi</artifactId><version>${project.version}</version></dependency>
@@ -0,0 +1,17 @@
package com.baiyue.iam.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 = "iamSecurityEventBridgeClient")
public interface SecurityEventBridgeClient {
@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) { }
}
@@ -39,5 +39,6 @@ public final class AuthorizationDtos {
private String clientType;
private Set<String> roles;
private Set<String> permissions;
private Long permissionVersion;
}
}
@@ -62,4 +62,7 @@ public interface AuthorizationQueryMapper {
@Param("tenantId") long tenantId,
@Param("membershipId") long membershipId,
@Param("appCode") String appCode);
@Select("SELECT COALESCE(MAX(permission_version),0) FROM iam_security_subject_version WHERE auth_user_id=#{userId}")
long findPermissionVersion(@Param("userId") long userId);
}
@@ -1,22 +0,0 @@
package com.baiyue.iam.securityevent;
import jakarta.annotation.Resource;
import org.apache.rocketmq.spring.core.RocketMQTemplate;
import org.apache.rocketmq.spring.support.RocketMQHeaders;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.messaging.support.MessageBuilder;
import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.stereotype.Component;
import java.util.UUID;
@Component
@ConditionalOnProperty(name="baiyue.security-event.publisher-enabled",havingValue="true")
public class SecurityEventOutboxRelay {
@Resource private SecurityEventRepository repository;
@Resource private RocketMQTemplate rocketMQ;
@Value("${baiyue.security-event.topic:baiyue-security-event-v1}") private String topic;
private final String owner=UUID.randomUUID().toString();
@Scheduled(fixedDelayString="${baiyue.security-event.poll-interval-ms:5000}")
public void relay(){for(String id:repository.pendingIds(50)){if(!repository.claim(id,owner))continue;var row=repository.find(id);try{rocketMQ.syncSend(topic+":"+row.eventType(),MessageBuilder.withPayload(row.payload()).setHeader(RocketMQHeaders.KEYS,row.eventId()).setHeader("eventId",row.eventId()).build(),3000);repository.sent(id,owner);}catch(RuntimeException ex){repository.failed(id,owner,row.attempts(),ex.getMessage());}}}
}
@@ -1,25 +0,0 @@
package com.baiyue.iam.securityevent;
import jakarta.annotation.Resource;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.stereotype.Repository;
import java.util.List;
@Repository
public class SecurityEventRepository {
@Resource private JdbcTemplate jdbc;
public long incrementPermissionVersion(long authUserId) {
jdbc.update("INSERT INTO iam_security_subject_version(auth_user_id,permission_version) VALUES (?,1) ON DUPLICATE KEY UPDATE permission_version=permission_version+1", authUserId);
return jdbc.queryForObject("SELECT permission_version FROM iam_security_subject_version WHERE auth_user_id=?", Long.class, authUserId);
}
public void append(String id,String type,String aggregateType,String aggregateId,String payload) {
jdbc.update("INSERT INTO iam_security_event_outbox(event_id,event_type,aggregate_type,aggregate_id,payload_json,status,attempt_count,next_retry_at) VALUES (?,?,?,?,CAST(? AS JSON),'PENDING',0,CURRENT_TIMESTAMP(3))", id,type,aggregateType,aggregateId,payload);
}
public List<String> pendingIds(int limit) { return jdbc.queryForList("SELECT event_id FROM iam_security_event_outbox WHERE (status='PENDING' AND next_retry_at<=CURRENT_TIMESTAMP(3)) OR (status='SENDING' AND lease_until<=CURRENT_TIMESTAMP(3)) ORDER BY created_at LIMIT ?", String.class,limit); }
public boolean claim(String id,String owner) { return jdbc.update("UPDATE iam_security_event_outbox SET status='SENDING',lease_owner=?,lease_until=DATE_ADD(CURRENT_TIMESTAMP(3),INTERVAL 60 SECOND),attempt_count=attempt_count+1 WHERE event_id=? AND ((status='PENDING' AND next_retry_at<=CURRENT_TIMESTAMP(3)) OR (status='SENDING' AND lease_until<=CURRENT_TIMESTAMP(3)))",owner,id)==1; }
public Row find(String id) { return jdbc.queryForObject("SELECT event_id,event_type,payload_json,attempt_count FROM iam_security_event_outbox WHERE event_id=?",(rs,n)->new Row(rs.getString(1),rs.getString(2),rs.getString(3),rs.getInt(4)),id); }
public void sent(String id,String owner) { jdbc.update("UPDATE iam_security_event_outbox SET status='SENT',sent_at=CURRENT_TIMESTAMP(3),lease_owner=NULL,lease_until=NULL,last_error=NULL WHERE event_id=? AND lease_owner=?",id,owner); }
public void failed(String id,String owner,int attempts,String error) { jdbc.update("UPDATE iam_security_event_outbox SET status=IF(attempt_count>=8,'DEAD','PENDING'),next_retry_at=DATE_ADD(CURRENT_TIMESTAMP(3),INTERVAL ? SECOND),lease_owner=NULL,lease_until=NULL,last_error=? WHERE event_id=? AND lease_owner=?",Math.min(900,5*(1<<Math.min(attempts,7))),safe(error),id,owner); }
private String safe(String value) { if(value==null)return "投递失败"; return value.length()<=1000?value:value.substring(0,1000); }
public record Row(String eventId,String eventType,String payload,int attempts) { }
}
@@ -1,23 +0,0 @@
package com.baiyue.iam.securityevent;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.ObjectMapper;
import jakarta.annotation.Resource;
import org.springframework.stereotype.Service;
import java.time.Instant;
import java.util.LinkedHashMap;
import java.util.Map;
import java.util.UUID;
@Service
public class SecurityEventService {
public static final String USER_STATUS_CHANGED="IAM_USER_STATUS_CHANGED_V1";
public static final String ACCOUNT_STATUS_CHANGED="IAM_ACCOUNT_STATUS_CHANGED_V1";
public static final String PERMISSION_VERSION_CHANGED="IAM_PERMISSION_VERSION_CHANGED_V1";
@Resource private SecurityEventRepository repository;
@Resource private ObjectMapper json;
public void userStatus(long authUserId,long tenantId,long membershipId,String status){append(USER_STATUS_CHANGED,"USER",authUserId,Map.of("authUserId",authUserId,"tenantId",tenantId,"membershipId",membershipId,"status",status));}
public void accountStatus(long accountId,String status){append(ACCOUNT_STATUS_CHANGED,"ACCOUNT",accountId,Map.of("accountId",accountId,"status",status));}
public long permissionsChanged(long authUserId,long tenantId,long membershipId,String reason){long version=repository.incrementPermissionVersion(authUserId);append(PERMISSION_VERSION_CHANGED,"AUTHORIZATION",authUserId,Map.of("authUserId",authUserId,"tenantId",tenantId,"membershipId",membershipId,"permissionVersion",version,"reason",reason));return version;}
private void append(String type,String aggregateType,long aggregateId,Map<String,Object> fields){String id=UUID.randomUUID().toString();Map<String,Object> payload=new LinkedHashMap<>();payload.put("eventId",id);payload.put("eventType",type);payload.put("schemaVersion",1);payload.put("occurredAt",Instant.now().toString());payload.putAll(fields);try{repository.append(id,type,aggregateType,Long.toString(aggregateId),json.writeValueAsString(payload));}catch(JsonProcessingException ex){throw new IllegalStateException("安全事件序列化失败",ex);}}
}
@@ -46,7 +46,8 @@ public class AuthorizationSnapshotService {
Set<String> permissions = normalizedSet(mapper.findPermissions(
request.getUserId(), request.getTenantId(), request.getMembershipId(), appCode));
return new SnapshotResponse(request.getUserId(), request.getTenantId(), request.getMembershipId(),
appCode, appCode, Set.copyOf(roles), Set.copyOf(permissions));
appCode, appCode, Set.copyOf(roles), Set.copyOf(permissions),
mapper.findPermissionVersion(request.getUserId()));
}
/**
@@ -40,6 +40,8 @@ public class MemberService {
private IamRoleMapper roleMapper;
@Resource
private AuthAccountClient authClient;
@Resource
private SecurityEventOutboxService securityEvents;
@Value("${baiyue.security.internal-service-token}")
private String internalToken;
@@ -159,6 +161,7 @@ public class MemberService {
membership.setStatus(request.status());
membership.setLeftAt("DISABLED".equals(request.status()) ? LocalDateTime.now() : null);
user.setStatus(request.status()); repository.saveUser(user); repository.save(membership);
securityEvents.userStatus(user.getAuthUserId(), tenantId, "MERCHANT", request.status());
} catch (RuntimeException localFailure) {
try {
authClient.status(internalToken, user.getAuthUserId(), new AuthAccountClient.StatusRequest(oldStatus));
@@ -38,6 +38,8 @@ public class PlatformInternalUserService {
private IamRoleMapper roleMapper;
@Resource
private AuthAccountClient authClient;
@Resource
private SecurityEventOutboxService securityEvents;
@Value("${baiyue.security.internal-service-token}")
private String internalToken;
@@ -138,6 +140,7 @@ public class PlatformInternalUserService {
user.setStatus(request.status());
repository.saveUser(user);
repository.save(membership);
securityEvents.userStatus(user.getAuthUserId(), PLATFORM_TENANT_ID, "PLATFORM", request.status());
return response(membership, user, authAccount.username(), roleIds);
} catch (RuntimeException localFailure) {
compensateStatus(user.getAuthUserId(), authAccount.status(), localFailure);
@@ -167,6 +170,8 @@ public class PlatformInternalUserService {
repository.replaceRoles(PLATFORM_TENANT_ID, membershipId, roleIds);
if (!new java.util.HashSet<>(currentRoleIds).equals(new java.util.HashSet<>(roleIds))) {
revokeSessions(user.getAuthUserId());
securityEvents.permissionChanged(user.getAuthUserId(), PLATFORM_TENANT_ID, "PLATFORM",
"MEMBER_ROLES_CHANGED");
}
AuthAccountClient.AccountSummary authAccount = account(user.getAuthUserId());
return response(membership, user, authAccount.username(), roleIds);
@@ -43,6 +43,8 @@ public class PlatformUserManagementService {
private IamRoleMapper roleMapper;
@Resource
private AuthAccountClient authClient;
@Resource
private SecurityEventOutboxService securityEvents;
@Value("${baiyue.security.internal-service-token}")
private String internalToken;
@@ -185,6 +187,8 @@ public class PlatformUserManagementService {
user.setStatus(request.status());
members.saveUser(user);
members.save(membership);
securityEvents.userStatus(user.getAuthUserId(), membership.getTenantId(),
appCode(membership.getTenantId()), request.status());
} catch (RuntimeException localFailure) {
call(() -> authClient.status(internalToken, user.getAuthUserId(),
new AuthAccountClient.StatusRequest(oldAccount.status())));
@@ -33,6 +33,7 @@ public class RbacService {
@Resource private RbacRepository repository;
@Resource private MemberRepository memberRepository;
@Resource private AuthAccountClient authClient;
@Resource private SecurityEventOutboxService securityEvents;
@Value("${baiyue.security.internal-service-token}")
private String internalToken;
@@ -232,6 +233,7 @@ public class RbacService {
var user = memberRepository.findUser(membership.getUserId());
if (user == null || user.getAuthUserId() == null) throw sessionRevocationUnavailable();
revokeSessions(List.of(user.getAuthUserId()));
securityEvents.permissionChanged(user.getAuthUserId(), tenantId, appCode, "MEMBER_ROLES_CHANGED");
}
}
@@ -249,7 +251,10 @@ public class RbacService {
}
}
repository.replaceRolePermissions(tenantId, request.roleId(), request.permissionIds());
revokeAssignedSessions(tenantId, request.roleId());
List<Long> affected = repository.findAssignedAuthUserIds(tenantId, request.roleId());
revokeSessions(affected);
affected.forEach(userId -> securityEvents.permissionChanged(userId, tenantId, appCode,
"ROLE_PERMISSIONS_CHANGED"));
}
private void revokeAssignedSessions(long tenantId, Long roleId) {
@@ -0,0 +1,87 @@
package com.baiyue.iam.service;
import com.baiyue.common.event.EventEnvelope;
import com.baiyue.common.event.EventEnvelopeValidator;
import com.baiyue.iam.client.SecurityEventBridgeClient;
import com.fasterxml.jackson.databind.ObjectMapper;
import jakarta.annotation.Resource;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.stereotype.Component;
import org.springframework.transaction.annotation.Transactional;
import java.time.LocalDateTime;
import java.util.List;
import java.util.UUID;
/** IAM 安全事件至少一次投递器,支持过期租约回收、退避重试和死信。 */
@Component
@Slf4j
public class SecurityEventOutboxRelay {
@Resource private JdbcTemplate jdbc;
@Resource private ObjectMapper objectMapper;
@Resource private SecurityEventBridgeClient client;
@Value("${baiyue.security.internal-service-token}") private String internalToken;
@Value("${baiyue.security-event.max-attempts:12}") private int maxAttempts;
private final String workerId = UUID.randomUUID().toString();
@Scheduled(fixedDelayString = "${baiyue.security-event.fixed-delay-ms:5000}")
public void relay() {
recoverExpiredLeases();
for (String eventId : candidates()) deliver(eventId);
}
@Transactional
public void deliver(String eventId) {
LocalDateTime now = LocalDateTime.now();
int claimed = jdbc.update("UPDATE iam_security_event_outbox SET status='SENDING',lease_owner=?,"
+ "lease_until=?,updated_at=? WHERE event_id=? AND status='PENDING' AND next_retry_at<=?",
workerId, now.plusMinutes(2), now, eventId, now);
if (claimed != 1) return;
String json = jdbc.queryForObject("SELECT payload_json FROM iam_security_event_outbox "
+ "WHERE event_id=? AND status='SENDING' AND lease_owner=?", String.class, eventId, workerId);
try {
EventEnvelope envelope = objectMapper.readValue(json, EventEnvelope.class);
EventEnvelopeValidator.requireValid(envelope);
var response = client.receive(internalToken, envelope);
if (response == null || !response.successful() || response.data() == null
|| !eventId.equals(response.data().eventId())) {
throw new IllegalStateException("事件桥未确认 IAM 安全事件");
}
jdbc.update("UPDATE iam_security_event_outbox SET status='SENT',sent_at=?,lease_owner=NULL,"
+ "lease_until=NULL,last_error=NULL,updated_at=? WHERE event_id=? AND lease_owner=?",
LocalDateTime.now(), LocalDateTime.now(), eventId, workerId);
} catch (Exception exception) {
fail(eventId, exception.getMessage());
log.warn("IAM 安全事件投递失败,eventId={}, reason={}", eventId, exception.getMessage());
}
}
private List<String> candidates() {
return jdbc.queryForList("SELECT event_id FROM iam_security_event_outbox WHERE status='PENDING' "
+ "AND next_retry_at<=NOW(3) ORDER BY created_at,event_id LIMIT 50", String.class);
}
private void recoverExpiredLeases() {
jdbc.update("UPDATE iam_security_event_outbox SET status='PENDING',lease_owner=NULL,lease_until=NULL,"
+ "next_retry_at=NOW(3),updated_at=NOW(3) WHERE status='SENDING' AND lease_until<NOW(3)");
}
private void fail(String eventId, String message) {
Integer attempts = jdbc.queryForObject("SELECT attempt_count FROM iam_security_event_outbox WHERE event_id=?",
Integer.class, eventId);
int next = (attempts == null ? 0 : attempts) + 1;
String status = next >= maxAttempts ? "DEAD" : "PENDING";
long delay = Math.min(1800, 5L << Math.min(next, 8));
jdbc.update("UPDATE iam_security_event_outbox SET status=?,attempt_count=?,next_retry_at=?,"
+ "lease_owner=NULL,lease_until=NULL,last_error=?,updated_at=? WHERE event_id=? AND lease_owner=?",
status, next, LocalDateTime.now().plusSeconds(delay), truncate(message), LocalDateTime.now(), eventId, workerId);
}
private String truncate(String value) {
String text = value == null ? "unknown" : value.replaceAll("[\\r\\n\\t]+", " ");
return text.length() > 1000 ? text.substring(0, 1000) : text;
}
}
@@ -0,0 +1,67 @@
package com.baiyue.iam.service;
import com.baiyue.common.event.EventEnvelope;
import com.baiyue.common.event.EventIdentifiers;
import com.baiyue.common.event.EventPayloadCanonicalizer;
import com.baiyue.common.event.EventPayloadHash;
import com.baiyue.common.event.EventTypes;
import com.baiyue.common.event.IamSecurityEventsV1;
import com.baiyue.common.event.RegionCode;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import jakarta.annotation.Resource;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.stereotype.Service;
import java.time.Instant;
/** 在 IAM 状态事务内递增安全版本并写入可靠事件发送箱。 */
@Service
public class SecurityEventOutboxService {
@Resource private JdbcTemplate jdbc;
@Resource private ObjectMapper objectMapper;
public void userStatus(long authUserId, long tenantId, String appCode, String status) {
long version = bump(authUserId);
write(EventTypes.IAM_USER_STATUS_CHANGED, "USER", String.valueOf(authUserId), tenantId, version,
new IamSecurityEventsV1.UserStatusChanged(authUserId, tenantId, appCode, status, version, Instant.now()));
}
public void tenantStatus(long tenantId, String status) {
jdbc.update("INSERT INTO iam_security_tenant_version(tenant_id,status_version) VALUES (?,1) "
+ "ON DUPLICATE KEY UPDATE status_version=status_version+1", tenantId);
long version = jdbc.queryForObject(
"SELECT status_version FROM iam_security_tenant_version WHERE tenant_id=?", Long.class, tenantId);
write(EventTypes.IAM_TENANT_STATUS_CHANGED, "TENANT", String.valueOf(tenantId), tenantId, version,
new IamSecurityEventsV1.TenantStatusChanged(tenantId, status, Instant.now()));
}
public void permissionChanged(long authUserId, long tenantId, String appCode, String reason) {
long version = bump(authUserId);
write(EventTypes.IAM_PERMISSION_VERSION_CHANGED, "AUTHORIZATION", String.valueOf(authUserId), tenantId, version,
new IamSecurityEventsV1.PermissionVersionChanged(authUserId, tenantId, appCode, version, reason, Instant.now()));
}
private long bump(long authUserId) {
jdbc.update("INSERT INTO iam_security_subject_version(auth_user_id,permission_version) VALUES (?,1) "
+ "ON DUPLICATE KEY UPDATE permission_version=permission_version+1", authUserId);
return jdbc.queryForObject("SELECT permission_version FROM iam_security_subject_version WHERE auth_user_id=?",
Long.class, authUserId);
}
private void write(String type, String aggregateType, String aggregateId, long tenantId,
long aggregateVersion, Object value) {
JsonNode payload = objectMapper.valueToTree(value);
String hash = EventPayloadHash.sha256(EventPayloadCanonicalizer.canonicalize(payload));
EventEnvelope envelope = new EventEnvelope(EventIdentifiers.newUuid(), type, IamSecurityEventsV1.VERSION,
Instant.now(), RegionCode.CN, RegionCode.OVERSEAS, String.valueOf(tenantId), aggregateType,
aggregateId, aggregateVersion, null, EventIdentifiers.newUuid(), null, null, payload, hash).requireValid();
try {
jdbc.update("INSERT INTO iam_security_event_outbox(event_id,event_type,aggregate_type,aggregate_id,payload_json) "
+ "VALUES (?,?,?,?,CAST(? AS JSON))", envelope.eventId(), type, aggregateType, aggregateId,
objectMapper.writeValueAsString(envelope));
} catch (Exception exception) {
throw new IllegalStateException("IAM 安全事件写入失败", exception);
}
}
}
@@ -66,6 +66,8 @@ public class TenantService {
private OverseasStorageFeeInitializationService overseasStorageFeeInitialization;
@Resource
private TenantAdministratorProvisioningService administratorProvisioning;
@Resource
private SecurityEventOutboxService securityEvents;
@Transactional
@BusinessAudit(event = "PLATFORM_CUSTOMER_OWNER_PASSWORD_RESET", resourceType = "TENANT",
@@ -380,6 +382,7 @@ public class TenantService {
}
tenant.setStatus(request.status());
repository.saveTenant(tenant);
securityEvents.tenantStatus(tenantId, request.status());
return response(tenant);
}
@@ -0,0 +1,25 @@
USE baiyue_iam;
-- 尾程订单权限属于平台端;列表权限使用 MENU,其余操作使用 BUTTON。
INSERT INTO iam_permission
(id, tenant_id, app_code, permission_type, parent_id, permission_code,
permission_name, route_path, http_method, sort_no, status, built_in, deleted)
VALUES
(5106000, 0, 'PLATFORM', 'MENU', 0, 'platform:lastleg:order:list', '尾程订单', '/lastleg/orders', 'GET', 60, 'ACTIVE', 1, 0),
(5106001, 0, 'PLATFORM', 'BUTTON', 5106000, 'platform:lastleg:order:detail', '查看尾程订单详情', NULL, 'GET', 10, 'ACTIVE', 1, 0),
(5106002, 0, 'PLATFORM', 'BUTTON', 5106000, 'platform:lastleg:order:assign', '分配尾程订单', NULL, 'POST', 20, 'ACTIVE', 1, 0),
(5106003, 0, 'PLATFORM', 'BUTTON', 5106000, 'platform:lastleg:order:transfer', '转派尾程订单', NULL, 'POST', 30, 'ACTIVE', 1, 0),
(5106004, 0, 'PLATFORM', 'BUTTON', 5106000, 'platform:lastleg:order:merge-pallet', '合并尾程托盘', NULL, 'POST', 40, 'ACTIVE', 1, 0)
ON DUPLICATE KEY UPDATE permission_name=VALUES(permission_name), route_path=VALUES(route_path),
http_method=VALUES(http_method), sort_no=VALUES(sort_no), status='ACTIVE', built_in=1, deleted=0;
INSERT IGNORE INTO iam_role_permission (tenant_id, role_id, permission_id, created_at)
SELECT 0, role.id, permission.id, NOW(3)
FROM iam_role role
JOIN iam_permission permission ON permission.tenant_id=0 AND permission.app_code='PLATFORM'
WHERE role.tenant_id=0 AND role.app_code='PLATFORM' AND role.role_code='PLATFORM_ADMIN'
AND role.status='ACTIVE' AND role.deleted=0
AND permission.permission_code IN (
'platform:lastleg:order:list', 'platform:lastleg:order:detail',
'platform:lastleg:order:assign', 'platform:lastleg:order:transfer',
'platform:lastleg:order:merge-pallet');
@@ -2,20 +2,34 @@ USE baiyue_iam;
CREATE TABLE IF NOT EXISTS iam_security_subject_version (
auth_user_id BIGINT NOT NULL COMMENT '认证账号ID',
permission_version BIGINT NOT NULL DEFAULT 0 COMMENT '权限版本,角色或权限变化时单调递增',
permission_version BIGINT NOT NULL DEFAULT 0 COMMENT '权限版本,身份或授权变化时单调递增',
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3),
PRIMARY KEY (auth_user_id)
) ENGINE=InnoDB COMMENT='安全主体权限版本';
CREATE TABLE IF NOT EXISTS iam_security_event_outbox (
event_id VARCHAR(64) NOT NULL COMMENT '全局事件ID,消费者幂等键', event_type VARCHAR(64) NOT NULL,
aggregate_type VARCHAR(32) NOT NULL COMMENT 'USER、ACCOUNT、AUTHORIZATION', aggregate_id VARCHAR(64) NOT NULL,
payload_json JSON NOT NULL COMMENT '不含密码、令牌和权限全集的安全事件',
status VARCHAR(16) NOT NULL DEFAULT 'PENDING' COMMENT 'PENDING、SENDING、SENT、DEAD',
attempt_count INT NOT NULL DEFAULT 0, next_retry_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
lease_owner VARCHAR(128) NULL, lease_until DATETIME(3) NULL, last_error VARCHAR(1000) NULL,
sent_at DATETIME(3) NULL, created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
CREATE TABLE IF NOT EXISTS iam_security_tenant_version (
tenant_id BIGINT NOT NULL COMMENT '客户ID',
status_version BIGINT NOT NULL DEFAULT 0 COMMENT '客户状态单调版本',
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3),
PRIMARY KEY (event_id), KEY idx_security_outbox_publish (status, next_retry_at, lease_until, created_at),
KEY idx_security_outbox_aggregate (aggregate_type, aggregate_id, created_at)
PRIMARY KEY (tenant_id)
) ENGINE=InnoDB COMMENT='客户状态事件版本';
CREATE TABLE IF NOT EXISTS iam_security_event_outbox (
event_id VARCHAR(64) NOT NULL COMMENT '全局事件ID,消费者幂等键',
event_type VARCHAR(64) NOT NULL COMMENT '安全事件类型',
aggregate_type VARCHAR(32) NOT NULL COMMENT 'USER、TENANT或AUTHORIZATION',
aggregate_id VARCHAR(64) NOT NULL COMMENT '聚合标识',
payload_json JSON NOT NULL COMMENT '标准跨区事件信封,不含密码、令牌和权限全集',
status VARCHAR(16) NOT NULL DEFAULT 'PENDING' COMMENT 'PENDING、SENDING、SENT、DEAD',
attempt_count INT NOT NULL DEFAULT 0,
next_retry_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
lease_owner VARCHAR(128) NULL,
lease_until DATETIME(3) NULL,
last_error VARCHAR(1000) NULL,
sent_at DATETIME(3) NULL,
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3),
PRIMARY KEY (event_id),
KEY idx_security_outbox_publish (status,next_retry_at,lease_until,created_at),
KEY idx_security_outbox_aggregate (aggregate_type,aggregate_id,created_at)
) ENGINE=InnoDB COMMENT='IAM可靠安全事件本地发送箱';
@@ -0,0 +1,43 @@
package com.baiyue.iam.repository;
import org.junit.jupiter.api.Test;
import org.springframework.core.io.ClassPathResource;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import static org.assertj.core.api.Assertions.assertThat;
class SecurityEventSqlContractTest {
@Test
void securityOutboxHasVersionsAndReliableDeliveryColumns() throws Exception {
String sql = new ClassPathResource("db/security-event-outbox.sql")
.getContentAsString(StandardCharsets.UTF_8);
assertThat(sql)
.contains("iam_security_subject_version")
.contains("permission_version BIGINT NOT NULL DEFAULT 0")
.contains("iam_security_tenant_version")
.contains("status_version BIGINT NOT NULL DEFAULT 0")
.contains("iam_security_event_outbox")
.contains("payload_json JSON NOT NULL")
.contains("idx_security_outbox_publish")
.contains("PENDING", "SENDING", "SENT", "DEAD");
}
@Test
void unifiedMigrationIncludesSecurityEventSchemaAndAssertion() throws Exception {
Path direct = Path.of("deploy", "sql", "头程数据库统一增量-MySQL8.sql");
Path migration = Files.exists(direct)
? direct : Path.of("..", "deploy", "sql", "头程数据库统一增量-MySQL8.sql").normalize();
String sql = Files.readString(migration, StandardCharsets.UTF_8);
assertThat(sql)
.contains("CREATE TABLE IF NOT EXISTS iam_security_subject_version")
.contains("CREATE TABLE IF NOT EXISTS iam_security_tenant_version")
.contains("CREATE TABLE IF NOT EXISTS iam_security_event_outbox")
.contains("idx_security_outbox_publish", "idx_security_outbox_aggregate")
.contains("CALL assert_iam_security_event_schema();");
assertThat(sql.indexOf("CALL assert_iam_security_event_schema();"))
.isLessThan(sql.lastIndexOf("SELECT 'PASS' AS migration_status"));
}
}
@@ -33,6 +33,7 @@ class MemberServiceTest {
@MockBean private TenantRepository tenantRepository;
@MockBean private IamRoleMapper roleMapper;
@MockBean private AuthAccountClient auth;
@MockBean private SecurityEventOutboxService securityEvents;
@Resource private MemberService service;
private IamMembership member;
private IamUser user;
@@ -30,6 +30,7 @@ class PlatformInternalUserServiceTest {
@MockBean private MemberRepository repository;
@MockBean private IamRoleMapper roleMapper;
@MockBean private AuthAccountClient auth;
@MockBean private SecurityEventOutboxService securityEvents;
@Resource private PlatformInternalUserService service;
private IamMembership membership;
private IamUser user;
@@ -31,6 +31,7 @@ class PlatformUserManagementServiceTest {
@MockBean private MemberRepository members;
@MockBean private IamRoleMapper roles;
@MockBean private AuthAccountClient auth;
@MockBean private SecurityEventOutboxService securityEvents;
@Resource private PlatformUserManagementService service;
private IamRole platformRole;
private IamMembership membership;
@@ -39,6 +39,7 @@ class RbacServiceTest {
@MockBean private RbacRepository repository;
@MockBean private MemberRepository memberRepository;
@MockBean private AuthAccountClient authClient;
@MockBean private SecurityEventOutboxService securityEvents;
@Resource private RbacService service;
@Test
@@ -28,6 +28,7 @@ class TenantCustomerExportTest {
@MockBean private CustomerDimensionRuleInitializationService dimensionRuleInitialization;
@MockBean private OverseasStorageFeeInitializationService overseasStorageFeeInitialization;
@MockBean private TenantAdministratorProvisioningService administratorProvisioning;
@MockBean private SecurityEventOutboxService securityEvents;
@Resource private TenantService service;
@Test
void returnsUnmaskedCustomerRowsAndAdvancingCursor() {
@@ -53,6 +53,7 @@ class TenantServiceTest {
@MockBean private MemberRepository members;
@MockBean private IamRoleMapper roles;
@MockBean private AuthAccountClient authClient;
@MockBean private SecurityEventOutboxService securityEvents;
@MockBean private CustomerDimensionRuleInitializationService dimensionRuleInitialization;
@MockBean private OverseasStorageFeeInitializationService overseasStorageFeeInitialization;
@SpyBean private TenantAdministratorProvisioningService provisioning;
@@ -23,6 +23,7 @@ public class MerchantBatchDraftService {
public MerchantBatchDraftDtos.Response create(long tenantId, long userId, String batchKey,
List<MerchantBatchDraftDtos.Item> items) {
if (!StringUtils.hasText(batchKey)) throw new BizException("必须提供 Idempotency-Key");
if (batchKey.trim().length() > 128) throw new BizException("Idempotency-Key 长度不能超过128");
if (items == null || items.isEmpty()) throw new BizException("批量草稿不能为空");
if (items.size() > MAX_ITEMS) throw new BizException("批量草稿最多 50 条");
List<MerchantBatchDraftDtos.Result> results = new ArrayList<>(items.size());
@@ -0,0 +1,109 @@
package com.baiyue.order.service;
import com.baiyue.common.core.exception.BizException;
import com.baiyue.order.dto.MerchantBatchDraftDtos;
import com.baiyue.order.dto.OrderRequests;
import com.baiyue.order.entity.OrderEntity;
import jakarta.annotation.Resource;
import org.junit.jupiter.api.Test;
import org.springframework.boot.test.mock.mockito.MockBean;
import org.springframework.test.context.junit.jupiter.SpringJUnitConfig;
import java.util.List;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyLong;
import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.Mockito.doAnswer;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
@SpringJUnitConfig(MerchantBatchDraftService.class)
class MerchantBatchDraftServiceTest {
@MockBean private MerchantBatchDraftItemService itemService;
@Resource private MerchantBatchDraftService service;
@Test
void createsEachRowAndReturnsOrderReferences() {
doAnswer(invocation -> order(101L, "FL101", "RK-101"))
.doAnswer(invocation -> order(102L, "FL102", "RK-102"))
.when(itemService).create(anyLong(), anyLong(), any(), anyString());
var response = service.create(10L, 20L, "batch-key", List.of(
item("row-1"), item("row-2")));
assertThat(response.total()).isEqualTo(2);
assertThat(response.succeeded()).isEqualTo(2);
assertThat(response.failed()).isZero();
assertThat(response.items()).extracting(MerchantBatchDraftDtos.Result::inboundCode)
.containsExactly("RK-101", "RK-102");
assertThat(response.items()).extracting(MerchantBatchDraftDtos.Result::index)
.containsExactly(0, 1);
}
@Test
void keepsSuccessfulRowsWhenAnotherRowFails() {
doAnswer(invocation -> order(101L, "FL101", "RK-101"))
.doThrow(new BizException("SKU不能为空"))
.when(itemService).create(anyLong(), anyLong(), any(), anyString());
var response = service.create(10L, 20L, "batch-key", List.of(
item("row-1"), item("row-2")));
assertThat(response.succeeded()).isEqualTo(1);
assertThat(response.failed()).isEqualTo(1);
assertThat(response.items().get(0).success()).isTrue();
assertThat(response.items().get(1).success()).isFalse();
assertThat(response.items().get(1).errorMessage()).isEqualTo("SKU不能为空");
}
@Test
void rejectsDuplicateRowWithoutCreatingItTwice() {
doAnswer(invocation -> order(101L, "FL101", "RK-101"))
.when(itemService).create(anyLong(), anyLong(), any(), anyString());
var response = service.create(10L, 20L, "batch-key", List.of(
item("same-row"), item("same-row")));
assertThat(response.succeeded()).isEqualTo(1);
assertThat(response.failed()).isEqualTo(1);
assertThat(response.items().get(1).errorCode()).isEqualTo("A0409");
verify(itemService).create(anyLong(), anyLong(), any(), anyString());
}
@Test
void derivesStablePerRowIdempotencyKeys() {
String first = MerchantBatchDraftService.derivedKey(" batch-key ", "row-1");
String retry = MerchantBatchDraftService.derivedKey("batch-key", "row-1");
String anotherRow = MerchantBatchDraftService.derivedKey("batch-key", "row-2");
assertThat(first).isEqualTo(retry).startsWith("batch-").hasSize(70);
assertThat(anotherRow).isNotEqualTo(first);
}
@Test
void rejectsInvalidBatchBeforeCreatingRows() {
assertThatThrownBy(() -> service.create(10L, 20L, " ", List.of(item("row-1"))))
.isInstanceOf(BizException.class);
assertThatThrownBy(() -> service.create(10L, 20L, "x".repeat(129), List.of(item("row-1"))))
.isInstanceOf(BizException.class);
assertThatThrownBy(() -> service.create(10L, 20L, "batch-key", List.of()))
.isInstanceOf(BizException.class);
verify(itemService, never()).create(anyLong(), anyLong(), any(), anyString());
}
private MerchantBatchDraftDtos.Item item(String rowId) {
return new MerchantBatchDraftDtos.Item(rowId, mock(OrderRequests.SaveRequest.class));
}
private OrderEntity order(long id, String orderNo, String inboundCode) {
OrderEntity order = new OrderEntity();
order.setId(id);
order.setOrderNo(orderNo);
order.setInboundCode(inboundCode);
return order;
}
}
+13
View File
@@ -16,6 +16,19 @@
<name>柏悦头程平台</name>
<description>柏悦悦物流国内头程业务微服务平台</description>
<distributionManagement>
<repository>
<id>baiyue-nexus-releases</id>
<name>柏悦 Nexus 正式制品库</name>
<url>http://8.163.30.128:18081/repository/maven-releases/</url>
</repository>
<snapshotRepository>
<id>baiyue-nexus-snapshots</id>
<name>柏悦 Nexus 快照制品库</name>
<url>http://8.163.30.128:18081/repository/maven-snapshots/</url>
</snapshotRepository>
</distributionManagement>
<modules>
<module>platform-bom</module>
<module>common-core</module>