From 75d5fd4e26db160c19744d67e8e06253a2296ee7 Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Mon, 10 Aug 2026 12:23:43 +0800 Subject: [PATCH] =?UTF-8?q?feat(phase3):=20=E6=94=B6=E5=8F=A3=E7=88=B6?= =?UTF-8?q?=E5=AD=90=E5=8D=95=E4=B8=8E=E5=85=A8=E9=93=BE=E8=B7=AF=E9=AA=8C?= =?UTF-8?q?=E6=94=B6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../auth/client/AuthorizationLoader.java | 2 +- .../com/baiyue/auth/service/AuthService.java | 12 +- .../auth/service/TokenSessionStore.java | 33 ++- .../auth/service/TokenSessionStoreTest.java | 9 +- .../common/security/AccessTokenSession.java | 15 ++ .../security/AccessTokenSessionValidator.java | 2 +- .../BaiyueSecurityAutoConfiguration.java | 3 +- .../security/JwtAuthenticationFilter.java | 6 +- .../common/security/JwtClaimsMapper.java | 12 +- .../RedisAccessTokenSessionValidator.java | 30 ++- .../common/security/JwtClaimsMapperTest.java | 20 +- .../RedisAccessTokenSessionValidatorTest.java | 42 +++- deploy/sql/头程数据库统一增量-MySQL8.sql | 226 ++++++++++++++++++ .../PlatformInboundMeasurementController.java | 2 +- .../dto/CartonMeasurementCommand.java | 3 +- .../firstleg/dto/CartonMeasurementResult.java | 1 + .../dto/InboundMeasurementRequests.java | 1 + .../dto/InboundMeasurementResponses.java | 1 + .../firstleg/dto/MojiaMeasurementDtos.java | 4 + .../entity/InboundMeasurementItemEntity.java | 1 + .../entity/MojiaMeasurementReceiptEntity.java | 1 + .../service/CartonMeasurementService.java | 9 +- .../service/InboundMeasurementService.java | 3 +- .../MojiaMeasurementOpenApiService.java | 3 +- .../resources/db/phase2-inbound-schema.sql | 2 + .../main/resources/openapi/firstleg-v1.yaml | 3 +- ...oundMeasurementControllerContractTest.java | 2 + .../PlatformCartonScanControllerTest.java | 7 +- ...ojiaMeasurementReceiptSqlContractTest.java | 2 + .../service/CartonMeasurementServiceTest.java | 12 +- .../MojiaMeasurementOpenApiServiceTest.java | 27 ++- .../baiyue/iam/repository/RbacRepository.java | 10 + .../com/baiyue/iam/service/MemberService.java | 4 + .../service/PlatformInternalUserService.java | 14 +- .../PlatformUserManagementService.java | 5 + .../com/baiyue/iam/service/RbacService.java | 6 + .../com/baiyue/order/entity/OrderEntity.java | 4 + .../entity/OrderFulfillmentSplitEntity.java | 34 +++ .../mapper/OrderFulfillmentSplitMapper.java | 8 + order/src/main/resources/db/order.sql | 38 +++ .../db/phase3-order-parent-child-schema.sql | 155 ++++++++++++ scripts/admin-test-env-acceptance.mjs | 20 +- .../inbound-measurement.mjs | 10 +- .../phase3-dispatch-gate.mjs | 27 ++- .../phase3-evidence-gate.mjs | 36 ++- .../phase3-loading-finalize.mjs | 75 ++++++ .../phase3-loading-model-bridge.mjs | 3 - .../admin-test-env-modules/phase3-loading.mjs | 52 ++-- .../phase3-order-splitting.mjs | 30 +++ .../phase3-outbound-aggregate.mjs | 57 ++++- .../phase3-outbound-dispatch.mjs | 14 +- .../phase3-pda-convergence.mjs | 15 +- .../phase3-port-schedules.mjs | 15 +- .../phase3-waiting-for-loading.mjs | 34 ++- .../service-center-flow.mjs | 63 ++++- 55 files changed, 1110 insertions(+), 115 deletions(-) create mode 100644 common-security/src/main/java/com/baiyue/common/security/AccessTokenSession.java create mode 100644 order/src/main/java/com/baiyue/order/entity/OrderFulfillmentSplitEntity.java create mode 100644 order/src/main/java/com/baiyue/order/mapper/OrderFulfillmentSplitMapper.java create mode 100644 order/src/main/resources/db/phase3-order-parent-child-schema.sql create mode 100644 scripts/admin-test-env-modules/phase3-loading-finalize.mjs create mode 100644 scripts/admin-test-env-modules/phase3-order-splitting.mjs diff --git a/auth/src/main/java/com/baiyue/auth/client/AuthorizationLoader.java b/auth/src/main/java/com/baiyue/auth/client/AuthorizationLoader.java index 9e4f51a9d..5d69f230d 100644 --- a/auth/src/main/java/com/baiyue/auth/client/AuthorizationLoader.java +++ b/auth/src/main/java/com/baiyue/auth/client/AuthorizationLoader.java @@ -13,7 +13,7 @@ import org.springframework.stereotype.Component; import java.util.Objects; /** - * 授权快照加载器,调用 IAM 服务并校验返回身份上下文,为认证令牌组装权限声明。 + * 授权快照加载器,调用 IAM 服务并校验返回身份上下文,为 Redis 会话组装授权快照。 */ @Component public class AuthorizationLoader { diff --git a/auth/src/main/java/com/baiyue/auth/service/AuthService.java b/auth/src/main/java/com/baiyue/auth/service/AuthService.java index dea140479..eafdb1401 100644 --- a/auth/src/main/java/com/baiyue/auth/service/AuthService.java +++ b/auth/src/main/java/com/baiyue/auth/service/AuthService.java @@ -32,7 +32,7 @@ import java.util.UUID; import java.time.Duration; /** - * 统一认证应用服务,编排密码校验、IAM 权限快照加载、JWT 签发、刷新令牌轮换与注销事务。 + * 统一认证应用服务,编排密码校验、IAM 权限快照加载、小 JWT 签发、刷新令牌轮换与注销事务。 */ @Service public class AuthService { @@ -117,8 +117,6 @@ public class AuthService { .claim("membership_id", account.getDefaultMembershipId()) .claim("app_code", account.getDefaultAppCode()) .claim("client_type", authorization.getClientType()) - .claim("roles", authorization.getRoles()) - .claim("permissions", authorization.getPermissions()) .claim("token_version", account.getTokenVersion()) .claim("password_change_required", Boolean.TRUE.equals(account.getPasswordChangeRequired())) .build(); @@ -128,7 +126,7 @@ public class AuthService { byte[] random = new byte[48]; secureRandom.nextBytes(random); String rawRefresh = Base64.getUrlEncoder().withoutPadding().encodeToString(random); - tokenSessionStore.store(jwtId, account.getId(), rawRefresh, + tokenSessionStore.store(jwtId, account.getId(), authorization.getRoles(), authorization.getPermissions(), rawRefresh, properties.accessTokenTtl(), properties.refreshTokenTtl()); return new TokenResponse("Bearer", accessToken, properties.accessTokenTtl().toSeconds(), rawRefresh, properties.refreshTokenTtl().toSeconds()); @@ -174,13 +172,13 @@ public class AuthService { .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("roles", authorization.getRoles()) - .claim("permissions", authorization.getPermissions()).claim("token_version", account.getTokenVersion()) + .claim("client_type", authorization.getClientType()).claim("token_version", 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(); - tokenSessionStore.storeAccessOnly(jwtId, account.getId(), Duration.ofMinutes(15)); + tokenSessionStore.storeAccessOnly(jwtId, account.getId(), authorization.getRoles(), + authorization.getPermissions(), Duration.ofMinutes(15)); return new ImpersonationTokenResponse("Bearer", accessToken, 900); } diff --git a/auth/src/main/java/com/baiyue/auth/service/TokenSessionStore.java b/auth/src/main/java/com/baiyue/auth/service/TokenSessionStore.java index 4ef5cdb2f..fbfefb41b 100644 --- a/auth/src/main/java/com/baiyue/auth/service/TokenSessionStore.java +++ b/auth/src/main/java/com/baiyue/auth/service/TokenSessionStore.java @@ -41,13 +41,15 @@ public class TokenSessionStore { keyPrefix = RedisTokenKeys.normalize(keyPrefix); } - public void store(String jwtId, Long userId, String rawRefreshToken, - Duration accessTtl, Duration refreshTtl) { + public void store(String jwtId, Long userId, Set roles, Set permissions, + String rawRefreshToken, Duration accessTtl, Duration refreshTtl) { String accessKey = RedisTokenKeys.access(keyPrefix, jwtId); String refreshKey = RedisTokenKeys.refresh(keyPrefix, sha256(rawRefreshToken)); String userKey = userSessionsKey(userId); try { - redisTemplate.opsForValue().set(accessKey, userId.toString(), accessTtl); + String accessValue = objectMapper.writeValueAsString( + new AccessSession(1, userId, normalized(roles), normalized(permissions))); + redisTemplate.opsForValue().set(accessKey, accessValue, accessTtl); String refreshValue = objectMapper.writeValueAsString(new RefreshSession(userId, jwtId)); redisTemplate.opsForValue().set(refreshKey, refreshValue, refreshTtl); SetOperations sets = redisTemplate.opsForSet(); @@ -59,10 +61,13 @@ public class TokenSessionStore { } } - public void storeAccessOnly(String jwtId, Long userId, Duration ttl) { + public void storeAccessOnly(String jwtId, Long userId, Set roles, + Set permissions, Duration ttl) { try { String accessKey = RedisTokenKeys.access(keyPrefix, jwtId); - redisTemplate.opsForValue().set(accessKey, userId.toString(), ttl); + String accessValue = objectMapper.writeValueAsString( + new AccessSession(1, userId, normalized(roles), normalized(permissions))); + redisTemplate.opsForValue().set(accessKey, accessValue, ttl); redisTemplate.opsForSet().add(userSessionsKey(userId), accessKey); redisTemplate.expire(userSessionsKey(userId), ttl); } catch (Exception exception) { @@ -134,7 +139,7 @@ public class TokenSessionStore { while (cursor.hasNext()) { String key = cursor.next(); String value = redisTemplate.opsForValue().get(key); - if ((!refresh && userId.toString().equals(value)) || (refresh && belongsTo(value, userId))) { + if ((!refresh && accessBelongsTo(value, userId)) || (refresh && belongsTo(value, userId))) { result.add(key); } } @@ -149,6 +154,16 @@ public class TokenSessionStore { catch (Exception ignored) { return false; } } + private boolean accessBelongsTo(String value, Long userId) { + if (value == null || value.isBlank()) return false; + try { return userId.equals(objectMapper.readValue(value, AccessSession.class).userId()); } + catch (Exception ignored) { return false; } + } + + private static Set normalized(Set values) { + return values == null ? Set.of() : Set.copyOf(values); + } + private String userSessionsKey(Long userId) { return keyPrefix + ":user-sessions:" + userId; } @@ -168,6 +183,12 @@ public class TokenSessionStore { } public record RefreshSession(Long userId, String accessJwtId) { } + public record AccessSession(int version, Long userId, Set roles, Set permissions) { + public AccessSession { + roles = normalized(roles); + permissions = normalized(permissions); + } + } public record ImpersonationTicket(String ticketId, Long targetAuthUserId, Long tenantId, Long membershipId, Long actorUserId, Long actorMembershipId, String reason) { } diff --git a/auth/src/test/java/com/baiyue/auth/service/TokenSessionStoreTest.java b/auth/src/test/java/com/baiyue/auth/service/TokenSessionStoreTest.java index 35a6f8c18..a18fc513d 100644 --- a/auth/src/test/java/com/baiyue/auth/service/TokenSessionStoreTest.java +++ b/auth/src/test/java/com/baiyue/auth/service/TokenSessionStoreTest.java @@ -46,9 +46,14 @@ class TokenSessionStoreTest { ValueOperations values = mock(ValueOperations.class); when(redis.opsForValue()).thenReturn(values); - store.store("jti-1", 42L, "raw-refresh", Duration.ofMinutes(30), Duration.ofDays(7)); + store.store("jti-1", 42L, Set.of("ADMIN"), Set.of("order:read"), + "raw-refresh", Duration.ofMinutes(30), Duration.ofDays(7)); - verify(values).set("baiyue:auth:access:jti-1", "42", Duration.ofMinutes(30)); + verify(values).set(eq("baiyue:auth:access:jti-1"), + org.mockito.ArgumentMatchers.argThat(value -> value.contains("\"version\":1") + && value.contains("\"userId\":42") + && value.contains("ADMIN") && value.contains("order:read")), + eq(Duration.ofMinutes(30))); verify(values).set(org.mockito.ArgumentMatchers.startsWith("baiyue:auth:refresh:"), org.mockito.ArgumentMatchers.contains("\"userId\":42"), org.mockito.ArgumentMatchers.eq(Duration.ofDays(7))); } diff --git a/common-security/src/main/java/com/baiyue/common/security/AccessTokenSession.java b/common-security/src/main/java/com/baiyue/common/security/AccessTokenSession.java new file mode 100644 index 000000000..b2615691f --- /dev/null +++ b/common-security/src/main/java/com/baiyue/common/security/AccessTokenSession.java @@ -0,0 +1,15 @@ +package com.baiyue.common.security; + +import java.util.Set; + +/** Server-side access session and its current authorization snapshot. */ +public record AccessTokenSession(int version, Long userId, Set roles, + Set permissions) { + public AccessTokenSession { + if (version != 1 || userId == null || userId <= 0) { + throw new IllegalArgumentException("Invalid access token session"); + } + roles = roles == null ? Set.of() : Set.copyOf(roles); + permissions = permissions == null ? Set.of() : Set.copyOf(permissions); + } +} diff --git a/common-security/src/main/java/com/baiyue/common/security/AccessTokenSessionValidator.java b/common-security/src/main/java/com/baiyue/common/security/AccessTokenSessionValidator.java index 6b431251e..e621e6f50 100644 --- a/common-security/src/main/java/com/baiyue/common/security/AccessTokenSessionValidator.java +++ b/common-security/src/main/java/com/baiyue/common/security/AccessTokenSessionValidator.java @@ -7,5 +7,5 @@ import org.springframework.security.oauth2.jwt.Jwt; */ @FunctionalInterface public interface AccessTokenSessionValidator { - void validate(Jwt jwt); + AccessTokenSession validate(Jwt jwt); } diff --git a/common-security/src/main/java/com/baiyue/common/security/BaiyueSecurityAutoConfiguration.java b/common-security/src/main/java/com/baiyue/common/security/BaiyueSecurityAutoConfiguration.java index 65199266b..d001a92e1 100644 --- a/common-security/src/main/java/com/baiyue/common/security/BaiyueSecurityAutoConfiguration.java +++ b/common-security/src/main/java/com/baiyue/common/security/BaiyueSecurityAutoConfiguration.java @@ -48,9 +48,10 @@ public class BaiyueSecurityAutoConfiguration { JwtAuthenticationFilter jwtAuthenticationFilter(JwtDecoder jwtDecoder, JwtClaimsMapper claimsMapper, AuthenticationEntryPoint entryPoint, StringRedisTemplate redisTemplate, + ObjectMapper objectMapper, @Value("${baiyue.security.token-key-prefix:baiyue:auth}") String keyPrefix) { return new JwtAuthenticationFilter(jwtDecoder, claimsMapper, entryPoint, - new RedisAccessTokenSessionValidator(redisTemplate, keyPrefix)); + new RedisAccessTokenSessionValidator(redisTemplate, keyPrefix, objectMapper)); } @Bean diff --git a/common-security/src/main/java/com/baiyue/common/security/JwtAuthenticationFilter.java b/common-security/src/main/java/com/baiyue/common/security/JwtAuthenticationFilter.java index ececf08fa..d77643366 100644 --- a/common-security/src/main/java/com/baiyue/common/security/JwtAuthenticationFilter.java +++ b/common-security/src/main/java/com/baiyue/common/security/JwtAuthenticationFilter.java @@ -27,7 +27,7 @@ public class JwtAuthenticationFilter extends OncePerRequestFilter { public JwtAuthenticationFilter(JwtDecoder jwtDecoder, JwtClaimsMapper claimsMapper, AuthenticationEntryPoint authenticationEntryPoint) { - this(jwtDecoder, claimsMapper, authenticationEntryPoint, jwt -> { }); + this(jwtDecoder, claimsMapper, authenticationEntryPoint, jwt -> null); } public JwtAuthenticationFilter(JwtDecoder jwtDecoder, JwtClaimsMapper claimsMapper, @@ -55,8 +55,8 @@ public class JwtAuthenticationFilter extends OncePerRequestFilter { String tokenValue = header.substring(BEARER.length()).trim(); try { Jwt jwt = jwtDecoder.decode(tokenValue); - sessionValidator.validate(jwt); - Authentication authentication = claimsMapper.map(jwt); + AccessTokenSession session = sessionValidator.validate(jwt); + Authentication authentication = claimsMapper.map(jwt, session); SecurityContextHolder.getContext().setAuthentication(authentication); filterChain.doFilter(request, response); } catch (JwtException | IllegalArgumentException exception) { diff --git a/common-security/src/main/java/com/baiyue/common/security/JwtClaimsMapper.java b/common-security/src/main/java/com/baiyue/common/security/JwtClaimsMapper.java index e8de88771..31f0faa9a 100644 --- a/common-security/src/main/java/com/baiyue/common/security/JwtClaimsMapper.java +++ b/common-security/src/main/java/com/baiyue/common/security/JwtClaimsMapper.java @@ -13,11 +13,15 @@ import java.util.Set; */ public class JwtClaimsMapper { public BaiyueJwtAuthenticationToken map(Jwt jwt) { + return map(jwt, null); + } + + public BaiyueJwtAuthenticationToken map(Jwt jwt, AccessTokenSession session) { Set permissions = new LinkedHashSet<>(); - addClaimValues(permissions, jwt.getClaims().get("authorities")); - addClaimValues(permissions, jwt.getClaims().get("permissions")); - addClaimValues(permissions, jwt.getClaims().get("scope")); - addRoles(permissions, jwt.getClaims().get("roles")); + if (session != null) { + permissions.addAll(session.permissions()); + addRoles(permissions, session.roles()); + } String appCode = jwt.getClaimAsString("app_code"); if (appCode != null && !appCode.isBlank()) { diff --git a/common-security/src/main/java/com/baiyue/common/security/RedisAccessTokenSessionValidator.java b/common-security/src/main/java/com/baiyue/common/security/RedisAccessTokenSessionValidator.java index 66098791f..394cecb5d 100644 --- a/common-security/src/main/java/com/baiyue/common/security/RedisAccessTokenSessionValidator.java +++ b/common-security/src/main/java/com/baiyue/common/security/RedisAccessTokenSessionValidator.java @@ -2,6 +2,7 @@ package com.baiyue.common.security; import org.springframework.data.redis.core.StringRedisTemplate; import org.springframework.security.oauth2.jwt.Jwt; +import com.fasterxml.jackson.databind.ObjectMapper; /** * Redis 访问令牌会话校验器,根据 JWT ID 判断登录会话是否仍然有效。 @@ -9,24 +10,49 @@ import org.springframework.security.oauth2.jwt.Jwt; public final class RedisAccessTokenSessionValidator implements AccessTokenSessionValidator { private final StringRedisTemplate redisTemplate; private final String keyPrefix; + private final ObjectMapper objectMapper; public RedisAccessTokenSessionValidator(StringRedisTemplate redisTemplate, String keyPrefix) { + this(redisTemplate, keyPrefix, new ObjectMapper()); + } + + public RedisAccessTokenSessionValidator(StringRedisTemplate redisTemplate, String keyPrefix, + ObjectMapper objectMapper) { this.redisTemplate = redisTemplate; this.keyPrefix = RedisTokenKeys.normalize(keyPrefix); + this.objectMapper = objectMapper; } @Override - public void validate(Jwt jwt) { + public AccessTokenSession validate(Jwt jwt) { String jwtId = jwt.getId(); if (jwtId == null || jwtId.isBlank()) { throw new IllegalArgumentException("Access token has no jti"); } try { - if (!Boolean.TRUE.equals(redisTemplate.hasKey(RedisTokenKeys.access(keyPrefix, jwtId)))) { + String value = redisTemplate.opsForValue().get(RedisTokenKeys.access(keyPrefix, jwtId)); + if (value == null || value.isBlank()) { throw new IllegalArgumentException("Access token session is revoked or expired"); } + AccessTokenSession session = objectMapper.readValue(value, AccessTokenSession.class); + Long userId = longClaim(jwt, "user_id"); + if (userId == null || !userId.equals(session.userId())) { + throw new IllegalArgumentException("Access token session identity mismatch"); + } + return session; } catch (org.springframework.dao.DataAccessException exception) { throw new IllegalArgumentException("Token session store is unavailable", exception); + } catch (IllegalArgumentException exception) { + throw exception; + } catch (Exception exception) { + throw new IllegalArgumentException("Invalid access token session", exception); } } + + private Long longClaim(Jwt jwt, String name) { + Object value = jwt.getClaims().get(name); + if (value instanceof Number number) return number.longValue(); + if (value instanceof String text) try { return Long.valueOf(text); } catch (NumberFormatException ignored) { } + return null; + } } diff --git a/common-security/src/test/java/com/baiyue/common/security/JwtClaimsMapperTest.java b/common-security/src/test/java/com/baiyue/common/security/JwtClaimsMapperTest.java index 025fdffdb..3189cd48a 100644 --- a/common-security/src/test/java/com/baiyue/common/security/JwtClaimsMapperTest.java +++ b/common-security/src/test/java/com/baiyue/common/security/JwtClaimsMapperTest.java @@ -27,13 +27,31 @@ class JwtClaimsMapperTest { .claim("roles", List.of("ADMIN")) .build(); - BaiyueJwtAuthenticationToken authentication = new JwtClaimsMapper().map(jwt); + AccessTokenSession session = new AccessTokenSession(1, 42L, + java.util.Set.of("ADMIN"), java.util.Set.of("order:read", "order:create")); + BaiyueJwtAuthenticationToken authentication = new JwtClaimsMapper().map(jwt, session); assertThat(authentication.getPrincipal().userId()).isEqualTo(42L); assertThat(authentication.getPrincipal().tenantId()).isEqualTo(7L); assertThat(authentication.getAuthorities()).extracting("authority") .contains("order:read", "order:create", "ROLE_ADMIN", "APP_MERCHANT"); } + @Test + void ignoresAuthorizationClaimsInJwt() { + Jwt jwt = Jwt.withTokenValue("token").header("alg", "none").subject("42") + .issuedAt(Instant.now()).expiresAt(Instant.now().plusSeconds(60)) + .claim("user_id", 42L).claim("tenant_id", 0L).claim("app_code", "PLATFORM") + .claim("permissions", List.of("platform:forged")) + .claim("roles", List.of("FORGED")).build(); + + BaiyueJwtAuthenticationToken authentication = new JwtClaimsMapper().map(jwt, + new AccessTokenSession(1, 42L, java.util.Set.of(), java.util.Set.of("platform:real"))); + + assertThat(authentication.getAuthorities()).extracting("authority") + .contains("platform:real", "APP_PLATFORM") + .doesNotContain("platform:forged", "ROLE_FORGED"); + } + @Test void fallsBackToNumericSubjectForUserId() { Jwt jwt = Jwt.withTokenValue("token") diff --git a/common-security/src/test/java/com/baiyue/common/security/RedisAccessTokenSessionValidatorTest.java b/common-security/src/test/java/com/baiyue/common/security/RedisAccessTokenSessionValidatorTest.java index b8f8b9db2..1f75a9232 100644 --- a/common-security/src/test/java/com/baiyue/common/security/RedisAccessTokenSessionValidatorTest.java +++ b/common-security/src/test/java/com/baiyue/common/security/RedisAccessTokenSessionValidatorTest.java @@ -4,6 +4,7 @@ import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; import org.springframework.data.redis.RedisConnectionFailureException; import org.springframework.data.redis.core.StringRedisTemplate; +import org.springframework.data.redis.core.ValueOperations; import org.springframework.security.oauth2.jwt.Jwt; import java.time.Instant; @@ -18,7 +19,10 @@ class RedisAccessTokenSessionValidatorTest { @Test void acceptsActiveSession() { StringRedisTemplate redis = mock(StringRedisTemplate.class); - when(redis.hasKey("baiyue:auth:access:jti-1")).thenReturn(true); + ValueOperations values = mock(ValueOperations.class); + when(redis.opsForValue()).thenReturn(values); + when(values.get("baiyue:auth:access:jti-1")) + .thenReturn("{\"version\":1,\"userId\":1,\"roles\":[\"ADMIN\"],\"permissions\":[\"order:read\"]}"); assertThatCode(() -> new RedisAccessTokenSessionValidator(redis, "baiyue:auth") .validate(jwt("jti-1"))).doesNotThrowAnyException(); @@ -27,7 +31,9 @@ class RedisAccessTokenSessionValidatorTest { @Test void rejectsRevokedSession() { StringRedisTemplate redis = mock(StringRedisTemplate.class); - when(redis.hasKey("baiyue:auth:access:jti-1")).thenReturn(false); + ValueOperations values = mock(ValueOperations.class); + when(redis.opsForValue()).thenReturn(values); + when(values.get("baiyue:auth:access:jti-1")).thenReturn(null); assertThatThrownBy(() -> new RedisAccessTokenSessionValidator(redis, "baiyue:auth") .validate(jwt("jti-1"))) @@ -38,7 +44,9 @@ class RedisAccessTokenSessionValidatorTest { @Test void failsClosedWhenRedisIsUnavailable() { StringRedisTemplate redis = mock(StringRedisTemplate.class); - when(redis.hasKey("baiyue:auth:access:jti-1")) + ValueOperations values = mock(ValueOperations.class); + when(redis.opsForValue()).thenReturn(values); + when(values.get("baiyue:auth:access:jti-1")) .thenThrow(new RedisConnectionFailureException("offline")); assertThatThrownBy(() -> new RedisAccessTokenSessionValidator(redis, "baiyue:auth") @@ -47,9 +55,37 @@ class RedisAccessTokenSessionValidatorTest { .hasMessageContaining("unavailable"); } + @Test + void rejectsSessionForAnotherUser() { + StringRedisTemplate redis = mock(StringRedisTemplate.class); + ValueOperations values = mock(ValueOperations.class); + when(redis.opsForValue()).thenReturn(values); + when(values.get("baiyue:auth:access:jti-1")) + .thenReturn("{\"version\":1,\"userId\":2,\"roles\":[],\"permissions\":[]}"); + + assertThatThrownBy(() -> new RedisAccessTokenSessionValidator(redis, "baiyue:auth") + .validate(jwt("jti-1"))) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("identity mismatch"); + } + + @Test + void rejectsMalformedSessionJson() { + StringRedisTemplate redis = mock(StringRedisTemplate.class); + ValueOperations values = mock(ValueOperations.class); + when(redis.opsForValue()).thenReturn(values); + when(values.get("baiyue:auth:access:jti-1")).thenReturn("not-json"); + + assertThatThrownBy(() -> new RedisAccessTokenSessionValidator(redis, "baiyue:auth") + .validate(jwt("jti-1"))) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("Invalid access token session"); + } + private Jwt jwt(String jwtId) { return Jwt.withTokenValue("token").header("alg", "none") .subject("1").claim("jti", jwtId).issuedAt(Instant.now()) + .claim("user_id", 1L) .expiresAt(Instant.now().plusSeconds(60)).build(); } } diff --git a/deploy/sql/头程数据库统一增量-MySQL8.sql b/deploy/sql/头程数据库统一增量-MySQL8.sql index 5d16acae9..f09c53b52 100644 --- a/deploy/sql/头程数据库统一增量-MySQL8.sql +++ b/deploy/sql/头程数据库统一增量-MySQL8.sql @@ -8766,6 +8766,232 @@ DROP PROCEDURE IF EXISTS assert_phase3_pda_item_assignment_schema; ALTER TABLE fl_inbound_pallet_allocation ADD COLUMN IF NOT EXISTS reservation_id BIGINT UNSIGNED NULL COMMENT '仓库预占资源唯一ID,A0共享场景用于区分托盘' AFTER location_code; +/* Phase 3: six-carrier schedule query tasks, truthful manual fallback and three-day rate limit. */ +USE baiyue_firstleg; +CREATE TABLE IF NOT EXISTS fl_carrier_schedule_query ( + id BIGINT UNSIGNED NOT NULL, + query_no VARCHAR(40) NOT NULL, + customer_id BIGINT UNSIGNED NOT NULL COMMENT '当前产品客户ID', + ocean_booking_id BIGINT UNSIGNED NOT NULL, + container_booking_id BIGINT UNSIGNED NULL, + container_scope_id BIGINT UNSIGNED GENERATED ALWAYS AS (COALESCE(container_booking_id,0)) STORED, + carrier_code VARCHAR(16) NOT NULL COMMENT 'MATSON/SML/ZIM/COSCO/EMC/HEDE', + tracking_type VARCHAR(32) NOT NULL COMMENT 'BOOKING_NO/BILL_OF_LADING/CONTAINER_NO', + tracking_no VARCHAR(128) NOT NULL, + official_url VARCHAR(500) NOT NULL, + access_mode VARCHAR(32) NOT NULL COMMENT 'PUBLIC_FORM/HCAPTCHA/DYNAMIC_IFRAME', + status VARCHAR(24) NOT NULL COMMENT 'READY/MANUAL_REQUIRED/SUCCESS', + window_started_at DATETIME(3) NOT NULL, + attempt_count INT UNSIGNED NOT NULL DEFAULT 0, + next_allowed_at DATETIME(3) NOT NULL, + last_attempt_at DATETIME(3) NULL, + last_success_at DATETIME(3) NULL, + source_updated_at DATETIME(3) NULL, + expires_at DATETIME(3) NULL, + result_port_schedule_id BIGINT UNSIGNED NULL, + last_attempt_status VARCHAR(24) NULL, + manual_reason VARCHAR(1000) NULL, + last_error_code VARCHAR(64) NULL, + last_error_message VARCHAR(1000) NULL, + remark VARCHAR(1000) NULL, + created_by BIGINT UNSIGNED NOT NULL, + created_by_name VARCHAR(64) NULL, + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + updated_by BIGINT UNSIGNED NOT NULL, + updated_by_name VARCHAR(64) NULL, + updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3), + version INT UNSIGNED NOT NULL DEFAULT 0, + PRIMARY KEY (id), + UNIQUE KEY uk_carrier_schedule_query_no (query_no), + UNIQUE KEY uk_carrier_schedule_query_scope + (customer_id,ocean_booking_id,container_scope_id,carrier_code,tracking_type,tracking_no), + KEY idx_carrier_schedule_query_due (status,next_allowed_at,id), + KEY idx_carrier_schedule_query_customer (customer_id,updated_at,id), + KEY idx_carrier_schedule_query_expiry (expires_at,id), + CONSTRAINT fk_carrier_schedule_query_booking FOREIGN KEY (ocean_booking_id) REFERENCES fl_ocean_booking(id), + CONSTRAINT fk_carrier_schedule_query_container FOREIGN KEY (container_booking_id) REFERENCES fl_container_booking(id), + CONSTRAINT fk_carrier_schedule_query_result FOREIGN KEY (result_port_schedule_id) REFERENCES fl_port_schedule(id), + CONSTRAINT chk_carrier_schedule_query_customer CHECK (customer_id > 0), + CONSTRAINT chk_carrier_schedule_query_carrier CHECK + (carrier_code IN ('MATSON','SML','ZIM','COSCO','EMC','HEDE')), + CONSTRAINT chk_carrier_schedule_query_tracking CHECK + (tracking_type IN ('BOOKING_NO','BILL_OF_LADING','CONTAINER_NO')), + CONSTRAINT chk_carrier_schedule_query_access CHECK + (access_mode IN ('PUBLIC_FORM','HCAPTCHA','DYNAMIC_IFRAME')), + CONSTRAINT chk_carrier_schedule_query_status CHECK + (status IN ('READY','MANUAL_REQUIRED','SUCCESS')), + CONSTRAINT chk_carrier_schedule_query_attempt CHECK (attempt_count BETWEEN 0 AND 3) +) ENGINE=InnoDB COMMENT='六家船公司官网查询任务与三天窗口频控'; + +CREATE TABLE IF NOT EXISTS fl_carrier_schedule_query_attempt ( + id BIGINT UNSIGNED NOT NULL, + query_id BIGINT UNSIGNED NOT NULL, + window_attempt_no INT UNSIGNED NOT NULL, + carrier_code VARCHAR(16) NOT NULL, + access_mode VARCHAR(32) NOT NULL, + official_url VARCHAR(500) NOT NULL, + attempt_status VARCHAR(24) NOT NULL, + coverage_gap TINYINT(1) NOT NULL DEFAULT 0, + coverage_gap_code VARCHAR(64) NULL, + manual_reason VARCHAR(1000) NULL, + error_code VARCHAR(64) NULL, + error_message VARCHAR(1000) NULL, + operator_id BIGINT UNSIGNED NOT NULL, + operator_name VARCHAR(64) NULL, + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + PRIMARY KEY (id), + KEY idx_carrier_schedule_attempt_query (query_id,created_at,id), + CONSTRAINT fk_carrier_schedule_attempt_query FOREIGN KEY (query_id) REFERENCES fl_carrier_schedule_query(id), + CONSTRAINT chk_carrier_schedule_attempt_no CHECK (window_attempt_no BETWEEN 1 AND 3), + CONSTRAINT chk_carrier_schedule_attempt_status CHECK + (attempt_status IN ('MANUAL_REQUIRED','SUCCESS','FAILED')) +) ENGINE=InnoDB COMMENT='船司官网查询尝试不可变审计'; + +CREATE TABLE IF NOT EXISTS fl_carrier_schedule_query_history ( + id BIGINT UNSIGNED NOT NULL, + query_id BIGINT UNSIGNED NOT NULL, + action VARCHAR(24) NOT NULL, + from_version INT UNSIGNED NULL, + to_version INT UNSIGNED NOT NULL, + reason VARCHAR(1000) NULL, + before_snapshot JSON NULL, + after_snapshot JSON NOT NULL, + operator_id BIGINT UNSIGNED NOT NULL, + operator_name VARCHAR(64) NULL, + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + PRIMARY KEY (id), + KEY idx_carrier_schedule_history_query (query_id,created_at,id), + CONSTRAINT fk_carrier_schedule_history_query FOREIGN KEY (query_id) REFERENCES fl_carrier_schedule_query(id), + CONSTRAINT chk_carrier_schedule_history_action CHECK (action IN ('CREATE','ATTEMPT','MANUAL_RESULT')) +) ENGINE=InnoDB COMMENT='船司查询任务不可变历史'; + +CREATE TABLE IF NOT EXISTS fl_carrier_schedule_query_operation ( + id BIGINT UNSIGNED NOT NULL, + query_id BIGINT UNSIGNED NULL, + operator_id BIGINT UNSIGNED NOT NULL, + operation VARCHAR(24) NOT NULL, + idempotency_key VARCHAR(128) NOT NULL, + request_hash CHAR(64) NOT NULL, + status VARCHAR(16) NOT NULL DEFAULT 'PROCESSING', + result_json JSON NULL, + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + completed_at DATETIME(3) NULL, + PRIMARY KEY (id), + UNIQUE KEY uk_carrier_schedule_operation (operator_id,idempotency_key), + KEY idx_carrier_schedule_operation_query (query_id,created_at,id), + CONSTRAINT fk_carrier_schedule_operation_query FOREIGN KEY (query_id) REFERENCES fl_carrier_schedule_query(id), + CONSTRAINT chk_carrier_schedule_operation_status CHECK (status IN ('PROCESSING','SUCCESS')) +) ENGINE=InnoDB COMMENT='船司查询写操作幂等与请求摘要'; + +DROP TRIGGER IF EXISTS trg_carrier_schedule_query_before_insert; +DELIMITER $$ +CREATE TRIGGER trg_carrier_schedule_query_before_insert BEFORE INSERT ON fl_carrier_schedule_query FOR EACH ROW +BEGIN + IF NOT EXISTS (SELECT 1 FROM fl_ocean_booking booking + WHERE booking.id=NEW.ocean_booking_id AND booking.customer_id=NEW.customer_id) THEN + SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='船司查询客户与订舱不一致'; + END IF; + IF NEW.container_booking_id IS NOT NULL AND NOT EXISTS ( + SELECT 1 FROM fl_ocean_booking_container_allocation allocation + WHERE allocation.ocean_booking_id=NEW.ocean_booking_id + AND allocation.container_booking_id=NEW.container_booking_id + ) THEN + SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='船司查询柜次未分配至订舱'; + END IF; +END$$ +DELIMITER ; + +DROP TRIGGER IF EXISTS trg_carrier_schedule_attempt_before_update; +DROP TRIGGER IF EXISTS trg_carrier_schedule_attempt_before_delete; +DROP TRIGGER IF EXISTS trg_carrier_schedule_history_before_update; +DROP TRIGGER IF EXISTS trg_carrier_schedule_history_before_delete; +DELIMITER $$ +CREATE TRIGGER trg_carrier_schedule_attempt_before_update BEFORE UPDATE ON fl_carrier_schedule_query_attempt +FOR EACH ROW BEGIN SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='船司查询尝试不可修改'; END$$ +CREATE TRIGGER trg_carrier_schedule_attempt_before_delete BEFORE DELETE ON fl_carrier_schedule_query_attempt +FOR EACH ROW BEGIN SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='船司查询尝试不可删除'; END$$ +CREATE TRIGGER trg_carrier_schedule_history_before_update BEFORE UPDATE ON fl_carrier_schedule_query_history +FOR EACH ROW BEGIN SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='船司查询历史不可修改'; END$$ +CREATE TRIGGER trg_carrier_schedule_history_before_delete BEFORE DELETE ON fl_carrier_schedule_query_history +FOR EACH ROW BEGIN SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='船司查询历史不可删除'; END$$ +DELIMITER ; + +USE baiyue_iam; +DROP PROCEDURE IF EXISTS assert_carrier_schedule_query_permission_ids; +DELIMITER $$ +CREATE PROCEDURE assert_carrier_schedule_query_permission_ids() +BEGIN + IF EXISTS ( + SELECT 1 FROM iam_permission + WHERE (id BETWEEN 5102184 AND 5102188 + AND permission_code <> CASE id + WHEN 5102184 THEN 'platform:firstleg:port-schedule-query:list' + WHEN 5102185 THEN 'platform:firstleg:port-schedule-query:view' + WHEN 5102186 THEN 'platform:firstleg:port-schedule-query:create' + WHEN 5102187 THEN 'platform:firstleg:port-schedule-query:attempt' + WHEN 5102188 THEN 'platform:firstleg:port-schedule-query:manual' + END) + OR (permission_code LIKE 'platform:firstleg:port-schedule-query:%' + AND (tenant_id<>0 OR app_code<>'PLATFORM' OR id NOT BETWEEN 5102184 AND 5102188)) + ) THEN + SIGNAL SQLSTATE '45000' + SET MESSAGE_TEXT='三期船司查询权限迁移失败:5102184-5102188 ID或权限码被占用'; + END IF; +END$$ +DELIMITER ; +CALL assert_carrier_schedule_query_permission_ids(); +DROP PROCEDURE IF EXISTS assert_carrier_schedule_query_permission_ids; + +INSERT INTO iam_permission + (id,tenant_id,app_code,permission_type,parent_id,permission_code,permission_name, + route_path,http_method,sort_no,status,built_in,deleted) +VALUES + (5102184,0,'PLATFORM','BUTTON',5102180,'platform:firstleg:port-schedule-query:list','查询船司任务',NULL,'GET',40,'ACTIVE',1,0), + (5102185,0,'PLATFORM','BUTTON',5102180,'platform:firstleg:port-schedule-query:view','查看船司任务',NULL,'GET',50,'ACTIVE',1,0), + (5102186,0,'PLATFORM','BUTTON',5102180,'platform:firstleg:port-schedule-query:create','创建船司任务',NULL,'POST',60,'ACTIVE',1,0), + (5102187,0,'PLATFORM','BUTTON',5102180,'platform:firstleg:port-schedule-query:attempt','执行船司查询',NULL,'POST',70,'ACTIVE',1,0), + (5102188,0,'PLATFORM','BUTTON',5102180,'platform:firstleg:port-schedule-query:manual','录入人工结果',NULL,'POST',80,'ACTIVE',1,0) +ON DUPLICATE KEY UPDATE tenant_id=0,app_code='PLATFORM',permission_type=VALUES(permission_type), + permission_code=VALUES(permission_code),permission_name=VALUES(permission_name),parent_id=VALUES(parent_id), + route_path=VALUES(route_path),http_method=VALUES(http_method),sort_no=VALUES(sort_no), + status='ACTIVE',built_in=1,deleted=0; + +INSERT IGNORE INTO iam_role_permission(role_id,permission_id,tenant_id) +SELECT role.id,permission.id,0 FROM iam_role role JOIN iam_permission permission + ON permission.tenant_id=0 AND permission.app_code='PLATFORM' + AND permission.permission_code LIKE 'platform:firstleg:port-schedule-query:%' +WHERE role.tenant_id=0 AND role.role_code IN ('PLATFORM_ADMIN','ORDER_OPERATOR'); + +USE baiyue_firstleg; +DROP PROCEDURE IF EXISTS assert_carrier_schedule_query_schema; +DELIMITER $$ +CREATE PROCEDURE assert_carrier_schedule_query_schema() +BEGIN + IF (SELECT COUNT(*) FROM information_schema.tables + WHERE table_schema='baiyue_firstleg' AND table_name IN + ('fl_carrier_schedule_query','fl_carrier_schedule_query_attempt', + 'fl_carrier_schedule_query_history','fl_carrier_schedule_query_operation')) <> 4 THEN + SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='船司查询任务表未完整创建'; + END IF; + IF (SELECT COUNT(*) FROM baiyue_iam.iam_permission + WHERE tenant_id=0 AND id BETWEEN 5102184 AND 5102188 + AND permission_code LIKE 'platform:firstleg:port-schedule-query:%') <> 5 THEN + SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='船司查询任务权限未完整创建'; + END IF; + IF (SELECT COUNT(*) + FROM baiyue_iam.iam_role_permission role_permission + JOIN baiyue_iam.iam_role role ON role.id=role_permission.role_id + JOIN baiyue_iam.iam_permission permission ON permission.id=role_permission.permission_id + WHERE role.tenant_id=0 AND role.role_code IN ('PLATFORM_ADMIN','ORDER_OPERATOR') + AND permission.tenant_id=0 + AND permission.permission_code LIKE 'platform:firstleg:port-schedule-query:%') <> 10 THEN + SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='船司查询任务角色授权未完整创建'; + END IF; +END$$ +DELIMITER ; +CALL assert_carrier_schedule_query_schema(); +DROP PROCEDURE IF EXISTS assert_carrier_schedule_query_schema; + -- 三期仓库常用角色初始化。模块来源:iam/src/main/resources/db/phase3-warehouse-roles.sql。 -- 三期仓库常用系统角色:仓库操作员、仓库拣货员、仓库理货员。 -- 本脚本只组合已经存在的权限资源,需在三期 Firstleg/IAM 权限脚本之后执行,可重复部署。 diff --git a/firstleg/src/main/java/com/baiyue/firstleg/controller/PlatformInboundMeasurementController.java b/firstleg/src/main/java/com/baiyue/firstleg/controller/PlatformInboundMeasurementController.java index 6b57ef921..1b9b672ce 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/controller/PlatformInboundMeasurementController.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/controller/PlatformInboundMeasurementController.java @@ -115,7 +115,7 @@ public class PlatformInboundMeasurementController { requirePlatformTenant(user, tenantId); CartonMeasurementCommand command = new CartonMeasurementCommand( request.boxCode(), request.length(), request.width(), request.height(), request.weight(), - request.machineNo(), request.measuredAt(), image); + request.machineNo(), request.measuredAt(), image, request.scannedShipmentId()); return ApiResponse.success(cartonMeasurements.upsertForInbound( tenantId, user.userId(), inboundId, command)); } diff --git a/firstleg/src/main/java/com/baiyue/firstleg/dto/CartonMeasurementCommand.java b/firstleg/src/main/java/com/baiyue/firstleg/dto/CartonMeasurementCommand.java index d15d0dd24..7cc75ad55 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/dto/CartonMeasurementCommand.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/dto/CartonMeasurementCommand.java @@ -14,6 +14,7 @@ public record CartonMeasurementCommand( BigDecimal unitWeightKg, String machineNo, LocalDateTime measuredAt, - MultipartFile image + MultipartFile image, + String scannedShipmentId ) { } diff --git a/firstleg/src/main/java/com/baiyue/firstleg/dto/CartonMeasurementResult.java b/firstleg/src/main/java/com/baiyue/firstleg/dto/CartonMeasurementResult.java index 49404e3cd..34997f196 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/dto/CartonMeasurementResult.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/dto/CartonMeasurementResult.java @@ -9,6 +9,7 @@ public record CartonMeasurementResult( Integer measurementVersion, Long itemId, String boxCode, + String scannedShipmentId, Integer confirmedPackageCount, BigDecimal lengthCm, BigDecimal widthCm, diff --git a/firstleg/src/main/java/com/baiyue/firstleg/dto/InboundMeasurementRequests.java b/firstleg/src/main/java/com/baiyue/firstleg/dto/InboundMeasurementRequests.java index c07cf2003..6721a62d5 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/dto/InboundMeasurementRequests.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/dto/InboundMeasurementRequests.java @@ -71,6 +71,7 @@ public final class InboundMeasurementRequests { @NotNull @DecimalMin("0.001") @Digits(integer = 9, fraction = 3) BigDecimal width, @NotNull @DecimalMin("0.001") @Digits(integer = 9, fraction = 3) BigDecimal height, @DecimalMin("0.001") @Digits(integer = 15, fraction = 3) BigDecimal weight, + @Size(max = 64) String scannedShipmentId, @NotBlank @Size(max = 64) String machineNo, @NotNull LocalDateTime measuredAt ) { diff --git a/firstleg/src/main/java/com/baiyue/firstleg/dto/InboundMeasurementResponses.java b/firstleg/src/main/java/com/baiyue/firstleg/dto/InboundMeasurementResponses.java index 1c9856c96..7ea2f6080 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/dto/InboundMeasurementResponses.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/dto/InboundMeasurementResponses.java @@ -67,6 +67,7 @@ public final class InboundMeasurementResponses { BigDecimal outerHeightCm, String remark, String boxCode, + String scannedShipmentId, Integer confirmedPackageCount, String machineNo, LocalDateTime deviceMeasuredAt, diff --git a/firstleg/src/main/java/com/baiyue/firstleg/dto/MojiaMeasurementDtos.java b/firstleg/src/main/java/com/baiyue/firstleg/dto/MojiaMeasurementDtos.java index 53211a804..ff8e0bd65 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/dto/MojiaMeasurementDtos.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/dto/MojiaMeasurementDtos.java @@ -1,5 +1,7 @@ package com.baiyue.firstleg.dto; +import com.fasterxml.jackson.annotation.JsonAlias; +import com.fasterxml.jackson.annotation.JsonProperty; import jakarta.validation.constraints.DecimalMax; import jakarta.validation.constraints.DecimalMin; import jakarta.validation.constraints.NotBlank; @@ -16,6 +18,8 @@ public final class MojiaMeasurementDtos { public record UploadRequest( @NotBlank @Size(max = 128) String boxCode, + @JsonProperty("shipment_id") @JsonAlias("shipmentId") + @NotBlank @Size(max = 64) String shipmentId, @NotNull @DecimalMin("0.001") @DecimalMax("999999999.999") BigDecimal length, @NotNull @DecimalMin("0.001") @DecimalMax("999999999.999") BigDecimal width, @NotNull @DecimalMin("0.001") @DecimalMax("999999999.999") BigDecimal height, diff --git a/firstleg/src/main/java/com/baiyue/firstleg/entity/InboundMeasurementItemEntity.java b/firstleg/src/main/java/com/baiyue/firstleg/entity/InboundMeasurementItemEntity.java index 86ac1c100..5cd87dcf4 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/entity/InboundMeasurementItemEntity.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/entity/InboundMeasurementItemEntity.java @@ -21,6 +21,7 @@ public class InboundMeasurementItemEntity { private Long inboundDetailId; private String externalDetailNo; private String boxCode; + private String scannedShipmentId; private String itemNo; private String boxSpecification; private Integer packageCount; diff --git a/firstleg/src/main/java/com/baiyue/firstleg/entity/MojiaMeasurementReceiptEntity.java b/firstleg/src/main/java/com/baiyue/firstleg/entity/MojiaMeasurementReceiptEntity.java index 6960316b0..cae614d9c 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/entity/MojiaMeasurementReceiptEntity.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/entity/MojiaMeasurementReceiptEntity.java @@ -19,6 +19,7 @@ public class MojiaMeasurementReceiptEntity { private Long tenantId; private Long subjectId; private String boxCode; + private String scannedShipmentId; private BigDecimal lengthCm; private BigDecimal widthCm; private BigDecimal heightCm; diff --git a/firstleg/src/main/java/com/baiyue/firstleg/service/CartonMeasurementService.java b/firstleg/src/main/java/com/baiyue/firstleg/service/CartonMeasurementService.java index 301f37b2f..27a43a37d 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/service/CartonMeasurementService.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/service/CartonMeasurementService.java @@ -177,6 +177,8 @@ public class CartonMeasurementService { item.setInboundDetailId(binding.inboundDetailId()); item.setExternalDetailNo(boxCode); item.setBoxCode(boxCode); + item.setScannedShipmentId(StringUtils.hasText(command.scannedShipmentId()) + ? command.scannedShipmentId().trim() : null); item.setItemNo("B" + binding.cartonIndex()); item.setBoxSpecification(StringUtils.hasText(binding.boxSpecification()) ? binding.boxSpecification().trim() : boxCode); @@ -380,6 +382,10 @@ public class CartonMeasurementService { if (!StringUtils.hasText(command.machineNo()) || command.machineNo().trim().length() > 64) { throw new BizException(ResultCode.VALIDATION_FAILED, "设备编号不能为空且不能超过64个字符"); } + if (StringUtils.hasText(command.scannedShipmentId()) + && command.scannedShipmentId().trim().length() > 64) { + throw new BizException(ResultCode.VALIDATION_FAILED, "实扫Shipment ID不能超过64个字符"); + } if (command.measuredAt() == null) { throw new BizException(ResultCode.VALIDATION_FAILED, "设备测量时间不能为空"); } @@ -404,7 +410,8 @@ public class CartonMeasurementService { private CartonMeasurementResult result(InboundMeasurementEntity measurement, InboundMeasurementItemEntity item, boolean created) { return new CartonMeasurementResult(measurement.getId(), measurement.getVersion(), item.getId(), - item.getBoxCode(), item.getPackageCount(), item.getLengthCm(), item.getWidthCm(), + item.getBoxCode(), item.getScannedShipmentId(), item.getPackageCount(), + item.getLengthCm(), item.getWidthCm(), item.getHeightCm(), item.getUnitWeightKg(), item.getSourceImageFileId(), item.getMachineNo(), item.getDeviceMeasuredAt(), created); } diff --git a/firstleg/src/main/java/com/baiyue/firstleg/service/InboundMeasurementService.java b/firstleg/src/main/java/com/baiyue/firstleg/service/InboundMeasurementService.java index 5a39acb2b..defc22b2c 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/service/InboundMeasurementService.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/service/InboundMeasurementService.java @@ -367,7 +367,8 @@ public class InboundMeasurementService { item.getUnitWeightKg(), item.getUnitVolumeCbm(), item.getTotalWeightKg(), item.getTotalVolumeCbm(), item.getVolumeWeightKg(), item.getSourceImageFileId(), item.getOverLimitWarning(), item.getOuterLengthCm(), item.getOuterWidthCm(), - item.getOuterHeightCm(), item.getRemark(), item.getBoxCode(), item.getPackageCount(), + item.getOuterHeightCm(), item.getRemark(), item.getBoxCode(), item.getScannedShipmentId(), + item.getPackageCount(), item.getMachineNo(), item.getDeviceMeasuredAt(), item.getUpdatedAt()); } diff --git a/firstleg/src/main/java/com/baiyue/firstleg/service/MojiaMeasurementOpenApiService.java b/firstleg/src/main/java/com/baiyue/firstleg/service/MojiaMeasurementOpenApiService.java index 04634abf7..ecbb8e1da 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/service/MojiaMeasurementOpenApiService.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/service/MojiaMeasurementOpenApiService.java @@ -79,7 +79,7 @@ public class MojiaMeasurementOpenApiService { app.tenantId(), app.subjectId(), new CartonMeasurementCommand( request.boxCode(), request.length(), request.width(), request.height(), request.weight(), request.machineNo(), request.measuredAt() == null ? LocalDateTime.now() : request.measuredAt(), - image)); + image, request.shipmentId().trim())); } catch (BizException exception) { if (exception.getResultCode() == ResultCode.NOT_FOUND) { return saveUnmatched(app, request, image, exception); @@ -117,6 +117,7 @@ public class MojiaMeasurementOpenApiService { receipt.setTenantId(app.tenantId()); receipt.setSubjectId(app.subjectId()); receipt.setBoxCode(request.boxCode().trim()); + receipt.setScannedShipmentId(request.shipmentId().trim()); receipt.setLengthCm(request.length()); receipt.setWidthCm(request.width()); receipt.setHeightCm(request.height()); diff --git a/firstleg/src/main/resources/db/phase2-inbound-schema.sql b/firstleg/src/main/resources/db/phase2-inbound-schema.sql index cbc2f1264..52474e8c0 100644 --- a/firstleg/src/main/resources/db/phase2-inbound-schema.sql +++ b/firstleg/src/main/resources/db/phase2-inbound-schema.sql @@ -203,6 +203,7 @@ CREATE TABLE IF NOT EXISTS fl_inbound_measurement_item ( inbound_detail_id BIGINT UNSIGNED NULL COMMENT '绑定的订单货物明细ID,平台手工明细可为空', external_detail_no VARCHAR(128) NULL COMMENT '第三方测量明细唯一号,平台手工明细可为空', box_code VARCHAR(128) NULL COMMENT '柏悦唛头箱号;设备单箱测量时同一客户内唯一', + scanned_shipment_id VARCHAR(64) NULL COMMENT '设备从Amazon FBA箱标实扫的Shipment ID', item_no VARCHAR(32) NOT NULL COMMENT '批次内明细号', box_specification VARCHAR(64) NOT NULL COMMENT '箱规标识', package_count INT UNSIGNED NOT NULL COMMENT '相同箱规箱数', @@ -240,6 +241,7 @@ CREATE TABLE IF NOT EXISTS fl_mojia_measurement_receipt ( tenant_id BIGINT UNSIGNED NOT NULL COMMENT '客户隔离ID', subject_id BIGINT UNSIGNED NOT NULL COMMENT '设备应用操作主体ID', box_code VARCHAR(128) NOT NULL COMMENT '设备扫描的原始箱码', + scanned_shipment_id VARCHAR(64) NULL COMMENT '设备从Amazon FBA箱标实扫的Shipment ID', length_cm DECIMAL(12,3) NOT NULL COMMENT '设备实测长度cm', width_cm DECIMAL(12,3) NOT NULL COMMENT '设备实测宽度cm', height_cm DECIMAL(12,3) NOT NULL COMMENT '设备实测高度cm', diff --git a/firstleg/src/main/resources/openapi/firstleg-v1.yaml b/firstleg/src/main/resources/openapi/firstleg-v1.yaml index 7a97f1d3c..3ccfacc58 100644 --- a/firstleg/src/main/resources/openapi/firstleg-v1.yaml +++ b/firstleg/src/main/resources/openapi/firstleg-v1.yaml @@ -239,9 +239,10 @@ paths: application/json: schema: type: object - required: [boxCode, length, width, height, machineNo, picture] + required: [boxCode, shipment_id, length, width, height, machineNo, picture] properties: boxCode: {type: string, maxLength: 128, example: 'RK-2085911321565003778#4-1'} + shipment_id: {type: string, maxLength: 64, example: 'FBA19L5KMY2CU000002', description: '设备从Amazon FBA箱标实际扫描得到;系统只保存,不生成且不覆盖客户下单值'} length: {type: number, format: decimal, minimum: 0.001} width: {type: number, format: decimal, minimum: 0.001} height: {type: number, format: decimal, minimum: 0.001} diff --git a/firstleg/src/test/java/com/baiyue/firstleg/controller/OpenApiInboundMeasurementControllerContractTest.java b/firstleg/src/test/java/com/baiyue/firstleg/controller/OpenApiInboundMeasurementControllerContractTest.java index e64702a44..64d1af51a 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/controller/OpenApiInboundMeasurementControllerContractTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/controller/OpenApiInboundMeasurementControllerContractTest.java @@ -57,6 +57,8 @@ class OpenApiInboundMeasurementControllerContractTest { .contains("Nonce") .contains("inboundDetailId") .contains("multipart/form-data") + .contains("required: [boxCode, shipment_id, length, width, height, machineNo, picture]") + .contains("shipment_id:") .contains("不改变入库单状态"); } } diff --git a/firstleg/src/test/java/com/baiyue/firstleg/controller/PlatformCartonScanControllerTest.java b/firstleg/src/test/java/com/baiyue/firstleg/controller/PlatformCartonScanControllerTest.java index 575390695..dc7af145c 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/controller/PlatformCartonScanControllerTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/controller/PlatformCartonScanControllerTest.java @@ -48,12 +48,14 @@ class PlatformCartonScanControllerTest { .setCustomArgumentResolvers(authenticationPrincipal(user)) .build(); when(cartonMeasurements.upsertForInbound(eq(10001L), eq(90001L), eq(70001L), any())) - .thenReturn(new CartonMeasurementResult(30001L, 1, 40001L, "RK-001#5-1", 1, + .thenReturn(new CartonMeasurementResult(30001L, 1, 40001L, "RK-001#5-1", + "FBA-SCAN-001", 1, new BigDecimal("60.1"), new BigDecimal("40.2"), new BigDecimal("30.3"), new BigDecimal("12.5"), 99001L, "SIM-01", LocalDateTime.parse("2026-08-07T10:30:00"), true)); MockPart data = new MockPart("data", "data", (""" - {"boxCode":"RK-001#5-1","length":60.1,"width":40.2,"height":30.3, + {"boxCode":"RK-001#5-1","scannedShipmentId":"FBA-SCAN-001", + "length":60.1,"width":40.2,"height":30.3, "weight":12.5,"machineNo":"SIM-01","measuredAt":"2026-08-07T10:30:00"} """).getBytes(java.nio.charset.StandardCharsets.UTF_8)); data.getHeaders().setContentType(MediaType.APPLICATION_JSON); @@ -70,6 +72,7 @@ class PlatformCartonScanControllerTest { verify(cartonMeasurements).upsertForInbound( eq(10001L), eq(90001L), eq(70001L), command.capture()); assertThat(command.getValue().machineNo()).isEqualTo("SIM-01"); + assertThat(command.getValue().scannedShipmentId()).isEqualTo("FBA-SCAN-001"); assertThat(command.getValue().image().getOriginalFilename()).isEqualTo("carton.jpg"); } diff --git a/firstleg/src/test/java/com/baiyue/firstleg/repository/MojiaMeasurementReceiptSqlContractTest.java b/firstleg/src/test/java/com/baiyue/firstleg/repository/MojiaMeasurementReceiptSqlContractTest.java index c2ea4b43f..6cf89ad8f 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/repository/MojiaMeasurementReceiptSqlContractTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/repository/MojiaMeasurementReceiptSqlContractTest.java @@ -22,6 +22,7 @@ class MojiaMeasurementReceiptSqlContractTest { assertThat(sql) .contains("CREATE TABLE IF NOT EXISTS fl_mojia_measurement_receipt") .contains("box_code VARCHAR(128) NOT NULL") + .contains("scanned_shipment_id VARCHAR(64) NULL") .contains("image_file_id BIGINT UNSIGNED NOT NULL") .contains("status VARCHAR(24) NOT NULL DEFAULT 'UNMATCHED'") .contains("idx_mojia_receipt_status_time") @@ -29,6 +30,7 @@ class MojiaMeasurementReceiptSqlContractTest { } assertThat(unified) .contains("墨家未匹配测量收件箱字段未完整创建") + .contains("箱级测量实扫Shipment ID字段未创建") .contains("测量计算规则版本字段长度不是64"); } } diff --git a/firstleg/src/test/java/com/baiyue/firstleg/service/CartonMeasurementServiceTest.java b/firstleg/src/test/java/com/baiyue/firstleg/service/CartonMeasurementServiceTest.java index 5848c0599..f6e4641a8 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/service/CartonMeasurementServiceTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/service/CartonMeasurementServiceTest.java @@ -86,6 +86,8 @@ class CartonMeasurementServiceTest { assertThat(result.created()).isTrue(); assertThat(result.confirmedPackageCount()).isEqualTo(1); assertThat(result.boxCode()).isEqualTo("RK-001#5-1"); + assertThat(result.scannedShipmentId()).isEqualTo("FBA-SCAN-002"); + assertThat(insertedItem.get().getScannedShipmentId()).isEqualTo("FBA-SCAN-002"); assertThat(insertedItem.get().getMeasurementId()).isEqualTo(30001L); assertThat(insertedItem.get().getTotalVolumeCbm()).isEqualByComparingTo("0.1870"); assertThat(insertedItem.get().getOuterLengthCm()).isEqualByComparingTo("64.000"); @@ -112,6 +114,7 @@ class CartonMeasurementServiceTest { assertThat(existing.getTotalVolumeCbm()).isEqualByComparingTo("0.7480"); assertThat(existing.getVolumeWeightKg()).isEqualByComparingTo("124.916"); assertThat(existing.getSourceImageFileId()).isEqualTo(99001L); + assertThat(existing.getScannedShipmentId()).isEqualTo("FBA-SCAN-002"); verify(images).delete("internal-token", 90001L, 10001L, 70001L, 88001L); } @@ -126,7 +129,8 @@ class CartonMeasurementServiceTest { "RK-001#5-1", new BigDecimal("60.100"), new BigDecimal("50.100"), new BigDecimal("50.100"), new BigDecimal("12.500"), "MJ-01", LocalDateTime.parse("2026-08-07T10:30:00"), - new MockMultipartFile("image", "measure.jpg", "image/jpeg", "image".getBytes())); + new MockMultipartFile("image", "measure.jpg", "image/jpeg", "image".getBytes()), + "FBA-SCAN-001"); CartonMeasurementResult result = service.upsert(10001L, 90001L, oversize); @@ -253,7 +257,8 @@ class CartonMeasurementServiceTest { return new CartonMeasurementCommand("RK-001#5-1", new BigDecimal("59.100"), new BigDecimal("40.200"), new BigDecimal("30.300"), new BigDecimal("12.500"), "MJ-01", LocalDateTime.parse("2026-08-07T10:30:00"), - new MockMultipartFile("image", "measure.jpg", "image/jpeg", "image".getBytes())); + new MockMultipartFile("image", "measure.jpg", "image/jpeg", "image".getBytes()), + "FBA-SCAN-001"); } private CartonMeasurementCommand acceptanceCommand() { @@ -261,7 +266,8 @@ class CartonMeasurementServiceTest { "RK-001#5-1", new BigDecimal("59.500"), new BigDecimal("41.200"), new BigDecimal("31.300"), new BigDecimal("13.600"), "MJ-ADMIN-E2E-02", LocalDateTime.parse("2026-08-08T10:01:00"), - new MockMultipartFile("image", "measure.jpg", "image/jpeg", "image".getBytes())); + new MockMultipartFile("image", "measure.jpg", "image/jpeg", "image".getBytes()), + "FBA-SCAN-002"); } private InboundOrderEntity inbound(String status) { diff --git a/firstleg/src/test/java/com/baiyue/firstleg/service/MojiaMeasurementOpenApiServiceTest.java b/firstleg/src/test/java/com/baiyue/firstleg/service/MojiaMeasurementOpenApiServiceTest.java index 25c25141f..8d06d7392 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/service/MojiaMeasurementOpenApiServiceTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/service/MojiaMeasurementOpenApiServiceTest.java @@ -6,6 +6,7 @@ import com.baiyue.common.core.exception.BizException; import com.baiyue.firstleg.client.MeasurementImageClient; import com.baiyue.firstleg.dto.CartonMeasurementCommand; import com.baiyue.firstleg.dto.CartonMeasurementResult; +import com.baiyue.firstleg.dto.MojiaMeasurementDtos.UploadRequest; import com.baiyue.firstleg.dto.MojiaMeasurementDtos.UploadResponse; import com.baiyue.firstleg.entity.MojiaMeasurementReceiptEntity; import com.baiyue.firstleg.repository.MojiaMeasurementReceiptRepository; @@ -21,6 +22,7 @@ import org.springframework.test.util.ReflectionTestUtils; import java.math.BigDecimal; import java.time.LocalDateTime; import java.util.Map; +import java.util.Set; import static org.assertj.core.api.Assertions.assertThat; import static org.mockito.ArgumentMatchers.any; @@ -30,6 +32,23 @@ import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; class MojiaMeasurementOpenApiServiceTest { + @Test + void shipmentIdIsRequiredAndCamelCaseAliasIsAccepted() throws Exception { + ObjectMapper mapper = new ObjectMapper().findAndRegisterModules(); + var validator = Validation.buildDefaultValidatorFactory().getValidator(); + + UploadRequest camelCase = mapper.readValue( + json().replace("\"shipment_id\"", "\"shipmentId\""), UploadRequest.class); + UploadRequest missing = mapper.readValue( + json().replace("\"shipment_id\":\"FBA19L5KMY2CU000002\",", ""), UploadRequest.class); + + assertThat(camelCase.shipmentId()).isEqualTo("FBA19L5KMY2CU000002"); + assertThat(validator.validate(camelCase)).isEmpty(); + assertThat(validator.validate(missing).stream() + .map(violation -> violation.getPropertyPath().toString()).collect(java.util.stream.Collectors.toSet())) + .isEqualTo(Set.of("shipmentId")); + } + @Test void fixedAppKeyJsonIsConvertedToTheExistingCartonMeasurementFlow() throws Exception { MeasurementRequestAuthenticator authenticator = mock(MeasurementRequestAuthenticator.class); @@ -43,7 +62,8 @@ class MojiaMeasurementOpenApiServiceTest { when(authenticator.authenticate("POST", MojiaMeasurementOpenApiService.CALLBACK_PATH, headers, json(), false)).thenReturn(new AuthenticatedMeasurementApp("mojia", 10001L, 90001L, Map.of())); when(cartons.upsert(eq(10001L), eq(90001L), any())).thenReturn(new CartonMeasurementResult( - 1L, 1, 2L, "RK-001#4-1", 1, new BigDecimal("60.1"), new BigDecimal("40.2"), + 1L, 1, 2L, "RK-001#4-1", "FBA19L5KMY2CU000002", 1, + new BigDecimal("60.1"), new BigDecimal("40.2"), new BigDecimal("30.3"), new BigDecimal("12.5"), 3L, "MJ-01", LocalDateTime.now(), true)); UploadResponse result = service.receive(headers, json()); @@ -53,6 +73,7 @@ class MojiaMeasurementOpenApiServiceTest { ArgumentCaptor command = ArgumentCaptor.forClass(CartonMeasurementCommand.class); verify(cartons).upsert(eq(10001L), eq(90001L), command.capture()); assertThat(command.getValue().boxCode()).isEqualTo("RK-001#4-1"); + assertThat(command.getValue().scannedShipmentId()).isEqualTo("FBA19L5KMY2CU000002"); assertThat(command.getValue().lengthCm()).isEqualByComparingTo("60.1"); assertThat(command.getValue().image().getContentType()).isEqualTo("image/jpeg"); assertThat(command.getValue().image().getBytes()).containsExactly(0xff, 0xd8, 0xff, 0xd9); @@ -87,6 +108,7 @@ class MojiaMeasurementOpenApiServiceTest { ArgumentCaptor.forClass(MojiaMeasurementReceiptEntity.class); verify(receipts).insert(receipt.capture()); assertThat(receipt.getValue().getBoxCode()).isEqualTo("RK-001#4-1"); + assertThat(receipt.getValue().getScannedShipmentId()).isEqualTo("FBA19L5KMY2CU000002"); assertThat(receipt.getValue().getLengthCm()).isEqualByComparingTo("60.1"); assertThat(receipt.getValue().getImageFileId()).isEqualTo(3L); assertThat(receipt.getValue().getStatus()).isEqualTo("UNMATCHED"); @@ -94,7 +116,8 @@ class MojiaMeasurementOpenApiServiceTest { private String json() { return """ - {"boxCode":"RK-001#4-1","length":60.1,"width":40.2,"height":30.3, + {"boxCode":"RK-001#4-1","shipment_id":"FBA19L5KMY2CU000002", + "length":60.1,"width":40.2,"height":30.3, "weight":12.5,"machineNo":"MJ-01","picture":"data:image/jpeg;base64,/9j/2Q=="} """; } diff --git a/iam/src/main/java/com/baiyue/iam/repository/RbacRepository.java b/iam/src/main/java/com/baiyue/iam/repository/RbacRepository.java index 72699047c..c1507098e 100644 --- a/iam/src/main/java/com/baiyue/iam/repository/RbacRepository.java +++ b/iam/src/main/java/com/baiyue/iam/repository/RbacRepository.java @@ -64,6 +64,16 @@ public class RbacRepository { .eq(IamRolePermission::getPermissionId, permissionId)) > 0; } + public Set findRoleIdsByPermission(long tenantId, Long permissionId) { + List rows = rolePermissionMapper.selectList( + new LambdaQueryWrapper() + .eq(IamRolePermission::getTenantId, tenantId) + .eq(IamRolePermission::getPermissionId, permissionId)); + Set result = new LinkedHashSet<>(); + rows.forEach(row -> result.add(row.getRoleId())); + return result; + } + public void deletePermission(IamPermission permission) { permissionMapper.deleteById(permission.getId()); } public List findRoles(long tenantId, String appCode) { diff --git a/iam/src/main/java/com/baiyue/iam/service/MemberService.java b/iam/src/main/java/com/baiyue/iam/service/MemberService.java index c5a9f0396..627998a7c 100644 --- a/iam/src/main/java/com/baiyue/iam/service/MemberService.java +++ b/iam/src/main/java/com/baiyue/iam/service/MemberService.java @@ -133,6 +133,10 @@ public class MemberService { membership.setMemberType(request.memberType()); membership.setMemberNo(request.memberNo()); repository.saveUser(user); repository.save(membership); repository.replaceRoles(tenantId, membershipId, request.roleIds()); + if (!new HashSet<>(currentRoleIds).equals(new HashSet<>(request.roleIds()))) { + call(() -> authClient.revokeSessions(internalToken, + new AuthAccountClient.RevokeAccountSessionsRequest(Set.of(user.getAuthUserId())))); + } return response(tenantId, membership); } diff --git a/iam/src/main/java/com/baiyue/iam/service/PlatformInternalUserService.java b/iam/src/main/java/com/baiyue/iam/service/PlatformInternalUserService.java index 1cbdfd4c2..64b8df116 100644 --- a/iam/src/main/java/com/baiyue/iam/service/PlatformInternalUserService.java +++ b/iam/src/main/java/com/baiyue/iam/service/PlatformInternalUserService.java @@ -93,6 +93,8 @@ public class PlatformInternalUserService { IamUser user = requiredUser(membership.getUserId()); AuthAccountClient.AccountSummary oldAccount = account(user.getAuthUserId()); boolean displayNameChanged = !Objects.equals(user.getDisplayName(), request.displayName()); + List oldRoleIds = repository.findRoleIds(PLATFORM_TENANT_ID, membershipId); + boolean rolesChanged = !new java.util.HashSet<>(oldRoleIds).equals(new java.util.HashSet<>(roleIds)); if (displayNameChanged) { call(() -> authClient.profile(internalToken, user.getAuthUserId(), new AuthAccountClient.ProfileRequest(request.displayName()))); @@ -105,6 +107,7 @@ public class PlatformInternalUserService { repository.saveUser(user); repository.save(membership); repository.replaceRoles(PLATFORM_TENANT_ID, membershipId, roleIds); + if (rolesChanged && !displayNameChanged) revokeSessions(user.getAuthUserId()); } catch (RuntimeException localFailure) { if (displayNameChanged) compensateProfile(user.getAuthUserId(), oldAccount.displayName(), localFailure); throw localFailure; @@ -159,8 +162,12 @@ public class PlatformInternalUserService { IamMembership membership = required(membershipId); List roleIds = normalizeRoles(request.roleIds()); validateRoles(roleIds); - repository.replaceRoles(PLATFORM_TENANT_ID, membershipId, roleIds); IamUser user = requiredUser(membership.getUserId()); + List currentRoleIds = repository.findRoleIds(PLATFORM_TENANT_ID, membershipId); + repository.replaceRoles(PLATFORM_TENANT_ID, membershipId, roleIds); + if (!new java.util.HashSet<>(currentRoleIds).equals(new java.util.HashSet<>(roleIds))) { + revokeSessions(user.getAuthUserId()); + } AuthAccountClient.AccountSummary authAccount = account(user.getAuthUserId()); return response(membership, user, authAccount.username(), roleIds); } @@ -283,6 +290,11 @@ public class PlatformInternalUserService { return data(call(() -> authClient.summary(internalToken, authUserId))); } + private void revokeSessions(Long authUserId) { + call(() -> authClient.revokeSessions(internalToken, + new AuthAccountClient.RevokeAccountSessionsRequest(java.util.Set.of(authUserId)))); + } + private void compensateCreate(long authUserId, RuntimeException localFailure) { try { call(() -> authClient.compensate(internalToken, authUserId)); diff --git a/iam/src/main/java/com/baiyue/iam/service/PlatformUserManagementService.java b/iam/src/main/java/com/baiyue/iam/service/PlatformUserManagementService.java index 41db6bf0e..d35d25a17 100644 --- a/iam/src/main/java/com/baiyue/iam/service/PlatformUserManagementService.java +++ b/iam/src/main/java/com/baiyue/iam/service/PlatformUserManagementService.java @@ -131,6 +131,7 @@ public class PlatformUserManagementService { AuthAccountClient.AccountSummary oldAccount = account(user.getAuthUserId()); boolean profileChanged = !Objects.equals(user.getDisplayName(), request.displayName().trim()); boolean scopeChanged = oldTenantId != newTenantId; + boolean rolesChanged = !new java.util.HashSet<>(oldRoleIds).equals(new java.util.HashSet<>(roleIds)); if (profileChanged) call(() -> authClient.profile(internalToken, user.getAuthUserId(), new AuthAccountClient.ProfileRequest(request.displayName().trim()))); @@ -149,6 +150,10 @@ public class PlatformUserManagementService { members.save(membership); if (scopeChanged) members.replaceRoles(oldTenantId, membershipId, List.of()); members.replaceRoles(newTenantId, membershipId, roleIds); + if (rolesChanged && !profileChanged && !scopeChanged) { + call(() -> authClient.revokeSessions(internalToken, + new AuthAccountClient.RevokeAccountSessionsRequest(Set.of(user.getAuthUserId())))); + } } catch (RuntimeException localFailure) { compensateUpdate(user.getAuthUserId(), membershipId, oldTenantId, oldAccount, profileChanged, scopeChanged, localFailure); diff --git a/iam/src/main/java/com/baiyue/iam/service/RbacService.java b/iam/src/main/java/com/baiyue/iam/service/RbacService.java index a7607632d..23c76a052 100644 --- a/iam/src/main/java/com/baiyue/iam/service/RbacService.java +++ b/iam/src/main/java/com/baiyue/iam/service/RbacService.java @@ -167,11 +167,17 @@ public class RbacService { @BusinessAudit(event = "PERMISSION_STATUS_CHANGE", resourceType = "PERMISSION", resourceId = "#permissionId", description = "启停权限资源", captureBefore = true, captureAfter = true) + @Transactional public IamPermission changePermissionStatus(long tenantId, String appCode, Long permissionId, ChangePermissionStatusRequest request) { IamPermission permission = requireMutablePermission(tenantId, appCode, permissionId); permission.setStatus(request.status()); repository.updatePermission(permission); + Set affectedAccounts = new LinkedHashSet<>(); + for (Long roleId : repository.findRoleIdsByPermission(tenantId, permissionId)) { + affectedAccounts.addAll(repository.findAssignedAuthUserIds(tenantId, roleId)); + } + revokeSessions(List.copyOf(affectedAccounts)); return permission; } diff --git a/order/src/main/java/com/baiyue/order/entity/OrderEntity.java b/order/src/main/java/com/baiyue/order/entity/OrderEntity.java index fbb773243..6f5de2a38 100644 --- a/order/src/main/java/com/baiyue/order/entity/OrderEntity.java +++ b/order/src/main/java/com/baiyue/order/entity/OrderEntity.java @@ -49,6 +49,10 @@ public class OrderEntity extends BaseEntity { private String amazonWarehouseCode; private String shipmentId; private String referenceId; + private String orderRole; + private Long parentOrderId; + private Long rootOrderId; + private Integer splitSequence; private String expressCompany; private String expressTrackingNo; private LocalDateTime submittedAt; diff --git a/order/src/main/java/com/baiyue/order/entity/OrderFulfillmentSplitEntity.java b/order/src/main/java/com/baiyue/order/entity/OrderFulfillmentSplitEntity.java new file mode 100644 index 000000000..fd4e00c55 --- /dev/null +++ b/order/src/main/java/com/baiyue/order/entity/OrderFulfillmentSplitEntity.java @@ -0,0 +1,34 @@ +package com.baiyue.order.entity; + +import com.baomidou.mybatisplus.annotation.IdType; +import com.baomidou.mybatisplus.annotation.TableId; +import com.baomidou.mybatisplus.annotation.TableName; +import lombok.Getter; +import lombok.Setter; + +import java.time.LocalDateTime; + +/** 父订单→子订单→柜次的数量台账;柜次ID仅为Firstleg逻辑引用。 */ +@Getter @Setter +@TableName("ord_order_fulfillment_split") +public class OrderFulfillmentSplitEntity { + @TableId(type = IdType.ASSIGN_ID) private Long id; + private Long tenantId; + private Long parentOrderId; + private Long childOrderId; + private Long loadingPlanId; + private Long containerBookingId; + private Integer sourcePackageCount; + private Integer loadedPackageCount; + private Integer remainingPackageCount; + private String sourceDetailJson; + private String operationId; + private String idempotencyKey; + private String requestHash; + private String splitRole; + private Integer expectedParentVersion; + private Long createdBy; + private String createdByName; + private LocalDateTime createdAt; + private Integer version; +} diff --git a/order/src/main/java/com/baiyue/order/mapper/OrderFulfillmentSplitMapper.java b/order/src/main/java/com/baiyue/order/mapper/OrderFulfillmentSplitMapper.java new file mode 100644 index 000000000..dbfeffa3c --- /dev/null +++ b/order/src/main/java/com/baiyue/order/mapper/OrderFulfillmentSplitMapper.java @@ -0,0 +1,8 @@ +package com.baiyue.order.mapper; + +import com.baiyue.order.entity.OrderFulfillmentSplitEntity; +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import org.apache.ibatis.annotations.Mapper; + +@Mapper +public interface OrderFulfillmentSplitMapper extends BaseMapper {} diff --git a/order/src/main/resources/db/order.sql b/order/src/main/resources/db/order.sql index f8697e490..7b1177129 100644 --- a/order/src/main/resources/db/order.sql +++ b/order/src/main/resources/db/order.sql @@ -37,6 +37,10 @@ CREATE TABLE IF NOT EXISTS ord_order ( amazon_warehouse_code VARCHAR(32) NULL COMMENT '亚马逊仓库编码', shipment_id VARCHAR(64) NULL COMMENT 'Amazon Shipment ID', reference_id VARCHAR(64) NULL COMMENT 'Amazon Reference ID', + order_role VARCHAR(12) NOT NULL DEFAULT 'MASTER' COMMENT '订单层级:MASTER主订单、CHILD子订单', + parent_order_id BIGINT UNSIGNED NULL COMMENT '直属父订单ID;主订单为空;禁止子订单嵌套', + root_order_id BIGINT UNSIGNED NULL COMMENT '根主订单ID', + split_sequence INT UNSIGNED NULL COMMENT '同一父订单下的同级子订单顺序', express_company VARCHAR(64) NULL COMMENT '履约快递公司名称快照', express_tracking_no VARCHAR(128) NULL COMMENT '履约快递单号', submitted_at DATETIME(3) NULL COMMENT '提交时间', @@ -55,6 +59,40 @@ CREATE TABLE IF NOT EXISTS ord_order ( KEY idx_order_tenant_warehouse (tenant_id, destination_warehouse_id) ) ENGINE=InnoDB COMMENT='头程订单主表:保存商户委托、目的仓、运输方式和订单当前状态'; +CREATE TABLE IF NOT EXISTS ord_order_fulfillment_split ( + id BIGINT UNSIGNED NOT NULL COMMENT '拆分台账ID', + tenant_id BIGINT UNSIGNED NOT NULL, + parent_order_id BIGINT UNSIGNED NOT NULL, + child_order_id BIGINT UNSIGNED NOT NULL, + split_role VARCHAR(12) NOT NULL COMMENT '本台账子片:LOADED或REMAINDER', + loading_plan_id BIGINT UNSIGNED NULL COMMENT 'Firstleg装柜计划逻辑ID', + container_booking_id BIGINT UNSIGNED NULL COMMENT 'Firstleg柜次逻辑ID', + source_package_count INT UNSIGNED NOT NULL, + loaded_package_count INT UNSIGNED NOT NULL, + remaining_package_count INT UNSIGNED NOT NULL, + source_detail_json JSON NULL COMMENT '来源货物明细及箱数快照', + operation_id VARCHAR(64) NOT NULL, + idempotency_key VARCHAR(128) NOT NULL, + request_hash CHAR(64) NOT NULL, + expected_parent_version INT NOT NULL, + created_by BIGINT UNSIGNED NOT NULL, + created_by_name VARCHAR(64) NULL, + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + version INT NOT NULL DEFAULT 0, + PRIMARY KEY (id), + UNIQUE KEY uk_order_split_operation (tenant_id, operation_id, split_role), + UNIQUE KEY uk_order_split_idempotency (tenant_id, parent_order_id, idempotency_key, split_role), + UNIQUE KEY uk_order_split_child (tenant_id, child_order_id), + KEY idx_order_split_parent (tenant_id, parent_order_id, created_at, id), + KEY idx_order_split_container (tenant_id, container_booking_id, created_at, id), + CONSTRAINT fk_order_split_parent FOREIGN KEY (parent_order_id) REFERENCES ord_order(id), + CONSTRAINT fk_order_split_child FOREIGN KEY (child_order_id) REFERENCES ord_order(id), + CONSTRAINT chk_order_split_quantity CHECK ( + source_package_count > 0 AND loaded_package_count > 0 + AND loaded_package_count + remaining_package_count = source_package_count + ) +) ENGINE=InnoDB COMMENT='父订单到子订单的装柜数量及柜次关联台账'; + CREATE TABLE IF NOT EXISTS ord_order_party ( id BIGINT UNSIGNED NOT NULL COMMENT '参与方快照ID', tenant_id BIGINT UNSIGNED NOT NULL COMMENT '数据所属租户ID', diff --git a/order/src/main/resources/db/phase3-order-parent-child-schema.sql b/order/src/main/resources/db/phase3-order-parent-child-schema.sql new file mode 100644 index 000000000..fdf201faf --- /dev/null +++ b/order/src/main/resources/db/phase3-order-parent-child-schema.sql @@ -0,0 +1,155 @@ +SET NAMES utf8mb4; +USE baiyue_order; + +DROP PROCEDURE IF EXISTS phase3_order_add_column; +DELIMITER $$ +CREATE PROCEDURE phase3_order_add_column(IN p_table VARCHAR(64), 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=p_table AND column_name=p_column) THEN + SET @ddl = CONCAT('ALTER TABLE `', p_table, '` ADD COLUMN `', p_column, '` ', p_definition); + PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt; + END IF; +END$$ +DELIMITER ; +CALL phase3_order_add_column('ord_order','order_role',"VARCHAR(12) NOT NULL DEFAULT 'MASTER' COMMENT '订单层级:MASTER、CHILD'"); +CALL phase3_order_add_column('ord_order','parent_order_id','BIGINT UNSIGNED NULL COMMENT ''直属父订单ID;禁止子订单嵌套'''); +CALL phase3_order_add_column('ord_order','root_order_id','BIGINT UNSIGNED NULL COMMENT ''根主订单ID'''); +CALL phase3_order_add_column('ord_order','split_sequence','INT UNSIGNED NULL COMMENT ''同一父订单下的同级顺序'''); +DROP PROCEDURE IF EXISTS phase3_order_add_column; + +DROP PROCEDURE IF EXISTS phase3_order_add_index; +DELIMITER $$ +CREATE PROCEDURE phase3_order_add_index(IN p_table VARCHAR(64), IN p_name VARCHAR(64), IN p_definition TEXT) +BEGIN + IF NOT EXISTS (SELECT 1 FROM information_schema.statistics WHERE table_schema=DATABASE() + AND table_name=p_table AND index_name=p_name) THEN + SET @ddl = CONCAT('ALTER TABLE `',p_table,'` ADD ',p_definition); + PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt; + END IF; +END$$ +DELIMITER ; +CALL phase3_order_add_index('ord_order','uk_order_parent_sequence', + 'UNIQUE KEY `uk_order_parent_sequence` (`tenant_id`,`parent_order_id`,`split_sequence`)'); +CALL phase3_order_add_index('ord_order','idx_order_root_role', + 'KEY `idx_order_root_role` (`tenant_id`,`root_order_id`,`order_role`,`created_at`,`id`)'); +DROP PROCEDURE IF EXISTS phase3_order_add_index; + +DROP PROCEDURE IF EXISTS phase3_order_parent_fk; +DELIMITER $$ +CREATE PROCEDURE phase3_order_parent_fk() +BEGIN + IF EXISTS (SELECT 1 FROM information_schema.table_constraints WHERE constraint_schema=DATABASE() + AND table_name='ord_order' AND constraint_name='fk_order_parent') THEN + ALTER TABLE ord_order DROP FOREIGN KEY fk_order_parent; + END IF; + ALTER TABLE ord_order ADD CONSTRAINT fk_order_parent FOREIGN KEY (parent_order_id) REFERENCES ord_order(id); +END$$ +DELIMITER ; +CALL phase3_order_parent_fk(); +DROP PROCEDURE IF EXISTS phase3_order_parent_fk; + +CREATE TABLE IF NOT EXISTS ord_order_fulfillment_split ( + id BIGINT UNSIGNED NOT NULL, + tenant_id BIGINT UNSIGNED NOT NULL, + parent_order_id BIGINT UNSIGNED NOT NULL, + child_order_id BIGINT UNSIGNED NOT NULL, + split_role VARCHAR(12) NOT NULL COMMENT 'LOADED或REMAINDER', + loading_plan_id BIGINT UNSIGNED NULL, + container_booking_id BIGINT UNSIGNED NULL, + source_package_count INT UNSIGNED NOT NULL, + loaded_package_count INT UNSIGNED NOT NULL, + remaining_package_count INT UNSIGNED NOT NULL, + source_detail_json JSON NULL, + operation_id VARCHAR(64) NOT NULL, + idempotency_key VARCHAR(128) NOT NULL, + request_hash CHAR(64) NOT NULL, + expected_parent_version INT NOT NULL, + created_by BIGINT UNSIGNED NOT NULL, + created_by_name VARCHAR(64) NULL, + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + version INT NOT NULL DEFAULT 0, + PRIMARY KEY(id), + UNIQUE KEY uk_order_split_operation (tenant_id,operation_id,split_role), + UNIQUE KEY uk_order_split_idempotency (tenant_id,parent_order_id,idempotency_key,split_role), + UNIQUE KEY uk_order_split_child (tenant_id,child_order_id), + KEY idx_order_split_parent (tenant_id,parent_order_id,created_at,id), + KEY idx_order_split_container (tenant_id,container_booking_id,created_at,id), + CONSTRAINT fk_order_split_parent FOREIGN KEY(parent_order_id) REFERENCES ord_order(id), + CONSTRAINT fk_order_split_child FOREIGN KEY(child_order_id) REFERENCES ord_order(id), + CONSTRAINT chk_order_split_quantity CHECK (source_package_count > 0 AND loaded_package_count > 0 + AND loaded_package_count + remaining_package_count = source_package_count) +) ENGINE=InnoDB COMMENT='父订单→子订单→柜次数量台账'; + +DROP TRIGGER IF EXISTS trg_ord_order_parent_insert; +DROP TRIGGER IF EXISTS trg_ord_order_parent_update; +DELIMITER $$ +CREATE TRIGGER trg_ord_order_parent_insert BEFORE INSERT ON ord_order FOR EACH ROW +BEGIN + IF NEW.order_role='MASTER' THEN + SET NEW.parent_order_id=NULL; + IF NEW.root_order_id IS NULL THEN SET NEW.root_order_id=NEW.id; END IF; + ELSEIF NEW.order_role='CHILD' THEN + IF NEW.parent_order_id IS NULL OR NEW.root_order_id IS NULL OR NEW.split_sequence IS NULL THEN + SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='子订单必须有父订单、根订单和顺序号'; + END IF; + IF NEW.root_order_id<>NEW.parent_order_id OR EXISTS( + SELECT 1 FROM ord_order p WHERE p.id=NEW.parent_order_id AND p.order_role<>'MASTER') THEN + SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='子订单只允许直接挂在主订单下'; + END IF; + IF NOT EXISTS(SELECT 1 FROM ord_order p WHERE p.id=NEW.parent_order_id AND p.tenant_id=NEW.tenant_id AND p.deleted=0) THEN + SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='父订单不存在或客户归属不一致'; + END IF; + ELSE + SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='订单层级非法'; + END IF; +END$$ +CREATE TRIGGER trg_ord_order_parent_update BEFORE UPDATE ON ord_order FOR EACH ROW +BEGIN + IF NEW.order_role<>OLD.order_role OR NOT (NEW.parent_order_id<=>OLD.parent_order_id) + OR NOT (NEW.root_order_id<=>OLD.root_order_id) OR NOT (NEW.split_sequence<=>OLD.split_sequence) THEN + SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='订单父子关系不可修改'; + END IF; +END$$ +DELIMITER ; + +CALL phase3_order_add_column('ord_order_fulfillment_split','split_role',"VARCHAR(12) NOT NULL DEFAULT 'LOADED' COMMENT 'LOADED或REMAINDER'"); +CALL phase3_order_add_column('ord_order_fulfillment_split','request_hash',"CHAR(64) NULL COMMENT '请求内容SHA-256;存量记录允许为空'"); + +DROP PROCEDURE IF EXISTS phase3_order_drop_split_index; +DELIMITER $$ +CREATE PROCEDURE phase3_order_drop_split_index() +BEGIN + IF EXISTS (SELECT 1 FROM information_schema.statistics WHERE table_schema=DATABASE() + AND table_name='ord_order_fulfillment_split' AND index_name='uk_order_split_operation') THEN + ALTER TABLE ord_order_fulfillment_split DROP INDEX uk_order_split_operation; + END IF; + IF EXISTS (SELECT 1 FROM information_schema.statistics WHERE table_schema=DATABASE() + AND table_name='ord_order_fulfillment_split' AND index_name='uk_order_split_idempotency') THEN + ALTER TABLE ord_order_fulfillment_split DROP INDEX uk_order_split_idempotency; + END IF; +END$$ +DELIMITER ; +CALL phase3_order_drop_split_index(); +DROP PROCEDURE IF EXISTS phase3_order_drop_split_index; +CALL phase3_order_add_index('ord_order_fulfillment_split','uk_order_split_operation', + 'UNIQUE KEY `uk_order_split_operation` (`tenant_id`,`operation_id`,`split_role`)'); +CALL phase3_order_add_index('ord_order_fulfillment_split','uk_order_split_idempotency', + 'UNIQUE KEY `uk_order_split_idempotency` (`tenant_id`,`parent_order_id`,`idempotency_key`,`split_role`)'); + +DROP PROCEDURE IF EXISTS phase3_order_assert; +DELIMITER $$ +CREATE PROCEDURE phase3_order_assert() +BEGIN + IF (SELECT COUNT(*) FROM information_schema.columns WHERE table_schema=DATABASE() + AND table_name='ord_order' AND column_name IN ('order_role','parent_order_id','root_order_id','split_sequence'))<>4 THEN + SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='订单父子字段未完整创建'; + END IF; + IF (SELECT COUNT(*) FROM information_schema.tables WHERE table_schema=DATABASE() + AND table_name='ord_order_fulfillment_split')<>1 THEN + SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='订单拆分台账未创建'; + END IF; +END$$ +DELIMITER ; +CALL phase3_order_assert(); +DROP PROCEDURE IF EXISTS phase3_order_assert; diff --git a/scripts/admin-test-env-acceptance.mjs b/scripts/admin-test-env-acceptance.mjs index dd2279d93..43ba435c3 100644 --- a/scripts/admin-test-env-acceptance.mjs +++ b/scripts/admin-test-env-acceptance.mjs @@ -11,7 +11,7 @@ import process from 'node:process' const DEFAULT_GATEWAY = 'http://8.163.30.128:9000' const DEFAULT_USERNAME = 'platform_admin' const DEFAULT_PASSWORD = 'ChangeMe123!' -const DEFAULT_MODULES = ['customers', 'overseas-storage-fees', 'system', 'master-data', 'orders', 'pricing', 'downloads', 'inbound-weight-ocr', 'inbound-measurement', 'phase3-container-loading', 'phase3-loading', 'supplemental', 'supplemental-boundaries', 'ocean-bookings', 'truck-appointments', 'service-center-flow', 'phase3-customs', 'phase3-port-schedules', 'phase3-outbound-dispatch', 'phase3-outbound-aggregate', 'phase3-dispatch-gate', 'phase3-waiting-for-loading', 'phase3-loading-model-bridge', 'phase3-pda-convergence', 'phase3-evidence-gate'] +const DEFAULT_MODULES = ['customers', 'overseas-storage-fees', 'system', 'master-data', 'orders', 'pricing', 'downloads', 'inbound-weight-ocr', 'inbound-measurement', 'phase3-container-loading', 'phase3-loading', 'supplemental', 'supplemental-boundaries', 'ocean-bookings', 'truck-appointments', 'service-center-flow', 'phase3-customs', 'phase3-port-schedules', 'phase3-carrier-schedule-queries', 'phase3-loading-finalize', 'phase3-outbound-dispatch', 'phase3-outbound-aggregate', 'phase3-dispatch-gate', 'phase3-waiting-for-loading', 'phase3-loading-model-bridge', 'phase3-pda-convergence', 'phase3-evidence-gate', 'phase3-warehouse-rbac', 'phase3-order-splitting'] const WEIGHT_IMAGE_PATH = 'C:/Users/BY-OY/Desktop/上传图片包/测量图片/测量图片/orig_1cf27695-b2c3-44b3-98e3-c0be8b80f79a.JPG' const CUSTOMER_FIELDS = [ 'id', 'tenantCode', 'tenantName', 'status', 'legalName', 'legalRepresentative', @@ -526,6 +526,24 @@ class AcceptanceRun { } } } + const reportSurface = JSON.stringify({ + steps: this.steps, modules: this.modules, scenarios: this.scenarios, + deploymentBlockers: this.deploymentBlockers, coverageGaps: this.coverageGaps, + createdResources: this.createdResources, + }) + const forbiddenValues = [this.options.password, this.token].filter((value) => typeof value === 'string' && value.length >= 8) + const sensitivePatterns = [ + /"(?:password|newPassword|temporaryPassword|accessToken|refreshToken|authorization)"\s*:/i, + /\bBearer\s+[A-Za-z0-9._~-]{16,}/i, + /\beyJ[A-Za-z0-9_-]{16,}\.[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}\b/, + /data:(?:image|application)\/[A-Za-z0-9.+-]+;base64,/i, + /"(?:bytes|base64|ocrText|ocrRawText)"\s*:/i, + ] + const sensitive = forbiddenValues.some((value) => reportSurface.includes(value)) + || sensitivePatterns.some((pattern) => pattern.test(reportSurface)) + this.addStep('report-security', '报告敏感信息扫描', null, !sensitive, + sensitive ? '检测到密码、Token、二进制/base64 或 OCR 原文字段' : '未发现密码、Token、文件二进制/base64 或 OCR 原文字段') + const failed = this.steps.filter((step) => !step.passed).length return { reportName: '后台管理测试环境 HTTP API 验收', marker: this.marker, diff --git a/scripts/admin-test-env-modules/inbound-measurement.mjs b/scripts/admin-test-env-modules/inbound-measurement.mjs index f11d6e88f..de85ef1f5 100644 --- a/scripts/admin-test-env-modules/inbound-measurement.mjs +++ b/scripts/admin-test-env-modules/inbound-measurement.mjs @@ -179,8 +179,10 @@ export async function run(context) { const scanPath = `/api/platform/v1/first-leg/inbounds/${inboundId}/measurement/carton-scans?${query(tenantId)}` // Keep the isolated sample inside the customer's largest configured inner box (60x50x50cm). const firstDimensions = { length: 59.1, width: 40.2, height: 30.3, weight: 12.5 } + const firstShipmentId = `FBA-${context.marker.slice(-12)}-01` const firstScanResponse = await multipartRequest(context, scanPath, { - boxCode, ...firstDimensions, machineNo: 'MJ-ADMIN-E2E-01', measuredAt: new Date().toISOString().slice(0, 19), + boxCode, ...firstDimensions, scannedShipmentId: firstShipmentId, + machineNo: 'MJ-ADMIN-E2E-01', measuredAt: new Date().toISOString().slice(0, 19), }) const firstScan = context.apiSuccess(MODULE, '模拟墨家首次扫描单箱', firstScanResponse, `boxCode=${boxCode}, machineNo=MJ-ADMIN-E2E-01`, OPTIONS) @@ -204,6 +206,7 @@ export async function run(context) { context.assert(MODULE, '箱级查询回显箱码、尺寸、图片与设备信息', firstQueryResponse, firstItem?.boxCode === boxCode && asId(firstItem?.intelligentImageFileId) && firstItem?.machineNo === 'MJ-ADMIN-E2E-01' + && firstItem?.scannedShipmentId === firstShipmentId && closeTo(firstItem?.lengthCm, firstDimensions.length) && closeTo(firstItem?.widthCm, firstDimensions.width) && closeTo(firstItem?.heightCm, firstDimensions.height), @@ -220,8 +223,10 @@ export async function run(context) { `version=${countResult?.measurementVersion ?? 'missing'}`, OPTIONS) const replacement = { length: 59.5, width: 41.2, height: 31.3, weight: 13.6 } + const replacementShipmentId = `FBA-${context.marker.slice(-12)}-02` const rescanResponse = await multipartRequest(context, scanPath, { - boxCode, ...replacement, machineNo: 'MJ-ADMIN-E2E-02', measuredAt: new Date().toISOString().slice(0, 19), + boxCode, ...replacement, scannedShipmentId: replacementShipmentId, + machineNo: 'MJ-ADMIN-E2E-02', measuredAt: new Date().toISOString().slice(0, 19), }) const rescan = context.apiSuccess(MODULE, '同箱号重新扫描覆盖当前记录', rescanResponse, `boxCode=${boxCode}, machineNo=MJ-ADMIN-E2E-02`, OPTIONS) @@ -244,6 +249,7 @@ export async function run(context) { context.assert(MODULE, '覆盖尺寸、箱数和服务端体积公式正确', measuredResponse, measuredItem && closeTo(measuredItem.lengthCm, replacement.length) && closeTo(measuredItem.widthCm, replacement.width) && closeTo(measuredItem.heightCm, replacement.height) + && measuredItem.scannedShipmentId === replacementShipmentId && Number(measuredItem.confirmedPackageCount) === originalPackageCount && closeTo(measuredItem.outerLengthCm, weightedOuter.length) && closeTo(measuredItem.outerWidthCm, weightedOuter.width) diff --git a/scripts/admin-test-env-modules/phase3-dispatch-gate.mjs b/scripts/admin-test-env-modules/phase3-dispatch-gate.mjs index 59b07db0c..2b85a49fb 100644 --- a/scripts/admin-test-env-modules/phase3-dispatch-gate.mjs +++ b/scripts/admin-test-env-modules/phase3-dispatch-gate.mjs @@ -1,10 +1,23 @@ -import { registerPendingContract } from './_phase3-pending-contract.mjs' +const MODULE = 'phase3-dispatch-gate' +const OPTIONS = { scenario: 'domestic-dispatch-gate', deploymentBlocker: true } +const BASE = '/api/platform/v1/first-leg/outbound-dispatches' export async function run(context) { - registerPendingContract(context, { - module: 'phase3-dispatch-gate', scenario: 'domestic-dispatch-gate', name: '国内出仓统一门禁', - reason: '统一门禁、confirm-dispatch/reverse-dispatch 已实现并推送,待目标提交部署且真实 HTTP 通过后删除此项', category: 'deployment-validation-pending', - fixtures: ['phase3Loading', 'phase3ContainerLoading', 'oceanBooking', 'truckAppointment'], contractSource: 'PlatformOutboundDispatchController / phase3-outbound-dispatch-v1.yaml', - assertions: ['门禁逐项返回真实阻断原因', '旧 version 和重复确认安全冲突', '确认与撤销均保留审计时间线'], - }) + const fixture = context.createdResources?.phase3OutboundDispatch + if (!fixture?.inboundId || !fixture?.containerBookingId || fixture.marker !== context.marker) { + context.addCoverageGap(MODULE, OPTIONS.scenario, '订舱/拖车/报关/PDA/五类证据统一硬门禁', + '本轮未取得 phase3-outbound-dispatch 的 marker 隔离聚合,未使用历史资源', 'fixture') + return + } + const selector = `inboundId=${encodeURIComponent(fixture.inboundId)}&containerBookingId=${encodeURIComponent(fixture.containerBookingId)}` + const response = await context.request('GET', `${BASE}/check-gate?${selector}`) + const gate = context.apiSuccess(MODULE, '复核国内出仓统一硬门禁', response, selector, OPTIONS) + const items = Array.isArray(gate?.items) ? gate.items : [] + const required = ['INBOUND_LOADED', 'LOADING_PLAN_COMPLETED', 'PDA_PICK_COMPLETED', + 'OCEAN_BOOKING_RELEASED', 'TRUCK_DEPARTED', 'LOADING_EVIDENCE_COMPLETE', + 'CUSTOMS_CLEARANCE', 'SAILING_SCHEDULE'] + context.assert(MODULE, '八项业务门禁全部由服务端真实判定并通过', response, + gate?.passed === true && required.every((code) => items.some((item) => item.code === code + && item.passed === true && item.actual !== 'MODULE_UNAVAILABLE' && item.actual !== 'ASSESSMENT_ERROR')), + `passed=${gate?.passed ?? 'missing'}, gates=${items.map((item) => `${item.code}:${item.passed}`).join(',')}`, OPTIONS) } diff --git a/scripts/admin-test-env-modules/phase3-evidence-gate.mjs b/scripts/admin-test-env-modules/phase3-evidence-gate.mjs index 9ce55822b..82ffcb7c5 100644 --- a/scripts/admin-test-env-modules/phase3-evidence-gate.mjs +++ b/scripts/admin-test-env-modules/phase3-evidence-gate.mjs @@ -1,10 +1,32 @@ -import { registerPendingContract } from './_phase3-pending-contract.mjs' +const MODULE = 'phase3-evidence-gate' +const OPTIONS = { scenario: 'loading-evidence-gate', deploymentBlocker: true } + +function id(value) { return value == null ? '' : String(value) } export async function run(context) { - registerPendingContract(context, { - module: 'phase3-evidence-gate', scenario: 'loading-evidence-gate', name: '四步照片与铅封证据完整性门禁', - reason: '五类证据、真实文件归属和柜次 PDA 复核门禁已实现并推送,待目标提交部署且 phase3-loading 真实 HTTP 通过后删除此项', category: 'deployment-validation-pending', - fixtures: ['phase3Loading', 'phase3ContainerLoading'], contractSource: 'PlatformLoadingDeliveryController / phase3-loading-delivery-v1.yaml', - assertions: ['四步照片和铅封均绑定本轮柜次', '缺任一证据时完成装柜被拒绝', '全部证据与 PDA 复核齐备后幂等完成'], - }) + const fixture = context.createdResources?.phase3Loading + if (!fixture?.tenantId || !fixture?.inboundId || fixture.marker !== context.marker) { + context.addCoverageGap(MODULE, OPTIONS.scenario, '五类证据多图追加、作废审计和有效证据门禁', + '本轮未取得 phase3-loading 的 marker 隔离证据 fixture,未使用历史资源', 'fixture') + return + } + const path = `/api/platform/v1/inbounds/${fixture.inboundId}/loading/evidence?tenantId=${encodeURIComponent(fixture.tenantId)}` + const response = await context.request('GET', path) + const data = context.apiSuccess(MODULE, '复核本轮装柜证据聚合', response, `inboundId=${fixture.inboundId}`, OPTIONS) + const evidences = Array.isArray(data?.evidences) ? data.evidences : [] + const required = ['PHOTO_STEP_1', 'PHOTO_STEP_2', 'PHOTO_STEP_3', 'PHOTO_STEP_4', 'SEAL'] + const active = evidences.filter((item) => item.status !== 'VOID') + const voided = evidences.find((item) => id(item.id) === id(fixture.voidedEvidenceId)) + context.assert(MODULE, '五类有效证据齐全且同类型多图追加不覆盖历史', response, + required.every((type) => active.some((item) => item.evidenceType === type)) + && active.filter((item) => item.evidenceType === 'PHOTO_STEP_1').length >= 1 + && active.some((item) => item.evidenceType === 'SEAL' && Boolean(String(item.sealNo || '').trim())) + && active.every((item) => String(item.fileName || '').includes(context.marker) + && String(item.remark || '').includes(context.marker)), + `active=${active.length}, types=${[...new Set(active.map((item) => item.evidenceType))].join(',')}`, OPTIONS) + context.assert(MODULE, '作废证据保留原因、操作者和时间且不计入有效门禁', response, + Boolean(voided) && voided.status === 'VOID' && voided.voidReason === `${context.marker}-wrong-photo` + && Boolean(voided.voidedBy) && Boolean(voided.voidedAt) + && !active.some((item) => id(item.id) === id(fixture.voidedEvidenceId)), + `voidedEvidenceId=${id(fixture.voidedEvidenceId) || 'missing'}, status=${voided?.status || 'missing'}`, OPTIONS) } diff --git a/scripts/admin-test-env-modules/phase3-loading-finalize.mjs b/scripts/admin-test-env-modules/phase3-loading-finalize.mjs new file mode 100644 index 000000000..329716879 --- /dev/null +++ b/scripts/admin-test-env-modules/phase3-loading-finalize.mjs @@ -0,0 +1,75 @@ +const MODULE = 'phase3-loading-finalize' +const OPTIONS = { scenario: 'phase3-loading-hard-gate-finalize', deploymentBlocker: true } + +function id(value) { return value == null ? '' : String(value) } + +async function request(context, method, path, body, headers = {}) { + try { + const response = await fetch(`${context.options.gateway}${path}`, { + method, + headers: { Accept: 'application/json', Authorization: `Bearer ${context.token}`, + ...(body === undefined ? {} : { 'Content-Type': 'application/json; charset=utf-8' }), ...headers }, + body: body === undefined ? undefined : JSON.stringify(body), signal: AbortSignal.timeout(60_000), + }) + const raw = await response.text(); let payload = null + try { payload = raw ? JSON.parse(raw) : null } catch { /* bounded raw evidence */ } + return { method, path, http: response.status, body: payload, + raw: payload === null ? raw.slice(0, 500) : null, + requestId: payload?.requestId || response.headers.get('x-request-id'), + traceId: payload?.traceId || response.headers.get('x-trace-id') } + } catch (error) { + return { method, path, http: 0, body: null, raw: String(error), requestId: null, traceId: null } + } +} + +export async function run(context) { + const fixture = context.createdResources?.phase3Loading + const tenantId = id(fixture?.tenantId); const inboundId = id(fixture?.inboundId) + if (!tenantId || !inboundId || fixture?.marker !== context.marker + || fixture?.loadingPlanStatus !== 'COMPLETED') { + context.addStep(MODULE, '取得下游门禁待完成装柜 fixture', null, false, + `tenantId=${tenantId || 'missing'}, inboundId=${inboundId || 'missing'}, marker=${fixture?.marker || 'missing'}, plan=${fixture?.loadingPlanStatus || 'missing'}`, OPTIONS) + return + } + const detailPath = `/api/platform/inbounds/${inboundId}?tenantId=${encodeURIComponent(tenantId)}` + const beforeResponse = await context.request('GET', detailPath) + const before = context.apiSuccess(MODULE, '读取下游门禁齐备后的装柜状态', beforeResponse, '', OPTIONS) + const version = Number(before?.summary?.version) + context.assert(MODULE, '最终装柜前保持 LOADING 且版本有效', beforeResponse, + before?.summary?.processStatus === 'LOADING' && Number.isInteger(version), + `status=${before?.summary?.processStatus || 'missing'}, version=${before?.summary?.version ?? 'missing'}`, OPTIONS) + if (!Number.isInteger(version)) return + + const base = `/api/platform/v1/inbounds/${inboundId}` + const completeResponse = await request(context, 'POST', + `${base}/loading/complete?tenantId=${encodeURIComponent(tenantId)}`, + { expectedVersion: version, remark: context.marker }, + { 'Idempotency-Key': `${context.marker}-p3-complete-after-downstream`.slice(0, 128) }) + const completed = context.apiSuccess(MODULE, '订舱拖车报关齐备后完成装柜', completeResponse, '', OPTIONS) + const evidences = completed?.evidences?.evidences || [] + context.assert(MODULE, '统一硬门禁通过后推进 LOADED 且五类有效证据仍完整', completeResponse, + completed?.mainStatus === 'LOADED' && fixture.evidenceTypes?.every?.((type) => + evidences.some((item) => item.evidenceType === type && item.status !== 'VOID')), + `mainStatus=${completed?.mainStatus || 'missing'}, evidences=${evidences.length}`, OPTIONS) + + const replayResponse = await request(context, 'POST', + `${base}/loading/complete?tenantId=${encodeURIComponent(tenantId)}`, + { expectedVersion: version, remark: context.marker }, + { 'Idempotency-Key': `${context.marker}-p3-complete-after-downstream`.slice(0, 128) }) + context.assert(MODULE, 'LOADED 完成请求 Idempotency-Key 重放保持终态', replayResponse, + replayResponse.http === 200 && String(replayResponse.body?.code) === '200' + && replayResponse.body?.data?.mainStatus === 'LOADED', + `HTTP=${replayResponse.http}, code=${replayResponse.body?.code ?? 'missing'}, status=${replayResponse.body?.data?.mainStatus || 'missing'}`, OPTIONS) + + const finalResponse = await context.request('GET', detailPath) + const finalData = context.apiSuccess(MODULE, '回查三期装柜完成终态', finalResponse, '', OPTIONS) + context.assert(MODULE, '入库单终态 LOADED、版本递增且审计历史存在', finalResponse, + finalData?.summary?.processStatus === 'LOADED' && Number(finalData?.summary?.version) === version + 1 + && finalData?.statusHistory?.some?.((item) => item.action === 'COMPLETE_LOADING' + && item.operatorName && item.fromStatus === 'LOADING' && item.toStatus === 'LOADED'), + `status=${finalData?.summary?.processStatus || 'missing'}, version=${finalData?.summary?.version ?? 'missing'}, histories=${finalData?.statusHistory?.length ?? 'n/a'}`, OPTIONS) + + context.createdResources.phase3Loading = { + ...fixture, inboundVersion: Number(finalData?.summary?.version), inboundStatus: 'LOADED', finalStatus: 'LOADED', + } +} diff --git a/scripts/admin-test-env-modules/phase3-loading-model-bridge.mjs b/scripts/admin-test-env-modules/phase3-loading-model-bridge.mjs index 1b366e48b..8592e8235 100644 --- a/scripts/admin-test-env-modules/phase3-loading-model-bridge.mjs +++ b/scripts/admin-test-env-modules/phase3-loading-model-bridge.mjs @@ -25,9 +25,6 @@ export async function run(context) { { activation: { state: 'pending-fixture', assertions: ['双向关联可查', '双版本冲突原子回滚', '终态门禁一致'] } }) return } - context.addCoverageGap(MODULE, OPTIONS.scenario, '入库单级预装与柜次级智能装柜串联', - '桥接契约、SQL和真实断言已完成,待目标提交部署并取得本模块真实 HTTP PASS 后删除此项', - 'deployment-validation-pending', { activation: { state: 'pending-deployment-validation' } }) const inboundPath = `/api/platform/v1/inbounds/${inboundId}/preload/loading-plans?tenantId=${encodeURIComponent(tenantId)}` const inboundResponse = await request(context, 'GET', inboundPath) const inboundLinks = context.apiSuccess(MODULE, '从入库侧查询预装与柜次关联', inboundResponse, '', OPTIONS) diff --git a/scripts/admin-test-env-modules/phase3-loading.mjs b/scripts/admin-test-env-modules/phase3-loading.mjs index c8178cbc8..2dd7a46c7 100644 --- a/scripts/admin-test-env-modules/phase3-loading.mjs +++ b/scripts/admin-test-env-modules/phase3-loading.mjs @@ -227,6 +227,32 @@ export async function run(context) { claimed?.workflowStatus === 'IN_PROGRESS' && Boolean(claimed?.assignment?.assigneeId), `workflow=${claimed?.workflowStatus || 'missing'}`, OPTIONS) + const itemClaimBody = { + expectedAssignmentVersion: 0, + operationId: `${context.marker}-bridge-item-claim`.slice(0, 128), + } + const itemClaim = await request(context, 'POST', + `${pdaBase}/tasks/${canonicalTaskId}/items/${id(canonicalItem.id)}/claim`, itemClaimBody) + const itemClaimed = context.apiSuccess(MODULE, '领取柜次 PDA 明细单人作业权', itemClaim, '', OPTIONS) + const claimedItem = itemClaimed?.items?.find?.((item) => id(item.id) === id(canonicalItem.id)) + context.assert(MODULE, 'PDA 明细作业员与任务领取人一致', itemClaim, + id(claimedItem?.assignment?.assigneeId) === id(itemClaimed?.assignment?.assigneeId) + && Number(claimedItem?.assignment?.version) === 0, + `taskAssignee=${id(itemClaimed?.assignment?.assigneeId) || 'missing'}, itemAssignee=${id(claimedItem?.assignment?.assigneeId) || 'missing'}`, OPTIONS) + const itemClaimReplay = await request(context, 'POST', + `${pdaBase}/tasks/${canonicalTaskId}/items/${id(canonicalItem.id)}/claim`, itemClaimBody) + context.assert(MODULE, 'PDA 明细领取 operationId 幂等重放', itemClaimReplay, + codeOf(itemClaimReplay) === '200' + && id(itemClaimReplay.body?.data?.items?.find?.((item) => id(item.id) === id(canonicalItem.id))?.assignment?.assigneeId) + === id(claimedItem?.assignment?.assigneeId), + `HTTP=${itemClaimReplay.http}, code=${codeOf(itemClaimReplay)}`, OPTIONS) + const claimHashConflict = await request(context, 'POST', + `${pdaBase}/tasks/${canonicalTaskId}/items/${id(canonicalItem.id)}/claim`, { + ...itemClaimBody, expectedAssignmentVersion: 1, + }) + context.addStep(MODULE, 'PDA 明细同 operationId 不同版本请求被拒绝', claimHashConflict, + failure(claimHashConflict), `HTTP=${claimHashConflict.http}, code=${codeOf(claimHashConflict)}`, OPTIONS) + const canonicalPick = await request(context, 'POST', `${pdaBase}/tasks/${canonicalTaskId}/items/${id(canonicalItem.id)}/pick`, { warehouseId, pickedPackageCount: 1, locationIds: [], version: Number(canonicalItem.version), @@ -370,26 +396,18 @@ export async function run(context) { && evidenceListData.evidences.some((item) => item.evidenceType === 'SEAL' && item.sealNo === `SEAL-${suffix}`), `evidences=${evidenceListData?.evidences?.length ?? 'n/a'}`, OPTIONS) - const complete = await request(context, 'POST', `${base}/loading/complete?tenantId=${encodeURIComponent(tenantId)}`, - { expectedVersion: loadingVersion, remark: context.marker }, { 'Idempotency-Key': `${context.marker}-p3-complete`.slice(0, 128) }) - const completed = context.apiSuccess(MODULE, '确认本轮入库单装柜完成', complete, '', OPTIONS) - context.assert(MODULE, '装柜完成推进主状态 LOADED 且保留五类证据', complete, - completed?.mainStatus === 'LOADED' && completed?.evidences?.evidences?.length >= evidenceTypes.length, - `mainStatus=${completed?.mainStatus || 'missing'}, evidences=${completed?.evidences?.evidences?.length ?? 'n/a'}`, OPTIONS) + const blockedBeforeDownstream = await request(context, 'POST', `${base}/loading/complete?tenantId=${encodeURIComponent(tenantId)}`, + { expectedVersion: loadingVersion, remark: `${context.marker}-before-downstream` }, + { 'Idempotency-Key': `${context.marker}-p3-complete-before-downstream`.slice(0, 128) }) + context.addStep(MODULE, '订舱拖车报关未齐备时 LOADED 硬门禁拒绝', blockedBeforeDownstream, + failure(blockedBeforeDownstream), + `HTTP=${blockedBeforeDownstream.http}, code=${codeOf(blockedBeforeDownstream)}`, OPTIONS) - const finalResponse = await context.request('GET', detailPath) - const finalData = context.apiSuccess(MODULE, '回查三期装柜完成终态', finalResponse, '', OPTIONS) - context.assert(MODULE, '入库单终态 LOADED、版本递增且审计历史存在', finalResponse, - finalData?.summary?.processStatus === 'LOADED' && Number(finalData?.summary?.version) === loadingVersion + 1 - && finalData?.statusHistory?.some?.((item) => item.action === 'COMPLETE_LOADING' && item.operatorName), - `status=${finalData?.summary?.processStatus || 'missing'}, version=${finalData?.summary?.version ?? 'missing'}, histories=${finalData?.statusHistory?.length ?? 'n/a'}`, OPTIONS) context.createdResources.phase3Loading = { tenantId, inboundId, preloadPlanId: planId, evidenceIds: evidenceItems.map((item) => id(item.id)), + voidedEvidenceId: id(voidTarget?.id), evidenceTypes, loadingPlanId, containerBookingId, containerNo, warehouseId, canonicalPdaTaskId: canonicalTaskId, - pickingTaskId: id(pickingCompleted?.id), marker: context.marker, finalStatus: 'LOADED', + pickingTaskId: id(pickingCompleted?.id), inboundVersion: loadingVersion, + marker: context.marker, inboundStatus: 'LOADING', loadingPlanStatus: 'COMPLETED', finalStatus: 'COMPLETED', } - - context.addCoverageGap(MODULE, OPTIONS.scenario, 'PDA 真机在线限制与设备持久化草稿批量提交', - '当前验收运行器只能发起平台 HTTP 请求,未具备 PDA 真机断网、设备草稿隔离及批量提交端点的可执行环境', - 'device-and-batch-fixture') } diff --git a/scripts/admin-test-env-modules/phase3-order-splitting.mjs b/scripts/admin-test-env-modules/phase3-order-splitting.mjs new file mode 100644 index 000000000..d228c8ec7 --- /dev/null +++ b/scripts/admin-test-env-modules/phase3-order-splitting.mjs @@ -0,0 +1,30 @@ +const MODULE = 'phase3-order-splitting' +const SCENARIO = 'parent-child-multi-container-consolidation' + +export async function run(context) { + const fixture = context.createdResources?.phase3Loading + || context.createdResources?.phase3ContainerLoading + const metadata = { + fixture: { + available: Boolean(fixture?.inboundId && fixture?.containerBookingId), + marker: fixture?.marker || null, + inboundId: fixture?.inboundId || null, + containerBookingId: fixture?.containerBookingId || null, + }, + activation: { + state: 'pending-public-contract', + assertions: [ + '父单与自动剩余子单关系可由 HTTP 查询', + '已装数量 + 自动子单剩余数量 = 拆分前数量', + '同一订单可分配多个柜次且同柜可包含多个订单', + '子单仅允许一层且继续拆分被拒绝', + ], + }, + } + context.addCoverageGap(MODULE, SCENARIO, '父/子订单拆分、装载数量守恒与剩余自动子订单', + 'PDA partial/skip 内部结算已实现,但公开 InboundOrderSummary/Detail 未返回 inboundRole、parentInboundId、rootInboundId、splitStatus 或拆分批次,HTTP 总控无法证明父子关系和守恒,不能用同 orderId 的记录数量伪造成功', + 'contract-gap', metadata) + context.addCoverageGap(MODULE, SCENARIO, '一单多柜与多单拼柜', + 'SmartLoadingController 支持按明细分配,但当前公开契约没有订单-多柜/柜次-多订单关系聚合,也没有本轮安全 fixture 一次生成两个可装柜入库单;待真实查询 DTO 与专用 fixture 接口公开后转为断言', + 'contract-gap', metadata) +} diff --git a/scripts/admin-test-env-modules/phase3-outbound-aggregate.mjs b/scripts/admin-test-env-modules/phase3-outbound-aggregate.mjs index b4ce2d24e..833587cb3 100644 --- a/scripts/admin-test-env-modules/phase3-outbound-aggregate.mjs +++ b/scripts/admin-test-env-modules/phase3-outbound-aggregate.mjs @@ -1,11 +1,52 @@ -import { registerPendingContract } from './_phase3-pending-contract.mjs' +const MODULE = 'phase3-outbound-aggregate' +const OPTIONS = { scenario: 'outbound-aggregate', deploymentBlocker: true } +const BASE = '/api/platform/v1/first-leg/outbound-dispatches' + +function id(value) { return value == null ? '' : String(value) } +function codeOf(response) { return response?.body?.code == null ? 'missing' : String(response.body.code) } +function rejected(response) { + return [400, 401, 403, 404, 409, 503].includes(response?.http) + || (response?.http === 200 && codeOf(response) !== '200') +} +function ordered(items) { + return items.every((item, index) => index === 0 + || String(items[index - 1]?.occurredAt || '') <= String(item?.occurredAt || '')) +} export async function run(context) { - registerPendingContract(context, { - module: 'phase3-outbound-aggregate', scenario: 'outbound-aggregate', name: '出仓单/柜次主聚合与整体时间线', - reason: '出仓主聚合与整体时间线契约已实现并推送,待目标提交部署且 phase3-outbound-dispatch 真实 HTTP 通过后删除此项', category: 'deployment-validation-pending', - fixtures: ['orders', 'phase3Loading', 'phase3ContainerLoading', 'oceanBooking', 'truckAppointment'], - contractSource: 'PlatformOutboundDispatchController / phase3-outbound-dispatch-v1.yaml', - assertions: ['marker 所属主单与柜次关联唯一', '时间线按真实动作有序且含审计字段', '跨客户 ID 不可见'], - }) + const fixture = context.createdResources?.phase3OutboundDispatch + if (!fixture?.inboundId || !fixture?.containerBookingId || fixture.marker !== context.marker) { + context.addCoverageGap(MODULE, OPTIONS.scenario, '出仓单/柜次主聚合与整体时间线', + '本轮未取得 phase3-outbound-dispatch 的 marker 隔离聚合,未使用历史资源', 'fixture') + return + } + const selector = `inboundId=${encodeURIComponent(fixture.inboundId)}&containerBookingId=${encodeURIComponent(fixture.containerBookingId)}` + const response = await context.request('GET', `${BASE}/detail?${selector}`) + const detail = context.apiSuccess(MODULE, '复核出仓主聚合与统一时间线', response, selector, OPTIONS) + const aggregate = detail?.aggregate + const timeline = Array.isArray(detail?.timeline) ? detail.timeline : [] + const expectedModules = ['INBOUND', 'LOADING_PLAN', 'PDA_PICK', 'LOADING_EVIDENCE', + 'OCEAN_BOOKING', 'TRUCK_APPOINTMENT', 'OUTBOUND_DISPATCH'] + context.assert(MODULE, '聚合唯一关联本轮订单、入库单、柜次和客户', response, + id(aggregate?.orderId) === id(fixture.orderId) && id(aggregate?.tenantId) === id(fixture.tenantId) + && id(aggregate?.inboundId) === id(fixture.inboundId) + && id(aggregate?.containerBookingId) === id(fixture.containerBookingId), + `orderId=${id(aggregate?.orderId) || 'missing'}, inboundId=${id(aggregate?.inboundId) || 'missing'}, containerBookingId=${id(aggregate?.containerBookingId) || 'missing'}`, OPTIONS) + context.assert(MODULE, '统一时间线有序且各子流程包含审计字段', response, + timeline.length > 0 && ordered(timeline) + && expectedModules.every((module) => timeline.some((item) => item.module === module)) + && timeline.every((item) => Boolean(item.action) && Boolean(item.occurredAt)), + `timeline=${timeline.length}, modules=${[...new Set(timeline.map((item) => item.module))].join(',')}`, OPTIONS) + + const other = context.createdResources?.phase3ContainerLoading + if (other?.inboundId && other?.containerBookingId + && (id(other.inboundId) !== id(fixture.inboundId) || id(other.containerBookingId) !== id(fixture.containerBookingId))) { + const forged = await context.request('GET', `${BASE}/detail?inboundId=${encodeURIComponent(fixture.inboundId)}&containerBookingId=${encodeURIComponent(other.containerBookingId)}`) + context.addStep(MODULE, '伪造跨资源入库单与柜次组合不可匹配', forged, rejected(forged), + `HTTP=${forged.http}, code=${codeOf(forged)}`, OPTIONS) + } + + context.addCoverageGap(MODULE, OPTIONS.scenario, '报关与船期历史并入统一时间线', + '当前 OutboundDispatchReadMapper 时间线仅聚合入库、装柜计划、PDA、证据、订舱、拖车和出仓历史,未公开报关与船期历史投影', + 'contract-gap') } diff --git a/scripts/admin-test-env-modules/phase3-outbound-dispatch.mjs b/scripts/admin-test-env-modules/phase3-outbound-dispatch.mjs index 2ea109a06..ccac7609f 100644 --- a/scripts/admin-test-env-modules/phase3-outbound-dispatch.mjs +++ b/scripts/admin-test-env-modules/phase3-outbound-dispatch.mjs @@ -93,7 +93,7 @@ export async function run(context) { const confirmed = context.apiSuccess(MODULE, '确认国内出仓进入等待装船', confirmResponse, '', OPTIONS) context.assert(MODULE, 'confirm-dispatch 推进 WAITING_FOR_LOADING 并递增版本', confirmResponse, confirmed?.aggregate?.dispatchStatus === 'WAITING_FOR_LOADING' - && confirmed?.aggregate?.inboundStatus === 'WAITING_FOR_LOADING' + && confirmed?.aggregate?.inboundStatus === 'LOADED' && Number(confirmed?.aggregate?.version) === expectedVersion + 1, `dispatch=${confirmed?.aggregate?.dispatchStatus || 'missing'}, inbound=${confirmed?.aggregate?.inboundStatus || 'missing'}, version=${confirmed?.aggregate?.version ?? 'missing'}`, OPTIONS) @@ -119,4 +119,16 @@ export async function run(context) { reversed?.aggregate?.dispatchStatus === 'READY_FOR_DISPATCH' && reversed?.aggregate?.inboundStatus === 'LOADED' && reversed?.timeline?.some?.((item) => item.module === 'OUTBOUND_DISPATCH' && item.action === 'REVERSE_DISPATCH'), `dispatch=${reversed?.aggregate?.dispatchStatus || 'missing'}, inbound=${reversed?.aggregate?.inboundStatus || 'missing'}`, OPTIONS) + if (reversed?.aggregate) { + context.createdResources.phase3OutboundDispatch = { + marker: context.marker, inboundId, containerBookingId, + orderId: id(reversed.aggregate.orderId), tenantId: id(reversed.aggregate.tenantId), + confirmedStatus: confirmed?.aggregate?.dispatchStatus || null, + confirmedInboundStatus: confirmed?.aggregate?.inboundStatus || null, + confirmedVersion: Number(confirmed?.aggregate?.version), + finalStatus: reversed.aggregate.dispatchStatus, + finalInboundStatus: reversed.aggregate.inboundStatus, + finalVersion: Number(reversed.aggregate.version), + } + } } diff --git a/scripts/admin-test-env-modules/phase3-pda-convergence.mjs b/scripts/admin-test-env-modules/phase3-pda-convergence.mjs index 41ae2fe13..1ee63affd 100644 --- a/scripts/admin-test-env-modules/phase3-pda-convergence.mjs +++ b/scripts/admin-test-env-modules/phase3-pda-convergence.mjs @@ -25,9 +25,6 @@ export async function run(context) { '本轮未取得 marker 隔离的柜次 PDA 与入库汇总 fixture,未使用历史资源', 'fixture') return } - context.addCoverageGap(MODULE, OPTIONS.scenario, 'PDA 柜次拣货与入库单汇总拣货收敛', - 'PDA唯一事实源、作业员隔离、复核和入库聚合真实断言已完成,待目标提交部署并取得真实 HTTP PASS 后删除此项', - 'deployment-validation-pending', { activation: { state: 'pending-deployment-validation' } }) const taskResponse = await request(context, 'GET', `/api/platform/v1/first-leg/pda-loading/tasks?containerNo=${encodeURIComponent(containerNo)}&warehouseId=${encodeURIComponent(warehouseId)}`) const task = context.apiSuccess(MODULE, '回查柜次 PDA 唯一事实任务', taskResponse, '', OPTIONS) @@ -47,7 +44,7 @@ export async function run(context) { `/api/platform/v1/first-leg/pda-loading/tasks/${taskId}/operations`) const operations = context.apiSuccess(MODULE, '查询柜次 PDA 不可变操作历史', operationsResponse, '', OPTIONS) context.assert(MODULE, '领取和复核操作历史含操作者与时间', operationsResponse, - Array.isArray(operations) && ['CLAIM', 'REVIEW'].every((action) => operations.some((item) => item.actionType === action + Array.isArray(operations) && ['CLAIM', 'ITEM_CLAIM', 'REVIEW'].every((action) => operations.some((item) => item.actionType === action && Boolean(item.operatorId) && Boolean(item.createdAt))), `actions=${Array.isArray(operations) ? operations.map((item) => item.actionType).join(',') : 'missing'}`, OPTIONS) const forged = await request(context, 'POST', @@ -56,4 +53,14 @@ export async function run(context) { { 'Idempotency-Key': `${context.marker}-pda-forged-projection`.slice(0, 128) }) context.addStep(MODULE, '入库兼容入口拒绝客户端伪造汇总数量', forged, failure(forged), `HTTP=${forged.http}, code=${codeOf(forged)}`, OPTIONS) + + context.addCoverageGap(MODULE, OPTIONS.scenario, 'PDA 批量提交、设备草稿版本与批次幂等', + '当前 PlatformLoadingPdaController 仅公开单明细 pick/partial/skip 与任务 review,没有批量提交或设备草稿 DTO/OpenAPI', + 'contract-gap') + context.addCoverageGap(MODULE, OPTIONS.scenario, 'PDA 实时库存查询与提交前库存快照校验', + '当前 PDA Controller/OpenAPI 未公开实时库存查询或库存版本快照端点,不能用智能装柜候选列表伪装库存契约', + 'contract-gap') + context.addCoverageGap(MODULE, OPTIONS.scenario, '不同仓库作业员竞争同一 PDA 明细', + '单明细领取和版本 CAS 已自动化;当前总控没有第二个具备 warehouse PDA work 权限的隔离账号 fixture,不能伪造跨操作者冲突', + 'fixture') } diff --git a/scripts/admin-test-env-modules/phase3-port-schedules.mjs b/scripts/admin-test-env-modules/phase3-port-schedules.mjs index 37515f8b2..435b661fe 100644 --- a/scripts/admin-test-env-modules/phase3-port-schedules.mjs +++ b/scripts/admin-test-env-modules/phase3-port-schedules.mjs @@ -71,6 +71,15 @@ function scheduleData(context, suffix, oceanBookingId, containerBookingId, expir } export async function run(context) { + context.addCoverageGap(MODULE, OPTIONS.scenario, '官网船期查询任务、3天窗口最多3次及失败/过期状态', + '当前 PlatformPortScheduleController/OpenAPI 仅公开来源观测 CRUD,没有官网查询 task、3天计数、FAILED/EXPIRED 或任务级人工兜底端点', + 'contract-gap') + context.addCoverageGap(MODULE, OPTIONS.scenario, 'ZIM 官网 hCaptcha 自动查询', + 'ZIM hCaptcha 需要第三方交互能力,仓库未公开 provider/task 契约;保留为外部依赖缺口', + 'external-third-party') + context.addCoverageGap(MODULE, OPTIONS.scenario, 'COSCO 动态页面自动查询', + 'COSCO 动态页面没有已公开 provider/task 契约或稳定机器接口;保留为外部依赖缺口', + 'external-third-party') const suffix = context.marker.replace(/\D/g, '').slice(-10) const probe = await request(context, 'GET', `${BASE}?page=1&size=5&keyword=${encodeURIComponent(context.marker)}`) if (probe.http === 404 || probe.http === 405 || probe.http === 503 || probe.http === 0) { @@ -180,14 +189,18 @@ export async function run(context) { const currentData = scheduleData(context, suffix, ocean.id, knownAllocation, false) currentData.sourceRef = payload.data.sourceRef + currentData.sourceType = 'MANUAL' + currentData.sourceName = `平台人工兜底-${suffix}` const refreshResponse = await request(context, 'PUT', `${BASE}/${scheduleId}`, { expectedVersion: initialVersion + 1, data: currentData, reason: '刷新为当前有效船期' }, { 'Idempotency-Key': `${context.marker}-port-refresh`.slice(0, 128) }) const refreshed = context.apiSuccess(MODULE, '刷新船期为国内出仓可用终态', refreshResponse, '', OPTIONS) context.assert(MODULE, '刷新后船期有效且版本再次递增', refreshResponse, refreshed?.portSchedule?.expired === false + && refreshed?.portSchedule?.schedule?.sourceType === 'MANUAL' + && refreshed?.portSchedule?.schedule?.sourceName === currentData.sourceName && Number(refreshed?.portSchedule?.schedule?.version) === initialVersion + 2, - `expired=${refreshed?.portSchedule?.expired ?? 'missing'}, version=${refreshed?.portSchedule?.schedule?.version ?? 'missing'}`, OPTIONS) + `expired=${refreshed?.portSchedule?.expired ?? 'missing'}, sourceType=${refreshed?.portSchedule?.schedule?.sourceType || 'missing'}, version=${refreshed?.portSchedule?.schedule?.version ?? 'missing'}`, OPTIONS) if (knownAllocation && inboundId) { const selector = `inboundId=${encodeURIComponent(inboundId)}&containerBookingId=${encodeURIComponent(knownAllocation)}` diff --git a/scripts/admin-test-env-modules/phase3-waiting-for-loading.mjs b/scripts/admin-test-env-modules/phase3-waiting-for-loading.mjs index 6bd4623d3..59b488555 100644 --- a/scripts/admin-test-env-modules/phase3-waiting-for-loading.mjs +++ b/scripts/admin-test-env-modules/phase3-waiting-for-loading.mjs @@ -1,10 +1,30 @@ -import { registerPendingContract } from './_phase3-pending-contract.mjs' +const MODULE = 'phase3-waiting-for-loading' +const OPTIONS = { scenario: 'waiting-for-loading-status', deploymentBlocker: true } +const BASE = '/api/platform/v1/first-leg/outbound-dispatches' export async function run(context) { - registerPendingContract(context, { - module: 'phase3-waiting-for-loading', scenario: 'waiting-for-loading-status', name: 'WAITING_FOR_LOADING 统一终态', - reason: 'WAITING_FOR_LOADING、查询和状态时间线已实现并推送,待目标提交部署且 confirm/reverse 真实 HTTP 通过后删除此项', category: 'deployment-validation-pending', - fixtures: ['phase3Loading', 'phase3ContainerLoading'], contractSource: 'OutboundDispatchStatus / phase3-outbound-dispatch-v1.yaml', - assertions: ['本轮出仓完成后进入 WAITING_FOR_LOADING', '列表与详情状态一致', '历史记录来源状态、操作者和时间完整'], - }) + const fixture = context.createdResources?.phase3OutboundDispatch + if (!fixture?.inboundId || !fixture?.containerBookingId || fixture.marker !== context.marker) { + context.addCoverageGap(MODULE, OPTIONS.scenario, 'WAITING_FOR_LOADING 状态与历史', + '本轮未取得 phase3-outbound-dispatch 的 marker 隔离聚合,未使用历史资源', 'fixture') + return + } + const selector = `inboundId=${encodeURIComponent(fixture.inboundId)}&containerBookingId=${encodeURIComponent(fixture.containerBookingId)}` + const response = await context.request('GET', `${BASE}/detail?${selector}`) + const detail = context.apiSuccess(MODULE, '回查 WAITING_FOR_LOADING 状态历史', response, selector, OPTIONS) + const history = Array.isArray(detail?.timeline) + ? detail.timeline.filter((item) => item.module === 'OUTBOUND_DISPATCH') : [] + const confirmed = history.find((item) => item.action === 'CONFIRM_DISPATCH') + const reversed = history.find((item) => item.action === 'REVERSE_DISPATCH') + context.assert(MODULE, '确认出仓真实进入 WAITING_FOR_LOADING', response, + fixture.confirmedStatus === 'WAITING_FOR_LOADING' + && fixture.confirmedInboundStatus === 'LOADED' + && confirmed?.fromStatus === 'READY_FOR_DISPATCH' && confirmed?.toStatus === 'WAITING_FOR_LOADING' + && Boolean(confirmed?.operatorId) && Boolean(confirmed?.operatorName) && Boolean(confirmed?.occurredAt), + `confirmed=${confirmed?.fromStatus || 'missing'}->${confirmed?.toStatus || 'missing'}, version=${fixture.confirmedVersion ?? 'missing'}`, OPTIONS) + context.assert(MODULE, '撤销后状态恢复且 WAITING 历史不可变', response, + detail?.aggregate?.dispatchStatus === 'READY_FOR_DISPATCH' && detail?.aggregate?.inboundStatus === 'LOADED' + && reversed?.fromStatus === 'WAITING_FOR_LOADING' && reversed?.toStatus === 'READY_FOR_DISPATCH' + && Boolean(reversed?.operatorId) && Boolean(reversed?.occurredAt), + `current=${detail?.aggregate?.dispatchStatus || 'missing'}, reversed=${reversed?.fromStatus || 'missing'}->${reversed?.toStatus || 'missing'}`, OPTIONS) } diff --git a/scripts/admin-test-env-modules/service-center-flow.mjs b/scripts/admin-test-env-modules/service-center-flow.mjs index 3afe75ede..da84a3c8b 100644 --- a/scripts/admin-test-env-modules/service-center-flow.mjs +++ b/scripts/admin-test-env-modules/service-center-flow.mjs @@ -6,6 +6,30 @@ const TRUCK = '/api/platform/v1/truck-appointments' function id(value) { return value == null ? '' : String(value) } function codeOf(response) { return response?.body?.code == null ? 'missing' : String(response.body.code) } +async function actionRequest(context, path, endpoint, version) { + try { + const response = await fetch(`${context.options.gateway}${path}/${endpoint}`, { + method: 'POST', + headers: { + Accept: 'application/json', Authorization: `Bearer ${context.token}`, + 'Content-Type': 'application/json; charset=utf-8', + 'Idempotency-Key': `${context.marker}-flow-${endpoint}`.slice(0, 128), + }, + body: JSON.stringify({ expectedVersion: version, reason: context.marker }), + signal: AbortSignal.timeout(60_000), + }) + const raw = await response.text(); let payload = null + try { payload = raw ? JSON.parse(raw) : null } catch { /* bounded raw evidence */ } + return { method: 'POST', path: `${path}/${endpoint}`, http: response.status, body: payload, + raw: payload === null ? raw.slice(0, 500) : null, + requestId: payload?.requestId || response.headers.get('x-request-id'), + traceId: payload?.traceId || response.headers.get('x-trace-id') } + } catch (error) { + return { method: 'POST', path: `${path}/${endpoint}`, http: 0, body: null, + raw: String(error), requestId: null, traceId: null } + } +} + export async function run(context) { const ocean = context.createdResources?.oceanBooking const containerFlow = context.createdResources?.phase3Loading @@ -76,27 +100,42 @@ export async function run(context) { appointmentId, oceanBookingId: ocean.id, containerBookingId: linkContainer ? id(containerFlow.containerBookingId) : null, loadingPlanId: linkContainer ? id(containerFlow.loadingPlanId) : null, - marker: context.marker, finalStatus: 'CANCELLED', + marker: context.marker, finalStatus: 'COMPLETED', + } + const path = `${TRUCK}/${appointmentId}` + let version = Number(appointment.version) + for (const [name, endpoint, status] of [ + ['提交跨链路预约', 'submit', 'PENDING_DISPATCH'], + ['派发跨链路拖车', 'dispatch', 'DISPATCHED'], + ['跨链路开始提货', 'pickup', 'PICKUP_IN_PROGRESS'], + ['跨链路拖车到仓', 'arrive', 'ARRIVED_WAREHOUSE'], + ['跨链路开始装货', 'load', 'LOADING'], + ['跨链路拖车离仓', 'depart', 'DEPARTED'], + ['完成跨链路拖车', 'complete', 'COMPLETED'], + ]) { + const action = await actionRequest(context, path, endpoint, version) + const changed = context.apiSuccess(MODULE, name, action, '', OPTIONS) + context.assert(MODULE, `${name}状态正确`, action, changed?.status === status, + `status=${changed?.status || 'missing'}, version=${changed?.version ?? 'missing'}`, OPTIONS) + if (!changed) return + version = Number(changed.version) } - const action = await context.request('POST', `${TRUCK}/${appointmentId}/cancel`, { expectedVersion: Number(appointment.version), reason: context.marker }) - const cancelled = context.apiSuccess(MODULE, '取消跨服务链路拖车预约', action, '', OPTIONS) - context.assert(MODULE, '跨服务链路安全终态为 CANCELLED', action, cancelled?.status === 'CANCELLED', `status=${cancelled?.status || 'missing'}`, OPTIONS) const detail = await context.request('GET', `${TRUCK}/${appointmentId}`) const final = context.apiSuccess(MODULE, '回查跨服务链路安全终态', detail, '', OPTIONS) context.assert(MODULE, '跨服务链路终态持久化且关联订舱未漂移', detail, - final?.status === 'CANCELLED' && id(final?.oceanBookingId) === id(ocean.id) + final?.status === 'COMPLETED' && id(final?.oceanBookingId) === id(ocean.id) && (!linkContainer || id(final?.containerBookingId) === id(containerFlow.containerBookingId) && id(final?.loadingPlanId) === id(containerFlow.loadingPlanId)), `status=${final?.status || 'missing'}, oceanBookingId=${final?.oceanBookingId || 'missing'}, containerBookingId=${final?.containerBookingId || 'n/a'}, loadingPlanId=${final?.loadingPlanId || 'n/a'}`, OPTIONS) const historyResponse = await context.request('GET', `${TRUCK}/${appointmentId}/history`) const history = context.apiSuccess(MODULE, '查询跨服务链路拖车预约状态历史', historyResponse, '', OPTIONS) - const cancel = Array.isArray(history) ? history.find((item) => item?.action === 'CANCEL') : null - context.assert(MODULE, '跨服务链路历史包含归属、状态、操作者、时间和备注', historyResponse, - Boolean(cancel) && id(cancel?.appointmentId) === appointmentId - && cancel?.fromStatus === 'DRAFT' && cancel?.toStatus === 'CANCELLED' - && Boolean(cancel?.operatorId) && Boolean(cancel?.operatorName) && Boolean(cancel?.createdAt) - && cancel?.reason === context.marker, - `history=${history?.length ?? 'n/a'}, cancelFrom=${cancel?.fromStatus || 'missing'}, cancelTo=${cancel?.toStatus || 'missing'}`, + const actions = Array.isArray(history) ? history.filter((item) => item?.action !== 'CREATE') : [] + const expectedActions = ['SUBMIT', 'DISPATCH', 'PICKUP', 'ARRIVE', 'LOAD', 'DEPART', 'COMPLETE'] + context.assert(MODULE, '跨服务链路历史包含完整状态链、操作者、时间和备注', historyResponse, + expectedActions.every((action) => actions.some((item) => item.action === action + && id(item.appointmentId) === appointmentId && Boolean(item.operatorId) + && Boolean(item.operatorName) && Boolean(item.createdAt) && item.reason === context.marker)), + `history=${history?.length ?? 'n/a'}, actions=${actions.map((item) => item.action).join(',')}`, OPTIONS) }