fix: complete single-order acceptance and add Lingxing WMS integration

This commit is contained in:
2026-10-12 16:09:21 +08:00
parent 6c4ee2bfde
commit c06e788b0f
27 changed files with 2305 additions and 46 deletions
@@ -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() + "]";
}
}
@@ -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<PageResult> 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<ProviderFailure> 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) { }
}
@@ -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<OrderSummary> records, Long total, int current, int size,
int sourceCode, Instant observedAt) {
public PageResult { records = List.copyOf(records); }
}
}
@@ -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<Integer> 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<String, Object> 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<String> 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<String> 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<OrderSummary> 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);
}
}
@@ -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; }
}
@@ -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<String, ?> 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]"; }
}
}