feat: add read-only lingxing connection test

This commit is contained in:
2026-09-22 11:44:06 +08:00
parent 212262e300
commit ab68334af3
6 changed files with 169 additions and 0 deletions
+10
View File
@@ -38,6 +38,16 @@ firstleg:
lingxing:
# 安全默认关闭;开启后自动任务才允许调用领星新品维护接口。
auto-sync-enabled: ${LINGXING_AUTO_SYNC_ENABLED:false}
base-url: ${LINGXING_DIRECT_BASE_URL:https://wms.xlwms.com}
login-origin: ${LINGXING_LOGIN_ORIGIN:https://oms.xlwms.com}
provider-key: ${LINGXING_PROVIDER_KEY:LINGXING_01}
browser-cdp-endpoint: ${LINGXING_BROWSER_CDP_ENDPOINT:}
browser-session-required: ${LINGXING_BROWSER_SESSION_REQUIRED:true}
token-ttl-seconds: ${LINGXING_TOKEN_TTL_SECONDS:172800}
token-expiry-safety-seconds: ${LINGXING_TOKEN_EXPIRY_SAFETY_SECONDS:300}
track-key-v2-secret-base64: ${LINGXING_TRACK_KEY_V2_SECRET_BASE64:}
request-timeout: ${LINGXING_REQUEST_TIMEOUT:30s}
warehouses: [US01, US02]
inbound:
allow-rejected-measurement: false
default-pallet-capacity-cbm: ${DEFAULT_PALLET_CAPACITY_CBM:1.200000}
+10
View File
@@ -24,6 +24,16 @@ baiyue.carrier-schedule.scheduler.lease-duration: ${CARRIER_SCHEDULE_LEASE_DURAT
firstleg:
lingxing:
auto-sync-enabled: ${LINGXING_AUTO_SYNC_ENABLED:false}
base-url: ${LINGXING_DIRECT_BASE_URL:https://wms.xlwms.com}
login-origin: ${LINGXING_LOGIN_ORIGIN:https://oms.xlwms.com}
provider-key: ${LINGXING_PROVIDER_KEY:LINGXING_01}
browser-cdp-endpoint: ${LINGXING_BROWSER_CDP_ENDPOINT:}
browser-session-required: ${LINGXING_BROWSER_SESSION_REQUIRED:true}
token-ttl-seconds: ${LINGXING_TOKEN_TTL_SECONDS:172800}
token-expiry-safety-seconds: ${LINGXING_TOKEN_EXPIRY_SAFETY_SECONDS:300}
track-key-v2-secret-base64: ${LINGXING_TRACK_KEY_V2_SECRET_BASE64:}
request-timeout: ${LINGXING_REQUEST_TIMEOUT:30s}
warehouses: [US01, US02]
inbound:
allow-rejected-measurement: false
default-pallet-capacity-cbm: ${DEFAULT_PALLET_CAPACITY_CBM:1.200000}
@@ -6,6 +6,10 @@ import org.springframework.cloud.context.config.annotation.RefreshScope;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.stereotype.Component;
import java.time.Duration;
import java.util.ArrayList;
import java.util.List;
/** 领星新品自动同步策略;默认关闭,开关由 Nacos 注入。 */
@Getter
@Setter
@@ -15,4 +19,14 @@ import org.springframework.stereotype.Component;
public class LingxingSyncProperties {
/** 安全默认值:墨家数据只入库,必须人工点击推送。 */
private boolean autoSyncEnabled = false;
private String baseUrl = "https://wms.xlwms.com";
private String loginOrigin = "https://oms.xlwms.com";
private String providerKey = "LINGXING_01";
private String browserCdpEndpoint;
private boolean browserSessionRequired = true;
private long tokenTtlSeconds = 172800;
private long tokenExpirySafetySeconds = 300;
private String trackKeyV2SecretBase64;
private Duration requestTimeout = Duration.ofSeconds(30);
private List<String> warehouses = new ArrayList<>(List.of("US01", "US02"));
}
@@ -3,6 +3,8 @@ package com.baiyue.firstleg.controller;
import com.baiyue.common.core.api.ApiResponse;
import com.baiyue.common.core.api.PageResult;
import com.baiyue.firstleg.dto.MojiaSkuSyncDtos;
import com.baiyue.firstleg.dto.LingxingTestDtos;
import com.baiyue.firstleg.lingxing.LingxingDirectClient;
import com.baiyue.firstleg.service.FirstlegScopeGuard;
import com.baiyue.firstleg.service.MojiaSkuSyncService;
import io.swagger.v3.oas.annotations.Operation;
@@ -21,6 +23,7 @@ import com.baiyue.common.security.AuthenticatedUser;
@RequestMapping("/api/platform/v1/firstleg/mojia-sku-sync")
public class PlatformMojiaSkuSyncController {
@Resource private MojiaSkuSyncService service;
@Resource private LingxingDirectClient lingxing;
@GetMapping
@PreAuthorize("hasAuthority('platform:mojia-sku-sync:list')")
@@ -43,6 +46,15 @@ public class PlatformMojiaSkuSyncController {
return ApiResponse.success(service.config());
}
@PostMapping("/test-connection")
@PreAuthorize("hasAuthority('platform:mojia-sku-sync:list')")
@Operation(summary = "测试领星连接并查询 SKU")
public ApiResponse<LingxingTestDtos.Response> testConnection(
@AuthenticationPrincipal AuthenticatedUser user, @Valid @RequestBody LingxingTestDtos.Request request) {
FirstlegScopeGuard.platform(user);
return ApiResponse.success(lingxing.test(request.skuCode()));
}
@PutMapping("/{id}")
@PreAuthorize("hasAuthority('platform:mojia-sku-sync:edit')")
@Operation(summary = "编辑墨家一件代发SKU测量数据")
@@ -0,0 +1,17 @@
package com.baiyue.firstleg.dto;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.Size;
import java.math.BigDecimal;
import java.util.List;
public final class LingxingTestDtos {
private LingxingTestDtos() {}
public record Request(@NotBlank @Size(max = 128) String skuCode) {}
public record WarehouseResult(String whCode, boolean reachable, boolean matched,
boolean maintained, String productSku, String barcode,
BigDecimal length, BigDecimal width, BigDecimal height,
BigDecimal weight, String message) {}
public record Response(String skuCode, List<WarehouseResult> warehouses) {}
}
@@ -0,0 +1,106 @@
package com.baiyue.firstleg.lingxing;
import com.baiyue.firstleg.config.LingxingSyncProperties;
import com.baiyue.firstleg.dto.LingxingTestDtos;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.node.ObjectNode;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Component;
import javax.crypto.Mac;
import javax.crypto.spec.SecretKeySpec;
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.util.ArrayList;
import java.util.Base64;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.TreeMap;
/** 领星新品查询只读客户端。不会调用 /measure。 */
@Component
public class LingxingDirectClient {
private final ObjectMapper mapper;
private final LingxingAuthManager authManager;
private final LingxingSyncProperties properties;
private final HttpClient http;
@Autowired
public LingxingDirectClient(ObjectMapper mapper, LingxingAuthManager authManager,
LingxingSyncProperties properties) {
this.mapper = mapper; this.authManager = authManager; this.properties = properties;
this.http = HttpClient.newBuilder().connectTimeout(properties.getRequestTimeout()).build();
}
public LingxingTestDtos.Response test(String skuCode) {
String sku = skuCode == null ? "" : skuCode.trim();
if (sku.isBlank()) throw new IllegalArgumentException("SKU不能为空");
LingxingAuthState auth = authManager.getAuthState(new LingxingProviderConfig(
properties.getProviderKey(), properties.getBrowserCdpEndpoint(), properties.isBrowserSessionRequired(),
properties.getTokenTtlSeconds(), properties.getTokenExpirySafetySeconds()), properties.getLoginOrigin()).join();
List<LingxingTestDtos.WarehouseResult> results = new ArrayList<>();
for (String whCode : properties.getWarehouses()) results.add(query(auth, sku, whCode));
return new LingxingTestDtos.Response(sku, results);
}
private LingxingTestDtos.WarehouseResult query(LingxingAuthState auth, String sku, String whCode) {
try {
ObjectNode body = mapper.createObjectNode().put("barcode", sku).put("current", 1)
.put("customerCode", "").put("keyword", sku).put("multiKeyword", "")
.put("searchType", "barcode").put("size", 20).put("whCode", whCode);
String json = canonical(body);
HttpRequest request = HttpRequest.newBuilder(URI.create(strip(properties.getBaseUrl()) + "/gateway/wms/newProduct/page"))
.timeout(properties.getRequestTimeout()).header("Content-Type", "application/json")
.header("Authorization", "Bearer " + auth.token()).header("Cookie", auth.cookies())
.header("Origin", properties.getLoginOrigin()).header("Referer", strip(properties.getLoginOrigin()) + "/")
.header("lang", "zh").header("version", "prod")
.header("Track-Key", trackKey("POST", json)).POST(HttpRequest.BodyPublishers.ofString(json)).build();
HttpResponse<String> response = http.send(request, HttpResponse.BodyHandlers.ofString());
JsonNode root = mapper.readTree(response.body());
if (response.statusCode() == 401) {
authManager.invalidate(properties.getProviderKey(), "lingxing_test_unauthorized");
return new LingxingTestDtos.WarehouseResult(whCode, false, false, false, null, null, null, null, null, null, "领星登录态已失效,请重新登录");
}
if (root.path("code").asInt(-1) != 200) return empty(whCode, false, "领星返回业务失败:" + root.path("msg").asText("未知错误"));
JsonNode records = root.path("data").path("records");
for (JsonNode record : records) {
if (sku.equals(record.path("productSku").asText()) || sku.equals(record.path("barcode").asText())) {
return new LingxingTestDtos.WarehouseResult(whCode, true, true, record.path("maintained").asInt(0) == 1,
text(record,"productSku"), text(record,"barcode"), decimal(record,"wmsLength"), decimal(record,"wmsWidth"),
decimal(record,"wmsHeight"), decimal(record,"wmsWeight"), "查询成功");
}
}
return empty(whCode, true, "未匹配到领星 SKU");
} catch (Exception ex) {
return empty(whCode, false, "查询领星失败:" + ex.getMessage());
}
}
private LingxingTestDtos.WarehouseResult empty(String whCode, boolean reachable, String message) {
return new LingxingTestDtos.WarehouseResult(whCode, reachable, false, false, null, null, null, null, null, null, message);
}
private static String text(JsonNode node, String key) { return node.path(key).isMissingNode() ? null : node.path(key).asText(null); }
private static java.math.BigDecimal decimal(JsonNode node, String key) { return node.path(key).isNumber() ? node.path(key).decimalValue() : null; }
private String trackKey(String method, String json) {
if (properties.getTrackKeyV2SecretBase64() == null || properties.getTrackKeyV2SecretBase64().isBlank()) throw new IllegalStateException("未配置领星 Track-Key v2 密钥");
try {
byte[] digest = MessageDigest.getInstance("SHA-256").digest(json.getBytes(StandardCharsets.UTF_8));
StringBuilder hex = new StringBuilder(); for (byte b : digest) hex.append(String.format(Locale.ROOT, "%02x", b));
Mac mac = Mac.getInstance("HmacSHA256"); mac.init(new SecretKeySpec(Base64.getDecoder().decode(properties.getTrackKeyV2SecretBase64()), "HmacSHA256"));
return "v2:" + Base64.getEncoder().encodeToString(mac.doFinal((method.toUpperCase(Locale.ROOT) + hex).getBytes(StandardCharsets.UTF_8)));
} catch (Exception ex) { throw new IllegalStateException("领星 Track-Key 生成失败", ex); }
}
private String canonical(JsonNode node) throws Exception { return mapper.writeValueAsString(sort(node)); }
private JsonNode sort(JsonNode node) {
if (!node.isObject()) return node;
ObjectNode out = mapper.createObjectNode(); Map<String, JsonNode> ordered = new TreeMap<>(); node.fields().forEachRemaining(e -> ordered.put(e.getKey(), e.getValue()));
ordered.forEach((key, value) -> out.set(key, sort(value))); return out;
}
private static String strip(String value) { return value.endsWith("/") ? value.substring(0, value.length() - 1) : value; }
}