From 03374c83f641835831cff2ef8c3267a96e5fa674 Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Mon, 21 Sep 2026 21:20:32 +0800 Subject: [PATCH] feat: prepare lingxing browser auth and sku measurement inbox --- deploy/sql/头程数据库统一增量-MySQL8.sql | 14 +- docs/lingxing-browser-dev-test.md | 50 +++++++ .../OpenApiInboundMeasurementController.java | 2 +- .../firstleg/dto/MojiaMeasurementDtos.java | 2 + .../entity/MojiaMeasurementReceiptEntity.java | 8 ++ .../lingxing/LingxingAuthManager.java | 76 +++++++++++ .../firstleg/lingxing/LingxingAuthState.java | 9 ++ .../LingxingBrowserSessionReader.java | 129 ++++++++++++++++++ .../lingxing/LingxingProviderConfig.java | 12 ++ ...ingxingSecondaryVerificationException.java | 5 + .../MojiaMeasurementOpenApiService.java | 43 ++++++ .../resources/db/phase2-inbound-schema.sql | 13 +- .../lingxing/LingxingAuthManagerTest.java | 54 ++++++++ .../MojiaMeasurementOpenApiServiceTest.java | 38 +++++- 14 files changed, 446 insertions(+), 9 deletions(-) create mode 100644 docs/lingxing-browser-dev-test.md create mode 100644 firstleg/src/main/java/com/baiyue/firstleg/lingxing/LingxingAuthManager.java create mode 100644 firstleg/src/main/java/com/baiyue/firstleg/lingxing/LingxingAuthState.java create mode 100644 firstleg/src/main/java/com/baiyue/firstleg/lingxing/LingxingBrowserSessionReader.java create mode 100644 firstleg/src/main/java/com/baiyue/firstleg/lingxing/LingxingProviderConfig.java create mode 100644 firstleg/src/main/java/com/baiyue/firstleg/lingxing/LingxingSecondaryVerificationException.java create mode 100644 firstleg/src/test/java/com/baiyue/firstleg/lingxing/LingxingAuthManagerTest.java diff --git a/deploy/sql/头程数据库统一增量-MySQL8.sql b/deploy/sql/头程数据库统一增量-MySQL8.sql index d4de2e435..08b793951 100644 --- a/deploy/sql/头程数据库统一增量-MySQL8.sql +++ b/deploy/sql/头程数据库统一增量-MySQL8.sql @@ -3318,7 +3318,8 @@ CREATE TABLE fl_mojia_measurement_receipt ( id bigint unsigned NOT NULL COMMENT '墨家联调收件记录ID', tenant_id bigint unsigned NOT NULL COMMENT '客户ID和数据隔离键', subject_id bigint unsigned NOT NULL COMMENT '设备应用操作主体ID', - box_code varchar(128) NOT NULL COMMENT '设备扫描的原始箱码', + box_code varchar(128) NOT NULL COMMENT '设备扫描的原始箱码或不带#的SKU', + measurement_kind varchar(16) NOT NULL DEFAULT 'BOX_LABEL' COMMENT '测量分流:BOX_LABEL箱唛;SKU一件代发SKU', scanned_shipment_id varchar(64) DEFAULT NULL COMMENT '设备从Amazon FBA箱标实扫的Shipment ID', length_cm decimal(12,3) NOT NULL COMMENT '设备实测长度cm', width_cm decimal(12,3) NOT NULL COMMENT '设备实测宽度cm', @@ -3327,9 +3328,14 @@ CREATE TABLE fl_mojia_measurement_receipt ( machine_no varchar(64) NOT NULL COMMENT '设备编号', measured_at datetime(3) NOT NULL COMMENT '设备测量时间或服务接收时间', image_file_id bigint unsigned NOT NULL COMMENT 'File服务中的墨家联调图片ID', - status varchar(24) NOT NULL DEFAULT 'UNMATCHED' COMMENT '测量回执匹配状态:UNMATCHED未匹配;当前首版仅记录未匹配回执', - match_failure_code varchar(16) NOT NULL COMMENT '正式业务匹配失败码', - match_failure_reason varchar(500) NOT NULL COMMENT '脱敏后的匹配失败原因', + status varchar(24) NOT NULL DEFAULT 'UNMATCHED' COMMENT '原始收件状态;SKU记录使用SKU_PENDING_SYNC', + sync_status varchar(24) DEFAULT NULL COMMENT 'SKU同步状态:PENDING/SYNCING/SUCCESS/FAILED', + sync_failure_code varchar(64) DEFAULT NULL COMMENT 'SKU同步失败业务码', + sync_failure_reason varchar(500) DEFAULT NULL COMMENT '脱敏后的SKU同步失败原因', + lingxing_sku_id varchar(128) DEFAULT NULL COMMENT '领星SKU内部标识', + revision int NOT NULL DEFAULT 1 COMMENT '当前测量版本号', + match_failure_code varchar(16) DEFAULT NULL COMMENT '正式箱唛业务匹配失败码', + match_failure_reason varchar(500) DEFAULT NULL COMMENT '脱敏后的箱唛匹配失败原因', request_id varchar(128) DEFAULT NULL COMMENT '请求链路ID', trace_id varchar(128) DEFAULT NULL COMMENT '调用链ID', created_at datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间', diff --git a/docs/lingxing-browser-dev-test.md b/docs/lingxing-browser-dev-test.md new file mode 100644 index 000000000..c28f19f0c --- /dev/null +++ b/docs/lingxing-browser-dev-test.md @@ -0,0 +1,50 @@ +# 领星浏览器登录态开发测试 + +开发服务器为 Ubuntu 22.04.5 LTS。开发阶段先验证持久化 Chromium/Chrome + CDP,不调用领星新品测量接口。 + +## 1. 启动持久化 Chromium(Ubuntu 22.04) + +服务器上使用专用浏览器 profile,不复用系统默认用户目录: + +```bash +sudo apt-get update +sudo apt-get install -y chromium-browser xvfb +sudo mkdir -p /opt/lingxing/browser-profile +sudo chown -R "$USER":"$USER" /opt/lingxing + +nohup xvfb-run --server-num=99 --server-args='-screen 0 1920x1080x24' \ + chromium-browser \ + --no-sandbox \ + --disable-dev-shm-usage \ + --remote-debugging-address=127.0.0.1 \ + --remote-debugging-port=9222 \ + --user-data-dir=/opt/lingxing/browser-profile \ + --window-size=1920,1080 \ + https://oms.xlwms.com \ + >/var/log/lingxing-chromium.log 2>&1 & +``` + +若系统命令名是 `chromium` 而非 `chromium-browser`,替换命令名即可。通过临时 VNC/远程桌面完成人工登录和二次验证码;后续复用该 profile。不要把真实账号、密码、验证码或 profile 目录提交到仓库。 + +建议通过 SSH 隧道访问 CDP,不要把 9222 暴露到公网: + +```bash +ssh -N -L 9222:127.0.0.1:9222 @ +``` + +## 2. 验证 CDP 页面 + +```bash +curl http://127.0.0.1:9222/json/version +curl http://127.0.0.1:9222/json/list +``` + +必须能看到 `https://oms.xlwms.com` 页面和 `webSocketDebuggerUrl`。页面仍在 `/login` 时先完成人工二次验证,读取器不会绕过验证码。Java 读取入口是: + +```java +new LingxingBrowserSessionReader(objectMapper).read( + new LingxingProviderConfig("LINGXING_01", "http://127.0.0.1:9222", true, 172800, 300), + "https://oms.xlwms.com"); +``` + +成功时只在内存返回 Token、Cookie 和过期时间;日志不得输出完整凭证。页面回到 `/login`、没有 `oms-token` 或没有 OMS Cookie 时,应判定为需要人工登录。 diff --git a/firstleg/src/main/java/com/baiyue/firstleg/controller/OpenApiInboundMeasurementController.java b/firstleg/src/main/java/com/baiyue/firstleg/controller/OpenApiInboundMeasurementController.java index b24b0f549..25e74b866 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/controller/OpenApiInboundMeasurementController.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/controller/OpenApiInboundMeasurementController.java @@ -43,7 +43,7 @@ public class OpenApiInboundMeasurementController { private MojiaMeasurementOpenApiService mojiaService; @Operation(summary = "墨家设备按箱码上传单箱测量结果", - description = "首轮联调使用固定 X-App-Key;JSON 中 picture 为 JPEG Data URL,boxCode 原样取自柏悦箱唛二维码") + description = "首轮联调使用固定 X-App-Key;JSON 中 picture 为 JPEG Data URL,boxCode 原样取自设备条码;带 # 的箱唛继续走箱唛测量,不带 # 的 SKU 进入一件代发 SKU 同步收件;weightKg 为 kg") @PostMapping(consumes = MediaType.APPLICATION_JSON_VALUE) public ApiResponse receiveMojia( @RequestHeader("X-App-Key") String appKey, diff --git a/firstleg/src/main/java/com/baiyue/firstleg/dto/MojiaMeasurementDtos.java b/firstleg/src/main/java/com/baiyue/firstleg/dto/MojiaMeasurementDtos.java index a64dc7948..1e9682d09 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/dto/MojiaMeasurementDtos.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/dto/MojiaMeasurementDtos.java @@ -23,6 +23,8 @@ public final class MojiaMeasurementDtos { @NotNull @DecimalMin("0.001") @DecimalMax("999999999.999") BigDecimal length, @NotNull @DecimalMin("0.001") @DecimalMax("999999999.999") BigDecimal width, @NotNull @DecimalMin("0.001") @DecimalMax("999999999.999") BigDecimal height, + /** 设备实测单件重量,单位 kg。墨家协议已提供但旧 Java 接收模型遗漏。 */ + @NotNull @DecimalMin("0.001") @DecimalMax("999999999.999") BigDecimal weightKg, @NotBlank @Size(max = 64) String machineNo, LocalDateTime measuredAt, @NotBlank @Size(max = 7_100_000) String picture diff --git a/firstleg/src/main/java/com/baiyue/firstleg/entity/MojiaMeasurementReceiptEntity.java b/firstleg/src/main/java/com/baiyue/firstleg/entity/MojiaMeasurementReceiptEntity.java index ff341cc93..9df013eef 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/entity/MojiaMeasurementReceiptEntity.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/entity/MojiaMeasurementReceiptEntity.java @@ -19,14 +19,22 @@ public class MojiaMeasurementReceiptEntity { private Long tenantId; private Long subjectId; private String boxCode; + /** BOX_LABEL 表示现有箱唛链路;SKU 表示一件代发新品测量同步收件。 */ + private String measurementKind; private String scannedShipmentId; private BigDecimal lengthCm; private BigDecimal widthCm; private BigDecimal heightCm; + private BigDecimal weightKg; private String machineNo; private LocalDateTime measuredAt; private Long imageFileId; private String status; + private String syncStatus; + private String syncFailureCode; + private String syncFailureReason; + private String lingxingSkuId; + private Integer revision; private String matchFailureCode; private String matchFailureReason; private String requestId; diff --git a/firstleg/src/main/java/com/baiyue/firstleg/lingxing/LingxingAuthManager.java b/firstleg/src/main/java/com/baiyue/firstleg/lingxing/LingxingAuthManager.java new file mode 100644 index 000000000..33dfd9b3a --- /dev/null +++ b/firstleg/src/main/java/com/baiyue/firstleg/lingxing/LingxingAuthManager.java @@ -0,0 +1,76 @@ +package com.baiyue.firstleg.lingxing; + +import java.time.Clock; +import java.util.Map; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.Executor; +import java.util.concurrent.Executors; +import java.util.function.Function; +import org.springframework.stereotype.Component; + +/** 领星登录态缓存:按 providerKey 隔离、提前过期、single-flight 和二次验证冷却。 */ +@Component +public class LingxingAuthManager { + private final LingxingBrowserSessionReader browser; + private final Clock clock; + private final Executor executor; + private final Map cache = new ConcurrentHashMap<>(); + private final Map> refreshing = new ConcurrentHashMap<>(); + private final Map cooldownUntil = new ConcurrentHashMap<>(); + private volatile Function passwordLogin; + + public LingxingAuthManager(LingxingBrowserSessionReader browser) { + this(browser, Clock.systemUTC(), Executors.newCachedThreadPool(r -> { + Thread thread = new Thread(r, "lingxing-auth-refresh"); thread.setDaemon(true); return thread; + })); + } + + LingxingAuthManager(LingxingBrowserSessionReader browser, Clock clock, Executor executor) { + this.browser = browser; this.clock = clock; this.executor = executor; + } + + public CompletableFuture getAuthState(LingxingProviderConfig config, String loginOrigin) { + LingxingAuthState cached = cache.get(config.providerKey()); + if (cached != null && cached.usable(clock.millis())) return CompletableFuture.completedFuture(cached); + CompletableFuture current = refreshing.get(config.providerKey()); + if (current != null) return current; + CompletableFuture created = CompletableFuture.supplyAsync( + () -> refresh(config, loginOrigin), executor); + CompletableFuture previous = refreshing.putIfAbsent(config.providerKey(), created); + if (previous != null) return previous; + created.whenComplete((value, error) -> refreshing.remove(config.providerKey(), created)); + return created; + } + + public void invalidate(String providerKey, String reason) { cache.remove(providerKey); } + + public void setPasswordLogin(Function passwordLogin) { + this.passwordLogin = passwordLogin; + } + + public void markSecondaryVerificationRequired(String providerKey, long cooldownSeconds) { + cooldownUntil.put(providerKey, clock.millis() + Math.max(1, cooldownSeconds) * 1000L); + } + + public void clear(String providerKey) { cache.remove(providerKey); cooldownUntil.remove(providerKey); } + + private LingxingAuthState refresh(LingxingProviderConfig config, String loginOrigin) { + Long retryAt = cooldownUntil.get(config.providerKey()); + if (retryAt != null && retryAt > clock.millis()) { + throw new LingxingSecondaryVerificationException("领星账号处于二次验证冷却期"); + } + if (config.browserCdpEndpoint() != null && !config.browserCdpEndpoint().isBlank()) { + try { + LingxingAuthState state = browser.read(config, loginOrigin); + cache.put(config.providerKey(), state); cooldownUntil.remove(config.providerKey()); return state; + } catch (RuntimeException ex) { + if (config.browserSessionRequired()) throw ex; + } + } + Function login = passwordLogin; + if (login == null) throw new IllegalStateException("领星浏览器登录态不可用,且未配置账号密码登录器"); + LingxingAuthState state = login.apply(config); + cache.put(config.providerKey(), state); cooldownUntil.remove(config.providerKey()); return state; + } +} diff --git a/firstleg/src/main/java/com/baiyue/firstleg/lingxing/LingxingAuthState.java b/firstleg/src/main/java/com/baiyue/firstleg/lingxing/LingxingAuthState.java new file mode 100644 index 000000000..065de5e3b --- /dev/null +++ b/firstleg/src/main/java/com/baiyue/firstleg/lingxing/LingxingAuthState.java @@ -0,0 +1,9 @@ +package com.baiyue.firstleg.lingxing; + +/** 不包含完整凭证日志输出的领星登录态快照。 */ +public record LingxingAuthState(String token, String cookies, long expiresAt, + String userAgent, String loginVia) { + public boolean usable(long now) { + return token != null && !token.isBlank() && cookies != null && expiresAt > now; + } +} diff --git a/firstleg/src/main/java/com/baiyue/firstleg/lingxing/LingxingBrowserSessionReader.java b/firstleg/src/main/java/com/baiyue/firstleg/lingxing/LingxingBrowserSessionReader.java new file mode 100644 index 000000000..bcb85d5f0 --- /dev/null +++ b/firstleg/src/main/java/com/baiyue/firstleg/lingxing/LingxingBrowserSessionReader.java @@ -0,0 +1,129 @@ +package com.baiyue.firstleg.lingxing; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.node.ArrayNode; +import com.fasterxml.jackson.databind.node.ObjectNode; +import java.net.URI; +import java.net.http.HttpClient; +import java.net.http.HttpRequest; +import java.net.http.HttpResponse; +import java.net.http.WebSocket; +import java.time.Duration; +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.CompletionStage; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.atomic.AtomicLong; +import org.springframework.stereotype.Component; + +/** 通过 Chrome DevTools Protocol 读取人工登录的持久化 OMS 页面。 */ +@Component +public class LingxingBrowserSessionReader { + private final ObjectMapper mapper; + private final HttpClient http; + private final Duration timeout; + + public LingxingBrowserSessionReader(ObjectMapper mapper) { + this(mapper, HttpClient.newBuilder().connectTimeout(Duration.ofSeconds(5)).build(), Duration.ofSeconds(5)); + } + + LingxingBrowserSessionReader(ObjectMapper mapper, HttpClient http, Duration timeout) { + this.mapper = mapper; this.http = http; this.timeout = timeout; + } + + public LingxingAuthState read(LingxingProviderConfig config, String loginOrigin) { + try { + ArrayNode pages = (ArrayNode) mapper.readTree(http.send( + HttpRequest.newBuilder(URI.create(stripSlash(config.browserCdpEndpoint()) + "/json/list")) + .timeout(timeout).GET().build(), HttpResponse.BodyHandlers.ofString()).body()); + JsonNode selected = selectPage(pages, loginOrigin); + if (selected == null) throw new IllegalStateException("未找到已登录的领星 OMS 页面"); + String ws = selected.path("webSocketDebuggerUrl").asText(""); + if (ws.isBlank()) throw new IllegalStateException("领星页面未提供 CDP WebSocket 地址"); + CdpSocket socket = new CdpSocket(mapper, http, timeout, URI.create(ws)); + ObjectNode browser = socket.command("Runtime.evaluate", mapper.createObjectNode() + .put("expression", "({token:localStorage.getItem('oms-token'),version:localStorage.getItem('version'),href:location.href,ua:navigator.userAgent})") + .put("returnByValue", true)).join(); + JsonNode value = browser.path("result").path("result").path("value"); + String token = value.path("token").asText(""); + String href = value.path("href").asText(""); + if (token.isBlank() || href.contains("/login")) throw new IllegalStateException("领星浏览器尚未完成登录"); + ObjectNode cookiesResult = socket.command("Network.getAllCookies", mapper.createObjectNode()).join(); + String host = URI.create(loginOrigin).getHost(); + List cookies = new ArrayList<>(); + for (JsonNode cookie : cookiesResult.path("result").path("cookies")) { + if (cookie.path("domain").asText("").contains(host)) cookies.add(cookie.path("name").asText() + "=" + cookie.path("value").asText()); + } + if (cookies.isEmpty()) throw new IllegalStateException("领星浏览器未返回有效 Cookie"); + long expiresAt = expiresAt(token, config); + socket.close(); + return new LingxingAuthState(token, String.join("; ", cookies), expiresAt, + value.path("ua").asText(null), "browser_session"); + } catch (Exception ex) { + throw new IllegalStateException("读取领星浏览器登录态失败", ex); + } + } + + private JsonNode selectPage(ArrayNode pages, String origin) { + JsonNode fallback = null; + for (JsonNode page : pages) { + String url = page.path("url").asText(""); + if (!url.startsWith(origin)) continue; + if (!url.contains("/login")) return page; + fallback = page; + } + return fallback; + } + + static long expiresAt(String token, LingxingProviderConfig config) { + long now = System.currentTimeMillis(); + try { + String[] parts = token.split("\\."); + if (parts.length >= 2) { + JsonNode payload = new ObjectMapper().readTree(java.util.Base64.getUrlDecoder().decode(parts[1])); + if (payload.has("exp")) return Math.max(now, payload.path("exp").asLong() * 1000L - config.expirySafetySeconds() * 1000L); + } + } catch (Exception ignored) { } + return now + Math.max(1, config.tokenTtlSeconds() - config.expirySafetySeconds()) * 1000L; + } + + private static String stripSlash(String value) { return value.endsWith("/") ? value.substring(0, value.length() - 1) : value; } + + static final class CdpSocket implements WebSocket.Listener { + private final ObjectMapper mapper; private final Duration timeout; private final AtomicLong ids = new AtomicLong(); + private final Map> pending = new ConcurrentHashMap<>(); + private final StringBuilder textBuffer = new StringBuilder(); + private final CompletableFuture connected; + private volatile WebSocket socket; + CdpSocket(ObjectMapper mapper, HttpClient http, Duration timeout, URI uri) { + this.mapper = mapper; this.timeout = timeout; + connected = http.newWebSocketBuilder().connectTimeout(timeout).buildAsync(uri, this); + } + CompletableFuture command(String method, ObjectNode params) { + return connected.thenCompose(ws -> { + long id = ids.incrementAndGet(); CompletableFuture result = new CompletableFuture<>(); pending.put(id, result); + ObjectNode request = mapper.createObjectNode().put("id", id).put("method", method).set("params", params); + ws.sendText(request.toString(), true); return result.orTimeout(timeout.toMillis(), java.util.concurrent.TimeUnit.MILLISECONDS); + }); + } + void close() { if (socket != null) socket.sendClose(WebSocket.NORMAL_CLOSURE, "done"); } + @Override public void onOpen(WebSocket webSocket) { socket = webSocket; webSocket.request(1); } + @Override public CompletionStage onText(WebSocket webSocket, CharSequence data, boolean last) { + try { + textBuffer.append(data); + if (last) { + ObjectNode response = (ObjectNode) mapper.readTree(textBuffer.toString()); + textBuffer.setLength(0); + long id = response.path("id").asLong(-1); + CompletableFuture f = pending.remove(id); + if (f != null) f.complete(response); + } + } catch (Exception ex) { textBuffer.setLength(0); } + webSocket.request(1); return CompletableFuture.completedFuture(null); + } + @Override public void onError(WebSocket webSocket, Throwable error) { pending.values().forEach(f -> f.completeExceptionally(error)); } + } +} diff --git a/firstleg/src/main/java/com/baiyue/firstleg/lingxing/LingxingProviderConfig.java b/firstleg/src/main/java/com/baiyue/firstleg/lingxing/LingxingProviderConfig.java new file mode 100644 index 000000000..bec577b82 --- /dev/null +++ b/firstleg/src/main/java/com/baiyue/firstleg/lingxing/LingxingProviderConfig.java @@ -0,0 +1,12 @@ +package com.baiyue.firstleg.lingxing; + +/** 领星账号运行配置;密码和签名密钥不进入此对象日志。 */ +public record LingxingProviderConfig(String providerKey, String browserCdpEndpoint, + boolean browserSessionRequired, long tokenTtlSeconds, + long expirySafetySeconds) { + public LingxingProviderConfig { + if (providerKey == null || providerKey.isBlank()) throw new IllegalArgumentException("providerKey不能为空"); + if (tokenTtlSeconds <= 0) tokenTtlSeconds = 172800; + if (expirySafetySeconds < 0) expirySafetySeconds = 300; + } +} diff --git a/firstleg/src/main/java/com/baiyue/firstleg/lingxing/LingxingSecondaryVerificationException.java b/firstleg/src/main/java/com/baiyue/firstleg/lingxing/LingxingSecondaryVerificationException.java new file mode 100644 index 000000000..1d63e5065 --- /dev/null +++ b/firstleg/src/main/java/com/baiyue/firstleg/lingxing/LingxingSecondaryVerificationException.java @@ -0,0 +1,5 @@ +package com.baiyue.firstleg.lingxing; + +public class LingxingSecondaryVerificationException extends RuntimeException { + public LingxingSecondaryVerificationException(String message) { super(message); } +} diff --git a/firstleg/src/main/java/com/baiyue/firstleg/service/MojiaMeasurementOpenApiService.java b/firstleg/src/main/java/com/baiyue/firstleg/service/MojiaMeasurementOpenApiService.java index a4e6947e2..182667e98 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/service/MojiaMeasurementOpenApiService.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/service/MojiaMeasurementOpenApiService.java @@ -80,6 +80,11 @@ public class MojiaMeasurementOpenApiService { throw new BizException(ResultCode.UNAUTHORIZED, "墨家设备必须使用平台级应用凭据"); } long machineWarehouseId = requireMachineWarehouse(app, request.machineNo()); + // 墨家同一个 boxCode 字段同时承载箱唛和 SKU:带 # 的继续走既有箱唛链路, + // 不带 # 的只进入一件代发 SKU 同步收件,不得误走箱码匹配。 + if (!request.boxCode().contains("#")) { + return saveSkuPending(app, request, image); + } final InboundCartonLabelService.PlatformBinding platformBinding; try { platformBinding = cartonLabels.resolvePlatformByBoxCode(request.boxCode()); @@ -125,6 +130,43 @@ public class MojiaMeasurementOpenApiService { } } + private UploadResponse saveSkuPending(AuthenticatedMeasurementApp app, UploadRequest request, + MultipartFile image) { + long receiptId = IdWorker.getId(); + MeasurementImageClient.UploadResult uploaded = uploadReceiptImage( + app.tenantId(), app.subjectId(), receiptId, image); + try { + LocalDateTime measuredAt = request.measuredAt() == null ? LocalDateTime.now() : request.measuredAt(); + MojiaMeasurementReceiptEntity receipt = new MojiaMeasurementReceiptEntity(); + receipt.setId(receiptId); + receipt.setTenantId(app.tenantId()); + receipt.setSubjectId(app.subjectId()); + receipt.setBoxCode(request.boxCode().trim()); + receipt.setMeasurementKind("SKU"); + receipt.setScannedShipmentId(optionalTrim(request.shipmentId())); + receipt.setLengthCm(request.length()); + receipt.setWidthCm(request.width()); + receipt.setHeightCm(request.height()); + receipt.setWeightKg(request.weightKg()); + receipt.setMachineNo(request.machineNo().trim()); + receipt.setMeasuredAt(measuredAt); + receipt.setImageFileId(uploaded.fileId()); + receipt.setStatus("SKU_PENDING_SYNC"); + receipt.setSyncStatus("PENDING"); + receipt.setRevision(1); + receipt.setRequestId(correlation("requestId")); + receipt.setTraceId(correlation("traceId")); + receipt.setCreatedAt(LocalDateTime.now()); + receipt.setUpdatedAt(LocalDateTime.now()); + receipts.insert(receipt); + return new UploadResponse(true, false, receiptId, null, null, + request.boxCode(), uploaded.fileId(), true); + } catch (RuntimeException exception) { + cleanupReceiptImage(app.tenantId(), app.subjectId(), receiptId, uploaded.fileId()); + throw exception; + } + } + private long requireMachineWarehouse(AuthenticatedMeasurementApp app, String machineNo) { Long warehouseId = app.warehouseBindings().get(machineNo.trim()); if (warehouseId == null || warehouseId <= 0) { @@ -148,6 +190,7 @@ public class MojiaMeasurementOpenApiService { receipt.setLengthCm(request.length()); receipt.setWidthCm(request.width()); receipt.setHeightCm(request.height()); + receipt.setWeightKg(request.weightKg()); receipt.setMachineNo(request.machineNo().trim()); receipt.setMeasuredAt(request.measuredAt() == null ? LocalDateTime.now() : request.measuredAt()); receipt.setImageFileId(uploaded.fileId()); diff --git a/firstleg/src/main/resources/db/phase2-inbound-schema.sql b/firstleg/src/main/resources/db/phase2-inbound-schema.sql index 9e951acfe..bd1f6b222 100644 --- a/firstleg/src/main/resources/db/phase2-inbound-schema.sql +++ b/firstleg/src/main/resources/db/phase2-inbound-schema.sql @@ -329,16 +329,23 @@ CREATE TABLE IF NOT EXISTS fl_mojia_measurement_receipt ( tenant_id BIGINT UNSIGNED NOT NULL COMMENT '客户ID和数据隔离键', subject_id BIGINT UNSIGNED NOT NULL COMMENT '设备应用操作主体ID', box_code VARCHAR(128) NOT NULL COMMENT '设备扫描的原始箱码', + measurement_kind VARCHAR(16) NOT NULL DEFAULT 'BOX_LABEL' COMMENT '测量分流:BOX_LABEL箱唛;SKU一件代发SKU', scanned_shipment_id VARCHAR(64) NULL COMMENT '设备从Amazon FBA箱标实扫的Shipment ID', length_cm DECIMAL(12,3) NOT NULL COMMENT '设备实测长度cm', width_cm DECIMAL(12,3) NOT NULL COMMENT '设备实测宽度cm', height_cm DECIMAL(12,3) NOT NULL COMMENT '设备实测高度cm', + weight_kg DECIMAL(18,3) NOT NULL COMMENT '设备实测重量kg', machine_no VARCHAR(64) NOT NULL COMMENT '设备编号', measured_at DATETIME(3) NOT NULL COMMENT '设备测量时间或服务接收时间', image_file_id BIGINT UNSIGNED NOT NULL COMMENT 'File服务中的墨家联调图片ID', - status VARCHAR(24) NOT NULL DEFAULT 'UNMATCHED' COMMENT '测量回执匹配状态:UNMATCHED未匹配;当前首版仅记录未匹配回执', - match_failure_code VARCHAR(16) NOT NULL COMMENT '正式业务匹配失败码', - match_failure_reason VARCHAR(500) NOT NULL COMMENT '脱敏后的匹配失败原因', + status VARCHAR(24) NOT NULL DEFAULT 'UNMATCHED' COMMENT '原始收件状态;SKU记录使用SKU_PENDING_SYNC', + sync_status VARCHAR(24) NULL COMMENT 'SKU同步状态:PENDING/SYNCING/SUCCESS/FAILED', + sync_failure_code VARCHAR(64) NULL COMMENT 'SKU同步失败业务码', + sync_failure_reason VARCHAR(500) NULL COMMENT '脱敏后的SKU同步失败原因', + lingxing_sku_id VARCHAR(128) NULL COMMENT '领星SKU内部标识', + revision INT NOT NULL DEFAULT 1 COMMENT '当前测量版本号', + match_failure_code VARCHAR(16) NULL COMMENT '正式箱唛业务匹配失败码', + match_failure_reason VARCHAR(500) NULL COMMENT '脱敏后的箱唛匹配失败原因', request_id VARCHAR(128) NULL COMMENT '请求链路ID', trace_id VARCHAR(128) NULL COMMENT '调用链ID', created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间', diff --git a/firstleg/src/test/java/com/baiyue/firstleg/lingxing/LingxingAuthManagerTest.java b/firstleg/src/test/java/com/baiyue/firstleg/lingxing/LingxingAuthManagerTest.java new file mode 100644 index 000000000..1e0079bcf --- /dev/null +++ b/firstleg/src/test/java/com/baiyue/firstleg/lingxing/LingxingAuthManagerTest.java @@ -0,0 +1,54 @@ +package com.baiyue.firstleg.lingxing; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import java.time.Clock; +import java.time.Instant; +import java.time.ZoneOffset; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.atomic.AtomicInteger; +import org.junit.jupiter.api.Test; + +class LingxingAuthManagerTest { + private final LingxingProviderConfig config = new LingxingProviderConfig( + "LINGXING_01", "http://127.0.0.1:9222", true, 172800, 300); + + @Test + void concurrentRequestsUseOneBrowserRefresh() { + LingxingBrowserSessionReader browser = mock(LingxingBrowserSessionReader.class); + LingxingAuthState state = new LingxingAuthState("token", "sid=1", Long.MAX_VALUE, "ua", "browser_session"); + when(browser.read(config, "https://oms.xlwms.com")).thenAnswer(invocation -> { + Thread.sleep(40); return state; + }); + LingxingAuthManager manager = new LingxingAuthManager(browser, Clock.systemUTC(), Runnable::run); + + CompletableFuture first = manager.getAuthState(config, "https://oms.xlwms.com"); + CompletableFuture second = manager.getAuthState(config, "https://oms.xlwms.com"); + + assertThat(first.join()).isSameAs(state); + assertThat(second.join()).isSameAs(state); + verify(browser).read(config, "https://oms.xlwms.com"); + } + + @Test + void secondaryVerificationBlocksRefreshUntilCooldownExpires() { + LingxingBrowserSessionReader browser = mock(LingxingBrowserSessionReader.class); + Clock clock = Clock.fixed(Instant.parse("2026-09-21T12:00:00Z"), ZoneOffset.UTC); + LingxingAuthManager manager = new LingxingAuthManager(browser, clock, Runnable::run); + manager.markSecondaryVerificationRequired(config.providerKey(), 1800); + + assertThatThrownBy(() -> manager.getAuthState(config, "https://oms.xlwms.com").join()) + .hasRootCauseInstanceOf(LingxingSecondaryVerificationException.class); + } + + @Test + void jwtExpiryUsesSafetyWindow() { + String token = "eyJhbGciOiJub25lIn0.eyJleHAiOjE3OTAwMDAwMDB9.signature"; + long expected = 1790000000000L - 300_000L; + assertThat(LingxingBrowserSessionReader.expiresAt(token, config)).isEqualTo(expected); + } +} diff --git a/firstleg/src/test/java/com/baiyue/firstleg/service/MojiaMeasurementOpenApiServiceTest.java b/firstleg/src/test/java/com/baiyue/firstleg/service/MojiaMeasurementOpenApiServiceTest.java index f7b5959a3..73c3dd64a 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/service/MojiaMeasurementOpenApiServiceTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/service/MojiaMeasurementOpenApiServiceTest.java @@ -119,11 +119,47 @@ class MojiaMeasurementOpenApiServiceTest { assertThat(receipt.getValue().getBoxCode()).isEqualTo("PCHK2608140001#5-1"); assertThat(receipt.getValue().getScannedShipmentId()).isNull(); assertThat(receipt.getValue().getLengthCm()).isEqualByComparingTo("60.1"); + assertThat(receipt.getValue().getWeightKg()).isEqualByComparingTo("15.13"); assertThat(receipt.getValue().getImageFileId()).isEqualTo(3L); assertThat(receipt.getValue().getStatus()).isEqualTo("UNMATCHED"); assertThat(receipt.getValue().getTenantId()).isZero(); } + @Test + void skuWithoutHashIsAcceptedIntoSkuSyncInboxAndDoesNotEnterCartonFlow() { + MeasurementRequestAuthenticator authenticator = mock(MeasurementRequestAuthenticator.class); + CartonMeasurementService cartons = mock(CartonMeasurementService.class); + MeasurementImageClient images = mock(MeasurementImageClient.class); + MojiaMeasurementReceiptRepository receipts = mock(MojiaMeasurementReceiptRepository.class); + InboundCartonLabelService labels = mock(InboundCartonLabelService.class); + var service = new MojiaMeasurementOpenApiService(authenticator, cartons, + new ObjectMapper().findAndRegisterModules(), Validation.buildDefaultValidatorFactory().getValidator(), + images, receipts, labels); + ReflectionTestUtils.setField(service, "internalToken", "internal-token"); + MeasurementRequestHeaders headers = new MeasurementRequestHeaders("mojia", null, null, null, null); + String requestJson = json().replace("PCHK2608140001#5-1", "SKU-NEW-001"); + when(authenticator.authenticate("POST", MojiaMeasurementOpenApiService.CALLBACK_PATH, + headers, requestJson, false)).thenReturn(new AuthenticatedMeasurementApp( + "mojia", 0L, 90001L, Map.of("MJ-01", 30001L))); + when(images.uploadReceipt(eq("internal-token"), eq(90001L), eq(0L), any(Long.TYPE), any())) + .thenReturn(ApiResponse.success(new MeasurementImageClient.UploadResult( + 9L, "mojia.jpg", "image/jpeg", 4L, "NOT_REQUIRED", null, null, null, null))); + + UploadResponse result = service.receive(headers, requestJson); + + assertThat(result.accepted()).isTrue(); + assertThat(result.matched()).isFalse(); + verify(labels, org.mockito.Mockito.never()).resolvePlatformByBoxCode(any()); + verify(cartons, org.mockito.Mockito.never()).upsert(any(Long.TYPE), any(Long.TYPE), any()); + ArgumentCaptor receipt = + ArgumentCaptor.forClass(MojiaMeasurementReceiptEntity.class); + verify(receipts).insert(receipt.capture()); + assertThat(receipt.getValue().getMeasurementKind()).isEqualTo("SKU"); + assertThat(receipt.getValue().getSyncStatus()).isEqualTo("PENDING"); + assertThat(receipt.getValue().getStatus()).isEqualTo("SKU_PENDING_SYNC"); + assertThat(receipt.getValue().getBoxCode()).isEqualTo("SKU-NEW-001"); + } + @Test void platformMachineCannotWriteAnInboundFromAnotherWarehouse() { MeasurementRequestAuthenticator authenticator = mock(MeasurementRequestAuthenticator.class); @@ -153,7 +189,7 @@ class MojiaMeasurementOpenApiServiceTest { private String json() { return """ {"boxCode":"PCHK2608140001#5-1","shipment_id":"FBA19L5KMY2CU000002", - "length":60.1,"width":40.2,"height":30.3, + "length":60.1,"width":40.2,"height":30.3,"weightKg":15.13, "machineNo":"MJ-01","picture":"data:image/jpeg;base64,/9j/2Q=="} """; }