优化
This commit is contained in:
@@ -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}
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -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"
|
||||||
@@ -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`。
|
||||||
@@ -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;
|
||||||
|
|||||||
+37
@@ -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);
|
||||||
|
try {
|
||||||
return ApiResponse.success(lingxing.test(request.skuCode()));
|
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);
|
||||||
|
try {
|
||||||
return ApiResponse.success(service.retry(id, request));
|
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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+6
@@ -0,0 +1,6 @@
|
|||||||
|
package com.baiyue.firstleg.lingxing;
|
||||||
|
|
||||||
|
/** 需要人工完成浏览器安全验证(例如腾讯滑块)时的可识别错误。 */
|
||||||
|
public class LingxingBrowserChallengeException extends RuntimeException {
|
||||||
|
public LingxingBrowserChallengeException(String message) { super(message); }
|
||||||
|
}
|
||||||
+158
-15
@@ -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,19 +38,98 @@ public class LingxingBrowserSessionReader {
|
|||||||
}
|
}
|
||||||
|
|
||||||
public LingxingAuthState read(LingxingProviderConfig config, String loginOrigin) {
|
public LingxingAuthState read(LingxingProviderConfig config, String loginOrigin) {
|
||||||
|
CdpSocket socket = null;
|
||||||
try {
|
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(
|
ArrayNode pages = (ArrayNode) mapper.readTree(http.send(
|
||||||
HttpRequest.newBuilder(URI.create(stripSlash(config.browserCdpEndpoint()) + "/json/list"))
|
HttpRequest.newBuilder(URI.create(stripSlash(config.browserCdpEndpoint()) + "/json/list"))
|
||||||
.timeout(timeout).GET().build(), HttpResponse.BodyHandlers.ofString()).body());
|
.timeout(timeout).GET().build(), HttpResponse.BodyHandlers.ofString()).body());
|
||||||
JsonNode selected = selectPage(pages, loginOrigin);
|
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("");
|
String ws = selected.path("webSocketDebuggerUrl").asText("");
|
||||||
if (ws.isBlank()) throw new IllegalStateException("领星页面未提供 CDP WebSocket 地址");
|
if (ws.isBlank()) throw new IllegalStateException("领星页面未提供 CDP WebSocket 地址");
|
||||||
CdpSocket socket = new CdpSocket(mapper, http, timeout, URI.create(ws));
|
CdpSocket socket = new CdpSocket(mapper, http, timeout, rewriteWebSocketEndpoint(URI.create(ws), URI.create(config.browserCdpEndpoint())));
|
||||||
ObjectNode browser = socket.command("Runtime.evaluate", mapper.createObjectNode()
|
socket.command("Runtime.enable", mapper.createObjectNode()).join();
|
||||||
.put("expression", "({token:localStorage.getItem('oms-token'),version:localStorage.getItem('version'),href:location.href,ua:navigator.userAgent})")
|
socket.command("Network.enable", mapper.createObjectNode()).join();
|
||||||
.put("returnByValue", true)).join();
|
if (allowLoginPage) socket.command("Page.enable", mapper.createObjectNode()).join();
|
||||||
JsonNode value = browser.path("result").path("result").path("value");
|
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 token = value.path("token").asText("");
|
||||||
String href = value.path("href").asText("");
|
String href = value.path("href").asText("");
|
||||||
if (token.isBlank() || href.contains("/login")) throw new IllegalStateException("领星浏览器尚未完成登录");
|
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 (cookie.path("domain").asText("").contains(host)) cookies.add(cookie.path("name").asText() + "=" + cookie.path("value").asText());
|
||||||
}
|
}
|
||||||
if (cookies.isEmpty()) throw new IllegalStateException("领星浏览器未返回有效 Cookie");
|
if (cookies.isEmpty()) throw new IllegalStateException("领星浏览器未返回有效 Cookie");
|
||||||
long expiresAt = expiresAt(token, config);
|
return new LingxingAuthState(token, String.join("; ", cookies), expiresAt(token, config), value.path("ua").asText(null), "browser_session");
|
||||||
socket.close();
|
}
|
||||||
return new LingxingAuthState(token, String.join("; ", cookies), expiresAt,
|
|
||||||
value.path("ua").asText(null), "browser_session");
|
private JsonNode evaluate(CdpSocket socket, String expression) {
|
||||||
} catch (Exception ex) {
|
return socket.command("Runtime.evaluate", mapper.createObjectNode().put("expression", expression).put("returnByValue", true))
|
||||||
throw new IllegalStateException("读取领星浏览器登录态失败", ex);
|
.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;
|
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); }
|
||||||
|
|||||||
@@ -2,11 +2,28 @@ package com.baiyue.firstleg.lingxing;
|
|||||||
|
|
||||||
/** 领星账号运行配置;密码和签名密钥不进入此对象日志。 */
|
/** 领星账号运行配置;密码和签名密钥不进入此对象日志。 */
|
||||||
public record LingxingProviderConfig(String providerKey, String browserCdpEndpoint,
|
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,
|
boolean browserSessionRequired, long tokenTtlSeconds,
|
||||||
long expirySafetySeconds) {
|
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));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+18
-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));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+40
@@ -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);
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user