feat: prepare lingxing browser auth and sku measurement inbox
This commit is contained in:
@@ -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 '创建时间',
|
||||
|
||||
@@ -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 时,应判定为需要人工登录。
|
||||
+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());
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
+37
-1
@@ -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=="}
|
||||
""";
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user