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
@@ -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 '创建时间',
+50
View File
@@ -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 <dev-user>@<dev-server>
```
## 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 时,应判定为需要人工登录。
@@ -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());
@@ -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 '创建时间',
@@ -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<LingxingAuthState> first = manager.getAuthState(config, "https://oms.xlwms.com");
CompletableFuture<LingxingAuthState> 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);
}
}
@@ -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<MojiaMeasurementReceiptEntity> 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=="}
""";
}