This commit is contained in:
2026-09-23 08:58:56 +08:00
parent 2555eda797
commit 5c01b34503
23 changed files with 790 additions and 54 deletions
+6 -2
View File
@@ -39,9 +39,13 @@ firstleg:
# 安全默认关闭;开启后自动任务才允许调用领星新品维护接口。
auto-sync-enabled: ${LINGXING_AUTO_SYNC_ENABLED:false}
base-url: ${LINGXING_DIRECT_BASE_URL:https://wms.xlwms.com}
login-origin: ${LINGXING_LOGIN_ORIGIN:https://oms.xlwms.com}
login-origin: ${LINGXING_LOGIN_ORIGIN:https://wms.xlwms.com}
provider-key: ${LINGXING_PROVIDER_KEY:LINGXING_01}
browser-cdp-endpoint: ${LINGXING_BROWSER_CDP_ENDPOINT:}
# Firstleg and the isolated Lingxing browser run on the same Docker network.
# Keep the environment override for deployments that use another endpoint.
browser-cdp-endpoint: ${LINGXING_BROWSER_CDP_ENDPOINT:http://lingxing-browser:9222}
username: ${LINGXING_USERNAME:}
password: ${LINGXING_PASSWORD:}
browser-session-required: ${LINGXING_BROWSER_SESSION_REQUIRED:true}
token-ttl-seconds: ${LINGXING_TOKEN_TTL_SECONDS:172800}
token-expiry-safety-seconds: ${LINGXING_TOKEN_EXPIRY_SAFETY_SECONDS:300}
+6 -2
View File
@@ -25,9 +25,13 @@ firstleg:
lingxing:
auto-sync-enabled: ${LINGXING_AUTO_SYNC_ENABLED:false}
base-url: ${LINGXING_DIRECT_BASE_URL:https://wms.xlwms.com}
login-origin: ${LINGXING_LOGIN_ORIGIN:https://oms.xlwms.com}
login-origin: ${LINGXING_LOGIN_ORIGIN:https://wms.xlwms.com}
provider-key: ${LINGXING_PROVIDER_KEY:LINGXING_01}
browser-cdp-endpoint: ${LINGXING_BROWSER_CDP_ENDPOINT:}
# Firstleg and the isolated Lingxing browser run on the same Docker network.
# Keep the environment override for deployments that use another endpoint.
browser-cdp-endpoint: ${LINGXING_BROWSER_CDP_ENDPOINT:http://lingxing-browser:9222}
username: ${LINGXING_USERNAME:}
password: ${LINGXING_PASSWORD:}
browser-session-required: ${LINGXING_BROWSER_SESSION_REQUIRED:true}
token-ttl-seconds: ${LINGXING_TOKEN_TTL_SECONDS:172800}
token-expiry-safety-seconds: ${LINGXING_TOKEN_EXPIRY_SAFETY_SECONDS:300}
@@ -0,0 +1,31 @@
name: firstleg-lingxing-${DEPLOY_ENV:-development}
services:
lingxing-browser:
build:
context: ./docker/lingxing-browser
image: ${LINGXING_BROWSER_IMAGE:-firstleg-lingxing-browser:dev}
container_name: firstleg-${DEPLOY_ENV:-development}-lingxing-browser
restart: unless-stopped
init: true
ports:
- "127.0.0.1:${LINGXING_NOVNC_PORT:-6080}:6080"
volumes:
- lingxing-browser-profile:/profile
networks:
- firstleg-network
healthcheck:
test: ["CMD-SHELL", "wget -q -O - http://127.0.0.1:9222/json/version >/dev/null || exit 1"]
interval: 15s
timeout: 5s
start_period: 30s
retries: 5
networks:
firstleg-network:
external: true
name: firstleg-${DEPLOY_ENV:-development}-network
volumes:
lingxing-browser-profile:
name: firstleg-${DEPLOY_ENV:-development}-lingxing-browser-profile
+20
View File
@@ -0,0 +1,20 @@
FROM alpine:3.21
ARG ALPINE_MIRROR=https://mirrors.aliyun.com/alpine
RUN sed -i "s#https://dl-cdn.alpinelinux.org/alpine#${ALPINE_MIRROR}#g" /etc/apk/repositories \
&& apk add --no-cache chromium font-noto-cjk novnc openbox socat su-exec tigervnc websockify xvfb \
&& adduser -D -u 1000 browser \
&& ln -sf /usr/share/novnc/vnc_lite.html /usr/share/novnc/index.html
COPY entrypoint.sh /usr/local/bin/lingxing-browser-entrypoint
RUN chmod 0755 /usr/local/bin/lingxing-browser-entrypoint
ENV DISPLAY=:99 \
LINGXING_LOGIN_URL=https://wms.xlwms.com/login \
PROFILE_DIR=/profile
VOLUME ["/profile"]
EXPOSE 6080 9222
ENTRYPOINT ["/usr/local/bin/lingxing-browser-entrypoint"]
@@ -0,0 +1,38 @@
#!/bin/sh
set -eu
mkdir -p "$PROFILE_DIR" /tmp/lingxing-runtime /tmp/.X11-unix
rm -f /tmp/.X99-lock /tmp/.X11-unix/X99
rm -f "$PROFILE_DIR/SingletonCookie" "$PROFILE_DIR/SingletonLock" "$PROFILE_DIR/SingletonSocket"
chmod 1777 /tmp/.X11-unix
chown -R browser:browser "$PROFILE_DIR" /tmp/lingxing-runtime
chmod 0700 "$PROFILE_DIR" /tmp/lingxing-runtime
cleanup() {
for pid in "${CHROMIUM_PID:-}" "${CDP_PROXY_PID:-}" "${NOVNC_PID:-}" "${VNC_PID:-}" "${OPENBOX_PID:-}" "${XVFB_PID:-}"; do
[ -n "$pid" ] && kill "$pid" 2>/dev/null || true
done
}
trap cleanup EXIT INT TERM
su-exec browser Xvfb "$DISPLAY" -screen 0 1440x900x24 -nolisten tcp & XVFB_PID=$!
sleep 1
su-exec browser env DISPLAY="$DISPLAY" XDG_RUNTIME_DIR=/tmp/lingxing-runtime openbox & OPENBOX_PID=$!
su-exec browser x0vncserver -display "$DISPLAY" -localhost -rfbport 5900 -SecurityTypes None -AlwaysShared & VNC_PID=$!
su-exec browser websockify --web=/usr/share/novnc 0.0.0.0:6080 127.0.0.1:5900 & NOVNC_PID=$!
su-exec browser socat TCP-LISTEN:9222,bind=0.0.0.0,reuseaddr,fork TCP:127.0.0.1:9223 & CDP_PROXY_PID=$!
su-exec browser env DISPLAY="$DISPLAY" XDG_RUNTIME_DIR=/tmp/lingxing-runtime chromium-browser \
--user-data-dir="$PROFILE_DIR" \
--remote-debugging-address=0.0.0.0 \
--remote-debugging-port=9223 \
--window-size=1440,900 \
--disable-gpu \
--use-gl=swiftshader \
--disable-dev-shm-usage \
--no-first-run \
--no-default-browser-check \
--no-sandbox \
"$LINGXING_LOGIN_URL" & CHROMIUM_PID=$!
wait "$CHROMIUM_PID"
+84
View File
@@ -0,0 +1,84 @@
# 开发环境独立领星浏览器
该浏览器只供开发环境 Java Firstleg 使用,使用独立 Docker volume,不复用其他 Node.js 账号的 profile。
## 启动
项目开发部署脚本使用 `DEPLOY_ENV=development`,因此先确保 Java 服务所在 Docker 网络已经存在:
```bash
docker network inspect firstleg-development-network >/dev/null
```
在仓库 `baiyue-firstleg` 根目录执行:
```bash
DEPLOY_ENV=development docker compose \
-f deploy/docker-compose.lingxing-browser.yml \
up -d --build
```
Java 容器加入同一个 `firstleg-development-network` 后,Nacos 使用:
```yaml
firstleg:
lingxing:
login-origin: https://wms.xlwms.com
browser-cdp-endpoint: http://lingxing-browser:9222
auto-sync-enabled: false
```
已有开发数据库需要单独执行一次 SKU 同步权限脚本(目标库为 `baiyue_iam`),否则页面菜单会因没有权限而隐藏、接口会返回 403:
```bash
mysql -h <开发数据库地址> -u <账号> -p baiyue_iam \
< iam/src/main/resources/db/mojia-sku-sync-permissions.sql
```
执行后让后台账号重新登录,使新的授权快照生效。
账号密码通过 Nacos 的加密配置保存:
```yaml
firstleg:
lingxing:
username: ${LINGXING_USERNAME}
password: ${LINGXING_PASSWORD}
```
不要把真实账号、密码、Cookie、Token 或签名密钥写入仓库文件。
## 首次登录
浏览器容器启动后,noVNC 只绑定宿主机回环地址:
```text
http://127.0.0.1:6080/?autoconnect=1&resize=scale
```
从个人电脑查看开发服务器上的 noVNC 时,先建立 SSH 隧道:
```bash
ssh -N -L 6080:127.0.0.1:6080 <开发服务器用户>@<开发服务器地址>
```
然后在个人电脑浏览器打开上面的地址。CDP 不需要暴露到宿主机,Java 通过 Docker 网络访问 `lingxing-browser:9222`。
正常情况下不需要先人工登录。点击后台测试连接或人工推送时,Java 会自动填写账号密码。遇到腾讯安全滑块时,通过 noVNC 人工完成滑块;遇到短信、邮箱或 TOTP 二次验证时,在后台输入验证码,Java 会自动继续原操作。
登录页已按领星实际页面校验以下控件:账号 `input[name="username"]`、密码 `input[name="password"]`、普通登录按钮 `.login-btn`;检测到二次验证页后,短信/邮箱立即自动点击 `.verify-code-login .code-btn` 获取验证码,后台输入验证码后只填写 `input[placeholder="输入验证码"]` 并点击 `.verify-code-login .login-btn`,不会重复发送。TOTP 由页面自动初始化,验证码输入框为 `input.verification-code-native`,后台提交方式相同。
## 检查
```bash
chmod +x deploy/scripts/check-lingxing-browser.sh
DEPLOY_ENV=development deploy/scripts/check-lingxing-browser.sh
DEPLOY_ENV=development docker compose \
-f deploy/docker-compose.lingxing-browser.yml ps
docker exec firstleg-dev-lingxing-browser \
wget -q -O - http://127.0.0.1:9222/json/version
```
不要把 6080 当成 CDP 地址,也不要使用容器内原生 9223;Java 使用同网络的 `http://lingxing-browser:9222`。
+12
View File
@@ -0,0 +1,12 @@
#!/usr/bin/env bash
set -euo pipefail
deployment_env="${DEPLOY_ENV:-development}"
compose_file="deploy/docker-compose.lingxing-browser.yml"
container="firstleg-${deployment_env}-lingxing-browser"
docker compose -f "$compose_file" ps --status running
docker exec "$container" wget -q -O - http://127.0.0.1:9222/json/version \
| sed -E 's/("webSocketDebuggerUrl"[[:space:]]*:[[:space:]]*")[^"]+(".*)/\1[redacted]\2/'
echo "CDP is reachable; credentials and browser storage were not printed."
@@ -6011,7 +6011,7 @@ INSERT INTO iam_membership_role (membership_id, role_id, tenant_id, created_at)
-- 系统基线 iam_number_sequence:1 行。
INSERT INTO iam_number_sequence (sequence_key, next_value, updated_at) VALUES ('CUSTOMER_CODE',1,CURRENT_TIMESTAMP(3));
-- 系统基线 iam_permission:364 行。
-- 系统基线 iam_permission:367 行。
INSERT INTO iam_permission (id, tenant_id, app_code, permission_type, parent_id, permission_code, permission_name, route_path, http_method, sort_no, status, built_in, created_at, updated_at, deleted) VALUES (5100000,0,'PLATFORM','MENU',0,'platform:customer','客户管理','/customers',NULL,10,'ACTIVE',1,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3),0);
INSERT INTO iam_permission (id, tenant_id, app_code, permission_type, parent_id, permission_code, permission_name, route_path, http_method, sort_no, status, built_in, created_at, updated_at, deleted) VALUES (5100010,0,'PLATFORM','MENU',5100000,'platform:customer:list','企业客户','/customers/list',NULL,10,'ACTIVE',1,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3),0);
INSERT INTO iam_permission (id, tenant_id, app_code, permission_type, parent_id, permission_code, permission_name, route_path, http_method, sort_no, status, built_in, created_at, updated_at, deleted) VALUES (5100011,0,'PLATFORM','BUTTON',5100010,'platform:customer:create','新增企业客户',NULL,NULL,10,'ACTIVE',1,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3),0);
@@ -6125,6 +6125,9 @@ INSERT INTO iam_permission (id, tenant_id, app_code, permission_type, parent_id,
INSERT INTO iam_permission (id, tenant_id, app_code, permission_type, parent_id, permission_code, permission_name, route_path, http_method, sort_no, status, built_in, created_at, updated_at, deleted) VALUES (5102127,0,'PLATFORM','BUTTON',5102030,'platform:firstleg:inbound:delivery:complete','完成派送',NULL,NULL,200,'ACTIVE',1,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3),0);
INSERT INTO iam_permission (id, tenant_id, app_code, permission_type, parent_id, permission_code, permission_name, route_path, http_method, sort_no, status, built_in, created_at, updated_at, deleted) VALUES (5102128,0,'PLATFORM','BUTTON',5102030,'platform:firstleg:inbound:picking:edit','执行拣货',NULL,NULL,210,'ACTIVE',1,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3),0);
INSERT INTO iam_permission (id, tenant_id, app_code, permission_type, parent_id, permission_code, permission_name, route_path, http_method, sort_no, status, built_in, created_at, updated_at, deleted) VALUES (5102129,0,'PLATFORM','BUTTON',5102030,'platform:firstleg:inbound:picking:view','查看拣货任务',NULL,NULL,220,'ACTIVE',1,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3),0);
INSERT INTO iam_permission (id, tenant_id, app_code, permission_type, parent_id, permission_code, permission_name, route_path, http_method, sort_no, status, built_in, created_at, updated_at, deleted) VALUES (5102131,0,'PLATFORM','MENU',5103000,'platform:mojia-sku-sync:list','一件代发SKU同步','/general/mojia-sku-sync','GET',130,'ACTIVE',1,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3),0);
INSERT INTO iam_permission (id, tenant_id, app_code, permission_type, parent_id, permission_code, permission_name, route_path, http_method, sort_no, status, built_in, created_at, updated_at, deleted) VALUES (5102132,0,'PLATFORM','BUTTON',5102131,'platform:mojia-sku-sync:edit','编辑一件代发SKU测量数据',NULL,'PUT',10,'ACTIVE',1,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3),0);
INSERT INTO iam_permission (id, tenant_id, app_code, permission_type, parent_id, permission_code, permission_name, route_path, http_method, sort_no, status, built_in, created_at, updated_at, deleted) VALUES (5102133,0,'PLATFORM','BUTTON',5102131,'platform:mojia-sku-sync:retry','重新推送一件代发SKU',NULL,'POST',20,'ACTIVE',1,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3),0);
INSERT INTO iam_permission (id, tenant_id, app_code, permission_type, parent_id, permission_code, permission_name, route_path, http_method, sort_no, status, built_in, created_at, updated_at, deleted) VALUES (5102140,0,'PLATFORM','MENU',5102000,'platform:firstleg:ocean-booking:list','订舱管理','/first-leg/ocean-bookings','GET',80,'ACTIVE',1,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3),0);
INSERT INTO iam_permission (id, tenant_id, app_code, permission_type, parent_id, permission_code, permission_name, route_path, http_method, sort_no, status, built_in, created_at, updated_at, deleted) VALUES (5102142,0,'PLATFORM','BUTTON',5102140,'platform:firstleg:ocean-booking:create','创建订舱',NULL,'POST',20,'ACTIVE',1,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3),0);
INSERT INTO iam_permission (id, tenant_id, app_code, permission_type, parent_id, permission_code, permission_name, route_path, http_method, sort_no, status, built_in, created_at, updated_at, deleted) VALUES (5102146,0,'PLATFORM','BUTTON',5102140,'platform:firstleg:ocean-booking:release','确认放舱',NULL,'POST',60,'ACTIVE',1,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3),0);
@@ -6390,7 +6393,7 @@ INSERT INTO iam_role (id, tenant_id, app_code, role_code, role_name, role_type,
INSERT INTO iam_role (id, tenant_id, app_code, role_code, role_name, role_type, status, description, created_by, created_at, updated_by, updated_at, deleted, version) VALUES (4000007,0,'PLATFORM','CN_FIRSTLEG_PDA_OPERATOR','国内头程 PDA 作业员','SYSTEM','ACTIVE','负责使用国内 PDA 执行柜次拣货、现场取证和装柜完成',NULL,CURRENT_TIMESTAMP(3),NULL,CURRENT_TIMESTAMP(3),0,0);
INSERT INTO iam_role (id, tenant_id, app_code, role_code, role_name, role_type, status, description, created_by, created_at, updated_by, updated_at, deleted, version) VALUES (4000100,0,'MERCHANT','MERCHANT_OWNER','企业管理员','SYSTEM','ACTIVE','一期企业全部菜单和操作按钮模板',1000001,CURRENT_TIMESTAMP(3),1000001,CURRENT_TIMESTAMP(3),0,0);
-- 系统基线 iam_role_permission:539 行。
-- 系统基线 iam_role_permission:545 行。
INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000001,5100000,0,CURRENT_TIMESTAMP(3));
INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000001,5100010,0,CURRENT_TIMESTAMP(3));
INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000001,5100011,0,CURRENT_TIMESTAMP(3));
@@ -6501,6 +6504,9 @@ INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at)
INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000001,5102127,0,CURRENT_TIMESTAMP(3));
INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000001,5102128,0,CURRENT_TIMESTAMP(3));
INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000001,5102129,0,CURRENT_TIMESTAMP(3));
INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000001,5102131,0,CURRENT_TIMESTAMP(3));
INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000001,5102132,0,CURRENT_TIMESTAMP(3));
INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000001,5102133,0,CURRENT_TIMESTAMP(3));
INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000001,5102140,0,CURRENT_TIMESTAMP(3));
INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000001,5102142,0,CURRENT_TIMESTAMP(3));
INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000001,5102146,0,CURRENT_TIMESTAMP(3));
@@ -6765,6 +6771,9 @@ INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at)
INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000003,5102127,0,CURRENT_TIMESTAMP(3));
INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000003,5102128,0,CURRENT_TIMESTAMP(3));
INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000003,5102129,0,CURRENT_TIMESTAMP(3));
INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000003,5102131,0,CURRENT_TIMESTAMP(3));
INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000003,5102132,0,CURRENT_TIMESTAMP(3));
INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000003,5102133,0,CURRENT_TIMESTAMP(3));
INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000003,5102140,0,CURRENT_TIMESTAMP(3));
INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000003,5102142,0,CURRENT_TIMESTAMP(3));
INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000003,5102146,0,CURRENT_TIMESTAMP(3));
@@ -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;
@@ -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);
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);
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;
}
}
@@ -0,0 +1,6 @@
package com.baiyue.firstleg.lingxing;
/** 需要人工完成浏览器安全验证(例如腾讯滑块)时的可识别错误。 */
public class LingxingBrowserChallengeException extends RuntimeException {
public LingxingBrowserChallengeException(String message) { super(message); }
}
@@ -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,19 +38,98 @@ public class LingxingBrowserSessionReader {
}
public LingxingAuthState read(LingxingProviderConfig config, String loginOrigin) {
CdpSocket socket = null;
try {
socket = open(config, loginOrigin, false);
return readFromSocket(socket, config, loginOrigin);
} catch (Exception ex) {
throw new IllegalStateException("读取领星浏览器登录态失败", ex);
} finally {
if (socket != null) socket.close();
}
}
/** 方案 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 页面");
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");
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("领星浏览器尚未完成登录");
@@ -60,26 +140,89 @@ public class LingxingBrowserSessionReader {
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);
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);
}
}
private JsonNode selectPage(ArrayNode pages, String origin) {
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); }
@@ -2,11 +2,28 @@ package com.baiyue.firstleg.lingxing;
/** 领星账号运行配置;密码和签名密钥不进入此对象日志。 */
public record LingxingProviderConfig(String providerKey, String browserCdpEndpoint,
boolean browserSessionRequired, long tokenTtlSeconds,
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));
}
}
@@ -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));
}
@@ -0,0 +1,40 @@
package com.baiyue.firstleg.lingxing;
import static org.assertj.core.api.Assertions.assertThat;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.node.ArrayNode;
import java.net.URI;
import org.junit.jupiter.api.Test;
class LingxingBrowserSessionReaderTest {
private final ObjectMapper mapper = new ObjectMapper();
@Test
void rewritesDiscoverySocketToConfiguredEndpointAndKeepsTargetPath() {
URI result = LingxingBrowserSessionReader.rewriteWebSocketEndpoint(
URI.create("ws://127.0.0.1:9223/devtools/page/abc"),
URI.create("http://127.0.0.1:19222"));
assertThat(result.toString()).isEqualTo("ws://127.0.0.1:19222/devtools/page/abc");
assertThat(LingxingBrowserSessionReader.rewriteWebSocketEndpoint(
URI.create("ws://127.0.0.1:9223/devtools/page/abc"), URI.create("https://cdp.example.test")))
.hasScheme("wss");
}
@Test
void selectsOnlyOmsPageWithExactOriginAndPrefersLoggedInPage() throws Exception {
ArrayNode pages = (ArrayNode) mapper.readTree("""
[
{"type":"other","url":"https://oms.xlwms.com/app","webSocketDebuggerUrl":"ws://x/other"},
{"type":"page","url":"https://oms.xlwms.com.evil.test/app","webSocketDebuggerUrl":"ws://x/evil"},
{"type":"page","url":"https://oms.xlwms.com/login","webSocketDebuggerUrl":"ws://x/login"},
{"type":"page","url":"https://oms.xlwms.com/app","webSocketDebuggerUrl":"ws://x/app"}
]
""");
assertThat(LingxingBrowserSessionReader.selectPage(pages, "https://oms.xlwms.com").path("url").asText())
.isEqualTo("https://oms.xlwms.com/app");
}
}
@@ -6,6 +6,13 @@ import static org.assertj.core.api.Assertions.assertThat;
import static org.junit.jupiter.api.Assumptions.assumeTrue;
class LingxingTrackKeyVectorTest {
@Test
void acceptsAStandard32ByteKeyAfterOneBase64Decode() {
assertThat(LingxingDirectClient.buildTrackKey(
"POST", "{}", "MDEyMzQ1Njc4OTAxMjM0NTY3ODkwMTIzNDU2Nzg5MDE="))
.isEqualTo("v2:0cA9d60u8HVKLHZRgbSCZJDOvec7bNGfg1tvJIFRIh4=");
}
@Test
void matchesNodeTrackKeyV2VectorWhenSecretIsProvided() {
String secret = System.getenv("LINGXING_TRACK_KEY_V2_SECRET_BASE64");
@@ -22,11 +22,11 @@ BEGIN
(id, tenant_id, app_code, permission_type, parent_id, permission_code,
permission_name, route_path, http_method, sort_no, status, built_in, deleted)
VALUES
(5102131, 0, 'PLATFORM', 'BUTTON', 5102000, 'platform:mojia-sku-sync:list', '查看一件代发SKU同步', NULL, 'GET', 130, 'ACTIVE', 1, 0),
(5102132, 0, 'PLATFORM', 'BUTTON', 5102000, 'platform:mojia-sku-sync:edit', '编辑一件代发SKU测量数据', NULL, 'PUT', 131, 'ACTIVE', 1, 0),
(5102133, 0, 'PLATFORM', 'BUTTON', 5102000, 'platform:mojia-sku-sync:retry', '重新推送一件代发SKU', NULL, 'POST', 132, 'ACTIVE', 1, 0)
(5102131, 0, 'PLATFORM', 'MENU', 5103000, 'platform:mojia-sku-sync:list', '一件代发SKU同步', '/general/mojia-sku-sync', 'GET', 130, 'ACTIVE', 1, 0),
(5102132, 0, 'PLATFORM', 'BUTTON', 5102131, 'platform:mojia-sku-sync:edit', '编辑一件代发SKU测量数据', NULL, 'PUT', 10, 'ACTIVE', 1, 0),
(5102133, 0, 'PLATFORM', 'BUTTON', 5102131, 'platform:mojia-sku-sync:retry', '重新推送一件代发SKU', NULL, 'POST', 20, 'ACTIVE', 1, 0)
ON DUPLICATE KEY UPDATE
permission_name = VALUES(permission_name), parent_id = VALUES(parent_id),
permission_type = VALUES(permission_type), permission_name = VALUES(permission_name), parent_id = VALUES(parent_id),
http_method = VALUES(http_method), status = 'ACTIVE', built_in = 1, deleted = 0;
INSERT IGNORE INTO iam_role_permission (role_id, permission_id, tenant_id)
@@ -81,6 +81,7 @@ VALUES
(5103020, 0, 'PLATFORM', 'MENU', 5103000, 'platform:express:list', '快递公司', '/master-data/express', NULL, 20, 'ACTIVE', 0),
(5103030, 0, 'PLATFORM', 'MENU', 5103000, 'platform:counterparty:list', '往来单位', '/master-data/counterparties', NULL, 30, 'ACTIVE', 0),
(5103040, 0, 'PLATFORM', 'MENU', 5103000, 'platform:download:list', '下载中心', '/downloads', NULL, 40, 'ACTIVE', 0),
(5102131, 0, 'PLATFORM', 'MENU', 5103000, 'platform:mojia-sku-sync:list', '一件代发SKU同步', '/general/mojia-sku-sync', 'GET', 130, 'ACTIVE', 0),
(5104010, 0, 'PLATFORM', 'MENU', 5104000, 'platform:audit:list', '操作日志', '/audit/logs', NULL, 10, 'ACTIVE', 0),
-- Platform: buttons.
(5100020, 0, 'PLATFORM', 'BUTTON', 5100010, 'platform:customer:review', '客户审核', NULL, NULL, 50, 'ACTIVE', 0),
@@ -118,6 +119,8 @@ VALUES
(5103032, 0, 'PLATFORM', 'BUTTON', 5103030, 'platform:counterparty:edit', '编辑往来单位', NULL, NULL, 20, 'ACTIVE', 0),
(5103033, 0, 'PLATFORM', 'BUTTON', 5103030, 'platform:counterparty:status', '启用停用往来单位', NULL, NULL, 30, 'ACTIVE', 0),
(5103034, 0, 'PLATFORM', 'BUTTON', 5103030, 'platform:counterparty:import', '批量导入往来单位', NULL, NULL, 40, 'ACTIVE', 0)
,(5102132, 0, 'PLATFORM', 'BUTTON', 5102131, 'platform:mojia-sku-sync:edit', '编辑一件代发SKU测量数据', NULL, 'PUT', 10, 'ACTIVE', 0)
,(5102133, 0, 'PLATFORM', 'BUTTON', 5102131, 'platform:mojia-sku-sync:retry', '重新推送一件代发SKU', NULL, 'POST', 20, 'ACTIVE', 0)
ON DUPLICATE KEY UPDATE
permission_type = VALUES(permission_type), parent_id = VALUES(parent_id),
permission_name = VALUES(permission_name), route_path = VALUES(route_path),
@@ -177,6 +180,7 @@ INSERT INTO iam_role_permission (role_id, permission_id, tenant_id)
SELECT 4000003, id, 0 FROM iam_permission
WHERE permission_code LIKE 'platform:order%'
OR permission_code LIKE 'platform:firstleg%'
OR permission_code LIKE 'platform:mojia-sku-sync:%'
OR permission_code = 'platform:download:list'
ON DUPLICATE KEY UPDATE tenant_id = VALUES(tenant_id);