优化
This commit is contained in:
@@ -20,7 +20,9 @@ public class LingxingSyncProperties {
|
||||
/** 安全默认值:墨家数据只入库,必须人工点击推送。 */
|
||||
private boolean autoSyncEnabled = false;
|
||||
private String baseUrl = "https://wms.xlwms.com";
|
||||
private String loginOrigin = "https://oms.xlwms.com";
|
||||
private String loginOrigin = "https://wms.xlwms.com";
|
||||
private String username;
|
||||
private String password;
|
||||
private String providerKey = "LINGXING_01";
|
||||
private String browserCdpEndpoint;
|
||||
private boolean browserSessionRequired = true;
|
||||
|
||||
+39
-2
@@ -5,8 +5,14 @@ import com.baiyue.common.core.api.PageResult;
|
||||
import com.baiyue.firstleg.dto.MojiaSkuSyncDtos;
|
||||
import com.baiyue.firstleg.dto.LingxingTestDtos;
|
||||
import com.baiyue.firstleg.lingxing.LingxingDirectClient;
|
||||
import com.baiyue.firstleg.lingxing.LingxingAuthManager;
|
||||
import com.baiyue.firstleg.config.LingxingSyncProperties;
|
||||
import com.baiyue.firstleg.service.FirstlegScopeGuard;
|
||||
import com.baiyue.firstleg.service.MojiaSkuSyncService;
|
||||
import com.baiyue.firstleg.lingxing.LingxingSecondaryVerificationException;
|
||||
import com.baiyue.firstleg.lingxing.LingxingBrowserChallengeException;
|
||||
import com.baiyue.common.core.exception.BizException;
|
||||
import com.baiyue.common.core.api.ResultCode;
|
||||
import io.swagger.v3.oas.annotations.Operation;
|
||||
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||
import jakarta.annotation.Resource;
|
||||
@@ -24,6 +30,8 @@ import com.baiyue.common.security.AuthenticatedUser;
|
||||
public class PlatformMojiaSkuSyncController {
|
||||
@Resource private MojiaSkuSyncService service;
|
||||
@Resource private LingxingDirectClient lingxing;
|
||||
@Resource private LingxingAuthManager authManager;
|
||||
@Resource private LingxingSyncProperties lingxingProperties;
|
||||
|
||||
@GetMapping
|
||||
@PreAuthorize("hasAuthority('platform:mojia-sku-sync:list')")
|
||||
@@ -52,7 +60,30 @@ public class PlatformMojiaSkuSyncController {
|
||||
public ApiResponse<LingxingTestDtos.Response> testConnection(
|
||||
@AuthenticationPrincipal AuthenticatedUser user, @Valid @RequestBody LingxingTestDtos.Request request) {
|
||||
FirstlegScopeGuard.platform(user);
|
||||
return ApiResponse.success(lingxing.test(request.skuCode()));
|
||||
try {
|
||||
return ApiResponse.success(lingxing.test(request.skuCode()));
|
||||
} catch (LingxingSecondaryVerificationException ex) {
|
||||
throw new BizException(ResultCode.CONFLICT, "领星需要二次验证码,请输入验证码后重试");
|
||||
} catch (LingxingBrowserChallengeException ex) {
|
||||
throw new BizException(ResultCode.CONFLICT, "领星需要在浏览器中完成安全验证");
|
||||
}
|
||||
}
|
||||
|
||||
@PostMapping("/verification-code")
|
||||
@PreAuthorize("hasAuthority('platform:mojia-sku-sync:list')")
|
||||
@Operation(summary = "提交领星二次验证码")
|
||||
public ApiResponse<String> verificationCode(
|
||||
@AuthenticationPrincipal AuthenticatedUser user,
|
||||
@Valid @RequestBody LingxingTestDtos.VerificationRequest request) {
|
||||
FirstlegScopeGuard.platform(user);
|
||||
try {
|
||||
authManager.submitVerification(lingxingProperties.getProviderKey(), lingxingProperties.getLoginOrigin(), request.code());
|
||||
return ApiResponse.success("领星登录成功,请重新执行原操作");
|
||||
} catch (LingxingSecondaryVerificationException ex) {
|
||||
throw new BizException(ResultCode.CONFLICT, "领星二次验证码仍未完成,请重新输入验证码");
|
||||
} catch (LingxingBrowserChallengeException ex) {
|
||||
throw new BizException(ResultCode.CONFLICT, "领星需要在浏览器中完成安全验证");
|
||||
}
|
||||
}
|
||||
|
||||
@PutMapping("/{id}")
|
||||
@@ -72,6 +103,12 @@ public class PlatformMojiaSkuSyncController {
|
||||
@AuthenticationPrincipal AuthenticatedUser user, @PathVariable long id,
|
||||
@Valid @RequestBody MojiaSkuSyncDtos.RetryRequest request) {
|
||||
FirstlegScopeGuard.platform(user);
|
||||
return ApiResponse.success(service.retry(id, request));
|
||||
try {
|
||||
return ApiResponse.success(service.retry(id, request));
|
||||
} catch (LingxingSecondaryVerificationException ex) {
|
||||
throw new BizException(ResultCode.CONFLICT, "领星需要二次验证码,请输入验证码后重试");
|
||||
} catch (LingxingBrowserChallengeException ex) {
|
||||
throw new BizException(ResultCode.CONFLICT, "领星需要在浏览器中完成安全验证");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,6 +9,7 @@ public final class LingxingTestDtos {
|
||||
private LingxingTestDtos() {}
|
||||
|
||||
public record Request(@NotBlank @Size(max = 128) String skuCode) {}
|
||||
public record VerificationRequest(@NotBlank @Size(max = 16) String code) {}
|
||||
public record WarehouseResult(String whCode, boolean reachable, boolean matched,
|
||||
boolean maintained, String productSku, String barcode,
|
||||
BigDecimal length, BigDecimal width, BigDecimal height,
|
||||
|
||||
@@ -19,6 +19,7 @@ public class LingxingAuthManager {
|
||||
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 final Map<String, LingxingProviderConfig> pendingVerification = new ConcurrentHashMap<>();
|
||||
private volatile Function<LingxingProviderConfig, LingxingAuthState> passwordLogin;
|
||||
|
||||
@Autowired
|
||||
@@ -67,12 +68,32 @@ public class LingxingAuthManager {
|
||||
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;
|
||||
if (config.browserSessionRequired() && (config.username() == null || config.password() == null)) throw ex;
|
||||
}
|
||||
}
|
||||
Function<LingxingProviderConfig, LingxingAuthState> login = passwordLogin;
|
||||
if (login == null) throw new IllegalStateException("领星浏览器登录态不可用,且未配置账号密码登录器");
|
||||
LingxingAuthState state = login.apply(config);
|
||||
LingxingAuthState state;
|
||||
if (login != null) state = login.apply(config);
|
||||
else if (config.username() != null && !config.username().isBlank()
|
||||
&& config.password() != null && !config.password().isBlank()) {
|
||||
try {
|
||||
state = browser.login(config, loginOrigin);
|
||||
} catch (LingxingSecondaryVerificationException | LingxingBrowserChallengeException ex) {
|
||||
pendingVerification.put(config.providerKey(), config);
|
||||
throw ex;
|
||||
}
|
||||
}
|
||||
else throw new IllegalStateException("领星浏览器登录态不可用,且未配置账号密码");
|
||||
cache.put(config.providerKey(), state); cooldownUntil.remove(config.providerKey()); return state;
|
||||
}
|
||||
|
||||
public LingxingAuthState submitVerification(String providerKey, String loginOrigin, String code) {
|
||||
LingxingProviderConfig config = pendingVerification.get(providerKey);
|
||||
if (config == null) throw new LingxingSecondaryVerificationException("当前没有等待验证码的领星登录任务");
|
||||
LingxingAuthState state = browser.submitVerification(config, loginOrigin, code);
|
||||
cache.put(providerKey, state);
|
||||
pendingVerification.remove(providerKey);
|
||||
cooldownUntil.remove(providerKey);
|
||||
return state;
|
||||
}
|
||||
}
|
||||
|
||||
+6
@@ -0,0 +1,6 @@
|
||||
package com.baiyue.firstleg.lingxing;
|
||||
|
||||
/** 需要人工完成浏览器安全验证(例如腾讯滑块)时的可识别错误。 */
|
||||
public class LingxingBrowserChallengeException extends RuntimeException {
|
||||
public LingxingBrowserChallengeException(String message) { super(message); }
|
||||
}
|
||||
+172
-29
@@ -5,6 +5,7 @@ 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.URISyntaxException;
|
||||
import java.net.http.HttpClient;
|
||||
import java.net.http.HttpRequest;
|
||||
import java.net.http.HttpResponse;
|
||||
@@ -37,49 +38,191 @@ public class LingxingBrowserSessionReader {
|
||||
}
|
||||
|
||||
public LingxingAuthState read(LingxingProviderConfig config, String loginOrigin) {
|
||||
CdpSocket socket = null;
|
||||
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");
|
||||
socket = open(config, loginOrigin, false);
|
||||
return readFromSocket(socket, config, loginOrigin);
|
||||
} catch (Exception ex) {
|
||||
throw new IllegalStateException("读取领星浏览器登录态失败", ex);
|
||||
} finally {
|
||||
if (socket != null) socket.close();
|
||||
}
|
||||
}
|
||||
|
||||
private JsonNode selectPage(ArrayNode pages, String origin) {
|
||||
/** 方案 A:业务调用触发的浏览器账号密码登录。不会绕过腾讯滑块。 */
|
||||
public LingxingAuthState login(LingxingProviderConfig config, String loginOrigin) {
|
||||
CdpSocket socket = null;
|
||||
try {
|
||||
socket = open(config, loginOrigin, true);
|
||||
JsonNode state = evaluate(socket, pageStateExpression());
|
||||
if (state.path("token").asText("").isBlank() || state.path("href").asText("").contains("/login")) {
|
||||
if (state.path("captcha").asBoolean(false)) throw new LingxingBrowserChallengeException("领星需要人工完成安全验证");
|
||||
fill(socket, "input[name='username']", config.username());
|
||||
fill(socket, "input[name='password']", config.password());
|
||||
click(socket, ".login-btn");
|
||||
long deadline = System.nanoTime() + timeout.toNanos() * 12;
|
||||
while (System.nanoTime() < deadline) {
|
||||
Thread.sleep(250);
|
||||
state = evaluate(socket, pageStateExpression());
|
||||
if (!state.path("token").asText("").isBlank() && !state.path("href").asText("").contains("/login")) break;
|
||||
if (state.path("captcha").asBoolean(false)) throw new LingxingBrowserChallengeException("领星需要人工完成安全验证");
|
||||
if (state.path("mfa").asBoolean(false)) {
|
||||
requestVerificationCode(socket);
|
||||
throw new LingxingSecondaryVerificationException("领星需要二次验证码");
|
||||
}
|
||||
}
|
||||
}
|
||||
return readFromSocket(socket, config, loginOrigin);
|
||||
} catch (LingxingSecondaryVerificationException | LingxingBrowserChallengeException ex) {
|
||||
throw ex;
|
||||
} catch (Exception ex) {
|
||||
throw new IllegalStateException("领星自动登录失败", ex);
|
||||
} finally {
|
||||
if (socket != null) socket.close();
|
||||
}
|
||||
}
|
||||
|
||||
/** 提交当前登录页面的短信、邮箱或 TOTP 验证码,并读取新的会话。 */
|
||||
public LingxingAuthState submitVerification(LingxingProviderConfig config, String loginOrigin, String code) {
|
||||
if (code == null || code.isBlank()) throw new IllegalArgumentException("验证码不能为空");
|
||||
CdpSocket socket = null;
|
||||
try {
|
||||
socket = open(config, loginOrigin, false);
|
||||
// SMS/email uses Element UI's placeholder input; TOTP uses the
|
||||
// custom six-digit input component with a native input element.
|
||||
fill(socket, "input[placeholder='输入验证码'], input.verification-code-native", code.trim());
|
||||
click(socket, ".verify-code-login .login-btn");
|
||||
long deadline = System.nanoTime() + timeout.toNanos() * 12;
|
||||
while (System.nanoTime() < deadline) {
|
||||
Thread.sleep(250);
|
||||
JsonNode state = evaluate(socket, pageStateExpression());
|
||||
if (!state.path("token").asText("").isBlank() && !state.path("href").asText("").contains("/login")) {
|
||||
return readFromSocket(socket, config, loginOrigin);
|
||||
}
|
||||
if (state.path("captcha").asBoolean(false)) {
|
||||
throw new LingxingBrowserChallengeException("领星需要人工完成安全验证");
|
||||
}
|
||||
if (!state.path("mfa").asBoolean(false)) throw new IllegalStateException("领星验证码验证失败");
|
||||
}
|
||||
throw new IllegalStateException("领星验证码验证超时");
|
||||
} catch (Exception ex) {
|
||||
if (ex instanceof IllegalStateException) throw (IllegalStateException) ex;
|
||||
throw new IllegalStateException("提交领星验证码失败", ex);
|
||||
} finally {
|
||||
if (socket != null) socket.close();
|
||||
}
|
||||
}
|
||||
|
||||
private CdpSocket open(LingxingProviderConfig config, String loginOrigin, boolean allowLoginPage) throws Exception {
|
||||
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, rewriteWebSocketEndpoint(URI.create(ws), URI.create(config.browserCdpEndpoint())));
|
||||
socket.command("Runtime.enable", mapper.createObjectNode()).join();
|
||||
socket.command("Network.enable", mapper.createObjectNode()).join();
|
||||
if (allowLoginPage) socket.command("Page.enable", mapper.createObjectNode()).join();
|
||||
return socket;
|
||||
}
|
||||
|
||||
private LingxingAuthState readFromSocket(CdpSocket socket, LingxingProviderConfig config, String loginOrigin) {
|
||||
JsonNode value = evaluate(socket, "({token:localStorage.getItem('oms-token'),version:localStorage.getItem('version'),href:location.href,ua:navigator.userAgent})");
|
||||
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");
|
||||
return new LingxingAuthState(token, String.join("; ", cookies), expiresAt(token, config), value.path("ua").asText(null), "browser_session");
|
||||
}
|
||||
|
||||
private JsonNode evaluate(CdpSocket socket, String expression) {
|
||||
return socket.command("Runtime.evaluate", mapper.createObjectNode().put("expression", expression).put("returnByValue", true))
|
||||
.join().path("result").path("result").path("value");
|
||||
}
|
||||
|
||||
private static String pageStateExpression() {
|
||||
return "(() => {"
|
||||
+ "const visible = (e) => { if (!e) return false; const s=getComputedStyle(e), r=e.getBoundingClientRect();"
|
||||
+ "return s.display !== 'none' && s.visibility !== 'hidden' && Number(s.opacity) !== 0 && r.width > 0 && r.height > 0; };"
|
||||
+ "const captcha = Array.from(document.querySelectorAll('#captchaId iframe,#captchaId > *,.tencent-captcha-dy')).some(visible);"
|
||||
+ "return {href:location.href,token:localStorage.getItem('oms-token'),mfa:!!document.querySelector('.verify-code-login'),captcha};"
|
||||
+ "})()";
|
||||
}
|
||||
|
||||
private void fill(CdpSocket socket, String selector, String value) {
|
||||
String expression = "(() => { const e=document.querySelector(" + mapper.createObjectNode().put("s", selector).path("s").toString() + "); if(!e) return false; e.focus(); e.select(); return true; })()";
|
||||
if (!evaluate(socket, expression).asBoolean(false)) throw new IllegalStateException("登录控件不存在: " + selector);
|
||||
socket.command("Input.insertText", mapper.createObjectNode().put("text", value)).join();
|
||||
}
|
||||
|
||||
private void click(CdpSocket socket, String selector) {
|
||||
String expression = "(() => { const e=document.querySelector(" + mapper.createObjectNode().put("s", selector).path("s").toString() + "); if(!e) return false; e.click(); return true; })()";
|
||||
if (!evaluate(socket, expression).asBoolean(false)) throw new IllegalStateException("登录按钮不存在: " + selector);
|
||||
}
|
||||
|
||||
private void requestVerificationCode(CdpSocket socket) throws InterruptedException {
|
||||
// SMS/EMAIL pages require an explicit send action. TOTP initializes itself
|
||||
// when the channel is mounted and has no .code-btn.
|
||||
long deadline = System.nanoTime() + Duration.ofSeconds(2).toNanos();
|
||||
while (System.nanoTime() < deadline) {
|
||||
if (evaluate(socket, "(() => { const e=document.querySelector('.verify-code-login .code-btn'); return !!e && !e.disabled; })()")
|
||||
.asBoolean(false)) {
|
||||
click(socket, ".verify-code-login .code-btn");
|
||||
Thread.sleep(300);
|
||||
return;
|
||||
}
|
||||
Thread.sleep(100);
|
||||
}
|
||||
}
|
||||
|
||||
static URI rewriteWebSocketEndpoint(URI discovered, URI endpoint) {
|
||||
if (discovered == null || endpoint == null || discovered.getPath() == null || discovered.getPath().isBlank()) {
|
||||
throw new IllegalArgumentException("CDP WebSocket 地址无效");
|
||||
}
|
||||
try {
|
||||
String scheme = "https".equalsIgnoreCase(endpoint.getScheme()) ? "wss" : "ws";
|
||||
return new URI(scheme, null, endpoint.getHost(), endpoint.getPort(), discovered.getPath(), discovered.getQuery(), discovered.getFragment());
|
||||
} catch (URISyntaxException ex) {
|
||||
throw new IllegalArgumentException("CDP WebSocket 地址无效", ex);
|
||||
}
|
||||
}
|
||||
|
||||
static JsonNode selectPage(ArrayNode pages, String origin) {
|
||||
URI expected = URI.create(origin);
|
||||
JsonNode fallback = null;
|
||||
for (JsonNode page : pages) {
|
||||
if (!"page".equals(page.path("type").asText(""))) continue;
|
||||
String url = page.path("url").asText("");
|
||||
if (!url.startsWith(origin)) continue;
|
||||
if (!url.contains("/login")) return page;
|
||||
if (page.path("webSocketDebuggerUrl").asText("").isBlank()) continue;
|
||||
URI actual;
|
||||
try { actual = URI.create(url); } catch (IllegalArgumentException ex) { continue; }
|
||||
if (!sameOrigin(expected, actual)) continue;
|
||||
String path = actual.getPath() == null ? "" : actual.getPath();
|
||||
if (!path.equals("/login") && !path.startsWith("/login/")) return page;
|
||||
fallback = page;
|
||||
}
|
||||
return fallback;
|
||||
}
|
||||
|
||||
private static boolean sameOrigin(URI expected, URI actual) {
|
||||
return expected.getScheme() != null && expected.getScheme().equalsIgnoreCase(actual.getScheme())
|
||||
&& expected.getHost() != null && expected.getHost().equalsIgnoreCase(actual.getHost())
|
||||
&& effectivePort(expected) == effectivePort(actual);
|
||||
}
|
||||
|
||||
private static int effectivePort(URI uri) {
|
||||
if (uri.getPort() >= 0) return uri.getPort();
|
||||
return "https".equalsIgnoreCase(uri.getScheme()) ? 443 : 80;
|
||||
}
|
||||
|
||||
static long expiresAt(String token, LingxingProviderConfig config) {
|
||||
long now = System.currentTimeMillis();
|
||||
try {
|
||||
|
||||
@@ -18,12 +18,15 @@ import java.nio.charset.StandardCharsets;
|
||||
import java.security.MessageDigest;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Base64;
|
||||
import java.util.concurrent.CompletionException;
|
||||
import java.util.List;
|
||||
import java.util.Locale;
|
||||
import java.util.Map;
|
||||
import java.util.TreeMap;
|
||||
import java.math.BigDecimal;
|
||||
import java.math.RoundingMode;
|
||||
|
||||
/** 领星新品查询只读客户端。不会调用 /measure。 */
|
||||
/** 领星新品查询与人工尺寸维护客户端;自动任务不会调用本客户端的写入入口。 */
|
||||
@Component
|
||||
public class LingxingDirectClient {
|
||||
private final ObjectMapper mapper;
|
||||
@@ -41,14 +44,68 @@ public class LingxingDirectClient {
|
||||
public LingxingTestDtos.Response test(String skuCode) {
|
||||
String sku = skuCode == null ? "" : skuCode.trim();
|
||||
if (sku.isBlank()) throw new IllegalArgumentException("SKU不能为空");
|
||||
LingxingAuthState auth = authManager.getAuthState(new LingxingProviderConfig(
|
||||
LingxingAuthState auth = awaitAuth(authManager.getAuthState(new LingxingProviderConfig(
|
||||
properties.getProviderKey(), properties.getBrowserCdpEndpoint(), properties.isBrowserSessionRequired(),
|
||||
properties.getTokenTtlSeconds(), properties.getTokenExpirySafetySeconds()), properties.getLoginOrigin()).join();
|
||||
properties.getTokenTtlSeconds(), properties.getTokenExpirySafetySeconds(),
|
||||
properties.getUsername(), properties.getPassword()), properties.getLoginOrigin()));
|
||||
List<LingxingTestDtos.WarehouseResult> results = new ArrayList<>();
|
||||
for (String whCode : properties.getWarehouses()) results.add(query(auth, sku, whCode));
|
||||
for (String whCode : properties.getWarehouses()) results.add(queryWithReLogin(auth, sku, whCode));
|
||||
return new LingxingTestDtos.Response(sku, results);
|
||||
}
|
||||
|
||||
/** 手工维护领星新品尺寸;只对精确命中的、尚未维护的仓库发起一次 /measure。 */
|
||||
public PushResult push(String skuCode, BigDecimal lengthCm, BigDecimal widthCm,
|
||||
BigDecimal heightCm, BigDecimal weightKg) {
|
||||
String sku = skuCode == null ? "" : skuCode.trim();
|
||||
if (sku.isBlank()) throw new IllegalArgumentException("SKU不能为空");
|
||||
LingxingAuthState auth = awaitAuth(authManager.getAuthState(new LingxingProviderConfig(
|
||||
properties.getProviderKey(), properties.getBrowserCdpEndpoint(), properties.isBrowserSessionRequired(),
|
||||
properties.getTokenTtlSeconds(), properties.getTokenExpirySafetySeconds(),
|
||||
properties.getUsername(), properties.getPassword()), properties.getLoginOrigin()));
|
||||
List<PushWarehouseResult> results = new ArrayList<>();
|
||||
for (String whCode : properties.getWarehouses()) {
|
||||
try {
|
||||
JsonNode record;
|
||||
try {
|
||||
record = findExact(auth, sku, whCode);
|
||||
} catch (IllegalStateException unauthorized) {
|
||||
if (!"领星登录态已失效".equals(unauthorized.getMessage())) throw unauthorized;
|
||||
authManager.invalidate(properties.getProviderKey(), "lingxing_push_unauthorized");
|
||||
auth = awaitAuth(authManager.getAuthState(providerConfig(), properties.getLoginOrigin()));
|
||||
record = findExact(auth, sku, whCode);
|
||||
}
|
||||
if (record == null) {
|
||||
results.add(new PushWarehouseResult(whCode, false, "未匹配到领星 SKU"));
|
||||
} else if (record.path("maintained").asInt(0) == 1) {
|
||||
results.add(new PushWarehouseResult(whCode, false, "领星 SKU 已维护,不能重复维护"));
|
||||
} else {
|
||||
try {
|
||||
measure(auth, record, lengthCm, widthCm, heightCm, weightKg);
|
||||
} catch (IllegalStateException unauthorized) {
|
||||
if (!"领星登录态已失效".equals(unauthorized.getMessage())) throw unauthorized;
|
||||
authManager.invalidate(properties.getProviderKey(), "lingxing_measure_unauthorized");
|
||||
auth = awaitAuth(authManager.getAuthState(providerConfig(), properties.getLoginOrigin()));
|
||||
JsonNode refreshedRecord = findExact(auth, sku, whCode);
|
||||
if (refreshedRecord == null || refreshedRecord.path("maintained").asInt(0) == 1) {
|
||||
results.add(new PushWarehouseResult(whCode, false, "领星 SKU 已维护,不能重复维护"));
|
||||
continue;
|
||||
}
|
||||
measure(auth, refreshedRecord, lengthCm, widthCm, heightCm, weightKg);
|
||||
}
|
||||
results.add(new PushWarehouseResult(whCode, true, "提交成功"));
|
||||
}
|
||||
} catch (Exception ex) {
|
||||
if (ex instanceof LingxingSecondaryVerificationException
|
||||
|| ex instanceof LingxingBrowserChallengeException) throw (RuntimeException) ex;
|
||||
results.add(new PushWarehouseResult(whCode, false, "提交领星失败"));
|
||||
}
|
||||
}
|
||||
boolean success = results.stream().anyMatch(PushWarehouseResult::success)
|
||||
&& results.stream().filter(r -> r.matched()).allMatch(PushWarehouseResult::success);
|
||||
String lingxingSkuId = sku;
|
||||
return new PushResult(success, lingxingSkuId, results);
|
||||
}
|
||||
|
||||
private LingxingTestDtos.WarehouseResult query(LingxingAuthState auth, String sku, String whCode) {
|
||||
try {
|
||||
ObjectNode body = mapper.createObjectNode().put("barcode", sku).put("current", 1)
|
||||
@@ -63,7 +120,7 @@ public class LingxingDirectClient {
|
||||
.header("Track-Key", trackKey("POST", json)).POST(HttpRequest.BodyPublishers.ofString(json)).build();
|
||||
HttpResponse<String> response = http.send(request, HttpResponse.BodyHandlers.ofString());
|
||||
JsonNode root = mapper.readTree(response.body());
|
||||
if (response.statusCode() == 401) {
|
||||
if (response.statusCode() == 401 || isUnauthorized(root)) {
|
||||
authManager.invalidate(properties.getProviderKey(), "lingxing_test_unauthorized");
|
||||
return new LingxingTestDtos.WarehouseResult(whCode, false, false, false, null, null, null, null, null, null, "领星登录态已失效,请重新登录");
|
||||
}
|
||||
@@ -82,18 +139,113 @@ public class LingxingDirectClient {
|
||||
}
|
||||
}
|
||||
|
||||
private LingxingTestDtos.WarehouseResult queryWithReLogin(LingxingAuthState initial, String sku, String whCode) {
|
||||
LingxingTestDtos.WarehouseResult result = query(initial, sku, whCode);
|
||||
if (result.reachable() || result.message() == null || !result.message().contains("登录态已失效")) return result;
|
||||
authManager.invalidate(properties.getProviderKey(), "lingxing_test_unauthorized");
|
||||
LingxingAuthState refreshed = awaitAuth(authManager.getAuthState(providerConfig(), properties.getLoginOrigin()));
|
||||
return query(refreshed, sku, whCode);
|
||||
}
|
||||
|
||||
/** Keep domain login/challenge exceptions visible to the REST controller. */
|
||||
private static LingxingAuthState awaitAuth(java.util.concurrent.CompletableFuture<LingxingAuthState> future) {
|
||||
try {
|
||||
return future.join();
|
||||
} catch (CompletionException ex) {
|
||||
Throwable cause = ex;
|
||||
while (cause instanceof CompletionException && cause.getCause() != null) cause = cause.getCause();
|
||||
if (cause instanceof RuntimeException runtime) throw runtime;
|
||||
throw ex;
|
||||
}
|
||||
}
|
||||
|
||||
private JsonNode findExact(LingxingAuthState auth, String sku, String whCode) throws Exception {
|
||||
ObjectNode body = mapper.createObjectNode().put("barcode", sku).put("current", 1)
|
||||
.put("customerCode", "").put("keyword", sku).put("multiKeyword", "")
|
||||
.put("searchType", "barcode").put("size", 20).put("whCode", whCode);
|
||||
String json = canonical(body);
|
||||
HttpRequest request = HttpRequest.newBuilder(URI.create(strip(properties.getBaseUrl()) + "/gateway/wms/newProduct/page"))
|
||||
.timeout(properties.getRequestTimeout()).header("Content-Type", "application/json")
|
||||
.header("Authorization", "Bearer " + auth.token()).header("Cookie", auth.cookies())
|
||||
.header("Origin", properties.getLoginOrigin()).header("Referer", strip(properties.getLoginOrigin()) + "/")
|
||||
.header("lang", "zh").header("version", "prod").header("Track-Key", trackKey("POST", json))
|
||||
.POST(HttpRequest.BodyPublishers.ofString(json)).build();
|
||||
HttpResponse<String> response = http.send(request, HttpResponse.BodyHandlers.ofString());
|
||||
JsonNode root = mapper.readTree(response.body());
|
||||
if (response.statusCode() == 401 || isUnauthorized(root)) {
|
||||
authManager.invalidate(properties.getProviderKey(), "lingxing_push_unauthorized");
|
||||
throw new IllegalStateException("领星登录态已失效");
|
||||
}
|
||||
if (root.path("code").asInt(-1) != 200) throw new IllegalStateException("领星查询失败");
|
||||
for (JsonNode record : root.path("data").path("records")) {
|
||||
if (sku.equals(record.path("productSku").asText()) || sku.equals(record.path("barcode").asText())) return record;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private LingxingProviderConfig providerConfig() {
|
||||
return new LingxingProviderConfig(properties.getProviderKey(), properties.getBrowserCdpEndpoint(),
|
||||
properties.isBrowserSessionRequired(), properties.getTokenTtlSeconds(),
|
||||
properties.getTokenExpirySafetySeconds(), properties.getUsername(), properties.getPassword());
|
||||
}
|
||||
|
||||
private void measure(LingxingAuthState auth, JsonNode record, BigDecimal lengthCm,
|
||||
BigDecimal widthCm, BigDecimal heightCm, BigDecimal weightKg) throws Exception {
|
||||
ObjectNode body = (ObjectNode) record.deepCopy();
|
||||
body.put("wmsLength", number(lengthCm));
|
||||
body.put("wmsLengthBs", number(lengthCm.divide(BigDecimal.valueOf(2.54), 3, RoundingMode.HALF_UP)));
|
||||
body.put("wmsWidth", number(widthCm));
|
||||
body.put("wmsWidthBs", number(widthCm.divide(BigDecimal.valueOf(2.54), 3, RoundingMode.HALF_UP)));
|
||||
body.put("wmsHeight", number(heightCm));
|
||||
body.put("wmsHeightBs", number(heightCm.divide(BigDecimal.valueOf(2.54), 3, RoundingMode.HALF_UP)));
|
||||
// The successful browser request uses display units in/lb for the original
|
||||
// product fields, while wms* fields remain the submitted cm/kg values.
|
||||
body.put("sizeUnit", "in");
|
||||
body.put("wmsSizeUnit", "cm");
|
||||
body.put("wmsWeight", number(weightKg));
|
||||
body.put("wmsWeightBs", number(weightKg.multiply(BigDecimal.valueOf(2.2046226218)).setScale(3, RoundingMode.HALF_UP)));
|
||||
body.put("weightUnit", "lb");
|
||||
body.put("wmsWeightUnit", "kg");
|
||||
body.put("wmsUnitMark", record.path("wmsUnitMark").asInt(0));
|
||||
String json = canonical(body);
|
||||
HttpRequest request = HttpRequest.newBuilder(URI.create(strip(properties.getBaseUrl()) + "/gateway/wms/newProduct/measure"))
|
||||
.timeout(properties.getRequestTimeout()).header("Content-Type", "application/json")
|
||||
.header("Authorization", "Bearer " + auth.token()).header("Cookie", auth.cookies())
|
||||
.header("Origin", properties.getLoginOrigin()).header("Referer", strip(properties.getLoginOrigin()) + "/")
|
||||
.header("lang", "zh").header("version", "prod").header("Track-Key", trackKey("POST", json))
|
||||
.POST(HttpRequest.BodyPublishers.ofString(json)).build();
|
||||
HttpResponse<String> response = http.send(request, HttpResponse.BodyHandlers.ofString());
|
||||
JsonNode root = mapper.readTree(response.body());
|
||||
if (response.statusCode() == 401 || isUnauthorized(root)) {
|
||||
authManager.invalidate(properties.getProviderKey(), "lingxing_measure_unauthorized");
|
||||
throw new IllegalStateException("领星登录态已失效");
|
||||
}
|
||||
if (root.path("code").asInt(-1) != 200) throw new IllegalStateException("领星维护失败");
|
||||
}
|
||||
|
||||
private static String number(BigDecimal value) { return value.stripTrailingZeros().toPlainString(); }
|
||||
|
||||
public record PushWarehouseResult(String whCode, boolean success, String message) {
|
||||
public boolean matched() { return !"未匹配到领星 SKU".equals(message); }
|
||||
}
|
||||
public record PushResult(boolean success, String lingxingSkuId, List<PushWarehouseResult> warehouses) {}
|
||||
|
||||
private LingxingTestDtos.WarehouseResult empty(String whCode, boolean reachable, String message) {
|
||||
return new LingxingTestDtos.WarehouseResult(whCode, reachable, false, false, null, null, null, null, null, null, message);
|
||||
}
|
||||
private static String text(JsonNode node, String key) { return node.path(key).isMissingNode() ? null : node.path(key).asText(null); }
|
||||
private static java.math.BigDecimal decimal(JsonNode node, String key) { return node.path(key).isNumber() ? node.path(key).decimalValue() : null; }
|
||||
private static boolean isUnauthorized(JsonNode root) {
|
||||
int code = root.path("code").asInt(-1);
|
||||
return code == 401 || code == 40101 || code == 40102 || code == 40301;
|
||||
}
|
||||
static String buildTrackKey(String method, String json, String secretBase64) {
|
||||
if (secretBase64 == null || secretBase64.isBlank()) throw new IllegalStateException("未配置领星 Track-Key v2 密钥");
|
||||
try {
|
||||
byte[] digest = MessageDigest.getInstance("SHA-256").digest(json.getBytes(StandardCharsets.UTF_8));
|
||||
StringBuilder hex = new StringBuilder(); for (byte b : digest) hex.append(String.format(Locale.ROOT, "%02x", b));
|
||||
byte[] key = Base64.getDecoder().decode(secretBase64);
|
||||
if (key.length != 44) throw new IllegalStateException("领星 Track-Key v2 密钥解码后必须是 44 字节");
|
||||
if (key.length == 0) throw new IllegalStateException("领星 Track-Key v2 密钥解码后不能为空");
|
||||
Mac mac = Mac.getInstance("HmacSHA256"); mac.init(new SecretKeySpec(key, "HmacSHA256"));
|
||||
return "v2:" + Base64.getEncoder().encodeToString(mac.doFinal((method.toUpperCase(Locale.ROOT) + hex).getBytes(StandardCharsets.UTF_8)));
|
||||
} catch (Exception ex) { throw new IllegalStateException("领星 Track-Key 生成失败", ex); }
|
||||
|
||||
@@ -3,10 +3,27 @@ package com.baiyue.firstleg.lingxing;
|
||||
/** 领星账号运行配置;密码和签名密钥不进入此对象日志。 */
|
||||
public record LingxingProviderConfig(String providerKey, String browserCdpEndpoint,
|
||||
boolean browserSessionRequired, long tokenTtlSeconds,
|
||||
long expirySafetySeconds) {
|
||||
long expirySafetySeconds, String username, String password) {
|
||||
public LingxingProviderConfig(String providerKey, String browserCdpEndpoint,
|
||||
boolean browserSessionRequired, long tokenTtlSeconds,
|
||||
long expirySafetySeconds) {
|
||||
this(providerKey, browserCdpEndpoint, browserSessionRequired, tokenTtlSeconds,
|
||||
expirySafetySeconds, null, null);
|
||||
}
|
||||
public LingxingProviderConfig {
|
||||
if (providerKey == null || providerKey.isBlank()) throw new IllegalArgumentException("providerKey不能为空");
|
||||
if (tokenTtlSeconds <= 0) tokenTtlSeconds = 172800;
|
||||
if (expirySafetySeconds < 0) expirySafetySeconds = 300;
|
||||
}
|
||||
|
||||
@Override
|
||||
public String toString() {
|
||||
return "LingxingProviderConfig[providerKey=" + providerKey
|
||||
+ ", browserCdpEndpoint=" + browserCdpEndpoint
|
||||
+ ", browserSessionRequired=" + browserSessionRequired
|
||||
+ ", tokenTtlSeconds=" + tokenTtlSeconds
|
||||
+ ", expirySafetySeconds=" + expirySafetySeconds
|
||||
+ ", usernamePresent=" + (username != null && !username.isBlank())
|
||||
+ ", passwordPresent=" + (password != null && !password.isBlank()) + "]";
|
||||
}
|
||||
}
|
||||
|
||||
@@ -61,4 +61,47 @@ public class MojiaSkuSyncRepository {
|
||||
.set(MojiaMeasurementReceiptEntity::getStatus, "SKU_PENDING_SYNC")
|
||||
.set(MojiaMeasurementReceiptEntity::getRevision, revision + 1));
|
||||
}
|
||||
|
||||
/**
|
||||
* A secondary-verification challenge happens before the Lingxing request is sent.
|
||||
* Release the optimistic claim so the original UI action can resume with its
|
||||
* original revision after the code is submitted.
|
||||
*/
|
||||
public int restorePendingClaim(long id, int claimedRevision) {
|
||||
return mapper.update(null, new LambdaUpdateWrapper<MojiaMeasurementReceiptEntity>()
|
||||
.eq(MojiaMeasurementReceiptEntity::getId, id)
|
||||
.eq(MojiaMeasurementReceiptEntity::getMeasurementKind, "SKU")
|
||||
.eq(MojiaMeasurementReceiptEntity::getRevision, claimedRevision)
|
||||
.eq(MojiaMeasurementReceiptEntity::getSyncStatus, "PENDING")
|
||||
.set(MojiaMeasurementReceiptEntity::getSyncStatus, "PENDING")
|
||||
.set(MojiaMeasurementReceiptEntity::getSyncFailureCode, null)
|
||||
.set(MojiaMeasurementReceiptEntity::getSyncFailureReason, null)
|
||||
.set(MojiaMeasurementReceiptEntity::getStatus, "SKU_PENDING_SYNC")
|
||||
.set(MojiaMeasurementReceiptEntity::getRevision, claimedRevision - 1));
|
||||
}
|
||||
|
||||
public int markSuccess(long id, int revision, String lingxingSkuId) {
|
||||
return mapper.update(null, new LambdaUpdateWrapper<MojiaMeasurementReceiptEntity>()
|
||||
.eq(MojiaMeasurementReceiptEntity::getId, id)
|
||||
.eq(MojiaMeasurementReceiptEntity::getMeasurementKind, "SKU")
|
||||
.eq(MojiaMeasurementReceiptEntity::getRevision, revision)
|
||||
.set(MojiaMeasurementReceiptEntity::getSyncStatus, "SUCCESS")
|
||||
.set(MojiaMeasurementReceiptEntity::getSyncFailureCode, null)
|
||||
.set(MojiaMeasurementReceiptEntity::getSyncFailureReason, null)
|
||||
.set(MojiaMeasurementReceiptEntity::getLingxingSkuId, lingxingSkuId)
|
||||
.set(MojiaMeasurementReceiptEntity::getStatus, "SKU_SYNCED")
|
||||
.set(MojiaMeasurementReceiptEntity::getRevision, revision + 1));
|
||||
}
|
||||
|
||||
public int markFailed(long id, int revision, String code, String reason) {
|
||||
return mapper.update(null, new LambdaUpdateWrapper<MojiaMeasurementReceiptEntity>()
|
||||
.eq(MojiaMeasurementReceiptEntity::getId, id)
|
||||
.eq(MojiaMeasurementReceiptEntity::getMeasurementKind, "SKU")
|
||||
.eq(MojiaMeasurementReceiptEntity::getRevision, revision)
|
||||
.set(MojiaMeasurementReceiptEntity::getSyncStatus, "FAILED")
|
||||
.set(MojiaMeasurementReceiptEntity::getSyncFailureCode, code)
|
||||
.set(MojiaMeasurementReceiptEntity::getSyncFailureReason, reason)
|
||||
.set(MojiaMeasurementReceiptEntity::getStatus, "SKU_SYNC_FAILED")
|
||||
.set(MojiaMeasurementReceiptEntity::getRevision, revision + 1));
|
||||
}
|
||||
}
|
||||
|
||||
+18
-1
@@ -23,6 +23,7 @@ import org.slf4j.LoggerFactory;
|
||||
import org.slf4j.MDC;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.web.multipart.MultipartFile;
|
||||
|
||||
import java.io.ByteArrayInputStream;
|
||||
@@ -49,17 +50,20 @@ public class MojiaMeasurementOpenApiService {
|
||||
private final MeasurementImageClient imageClient;
|
||||
private final MojiaMeasurementReceiptRepository receipts;
|
||||
private final InboundCartonLabelService cartonLabels;
|
||||
private final MojiaSkuSyncService skuSync;
|
||||
|
||||
@Value("${baiyue.security.internal-service-token}")
|
||||
private String internalToken;
|
||||
|
||||
@Autowired
|
||||
public MojiaMeasurementOpenApiService(MeasurementRequestAuthenticator authenticator,
|
||||
CartonMeasurementService cartonMeasurements,
|
||||
ObjectMapper objectMapper,
|
||||
Validator validator,
|
||||
MeasurementImageClient imageClient,
|
||||
MojiaMeasurementReceiptRepository receipts,
|
||||
InboundCartonLabelService cartonLabels) {
|
||||
InboundCartonLabelService cartonLabels,
|
||||
MojiaSkuSyncService skuSync) {
|
||||
this.authenticator = authenticator;
|
||||
this.cartonMeasurements = cartonMeasurements;
|
||||
this.objectMapper = objectMapper;
|
||||
@@ -67,6 +71,18 @@ public class MojiaMeasurementOpenApiService {
|
||||
this.imageClient = imageClient;
|
||||
this.receipts = receipts;
|
||||
this.cartonLabels = cartonLabels;
|
||||
this.skuSync = skuSync;
|
||||
}
|
||||
|
||||
/** Test/embedded construction compatibility; production wiring uses the constructor above. */
|
||||
public MojiaMeasurementOpenApiService(MeasurementRequestAuthenticator authenticator,
|
||||
CartonMeasurementService cartonMeasurements,
|
||||
ObjectMapper objectMapper,
|
||||
Validator validator,
|
||||
MeasurementImageClient imageClient,
|
||||
MojiaMeasurementReceiptRepository receipts,
|
||||
InboundCartonLabelService cartonLabels) {
|
||||
this(authenticator, cartonMeasurements, objectMapper, validator, imageClient, receipts, cartonLabels, null);
|
||||
}
|
||||
|
||||
public UploadResponse receive(MeasurementRequestHeaders headers, String rawJson) {
|
||||
@@ -159,6 +175,7 @@ public class MojiaMeasurementOpenApiService {
|
||||
receipt.setCreatedAt(LocalDateTime.now());
|
||||
receipt.setUpdatedAt(LocalDateTime.now());
|
||||
receipts.insert(receipt);
|
||||
if (skuSync != null) skuSync.autoSync(receiptId);
|
||||
return new UploadResponse(true, false, receiptId, null, null,
|
||||
request.boxCode(), uploaded.fileId(), true);
|
||||
} catch (RuntimeException exception) {
|
||||
|
||||
@@ -7,19 +7,39 @@ import com.baiyue.firstleg.config.LingxingSyncProperties;
|
||||
import com.baiyue.firstleg.dto.MojiaSkuSyncDtos;
|
||||
import com.baiyue.firstleg.entity.MojiaMeasurementReceiptEntity;
|
||||
import com.baiyue.firstleg.repository.MojiaSkuSyncRepository;
|
||||
import com.baiyue.firstleg.lingxing.LingxingDirectClient;
|
||||
import com.baiyue.firstleg.lingxing.LingxingBrowserChallengeException;
|
||||
import com.baiyue.firstleg.lingxing.LingxingSecondaryVerificationException;
|
||||
import jakarta.annotation.Resource;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
|
||||
@Service
|
||||
public class MojiaSkuSyncService {
|
||||
private static final Logger log = LoggerFactory.getLogger(MojiaSkuSyncService.class);
|
||||
@Resource private MojiaSkuSyncRepository repository;
|
||||
@Resource private LingxingSyncProperties lingxingProperties;
|
||||
@Resource private LingxingDirectClient lingxing;
|
||||
|
||||
public MojiaSkuSyncDtos.Config config() {
|
||||
return new MojiaSkuSyncDtos.Config(lingxingProperties.isAutoSyncEnabled());
|
||||
}
|
||||
|
||||
/** Called after a SKU receipt is durably saved; the Nacos switch is deliberately the only gate. */
|
||||
public void autoSync(long id) {
|
||||
if (!lingxingProperties.isAutoSyncEnabled()) return;
|
||||
try {
|
||||
MojiaMeasurementReceiptEntity value = required(id);
|
||||
retry(id, new MojiaSkuSyncDtos.RetryRequest(value.getRevision()));
|
||||
} catch (LingxingSecondaryVerificationException | LingxingBrowserChallengeException ex) {
|
||||
log.warn("领星自动同步等待人工验证 receiptId={} reason={}", id, ex.getMessage());
|
||||
} catch (RuntimeException ex) {
|
||||
log.warn("领星自动同步失败 receiptId={}", id, ex);
|
||||
}
|
||||
}
|
||||
|
||||
public PageResult<MojiaSkuSyncDtos.Record> page(MojiaSkuSyncDtos.Query query) {
|
||||
PageResult<MojiaMeasurementReceiptEntity> source = repository.page(
|
||||
query.skuCode(), query.syncStatus(), query.page(), query.size());
|
||||
@@ -36,12 +56,36 @@ public class MojiaSkuSyncService {
|
||||
return view(required(id));
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public MojiaSkuSyncDtos.Record retry(long id, MojiaSkuSyncDtos.RetryRequest request) {
|
||||
required(id);
|
||||
if (repository.retry(id, request.revision()) != 1) {
|
||||
throw conflict("仅待同步或同步失败记录可推送,或记录已被其他人修改");
|
||||
}
|
||||
MojiaMeasurementReceiptEntity claimed = required(id);
|
||||
LingxingDirectClient.PushResult result;
|
||||
try {
|
||||
result = lingxing.push(claimed.getBoxCode(), claimed.getLengthCm(), claimed.getWidthCm(),
|
||||
claimed.getHeightCm(), claimed.getWeightKg());
|
||||
} catch (RuntimeException ex) {
|
||||
if (ex instanceof LingxingSecondaryVerificationException
|
||||
|| ex instanceof LingxingBrowserChallengeException) {
|
||||
repository.restorePendingClaim(id, claimed.getRevision());
|
||||
throw ex;
|
||||
}
|
||||
repository.markFailed(id, claimed.getRevision(), "PUSH_ERROR", "提交领星失败");
|
||||
return view(required(id));
|
||||
}
|
||||
if (result.success()) {
|
||||
if (repository.markSuccess(id, claimed.getRevision(), result.lingxingSkuId()) != 1) {
|
||||
throw conflict("推送完成但记录已被其他人修改,请刷新查看结果");
|
||||
}
|
||||
} else {
|
||||
String reason = result.warehouses().stream().map(LingxingDirectClient.PushWarehouseResult::message)
|
||||
.filter(message -> message != null && !message.isBlank()).findFirst().orElse("提交领星失败");
|
||||
if (repository.markFailed(id, claimed.getRevision(), "PUSH_FAILED", reason) != 1) {
|
||||
throw conflict("推送失败但记录已被其他人修改,请刷新查看结果");
|
||||
}
|
||||
}
|
||||
return view(required(id));
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user