fix: complete single-order acceptance and add Lingxing WMS integration
This commit is contained in:
@@ -0,0 +1,3 @@
|
||||
# Copy to .env.local for local WMS connectivity verification.
|
||||
LINGXING_WMS_APP_KEY=
|
||||
LINGXING_WMS_APP_SECRET=
|
||||
+49
@@ -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() + "]";
|
||||
}
|
||||
}
|
||||
+55
@@ -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) { }
|
||||
}
|
||||
+25
@@ -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); }
|
||||
}
|
||||
}
|
||||
+263
@@ -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);
|
||||
}
|
||||
}
|
||||
+24
@@ -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; }
|
||||
}
|
||||
+74
@@ -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]"; }
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
@@ -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:}
|
||||
|
||||
@@ -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}
|
||||
+100
@@ -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());
|
||||
}
|
||||
}
|
||||
+261
@@ -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<String> responseBody = new AtomicReference<>(EMPTY);
|
||||
private final AtomicReference<String> receivedBody = new AtomicReference<>();
|
||||
private final AtomicReference<URI> receivedUri = new AtomicReference<>();
|
||||
private final AtomicReference<String> 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 = {
|
||||
{"<html>" + PRIVATE_DATA + "</html>", "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)));
|
||||
}
|
||||
}
|
||||
+91
@@ -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<String, Object>();
|
||||
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<String>();
|
||||
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<String, Object>();
|
||||
child.put("z", "中文");
|
||||
child.put("A", 2);
|
||||
var data = new LinkedHashMap<String, Object>();
|
||||
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)));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user