From 073cb6a57749e1c89823844141b8723a64ecac76 Mon Sep 17 00:00:00 2001 From: liuyihe <2513762445@qq.com> Date: Thu, 13 Aug 2026 19:01:16 +0800 Subject: [PATCH] =?UTF-8?q?=E6=9A=82=E5=AD=98=E6=8F=90=E4=BA=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- auth/JWT-JWKS-CONTRACT.md | 76 ++++++++++++ .../auth/client/AuthorizationSnapshot.java | 1 + .../auth/config/AuthSecurityConfig.java | 12 +- .../auth/config/DomesticJwtConfiguration.java | 22 ++++ .../auth/config/OverseasJwtConfiguration.java | 37 ++++++ .../auth/config/OverseasJwtProperties.java | 10 ++ .../auth/controller/AuthController.java | 14 +++ .../auth/controller/JwksController.java | 31 +++++ .../com/baiyue/auth/service/AuthService.java | 17 +-- .../auth/service/OverseasTokenService.java | 56 +++++++++ auth/src/main/resources/application.yml | 7 ++ .../auth/client/AuthorizationLoaderTest.java | 2 +- ...AuthTokenTtlConfigurationContractTest.java | 3 +- .../config/DualTrackJwtConfigurationTest.java | 29 +++++ .../auth/controller/JwksControllerTest.java | 40 +++++++ .../auth/service/AuthServiceSmallJwtTest.java | 15 +-- .../service/OverseasTokenServiceTest.java | 74 ++++++++++++ .../common/event/IamSecurityEventsV1.java | 18 +++ config/nacos/cn/auth-service.yml | 10 +- deploy/maven/settings-cn.xml | 42 +++++-- deploy/sql/README.md | 1 + deploy/sql/头程数据库统一增量-MySQL8.sql | 57 ++++++++- iam/SECURITY-EVENT-AUDIT.md | 39 +++++++ iam/pom.xml | 1 + .../iam/client/SecurityEventBridgeClient.java | 17 +++ .../com/baiyue/iam/dto/AuthorizationDtos.java | 1 + .../iam/mapper/AuthorizationQueryMapper.java | 3 + .../SecurityEventOutboxRelay.java | 22 ---- .../SecurityEventRepository.java | 25 ---- .../securityevent/SecurityEventService.java | 23 ---- .../service/AuthorizationSnapshotService.java | 3 +- .../com/baiyue/iam/service/MemberService.java | 3 + .../service/PlatformInternalUserService.java | 5 + .../PlatformUserManagementService.java | 4 + .../com/baiyue/iam/service/RbacService.java | 7 +- .../iam/service/SecurityEventOutboxRelay.java | 87 ++++++++++++++ .../service/SecurityEventOutboxService.java | 67 +++++++++++ .../com/baiyue/iam/service/TenantService.java | 3 + .../main/resources/db/lastleg-permissions.sql | 25 ++++ .../resources/db/security-event-outbox.sql | 36 ++++-- .../SecurityEventSqlContractTest.java | 43 +++++++ .../baiyue/iam/service/MemberServiceTest.java | 1 + .../PlatformInternalUserServiceTest.java | 1 + .../PlatformUserManagementServiceTest.java | 1 + .../baiyue/iam/service/RbacServiceTest.java | 1 + .../iam/service/TenantCustomerExportTest.java | 1 + .../baiyue/iam/service/TenantServiceTest.java | 1 + .../service/MerchantBatchDraftService.java | 1 + .../MerchantBatchDraftServiceTest.java | 109 ++++++++++++++++++ pom.xml | 13 +++ 50 files changed, 981 insertions(+), 136 deletions(-) create mode 100644 auth/JWT-JWKS-CONTRACT.md create mode 100644 auth/src/main/java/com/baiyue/auth/config/DomesticJwtConfiguration.java create mode 100644 auth/src/main/java/com/baiyue/auth/config/OverseasJwtConfiguration.java create mode 100644 auth/src/main/java/com/baiyue/auth/config/OverseasJwtProperties.java create mode 100644 auth/src/main/java/com/baiyue/auth/controller/JwksController.java create mode 100644 auth/src/main/java/com/baiyue/auth/service/OverseasTokenService.java create mode 100644 auth/src/test/java/com/baiyue/auth/config/DualTrackJwtConfigurationTest.java create mode 100644 auth/src/test/java/com/baiyue/auth/controller/JwksControllerTest.java create mode 100644 auth/src/test/java/com/baiyue/auth/service/OverseasTokenServiceTest.java create mode 100644 common-event-contract/src/main/java/com/baiyue/common/event/IamSecurityEventsV1.java create mode 100644 iam/SECURITY-EVENT-AUDIT.md create mode 100644 iam/src/main/java/com/baiyue/iam/client/SecurityEventBridgeClient.java delete mode 100644 iam/src/main/java/com/baiyue/iam/securityevent/SecurityEventOutboxRelay.java delete mode 100644 iam/src/main/java/com/baiyue/iam/securityevent/SecurityEventRepository.java delete mode 100644 iam/src/main/java/com/baiyue/iam/securityevent/SecurityEventService.java create mode 100644 iam/src/main/java/com/baiyue/iam/service/SecurityEventOutboxRelay.java create mode 100644 iam/src/main/java/com/baiyue/iam/service/SecurityEventOutboxService.java create mode 100644 iam/src/main/resources/db/lastleg-permissions.sql create mode 100644 iam/src/test/java/com/baiyue/iam/repository/SecurityEventSqlContractTest.java create mode 100644 order/src/test/java/com/baiyue/order/service/MerchantBatchDraftServiceTest.java diff --git a/auth/JWT-JWKS-CONTRACT.md b/auth/JWT-JWKS-CONTRACT.md new file mode 100644 index 000000000..d92044357 --- /dev/null +++ b/auth/JWT-JWKS-CONTRACT.md @@ -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。 diff --git a/auth/src/main/java/com/baiyue/auth/client/AuthorizationSnapshot.java b/auth/src/main/java/com/baiyue/auth/client/AuthorizationSnapshot.java index 4e7361ecf..47bc2000a 100644 --- a/auth/src/main/java/com/baiyue/auth/client/AuthorizationSnapshot.java +++ b/auth/src/main/java/com/baiyue/auth/client/AuthorizationSnapshot.java @@ -20,4 +20,5 @@ public class AuthorizationSnapshot { private String clientType; private Set roles; private Set permissions; + private Long permissionVersion; } diff --git a/auth/src/main/java/com/baiyue/auth/config/AuthSecurityConfig.java b/auth/src/main/java/com/baiyue/auth/config/AuthSecurityConfig.java index 7362715cc..c301d1aff 100644 --- a/auth/src/main/java/com/baiyue/auth/config/AuthSecurityConfig.java +++ b/auth/src/main/java/com/baiyue/auth/config/AuthSecurityConfig.java @@ -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, diff --git a/auth/src/main/java/com/baiyue/auth/config/DomesticJwtConfiguration.java b/auth/src/main/java/com/baiyue/auth/config/DomesticJwtConfiguration.java new file mode 100644 index 000000000..acae75153 --- /dev/null +++ b/auth/src/main/java/com/baiyue/auth/config/DomesticJwtConfiguration.java @@ -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)); + } +} diff --git a/auth/src/main/java/com/baiyue/auth/config/OverseasJwtConfiguration.java b/auth/src/main/java/com/baiyue/auth/config/OverseasJwtConfiguration.java new file mode 100644 index 000000000..ed7d60faa --- /dev/null +++ b/auth/src/main/java/com/baiyue/auth/config/OverseasJwtConfiguration.java @@ -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); + } + } +} diff --git a/auth/src/main/java/com/baiyue/auth/config/OverseasJwtProperties.java b/auth/src/main/java/com/baiyue/auth/config/OverseasJwtProperties.java new file mode 100644 index 000000000..62e173c7f --- /dev/null +++ b/auth/src/main/java/com/baiyue/auth/config/OverseasJwtProperties.java @@ -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) { } diff --git a/auth/src/main/java/com/baiyue/auth/controller/AuthController.java b/auth/src/main/java/com/baiyue/auth/controller/AuthController.java index e021efea6..5901b1d0e 100644 --- a/auth/src/main/java/com/baiyue/auth/controller/AuthController.java +++ b/auth/src/main/java/com/baiyue/auth/controller/AuthController.java @@ -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 overseasTokens; + + @PostMapping("/overseas/token") + @PreAuthorize("isAuthenticated()") + @Operation(summary = "兑换海外尾程访问令牌", description = "使用已通过国内会话校验的身份兑换 15 分钟 RS256 海外令牌。") + public ApiResponse exchangeOverseasToken( + @AuthenticationPrincipal AuthenticatedUser user) { + OverseasTokenService service = overseasTokens.getIfAvailable(); + if (service == null) throw new BizException(ResultCode.SERVICE_UNAVAILABLE, "海外认证未启用"); + return ApiResponse.success(service.exchange(user.userId())); + } @PutMapping("/password") @PreAuthorize("isAuthenticated()") diff --git a/auth/src/main/java/com/baiyue/auth/controller/JwksController.java b/auth/src/main/java/com/baiyue/auth/controller/JwksController.java new file mode 100644 index 000000000..f8fa9487e --- /dev/null +++ b/auth/src/main/java/com/baiyue/auth/controller/JwksController.java @@ -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> jwks() throws Exception { + return ResponseEntity.ok().cacheControl(CacheControl.maxAge(5, TimeUnit.MINUTES).cachePublic()) + .body(JWKSet.parse(properties.rsaJwkSet()).toPublicJWKSet().toJSONObject()); + } +} diff --git a/auth/src/main/java/com/baiyue/auth/service/AuthService.java b/auth/src/main/java/com/baiyue/auth/service/AuthService.java index f9be566f8..4a6783539 100644 --- a/auth/src/main/java/com/baiyue/auth/service/AuthService.java +++ b/auth/src/main/java/com/baiyue/auth/service/AuthService.java @@ -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(); diff --git a/auth/src/main/java/com/baiyue/auth/service/OverseasTokenService.java b/auth/src/main/java/com/baiyue/auth/service/OverseasTokenService.java new file mode 100644 index 000000000..0f7eb256a --- /dev/null +++ b/auth/src/main/java/com/baiyue/auth/service/OverseasTokenService.java @@ -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) { } +} diff --git a/auth/src/main/resources/application.yml b/auth/src/main/resources/application.yml index 126c6e6c3..33fe66410 100644 --- a/auth/src/main/resources/application.yml +++ b/auth/src/main/resources/application.yml @@ -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: diff --git a/auth/src/test/java/com/baiyue/auth/client/AuthorizationLoaderTest.java b/auth/src/test/java/com/baiyue/auth/client/AuthorizationLoaderTest.java index d0768d74c..7712cde85 100644 --- a/auth/src/test/java/com/baiyue/auth/client/AuthorizationLoaderTest.java +++ b/auth/src/test/java/com/baiyue/auth/client/AuthorizationLoaderTest.java @@ -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); } } diff --git a/auth/src/test/java/com/baiyue/auth/config/AuthTokenTtlConfigurationContractTest.java b/auth/src/test/java/com/baiyue/auth/config/AuthTokenTtlConfigurationContractTest.java index 9da820b4c..0b7440f31 100644 --- a/auth/src/test/java/com/baiyue/auth/config/AuthTokenTtlConfigurationContractTest.java +++ b/auth/src/test/java/com/baiyue/auth/config/AuthTokenTtlConfigurationContractTest.java @@ -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}"); } } diff --git a/auth/src/test/java/com/baiyue/auth/config/DualTrackJwtConfigurationTest.java b/auth/src/test/java/com/baiyue/auth/config/DualTrackJwtConfigurationTest.java new file mode 100644 index 000000000..65c871721 --- /dev/null +++ b/auth/src/test/java/com/baiyue/auth/config/DualTrackJwtConfigurationTest.java @@ -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); + }); + } +} diff --git a/auth/src/test/java/com/baiyue/auth/controller/JwksControllerTest.java b/auth/src/test/java/com/baiyue/auth/controller/JwksControllerTest.java new file mode 100644 index 000000000..53a673c72 --- /dev/null +++ b/auth/src/test/java/com/baiyue/auth/controller/JwksControllerTest.java @@ -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(); + } +} diff --git a/auth/src/test/java/com/baiyue/auth/service/AuthServiceSmallJwtTest.java b/auth/src/test/java/com/baiyue/auth/service/AuthServiceSmallJwtTest.java index 517d0f459..997ed697c 100644 --- a/auth/src/test/java/com/baiyue/auth/service/AuthServiceSmallJwtTest.java +++ b/auth/src/test/java/com/baiyue/auth/service/AuthServiceSmallJwtTest.java @@ -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), diff --git a/auth/src/test/java/com/baiyue/auth/service/OverseasTokenServiceTest.java b/auth/src/test/java/com/baiyue/auth/service/OverseasTokenServiceTest.java new file mode 100644 index 000000000..f2a0fe86b --- /dev/null +++ b/auth/src/test/java/com/baiyue/auth/service/OverseasTokenServiceTest.java @@ -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 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); + } +} diff --git a/common-event-contract/src/main/java/com/baiyue/common/event/IamSecurityEventsV1.java b/common-event-contract/src/main/java/com/baiyue/common/event/IamSecurityEventsV1.java new file mode 100644 index 000000000..3794f37ef --- /dev/null +++ b/common-event-contract/src/main/java/com/baiyue/common/event/IamSecurityEventsV1.java @@ -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) { } +} diff --git a/config/nacos/cn/auth-service.yml b/config/nacos/cn/auth-service.yml index fd913df2e..6c2d38bdd 100644 --- a/config/nacos/cn/auth-service.yml +++ b/config/nacos/cn/auth-service.yml @@ -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} diff --git a/deploy/maven/settings-cn.xml b/deploy/maven/settings-cn.xml index 96d2d9207..d3516a762 100644 --- a/deploy/maven/settings-cn.xml +++ b/deploy/maven/settings-cn.xml @@ -4,36 +4,54 @@ xsi:schemaLocation="http://maven.apache.org/SETTINGS/1.2.0 https://maven.apache.org/xsd/settings-1.2.0.xsd"> - aliyun-public - 阿里云 Maven 公共仓库 - https://maven.aliyun.com/repository/public - central + baiyue-nexus-public + 柏悦 Nexus Maven 聚合仓库 + http://8.163.30.128:18081/repository/maven-public/ + external:* + + + baiyue-nexus-public + ${env.NEXUS_USERNAME} + ${env.NEXUS_PASSWORD} + + + baiyue-nexus-releases + ${env.NEXUS_USERNAME} + ${env.NEXUS_PASSWORD} + + + baiyue-nexus-snapshots + ${env.NEXUS_USERNAME} + ${env.NEXUS_PASSWORD} + + + - 国内仓库 + 柏悦 Nexus - central - https://repo.maven.apache.org/maven2 + baiyue-nexus-public + http://8.163.30.128:18081/repository/maven-public/ true - false + true - central - https://repo.maven.apache.org/maven2 + baiyue-nexus-public + http://8.163.30.128:18081/repository/maven-public/ true - false + true - 国内仓库 + 柏悦 Nexus diff --git a/deploy/sql/README.md b/deploy/sql/README.md index 0f4dc2457..f5caabb04 100644 --- a/deploy/sql/README.md +++ b/deploy/sql/README.md @@ -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` 终止。 后续追加增量必须满足: diff --git a/deploy/sql/头程数据库统一增量-MySQL8.sql b/deploy/sql/头程数据库统一增量-MySQL8.sql index acf8dced0..26be8bd62 100644 --- a/deploy/sql/头程数据库统一增量-MySQL8.sql +++ b/deploy/sql/头程数据库统一增量-MySQL8.sql @@ -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; diff --git a/iam/SECURITY-EVENT-AUDIT.md b/iam/SECURITY-EVENT-AUDIT.md new file mode 100644 index 000000000..0f0a49f51 --- /dev/null +++ b/iam/SECURITY-EVENT-AUDIT.md @@ -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` diff --git a/iam/pom.xml b/iam/pom.xml index 2c35a4bc9..7f583178c 100644 --- a/iam/pom.xml +++ b/iam/pom.xml @@ -5,6 +5,7 @@ com.baiyue.firstlegbaiyue-firstleg-platform1.0.0-SNAPSHOT../pom.xml iam-service + com.baiyue.sharedcommon-event-contract${project.version} com.baiyue.sharedbaiyue-common-audit${project.version} org.apache.rocketmqrocketmq-spring-boot-starter com.baiyue.sharedbaiyue-common-openapi${project.version} diff --git a/iam/src/main/java/com/baiyue/iam/client/SecurityEventBridgeClient.java b/iam/src/main/java/com/baiyue/iam/client/SecurityEventBridgeClient.java new file mode 100644 index 000000000..4adf81539 --- /dev/null +++ b/iam/src/main/java/com/baiyue/iam/client/SecurityEventBridgeClient.java @@ -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 receive(@RequestHeader("X-Internal-Service-Token") String token, + @RequestBody EventEnvelope envelope); + + record Acknowledgement(String eventId, String result, String receivedAt) { } +} diff --git a/iam/src/main/java/com/baiyue/iam/dto/AuthorizationDtos.java b/iam/src/main/java/com/baiyue/iam/dto/AuthorizationDtos.java index 1ea405654..6867ebf88 100644 --- a/iam/src/main/java/com/baiyue/iam/dto/AuthorizationDtos.java +++ b/iam/src/main/java/com/baiyue/iam/dto/AuthorizationDtos.java @@ -39,5 +39,6 @@ public final class AuthorizationDtos { private String clientType; private Set roles; private Set permissions; + private Long permissionVersion; } } diff --git a/iam/src/main/java/com/baiyue/iam/mapper/AuthorizationQueryMapper.java b/iam/src/main/java/com/baiyue/iam/mapper/AuthorizationQueryMapper.java index 1a65a7d07..f38fff466 100644 --- a/iam/src/main/java/com/baiyue/iam/mapper/AuthorizationQueryMapper.java +++ b/iam/src/main/java/com/baiyue/iam/mapper/AuthorizationQueryMapper.java @@ -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); } diff --git a/iam/src/main/java/com/baiyue/iam/securityevent/SecurityEventOutboxRelay.java b/iam/src/main/java/com/baiyue/iam/securityevent/SecurityEventOutboxRelay.java deleted file mode 100644 index cf540d3c5..000000000 --- a/iam/src/main/java/com/baiyue/iam/securityevent/SecurityEventOutboxRelay.java +++ /dev/null @@ -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());}}} -} diff --git a/iam/src/main/java/com/baiyue/iam/securityevent/SecurityEventRepository.java b/iam/src/main/java/com/baiyue/iam/securityevent/SecurityEventRepository.java deleted file mode 100644 index 930ae9f69..000000000 --- a/iam/src/main/java/com/baiyue/iam/securityevent/SecurityEventRepository.java +++ /dev/null @@ -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 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< fields){String id=UUID.randomUUID().toString();Map 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);}} -} diff --git a/iam/src/main/java/com/baiyue/iam/service/AuthorizationSnapshotService.java b/iam/src/main/java/com/baiyue/iam/service/AuthorizationSnapshotService.java index c90ffec80..1592dbcf0 100644 --- a/iam/src/main/java/com/baiyue/iam/service/AuthorizationSnapshotService.java +++ b/iam/src/main/java/com/baiyue/iam/service/AuthorizationSnapshotService.java @@ -46,7 +46,8 @@ public class AuthorizationSnapshotService { Set 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())); } /** diff --git a/iam/src/main/java/com/baiyue/iam/service/MemberService.java b/iam/src/main/java/com/baiyue/iam/service/MemberService.java index 627998a7c..bc1c9b896 100644 --- a/iam/src/main/java/com/baiyue/iam/service/MemberService.java +++ b/iam/src/main/java/com/baiyue/iam/service/MemberService.java @@ -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)); diff --git a/iam/src/main/java/com/baiyue/iam/service/PlatformInternalUserService.java b/iam/src/main/java/com/baiyue/iam/service/PlatformInternalUserService.java index 64b8df116..f0285949a 100644 --- a/iam/src/main/java/com/baiyue/iam/service/PlatformInternalUserService.java +++ b/iam/src/main/java/com/baiyue/iam/service/PlatformInternalUserService.java @@ -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); diff --git a/iam/src/main/java/com/baiyue/iam/service/PlatformUserManagementService.java b/iam/src/main/java/com/baiyue/iam/service/PlatformUserManagementService.java index d35d25a17..73a8e7fe9 100644 --- a/iam/src/main/java/com/baiyue/iam/service/PlatformUserManagementService.java +++ b/iam/src/main/java/com/baiyue/iam/service/PlatformUserManagementService.java @@ -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()))); diff --git a/iam/src/main/java/com/baiyue/iam/service/RbacService.java b/iam/src/main/java/com/baiyue/iam/service/RbacService.java index 23c76a052..2645ca088 100644 --- a/iam/src/main/java/com/baiyue/iam/service/RbacService.java +++ b/iam/src/main/java/com/baiyue/iam/service/RbacService.java @@ -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 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) { diff --git a/iam/src/main/java/com/baiyue/iam/service/SecurityEventOutboxRelay.java b/iam/src/main/java/com/baiyue/iam/service/SecurityEventOutboxRelay.java new file mode 100644 index 000000000..32b9bd9e4 --- /dev/null +++ b/iam/src/main/java/com/baiyue/iam/service/SecurityEventOutboxRelay.java @@ -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 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= 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; + } +} diff --git a/iam/src/main/java/com/baiyue/iam/service/SecurityEventOutboxService.java b/iam/src/main/java/com/baiyue/iam/service/SecurityEventOutboxService.java new file mode 100644 index 000000000..d9372a2e2 --- /dev/null +++ b/iam/src/main/java/com/baiyue/iam/service/SecurityEventOutboxService.java @@ -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); + } + } +} diff --git a/iam/src/main/java/com/baiyue/iam/service/TenantService.java b/iam/src/main/java/com/baiyue/iam/service/TenantService.java index e84f24792..939e7a406 100644 --- a/iam/src/main/java/com/baiyue/iam/service/TenantService.java +++ b/iam/src/main/java/com/baiyue/iam/service/TenantService.java @@ -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); } diff --git a/iam/src/main/resources/db/lastleg-permissions.sql b/iam/src/main/resources/db/lastleg-permissions.sql new file mode 100644 index 000000000..98348b610 --- /dev/null +++ b/iam/src/main/resources/db/lastleg-permissions.sql @@ -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'); diff --git a/iam/src/main/resources/db/security-event-outbox.sql b/iam/src/main/resources/db/security-event-outbox.sql index e9afe00e5..995b26206 100644 --- a/iam/src/main/resources/db/security-event-outbox.sql +++ b/iam/src/main/resources/db/security-event-outbox.sql @@ -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可靠安全事件本地发送箱'; diff --git a/iam/src/test/java/com/baiyue/iam/repository/SecurityEventSqlContractTest.java b/iam/src/test/java/com/baiyue/iam/repository/SecurityEventSqlContractTest.java new file mode 100644 index 000000000..55171dea6 --- /dev/null +++ b/iam/src/test/java/com/baiyue/iam/repository/SecurityEventSqlContractTest.java @@ -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")); + } +} diff --git a/iam/src/test/java/com/baiyue/iam/service/MemberServiceTest.java b/iam/src/test/java/com/baiyue/iam/service/MemberServiceTest.java index beefc4f31..265237dc1 100644 --- a/iam/src/test/java/com/baiyue/iam/service/MemberServiceTest.java +++ b/iam/src/test/java/com/baiyue/iam/service/MemberServiceTest.java @@ -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; diff --git a/iam/src/test/java/com/baiyue/iam/service/PlatformInternalUserServiceTest.java b/iam/src/test/java/com/baiyue/iam/service/PlatformInternalUserServiceTest.java index bb1ed8d8f..adaccd1b1 100644 --- a/iam/src/test/java/com/baiyue/iam/service/PlatformInternalUserServiceTest.java +++ b/iam/src/test/java/com/baiyue/iam/service/PlatformInternalUserServiceTest.java @@ -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; diff --git a/iam/src/test/java/com/baiyue/iam/service/PlatformUserManagementServiceTest.java b/iam/src/test/java/com/baiyue/iam/service/PlatformUserManagementServiceTest.java index c79c9b22f..42df71a34 100644 --- a/iam/src/test/java/com/baiyue/iam/service/PlatformUserManagementServiceTest.java +++ b/iam/src/test/java/com/baiyue/iam/service/PlatformUserManagementServiceTest.java @@ -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; diff --git a/iam/src/test/java/com/baiyue/iam/service/RbacServiceTest.java b/iam/src/test/java/com/baiyue/iam/service/RbacServiceTest.java index 228deafe6..50e3083d5 100644 --- a/iam/src/test/java/com/baiyue/iam/service/RbacServiceTest.java +++ b/iam/src/test/java/com/baiyue/iam/service/RbacServiceTest.java @@ -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 diff --git a/iam/src/test/java/com/baiyue/iam/service/TenantCustomerExportTest.java b/iam/src/test/java/com/baiyue/iam/service/TenantCustomerExportTest.java index 3c805ff66..6ff844faa 100644 --- a/iam/src/test/java/com/baiyue/iam/service/TenantCustomerExportTest.java +++ b/iam/src/test/java/com/baiyue/iam/service/TenantCustomerExportTest.java @@ -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() { diff --git a/iam/src/test/java/com/baiyue/iam/service/TenantServiceTest.java b/iam/src/test/java/com/baiyue/iam/service/TenantServiceTest.java index 4b274ddec..fd402ab75 100644 --- a/iam/src/test/java/com/baiyue/iam/service/TenantServiceTest.java +++ b/iam/src/test/java/com/baiyue/iam/service/TenantServiceTest.java @@ -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; diff --git a/order/src/main/java/com/baiyue/order/service/MerchantBatchDraftService.java b/order/src/main/java/com/baiyue/order/service/MerchantBatchDraftService.java index 56b4e7f34..dfa6bbd1a 100644 --- a/order/src/main/java/com/baiyue/order/service/MerchantBatchDraftService.java +++ b/order/src/main/java/com/baiyue/order/service/MerchantBatchDraftService.java @@ -23,6 +23,7 @@ public class MerchantBatchDraftService { public MerchantBatchDraftDtos.Response create(long tenantId, long userId, String batchKey, List 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 results = new ArrayList<>(items.size()); diff --git a/order/src/test/java/com/baiyue/order/service/MerchantBatchDraftServiceTest.java b/order/src/test/java/com/baiyue/order/service/MerchantBatchDraftServiceTest.java new file mode 100644 index 000000000..eb5a52742 --- /dev/null +++ b/order/src/test/java/com/baiyue/order/service/MerchantBatchDraftServiceTest.java @@ -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; + } +} diff --git a/pom.xml b/pom.xml index c66bdf057..9da4d3cd5 100644 --- a/pom.xml +++ b/pom.xml @@ -16,6 +16,19 @@ 柏悦头程平台 柏悦悦物流国内头程业务微服务平台 + + + baiyue-nexus-releases + 柏悦 Nexus 正式制品库 + http://8.163.30.128:18081/repository/maven-releases/ + + + baiyue-nexus-snapshots + 柏悦 Nexus 快照制品库 + http://8.163.30.128:18081/repository/maven-snapshots/ + + + platform-bom common-core