打印箱唛修改 尾程垮桥事件提交

This commit is contained in:
2026-08-13 17:13:58 +08:00
parent b004edaf9e
commit c43d581ba6
59 changed files with 1609 additions and 96 deletions
@@ -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
@@ -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) {
}
@@ -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<Map<String, Object>> jwks() throws Exception {
JWKSet publicSet = JWKSet.parse(properties.rsaJwkSet()).toPublicJWKSet();
return ResponseEntity.ok()
.cacheControl(CacheControl.maxAge(5, TimeUnit.MINUTES).cachePublic())
.body(publicSet.toJSONObject());
}
}
@@ -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, "用户名或密码错误");
}
+4 -1
View File
@@ -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}
@@ -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}");
}
@@ -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(),
@@ -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";
@@ -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<Order> 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<Item> 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) {
}
}
@@ -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");
}
}
@@ -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<StringRedisTemplate> 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
@@ -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<String> values(Object value){ Set<String> result=new LinkedHashSet<>(); if(value instanceof Iterable<?> it)it.forEach(v->result.add(String.valueOf(v))); else if(value!=null)for(String v:String.valueOf(value).split("[ ,]+"))if(!v.isBlank())result.add(v); return result; }
}
@@ -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)
@@ -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<List<String>>("aud", values -> values != null && values.contains(audience))));
return decoder;
}
}
@@ -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;
@@ -1,2 +1,3 @@
com.baiyue.common.security.HmacJwtDecoderAutoConfiguration
com.baiyue.common.security.JwksJwtDecoderAutoConfiguration
com.baiyue.common.security.BaiyueSecurityAutoConfiguration
+3 -1
View File
@@ -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}
@@ -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,
+1
View File
@@ -6,6 +6,7 @@
<artifactId>firstleg-service</artifactId>
<dependencies>
<dependency><groupId>com.baiyue.shared</groupId><artifactId>baiyue-common-core</artifactId><version>${project.version}</version></dependency>
<dependency><groupId>com.baiyue.shared</groupId><artifactId>common-event-contract</artifactId><version>${project.version}</version></dependency>
<dependency><groupId>com.baiyue.shared</groupId><artifactId>baiyue-common-audit</artifactId><version>${project.version}</version></dependency>
<dependency><groupId>org.apache.rocketmq</groupId><artifactId>rocketmq-spring-boot-starter</artifactId></dependency>
<dependency><groupId>com.baiyue.shared</groupId><artifactId>baiyue-common-openapi</artifactId><version>${project.version}</version></dependency>
@@ -0,0 +1,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<Acknowledgement> receive(
@RequestHeader("X-Internal-Service-Token") String token,
@RequestBody EventEnvelope envelope);
record Acknowledgement(String eventId, String result, String receivedAt) { }
}
@@ -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<OrderApprovedInboundCommand> 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,
@@ -62,6 +62,33 @@ public class InternalInboundOrderController {
return ApiResponse.success(service.createFromApprovedOrder(command));
}
@PostMapping("/order-preparing")
public ApiResponse<Long> 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<List<InboundOrderReference.Item>> prepareFromDraftBatch(
@RequestHeader(value = "X-Internal-Service-Token", required = false) String token,
@RequestBody List<OrderApprovedInboundCommand> 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<List<InboundOrderReference.Item>> 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, "草稿预入库快照不完整");
}
}
}
@@ -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<byte[]> 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<byte[]> 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<byte[]> printByOrders(
@AuthenticationPrincipal AuthenticatedUser user,
@RequestBody List<Long> orderIds,
@RequestHeader(value = "X-Request-Id", required = false) String requestId) {
return cartonLabelPrintService.generateByOrders(
FirstlegScopeGuard.merchant(user), orderIds, requestId);
}
}
@@ -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<String> 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
);
@@ -17,4 +17,5 @@ public final class InboundOrderReference {
public record Item(Long orderId, Long inboundId, String inboundCode, String processStatus) {
}
}
@@ -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;
@@ -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<Row> 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;
}
}
@@ -17,9 +17,10 @@ public interface LoadingPickItemMapper extends BaseMapper<LoadingPickItemEntity>
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
""")
@@ -24,19 +24,63 @@ public class InboundCartonLabelRepository {
.last("FOR UPDATE"));
}
public List<InboundCartonLabelEntity> findByInbound(long tenantId, long inboundId) {
public InboundOrderEntity findByOrder(long tenantId, long orderId) {
return inbounds.selectOne(new LambdaQueryWrapper<InboundOrderEntity>()
.eq(InboundOrderEntity::getTenantId, tenantId)
.eq(InboundOrderEntity::getOrderId, orderId)
.eq(InboundOrderEntity::getDeleted, false)
.orderByDesc(InboundOrderEntity::getCreatedAt)
.last("LIMIT 1"));
}
public List<InboundCartonLabelEntity> findActiveByInbound(long tenantId, long inboundId) {
return labels.selectList(new LambdaQueryWrapper<InboundCartonLabelEntity>()
.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<InboundCartonLabelEntity> findByInbound(long tenantId, long inboundId) {
return findActiveByInbound(tenantId, inboundId);
}
public InboundCartonLabelEntity findByBoxCode(long tenantId, String boxCode) {
return labels.selectOne(new LambdaQueryWrapper<InboundCartonLabelEntity>()
.eq(InboundCartonLabelEntity::getTenantId, tenantId)
.eq(InboundCartonLabelEntity::getBoxCode, boxCode));
}
public int nextVersion(long tenantId, long inboundId) {
InboundCartonLabelEntity latest = labels.selectOne(new LambdaQueryWrapper<InboundCartonLabelEntity>()
.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<InboundCartonLabelEntity>()
.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<InboundCartonLabelEntity>()
.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<InboundOrderEntity>()
.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<InboundOrderEntity>()
.eq(InboundOrderEntity::getTenantId, tenantId)
.eq(InboundOrderEntity::getId, inboundId)
.set(InboundOrderEntity::getTotalPackageCount, packageCount)
.setSql("version = version + 1"));
}
public void insert(InboundCartonLabelEntity label) {
labels.insert(label);
}
@@ -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<InboundOutboxEntity> pending(int limit, LocalDateTime now) {
return mapper.selectList(new LambdaQueryWrapper<InboundOutboxEntity>()
.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<InboundOutboxEntity>()
.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<InboundOutboxEntity> base(long id, String status) { return new LambdaUpdateWrapper<InboundOutboxEntity>()
.eq(InboundOutboxEntity::getId,id).eq(InboundOutboxEntity::getEventType,EventTypes.FIRST_LEG_LOADING_PLAN_COMPLETED)
.eq(InboundOutboxEntity::getPublishStatus,status); }
private LambdaUpdateWrapper<InboundOutboxEntity> 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; }
}
@@ -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<byte[]> generateByOrder(long tenantId, long orderId,
List<Integer> cartonIndexes, String requestId) {
return generate(tenantId, labelService.requireInboundIdByOrder(tenantId, orderId),
cartonIndexes, requestId);
}
public ResponseEntity<byte[]> generateByOrders(long tenantId, List<Long> 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<InboundCartonLabels.Label> labels = new ArrayList<>();
List<String> 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<byte[]> 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<byte[]> generatePdf(String fileName, List<InboundCartonLabels.Label> 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, "打印服务暂不可用,请稍后重试");
}
}
@@ -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<String> ALLOCATABLE_STATUSES = Set.of("WAIT_MEASURE", "MEASURING");
private static final Set<String> 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<InboundCartonLabelEntity> existing = new ArrayList<>(repository.findByInbound(tenantId, inboundId));
if (!existing.isEmpty()) {
List<InboundCartonLabelEntity> consistent = requireConsistent(existing);
freezeOriginalPackageCount(inbound, consistent.get(0).getTotalCartonCount());
return batch(inbound, consistent);
}
if (!ALLOCATABLE_STATUSES.contains(inbound.getProcessStatus())) {
List<InboundCartonLabelEntity> existing = new ArrayList<>(activeLabels(tenantId, inboundId));
List<InboundCartonLabelEntity> consistent = existing.isEmpty() ? List.of() : requireConsistent(existing);
if (existing.isEmpty() && !ALLOCATABLE_STATUSES.contains(inbound.getProcessStatus())) {
throw new BizException(ResultCode.CONFLICT, "当前入库单状态不允许首次生成箱号");
}
List<OrderCargoClient.CargoLabelBinding> 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<com.baiyue.firstleg.dto.OrderApprovedInboundCommand> 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<Integer> 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<InboundCartonLabelEntity> requireConsistent(List<InboundCartonLabelEntity> 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<InboundCartonLabelEntity> activeLabels(long tenantId, long inboundId) {
List<InboundCartonLabelEntity> 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<OrderCargoClient.CargoLabelBinding> 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, "入库单箱号映射不完整,请联系管理员");
}
@@ -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<InboundOrderEntity>()
.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<InboundOrderEntity>()
.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<InboundOrderEntity>()
.eq(InboundOrderEntity::getTenantId, tenantId)
@@ -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());
}
}
}
}
@@ -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<Row> 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<Row> rows) {
Row first = rows.get(0);
Map<Long, List<Row>> byInbound = rows.stream().collect(java.util.stream.Collectors.groupingBy(
Row::getSourceInboundId, java.util.TreeMap::new, java.util.stream.Collectors.toList()));
List<FirstLegLoadingPlanCompletedV1.Order> 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<Row> values) {
List<Row> sorted = values.stream().sorted(Comparator.comparing(Row::getSourceInboundDetailId)).toList();
Row first = sorted.get(0);
List<FirstLegLoadingPlanCompletedV1.Item> 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<Row> rows, java.util.function.Function<Row,Integer> getter) { return rows.stream().map(getter).filter(java.util.Objects::nonNull).mapToInt(Integer::intValue).sum(); }
private static Integer sumNullableInt(List<Row> rows, java.util.function.Function<Row,Integer> getter) { List<Integer> 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<Row> rows, java.util.function.Function<Row,BigDecimal> getter) { List<BigDecimal> values=rows.stream().map(getter).filter(java.util.Objects::nonNull).toList(); return values.isEmpty()?null:values.stream().reduce(BigDecimal.ZERO, BigDecimal::add); }
}
@@ -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) {
@@ -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;
@@ -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='首次打印前冻结的实际箱号与订单货物明细映射';
@@ -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='首次打印前冻结的实际箱号与订单货物明细映射';
@@ -0,0 +1,2 @@
-- 装柜完成跨区事件使用标准 sha256: 摘要,共 71 字符。
ALTER TABLE fl_inbound_outbox MODIFY COLUMN payload_hash VARCHAR(71) NULL COMMENT '事件规范化负载SHA-256;标准跨区事件含sha256:前缀';
@@ -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: 幂等初始化客户四箱规默认配置
@@ -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");
}
}
@@ -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<byte[]> 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,
@@ -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<InboundCartonLabelEntity> 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<InboundCartonLabelEntity> 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<InboundCartonLabelEntity> 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<InboundCartonLabelEntity> 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<InboundCartonLabelEntity> 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"));
@@ -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();
@@ -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<InboundOutboxEntity> captor=ArgumentCaptor.forClass(InboundOutboxEntity.class);
verify(outbox,org.mockito.Mockito.times(2)).insert(captor.capture());
List<InboundOutboxEntity> 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;
}
}
@@ -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
@@ -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());}}}
}
@@ -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<String> pendingIds(int limit) { return jdbc.queryForList("SELECT event_id FROM iam_security_event_outbox WHERE (status='PENDING' AND next_retry_at<=CURRENT_TIMESTAMP(3)) OR (status='SENDING' AND lease_until<=CURRENT_TIMESTAMP(3)) ORDER BY created_at LIMIT ?", String.class,limit); }
public boolean claim(String id,String owner) { return jdbc.update("UPDATE iam_security_event_outbox SET status='SENDING',lease_owner=?,lease_until=DATE_ADD(CURRENT_TIMESTAMP(3),INTERVAL 60 SECOND),attempt_count=attempt_count+1 WHERE event_id=? AND ((status='PENDING' AND next_retry_at<=CURRENT_TIMESTAMP(3)) OR (status='SENDING' AND lease_until<=CURRENT_TIMESTAMP(3)))",owner,id)==1; }
public Row find(String id) { return jdbc.queryForObject("SELECT event_id,event_type,payload_json,attempt_count FROM iam_security_event_outbox WHERE event_id=?",(rs,n)->new Row(rs.getString(1),rs.getString(2),rs.getString(3),rs.getInt(4)),id); }
public void sent(String id,String owner) { jdbc.update("UPDATE iam_security_event_outbox SET status='SENT',sent_at=CURRENT_TIMESTAMP(3),lease_owner=NULL,lease_until=NULL,last_error=NULL WHERE event_id=? AND lease_owner=?",id,owner); }
public void failed(String id,String owner,int attempts,String error) { jdbc.update("UPDATE iam_security_event_outbox SET status=IF(attempt_count>=8,'DEAD','PENDING'),next_retry_at=DATE_ADD(CURRENT_TIMESTAMP(3),INTERVAL ? SECOND),lease_owner=NULL,lease_until=NULL,last_error=? WHERE event_id=? AND lease_owner=?",Math.min(900,5*(1<<Math.min(attempts,7))),safe(error),id,owner); }
private String safe(String value) { if(value==null)return "投递失败"; return value.length()<=1000?value:value.substring(0,1000); }
public record Row(String eventId,String eventType,String payload,int attempts) { }
}
@@ -0,0 +1,23 @@
package com.baiyue.iam.securityevent;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.ObjectMapper;
import jakarta.annotation.Resource;
import org.springframework.stereotype.Service;
import java.time.Instant;
import java.util.LinkedHashMap;
import java.util.Map;
import java.util.UUID;
@Service
public class SecurityEventService {
public static final String USER_STATUS_CHANGED="IAM_USER_STATUS_CHANGED_V1";
public static final String ACCOUNT_STATUS_CHANGED="IAM_ACCOUNT_STATUS_CHANGED_V1";
public static final String PERMISSION_VERSION_CHANGED="IAM_PERMISSION_VERSION_CHANGED_V1";
@Resource private SecurityEventRepository repository;
@Resource private ObjectMapper json;
public void userStatus(long authUserId,long tenantId,long membershipId,String status){append(USER_STATUS_CHANGED,"USER",authUserId,Map.of("authUserId",authUserId,"tenantId",tenantId,"membershipId",membershipId,"status",status));}
public void accountStatus(long accountId,String status){append(ACCOUNT_STATUS_CHANGED,"ACCOUNT",accountId,Map.of("accountId",accountId,"status",status));}
public long permissionsChanged(long authUserId,long tenantId,long membershipId,String reason){long version=repository.incrementPermissionVersion(authUserId);append(PERMISSION_VERSION_CHANGED,"AUTHORIZATION",authUserId,Map.of("authUserId",authUserId,"tenantId",tenantId,"membershipId",membershipId,"permissionVersion",version,"reason",reason));return version;}
private void append(String type,String aggregateType,long aggregateId,Map<String,Object> fields){String id=UUID.randomUUID().toString();Map<String,Object> payload=new LinkedHashMap<>();payload.put("eventId",id);payload.put("eventType",type);payload.put("schemaVersion",1);payload.put("occurredAt",Instant.now().toString());payload.putAll(fields);try{repository.append(id,type,aggregateType,Long.toString(aggregateId),json.writeValueAsString(payload));}catch(JsonProcessingException ex){throw new IllegalStateException("安全事件序列化失败",ex);}}
}
@@ -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可靠安全事件本地发送箱';
@@ -17,6 +17,15 @@ public interface FirstlegInboundClient {
ApiResponse<Long> create(@RequestHeader("X-Internal-Service-Token") String token,
@RequestBody OrderApprovedInboundCommand command);
@PostMapping("/order-preparing")
ApiResponse<Long> prepare(@RequestHeader("X-Internal-Service-Token") String token,
@RequestBody OrderApprovedInboundCommand command);
@PostMapping("/order-preparing/batch")
ApiResponse<List<InboundReference>> prepareBatch(
@RequestHeader("X-Internal-Service-Token") String token,
@RequestBody List<OrderApprovedInboundCommand> commands);
@PostMapping("/order-references")
ApiResponse<List<InboundReference>> references(
@RequestHeader("X-Internal-Service-Token") String token,
@@ -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<FirstlegInboundClient.OrderApprovedInboundCommand> 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, "内部服务凭证无效");
@@ -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<CargoColumn> 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) {
}
}
@@ -46,11 +46,21 @@ public class MerchantInboundOrderListService {
.stream().collect(Collectors.toMap(
OrderStatusHistoryEntity::getOrderId, Function.identity(), (latest, ignored) -> latest));
Map<Long, FirstlegInboundClient.InboundReference> inboundReferences = references(tenantId, orderIds);
Map<Long, FirstlegInboundClient.InboundReference> initialReferences = inboundReferences;
List<OrderEntity> 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<Long, FirstlegInboundClient.InboundReference> finalReferences = inboundReferences;
List<MerchantInboundOrderRow> 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<Long, FirstlegInboundClient.InboundReference> repairedReferences(
long tenantId, Map<Long, FirstlegInboundClient.InboundReference> current,
List<OrderEntity> missing, Map<Long, List<OrderCargoEntity>> cargoes) {
try {
List<FirstlegInboundClient.OrderApprovedInboundCommand> 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<Long, FirstlegInboundClient.InboundReference> 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<OrderCargoEntity> 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);
}
@@ -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<FirstlegInboundClient> 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<Long> 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)
@@ -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
@@ -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();
@@ -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;
}
}