From ab68334af3b06352a2466438fa1bc5125261544e Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Tue, 22 Sep 2026 11:44:06 +0800 Subject: [PATCH] feat: add read-only lingxing connection test --- config/nacos/cn/firstleg-service.yml | 10 ++ config/nacos/local/firstleg-service.yml | 10 ++ .../config/LingxingSyncProperties.java | 14 +++ .../PlatformMojiaSkuSyncController.java | 12 ++ .../baiyue/firstleg/dto/LingxingTestDtos.java | 17 +++ .../lingxing/LingxingDirectClient.java | 106 ++++++++++++++++++ 6 files changed, 169 insertions(+) create mode 100644 firstleg/src/main/java/com/baiyue/firstleg/dto/LingxingTestDtos.java create mode 100644 firstleg/src/main/java/com/baiyue/firstleg/lingxing/LingxingDirectClient.java diff --git a/config/nacos/cn/firstleg-service.yml b/config/nacos/cn/firstleg-service.yml index f5c3ebf8f..1f463b417 100644 --- a/config/nacos/cn/firstleg-service.yml +++ b/config/nacos/cn/firstleg-service.yml @@ -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} diff --git a/config/nacos/local/firstleg-service.yml b/config/nacos/local/firstleg-service.yml index 792fe4eab..80a026f62 100644 --- a/config/nacos/local/firstleg-service.yml +++ b/config/nacos/local/firstleg-service.yml @@ -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} diff --git a/firstleg/src/main/java/com/baiyue/firstleg/config/LingxingSyncProperties.java b/firstleg/src/main/java/com/baiyue/firstleg/config/LingxingSyncProperties.java index 1f865c910..385834105 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/config/LingxingSyncProperties.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/config/LingxingSyncProperties.java @@ -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 warehouses = new ArrayList<>(List.of("US01", "US02")); } diff --git a/firstleg/src/main/java/com/baiyue/firstleg/controller/PlatformMojiaSkuSyncController.java b/firstleg/src/main/java/com/baiyue/firstleg/controller/PlatformMojiaSkuSyncController.java index 222c36b9c..92ad3cd80 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/controller/PlatformMojiaSkuSyncController.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/controller/PlatformMojiaSkuSyncController.java @@ -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 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测量数据") diff --git a/firstleg/src/main/java/com/baiyue/firstleg/dto/LingxingTestDtos.java b/firstleg/src/main/java/com/baiyue/firstleg/dto/LingxingTestDtos.java new file mode 100644 index 000000000..49244d86d --- /dev/null +++ b/firstleg/src/main/java/com/baiyue/firstleg/dto/LingxingTestDtos.java @@ -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 warehouses) {} +} diff --git a/firstleg/src/main/java/com/baiyue/firstleg/lingxing/LingxingDirectClient.java b/firstleg/src/main/java/com/baiyue/firstleg/lingxing/LingxingDirectClient.java new file mode 100644 index 000000000..b3b81ca06 --- /dev/null +++ b/firstleg/src/main/java/com/baiyue/firstleg/lingxing/LingxingDirectClient.java @@ -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 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 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 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; } +}