diff --git a/config/nacos/cn/firstleg-service.yml b/config/nacos/cn/firstleg-service.yml index 1f463b417..42b868723 100644 --- a/config/nacos/cn/firstleg-service.yml +++ b/config/nacos/cn/firstleg-service.yml @@ -39,9 +39,13 @@ firstleg: # 安全默认关闭;开启后自动任务才允许调用领星新品维护接口。 auto-sync-enabled: ${LINGXING_AUTO_SYNC_ENABLED:false} base-url: ${LINGXING_DIRECT_BASE_URL:https://wms.xlwms.com} - login-origin: ${LINGXING_LOGIN_ORIGIN:https://oms.xlwms.com} + login-origin: ${LINGXING_LOGIN_ORIGIN:https://wms.xlwms.com} provider-key: ${LINGXING_PROVIDER_KEY:LINGXING_01} - browser-cdp-endpoint: ${LINGXING_BROWSER_CDP_ENDPOINT:} + # Firstleg and the isolated Lingxing browser run on the same Docker network. + # Keep the environment override for deployments that use another endpoint. + browser-cdp-endpoint: ${LINGXING_BROWSER_CDP_ENDPOINT:http://lingxing-browser:9222} + username: ${LINGXING_USERNAME:} + password: ${LINGXING_PASSWORD:} browser-session-required: ${LINGXING_BROWSER_SESSION_REQUIRED:true} token-ttl-seconds: ${LINGXING_TOKEN_TTL_SECONDS:172800} token-expiry-safety-seconds: ${LINGXING_TOKEN_EXPIRY_SAFETY_SECONDS:300} diff --git a/config/nacos/local/firstleg-service.yml b/config/nacos/local/firstleg-service.yml index 80a026f62..de2ce6708 100644 --- a/config/nacos/local/firstleg-service.yml +++ b/config/nacos/local/firstleg-service.yml @@ -25,9 +25,13 @@ firstleg: lingxing: auto-sync-enabled: ${LINGXING_AUTO_SYNC_ENABLED:false} base-url: ${LINGXING_DIRECT_BASE_URL:https://wms.xlwms.com} - login-origin: ${LINGXING_LOGIN_ORIGIN:https://oms.xlwms.com} + login-origin: ${LINGXING_LOGIN_ORIGIN:https://wms.xlwms.com} provider-key: ${LINGXING_PROVIDER_KEY:LINGXING_01} - browser-cdp-endpoint: ${LINGXING_BROWSER_CDP_ENDPOINT:} + # Firstleg and the isolated Lingxing browser run on the same Docker network. + # Keep the environment override for deployments that use another endpoint. + browser-cdp-endpoint: ${LINGXING_BROWSER_CDP_ENDPOINT:http://lingxing-browser:9222} + username: ${LINGXING_USERNAME:} + password: ${LINGXING_PASSWORD:} browser-session-required: ${LINGXING_BROWSER_SESSION_REQUIRED:true} token-ttl-seconds: ${LINGXING_TOKEN_TTL_SECONDS:172800} token-expiry-safety-seconds: ${LINGXING_TOKEN_EXPIRY_SAFETY_SECONDS:300} diff --git a/deploy/docker-compose.lingxing-browser.yml b/deploy/docker-compose.lingxing-browser.yml new file mode 100644 index 000000000..fe39942fb --- /dev/null +++ b/deploy/docker-compose.lingxing-browser.yml @@ -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 diff --git a/deploy/docker/lingxing-browser/Dockerfile b/deploy/docker/lingxing-browser/Dockerfile new file mode 100644 index 000000000..cecbce563 --- /dev/null +++ b/deploy/docker/lingxing-browser/Dockerfile @@ -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"] diff --git a/deploy/docker/lingxing-browser/entrypoint.sh b/deploy/docker/lingxing-browser/entrypoint.sh new file mode 100644 index 000000000..74afcba3c --- /dev/null +++ b/deploy/docker/lingxing-browser/entrypoint.sh @@ -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" diff --git a/deploy/lingxing-browser-dev.md b/deploy/lingxing-browser-dev.md new file mode 100644 index 000000000..601108023 --- /dev/null +++ b/deploy/lingxing-browser-dev.md @@ -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`。 diff --git a/deploy/scripts/check-lingxing-browser.sh b/deploy/scripts/check-lingxing-browser.sh new file mode 100644 index 000000000..6cba8e246 --- /dev/null +++ b/deploy/scripts/check-lingxing-browser.sh @@ -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." diff --git a/deploy/sql/头程数据库统一增量-MySQL8.sql b/deploy/sql/头程数据库统一增量-MySQL8.sql index a93cf2944..8bd2d0573 100644 --- a/deploy/sql/头程数据库统一增量-MySQL8.sql +++ b/deploy/sql/头程数据库统一增量-MySQL8.sql @@ -6011,7 +6011,7 @@ INSERT INTO iam_membership_role (membership_id, role_id, tenant_id, created_at) -- 系统基线 iam_number_sequence:1 行。 INSERT INTO iam_number_sequence (sequence_key, next_value, updated_at) VALUES ('CUSTOMER_CODE',1,CURRENT_TIMESTAMP(3)); --- 系统基线 iam_permission:364 行。 +-- 系统基线 iam_permission:367 行。 INSERT INTO iam_permission (id, tenant_id, app_code, permission_type, parent_id, permission_code, permission_name, route_path, http_method, sort_no, status, built_in, created_at, updated_at, deleted) VALUES (5100000,0,'PLATFORM','MENU',0,'platform:customer','客户管理','/customers',NULL,10,'ACTIVE',1,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3),0); INSERT INTO iam_permission (id, tenant_id, app_code, permission_type, parent_id, permission_code, permission_name, route_path, http_method, sort_no, status, built_in, created_at, updated_at, deleted) VALUES (5100010,0,'PLATFORM','MENU',5100000,'platform:customer:list','企业客户','/customers/list',NULL,10,'ACTIVE',1,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3),0); INSERT INTO iam_permission (id, tenant_id, app_code, permission_type, parent_id, permission_code, permission_name, route_path, http_method, sort_no, status, built_in, created_at, updated_at, deleted) VALUES (5100011,0,'PLATFORM','BUTTON',5100010,'platform:customer:create','新增企业客户',NULL,NULL,10,'ACTIVE',1,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3),0); @@ -6125,6 +6125,9 @@ INSERT INTO iam_permission (id, tenant_id, app_code, permission_type, parent_id, INSERT INTO iam_permission (id, tenant_id, app_code, permission_type, parent_id, permission_code, permission_name, route_path, http_method, sort_no, status, built_in, created_at, updated_at, deleted) VALUES (5102127,0,'PLATFORM','BUTTON',5102030,'platform:firstleg:inbound:delivery:complete','完成派送',NULL,NULL,200,'ACTIVE',1,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3),0); INSERT INTO iam_permission (id, tenant_id, app_code, permission_type, parent_id, permission_code, permission_name, route_path, http_method, sort_no, status, built_in, created_at, updated_at, deleted) VALUES (5102128,0,'PLATFORM','BUTTON',5102030,'platform:firstleg:inbound:picking:edit','执行拣货',NULL,NULL,210,'ACTIVE',1,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3),0); INSERT INTO iam_permission (id, tenant_id, app_code, permission_type, parent_id, permission_code, permission_name, route_path, http_method, sort_no, status, built_in, created_at, updated_at, deleted) VALUES (5102129,0,'PLATFORM','BUTTON',5102030,'platform:firstleg:inbound:picking:view','查看拣货任务',NULL,NULL,220,'ACTIVE',1,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3),0); +INSERT INTO iam_permission (id, tenant_id, app_code, permission_type, parent_id, permission_code, permission_name, route_path, http_method, sort_no, status, built_in, created_at, updated_at, deleted) VALUES (5102131,0,'PLATFORM','MENU',5103000,'platform:mojia-sku-sync:list','一件代发SKU同步','/general/mojia-sku-sync','GET',130,'ACTIVE',1,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3),0); +INSERT INTO iam_permission (id, tenant_id, app_code, permission_type, parent_id, permission_code, permission_name, route_path, http_method, sort_no, status, built_in, created_at, updated_at, deleted) VALUES (5102132,0,'PLATFORM','BUTTON',5102131,'platform:mojia-sku-sync:edit','编辑一件代发SKU测量数据',NULL,'PUT',10,'ACTIVE',1,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3),0); +INSERT INTO iam_permission (id, tenant_id, app_code, permission_type, parent_id, permission_code, permission_name, route_path, http_method, sort_no, status, built_in, created_at, updated_at, deleted) VALUES (5102133,0,'PLATFORM','BUTTON',5102131,'platform:mojia-sku-sync:retry','重新推送一件代发SKU',NULL,'POST',20,'ACTIVE',1,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3),0); INSERT INTO iam_permission (id, tenant_id, app_code, permission_type, parent_id, permission_code, permission_name, route_path, http_method, sort_no, status, built_in, created_at, updated_at, deleted) VALUES (5102140,0,'PLATFORM','MENU',5102000,'platform:firstleg:ocean-booking:list','订舱管理','/first-leg/ocean-bookings','GET',80,'ACTIVE',1,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3),0); INSERT INTO iam_permission (id, tenant_id, app_code, permission_type, parent_id, permission_code, permission_name, route_path, http_method, sort_no, status, built_in, created_at, updated_at, deleted) VALUES (5102142,0,'PLATFORM','BUTTON',5102140,'platform:firstleg:ocean-booking:create','创建订舱',NULL,'POST',20,'ACTIVE',1,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3),0); INSERT INTO iam_permission (id, tenant_id, app_code, permission_type, parent_id, permission_code, permission_name, route_path, http_method, sort_no, status, built_in, created_at, updated_at, deleted) VALUES (5102146,0,'PLATFORM','BUTTON',5102140,'platform:firstleg:ocean-booking:release','确认放舱',NULL,'POST',60,'ACTIVE',1,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3),0); @@ -6390,7 +6393,7 @@ INSERT INTO iam_role (id, tenant_id, app_code, role_code, role_name, role_type, INSERT INTO iam_role (id, tenant_id, app_code, role_code, role_name, role_type, status, description, created_by, created_at, updated_by, updated_at, deleted, version) VALUES (4000007,0,'PLATFORM','CN_FIRSTLEG_PDA_OPERATOR','国内头程 PDA 作业员','SYSTEM','ACTIVE','负责使用国内 PDA 执行柜次拣货、现场取证和装柜完成',NULL,CURRENT_TIMESTAMP(3),NULL,CURRENT_TIMESTAMP(3),0,0); INSERT INTO iam_role (id, tenant_id, app_code, role_code, role_name, role_type, status, description, created_by, created_at, updated_by, updated_at, deleted, version) VALUES (4000100,0,'MERCHANT','MERCHANT_OWNER','企业管理员','SYSTEM','ACTIVE','一期企业全部菜单和操作按钮模板',1000001,CURRENT_TIMESTAMP(3),1000001,CURRENT_TIMESTAMP(3),0,0); --- 系统基线 iam_role_permission:539 行。 +-- 系统基线 iam_role_permission:545 行。 INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000001,5100000,0,CURRENT_TIMESTAMP(3)); INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000001,5100010,0,CURRENT_TIMESTAMP(3)); INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000001,5100011,0,CURRENT_TIMESTAMP(3)); @@ -6501,6 +6504,9 @@ INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000001,5102127,0,CURRENT_TIMESTAMP(3)); INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000001,5102128,0,CURRENT_TIMESTAMP(3)); INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000001,5102129,0,CURRENT_TIMESTAMP(3)); +INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000001,5102131,0,CURRENT_TIMESTAMP(3)); +INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000001,5102132,0,CURRENT_TIMESTAMP(3)); +INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000001,5102133,0,CURRENT_TIMESTAMP(3)); INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000001,5102140,0,CURRENT_TIMESTAMP(3)); INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000001,5102142,0,CURRENT_TIMESTAMP(3)); INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000001,5102146,0,CURRENT_TIMESTAMP(3)); @@ -6765,6 +6771,9 @@ INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000003,5102127,0,CURRENT_TIMESTAMP(3)); INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000003,5102128,0,CURRENT_TIMESTAMP(3)); INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000003,5102129,0,CURRENT_TIMESTAMP(3)); +INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000003,5102131,0,CURRENT_TIMESTAMP(3)); +INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000003,5102132,0,CURRENT_TIMESTAMP(3)); +INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000003,5102133,0,CURRENT_TIMESTAMP(3)); INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000003,5102140,0,CURRENT_TIMESTAMP(3)); INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000003,5102142,0,CURRENT_TIMESTAMP(3)); INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000003,5102146,0,CURRENT_TIMESTAMP(3)); diff --git a/firstleg/src/main/java/com/baiyue/firstleg/config/LingxingSyncProperties.java b/firstleg/src/main/java/com/baiyue/firstleg/config/LingxingSyncProperties.java index 385834105..a9c1dd4c4 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/config/LingxingSyncProperties.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/config/LingxingSyncProperties.java @@ -20,7 +20,9 @@ public class LingxingSyncProperties { /** 安全默认值:墨家数据只入库,必须人工点击推送。 */ private boolean autoSyncEnabled = false; private String baseUrl = "https://wms.xlwms.com"; - private String loginOrigin = "https://oms.xlwms.com"; + private String loginOrigin = "https://wms.xlwms.com"; + private String username; + private String password; private String providerKey = "LINGXING_01"; private String browserCdpEndpoint; private boolean browserSessionRequired = true; diff --git a/firstleg/src/main/java/com/baiyue/firstleg/controller/PlatformMojiaSkuSyncController.java b/firstleg/src/main/java/com/baiyue/firstleg/controller/PlatformMojiaSkuSyncController.java index 92ad3cd80..44675775d 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/controller/PlatformMojiaSkuSyncController.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/controller/PlatformMojiaSkuSyncController.java @@ -5,8 +5,14 @@ import com.baiyue.common.core.api.PageResult; import com.baiyue.firstleg.dto.MojiaSkuSyncDtos; import com.baiyue.firstleg.dto.LingxingTestDtos; import com.baiyue.firstleg.lingxing.LingxingDirectClient; +import com.baiyue.firstleg.lingxing.LingxingAuthManager; +import com.baiyue.firstleg.config.LingxingSyncProperties; import com.baiyue.firstleg.service.FirstlegScopeGuard; import com.baiyue.firstleg.service.MojiaSkuSyncService; +import com.baiyue.firstleg.lingxing.LingxingSecondaryVerificationException; +import com.baiyue.firstleg.lingxing.LingxingBrowserChallengeException; +import com.baiyue.common.core.exception.BizException; +import com.baiyue.common.core.api.ResultCode; import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.tags.Tag; import jakarta.annotation.Resource; @@ -24,6 +30,8 @@ import com.baiyue.common.security.AuthenticatedUser; public class PlatformMojiaSkuSyncController { @Resource private MojiaSkuSyncService service; @Resource private LingxingDirectClient lingxing; + @Resource private LingxingAuthManager authManager; + @Resource private LingxingSyncProperties lingxingProperties; @GetMapping @PreAuthorize("hasAuthority('platform:mojia-sku-sync:list')") @@ -52,7 +60,30 @@ public class PlatformMojiaSkuSyncController { public ApiResponse testConnection( @AuthenticationPrincipal AuthenticatedUser user, @Valid @RequestBody LingxingTestDtos.Request request) { FirstlegScopeGuard.platform(user); - return ApiResponse.success(lingxing.test(request.skuCode())); + try { + return ApiResponse.success(lingxing.test(request.skuCode())); + } catch (LingxingSecondaryVerificationException ex) { + throw new BizException(ResultCode.CONFLICT, "领星需要二次验证码,请输入验证码后重试"); + } catch (LingxingBrowserChallengeException ex) { + throw new BizException(ResultCode.CONFLICT, "领星需要在浏览器中完成安全验证"); + } + } + + @PostMapping("/verification-code") + @PreAuthorize("hasAuthority('platform:mojia-sku-sync:list')") + @Operation(summary = "提交领星二次验证码") + public ApiResponse verificationCode( + @AuthenticationPrincipal AuthenticatedUser user, + @Valid @RequestBody LingxingTestDtos.VerificationRequest request) { + FirstlegScopeGuard.platform(user); + try { + authManager.submitVerification(lingxingProperties.getProviderKey(), lingxingProperties.getLoginOrigin(), request.code()); + return ApiResponse.success("领星登录成功,请重新执行原操作"); + } catch (LingxingSecondaryVerificationException ex) { + throw new BizException(ResultCode.CONFLICT, "领星二次验证码仍未完成,请重新输入验证码"); + } catch (LingxingBrowserChallengeException ex) { + throw new BizException(ResultCode.CONFLICT, "领星需要在浏览器中完成安全验证"); + } } @PutMapping("/{id}") @@ -72,6 +103,12 @@ public class PlatformMojiaSkuSyncController { @AuthenticationPrincipal AuthenticatedUser user, @PathVariable long id, @Valid @RequestBody MojiaSkuSyncDtos.RetryRequest request) { FirstlegScopeGuard.platform(user); - return ApiResponse.success(service.retry(id, request)); + try { + return ApiResponse.success(service.retry(id, request)); + } catch (LingxingSecondaryVerificationException ex) { + throw new BizException(ResultCode.CONFLICT, "领星需要二次验证码,请输入验证码后重试"); + } catch (LingxingBrowserChallengeException ex) { + throw new BizException(ResultCode.CONFLICT, "领星需要在浏览器中完成安全验证"); + } } } diff --git a/firstleg/src/main/java/com/baiyue/firstleg/dto/LingxingTestDtos.java b/firstleg/src/main/java/com/baiyue/firstleg/dto/LingxingTestDtos.java index 49244d86d..b2754712b 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/dto/LingxingTestDtos.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/dto/LingxingTestDtos.java @@ -9,6 +9,7 @@ public final class LingxingTestDtos { private LingxingTestDtos() {} public record Request(@NotBlank @Size(max = 128) String skuCode) {} + public record VerificationRequest(@NotBlank @Size(max = 16) String code) {} public record WarehouseResult(String whCode, boolean reachable, boolean matched, boolean maintained, String productSku, String barcode, BigDecimal length, BigDecimal width, BigDecimal height, diff --git a/firstleg/src/main/java/com/baiyue/firstleg/lingxing/LingxingAuthManager.java b/firstleg/src/main/java/com/baiyue/firstleg/lingxing/LingxingAuthManager.java index 7a72d6db9..96de6b70d 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/lingxing/LingxingAuthManager.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/lingxing/LingxingAuthManager.java @@ -19,6 +19,7 @@ public class LingxingAuthManager { private final Map cache = new ConcurrentHashMap<>(); private final Map> refreshing = new ConcurrentHashMap<>(); private final Map cooldownUntil = new ConcurrentHashMap<>(); + private final Map pendingVerification = new ConcurrentHashMap<>(); private volatile Function passwordLogin; @Autowired @@ -67,12 +68,32 @@ public class LingxingAuthManager { LingxingAuthState state = browser.read(config, loginOrigin); cache.put(config.providerKey(), state); cooldownUntil.remove(config.providerKey()); return state; } catch (RuntimeException ex) { - if (config.browserSessionRequired()) throw ex; + if (config.browserSessionRequired() && (config.username() == null || config.password() == null)) throw ex; } } Function login = passwordLogin; - if (login == null) throw new IllegalStateException("领星浏览器登录态不可用,且未配置账号密码登录器"); - LingxingAuthState state = login.apply(config); + LingxingAuthState state; + if (login != null) state = login.apply(config); + else if (config.username() != null && !config.username().isBlank() + && config.password() != null && !config.password().isBlank()) { + try { + state = browser.login(config, loginOrigin); + } catch (LingxingSecondaryVerificationException | LingxingBrowserChallengeException ex) { + pendingVerification.put(config.providerKey(), config); + throw ex; + } + } + else throw new IllegalStateException("领星浏览器登录态不可用,且未配置账号密码"); cache.put(config.providerKey(), state); cooldownUntil.remove(config.providerKey()); return state; } + + public LingxingAuthState submitVerification(String providerKey, String loginOrigin, String code) { + LingxingProviderConfig config = pendingVerification.get(providerKey); + if (config == null) throw new LingxingSecondaryVerificationException("当前没有等待验证码的领星登录任务"); + LingxingAuthState state = browser.submitVerification(config, loginOrigin, code); + cache.put(providerKey, state); + pendingVerification.remove(providerKey); + cooldownUntil.remove(providerKey); + return state; + } } diff --git a/firstleg/src/main/java/com/baiyue/firstleg/lingxing/LingxingBrowserChallengeException.java b/firstleg/src/main/java/com/baiyue/firstleg/lingxing/LingxingBrowserChallengeException.java new file mode 100644 index 000000000..20c16c288 --- /dev/null +++ b/firstleg/src/main/java/com/baiyue/firstleg/lingxing/LingxingBrowserChallengeException.java @@ -0,0 +1,6 @@ +package com.baiyue.firstleg.lingxing; + +/** 需要人工完成浏览器安全验证(例如腾讯滑块)时的可识别错误。 */ +public class LingxingBrowserChallengeException extends RuntimeException { + public LingxingBrowserChallengeException(String message) { super(message); } +} diff --git a/firstleg/src/main/java/com/baiyue/firstleg/lingxing/LingxingBrowserSessionReader.java b/firstleg/src/main/java/com/baiyue/firstleg/lingxing/LingxingBrowserSessionReader.java index f899741d1..b4410f910 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/lingxing/LingxingBrowserSessionReader.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/lingxing/LingxingBrowserSessionReader.java @@ -5,6 +5,7 @@ import com.fasterxml.jackson.databind.ObjectMapper; import com.fasterxml.jackson.databind.node.ArrayNode; import com.fasterxml.jackson.databind.node.ObjectNode; import java.net.URI; +import java.net.URISyntaxException; import java.net.http.HttpClient; import java.net.http.HttpRequest; import java.net.http.HttpResponse; @@ -37,49 +38,191 @@ public class LingxingBrowserSessionReader { } public LingxingAuthState read(LingxingProviderConfig config, String loginOrigin) { + CdpSocket socket = null; try { - ArrayNode pages = (ArrayNode) mapper.readTree(http.send( - HttpRequest.newBuilder(URI.create(stripSlash(config.browserCdpEndpoint()) + "/json/list")) - .timeout(timeout).GET().build(), HttpResponse.BodyHandlers.ofString()).body()); - JsonNode selected = selectPage(pages, loginOrigin); - if (selected == null) throw new IllegalStateException("未找到已登录的领星 OMS 页面"); - String ws = selected.path("webSocketDebuggerUrl").asText(""); - if (ws.isBlank()) throw new IllegalStateException("领星页面未提供 CDP WebSocket 地址"); - CdpSocket socket = new CdpSocket(mapper, http, timeout, URI.create(ws)); - ObjectNode browser = socket.command("Runtime.evaluate", mapper.createObjectNode() - .put("expression", "({token:localStorage.getItem('oms-token'),version:localStorage.getItem('version'),href:location.href,ua:navigator.userAgent})") - .put("returnByValue", true)).join(); - JsonNode value = browser.path("result").path("result").path("value"); - String token = value.path("token").asText(""); - String href = value.path("href").asText(""); - if (token.isBlank() || href.contains("/login")) throw new IllegalStateException("领星浏览器尚未完成登录"); - ObjectNode cookiesResult = socket.command("Network.getAllCookies", mapper.createObjectNode()).join(); - String host = URI.create(loginOrigin).getHost(); - List cookies = new ArrayList<>(); - for (JsonNode cookie : cookiesResult.path("result").path("cookies")) { - if (cookie.path("domain").asText("").contains(host)) cookies.add(cookie.path("name").asText() + "=" + cookie.path("value").asText()); - } - if (cookies.isEmpty()) throw new IllegalStateException("领星浏览器未返回有效 Cookie"); - long expiresAt = expiresAt(token, config); - socket.close(); - return new LingxingAuthState(token, String.join("; ", cookies), expiresAt, - value.path("ua").asText(null), "browser_session"); + socket = open(config, loginOrigin, false); + return readFromSocket(socket, config, loginOrigin); } catch (Exception ex) { throw new IllegalStateException("读取领星浏览器登录态失败", ex); + } finally { + if (socket != null) socket.close(); } } - private JsonNode selectPage(ArrayNode pages, String origin) { + /** 方案 A:业务调用触发的浏览器账号密码登录。不会绕过腾讯滑块。 */ + public LingxingAuthState login(LingxingProviderConfig config, String loginOrigin) { + CdpSocket socket = null; + try { + socket = open(config, loginOrigin, true); + JsonNode state = evaluate(socket, pageStateExpression()); + if (state.path("token").asText("").isBlank() || state.path("href").asText("").contains("/login")) { + if (state.path("captcha").asBoolean(false)) throw new LingxingBrowserChallengeException("领星需要人工完成安全验证"); + fill(socket, "input[name='username']", config.username()); + fill(socket, "input[name='password']", config.password()); + click(socket, ".login-btn"); + long deadline = System.nanoTime() + timeout.toNanos() * 12; + while (System.nanoTime() < deadline) { + Thread.sleep(250); + state = evaluate(socket, pageStateExpression()); + if (!state.path("token").asText("").isBlank() && !state.path("href").asText("").contains("/login")) break; + if (state.path("captcha").asBoolean(false)) throw new LingxingBrowserChallengeException("领星需要人工完成安全验证"); + if (state.path("mfa").asBoolean(false)) { + requestVerificationCode(socket); + throw new LingxingSecondaryVerificationException("领星需要二次验证码"); + } + } + } + return readFromSocket(socket, config, loginOrigin); + } catch (LingxingSecondaryVerificationException | LingxingBrowserChallengeException ex) { + throw ex; + } catch (Exception ex) { + throw new IllegalStateException("领星自动登录失败", ex); + } finally { + if (socket != null) socket.close(); + } + } + + /** 提交当前登录页面的短信、邮箱或 TOTP 验证码,并读取新的会话。 */ + public LingxingAuthState submitVerification(LingxingProviderConfig config, String loginOrigin, String code) { + if (code == null || code.isBlank()) throw new IllegalArgumentException("验证码不能为空"); + CdpSocket socket = null; + try { + socket = open(config, loginOrigin, false); + // SMS/email uses Element UI's placeholder input; TOTP uses the + // custom six-digit input component with a native input element. + fill(socket, "input[placeholder='输入验证码'], input.verification-code-native", code.trim()); + click(socket, ".verify-code-login .login-btn"); + long deadline = System.nanoTime() + timeout.toNanos() * 12; + while (System.nanoTime() < deadline) { + Thread.sleep(250); + JsonNode state = evaluate(socket, pageStateExpression()); + if (!state.path("token").asText("").isBlank() && !state.path("href").asText("").contains("/login")) { + return readFromSocket(socket, config, loginOrigin); + } + if (state.path("captcha").asBoolean(false)) { + throw new LingxingBrowserChallengeException("领星需要人工完成安全验证"); + } + if (!state.path("mfa").asBoolean(false)) throw new IllegalStateException("领星验证码验证失败"); + } + throw new IllegalStateException("领星验证码验证超时"); + } catch (Exception ex) { + if (ex instanceof IllegalStateException) throw (IllegalStateException) ex; + throw new IllegalStateException("提交领星验证码失败", ex); + } finally { + if (socket != null) socket.close(); + } + } + + private CdpSocket open(LingxingProviderConfig config, String loginOrigin, boolean allowLoginPage) throws Exception { + ArrayNode pages = (ArrayNode) mapper.readTree(http.send( + HttpRequest.newBuilder(URI.create(stripSlash(config.browserCdpEndpoint()) + "/json/list")) + .timeout(timeout).GET().build(), HttpResponse.BodyHandlers.ofString()).body()); + JsonNode selected = selectPage(pages, loginOrigin); + if (selected == null) throw new IllegalStateException("未找到领星 OMS 页面"); + String ws = selected.path("webSocketDebuggerUrl").asText(""); + if (ws.isBlank()) throw new IllegalStateException("领星页面未提供 CDP WebSocket 地址"); + CdpSocket socket = new CdpSocket(mapper, http, timeout, rewriteWebSocketEndpoint(URI.create(ws), URI.create(config.browserCdpEndpoint()))); + socket.command("Runtime.enable", mapper.createObjectNode()).join(); + socket.command("Network.enable", mapper.createObjectNode()).join(); + if (allowLoginPage) socket.command("Page.enable", mapper.createObjectNode()).join(); + return socket; + } + + private LingxingAuthState readFromSocket(CdpSocket socket, LingxingProviderConfig config, String loginOrigin) { + JsonNode value = evaluate(socket, "({token:localStorage.getItem('oms-token'),version:localStorage.getItem('version'),href:location.href,ua:navigator.userAgent})"); + String token = value.path("token").asText(""); + String href = value.path("href").asText(""); + if (token.isBlank() || href.contains("/login")) throw new IllegalStateException("领星浏览器尚未完成登录"); + ObjectNode cookiesResult = socket.command("Network.getAllCookies", mapper.createObjectNode()).join(); + String host = URI.create(loginOrigin).getHost(); + List cookies = new ArrayList<>(); + for (JsonNode cookie : cookiesResult.path("result").path("cookies")) { + if (cookie.path("domain").asText("").contains(host)) cookies.add(cookie.path("name").asText() + "=" + cookie.path("value").asText()); + } + if (cookies.isEmpty()) throw new IllegalStateException("领星浏览器未返回有效 Cookie"); + return new LingxingAuthState(token, String.join("; ", cookies), expiresAt(token, config), value.path("ua").asText(null), "browser_session"); + } + + private JsonNode evaluate(CdpSocket socket, String expression) { + return socket.command("Runtime.evaluate", mapper.createObjectNode().put("expression", expression).put("returnByValue", true)) + .join().path("result").path("result").path("value"); + } + + private static String pageStateExpression() { + return "(() => {" + + "const visible = (e) => { if (!e) return false; const s=getComputedStyle(e), r=e.getBoundingClientRect();" + + "return s.display !== 'none' && s.visibility !== 'hidden' && Number(s.opacity) !== 0 && r.width > 0 && r.height > 0; };" + + "const captcha = Array.from(document.querySelectorAll('#captchaId iframe,#captchaId > *,.tencent-captcha-dy')).some(visible);" + + "return {href:location.href,token:localStorage.getItem('oms-token'),mfa:!!document.querySelector('.verify-code-login'),captcha};" + + "})()"; + } + + private void fill(CdpSocket socket, String selector, String value) { + String expression = "(() => { const e=document.querySelector(" + mapper.createObjectNode().put("s", selector).path("s").toString() + "); if(!e) return false; e.focus(); e.select(); return true; })()"; + if (!evaluate(socket, expression).asBoolean(false)) throw new IllegalStateException("登录控件不存在: " + selector); + socket.command("Input.insertText", mapper.createObjectNode().put("text", value)).join(); + } + + private void click(CdpSocket socket, String selector) { + String expression = "(() => { const e=document.querySelector(" + mapper.createObjectNode().put("s", selector).path("s").toString() + "); if(!e) return false; e.click(); return true; })()"; + if (!evaluate(socket, expression).asBoolean(false)) throw new IllegalStateException("登录按钮不存在: " + selector); + } + + private void requestVerificationCode(CdpSocket socket) throws InterruptedException { + // SMS/EMAIL pages require an explicit send action. TOTP initializes itself + // when the channel is mounted and has no .code-btn. + long deadline = System.nanoTime() + Duration.ofSeconds(2).toNanos(); + while (System.nanoTime() < deadline) { + if (evaluate(socket, "(() => { const e=document.querySelector('.verify-code-login .code-btn'); return !!e && !e.disabled; })()") + .asBoolean(false)) { + click(socket, ".verify-code-login .code-btn"); + Thread.sleep(300); + return; + } + Thread.sleep(100); + } + } + + static URI rewriteWebSocketEndpoint(URI discovered, URI endpoint) { + if (discovered == null || endpoint == null || discovered.getPath() == null || discovered.getPath().isBlank()) { + throw new IllegalArgumentException("CDP WebSocket 地址无效"); + } + try { + String scheme = "https".equalsIgnoreCase(endpoint.getScheme()) ? "wss" : "ws"; + return new URI(scheme, null, endpoint.getHost(), endpoint.getPort(), discovered.getPath(), discovered.getQuery(), discovered.getFragment()); + } catch (URISyntaxException ex) { + throw new IllegalArgumentException("CDP WebSocket 地址无效", ex); + } + } + + static JsonNode selectPage(ArrayNode pages, String origin) { + URI expected = URI.create(origin); JsonNode fallback = null; for (JsonNode page : pages) { + if (!"page".equals(page.path("type").asText(""))) continue; String url = page.path("url").asText(""); - if (!url.startsWith(origin)) continue; - if (!url.contains("/login")) return page; + if (page.path("webSocketDebuggerUrl").asText("").isBlank()) continue; + URI actual; + try { actual = URI.create(url); } catch (IllegalArgumentException ex) { continue; } + if (!sameOrigin(expected, actual)) continue; + String path = actual.getPath() == null ? "" : actual.getPath(); + if (!path.equals("/login") && !path.startsWith("/login/")) return page; fallback = page; } return fallback; } + private static boolean sameOrigin(URI expected, URI actual) { + return expected.getScheme() != null && expected.getScheme().equalsIgnoreCase(actual.getScheme()) + && expected.getHost() != null && expected.getHost().equalsIgnoreCase(actual.getHost()) + && effectivePort(expected) == effectivePort(actual); + } + + private static int effectivePort(URI uri) { + if (uri.getPort() >= 0) return uri.getPort(); + return "https".equalsIgnoreCase(uri.getScheme()) ? 443 : 80; + } + static long expiresAt(String token, LingxingProviderConfig config) { long now = System.currentTimeMillis(); try { diff --git a/firstleg/src/main/java/com/baiyue/firstleg/lingxing/LingxingDirectClient.java b/firstleg/src/main/java/com/baiyue/firstleg/lingxing/LingxingDirectClient.java index a6043dd01..0b246d123 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/lingxing/LingxingDirectClient.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/lingxing/LingxingDirectClient.java @@ -18,12 +18,15 @@ import java.nio.charset.StandardCharsets; import java.security.MessageDigest; import java.util.ArrayList; import java.util.Base64; +import java.util.concurrent.CompletionException; import java.util.List; import java.util.Locale; import java.util.Map; import java.util.TreeMap; +import java.math.BigDecimal; +import java.math.RoundingMode; -/** 领星新品查询只读客户端。不会调用 /measure。 */ +/** 领星新品查询与人工尺寸维护客户端;自动任务不会调用本客户端的写入入口。 */ @Component public class LingxingDirectClient { private final ObjectMapper mapper; @@ -41,14 +44,68 @@ public class LingxingDirectClient { public LingxingTestDtos.Response test(String skuCode) { String sku = skuCode == null ? "" : skuCode.trim(); if (sku.isBlank()) throw new IllegalArgumentException("SKU不能为空"); - LingxingAuthState auth = authManager.getAuthState(new LingxingProviderConfig( + LingxingAuthState auth = awaitAuth(authManager.getAuthState(new LingxingProviderConfig( properties.getProviderKey(), properties.getBrowserCdpEndpoint(), properties.isBrowserSessionRequired(), - properties.getTokenTtlSeconds(), properties.getTokenExpirySafetySeconds()), properties.getLoginOrigin()).join(); + properties.getTokenTtlSeconds(), properties.getTokenExpirySafetySeconds(), + properties.getUsername(), properties.getPassword()), properties.getLoginOrigin())); List results = new ArrayList<>(); - for (String whCode : properties.getWarehouses()) results.add(query(auth, sku, whCode)); + for (String whCode : properties.getWarehouses()) results.add(queryWithReLogin(auth, sku, whCode)); return new LingxingTestDtos.Response(sku, results); } + /** 手工维护领星新品尺寸;只对精确命中的、尚未维护的仓库发起一次 /measure。 */ + public PushResult push(String skuCode, BigDecimal lengthCm, BigDecimal widthCm, + BigDecimal heightCm, BigDecimal weightKg) { + String sku = skuCode == null ? "" : skuCode.trim(); + if (sku.isBlank()) throw new IllegalArgumentException("SKU不能为空"); + LingxingAuthState auth = awaitAuth(authManager.getAuthState(new LingxingProviderConfig( + properties.getProviderKey(), properties.getBrowserCdpEndpoint(), properties.isBrowserSessionRequired(), + properties.getTokenTtlSeconds(), properties.getTokenExpirySafetySeconds(), + properties.getUsername(), properties.getPassword()), properties.getLoginOrigin())); + List results = new ArrayList<>(); + for (String whCode : properties.getWarehouses()) { + try { + JsonNode record; + try { + record = findExact(auth, sku, whCode); + } catch (IllegalStateException unauthorized) { + if (!"领星登录态已失效".equals(unauthorized.getMessage())) throw unauthorized; + authManager.invalidate(properties.getProviderKey(), "lingxing_push_unauthorized"); + auth = awaitAuth(authManager.getAuthState(providerConfig(), properties.getLoginOrigin())); + record = findExact(auth, sku, whCode); + } + if (record == null) { + results.add(new PushWarehouseResult(whCode, false, "未匹配到领星 SKU")); + } else if (record.path("maintained").asInt(0) == 1) { + results.add(new PushWarehouseResult(whCode, false, "领星 SKU 已维护,不能重复维护")); + } else { + try { + measure(auth, record, lengthCm, widthCm, heightCm, weightKg); + } catch (IllegalStateException unauthorized) { + if (!"领星登录态已失效".equals(unauthorized.getMessage())) throw unauthorized; + authManager.invalidate(properties.getProviderKey(), "lingxing_measure_unauthorized"); + auth = awaitAuth(authManager.getAuthState(providerConfig(), properties.getLoginOrigin())); + JsonNode refreshedRecord = findExact(auth, sku, whCode); + if (refreshedRecord == null || refreshedRecord.path("maintained").asInt(0) == 1) { + results.add(new PushWarehouseResult(whCode, false, "领星 SKU 已维护,不能重复维护")); + continue; + } + measure(auth, refreshedRecord, lengthCm, widthCm, heightCm, weightKg); + } + results.add(new PushWarehouseResult(whCode, true, "提交成功")); + } + } catch (Exception ex) { + if (ex instanceof LingxingSecondaryVerificationException + || ex instanceof LingxingBrowserChallengeException) throw (RuntimeException) ex; + results.add(new PushWarehouseResult(whCode, false, "提交领星失败")); + } + } + boolean success = results.stream().anyMatch(PushWarehouseResult::success) + && results.stream().filter(r -> r.matched()).allMatch(PushWarehouseResult::success); + String lingxingSkuId = sku; + return new PushResult(success, lingxingSkuId, results); + } + private LingxingTestDtos.WarehouseResult query(LingxingAuthState auth, String sku, String whCode) { try { ObjectNode body = mapper.createObjectNode().put("barcode", sku).put("current", 1) @@ -63,7 +120,7 @@ public class LingxingDirectClient { .header("Track-Key", trackKey("POST", json)).POST(HttpRequest.BodyPublishers.ofString(json)).build(); HttpResponse response = http.send(request, HttpResponse.BodyHandlers.ofString()); JsonNode root = mapper.readTree(response.body()); - if (response.statusCode() == 401) { + if (response.statusCode() == 401 || isUnauthorized(root)) { authManager.invalidate(properties.getProviderKey(), "lingxing_test_unauthorized"); return new LingxingTestDtos.WarehouseResult(whCode, false, false, false, null, null, null, null, null, null, "领星登录态已失效,请重新登录"); } @@ -82,18 +139,113 @@ public class LingxingDirectClient { } } + private LingxingTestDtos.WarehouseResult queryWithReLogin(LingxingAuthState initial, String sku, String whCode) { + LingxingTestDtos.WarehouseResult result = query(initial, sku, whCode); + if (result.reachable() || result.message() == null || !result.message().contains("登录态已失效")) return result; + authManager.invalidate(properties.getProviderKey(), "lingxing_test_unauthorized"); + LingxingAuthState refreshed = awaitAuth(authManager.getAuthState(providerConfig(), properties.getLoginOrigin())); + return query(refreshed, sku, whCode); + } + + /** Keep domain login/challenge exceptions visible to the REST controller. */ + private static LingxingAuthState awaitAuth(java.util.concurrent.CompletableFuture 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 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 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 warehouses) {} + private LingxingTestDtos.WarehouseResult empty(String whCode, boolean reachable, String message) { return new LingxingTestDtos.WarehouseResult(whCode, reachable, false, false, null, null, null, null, null, null, message); } private static String text(JsonNode node, String key) { return node.path(key).isMissingNode() ? null : node.path(key).asText(null); } private static java.math.BigDecimal decimal(JsonNode node, String key) { return node.path(key).isNumber() ? node.path(key).decimalValue() : null; } + private static boolean isUnauthorized(JsonNode root) { + int code = root.path("code").asInt(-1); + return code == 401 || code == 40101 || code == 40102 || code == 40301; + } static String buildTrackKey(String method, String json, String secretBase64) { if (secretBase64 == null || secretBase64.isBlank()) throw new IllegalStateException("未配置领星 Track-Key v2 密钥"); try { byte[] digest = MessageDigest.getInstance("SHA-256").digest(json.getBytes(StandardCharsets.UTF_8)); StringBuilder hex = new StringBuilder(); for (byte b : digest) hex.append(String.format(Locale.ROOT, "%02x", b)); byte[] key = Base64.getDecoder().decode(secretBase64); - if (key.length != 44) throw new IllegalStateException("领星 Track-Key v2 密钥解码后必须是 44 字节"); + if (key.length == 0) throw new IllegalStateException("领星 Track-Key v2 密钥解码后不能为空"); Mac mac = Mac.getInstance("HmacSHA256"); mac.init(new SecretKeySpec(key, "HmacSHA256")); return "v2:" + Base64.getEncoder().encodeToString(mac.doFinal((method.toUpperCase(Locale.ROOT) + hex).getBytes(StandardCharsets.UTF_8))); } catch (Exception ex) { throw new IllegalStateException("领星 Track-Key 生成失败", ex); } diff --git a/firstleg/src/main/java/com/baiyue/firstleg/lingxing/LingxingProviderConfig.java b/firstleg/src/main/java/com/baiyue/firstleg/lingxing/LingxingProviderConfig.java index bec577b82..5c5fff384 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/lingxing/LingxingProviderConfig.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/lingxing/LingxingProviderConfig.java @@ -3,10 +3,27 @@ package com.baiyue.firstleg.lingxing; /** 领星账号运行配置;密码和签名密钥不进入此对象日志。 */ public record LingxingProviderConfig(String providerKey, String browserCdpEndpoint, boolean browserSessionRequired, long tokenTtlSeconds, - long expirySafetySeconds) { + long expirySafetySeconds, String username, String password) { + public LingxingProviderConfig(String providerKey, String browserCdpEndpoint, + boolean browserSessionRequired, long tokenTtlSeconds, + long expirySafetySeconds) { + this(providerKey, browserCdpEndpoint, browserSessionRequired, tokenTtlSeconds, + expirySafetySeconds, null, null); + } public LingxingProviderConfig { if (providerKey == null || providerKey.isBlank()) throw new IllegalArgumentException("providerKey不能为空"); if (tokenTtlSeconds <= 0) tokenTtlSeconds = 172800; if (expirySafetySeconds < 0) expirySafetySeconds = 300; } + + @Override + public String toString() { + return "LingxingProviderConfig[providerKey=" + providerKey + + ", browserCdpEndpoint=" + browserCdpEndpoint + + ", browserSessionRequired=" + browserSessionRequired + + ", tokenTtlSeconds=" + tokenTtlSeconds + + ", expirySafetySeconds=" + expirySafetySeconds + + ", usernamePresent=" + (username != null && !username.isBlank()) + + ", passwordPresent=" + (password != null && !password.isBlank()) + "]"; + } } diff --git a/firstleg/src/main/java/com/baiyue/firstleg/repository/MojiaSkuSyncRepository.java b/firstleg/src/main/java/com/baiyue/firstleg/repository/MojiaSkuSyncRepository.java index 4b7697f53..7482ac209 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/repository/MojiaSkuSyncRepository.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/repository/MojiaSkuSyncRepository.java @@ -61,4 +61,47 @@ public class MojiaSkuSyncRepository { .set(MojiaMeasurementReceiptEntity::getStatus, "SKU_PENDING_SYNC") .set(MojiaMeasurementReceiptEntity::getRevision, revision + 1)); } + + /** + * A secondary-verification challenge happens before the Lingxing request is sent. + * Release the optimistic claim so the original UI action can resume with its + * original revision after the code is submitted. + */ + public int restorePendingClaim(long id, int claimedRevision) { + return mapper.update(null, new LambdaUpdateWrapper() + .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() + .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() + .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)); + } } diff --git a/firstleg/src/main/java/com/baiyue/firstleg/service/MojiaMeasurementOpenApiService.java b/firstleg/src/main/java/com/baiyue/firstleg/service/MojiaMeasurementOpenApiService.java index 63527dbd1..1de4e3153 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/service/MojiaMeasurementOpenApiService.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/service/MojiaMeasurementOpenApiService.java @@ -23,6 +23,7 @@ import org.slf4j.LoggerFactory; import org.slf4j.MDC; import org.springframework.stereotype.Service; import org.springframework.beans.factory.annotation.Value; +import org.springframework.beans.factory.annotation.Autowired; import org.springframework.web.multipart.MultipartFile; import java.io.ByteArrayInputStream; @@ -49,17 +50,20 @@ public class MojiaMeasurementOpenApiService { private final MeasurementImageClient imageClient; private final MojiaMeasurementReceiptRepository receipts; private final InboundCartonLabelService cartonLabels; + private final MojiaSkuSyncService skuSync; @Value("${baiyue.security.internal-service-token}") private String internalToken; + @Autowired public MojiaMeasurementOpenApiService(MeasurementRequestAuthenticator authenticator, CartonMeasurementService cartonMeasurements, ObjectMapper objectMapper, Validator validator, MeasurementImageClient imageClient, MojiaMeasurementReceiptRepository receipts, - InboundCartonLabelService cartonLabels) { + InboundCartonLabelService cartonLabels, + MojiaSkuSyncService skuSync) { this.authenticator = authenticator; this.cartonMeasurements = cartonMeasurements; this.objectMapper = objectMapper; @@ -67,6 +71,18 @@ public class MojiaMeasurementOpenApiService { this.imageClient = imageClient; this.receipts = receipts; this.cartonLabels = cartonLabels; + this.skuSync = skuSync; + } + + /** Test/embedded construction compatibility; production wiring uses the constructor above. */ + public MojiaMeasurementOpenApiService(MeasurementRequestAuthenticator authenticator, + CartonMeasurementService cartonMeasurements, + ObjectMapper objectMapper, + Validator validator, + MeasurementImageClient imageClient, + MojiaMeasurementReceiptRepository receipts, + InboundCartonLabelService cartonLabels) { + this(authenticator, cartonMeasurements, objectMapper, validator, imageClient, receipts, cartonLabels, null); } public UploadResponse receive(MeasurementRequestHeaders headers, String rawJson) { @@ -159,6 +175,7 @@ public class MojiaMeasurementOpenApiService { receipt.setCreatedAt(LocalDateTime.now()); receipt.setUpdatedAt(LocalDateTime.now()); receipts.insert(receipt); + if (skuSync != null) skuSync.autoSync(receiptId); return new UploadResponse(true, false, receiptId, null, null, request.boxCode(), uploaded.fileId(), true); } catch (RuntimeException exception) { diff --git a/firstleg/src/main/java/com/baiyue/firstleg/service/MojiaSkuSyncService.java b/firstleg/src/main/java/com/baiyue/firstleg/service/MojiaSkuSyncService.java index be8612342..ea51e2c90 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/service/MojiaSkuSyncService.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/service/MojiaSkuSyncService.java @@ -7,19 +7,39 @@ import com.baiyue.firstleg.config.LingxingSyncProperties; import com.baiyue.firstleg.dto.MojiaSkuSyncDtos; import com.baiyue.firstleg.entity.MojiaMeasurementReceiptEntity; import com.baiyue.firstleg.repository.MojiaSkuSyncRepository; +import com.baiyue.firstleg.lingxing.LingxingDirectClient; +import com.baiyue.firstleg.lingxing.LingxingBrowserChallengeException; +import com.baiyue.firstleg.lingxing.LingxingSecondaryVerificationException; import jakarta.annotation.Resource; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; @Service public class MojiaSkuSyncService { + private static final Logger log = LoggerFactory.getLogger(MojiaSkuSyncService.class); @Resource private MojiaSkuSyncRepository repository; @Resource private LingxingSyncProperties lingxingProperties; + @Resource private LingxingDirectClient lingxing; public MojiaSkuSyncDtos.Config config() { return new MojiaSkuSyncDtos.Config(lingxingProperties.isAutoSyncEnabled()); } + /** Called after a SKU receipt is durably saved; the Nacos switch is deliberately the only gate. */ + public void autoSync(long id) { + if (!lingxingProperties.isAutoSyncEnabled()) return; + try { + MojiaMeasurementReceiptEntity value = required(id); + retry(id, new MojiaSkuSyncDtos.RetryRequest(value.getRevision())); + } catch (LingxingSecondaryVerificationException | LingxingBrowserChallengeException ex) { + log.warn("领星自动同步等待人工验证 receiptId={} reason={}", id, ex.getMessage()); + } catch (RuntimeException ex) { + log.warn("领星自动同步失败 receiptId={}", id, ex); + } + } + public PageResult page(MojiaSkuSyncDtos.Query query) { PageResult source = repository.page( query.skuCode(), query.syncStatus(), query.page(), query.size()); @@ -36,12 +56,36 @@ public class MojiaSkuSyncService { return view(required(id)); } - @Transactional public MojiaSkuSyncDtos.Record retry(long id, MojiaSkuSyncDtos.RetryRequest request) { required(id); if (repository.retry(id, request.revision()) != 1) { throw conflict("仅待同步或同步失败记录可推送,或记录已被其他人修改"); } + MojiaMeasurementReceiptEntity claimed = required(id); + LingxingDirectClient.PushResult result; + try { + result = lingxing.push(claimed.getBoxCode(), claimed.getLengthCm(), claimed.getWidthCm(), + claimed.getHeightCm(), claimed.getWeightKg()); + } catch (RuntimeException ex) { + if (ex instanceof LingxingSecondaryVerificationException + || ex instanceof LingxingBrowserChallengeException) { + repository.restorePendingClaim(id, claimed.getRevision()); + throw ex; + } + repository.markFailed(id, claimed.getRevision(), "PUSH_ERROR", "提交领星失败"); + return view(required(id)); + } + if (result.success()) { + if (repository.markSuccess(id, claimed.getRevision(), result.lingxingSkuId()) != 1) { + throw conflict("推送完成但记录已被其他人修改,请刷新查看结果"); + } + } else { + String reason = result.warehouses().stream().map(LingxingDirectClient.PushWarehouseResult::message) + .filter(message -> message != null && !message.isBlank()).findFirst().orElse("提交领星失败"); + if (repository.markFailed(id, claimed.getRevision(), "PUSH_FAILED", reason) != 1) { + throw conflict("推送失败但记录已被其他人修改,请刷新查看结果"); + } + } return view(required(id)); } diff --git a/firstleg/src/test/java/com/baiyue/firstleg/lingxing/LingxingBrowserSessionReaderTest.java b/firstleg/src/test/java/com/baiyue/firstleg/lingxing/LingxingBrowserSessionReaderTest.java new file mode 100644 index 000000000..5764f8778 --- /dev/null +++ b/firstleg/src/test/java/com/baiyue/firstleg/lingxing/LingxingBrowserSessionReaderTest.java @@ -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"); + } +} diff --git a/firstleg/src/test/java/com/baiyue/firstleg/lingxing/LingxingTrackKeyVectorTest.java b/firstleg/src/test/java/com/baiyue/firstleg/lingxing/LingxingTrackKeyVectorTest.java index a06f80fad..60c715482 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/lingxing/LingxingTrackKeyVectorTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/lingxing/LingxingTrackKeyVectorTest.java @@ -6,6 +6,13 @@ import static org.assertj.core.api.Assertions.assertThat; import static org.junit.jupiter.api.Assumptions.assumeTrue; class LingxingTrackKeyVectorTest { + @Test + void acceptsAStandard32ByteKeyAfterOneBase64Decode() { + assertThat(LingxingDirectClient.buildTrackKey( + "POST", "{}", "MDEyMzQ1Njc4OTAxMjM0NTY3ODkwMTIzNDU2Nzg5MDE=")) + .isEqualTo("v2:0cA9d60u8HVKLHZRgbSCZJDOvec7bNGfg1tvJIFRIh4="); + } + @Test void matchesNodeTrackKeyV2VectorWhenSecretIsProvided() { String secret = System.getenv("LINGXING_TRACK_KEY_V2_SECRET_BASE64"); diff --git a/iam/src/main/resources/db/mojia-sku-sync-permissions.sql b/iam/src/main/resources/db/mojia-sku-sync-permissions.sql index 5a6bb62b8..25b36b996 100644 --- a/iam/src/main/resources/db/mojia-sku-sync-permissions.sql +++ b/iam/src/main/resources/db/mojia-sku-sync-permissions.sql @@ -22,11 +22,11 @@ BEGIN (id, tenant_id, app_code, permission_type, parent_id, permission_code, permission_name, route_path, http_method, sort_no, status, built_in, deleted) VALUES - (5102131, 0, 'PLATFORM', 'BUTTON', 5102000, 'platform:mojia-sku-sync:list', '查看一件代发SKU同步', NULL, 'GET', 130, 'ACTIVE', 1, 0), - (5102132, 0, 'PLATFORM', 'BUTTON', 5102000, 'platform:mojia-sku-sync:edit', '编辑一件代发SKU测量数据', NULL, 'PUT', 131, 'ACTIVE', 1, 0), - (5102133, 0, 'PLATFORM', 'BUTTON', 5102000, 'platform:mojia-sku-sync:retry', '重新推送一件代发SKU', NULL, 'POST', 132, 'ACTIVE', 1, 0) + (5102131, 0, 'PLATFORM', 'MENU', 5103000, 'platform:mojia-sku-sync:list', '一件代发SKU同步', '/general/mojia-sku-sync', 'GET', 130, 'ACTIVE', 1, 0), + (5102132, 0, 'PLATFORM', 'BUTTON', 5102131, 'platform:mojia-sku-sync:edit', '编辑一件代发SKU测量数据', NULL, 'PUT', 10, 'ACTIVE', 1, 0), + (5102133, 0, 'PLATFORM', 'BUTTON', 5102131, 'platform:mojia-sku-sync:retry', '重新推送一件代发SKU', NULL, 'POST', 20, 'ACTIVE', 1, 0) ON DUPLICATE KEY UPDATE - permission_name = VALUES(permission_name), parent_id = VALUES(parent_id), + permission_type = VALUES(permission_type), permission_name = VALUES(permission_name), parent_id = VALUES(parent_id), http_method = VALUES(http_method), status = 'ACTIVE', built_in = 1, deleted = 0; INSERT IGNORE INTO iam_role_permission (role_id, permission_id, tenant_id) diff --git a/iam/src/main/resources/db/phase1-admin-rbac.sql b/iam/src/main/resources/db/phase1-admin-rbac.sql index 7a9917d00..f26c40e87 100644 --- a/iam/src/main/resources/db/phase1-admin-rbac.sql +++ b/iam/src/main/resources/db/phase1-admin-rbac.sql @@ -81,6 +81,7 @@ VALUES (5103020, 0, 'PLATFORM', 'MENU', 5103000, 'platform:express:list', '快递公司', '/master-data/express', NULL, 20, 'ACTIVE', 0), (5103030, 0, 'PLATFORM', 'MENU', 5103000, 'platform:counterparty:list', '往来单位', '/master-data/counterparties', NULL, 30, 'ACTIVE', 0), (5103040, 0, 'PLATFORM', 'MENU', 5103000, 'platform:download:list', '下载中心', '/downloads', NULL, 40, 'ACTIVE', 0), + (5102131, 0, 'PLATFORM', 'MENU', 5103000, 'platform:mojia-sku-sync:list', '一件代发SKU同步', '/general/mojia-sku-sync', 'GET', 130, 'ACTIVE', 0), (5104010, 0, 'PLATFORM', 'MENU', 5104000, 'platform:audit:list', '操作日志', '/audit/logs', NULL, 10, 'ACTIVE', 0), -- Platform: buttons. (5100020, 0, 'PLATFORM', 'BUTTON', 5100010, 'platform:customer:review', '客户审核', NULL, NULL, 50, 'ACTIVE', 0), @@ -118,6 +119,8 @@ VALUES (5103032, 0, 'PLATFORM', 'BUTTON', 5103030, 'platform:counterparty:edit', '编辑往来单位', NULL, NULL, 20, 'ACTIVE', 0), (5103033, 0, 'PLATFORM', 'BUTTON', 5103030, 'platform:counterparty:status', '启用停用往来单位', NULL, NULL, 30, 'ACTIVE', 0), (5103034, 0, 'PLATFORM', 'BUTTON', 5103030, 'platform:counterparty:import', '批量导入往来单位', NULL, NULL, 40, 'ACTIVE', 0) + ,(5102132, 0, 'PLATFORM', 'BUTTON', 5102131, 'platform:mojia-sku-sync:edit', '编辑一件代发SKU测量数据', NULL, 'PUT', 10, 'ACTIVE', 0) + ,(5102133, 0, 'PLATFORM', 'BUTTON', 5102131, 'platform:mojia-sku-sync:retry', '重新推送一件代发SKU', NULL, 'POST', 20, 'ACTIVE', 0) ON DUPLICATE KEY UPDATE permission_type = VALUES(permission_type), parent_id = VALUES(parent_id), permission_name = VALUES(permission_name), route_path = VALUES(route_path), @@ -177,6 +180,7 @@ INSERT INTO iam_role_permission (role_id, permission_id, tenant_id) SELECT 4000003, id, 0 FROM iam_permission WHERE permission_code LIKE 'platform:order%' OR permission_code LIKE 'platform:firstleg%' + OR permission_code LIKE 'platform:mojia-sku-sync:%' OR permission_code = 'platform:download:list' ON DUPLICATE KEY UPDATE tenant_id = VALUES(tenant_id);