feat: prepare lingxing browser auth and sku measurement inbox

This commit is contained in:
2026-09-21 21:20:32 +08:00
parent 656f197737
commit 03374c83f6
14 changed files with 446 additions and 9 deletions
@@ -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<UploadResponse> receiveMojia(
@RequestHeader("X-App-Key") String appKey,
@@ -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
@@ -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;
@@ -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<String, LingxingAuthState> cache = new ConcurrentHashMap<>();
private final Map<String, CompletableFuture<LingxingAuthState>> refreshing = new ConcurrentHashMap<>();
private final Map<String, Long> cooldownUntil = new ConcurrentHashMap<>();
private volatile Function<LingxingProviderConfig, LingxingAuthState> 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<LingxingAuthState> getAuthState(LingxingProviderConfig config, String loginOrigin) {
LingxingAuthState cached = cache.get(config.providerKey());
if (cached != null && cached.usable(clock.millis())) return CompletableFuture.completedFuture(cached);
CompletableFuture<LingxingAuthState> current = refreshing.get(config.providerKey());
if (current != null) return current;
CompletableFuture<LingxingAuthState> created = CompletableFuture.supplyAsync(
() -> refresh(config, loginOrigin), executor);
CompletableFuture<LingxingAuthState> 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<LingxingProviderConfig, LingxingAuthState> 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<LingxingProviderConfig, LingxingAuthState> login = passwordLogin;
if (login == null) throw new IllegalStateException("领星浏览器登录态不可用,且未配置账号密码登录器");
LingxingAuthState state = login.apply(config);
cache.put(config.providerKey(), state); cooldownUntil.remove(config.providerKey()); return state;
}
}
@@ -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;
}
}
@@ -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<String> 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<Long, CompletableFuture<ObjectNode>> pending = new ConcurrentHashMap<>();
private final StringBuilder textBuffer = new StringBuilder();
private final CompletableFuture<WebSocket> 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<ObjectNode> command(String method, ObjectNode params) {
return connected.thenCompose(ws -> {
long id = ids.incrementAndGet(); CompletableFuture<ObjectNode> 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<ObjectNode> 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)); }
}
}
@@ -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;
}
}
@@ -0,0 +1,5 @@
package com.baiyue.firstleg.lingxing;
public class LingxingSecondaryVerificationException extends RuntimeException {
public LingxingSecondaryVerificationException(String message) { super(message); }
}
@@ -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());