feat: prepare lingxing browser auth and sku measurement inbox
This commit is contained in:
+1
-1
@@ -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;
|
||||
}
|
||||
}
|
||||
+129
@@ -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;
|
||||
}
|
||||
}
|
||||
+5
@@ -0,0 +1,5 @@
|
||||
package com.baiyue.firstleg.lingxing;
|
||||
|
||||
public class LingxingSecondaryVerificationException extends RuntimeException {
|
||||
public LingxingSecondaryVerificationException(String message) { super(message); }
|
||||
}
|
||||
+43
@@ -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());
|
||||
|
||||
Reference in New Issue
Block a user