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} auto-sync-enabled: ${LINGXING_AUTO_SYNC_ENABLED:false}
base-url: ${LINGXING_DIRECT_BASE_URL:https://wms.xlwms.com} 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} 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} browser-session-required: ${LINGXING_BROWSER_SESSION_REQUIRED:true}
token-ttl-seconds: ${LINGXING_TOKEN_TTL_SECONDS:172800} token-ttl-seconds: ${LINGXING_TOKEN_TTL_SECONDS:172800}
token-expiry-safety-seconds: ${LINGXING_TOKEN_EXPIRY_SAFETY_SECONDS:300} token-expiry-safety-seconds: ${LINGXING_TOKEN_EXPIRY_SAFETY_SECONDS:300}
+6 -2
View File
@@ -25,9 +25,13 @@ firstleg:
lingxing: lingxing:
auto-sync-enabled: ${LINGXING_AUTO_SYNC_ENABLED:false} auto-sync-enabled: ${LINGXING_AUTO_SYNC_ENABLED:false}
base-url: ${LINGXING_DIRECT_BASE_URL:https://wms.xlwms.com} 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} 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} browser-session-required: ${LINGXING_BROWSER_SESSION_REQUIRED:true}
token-ttl-seconds: ${LINGXING_TOKEN_TTL_SECONDS:172800} token-ttl-seconds: ${LINGXING_TOKEN_TTL_SECONDS:172800}
token-expiry-safety-seconds: ${LINGXING_TOKEN_EXPIRY_SAFETY_SECONDS:300} 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 行。 -- 系统基线 iam_number_sequence:1 行。
INSERT INTO iam_number_sequence (sequence_key, next_value, updated_at) VALUES ('CUSTOMER_CODE',1,CURRENT_TIMESTAMP(3)); 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 (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 (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); 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 (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 (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 (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 (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 (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); 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 (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); 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,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,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)); 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,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,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,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,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,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)); 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,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,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,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,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,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)); 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 boolean autoSyncEnabled = false;
private String baseUrl = "https://wms.xlwms.com"; 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 providerKey = "LINGXING_01";
private String browserCdpEndpoint; private String browserCdpEndpoint;
private boolean browserSessionRequired = true; 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.MojiaSkuSyncDtos;
import com.baiyue.firstleg.dto.LingxingTestDtos; import com.baiyue.firstleg.dto.LingxingTestDtos;
import com.baiyue.firstleg.lingxing.LingxingDirectClient; 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.FirstlegScopeGuard;
import com.baiyue.firstleg.service.MojiaSkuSyncService; 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.Operation;
import io.swagger.v3.oas.annotations.tags.Tag; import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.annotation.Resource; import jakarta.annotation.Resource;
@@ -24,6 +30,8 @@ import com.baiyue.common.security.AuthenticatedUser;
public class PlatformMojiaSkuSyncController { public class PlatformMojiaSkuSyncController {
@Resource private MojiaSkuSyncService service; @Resource private MojiaSkuSyncService service;
@Resource private LingxingDirectClient lingxing; @Resource private LingxingDirectClient lingxing;
@Resource private LingxingAuthManager authManager;
@Resource private LingxingSyncProperties lingxingProperties;
@GetMapping @GetMapping
@PreAuthorize("hasAuthority('platform:mojia-sku-sync:list')") @PreAuthorize("hasAuthority('platform:mojia-sku-sync:list')")
@@ -52,7 +60,30 @@ public class PlatformMojiaSkuSyncController {
public ApiResponse<LingxingTestDtos.Response> testConnection( public ApiResponse<LingxingTestDtos.Response> testConnection(
@AuthenticationPrincipal AuthenticatedUser user, @Valid @RequestBody LingxingTestDtos.Request request) { @AuthenticationPrincipal AuthenticatedUser user, @Valid @RequestBody LingxingTestDtos.Request request) {
FirstlegScopeGuard.platform(user); 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}") @PutMapping("/{id}")
@@ -72,6 +103,12 @@ public class PlatformMojiaSkuSyncController {
@AuthenticationPrincipal AuthenticatedUser user, @PathVariable long id, @AuthenticationPrincipal AuthenticatedUser user, @PathVariable long id,
@Valid @RequestBody MojiaSkuSyncDtos.RetryRequest request) { @Valid @RequestBody MojiaSkuSyncDtos.RetryRequest request) {
FirstlegScopeGuard.platform(user); 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() {} private LingxingTestDtos() {}
public record Request(@NotBlank @Size(max = 128) String skuCode) {} 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, public record WarehouseResult(String whCode, boolean reachable, boolean matched,
boolean maintained, String productSku, String barcode, boolean maintained, String productSku, String barcode,
BigDecimal length, BigDecimal width, BigDecimal height, 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, LingxingAuthState> cache = new ConcurrentHashMap<>();
private final Map<String, CompletableFuture<LingxingAuthState>> refreshing = new ConcurrentHashMap<>(); private final Map<String, CompletableFuture<LingxingAuthState>> refreshing = new ConcurrentHashMap<>();
private final Map<String, Long> cooldownUntil = new ConcurrentHashMap<>(); private final Map<String, Long> cooldownUntil = new ConcurrentHashMap<>();
private final Map<String, LingxingProviderConfig> pendingVerification = new ConcurrentHashMap<>();
private volatile Function<LingxingProviderConfig, LingxingAuthState> passwordLogin; private volatile Function<LingxingProviderConfig, LingxingAuthState> passwordLogin;
@Autowired @Autowired
@@ -67,12 +68,32 @@ public class LingxingAuthManager {
LingxingAuthState state = browser.read(config, loginOrigin); LingxingAuthState state = browser.read(config, loginOrigin);
cache.put(config.providerKey(), state); cooldownUntil.remove(config.providerKey()); return state; cache.put(config.providerKey(), state); cooldownUntil.remove(config.providerKey()); return state;
} catch (RuntimeException ex) { } catch (RuntimeException ex) {
if (config.browserSessionRequired()) throw ex; if (config.browserSessionRequired() && (config.username() == null || config.password() == null)) throw ex;
} }
} }
Function<LingxingProviderConfig, LingxingAuthState> login = passwordLogin; Function<LingxingProviderConfig, LingxingAuthState> login = passwordLogin;
if (login == null) throw new IllegalStateException("领星浏览器登录态不可用,且未配置账号密码登录器"); LingxingAuthState state;
LingxingAuthState state = login.apply(config); 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; 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.ArrayNode;
import com.fasterxml.jackson.databind.node.ObjectNode; import com.fasterxml.jackson.databind.node.ObjectNode;
import java.net.URI; import java.net.URI;
import java.net.URISyntaxException;
import java.net.http.HttpClient; import java.net.http.HttpClient;
import java.net.http.HttpRequest; import java.net.http.HttpRequest;
import java.net.http.HttpResponse; import java.net.http.HttpResponse;
@@ -37,49 +38,191 @@ public class LingxingBrowserSessionReader {
} }
public LingxingAuthState read(LingxingProviderConfig config, String loginOrigin) { public LingxingAuthState read(LingxingProviderConfig config, String loginOrigin) {
CdpSocket socket = null;
try { try {
ArrayNode pages = (ArrayNode) mapper.readTree(http.send( socket = open(config, loginOrigin, false);
HttpRequest.newBuilder(URI.create(stripSlash(config.browserCdpEndpoint()) + "/json/list")) return readFromSocket(socket, config, loginOrigin);
.timeout(timeout).GET().build(), HttpResponse.BodyHandlers.ofString()).body());
JsonNode selected = selectPage(pages, loginOrigin);
if (selected == null) throw new IllegalStateException("未找到已登录的领星 OMS 页面");
String ws = selected.path("webSocketDebuggerUrl").asText("");
if (ws.isBlank()) throw new IllegalStateException("领星页面未提供 CDP WebSocket 地址");
CdpSocket socket = new CdpSocket(mapper, http, timeout, URI.create(ws));
ObjectNode browser = socket.command("Runtime.evaluate", mapper.createObjectNode()
.put("expression", "({token:localStorage.getItem('oms-token'),version:localStorage.getItem('version'),href:location.href,ua:navigator.userAgent})")
.put("returnByValue", true)).join();
JsonNode value = browser.path("result").path("result").path("value");
String token = value.path("token").asText("");
String href = value.path("href").asText("");
if (token.isBlank() || href.contains("/login")) throw new IllegalStateException("领星浏览器尚未完成登录");
ObjectNode cookiesResult = socket.command("Network.getAllCookies", mapper.createObjectNode()).join();
String host = URI.create(loginOrigin).getHost();
List<String> cookies = new ArrayList<>();
for (JsonNode cookie : cookiesResult.path("result").path("cookies")) {
if (cookie.path("domain").asText("").contains(host)) cookies.add(cookie.path("name").asText() + "=" + cookie.path("value").asText());
}
if (cookies.isEmpty()) throw new IllegalStateException("领星浏览器未返回有效 Cookie");
long expiresAt = expiresAt(token, config);
socket.close();
return new LingxingAuthState(token, String.join("; ", cookies), expiresAt,
value.path("ua").asText(null), "browser_session");
} catch (Exception ex) { } catch (Exception ex) {
throw new IllegalStateException("读取领星浏览器登录态失败", 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; JsonNode fallback = null;
for (JsonNode page : pages) { for (JsonNode page : pages) {
if (!"page".equals(page.path("type").asText(""))) continue;
String url = page.path("url").asText(""); String url = page.path("url").asText("");
if (!url.startsWith(origin)) continue; if (page.path("webSocketDebuggerUrl").asText("").isBlank()) continue;
if (!url.contains("/login")) return page; 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; fallback = page;
} }
return fallback; 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) { static long expiresAt(String token, LingxingProviderConfig config) {
long now = System.currentTimeMillis(); long now = System.currentTimeMillis();
try { try {
@@ -18,12 +18,15 @@ import java.nio.charset.StandardCharsets;
import java.security.MessageDigest; import java.security.MessageDigest;
import java.util.ArrayList; import java.util.ArrayList;
import java.util.Base64; import java.util.Base64;
import java.util.concurrent.CompletionException;
import java.util.List; import java.util.List;
import java.util.Locale; import java.util.Locale;
import java.util.Map; import java.util.Map;
import java.util.TreeMap; import java.util.TreeMap;
import java.math.BigDecimal;
import java.math.RoundingMode;
/** 领星新品查询只读客户端。不会调用 /measure。 */ /** 领星新品查询与人工尺寸维护客户端;自动任务不会调用本客户端的写入入口。 */
@Component @Component
public class LingxingDirectClient { public class LingxingDirectClient {
private final ObjectMapper mapper; private final ObjectMapper mapper;
@@ -41,14 +44,68 @@ public class LingxingDirectClient {
public LingxingTestDtos.Response test(String skuCode) { public LingxingTestDtos.Response test(String skuCode) {
String sku = skuCode == null ? "" : skuCode.trim(); String sku = skuCode == null ? "" : skuCode.trim();
if (sku.isBlank()) throw new IllegalArgumentException("SKU不能为空"); 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.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<>(); 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); 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) { private LingxingTestDtos.WarehouseResult query(LingxingAuthState auth, String sku, String whCode) {
try { try {
ObjectNode body = mapper.createObjectNode().put("barcode", sku).put("current", 1) 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(); .header("Track-Key", trackKey("POST", json)).POST(HttpRequest.BodyPublishers.ofString(json)).build();
HttpResponse<String> response = http.send(request, HttpResponse.BodyHandlers.ofString()); HttpResponse<String> response = http.send(request, HttpResponse.BodyHandlers.ofString());
JsonNode root = mapper.readTree(response.body()); JsonNode root = mapper.readTree(response.body());
if (response.statusCode() == 401) { if (response.statusCode() == 401 || isUnauthorized(root)) {
authManager.invalidate(properties.getProviderKey(), "lingxing_test_unauthorized"); authManager.invalidate(properties.getProviderKey(), "lingxing_test_unauthorized");
return new LingxingTestDtos.WarehouseResult(whCode, false, false, false, null, null, null, null, null, null, "领星登录态已失效,请重新登录"); 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) { 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); 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 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 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) { static String buildTrackKey(String method, String json, String secretBase64) {
if (secretBase64 == null || secretBase64.isBlank()) throw new IllegalStateException("未配置领星 Track-Key v2 密钥"); if (secretBase64 == null || secretBase64.isBlank()) throw new IllegalStateException("未配置领星 Track-Key v2 密钥");
try { try {
byte[] digest = MessageDigest.getInstance("SHA-256").digest(json.getBytes(StandardCharsets.UTF_8)); 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)); StringBuilder hex = new StringBuilder(); for (byte b : digest) hex.append(String.format(Locale.ROOT, "%02x", b));
byte[] key = Base64.getDecoder().decode(secretBase64); 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")); 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))); 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); } } 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, public record LingxingProviderConfig(String providerKey, String browserCdpEndpoint,
boolean browserSessionRequired, long tokenTtlSeconds, 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 { public LingxingProviderConfig {
if (providerKey == null || providerKey.isBlank()) throw new IllegalArgumentException("providerKey不能为空"); if (providerKey == null || providerKey.isBlank()) throw new IllegalArgumentException("providerKey不能为空");
if (tokenTtlSeconds <= 0) tokenTtlSeconds = 172800; if (tokenTtlSeconds <= 0) tokenTtlSeconds = 172800;
if (expirySafetySeconds < 0) expirySafetySeconds = 300; 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::getStatus, "SKU_PENDING_SYNC")
.set(MojiaMeasurementReceiptEntity::getRevision, revision + 1)); .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.slf4j.MDC;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
import org.springframework.beans.factory.annotation.Value; import org.springframework.beans.factory.annotation.Value;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.web.multipart.MultipartFile; import org.springframework.web.multipart.MultipartFile;
import java.io.ByteArrayInputStream; import java.io.ByteArrayInputStream;
@@ -49,17 +50,20 @@ public class MojiaMeasurementOpenApiService {
private final MeasurementImageClient imageClient; private final MeasurementImageClient imageClient;
private final MojiaMeasurementReceiptRepository receipts; private final MojiaMeasurementReceiptRepository receipts;
private final InboundCartonLabelService cartonLabels; private final InboundCartonLabelService cartonLabels;
private final MojiaSkuSyncService skuSync;
@Value("${baiyue.security.internal-service-token}") @Value("${baiyue.security.internal-service-token}")
private String internalToken; private String internalToken;
@Autowired
public MojiaMeasurementOpenApiService(MeasurementRequestAuthenticator authenticator, public MojiaMeasurementOpenApiService(MeasurementRequestAuthenticator authenticator,
CartonMeasurementService cartonMeasurements, CartonMeasurementService cartonMeasurements,
ObjectMapper objectMapper, ObjectMapper objectMapper,
Validator validator, Validator validator,
MeasurementImageClient imageClient, MeasurementImageClient imageClient,
MojiaMeasurementReceiptRepository receipts, MojiaMeasurementReceiptRepository receipts,
InboundCartonLabelService cartonLabels) { InboundCartonLabelService cartonLabels,
MojiaSkuSyncService skuSync) {
this.authenticator = authenticator; this.authenticator = authenticator;
this.cartonMeasurements = cartonMeasurements; this.cartonMeasurements = cartonMeasurements;
this.objectMapper = objectMapper; this.objectMapper = objectMapper;
@@ -67,6 +71,18 @@ public class MojiaMeasurementOpenApiService {
this.imageClient = imageClient; this.imageClient = imageClient;
this.receipts = receipts; this.receipts = receipts;
this.cartonLabels = cartonLabels; 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) { public UploadResponse receive(MeasurementRequestHeaders headers, String rawJson) {
@@ -159,6 +175,7 @@ public class MojiaMeasurementOpenApiService {
receipt.setCreatedAt(LocalDateTime.now()); receipt.setCreatedAt(LocalDateTime.now());
receipt.setUpdatedAt(LocalDateTime.now()); receipt.setUpdatedAt(LocalDateTime.now());
receipts.insert(receipt); receipts.insert(receipt);
if (skuSync != null) skuSync.autoSync(receiptId);
return new UploadResponse(true, false, receiptId, null, null, return new UploadResponse(true, false, receiptId, null, null,
request.boxCode(), uploaded.fileId(), true); request.boxCode(), uploaded.fileId(), true);
} catch (RuntimeException exception) { } catch (RuntimeException exception) {
@@ -7,19 +7,39 @@ import com.baiyue.firstleg.config.LingxingSyncProperties;
import com.baiyue.firstleg.dto.MojiaSkuSyncDtos; import com.baiyue.firstleg.dto.MojiaSkuSyncDtos;
import com.baiyue.firstleg.entity.MojiaMeasurementReceiptEntity; import com.baiyue.firstleg.entity.MojiaMeasurementReceiptEntity;
import com.baiyue.firstleg.repository.MojiaSkuSyncRepository; 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 jakarta.annotation.Resource;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional; import org.springframework.transaction.annotation.Transactional;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
@Service @Service
public class MojiaSkuSyncService { public class MojiaSkuSyncService {
private static final Logger log = LoggerFactory.getLogger(MojiaSkuSyncService.class);
@Resource private MojiaSkuSyncRepository repository; @Resource private MojiaSkuSyncRepository repository;
@Resource private LingxingSyncProperties lingxingProperties; @Resource private LingxingSyncProperties lingxingProperties;
@Resource private LingxingDirectClient lingxing;
public MojiaSkuSyncDtos.Config config() { public MojiaSkuSyncDtos.Config config() {
return new MojiaSkuSyncDtos.Config(lingxingProperties.isAutoSyncEnabled()); 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) { public PageResult<MojiaSkuSyncDtos.Record> page(MojiaSkuSyncDtos.Query query) {
PageResult<MojiaMeasurementReceiptEntity> source = repository.page( PageResult<MojiaMeasurementReceiptEntity> source = repository.page(
query.skuCode(), query.syncStatus(), query.page(), query.size()); query.skuCode(), query.syncStatus(), query.page(), query.size());
@@ -36,12 +56,36 @@ public class MojiaSkuSyncService {
return view(required(id)); return view(required(id));
} }
@Transactional
public MojiaSkuSyncDtos.Record retry(long id, MojiaSkuSyncDtos.RetryRequest request) { public MojiaSkuSyncDtos.Record retry(long id, MojiaSkuSyncDtos.RetryRequest request) {
required(id); required(id);
if (repository.retry(id, request.revision()) != 1) { if (repository.retry(id, request.revision()) != 1) {
throw conflict("仅待同步或同步失败记录可推送,或记录已被其他人修改"); 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)); 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; import static org.junit.jupiter.api.Assumptions.assumeTrue;
class LingxingTrackKeyVectorTest { class LingxingTrackKeyVectorTest {
@Test
void acceptsAStandard32ByteKeyAfterOneBase64Decode() {
assertThat(LingxingDirectClient.buildTrackKey(
"POST", "{}", "MDEyMzQ1Njc4OTAxMjM0NTY3ODkwMTIzNDU2Nzg5MDE="))
.isEqualTo("v2:0cA9d60u8HVKLHZRgbSCZJDOvec7bNGfg1tvJIFRIh4=");
}
@Test @Test
void matchesNodeTrackKeyV2VectorWhenSecretIsProvided() { void matchesNodeTrackKeyV2VectorWhenSecretIsProvided() {
String secret = System.getenv("LINGXING_TRACK_KEY_V2_SECRET_BASE64"); 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, (id, tenant_id, app_code, permission_type, parent_id, permission_code,
permission_name, route_path, http_method, sort_no, status, built_in, deleted) permission_name, route_path, http_method, sort_no, status, built_in, deleted)
VALUES VALUES
(5102131, 0, 'PLATFORM', 'BUTTON', 5102000, 'platform:mojia-sku-sync:list', '查看一件代发SKU同步', NULL, 'GET', 130, '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', 5102000, 'platform:mojia-sku-sync:edit', '编辑一件代发SKU测量数据', NULL, 'PUT', 131, 'ACTIVE', 1, 0), (5102132, 0, 'PLATFORM', 'BUTTON', 5102131, 'platform:mojia-sku-sync:edit', '编辑一件代发SKU测量数据', NULL, 'PUT', 10, 'ACTIVE', 1, 0),
(5102133, 0, 'PLATFORM', 'BUTTON', 5102000, 'platform:mojia-sku-sync:retry', '重新推送一件代发SKU', NULL, 'POST', 132, 'ACTIVE', 1, 0) (5102133, 0, 'PLATFORM', 'BUTTON', 5102131, 'platform:mojia-sku-sync:retry', '重新推送一件代发SKU', NULL, 'POST', 20, 'ACTIVE', 1, 0)
ON DUPLICATE KEY UPDATE 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; http_method = VALUES(http_method), status = 'ACTIVE', built_in = 1, deleted = 0;
INSERT IGNORE INTO iam_role_permission (role_id, permission_id, tenant_id) 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), (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), (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), (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), (5104010, 0, 'PLATFORM', 'MENU', 5104000, 'platform:audit:list', '操作日志', '/audit/logs', NULL, 10, 'ACTIVE', 0),
-- Platform: buttons. -- Platform: buttons.
(5100020, 0, 'PLATFORM', 'BUTTON', 5100010, 'platform:customer:review', '客户审核', NULL, NULL, 50, 'ACTIVE', 0), (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), (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), (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) (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 ON DUPLICATE KEY UPDATE
permission_type = VALUES(permission_type), parent_id = VALUES(parent_id), permission_type = VALUES(permission_type), parent_id = VALUES(parent_id),
permission_name = VALUES(permission_name), route_path = VALUES(route_path), 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 SELECT 4000003, id, 0 FROM iam_permission
WHERE permission_code LIKE 'platform:order%' WHERE permission_code LIKE 'platform:order%'
OR permission_code LIKE 'platform:firstleg%' OR permission_code LIKE 'platform:firstleg%'
OR permission_code LIKE 'platform:mojia-sku-sync:%'
OR permission_code = 'platform:download:list' OR permission_code = 'platform:download:list'
ON DUPLICATE KEY UPDATE tenant_id = VALUES(tenant_id); ON DUPLICATE KEY UPDATE tenant_id = VALUES(tenant_id);