From c43d581ba6691b91bb0417ebf5248eb496ecb7df Mon Sep 17 00:00:00 2001 From: liuyihe <2513762445@qq.com> Date: Thu, 13 Aug 2026 17:13:58 +0800 Subject: [PATCH] =?UTF-8?q?=E6=89=93=E5=8D=B0=E7=AE=B1=E5=94=9B=E4=BF=AE?= =?UTF-8?q?=E6=94=B9=20=20=E5=B0=BE=E7=A8=8B=E5=9E=AE=E6=A1=A5=E4=BA=8B?= =?UTF-8?q?=E4=BB=B6=E6=8F=90=E4=BA=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../auth/config/AuthSecurityConfig.java | 21 ++- .../auth/config/AuthSecurityProperties.java | 3 +- .../auth/controller/JwksController.java | 26 +++ .../com/baiyue/auth/service/AuthService.java | 27 ++- auth/src/main/resources/application.yml | 5 +- ...AuthTokenTtlConfigurationContractTest.java | 2 +- .../auth/service/AuthServiceSmallJwtTest.java | 15 +- .../com/baiyue/common/event/EventTypes.java | 4 + .../event/FirstLegLoadingPlanCompletedV1.java | 32 ++++ .../FirstLegLoadingPlanCompletedV1Test.java | 29 ++++ .../BaiyueSecurityAutoConfiguration.java | 17 +- .../ClaimsAccessTokenSessionValidator.java | 16 ++ .../HmacJwtDecoderAutoConfiguration.java | 2 +- .../JwksJwtDecoderAutoConfiguration.java | 32 ++++ .../common/security/JwtClaimsMapper.java | 13 +- ...ot.autoconfigure.AutoConfiguration.imports | 1 + config/nacos/cn/auth-service.yml | 4 +- deploy/sql/头程数据库统一增量-MySQL8.sql | 12 +- firstleg/pom.xml | 1 + .../firstleg/client/EventBridgeClient.java | 19 +++ .../firstleg/client/OrderCargoClient.java | 7 + .../InternalInboundOrderController.java | 40 +++++ .../MerchantInboundCartonLabelController.java | 67 ++++++++ .../firstleg/domain/InboundProcessStatus.java | 4 +- .../firstleg/dto/InboundOrderReference.java | 1 + .../entity/InboundCartonLabelEntity.java | 4 + .../mapper/LoadingCompletedEventMapper.java | 53 ++++++ .../mapper/LoadingPickItemMapper.java | 3 +- .../InboundCartonLabelRepository.java | 54 +++++- .../LoadingCompletedOutboxRepository.java | 45 +++++ .../InboundCartonLabelPrintService.java | 50 +++++- .../service/InboundCartonLabelService.java | 124 ++++++++++---- .../service/InboundOrderCreationService.java | 141 ++++++++++++++++ .../service/LoadingCompletedOutboxRelay.java | 47 ++++++ .../LoadingCompletedOutboxService.java | 103 ++++++++++++ .../firstleg/service/LoadingListService.java | 3 + ...ase2-carton-label-versioning-migration.sql | 55 ++++++ .../db/phase2-inbound-carton-label.sql | 7 +- .../resources/db/phase2-inbound-schema.sql | 6 +- ...se3-loading-completed-outbox-migration.sql | 2 + .../main/resources/openapi/firstleg-v1.yaml | 20 +++ ...chantInboundCartonLabelControllerTest.java | 54 ++++++ .../InboundCartonLabelPrintServiceTest.java | 39 +++++ .../InboundCartonLabelServiceTest.java | 159 +++++++++++++++--- .../InboundOrderCreationServiceTest.java | 17 ++ .../LoadingCompletedOutboxServiceTest.java | 61 +++++++ .../service/LoadingListServiceTest.java | 4 + .../SecurityEventOutboxRelay.java | 22 +++ .../SecurityEventRepository.java | 25 +++ .../securityevent/SecurityEventService.java | 23 +++ .../resources/db/security-event-outbox.sql | 21 +++ .../order/client/FirstlegInboundClient.java | 9 + .../InternalOrderCargoController.java | 21 +++ .../order/dto/MerchantInboundOrderRow.java | 7 +- .../MerchantInboundOrderListService.java | 41 ++++- .../service/OrderApplicationService.java | 35 +++- .../src/main/resources/openapi/order-v1.yaml | 16 ++ .../InternalOrderCargoControllerTest.java | 24 +++ .../MerchantInboundOrderListServiceTest.java | 10 +- 59 files changed, 1609 insertions(+), 96 deletions(-) create mode 100644 auth/src/main/java/com/baiyue/auth/controller/JwksController.java create mode 100644 common-event-contract/src/main/java/com/baiyue/common/event/FirstLegLoadingPlanCompletedV1.java create mode 100644 common-event-contract/src/test/java/com/baiyue/common/event/FirstLegLoadingPlanCompletedV1Test.java create mode 100644 common-security/src/main/java/com/baiyue/common/security/ClaimsAccessTokenSessionValidator.java create mode 100644 common-security/src/main/java/com/baiyue/common/security/JwksJwtDecoderAutoConfiguration.java create mode 100644 firstleg/src/main/java/com/baiyue/firstleg/client/EventBridgeClient.java create mode 100644 firstleg/src/main/java/com/baiyue/firstleg/controller/MerchantInboundCartonLabelController.java create mode 100644 firstleg/src/main/java/com/baiyue/firstleg/mapper/LoadingCompletedEventMapper.java create mode 100644 firstleg/src/main/java/com/baiyue/firstleg/repository/LoadingCompletedOutboxRepository.java create mode 100644 firstleg/src/main/java/com/baiyue/firstleg/service/LoadingCompletedOutboxRelay.java create mode 100644 firstleg/src/main/java/com/baiyue/firstleg/service/LoadingCompletedOutboxService.java create mode 100644 firstleg/src/main/resources/db/phase2-carton-label-versioning-migration.sql create mode 100644 firstleg/src/main/resources/db/phase3-loading-completed-outbox-migration.sql create mode 100644 firstleg/src/test/java/com/baiyue/firstleg/controller/MerchantInboundCartonLabelControllerTest.java create mode 100644 firstleg/src/test/java/com/baiyue/firstleg/service/LoadingCompletedOutboxServiceTest.java create mode 100644 iam/src/main/java/com/baiyue/iam/securityevent/SecurityEventOutboxRelay.java create mode 100644 iam/src/main/java/com/baiyue/iam/securityevent/SecurityEventRepository.java create mode 100644 iam/src/main/java/com/baiyue/iam/securityevent/SecurityEventService.java create mode 100644 iam/src/main/resources/db/security-event-outbox.sql 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..d4a1960e7 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,8 @@ package com.baiyue.auth.config; -import com.nimbusds.jose.jwk.source.ImmutableSecret; +import com.nimbusds.jose.jwk.JWKSet; +import com.nimbusds.jose.jwk.RSAKey; +import com.nimbusds.jose.jwk.source.ImmutableJWKSet; import com.baiyue.common.security.JwtAuthenticationFilter; import org.springframework.boot.context.properties.EnableConfigurationProperties; import org.springframework.context.annotation.Bean; @@ -31,6 +33,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/**", @@ -42,9 +45,19 @@ public class AuthSecurityConfig { @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)); + 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("RSA JWK Set 只能包含 RSA 密钥"); + } + return new NimbusJwtEncoder(new ImmutableJWKSet<>(set)); + } catch (java.text.ParseException exception) { + throw new IllegalStateException("RSA JWK Set配置无效", exception); + } } @Bean diff --git a/auth/src/main/java/com/baiyue/auth/config/AuthSecurityProperties.java b/auth/src/main/java/com/baiyue/auth/config/AuthSecurityProperties.java index ba0804300..3a58bb6ce 100644 --- a/auth/src/main/java/com/baiyue/auth/config/AuthSecurityProperties.java +++ b/auth/src/main/java/com/baiyue/auth/config/AuthSecurityProperties.java @@ -8,7 +8,8 @@ import java.time.Duration; * 认证安全配置属性,承载 JWT 密钥、签发方及访问令牌、刷新令牌有效期。 */ @ConfigurationProperties(prefix = "baiyue.security") -public record AuthSecurityProperties(String jwtSecret, String issuer, +public record AuthSecurityProperties(String jwtSecret, String issuer, String audience, + String rsaJwkSet, String currentKid, Duration accessTokenTtl, Duration refreshTokenTtl, int maxLoginFailures, Duration lockDuration) { } 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..bd2246c57 --- /dev/null +++ b/auth/src/main/java/com/baiyue/auth/controller/JwksController.java @@ -0,0 +1,26 @@ +package com.baiyue.auth.controller; + +import com.baiyue.auth.config.AuthSecurityProperties; +import com.nimbusds.jose.jwk.JWKSet; +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; + +/** 仅发布 RSA 公钥;轮换期配置当前与上一把公钥。 */ +@RestController +public class JwksController { + private final AuthSecurityProperties properties; + public JwksController(AuthSecurityProperties properties) { this.properties = properties; } + + @GetMapping("/api/auth/.well-known/jwks.json") + public ResponseEntity> jwks() throws Exception { + JWKSet publicSet = JWKSet.parse(properties.rsaJwkSet()).toPublicJWKSet(); + return ResponseEntity.ok() + .cacheControl(CacheControl.maxAge(5, TimeUnit.MINUTES).cachePublic()) + .body(publicSet.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 eafdb1401..2d3125afd 100644 --- a/auth/src/main/java/com/baiyue/auth/service/AuthService.java +++ b/auth/src/main/java/com/baiyue/auth/service/AuthService.java @@ -16,7 +16,7 @@ import com.baiyue.common.core.exception.BizException; import jakarta.annotation.Resource; import org.springframework.security.crypto.password.PasswordEncoder; -import org.springframework.security.oauth2.jose.jws.MacAlgorithm; +import org.springframework.security.oauth2.jose.jws.SignatureAlgorithm; import org.springframework.security.oauth2.jwt.JwtClaimsSet; import org.springframework.security.oauth2.jwt.JwtEncoder; import org.springframework.security.oauth2.jwt.JwtEncoderParameters; @@ -30,6 +30,7 @@ import java.time.LocalDateTime; import java.util.Base64; import java.util.UUID; import java.time.Duration; +import java.util.List; /** * 统一认证应用服务,编排密码校验、IAM 权限快照加载、小 JWT 签发、刷新令牌轮换与注销事务。 @@ -108,7 +109,8 @@ public class AuthService { String jwtId = UUID.randomUUID().toString(); Instant expiresAt = issuedAt.plus(properties.accessTokenTtl()); JwtClaimsSet claims = JwtClaimsSet.builder() - .issuer(properties.issuer()).id(jwtId).issuedAt(issuedAt).expiresAt(expiresAt) + .issuer(properties.issuer()).audience(List.of(properties.audience())) + .id(jwtId).issuedAt(issuedAt).expiresAt(expiresAt) .subject(account.getId().toString()) .claim("user_id", account.getId()) .claim("username", account.getUsername()) @@ -119,9 +121,15 @@ 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( - JwsHeader.with(MacAlgorithm.HS256).build(), claims)).getTokenValue(); + signingHeader(), claims)).getTokenValue(); byte[] random = new byte[48]; secureRandom.nextBytes(random); @@ -167,16 +175,21 @@ 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()).id(jwtId) + JwtClaimsSet claims = JwtClaimsSet.builder().issuer(properties.issuer()) + .audience(List.of(properties.audience())).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(); - String accessToken = jwtEncoder.encode(JwtEncoderParameters.from(JwsHeader.with(MacAlgorithm.HS256).build(), claims)).getTokenValue(); + String accessToken = jwtEncoder.encode(JwtEncoderParameters.from(signingHeader(), claims)).getTokenValue(); tokenSessionStore.storeAccessOnly(jwtId, account.getId(), authorization.getRoles(), authorization.getPermissions(), Duration.ofMinutes(15)); return new ImpersonationTokenResponse("Bearer", accessToken, 900); @@ -184,6 +197,10 @@ public class AuthService { public record ImpersonationTokenResponse(String tokenType, String accessToken, long expiresIn) { } + private JwsHeader signingHeader() { + return JwsHeader.with(SignatureAlgorithm.RS256).keyId(properties.currentKid()).build(); + } + private static BizException invalidCredentials() { return new BizException(ResultCode.UNAUTHORIZED, "用户名或密码错误"); } diff --git a/auth/src/main/resources/application.yml b/auth/src/main/resources/application.yml index 346a5d5f2..3b3e03682 100644 --- a/auth/src/main/resources/application.yml +++ b/auth/src/main/resources/application.yml @@ -31,10 +31,13 @@ spring: encode: UTF-8 baiyue.security: jwt-secret: ${JWT_SECRET:change-me-to-at-least-32-bytes-long-before-start} + rsa-jwk-set: ${JWT_RSA_JWK_SET:} + current-kid: ${JWT_CURRENT_KID:} internal-service-token: ${INTERNAL_SERVICE_TOKEN:change-me-to-an-internal-token-at-least-32-bytes-long} token-key-prefix: ${REDIS_TOKEN_KEY_PREFIX:baiyue:auth} issuer: ${JWT_ISSUER:auth-service} - access-token-ttl: ${ACCESS_TOKEN_TTL:24h} + audience: ${JWT_AUDIENCE:lastleg-api} + access-token-ttl: ${ACCESS_TOKEN_TTL:24m} refresh-token-ttl: ${REFRESH_TOKEN_TTL:7d} max-login-failures: ${MAX_LOGIN_FAILURES:5} lock-duration: ${LOGIN_LOCK_DURATION:15m} 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 8df324da5..9da820b4c 100644 --- a/auth/src/test/java/com/baiyue/auth/config/AuthTokenTtlConfigurationContractTest.java +++ b/auth/src/test/java/com/baiyue/auth/config/AuthTokenTtlConfigurationContractTest.java @@ -15,7 +15,7 @@ class AuthTokenTtlConfigurationContractTest { Path.of("..", "config", "nacos", "cn", "auth-service.yml"), StandardCharsets.UTF_8); assertThat(configuration) - .contains("access-token-ttl: ${ACCESS_TOKEN_TTL:2h}") + .contains("access-token-ttl: ${ACCESS_TOKEN_TTL:15m}") .contains("refresh-token-ttl: ${REFRESH_TOKEN_TTL:7d}") .doesNotContain("access-token-ttl: ${ACCESS_TOKEN_TTL:7d}"); } 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 cfaf78348..ad6fda064 100644 --- a/auth/src/test/java/com/baiyue/auth/service/AuthServiceSmallJwtTest.java +++ b/auth/src/test/java/com/baiyue/auth/service/AuthServiceSmallJwtTest.java @@ -39,13 +39,14 @@ class AuthServiceSmallJwtTest { static class TestConfig { @Bean AuthSecurityProperties properties() { - return new AuthSecurityProperties("unused", "auth-service", Duration.ofMinutes(30), + return new AuthSecurityProperties("unused", "auth-service", "lastleg-api", "{}", "current-2026-08", + Duration.ofMinutes(30), Duration.ofDays(7), 5, Duration.ofMinutes(10)); } } @Test - void signsIdentityOnlyJwtAndStoresAuthorizationInRedis() { + void signsCrossRegionClaimsAndStoresSession() { AuthAccount account = new AuthAccount(); account.setId(42L); account.setUsername("operator"); @@ -72,8 +73,16 @@ 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("roles", "permissions", "authorities", "scope"); + .doesNotContainKeys("permissions", "scope"); + assertThat(jwt.getValue().getClaims().getAudience()).containsExactly("lastleg-api"); + assertThat(jwt.getValue().getJwsHeader().getKeyId()).isEqualTo("current-2026-08"); verify(tokenSessionStore).store(any(), org.mockito.ArgumentMatchers.eq(42L), org.mockito.ArgumentMatchers.eq(Set.of("PLATFORM_ADMIN")), org.mockito.ArgumentMatchers.eq(Set.of("platform:order:list")), any(), diff --git a/common-event-contract/src/main/java/com/baiyue/common/event/EventTypes.java b/common-event-contract/src/main/java/com/baiyue/common/event/EventTypes.java index eb12cc898..ccee66d60 100644 --- a/common-event-contract/src/main/java/com/baiyue/common/event/EventTypes.java +++ b/common-event-contract/src/main/java/com/baiyue/common/event/EventTypes.java @@ -9,6 +9,10 @@ public final class EventTypes { public static final String ORDER_APPROVED = "OrderApproved"; public static final String ORDER_CANCELLED = "OrderCancelled"; public static final String FIRST_LEG_SHIPMENT_DEPARTED = "FirstLegShipmentDeparted"; + public static final String FIRST_LEG_LOADING_PLAN_COMPLETED = "FirstLegLoadingPlanCompleted"; + public static final String IAM_USER_STATUS_CHANGED = "IamUserStatusChanged"; + public static final String IAM_TENANT_STATUS_CHANGED = "IamTenantStatusChanged"; + public static final String IAM_PERMISSION_VERSION_CHANGED = "IamPermissionVersionChanged"; public static final String LAST_MILE_HANDOFF_ACCEPTED = "LastMileHandoffAccepted"; public static final String LAST_MILE_HANDOFF_REJECTED = "LastMileHandoffRejected"; public static final String LAST_MILE_MILESTONE_CHANGED = "LastMileMilestoneChanged"; diff --git a/common-event-contract/src/main/java/com/baiyue/common/event/FirstLegLoadingPlanCompletedV1.java b/common-event-contract/src/main/java/com/baiyue/common/event/FirstLegLoadingPlanCompletedV1.java new file mode 100644 index 000000000..d396416d8 --- /dev/null +++ b/common-event-contract/src/main/java/com/baiyue/common/event/FirstLegLoadingPlanCompletedV1.java @@ -0,0 +1,32 @@ +package com.baiyue.common.event; + +import java.math.BigDecimal; +import java.util.List; + +/** 头程装柜完成后交给海外尾程的不可变 V1 快照。 */ +public record FirstLegLoadingPlanCompletedV1( + String loadingPlanId, String planNo, String containerBookingId, + String containerNo, String cabinetNo, String warehouseId, String warehouseName, + List orders) { + + public record Order( + String sourceInboundId, String rootInboundId, + String sourceOrderId, String sourceOrderNo, String rootOrderId, + String inboundCode, String destinationWarehouseId, + String destinationWarehouseCode, String destinationWarehouseName, + int packageCount, Integer productQuantity, Integer palletCount, + BigDecimal grossWeightKg, BigDecimal netWeightKg, BigDecimal volumeCbm, + List items) { + } + + public record Item( + String sourceInboundDetailId, String skuCode, + String productNameZh, String productNameEn, + int packageCount, Integer productQuantity, Integer palletCount, + BigDecimal grossWeightKg, BigDecimal netWeightKg, + BigDecimal dimensionalWeightKg, BigDecimal volumeCbm, + String destinationWarehouseId, String destinationWarehouseCode, + String destinationWarehouseName, String fbaTrackingNo, + String shipmentNo, String shippingMark) { + } +} diff --git a/common-event-contract/src/test/java/com/baiyue/common/event/FirstLegLoadingPlanCompletedV1Test.java b/common-event-contract/src/test/java/com/baiyue/common/event/FirstLegLoadingPlanCompletedV1Test.java new file mode 100644 index 000000000..2c9fb34cb --- /dev/null +++ b/common-event-contract/src/test/java/com/baiyue/common/event/FirstLegLoadingPlanCompletedV1Test.java @@ -0,0 +1,29 @@ +package com.baiyue.common.event; + +import com.fasterxml.jackson.databind.ObjectMapper; +import org.junit.jupiter.api.Test; + +import java.math.BigDecimal; +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; + +class FirstLegLoadingPlanCompletedV1Test { + private final ObjectMapper mapper = new ObjectMapper(); + + @Test + void v1PayloadRoundTripsWithoutLosingLargeIds() throws Exception { + var item = new FirstLegLoadingPlanCompletedV1.Item("2088000000000000001", "SKU-1", "产品", "Product", + 2, 20, 1, new BigDecimal("10.500"), new BigDecimal("9.500"), new BigDecimal("12.000"), + new BigDecimal("0.200"), "2088000000000000002", "ONT8", "ONT8", "FBA-1", "SHIP-1", "MARK"); + var order = new FirstLegLoadingPlanCompletedV1.Order("2088000000000000003", "2088000000000000004", + "2088000000000000005", "FL-1", "2088000000000000006", "RK-1", "2088000000000000002", + "ONT8", "ONT8", 2, 20, 1, new BigDecimal("10.500"), new BigDecimal("9.500"), + new BigDecimal("0.200"), List.of(item)); + var payload = new FirstLegLoadingPlanCompletedV1("2088000000000000007", "P-1", "2088000000000000008", + "CONT-1", "CAB-1", "2088000000000000009", "中转仓", List.of(order)); + String json = mapper.writeValueAsString(payload); + assertThat(mapper.readValue(json, FirstLegLoadingPlanCompletedV1.class)).isEqualTo(payload); + assertThat(json).contains("2088000000000000003", "sourceInboundDetailId"); + } +} diff --git a/common-security/src/main/java/com/baiyue/common/security/BaiyueSecurityAutoConfiguration.java b/common-security/src/main/java/com/baiyue/common/security/BaiyueSecurityAutoConfiguration.java index d001a92e1..e03486d95 100644 --- a/common-security/src/main/java/com/baiyue/common/security/BaiyueSecurityAutoConfiguration.java +++ b/common-security/src/main/java/com/baiyue/common/security/BaiyueSecurityAutoConfiguration.java @@ -23,7 +23,7 @@ import org.springframework.security.web.authentication.UsernamePasswordAuthentic * Servlet 服务安全自动配置,建立无状态 JWT 认证链、统一异常响应和审计操作者解析。 */ @AutoConfiguration( - after = HmacJwtDecoderAutoConfiguration.class, + after = {HmacJwtDecoderAutoConfiguration.class, JwksJwtDecoderAutoConfiguration.class}, before = SecurityAutoConfiguration.class) @EnableMethodSecurity public class BaiyueSecurityAutoConfiguration { @@ -47,11 +47,18 @@ public class BaiyueSecurityAutoConfiguration { @ConditionalOnMissingBean JwtAuthenticationFilter jwtAuthenticationFilter(JwtDecoder jwtDecoder, JwtClaimsMapper claimsMapper, AuthenticationEntryPoint entryPoint, - StringRedisTemplate redisTemplate, + org.springframework.beans.factory.ObjectProvider redis, ObjectMapper objectMapper, - @Value("${baiyue.security.token-key-prefix:baiyue:auth}") String keyPrefix) { - return new JwtAuthenticationFilter(jwtDecoder, claimsMapper, entryPoint, - new RedisAccessTokenSessionValidator(redisTemplate, keyPrefix, objectMapper)); + @Value("${baiyue.security.token-key-prefix:baiyue:auth}") String keyPrefix, + @Value("${baiyue.security.session-mode:redis}") String sessionMode) { + AccessTokenSessionValidator validator; + if ("claims".equalsIgnoreCase(sessionMode)) validator = new ClaimsAccessTokenSessionValidator(); + else { + StringRedisTemplate template=redis.getIfAvailable(); + if(template==null) throw new IllegalStateException("session-mode=redis requires StringRedisTemplate"); + validator=new RedisAccessTokenSessionValidator(template,keyPrefix,objectMapper); + } + return new JwtAuthenticationFilter(jwtDecoder, claimsMapper, entryPoint, validator); } @Bean diff --git a/common-security/src/main/java/com/baiyue/common/security/ClaimsAccessTokenSessionValidator.java b/common-security/src/main/java/com/baiyue/common/security/ClaimsAccessTokenSessionValidator.java new file mode 100644 index 000000000..c32b30317 --- /dev/null +++ b/common-security/src/main/java/com/baiyue/common/security/ClaimsAccessTokenSessionValidator.java @@ -0,0 +1,16 @@ +package com.baiyue.common.security; + +import org.springframework.security.oauth2.jwt.Jwt; +import java.util.LinkedHashSet; +import java.util.Set; + +/** 海外模式直接使用已由 RS256/JWKS 验证的短时令牌授权快照。 */ +public class ClaimsAccessTokenSessionValidator implements AccessTokenSessionValidator { + @Override public AccessTokenSession validate(Jwt jwt) { + Long userId=number(jwt.getClaims().getOrDefault("userId",jwt.getClaims().get("user_id"))); + return new AccessTokenSession(1,userId,values(jwt.getClaims().get("roles")), + values(jwt.getClaims().getOrDefault("authorities",jwt.getClaims().get("permissions")))); + } + private static Long number(Object value){ if(value instanceof Number n)return n.longValue(); try{return Long.valueOf(String.valueOf(value));}catch(Exception e){return null;} } + private static Set values(Object value){ Set result=new LinkedHashSet<>(); if(value instanceof Iterable it)it.forEach(v->result.add(String.valueOf(v))); else if(value!=null)for(String v:String.valueOf(value).split("[ ,]+"))if(!v.isBlank())result.add(v); return result; } +} diff --git a/common-security/src/main/java/com/baiyue/common/security/HmacJwtDecoderAutoConfiguration.java b/common-security/src/main/java/com/baiyue/common/security/HmacJwtDecoderAutoConfiguration.java index 4cc24909d..b4b8261ed 100644 --- a/common-security/src/main/java/com/baiyue/common/security/HmacJwtDecoderAutoConfiguration.java +++ b/common-security/src/main/java/com/baiyue/common/security/HmacJwtDecoderAutoConfiguration.java @@ -17,7 +17,7 @@ import java.nio.charset.StandardCharsets; * HMAC JWT 解码器自动配置,按共享密钥和签发方构建统一令牌验证器。 */ @AutoConfiguration(before = BaiyueSecurityAutoConfiguration.class) -@ConditionalOnProperty(name = "baiyue.security.jwt-secret") +@ConditionalOnProperty(name = "baiyue.security.jwt-mode", havingValue = "hmac", matchIfMissing = true) public class HmacJwtDecoderAutoConfiguration { @Bean @ConditionalOnMissingBean(JwtDecoder.class) diff --git a/common-security/src/main/java/com/baiyue/common/security/JwksJwtDecoderAutoConfiguration.java b/common-security/src/main/java/com/baiyue/common/security/JwksJwtDecoderAutoConfiguration.java new file mode 100644 index 000000000..b73770483 --- /dev/null +++ b/common-security/src/main/java/com/baiyue/common/security/JwksJwtDecoderAutoConfiguration.java @@ -0,0 +1,32 @@ +package com.baiyue.common.security; + +import org.springframework.beans.factory.annotation.Value; +import org.springframework.boot.autoconfigure.AutoConfiguration; +import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.context.annotation.Bean; +import org.springframework.security.oauth2.jwt.JwtDecoder; +import org.springframework.security.oauth2.jwt.JwtValidators; +import org.springframework.security.oauth2.jwt.NimbusJwtDecoder; +import org.springframework.security.oauth2.jwt.JwtClaimValidator; +import org.springframework.security.oauth2.core.DelegatingOAuth2TokenValidator; +import org.springframework.security.oauth2.jose.jws.SignatureAlgorithm; + +import java.util.List; + +/** 海外服务仅通过可轮换 JWKS 验证 RS256,不访问国内 Redis。 */ +@AutoConfiguration(before = BaiyueSecurityAutoConfiguration.class) +@ConditionalOnProperty(name="baiyue.security.jwt-mode",havingValue="jwks") +public class JwksJwtDecoderAutoConfiguration { + @Bean @ConditionalOnMissingBean(JwtDecoder.class) + JwtDecoder jwksJwtDecoder(@Value("${baiyue.security.jwk-set-uri}") String uri, + @Value("${baiyue.security.issuer:auth-service}") String issuer, + @Value("${baiyue.security.audience:lastleg-api}") String audience) { + NimbusJwtDecoder decoder = NimbusJwtDecoder.withJwkSetUri(uri) + .jwsAlgorithm(SignatureAlgorithm.RS256).build(); + decoder.setJwtValidator(new DelegatingOAuth2TokenValidator<>( + JwtValidators.createDefaultWithIssuer(issuer), + new JwtClaimValidator>("aud", values -> values != null && values.contains(audience)))); + return decoder; + } +} diff --git a/common-security/src/main/java/com/baiyue/common/security/JwtClaimsMapper.java b/common-security/src/main/java/com/baiyue/common/security/JwtClaimsMapper.java index 31f0faa9a..84faded6a 100644 --- a/common-security/src/main/java/com/baiyue/common/security/JwtClaimsMapper.java +++ b/common-security/src/main/java/com/baiyue/common/security/JwtClaimsMapper.java @@ -23,20 +23,21 @@ public class JwtClaimsMapper { addRoles(permissions, session.roles()); } - String appCode = jwt.getClaimAsString("app_code"); + String appCode = firstNonBlank(jwt.getClaimAsString("appCode"), jwt.getClaimAsString("app_code")); if (appCode != null && !appCode.isBlank()) { permissions.add("APP_" + appCode.toUpperCase(java.util.Locale.ROOT)); } String username = firstNonBlank(jwt.getClaimAsString("username"), jwt.getClaimAsString("preferred_username"), jwt.getSubject()); - Long userId = longClaim(jwt, "user_id"); + Long userId = longClaim(jwt, "userId"); + if (userId == null) userId = longClaim(jwt, "user_id"); AuthenticatedUser user = new AuthenticatedUser( userId == null ? longValue(jwt.getSubject()) : userId, - longClaim(jwt, "tenant_id"), + longClaimEither(jwt, "tenantId", "tenant_id"), longClaim(jwt, "operator_tenant_id"), longClaim(jwt, "membership_id"), username, - jwt.getClaimAsString("app_code"), + appCode, jwt.getClaimAsString("client_type"), permissions, Boolean.TRUE.equals(jwt.getClaim("impersonated")), @@ -76,6 +77,10 @@ public class JwtClaimsMapper { if (value instanceof String text) return longValue(text); return null; } + private Long longClaimEither(Jwt jwt, String first, String second) { + Long value = longClaim(jwt, first); + return value == null ? longClaim(jwt, second) : value; + } private String firstNonBlank(String... values) { for (String value : values) if (value != null && !value.isBlank()) return value; diff --git a/common-security/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports b/common-security/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports index 09441e4a6..d4d261650 100644 --- a/common-security/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports +++ b/common-security/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports @@ -1,2 +1,3 @@ com.baiyue.common.security.HmacJwtDecoderAutoConfiguration +com.baiyue.common.security.JwksJwtDecoderAutoConfiguration com.baiyue.common.security.BaiyueSecurityAutoConfiguration diff --git a/config/nacos/cn/auth-service.yml b/config/nacos/cn/auth-service.yml index b7cabf2c4..5166142e8 100644 --- a/config/nacos/cn/auth-service.yml +++ b/config/nacos/cn/auth-service.yml @@ -6,5 +6,7 @@ spring: driver-class-name: com.mysql.cj.jdbc.Driver baiyue: security: - access-token-ttl: ${ACCESS_TOKEN_TTL:2h} + audience: ${JWT_AUDIENCE:lastleg-api} + current-kid: ${JWT_CURRENT_KID:} + access-token-ttl: ${ACCESS_TOKEN_TTL:15m} refresh-token-ttl: ${REFRESH_TOKEN_TTL:7d} diff --git a/deploy/sql/头程数据库统一增量-MySQL8.sql b/deploy/sql/头程数据库统一增量-MySQL8.sql index ab690cc46..109b7c8d9 100644 --- a/deploy/sql/头程数据库统一增量-MySQL8.sql +++ b/deploy/sql/头程数据库统一增量-MySQL8.sql @@ -2401,7 +2401,7 @@ CREATE TABLE IF NOT EXISTS fl_inbound_order ( order_id BIGINT UNSIGNED NOT NULL COMMENT '订单服务订单ID,不建立跨库外键', order_no VARCHAR(32) NOT NULL COMMENT '订单号快照', inbound_code VARCHAR(40) NOT NULL COMMENT '客户内唯一入仓码', - process_status VARCHAR(24) NOT NULL DEFAULT 'WAIT_MEASURE' COMMENT '入库流程状态:WAIT_MEASURE待测量、MEASURING测量中、MEASURE_PENDING待审核、MEASURE_REJECTED测量驳回、WAIT_PRICING待计费、FEE_CONFIRMING费用确认中、INBOUNDING入库收口中、WAIT_SPLIT待分柜', + process_status VARCHAR(24) NOT NULL DEFAULT 'WAIT_MEASURE' COMMENT '入库流程状态:WAIT_MEASURE待测量、PREPARING草稿预入库、MEASURING测量中、MEASURE_PENDING待审核、MEASURE_REJECTED测量驳回、WAIT_PRICING待计费、FEE_CONFIRMING费用确认中、INBOUNDING入库收口中、WAIT_SPLIT待分柜', order_status_snapshot VARCHAR(24) NOT NULL COMMENT '订单状态快照', measure_review_status VARCHAR(24) NOT NULL DEFAULT 'NOT_SUBMITTED' COMMENT '测量审核状态:NOT_SUBMITTED未提交、PENDING待审核、APPROVED已通过、REJECTED已驳回', fee_status VARCHAR(24) NOT NULL DEFAULT 'UNPRICED' COMMENT '费用状态:UNPRICED未计费、CALCULATED已计算、CONFIRMED已确认、SENT已发送、VOIDED已作废', @@ -2657,6 +2657,10 @@ CREATE TABLE IF NOT EXISTS fl_inbound_carton_label ( box_code VARCHAR(80) NOT NULL COMMENT '固定二维码内容:入仓码#总箱数-箱下标', total_carton_count INT UNSIGNED NOT NULL COMMENT '首次分配时整单总箱数', carton_index INT UNSIGNED NOT NULL COMMENT '整单从1开始的稳定箱下标', + label_version INT UNSIGNED NOT NULL DEFAULT 1 COMMENT '箱唛批次版本,从1递增', + batch_status VARCHAR(16) NOT NULL DEFAULT 'ACTIVE' COMMENT '批次状态:ACTIVE有效、VOID已作废', + cargo_fingerprint CHAR(64) NULL COMMENT '影响标签字段的SHA-256指纹', + voided_at DATETIME(3) NULL COMMENT '批次作废时间', box_specification VARCHAR(255) NOT NULL COMMENT '订单箱规或货物明细描述快照', length_cm DECIMAL(12,3) NULL COMMENT '下单箱规长度cm快照', width_cm DECIMAL(12,3) NULL COMMENT '下单箱规宽度cm快照', @@ -2664,7 +2668,8 @@ CREATE TABLE IF NOT EXISTS fl_inbound_carton_label ( created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '首次分配时间', PRIMARY KEY (id), UNIQUE KEY uk_carton_label_box_code (tenant_id, box_code), - UNIQUE KEY uk_carton_label_index (tenant_id, inbound_id, carton_index), + UNIQUE KEY uk_carton_label_index (tenant_id, inbound_id, label_version, carton_index), + KEY idx_carton_label_active (tenant_id, inbound_id, batch_status, label_version), KEY idx_carton_label_detail (tenant_id, inbound_id, inbound_detail_id), CONSTRAINT fk_carton_label_inbound FOREIGN KEY (inbound_id) REFERENCES fl_inbound_order (id) ) ENGINE=InnoDB COMMENT='首次打印前冻结的实际箱号与订单货物明细映射'; @@ -14819,6 +14824,9 @@ CALL assert_finance_permissions(); DROP PROCEDURE IF EXISTS assert_finance_permissions; USE baiyue_firstleg; +-- 标准跨区事件摘要包含 sha256: 前缀,共 71 字符;兼容既有 Finance 64 位摘要。 +ALTER TABLE fl_inbound_outbox + MODIFY COLUMN payload_hash VARCHAR(71) NULL COMMENT '事件规范化负载SHA-256;标准跨区事件含sha256:前缀'; CALL baiyue_firstleg.assert_unified_final_schema(); DROP PROCEDURE IF EXISTS baiyue_firstleg.assert_unified_final_schema; SELECT 'PASS' AS migration_status, diff --git a/firstleg/pom.xml b/firstleg/pom.xml index b651e8fee..02098cbe8 100644 --- a/firstleg/pom.xml +++ b/firstleg/pom.xml @@ -6,6 +6,7 @@ firstleg-service com.baiyue.sharedbaiyue-common-core${project.version} + 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/firstleg/src/main/java/com/baiyue/firstleg/client/EventBridgeClient.java b/firstleg/src/main/java/com/baiyue/firstleg/client/EventBridgeClient.java new file mode 100644 index 000000000..491896aea --- /dev/null +++ b/firstleg/src/main/java/com/baiyue/firstleg/client/EventBridgeClient.java @@ -0,0 +1,19 @@ +package com.baiyue.firstleg.client; + +import com.baiyue.common.core.api.ApiResponse; +import com.baiyue.common.event.EventEnvelope; +import org.springframework.cloud.openfeign.FeignClient; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestHeader; + +/** 将本地业务 Outbox 投递到跨区事件桥。 */ +@FeignClient(name = "event-bridge-service", contextId = "loadingCompletedEventBridgeClient") +public interface EventBridgeClient { + @PostMapping("/internal/event-bridge/events") + ApiResponse receive( + @RequestHeader("X-Internal-Service-Token") String token, + @RequestBody EventEnvelope envelope); + + record Acknowledgement(String eventId, String result, String receivedAt) { } +} diff --git a/firstleg/src/main/java/com/baiyue/firstleg/client/OrderCargoClient.java b/firstleg/src/main/java/com/baiyue/firstleg/client/OrderCargoClient.java index 3a095f79b..e4f361d90 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/client/OrderCargoClient.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/client/OrderCargoClient.java @@ -8,6 +8,7 @@ import org.springframework.web.bind.annotation.RequestHeader; import java.math.BigDecimal; import java.util.List; +import com.baiyue.firstleg.dto.OrderApprovedInboundCommand; @FeignClient(name = "order-service", contextId = "firstlegOrderCargoClient", url = "${baiyue.clients.order.url:}", path = "/internal/order/v1/cargoes") @@ -24,6 +25,12 @@ public interface OrderCargoClient { @PathVariable long tenantId, @PathVariable long orderId); + @GetMapping("/{tenantId}/{orderId}/inbound-snapshot") + ApiResponse inboundSnapshot( + @RequestHeader("X-Internal-Service-Token") String token, + @PathVariable long tenantId, + @PathVariable long orderId); + record CargoBinding(Long cargoId, Long orderId, Integer itemNo, String cargoName, Integer originalPackageCount, BigDecimal originalGrossWeightKg, BigDecimal originalVolumeCbm, String skuNo, String cargoNameEn, diff --git a/firstleg/src/main/java/com/baiyue/firstleg/controller/InternalInboundOrderController.java b/firstleg/src/main/java/com/baiyue/firstleg/controller/InternalInboundOrderController.java index 3fe2aab35..3f8f4dbc8 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/controller/InternalInboundOrderController.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/controller/InternalInboundOrderController.java @@ -62,6 +62,33 @@ public class InternalInboundOrderController { return ApiResponse.success(service.createFromApprovedOrder(command)); } + @PostMapping("/order-preparing") + public ApiResponse prepareFromDraft( + @RequestHeader(value = "X-Internal-Service-Token", required = false) String token, + @RequestBody OrderApprovedInboundCommand command) { + requireToken(token); + requireSnapshot(command); + return ApiResponse.success(service.prepareFromDraft(command)); + } + + @PostMapping("/order-preparing/batch") + public ApiResponse> prepareFromDraftBatch( + @RequestHeader(value = "X-Internal-Service-Token", required = false) String token, + @RequestBody List commands) { + requireToken(token); + if (commands == null || commands.isEmpty() || commands.size() > 200) { + throw new BizException(ResultCode.BAD_REQUEST, "预入库订单批次必须为1至200条"); + } + commands.forEach(this::requireSnapshot); + long tenantId = commands.get(0).tenantId(); + if (commands.stream().anyMatch(command -> command.tenantId() != tenantId)) { + throw new BizException(ResultCode.BAD_REQUEST, "预入库订单批次必须属于同一客户"); + } + commands.forEach(service::prepareFromDraft); + return ApiResponse.success(service.findReferences(tenantId, + commands.stream().map(OrderApprovedInboundCommand::orderId).distinct().toList())); + } + @PostMapping("/order-references") public ApiResponse> references( @RequestHeader(value = "X-Internal-Service-Token", required = false) String token, @@ -101,4 +128,17 @@ public class InternalInboundOrderController { throw new BizException(ResultCode.FORBIDDEN, "内部服务凭证无效"); } } + + private void requireSnapshot(OrderApprovedInboundCommand command) { + if (command == null || command.tenantId() <= 0 || command.orderId() <= 0 + || command.orderNo() == null || command.orderNo().isBlank() + || command.inboundCode() == null || command.inboundCode().isBlank() + || command.inboundCode().length() > 40 + || command.customerName() == null || command.customerName().isBlank() + || command.warehouseId() == null || command.warehouseId() <= 0 + || command.warehouseName() == null || command.warehouseName().isBlank() + || command.businessType() == null || command.businessType().isBlank()) { + throw new BizException(ResultCode.BAD_REQUEST, "草稿预入库快照不完整"); + } + } } diff --git a/firstleg/src/main/java/com/baiyue/firstleg/controller/MerchantInboundCartonLabelController.java b/firstleg/src/main/java/com/baiyue/firstleg/controller/MerchantInboundCartonLabelController.java new file mode 100644 index 000000000..f4272b104 --- /dev/null +++ b/firstleg/src/main/java/com/baiyue/firstleg/controller/MerchantInboundCartonLabelController.java @@ -0,0 +1,67 @@ +package com.baiyue.firstleg.controller; + +import com.baiyue.common.security.AuthenticatedUser; +import com.baiyue.firstleg.service.FirstlegScopeGuard; +import com.baiyue.firstleg.service.InboundCartonLabelPrintService; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.annotation.Resource; +import jakarta.validation.constraints.Min; +import org.springframework.http.ResponseEntity; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.validation.annotation.Validated; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestHeader; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; +import org.springframework.web.bind.annotation.RequestBody; + +import java.util.List; + +/** 客户端下载本客户入库单箱唛。 */ +@Tag(name = "客户端入库箱唛") +@Validated +@RestController +@RequestMapping("/api/merchant/v1/first-leg") +public class MerchantInboundCartonLabelController { + @Resource + private InboundCartonLabelPrintService cartonLabelPrintService; + + @Operation(summary = "生成入仓箱唛 PDF") + @PostMapping("/inbounds/{inboundId}/carton-label-pdf") + @PreAuthorize("hasAuthority('merchant:order:view')") + public ResponseEntity print( + @AuthenticationPrincipal AuthenticatedUser user, + @PathVariable @Min(1) long inboundId, + @RequestParam(required = false) List<@Min(1) Integer> cartonIndex, + @RequestHeader(value = "X-Request-Id", required = false) String requestId) { + return cartonLabelPrintService.generate( + FirstlegScopeGuard.merchant(user), inboundId, cartonIndex, requestId); + } + + @Operation(summary = "按订单生成入仓箱唛 PDF") + @PostMapping("/orders/{orderId}/carton-label-pdf") + @PreAuthorize("hasAuthority('merchant:order:view')") + public ResponseEntity printByOrder( + @AuthenticationPrincipal AuthenticatedUser user, + @PathVariable @Min(1) long orderId, + @RequestParam(required = false) List<@Min(1) Integer> cartonIndex, + @RequestHeader(value = "X-Request-Id", required = false) String requestId) { + return cartonLabelPrintService.generateByOrder( + FirstlegScopeGuard.merchant(user), orderId, cartonIndex, requestId); + } + + @Operation(summary = "批量按订单生成箱唛 PDF") + @PostMapping("/orders/carton-label-pdf") + @PreAuthorize("hasAuthority('merchant:order:view')") + public ResponseEntity printByOrders( + @AuthenticationPrincipal AuthenticatedUser user, + @RequestBody List orderIds, + @RequestHeader(value = "X-Request-Id", required = false) String requestId) { + return cartonLabelPrintService.generateByOrders( + FirstlegScopeGuard.merchant(user), orderIds, requestId); + } +} diff --git a/firstleg/src/main/java/com/baiyue/firstleg/domain/InboundProcessStatus.java b/firstleg/src/main/java/com/baiyue/firstleg/domain/InboundProcessStatus.java index c467933fe..469b4a156 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/domain/InboundProcessStatus.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/domain/InboundProcessStatus.java @@ -6,6 +6,8 @@ import java.util.Set; public final class InboundProcessStatus { /** 等待录入或接收测量数据。 */ public static final String WAIT_MEASURE = "WAIT_MEASURE"; + /** 草稿预入库:允许生成箱唛,但尚未进入正式测量流程。 */ + public static final String PREPARING = "PREPARING"; /** 已存在测量草稿、重量 OCR 或人工修正,尚未提交审核。 */ public static final String MEASURING = "MEASURING"; /** 测量已提交,等待平台审核。 */ @@ -30,7 +32,7 @@ public final class InboundProcessStatus { public static final String VOIDED = "VOIDED"; public static final Set VALUES = Set.of( - WAIT_MEASURE, MEASURING, MEASURE_PENDING, MEASURE_REJECTED, + PREPARING, WAIT_MEASURE, MEASURING, MEASURE_PENDING, MEASURE_REJECTED, WAIT_PRICING, FEE_CONFIRMING, INBOUNDING, WAIT_SPLIT, LOADING, LOADED, DELIVERED, VOIDED ); diff --git a/firstleg/src/main/java/com/baiyue/firstleg/dto/InboundOrderReference.java b/firstleg/src/main/java/com/baiyue/firstleg/dto/InboundOrderReference.java index 9ddb299f1..d308563a8 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/dto/InboundOrderReference.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/dto/InboundOrderReference.java @@ -17,4 +17,5 @@ public final class InboundOrderReference { public record Item(Long orderId, Long inboundId, String inboundCode, String processStatus) { } + } diff --git a/firstleg/src/main/java/com/baiyue/firstleg/entity/InboundCartonLabelEntity.java b/firstleg/src/main/java/com/baiyue/firstleg/entity/InboundCartonLabelEntity.java index 75cb1cc5b..bf0674011 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/entity/InboundCartonLabelEntity.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/entity/InboundCartonLabelEntity.java @@ -27,6 +27,10 @@ public class InboundCartonLabelEntity { private String boxCode; private Integer totalCartonCount; private Integer cartonIndex; + private Integer labelVersion; + private String batchStatus; + private String cargoFingerprint; + private LocalDateTime voidedAt; private String boxSpecification; private BigDecimal lengthCm; private BigDecimal widthCm; diff --git a/firstleg/src/main/java/com/baiyue/firstleg/mapper/LoadingCompletedEventMapper.java b/firstleg/src/main/java/com/baiyue/firstleg/mapper/LoadingCompletedEventMapper.java new file mode 100644 index 000000000..86735b3a3 --- /dev/null +++ b/firstleg/src/main/java/com/baiyue/firstleg/mapper/LoadingCompletedEventMapper.java @@ -0,0 +1,53 @@ +package com.baiyue.firstleg.mapper; + +import lombok.Getter; +import lombok.Setter; +import org.apache.ibatis.annotations.Mapper; +import org.apache.ibatis.annotations.Param; +import org.apache.ibatis.annotations.Select; + +import java.math.BigDecimal; +import java.util.List; + +/** 读取 PDA 结算后的最终实装入仓单和冻结货物快照。 */ +@Mapper +public interface LoadingCompletedEventMapper { + @Select(""" + SELECT p.id AS loading_plan_id, p.plan_no, p.container_booking_id, p.version AS aggregate_version, + b.container_no, b.cabinet_no, b.warehouse_id, b.warehouse_name, + i.account_id, i.inbound_id AS source_inbound_id, + COALESCE(o.root_inbound_id, o.id) AS root_inbound_id, + o.order_id AS source_order_id, o.order_no AS source_order_no, + COALESCE(root_order.order_id, o.order_id) AS root_order_id, + o.inbound_code, o.destination_warehouse_id, o.destination_code AS destination_warehouse_code, + o.destination_name AS destination_warehouse_name, + i.inbound_detail_id AS source_inbound_detail_id, i.sku_code, + i.product_name_zh, i.product_name_en, i.package_count, i.product_quantity, + i.pallet_count, i.gross_weight_kg, i.net_weight_kg, i.dimensional_weight_kg, + i.volume_cbm, i.fba_tracking_no, i.shipment_no, i.shipping_mark + FROM fl_loading_plan p + JOIN fl_container_booking b ON b.id = p.container_booking_id + JOIN fl_loading_plan_item i ON i.loading_plan_id = p.id AND i.deleted = 0 + JOIN fl_inbound_order o ON o.id = i.inbound_id AND o.tenant_id = i.account_id AND o.deleted = 0 + LEFT JOIN fl_inbound_order root_order ON root_order.id = COALESCE(o.root_inbound_id, o.id) + WHERE p.id = #{planId} AND p.plan_status = 'COMPLETED' + AND i.package_count > 0 + ORDER BY i.account_id, i.inbound_id, i.inbound_detail_id, i.id + """) + List rows(@Param("planId") long planId); + + @Getter @Setter + class Row { + private Long loadingPlanId; private String planNo; private Long containerBookingId; + private Integer aggregateVersion; private String containerNo; private String cabinetNo; + private Long warehouseId; private String warehouseName; private Long accountId; + private Long sourceInboundId; private Long rootInboundId; private Long sourceOrderId; + private String sourceOrderNo; private Long rootOrderId; private String inboundCode; + private Long destinationWarehouseId; private String destinationWarehouseCode; + private String destinationWarehouseName; private Long sourceInboundDetailId; private String skuCode; + private String productNameZh; private String productNameEn; private Integer packageCount; + private Integer productQuantity; private Integer palletCount; private BigDecimal grossWeightKg; + private BigDecimal netWeightKg; private BigDecimal dimensionalWeightKg; private BigDecimal volumeCbm; + private String fbaTrackingNo; private String shipmentNo; private String shippingMark; + } +} diff --git a/firstleg/src/main/java/com/baiyue/firstleg/mapper/LoadingPickItemMapper.java b/firstleg/src/main/java/com/baiyue/firstleg/mapper/LoadingPickItemMapper.java index 5f9b4f43e..ea943e0b9 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/mapper/LoadingPickItemMapper.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/mapper/LoadingPickItemMapper.java @@ -17,9 +17,10 @@ public interface LoadingPickItemMapper extends BaseMapper label.box_code AS boxCode FROM fl_loading_pick_item pick_item JOIN fl_loading_plan_item plan_item ON plan_item.id = pick_item.loading_plan_item_id - JOIN fl_inbound_carton_label label ON label.tenant_id = plan_item.account_id + JOIN fl_inbound_carton_label label ON label.tenant_id = plan_item.account_id AND label.inbound_id = plan_item.inbound_id AND label.inbound_detail_id = plan_item.inbound_detail_id + AND label.batch_status = 'ACTIVE' WHERE pick_item.pick_task_id = #{taskId} AND plan_item.deleted = 0 ORDER BY pick_item.id, label.carton_index """) diff --git a/firstleg/src/main/java/com/baiyue/firstleg/repository/InboundCartonLabelRepository.java b/firstleg/src/main/java/com/baiyue/firstleg/repository/InboundCartonLabelRepository.java index b7b8b8abe..2195c3364 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/repository/InboundCartonLabelRepository.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/repository/InboundCartonLabelRepository.java @@ -24,19 +24,63 @@ public class InboundCartonLabelRepository { .last("FOR UPDATE")); } - public List findByInbound(long tenantId, long inboundId) { + public InboundOrderEntity findByOrder(long tenantId, long orderId) { + return inbounds.selectOne(new LambdaQueryWrapper() + .eq(InboundOrderEntity::getTenantId, tenantId) + .eq(InboundOrderEntity::getOrderId, orderId) + .eq(InboundOrderEntity::getDeleted, false) + .orderByDesc(InboundOrderEntity::getCreatedAt) + .last("LIMIT 1")); + } + + public List findActiveByInbound(long tenantId, long inboundId) { return labels.selectList(new LambdaQueryWrapper() .eq(InboundCartonLabelEntity::getTenantId, tenantId) .eq(InboundCartonLabelEntity::getInboundId, inboundId) + .eq(InboundCartonLabelEntity::getBatchStatus, "ACTIVE") .orderByAsc(InboundCartonLabelEntity::getCartonIndex)); } + /** Compatibility for older callers; new code must use the ACTIVE-only query. */ + public List findByInbound(long tenantId, long inboundId) { + return findActiveByInbound(tenantId, inboundId); + } + public InboundCartonLabelEntity findByBoxCode(long tenantId, String boxCode) { return labels.selectOne(new LambdaQueryWrapper() .eq(InboundCartonLabelEntity::getTenantId, tenantId) .eq(InboundCartonLabelEntity::getBoxCode, boxCode)); } + public int nextVersion(long tenantId, long inboundId) { + InboundCartonLabelEntity latest = labels.selectOne(new LambdaQueryWrapper() + .select(InboundCartonLabelEntity::getLabelVersion) + .eq(InboundCartonLabelEntity::getTenantId, tenantId) + .eq(InboundCartonLabelEntity::getInboundId, inboundId) + .orderByDesc(InboundCartonLabelEntity::getLabelVersion) + .last("LIMIT 1")); + return latest == null || latest.getLabelVersion() == null ? 1 : latest.getLabelVersion() + 1; + } + + public void voidActive(long tenantId, long inboundId, java.time.LocalDateTime at) { + labels.update(null, new LambdaUpdateWrapper() + .eq(InboundCartonLabelEntity::getTenantId, tenantId) + .eq(InboundCartonLabelEntity::getInboundId, inboundId) + .eq(InboundCartonLabelEntity::getBatchStatus, "ACTIVE") + .set(InboundCartonLabelEntity::getBatchStatus, "VOID") + .set(InboundCartonLabelEntity::getVoidedAt, at)); + } + + public void initializeLegacyFingerprint(long tenantId, long inboundId, int version, String fingerprint) { + labels.update(null, new LambdaUpdateWrapper() + .eq(InboundCartonLabelEntity::getTenantId, tenantId) + .eq(InboundCartonLabelEntity::getInboundId, inboundId) + .eq(InboundCartonLabelEntity::getLabelVersion, version) + .eq(InboundCartonLabelEntity::getBatchStatus, "ACTIVE") + .isNull(InboundCartonLabelEntity::getCargoFingerprint) + .set(InboundCartonLabelEntity::getCargoFingerprint, fingerprint)); + } + public boolean initializeOriginalPackageCount(long tenantId, long inboundId, int packageCount) { return inbounds.update(null, new LambdaUpdateWrapper() .eq(InboundOrderEntity::getTenantId, tenantId) @@ -46,6 +90,14 @@ public class InboundCartonLabelRepository { .setSql("version = version + 1")) == 1; } + public void updatePackageCount(long tenantId, long inboundId, int packageCount) { + inbounds.update(null, new LambdaUpdateWrapper() + .eq(InboundOrderEntity::getTenantId, tenantId) + .eq(InboundOrderEntity::getId, inboundId) + .set(InboundOrderEntity::getTotalPackageCount, packageCount) + .setSql("version = version + 1")); + } + public void insert(InboundCartonLabelEntity label) { labels.insert(label); } diff --git a/firstleg/src/main/java/com/baiyue/firstleg/repository/LoadingCompletedOutboxRepository.java b/firstleg/src/main/java/com/baiyue/firstleg/repository/LoadingCompletedOutboxRepository.java new file mode 100644 index 000000000..a82cffec0 --- /dev/null +++ b/firstleg/src/main/java/com/baiyue/firstleg/repository/LoadingCompletedOutboxRepository.java @@ -0,0 +1,45 @@ +package com.baiyue.firstleg.repository; + +import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; +import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper; +import com.baiyue.common.event.EventTypes; +import com.baiyue.firstleg.entity.InboundOutboxEntity; +import com.baiyue.firstleg.mapper.InboundOutboxMapper; +import jakarta.annotation.Resource; +import org.springframework.stereotype.Repository; + +import java.time.LocalDateTime; +import java.util.List; + +@Repository +public class LoadingCompletedOutboxRepository { + @Resource private InboundOutboxMapper mapper; + + public List pending(int limit, LocalDateTime now) { + return mapper.selectList(new LambdaQueryWrapper() + .eq(InboundOutboxEntity::getEventType, EventTypes.FIRST_LEG_LOADING_PLAN_COMPLETED) + .eq(InboundOutboxEntity::getPublishStatus, "PENDING") + .and(v -> v.isNull(InboundOutboxEntity::getNextRetryAt).or().le(InboundOutboxEntity::getNextRetryAt, now)) + .orderByAsc(InboundOutboxEntity::getCreatedAt).orderByAsc(InboundOutboxEntity::getId) + .last("LIMIT " + Math.min(Math.max(limit, 1), 100))); + } + public boolean claim(long id, String worker, LocalDateTime now) { return mapper.update(null, base(id, "PENDING") + .set(InboundOutboxEntity::getPublishStatus, "PROCESSING").set(InboundOutboxEntity::getLockedBy, worker) + .set(InboundOutboxEntity::getLockedAt, now)) == 1; } + public InboundOutboxEntity claimed(long id, String worker) { return mapper.selectOne(new LambdaQueryWrapper() + .eq(InboundOutboxEntity::getId, id).eq(InboundOutboxEntity::getEventType, EventTypes.FIRST_LEG_LOADING_PLAN_COMPLETED) + .eq(InboundOutboxEntity::getPublishStatus, "PROCESSING").eq(InboundOutboxEntity::getLockedBy, worker)); } + public void published(long id, String worker, LocalDateTime now) { mapper.update(null, claimedBase(id, worker) + .set(InboundOutboxEntity::getPublishStatus, "PUBLISHED").set(InboundOutboxEntity::getPublishedAt, now) + .set(InboundOutboxEntity::getLockedBy, null).set(InboundOutboxEntity::getLockedAt, null) + .set(InboundOutboxEntity::getLastError, null)); } + public void retry(long id, String worker, LocalDateTime retryAt, String error) { mapper.update(null, claimedBase(id, worker) + .setSql("retry_count = retry_count + 1").set(InboundOutboxEntity::getPublishStatus, "PENDING") + .set(InboundOutboxEntity::getNextRetryAt, retryAt).set(InboundOutboxEntity::getLockedBy, null) + .set(InboundOutboxEntity::getLockedAt, null).set(InboundOutboxEntity::getLastError, truncate(error))); } + private LambdaUpdateWrapper base(long id, String status) { return new LambdaUpdateWrapper() + .eq(InboundOutboxEntity::getId,id).eq(InboundOutboxEntity::getEventType,EventTypes.FIRST_LEG_LOADING_PLAN_COMPLETED) + .eq(InboundOutboxEntity::getPublishStatus,status); } + private LambdaUpdateWrapper claimedBase(long id,String worker) { return base(id,"PROCESSING").eq(InboundOutboxEntity::getLockedBy,worker); } + private static String truncate(String value) { if(value==null)return "unknown"; String v=value.replaceAll("[\\r\\n\\t]+"," "); return v.length()>500?v.substring(0,500):v; } +} diff --git a/firstleg/src/main/java/com/baiyue/firstleg/service/InboundCartonLabelPrintService.java b/firstleg/src/main/java/com/baiyue/firstleg/service/InboundCartonLabelPrintService.java index 0cb143607..eaef51764 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/service/InboundCartonLabelPrintService.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/service/InboundCartonLabelPrintService.java @@ -9,6 +9,7 @@ import com.fasterxml.jackson.core.type.TypeReference; import com.fasterxml.jackson.databind.ObjectMapper; import feign.FeignException; import org.springframework.beans.factory.annotation.Value; +import org.springframework.cloud.client.circuitbreaker.NoFallbackAvailableException; import org.springframework.http.HttpHeaders; import org.springframework.http.MediaType; import org.springframework.http.ResponseEntity; @@ -17,6 +18,7 @@ import org.springframework.util.StringUtils; import java.math.BigDecimal; import java.util.List; +import java.util.ArrayList; /** Allocates stable carton codes and forwards immutable label facts to Print. */ @Service @@ -50,7 +52,53 @@ public class InboundCartonLabelPrintService { batch.labels().stream().map(this::toPrintLabel).toList()); try { return requirePdf(printClient.generateInboundMarks(internalToken, requestId, request)); - } catch (FeignException exception) { + } catch (FeignException | NoFallbackAvailableException exception) { + throw new BizException(ResultCode.SERVICE_UNAVAILABLE, "打印服务暂不可用,请稍后重试"); + } + } + + public ResponseEntity generateByOrder(long tenantId, long orderId, + List cartonIndexes, String requestId) { + return generate(tenantId, labelService.requireInboundIdByOrder(tenantId, orderId), + cartonIndexes, requestId); + } + + public ResponseEntity generateByOrders(long tenantId, List orderIds, String requestId) { + if (orderIds == null || orderIds.isEmpty() || orderIds.size() > 200 + || orderIds.stream().anyMatch(id -> id == null || id <= 0)) { + throw new BizException(ResultCode.BAD_REQUEST, "订单ID数组必须包含1至200个有效值"); + } + List labels = new ArrayList<>(); + List failures = new ArrayList<>(); + for (Long orderId : orderIds.stream().distinct().toList()) { + try { + long inboundId = labelService.requireInboundIdByOrder(tenantId, orderId); + labels.addAll(labelService.list(tenantId, inboundId, null).labels()); + } catch (BizException exception) { + failures.add(orderId + ":" + exception.getResultCode().code()); + } + } + if (labels.isEmpty()) { + throw new BizException(ResultCode.VALIDATION_FAILED, + failures.isEmpty() ? "没有可生成箱唛的订单" : "所选订单均无法生成箱唛:" + String.join(",", failures)); + } + ResponseEntity response = generatePdf("carton-labels-" + labels.size() + ".pdf", labels, requestId); + HttpHeaders headers = new HttpHeaders(); + headers.putAll(response.getHeaders()); + headers.set("X-Carton-Label-Order-Count", String.valueOf(orderIds.stream().distinct().count())); + headers.set("X-Carton-Label-Failure-Count", String.valueOf(failures.size())); + if (!failures.isEmpty()) headers.set("X-Carton-Label-Failures", String.join(",", failures)); + return ResponseEntity.status(response.getStatusCode()).headers(headers).body(response.getBody()); + } + + private ResponseEntity generatePdf(String fileName, List labels, + String requestId) { + PrintPdfClient.InboundMarkPdfRequest request = new PrintPdfClient.InboundMarkPdfRequest( + "INBOUND_MARK_V1", new PrintPdfClient.PageSpec(PAGE_MM, PAGE_MM), fileName, + labels.stream().map(this::toPrintLabel).toList()); + try { + return requirePdf(printClient.generateInboundMarks(internalToken, requestId, request)); + } catch (FeignException | NoFallbackAvailableException exception) { throw new BizException(ResultCode.SERVICE_UNAVAILABLE, "打印服务暂不可用,请稍后重试"); } } diff --git a/firstleg/src/main/java/com/baiyue/firstleg/service/InboundCartonLabelService.java b/firstleg/src/main/java/com/baiyue/firstleg/service/InboundCartonLabelService.java index 73c1c9ea9..3dafa0a89 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/service/InboundCartonLabelService.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/service/InboundCartonLabelService.java @@ -22,16 +22,20 @@ import java.util.ArrayList; import java.util.HashSet; import java.util.List; import java.util.Set; +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; /** 首次生成后不可变的箱号分配;重打和测量解析只读取该映射。 */ @Service public class InboundCartonLabelService implements CartonLabelBindingResolver { private static final int MAX_LABELS = 2000; private static final int MAX_QR_CODE_POINTS = 34; - private static final Set ALLOCATABLE_STATUSES = Set.of("WAIT_MEASURE", "MEASURING"); + private static final Set ALLOCATABLE_STATUSES = Set.of("PREPARING", "WAIT_MEASURE", "MEASURING"); @Resource private InboundCartonLabelRepository repository; @Resource private OrderCargoClient orderCargoClient; + @Resource private InboundOrderCreationService inboundOrderCreationService; @Value("${baiyue.security.internal-service-token}") private String internalToken; @Transactional @@ -40,23 +44,32 @@ public class InboundCartonLabelService implements CartonLabelBindingResolver { InboundOrderEntity inbound = repository.lockInbound(tenantId, inboundId); if (inbound == null) throw new BizException(ResultCode.NOT_FOUND, "入库单不存在"); - List existing = new ArrayList<>(repository.findByInbound(tenantId, inboundId)); - if (!existing.isEmpty()) { - List consistent = requireConsistent(existing); - freezeOriginalPackageCount(inbound, consistent.get(0).getTotalCartonCount()); - return batch(inbound, consistent); - } - if (!ALLOCATABLE_STATUSES.contains(inbound.getProcessStatus())) { + List existing = new ArrayList<>(activeLabels(tenantId, inboundId)); + List consistent = existing.isEmpty() ? List.of() : requireConsistent(existing); + if (existing.isEmpty() && !ALLOCATABLE_STATUSES.contains(inbound.getProcessStatus())) { throw new BizException(ResultCode.CONFLICT, "当前入库单状态不允许首次生成箱号"); } - List cargoes = loadCargoes(inbound); + String fingerprint = fingerprint(cargoes); + if (!existing.isEmpty()) { + String existingFingerprint = consistent.get(0).getCargoFingerprint(); + if (existingFingerprint == null) { + repository.initializeLegacyFingerprint(tenantId, inboundId, version(consistent.get(0)), fingerprint); + consistent.forEach(label -> label.setCargoFingerprint(fingerprint)); + return batch(inbound, consistent); + } + if (existingFingerprint.equals(fingerprint)) return batch(inbound, consistent); + repository.voidActive(tenantId, inboundId, LocalDateTime.now()); + existing.clear(); + } int total = cargoes.stream().mapToInt(this::requirePackageCount).sum(); if (total < 1 || total > MAX_LABELS) { throw new BizException(ResultCode.VALIDATION_FAILED, "唛头箱数必须为1至2000箱"); } - freezeOriginalPackageCount(inbound, total); - requirePrintableInboundCode(inbound.getInboundCode(), total); + repository.updatePackageCount(tenantId, inboundId, total); + inbound.setTotalPackageCount(total); + int labelVersion = repository.nextVersion(tenantId, inboundId); + requirePrintableInboundCode(inbound.getInboundCode(), total, labelVersion); int cartonIndex = 0; LocalDateTime now = LocalDateTime.now(); @@ -64,7 +77,7 @@ public class InboundCartonLabelService implements CartonLabelBindingResolver { int detailCount = requirePackageCount(cargo); for (int offset = 0; offset < detailCount; offset++) { cartonIndex++; - InboundCartonLabelEntity label = label(inbound, cargo, total, cartonIndex, now); + InboundCartonLabelEntity label = label(inbound, cargo, total, cartonIndex, labelVersion, fingerprint, now); repository.insert(label); existing.add(label); } @@ -72,6 +85,31 @@ public class InboundCartonLabelService implements CartonLabelBindingResolver { return batch(inbound, List.copyOf(existing)); } + public long requireInboundIdByOrder(long tenantId, long orderId) { + requireIds(tenantId, orderId); + InboundOrderEntity inbound = repository.findByOrder(tenantId, orderId); + if (inbound == null) inbound = prepareMissingInbound(tenantId, orderId); + return inbound.getId(); + } + + private InboundOrderEntity prepareMissingInbound(long tenantId, long orderId) { + try { + ApiResponse response = + orderCargoClient.inboundSnapshot(internalToken, tenantId, orderId); + if (response == null || !response.successful() || response.data() == null) { + throw new BizException(ResultCode.NOT_FOUND, "订单不存在或无法生成预入库单"); + } + inboundOrderCreationService.prepareFromDraft(response.data()); + InboundOrderEntity prepared = repository.findByOrder(tenantId, orderId); + if (prepared == null) { + throw new BizException(ResultCode.INTERNAL_ERROR, "预入库单补建后未能读取"); + } + return prepared; + } catch (FeignException exception) { + throw new BizException(ResultCode.SERVICE_UNAVAILABLE, "订单服务暂不可用,无法补建预入库单"); + } + } + @Transactional public InboundCartonLabels.Batch list(long tenantId, long inboundId, List cartonIndexes) { InboundCartonLabels.Batch batch = getOrCreate(tenantId, inboundId); @@ -109,6 +147,9 @@ public class InboundCartonLabelService implements CartonLabelBindingResolver { } InboundCartonLabelEntity label = repository.findByBoxCode(tenantId, boxCode.trim()); if (label == null) throw new BizException(ResultCode.NOT_FOUND, "箱号不存在"); + if ("VOID".equals(label.getBatchStatus())) { + throw new BizException(ResultCode.CONFLICT, "箱唛版本已作废,请重新打印"); + } return label; } @@ -141,33 +182,20 @@ public class InboundCartonLabelService implements CartonLabelBindingResolver { return cargo.packageCount(); } - private void requirePrintableInboundCode(String inboundCode, int total) { + private void requirePrintableInboundCode(String inboundCode, int total, int version) { if (!StringUtils.hasText(inboundCode) || !inboundCode.matches("[A-Za-z0-9._:-]+")) { throw new BizException(ResultCode.VALIDATION_FAILED, "入仓码不能生成可打印二维码"); } - String longest = inboundCode + "#" + total + "-" + total; + String longest = inboundCode + "#V" + version + "#" + total + "-" + total; if (longest.codePointCount(0, longest.length()) > MAX_QR_CODE_POINTS) { throw new BizException(ResultCode.VALIDATION_FAILED, "入仓码和箱号组合超过二维码长度限制"); } } - private void freezeOriginalPackageCount(InboundOrderEntity inbound, int total) { - Integer original = inbound.getTotalPackageCount(); - if (original != null && original != total) { - throw new BizException(ResultCode.CONFLICT, "订单总箱数与已冻结箱号数量不一致"); - } - if (original == null) { - if (!repository.initializeOriginalPackageCount(inbound.getTenantId(), inbound.getId(), total)) { - throw new BizException(ResultCode.CONFLICT, "订单总箱数已变化,请刷新后重试"); - } - inbound.setTotalPackageCount(total); - inbound.setVersion(inbound.getVersion() == null ? 1 : inbound.getVersion() + 1); - } - } - private InboundCartonLabelEntity label(InboundOrderEntity inbound, OrderCargoClient.CargoLabelBinding cargo, - int total, int index, LocalDateTime now) { + int total, int index, int labelVersion, String fingerprint, + LocalDateTime now) { InboundCartonLabelEntity label = new InboundCartonLabelEntity(); label.setId(IdWorker.getId()); label.setTenantId(inbound.getTenantId()); @@ -178,9 +206,12 @@ public class InboundCartonLabelService implements CartonLabelBindingResolver { label.setCargoName(cargo.cargoName()); label.setDetailPackageCount(cargo.packageCount()); label.setInboundCode(inbound.getInboundCode()); - label.setBoxCode(inbound.getInboundCode() + "#" + total + "-" + index); + label.setBoxCode(inbound.getInboundCode() + "#V" + labelVersion + "#" + total + "-" + index); label.setTotalCartonCount(total); label.setCartonIndex(index); + label.setLabelVersion(labelVersion); + label.setBatchStatus("ACTIVE"); + label.setCargoFingerprint(fingerprint); label.setLengthCm(cargo.lengthCm()); label.setWidthCm(cargo.widthCm()); label.setHeightCm(cargo.heightCm()); @@ -203,14 +234,45 @@ public class InboundCartonLabelService implements CartonLabelBindingResolver { private List requireConsistent(List labels) { int total = labels.get(0).getTotalCartonCount(); + int version = version(labels.get(0)); if (total != labels.size()) throw corrupt(); for (int index = 0; index < labels.size(); index++) { InboundCartonLabelEntity label = labels.get(index); - if (label.getTotalCartonCount() != total || label.getCartonIndex() != index + 1) throw corrupt(); + if (label.getTotalCartonCount() != total || label.getCartonIndex() != index + 1 + || version(label) != version || "VOID".equals(label.getBatchStatus())) throw corrupt(); } return labels; } + private int version(InboundCartonLabelEntity label) { + return label.getLabelVersion() == null ? 1 : label.getLabelVersion(); + } + + private List activeLabels(long tenantId, long inboundId) { + List result = repository.findActiveByInbound(tenantId, inboundId); + if (result != null && !result.isEmpty()) return result; + return repository.findByInbound(tenantId, inboundId).stream() + .filter(label -> label.getBatchStatus() == null || "ACTIVE".equals(label.getBatchStatus())) + .toList(); + } + + private String fingerprint(List cargoes) { + String source = cargoes.stream().map(cargo -> String.join("|", String.valueOf(cargo.itemNo()), + cargo.cargoName() == null ? "" : cargo.cargoName().trim(), String.valueOf(cargo.packageCount()), + decimalOrEmpty(cargo.lengthCm()), decimalOrEmpty(cargo.widthCm()), decimalOrEmpty(cargo.heightCm()))) + .collect(java.util.stream.Collectors.joining("\n")); + try { + byte[] digest = MessageDigest.getInstance("SHA-256").digest(source.getBytes(StandardCharsets.UTF_8)); + StringBuilder value = new StringBuilder(64); + for (byte item : digest) value.append(String.format("%02x", item)); + return value.toString(); + } catch (NoSuchAlgorithmException exception) { + throw new BizException(ResultCode.INTERNAL_ERROR, "无法生成箱唛货物指纹"); + } + } + + private String decimalOrEmpty(BigDecimal value) { return value == null ? "" : decimal(value); } + private BizException corrupt() { return new BizException(ResultCode.INTERNAL_ERROR, "入库单箱号映射不完整,请联系管理员"); } diff --git a/firstleg/src/main/java/com/baiyue/firstleg/service/InboundOrderCreationService.java b/firstleg/src/main/java/com/baiyue/firstleg/service/InboundOrderCreationService.java index 2045c20ad..7a1ba5a1a 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/service/InboundOrderCreationService.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/service/InboundOrderCreationService.java @@ -36,6 +36,7 @@ public class InboundOrderCreationService { long inboundId = requireMatchingInboundCode(existing, command.inboundCode()); FulfillmentTaskEntity task = fulfillmentTasks.provision(command, LocalDateTime.now()); linkTask(existing, task.getId()); + upgradePreparing(existing, command); return inboundId; } @@ -107,6 +108,146 @@ public class InboundOrderCreationService { return inbound.getId(); } + /** + * Creates the real first-leg aggregate used by draft carton labels. It is + * deliberately not a fulfillment task yet; approval upgrades the same row + * in-place so the inbound id printed on labels never changes. + */ + @Transactional + public long prepareFromDraft(OrderApprovedInboundCommand command) { + InboundOrderEntity existing = findByOrder(command.tenantId(), command.orderId(), false); + if (existing != null) { + long inboundId = requireMatchingInboundCode(existing, command.inboundCode()); + refreshPreparing(existing, command); + return inboundId; + } + + LocalDateTime now = LocalDateTime.now(); + InboundOrderEntity inbound = newInbound(command, now, null, "PREPARING", "DRAFT"); + try { + orders.insert(inbound); + } catch (DuplicateKeyException duplicate) { + InboundOrderEntity winner = findByOrder(command.tenantId(), command.orderId(), true); + if (winner == null) { + throw new BizException(ResultCode.CONFLICT, "入仓码已被其他订单使用"); + } + return requireMatchingInboundCode(winner, command.inboundCode()); + } + addHistory(inbound.getId(), command.tenantId(), null, "PREPARING", "ORDER_DRAFT_PREPARED", now); + return inbound.getId(); + } + + private void refreshPreparing(InboundOrderEntity existing, OrderApprovedInboundCommand command) { + if (!"PREPARING".equals(existing.getProcessStatus())) return; + orders.update(null, new com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper() + .eq(InboundOrderEntity::getTenantId, existing.getTenantId()) + .eq(InboundOrderEntity::getId, existing.getId()) + .eq(InboundOrderEntity::getProcessStatus, "PREPARING") + .set(InboundOrderEntity::getOrderNo, command.orderNo()) + .set(InboundOrderEntity::getCustomerId, command.customerId()) + .set(InboundOrderEntity::getCustomerName, command.customerName()) + .set(InboundOrderEntity::getWarehouseId, command.warehouseId()) + .set(InboundOrderEntity::getWarehouseName, command.warehouseName()) + .set(InboundOrderEntity::getDeliveryWarehouseId, command.deliveryWarehouseId()) + .set(InboundOrderEntity::getDeliveryWarehouseName, command.deliveryWarehouseName()) + .set(InboundOrderEntity::getExpectedArrivalAt, command.expectedArrivalAt()) + .set(InboundOrderEntity::getBusinessType, command.businessType()) + .set(InboundOrderEntity::getChannelCode, command.channelCode()) + .set(InboundOrderEntity::getChannelName, command.channelName()) + .set(InboundOrderEntity::getDestinationType, command.destinationType()) + .set(InboundOrderEntity::getDestinationWarehouseId, command.destinationWarehouseId()) + .set(InboundOrderEntity::getDestinationCode, command.destinationCode()) + .set(InboundOrderEntity::getDestinationName, command.destinationName()) + .set(InboundOrderEntity::getPublicRemark, command.publicRemark()) + .set(InboundOrderEntity::getTotalPackageCount, command.totalPackageCount()) + .set(InboundOrderEntity::getUpdatedAt, LocalDateTime.now()) + .setSql("version = version + 1")); + } + + private void upgradePreparing(InboundOrderEntity existing, OrderApprovedInboundCommand command) { + if (!"PREPARING".equals(existing.getProcessStatus())) return; + var update = new com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper() + .eq(InboundOrderEntity::getTenantId, existing.getTenantId()) + .eq(InboundOrderEntity::getId, existing.getId()) + .eq(InboundOrderEntity::getProcessStatus, "PREPARING") + .set(InboundOrderEntity::getProcessStatus, "WAIT_MEASURE") + .set(InboundOrderEntity::getOrderStatusSnapshot, "APPROVED") + .set(InboundOrderEntity::getTotalPackageCount, command.totalPackageCount()) + .set(InboundOrderEntity::getUpdatedAt, LocalDateTime.now()) + .setSql("version = version + 1"); + if (orders.update(null, update) != 1) { + InboundOrderEntity current = findByOrder(existing.getTenantId(), existing.getOrderId(), true); + if (current == null || !"WAIT_MEASURE".equals(current.getProcessStatus())) { + throw new BizException(ResultCode.CONFLICT, "预入库单审核升级发生并发冲突"); + } + return; + } + addHistory(existing.getId(), existing.getTenantId(), "PREPARING", "WAIT_MEASURE", + "ORDER_APPROVED", LocalDateTime.now()); + } + + private InboundOrderEntity newInbound(OrderApprovedInboundCommand command, LocalDateTime now, + Long taskId, String processStatus, String orderStatus) { + InboundOrderEntity inbound = new InboundOrderEntity(); + inbound.setId(IdWorker.getId()); + inbound.setTenantId(command.tenantId()); + inbound.setTaskId(taskId); + inbound.setOrderId(command.orderId()); + inbound.setOrderNo(command.orderNo()); + inbound.setInboundCode(command.inboundCode()); + inbound.setProcessStatus(processStatus); + inbound.setOrderStatusSnapshot(orderStatus); + inbound.setMeasureReviewStatus("NOT_SUBMITTED"); + inbound.setFeeStatus("UNPRICED"); + inbound.setFeeReviewStatus("NOT_SUBMITTED"); + inbound.setNotificationStatus("NOT_CREATED"); + inbound.setExpectedArrivalAt(command.expectedArrivalAt()); + inbound.setCustomerId(command.customerId()); + inbound.setCustomerName(command.customerName()); + inbound.setWarehouseId(command.warehouseId()); + inbound.setWarehouseName(command.warehouseName()); + inbound.setDeliveryWarehouseId(command.deliveryWarehouseId()); + inbound.setDeliveryWarehouseName(command.deliveryWarehouseName()); + inbound.setBusinessType(command.businessType()); + inbound.setChannelCode(command.channelCode()); + inbound.setChannelName(command.channelName()); + inbound.setCarrierId(command.carrierId()); + inbound.setCarrierName(command.carrierName()); + inbound.setTaxMode(command.taxMode()); + inbound.setPricingTaxMode(command.pricingTaxMode()); + inbound.setContainerMode(command.containerMode()); + inbound.setCustomsInspection(command.customsInspection()); + inbound.setDestinationType(command.destinationType()); + inbound.setDestinationWarehouseId(command.destinationWarehouseId()); + inbound.setDestinationCode(command.destinationCode()); + inbound.setDestinationName(command.destinationName()); + inbound.setExpressCompanyName(command.expressCompanyName()); + inbound.setExpressTrackingNo(command.expressTrackingNo()); + inbound.setPublicRemark(command.publicRemark()); + inbound.setCreatedBy(0L); + inbound.setUpdatedBy(0L); + inbound.setCreatedAt(now); + inbound.setUpdatedAt(now); + inbound.setDeleted(false); + inbound.setVersion(0); + return inbound; + } + + private void addHistory(long inboundId, long tenantId, String from, String to, + String action, LocalDateTime now) { + InboundStatusHistoryEntity history = new InboundStatusHistoryEntity(); + history.setId(IdWorker.getId()); + history.setTenantId(tenantId); + history.setInboundId(inboundId); + history.setFromStatus(from); + history.setToStatus(to); + history.setAction(action); + history.setOperatorId(0L); + history.setOperatorName("订单服务"); + history.setOccurredAt(now); + histories.insert(history); + } + private InboundOrderEntity findByOrder(long tenantId, long orderId, boolean currentRead) { var query = new com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper() .eq(InboundOrderEntity::getTenantId, tenantId) diff --git a/firstleg/src/main/java/com/baiyue/firstleg/service/LoadingCompletedOutboxRelay.java b/firstleg/src/main/java/com/baiyue/firstleg/service/LoadingCompletedOutboxRelay.java new file mode 100644 index 000000000..bc3052d4a --- /dev/null +++ b/firstleg/src/main/java/com/baiyue/firstleg/service/LoadingCompletedOutboxRelay.java @@ -0,0 +1,47 @@ +package com.baiyue.firstleg.service; + +import com.baiyue.common.event.EventEnvelope; +import com.baiyue.common.event.EventEnvelopeValidator; +import com.baiyue.firstleg.client.EventBridgeClient; +import com.baiyue.firstleg.entity.InboundOutboxEntity; +import com.baiyue.firstleg.repository.LoadingCompletedOutboxRepository; +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.scheduling.annotation.Scheduled; +import org.springframework.stereotype.Component; + +import java.time.LocalDateTime; +import java.util.UUID; + +/** 至少一次投递装柜完成跨区事件;事件桥按 eventId 幂等接收。 */ +@Component @Slf4j +public class LoadingCompletedOutboxRelay { + @Resource private LoadingCompletedOutboxRepository repository; + @Resource private EventBridgeClient client; + @Resource private ObjectMapper objectMapper; + @Value("${baiyue.security.internal-service-token}") private String internalToken; + private final String workerId = UUID.randomUUID().toString(); + + @Scheduled(fixedDelayString = "${baiyue.lastleg.outbox-relay.fixed-delay-ms:5000}") + public void relay() { + LocalDateTime now=LocalDateTime.now(); + for (InboundOutboxEntity candidate:repository.pending(50,now)) { + if(!repository.claim(candidate.getId(),workerId,now)) continue; + InboundOutboxEntity row=repository.claimed(candidate.getId(),workerId); + if(row==null) continue; + try { + EventEnvelope event=objectMapper.readValue(row.getPayloadJson(),EventEnvelope.class); + EventEnvelopeValidator.requireValid(event); + var response=client.receive(internalToken,event); + if(response==null || !response.successful() || response.data()==null + || !event.eventId().equals(response.data().eventId())) throw new IllegalStateException("事件桥未确认装柜完成事件"); + repository.published(row.getId(),workerId,LocalDateTime.now()); + } catch(Exception exception) { + repository.retry(row.getId(),workerId,LocalDateTime.now().plusSeconds(30),exception.getMessage()); + log.warn("装柜完成事件投递失败: eventKey={}, reason={}",row.getEventKey(),exception.getMessage()); + } + } + } +} diff --git a/firstleg/src/main/java/com/baiyue/firstleg/service/LoadingCompletedOutboxService.java b/firstleg/src/main/java/com/baiyue/firstleg/service/LoadingCompletedOutboxService.java new file mode 100644 index 000000000..396341a2b --- /dev/null +++ b/firstleg/src/main/java/com/baiyue/firstleg/service/LoadingCompletedOutboxService.java @@ -0,0 +1,103 @@ +package com.baiyue.firstleg.service; + +import com.baomidou.mybatisplus.core.toolkit.IdWorker; +import com.baiyue.common.core.api.ResultCode; +import com.baiyue.common.core.exception.BizException; +import com.baiyue.common.event.EventEnvelope; +import com.baiyue.common.event.EventIdentifiers; +import com.baiyue.common.event.EventPayloadCanonicalizer; +import com.baiyue.common.event.EventPayloadHash; +import com.baiyue.common.event.EventTypes; +import com.baiyue.common.event.FirstLegLoadingPlanCompletedV1; +import com.baiyue.common.event.RegionCode; +import com.baiyue.firstleg.entity.InboundOutboxEntity; +import com.baiyue.firstleg.mapper.InboundOutboxMapper; +import com.baiyue.firstleg.mapper.LoadingCompletedEventMapper; +import com.baiyue.firstleg.mapper.LoadingCompletedEventMapper.Row; +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import jakarta.annotation.Resource; +import org.springframework.dao.DuplicateKeyException; +import org.springframework.stereotype.Service; + +import java.math.BigDecimal; +import java.time.Instant; +import java.time.LocalDateTime; +import java.util.ArrayList; +import java.util.Comparator; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +/** 在装柜完成事务内按客户写跨区业务 Outbox。 */ +@Service +public class LoadingCompletedOutboxService { + private static final long HANDOVER_VERSION = 1L; + @Resource private LoadingCompletedEventMapper snapshotMapper; + @Resource private InboundOutboxMapper outboxMapper; + @Resource private ObjectMapper objectMapper; + + public void publish(long planId) { + List rows = snapshotMapper.rows(planId); + if (rows.isEmpty()) throw new BizException(ResultCode.CONFLICT, "装柜计划没有最终实装入仓单,不能生成尾程交接事件"); + rows.stream().collect(java.util.stream.Collectors.groupingBy(Row::getAccountId, + java.util.TreeMap::new, java.util.stream.Collectors.toList())) + .forEach((accountId, accountRows) -> insert(envelope(accountId, accountRows))); + } + + private EventEnvelope envelope(long accountId, List rows) { + Row first = rows.get(0); + Map> byInbound = rows.stream().collect(java.util.stream.Collectors.groupingBy( + Row::getSourceInboundId, java.util.TreeMap::new, java.util.stream.Collectors.toList())); + List orders = byInbound.values().stream() + .map(this::order).toList(); + var payload = new FirstLegLoadingPlanCompletedV1(String.valueOf(first.getLoadingPlanId()), + first.getPlanNo(), string(first.getContainerBookingId()), first.getContainerNo(), first.getCabinetNo(), + string(first.getWarehouseId()), first.getWarehouseName(), orders); + JsonNode payloadNode = objectMapper.valueToTree(payload); + String hash = EventPayloadHash.sha256(EventPayloadCanonicalizer.canonicalize(payloadNode)); + String eventKey = "FIRSTLEG_LOADING_COMPLETED:" + first.getLoadingPlanId() + ':' + accountId + ':' + HANDOVER_VERSION; + String traceId = EventIdentifiers.newUuid(); + return new EventEnvelope(EventIdentifiers.newUuid(), EventTypes.FIRST_LEG_LOADING_PLAN_COMPLETED, 1, + Instant.now(), RegionCode.CN, RegionCode.OVERSEAS, String.valueOf(accountId), "LOADING_PLAN", + String.valueOf(first.getLoadingPlanId()), first.getAggregateVersion(), HANDOVER_VERSION, + traceId, eventKey, null, payloadNode, hash).requireValid(); + } + + private FirstLegLoadingPlanCompletedV1.Order order(List values) { + List sorted = values.stream().sorted(Comparator.comparing(Row::getSourceInboundDetailId)).toList(); + Row first = sorted.get(0); + List items = sorted.stream().map(row -> + new FirstLegLoadingPlanCompletedV1.Item(string(row.getSourceInboundDetailId()), row.getSkuCode(), + row.getProductNameZh(), row.getProductNameEn(), row.getPackageCount(), row.getProductQuantity(), + row.getPalletCount(), row.getGrossWeightKg(), row.getNetWeightKg(), row.getDimensionalWeightKg(), + row.getVolumeCbm(), string(row.getDestinationWarehouseId()), row.getDestinationWarehouseCode(), + row.getDestinationWarehouseName(), row.getFbaTrackingNo(), row.getShipmentNo(), row.getShippingMark())) + .toList(); + return new FirstLegLoadingPlanCompletedV1.Order(string(first.getSourceInboundId()), + string(first.getRootInboundId()), string(first.getSourceOrderId()), first.getSourceOrderNo(), + string(first.getRootOrderId()), first.getInboundCode(), string(first.getDestinationWarehouseId()), + first.getDestinationWarehouseCode(), first.getDestinationWarehouseName(), sumInt(sorted, Row::getPackageCount), + sumNullableInt(sorted, Row::getProductQuantity), sumNullableInt(sorted, Row::getPalletCount), + sum(sorted, Row::getGrossWeightKg), sum(sorted, Row::getNetWeightKg), sum(sorted, Row::getVolumeCbm), items); + } + + private void insert(EventEnvelope envelope) { + InboundOutboxEntity value = new InboundOutboxEntity(); + value.setId(IdWorker.getId()); value.setTenantId(Long.valueOf(envelope.tenantId())); + value.setAggregateType(envelope.aggregateType()); value.setAggregateId(Long.valueOf(envelope.aggregateId())); + value.setEventType(envelope.eventType()); value.setEventKey(envelope.correlationId()); + value.setEventVersion(envelope.eventVersion()); value.setAggregateSequence((int) envelope.aggregateVersion()); + value.setPayloadHash(envelope.payloadHash()); value.setPayloadJson(json(envelope)); + value.setPublishStatus("PENDING"); value.setRetryCount(0); value.setCreatedAt(LocalDateTime.now()); + try { outboxMapper.insert(value); } catch (DuplicateKeyException ignored) { } + } + + private String json(Object value) { try { return objectMapper.writeValueAsString(value); } + catch (JsonProcessingException e) { throw new BizException(ResultCode.INTERNAL_ERROR, "装柜完成事件序列化失败"); } } + private static String string(Long value) { return value == null ? null : String.valueOf(value); } + private static int sumInt(List rows, java.util.function.Function getter) { return rows.stream().map(getter).filter(java.util.Objects::nonNull).mapToInt(Integer::intValue).sum(); } + private static Integer sumNullableInt(List rows, java.util.function.Function getter) { List values=rows.stream().map(getter).filter(java.util.Objects::nonNull).toList(); return values.isEmpty()?null:values.stream().mapToInt(Integer::intValue).sum(); } + private static BigDecimal sum(List rows, java.util.function.Function getter) { List values=rows.stream().map(getter).filter(java.util.Objects::nonNull).toList(); return values.isEmpty()?null:values.stream().reduce(BigDecimal.ZERO, BigDecimal::add); } +} diff --git a/firstleg/src/main/java/com/baiyue/firstleg/service/LoadingListService.java b/firstleg/src/main/java/com/baiyue/firstleg/service/LoadingListService.java index ba721690d..c70c01d36 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/service/LoadingListService.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/service/LoadingListService.java @@ -57,6 +57,8 @@ public class LoadingListService { @Resource private LoadingPickRepository pickRepository; @Resource + private LoadingCompletedOutboxService loadingCompletedOutboxService; + @Resource private ObjectMapper objectMapper; @Resource private LoadingDocumentFileClient documentFiles; @@ -455,6 +457,7 @@ public class LoadingListService { json(values("source", "PDA", "pickTaskId", pickTaskId, "status", "COMPLETED", "version", before.getVersion() + 1)), operatorId, trim(operatorName), "PDA已人工确认装柜完成"); + loadingCompletedOutboxService.publish(planId); } private AggregateRow requirePlan(long planId) { diff --git a/firstleg/src/main/resources/db/phase2-carton-label-versioning-migration.sql b/firstleg/src/main/resources/db/phase2-carton-label-versioning-migration.sql new file mode 100644 index 000000000..789afb4ef --- /dev/null +++ b/firstleg/src/main/resources/db/phase2-carton-label-versioning-migration.sql @@ -0,0 +1,55 @@ +SET NAMES utf8mb4; +USE baiyue_firstleg; + +DROP PROCEDURE IF EXISTS phase2_carton_label_add_column; +DELIMITER $$ +CREATE PROCEDURE phase2_carton_label_add_column(IN p_column VARCHAR(64), IN p_definition TEXT) +BEGIN + IF NOT EXISTS (SELECT 1 FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = DATABASE() + AND TABLE_NAME = 'fl_inbound_carton_label' AND COLUMN_NAME = p_column) THEN + SET @sql = CONCAT('ALTER TABLE fl_inbound_carton_label ADD COLUMN ', p_column, ' ', p_definition); + PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt; + END IF; +END$$ +DELIMITER ; +CALL phase2_carton_label_add_column('label_version', 'INT UNSIGNED NOT NULL DEFAULT 1 COMMENT ''箱唛批次版本,从1递增'' AFTER carton_index'); +CALL phase2_carton_label_add_column('batch_status', 'VARCHAR(16) NOT NULL DEFAULT ''ACTIVE'' COMMENT ''批次状态:ACTIVE有效、VOID已作废'' AFTER label_version'); +CALL phase2_carton_label_add_column('cargo_fingerprint', 'CHAR(64) NULL COMMENT ''影响标签字段的SHA-256指纹'' AFTER batch_status'); +CALL phase2_carton_label_add_column('voided_at', 'DATETIME(3) NULL COMMENT ''批次作废时间'' AFTER cargo_fingerprint'); +DROP PROCEDURE phase2_carton_label_add_column; + +UPDATE fl_inbound_carton_label + SET label_version = 1, + batch_status = 'ACTIVE' + WHERE label_version IS NULL OR batch_status IS NULL; + +UPDATE fl_inbound_carton_label + SET box_code = CONCAT(inbound_code, '#V', label_version, '#', total_carton_count, '-', carton_index) + WHERE box_code NOT LIKE CONCAT(inbound_code, '#V%#%'); + +SET @has_old_index := (SELECT COUNT(*) FROM information_schema.statistics + WHERE table_schema = DATABASE() AND table_name = 'fl_inbound_carton_label' + AND index_name = 'uk_carton_label_index'); +SET @drop_old_index := IF(@has_old_index > 0, + 'ALTER TABLE fl_inbound_carton_label DROP INDEX uk_carton_label_index', 'SELECT 1'); +PREPARE carton_label_stmt FROM @drop_old_index; +EXECUTE carton_label_stmt; +DEALLOCATE PREPARE carton_label_stmt; + +SET @has_version_index := (SELECT COUNT(*) FROM information_schema.statistics + WHERE table_schema = DATABASE() AND table_name = 'fl_inbound_carton_label' + AND index_name = 'uk_carton_label_index'); +SET @add_version_index := IF(@has_version_index = 0, + 'ALTER TABLE fl_inbound_carton_label ADD UNIQUE KEY uk_carton_label_index (tenant_id, inbound_id, label_version, carton_index)', 'SELECT 1'); +PREPARE carton_label_index_stmt FROM @add_version_index; +EXECUTE carton_label_index_stmt; +DEALLOCATE PREPARE carton_label_index_stmt; + +SET @has_active_index := (SELECT COUNT(*) FROM information_schema.statistics + WHERE table_schema = DATABASE() AND table_name = 'fl_inbound_carton_label' + AND index_name = 'idx_carton_label_active'); +SET @add_active_index := IF(@has_active_index = 0, + 'ALTER TABLE fl_inbound_carton_label ADD KEY idx_carton_label_active (tenant_id, inbound_id, batch_status, label_version)', 'SELECT 1'); +PREPARE carton_label_active_stmt FROM @add_active_index; +EXECUTE carton_label_active_stmt; +DEALLOCATE PREPARE carton_label_active_stmt; diff --git a/firstleg/src/main/resources/db/phase2-inbound-carton-label.sql b/firstleg/src/main/resources/db/phase2-inbound-carton-label.sql index 84895235e..86cb6869c 100644 --- a/firstleg/src/main/resources/db/phase2-inbound-carton-label.sql +++ b/firstleg/src/main/resources/db/phase2-inbound-carton-label.sql @@ -15,6 +15,10 @@ CREATE TABLE IF NOT EXISTS fl_inbound_carton_label ( box_code VARCHAR(80) NOT NULL COMMENT '固定二维码内容:入仓码#总箱数-箱下标', total_carton_count INT UNSIGNED NOT NULL COMMENT '首次分配时整单总箱数', carton_index INT UNSIGNED NOT NULL COMMENT '整单从1开始的稳定箱下标', + label_version INT UNSIGNED NOT NULL DEFAULT 1 COMMENT '箱唛批次版本,从1递增', + batch_status VARCHAR(16) NOT NULL DEFAULT 'ACTIVE' COMMENT '批次状态:ACTIVE有效、VOID已作废', + cargo_fingerprint CHAR(64) NULL COMMENT '影响标签字段的SHA-256指纹', + voided_at DATETIME(3) NULL COMMENT '批次作废时间', box_specification VARCHAR(255) NOT NULL COMMENT '订单箱规或货物明细描述快照', length_cm DECIMAL(12,3) NULL COMMENT '下单箱规长度cm快照', width_cm DECIMAL(12,3) NULL COMMENT '下单箱规宽度cm快照', @@ -22,7 +26,8 @@ CREATE TABLE IF NOT EXISTS fl_inbound_carton_label ( created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '首次分配时间', PRIMARY KEY (id), UNIQUE KEY uk_carton_label_box_code (tenant_id, box_code), - UNIQUE KEY uk_carton_label_index (tenant_id, inbound_id, carton_index), + UNIQUE KEY uk_carton_label_index (tenant_id, inbound_id, label_version, carton_index), + KEY idx_carton_label_active (tenant_id, inbound_id, batch_status, label_version), KEY idx_carton_label_detail (tenant_id, inbound_id, inbound_detail_id), CONSTRAINT fk_carton_label_inbound FOREIGN KEY (inbound_id) REFERENCES fl_inbound_order (id) ) ENGINE=InnoDB COMMENT='首次打印前冻结的实际箱号与订单货物明细映射'; diff --git a/firstleg/src/main/resources/db/phase2-inbound-schema.sql b/firstleg/src/main/resources/db/phase2-inbound-schema.sql index 71c423dbe..074c1f433 100644 --- a/firstleg/src/main/resources/db/phase2-inbound-schema.sql +++ b/firstleg/src/main/resources/db/phase2-inbound-schema.sql @@ -153,6 +153,10 @@ CREATE TABLE IF NOT EXISTS fl_inbound_carton_label ( box_code VARCHAR(80) NOT NULL COMMENT '固定二维码内容:入仓码#总箱数-箱下标', total_carton_count INT UNSIGNED NOT NULL COMMENT '首次分配时整单总箱数', carton_index INT UNSIGNED NOT NULL COMMENT '整单从1开始的稳定箱下标', + label_version INT UNSIGNED NOT NULL DEFAULT 1 COMMENT '箱唛批次版本,从1递增', + batch_status VARCHAR(16) NOT NULL DEFAULT 'ACTIVE' COMMENT '批次状态:ACTIVE有效、VOID已作废', + cargo_fingerprint CHAR(64) NULL COMMENT '影响标签字段的SHA-256指纹', + voided_at DATETIME(3) NULL COMMENT '批次作废时间', box_specification VARCHAR(255) NOT NULL COMMENT '订单箱规或货物明细描述快照', length_cm DECIMAL(12,3) NULL COMMENT '下单箱规长度cm快照', width_cm DECIMAL(12,3) NULL COMMENT '下单箱规宽度cm快照', @@ -160,7 +164,7 @@ CREATE TABLE IF NOT EXISTS fl_inbound_carton_label ( created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '首次分配时间', PRIMARY KEY (id), UNIQUE KEY uk_carton_label_box_code (tenant_id, box_code), - UNIQUE KEY uk_carton_label_index (tenant_id, inbound_id, carton_index), + UNIQUE KEY uk_carton_label_index (tenant_id, inbound_id, label_version, carton_index), KEY idx_carton_label_detail (tenant_id, inbound_id, inbound_detail_id), CONSTRAINT fk_carton_label_inbound FOREIGN KEY (inbound_id) REFERENCES fl_inbound_order (id) ) ENGINE=InnoDB COMMENT='首次打印前冻结的实际箱号与订单货物明细映射'; diff --git a/firstleg/src/main/resources/db/phase3-loading-completed-outbox-migration.sql b/firstleg/src/main/resources/db/phase3-loading-completed-outbox-migration.sql new file mode 100644 index 000000000..4b5514187 --- /dev/null +++ b/firstleg/src/main/resources/db/phase3-loading-completed-outbox-migration.sql @@ -0,0 +1,2 @@ +-- 装柜完成跨区事件使用标准 sha256: 摘要,共 71 字符。 +ALTER TABLE fl_inbound_outbox MODIFY COLUMN payload_hash VARCHAR(71) NULL COMMENT '事件规范化负载SHA-256;标准跨区事件含sha256:前缀'; diff --git a/firstleg/src/main/resources/openapi/firstleg-v1.yaml b/firstleg/src/main/resources/openapi/firstleg-v1.yaml index 093d4ba94..913f01ba5 100644 --- a/firstleg/src/main/resources/openapi/firstleg-v1.yaml +++ b/firstleg/src/main/resources/openapi/firstleg-v1.yaml @@ -6,6 +6,26 @@ info: security: - bearerAuth: [] paths: + /api/merchant/v1/first-leg/inbounds/{inboundId}/carton-label-pdf: + post: + summary: 客户端生成本客户入库单箱唛 PDF + parameters: + - {name: inboundId, in: path, required: true, schema: {type: string, pattern: '^[1-9][0-9]*$'}} + - {name: cartonIndex, in: query, required: false, schema: {type: array, items: {type: integer, minimum: 1}}} + responses: + '200': {description: PDF 文件} + '403': {description: 非客户端身份、无订单查看权限或入库单不属于当前客户} + /api/merchant/v1/first-leg/orders/{orderId}/carton-label-pdf: + post: + summary: 客户端按订单生成本客户草稿或正式订单箱唛 PDF + description: 后端按登录客户和 orderId 查找真实预入库单;不要求客户端先取得 inboundId。 + parameters: + - {name: orderId, in: path, required: true, schema: {type: string, pattern: '^[1-9][0-9]*$'}} + - {name: cartonIndex, in: query, required: false, schema: {type: array, items: {type: integer, minimum: 1}}} + responses: + '200': {description: PDF 文件} + '403': {description: 非客户端身份、无订单查看权限或订单不属于当前客户} + '404': {description: 订单尚未生成预入库单} /internal/firstleg/v1/customer-dimension-configs/defaults: post: summary: 幂等初始化客户四箱规默认配置 diff --git a/firstleg/src/test/java/com/baiyue/firstleg/controller/MerchantInboundCartonLabelControllerTest.java b/firstleg/src/test/java/com/baiyue/firstleg/controller/MerchantInboundCartonLabelControllerTest.java new file mode 100644 index 000000000..bffc1f45d --- /dev/null +++ b/firstleg/src/test/java/com/baiyue/firstleg/controller/MerchantInboundCartonLabelControllerTest.java @@ -0,0 +1,54 @@ +package com.baiyue.firstleg.controller; + +import com.baiyue.common.core.exception.BizException; +import com.baiyue.common.security.AuthenticatedUser; +import com.baiyue.firstleg.service.InboundCartonLabelPrintService; +import jakarta.annotation.Resource; +import org.junit.jupiter.api.Test; +import org.springframework.boot.test.mock.mockito.MockBean; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.test.context.junit.jupiter.SpringJUnitConfig; + +import java.util.Set; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.Mockito.verify; + +@SpringJUnitConfig(MerchantInboundCartonLabelController.class) +class MerchantInboundCartonLabelControllerTest { + @MockBean private InboundCartonLabelPrintService service; + @Resource private MerchantInboundCartonLabelController controller; + + @Test + void usesAuthenticatedCustomerId() throws Exception { + AuthenticatedUser merchant = new AuthenticatedUser(1L, 10001L, 0L, 1L, + "merchant", "MERCHANT", "WEB", Set.of()); + + controller.print(merchant, 88L, null, "req-1"); + + verify(service).generate(10001L, 88L, null, "req-1"); + PreAuthorize permission = MerchantInboundCartonLabelController.class + .getDeclaredMethod("print", AuthenticatedUser.class, long.class, java.util.List.class, String.class) + .getAnnotation(PreAuthorize.class); + assertThat(permission.value()).isEqualTo("hasAuthority('merchant:order:view')"); + } + + @Test + void rejectsPlatformIdentity() { + AuthenticatedUser platform = new AuthenticatedUser(1L, 0L, 0L, 1L, + "admin", "PLATFORM", "WEB", Set.of()); + assertThatThrownBy(() -> controller.print(platform, 88L, null, null)) + .isInstanceOf(BizException.class); + } + + @Test + void printsByOrderWithoutRequiringAnInboundIdFromTheClient() { + AuthenticatedUser merchant = new AuthenticatedUser(1L, 10001L, 0L, 1L, + "merchant", "MERCHANT", "WEB", Set.of()); + + controller.printByOrder(merchant, 77L, null, "req-2"); + + verify(service).generateByOrder(10001L, 77L, null, "req-2"); + } +} diff --git a/firstleg/src/test/java/com/baiyue/firstleg/service/InboundCartonLabelPrintServiceTest.java b/firstleg/src/test/java/com/baiyue/firstleg/service/InboundCartonLabelPrintServiceTest.java index 83d334252..40e3eaea7 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/service/InboundCartonLabelPrintServiceTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/service/InboundCartonLabelPrintServiceTest.java @@ -8,6 +8,7 @@ import com.fasterxml.jackson.databind.ObjectMapper; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.mockito.ArgumentCaptor; +import org.springframework.cloud.client.circuitbreaker.NoFallbackAvailableException; import org.springframework.http.HttpHeaders; import org.springframework.http.MediaType; import org.springframework.http.ResponseEntity; @@ -79,6 +80,44 @@ class InboundCartonLabelPrintServiceTest { .isEqualTo(ResultCode.VALIDATION_FAILED); } + @Test + void convertsCircuitBreakerFailureToServiceUnavailable() { + when(labels.list(10001L, 70001L, null)).thenReturn(batch()); + when(print.generateInboundMarks(org.mockito.ArgumentMatchers.anyString(), + org.mockito.ArgumentMatchers.isNull(), org.mockito.ArgumentMatchers.any())) + .thenThrow(new NoFallbackAvailableException("print unavailable", + new IllegalStateException("down"))); + + assertThatThrownBy(() -> service.generate(10001L, 70001L, null, null)) + .isInstanceOf(BizException.class) + .hasMessage("打印服务暂不可用,请稍后重试") + .extracting(value -> ((BizException) value).getResultCode()) + .isEqualTo(ResultCode.SERVICE_UNAVAILABLE); + } + + @Test + void batchCombinesSuccessfulOrdersAndReportsIsolatedFailures() { + when(labels.requireInboundIdByOrder(10001L, 60001L)).thenReturn(70001L); + when(labels.requireInboundIdByOrder(10001L, 60002L)) + .thenThrow(new BizException(ResultCode.NOT_FOUND, "missing")); + when(labels.list(10001L, 70001L, null)).thenReturn(batch()); + HttpHeaders headers = new HttpHeaders(); + headers.setContentType(MediaType.APPLICATION_PDF); + when(print.generateInboundMarks(org.mockito.ArgumentMatchers.anyString(), + org.mockito.ArgumentMatchers.eq("request-batch"), org.mockito.ArgumentMatchers.any())) + .thenReturn(new ResponseEntity<>(new byte[] {1, 2, 3}, headers, + org.springframework.http.HttpStatus.OK)); + + ResponseEntity response = service.generateByOrders( + 10001L, List.of(60001L, 60002L), "request-batch"); + + assertThat(response.getHeaders().getFirst("X-Carton-Label-Order-Count")).isEqualTo("2"); + assertThat(response.getHeaders().getFirst("X-Carton-Label-Failure-Count")).isEqualTo("1"); + assertThat(response.getHeaders().getFirst("X-Carton-Label-Failures")).contains("60002:A0404"); + verify(print).generateInboundMarks(org.mockito.ArgumentMatchers.anyString(), + org.mockito.ArgumentMatchers.eq("request-batch"), org.mockito.ArgumentMatchers.any()); + } + private InboundCartonLabels.Batch batch() { InboundCartonLabels.Label label = new InboundCartonLabels.Label( 1L, 2L, 1, "音箱", 19, "PCHK2608040174#19-1", 19, 1, diff --git a/firstleg/src/test/java/com/baiyue/firstleg/service/InboundCartonLabelServiceTest.java b/firstleg/src/test/java/com/baiyue/firstleg/service/InboundCartonLabelServiceTest.java index ac3948572..3114d3e8c 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/service/InboundCartonLabelServiceTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/service/InboundCartonLabelServiceTest.java @@ -28,6 +28,7 @@ import static org.mockito.Mockito.when; class InboundCartonLabelServiceTest { @Mock private InboundCartonLabelRepository repository; @Mock private OrderCargoClient orderCargoClient; + @Mock private InboundOrderCreationService inboundOrderCreationService; private InboundCartonLabelService service; @BeforeEach @@ -35,6 +36,7 @@ class InboundCartonLabelServiceTest { service = new InboundCartonLabelService(); ReflectionTestUtils.setField(service, "repository", repository); ReflectionTestUtils.setField(service, "orderCargoClient", orderCargoClient); + ReflectionTestUtils.setField(service, "inboundOrderCreationService", inboundOrderCreationService); ReflectionTestUtils.setField(service, "internalToken", "internal-token"); } @@ -42,12 +44,13 @@ class InboundCartonLabelServiceTest { void firstAllocationUsesCargoOrderAndFreezesEveryPhysicalCarton() { InboundOrderEntity inbound = inbound(); when(repository.lockInbound(10001L, 70001L)).thenReturn(inbound); + when(repository.findActiveByInbound(10001L, 70001L)).thenReturn(new ArrayList<>()); when(repository.findByInbound(10001L, 70001L)).thenReturn(new ArrayList<>()); var second = cargo(80002L, 2, 2, "50", "30", "20"); var first = cargo(80001L, 1, 3, "60", "40", "30"); when(orderCargoClient.listForCartonLabels("internal-token", 10001L, 60001L)) .thenReturn(ApiResponse.success(List.of(second, first))); - when(repository.initializeOriginalPackageCount(10001L, 70001L, 5)).thenReturn(true); + when(repository.nextVersion(10001L, 70001L)).thenReturn(1); List inserted = new ArrayList<>(); org.mockito.Mockito.doAnswer(invocation -> { inserted.add(invocation.getArgument(0)); @@ -58,56 +61,116 @@ class InboundCartonLabelServiceTest { assertThat(batch.totalCount()).isEqualTo(5); assertThat(batch.labels()).extracting(label -> label.boxCode()) - .containsExactly("PCHK2608040174#5-1", "PCHK2608040174#5-2", "PCHK2608040174#5-3", - "PCHK2608040174#5-4", "PCHK2608040174#5-5"); + .containsExactly("PCHK2608040174#V1#5-1", "PCHK2608040174#V1#5-2", "PCHK2608040174#V1#5-3", + "PCHK2608040174#V1#5-4", "PCHK2608040174#V1#5-5"); assertThat(batch.labels()).extracting(label -> label.inboundDetailId()) .containsExactly(80001L, 80001L, 80001L, 80002L, 80002L); assertThat(batch.labels().get(0).boxSpecification()).isEqualTo("60x40x30cm"); assertThat(batch.labels().get(0).cartonText()).isEqualTo("CTNS:5-1"); assertThat(inbound.getTotalPackageCount()).isEqualTo(5); assertThat(inserted).hasSize(5); - verify(repository).initializeOriginalPackageCount(10001L, 70001L, 5); + verify(repository).updatePackageCount(10001L, 70001L, 5); } @Test - void repeatedAndPartialPrintsReadFrozenMappingWithoutReallocation() { + void repeatedAndPartialPrintsReadCurrentVersionWithoutReallocation() { InboundOrderEntity inbound = inbound(); inbound.setTotalPackageCount(5); List existing = labels(inbound, 5); when(repository.lockInbound(10001L, 70001L)).thenReturn(inbound); - when(repository.findByInbound(10001L, 70001L)).thenReturn(existing); + when(repository.findActiveByInbound(10001L, 70001L)).thenReturn(existing); + when(orderCargoClient.listForCartonLabels("internal-token", 10001L, 60001L)) + .thenReturn(ApiResponse.success(List.of(cargo(80001L, 1, 5, "60", "40", "30")))); var batch = service.list(10001L, 70001L, List.of(5, 2, 2)); assertThat(batch.totalCount()).isEqualTo(5); assertThat(batch.labels()).extracting(label -> label.cartonIndex()).containsExactly(2, 5); - verify(orderCargoClient, never()).listForCartonLabels(any(String.class), - org.mockito.ArgumentMatchers.anyLong(), org.mockito.ArgumentMatchers.anyLong()); + verify(repository).initializeLegacyFingerprint(org.mockito.ArgumentMatchers.eq(10001L), + org.mockito.ArgumentMatchers.eq(70001L), org.mockito.ArgumentMatchers.eq(1), any(String.class)); verify(repository, never()).insert(any()); } @Test - void refusesFrozenMappingWhenOriginalPackageCountDoesNotMatch() { + void changedCargoCreatesNewVersion() { InboundOrderEntity inbound = inbound(); inbound.setTotalPackageCount(4); when(repository.lockInbound(10001L, 70001L)).thenReturn(inbound); - when(repository.findByInbound(10001L, 70001L)).thenReturn(labels(inbound, 5)); + List existing = labels(inbound, 5); + existing.forEach(label -> { label.setCargoFingerprint("old"); label.setBatchStatus("ACTIVE"); }); + when(repository.findActiveByInbound(10001L, 70001L)).thenReturn(existing); + when(orderCargoClient.listForCartonLabels("internal-token", 10001L, 60001L)) + .thenReturn(ApiResponse.success(List.of(cargo(80001L, 1, 2, "60", "40", "30")))); + when(repository.nextVersion(10001L, 70001L)).thenReturn(2); + org.mockito.Mockito.doAnswer(invocation -> null).when(repository).insert(any()); + var result = service.getOrCreate(10001L, 70001L); + assertThat(result.labels()).extracting(label -> label.boxCode()) + .containsExactly("PCHK2608040174#V2#2-1", "PCHK2608040174#V2#2-2"); + verify(repository).voidActive(org.mockito.ArgumentMatchers.eq(10001L), + org.mockito.ArgumentMatchers.eq(70001L), any()); + } - assertThatThrownBy(() -> service.getOrCreate(10001L, 70001L)) - .isInstanceOf(BizException.class) - .hasMessageContaining("订单总箱数与已冻结箱号数量不一致"); + @Test + void draftCanGenerateLabelsBeforeSubmission() { + InboundOrderEntity inbound = inbound(); + inbound.setProcessStatus("PREPARING"); + when(repository.lockInbound(10001L, 70001L)).thenReturn(inbound); + when(repository.findActiveByInbound(10001L, 70001L)).thenReturn(new ArrayList<>()); + when(repository.findByInbound(10001L, 70001L)).thenReturn(new ArrayList<>()); + when(orderCargoClient.listForCartonLabels("internal-token", 10001L, 60001L)) + .thenReturn(ApiResponse.success(List.of(cargo(80001L, 1, 2, "60", "40", "30")))); + when(repository.nextVersion(10001L, 70001L)).thenReturn(1); + org.mockito.Mockito.doAnswer(invocation -> null).when(repository).insert(any()); - verify(repository, never()).initializeOriginalPackageCount( - org.mockito.ArgumentMatchers.anyLong(), org.mockito.ArgumentMatchers.anyLong(), - org.mockito.ArgumentMatchers.anyInt()); + var result = service.getOrCreate(10001L, 70001L); + + assertThat(result.totalCount()).isEqualTo(2); + assertThat(result.labels()).extracting(label -> label.boxCode()) + .containsExactly("PCHK2608040174#V1#2-1", "PCHK2608040174#V1#2-2"); + } + + @Test + void existingLabelsCanBeRepeatedAfterLoading() { + InboundOrderEntity inbound = inbound(); + inbound.setProcessStatus("LOADING"); + List existing = labels(inbound, 1); + when(repository.lockInbound(10001L, 70001L)).thenReturn(inbound); + when(repository.findActiveByInbound(10001L, 70001L)).thenReturn(existing); + when(orderCargoClient.listForCartonLabels("internal-token", 10001L, 60001L)) + .thenReturn(ApiResponse.success(List.of(cargo(80001L, 1, 1, "60", "40", "30")))); + var result = service.getOrCreate(10001L, 70001L); + assertThat(result.totalCount()).isEqualTo(1); + } + + @Test + void changedCargoAfterLoadingVoidsCurrentBatchAndCreatesNextVersion() { + InboundOrderEntity inbound = inbound(); + inbound.setProcessStatus("LOADING"); + List existing = labels(inbound, 1); + existing.forEach(label -> { + label.setCargoFingerprint("old-fingerprint"); + label.setBatchStatus("ACTIVE"); + }); + when(repository.lockInbound(10001L, 70001L)).thenReturn(inbound); + when(repository.findActiveByInbound(10001L, 70001L)).thenReturn(existing); + when(orderCargoClient.listForCartonLabels("internal-token", 10001L, 60001L)) + .thenReturn(ApiResponse.success(List.of(cargo(80001L, 1, 2, "60", "40", "30")))); + when(repository.nextVersion(10001L, 70001L)).thenReturn(2); + + var result = service.getOrCreate(10001L, 70001L); + + assertThat(result.labels()).extracting(label -> label.boxCode()) + .containsExactly("PCHK2608040174#V2#2-1", "PCHK2608040174#V2#2-2"); + verify(repository).voidActive(org.mockito.ArgumentMatchers.eq(10001L), + org.mockito.ArgumentMatchers.eq(70001L), any()); } @Test void resolvesBoxCodeInsideCustomerBoundary() { InboundCartonLabelEntity label = labels(inbound(), 1).get(0); - when(repository.findByBoxCode(10001L, "PCHK2608040174#1-1")).thenReturn(label); + when(repository.findByBoxCode(10001L, "PCHK2608040174#V1#1-1")).thenReturn(label); - var resolved = service.resolveByBoxCode(10001L, " PCHK2608040174#1-1 "); + var resolved = service.resolveByBoxCode(10001L, " PCHK2608040174#V1#1-1 "); assertThat(resolved.inboundId()).isEqualTo(70001L); assertThat(resolved.inboundDetailId()).isEqualTo(80001L); @@ -115,27 +178,37 @@ class InboundCartonLabelServiceTest { assertThat(resolved.cartonIndex()).isEqualTo(1); assertThat(resolved.totalCartons()).isEqualTo(1); assertThat(resolved.boxSpecification()).isEqualTo("60x40x30cm"); - verify(repository).findByBoxCode(10001L, "PCHK2608040174#1-1"); + verify(repository).findByBoxCode(10001L, "PCHK2608040174#V1#1-1"); } @Test void exposesDetailedResolutionForFirstlegConsumers() { InboundCartonLabelEntity label = labels(inbound(), 1).get(0); - when(repository.findByBoxCode(10001L, "PCHK2608040174#1-1")).thenReturn(label); + when(repository.findByBoxCode(10001L, "PCHK2608040174#V1#1-1")).thenReturn(label); - var resolved = service.resolveDetailsByBoxCode(10001L, "PCHK2608040174#1-1"); + var resolved = service.resolveDetailsByBoxCode(10001L, "PCHK2608040174#V1#1-1"); assertThat(resolved.orderId()).isEqualTo(60001L); - assertThat(resolved.boxCode()).isEqualTo("PCHK2608040174#1-1"); + assertThat(resolved.boxCode()).isEqualTo("PCHK2608040174#V1#1-1"); assertThat(resolved.cargoItemNo()).isEqualTo(1); } + @Test + void rejectsVoidedLabelVersionDuringScanResolution() { + InboundCartonLabelEntity label = labels(inbound(), 1).get(0); + label.setBatchStatus("VOID"); + when(repository.findByBoxCode(10001L, "PCHK2608040174#V1#1-1")).thenReturn(label); + + assertThatThrownBy(() -> service.resolveByBoxCode(10001L, "PCHK2608040174#V1#1-1")) + .isInstanceOf(BizException.class) + .hasMessage("箱唛版本已作废,请重新打印"); + } + @Test void refusesFirstAllocationAfterMeasurementSubmission() { InboundOrderEntity inbound = inbound(); inbound.setProcessStatus("MEASURE_PENDING"); when(repository.lockInbound(10001L, 70001L)).thenReturn(inbound); - when(repository.findByInbound(10001L, 70001L)).thenReturn(List.of()); assertThatThrownBy(() -> service.getOrCreate(10001L, 70001L)) .isInstanceOf(BizException.class) @@ -144,6 +217,42 @@ class InboundCartonLabelServiceTest { org.mockito.ArgumentMatchers.anyLong(), org.mockito.ArgumentMatchers.anyLong()); } + @Test + void resolvesTheRealInboundIdByTenantAndOrder() { + InboundOrderEntity inbound = inbound(); + when(repository.findByOrder(10001L, 60001L)).thenReturn(inbound); + + assertThat(service.requireInboundIdByOrder(10001L, 60001L)).isEqualTo(70001L); + verify(repository).findByOrder(10001L, 60001L); + } + + @Test + void rejectsAnOrderWithoutAnOwnedPreparingInbound() { + when(repository.findByOrder(10001L, 60001L)).thenReturn(null); + when(orderCargoClient.inboundSnapshot("internal-token", 10001L, 60001L)) + .thenThrow(org.mockito.Mockito.mock(feign.FeignException.class)); + + assertThatThrownBy(() -> service.requireInboundIdByOrder(10001L, 60001L)) + .isInstanceOf(BizException.class) + .hasMessageContaining("订单服务暂不可用"); + } + + @Test + void repairsMissingHistoricalInboundFromOwnedOrderSnapshot() { + var snapshot = new com.baiyue.firstleg.dto.OrderApprovedInboundCommand( + 10001L, 60001L, "FL60001", "PCHK2608040174", 1L, "Customer", + 41L, "Warehouse", null, null, null, "EXPORT_SEA", null, null, + null, null, null, null, null, false, "FBA", null, null, null, + null, null, null, 2); + InboundOrderEntity repaired = inbound(); + when(repository.findByOrder(10001L, 60001L)).thenReturn(null, repaired); + when(orderCargoClient.inboundSnapshot("internal-token", 10001L, 60001L)) + .thenReturn(ApiResponse.success(snapshot)); + + assertThat(service.requireInboundIdByOrder(10001L, 60001L)).isEqualTo(70001L); + verify(inboundOrderCreationService).prepareFromDraft(snapshot); + } + private InboundOrderEntity inbound() { InboundOrderEntity inbound = new InboundOrderEntity(); inbound.setId(70001L); @@ -175,9 +284,11 @@ class InboundCartonLabelServiceTest { label.setCargoName("货物1"); label.setDetailPackageCount(total); label.setInboundCode(inbound.getInboundCode()); - label.setBoxCode(inbound.getInboundCode() + "#" + total + "-" + index); + label.setBoxCode(inbound.getInboundCode() + "#V1#" + total + "-" + index); label.setTotalCartonCount(total); label.setCartonIndex(index); + label.setLabelVersion(1); + label.setBatchStatus("ACTIVE"); label.setBoxSpecification("60x40x30cm"); label.setLengthCm(new BigDecimal("60")); label.setWidthCm(new BigDecimal("40")); diff --git a/firstleg/src/test/java/com/baiyue/firstleg/service/InboundOrderCreationServiceTest.java b/firstleg/src/test/java/com/baiyue/firstleg/service/InboundOrderCreationServiceTest.java index 2e67b047b..c69848b76 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/service/InboundOrderCreationServiceTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/service/InboundOrderCreationServiceTest.java @@ -113,6 +113,23 @@ class InboundOrderCreationServiceTest { verify(orders, never()).insert(any(InboundOrderEntity.class)); } + @Test + void shouldPrepareDraftIdempotentlyAndUpgradeSameInboundOnApproval() { + InboundOrderEntity prepared = inbound(900L, "RK-CUSTOM-77", "UNPRICED", null, null); + prepared.setProcessStatus("PREPARING"); + when(orders.selectOne(any(Wrapper.class))).thenReturn(null, prepared, prepared); + when(orders.update(any(), any(Wrapper.class))).thenReturn(1); + when(fulfillmentTasks.provision(any(), any())).thenReturn(task(700L)); + + long preparedId = service.prepareFromDraft(approvedCommand("RK-CUSTOM-77")); + long approvedId = service.createFromApprovedOrder(approvedCommand("RK-CUSTOM-77")); + + assertThat(preparedId).isNotNull(); + assertThat(approvedId).isEqualTo(900L); + verify(orders, times(1)).insert(any(InboundOrderEntity.class)); + verify(orders, times(2)).update(any(), any(Wrapper.class)); + } + private static InboundOrderEntity inbound(long id, String code, String feeStatus, Long carrierId, String carrierName) { InboundOrderEntity inbound = new InboundOrderEntity(); diff --git a/firstleg/src/test/java/com/baiyue/firstleg/service/LoadingCompletedOutboxServiceTest.java b/firstleg/src/test/java/com/baiyue/firstleg/service/LoadingCompletedOutboxServiceTest.java new file mode 100644 index 000000000..a513d6b4c --- /dev/null +++ b/firstleg/src/test/java/com/baiyue/firstleg/service/LoadingCompletedOutboxServiceTest.java @@ -0,0 +1,61 @@ +package com.baiyue.firstleg.service; + +import com.baiyue.common.event.EventEnvelope; +import com.baiyue.common.event.EventTypes; +import com.baiyue.firstleg.entity.InboundOutboxEntity; +import com.baiyue.firstleg.mapper.InboundOutboxMapper; +import com.baiyue.firstleg.mapper.LoadingCompletedEventMapper; +import com.baiyue.firstleg.mapper.LoadingCompletedEventMapper.Row; +import com.fasterxml.jackson.databind.ObjectMapper; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; +import org.springframework.test.util.ReflectionTestUtils; + +import java.math.BigDecimal; +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +class LoadingCompletedOutboxServiceTest { + private final LoadingCompletedEventMapper snapshots = mock(LoadingCompletedEventMapper.class); + private final InboundOutboxMapper outbox = mock(InboundOutboxMapper.class); + private final ObjectMapper mapper = new ObjectMapper().findAndRegisterModules(); + private final LoadingCompletedOutboxService service = new LoadingCompletedOutboxService(); + + @BeforeEach void setup() { + ReflectionTestUtils.setField(service,"snapshotMapper",snapshots); + ReflectionTestUtils.setField(service,"outboxMapper",outbox); + ReflectionTestUtils.setField(service,"objectMapper",mapper); + } + + @Test void groupsByAccountAndKeepsAllOrdersAndItemsStable() throws Exception { + when(snapshots.rows(90L)).thenReturn(List.of(row(22,300,302),row(11,200,202),row(11,100,102),row(11,100,101))); + service.publish(90L); + ArgumentCaptor captor=ArgumentCaptor.forClass(InboundOutboxEntity.class); + verify(outbox,org.mockito.Mockito.times(2)).insert(captor.capture()); + List rows=captor.getAllValues(); + assertThat(rows).extracting(InboundOutboxEntity::getTenantId).containsExactly(11L,22L); + EventEnvelope first=mapper.readValue(rows.get(0).getPayloadJson(),EventEnvelope.class); + assertThat(first.eventType()).isEqualTo(EventTypes.FIRST_LEG_LOADING_PLAN_COMPLETED); + assertThat(first.tenantId()).isEqualTo("11"); + assertThat(first.payload().get("orders")).hasSize(2); + assertThat(first.payload().at("/orders/0/sourceInboundId").asText()).isEqualTo("100"); + assertThat(first.payload().at("/orders/0/items/0/sourceInboundDetailId").asText()).isEqualTo("101"); + assertThat(rows.get(0).getEventKey()).isEqualTo("FIRSTLEG_LOADING_COMPLETED:90:11:1"); + } + + private Row row(long account,long inbound,long detail) { + Row r=new Row(); r.setLoadingPlanId(90L); r.setPlanNo("P90"); r.setContainerBookingId(91L); + r.setAggregateVersion(8); r.setContainerNo("CONT"); r.setCabinetNo("CAB"); r.setWarehouseId(92L); + r.setWarehouseName("WH"); r.setAccountId(account); r.setSourceInboundId(inbound); r.setRootInboundId(99L); + r.setSourceOrderId(inbound+1000); r.setSourceOrderNo("FL"+inbound); r.setRootOrderId(1099L); + r.setInboundCode("RK"+inbound); r.setDestinationWarehouseId(93L); r.setDestinationWarehouseCode("ONT8"); + r.setDestinationWarehouseName("ONT8"); r.setSourceInboundDetailId(detail); r.setSkuCode("SKU"+detail); + r.setPackageCount(1); r.setProductQuantity(10); r.setPalletCount(1); r.setGrossWeightKg(BigDecimal.ONE); + r.setNetWeightKg(BigDecimal.ONE); r.setVolumeCbm(BigDecimal.ONE); return r; + } +} diff --git a/firstleg/src/test/java/com/baiyue/firstleg/service/LoadingListServiceTest.java b/firstleg/src/test/java/com/baiyue/firstleg/service/LoadingListServiceTest.java index 09a584830..071387fa7 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/service/LoadingListServiceTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/service/LoadingListServiceTest.java @@ -46,6 +46,8 @@ class LoadingListServiceTest { private LoadingPickRepository pickRepository; @MockBean private LoadingDocumentFileClient documentFiles; + @MockBean + private LoadingCompletedOutboxService loadingCompletedOutboxService; @Resource private LoadingListService service; @@ -135,6 +137,7 @@ class LoadingListServiceTest { verify(repository).appendHistory(eq(10L), eq("PDA-COMPLETE-10-21"), eq("PDA_COMPLETE_LOADING"), eq("LOADING"), eq("COMPLETED"), anyString(), anyString(), eq(9L), eq("operator"), eq("PDA已人工确认装柜完成")); + verify(loadingCompletedOutboxService).publish(10L); } @Test @@ -147,6 +150,7 @@ class LoadingListServiceTest { .isEqualTo(ResultCode.CONFLICT); verify(repository, never()).advance(eq(10L), anyString(), anyString(), org.mockito.ArgumentMatchers.anyInt(), eq(9L), anyString()); + verify(loadingCompletedOutboxService, never()).publish(10L); } @Test diff --git a/iam/src/main/java/com/baiyue/iam/securityevent/SecurityEventOutboxRelay.java b/iam/src/main/java/com/baiyue/iam/securityevent/SecurityEventOutboxRelay.java new file mode 100644 index 000000000..cf540d3c5 --- /dev/null +++ b/iam/src/main/java/com/baiyue/iam/securityevent/SecurityEventOutboxRelay.java @@ -0,0 +1,22 @@ +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 new file mode 100644 index 000000000..930ae9f69 --- /dev/null +++ b/iam/src/main/java/com/baiyue/iam/securityevent/SecurityEventRepository.java @@ -0,0 +1,25 @@ +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/resources/db/security-event-outbox.sql b/iam/src/main/resources/db/security-event-outbox.sql new file mode 100644 index 000000000..e9afe00e5 --- /dev/null +++ b/iam/src/main/resources/db/security-event-outbox.sql @@ -0,0 +1,21 @@ +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_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), + 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/order/src/main/java/com/baiyue/order/client/FirstlegInboundClient.java b/order/src/main/java/com/baiyue/order/client/FirstlegInboundClient.java index 085b8a38d..4b5cd813f 100644 --- a/order/src/main/java/com/baiyue/order/client/FirstlegInboundClient.java +++ b/order/src/main/java/com/baiyue/order/client/FirstlegInboundClient.java @@ -17,6 +17,15 @@ public interface FirstlegInboundClient { ApiResponse create(@RequestHeader("X-Internal-Service-Token") String token, @RequestBody OrderApprovedInboundCommand command); + @PostMapping("/order-preparing") + ApiResponse prepare(@RequestHeader("X-Internal-Service-Token") String token, + @RequestBody OrderApprovedInboundCommand command); + + @PostMapping("/order-preparing/batch") + ApiResponse> prepareBatch( + @RequestHeader("X-Internal-Service-Token") String token, + @RequestBody List commands); + @PostMapping("/order-references") ApiResponse> references( @RequestHeader("X-Internal-Service-Token") String token, diff --git a/order/src/main/java/com/baiyue/order/controller/InternalOrderCargoController.java b/order/src/main/java/com/baiyue/order/controller/InternalOrderCargoController.java index 43b1f2b83..6fc2e3701 100644 --- a/order/src/main/java/com/baiyue/order/controller/InternalOrderCargoController.java +++ b/order/src/main/java/com/baiyue/order/controller/InternalOrderCargoController.java @@ -8,6 +8,8 @@ import com.baiyue.common.core.api.ResultCode; import com.baiyue.common.core.exception.BizException; import com.baiyue.order.entity.OrderCargoEntity; import com.baiyue.order.repository.OrderRepository; +import com.baiyue.order.service.OrderApplicationService; +import com.baiyue.order.client.FirstlegInboundClient; import org.springframework.beans.factory.annotation.Value; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.PathVariable; @@ -68,6 +70,25 @@ public class InternalOrderCargoController { .toList()); } + @GetMapping("/{tenantId}/{orderId}/inbound-snapshot") + public ApiResponse inboundSnapshot( + @RequestHeader(value = "X-Internal-Service-Token", required = false) String token, + @PathVariable long tenantId, + @PathVariable long orderId) { + requireToken(token); + if (tenantId <= 0 || orderId <= 0) { + throw new BizException(ResultCode.BAD_REQUEST, "租户和订单ID必须大于0"); + } + var order = orders.findOwned(tenantId, orderId); + if (order == null) { + throw new BizException(ResultCode.NOT_FOUND, "订单不存在或不属于当前客户"); + } + int packageCount = orders.findCargoInheritedByOrder(tenantId, orderId).stream() + .map(OrderCargoEntity::getPackageCount).filter(java.util.Objects::nonNull) + .mapToInt(Integer::intValue).sum(); + return ApiResponse.success(OrderApplicationService.inboundCommand(order, packageCount)); + } + private void requireToken(String token) { if (token == null || !MessageDigest.isEqual(expectedToken, token.getBytes(StandardCharsets.UTF_8))) { throw new BizException(ResultCode.FORBIDDEN, "内部服务凭证无效"); diff --git a/order/src/main/java/com/baiyue/order/dto/MerchantInboundOrderRow.java b/order/src/main/java/com/baiyue/order/dto/MerchantInboundOrderRow.java index cadfc3307..61b8e4bca 100644 --- a/order/src/main/java/com/baiyue/order/dto/MerchantInboundOrderRow.java +++ b/order/src/main/java/com/baiyue/order/dto/MerchantInboundOrderRow.java @@ -12,6 +12,7 @@ public record MerchantInboundOrderRow( String orderNo, Integer packageCount, BigDecimal weightKg, + Long inboundId, String inboundCode, String channelName, String destinationType, @@ -36,12 +37,16 @@ public record MerchantInboundOrderRow( Long operatorId, String operatorName, String remark, + String destinationWarehouseName, + String expressCompany, + String skuNos, + String shipmentId, List cargoes ) { public record CargoColumn( Long cargoId, Integer itemNo, String cargoName, Integer packageCount, Integer totalQuantity, BigDecimal grossWeightKg, BigDecimal netWeightKg, BigDecimal volumeCbm, BigDecimal unitDeclaredValue, BigDecimal declaredValue, - String currency, String model, String material, String productUsage, String brand) { + String currency, String skuNo, String model, String material, String productUsage, String brand) { } } diff --git a/order/src/main/java/com/baiyue/order/service/MerchantInboundOrderListService.java b/order/src/main/java/com/baiyue/order/service/MerchantInboundOrderListService.java index 0155274d6..726442cc9 100644 --- a/order/src/main/java/com/baiyue/order/service/MerchantInboundOrderListService.java +++ b/order/src/main/java/com/baiyue/order/service/MerchantInboundOrderListService.java @@ -46,11 +46,21 @@ public class MerchantInboundOrderListService { .stream().collect(Collectors.toMap( OrderStatusHistoryEntity::getOrderId, Function.identity(), (latest, ignored) -> latest)); Map inboundReferences = references(tenantId, orderIds); + Map initialReferences = inboundReferences; + List missing = source.records().stream() + .filter(order -> !initialReferences.containsKey(order.getId())) + .filter(order -> order.getStatus() == com.baiyue.order.entity.OrderStatus.DRAFT + || order.getStatus() == com.baiyue.order.entity.OrderStatus.SUBMITTED) + .toList(); + if (!missing.isEmpty()) { + inboundReferences = repairedReferences(tenantId, inboundReferences, missing, cargoes); + } + Map finalReferences = inboundReferences; List records = source.records().stream() .map(order -> row(order, cargoes.getOrDefault(order.getId(), List.of()), consignees.get(order.getId()), latestHistory.get(order.getId()), - inboundReferences.get(order.getId()))) + finalReferences.get(order.getId()))) .toList(); return new PageResult<>(records, source.page(), source.size(), source.total(), source.pages()); } @@ -69,6 +79,26 @@ public class MerchantInboundOrderListService { } } + private Map repairedReferences( + long tenantId, Map current, + List missing, Map> cargoes) { + try { + List commands = missing.stream().map(order -> { + int packages = cargoes.getOrDefault(order.getId(), List.of()).stream() + .map(OrderCargoEntity::getPackageCount).filter(Objects::nonNull) + .mapToInt(Integer::intValue).sum(); + return OrderApplicationService.inboundCommand(order, packages); + }).toList(); + var response = firstleg.prepareBatch(internalToken, commands); + if (response == null || !response.successful() || response.data() == null) return current; + Map result = new java.util.HashMap<>(current); + response.data().forEach(reference -> result.put(reference.orderId(), reference)); + return result; + } catch (RuntimeException ignored) { + return current; + } + } + private MerchantInboundOrderRow row( OrderEntity order, List cargoes, @@ -89,13 +119,15 @@ public class MerchantInboundOrderListService { cargo.getId(), cargo.getItemNo(), cargo.getCargoName(), cargo.getPackageCount(), cargo.getTotalQuantity(), cargo.getGrossWeightKg(), cargo.getNetWeightKg(), cargo.getVolumeCbm(), cargo.getUnitDeclaredValue(), cargo.getDeclaredValue(), - cargo.getCurrency(), cargo.getModel(), cargo.getMaterial(), cargo.getProductUsage(), + cargo.getCurrency(), cargo.getSkuNo(), cargo.getModel(), cargo.getMaterial(), cargo.getProductUsage(), cargo.getBrand())) .toList(); return new MerchantInboundOrderRow( order.getId(), order.getOrderNo(), packageCount, grossWeight, - inbound == null ? null : inbound.inboundCode(), order.getChannelName(), + inbound == null ? null : inbound.inboundId(), + inbound == null || inbound.inboundCode() == null || inbound.inboundCode().isBlank() + ? order.getInboundCode() : inbound.inboundCode(), order.getChannelName(), order.getDestinationType(), order.getCustomerName(), consignee == null ? null : consignee.getCompanyName(), joined(cargoes, OrderCargoEntity::getModel), order.getStatus(), totalQuantity, grossWeight, netWeight, volume, @@ -106,6 +138,9 @@ public class MerchantInboundOrderListService { order.getExpressTrackingNo(), order.getShipmentId(), order.getReferenceId(), latestHistory == null ? order.getCreatedBy() : latestHistory.getOperatorId(), latestHistory == null ? null : latestHistory.getOperatorName(), order.getCustomerRemark(), + order.getDestinationWarehouseName(), order.getExpressCompany(), + joined(cargoes, OrderCargoEntity::getSkuNo), + order.getShipmentId(), cargoColumns); } diff --git a/order/src/main/java/com/baiyue/order/service/OrderApplicationService.java b/order/src/main/java/com/baiyue/order/service/OrderApplicationService.java index af245c92a..19a5b558b 100644 --- a/order/src/main/java/com/baiyue/order/service/OrderApplicationService.java +++ b/order/src/main/java/com/baiyue/order/service/OrderApplicationService.java @@ -1,6 +1,7 @@ package com.baiyue.order.service; import com.baiyue.common.core.api.PageResult; +import com.baiyue.common.core.api.ApiResponse; import com.baiyue.common.core.api.ResultCode; import com.baiyue.common.core.exception.BizException; import com.baiyue.order.dto.OrderDetailResponse; @@ -18,6 +19,10 @@ import org.springframework.dao.DuplicateKeyException; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; import org.springframework.util.StringUtils; +import org.springframework.beans.factory.ObjectProvider; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.transaction.support.TransactionSynchronization; +import org.springframework.transaction.support.TransactionSynchronizationManager; import java.time.LocalDateTime; import java.nio.charset.StandardCharsets; @@ -37,6 +42,8 @@ public class OrderApplicationService { @Resource private OrderMasterDataGuard masterDataGuard; @Resource private OrderPricingChannelGuard pricingChannelGuard; @Resource private ProductImageGuard productImageGuard; + @Resource private ObjectProvider firstlegProvider; + @Value("${baiyue.security.internal-service-token:}") private String internalToken; /** 创建草稿时使用数据库唯一键保证请求幂等。 */ @Transactional @@ -63,6 +70,7 @@ public class OrderApplicationService { repository.insertOrder(order); saveChildren(tenantId, userId, order.getId(), request); record(tenantId, order.getId(), null, OrderStatus.DRAFT, "CREATE", null, userId, null); + prepareDraftInbound(order, request); return order.getId(); } @@ -104,6 +112,7 @@ public class OrderApplicationService { repository.replaceDraftChildren(tenantId, id); saveChildren(tenantId, userId, id, request); record(tenantId, id, before, order.getStatus(), "UPDATE", null, userId, null); + prepareDraftInbound(order, request); } @Transactional @@ -482,10 +491,10 @@ public class OrderApplicationService { event.setPublishStatus("PENDING"); event.setRetryCount(0); event.setCreatedAt(LocalDateTime.now()); repository.insertOutbox(event); } - static FirstlegInboundClient.OrderApprovedInboundCommand inboundCommand(OrderEntity order) { + public static FirstlegInboundClient.OrderApprovedInboundCommand inboundCommand(OrderEntity order) { return inboundCommand(order, null); } - static FirstlegInboundClient.OrderApprovedInboundCommand inboundCommand(OrderEntity order, + public static FirstlegInboundClient.OrderApprovedInboundCommand inboundCommand(OrderEntity order, Integer totalPackageCount) { return new FirstlegInboundClient.OrderApprovedInboundCommand( order.getTenantId(), order.getId(), order.getOrderNo(), order.getInboundCode(), @@ -499,6 +508,28 @@ public class OrderApplicationService { order.getDestinationWarehouseId(), order.getAmazonWarehouseCode(), order.getDestinationWarehouseName(), order.getCustomerRemark(), order.getExpressCompany(), order.getExpressTrackingNo(), totalPackageCount); } + private void prepareDraftInbound(OrderEntity order, OrderRequests.SaveRequest request) { + FirstlegInboundClient client = firstlegProvider == null ? null : firstlegProvider.getIfAvailable(); + if (client == null || !StringUtils.hasText(internalToken)) return; + int packageCount = request.cargoes().stream().map(OrderRequests.Cargo::packageCount) + .filter(java.util.Objects::nonNull).mapToInt(Integer::intValue).sum(); + FirstlegInboundClient.OrderApprovedInboundCommand command = inboundCommand(order, packageCount); + Runnable prepare = () -> { + try { + ApiResponse response = client.prepare(internalToken, command); + if (response == null || !response.successful() || response.data() == null) return; + } catch (RuntimeException ignored) { + // Repaired by MerchantInboundOrderListService on the next query. + } + }; + if (TransactionSynchronizationManager.isSynchronizationActive()) { + TransactionSynchronizationManager.registerSynchronization(new TransactionSynchronization() { + @Override public void afterCommit() { prepare.run(); } + }); + } else { + prepare.run(); + } + } private int totalPackageCount(OrderEntity order) { long total = repository.findCargo(order.getTenantId(), order.getId()).stream() .map(com.baiyue.order.entity.OrderCargoEntity::getPackageCount) diff --git a/order/src/main/resources/openapi/order-v1.yaml b/order/src/main/resources/openapi/order-v1.yaml index 4ecbb5e8e..f32a3380a 100644 --- a/order/src/main/resources/openapi/order-v1.yaml +++ b/order/src/main/resources/openapi/order-v1.yaml @@ -665,6 +665,22 @@ components: properties: orderId: {type: string, pattern: '^[1-9][0-9]*$'} orderNo: {type: string} + inboundId: {type: string, pattern: '^[1-9][0-9]*$', nullable: true} + inboundCode: {type: string} + channelName: {type: string} + destinationWarehouseName: {type: string} + status: {type: string} + packageCount: {type: integer} + expressCompany: {type: string} + trackingNo: {type: string} + skuNos: {type: string, description: 多个 SKU 使用中文顿号分隔} + totalGrossWeightKg: {type: number} + totalNetWeightKg: {type: number} + totalVolumeCbm: {type: number} + totalQuantity: {type: integer} + remark: {type: string} + shipmentId: {type: string} + referenceId: {type: string} pricingTaxMode: {$ref: '#/components/schemas/PricingTaxMode'} ContainerSlice: type: object diff --git a/order/src/test/java/com/baiyue/order/controller/InternalOrderCargoControllerTest.java b/order/src/test/java/com/baiyue/order/controller/InternalOrderCargoControllerTest.java index c29974a56..630fe41e7 100644 --- a/order/src/test/java/com/baiyue/order/controller/InternalOrderCargoControllerTest.java +++ b/order/src/test/java/com/baiyue/order/controller/InternalOrderCargoControllerTest.java @@ -2,6 +2,7 @@ package com.baiyue.order.controller; import com.baiyue.common.core.exception.BizException; import com.baiyue.order.entity.OrderCargoEntity; +import com.baiyue.order.entity.OrderEntity; import com.baiyue.order.repository.OrderRepository; import org.junit.jupiter.api.Test; import jakarta.annotation.Resource; @@ -104,6 +105,29 @@ class InternalOrderCargoControllerTest { verify(orders).findCargoInheritedByOrder(11L, 201L, 31L); } + @Test + void returnsOwnedOrderSnapshotForPreparingInboundRepair() { + OrderEntity order = new OrderEntity(); + order.setId(21L); + order.setTenantId(11L); + order.setOrderNo("FL21"); + order.setInboundCode("RK-21"); + order.setCustomerName("Customer"); + order.setReceivingWarehouseId(41L); + order.setReceivingWarehouseName("Warehouse"); + order.setDestinationType("FBA"); + order.setTransportMode("SEA"); + when(orders.findOwned(11L, 21L)).thenReturn(order); + when(orders.findCargoInheritedByOrder(11L, 21L)) + .thenReturn(List.of(cargo(31L, 1, 3, "60", "40", "30"))); + + var response = controller.inboundSnapshot("internal-token", 11L, 21L); + + assertThat(response.data().orderId()).isEqualTo(21L); + assertThat(response.data().totalPackageCount()).isEqualTo(3); + verify(orders).findOwned(11L, 21L); + } + private OrderCargoEntity cargo(long id, int itemNo, int packageCount, String length, String width, String height) { OrderCargoEntity cargo = new OrderCargoEntity(); diff --git a/order/src/test/java/com/baiyue/order/service/MerchantInboundOrderListServiceTest.java b/order/src/test/java/com/baiyue/order/service/MerchantInboundOrderListServiceTest.java index c4002afcb..5a6c9b4d5 100644 --- a/order/src/test/java/com/baiyue/order/service/MerchantInboundOrderListServiceTest.java +++ b/order/src/test/java/com/baiyue/order/service/MerchantInboundOrderListServiceTest.java @@ -41,8 +41,10 @@ class MerchantInboundOrderListServiceTest { order.setStatus(OrderStatus.APPROVED); order.setChannelName("美森"); order.setDestinationType("FBA"); + order.setDestinationWarehouseName("ONT8"); order.setCustomerName("测试客户"); order.setShipmentId("FBA-SHIPMENT-77"); + order.setExpressCompany("UPS"); order.setExpressTrackingNo("TRACK-77"); order.setCustomerRemark("客户备注"); when(repository.merchantInboundPage(10001L, query)) @@ -63,10 +65,11 @@ class MerchantInboundOrderListServiceTest { when(repository.findHistoryByOrderIds(10001L, List.of(77L))).thenReturn(List.of(history)); when(firstleg.references(org.mockito.ArgumentMatchers.eq("internal-token"), org.mockito.ArgumentMatchers.any())).thenReturn(ApiResponse.success(List.of( - new FirstlegInboundClient.InboundReference(77L, 88L, "RK-77", "WAIT_MEASURE")))); + new FirstlegInboundClient.InboundReference(77L, 88L, "RK-77", "INBOUNDED")))); var row = service.page(10001L, query).records().get(0); + assertThat(row.inboundId()).isEqualTo(88L); assertThat(row.inboundCode()).isEqualTo("RK-77"); assertThat(row.packageCount()).isEqualTo(5); assertThat(row.totalQuantity()).isEqualTo(50); @@ -78,6 +81,10 @@ class MerchantInboundOrderListServiceTest { assertThat(row.specificationModel()).isEqualTo("A、B"); assertThat(row.receivingCompanyName()).isEqualTo("收货公司"); assertThat(row.operatorName()).isEqualTo("操作员"); + assertThat(row.destinationWarehouseName()).isEqualTo("ONT8"); + assertThat(row.expressCompany()).isEqualTo("UPS"); + assertThat(row.skuNos()).isEqualTo("SKU-A、SKU-B"); + assertThat(row.shipmentId()).isEqualTo("FBA-SHIPMENT-77"); assertThat(row.cargoes()).hasSize(2); verify(repository).merchantInboundPage(10001L, query); } @@ -97,6 +104,7 @@ class MerchantInboundOrderListServiceTest { cargo.setUnitDeclaredValue(new BigDecimal(unitPrice)); cargo.setDeclaredValue(new BigDecimal(amount)); cargo.setModel(model); + cargo.setSkuNo("SKU-" + model); return cargo; } }