diff --git a/integration-service/.env.example b/integration-service/.env.example new file mode 100644 index 000000000..5a8b20f6e --- /dev/null +++ b/integration-service/.env.example @@ -0,0 +1,3 @@ +# Copy to .env.local for local WMS connectivity verification. +LINGXING_WMS_APP_KEY= +LINGXING_WMS_APP_SECRET= diff --git a/integration-service/src/main/java/com/baiyue/integration/config/LingxingWmsProperties.java b/integration-service/src/main/java/com/baiyue/integration/config/LingxingWmsProperties.java new file mode 100644 index 000000000..5226455a5 --- /dev/null +++ b/integration-service/src/main/java/com/baiyue/integration/config/LingxingWmsProperties.java @@ -0,0 +1,49 @@ +package com.baiyue.integration.config; + +import org.springframework.boot.context.properties.ConfigurationProperties; +import org.springframework.boot.context.properties.bind.DefaultValue; + +import java.net.URI; +import java.time.Duration; + +@ConfigurationProperties(prefix = "baiyue.integration.lingxing-wms") +public record LingxingWmsProperties( + @DefaultValue("false") boolean enabled, + @DefaultValue("https://api.xlwms.com") URI baseUri, + String appKey, + String appSecret, + @DefaultValue("5s") Duration connectTimeout, + @DefaultValue("15s") Duration requestTimeout, + @DefaultValue("1s") Duration minRequestInterval +) { + public LingxingWmsProperties { + baseUri = baseUri == null ? URI.create("https://api.xlwms.com") : baseUri; + connectTimeout = connectTimeout == null ? Duration.ofSeconds(5) : connectTimeout; + requestTimeout = requestTimeout == null ? Duration.ofSeconds(15) : requestTimeout; + minRequestInterval = minRequestInterval == null ? Duration.ofSeconds(1) : minRequestInterval; + if (connectTimeout.isZero() || connectTimeout.isNegative() + || requestTimeout.isZero() || requestTimeout.isNegative() + || minRequestInterval.isNegative() || minRequestInterval.compareTo(Duration.ofDays(1)) > 0) { + throw new IllegalArgumentException("领星 WMS 超时必须为正数,最小请求间隔须在 0 至 1 天之间"); + } + String host = baseUri.getHost(); + boolean loopback = "localhost".equalsIgnoreCase(host) || "127.0.0.1".equals(host) + || "[::1]".equals(host); + boolean secureScheme = "https".equalsIgnoreCase(baseUri.getScheme()) + || ("http".equalsIgnoreCase(baseUri.getScheme()) && loopback); + if (host == null || !secureScheme || baseUri.getUserInfo() != null + || baseUri.getQuery() != null || baseUri.getFragment() != null + || (!baseUri.getPath().isEmpty() && !"/".equals(baseUri.getPath()))) { + throw new IllegalArgumentException("领星 WMS base-uri 必须是 HTTPS 源地址;仅本机测试允许 HTTP"); + } + } + + public boolean hasCredentials() { + return appKey != null && !appKey.isBlank() && appSecret != null && !appSecret.isBlank(); + } + + @Override + public String toString() { + return "LingxingWmsProperties[enabled=" + enabled + ", credentialsConfigured=" + hasCredentials() + "]"; + } +} diff --git a/integration-service/src/main/java/com/baiyue/integration/controller/LingxingInternalController.java b/integration-service/src/main/java/com/baiyue/integration/controller/LingxingInternalController.java new file mode 100644 index 000000000..6d57af5df --- /dev/null +++ b/integration-service/src/main/java/com/baiyue/integration/controller/LingxingInternalController.java @@ -0,0 +1,55 @@ +package com.baiyue.integration.controller; + +import com.baiyue.common.core.api.ApiResponse; +import com.baiyue.common.core.api.ResultCode; +import com.baiyue.common.web.CorrelationContext; +import com.baiyue.integration.dto.lingxing.LingxingWmsDtos.PageResult; +import com.baiyue.integration.dto.lingxing.LingxingWmsDtos.QueryRequest; +import com.baiyue.integration.provider.lingxing.LingxingWmsClient; +import com.baiyue.integration.provider.lingxing.LingxingWmsException; +import com.baiyue.integration.security.InternalServiceTokenGuard; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.validation.Valid; +import org.springframework.web.bind.annotation.ExceptionHandler; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestHeader; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +@Tag(name = "领星 WMS 内部查询", description = "仅内部服务令牌认证,不提供公网路由") +@RestController +@RequestMapping("/internal/integrations/lingxing/wms") +public class LingxingInternalController { + private final InternalServiceTokenGuard tokenGuard; + private final LingxingWmsClient client; + + public LingxingInternalController(InternalServiceTokenGuard tokenGuard, LingxingWmsClient client) { + this.tokenGuard = tokenGuard; + this.client = client; + } + + @Operation(summary = "按出库时间分页只读查询 WMS 已出库摘要") + @PostMapping("/outbound-orders/query") + public ApiResponse query( + @RequestHeader(value = "X-Internal-Service-Token", required = false) String token, + @Valid @RequestBody QueryRequest request) { + tokenGuard.verify(token); + return ApiResponse.success(client.queryPage(request)); + } + + @ExceptionHandler(LingxingWmsException.class) + public ApiResponse handleProviderFailure(LingxingWmsException exception, + HttpServletRequest request) { + ResultCode code = exception.retryable() ? ResultCode.SERVICE_UNAVAILABLE : ResultCode.BUSINESS_ERROR; + return ApiResponse.of(code, exception.getMessage(), + new ProviderFailure(exception.code(), exception.retryable(), + exception.upstreamStatus(), exception.sourceCode()), + CorrelationContext.requestId(request), CorrelationContext.traceId(request)); + } + + public record ProviderFailure(String errorCode, boolean retryable, + Integer upstreamStatus, Integer sourceCode) { } +} diff --git a/integration-service/src/main/java/com/baiyue/integration/dto/lingxing/LingxingWmsDtos.java b/integration-service/src/main/java/com/baiyue/integration/dto/lingxing/LingxingWmsDtos.java new file mode 100644 index 000000000..12ddac758 --- /dev/null +++ b/integration-service/src/main/java/com/baiyue/integration/dto/lingxing/LingxingWmsDtos.java @@ -0,0 +1,25 @@ +package com.baiyue.integration.dto.lingxing; + +import jakarta.validation.constraints.Max; +import jakarta.validation.constraints.Min; +import jakarta.validation.constraints.NotNull; + +import java.time.Instant; +import java.time.LocalDateTime; +import java.util.List; + +public final class LingxingWmsDtos { + private LingxingWmsDtos() { } + + public record QueryRequest(@NotNull LocalDateTime startTime, @NotNull LocalDateTime endTime, + @Min(1) int current, @Min(1) @Max(100) int size) { } + + // Preserve source time strings until the account's source timezone has been verified. + public record OrderSummary(String deliveryNo, String sourceNo, String customerCode, String whCode, + Integer status, String createTime, String outboundTime) { } + + public record PageResult(List records, Long total, int current, int size, + int sourceCode, Instant observedAt) { + public PageResult { records = List.copyOf(records); } + } +} diff --git a/integration-service/src/main/java/com/baiyue/integration/provider/lingxing/LingxingWmsClient.java b/integration-service/src/main/java/com/baiyue/integration/provider/lingxing/LingxingWmsClient.java new file mode 100644 index 000000000..01a27a7c6 --- /dev/null +++ b/integration-service/src/main/java/com/baiyue/integration/provider/lingxing/LingxingWmsClient.java @@ -0,0 +1,263 @@ +package com.baiyue.integration.provider.lingxing; + +import com.baiyue.integration.config.LingxingWmsProperties; +import com.baiyue.integration.dto.lingxing.LingxingWmsDtos.OrderSummary; +import com.baiyue.integration.dto.lingxing.LingxingWmsDtos.PageResult; +import com.baiyue.integration.dto.lingxing.LingxingWmsDtos.QueryRequest; +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.DeserializationFeature; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.stereotype.Component; + +import java.io.IOException; +import java.net.http.HttpClient; +import java.net.http.HttpRequest; +import java.net.http.HttpResponse; +import java.net.http.HttpTimeoutException; +import java.nio.charset.StandardCharsets; +import java.time.Clock; +import java.time.format.DateTimeFormatter; +import java.time.temporal.ChronoUnit; +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.concurrent.Semaphore; + +@Component +public final class LingxingWmsClient { + private static final String PAGE_PATH = "/openapi/v2/order/delivery/page"; + private static final Set AUTH_ERROR_CODES = Set.of(11002, 11003, 11004, 11005, 11006, 11007, 11008, 11010, 31001); + private static final DateTimeFormatter SOURCE_TIME = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss"); + private final LingxingWmsProperties properties; + private final ObjectMapper mapper; + private final LingxingWmsSigner signer; + private final HttpClient httpClient; + private final Clock clock; + private final Semaphore concurrency = new Semaphore(1); + private long lastRequestNanos; + private boolean hasRequested; + + @Autowired + public LingxingWmsClient(LingxingWmsProperties properties, ObjectMapper mapper) { + this(properties, mapper, Clock.systemUTC()); + } + + LingxingWmsClient(LingxingWmsProperties properties, ObjectMapper mapper, Clock clock) { + this.properties = properties; + this.mapper = mapper; + this.clock = clock; + signer = new LingxingWmsSigner(mapper); + httpClient = HttpClient.newBuilder().connectTimeout(properties.connectTimeout()) + .followRedirects(HttpClient.Redirect.NEVER).build(); + } + + public PageResult queryPage(QueryRequest request) { + if (!properties.enabled()) throw failure("DISABLED", "领星 WMS 接入未启用", false); + if (!properties.hasCredentials()) throw failure("MISSING_CREDENTIALS", "领星 WMS 授权配置不完整", false); + validate(request); + if (!concurrency.tryAcquire()) throw failure("LOCAL_RATE_LIMITED", "领星 WMS 查询正在执行,请稍后重试", true); + try { + reserveRequest(); + Map data = new LinkedHashMap<>(); + data.put("current", request.current()); + data.put("size", request.size()); + data.put("timeType", "outboundTime"); + data.put("startTime", SOURCE_TIME.format(request.startTime())); + data.put("endTime", SOURCE_TIME.format(request.endTime())); + data.put("status", 100); + var signed = signer.sign(properties.appKey(), properties.appSecret(), PAGE_PATH, data, clock.instant()); + HttpRequest httpRequest = HttpRequest.newBuilder(properties.baseUri().resolve(PAGE_PATH)) + .timeout(properties.requestTimeout()).header("Accept", "application/json") + .header("Content-Type", "application/json; charset=UTF-8") + .POST(HttpRequest.BodyPublishers.ofString(signed.body(), StandardCharsets.UTF_8)).build(); + HttpResponse response = send(httpRequest); + validateStatus(response.statusCode()); + return page(response.body(), request); + } finally { + concurrency.release(); + } + } + + private void validate(QueryRequest request) { + if (request == null || request.startTime() == null || request.endTime() == null + || !request.startTime().truncatedTo(ChronoUnit.SECONDS).isBefore(request.endTime().truncatedTo(ChronoUnit.SECONDS)) + || request.startTime().isBefore(request.endTime().minusMonths(1)) + || request.current() < 1 || request.size() < 1 || request.size() > 100) { + // These are local request bounds, not a claim about the supplier's pagination limits. + throw failure("INVALID_REQUEST", "领星 WMS 查询范围最多一个自然月,每页条数须为 1 至 100", false); + } + } + + private void reserveRequest() { + long now = System.nanoTime(); + if (hasRequested && now - lastRequestNanos < properties.minRequestInterval().toNanos()) { + throw failure("LOCAL_RATE_LIMITED", "领星 WMS 请求过于频繁,请稍后重试", true); + } + hasRequested = true; + lastRequestNanos = now; + } + + private HttpResponse send(HttpRequest request) { + try { + return httpClient.send(request, HttpResponse.BodyHandlers.ofString(StandardCharsets.UTF_8)); + } catch (HttpTimeoutException exception) { + throw failure("TIMEOUT", "领星 WMS 请求超时", true); + } catch (InterruptedException exception) { + Thread.currentThread().interrupt(); + throw failure("INTERRUPTED", "领星 WMS 请求被中断", true); + } catch (IOException exception) { + throw failure("IO_FAILED", "领星 WMS 网络请求失败", true); + } + } + + private void validateStatus(int status) { + if (status == 429) throw upstreamFailure("HTTP_RATE_LIMITED", "领星 WMS 请求受到限流", true, status, null); + if (status == 401 || status == 403) { + throw upstreamFailure("AUTHORIZATION_REJECTED", "领星 WMS 拒绝授权,请核对凭据与账号权限", false, status, null); + } + if (status != 200) { + throw upstreamFailure("HTTP_FAILED", "领星 WMS 返回异常 HTTP 状态", status >= 500, status, null); + } + } + + private PageResult page(String body, QueryRequest request) { + JsonNode root; + try { + root = mapper.reader().with(DeserializationFeature.FAIL_ON_TRAILING_TOKENS).readTree(body); + } catch (JsonProcessingException exception) { + throw invalidResponse("JSON", null); + } + if (root == null || !root.isObject()) throw invalidResponse("ROOT_TYPE_" + nodeType(root), null); + JsonNode sourceCode = root.get("code"); + // Official OMP ErrorResponse is distinct from its integer-code business response. + if (sourceCode != null && sourceCode.isTextual()) { + Integer errorCode = diagnosticCode(sourceCode); + if (errorCode != null && AUTH_ERROR_CODES.contains(errorCode)) throw sourceRejected(errorCode); + throw invalidResponse("CODE_TYPE_STRING", errorCode); + } + if (sourceCode == null || !sourceCode.isIntegralNumber() || !sourceCode.canConvertToInt()) { + throw invalidResponse("CODE_TYPE_" + nodeType(sourceCode), diagnosticCode(sourceCode)); + } + if (sourceCode.intValue() != 200) { + throw sourceRejected(sourceCode.intValue()); + } + JsonNode success = root.get("success"); + // OMP marks success required, but the verified WMS response omits it; validate it when present. + if (success != null) { + if (!success.isBoolean()) throw invalidResponse("SUCCESS_TYPE_" + nodeType(success), 200); + if (!success.booleanValue()) throw sourceRejected(200); + } + JsonNode data = root.get("data"); + if (data == null || !data.isObject()) throw invalidResponse("DATA_TYPE_" + nodeType(data), 200); + JsonNode records = data.get("records"); + if (records == null || !records.isArray()) throw invalidResponse("RECORDS_TYPE_" + nodeType(records), 200); + if (records.size() > request.size()) throw invalidResponse("RECORDS_OVER_PAGE_SIZE", 200); + Long total = optionalTotal(data.get("total")); + if (total != null && total < records.size()) throw invalidResponse("TOTAL_BELOW_RECORD_COUNT", 200); + verifyPage(data.get("current"), request.current(), "CURRENT"); + verifyPage(data.get("size"), request.size(), "SIZE"); + List summaries = new ArrayList<>(); + for (JsonNode record : records) { + if (!record.isObject()) throw invalidResponse("ORDER_TYPE_" + nodeType(record), 200); + String deliveryNo = optionalText(record, "deliveryNo"); + if (deliveryNo == null || deliveryNo.isBlank()) throw invalidResponse("DELIVERY_NO_EMPTY", 200); + summaries.add(new OrderSummary(deliveryNo, optionalText(record, "sourceNo"), optionalText(record, "customerCode"), + optionalText(record, "whCode"), optionalStatus(record.get("status")), + optionalText(record, "createTime"), optionalText(record, "outboundTime"))); + } + return new PageResult(summaries, total, request.current(), request.size(), 200, clock.instant()); + } + + private static String optionalText(JsonNode record, String field) { + JsonNode value = record.get(field); + if (value == null || value.isNull()) return null; + if (!value.isTextual()) { + String stage = switch (field) { + case "deliveryNo" -> "DELIVERY_NO"; + case "sourceNo" -> "SOURCE_NO"; + case "customerCode" -> "CUSTOMER_CODE"; + case "whCode" -> "WAREHOUSE_CODE"; + case "createTime" -> "CREATE_TIME"; + case "outboundTime" -> "OUTBOUND_TIME"; + default -> "TEXT_FIELD"; + }; + throw invalidResponse(stage + "_TYPE_" + nodeType(value), 200); + } + return value.textValue(); + } + + private static Integer optionalStatus(JsonNode value) { + if (missingNumber(value)) return null; + if (!value.isIntegralNumber() || !value.canConvertToInt()) { + throw invalidResponse("STATUS_TYPE_" + nodeType(value), 200); + } + return value.intValue(); + } + + private static Long optionalTotal(JsonNode value) { + if (missingNumber(value)) return null; + if (!value.isIntegralNumber() || !value.canConvertToLong()) { + throw invalidResponse("TOTAL_TYPE_" + nodeType(value), 200); + } + if (value.longValue() < 0) throw invalidResponse("TOTAL_NEGATIVE", 200); + return value.longValue(); + } + + private static void verifyPage(JsonNode value, int expected, String stage) { + if (missingNumber(value)) return; + if (!value.isIntegralNumber() || !value.canConvertToInt()) { + throw invalidResponse(stage + "_TYPE_" + nodeType(value), 200); + } + if (value.intValue() != expected) throw invalidResponse(stage + "_MISMATCH", 200); + } + + private static Integer diagnosticCode(JsonNode value) { + if (value == null) return null; + if (value.isIntegralNumber() && value.canConvertToInt()) return value.intValue(); + // Only the documented authentication error envelope accepts string error codes. + if (!value.isTextual() || !value.textValue().matches("[0-9]{1,10}")) return null; + try { + return Integer.valueOf(value.textValue()); + } catch (NumberFormatException exception) { + return null; + } + } + + private static String nodeType(JsonNode value) { + return value == null ? "MISSING" : value.getNodeType().name(); + } + + private static boolean missingNumber(JsonNode value) { + // OMP explicitly serializes null numeric fields as an empty string or null. + return value == null || value.isNull() || (value.isTextual() && value.textValue().isEmpty()); + } + + private static LingxingWmsException sourceRejected(int code) { + if (code == 11008) { + return upstreamFailure("AUTHORIZATION_REJECTED", + "领星 WMS 未授予查询接口权限,请核对管理后台自研系统授权", false, 200, code); + } + if (code == 11006) { + return upstreamFailure("SIGNATURE_REJECTED", "领星 WMS 请求验签未通过", false, 200, code); + } + return upstreamFailure("SOURCE_REJECTED", "领星 WMS 业务请求失败,请核对来源错误码", code == 31001, 200, code); + } + + private static LingxingWmsException invalidResponse(String stage, Integer sourceCode) { + return upstreamFailure("INVALID_RESPONSE", "领星 WMS 返回内容不符合出库分页接口契约(" + stage + ")", + false, 200, sourceCode); + } + + private static LingxingWmsException failure(String code, String message, boolean retryable) { + return upstreamFailure(code, message, retryable, null, null); + } + + private static LingxingWmsException upstreamFailure(String code, String message, boolean retryable, + Integer status, Integer sourceCode) { + return new LingxingWmsException("LINGXING_WMS_" + code, message, retryable, status, sourceCode); + } +} diff --git a/integration-service/src/main/java/com/baiyue/integration/provider/lingxing/LingxingWmsException.java b/integration-service/src/main/java/com/baiyue/integration/provider/lingxing/LingxingWmsException.java new file mode 100644 index 000000000..d75bf0cb8 --- /dev/null +++ b/integration-service/src/main/java/com/baiyue/integration/provider/lingxing/LingxingWmsException.java @@ -0,0 +1,24 @@ +package com.baiyue.integration.provider.lingxing; + +/** Only fixed messages and numeric upstream metadata may leave the adapter. */ +public final class LingxingWmsException extends RuntimeException { + private final String code; + private final boolean retryable; + private final Integer upstreamStatus; + private final Integer sourceCode; + + public LingxingWmsException(String code, String message, boolean retryable, + Integer upstreamStatus, Integer sourceCode) { + // Upstream exceptions can contain signed URLs or response fragments; never attach them. + super(message); + this.code = code; + this.retryable = retryable; + this.upstreamStatus = upstreamStatus; + this.sourceCode = sourceCode; + } + + public String code() { return code; } + public boolean retryable() { return retryable; } + public Integer upstreamStatus() { return upstreamStatus; } + public Integer sourceCode() { return sourceCode; } +} diff --git a/integration-service/src/main/java/com/baiyue/integration/provider/lingxing/LingxingWmsSigner.java b/integration-service/src/main/java/com/baiyue/integration/provider/lingxing/LingxingWmsSigner.java new file mode 100644 index 000000000..7293fdf1e --- /dev/null +++ b/integration-service/src/main/java/com/baiyue/integration/provider/lingxing/LingxingWmsSigner.java @@ -0,0 +1,74 @@ +package com.baiyue.integration.provider.lingxing; + +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.ObjectWriter; +import com.fasterxml.jackson.databind.SerializationFeature; +import com.fasterxml.jackson.databind.node.ObjectNode; + +import javax.crypto.Mac; +import javax.crypto.spec.SecretKeySpec; +import java.nio.charset.StandardCharsets; +import java.security.GeneralSecurityException; +import java.time.Instant; +import java.util.HexFormat; +import java.util.Map; +import java.util.StringJoiner; + +/** OMP service-provider signature; the data object keeps its transmitted insertion order. */ +public final class LingxingWmsSigner { + private final ObjectMapper mapper; + private final ObjectWriter writer; + + public LingxingWmsSigner(ObjectMapper mapper) { + this.mapper = mapper; + writer = mapper.writer().without(SerializationFeature.INDENT_OUTPUT, SerializationFeature.WRAP_ROOT_VALUE); + } + + public SignedRequest sign(String appKey, String appSecret, String path, Map data, Instant requestedAt) { + if (appKey == null || appKey.isBlank() || appSecret == null || appSecret.isBlank() + || path == null || !path.startsWith("/openapi/v2/") || path.contains("?") + || data == null || requestedAt == null) throw failure(); + try { + JsonNode dataNode = mapper.valueToTree(data); + String timestamp = Long.toString(requestedAt.getEpochSecond()); + // Official OMP sample uses Map/List string form, not JSON, and includes parameter names. + String input = appSecret + path + "appKey" + appKey + "data" + parameterValue(dataNode) + + "timestamp" + timestamp + appSecret; + Mac mac = Mac.getInstance("HmacSHA256"); + mac.init(new SecretKeySpec(appSecret.getBytes(StandardCharsets.UTF_8), "HmacSHA256")); + String signature = HexFormat.of().formatHex(mac.doFinal(input.getBytes(StandardCharsets.UTF_8))); + ObjectNode body = mapper.createObjectNode(); + body.put("appKey", appKey); + body.set("data", dataNode); + body.put("timestamp", timestamp); + body.put("sign", signature); + return new SignedRequest(writer.writeValueAsString(body)); + } catch (JsonProcessingException | GeneralSecurityException | IllegalArgumentException exception) { + throw failure(); + } + } + + private static String parameterValue(JsonNode value) { + if (value.isObject()) { + StringJoiner rendered = new StringJoiner(", ", "{", "}"); + value.fields().forEachRemaining(field -> rendered.add(field.getKey() + "=" + parameterValue(field.getValue()))); + return rendered.toString(); + } + if (value.isArray()) { + StringJoiner rendered = new StringJoiner(", ", "[", "]"); + value.forEach(child -> rendered.add(parameterValue(child))); + return rendered.toString(); + } + return value.isNull() ? "null" : value.asText(); + } + + private static LingxingWmsException failure() { + return new LingxingWmsException("LINGXING_WMS_SIGNATURE_ERROR", "领星 WMS 请求签名失败", false, null, null); + } + + public record SignedRequest(String body) { + @Override public String toString() { return "SignedRequest[REDACTED]"; } + } +} diff --git a/integration-service/src/main/resources/application-lingxing-local.yml b/integration-service/src/main/resources/application-lingxing-local.yml new file mode 100644 index 000000000..9fae3d231 --- /dev/null +++ b/integration-service/src/main/resources/application-lingxing-local.yml @@ -0,0 +1,68 @@ +# Select explicitly with --spring.config.location=classpath:/application-lingxing-local.yml. +# No remote imports, database, registry or scheduled jobs are used by this local instance. +server: + address: 127.0.0.1 + port: ${INTEGRATION_LOCAL_PORT:19210} + +spring: + application: + name: integration-service + autoconfigure: + exclude: + - org.springframework.boot.autoconfigure.jdbc.DataSourceAutoConfiguration + - com.baomidou.mybatisplus.autoconfigure.MybatisPlusAutoConfiguration + cloud: + bootstrap: + enabled: false + discovery: + enabled: false + service-registry: + auto-registration: + enabled: false + nacos: + config: + enabled: false + import-check: + enabled: false + discovery: + enabled: false + register-enabled: false + +baiyue: + runtime: + scheduling-enabled: false + security: + internal-service-token: ${INTERNAL_SERVICE_TOKEN:} + integration: + lingxing-wms: + enabled: ${LINGXING_WMS_ENABLED:false} + base-uri: https://api.xlwms.com + app-key: ${LINGXING_WMS_APP_KEY:} + app-secret: ${LINGXING_WMS_APP_SECRET:} + connect-timeout: 5s + request-timeout: 15s + min-request-interval: 1s + baidu-ocr: + enabled: false + amazon-sp-api: + enabled: false + carrier-schedule: + matson: + enabled: false + hede: + enabled: false + sml: + enabled: false + zim: + enabled: false + evergreen: + enabled: false + +management: + endpoints: + web: + exposure: + include: health + endpoint: + health: + show-details: never diff --git a/integration-service/src/main/resources/application.yml b/integration-service/src/main/resources/application.yml index 7916af7b8..336a6e74b 100644 --- a/integration-service/src/main/resources/application.yml +++ b/integration-service/src/main/resources/application.yml @@ -35,6 +35,14 @@ baiyue: security: internal-service-token: ${INTERNAL_SERVICE_TOKEN:} integration: + lingxing-wms: + enabled: ${LINGXING_WMS_ENABLED:false} + base-uri: https://api.xlwms.com + app-key: ${LINGXING_WMS_APP_KEY:} + app-secret: ${LINGXING_WMS_APP_SECRET:} + connect-timeout: 5s + request-timeout: 15s + min-request-interval: 1s baidu-ocr: enabled: ${BAIDU_OCR_ENABLED:false} api-key: ${BAIDU_OCR_API_KEY:} diff --git a/integration-service/src/main/resources/openapi/lingxing-wms-v1.yaml b/integration-service/src/main/resources/openapi/lingxing-wms-v1.yaml new file mode 100644 index 000000000..cb25a3309 --- /dev/null +++ b/integration-service/src/main/resources/openapi/lingxing-wms-v1.yaml @@ -0,0 +1,81 @@ +openapi: 3.0.3 +info: + title: Lingxing WMS Internal Read API + version: 1.0.0 + description: Read-only provider adapter. No business scheduling, persistence, inventory mutation or public Gateway route. +security: + - InternalServiceToken: [] +paths: + /internal/integrations/lingxing/wms/outbound-orders/query: + post: + summary: Query one page of completed WMS outbound summaries by outbound time + description: Calls OMP /openapi/v2/order/delivery/page with timeType=outboundTime and status=100. Source time strings are preserved. This is a single page, not a complete sales report. It uses the self-developed-system API authorization in the WMS management console. + requestBody: + required: true + content: + application/json: + schema: {$ref: '#/components/schemas/QueryRequest'} + responses: + '200': + description: Standard envelope; code 200 for success, A0401 for rejected internal identity, A0422 for bean validation, B0001 or S0503 for provider/configuration failures. Failures never return a successful empty page. + content: + application/json: + schema: {$ref: '#/components/schemas/Response'} +components: + securitySchemes: + InternalServiceToken: + type: apiKey + in: header + name: X-Internal-Service-Token + schemas: + QueryRequest: + type: object + required: [startTime, endTime, current, size] + properties: + startTime: {type: string, example: '2026-09-12T08:00:00', description: Source local datetime; must precede endTime and must not be earlier than endTime minus one calendar month. This is a local guard.} + endTime: {type: string, example: '2026-10-12T08:00:00', description: Source local datetime.} + current: {type: integer, minimum: 1} + size: {type: integer, minimum: 1, maximum: 100, description: Local request cap; not a claim about the provider quota. Use 1 for connectivity verification.} + OrderSummary: + type: object + required: [deliveryNo] + properties: + deliveryNo: {type: string} + sourceNo: {type: string, nullable: true} + customerCode: {type: string, nullable: true} + whCode: {type: string, nullable: true} + status: {type: integer, nullable: true} + createTime: {type: string, nullable: true} + outboundTime: {type: string, nullable: true} + PageResult: + type: object + required: [records, current, size, sourceCode, observedAt] + properties: + records: {type: array, items: {$ref: '#/components/schemas/OrderSummary'}} + total: {type: string, pattern: '^[0-9]+$', nullable: true, description: Provider total only when supplied. Java Long follows the platform JSON string convention.} + current: {type: integer} + size: {type: integer} + sourceCode: {type: integer} + observedAt: {type: string, format: date-time} + ProviderFailure: + type: object + required: [errorCode, retryable] + properties: + errorCode: {type: string} + retryable: {type: boolean} + upstreamStatus: {type: integer, nullable: true} + sourceCode: {type: integer, nullable: true} + Response: + type: object + required: [code, message, data, timestamp] + properties: + code: {type: string} + message: {type: string, description: Sanitized local message; never the raw provider response.} + data: + nullable: true + oneOf: + - {$ref: '#/components/schemas/PageResult'} + - {$ref: '#/components/schemas/ProviderFailure'} + requestId: {type: string, nullable: true} + traceId: {type: string, nullable: true} + timestamp: {type: string, format: date-time} diff --git a/integration-service/src/test/java/com/baiyue/integration/controller/LingxingInternalControllerTest.java b/integration-service/src/test/java/com/baiyue/integration/controller/LingxingInternalControllerTest.java new file mode 100644 index 000000000..870b2a46b --- /dev/null +++ b/integration-service/src/test/java/com/baiyue/integration/controller/LingxingInternalControllerTest.java @@ -0,0 +1,100 @@ +package com.baiyue.integration.controller; + +import com.baiyue.common.web.GlobalExceptionHandler; +import com.baiyue.common.web.LongIdJacksonConfiguration; +import com.baiyue.integration.dto.lingxing.LingxingWmsDtos.OrderSummary; +import com.baiyue.integration.dto.lingxing.LingxingWmsDtos.PageResult; +import com.baiyue.integration.provider.lingxing.LingxingWmsClient; +import com.baiyue.integration.provider.lingxing.LingxingWmsException; +import com.baiyue.integration.security.InternalServiceTokenGuard; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.http.MediaType; +import org.springframework.http.converter.json.Jackson2ObjectMapperBuilder; +import org.springframework.http.converter.json.MappingJackson2HttpMessageConverter; +import org.springframework.boot.autoconfigure.jackson.Jackson2ObjectMapperBuilderCustomizer; +import org.springframework.context.annotation.AnnotationConfigApplicationContext; +import org.springframework.test.util.ReflectionTestUtils; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.test.web.servlet.setup.MockMvcBuilders; + +import java.time.Instant; +import java.util.List; +import java.util.UUID; + +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verifyNoInteractions; +import static org.mockito.Mockito.when; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +class LingxingInternalControllerTest { + private static final String PATH = "/internal/integrations/lingxing/wms/outbound-orders/query"; + private static final String BODY = """ + {"startTime":"2026-09-12T08:00:00","endTime":"2026-10-12T08:00:00","current":1,"size":1} + """; + private final String token = UUID.randomUUID().toString(); + private LingxingWmsClient client; + private MockMvc mvc; + + @BeforeEach + void setUp() { + var guard = new InternalServiceTokenGuard(); + ReflectionTestUtils.setField(guard, "token", token); + ReflectionTestUtils.invokeMethod(guard, "initializeToken"); + client = mock(LingxingWmsClient.class); + var builder = Jackson2ObjectMapperBuilder.json(); + try (var context = new AnnotationConfigApplicationContext(LongIdJacksonConfiguration.class)) { + context.getBean(Jackson2ObjectMapperBuilderCustomizer.class).customize(builder); + } + mvc = MockMvcBuilders.standaloneSetup(new LingxingInternalController(guard, client)) + .setMessageConverters(new MappingJackson2HttpMessageConverter(builder.build())) + .setControllerAdvice(new GlobalExceptionHandler()).build(); + } + + @Test + void missingOrWrongInternalTokenCannotCallProvider() throws Exception { + mvc.perform(post(PATH).contentType(MediaType.APPLICATION_JSON).content(BODY)) + .andExpect(status().isOk()).andExpect(jsonPath("$.code").value("A0401")); + mvc.perform(post(PATH).header("X-Internal-Service-Token", "wrong") + .contentType(MediaType.APPLICATION_JSON).content(BODY)) + .andExpect(status().isOk()).andExpect(jsonPath("$.code").value("A0401")); + verifyNoInteractions(client); + } + + @Test + void authenticatedReadReturnsOnlyOutboundSummary() throws Exception { + when(client.queryPage(any())).thenReturn(new PageResult( + List.of(new OrderSummary("TEST-OUT-001", "TEST-SRC-001", "TEST-CUSTOMER", "TEST-WH", 100, + "2026-10-01 08:00:00", "2026-10-02 08:00:00")), + 1L, 1, 1, 200, Instant.parse("2026-10-12T00:00:00Z"))); + mvc.perform(post(PATH).header("X-Internal-Service-Token", token) + .contentType(MediaType.APPLICATION_JSON).content(BODY)) + .andExpect(status().isOk()).andExpect(jsonPath("$.code").value("200")) + .andExpect(jsonPath("$.data.total").isString()).andExpect(jsonPath("$.data.total").value("1")) + .andExpect(jsonPath("$.data.records[0].deliveryNo").value("TEST-OUT-001")) + .andExpect(jsonPath("$.data.records[0].recipient").doesNotExist()); + } + + @Test + void invalidPageCannotReachProvider() throws Exception { + mvc.perform(post(PATH).header("X-Internal-Service-Token", token) + .contentType(MediaType.APPLICATION_JSON).content(BODY.replace("\"current\":1", "\"current\":0"))) + .andExpect(status().isOk()).andExpect(jsonPath("$.code").value("A0422")); + verifyNoInteractions(client); + } + + @Test + void providerFailureRemainsAnExplicitBusinessFailure() throws Exception { + when(client.queryPage(any())).thenThrow(new LingxingWmsException( + "LINGXING_WMS_HTTP_RATE_LIMITED", "领星 WMS 请求受到限流", true, 429, null)); + mvc.perform(post(PATH).header("X-Internal-Service-Token", token) + .contentType(MediaType.APPLICATION_JSON).content(BODY)) + .andExpect(status().isOk()).andExpect(jsonPath("$.code").value("S0503")) + .andExpect(jsonPath("$.data.errorCode").value("LINGXING_WMS_HTTP_RATE_LIMITED")) + .andExpect(jsonPath("$.data.upstreamStatus").value(429)) + .andExpect(jsonPath("$.data.records").doesNotExist()); + } +} diff --git a/integration-service/src/test/java/com/baiyue/integration/provider/lingxing/LingxingWmsClientTest.java b/integration-service/src/test/java/com/baiyue/integration/provider/lingxing/LingxingWmsClientTest.java new file mode 100644 index 000000000..aa634c59e --- /dev/null +++ b/integration-service/src/test/java/com/baiyue/integration/provider/lingxing/LingxingWmsClientTest.java @@ -0,0 +1,261 @@ +package com.baiyue.integration.provider.lingxing; + +import com.baiyue.integration.config.LingxingWmsProperties; +import com.baiyue.integration.dto.lingxing.LingxingWmsDtos.QueryRequest; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.sun.net.httpserver.HttpServer; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; + +import javax.crypto.Mac; +import javax.crypto.spec.SecretKeySpec; +import java.io.IOException; +import java.io.PrintWriter; +import java.io.StringWriter; +import java.net.InetSocketAddress; +import java.net.URI; +import java.nio.charset.StandardCharsets; +import java.time.Clock; +import java.time.Duration; +import java.time.Instant; +import java.time.LocalDateTime; +import java.time.ZoneOffset; +import java.util.HexFormat; +import java.util.concurrent.atomic.AtomicInteger; +import java.util.concurrent.atomic.AtomicReference; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.junit.jupiter.api.Assertions.assertThrows; + +class LingxingWmsClientTest { + private static final String APP_KEY = "local-test-app-key"; + private static final String APP_SECRET = "local-test-app-secret"; + private static final String PRIVATE_DATA = "customer-private@example.test"; + private static final String PATH = "/openapi/v2/order/delivery/page"; + private static final Instant NOW = Instant.parse("2026-10-12T00:00:00.987Z"); + private static final String EMPTY = "{\"code\":200,\"success\":true,\"data\":{\"total\":0,\"current\":1,\"size\":1,\"records\":[]}}"; + private final ObjectMapper mapper = new ObjectMapper(); + private final AtomicInteger calls = new AtomicInteger(); + private final AtomicInteger responseStatus = new AtomicInteger(200); + private final AtomicReference responseBody = new AtomicReference<>(EMPTY); + private final AtomicReference receivedBody = new AtomicReference<>(); + private final AtomicReference receivedUri = new AtomicReference<>(); + private final AtomicReference receivedMethod = new AtomicReference<>(); + private HttpServer server; + + @BeforeEach + void startServer() throws IOException { + server = HttpServer.create(new InetSocketAddress("127.0.0.1", 0), 0); + server.createContext(PATH, exchange -> { + calls.incrementAndGet(); + receivedMethod.set(exchange.getRequestMethod()); + receivedUri.set(exchange.getRequestURI()); + receivedBody.set(new String(exchange.getRequestBody().readAllBytes(), StandardCharsets.UTF_8)); + byte[] bytes = responseBody.get().getBytes(StandardCharsets.UTF_8); + exchange.getResponseHeaders().add("Content-Type", "application/json; charset=UTF-8"); + exchange.sendResponseHeaders(responseStatus.get(), bytes.length); + exchange.getResponseBody().write(bytes); + exchange.close(); + }); + server.start(); + } + + @AfterEach + void stopServer() { server.stop(0); } + + @Test + void sendsOmpWmsSignatureAndOutboundTimeFilterAndOnlyReturnsWhitelist() throws Exception { + responseBody.set(""" + {"code":200,"success":true,"data":{"total":12,"current":1,"size":1,"records":[{ + "deliveryNo":"DO-LOCAL-001","sourceNo":"OB-LOCAL-001","customerCode":"C-LOCAL", + "whCode":"WH-LOCAL","status":100,"createTime":"2026-09-21 10:11:12", + "outboundTime":"2026-09-22 20:21:22","email":"customer-private@example.test", + "remark":"local-test-app-secret","receiver":"PRIVATE_RECEIVER" + }]}} + """); + var result = client(true, APP_KEY, APP_SECRET, Duration.ZERO).queryPage(query()); + + var body = mapper.readTree(receivedBody.get()); + String timestamp = Long.toString(NOW.getEpochSecond()); + String dataValue = "{current=1, size=1, timeType=outboundTime, startTime=2026-09-12 08:00:00, endTime=2026-10-12 08:00:00, status=100}"; + String input = APP_SECRET + PATH + "appKey" + APP_KEY + "data" + dataValue + "timestamp" + timestamp + APP_SECRET; + assertThat(body.path("sign").textValue()).isEqualTo(hmac(input)); + assertThat(body.path("timestamp").textValue()).isEqualTo(timestamp).matches("\\d{10}"); + assertThat(body.path("appKey").textValue()).isEqualTo(APP_KEY); + assertThat(body.path("data").toString()).isEqualTo("{\"current\":1,\"size\":1,\"timeType\":\"outboundTime\",\"startTime\":\"2026-09-12 08:00:00\",\"endTime\":\"2026-10-12 08:00:00\",\"status\":100}"); + assertThat(receivedUri.get().getPath()).isEqualTo(PATH); + assertThat(receivedUri.get().getRawQuery()).isNull(); + assertThat(receivedMethod.get()).isEqualTo("POST"); + assertThat(receivedBody.get()).doesNotContain(APP_SECRET, "authcode", "reqTime", "access_token", "pageSize"); + assertThat(result.total()).isEqualTo(12L); + assertThat(result.observedAt()).isEqualTo(NOW); + assertThat(result.sourceCode()).isEqualTo(200); + assertThat(result.records()).singleElement().satisfies(order -> { + assertThat(order.deliveryNo()).isEqualTo("DO-LOCAL-001"); + assertThat(order.sourceNo()).isEqualTo("OB-LOCAL-001"); + assertThat(order.customerCode()).isEqualTo("C-LOCAL"); + assertThat(order.whCode()).isEqualTo("WH-LOCAL"); + assertThat(order.status()).isEqualTo(100); + assertThat(order.createTime()).isEqualTo("2026-09-21 10:11:12"); + assertThat(order.outboundTime()).isEqualTo("2026-09-22 20:21:22"); + }); + String summary = mapper.writeValueAsString(result.records().get(0)); + assertThat(mapper.readTree(summary).size()).isEqualTo(7); + assertThat(summary).doesNotContain(PRIVATE_DATA, APP_SECRET, "PRIVATE_RECEIVER"); + } + + @Test + void emptySuccessfulPageHasNoInventedOrder() { + var result = client(true, APP_KEY, APP_SECRET, Duration.ZERO).queryPage(query()); + assertThat(result.records()).isEmpty(); + assertThat(result.total()).isZero(); + assertThat(calls).hasValue(1); + } + + @Test + void verifiedWmsEnvelopeWithoutSuccessRequiresValidPage() { + responseBody.set(""" + {"code":200,"data":{"total":1,"current":1,"size":1,"records":[{ + "deliveryNo":"DO-LOCAL-001","status":100,"outboundTime":"2026-09-22 20:21:22" + }]}} + """); + var result = client(true, APP_KEY, APP_SECRET, Duration.ZERO).queryPage(query()); + assertThat(result.sourceCode()).isEqualTo(200); + assertThat(result.total()).isEqualTo(1L); + assertThat(result.records()).singleElement().satisfies(order -> { + assertThat(order.deliveryNo()).isEqualTo("DO-LOCAL-001"); + assertThat(order.status()).isEqualTo(100); + assertThat(order.outboundTime()).isEqualTo("2026-09-22 20:21:22"); + }); + assertThat(calls).hasValue(1); + } + + @Test + void documentedEmptyNumericValuesRemainUnknown() { + responseBody.set("{\"code\":200,\"success\":true,\"data\":{\"total\":\"\",\"records\":[{\"deliveryNo\":\"DO-LOCAL\",\"status\":\"\",\"outboundTime\":\"\"}]}}"); + var result = client(true, APP_KEY, APP_SECRET, Duration.ZERO).queryPage(query()); + assertThat(result.total()).isNull(); + assertThat(result.records().get(0).status()).isNull(); + assertThat(result.records().get(0).outboundTime()).isEmpty(); + } + + @Test + void disabledAndMissingCredentialsNeverSendRequest() { + assertFailure(client(false, APP_KEY, APP_SECRET, Duration.ZERO), "DISABLED", false); + assertFailure(client(true, "", APP_SECRET, Duration.ZERO), "MISSING_CREDENTIALS", false); + assertFailure(client(true, APP_KEY, " ", Duration.ZERO), "MISSING_CREDENTIALS", false); + assertThat(calls).hasValue(0); + } + + @ParameterizedTest + @ValueSource(ints = {11002, 11003, 11004, 11005, 11006, 11007, 11008, 11010, 31001}) + void documentedStringAuthErrorsNeedNoDataAndDoNotEchoPayloadOrRetry(int sourceCode) { + responseBody.set("{\"code\":\"" + sourceCode + "\",\"msg\":\"" + APP_KEY + APP_SECRET + PRIVATE_DATA + "\"}"); + String expected = sourceCode == 11008 ? "AUTHORIZATION_REJECTED" + : sourceCode == 11006 ? "SIGNATURE_REJECTED" : "SOURCE_REJECTED"; + var error = assertFailure(client(true, APP_KEY, APP_SECRET, Duration.ZERO), expected, sourceCode == 31001); + assertThat(error.sourceCode()).isEqualTo(sourceCode); + assertThat(error.upstreamStatus()).isEqualTo(200); + assertThat(calls).hasValue(1); + } + + @Test + void falseBusinessSuccessIsNotAnEmptySuccess() { + responseBody.set("{\"code\":200,\"success\":false,\"msg\":\"" + PRIVATE_DATA + "\"}"); + assertFailure(client(true, APP_KEY, APP_SECRET, Duration.ZERO), "SOURCE_REJECTED", false); + } + + @ParameterizedTest + @ValueSource(ints = {401, 403, 429, 500, 302}) + void httpFailuresDoNotEchoBodyOrRetry(int status) { + responseStatus.set(status); + responseBody.set(APP_KEY + APP_SECRET + PRIVATE_DATA); + String code = status == 429 ? "HTTP_RATE_LIMITED" : status == 401 || status == 403 ? "AUTHORIZATION_REJECTED" : "HTTP_FAILED"; + var error = assertFailure(client(true, APP_KEY, APP_SECRET, Duration.ZERO), code, status == 429 || status >= 500); + assertThat(error.upstreamStatus()).isEqualTo(status); + assertThat(calls).hasValue(1); + } + + @Test + void malformedResponsesExposeFixedContractStageOnly() { + String[][] scenarios = { + {"" + PRIVATE_DATA + "", "JSON"}, + {"[]", "ROOT_TYPE_ARRAY"}, + {"{}", "CODE_TYPE_MISSING"}, + {"{\"code\":\"200\"}", "CODE_TYPE_STRING"}, + {"{\"code\":\"99999\"}", "CODE_TYPE_STRING"}, + {"{\"code\":200}", "DATA_TYPE_MISSING"}, + {"{\"code\":200,\"data\":{}}", "RECORDS_TYPE_MISSING"}, + {"{\"code\":200,\"success\":null}", "SUCCESS_TYPE_NULL"}, + {"{\"code\":200,\"success\":\"true\"}", "SUCCESS_TYPE_STRING"}, + {"{\"code\":200,\"success\":true,\"data\":[]}", "DATA_TYPE_ARRAY"}, + {"{\"code\":200,\"success\":true,\"data\":{\"records\":{}}}", "RECORDS_TYPE_OBJECT"}, + {"{\"code\":200,\"success\":true,\"data\":{\"records\":[{},{}]}}", "RECORDS_OVER_PAGE_SIZE"}, + {"{\"code\":200,\"success\":true,\"data\":{\"total\":\"0\",\"records\":[]}}", "TOTAL_TYPE_STRING"}, + {"{\"code\":200,\"success\":true,\"data\":{\"current\":2,\"records\":[]}}", "CURRENT_MISMATCH"}, + {"{\"code\":200,\"success\":true,\"data\":{\"records\":[{\"deliveryNo\":1}]}}", "DELIVERY_NO_TYPE_NUMBER"}, + {"{\"code\":200,\"success\":true,\"data\":{\"records\":[{\"deliveryNo\":\"A\",\"status\":\"100\"}]}}", "STATUS_TYPE_STRING"} + }; + var client = client(true, APP_KEY, APP_SECRET, Duration.ZERO); + for (String[] scenario : scenarios) { + responseBody.set(scenario[0]); + var error = assertFailure(client, "INVALID_RESPONSE", false); + assertThat(error.getMessage()).contains("(" + scenario[1] + ")"); + assertThat(error.upstreamStatus()).isEqualTo(200); + } + } + + @Test + void localRateLimitStopsSecondRequest() { + var client = client(true, APP_KEY, APP_SECRET, Duration.ofMinutes(1)); + client.queryPage(query()); + assertFailure(client, "LOCAL_RATE_LIMITED", true); + assertThat(calls).hasValue(1); + } + + @Test + void validatesLocalPaginationAndMonthBeforeSending() { + var client = client(true, APP_KEY, APP_SECRET, Duration.ZERO); + var query = query(); + for (var invalid : new QueryRequest[] { + new QueryRequest(query.startTime(), query.endTime(), 0, 1), + new QueryRequest(query.startTime(), query.endTime(), 1, 101), + new QueryRequest(query.endTime(), query.startTime(), 1, 1), + new QueryRequest(query.startTime().minusSeconds(1), query.endTime(), 1, 1), + new QueryRequest(null, query.endTime(), 1, 1) + }) assertThatThrownBy(() -> client.queryPage(invalid)).isInstanceOf(LingxingWmsException.class); + assertThat(calls).hasValue(0); + } + + private LingxingWmsException assertFailure(LingxingWmsClient client, String code, boolean retryable) { + var error = assertThrows(LingxingWmsException.class, () -> client.queryPage(query())); + assertThat(error.code()).isEqualTo("LINGXING_WMS_" + code); + assertThat(error.retryable()).isEqualTo(retryable); + assertThat(error.getCause()).isNull(); + StringWriter trace = new StringWriter(); + error.printStackTrace(new PrintWriter(trace)); + assertThat(trace.toString()).doesNotContain(APP_KEY, APP_SECRET, PRIVATE_DATA, "sign=", "http://"); + return error; + } + + private LingxingWmsClient client(boolean enabled, String key, String secret, Duration interval) { + var properties = new LingxingWmsProperties(enabled, URI.create("http://127.0.0.1:" + server.getAddress().getPort()), + key, secret, Duration.ofSeconds(2), Duration.ofSeconds(2), interval); + return new LingxingWmsClient(properties, mapper, Clock.fixed(NOW, ZoneOffset.UTC)); + } + + private static QueryRequest query() { + LocalDateTime end = LocalDateTime.of(2026, 10, 12, 8, 0); + return new QueryRequest(end.minusMonths(1), end, 1, 1); + } + + private static String hmac(String input) throws Exception { + Mac mac = Mac.getInstance("HmacSHA256"); + mac.init(new SecretKeySpec(APP_SECRET.getBytes(StandardCharsets.UTF_8), "HmacSHA256")); + return HexFormat.of().formatHex(mac.doFinal(input.getBytes(StandardCharsets.UTF_8))); + } +} diff --git a/integration-service/src/test/java/com/baiyue/integration/provider/lingxing/LingxingWmsSignerTest.java b/integration-service/src/test/java/com/baiyue/integration/provider/lingxing/LingxingWmsSignerTest.java new file mode 100644 index 000000000..c34b9b55c --- /dev/null +++ b/integration-service/src/test/java/com/baiyue/integration/provider/lingxing/LingxingWmsSignerTest.java @@ -0,0 +1,91 @@ +package com.baiyue.integration.provider.lingxing; + +import com.baiyue.integration.config.LingxingWmsProperties; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.SerializationFeature; +import org.junit.jupiter.api.Test; +import org.springframework.boot.context.properties.bind.Bindable; +import org.springframework.boot.context.properties.bind.Binder; +import org.springframework.boot.context.properties.source.MapConfigurationPropertySource; + +import javax.crypto.Mac; +import javax.crypto.spec.SecretKeySpec; +import java.net.URI; +import java.nio.charset.StandardCharsets; +import java.time.Duration; +import java.time.Instant; +import java.util.ArrayList; +import java.util.HexFormat; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +class LingxingWmsSignerTest { + @Test + void matchesOfficialOmpParameterRenderingIncludingNamesPathAndBothSecrets() throws Exception { + var mapper = new ObjectMapper().enable(SerializationFeature.INDENT_OUTPUT); + var signer = new LingxingWmsSigner(mapper); + var data = new LinkedHashMap(); + data.put("status", 0); + data.put("current", 1); + data.put("size", 10); + var signed = signer.sign("YOUR_APP_KEY", "YOUR_APP_SECRET", "/openapi/v2/customer/list", data, + Instant.ofEpochSecond(1790056664)); + var body = mapper.readTree(signed.body()); + String officialInput = "YOUR_APP_SECRET/openapi/v2/customer/listappKeyYOUR_APP_KEYdata{status=0, current=1, size=10}timestamp1790056664YOUR_APP_SECRET"; + assertThat(body.path("sign").textValue()).isEqualTo(hmac("YOUR_APP_SECRET", officialInput)); + var fieldNames = new ArrayList(); + body.path("data").fieldNames().forEachRemaining(fieldNames::add); + assertThat(fieldNames).containsExactly("status", "current", "size"); + assertThat(signed.body()).doesNotContain("\n", "YOUR_APP_SECRET", "authcode", "reqTime"); + assertThat(signed.toString()).doesNotContain("YOUR_APP_KEY", "YOUR_APP_SECRET", body.path("sign").textValue()); + } + + @Test + void nestedDataUsesTransmittedOrderAndMapListValueSyntax() throws Exception { + var mapper = new ObjectMapper(); + var signer = new LingxingWmsSigner(mapper); + var child = new LinkedHashMap(); + child.put("z", "中文"); + child.put("A", 2); + var data = new LinkedHashMap(); + data.put("items", List.of(child)); + var signed = signer.sign("key", "secret", "/openapi/v2/customer/list", data, Instant.ofEpochSecond(1770000000)); + String input = "secret/openapi/v2/customer/listappKeykeydata{items=[{z=中文, A=2}]}timestamp1770000000secret"; + assertThat(mapper.readTree(signed.body()).path("sign").textValue()).isEqualTo(hmac("secret", input)); + } + + @Test + void propertiesBindWmsOnlyAndRedactBothCredentials() { + var source = new MapConfigurationPropertySource(Map.of( + "baiyue.integration.lingxing-wms.enabled", "true", + "baiyue.integration.lingxing-wms.app-key", "example-key", + "baiyue.integration.lingxing-wms.app-secret", "example-secret")); + var properties = new Binder(source).bind("baiyue.integration.lingxing-wms", Bindable.of(LingxingWmsProperties.class)).get(); + assertThat(properties.enabled()).isTrue(); + assertThat(properties.hasCredentials()).isTrue(); + assertThat(properties.baseUri()).isEqualTo(URI.create("https://api.xlwms.com")); + assertThat(properties.connectTimeout()).isEqualTo(Duration.ofSeconds(5)); + assertThat(properties.requestTimeout()).isEqualTo(Duration.ofSeconds(15)); + assertThat(properties.minRequestInterval()).isEqualTo(Duration.ofSeconds(1)); + assertThat(properties.toString()).doesNotContain("example-key", "example-secret"); + } + + @Test + void disabledIsDefaultAndPlainHttpOutsideLoopbackIsRejected() { + var properties = new LingxingWmsProperties(false, null, null, null, null, null, null); + assertThat(properties.enabled()).isFalse(); + assertThat(properties.hasCredentials()).isFalse(); + assertThatThrownBy(() -> new LingxingWmsProperties(true, URI.create("http://example.test"), "key", "secret", null, null, null)) + .isInstanceOf(IllegalArgumentException.class); + } + + private static String hmac(String key, String input) throws Exception { + Mac mac = Mac.getInstance("HmacSHA256"); + mac.init(new SecretKeySpec(key.getBytes(StandardCharsets.UTF_8), "HmacSHA256")); + return HexFormat.of().formatHex(mac.doFinal(input.getBytes(StandardCharsets.UTF_8))); + } +} diff --git a/scripts/prod-release-acceptance.mjs b/scripts/prod-release-acceptance.mjs index 77b12c81c..05ca45f06 100644 --- a/scripts/prod-release-acceptance.mjs +++ b/scripts/prod-release-acceptance.mjs @@ -3,7 +3,7 @@ import { dirname, resolve } from 'node:path' import { pathToFileURL } from 'node:url' import { normalizeOrderInput, measurementPlan } from './prod-release-measurement-plan.mjs' -// Production is deliberately not routed through the multi-customer test runner. +// Single-order acceptance never invokes the multi-customer fixture runner. export const STAGES = Object.freeze([ 'CUSTOMER_ORDER', 'ORDER_REVIEW', 'YIWU_MEASUREMENT', 'CUSTOMER_FEE_CONFIRMATION', 'CONTAINER_ALLOCATION', 'DOMESTIC_PDA_LOADING', 'CUSTOMS_AND_BOOKING', @@ -20,9 +20,8 @@ export const DOMESTIC_WAREHOUSE_CODES = Object.freeze(['CN-YIWU-01', 'CN-DONGGUA export function plan() { return { scope: { orders: 1, origins: [...DOMESTIC_WAREHOUSE_CODES], origin: 'CN-YIWU-01', receiving: 'US-LAX-01', sameOrder: true }, - status: 'FULL_FLOW_NOT_IMPLEMENTED', fullFlowVerified: false, - stages: STAGES.map(name => ({ name, status: ['CUSTOMER_ORDER', 'ORDER_REVIEW'].includes(name) - ? 'IMPLEMENTED_NOT_HTTP_VERIFIED' : 'NOT_IMPLEMENTED' })), + status: 'FULL_FLOW_IMPLEMENTED_NOT_HTTP_VERIFIED', fullFlowVerified: false, + stages: STAGES.map(name => ({ name, status: 'IMPLEMENTED_NOT_HTTP_VERIFIED' })), excluded: ['其他客户', '混柜及拆单', '退货及异常分支', '真实付款', '第三方下单或发送邮件', '浏览器和设备交互'], policy: { createMasterData: false, createAccounts: false, deleteData: false }, } @@ -43,20 +42,22 @@ function baseUrl(value, path) { } export function config(env) { - if (env.CHONGCHONG_ENV !== 'prod') throw new Error('必须显式配置 CHONGCHONG_ENV=prod') + if (!['test', 'gray'].includes(env.CHONGCHONG_ENV)) throw new Error('必须显式配置 CHONGCHONG_ENV=test 或 gray') + for (const key of ['CHONGCHONG_ADMIN_OVERSEAS_API_BASE_URL', 'CHONGCHONG_MERCHANT_OVERSEAS_API_BASE_URL']) { + if (env[key] != null) throw new Error(`移除旧海外网关配置:${key};所有业务使用统一 Gateway`) + } const get = key => requireValue(env, `CHONGCHONG_${key}`) - const cn = baseUrl(get('ADMIN_API_BASE_URL'), '/api') - const overseas = baseUrl(get('ADMIN_OVERSEAS_API_BASE_URL'), '/overseas-api') + const apiBase = baseUrl(get('ADMIN_API_BASE_URL'), '/api') const merchant = baseUrl(get('MERCHANT_API_BASE_URL'), '/api') - if (new URL(cn).origin !== new URL(overseas).origin || cn !== merchant) { - throw new Error('当前专属入口要求国内、海外及客户 API 使用同一 Admin 前端代理') + if (apiBase !== merchant) { + throw new Error('单票入口要求平台及客户 API 使用同一 HTTPS /api 统一入口') } const customer = { id: get('ACCEPTANCE_CUSTOMER_ID'), code: get('ACCEPTANCE_CUSTOMER_CODE'), name: get('ACCEPTANCE_CUSTOMER_NAME') } if (!/^[1-9]\d*$/.test(customer.id)) throw new Error('客户 ID 必须为非零十进制字符串') const credentials = Object.fromEntries(['ADMIN', 'MERCHANT', 'CN_PDA', 'OVERSEAS_LAX_PDA'].map(role => [role, { loginId: get(`${role}_USERNAME`), password: get(`${role}_PASSWORD`) }])) if (get('OVERSEAS_LAX_PDA_WAREHOUSE_CODE') !== 'US-LAX-01') throw new Error('海外 PDA 必须绑定洛杉矶仓') - return { cn, overseas, customer, credentials } + return { environment: env.CHONGCHONG_ENV, apiBase, customer, credentials } } function id(value) { @@ -88,11 +89,11 @@ export function client(settings, fetcher = fetch, policy = allowedRequest) { return { get requests() { return requests }, async request(region, method, path, token, body, idempotencyKey) { - if (!policy(method, path, region, body, idempotencyKey)) throw new Error('请求不在生产前置检查白名单内') - if (++requests > 100) throw new Error('超过单次前置检查请求上限') + if (!policy(method, path, region, body, idempotencyKey)) throw new Error('请求不在单票请求白名单内') + if (++requests > 100) throw new Error('超过单阶段请求上限') let response try { - response = await fetcher((region === 'cn' ? settings.cn : settings.overseas) + path, { + response = await fetcher(settings.apiBase + path, { method, redirect: 'error', signal: AbortSignal.timeout(20_000), headers: { 'Content-Type': 'application/json', ...(token ? { Authorization: `Bearer ${token}` } : {}), ...(idempotencyKey ? { 'Idempotency-Key': idempotencyKey } : {}) }, @@ -129,7 +130,7 @@ export async function preflight(settings, http, receiveContext = () => {}) { const logins = {} for (const [role, credentials] of Object.entries(settings.credentials)) { logins[role] = await http.request('cn', 'POST', '/auth/login', undefined, credentials) - if (!logins[role]?.accessToken) throw new Error(`${role} 登录缺少国内 Token`) + if (!logins[role]?.accessToken) throw new Error(`${role} 登录缺少统一 accessToken`) } const admin = logins.ADMIN.accessToken assertCustomer(await http.request('cn', 'GET', `/platform/v1/iam/tenants/${settings.customer.id}`, admin), settings.customer) @@ -152,8 +153,8 @@ export async function preflight(settings, http, receiveContext = () => {}) { !cnWarehouses.some(w => w.warehouseCode === code && id(w.id) === warehouses[code]))) { throw new Error('国内 PDA 不可访问义乌或东莞仓') } - const overseasToken = logins.OVERSEAS_LAX_PDA.overseasAccessToken - if (!overseasToken) throw new Error('洛杉矶 PDA 登录缺少海外 Token') + const overseasToken = logins.OVERSEAS_LAX_PDA.accessToken + if (!overseasToken) throw new Error('洛杉矶 PDA 登录缺少统一 accessToken') const binding = await http.request('overseas', 'GET', '/pda/v1/last-leg/receiving-containers/warehouse-profile', overseasToken) if (binding?.warehouseCode !== 'US-LAX-01' || id(binding.warehouseId) !== warehouses['US-LAX-01']) throw new Error('洛杉矶 PDA 仓库绑定不符') const counts = {} @@ -169,14 +170,14 @@ export async function preflight(settings, http, receiveContext = () => {}) { return { ...plan(), status: 'BASIC_PREFLIGHT_PASSED', requests: http.requests, checks: ['客户身份及箱规状态', '四个账号登录', '义乌、东莞及洛杉矶仓', 'PDA 仓库范围', '国内既有托盘及可用库位'], resourceCounts: counts, - pending: ['单票执行入口的精确线路及小票报价检查', '供应商与海外资源门禁', '测量至签收的写入执行器和检查点', '所有业务阶段真实 HTTP 验证'], + pending: ['单票执行入口的精确线路及小票报价检查', '供应商与海外资源门禁', '全流程输入指定的资源资格', '本轮全部业务阶段真实 HTTP 验证'], } } export async function executeOrder(settings, input, journalPath, resume, fetcher = fetch) { const { validateOrderInput, withOrderJournal, runOrderStage } = await import('./prod-release-order-stage.mjs') // These facts come from the selected external environment, not from user-editable order JSON. - const bound = { ...normalizeOrderInput(input), customer: settings.customer, environment: settings.cn, + const bound = { ...normalizeOrderInput(input), customer: settings.customer, environment: { mode: settings.environment, apiBase: settings.apiBase }, operator: { merchant: settings.credentials.MERCHANT.loginId, admin: settings.credentials.ADMIN.loginId } } validateOrderInput(bound) return withOrderJournal(journalPath, bound, async (state, save) => { @@ -229,9 +230,19 @@ export async function executeOrder(settings, input, journalPath, resume, fetcher export async function main(argv = process.argv.slice(2), env = process.env) { const [mode = 'plan', ...rest] = argv + if (mode === 'full-flow') { + if ((rest.length !== 3 && rest.length !== 4) || rest[2] !== '--confirm-single-full-flow' + || rest.length === 4 && rest[3] !== '--resume') throw Error('full-flow 需要输入文件、检查点、--confirm-single-full-flow,可选 --resume') + const inputPath = resolve(rest[0]), journalPath = resolve(rest[1]) + if (inputPath === journalPath) throw Error('输入和检查点不能使用同一文件') + const { executeFullFlow } = await import('./prod-release-full-flow.mjs') + const result = await executeFullFlow(config(env), JSON.parse(await readFile(inputPath, 'utf8')), journalPath, rest[3] === '--resume') + console.log(JSON.stringify(result, null, 2)) + return + } if (mode === 'delivery-stage') { - if (rest.length !== 3 || rest[2] !== '--confirm-production-delivery') { - throw new Error('delivery-stage 需要输入文件、独立签收检查点、--confirm-production-delivery') + if (rest.length !== 3 || rest[2] !== '--confirm-single-delivery') { + throw new Error('delivery-stage 需要输入文件、独立签收检查点、--confirm-single-delivery') } const inputPath = resolve(rest[0]), journalPath = resolve(rest[1]) if (inputPath === journalPath) throw new Error('输入和检查点不能使用同一文件') @@ -252,8 +263,8 @@ export async function main(argv = process.argv.slice(2), env = process.env) { return } if (mode === 'order-stage') { - if ((rest.length !== 3 && rest.length !== 4) || rest[2] !== '--confirm-production-order' - || rest.length === 4 && rest[3] !== '--resume') throw new Error('order-stage 需要输入文件、检查点、--confirm-production-order,可选 --resume') + if ((rest.length !== 3 && rest.length !== 4) || rest[2] !== '--confirm-single-order' + || rest.length === 4 && rest[3] !== '--resume') throw new Error('order-stage 需要输入文件、检查点、--confirm-single-order,可选 --resume') const inputPath = resolve(rest[0]), journalPath = resolve(rest[1]) if (inputPath === journalPath) throw new Error('输入和检查点不能使用同一文件') const input = JSON.parse(await readFile(inputPath, 'utf8')) @@ -261,7 +272,7 @@ export async function main(argv = process.argv.slice(2), env = process.env) { console.log(JSON.stringify(result)) return } - if (!['plan', 'preflight'].includes(mode) || rest.length) throw new Error('用法:prod-release-acceptance.mjs [plan|preflight];全链路执行尚未开放') + if (!['plan', 'preflight'].includes(mode) || rest.length) throw new Error('用法:prod-release-acceptance.mjs [plan|preflight];完整链使用 full-flow <输入> <检查点> --confirm-single-full-flow') if (mode === 'plan') { console.log(JSON.stringify(plan(), null, 2)); return } const settings = config(env) const reportPath = resolve('reports', `prod-release-preflight-${Date.now()}.json`) @@ -276,5 +287,5 @@ export async function main(argv = process.argv.slice(2), env = process.env) { } if (process.argv[1] && import.meta.url === pathToFileURL(resolve(process.argv[1])).href) { - main().catch(error => { console.error(`生产验收入口失败:${error instanceof SyntaxError ? '输入或检查点 JSON 无效' : error.message}`); process.exitCode = 1 }) + main().catch(error => { console.error(`单票验收入口失败:${error instanceof SyntaxError ? '输入或检查点 JSON 无效' : error.message}`); process.exitCode = 1 }) } diff --git a/scripts/prod-release-acceptance.test.mjs b/scripts/prod-release-acceptance.test.mjs index 2eadb69bb..5556e30bc 100644 --- a/scripts/prod-release-acceptance.test.mjs +++ b/scripts/prod-release-acceptance.test.mjs @@ -3,9 +3,8 @@ import assert from 'node:assert/strict' import { plan, config, assertCustomer, allowedRequest, client, listAll, preflight, main, DOMESTIC_WAREHOUSE_CODES } from './prod-release-acceptance.mjs' function environment() { - const env = { CHONGCHONG_ENV: 'prod', + const env = { CHONGCHONG_ENV: 'gray', CHONGCHONG_ADMIN_API_BASE_URL: 'https://acceptance.invalid/api', - CHONGCHONG_ADMIN_OVERSEAS_API_BASE_URL: 'https://acceptance.invalid/overseas-api', CHONGCHONG_MERCHANT_API_BASE_URL: 'https://acceptance.invalid/api', CHONGCHONG_ACCEPTANCE_CUSTOMER_ID: '9007199254740993', CHONGCHONG_ACCEPTANCE_CUSTOMER_CODE: 'fixture', CHONGCHONG_ACCEPTANCE_CUSTOMER_NAME: 'fixture', @@ -25,8 +24,8 @@ test('single order scope does not claim implemented or full coverage', () => { assert.deepEqual(result.scope.origins, [...DOMESTIC_WAREHOUSE_CODES]) assert.equal(result.scope.receiving, 'US-LAX-01') assert.equal(result.fullFlowVerified, false) - assert.equal(result.stages.filter(s => s.status === 'IMPLEMENTED_NOT_HTTP_VERIFIED').length, 2) - assert.equal(result.stages.filter(s => s.status === 'NOT_IMPLEMENTED').length, 12) + assert.equal(result.stages.filter(s => s.status === 'IMPLEMENTED_NOT_HTTP_VERIFIED').length, 14) + assert.equal(result.stages.filter(s => s.status === 'NOT_IMPLEMENTED').length, 0) }) test('configuration has no development/test fallback', () => { assert.throws(() => config({ ADMIN_TEST_GATEWAY: 'https://acceptance.invalid' })) @@ -85,7 +84,7 @@ function fakeHttp(bindingId = '3') { return { requests: 0, async request(region, method, path) { this.requests++ assert.equal(allowedRequest(method, path, region), true) - if (method === 'POST') return { accessToken: 'fixture-token', overseasAccessToken: 'fixture-overseas-token' } + if (method === 'POST') return { accessToken: 'fixture-token' } if (path.includes('/iam/')) return profile if (path.includes('/warehouse-profile')) return { warehouseId: bindingId, warehouseCode: 'US-LAX-01' } if (path.endsWith('/pda-loading/warehouses')) return [{ id: '2', warehouseCode: 'CN-YIWU-01' }, { id: '4', warehouseCode: 'CN-DONGGUAN-01' }] @@ -110,6 +109,39 @@ test('PDA warehouse mismatch blocks preflight', async () => { }) test('execute, resume and unrecognized arguments fail closed', async () => { for (const args of [['execute'], ['resume'], ['preflight', '--execute'], ['plan', '--count', '2']]) { - await assert.rejects(main(args, {}), /尚未开放/) + await assert.rejects(main(args, {}), /完整链使用/) } }) + + +test('explicit gray/test mode shares one API base and rejects retired environment keys', () => { + for (const mode of ['gray', 'test']) { + const settings = config({ ...environment(), CHONGCHONG_ENV: mode }) + assert.equal(settings.environment, mode) + assert.equal(settings.apiBase, 'https://acceptance.invalid/api') + assert.equal(settings.overseas, undefined) + } + for (const mode of ['prod', 'dev', '', undefined]) { + assert.throws(() => config({ ...environment(), CHONGCHONG_ENV: mode }), /显式配置/) + } + for (const key of ['CHONGCHONG_ADMIN_OVERSEAS_API_BASE_URL', 'CHONGCHONG_MERCHANT_OVERSEAS_API_BASE_URL']) { + assert.throws(() => config({ ...environment(), [key]: 'https://acceptance.invalid/api' }), /旧海外网关/) + } +}) + +test('overseas PDA calls the unified gateway using its ordinary access token', async () => { + const calls = [] + const http = client(config(environment()), async (url, options) => { + calls.push({ url, token: options.headers.Authorization }) + return { ok: true, status: 200, json: async () => ({ code: '200', data: {} }) } + }) + await http.request('overseas', 'GET', '/pda/v1/last-leg/receiving-containers/warehouse-profile', 'ordinary-access') + assert.deepEqual(calls, [{ url: 'https://acceptance.invalid/api/pda/v1/last-leg/receiving-containers/warehouse-profile', token: 'Bearer ordinary-access' }]) +}) + +test('unapproved customer blocks preflight before any business stage', async () => { + const http = fakeHttp(), original = http.request.bind(http) + http.request = async (...args) => args[2].includes('/iam/') + ? { ...profile, status: 'PROFILE_REQUIRED' } : original(...args) + await assert.rejects(preflight(config(environment()), http), /审核状态/) +}) diff --git a/scripts/prod-release-delivery-stage.mjs b/scripts/prod-release-delivery-stage.mjs index 2789cf621..86f77e548 100644 --- a/scripts/prod-release-delivery-stage.mjs +++ b/scripts/prod-release-delivery-stage.mjs @@ -5,7 +5,8 @@ import { fingerprint } from './prod-release-order-stage.mjs' const check = (ok, message) => { if (!ok) throw new Error(message) } const longId = value => typeof value === 'string' && /^[1-9]\d*$/.test(value) const segment = value => typeof value === 'string' && /^[A-Za-z0-9_-]{1,80}$/.test(value) -const number = value => Number.isSafeInteger(Number(value)) ? Number(value) : NaN +const number = value => (typeof value === 'number' || typeof value === 'string' && /^\d+$/.test(value)) + && Number.isSafeInteger(Number(value)) && Number(value) >= 0 ? Number(value) : NaN export function validateDeliveryInput(input) { check(/^[A-Za-z0-9][A-Za-z0-9_-]{7,47}$/.test(input.batch || ''), '无效验收批次') @@ -79,6 +80,7 @@ export async function runDeliveryStage({ input, customerId, state, save, request let delivery = await readDelivery() if (delivery.order.status === 'WAIT_DELIVERY') { check(!state.startIntent, '配送中操作结果不确定,禁止重发;请核对服务端状态') + check(Number.isSafeInteger(number(delivery.order.version)), '派送状态版本缺失或无效') await save({ startIntent: true }) await request('overseas', 'PUT', scoped(`${deliveryPath}/status`), { status: 'DELIVERING', expectedVersion: number(delivery.order.version), remark: `RELEASE-ACCEPTANCE:${input.batch}`, @@ -142,12 +144,12 @@ export async function runDeliveryStage({ input, customerId, state, save, request export async function executeDelivery(settings, input, journalPath, fetcher = fetch) { validateDeliveryInput(input) - const binding = fingerprint({ input, cn: settings.cn, overseas: settings.overseas, + const binding = fingerprint({ input, environment: settings.environment, apiBase: settings.apiBase, customer: settings.customer, admin: settings.credentials.ADMIN.loginId }) return withDeliveryJournal(journalPath, binding, async (state, save) => { const { client } = await import('./prod-release-acceptance.mjs') const login = await client(settings, fetcher).request('cn', 'POST', '/auth/login', null, settings.credentials.ADMIN) - check(login?.accessToken && login?.overseasAccessToken, '缺少国内或海外令牌') + check(login?.accessToken, '缺少统一 accessToken') const root = '/platform/v1/last-leg' const delivery = `${root}/delivery-orders/${input.deliveryNo}` const scoped = path => `${path}?customerId=${settings.customer.id}` @@ -161,13 +163,13 @@ export async function executeDelivery(settings, input, journalPath, fetcher = fe : region === 'overseas' && (method === 'GET' && reads.has(path) || method === 'PUT' && writes.has(path))) return runDeliveryStage({ input, customerId: settings.customer.id, state, save, request: (region, method, path, body) => http.request(region, method, path, - region === 'cn' ? login.accessToken : login.overseasAccessToken, body), + login.accessToken, body), upload: async (path, name, text) => { check(path === scoped(`${delivery}/documents/RECEIPT/files`), '上传路径不符') const form = new FormData(); form.append('file', new Blob([text], { type: 'text/plain' }), name) let response - try { response = await fetcher(settings.overseas + path, { method: 'POST', redirect: 'error', - signal: AbortSignal.timeout(20_000), headers: { Authorization: `Bearer ${login.overseasAccessToken}` }, body: form }) } + try { response = await fetcher(settings.apiBase + path, { method: 'POST', redirect: 'error', + signal: AbortSignal.timeout(20_000), headers: { Authorization: `Bearer ${login.accessToken}` }, body: form }) } catch { throw new Error('签收上传连接失败或超时;未自动重试') } const data = await response.json() const safe = value => /^[\w-]{1,100}$/.test(String(value)) ? value : 'unavailable' diff --git a/scripts/prod-release-delivery-stage.test.mjs b/scripts/prod-release-delivery-stage.test.mjs index cbd933ea8..0880f44f8 100644 --- a/scripts/prod-release-delivery-stage.test.mjs +++ b/scripts/prod-release-delivery-stage.test.mjs @@ -3,7 +3,7 @@ import assert from 'node:assert/strict' import { mkdtemp, rm } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' -import { runDeliveryStage, validateDeliveryInput, verifyOutbound, withDeliveryJournal } from './prod-release-delivery-stage.mjs' +import { executeDelivery, runDeliveryStage, validateDeliveryInput, verifyOutbound, withDeliveryJournal } from './prod-release-delivery-stage.mjs' const input = { batch: 'acceptance-1234', orderId: '101', orderNo: 'FL-101', tailOrderId: '201', warehouseId: '301', inboundCode: 'IN-101', dispatchNo: 'DS-101', outboundNo: 'OB-101', deliveryNo: 'DL-101', packageCount: 1 } @@ -112,3 +112,49 @@ test('journal rejects changed binding and concurrent processes', async () => { await assert.rejects(withDeliveryJournal(path, 'changed', async () => {}), /不符/) } finally { await rm(dir, { recursive: true, force: true }) } }) + + +test('missing or malformed delivery version blocks status write and upload', async () => { + for (const version of [undefined, null, '', ' ', false, -1, '9007199254740993']) { + const h = harness(), original = h.options.request + h.options.request = async (...args) => { + const result = await original(...args) + if (result?.order?.deliveryNo) result.order.version = version + return result + } + await assert.rejects(runDeliveryStage(h.options), /版本缺失或无效/) + assert.equal(h.writes.length, 0) + assert.equal(h.uploads(), 0) + } +}) + +test('delivery executor uses one gateway/accessToken for status, multipart receipt and domestic reconciliation', async t => { + const dir = await mkdtemp(join(tmpdir(), 'single-delivery-test-')) + t.after(() => rm(dir, { recursive: true, force: true })) + const h = harness(), calls = [] + const settings = { environment: 'gray', apiBase: 'https://fixture.invalid/api', customer: { id: '501' }, + credentials: { ADMIN: { loginId: 'admin-fixture', password: 'secret-fixture' } } } + const fetcher = async (url, options) => { + assert.ok(url.startsWith(settings.apiBase + '/')) + const path = url.slice(settings.apiBase.length) + calls.push(path) + let data + if (path === '/auth/login') data = { accessToken: 'single-access' } + else { + assert.equal(options.headers.Authorization, 'Bearer single-access') + if (options.body instanceof FormData) { + assert.equal(path, '/platform/v1/last-leg/delivery-orders/DL-101/documents/RECEIPT/files?customerId=501') + assert.match(await options.body.get('file').text(), /NOT A REAL PROOF/) + data = { fileId: '701' } + } else { + data = await h.options.request(path.startsWith('/platform/v1/last-leg/') ? 'overseas' : 'cn', + options.method, path, options.body ? JSON.parse(options.body) : undefined) + } + } + return { ok: true, status: 200, json: async () => ({ code: '200', data }) } + } + const result = await executeDelivery(settings, input, join(dir, 'journal.json'), fetcher) + assert.equal(result.status, 'DELIVERY_STAGE_PASSED') + assert.equal(result.fullFlowVerified, false) + assert.equal(calls.filter(path => path === '/auth/login').length, 1) +}) diff --git a/scripts/prod-release-domestic-stage.mjs b/scripts/prod-release-domestic-stage.mjs new file mode 100644 index 000000000..40f36fc91 --- /dev/null +++ b/scripts/prod-release-domestic-stage.mjs @@ -0,0 +1,231 @@ +import { fingerprint } from './prod-release-order-stage.mjs' +import { oceanBookingDraft } from './admin-test-env-modules/ocean-booking-fixture.mjs' +import { iso6346ContainerNumber } from './admin-test-env-modules/iso6346-container-number.mjs' +import { channelGroup } from './admin-test-env-modules/smart-loading-channel-group.mjs' + +const assert = (value, message) => { if (!value) throw Error(message) } +const id = value => value == null ? '' : String(value) +const PNG = Buffer.from('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVQIHWP4z8DwHwAFAAH/iZk9HQAAAABJRU5ErkJggg==', 'base64') +const NODES = ['DEPARTED_WAREHOUSE', 'AT_ORIGIN_PORT', 'LOADED_ON_VESSEL', 'DEPARTED_ORIGIN_PORT', 'IN_TRANSIT', 'ARRIVED_DESTINATION_PORT', 'DISCHARGED_FROM_VESSEL', 'PICKUP_READY'] +const localTime = () => new Date(Date.now() + 8 * 3600000).toISOString().slice(0, 19) +function fileForm(name, content = PNG, type = 'image/png', fields = {}) { + const form = new FormData() + for (const [key, value] of Object.entries(fields)) form.append(key, String(value)) + form.append('file', new Blob([content], { type }), name) + return form +} +export function domesticSuccess(response) { + if (response?.http !== 200 || String(response?.body?.code) !== '200') { + const error = Error(`国内履约请求失败 HTTP=${response?.http} code=${response?.body?.code}`) + error.evidence = { http: response?.http, code: response?.body?.code, message: response?.body?.message, + requestId: response?.requestId, traceId: response?.traceId } + throw error + } + return response.body.data +} + +// Persist intent before invoking transport. Never retry a partially observed write. +export function domesticCommands(checkpoint, binding) { + const { state, save } = checkpoint + assert(!state.binding || state.binding === binding, '国内检查点绑定发生变化') + return async (name, command, execute) => { + const previous = state.commands?.[name] + if (previous?.status === 'DONE') return previous.result + assert(!previous, `国内写入 ${name} 结果未知,必须人工核对原请求,禁止自动重放`) + const intent = { status: 'INTENT', command, fingerprint: fingerprint(command) } + const persist = async value => { + const patch = { binding, commands: { ...state.commands, [name]: value } } + await save(patch); Object.assign(state, patch) + } + await persist(intent) + const result = await execute() + await persist({ ...intent, status: 'DONE', result }) + return result + } +} + +export async function runDomesticStage({ input, order, context, checkpoint }) { + const config = input.domestic + const cargo = input.payload.cargoes[0] + assert(order?.status === 'ORDER_APPROVED' && order.orderId && order.inboundCode, '必须使用本票已审核订单') + assert(cargo.packageCount >= 1 && cargo.packageCount <= 2 && input.payload.cargoes.length === 1, '国内阶段仅支持本票一个 SKU、1~2 箱') + assert(input.payload.taxMode === 'BUY_DECLARATION', '当前专票须为买单报关,不自动改写报关方式') + assert(config?.palletId && config.assigneeId && config.bookingAgent?.id && config.carrier?.id + && config.carrier.carrierCode && config.estimatedSailingAt && config.estimatedArrivalAt + && config.terminalFleet?.id && config.terminalFleet.name, '缺少已有国内资源、目的港车队或明确船期') + assert(context.token && context.pdaToken && context.merchantToken, '缺少已登录平台、国内 PDA 或客户会话') + const binding = fingerprint({ input, orderId: order.orderId }) + const command = domesticCommands(checkpoint, binding) + const tenantId = input.customer.id, warehouseId = input.origin.id + const q = `tenantId=${encodeURIComponent(tenantId)}` + const key = name => `release-${fingerprint({ binding, name }).slice(0, 48)}` + const get = async (path, token = context.token) => domesticSuccess(await context.request('GET', path, undefined, token)) + const write = (name, method, path, body, token = context.token) => command(name, { method, path, body, idempotencyKey: key(name) }, + async () => domesticSuccess(await context.request(method, path, body, token, { 'Idempotency-Key': key(name) }))) + const upload = (name, path, form, token = context.token) => command(name, { method: 'POST', path, idempotencyKey: key(name), testEvidence: name }, + async () => domesticSuccess(await context.requestMultipart('POST', path, form, token, { 'Idempotency-Key': key(name) }))) + const poll = async (read, condition, description) => { + for (let i = 0; i < (context.pollAttempts || 45); i++) { + const data = await read() + if (condition(data)) return data + await (context.sleep || (ms => new Promise(resolve => setTimeout(resolve, ms))))(1000) + } + throw Error(`国内异步事实未就绪:${description}`) + } + const unified = await poll(() => get(`/api/platform/v1/orders/${order.orderId}?${q}`), data => data.summary?.inboundId, '订单入库投影') + const inboundId = id(unified.summary.inboundId) + const inboundPath = `/api/platform/inbounds/${inboundId}?${q}` + const base = `/api/platform/v1/first-leg/inbounds/${inboundId}` + const measurementPath = `${base}/measurement?${q}` + const inbound = await get(inboundPath) + assert(id(inbound.summary?.tenantId) === tenantId && inbound.summary.inboundCode === order.inboundCode + && id(inbound.summary?.orderId) === order.orderId, '入库单客户、订单或入仓码不属于本票') + await write('accept', 'POST', `/api/platform/v1/orders/first-leg/${order.orderId}/accept?${q}`) + await write('start', 'POST', `/api/platform/v1/orders/first-leg/${order.orderId}/start?${q}`) + await command('carton-labels', { method: 'POST', path: `/api/platform/inbounds/${inboundId}/carton-label-pdf?${q}` }, async () => { + const result = await context.requestBinary('POST', `/api/platform/inbounds/${inboundId}/carton-label-pdf?${q}`) + assert(result.http === 200 && String(result.contentType).includes('pdf') && Buffer.from(result.bytes || []).subarray(0, 4).toString() === '%PDF', '本票箱唛未成功生成') + return { generated: true } + }) + const scanForm = new FormData() + scanForm.append('data', new Blob([JSON.stringify({ boxCode: `${order.inboundCode}#${cargo.packageCount}-1`, + length: cargo.lengthCm, width: cargo.widthCm, height: cargo.heightCm, + machineNo: 'TEST-RELEASE-SIMULATED', measuredAt: localTime() })], { type: 'application/json' })) + scanForm.append('image', new Blob([PNG], { type: 'image/png' }), 'TEST-simulated-carton.png') + const scan = await upload('carton-scan', `${base}/measurement/carton-scans?${q}`, scanForm) + assert(scan?.itemId && scan.measurementId, '测量扫描未生成本票明细') + await write('carton-count', 'PUT', `${base}/measurement/items/${scan.itemId}/confirmed-package-count?${q}`, + { measurementVersion: Number(scan.measurementVersion), confirmedPackageCount: cargo.packageCount }) + const measured = await get(measurementPath) + assert(measured.items?.length === 1 && id(measured.items[0].id) === id(scan.itemId), '本票测量明细数量不符') + const item = measured.items[0] + await write('measurement-draft', 'PUT', `${base}/measurement/draft?${q}`, { + measurementId: measured.measurementId, version: Number(measured.version), actualGoodsName: cargo.goodsName || cargo.skuNo, + items: [{ inboundDetailId: item.inboundDetailId, itemNo: item.itemNo, boxSpecification: item.boxSpecification, + packageCount: cargo.packageCount, lengthCm: cargo.lengthCm, widthCm: cargo.widthCm, heightCm: cargo.heightCm, + intelligentImageFileId: item.intelligentImageFileId, overLimitWarning: item.overLimitWarning ?? null, + outerLengthCm: cargo.lengthCm, outerWidthCm: cargo.widthCm, outerHeightCm: cargo.heightCm, + coefficientMode: 'ADD', remark: `${input.batch} TEST模拟测量,非实测证据` }] }) + const weightFile = await upload('weight-file', `/api/platform/v1/files/measurements/${tenantId}/${inboundId}`, fileForm('TEST-weight.png')) + const weight = await write('weight-create', 'POST', `${base}/weight-results?${q}`, { fileId: weightFile.fileId, palletId: config.palletId, + ocrProvider: 'ADMIN_ACCEPTANCE', ocrRawText: null, ocrWeightValue: null, ocrWeightUnit: null, confidence: null }) + assert(weight.palletTareWeightKg != null && Number(weight.palletTareWeightKg) >= 0, '缺少已有托盘皮重快照') + await write('weight-confirm', 'POST', `${base}/weight-results/${weight.id}/confirm?${q}`, { + version: Number(weight.version), adjustedWeightValue: Number(cargo.grossWeightKg) + Number(weight.palletTareWeightKg), + adjustedWeightUnit: 'KG', adjustmentReason: `${input.batch} TEST模拟称重,申报货重加托盘皮重` }) + const ready = await get(measurementPath) + await write('measurement-submit', 'POST', `${base}/measurement/submit?${q}`, { measurementId: ready.measurementId, version: Number(ready.version) }) + const pending = await get(inboundPath) + await write('measurement-approve', 'POST', `/api/platform/v1/inbounds/${inboundId}/measurements/approve?${q}`, + { measurementId: ready.measurementId, expectedVersion: Number(pending.summary.version), reason: `${input.batch} TEST专票流程` }) + const approved = await poll(() => get(measurementPath), value => value.processStatus === 'WAIT_SPLIT' + || checkpoint.state.commands?.['plan-assign']?.status === 'DONE', '测量审核待分柜') + assert(approved.measurementStatus === 'APPROVED' && approved.measureReviewStatus === 'APPROVED' + && Number(approved.actualPackageCount) === cargo.packageCount + && Math.abs(Number(approved.actualNetWeightKg) - cargo.grossWeightKg) <= 0.001, '测量审核、箱数或净重不符') + const latestFee = await context.request('GET', `/api/platform/v1/inbounds/${inboundId}/fees/latest?${q}`, undefined, context.token) + if (latestFee.http === 200 && String(latestFee.body?.code) === 'A0404') { + await write('fee-calculate', 'POST', `/api/platform/v1/inbounds/${inboundId}/fees/calculate?${q}`) + } else domesticSuccess(latestFee) + await write('fee-confirm', 'POST', `/api/platform/v1/inbounds/${inboundId}/fees/confirm?${q}`) + const sent = await write('fee-send', 'POST', `/api/platform/v1/inbounds/${inboundId}/fees/send?${q}`) + assert(sent.feeStatus === 'SENT', '头程费用未发送客户') + const billing = `/api/merchant/v1/first-leg/orders/${order.orderId}/inbound-billing` + const customerFee = await write('customer-fee-confirm', 'POST', `${billing}/confirm`, { expectedFeeVersion: Number(sent.feeVersion) }, context.merchantToken) + assert(customerFee.customerConfirmationStatus === 'CONFIRMED', '客户费用未确认') + const containers = '/api/platform/v1/containers', smart = '/api/platform/v1/first-leg/smart-loading', pda = '/api/platform/v1/first-leg/pda-loading' + const simulation = await write('container-create', 'POST', containers, { channelGroupCode: channelGroup(input.payload.channelCode), warehouseId, + containerType: '40HQ', capacityCbm: 68, maxWeightKg: 19500 }) + const containerId = id(simulation.id) + const booking = await write('booking-create', 'POST', '/api/platform/v1/ocean-bookings', { expectedVersion: 0, + data: oceanBookingDraft({ marker: input.batch }, input.batch, { customerId: tenantId, customerName: input.customer.name, + channelCode: input.payload.channelCode, warehouseId }, { shippingCompanyId: config.carrier.id, shippingCompanyName: config.carrier.name, + carrierCode: config.carrier.carrierCode, bookingAgentId: config.bookingAgent.id, bookingAgentName: config.bookingAgent.name, + estimatedSailingAt: config.estimatedSailingAt, estimatedArrivalAt: config.estimatedArrivalAt }) }) + const oceanBookingId = id(booking.oceanBooking.id) + const document = await upload('booking-document', `/api/platform/v1/ocean-bookings/${oceanBookingId}/documents/upload`, + fileForm('TEST-booking-confirmation.txt', `${input.batch} TEST模拟订舱确认文件,非真实单证`, 'text/plain', + { expectedVersion: booking.oceanBooking.version, documentType: 'BOOKING_CONFIRMATION' })) + const released = await write('booking-release', 'POST', `/api/platform/v1/ocean-bookings/${oceanBookingId}/release`, + { expectedVersion: Number(document.bookingVersion), reason: `${input.batch} TEST` }) + assert(released.oceanBooking?.status === 'RELEASED', '订舱未放舱') + const requirement = released.containerRequirements?.find(value => value.containerType === '40HQ') + assert(requirement?.id, '订舱缺少40HQ需求') + const containerNo = iso6346ContainerNumber(input.batch) + await write('booking-link', 'POST', `${containers}/${containerId}/ocean-booking-link`, { oceanBookingId, + requirementId: id(requirement.id), containerNo, expectedVersion: Number(simulation.version) }) + const candidates = await get(`${smart}/candidates?bookingId=${containerId}&warehouseId=${warehouseId}`) + const candidate = candidates.find(value => id(value.orderId) === order.orderId && id(value.accountId) === tenantId) + // After assignment a resumed run uses the captured candidate, never another order. + const assignedBefore = checkpoint.state.commands?.['plan-assign'] + assert(candidate || assignedBefore?.status === 'DONE', '本票不在可分柜候选中') + const plan = await get(`${smart}/plans/by-booking/${containerId}`), planId = id(plan.id) + const assigned = await write('plan-assign', 'POST', `${smart}/plans/${planId}/assignments`, { + inboundId, inboundDetailId: id(candidate?.inboundDetailId), packageCount: cargo.packageCount, + expectedVersion: Number(plan.version), operationId: key('plan-assign') }) + assert(assigned.items?.length === 1 && Number(assigned.items[0].packageCount) === cargo.packageCount, '装柜计划混入其他货物或数量不符') + const confirmable = await get(`/api/platform/v1/first-leg/loading-plans/${planId}?warehouseId=${warehouseId}`) + const confirmed = await write('plan-confirm', 'POST', `/api/platform/v1/first-leg/loading-plans/${planId}/confirm`, + { expectedVersion: Number(confirmable.summary.version), assigneeId: config.assigneeId }) + const taskId = id(confirmed.readiness?.pdaTaskId) + assert(taskId && confirmed.confirmationStatus === 'CONFIRMED', '国内 PDA 任务未生成') + const claimed = await write('pda-claim', 'POST', `${pda}/tasks/${taskId}/claim`, + { expectedAssignmentVersion: 0, operationId: key('pda-claim') }, context.pdaToken) + assert(claimed.items?.length === 1, '国内 PDA 任务货物不唯一') + const snapshot = await get(`${pda}/tasks/${taskId}/inventory-snapshot?warehouseId=${warehouseId}`, context.pdaToken) + const picked = await write('pda-pick', 'POST', `${pda}/tasks/${taskId}/batch`, { warehouseId, loadingPlanId: planId, + containerBookingId: containerId, expectedVersion: Number(snapshot.taskVersion), inventorySnapshotToken: snapshot.snapshotToken, + items: [{ itemId: id(claimed.items[0].id), action: 'PICK', pickedPackageCount: cargo.packageCount, version: Number(claimed.items[0].version) }] }, context.pdaToken) + for (const evidenceType of ['PHOTO_STEP_1', 'PHOTO_STEP_2', 'PHOTO_STEP_3', 'PHOTO_STEP_4', 'SEAL']) { + const params = new URLSearchParams({ warehouseId, expectedVersion: '0', evidenceType, remark: `${input.batch} TEST模拟凭证` }) + if (evidenceType === 'SEAL') params.set('sealNo', `TEST-${input.batch}`.slice(0, 64)) + await upload(`loading-${evidenceType}`, `${pda}/tasks/${taskId}/evidence?${params}`, fileForm(`TEST-${evidenceType}.png`), context.pdaToken) + } + await write('pda-complete', 'POST', `${pda}/tasks/${taskId}/complete`, { expectedTaskVersion: Number(picked.version), + operationId: key('pda-complete'), remark: `${input.batch} TEST装柜` }, context.pdaToken) + await poll(() => get(`${pda}/tasks/${taskId}?warehouseId=${warehouseId}`, context.pdaToken), + value => value.taskStatus === 'COMPLETED' && value.workflowStatus === 'COMPLETED', '国内装柜完成') + const customsPath = `/api/platform/v1/customs/loading-plans/${planId}` + let customs = await get(customsPath) + assert(customs.inbounds?.length === 1 && id(customs.inbounds[0].inboundId) === inboundId + && customs.inbounds.every(value => value.taxMode === 'BUY_DECLARATION'), '报关聚合不是本票买单货物') + if (customs.status === 'READY_TO_START') customs = await write('customs-start', 'POST', `${customsPath}/start`, + { expectedVersion: Number(customs.version), confirm: true, remark: `${input.batch} TEST` }) + if (customs.status === 'DECLARING') customs = await write('customs-complete', 'POST', `${customsPath}/complete-all-buy`, + { expectedVersion: Number(customs.version), confirm: true, remark: `${input.batch} TEST` }) + assert(customs.status === 'COMPLETED', '国内报关未完成') + const outboundPath = `/api/platform/v1/first-leg/outbound-dispatches/${containerId}` + const outbound = await poll(() => get(outboundPath), value => value.aggregate?.dispatchStatus === 'WAITING_FOR_LOADING', '报关自动国内离仓') + assert(outbound.aggregate.inbounds?.length === 1 && id(outbound.aggregate.inbounds[0].inboundId) === inboundId, '离仓货物归属不符') + const queries = await poll(() => get(`/api/platform/v1/carrier-schedule-queries?containerBookingId=${containerId}&page=1&size=20`), + value => value.records?.some(row => row.query?.triggerSource === 'OUTBOUND_CONFIRM'), '离仓船节点任务') + const owned = queries.records.filter(row => id(row.query?.containerBookingId) === containerId && row.query.triggerSource === 'OUTBOUND_CONFIRM') + assert(owned.length === 1, '本柜船节点任务不唯一') + const carrierQueryId = id(owned[0].query.id), carrierPath = `/api/platform/v1/carrier-schedule-queries/${carrierQueryId}` + let carrierDetail = await get(carrierPath) + let start = NODES.indexOf(carrierDetail.query?.query?.latestActualNode) + assert(start >= 0, '缺少自动离仓起点') + for (let index = start + 1; index < NODES.length; index++) { + const latest = carrierDetail.query.query + // Current observed wall clock; actual timestamps advance from the authoritative last node. + const actualAt = new Date(Date.parse(`${latest.latestActualAt}Z`) + 1000).toISOString().slice(0, 19) + carrierDetail = await write(`carrier-${NODES[index]}`, 'POST', `${carrierPath}/manual-nodes`, { + expectedVersion: Number(latest.version), standardNode: NODES[index], observedAt: localTime(), actualAt, estimatedAt: null, + locationCode: 'USLAX', locationName: 'Los Angeles', reason: `${input.batch} TEST模拟船节点 ${NODES[index]}` }) + } + assert(carrierDetail.query?.query?.latestActualNode === 'PICKUP_READY', '船节点未到可提柜') + const terminalPath = `/api/platform/v1/terminal-drayages/${containerId}` + const initialTerminal = await get(terminalPath) + assert(id(initialTerminal.containerBookingId) === containerId && initialTerminal.containerNo === containerNo, '目的港拖车货柜不符') + assert(!initialTerminal.fleetSupplierId || checkpoint.state.commands?.['terminal-fleet']?.status === 'DONE', '禁止更换既有车队触发邮件') + const selected = await write('terminal-fleet', 'POST', `${terminalPath}/fleet`, { expectedVersion: Number(initialTerminal.version), + fleetSupplierId: config.terminalFleet.id, fleetSupplierName: config.terminalFleet.name, reason: `${input.batch} TEST` }) + const appointmentAt = new Date(Date.parse(`${carrierDetail.query.query.latestActualAt}Z`) + 3600000).toISOString().slice(0, 19) + const scheduled = await write('terminal-appointment', 'POST', `${terminalPath}/pickup-appointment`, { + expectedVersion: Number(selected.version), pickupAppointmentAt: appointmentAt, reason: `${input.batch} TEST` }) + assert(scheduled.pickupAppointmentAt === appointmentAt, '目的港提柜预约未持久化') + return { status: 'DOMESTIC_PICKUP_SCHEDULED', orderId: order.orderId, orderNo: order.orderNo, tenantId, warehouseId, + inboundId, inboundCode: order.inboundCode, containerId, containerBookingId: containerId, containerNo, + oceanBookingId, planId, loadingPlanId: planId, taskId, carrierQueryId, packageCount: cargo.packageCount, + terminalDrayageVersion: Number(scheduled.version), pickupAppointmentAt: appointmentAt } +} diff --git a/scripts/prod-release-domestic-stage.test.mjs b/scripts/prod-release-domestic-stage.test.mjs new file mode 100644 index 000000000..39b9a8ed0 --- /dev/null +++ b/scripts/prod-release-domestic-stage.test.mjs @@ -0,0 +1,122 @@ +import test from 'node:test' +import assert from 'node:assert/strict' +import { domesticCommands, domesticSuccess, runDomesticStage } from './prod-release-domestic-stage.mjs' + +const journal = () => { + const state = {}, saves = [] + return { state, saves, async save(patch) { saves.push(structuredClone(patch)); Object.assign(state, patch) } } +} +test('uncertain writes retain original intent and cannot replay', async () => { + const checkpoint = journal(), run = domesticCommands(checkpoint, 'bound') + let count = 0 + const execute = async () => { count++; assert.equal(checkpoint.state.commands.create.status, 'INTENT'); throw Error('network') } + await assert.rejects(run('create', { path: '/create' }, execute), /network/) + await assert.rejects(run('create', { path: '/create' }, execute), /结果未知/) + assert.equal(count, 1) +}) +test('completed writes reuse exact saved result and reject another binding', async () => { + const checkpoint = journal(), run = domesticCommands(checkpoint, 'bound') + const first = await run('create', { path: '/create' }, async () => ({ id: '17' })) + assert.deepEqual(await run('create', { path: '/create' }, () => { throw Error('must not run') }), first) + assert.throws(() => domesticCommands(checkpoint, 'other'), /绑定/) +}) +test('business rejection retains diagnostic evidence and cannot count as success', () => { + assert.throws(() => domesticSuccess({ http: 200, body: { code: 'A0409', message: 'version' }, requestId: 'r', traceId: 't' }), + error => error.evidence.code === 'A0409' && error.evidence.requestId === 'r' && error.evidence.traceId === 't') + assert.throws(() => domesticSuccess({ http: 503, body: { code: '200' } })) +}) + +function fixture() { + const input = { batch: 'release-test-001', customer: { id: '1', name: 'TEST' }, origin: { id: '2' }, + payload: { taxMode: 'BUY_DECLARATION', channelCode: 'MATSON_CLX', cargoes: [{ skuNo: 'TEST-SKU', packageCount: 2, + lengthCm: 30, widthCm: 20, heightCm: 10, grossWeightKg: 8 }] }, + domestic: { palletId: '3', assigneeId: '4', bookingAgent: { id: '5', name: 'AGENT' }, + carrier: { id: '6', name: 'CARRIER', carrierCode: 'MATSON' }, terminalFleet: { id: '7', name: 'FLEET' }, + estimatedSailingAt: '2026-10-12T08:00:00', estimatedArrivalAt: '2026-10-20T08:00:00' } } + const order = { status: 'ORDER_APPROVED', orderId: '10', orderNo: 'TEST-ORDER', inboundCode: 'TEST-INBOUND' } + const checkpoint = journal(), calls = [] + const context = { token: 'platform-test', merchantToken: 'merchant-test', pdaToken: 'pda-test', pollAttempts: 1, sleep: async () => {} } + let containerNo, carrierNode = 'DEPARTED_WAREHOUSE', carrierVersion = 0, latestActualAt = '2026-10-12T10:00:00' + let measured = false + const carrier = () => ({ query: { query: { latestActualNode: carrierNode, version: carrierVersion, latestActualAt } } }) + context.request = async (method, path, body, token, headers) => { + calls.push({ method, path, body, token, headers }) + let data + if (path === '/api/platform/v1/orders/10?tenantId=1') data = { summary: { inboundId: '20' } } + else if (path === '/api/platform/inbounds/20?tenantId=1') data = { summary: { tenantId: '1', orderId: '10', inboundCode: 'TEST-INBOUND', version: 1 } } + else if (path.includes('/measurement?')) data = { measurementId: '30', version: 2, items: [{ id: '31', inboundDetailId: '32', itemNo: 1 }], + processStatus: measured ? 'WAIT_SPLIT' : 'MEASURING', measurementStatus: 'APPROVED', measureReviewStatus: 'APPROVED', actualPackageCount: 2, actualNetWeightKg: 8 } + else if (path.includes('/measurements/approve')) { measured = true; data = {} } + else if (path.includes('/weight-results?')) data = { id: '40', version: 0, palletTareWeightKg: 2 } + else if (path.includes('/fees/send')) data = { feeStatus: 'SENT', feeVersion: 1 } + else if (path.includes('/inbound-billing/confirm')) data = { customerConfirmationStatus: 'CONFIRMED' } + else if (path === '/api/platform/v1/containers') data = { id: '50', version: 0 } + else if (path === '/api/platform/v1/ocean-bookings') data = { oceanBooking: { id: '60', version: 0 } } + else if (path.includes('/ocean-bookings/60/release')) data = { oceanBooking: { status: 'RELEASED' }, containerRequirements: [{ id: '61', containerType: '40HQ' }] } + else if (path.includes('/ocean-booking-link')) { containerNo = body.containerNo; data = {} } + else if (path.includes('/smart-loading/candidates')) data = [{ orderId: '10', accountId: '1', inboundDetailId: '32' }] + else if (path.includes('/plans/by-booking/')) data = { id: '70', version: 0 } + else if (path.includes('/plans/70/assignments')) data = { items: [{ id: '71', packageCount: 2 }] } + else if (path.includes('/loading-plans/70/confirm')) data = { confirmationStatus: 'CONFIRMED', readiness: { pdaTaskId: '80' } } + else if (path.includes('/first-leg/loading-plans/70?')) data = { summary: { version: 1 } } + else if (path.includes('/tasks/80/claim')) data = { items: [{ id: '81', version: 0 }] } + else if (path.includes('/tasks/80/inventory-snapshot')) data = { taskVersion: 1, snapshotToken: 'snapshot' } + else if (path.includes('/tasks/80/batch')) data = { version: 2 } + else if (path.includes('/tasks/80?')) data = { taskStatus: 'COMPLETED', workflowStatus: 'COMPLETED' } + else if (path === '/api/platform/v1/customs/loading-plans/70') data = { inbounds: [{ inboundId: '20', taxMode: 'BUY_DECLARATION' }], status: 'READY_TO_START', version: 0 } + else if (path.endsWith('/customs/loading-plans/70/start')) data = { status: 'DECLARING', version: 1 } + else if (path.endsWith('/complete-all-buy')) data = { status: 'COMPLETED', version: 2 } + else if (path.includes('/outbound-dispatches/50')) data = { aggregate: { dispatchStatus: 'WAITING_FOR_LOADING', inbounds: [{ inboundId: '20' }] } } + else if (path.includes('/carrier-schedule-queries?')) data = { records: [{ query: { id: '90', containerBookingId: '50', triggerSource: 'OUTBOUND_CONFIRM' } }] } + else if (path.endsWith('/carrier-schedule-queries/90')) data = carrier() + else if (path.endsWith('/manual-nodes')) { carrierNode = body.standardNode; carrierVersion++; latestActualAt = body.actualAt; data = carrier() } + else if (path.endsWith('/terminal-drayages/50')) data = { containerBookingId: '50', containerNo, version: 0 } + else if (path.endsWith('/terminal-drayages/50/fleet')) data = { version: 1 } + else if (path.endsWith('/pickup-appointment')) data = { version: 2, pickupAppointmentAt: body.pickupAppointmentAt } + else if (/\/(accept|start)\?/.test(path) || /confirmed-package-count|measurement\/draft|weight-results\/40\/confirm|measurement\/submit|fees\/latest|fees\/confirm|tasks\/80\/complete/.test(path)) data = {} + else throw Error(`unhandled ${method} ${path}`) + return { http: 200, body: { code: '200', data } } + } + context.requestMultipart = async (method, path, form, token, headers) => { + assert.ok(form instanceof FormData) + calls.push({ method, path, token, headers, multipart: true }) + let data + if (path.includes('/carton-scans')) data = { itemId: '31', measurementId: '30', measurementVersion: 0 } + else if (path.includes('/files/measurements/')) data = { fileId: '41' } + else if (path.includes('/documents/upload')) data = { bookingVersion: 1 } + else if (path.includes('/tasks/80/evidence')) data = { taskId: '80' } + else throw Error(`unhandled upload ${path}`) + return { http: 200, body: { code: '200', data } } + } + context.requestBinary = async () => ({ http: 200, contentType: 'application/pdf', bytes: Buffer.from('%PDF-test') }) + return { input, order, context, checkpoint, calls } +} + +test('single-ticket positive HTTP orchestration conserves two cartons and stops at pickup appointment', async () => { + const value = fixture(), result = await runDomesticStage(value) + assert.equal(result.status, 'DOMESTIC_PICKUP_SCHEDULED') + assert.equal(result.inboundId, '20') + assert.equal(result.packageCount, 2) + const batch = value.calls.find(call => call.path.endsWith('/tasks/80/batch')) + assert.equal(batch.body.items[0].pickedPackageCount, 2) + assert.equal(batch.token, value.context.pdaToken) + const weight = value.calls.find(call => call.path.includes('/weight-results/40/confirm')) + assert.equal(weight.body.adjustedWeightValue, 10) + assert.equal(value.calls.filter(call => call.path.endsWith('/manual-nodes')).length, 7) + assert.equal(value.calls.filter(call => call.path.includes('/evidence?')).length, 5) + assert.ok(value.calls.every(call => !/pickup-email|\/suppliers|\/users|\/customers/.test(call.path))) + assert.ok(Object.values(value.checkpoint.state.commands).every(command => command.status === 'DONE')) +}) +test('tax mode and customer identity fail closed before unrelated business writes', async () => { + const value = fixture() + value.input.payload.taxMode = 'SELF_DECLARATION' + await assert.rejects(runDomesticStage(value), /买单报关/) + assert.equal(value.calls.length, 0) +}) +test('a timeout at first business write preserves intent and stops all later actions', async () => { + const value = fixture(), request = value.context.request + value.context.request = async (...args) => args[0] === 'POST' ? Promise.reject(Error('timeout')) : request(...args) + await assert.rejects(runDomesticStage(value), /timeout/) + assert.equal(value.checkpoint.state.commands.accept.status, 'INTENT') + assert.equal(Object.keys(value.checkpoint.state.commands).length, 1) +}) diff --git a/scripts/prod-release-full-flow.mjs b/scripts/prod-release-full-flow.mjs new file mode 100644 index 000000000..e8e0cdaa5 --- /dev/null +++ b/scripts/prod-release-full-flow.mjs @@ -0,0 +1,93 @@ +import { resolve } from 'node:path' +import { fingerprint, validateOrderInput } from './prod-release-order-stage.mjs' +import { normalizeOrderInput } from './prod-release-measurement-plan.mjs' +import { executeOrder } from './prod-release-acceptance.mjs' +import { executeDelivery } from './prod-release-delivery-stage.mjs' +import { createFlowContext, withFlowJournal, requireSuccess } from './prod-release-http.mjs' +import { runDomesticStage } from './prod-release-domestic-stage.mjs' +import { runOverseasStage, finishSingleOrderTrip } from './prod-release-overseas-stage.mjs' + +export async function executeFullFlow(settings, rawInput, journalPath, resume = false, fetcher = fetch) { + const input = { ...normalizeOrderInput(rawInput), customer: settings.customer, + environment: { mode: settings.environment, apiBase: settings.apiBase }, + operator: { merchant: settings.credentials.MERCHANT.loginId, admin: settings.credentials.ADMIN.loginId } } + validateOrderInput(input) + const binding = fingerprint({ input, credentials: Object.fromEntries(Object.entries(settings.credentials).map(([key,value])=>[key,value.loginId])) }) + return withFlowJournal(journalPath, binding, async (state, save) => { + if (state.status !== 'NEW' && !resume) throw Error('已有全流程检查点,须保持原输入并显式 --resume') + const ctx = createFlowContext({ settings, state, save, fetcher }) + ctx.marker = input.batch + const checkpoint = name => { + const local = state.stages[name] || {} + return { state: local, save: async patch => { + const next = { ...local, ...patch } + await save({ stages: { ...state.stages, [name]: next } }); Object.assign(local, next) + } } + } + const bridgeFetch = async (url, options) => { + if (!url.startsWith(settings.apiBase + '/')) throw Error('全流程请求偏离统一Gateway') + const path = '/api' + url.slice(settings.apiBase.length) + const token = options.headers?.Authorization?.replace(/^Bearer /, '') + const body = options.body instanceof FormData ? options.body : options.body ? JSON.parse(options.body) : undefined + const result = await ctx.requestWithBody(options.method, path, body, token ?? null, + options.headers?.['Idempotency-Key'] ? { 'Idempotency-Key': options.headers['Idempotency-Key'] } : {}) + if (path === '/api/auth/login' && result.body?.code === '200') ctx.registerSession(result.body.data) + return { ok: result.http >= 200 && result.http < 300, status: result.http, json: async () => result.body } + } + try { + await save({ status: 'RUNNING' }) + ctx.token = (await ctx.login('ADMIN')).accessToken + ctx.merchantToken = (await ctx.login('MERCHANT')).accessToken + ctx.pdaToken = (await ctx.login('CN_PDA')).accessToken + ctx.overseasPdaToken = (await ctx.login('OVERSEAS_LAX_PDA')).accessToken + const binding = requireSuccess(await ctx.request('GET', '/api/pda/v1/last-leg/receiving-containers/warehouse-profile', undefined, ctx.overseasPdaToken)) + if (binding.warehouseCode !== 'US-LAX-01') throw Error('海外PDA不是洛杉矶接柜仓') + const orderCp = checkpoint('order') + let order = orderCp.state.result + if (!order) { + order = await executeOrder(settings, input, resolve(`${journalPath}.order.json`), resume, bridgeFetch) + await orderCp.save({ result: order }) + } + console.log('单票阶段:ORDER_APPROVED') + const domesticCp = checkpoint('domestic') + let domestic = domesticCp.state.result + if (!domestic) { + domestic = await runDomesticStage({ input, order, context: ctx, checkpoint: domesticCp }) + await domesticCp.save({ result: domestic }) + } + console.log(`单票阶段:${domestic.status}`) + const overseasCp = checkpoint('overseas') + let overseas = overseasCp.state.result + if (!overseas) { + overseas = await runOverseasStage({ input: { ...input, customerId: settings.customer.id, + packageCount: input.payload.cargoes[0].packageCount, warehouseId: binding.warehouseId }, + domestic: { ...domestic, sourceInboundId: domestic.inboundId, orderId: order.orderId, orderNo: order.orderNo, inboundCode: order.inboundCode }, + context: ctx, checkpoint: overseasCp }) + await overseasCp.save({ result: overseas }) + } + console.log(`单票阶段:${overseas.status}`) + const delivery = await executeDelivery(settings, { batch: input.batch, ...overseas, + orderId: order.orderId, orderNo: order.orderNo, inboundCode: order.inboundCode, + packageCount: input.payload.cargoes[0].packageCount, warehouseId: binding.warehouseId }, + resolve(`${journalPath}.delivery.json`), bridgeFetch) + if (delivery.blockers.length) throw Error(`签收/回传未闭环:${delivery.blockers.join(',')}`) + const tripClosure = await finishSingleOrderTrip({ input: { ...input, customerId: settings.customer.id }, overseas, context: ctx, checkpoint: checkpoint('tripClosure') }) + const result = { status: 'SINGLE_ORDER_HTTP_COMPLETED', fullFlowVerified: true, + scope: 'ONE_ORDER_NORMAL_HTTP_PATH', orderNo: order.orderNo, inboundCode: order.inboundCode, + containerNo: domestic.containerNo, dispatchNo: overseas.dispatchNo, deliveryNo: overseas.deliveryNo, + domesticOrderStatus: delivery.domesticOrderStatus, domesticInboundStatus: delivery.domesticInboundStatus, + tripClosureStatus: tripClosure.status, + syntheticEvidence: true, excluded: ['真实货物及设备', '浏览器视觉', '故障注入', '多客户/拆单/异常分支', '真实付款/第三方下单/邮件'], + completedAt: new Date().toISOString() } + await save({ status: 'COMPLETED', result }) + return result + } catch (error) { + await save({ status: 'BLOCKED', failure: error.message }) + throw error + } finally { + const cleanup = await ctx.close() + if (state.result) state.result.sessionCleanup = cleanup.length ? 'FAILED' : 'COMPLETED' + await save({ cleanup }) + } + }) +} diff --git a/scripts/prod-release-http.mjs b/scripts/prod-release-http.mjs new file mode 100644 index 000000000..d0d0c7af2 --- /dev/null +++ b/scripts/prod-release-http.mjs @@ -0,0 +1,123 @@ +import { createHash } from 'node:crypto' +import { mkdir, open, readFile, rename, unlink } from 'node:fs/promises' +import { dirname } from 'node:path' + +const hash = value => createHash('sha256').update(value).digest('hex') +const safe = value => typeof value === 'string' && /^[\w-]{1,100}$/.test(value) ? value : 'unavailable' +export const success = response => response?.http === 200 && response.body?.code === '200' +export function requireSuccess(response) { + if (!success(response)) throw new Error(`HTTP=${response?.http}; code=${safe(response?.body?.code)}; requestId=${safe(response?.requestId)}; traceId=${safe(response?.traceId)}`) + return response.body.data +} + +export async function withFlowJournal(path, binding, action) { + await mkdir(dirname(path), { recursive: true }) + const lock = await open(`${path}.lock`, 'wx', 0o600) + try { + let state + try { state = JSON.parse(await readFile(path, 'utf8')) } + catch (error) { + if (error.code !== 'ENOENT') throw new Error('全流程检查点损坏;禁止新建替代批次') + state = { version: 1, binding, stages: {}, requests: [], status: 'NEW' } + } + if (state.version !== 1 || state.binding !== binding) throw new Error('全流程检查点绑定变化') + const save = async patch => { + const next = { ...state, ...patch } + const file = await open(`${path}.next`, 'wx', 0o600) + try { await file.writeFile(JSON.stringify(next, null, 2)); await file.sync() } finally { await file.close() } + await rename(`${path}.next`, path) + Object.assign(state, next) + } + await save({}) + return await action(state, save) + } finally { await lock.close(); await unlink(`${path}.lock`) } +} + +async function bodyDigest(body) { + if (!(body instanceof FormData)) return hash(JSON.stringify(body ?? null)) + const parts = [] + for (const [key, value] of body) parts.push([key, typeof value === 'string' ? value + : { name: value.name, type: value.type, sha256: hash(Buffer.from(await value.arrayBuffer())) }]) + return hash(JSON.stringify(parts)) +} + +// Only public business HTTP. No fixture runner, SQL, deployment or automatic network retry. +export function createFlowContext({ settings, state, save, fetcher = fetch }) { + const sessions = [] + const context = { + marker: '', options: {}, context: {}, createdResources: {}, steps: [], + registerSession(session) { sessions.push(session) }, + async request(method, path, body, token = context.token, extraHeaders = {}) { + return send(method, path, body, token, extraHeaders, false) + }, + async requestWithBody(method, path, body, token = context.token, extraHeaders = {}) { + return send(method, path, body, token, extraHeaders, false) + }, + async requestMultipart(method, path, file, token = context.token, extraHeaders = {}) { + const form = file instanceof FormData ? file : new FormData() + if (!(file instanceof FormData)) { + if (!file || file.content === undefined) throw new Error('缺少上传内容') + form.append('file', new Blob([file.content], { type: file.type || 'application/octet-stream' }), file.name || 'acceptance.bin') + } + return send(method, path, form, token, extraHeaders, false) + }, + async requestBinary(method, path, token = context.token) { return send(method, path, undefined, token, {}, true) }, + apiSuccess(module, name, response) { + const data = requireSuccess(response) + context.steps.push({ module, name, passed: true }) + return data + }, + assert(module, name, response, condition) { + context.steps.push({ module, name, passed: Boolean(condition) }) + if (!condition) throw new Error(`${module}: ${name}`) + }, + async login(role) { + const data = requireSuccess(await send('POST', '/api/auth/login', settings.credentials[role], null, {}, false)) + if (!data?.accessToken || !data.refreshToken) throw new Error(`${role}: 登录令牌不完整`) + sessions.push(data) + return data + }, + async close() { + const failures = [] + for (const session of sessions) { + try { requireSuccess(await send('POST', '/api/auth/logout', { refreshToken: session.refreshToken }, session.accessToken, {}, false)) } + catch { failures.push('SESSION_LOGOUT_FAILED') } + } + return failures + }, + } + async function send(method, path, body, token, extraHeaders, binary) { + if (!['GET', 'POST', 'PUT'].includes(method) || !/^\/api\/(auth\/(login|logout)$|platform\/|merchant\/|pda\/)/.test(path) + || path.includes('..') || path.includes('#')) throw new Error('全流程请求路径或方法不允许') + const auth = path === '/api/auth/login' || path === '/api/auth/logout' + if (path !== '/api/auth/logout' && state.requests.length >= 2500) throw new Error('全流程请求超过安全上限') + const entry = { sequence: state.requests.length + 1, method, path, startedAt: new Date().toISOString(), + status: 'INTENT', ...(auth ? {} : { bodyHash: await bodyDigest(body) }) } + await save({ requests: [...state.requests, entry] }) + let result + try { + const response = await fetcher(settings.apiBase.replace(/\/api$/, '') + path, { + method, redirect: 'error', signal: AbortSignal.timeout(45000), + headers: { ...(body !== undefined && !(body instanceof FormData) ? { 'Content-Type': 'application/json' } : {}), + ...extraHeaders, ...(token ? { Authorization: `Bearer ${token}` } : {}) }, + ...(body !== undefined ? { body: body instanceof FormData ? body : JSON.stringify(body) } : {}), + }) + if (binary && response.ok && !response.headers.get('content-type')?.includes('json')) { + const bytes = new Uint8Array(await response.arrayBuffer()) + result = { http: response.status, bytes, buffer: Buffer.from(bytes), contentType: response.headers.get('content-type'), headers: Object.fromEntries(response.headers) } + } else { + const envelope = await response.json() + result = { http: response.status, body: envelope, requestId: envelope?.requestId || response.headers.get('x-request-id'), + traceId: envelope?.traceId || response.headers.get('x-trace-id') } + } + } catch { + await save({ requests: state.requests.map(row => row.sequence === entry.sequence ? { ...row, status: 'UNKNOWN' } : row) }) + throw new Error(`HTTP结果不确定:${method} ${path};禁止自动重试`) + } + await save({ requests: state.requests.map(row => row.sequence === entry.sequence ? { ...row, + status: success(result) || result.bytes ? 'DONE' : 'REJECTED', http: result.http, + code: safe(result.body?.code), requestId: safe(result.requestId), traceId: safe(result.traceId) } : row) }) + return result + } + return context +} diff --git a/scripts/prod-release-http.test.mjs b/scripts/prod-release-http.test.mjs new file mode 100644 index 000000000..5ba67e6c3 --- /dev/null +++ b/scripts/prod-release-http.test.mjs @@ -0,0 +1,50 @@ +import test from 'node:test' +import assert from 'node:assert/strict' +import { createFlowContext, requireSuccess } from './prod-release-http.mjs' +const settings = { apiBase: 'https://fixture.invalid/api', credentials: { ADMIN: { loginId: 'fixture', password: 'private' } } } +function harness(fetcher, saveError = false) { + const state = { requests: [] }, saves = [] + const save = async patch => { if (saveError) throw Error('disk failed'); Object.assign(state,patch); saves.push(structuredClone(state)) } + return { state, saves, ctx: createFlowContext({ settings, state, save, fetcher }) } +} +test('persists intent before writes and never saves credentials or tokens', async () => { + let h + h = harness(async (_url, options) => { + assert.equal(h.state.requests.at(-1).status, 'INTENT') + return new Response(JSON.stringify({ code: '200', data: { accessToken: 'access-secret', refreshToken: 'refresh-secret' } })) + }) + await h.ctx.login('ADMIN') + await h.ctx.close() + assert.doesNotMatch(JSON.stringify(h.state), /private|access-secret|refresh-secret/) + assert.ok(h.state.requests.every(row=>row.status==='DONE')) +}) +test('disk failure prevents HTTP and unknown network result is never retried', async () => { + let calls=0 + const broken=harness(async()=>{calls++;throw Error('network')},true) + await assert.rejects(broken.ctx.request('POST','/api/platform/v1/example',{}),/disk/) + assert.equal(calls,0) + const h=harness(async()=>{calls++;throw Error('network')}) + await assert.rejects(h.ctx.request('POST','/api/platform/v1/example',{}),/结果不确定/) + assert.equal(calls,1) + assert.equal(h.state.requests[0].status,'UNKNOWN') +}) +test('business errors retain diagnostic IDs and binary responses preserve bytes',async()=>{ + const h=harness(async()=>new Response(JSON.stringify({code:'A0403',requestId:'req-1',traceId:'trace-1',message:'private'}))) + const response=await h.ctx.request('GET','/api/platform/v1/example') + assert.throws(()=>requireSuccess(response),/A0403.*req-1.*trace-1/) + assert.doesNotMatch(JSON.stringify(h.state),/private/) + const binary=harness(async()=>new Response('%PDF-fixture',{headers:{'content-type':'application/pdf'}})) + const pdf=await binary.ctx.requestBinary('POST','/api/platform/inbounds/1/carton-label-pdf') + assert.equal(Buffer.from(pdf.bytes).toString(),'%PDF-fixture') + assert.equal(pdf.contentType,'application/pdf') +}) + +test('request limit blocks business calls but permits session logout', async () => { + const h = harness(async () => new Response(JSON.stringify({ code: '200', data: {} }))) + h.ctx.registerSession({ accessToken: 'secret', refreshToken: 'refresh' }) + h.state.requests = Array.from({ length: 2500 }, (_, index) => ({ sequence: index + 1, status: 'DONE' })) + await assert.rejects(h.ctx.request('GET', '/api/platform/v1/example'), /安全上限/) + assert.deepEqual(await h.ctx.close(), []) + assert.equal(h.state.requests.at(-1).path, '/api/auth/logout') + assert.equal(h.state.requests.at(-1).status, 'DONE') +}) diff --git a/scripts/prod-release-measurement-plan.mjs b/scripts/prod-release-measurement-plan.mjs index c9ad7584c..8c2ef15ef 100644 --- a/scripts/prod-release-measurement-plan.mjs +++ b/scripts/prod-release-measurement-plan.mjs @@ -2,13 +2,13 @@ export const CARTON_PROFILE = Object.freeze({ lengthCm: 40, widthCm: 30, heightCm: 30, weightKg: 7.5 }) export function cartonTotals(packageCount) { - if (!Number.isInteger(packageCount) || packageCount < 1 || packageCount > 2) throw new Error('生产冒烟仅允许 1~2 箱') + if (!Number.isInteger(packageCount) || packageCount < 1 || packageCount > 2) throw new Error('单票冒烟仅允许 1~2 箱') return { packageCount, lengthCm: 40, widthCm: 30, heightCm: 30, grossWeightKg: packageCount * 7.5, volumeCbm: packageCount * 36 / 1000 } } export function normalizeOrderInput(input) { - if (!Array.isArray(input?.payload?.cargoes) || input.payload.cargoes.length !== 1) throw new Error('生产冒烟仅支持一个 SKU') + if (!Array.isArray(input?.payload?.cargoes) || input.payload.cargoes.length !== 1) throw new Error('单票冒烟仅支持一个 SKU') const cargo = input.payload.cargoes[0] const totals = cartonTotals(cargo.packageCount) if (cargo.netWeightKg != null && (!Number.isFinite(Number(cargo.netWeightKg)) diff --git a/scripts/prod-release-order-stage.mjs b/scripts/prod-release-order-stage.mjs index 5d54f8ea8..86260e464 100644 --- a/scripts/prod-release-order-stage.mjs +++ b/scripts/prod-release-order-stage.mjs @@ -36,6 +36,8 @@ export function validateOrderInput(input) { assert(payload.inboundCode == null && payload.senderAddressId == null && payload.privateDestinationAddressId == null, '不允许手工入仓码或地址副作用') assert(Array.isArray(payload.cargoes) && payload.cargoes.length === 1, '专属冒烟固定一个 SKU') const cargo = payload.cargoes[0] + assert(payload.shipmentId && payload.referenceId && cargo.shipmentId && cargo.referenceId, + 'FBA 订单及货物行均必须提供 Shipment ID 和 Reference ID') assert(Number.isInteger(cargo.packageCount) && cargo.packageCount >= 1 && cargo.packageCount <= 2, '冒烟仅允许 1~2 箱') assert(cargo.woodenProduct === false && cargo.dangerousGoods === false && !cargo.packingGroupNo, '仅支持非木制非危险单 SKU 货物') assert(longId(cargo.productImageFileId), '需要客户已上传产品图片,不生成虚假证明文件') @@ -58,7 +60,8 @@ function binding(input) { // The caller must retain this file between invocations. Never generate a new batch to retry a failed one. export async function withOrderJournal(path, input, action) { validateOrderInput(input) - assert(typeof input.environment === 'string' && input.environment.startsWith('https://'), '缺少环境绑定') + assert(['test', 'gray'].includes(input.environment?.mode) + && typeof input.environment.apiBase === 'string' && input.environment.apiBase.startsWith('https://'), '缺少环境绑定') await mkdir(dirname(path), { recursive: true }) const lock = await open(`${path}.lock`, 'wx', 0o600) const temp = `${path}.next` diff --git a/scripts/prod-release-order-stage.test.mjs b/scripts/prod-release-order-stage.test.mjs index afa9b0ced..f9cbcad51 100644 --- a/scripts/prod-release-order-stage.test.mjs +++ b/scripts/prod-release-order-stage.test.mjs @@ -7,15 +7,17 @@ import { fingerprint, validateOrderInput, withOrderJournal, runOrderStage, valid import { executeOrder } from './prod-release-acceptance.mjs' function fixture() { - return { batch: 'release-fixture-001', environment: 'https://fixture.invalid/api', + return { batch: 'release-fixture-001', environment: { mode: 'gray', apiBase: 'https://fixture.invalid/api' }, operator: { merchant: 'fixture-client', admin: 'fixture-admin' }, customer: { id: '1', name: 'fixture', code: 'fixture' }, origin: { id: '2', code: 'CN-YIWU-01' }, destination: { id: '3', code: 'FIXTURE', type: 'FBA' }, payload: { customerName: 'fixture', receivingWarehouseId: '2', destinationWarehouseId: '3', + shipmentId: 'SHIP-fixture', referenceId: 'REF-fixture', amazonWarehouseCode: 'FIXTURE', transportMode: 'SEA', destinationType: 'FBA', fulfillmentType: 'STANDARD', containerMode: 'LCL', pickupMethod: 'SELF_EXPRESS', channelCode: 'FIXTURE', pricingTaxMode: 'TAX_INCLUDED', customerRemark: 'RELEASE-ACCEPTANCE:release-fixture-001', cargoes: [{ skuNo: 'fixture-sku', packageCount: 2, unitsPerPackage: 1, totalQuantity: 2, + shipmentId: 'SHIP-fixture', referenceId: 'REF-fixture', lengthCm: 40, widthCm: 30, heightCm: 30, grossWeightKg: 15, volumeCbm: 0.072, productImageFileId: '4', woodenProduct: false, dangerousGoods: false }] } } } @@ -143,7 +145,7 @@ test('uncertain approval that still reads submitted must not be resent', async t test('change in environment, account, payload or batch is blocked before HTTP', async t => { const path = await sandbox(t), input = fixture(), api = server(input) await run(path, input, api) - for (const change of [i => i.environment = 'https://other.invalid/api', i => i.operator.merchant = 'other', + for (const change of [i => i.environment.apiBase = 'https://other.invalid/api', i => i.environment.mode = 'test', i => i.operator.merchant = 'other', i => i.payload.channelCode = 'OTHER', i => { i.batch = 'release-fixture-002'; i.payload.customerRemark = `RELEASE-ACCEPTANCE:${i.batch}` }]) { const other = fixture(); change(other) const count = api.calls.length @@ -184,14 +186,15 @@ test('corrupt journal is preserved and fails closed', async t => { for (const quoteMismatch of [false, true]) { test(`entry integration: customer/profile/route checks and HTTP headers (quote mismatch=${quoteMismatch})`, async t => { const path = await sandbox(t), input = fixture(), api = server(input) - const settings = { cn: 'https://fixture.invalid/api', overseas: 'https://fixture.invalid/overseas-api', customer: input.customer, + const settings = { environment: 'gray', apiBase: 'https://fixture.invalid/api', customer: input.customer, credentials: Object.fromEntries(['ADMIN', 'MERCHANT', 'CN_PDA', 'OVERSEAS_LAX_PDA'].map(role => [role, { loginId: role, password: 'secret' }])) } const fetcher = async (url, options) => { - const endpoint = new URL(url).pathname.replace(/^\/(overseas-api|api)/, '') + new URL(url).search + assert.ok(url.startsWith('https://fixture.invalid/api/')) + const endpoint = new URL(url).pathname.replace(/^\/api/, '') + new URL(url).search let data if (endpoint === '/auth/login') { const role = JSON.parse(options.body).loginId - data = { accessToken: role, overseasAccessToken: `${role}-overseas` } + data = { accessToken: role } } else if (endpoint.includes('/iam/')) { data = { id: '1', tenantCode: 'fixture', tenantName: 'fixture', status: 'ACTIVE', dimensionInitializationStatus: 'READY' } } else if (endpoint.includes('/warehouse-admin?')) { @@ -203,7 +206,7 @@ for (const quoteMismatch of [false, true]) { assert.equal(options.headers.Authorization, 'Bearer CN_PDA') data = [{ id: '2', warehouseCode: 'CN-YIWU-01' }, { id: '4', warehouseCode: 'CN-DONGGUAN-01' }] } else if (endpoint.endsWith('/warehouse-profile')) { - assert.equal(options.headers.Authorization, 'Bearer OVERSEAS_LAX_PDA-overseas') + assert.equal(options.headers.Authorization, 'Bearer OVERSEAS_LAX_PDA') data = { warehouseId: '6', warehouseCode: 'US-LAX-01' } } else if (/\/platform\/v1\/(pallets|locations)\?/.test(endpoint)) { data = { total: '1', records: [{ warehouseId: '2', status: 'AVAILABLE' }] } diff --git a/scripts/prod-release-overseas-stage.mjs b/scripts/prod-release-overseas-stage.mjs new file mode 100644 index 000000000..e936369ef --- /dev/null +++ b/scripts/prod-release-overseas-stage.mjs @@ -0,0 +1,283 @@ +import { fingerprint } from './prod-release-order-stage.mjs' +import { controlledPalletMeasurements } from './admin-test-env-modules/fleet-trip-fulfillment.mjs' + +const BASE = '/api/platform/v1/last-leg' +const PDA = '/api/pda/v1/last-leg' +const check = (ok, message) => { if (!ok) throw new Error(message) } +const id = value => value == null ? '' : String(value) +const query = (path, values) => `${path}?${new URLSearchParams(values)}` +const PNG = Buffer.from('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+aH1kAAAAASUVORK5CYII=', 'base64') + +/** Each successful command is persisted; an interrupted command must be investigated, never resent. */ +export function overseasCommands(context, checkpoint) { + const { state, save } = checkpoint + const unwrap = response => { + check(response?.http === 200 && String(response?.body?.code) === '200', + `海外 HTTP=${response?.http}; code=${response?.body?.code}; requestId=${response?.body?.requestId}; traceId=${response?.body?.traceId}`) + return response.body.data + } + async function command(name, method, path, body, token = context.token, headers = {}, file = null) { + const binding = fingerprint({ method, path, body: body ?? null, headers, file }) + const previous = state.commands?.[name] + check(!previous || previous.binding === binding, `海外命令 ${name} 与检查点不符`) + if (previous?.status === 'DONE') return previous.result + check(!previous, `海外命令 ${name} 结果未知;停止重发并保留检查点`) + const persist = async value => { const commands = { ...state.commands, [name]: value }; await save({ commands }); state.commands = commands } + await persist({ binding, status: 'INTENT' }) + let response + if (file) { + const form = new FormData() + form.append('file', new Blob([PNG], { type: 'image/png' }), file) + response = await context.requestWithBody(method, path, form, token) + } else response = await context.request(method, path, body, token, headers) + const result = unwrap(response) + await persist({ binding, status: 'DONE', result }) + return result + } + return { command, read: async (path, token = context.token) => unwrap(await context.request('GET', path, undefined, token)), + readCurrentReceivingSession: async token => { + const response = await context.request('GET', `${PDA}/receiving-containers/current`, undefined, token) + // Only the initial current-session probe treats the service's absent-session business code as empty. + // Subsequent mandatory session reads and every other resource remain strict. + if (response?.http === 200 && String(response?.body?.code) === 'A0404') return null + return unwrap(response) + }, + lookup: async (path, body) => unwrap(await context.request('POST', path, body)) } +} + +export function validateOverseasInput(input, domestic) { + check(/^[A-Za-z0-9][A-Za-z0-9_-]{7,47}$/.test(input.batch || ''), '无效验收批次') + check([1, 2].includes(input.packageCount), '海外仅支持1~2箱单票') + for (const key of ['customerId', 'warehouseId']) check(/^[1-9]\d*$/.test(input[key] || ''), `缺少 ${key}`) + for (const key of ['vehicleId', 'driverId']) check(/^[1-9]\d*$/.test(input.resources?.[key] || ''), `缺少既有 ${key}`) + check(Boolean(input.resources.carrierName), '缺少既有车队名称') + for (const key of ['containerBookingId', 'sourceInboundId', 'orderId']) check(/^[1-9]\d*$/.test(domestic[key] || ''), `缺少 ${key}`) + for (const key of ['containerNo', 'inboundCode', 'orderNo']) check(Boolean(domestic[key]), `缺少 ${key}`) + if (domestic.cartonMarks != null) check(Array.isArray(domestic.cartonMarks) + && domestic.cartonMarks.length === input.packageCount && new Set(domestic.cartonMarks).size === input.packageCount, + '本票冻结箱码数量不符') +} + +/** Normal single-ticket path only. No fixture runners, account creation, or master-data mutation. */ +export async function runOverseasStage({ input, domestic, context, checkpoint }) { + validateOverseasInput(input, domestic) + check(context.overseasPdaToken && context.merchantToken, '缺少既有海外 PDA 或本客户会话') + const { state, save } = checkpoint + check(!Object.values(state.commands || {}).some(row => row.status !== 'DONE'), '海外命令结果未知;停止重发并保留检查点') + const { read, lookup, command, readCurrentReceivingSession } = overseasCommands(context, checkpoint) + const pdaToken = context.overseasPdaToken + const remark = `RELEASE-ACCEPTANCE:${input.batch}` + const key = name => `${input.batch}-${name}` + if (!state.schedule) { + const schedule = { plannedDepartureAt: new Date(Date.now() + 3600000).toISOString(), + expectedDriverEndAt: new Date(Date.now() + 48 * 3600000).toISOString(), + arrival: new Date(Date.now() + 86400000).toISOString().slice(0, 19) } + await save({ schedule }); state.schedule = schedule + } + const schedule = state.schedule + const vehicle = await read(`${BASE}/master-data/vehicles/${input.resources.vehicleId}`) + const driver = await read(query(`${BASE}/fleet/drivers/${input.resources.driverId}`, { expectedEnd: schedule.expectedDriverEndAt })) + check(id(vehicle.id) === input.resources.vehicleId && vehicle.region === 'OVERSEAS' && vehicle.status === 'IN_SERVICE', '既有海外车辆不可用') + check(id(driver.id) === input.resources.driverId && driver.qualification?.canDispatch === true, '既有司机证照/审核不满足本次出车') + let sources + for (let attempt = 0; attempt < 60; attempt++) { + const page = await read(query(`${BASE}/orders`, { containerNo: domestic.containerNo, page: '1', size: '200' })) + sources = (page.records || []).filter(row => id(row.sourceContainerBookingId) === domestic.containerBookingId) + if (sources.length) break + if (attempt < 59) await (context.wait || (ms => new Promise(resolve => setTimeout(resolve, ms))))(1000) + } + check(sources?.length === 1 && id(sources[0].customerId) === input.customerId + && id(sources[0].sourceInboundId) === domestic.sourceInboundId && sources[0].inboundCode === domestic.inboundCode + && id(sources[0].overseasWarehouseId) === input.warehouseId, '柜内尾程来源不属于本次唯一客户/单票/接柜仓') + const tailOrderId = id(sources[0].id) + const arrivalRows = await lookup(`${BASE}/orders/container-arrival-times/query`, { sourceContainerBookingIds: [domestic.containerBookingId] }) + const arrival = arrivalRows.find(row => id(row.sourceContainerBookingId) === domestic.containerBookingId) + check(arrival && Number.isSafeInteger(Number(arrival.version)), '柜级到仓版本缺失') + if (!arrival.estimatedWarehouseArrivalAt) await command('arrival', 'PUT', `${BASE}/orders/container-summaries/${domestic.containerBookingId}/expected-arrival`, + { estimatedWarehouseArrivalAt: schedule.arrival, expectedVersion: Number(arrival.version), remark }, undefined, { 'Idempotency-Key': key('arrival') }) + let plans = state.commands?.convert?.result + if (!plans) { + const preview = await read(query(`${BASE}/inbound-plans/container-preview`, { sourceContainerBookingId: domestic.containerBookingId })) + check(preview.ready === true && Number(preview.totalOrderCount) === 1 && Number(preview.pendingOrderCount) === 1, '本票计划不可转换或存在未登记计划') + plans = await command('convert', 'POST', `${BASE}/inbound-plans/convert-container`, { + sourceContainerBookingId: domestic.containerBookingId, snapshotVersion: Number(preview.snapshotVersion), snapshotHash: preview.snapshotHash, + }, undefined, { 'Idempotency-Key': key('convert') }) + } + check(plans?.length === 1 && plans[0].lines?.length > 0 && plans[0].lines.every(line => line.inboundCode === domestic.inboundCode) + && plans[0].pallets?.length === 1, '本票入仓计划/实体托盘不唯一') + const pallet = plans[0].pallets[0] + check(pallet.palletNo && pallet.warehousePalletId, '计划未生成实体托盘') + const receiving = `${PDA}/receiving-containers` + if (!state.commands?.receiveComplete) { + const current = await readCurrentReceivingSession(pdaToken) + check(!current || current.containerNo === domestic.containerNo && current.inbounds?.length === 1 + && current.inbounds[0].inboundCode === domestic.inboundCode, '海外 PDA 正在操作其他柜') + const detailPath = query(`${receiving}/${encodeURIComponent(domestic.containerNo)}`, { sourceContainerBookingId: domestic.containerBookingId }) + const detail = await read(detailPath, pdaToken) + check(id(detail.overseasWarehouseId) === input.warehouseId && detail.inbounds?.length === 1 + && detail.inbounds[0].inboundCode === domestic.inboundCode && Number(detail.totalPackages) === input.packageCount, '收货柜身份或箱数不符') + const destinationType = detail.inbounds[0].destinationType + check(['OVERSEAS', 'FBA'].includes(destinationType), '单票不支持此收货类型') + if (destinationType === 'OVERSEAS') check(domestic.cartonMarks?.length === input.packageCount, + 'OVERSEAS逐箱收货必须提供本票正式冻结箱码') + await command('prepare', 'POST', query(`${receiving}/${encodeURIComponent(domestic.containerNo)}/prepare`, { sourceContainerBookingId: domestic.containerBookingId }), { idempotencyKey: key('prepare') }, pdaToken) + const photos = [] + for (const role of ['LICENSE_PLATE', 'CONTAINER_SEAL', 'DOOR_OPEN']) { + const uploaded = await command(`photo-${role}`, 'POST', `${receiving}/current/unload-photos/files`, undefined, pdaToken, {}, `${input.batch}-SYNTHETIC-${role}.png`) + check(/^[1-9]\d*$/.test(id(uploaded.fileId)), '验收图片未返回文件ID') + photos.push({ role, fileId: id(uploaded.fileId) }) + } + await command('sign', 'POST', `${receiving}/current/sign`, { photos, idempotencyKey: key('sign') }, pdaToken) + const resolved = await command('resolve-pallet', 'POST', `${receiving}/current/pallets/resolve`, { palletCode: pallet.palletNo, inboundCode: domestic.inboundCode }, pdaToken) + check(resolved.compatible === true, '计划托盘不兼容') + // FBA receives cumulative physical counts on its source pallet; carton resolution is not a receiving prerequisite. + for (const [index, cartonMark] of (destinationType === 'OVERSEAS' ? domestic.cartonMarks : []).entries()) { + const carton = await command(`resolve-carton-${index}`, 'POST', `${receiving}/current/cartons/resolve`, { cartonMark }, pdaToken) + check(carton.inboundCode === domestic.inboundCode, '箱码未归属本票') + await command(`receive-${index}`, 'POST', `${receiving}/current/receipts/carton`, { cartonMark, palletCode: pallet.palletNo, idempotencyKey: key(`receive-${index}`) }, pdaToken) + } + if (destinationType === 'FBA') await command('receive-bulk', 'POST', `${receiving}/current/receipts/bulk`, { + inboundCode: domestic.inboundCode, palletCode: pallet.palletNo, packageCount: input.packageCount, idempotencyKey: key('receive-bulk'), + }, pdaToken) + const received = await read(`${receiving}/current`, pdaToken) + check(Number(received.pendingPackages) === 0 && Number(received.totalPackages) === input.packageCount, '本票收货数量未核平') + await command('receiveComplete', 'POST', `${receiving}/current/complete`, { idempotencyKey: key('receive-complete') }, pdaToken) + } + if (!state.commands?.emptyReturn) { + const drayagePath = `/api/platform/v1/terminal-drayages/${domestic.containerBookingId}` + let drayage + for (let attempt = 0; attempt < 60; attempt++) { + drayage = await read(drayagePath) + if (drayage.status === 'UNLOADED' && drayage.unloadingCompletedAt) break + if (attempt < 59) await (context.wait || (ms => new Promise(resolve => setTimeout(resolve, ms))))(1000) + } + check(id(drayage.containerBookingId) === domestic.containerBookingId && drayage.status === 'UNLOADED' + && drayage.allowedActions?.includes('RECORD_EMPTY_RETURN'), '海外卸柜事实尚未回传国内码头拖车') + const unloaded = Date.parse(`${drayage.unloadingCompletedAt}Z`) + check(Number.isFinite(unloaded), '卸柜时间无效') + const returned = await command('emptyReturn', 'POST', `${drayagePath}/empty-return`, { + expectedVersion: Number(drayage.version), actualAt: new Date(unloaded + 3600000).toISOString().slice(0, 19), remark, + }, undefined, { 'Idempotency-Key': key('empty-return') }) + check(returned.status === 'EMPTY_RETURNED', '实际还柜未完成') + } + let dispatch = state.commands?.dispatch?.result + if (!dispatch) { + const candidates = await read(`${BASE}/dispatches/candidates?sourceStage=RECEIVED`) + const matches = candidates.filter(row => row.sourceStage === 'RECEIVED' && row.inboundCode === domestic.inboundCode + && id(row.customerId) === input.customerId && id(row.overseasWarehouseId) === input.warehouseId) + check(matches.length === 1 && Number(matches[0].packageCount) === input.packageCount, '本票排车候选不唯一或数量不符') + dispatch = await command('dispatch', 'POST', `${BASE}/dispatches/pre-arrange`, { planPalletIds: matches.map(row => id(row.planPalletId)) }) + } + const dispatchNo = dispatch.dispatchNo + check(Boolean(dispatchNo), '排车单号缺失') + const transport = { vehiclePlate: vehicle.plateNumber, driverName: driver.details?.name, + driverPhone: driver.details?.phone, carrierName: input.resources.carrierName, estimatedArrivalAt: schedule.arrival } + await command('dispatch-vehicle', 'PUT', `${BASE}/dispatches/${dispatchNo}`, transport) + const transfer = await command('transfer', 'POST', query('/api/merchant/v1/last-leg/delivery-orders', { dispatchNo }), { + idempotencyKey: key('transfer'), plannedDeliveryAt: schedule.arrival, deliveryMethod: 'TRUCK_PALLET', + waybillNo: key('WAYBILL'), carrierName: transport.carrierName, pickupMethod: 'WAREHOUSE_PICKUP', loadingType: 'PALLET', + vehicleType: 'TRUCK', vehicleSize: '15托', driverName: transport.driverName, driverPhone: transport.driverPhone, + vehiclePlate: transport.vehiclePlate, appointmentAccount: 'SELF', sellerName: null, remark, + fees: [{ feeType: 'DELIVERY_FEE', feeName: '派送费', direction: 'RECEIVABLE', currency: 'USD', amount: 10, remark }], + }, context.merchantToken) + check(transfer.deliveries?.length === 1 && transfer.outboundNo, '本票未形成唯一出库/派送单') + let trip = await command('trip', 'POST', `${BASE}/fleet/trips`, { warehouseId: input.warehouseId, + plannedDepartureAt: schedule.plannedDepartureAt, expectedDriverEndAt: schedule.expectedDriverEndAt, + stops: [{ name: '验收起运仓' }, { name: '待匹配本票目的地' }], remark, idempotencyKey: key('trip') }) + const tripNo = trip.trip.tripNo, tripPath = `${BASE}/fleet/trips/${tripNo}` + trip = await command('trip-assign', 'POST', `${tripPath}/assignment`, { recordVersion: trip.trip.recordVersion, + vehicleId: input.resources.vehicleId, driverId: input.resources.driverId, reason: remark }) + trip = await command('trip-bind', 'POST', `${tripPath}/dispatches/bind`, { recordVersion: trip.trip.recordVersion, dispatchNo, reason: remark }) + let stops = state.stops + if (!stops) { + const loading = await read(`${tripPath}/loading`) + check(loading.cargo?.length > 0 && loading.cargo.every(row => row.destinationKey), '本票装载缺少真实目的地') + stops = [{ name: '验收起运仓' }, ...new Map(loading.cargo.map(row => [row.destinationKey, { name: row.destinationLabel, destinationKey: row.destinationKey }])).values()] + await save({ stops }); state.stops = stops + } + trip = await command('trip-plan', 'PUT', `${tripPath}/plan`, { recordVersion: trip.trip.recordVersion, + plannedDepartureAt: schedule.plannedDepartureAt, expectedDriverEndAt: schedule.expectedDriverEndAt, stops, reason: remark }) + trip = await command('trip-review', 'POST', `${tripPath}/dispatches/${dispatchNo}/review`, { recordVersion: trip.trip.recordVersion, decision: 'APPROVED' }) + const pdaDispatch = `${PDA}/dispatches/${dispatchNo}` + if (!state.commands?.pickComplete) { + const picking = await read(pdaDispatch, pdaToken) + const exactDispatch = await read(`${BASE}/dispatches/${dispatchNo}`) + check(exactDispatch.items?.length > 0 && exactDispatch.items.every(row => row.inboundCode === domestic.inboundCode) + && picking.items?.length === exactDispatch.items.length + && picking.items.every(row => exactDispatch.items.some(item => id(item.id) === id(row.dispatchItemId))), 'PDA拣货包含其他票') + for (const item of picking.items) { + let remaining = Number(item.scheduledPackages) - Number(item.pickedPackages) + for (const position of item.positions || []) { + if (remaining <= 0) break + const packages = Math.min(remaining, Number(position.availablePackages)) + if (!(packages > 0)) continue + await command(`pick-${item.dispatchItemId}-${position.id}`, 'POST', `${pdaDispatch}/items/${item.dispatchItemId}/pick`, { + positionId: id(position.id), packages, productQuantity: packages, idempotencyKey: key(`pick-${position.id}`), remark, + }, pdaToken) + remaining -= packages + } + check(remaining === 0, '本票库存不足以完成拣货') + } + await command('pickComplete', 'POST', `${pdaDispatch}/complete`, { idempotencyKey: key('pick-complete') }, pdaToken) + } + let loadingResult = state.commands?.loading?.result + if (!loadingResult) { + const loading = await read(`${tripPath}/loading`) + check(!loading.blockReason, '整车装载未就绪') + loadingResult = await command('loading', 'POST', `${tripPath}/loading`, { recordVersion: loading.recordVersion, + manifestHash: loading.manifestHash, pallets: controlledPalletMeasurements(loading.cargo, stops), reason: `${remark} 合成验收测量` }) + } + const reviewed = await command('loading-review', 'POST', `${tripPath}/loading-review`, { recordVersion: loadingResult.recordVersion, decision: 'APPROVED' }) + if (!state.commands?.depart) { + const ready = await read(pdaDispatch, pdaToken) + check(ready.status === 'COMPLETED' && ready.canDepart === true, 'PDA发车门禁未通过') + } + await command('depart', 'POST', `${pdaDispatch}/depart`, { recordVersion: reviewed.trip.recordVersion, idempotencyKey: key('depart'), remark }, pdaToken) + const outbound = await read(`${BASE}/outbound-orders/${transfer.outboundNo}`) + check(outbound.outbound?.status === 'OUTBOUNDED' && outbound.pallets?.length > 0 + && outbound.pallets.every(row => row.inboundCode === domestic.inboundCode), '本票未真实完成出库') + const result = { ...domestic, batch: input.batch, packageCount: input.packageCount, warehouseId: input.warehouseId, + tailOrderId, dispatchNo, outboundNo: transfer.outboundNo, deliveryNo: transfer.deliveries[0].deliveryNo, + tripNo, status: 'OVERSEAS_OUTBOUNDED', fullFlowVerified: false, photos: 'SYNTHETIC_ACCEPTANCE_ONLY' } + await save({ result }); return result +} + +/** Close only this ticket's trip after delivery; release its two resource claims without creating probe trips. */ +export async function finishSingleOrderTrip({ input, overseas, context, checkpoint }) { + check(overseas?.tripNo && overseas?.dispatchNo && overseas?.deliveryNo, '缺少本票车次与派送身份') + check(!Object.values(checkpoint.state.commands || {}).some(row => row.status !== 'DONE'), '车次结案命令结果未知;停止重发') + const { read, command } = overseasCommands(context, checkpoint) + const path = `${BASE}/fleet/trips/${encodeURIComponent(overseas.tripNo)}` + const delivery = await read(query(`${BASE}/delivery-orders/${encodeURIComponent(overseas.deliveryNo)}`, { customerId: input.customerId })) + check(delivery.order?.status === 'DELIVERED' && id(delivery.order.customerId) === input.customerId + && delivery.order.dispatchNo === overseas.dispatchNo, '本票尚未完成签收,不能结案车次') + const detail = await read(path) + check(detail.trip?.tripNo === overseas.tripNo && id(detail.trip.vehicleId) === input.resources.vehicleId + && id(detail.trip.driverId) === input.resources.driverId && detail.dispatches?.length === 1 + && detail.dispatches[0].dispatchNo === overseas.dispatchNo, '车次含其他票或资源身份不符') + let completed = checkpoint.state.commands?.tripComplete?.result + if (!completed) { + const completion = await read(`${path}/completion`) + check(completion.canComplete === true && completion.outcomes?.length > 0, '车次结案条件未通过') + completed = await command('tripComplete', 'POST', `${path}/complete`, { recordVersion: completion.recordVersion, + idempotencyKey: `${input.batch}-trip-complete`, reason: '单票验收实际签收完成' }) + } + check(completed.trip?.status === 'COMPLETED' && completed.trip.completedAt, '车次货物未结案') + const endLocation = detail.trip.stops?.at(-1)?.name + check(Boolean(endLocation), '本车次缺少结束地点') + const vehicle = await command('releaseVehicle', 'POST', `${path}/resources/release`, { + recordVersion: completed.trip.recordVersion, resourceKind: 'VEHICLE', actualEndAt: completed.trip.completedAt, + endLocation, vehicleStatus: 'IN_SERVICE', idempotencyKey: `${input.batch}-vehicle-release`, + reason: '单票受控验收车辆在签收末站结束,无返程安排', + }) + const driver = await command('releaseDriver', 'POST', `${path}/resources/release`, { + recordVersion: vehicle.trip.recordVersion, resourceKind: 'DRIVER', actualEndAt: completed.trip.completedAt, + endLocation, vehicleStatus: null, idempotencyKey: `${input.batch}-driver-release`, + reason: '单票受控验收司机在签收末站结束,无返程安排', + }) + check(driver.trip?.vehicleRelease && driver.trip?.driverRelease, '本车次车辆或司机未释放') + const final = await read(path) + check(final.trip?.status === 'COMPLETED' && final.trip.vehicleRelease && final.trip.driverRelease, '车次结案回查未通过') + const result = { tripNo: overseas.tripNo, status: 'CARGO_COMPLETED_AND_RESOURCES_RELEASED' } + await checkpoint.save({ result }); return result +} diff --git a/scripts/prod-release-overseas-stage.test.mjs b/scripts/prod-release-overseas-stage.test.mjs new file mode 100644 index 000000000..80cbf4dd6 --- /dev/null +++ b/scripts/prod-release-overseas-stage.test.mjs @@ -0,0 +1,158 @@ +import test from 'node:test' +import assert from 'node:assert/strict' +import { overseasCommands, runOverseasStage, validateOverseasInput, finishSingleOrderTrip } from './prod-release-overseas-stage.mjs' + +const input = { batch: 'release-test-001', customerId: '1', warehouseId: '2', packageCount: 1, + resources: { vehicleId: '3', driverId: '4', carrierName: '验收既有车队' } } +const domestic = { containerBookingId: '5', sourceInboundId: '6', orderId: '7', containerNo: 'TEST0000001', + inboundCode: 'IN-TEST', orderNo: 'ORDER-TEST', cartonMarks: ['CARTON-TEST'] } +const ok = data => ({ http: 200, body: { code: '200', data } }) +const journal = () => { const state = {}; return { state, save: async patch => Object.assign(state, patch) } } + +test('reject multi-ticket quantities and duplicate frozen cartons before HTTP', () => { + assert.throws(() => validateOverseasInput({ ...input, packageCount: 3 }, domestic), /1~2/) + assert.throws(() => validateOverseasInput({ ...input, packageCount: 2 }, { ...domestic, cartonMarks: ['a', 'a'] }), /箱码/) +}) + +test('command records intent before HTTP; uncertain result blocks resend', async () => { + const checkpoint = journal(); let calls = 0 + const { command } = overseasCommands({ request: async () => { + calls++; assert.equal(checkpoint.state.commands.create.status, 'INTENT'); throw new Error('timeout') + } }, checkpoint) + await assert.rejects(command('create', 'POST', '/example', { x: 1 }), /timeout/) + await assert.rejects(command('create', 'POST', '/example', { x: 1 }), /结果未知/) + assert.equal(calls, 1) +}) + +test('completed command restores result without HTTP and rejects changed request', async () => { + const checkpoint = journal(); let calls = 0 + const { command } = overseasCommands({ request: async () => { calls++; return ok({ id: '99' }) } }, checkpoint) + await command('create', 'POST', '/example', { x: 1 }) + assert.deepEqual(await command('create', 'POST', '/example', { x: 1 }), { id: '99' }) + await assert.rejects(command('create', 'POST', '/example', { x: 2 }), /不符/) + assert.equal(calls, 1) +}) + +test('unknown prior command blocks even read/other writes on continuation', async () => { + const checkpoint = journal(); checkpoint.state.commands = { lost: { status: 'INTENT' } } + await assert.rejects(runOverseasStage({ input, domestic, checkpoint, + context: { overseasPdaToken: 'pda', merchantToken: 'merchant', request: () => assert.fail('must not call HTTP') } }), /结果未知/) +}) + +test('only initial current receiving probe accepts HTTP 200 A0404 as no session', async () => { + const calls = [] + const helpers = overseasCommands({ request: async (method, path, body, token) => { + calls.push({ method, path, token }); return { http: 200, body: { code: 'A0404' } } + } }, journal()) + assert.equal(await helpers.readCurrentReceivingSession('pda'), null) + assert.deepEqual(calls[0], { method: 'GET', path: '/api/pda/v1/last-leg/receiving-containers/current', token: 'pda' }) + await assert.rejects(helpers.read('/api/pda/v1/last-leg/receiving-containers/current', 'pda'), /A0404/) + await assert.rejects(helpers.read('/api/platform/v1/last-leg/orders/1'), /A0404/) +}) + +test('current receiving probe preserves auth, transport and other business errors', async () => { + for (const [http, code] of [[200, 'A0403'], [200, 'A0500'], [401, 'A0404'], [404, 'A0404'], [503, 'A0404']]) { + const helpers = overseasCommands({ request: async () => ({ http, body: { code, requestId: 'request-test' } }) }, journal()) + await assert.rejects(helpers.readCurrentReceivingSession('pda'), /request-test/) + } +}) + +function fixture({ foreignSource = false, destinationType = 'OVERSEAS' } = {}) { + const checkpoint = journal(), calls = [] + const session = { containerNo: domestic.containerNo, overseasWarehouseId: '2', totalPackages: 1, pendingPackages: 0, + inbounds: [{ inboundCode: domestic.inboundCode, destinationType }] } + const trip = { trip: { tripNo: 'TRIP-TEST', recordVersion: 1, stops: [] } } + const cargo = [{ warehousePalletId: '9', palletNo: 'PLT-TEST', destinationKey: 'DEST', destinationLabel: 'Destination' }] + const context = { token: 'admin', overseasPdaToken: 'pda', merchantToken: 'merchant', wait: async () => {}, + requestWithBody: async (method, path, form, token) => { + calls.push({ method, path, token }); assert.equal(token, 'pda'); assert.ok(form instanceof FormData); return ok({ fileId: '10' }) + }, request: async (method, path, body, token) => { + calls.push({ method, path, body, token }) + if (path.includes('/master-data/vehicles/')) return ok({ id: '3', region: 'OVERSEAS', status: 'IN_SERVICE', plateNumber: 'TEST' }) + if (path.includes('/fleet/drivers/')) return ok({ id: '4', qualification: { canDispatch: true }, details: { name: 'Driver', phone: '10000000000' } }) + if (path.includes('/orders?containerNo=')) return ok({ records: [{ sourceContainerBookingId: '5', customerId: foreignSource ? '99' : '1', sourceInboundId: '6', + inboundCode: domestic.inboundCode, overseasWarehouseId: '2', id: '8' }] }) + if (path.endsWith('/container-arrival-times/query')) return ok([{ sourceContainerBookingId: '5', version: 0 }]) + if (path.includes('/container-preview?')) return ok({ ready: true, totalOrderCount: 1, pendingOrderCount: 1, snapshotVersion: 1, snapshotHash: 'hash' }) + if (path.endsWith('/convert-container')) return ok([{ lines: [{ inboundCode: domestic.inboundCode }], pallets: [{ palletNo: 'PLT-TEST', warehousePalletId: '9' }] }]) + if (path.endsWith('/current') && method === 'GET') return calls.filter(c => c.path.endsWith('/current')).length === 1 + ? { http: 200, body: { code: 'A0404' } } : ok(session) + if (path.includes('/receiving-containers/TEST0000001?')) return ok(session) + if (path.endsWith('/pallets/resolve')) return ok({ compatible: true }) + if (path.endsWith('/cartons/resolve')) return ok({ inboundCode: domestic.inboundCode }) + if (path.endsWith('/terminal-drayages/5')) return ok({ containerBookingId: '5', status: 'UNLOADED', + version: 1, unloadingCompletedAt: '2026-10-12T12:00:00', allowedActions: ['RECORD_EMPTY_RETURN'] }) + if (path.endsWith('/empty-return')) return ok({ status: 'EMPTY_RETURNED' }) + if (path.endsWith('/candidates?sourceStage=RECEIVED')) return ok([{ sourceStage: 'RECEIVED', inboundCode: domestic.inboundCode, + customerId: '1', overseasWarehouseId: '2', packageCount: 1, planPalletId: '11' }]) + if (path.endsWith('/pre-arrange')) return ok({ dispatchNo: 'DISPATCH-TEST' }) + if (path.includes('/merchant/v1/last-leg/delivery-orders?')) return ok({ outboundNo: 'OUT-TEST', deliveries: [{ deliveryNo: 'DELIVERY-TEST' }] }) + if (path.endsWith('/loading') && method === 'GET') return ok({ cargo, recordVersion: 1, manifestHash: 'manifest' }) + if (path.endsWith('/loading') && method === 'POST') return ok({ recordVersion: 2 }) + if (path.includes('/fleet/trips')) return ok(trip) + if (path === '/api/pda/v1/last-leg/dispatches/DISPATCH-TEST') return ok({ status: 'COMPLETED', canDepart: true, + items: [{ dispatchItemId: '12', scheduledPackages: 1, pickedPackages: 0, positions: [{ id: '13', availablePackages: 1 }] }] }) + if (path === '/api/platform/v1/last-leg/dispatches/DISPATCH-TEST' && method === 'GET') return ok({ items: [{ id: '12', inboundCode: domestic.inboundCode }] }) + if (path.includes('/outbound-orders/')) return ok({ outbound: { status: 'OUTBOUNDED' }, pallets: [{ inboundCode: domestic.inboundCode }] }) + if (method === 'GET') assert.fail(`unexpected GET ${path}`) + return ok({}) + } } + return { input, domestic, context, checkpoint, calls } +} + +test('normal single-ticket HTTP chain reaches outbound with existing master resources and PDA identity', async () => { + const f = fixture(), result = await runOverseasStage(f) + assert.equal(result.status, 'OVERSEAS_OUTBOUNDED') + assert.equal(result.fullFlowVerified, false) + assert.equal(result.deliveryNo, 'DELIVERY-TEST') + assert.ok(f.calls.some(row => row.path.endsWith('/receipts/carton') && row.token === 'pda')) + assert.ok(f.calls.some(row => row.path.endsWith('/depart') && row.token === 'pda')) + assert.equal(f.calls.filter(row => row.path.endsWith('/terminal-drayages/5')).length, 1) + assert.equal(f.calls.filter(row => row.path.endsWith('/empty-return')).length, 1) + assert.equal(f.calls.filter(row => row.method !== 'GET' && /\/master-data\/|\/fleet\/drivers|\/iam\//.test(row.path)).length, 0) + assert.ok(Object.values(f.checkpoint.state.commands).every(row => row.status === 'DONE')) +}) + +test('foreign customer source stops before plan conversion or receiving', async () => { + const f = fixture({ foreignSource: true }) + await assert.rejects(runOverseasStage(f), /来源不属于/) + assert.equal(f.calls.filter(row => row.method !== 'GET').length, 0) +}) + +test('FBA receives cumulative package count without fabricated or resolved carton marks', async () => { + const f = fixture({ destinationType: 'FBA' }) + f.domestic = { ...f.domestic }; delete f.domestic.cartonMarks + const result = await runOverseasStage(f) + assert.equal(result.status, 'OVERSEAS_OUTBOUNDED') + const received = f.calls.filter(row => row.path.endsWith('/receipts/bulk')) + assert.equal(received.length, 1) + assert.equal(received[0].body.packageCount, 1) + assert.equal(f.calls.filter(row => /\/cartons\/resolve|\/receipts\/carton/.test(row.path)).length, 0) +}) + +test('OVERSEAS missing frozen carton marks fails before opening receiving session', async () => { + const f = fixture(); f.domestic = { ...f.domestic }; delete f.domestic.cartonMarks + await assert.rejects(runOverseasStage(f), /正式冻结箱码/) + assert.equal(f.calls.filter(row => /\/prepare\?|\/current\/sign|\/receipts\//.test(row.path)).length, 0) +}) + +test('signed single ticket closes its trip and releases only its existing resources', async () => { + const checkpoint = journal(), writes = [] + const overseas = { tripNo: 'TRIP-TEST', dispatchNo: 'DISPATCH-TEST', deliveryNo: 'DELIVERY-TEST' } + let trip = { tripNo: overseas.tripNo, vehicleId: '3', driverId: '4', recordVersion: 1, + stops: [{ name: 'Origin' }, { name: 'Destination' }] } + const context = { request: async (method, path, body) => { + if (path.includes('/delivery-orders/')) return ok({ order: { customerId: '1', status: 'DELIVERED', dispatchNo: overseas.dispatchNo } }) + if (path.endsWith('/completion')) return ok({ canComplete: true, recordVersion: 1, outcomes: [{}] }) + if (method === 'POST') { + writes.push({ path, body }); trip = { ...trip, recordVersion: trip.recordVersion + 1 } + if (path.endsWith('/complete')) trip = { ...trip, status: 'COMPLETED', completedAt: '2026-10-12T12:00:00Z' } + else if (body.resourceKind === 'VEHICLE') trip.vehicleRelease = { ended: true } + else trip.driverRelease = { ended: true } + } + return ok({ trip, dispatches: [{ dispatchNo: overseas.dispatchNo }] }) + } } + assert.equal((await finishSingleOrderTrip({ input, overseas, context, checkpoint })).status, 'CARGO_COMPLETED_AND_RESOURCES_RELEASED') + assert.equal(writes.length, 3) + assert.deepEqual(writes.map(row => row.body.recordVersion), [1, 2, 3]) +})