diff --git a/.dockerignore b/.dockerignore index 7166685e..26c5bd74 100644 --- a/.dockerignore +++ b/.dockerignore @@ -16,12 +16,14 @@ apps/*/dist apps/*/.vite # backend runtime data +apps/backend/data/ apps/backend/data/*.db apps/backend/data/*.db-shm apps/backend/data/*.db-wal apps/backend/data/browser-sessions apps/backend/data/redeem-screenshots +apps/backend/data/cache apps/backend/.npm-cache -# deploy data (server only) +# legacy deploy data (server only) deploy/data/ diff --git a/.env.mac-docker.example b/.env.mac-docker.example index 0a934257..d12af4db 100644 --- a/.env.mac-docker.example +++ b/.env.mac-docker.example @@ -1,69 +1,34 @@ -# Mac Docker development template. Copy to .env for local full-stack testing. -# For public claim links, update APP_BASE_URL and CLAIM_BASE_URL together. -# App +# Mac Docker 本机联调模板。复制为 .env 后按需修改。 +# 如果使用 ngrok / cloudflared 等公网地址,只需要同步修改 APP_DOMAIN / CADDY_SITE_ADDR / CLAIM_BASE_URL。 + +# App / Caddy APP_DOMAIN=localhost -APP_BASE_URL=http://localhost CADDY_SITE_ADDR=http://localhost TZ=Asia/Shanghai BACKEND_PORT=3000 # Claim Links CLAIM_BASE_URL=http://localhost/#/claim -CLAIM_TOKEN_TTL_HOURS=24 # Database POSTGRES_DB=order_site POSTGRES_USER=postgres POSTGRES_PASSWORD=postgres DATABASE_URL=postgres://postgres:postgres@postgres:5432/order_site -DATABASE_SSL=false -DATABASE_MAX_CONNECTIONS=10 POSTGRES_PORT=5432 -# Data And Logging -DATA_ROOT=/app/data +# Logging LOG_LEVEL=info - -# OCR -OCR_IMAGE=order_site-ocr-worker:latest -OCR_PORT=8100 -OCR_BASE_URL=http://ocr-worker:8100 - -# Tencent Browser -TENCENT_REDEEM_PROOF_MODE=basic -TENCENT_BROWSER_HEADLESS=false -TENCENT_BROWSER_DEVTOOLS=false -TENCENT_BROWSER_PREWARM=true -TENCENT_BROWSER_KEEP_ALIVE=true -TENCENT_BROWSER_SLOW_MO=200 -TENCENT_BROWSER_VNC_RESOLUTION=1600x900x24 -TENCENT_SESSION_DEBUG=true -TENCENT_BROWSER_NOVNC_ENABLED=true -TENCENT_BROWSER_NOVNC_PORT=6080 -TENCENT_BROWSER_VNC_PORT=5900 +LOG_RETENTION_DAYS=30 # Admin ADMIN_SESSION_SECRET=dev-local-session-secret -ADMIN_SESSION_TTL_HOURS=12 ADMIN_DEFAULT_USERS_JSON=[{"username":"admin","password":"dev-admin-123456","role":"admin"},{"username":"operator","password":"dev-operator-123456","role":"operator"}] -# Agiso -AGISO_APP_SECRET= -AGISO_MESSAGING_ENABLED=true -AGISO_TRADE_DETAIL_ENDPOINT=https://gw-api.agiso.com/aldsIdle/Order/Detail -AGISO_TRADE_DETAIL_API_VERSION=1 -AGISO_TRADE_DETAIL_TIMEOUT_MS=5000 -AGISO_AUTO_DELIVERY_ENABLED=true -AGISO_AUTO_DELIVERY_ENDPOINT=https://gw-api.agiso.com/aldsIdle/Order/DummySend -AGISO_AUTO_DELIVERY_API_VERSION=1 -AGISO_MESSAGE_API_VERSION=1 -AGISO_SEND_MESSAGE_ENDPOINT=https://gw-api.agiso.com/aldsIdle/ImMsg/SendMsg - # 91Kaquan -NINETYONE_USER_ID= -NINETYONE_SECRET= -NINETYONE_VERSION=1.0 -NINETYONE_SHOP_ID=91kaquan -NINETYONE_SHOP_NAME=91卡券 -NINETYONE_TIMESTAMP_TOLERANCE_SECONDS=600 -NINETYONE_CARDS_ENCODING=aes-256-ecb-base64 +KAQUAN91_USER_ID= +KAQUAN91_SECRET= + +# Cloudtentacles +CLOUDTENTACLES_DEVICE_ID=08bc9d8c-fd15-48ea-bc00-8d754076cafc +CLOUDTENTACLES_DEVICE_TYPE=1 diff --git a/.env.server.example b/.env.server.example index 20443117..dd1e4cfe 100644 --- a/.env.server.example +++ b/.env.server.example @@ -1,64 +1,33 @@ -# Production deployment template. Copy to .env and replace every change-me value. -# Keep real secrets out of git. -# App +# 生产部署模板。复制为 .env 后替换所有 change-me 值。 +# 真实密钥不要提交到 git。 + +# App / Caddy APP_DOMAIN=order.khhao.com -APP_BASE_URL=https://order.khhao.com CADDY_SITE_ADDR=https://order.khhao.com TZ=Asia/Shanghai BACKEND_PORT=3000 # Claim Links CLAIM_BASE_URL=https://order.khhao.com/#/claim -CLAIM_TOKEN_TTL_HOURS=24 # Database POSTGRES_DB=order_site POSTGRES_USER=postgres POSTGRES_PASSWORD=change-me-postgres-password DATABASE_URL=postgres://postgres:change-me-postgres-password@postgres:5432/order_site -DATABASE_SSL=false -DATABASE_MAX_CONNECTIONS=20 -# Data And Logging -DATA_ROOT=/app/data +# Logging LOG_LEVEL=info - -# OCR -OCR_IMAGE=order_site-ocr-worker:latest -OCR_BASE_URL=http://ocr-worker:8100 - -# Tencent Browser -TENCENT_REDEEM_PROOF_MODE=full -TENCENT_BROWSER_HEADLESS=true -TENCENT_BROWSER_DEVTOOLS=false -TENCENT_BROWSER_PREWARM=true -TENCENT_BROWSER_KEEP_ALIVE=true -TENCENT_BROWSER_SLOW_MO=0 -TENCENT_BROWSER_VNC_RESOLUTION=1600x900x24 -TENCENT_SESSION_DEBUG=false +LOG_RETENTION_DAYS=30 # Admin ADMIN_SESSION_SECRET=change-me-long-random-session-secret -ADMIN_SESSION_TTL_HOURS=12 ADMIN_DEFAULT_USERS_JSON=[{"username":"admin","password":"change-me-admin-password","role":"admin"},{"username":"operator","password":"change-me-operator-password","role":"operator"}] -# Agiso -AGISO_APP_SECRET=change-me-agiso-app-secret -AGISO_MESSAGING_ENABLED=true -AGISO_TRADE_DETAIL_ENDPOINT=https://gw-api.agiso.com/aldsIdle/Order/Detail -AGISO_TRADE_DETAIL_API_VERSION=1 -AGISO_TRADE_DETAIL_TIMEOUT_MS=5000 -AGISO_AUTO_DELIVERY_ENABLED=true -AGISO_AUTO_DELIVERY_ENDPOINT=https://gw-api.agiso.com/aldsIdle/Order/DummySend -AGISO_AUTO_DELIVERY_API_VERSION=1 -AGISO_MESSAGE_API_VERSION=1 -AGISO_SEND_MESSAGE_ENDPOINT=https://gw-api.agiso.com/aldsIdle/ImMsg/SendMsg - # 91Kaquan -NINETYONE_USER_ID= -NINETYONE_SECRET= -NINETYONE_VERSION=1.0 -NINETYONE_SHOP_ID=91kaquan -NINETYONE_SHOP_NAME=91卡券 -NINETYONE_TIMESTAMP_TOLERANCE_SECONDS=600 -NINETYONE_CARDS_ENCODING=aes-256-ecb-base64 +KAQUAN91_USER_ID= +KAQUAN91_SECRET= + +# Cloudtentacles +CLOUDTENTACLES_DEVICE_ID=08bc9d8c-fd15-48ea-bc00-8d754076cafc +CLOUDTENTACLES_DEVICE_TYPE=1 diff --git a/.gitignore b/.gitignore index 5bf260ed..5713aca6 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,6 @@ node_modules .env +.env copy .env.local .DS_Store @@ -11,8 +12,12 @@ apps/*/.env.local apps/backend/data/*.db apps/backend/data/*.db-shm apps/backend/data/*.db-wal +apps/backend/data/*.json +!apps/backend/data/*.example.json +apps/backend/data/cloudtentacles-session.json apps/backend/data/browser-sessions apps/backend/data/redeem-screenshots +apps/backend/data/cache apps/backend/data/logs .ace-tool diff --git a/README.md b/README.md index 682eefdf..e631068a 100644 --- a/README.md +++ b/README.md @@ -1,14 +1,13 @@ # order_site -订单自动兑换与履约管理系统。 +快手自动发货与履约管理系统。 -项目采用前后端分离架构,主要用于接收订单、匹配履约规则、生成领取链接、管理库存、执行自动/半自动兑换,并提供后台页面处理人工复核、平台配置和任务追踪。 +项目采用前后端分离架构,用于接收订单、匹配快手履约规则、管理云卡资源,并提供后台页面处理平台配置、任务追踪和人工复核。 ## 技术栈 - 前端:Vue 3、Vite、Element Plus、TypeScript -- 后端:Node.js、Express、PostgreSQL、Playwright -- OCR:外部镜像服务,后端通过 `OCR_BASE_URL` 调用 +- 后端:Node.js、Express、PostgreSQL - 网关:Caddy - 本地与生产运行:Docker Compose @@ -30,7 +29,7 @@ docker-compose.yml 生产/服务器 Compose ## 本地开发 -推荐直接使用 Docker,本地不需要额外维护 Node、PostgreSQL、Playwright 浏览器和 OCR 运行环境。 +推荐直接使用 Docker,本地不需要额外维护 Node 和 PostgreSQL。 ```bash cp .env.mac-docker.example .env @@ -42,8 +41,6 @@ docker compose -f docker-compose.dev.yml up -d --build - 前端、后台、领取页:`http://localhost` - 后端健康检查:`http://localhost/health` - 后端 API 前缀:`http://localhost/api/v1/...` -- OCR worker:`http://127.0.0.1:8100/health` -- noVNC 浏览器画面:`http://127.0.0.1:6080/vnc.html` 查看容器状态: @@ -56,7 +53,6 @@ docker compose -f docker-compose.dev.yml ps ```bash docker compose -f docker-compose.dev.yml logs -f backend docker compose -f docker-compose.dev.yml logs -f frontend -docker compose -f docker-compose.dev.yml logs -f ocr-worker ``` 停止环境: @@ -75,11 +71,6 @@ docker compose -f docker-compose.dev.yml exec -T backend npm run typecheck docker compose -f docker-compose.dev.yml exec -T backend npm run build docker compose -f docker-compose.dev.yml exec -T frontend npm run typecheck docker compose -f docker-compose.dev.yml exec -T frontend npm run lint -``` - -前端生产构建: - -```bash docker compose -f docker-compose.dev.yml exec -T frontend npm run build ``` @@ -89,15 +80,7 @@ docker compose -f docker-compose.dev.yml exec -T frontend npm run build docker compose -f docker-compose.dev.yml exec -T backend npm run db:migrate ``` -## 热更新 - -开发版 Compose 会把源码挂载进容器: - -- 修改 `apps/backend/src`:后端容器内 `tsx watch` 自动重启 -- 修改 `apps/frontend/src`:Vite 自动 HMR -- 修改数据库 migration:重启后端会自动执行迁移 - -如果只改业务代码,一般不需要重新 `--build`。改 Dockerfile、系统依赖或基础镜像时再重新构建。 +当前数据库脚本只保留新库初始化结构,不维护历史测试库的升级兼容;切换到这版前请重建开发库或重置 Docker volume。 ## 环境变量 @@ -115,15 +98,12 @@ cp .env.server.example .env 常用变量: -- `APP_BASE_URL`:应用公网地址,用于推导领取链接 -- `CLAIM_BASE_URL`:领取页地址,优先级高于 `APP_BASE_URL` +- `APP_DOMAIN` / `CADDY_SITE_ADDR`:Caddy 对外域名与监听地址 +- `CLAIM_BASE_URL`:领取页完整地址 +- `DATABASE_URL`:PostgreSQL 连接串 - `ADMIN_SESSION_SECRET`:后台登录态签名密钥 - `ADMIN_DEFAULT_USERS_JSON`:默认后台用户 -- `DATABASE_URL`:PostgreSQL 连接串 -- `TENCENT_BROWSER_HEADLESS`:Playwright 是否无头运行 -- `TENCENT_BROWSER_PREWARM`:启动时是否预热浏览器 -- `OCR_IMAGE`:OCR 外部服务镜像,默认 `order_site-ocr-worker:latest` -- `OCR_BASE_URL`:后端访问 OCR 的 HTTP 地址,Docker 内默认 `http://ocr-worker:8100` +- `KAQUAN91_USER_ID` / `KAQUAN91_SECRET`:91 卡券开放接口凭据 ## 运行数据 @@ -132,16 +112,16 @@ cp .env.server.example .env - 后端配置与运行产物:`apps/backend/data` - PostgreSQL 数据:Docker volume `postgres_dev_data` - 后端日志:`apps/backend/data/logs` -- 浏览器会话:`apps/backend/data/browser-sessions` -- 兑换截图:`apps/backend/data/redeem-screenshots` 生产部署数据: -- 后端数据挂载到 `deploy/data/backend` +- 后端数据挂载到 `apps/backend/data`,与本地开发路径保持一致 - PostgreSQL 数据保存在 Docker volume `postgres_data` - Caddy 数据保存在 Docker volume `caddy_data` -注意:`apps/backend/data/*.json` 中可能包含账号、Cookie、token、平台配置等敏感信息。提交代码前请确认没有把真实生产凭据提交到仓库。 +如果旧部署曾使用 `deploy/data/backend`,升级前请先把其中的 JSON 配置迁移到 `apps/backend/data`。 + +注意:`apps/backend/data/*.json` 中可能包含账号、Cookie、token、平台配置等敏感信息,默认不进入仓库;仓库只保留 `*.example.json` 模板。 ## 生产部署 @@ -149,10 +129,19 @@ cp .env.server.example .env ```bash cp .env.server.example .env -mkdir -p deploy/data/backend +mkdir -p apps/backend/data +sudo chown -R 1001:1001 apps/backend/data docker compose up -d --build ``` +Ubuntu 服务器也可以使用一键部署脚本: + +```bash +bash deploy/ubuntu-deploy.sh +``` + +第一次执行如果没有 `.env`,脚本会从 `.env.server.example` 生成模板并停止;编辑真实配置后再次执行即可。脚本会自动检查 Docker、检查 `.env` 占位值、修正后端数据目录权限、构建启动服务并等待健康检查通过。 + 生产入口: - 前端与领取页:`https://你的域名/` @@ -162,78 +151,21 @@ docker compose up -d --build 生产 Compose 会自动: - 启动 PostgreSQL -- 启动 OCR worker - 启动后端并执行 migration - 初始化默认后台管理员 - 同步履约目录 - 通过 Caddy 暴露前端和 API -查看生产日志: - -```bash -docker compose logs -f backend -docker compose logs -f web -``` - -## 开发数据脚本 - -后端提供开发数据脚本: - -```bash -docker compose -f docker-compose.dev.yml exec -T backend npm run seed:dev-data -docker compose -f docker-compose.dev.yml exec -T backend npm run cleanup:dev-data -``` - -脚本默认有预览/确认语义,执行前注意终端输出说明。 - ## 主要业务模块 - `apps/backend/src/routes`:API 路由 -- `apps/backend/src/services/order`:订单、webhook、库存和履约匹配 -- `apps/backend/src/services/fulfillment`:快手 Cloud 等履约编排 -- `apps/backend/src/services/session`:腾讯登录、二维码、兑换和截图 +- `apps/backend/src/services/order`:订单、商品匹配和履约任务编排 +- `apps/backend/src/services/fulfillment`:快手 Cloud 履约编排 - `apps/backend/src/services/admin`:后台读写、权限、配置管理 -- `apps/backend/src/services/platforms`:外部平台 API 封装 - `apps/backend/src/repositories`:数据库访问 - `apps/frontend/src/views/admin`:后台页面 - `apps/frontend/src/views/claim`:用户领取页 -## 排障 - -如果页面打不开: - -```bash -docker compose -f docker-compose.dev.yml ps -docker compose -f docker-compose.dev.yml logs -f web -``` - -如果后端未 ready: - -```bash -docker compose -f docker-compose.dev.yml logs -f backend -curl http://localhost/health/ready -``` - -如果浏览器兑换或扫码异常: - -```bash -docker compose -f docker-compose.dev.yml logs -f backend -open http://127.0.0.1:6080/vnc.html -``` - -如果前端类型或 lint 失败: - -```bash -docker compose -f docker-compose.dev.yml exec -T frontend npm run typecheck -docker compose -f docker-compose.dev.yml exec -T frontend npm run lint -``` - -如果后端单测失败: - -```bash -docker compose -f docker-compose.dev.yml exec -T backend npm test -``` - ## 提交前检查清单 ```bash @@ -249,4 +181,3 @@ docker compose -f docker-compose.dev.yml exec -T frontend npm run lint - `.env` 未被提交 - 数据库 migration 与代码一起提交 - 领取链接相关改动已在 Docker 环境验证 -- 涉及 Playwright/OCR 的改动已看过 noVNC 或相关日志 diff --git a/apps/backend/.gitignore b/apps/backend/.gitignore index 778e486a..65e17870 100644 --- a/apps/backend/.gitignore +++ b/apps/backend/.gitignore @@ -12,3 +12,6 @@ data/logs/ data/*.db data/*.db-shm data/*.db-wal +data/*.json +!data/*.example.json +data/cloudtentacles-session.json diff --git a/apps/backend/.prettierrc.json b/apps/backend/.prettierrc.json new file mode 100644 index 00000000..3ee2d113 --- /dev/null +++ b/apps/backend/.prettierrc.json @@ -0,0 +1,6 @@ +{ + "semi": false, + "singleQuote": true, + "printWidth": 100, + "trailingComma": "all" +} diff --git a/apps/backend/README.md b/apps/backend/README.md index ce404ff2..0caa9f8e 100644 --- a/apps/backend/README.md +++ b/apps/backend/README.md @@ -1,13 +1,8 @@ # order-site-backend -腾讯活动浏览器会话后端。 +快手轻量后端服务。 -当前主线只有一条: - -- 后端托管 Playwright 浏览器 -- 前端展示二维码并轮询会话状态 -- 登录完成后,后端直接在活动页填写 CDK、识别验证码、执行兑换 -- 兑换完成后返回状态,并按配置决定是否生成截图/证明产物 +后端负责订单入库、履约任务编排、快手 Cloud / 91 卡券 / 快手小店核销配置、后台管理接口和数据库迁移。默认入口就是快手轻量后端。 ## 快速启动 @@ -20,21 +15,18 @@ npm install 复制并填写环境变量后启动: ```bash -cp /Users/yml/codes/order-site-workspace/.env.mac-docker.example /Users/yml/codes/order-site-workspace/.env -cd /Users/yml/codes/order-site-workspace/apps/backend +cp ../../.env.mac-docker.example ../../.env npm run dev ``` -Linux 部署: +生产构建: ```bash -npm install npm run build -npm run browser:install:linux npm run start ``` -后端渐进 TypeScript 迁移已经进入源码和构建双轨模式: +常用验证: ```bash npm run typecheck @@ -42,120 +34,63 @@ npm run build npm test ``` -当前约定: - -- 开发:`npm run dev` 使用 `tsx watch` 直接运行 `src` -- 数据库迁移:`npm run db:migrate` 使用 `tsx` -- 生产:`npm run build` 输出 `dist`,`npm run start` 运行 `dist/index.js` -- 新增或迁移后端模块优先使用 `.ts` - ## 配置 -当前后端采用 `.env` 单一路径做环境配置: +后端采用工作区根目录 `.env` 做环境配置: - 通用默认值:`src/config/defaults.ts` - 本地/部署覆盖:工作区根目录 `.env` -当前最常改的配置有: +常用配置: -- 服务端口 +- 服务端口 `PORT`(Docker 部署中由根目录 `.env` 的 `BACKEND_PORT` 映射) - 日志级别 `LOG_LEVEL=debug|info|warn|error` -- 浏览器是否无头、是否预热、是否常驻、slowMo -- OCR 外部服务镜像 `OCR_IMAGE` 与访问地址 `OCR_BASE_URL` -- 会话调试开关 -- 兑换证明模式 `full | basic | off` -- Agiso 全局配置与后台店铺文件配置 - -### Agiso 多店铺 - -当前支持同一个 Agiso 应用下配置多个店铺的消息 token。 - -- 全局兜底配置仍走 `.env`: - - `AGISO_APP_SECRET` - - `AGISO_MESSAGING_ENABLED` -- 默认消息模板与店铺级覆盖配置改为文件: - - `apps/backend/data/agiso-shops.json` -- 后台维护入口: - - `#/admin/platform-shops` - -消息发送时会按 webhook 识别出的 `shop_id` 读取 `agiso-shops.json` 中的店铺配置;模板未命中时,会先回退到文件里的 `defaults`,再回退到系统内置默认值。店铺的 `accessToken` 也需要在文件中维护,不再使用全局 `.env` 兜底。 +- 数据库连接 `DATABASE_URL` +- 后台登录密钥 `ADMIN_SESSION_SECRET` +- 默认后台用户 `ADMIN_DEFAULT_USERS_JSON` +- 91 卡券开放接口凭据 `KAQUAN91_USER_ID` / `KAQUAN91_SECRET` ## 接口 -- `POST /api/v1/tencent/browser/session` - 创建浏览器会话,返回 `sessionId`、二维码和初始状态 -- `GET /api/v1/tencent/browser/session/:sessionId` - 返回完整会话状态 -- `GET /api/v1/tencent/browser/session/:sessionId/summary` - 轮询用轻量摘要接口,默认不返回二维码 base64 -- `POST /api/v1/tencent/browser/session/:sessionId/refresh` - 强制刷新后端活动页 -- `POST /api/v1/tencent/browser/session/:sessionId/redeem` - 执行兑换 -- `GET /api/v1/tencent/browser/session/:sessionId/screenshot` - 读取最近一次兑换截图 -- `DELETE /api/v1/tencent/browser/session/:sessionId` - 关闭浏览器会话 +- `GET /health`:健康检查 +- `GET /health/live`:存活检查 +- `GET /health/ready`:就绪检查 +- `/api/v1/open/91/*`:91 卡券开放回调 +- `/api/v1/claim/*`:领取页接口 +- `/api/v1/admin/*`:后台管理接口 ## 目录 -- `src/index.ts` - Express 入口 -- `src/routes/tencent.ts` - 接口路由 -- `src/services/session/session.ts` - 会话编排与浏览器生命周期 -- `src/services/session/*.ts` - 登录、兑换、OCR、凭证产物等模块 +- `src/index.ts`:服务入口 +- `src/app.ts`:Express 应用组装 +- `src/routes`:API 路由 +- `src/services/order`:订单、商品匹配和履约任务编排 +- `src/services/fulfillment`:快手 Cloud 履约编排 +- `src/services/admin`:后台读写、权限、配置管理 +- `src/repositories`:数据库访问 +- `src/db/migrations`:新库初始化脚本 ## Docker 开发 -当前 `docker-compose.dev.yml` 已切到源码挂载模式: +开发版 Compose 会把源码挂载进容器: - `postgres` 容器提供开发库,后端通过 `DATABASE_URL` 连接 -- `ocr-worker` 使用外部镜像启动,后端通过 `OCR_BASE_URL=http://ocr-worker:8100` 调用 -- 后端容器启动时执行 `npm install` 和 `npm run dev` -- 平时改 `src/` 或 `.env`,一般都不需要重建镜像 -- 生产镜像会在 build 阶段编译后端并只复制 `dist`、`config` 和运行依赖 +- 后端容器启动时执行依赖安装和 `npm run dev` +- 平时改 `src/` 或 `.env`,一般不需要重建镜像 -只有下面几类改动通常还需要 `docker compose -f docker-compose.dev.yml up -d --build backend`: +只有下面几类改动通常还需要重新构建 backend: -- `deploy/docker/backend.Dockerfile` -- 系统层依赖 -- Playwright 浏览器基础环境 +- `deploy/docker/backend*.Dockerfile` +- Node 版本或系统层依赖 +- `package.json` / `package-lock.json` -## 产物 - -浏览器会话产物默认保存在: - -- `data/browser-sessions//qq-qr.png` -- `data/browser-sessions//session.json` -- `data/browser-sessions//captcha-attempt-*.png` -- `data/browser-sessions//redeem-result.png` -- `data/browser-sessions//page.html` -- `data/browser-sessions//result.json` - -这些都是运行时产物,默认不提交 Git。 +## 运行产物 运行日志默认保存在: - `data/logs/app-YYYY-MM-DD.log` -- `data/logs/webhook-YYYY-MM-DD.log` +- `data/logs/integration-YYYY-MM-DD.log` -日志按天切分,默认自动清理 7 天前的旧日志。 -成功的 `/health`、`/health/live`、`/health/ready` 探活请求默认不写 access log。 +日志按天切分,默认自动清理 7 天前的旧日志。成功的 `/health`、`/health/live`、`/health/ready` 探活请求默认不写 access log。 -`LOG_LEVEL` 默认是 `info`: - -- `debug`:输出最详细的调试日志 -- `info`:输出启动、请求、业务成功/失败等常规日志 -- `warn`:只保留告警和错误 -- `error`:只保留错误 - -OCR 识别通过 `OCR_BASE_URL` 调用 HTTP 服务。Docker Compose 会启动 `ocr-worker` 外部镜像并注入默认地址 `http://ocr-worker:8100`;如果单独运行后端且未配置该地址,启动健康检查会标记 OCR 为 degraded,兑换中需要 OCR 的流程会在调用时返回明确错误。 - -`TENCENT_REDEEM_PROOF_MODE` 对应的配置文件项会影响生成强度: - -- `full`:完整证明 -- `basic`:只保留最终截图和结果 JSON -- `off`:不生成证明文件 +`data/*.json` 为本地运行配置,可能包含账号、Cookie、token 或平台配置,默认不提交;请复制 `data/*.example.json` 后填写真实值。 diff --git a/apps/backend/data/agiso-shops.json b/apps/backend/data/agiso-shops.json deleted file mode 100644 index e36a8b87..00000000 --- a/apps/backend/data/agiso-shops.json +++ /dev/null @@ -1,23 +0,0 @@ -{ - "defaults": { - "messageTemplate": "您的订单 {platformOrderId} 已创建领取链接,请您复制链接到浏览器自动兑换,请尽快打开并完成领取:\n\n{claimUrl}", - "autoDeliveryMessageTemplate": "亲亲,您购买的兑换码已自行兑换成功,请在游戏中邮件领取,感谢您的支持" - }, - "shops": { - "693760716": { - "enabled": true, - "shopName": "大锤商行", - "accessToken": "AldsIdlefmykv7w6xdvgf9r2ymsxrmznux74tyaehc9pwygdnrgr4" - }, - "2037495620": { - "enabled": true, - "shopName": "羊小胖。", - "accessToken": "AldsIdle4925bh8c9n3ka79fxauc9egk7uea6f6frfbbb5btvxpvu" - }, - "2209880145223": { - "enabled": true, - "shopName": "大锤号商", - "accessToken": "AldsIdleu5rzb68g8gh32sk9e8rbunes6gwyg59aph2nn5em6fp4r" - } - } -} diff --git a/apps/backend/data/cloudtentacles-session.json b/apps/backend/data/cloudtentacles-session.json deleted file mode 100644 index 61e954de..00000000 --- a/apps/backend/data/cloudtentacles-session.json +++ /dev/null @@ -1,22 +0,0 @@ -{ - "sessions": { - "default": { - "token": "P1ifGvDcxYchECzx51cqL2TzBkntjQtRwxpetU7RDMMjsSCmC8kHU751bJ4rJMSSjQN73QveARqGLIWe078nKvnA", - "baseUrl": "https://123.207.217.176", - "username": "17665234375", - "phone": "17665234375", - "loggedInAt": "2026-05-25T06:48:25.301Z", - "deviceId": "-", - "deviceType": 0 - }, - "account2": { - "token": "KU2draP6Rm7AQDIeN0SSIVeNBkdMdGODlN1ToNhAo0wYk6clo9EeHh8fE5cN2kIhxThC30gCZXZl6LQfs19K03S9", - "baseUrl": "https://123.207.217.176", - "username": "18216161068", - "phone": "18216161068", - "loggedInAt": "2026-05-25T06:38:36.698Z", - "deviceId": "-", - "deviceType": 0 - } - } -} diff --git a/apps/backend/data/cloudtentacles-sources.example.json b/apps/backend/data/cloudtentacles-sources.example.json new file mode 100644 index 00000000..3e5b3f88 --- /dev/null +++ b/apps/backend/data/cloudtentacles-sources.example.json @@ -0,0 +1,16 @@ +{ + "enabled": true, + "sources": [ + { + "key": "default", + "label": "默认账号", + "enabled": true, + "baseUrl": "https://example.com", + "username": "", + "password": "", + "phone": "", + "deviceId": "08bc9d8c-fd15-48ea-bc00-8d754076cafc", + "deviceType": 1 + } + ] +} diff --git a/apps/backend/data/cloudtentacles-sources.json b/apps/backend/data/cloudtentacles-sources.json deleted file mode 100644 index 9382f626..00000000 --- a/apps/backend/data/cloudtentacles-sources.json +++ /dev/null @@ -1,27 +0,0 @@ -{ - "enabled": true, - "sources": [ - { - "key": "default", - "label": "默认账号", - "enabled": true, - "baseUrl": "https://123.207.217.176", - "username": "17665234375", - "password": "yaochao11", - "phone": "17665234375", - "deviceId": "-", - "deviceType": 0 - }, - { - "key": "account2", - "label": "备用账号 1", - "enabled": true, - "baseUrl": "https://123.207.217.176", - "username": "18216161068", - "password": "yaochao11", - "phone": "18216161068", - "deviceId": "-", - "deviceType": 0 - } - ] -} diff --git a/apps/backend/data/kuaishou-cloud-fulfillment.json b/apps/backend/data/kuaishou-cloud-fulfillment.json deleted file mode 100644 index ad882ae5..00000000 --- a/apps/backend/data/kuaishou-cloud-fulfillment.json +++ /dev/null @@ -1,85 +0,0 @@ -{ - "enabled": true, - "items": [ - { - "id": "套装-浪漫天命", - "enabled": true, - "priority": 100, - "provider": "91kaquan", - "platform": "kuaishou", - "shopId": "91kaquan", - "internalSkuCode": "套装-浪漫天命", - "internalSkuName": "183999074512936", - "externalSkuCode": "183999074512936", - "externalItemId": "183999074512936", - "externalSkuName": "183999074512936", - "resolvedSkuName": "183999074512936", - "cloudSourceKey": "default", - "cloudSourceKeyFallbacks": [ - "account2" - ], - "cloudSkuId": 28, - "cloudSkuName": "套装-浪漫天命", - "vnKey": "1", - "autoBuyEnabled": true, - "minAssetReserve": 0, - "autoReturnNumberAfterDispatch": true, - "autoConsumeAfterDispatch": true, - "kuaishouConsumeShopId": "3676797936", - "kuaishouConsumeShopName": "小鱼儿游戏服务店", - "notes": "从 91卡券订单 2614500561157561 导入" - }, - { - "id": "套装-浪漫天命", - "enabled": true, - "priority": 100, - "provider": "91kaquan", - "platform": "kuaishou", - "shopId": "3676797936", - "internalSkuCode": "套装-浪漫天命", - "internalSkuName": "183999074511936", - "externalSkuCode": "183999074511936", - "externalItemId": "183999074511936", - "externalSkuName": "183999074511936", - "resolvedSkuName": "183999074511936", - "cloudSourceKey": "default", - "cloudSourceKeyFallbacks": [], - "cloudSkuId": 28, - "cloudSkuName": "套装-浪漫天命", - "vnKey": "1", - "autoBuyEnabled": true, - "minAssetReserve": 0, - "autoReturnNumberAfterDispatch": true, - "autoConsumeAfterDispatch": true, - "kuaishouConsumeShopId": "3676797936", - "kuaishouConsumeShopName": "小鱼儿游戏服务店", - "notes": "从 91卡券订单 2613900255300561 导入" - }, - { - "id": "91卡券订单取样导入", - "enabled": true, - "priority": 100, - "provider": "91kaquan", - "platform": "kuaishou", - "shopId": "91kaquan", - "internalSkuCode": "套装-浪漫天命", - "internalSkuName": "套装-浪漫天命", - "externalSkuCode": "183999074510936", - "externalItemId": "183999074510936", - "externalSkuName": "183999074510936", - "resolvedSkuName": "183999074510936", - "cloudSourceKey": "default", - "cloudSourceKeyFallbacks": [], - "cloudSkuId": 28, - "cloudSkuName": "套装-浪漫天命", - "vnKey": "1", - "autoBuyEnabled": true, - "minAssetReserve": 0, - "autoReturnNumberAfterDispatch": true, - "autoConsumeAfterDispatch": true, - "kuaishouConsumeShopId": "3676797936", - "kuaishouConsumeShopName": "小鱼儿游戏服务店", - "notes": "从 91卡券订单 2613300010204561 导入" - } - ] -} diff --git a/apps/backend/data/kuaishou-eticket-source.example.json b/apps/backend/data/kuaishou-eticket-source.example.json new file mode 100644 index 00000000..74ea81eb --- /dev/null +++ b/apps/backend/data/kuaishou-eticket-source.example.json @@ -0,0 +1,13 @@ +{ + "enabled": true, + "baseUrl": "https://s.kwaixiaodian.com", + "shops": [ + { + "shopId": "", + "kshopName": "", + "cookie": "", + "userAvatar": "", + "enabled": false + } + ] +} diff --git a/apps/backend/data/kuaishou-eticket-source.json b/apps/backend/data/kuaishou-eticket-source.json deleted file mode 100644 index 400be546..00000000 --- a/apps/backend/data/kuaishou-eticket-source.json +++ /dev/null @@ -1,20 +0,0 @@ -{ - "enabled": true, - "baseUrl": "https://s.kwaixiaodian.com", - "shops": [ - { - "shopId": "3676797936", - "kshopName": "小鱼儿游戏服务店", - "cookie": "_did=web_977722931F45F3C4; did=web_mxsox80uwsj3o4bkdsi2xhbxnx5gdlpn; sid=kshop.api; kshop.api_st=Cgxrc2hvcC5hcGkuc3QSoAHnDz5KbyPrdR8uWNB0MspVtX2zUOBrDcysluKZ9P6NT0IaEtnV-24otPiLyxcbH1iStuNg84bSxClfdu63cgMKzi0AovJn5DFqJUjXPfJZ1XxL8xDXOIdvv73SVr0eQhPKrD_twqnWJu-1HLgxl294XMhDPhO8JbNcFhv_cOwBsxjZYUNMs_7kpRHIpDEU_adQH0zQOBVQYu4AzU0rhF2EGhI9zclILZeuwZLmGG1mEKpcYGMiIOkT8avvcn-qSsIRdLyBf0U1Fjoud_FP9qcCFMjohN1AKAUwAQ; kshop.api_ph=a5c01f4da50e7eda8facb70fd1b13af57eb3; bUserId=1000324969453; userId=3676797936; kuaishou.shop.b_st=ChJrdWFpc2hvdS5zaG9wLmIuc3QSoAHbRIrpTvTjW-nbSsGDVimfmFK01ohV83Wh1v9vCN89z4-bJqpa_r7jmXBgL53hZ0OD5Y8Jz5fUPxWT-6PYNCfLVB20XogIDibxfXXjJSW5d4nchty1_veIl6r4NsUhHu0N93XRsx8vUxfQvAwlczsvefFqvPeZ3iAQnACDEVqJ7m7oI5BK9MCobzGGGuznAgtY8l0Z10qy3e5WmPgiSaI4GhLSubyi-uEBAthqCoUoWxNxjR0iINJ8MTa8OcsVUjQ5AuIuH9BtGr4JuS89zMjEz3McjsjmKAUwAQ; kuaishou.shop.b_ph=0a29c066148190b0759c776da8e94775e7dd", - "userAvatar": "https://p2-pro.a.yximgs.com/uhead/AB/2024/11/17/20/BMjAyNDExMTcyMDQ3NTdfMzY3Njc5NzkzNl8yX2hkODUyXzIwNg==_s.jpg", - "enabled": true - }, - { - "shopId": "4269276762", - "kshopName": "稚嫩游戏交易店", - "cookie": "_did=web_1426300268274CD9; did=web_ozkrrsevhb6yuztyk6m63vqqi5h2pzfl; __risk_web_device_id=cd52e12c1773406609757e71; sid=kuaishou.shop.b; sellerId=4269276762; bUserId=1000492186791; userId=4269276762; merchantSellerId=4269276762; merchantSessionKey=1777621574120_fbd47024d23e73fc4ad1a157ffece433; kuaishou.shop.b_st=ChJrdWFpc2hvdS5zaG9wLmIuc3QSoAFzGnewF-wCpt4rHi-D-pp8zPUp7HByKY0MlHkw-WV676zG7z09Y5i7d7URwmGC8CL13jN0fOGV7d3GfaH2XLH8d4p60z0Ms-dIX0bipkI0yUWHfgnWqv9VPa8HqVRqMLs2VGD82X9tAR3eN9PXu38AYg0a5kpawchGPb2tjcYgLgH5b5DnJgjipHqzWb8Ma8AcLOmryqCSg4ZHwO5dbVJHGhIdwFBlxRpZgyh6i0Do-DSRsroiIIayd1xA628OsvCY2qV1HjCrTvMVt-3LmXcU6rLKSzytKAUwAQ; kuaishou.shop.b_ph=38925ccb882ec5456dd1c6bcee3f87b7e740", - "userAvatar": "https://p2-pro.a.yximgs.com/uhead/AB/2024/07/23/17/BMjAyNDA3MjMxNzA1MThfNDI2OTI3Njc2Ml8yX2hkNDg1XzgxNQ==_s.jpg", - "enabled": true - } - ] -} diff --git a/apps/backend/data/notification-config.example.json b/apps/backend/data/notification-config.example.json new file mode 100644 index 00000000..1f1a0785 --- /dev/null +++ b/apps/backend/data/notification-config.example.json @@ -0,0 +1,14 @@ +{ + "enabled": true, + "channels": { + "bark": { + "enabled": true, + "serverUrl": "https://api.day.app", + "recipients": [] + }, + "wpush": { + "enabled": true, + "recipients": [] + } + } +} diff --git a/apps/backend/data/notification-config.json b/apps/backend/data/notification-config.json deleted file mode 100644 index 573fe861..00000000 --- a/apps/backend/data/notification-config.json +++ /dev/null @@ -1,40 +0,0 @@ -{ - "enabled": true, - "channels": { - "bark": { - "enabled": true, - "serverUrl": "https://api.day.app", - "recipients": [ - { - "id": "cdab629b-0480-4741-a4b5-4c11fb8fbc6b", - "name": "弋宸先生", - "deviceKey": "NaiQzbZkJTcaW23VWU9AXf", - "enabled": true - }, - { - "id": "82af4cb0-f6b2-4636-b1ca-29f482c49b30", - "name": "yml-测试1", - "deviceKey": "5EdwoMkrpuAfNLZN8aVrh5", - "enabled": true - } - ] - }, - "wpush": { - "enabled": true, - "recipients": [ - { - "id": "1c1089bb-4a54-4ec9-a10f-15b8099fdfb0", - "name": "y", - "apiKey": "WPUSHcPSmgodKUwMCB13IFSHJsyBqUI4", - "enabled": true - }, - { - "id": "5affc7d0-3944-43ee-90cb-8242ab9b34e7", - "name": "yml", - "apiKey": "WPUSHsa44qjF35klEZRqVHxAouoDE2A6", - "enabled": true - } - ] - } - } -} diff --git a/apps/backend/data/order-fulfillment-bindings.json b/apps/backend/data/order-fulfillment-bindings.json deleted file mode 100644 index c3837b32..00000000 --- a/apps/backend/data/order-fulfillment-bindings.json +++ /dev/null @@ -1,42 +0,0 @@ -[ - { - "provider": "agiso", - "platform": "xianyu", - "shopId": "2209880145223", - "shopName": "", - "skuCode": "海底捞第五人格皮肤", - "skuName": "海底捞第五人格皮肤", - "profileKey": "tencent_claim_redeem", - "enabled": false, - "priority": 100, - "config": {}, - "match": { - "externalSkuCode": "6227949975509", - "externalItemId": "1038123159888", - "externalSkuName": "海底捞第五人格皮肤", - "config": { - "resolvedSkuName": "海底捞第五人格皮肤" - } - } - }, - { - "provider": "agiso", - "platform": "xianyu", - "shopId": "2209880145223", - "shopName": "", - "skuCode": "三角洲海底捞动作", - "skuName": "海底捞干员庆生动作1个", - "profileKey": "tencent_claim_redeem", - "enabled": true, - "priority": 100, - "config": {}, - "match": { - "externalSkuCode": "6063968816746", - "externalItemId": "1041598483721", - "externalSkuName": "海底捞干员庆生动作1个", - "config": { - "resolvedSkuName": "海底捞干员庆生动作1个" - } - } - } -] diff --git a/apps/backend/data/scheduled-jobs.json b/apps/backend/data/scheduled-jobs.example.json similarity index 65% rename from apps/backend/data/scheduled-jobs.json rename to apps/backend/data/scheduled-jobs.example.json index 650f4da9..ac9517a1 100644 --- a/apps/backend/data/scheduled-jobs.json +++ b/apps/backend/data/scheduled-jobs.example.json @@ -4,8 +4,8 @@ { "id": "cloudtentacles-health", "type": "cloudtentacles_health", - "enabled": true, - "intervalSeconds": 18000, + "enabled": false, + "intervalSeconds": 300, "cooldownSeconds": 1800, "config": { "assetThreshold": 500, @@ -14,12 +14,6 @@ "sourceKey": "default", "label": "默认账号", "enabled": true, - "assetThreshold": 100 - }, - { - "sourceKey": "account2", - "label": "备用账号 1", - "enabled": true, "assetThreshold": 500 } ] diff --git a/apps/backend/package-lock.json b/apps/backend/package-lock.json index 9e587fd5..eb301548 100644 --- a/apps/backend/package-lock.json +++ b/apps/backend/package-lock.json @@ -9,13 +9,13 @@ "version": "0.1.0", "dependencies": { "express": "^5.1.0", - "pg": "^8.16.3", - "playwright": "1.42.1" + "pg": "^8.16.3" }, "devDependencies": { "@types/express": "^5.0.6", "@types/node": "^25.6.0", "@types/pg": "^8.20.0", + "prettier": "^3.8.3", "tsx": "^4.22.3", "typescript": "^6.0.2" } @@ -911,20 +911,6 @@ "node": ">= 0.8" } }, - "node_modules/fsevents": { - "version": "2.3.2", - "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz", - "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==", - "hasInstallScript": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": "^8.16.0 || ^10.6.0 || >=11.0.0" - } - }, "node_modules/function-bind": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", @@ -1275,36 +1261,6 @@ "split2": "^4.1.0" } }, - "node_modules/playwright": { - "version": "1.42.1", - "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.42.1.tgz", - "integrity": "sha512-PgwB03s2DZBcNRoW+1w9E+VkLBxweib6KTXM0M3tkiT4jVxKSi6PmVJ591J+0u10LUrgxB7dLRbiJqO5s2QPMg==", - "license": "Apache-2.0", - "dependencies": { - "playwright-core": "1.42.1" - }, - "bin": { - "playwright": "cli.js" - }, - "engines": { - "node": ">=16" - }, - "optionalDependencies": { - "fsevents": "2.3.2" - } - }, - "node_modules/playwright-core": { - "version": "1.42.1", - "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.42.1.tgz", - "integrity": "sha512-mxz6zclokgrke9p1vtdy/COWBH+eOZgYUVVU34C73M+4j4HLlQJHtfcqiqqxpP0o8HhMkflvfbquLX5dg6wlfA==", - "license": "Apache-2.0", - "bin": { - "playwright-core": "cli.js" - }, - "engines": { - "node": ">=16" - } - }, "node_modules/postgres-array": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/postgres-array/-/postgres-array-2.0.0.tgz", @@ -1344,6 +1300,22 @@ "node": ">=0.10.0" } }, + "node_modules/prettier": { + "version": "3.8.3", + "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.8.3.tgz", + "integrity": "sha512-7igPTM53cGHMW8xWuVTydi2KO233VFiTNyF5hLJqpilHfmn8C8gPf+PS7dUT64YcXFbiMGZxS9pCSxL/Dxm/Jw==", + "dev": true, + "license": "MIT", + "bin": { + "prettier": "bin/prettier.cjs" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/prettier/prettier?sponsor=1" + } + }, "node_modules/proxy-addr": { "version": "2.0.7", "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz", diff --git a/apps/backend/package.json b/apps/backend/package.json index 050fab74..a01d9187 100644 --- a/apps/backend/package.json +++ b/apps/backend/package.json @@ -4,14 +4,13 @@ "private": true, "type": "module", "scripts": { - "browser:install": "playwright install chromium", - "browser:install:linux": "playwright install --with-deps chromium", - "agiso:auto-delivery:test": "tsx scripts/test-agiso-auto-delivery.ts", "cleanup:dev-data": "tsx scripts/cleanup-dev-data.ts", - "seed:dev-data": "tsx scripts/seed-dev-data.ts", "build": "tsc -p tsconfig.build.json && rm -rf dist/db/migrations && mkdir -p dist/db && cp -R src/db/migrations dist/db/migrations", "db:migrate": "tsx src/db/migrate.ts", "dev": "tsx watch --clear-screen=false src/index.ts", + "format": "prettier --write .", + "mock:claim": "tsx scripts/mock-kuaishou-cloud-claim.ts", + "mock:open91": "tsx scripts/mock-open91-order.ts", "test": "node --import tsx --test $(find src \\( -name '*.test.ts' -o -name '*.test.js' \\) -print)", "typecheck": "tsc -p tsconfig.json --noEmit", "start": "node dist/index.js", @@ -19,13 +18,13 @@ }, "dependencies": { "express": "^5.1.0", - "pg": "^8.16.3", - "playwright": "1.42.1" + "pg": "^8.16.3" }, "devDependencies": { "@types/express": "^5.0.6", "@types/node": "^25.6.0", "@types/pg": "^8.20.0", + "prettier": "^3.8.3", "tsx": "^4.22.3", "typescript": "^6.0.2" } diff --git a/apps/backend/scripts/cleanup-dev-data.ts b/apps/backend/scripts/cleanup-dev-data.ts index 0a9cf007..871157bf 100644 --- a/apps/backend/scripts/cleanup-dev-data.ts +++ b/apps/backend/scripts/cleanup-dev-data.ts @@ -6,16 +6,12 @@ import { closeDb, query } from '../src/db/client.js' import { runtimeConfig } from '../src/config/runtime.js' const RESET_TABLES = [ - 'message_deliveries', - 'tencent_browser_contexts', + 'task_runtime_contexts', 'task_events', 'claim_tokens', - 'task_inventory_bindings', 'fulfillment_tasks', 'order_items', 'orders', - 'webhook_events', - 'inventory_items', ] const options = parseArgs(process.argv.slice(2)) diff --git a/apps/backend/scripts/mock-kuaishou-cloud-claim.ts b/apps/backend/scripts/mock-kuaishou-cloud-claim.ts new file mode 100644 index 00000000..7c09a858 --- /dev/null +++ b/apps/backend/scripts/mock-kuaishou-cloud-claim.ts @@ -0,0 +1,444 @@ +import process from 'node:process' + +import { closeDb } from '../src/db/client.js' +import { createOrder, findOrderByPlatformOrderId } from '../src/repositories/order-repo.js' +import { replaceOrderItems } from '../src/repositories/order-item-repo.js' +import { createTask, updateTask } from '../src/repositories/task-repo.js' +import { + getFulfillmentProfileByKey, + upsertFulfillmentProfile, +} from '../src/repositories/fulfillment-profile-repo.js' +import { buildClaimUrl, createTaskClaimToken } from '../src/services/claim/claim-service.js' +import { OPEN_91_PLATFORM, OPEN_91_PROVIDER } from '../src/services/open-91/config.js' +import { parseOpen91ProductNo } from '../src/services/platforms/ninetyone/order-service.js' +import { isProductionLike } from '../src/config/runtime-validation.js' +import { addHours, nowIso } from '../src/utils/time.js' +import { randomId } from '../src/utils/random.js' + +type JsonObject = Record +type DeliveryItem = { + cloudSkuId: number + cloudSkuName: string + quantity: number +} + +const args = parseArgs(process.argv.slice(2)) + +if (args.help) { + printHelp() + process.exit(0) +} + +if (isProductionLike() && args.force !== true) { + console.error('当前是生产环境,已拒绝生成 mock 数据。如确实要执行,请追加 --force。') + process.exit(1) +} + +const step = normalizeStep(args.step) +const orderNo = String(args.orderNo || `MOCK91${Date.now()}`).trim() +const productNo = String(args.productNo || '套餐_1----3676797936').trim() +const productInfo = parseOpen91ProductNo(productNo) +const productName = productInfo.productName || productInfo.rawProductNo || '套餐_1' +const consumeShopId = productInfo.shopId || String(args.kuaishouShopId || '3676797936').trim() +const deliveryItems = parseDeliveryItems(args.items) +const now = nowIso() +const existingOrder = await findOrderByPlatformOrderId({ + provider: OPEN_91_PROVIDER, + platform: OPEN_91_PLATFORM, + shopId: OPEN_91_PROVIDER, + platformOrderId: orderNo, +}) + +if (existingOrder) { + console.error(`订单号已存在:${orderNo}`) + console.error('请换一个 --orderNo,或不传 orderNo 让脚本自动生成。') + await closeDb() + process.exit(1) +} + +try { + const profile = await ensureKuaishouCloudProfile(now) + const order = await createOrder({ + provider: OPEN_91_PROVIDER, + platform: OPEN_91_PLATFORM, + shopId: OPEN_91_PROVIDER, + shopName: '91卡券', + platformOrderId: orderNo, + orderStatus: 'paid', + payStatus: 'paid', + buyerId: 'mock-buyer', + buyerName: 'mock 领取客户', + receiverContact: '', + totalAmount: 1, + currency: 'CNY', + rawPayloadJson: JSON.stringify({ + source: OPEN_91_PROVIDER, + mock: true, + receivedAt: now, + body: { + orderNo, + productNo, + productName, + buyNum: 1, + maxAmount: '0.01', + }, + }), + paidAt: now, + createdAt: now, + updatedAt: now, + }) + + if (!order) { + throw new Error('订单创建失败') + } + + const [orderItem] = await replaceOrderItems(order.id, [{ + skuCode: productName, + skuName: productName, + quantity: 1, + specJson: JSON.stringify({ + source: OPEN_91_PROVIDER, + orderNo, + productNo, + productName, + shopId: consumeShopId, + }), + itemSnapshotJson: JSON.stringify({ + source: OPEN_91_PROVIDER, + orderNo, + productNo, + productName, + shopId: consumeShopId, + cloudtentacles: { + matchMode: 'mock', + normalizedProductName: productName, + cloudSourceKeys: ['mock-cloudtentacles'], + resolvedSourceKey: 'mock-cloudtentacles', + deliveryItems, + }, + }), + createdAt: now, + updatedAt: now, + }]) + + if (!orderItem) { + throw new Error('订单商品创建失败') + } + + const task = await createTask({ + orderId: order.id, + orderItemId: orderItem.id, + unitIndex: 1, + provider: OPEN_91_PROVIDER, + platform: OPEN_91_PLATFORM, + shopId: OPEN_91_PROVIDER, + shopName: '91卡券', + platformOrderId: orderNo, + taskNo: randomId('DT'), + profileId: profile.id, + executorKey: 'kuaishou_ct_assisted', + taskStatus: resolveInitialTaskStatus(step), + deliveryStatus: step === 'result' ? 'success' : 'pending', + resultCode: step === 'result' ? 'mock_success' : '', + resultMessage: step === 'result' ? '开发 mock 已模拟兑换成功' : '', + claimToken: '', + claimExpiresAt: null, + automationMode: 'manual', + requiresClaim: false, + userActionStatus: step === 'result' ? 'not_required' : 'pending_claim', + attemptCount: 0, + lastError: '', + contextJson: JSON.stringify(buildMockTaskContext({ + step, + orderNo, + productName, + productNo, + consumeShopId, + deliveryItems, + timestamp: now, + })), + createdAt: now, + updatedAt: now, + }) + + if (!task) { + throw new Error('履约任务创建失败') + } + + const claimToken = await createTaskClaimToken(task.id) + const claimUrl = buildClaimUrl(claimToken.token) + await updateTask(task.id, { + claim_token: claimToken.token, + claim_expires_at: claimToken.expired_at, + ...(step !== 'ticket' ? { claimed_at: now } : {}), + ...(step === 'confirm' || step === 'result' ? { role_confirmed_at: now } : {}), + ...(step === 'result' ? { redeemed_at: now } : {}), + updated_at: now, + }) + + console.log('已生成快手 Cloud 领取 mock 数据:') + console.log(` 91订单号:${orderNo}`) + console.log(` 商品:${productName}`) + console.log(` 当前步骤:${step}`) + console.log(` 领取链接:${claimUrl}`) + console.log(` 前端本地链接:${buildFrontendClaimUrl(claimToken.token, args.frontendBaseUrl)}`) + console.log('') + console.log('页面核销码可填:MOCK') + console.log(`91 查询接口可用这个订单号测试:npm run mock:open91 -- --mode=query --orderNo=${orderNo}`) +} finally { + await closeDb() +} + +async function ensureKuaishouCloudProfile(timestamp: string) { + const existing = await getFulfillmentProfileByKey('kuaishou_ct_assisted') + if (existing) { + return existing + } + + const profile = await upsertFulfillmentProfile({ + profileKey: 'kuaishou_ct_assisted', + name: '快手 cloud 履约', + executorKey: 'kuaishou_ct_assisted', + requiresClaim: false, + autoDispatch: false, + inventoryStrategy: 'external_platform', + configJson: '{}', + createdAt: timestamp, + updatedAt: timestamp, + }) + + if (!profile) { + throw new Error('履约配置创建失败') + } + + return profile +} + +function buildMockTaskContext({ + step, + orderNo, + productName, + productNo, + consumeShopId, + deliveryItems, + timestamp, +}: { + step: string + orderNo: string + productName: string + productNo: string + consumeShopId: string + deliveryItems: DeliveryItem[] + timestamp: string +}) { + const ticketVerified = ['binding', 'confirm', 'result'].includes(step) + const roleReady = ['binding', 'confirm', 'result'].includes(step) + const roleConfirmed = ['confirm', 'result'].includes(step) + const completed = step === 'result' + const primaryItem = deliveryItems[0] || { + cloudSkuId: 910001, + cloudSkuName: 'Mock 商品', + quantity: 1, + } + + return { + profileKey: 'kuaishou_ct_assisted', + profileName: '快手 cloud 履约', + skuCode: productName, + skuName: productName, + kuaishouCloudFulfillment: { + flowType: 'kuaishou_cloud_fulfillment', + configId: `mock:${productName}`, + internalSkuCode: productName, + internalSkuName: productName, + deliveryItems, + mock: { + enabled: true, + orderNo, + productNo, + createdAt: timestamp, + }, + ticket: { + code: ticketVerified ? `MOCK-${orderNo}` : '', + status: ticketVerified ? 'verified' : 'pending', + capturedAt: ticketVerified ? timestamp : null, + capturedBy: ticketVerified ? { source: 'mock_script' } : null, + verifiedAt: ticketVerified ? timestamp : null, + oid: ticketVerified ? `MOCK-OID-${orderNo}` : '', + formToken: ticketVerified ? `MOCK-FORM-${orderNo}` : '', + leftCount: ticketVerified ? 1 : 0, + goodsTitle: ticketVerified ? productName : '', + }, + binding: { + prepareStatus: roleReady ? 'ready' : 'pending', + cloudSourceKeys: ['mock-cloudtentacles'], + resolvedSourceKey: 'mock-cloudtentacles', + skuId: primaryItem.cloudSkuId, + skuName: primaryItem.cloudSkuName, + vnKey: '1', + vnId: roleReady ? 900001 : 0, + vnPhone: roleReady ? '13800000000' : '', + bindUrl: roleReady ? `https://example.com/mock-kuaishou-cloud-bind/${orderNo}` : '', + bindPreparedAt: roleReady ? timestamp : null, + bindExpiresAt: roleReady ? addHours(timestamp, 24) : null, + bindProbeAt: roleReady ? timestamp : null, + bindProbeStatus: roleReady ? 'success' : '', + bindProbeMessage: '', + roleName: roleReady ? '测试角色' : '', + roleId: roleReady ? '10001' : '', + }, + role: { + status: roleReady ? 'ready' : 'pending', + name: roleReady ? '测试角色' : '', + rid: roleReady ? '10001' : '', + refreshedAt: roleReady ? timestamp : null, + errorMessage: '', + rawInfo: roleReady ? { mock: true } : null, + }, + purchase: { + autoBuyEnabled: true, + minAssetReserve: 0, + usedKnapsack: false, + purchaseTriggered: false, + assetBefore: 0, + assetAfter: 0, + purchaseAt: null, + items: [], + }, + dispatch: { + status: completed ? 'success' : 'pending', + dispatchAt: completed ? timestamp : null, + dispatchBy: completed ? { source: 'mock_script' } : null, + sendType: 0, + note: completed ? '开发 mock 已模拟发货成功' : '', + items: completed ? deliveryItems : [], + }, + returnNumber: { + status: completed ? 'success' : 'pending', + returnedAt: completed ? timestamp : null, + returnedBy: completed ? { source: 'mock_script' } : null, + autoReturnEnabled: true, + }, + consume: { + status: completed ? 'success' : 'pending', + shopId: consumeShopId, + shopName: 'Mock 快手小店', + autoConsumeEnabled: true, + consumedAt: completed ? timestamp : null, + errorMessage: '', + }, + notes: '开发 mock:不调用 cloudtentacles,只用于领取页流程验证', + }, + } +} + +function parseArgs(rawArgs: string[]) { + const parsed: JsonObject = {} + + for (const rawArg of rawArgs) { + const arg = String(rawArg || '').trim() + if (!arg) continue + if (arg === '--help' || arg === '-h') { + parsed.help = true + continue + } + + const normalized = arg.startsWith('--') ? arg.slice(2) : arg + const separatorIndex = normalized.indexOf('=') + if (separatorIndex < 0) { + parsed[normalized] = true + continue + } + + const key = normalized.slice(0, separatorIndex).trim() + const value = normalized.slice(separatorIndex + 1).trim() + if (key) { + parsed[key] = value + } + } + + return parsed +} + +function normalizeStep(value: unknown) { + const normalized = String(value || 'ticket').trim() + if (['ticket', 'binding', 'confirm', 'result'].includes(normalized)) { + return normalized + } + + throw new Error('--step 只能是 ticket、binding、confirm、result') +} + +function resolveInitialTaskStatus(step: string) { + if (step === 'ticket') return 'pending_binding_prepare' + if (step === 'binding') return 'waiting_binding' + if (step === 'confirm') return 'role_confirmed' + return 'completed' +} + +function parseDeliveryItems(value: unknown): DeliveryItem[] { + const text = String(value || '').trim() + const fallback = [ + { cloudSkuId: 910001, cloudSkuName: '套餐商品 A', quantity: 1 }, + { cloudSkuId: 910002, cloudSkuName: '套餐商品 B', quantity: 2 }, + ] + + if (!text) { + return fallback + } + + const items = text + .split(',') + .map((segment) => { + const [id, name, quantity] = segment.split(':') + const cloudSkuId = Number(id) + return { + cloudSkuId, + cloudSkuName: String(name || '').trim(), + quantity: Math.max(1, Number(quantity || 1) || 1), + } + }) + .filter((item) => Number.isInteger(item.cloudSkuId) && item.cloudSkuId > 0 && item.cloudSkuName) + + return items.length > 0 ? mergeDeliveryItems(items) : fallback +} + +function mergeDeliveryItems(items: DeliveryItem[]) { + const merged = new Map() + + for (const item of items) { + const existing = merged.get(item.cloudSkuId) + if (existing) { + existing.quantity += item.quantity + existing.cloudSkuName = existing.cloudSkuName || item.cloudSkuName + continue + } + merged.set(item.cloudSkuId, { ...item }) + } + + return Array.from(merged.values()) +} + +function buildFrontendClaimUrl(token: string, rawBaseUrl: unknown) { + const baseUrl = String(rawBaseUrl || process.env.MOCK_CLAIM_FRONTEND_BASE_URL || 'http://127.0.0.1:5173').replace(/\/+$/, '') + return `${baseUrl}/#/claim/${token}` +} + +function printHelp() { + console.log(` +生成快手 Cloud 领取 mock 数据: + + npm run mock:claim + npm run mock:claim -- --step=binding + npm run mock:claim -- --step=result --productNo=套餐_1----3676797936 + npm run mock:claim -- --items=910001:商品A:1,910002:商品B:2 + +参数: + --step ticket、binding、confirm、result,默认 ticket + --orderNo 91 订单号,默认自动生成 + --productNo 91 商品名,可带 ----快手小店ID,默认 套餐_1----3676797936 + --items 套餐明细,格式 skuId:名称:数量,skuId:名称:数量 + --frontendBaseUrl 前端地址,默认 http://127.0.0.1:5173 + --force 允许在 NODE_ENV=production 执行 +`) +} diff --git a/apps/backend/scripts/mock-open91-order.ts b/apps/backend/scripts/mock-open91-order.ts new file mode 100644 index 00000000..77d6fc5e --- /dev/null +++ b/apps/backend/scripts/mock-open91-order.ts @@ -0,0 +1,182 @@ +import crypto from 'node:crypto' +import path from 'node:path' +import { fileURLToPath } from 'node:url' + +import { loadEnvFiles } from '../src/config/runtime-env.js' + +type JsonObject = Record + +const CURRENT_DIR = path.dirname(fileURLToPath(import.meta.url)) +const PROJECT_ROOT = path.resolve(CURRENT_DIR, '..') +const WORKSPACE_ROOT = path.resolve(PROJECT_ROOT, '../..') + +loadEnvFiles([ + path.join(WORKSPACE_ROOT, '.env'), + path.join(PROJECT_ROOT, '.env'), +]) + +const args = parseArgs(process.argv.slice(2)) + +if (args.help) { + printHelp() + process.exit(0) +} + +const secret = readEnv('KAQUAN91_SECRET') +const version = readEnv('KAQUAN91_VERSION') || '1.0' +const baseUrl = String(args.baseUrl || process.env.OPEN91_MOCK_BASE_URL || 'http://127.0.0.1').replace(/\/$/, '') +const mode = String(args.mode || 'create').trim() + +if (!secret || secret.length !== 32) { + console.error('缺少 32 位 91 密钥:请配置 KAQUAN91_SECRET。') + process.exit(1) +} + +if (!['create', 'query'].includes(mode)) { + console.error('mode 只能是 create 或 query。') + process.exit(1) +} + +const orderNo = String(args.orderNo || `MOCK91${Date.now()}`).trim() +const timestamp = Number(args.timestamp || Math.floor(Date.now() / 1000)) + +const payload = mode === 'query' + ? { + orderNo, + timestamp, + version, + } + : { + buyNum: normalizePositiveInteger(args.buyNum, 1), + callbackUrl: String(args.callbackUrl || ''), + maxAmount: String(args.maxAmount || '0.01'), + orderNo, + productNo: String(args.productNo || '套装-浪漫天命').trim(), + timestamp, + version, + } + +const body = { + ...payload, + sign: signOpen91Payload(payload, secret), +} + +const endpoint = `${baseUrl}/api/v1/open/91/orders/${mode}` + +console.log(`POST ${endpoint}`) +console.log(JSON.stringify(maskPayload(body), null, 2)) + +const response = await fetch(endpoint, { + method: 'POST', + headers: { + 'content-type': 'application/json', + }, + body: JSON.stringify(body), +}).catch((error) => { + console.error('请求 91 mock 接口失败,请确认后端入口可访问。') + console.error(`当前地址:${endpoint}`) + console.error('Docker 开发环境通常使用 http://127.0.0.1;本地直启后端通常使用 http://127.0.0.1:3000。') + throw error +}) + +const responseText = await response.text() +console.log(`HTTP ${response.status}`) + +try { + console.log(JSON.stringify(JSON.parse(responseText), null, 2)) +} catch { + console.log(responseText) +} + +function parseArgs(rawArgs: string[]) { + const parsed: JsonObject = {} + + for (const rawArg of rawArgs) { + const arg = String(rawArg || '').trim() + if (!arg) continue + if (arg === '--help' || arg === '-h') { + parsed.help = true + continue + } + + const normalized = arg.startsWith('--') ? arg.slice(2) : arg + const separatorIndex = normalized.indexOf('=') + if (separatorIndex < 0) { + parsed[normalized] = true + continue + } + + const key = normalized.slice(0, separatorIndex).trim() + const value = normalized.slice(separatorIndex + 1).trim() + if (key) { + parsed[key] = value + } + } + + return parsed +} + +function readEnv(...keys: string[]) { + for (const key of keys) { + const value = String(process.env[key] || '').trim() + if (value) return value + } + + return '' +} + +function signOpen91Payload(payload: JsonObject, currentSecret: string) { + return crypto + .createHash('md5') + .update(buildOpen91SignSource(payload, currentSecret), 'utf8') + .digest('hex') + .toUpperCase() +} + +function buildOpen91SignSource(payload: JsonObject, currentSecret: string) { + const queryString = Object.entries(payload) + .filter(([key]) => key !== 'sign') + .sort(([left], [right]) => left.localeCompare(right)) + .map(([key, value]) => `${key}=${stringifySignValue(value)}`) + .join('&') + + return `${currentSecret}${queryString}${currentSecret}` +} + +function stringifySignValue(value: unknown) { + if (typeof value === 'number' && Number.isFinite(value)) return String(value) + if (typeof value === 'boolean') return value ? 'true' : 'false' + if (value == null) return '' + return String(value) +} + +function normalizePositiveInteger(value: unknown, fallback: number) { + const parsed = Number(value) + return Number.isInteger(parsed) && parsed > 0 ? parsed : fallback +} + +function maskPayload(payload: JsonObject) { + return { + ...payload, + sign: String(payload.sign || '').replace(/^(.{6}).+(.{4})$/, '$1***$2'), + } +} + +function printHelp() { + console.log(` +模拟 91 卡券请求: + + npm run mock:open91 -- --productNo=套装-浪漫天命 + npm run mock:open91 -- --productNo=荣耀勋章礼包(30个) --maxAmount=300 --buyNum=1 + npm run mock:open91 -- --mode=query --orderNo=MOCK911779000000000 + +参数: + --baseUrl 后端地址,默认 http://127.0.0.1 + --mode create 或 query,默认 create + --orderNo 91 订单号,默认自动生成 + --productNo 91 商品名,默认 套装-浪漫天命 + --buyNum 购买数量,默认 1 + --maxAmount 最大金额/正常金额,默认 0.01 + --callbackUrl 回调地址,默认空 +`) +} diff --git a/apps/backend/scripts/seed-dev-data.ts b/apps/backend/scripts/seed-dev-data.ts deleted file mode 100644 index 45444b50..00000000 --- a/apps/backend/scripts/seed-dev-data.ts +++ /dev/null @@ -1,513 +0,0 @@ -#!/usr/bin/env node - -import process from 'node:process' - -import { runtimeConfig } from '../src/config/runtime.js' -import { closeDb, query } from '../src/db/client.js' -import { runDatabaseMigrations } from '../src/db/migrate.js' -import { updateClaimToken } from '../src/repositories/claim-token-repo.js' -import { getFulfillmentProfileByKey, upsertSkuFulfillmentBinding } from '../src/repositories/fulfillment-profile-repo.js' -import { createInventoryItems, releaseReservedInventoryItem } from '../src/repositories/inventory-repo.js' -import { getTaskById, updateTask } from '../src/repositories/task-repo.js' -import { ensureFulfillmentCatalogBootstrapped } from '../src/services/bootstrap/fulfillment-bootstrap-service.js' -import { completeAdminTaskManualDispatch } from '../src/services/admin/admin-service.js' -import { upsertOrderFromWebhook } from '../src/services/order/order-service.js' -import { addHours, nowIso } from '../src/utils/time.js' - -const RESET_TABLES = [ - 'message_deliveries', - 'tencent_browser_contexts', - 'task_events', - 'claim_tokens', - 'task_inventory_bindings', - 'fulfillment_tasks', - 'order_items', - 'orders', - 'webhook_events', - 'product_match_rules', - 'inventory_items', -] - -const SEED_PROVIDER = 'seed' -const SEED_PLATFORM = 'sandbox' -const SEED_SHOP_ID = 'seed-shop' -const SEED_SHOP_NAME = 'Seed Sandbox Shop' - -const options = parseArgs(process.argv.slice(2)) - -if (options.help) { - printHelp() - process.exit(0) -} - -try { - printPreview() - - if (!options.apply) { - console.log('\n当前为预览模式。确认后追加 `--apply`,需要清库时再加 `--reset`。') - process.exit(0) - } - - await runDatabaseMigrations() - await ensureFulfillmentCatalogBootstrapped() - - if (options.reset) { - await query(`TRUNCATE TABLE ${RESET_TABLES.join(', ')} RESTART IDENTITY CASCADE`) - console.log('\n已先清空测试业务数据。') - } - - await ensureSeedBindings() - await createSeedInventory() - - const manualPending = await seedScenario({ - scenarioKey: 'manual_pending', - orderLabel: 'SEED-MANUAL-PENDING', - skuCode: 'seed-manual-pending', - skuName: '开发种子-人工履约待处理', - }) - - const manualCompleted = await seedScenario({ - scenarioKey: 'manual_completed', - orderLabel: 'SEED-MANUAL-COMPLETED', - skuCode: 'seed-manual-completed', - skuName: '开发种子-人工履约已完成', - afterCreate: async (result) => { - const task = result.tasks[0] - if (!task) { - return null - } - - await completeAdminTaskManualDispatch(task.id, { - outcome: 'delivered', - resultMessage: '开发种子数据:人工履约已完成', - deliveryReference: 'SEED-MANUAL-REF-001', - deliveredCredential: '开发种子数据:已通过人工渠道发放', - }) - - return getTaskById(task.id) - }, - }) - - const claimReady = await seedScenario({ - scenarioKey: 'claim_ready', - orderLabel: 'SEED-CLAIM-READY', - skuCode: 'seed-claim-ready', - skuName: '开发种子-领取兑换可用', - }) - - const claimClaimed = await seedScenario({ - scenarioKey: 'claim_claimed', - orderLabel: 'SEED-CLAIM-CLAIMED', - skuCode: 'seed-claim-claimed', - skuName: '开发种子-已打开领取链接', - afterCreate: async (result) => applyClaimedState(result.tasks[0]), - }) - - const claimRoleConfirmed = await seedScenario({ - scenarioKey: 'claim_role_confirmed', - orderLabel: 'SEED-CLAIM-ROLE', - skuCode: 'seed-claim-role-confirmed', - skuName: '开发种子-已确认角色', - afterCreate: async (result) => applyRoleConfirmedState(result.tasks[0]), - }) - - const claimRetryPending = await seedScenario({ - scenarioKey: 'claim_retry_pending', - orderLabel: 'SEED-CLAIM-RETRY', - skuCode: 'seed-claim-retry', - skuName: '开发种子-等待重试', - afterCreate: async (result) => applyRetryPendingState(result.tasks[0]), - }) - - const claimExpired = await seedScenario({ - scenarioKey: 'claim_expired', - orderLabel: 'SEED-CLAIM-EXPIRED', - skuCode: 'seed-claim-expired', - skuName: '开发种子-领取链接已过期', - afterCreate: async (result) => applyExpiredState(result.tasks[0]), - }) - - const claimWaitingInventory = await seedScenario({ - scenarioKey: 'claim_waiting_inventory', - orderLabel: 'SEED-CLAIM-WAIT', - skuCode: 'seed-claim-wait', - skuName: '开发种子-等待库存', - }) - - printResultSummary([ - manualPending, - manualCompleted, - claimReady, - claimClaimed, - claimRoleConfirmed, - claimRetryPending, - claimExpired, - claimWaitingInventory, - ]) -} catch (error) { - console.error('\n写入开发种子数据失败:', error instanceof Error ? error.message : String(error || '未知错误')) - process.exitCode = 1 -} finally { - await closeDb() -} - -async function ensureSeedBindings() { - const timestamp = nowIso() - const manualProfile = await getRequiredProfile('manual_review') - const claimProfile = await getRequiredProfile('tencent_claim_redeem') - - const bindings = [ - { skuCode: 'seed-manual-pending', profileId: manualProfile.id, priority: 10 }, - { skuCode: 'seed-manual-completed', profileId: manualProfile.id, priority: 20 }, - { skuCode: 'seed-claim-ready', profileId: claimProfile.id, priority: 30 }, - { skuCode: 'seed-claim-claimed', profileId: claimProfile.id, priority: 40 }, - { skuCode: 'seed-claim-role-confirmed', profileId: claimProfile.id, priority: 50 }, - { skuCode: 'seed-claim-retry', profileId: claimProfile.id, priority: 60 }, - { skuCode: 'seed-claim-expired', profileId: claimProfile.id, priority: 70 }, - { skuCode: 'seed-claim-wait', profileId: claimProfile.id, priority: 80 }, - ] - - for (const binding of bindings) { - await upsertSkuFulfillmentBinding({ - skuCode: binding.skuCode, - provider: SEED_PROVIDER, - platform: SEED_PLATFORM, - shopId: SEED_SHOP_ID, - profileId: binding.profileId, - enabled: true, - priority: binding.priority, - configJson: JSON.stringify({ - seededBy: 'scripts/seed-dev-data.ts', - scenario: binding.skuCode, - }), - createdAt: timestamp, - updatedAt: timestamp, - }) - } -} - -async function createSeedInventory() { - const timestamp = nowIso() - - await createInventoryItems([ - { - skuCode: 'seed-claim-ready', - displayValue: 'SEED-CLAIM-CODE-READY-001', - batchNo: 'seed-20260409', - credentialType: 'tencent_code', - payload: { - code: 'SEED-CLAIM-CODE-READY-001', - scenario: 'claim_ready', - }, - createdAt: timestamp, - updatedAt: timestamp, - }, - { - skuCode: 'seed-claim-claimed', - displayValue: 'SEED-CLAIM-CODE-CLAIMED-001', - batchNo: 'seed-20260409', - credentialType: 'tencent_code', - payload: { - code: 'SEED-CLAIM-CODE-CLAIMED-001', - scenario: 'claim_claimed', - }, - createdAt: timestamp, - updatedAt: timestamp, - }, - { - skuCode: 'seed-claim-role-confirmed', - displayValue: 'SEED-CLAIM-CODE-ROLE-001', - batchNo: 'seed-20260409', - credentialType: 'tencent_code', - payload: { - code: 'SEED-CLAIM-CODE-ROLE-001', - scenario: 'claim_role_confirmed', - }, - createdAt: timestamp, - updatedAt: timestamp, - }, - { - skuCode: 'seed-claim-retry', - displayValue: 'SEED-CLAIM-CODE-RETRY-001', - batchNo: 'seed-20260409', - credentialType: 'tencent_code', - payload: { - code: 'SEED-CLAIM-CODE-RETRY-001', - scenario: 'claim_retry_pending', - }, - createdAt: timestamp, - updatedAt: timestamp, - }, - { - skuCode: 'seed-claim-expired', - displayValue: 'SEED-CLAIM-CODE-EXPIRED-001', - batchNo: 'seed-20260409', - credentialType: 'tencent_code', - payload: { - code: 'SEED-CLAIM-CODE-EXPIRED-001', - scenario: 'claim_expired', - }, - createdAt: timestamp, - updatedAt: timestamp, - }, - { - skuCode: 'seed-spare-available', - displayValue: 'SEED-SPARE-CODE-001', - batchNo: 'seed-20260409', - credentialType: 'tencent_code', - payload: { - code: 'SEED-SPARE-CODE-001', - scenario: 'spare_available', - }, - createdAt: timestamp, - updatedAt: timestamp, - }, - ]) -} - -async function seedScenario({ scenarioKey, orderLabel, skuCode, skuName, afterCreate = null }) { - const result = await upsertOrderFromWebhook(buildSeedOrderEvent({ - scenarioKey, - orderLabel, - skuCode, - skuName, - })) - - const task = typeof afterCreate === 'function' - ? await afterCreate(result) - : result.tasks[0] || null - - return { - scenarioKey, - orderId: result.order.id, - platformOrderId: result.order.platform_order_id, - taskId: task?.id || null, - taskNo: task?.task_no || '', - taskStatus: task?.task_status || '', - deliveryStatus: task?.delivery_status || '', - inventoryStatus: task?.inventory_status || '', - inventoryItemId: Number(task?.primary_inventory_item_id || 0) || null, - claimTokenId: Number(task?.primary_claim_token_id || 0) || null, - executorKey: String(task?.executor_key || '').trim(), - } -} - -async function applyClaimedState(task) { - if (!task) { - return null - } - - const now = nowIso() - await updateTask(task.id, { - task_status: 'claimed', - user_action_status: 'claimed', - browser_session_id: `seed-browser-${task.id}`, - login_type: 'qq', - claimed_at: now, - updated_at: now, - last_error: '', - }) - - return getTaskById(task.id) -} - -async function applyRoleConfirmedState(task) { - if (!task) { - return null - } - - const claimedAt = addHours(nowIso(), -1) - const roleConfirmedAt = nowIso() - await updateTask(task.id, { - task_status: 'role_confirmed', - user_action_status: 'role_confirmed', - browser_session_id: `seed-browser-${task.id}`, - login_type: 'qq', - nickname: 'SeedHero', - role_id: 'seed-role-1001', - role_name: '开发角色-已确认', - area: 'seed-area', - partition_name: 'seed-partition', - claimed_at: claimedAt, - role_confirmed_at: roleConfirmedAt, - updated_at: roleConfirmedAt, - last_error: '', - }) - - return getTaskById(task.id) -} - -async function applyRetryPendingState(task) { - if (!task) { - return null - } - - const claimedAt = addHours(nowIso(), -2) - const roleConfirmedAt = addHours(nowIso(), -1) - const updatedAt = nowIso() - await updateTask(task.id, { - task_status: 'retry_pending', - delivery_status: 'pending', - inventory_status: 'reserved', - user_action_status: 'role_confirmed', - browser_session_id: `seed-browser-${task.id}`, - login_type: 'qq', - nickname: 'SeedRetryUser', - role_id: 'seed-role-retry', - role_name: '开发角色-待重试', - area: 'seed-area', - partition_name: 'seed-partition', - claimed_at: claimedAt, - role_confirmed_at: roleConfirmedAt, - attempt_count: 2, - last_error: '开发种子数据:模拟兑换失败,等待重试', - updated_at: updatedAt, - }) - - return getTaskById(task.id) -} - -async function applyExpiredState(task) { - if (!task) { - return null - } - - const now = nowIso() - if (task.primary_claim_token_id) { - await updateClaimToken(task.primary_claim_token_id, { - status: 'expired', - expired_at: addHours(now, -2), - updated_at: now, - }) - } - - if (task.primary_inventory_item_id) { - await releaseReservedInventoryItem(task.primary_inventory_item_id, now) - } - - await updateTask(task.id, { - task_status: 'expired', - inventory_status: 'pending', - user_action_status: 'expired', - last_error: '领取链接已过期,预占库存项已释放', - updated_at: now, - }) - - return getTaskById(task.id) -} - -function buildSeedOrderEvent({ scenarioKey, orderLabel, skuCode, skuName }) { - const timestamp = nowIso() - - return { - provider: SEED_PROVIDER, - platform: SEED_PLATFORM, - shopId: SEED_SHOP_ID, - shopName: SEED_SHOP_NAME, - platformOrderId: orderLabel, - orderStatus: 'paid', - payStatus: 'paid', - buyerId: `${scenarioKey}-buyer`, - buyerName: `开发种子-${scenarioKey}`, - receiverContact: `seed+${scenarioKey}@example.com`, - totalAmount: 100, - currency: 'CNY', - paidAt: timestamp, - rawPayload: { - source: 'seed-dev-data', - scenarioKey, - platformOrderId: orderLabel, - }, - items: [ - { - skuCode, - skuName, - quantity: 1, - spec: { - title: skuName, - scenarioKey, - }, - snapshot: { - title: skuName, - scenarioKey, - seeded: true, - }, - }, - ], - } -} - -async function getRequiredProfile(profileKey) { - const profile = await getFulfillmentProfileByKey(profileKey) - - if (!profile) { - throw new Error(`缺少履约档案: ${profileKey}`) - } - - return profile -} - -function parseArgs(argv) { - return { - apply: argv.includes('--apply'), - reset: argv.includes('--reset'), - help: argv.includes('-h') || argv.includes('--help'), - } -} - -function printHelp() { - console.log(` -用法: - npm run seed:dev-data -- [--apply] [--reset] - -说明: - 基于当前 PostgreSQL fulfillment schema 生成一组可重复验证的开发种子数据。 - -场景: - - manual_pending: 人工履约待处理 - - manual_completed: 人工履约已完成 - - claim_ready: 已预占库存并生成领取链接 - - claim_claimed: 用户已打开领取链接 - - claim_role_confirmed: 用户已确认角色 - - claim_retry_pending: 自动履约失败等待重试 - - claim_expired: 领取链接已过期 - - claim_waiting_inventory: 等待库存 - -参数: - --apply 实际写入数据 - --reset 写入前先清空测试业务数据 - -数据库连接: - ${String(runtimeConfig.database?.url || '').trim() || '(未配置 DATABASE_URL)'} -`.trim()) -} - -function printPreview() { - console.log('准备生成开发种子数据:') - console.log('- manual_pending -> 人工履约待处理') - console.log('- manual_completed -> 人工履约已完成') - console.log('- claim_ready -> 领取兑换已就绪') - console.log('- claim_claimed -> 用户已打开领取链接') - console.log('- claim_role_confirmed -> 用户已确认角色') - console.log('- claim_retry_pending -> 自动履约失败等待重试') - console.log('- claim_expired -> 领取链接已过期') - console.log('- claim_waiting_inventory -> 等待库存') - console.log(`- database: ${String(runtimeConfig.database?.url || '').trim() || '(未配置 DATABASE_URL)'}`) -} - -function printResultSummary(rows) { - console.log('\n开发种子数据已写入:') - - for (const row of rows) { - console.log(`- ${row.scenarioKey}`) - console.log(` orderId=${row.orderId} platformOrderId=${row.platformOrderId}`) - console.log(` taskId=${row.taskId || '-'} taskNo=${row.taskNo || '-'} executor=${row.executorKey || '-'}`) - console.log(` taskStatus=${row.taskStatus || '-'} deliveryStatus=${row.deliveryStatus || '-'} inventoryStatus=${row.inventoryStatus || '-'}`) - console.log(` inventoryItemId=${row.inventoryItemId || '-'} claimTokenId=${row.claimTokenId || '-'}`) - } - - console.log('\n建议验证入口:') - console.log('- `/api/v1/admin/orders?platformOrderId=SEED-`') - console.log('- `/api/v1/admin/tasks?taskNo=DT`') - console.log('- `/api/v1/admin/inventory`') -} diff --git a/apps/backend/scripts/start-dev-container.sh b/apps/backend/scripts/start-dev-container.sh index ec4373ea..69990b1c 100644 --- a/apps/backend/scripts/start-dev-container.sh +++ b/apps/backend/scripts/start-dev-container.sh @@ -1,97 +1,5 @@ #!/bin/sh set -eu -is_true() { - normalized=$(printf '%s' "${1:-}" | tr '[:upper:]' '[:lower:]') - case "$normalized" in - 1|true|yes|on) - return 0 - ;; - *) - return 1 - ;; - esac -} - -start_browser_debug_services() { - display="${TENCENT_BROWSER_DISPLAY:-:99}" - resolution="${TENCENT_BROWSER_VNC_RESOLUTION:-1600x900x24}" - display_number="${display#:}" - display_number="${display_number%%.*}" - display_socket="/tmp/.X11-unix/X${display_number}" - display_lock="/tmp/.X${display_number}-lock" - - if [ -S "$display_socket" ] && [ ! -f "$display_lock" ]; then - rm -f "$display_socket" - fi - - if [ -f "$display_lock" ]; then - display_pid=$(cat "$display_lock" 2>/dev/null || true) - if [ -z "$display_pid" ] || ! kill -0 "$display_pid" 2>/dev/null; then - rm -f "$display_lock" "$display_socket" - fi - fi - - export DISPLAY="$display" - Xvfb "$display" -screen 0 "$resolution" -nolisten tcp >/tmp/xvfb.log 2>&1 & - xvfb_pid=$! - - ready=0 - attempt=0 - while [ "$attempt" -lt 50 ]; do - if [ -S "$display_socket" ]; then - ready=1 - break - fi - - if ! kill -0 "$xvfb_pid" 2>/dev/null; then - break - fi - - attempt=$((attempt + 1)) - sleep 0.1 - done - - if [ "$ready" -ne 1 ]; then - echo "[backend-dev] Xvfb display ${DISPLAY} not ready for x11vnc; skip noVNC startup" >&2 - return - fi - - if ! is_true "${TENCENT_BROWSER_NOVNC_ENABLED:-true}"; then - echo "[backend-dev] headed browser enabled on ${DISPLAY}; noVNC disabled" - return - fi - - vnc_port="${TENCENT_BROWSER_VNC_PORT:-5900}" - novnc_port="${TENCENT_BROWSER_NOVNC_PORT:-6080}" - - x11vnc \ - -display "$display" \ - -rfbport "$vnc_port" \ - -forever \ - -shared \ - -nopw \ - -listen 0.0.0.0 \ - -xkb >/tmp/x11vnc.log 2>&1 & - - if command -v novnc_proxy >/dev/null 2>&1; then - novnc_proxy --listen "$novnc_port" --vnc "127.0.0.1:${vnc_port}" >/tmp/novnc.log 2>&1 & - elif [ -x /usr/share/novnc/utils/novnc_proxy ]; then - /usr/share/novnc/utils/novnc_proxy --listen "$novnc_port" --vnc "127.0.0.1:${vnc_port}" >/tmp/novnc.log 2>&1 & - elif command -v websockify >/dev/null 2>&1 && [ -d /usr/share/novnc ]; then - websockify --web=/usr/share/novnc "$novnc_port" "127.0.0.1:${vnc_port}" >/tmp/novnc.log 2>&1 & - else - echo "[backend-dev] noVNC requested but novnc/websockify not found" - return - fi - - echo "[backend-dev] headed browser enabled on ${DISPLAY}; noVNC: http://127.0.0.1:${novnc_port}/vnc.html" -} - npm install --no-fund --no-audit - -if ! is_true "${TENCENT_BROWSER_HEADLESS:-true}"; then - start_browser_debug_services -fi - exec npm run dev diff --git a/apps/backend/scripts/test-agiso-auto-delivery.ts b/apps/backend/scripts/test-agiso-auto-delivery.ts deleted file mode 100644 index 5dac381b..00000000 --- a/apps/backend/scripts/test-agiso-auto-delivery.ts +++ /dev/null @@ -1,288 +0,0 @@ -#!/usr/bin/env node - -import crypto from 'node:crypto' -import process from 'node:process' -import { fileURLToPath } from 'node:url' -import { setTimeout as sleep } from 'node:timers/promises' - -import { query, closeDb } from '../src/db/client.js' -import { parseJsonObject } from '../src/utils/json.js' -import { getAgisoShopConfig } from '../src/services/platforms/agiso/shop-config-service.js' -import { queryAgisoXianyuOrderDetail } from '../src/services/platforms/agiso/xianyu/order-detail-service.js' - -const DEFAULT_DUMMY_ENDPOINT = 'https://gw-api.agiso.com/aldsIdle/Order/DummySend' -const DEFAULT_POLL_SECONDS = [0, 3, 10] - -async function main() { - const options = parseArgs(process.argv.slice(2)) - if (options.help || !options.orderId) { - printHelp() - process.exit(options.help ? 0 : 1) - } - - const orderRecord = await resolveOrderRecord(options) - const shopId = orderRecord?.shop_id || options.shopId - const shopConfig = getAgisoShopConfig(shopId) || {} - const accessToken = String(shopConfig.accessToken || '').trim() - const appSecret = String(shopConfig.appSecret || process.env.AGISO_APP_SECRET || '').trim() - - if (!shopId) { - throw new Error('未提供 shopId,且数据库中也没有查到该订单对应的店铺') - } - - if (!accessToken) { - throw new Error(`店铺 ${shopId} 缺少 accessToken 配置,请检查 apps/backend/data/agiso-shops.json`) - } - - if (!appSecret) { - throw new Error('缺少 AGISO_APP_SECRET 或店铺级 appSecret 配置') - } - - printJson('测试输入', { - orderId: options.orderId, - shopId, - pollSeconds: options.pollSeconds, - orderRecord: orderRecord - ? { - id: Number(orderRecord.id || 0) || null, - platformOrderId: String(orderRecord.platform_order_id || '').trim(), - orderStatus: String(orderRecord.order_status || '').trim(), - payStatus: String(orderRecord.pay_status || '').trim(), - provider: String(orderRecord.provider || '').trim(), - platform: String(orderRecord.platform || '').trim(), - } - : null, - }) - - const beforeDetail = await queryAgisoXianyuOrderDetail({ - shopId, - platformOrderId: options.orderId, - requestId: `diag-before-${Date.now()}`, - }) - printJson('调用前订单详情', summarizeDetail(beforeDetail)) - - const result = await executeRequest({ - platformOrderId: options.orderId, - accessToken, - appSecret, - }) - printJson('接口响应 更新发货状态 DummySend', result) - - for (const seconds of options.pollSeconds) { - if (seconds > 0) { - await sleep(seconds * 1000) - } - - const detail = await queryAgisoXianyuOrderDetail({ - shopId, - platformOrderId: options.orderId, - requestId: `diag-after-${seconds}s-${Date.now()}`, - }) - printJson(`调用后订单详情 +${seconds}s`, summarizeDetail(detail)) - } -} - -function parseArgs(argv) { - const output = { - orderId: '', - shopId: '', - pollSeconds: [...DEFAULT_POLL_SECONDS], - help: false, - } - - for (let index = 0; index < argv.length; index += 1) { - const current = String(argv[index] || '').trim() - if (!current) { - continue - } - - if (!current.startsWith('--')) { - if (!output.orderId) { - output.orderId = current - continue - } - - throw new Error(`无法识别的参数:${current}`) - } - - if (current === '--help') { - output.help = true - continue - } - - const [rawKey, inlineValue = ''] = current.split('=', 2) - const key = rawKey.slice(2) - const nextValue = inlineValue || argv[index + 1] || '' - const shouldConsumeNext = !inlineValue && argv[index + 1] && !String(argv[index + 1]).startsWith('--') - - switch (key) { - case 'shop-id': - output.shopId = String(nextValue || '').trim() - break - case 'poll': - output.pollSeconds = parsePollSeconds(nextValue) - break - default: - throw new Error(`无法识别的参数:${current}`) - } - - if (shouldConsumeNext) { - index += 1 - } - } - - return output -} - -function parsePollSeconds(value) { - const normalized = String(value || '').trim() - if (!normalized) { - return [...DEFAULT_POLL_SECONDS] - } - - const parsed = normalized - .split(',') - .map((item) => Number(String(item || '').trim())) - .filter((item) => Number.isFinite(item) && item >= 0) - .map((item) => Math.floor(item)) - - if (parsed.length === 0) { - throw new Error(`poll 参数格式无效:${value}`) - } - - return parsed -} - -async function resolveOrderRecord(options) { - const explicitShopId = String(options.shopId || '').trim() - if (explicitShopId) { - const result = await query( - ` - SELECT * - FROM orders - WHERE provider = 'agiso' - AND platform = 'xianyu' - AND shop_id = $1 - AND platform_order_id = $2 - ORDER BY id DESC - LIMIT 1 - `, - [explicitShopId, options.orderId], - ) - - return result.rows[0] || null - } - - const result = await query( - ` - SELECT * - FROM orders - WHERE provider = 'agiso' - AND platform = 'xianyu' - AND platform_order_id = $1 - ORDER BY id DESC - LIMIT 2 - `, - [options.orderId], - ) - - if (result.rows.length > 1) { - throw new Error(`数据库里命中多条同订单号记录,请显式传入 --shop-id。订单号:${options.orderId}`) - } - - return result.rows[0] || null -} - -async function executeRequest(context) { - const timestamp = String(Math.floor(Date.now() / 1000)) - const requestBody = buildDummySendBody(context, timestamp) - const response = await fetch(DEFAULT_DUMMY_ENDPOINT, { - method: 'POST', - headers: { - Authorization: `Bearer ${context.accessToken}`, - ApiVersion: '1', - 'content-type': 'application/x-www-form-urlencoded; charset=utf-8', - }, - body: new URLSearchParams(requestBody).toString(), - }) - const rawText = await response.text() - const payload = parseJsonObject(rawText, { preserveLargeIntegers: true }) - - return { - endpoint: DEFAULT_DUMMY_ENDPOINT, - status: response.status, - requestBody, - response: payload, - rawText, - } -} - -function buildDummySendBody(context, timestamp) { - const payload = { - tid: context.platformOrderId, - timestamp, - } - - payload.sign = generateSign(payload, context.appSecret) - return payload -} - -function generateSign(params, appSecret) { - let raw = String(appSecret || '').trim() - for (const [key, value] of Object.entries(params).sort(([left], [right]) => left.localeCompare(right))) { - raw += `${key}${value}` - } - raw += String(appSecret || '').trim() - return crypto.createHash('md5').update(raw, 'utf8').digest('hex').toLowerCase() -} - -function summarizeDetail(detailResult) { - return { - success: Boolean(detailResult?.success), - reason: String(detailResult?.reason || '').trim(), - errorMessage: String(detailResult?.errorMessage || '').trim(), - responseStatus: Number(detailResult?.responseStatus || 0), - shipped: Boolean(detailResult?.shipped), - shipTime: Number(detailResult?.shipTime || 0), - orderStatus: Number(detailResult?.orderStatus || 0), - bizOrderId: String(detailResult?.detailPayload?.biz_order_id || '').trim(), - sellerNick: String(detailResult?.detailPayload?.seller_nick || '').trim(), - buyerNick: String(detailResult?.detailPayload?.buyer_nick || '').trim(), - sku: String(detailResult?.detailPayload?.sku || '').trim(), - itemTitle: String(detailResult?.detailPayload?.item?.title || '').trim(), - } -} - -function printJson(title, value) { - process.stdout.write(`\n=== ${title} ===\n`) - process.stdout.write(`${JSON.stringify(value, null, 2)}\n`) -} - -function printHelp() { - process.stdout.write(`用法: - npm run agiso:auto-delivery:test -- <订单号> [--shop-id <店铺ID>] [--poll 0,3,10] - -说明: - 1. 该脚本只测试正确的发货接口 DummySend。 - 2. 会先查 Order/Detail,再调用 DummySend,再按 poll 秒数轮询发货状态。 - -示例: - npm run agiso:auto-delivery:test -- 4502285714045005830 - npm run agiso:auto-delivery:test -- 4502285714045005830 --poll 0,5,15 - npm run agiso:auto-delivery:test -- 4502285714045005830 --shop-id 2209880145223 -`) -} - -const isNodeTestRunner = Array.isArray(process.execArgv) && process.execArgv.includes('--test') -const isDirectRun = !isNodeTestRunner && process.argv[1] && fileURLToPath(import.meta.url) === process.argv[1] - -if (isDirectRun) { - main() - .catch((error) => { - process.stderr.write(`${error instanceof Error ? error.stack || error.message : String(error)}\n`) - process.exitCode = 1 - }) - .finally(async () => { - await closeDb() - }) -} diff --git a/apps/backend/scripts/test-order-flow.sh b/apps/backend/scripts/test-order-flow.sh deleted file mode 100755 index 0924019a..00000000 --- a/apps/backend/scripts/test-order-flow.sh +++ /dev/null @@ -1,98 +0,0 @@ -#!/usr/bin/env bash -set -euo pipefail - -BASE_URL="http://127.0.0.1:3000" -APP_SECRET=$(cd /Users/yml/codes/order-site-workspace/apps/backend && node --input-type=module -e "import { runtimeConfig } from './src/config/runtime.js'; process.stdout.write(String(runtimeConfig.platforms.agiso.appSecret || ''))") - -ORDER_ID="${1:-SIM-ORDER-20260408-0001}" -SKU_ID="32768" -SKU_NAME="海底捞套餐" - -sign_agiso() { - local raw_json="$1" - local ts="$2" - - node -e ' - const crypto = require("crypto") - const appSecret = process.argv[1] - const rawJson = process.argv[2] - const timestamp = process.argv[3] - const sign = crypto - .createHash("md5") - .update(`${appSecret}json${rawJson}timestamp${timestamp}${appSecret}`, "utf8") - .digest("hex") - .toLowerCase() - process.stdout.write(sign) - ' "$APP_SECRET" "$raw_json" "$ts" -} - -push_agiso_event() { - local aopic="$1" - local raw_json="$2" - local ts - ts=$(date +%s) - local sign - sign=$(sign_agiso "$raw_json" "$ts") - - echo - echo "========== PUSH aopic=$aopic ==========" - echo "ORDER_ID=$ORDER_ID" - echo - - curl -sS -X POST "${BASE_URL}/api/v1/webhooks/agiso/trade?aopic=${aopic}×tamp=${ts}&sign=${sign}" \ - -H "Content-Type: application/x-www-form-urlencoded" \ - --data-urlencode "json=${raw_json}" - - echo - echo -} - -create_payload() { - cat < boolean; @@ -44,6 +43,7 @@ export function createApp({ buildSuccessPayload({ status: isShutdownStarted() ? "shutting_down" : "alive", pid: process.pid, + mode: "kuaishou-lite", }) ); }); @@ -89,9 +89,7 @@ export function createApp({ }); }); - app.use("/api/v1/tencent", tencentRouter); app.use("/api/v1/open/91", open91Router); - app.use("/api/v1/webhooks", webhooksRouter); app.use("/api/v1/claim", claimsRouter); app.use("/api/v1/admin", adminRouter); diff --git a/apps/backend/src/config/defaults.ts b/apps/backend/src/config/defaults.ts index aabc6eff..07f4f4f2 100644 --- a/apps/backend/src/config/defaults.ts +++ b/apps/backend/src/config/defaults.ts @@ -8,30 +8,13 @@ export function createDefaultRuntimeConfig(projectRoot: string): RuntimeConfig { port: 3000, }, - browser: { - chromePath: "", - headless: null, - devtools: null, - keepAlive: null, - prewarm: null, - slowMoMs: null, - vncResolution: "1600x900x24", - }, - - session: { - debug: false, - }, - - ocr: { - baseUrl: "", - }, - data: { root: path.resolve(projectRoot, "data"), }, logging: { level: "info", + retentionDays: 30, }, database: { @@ -52,30 +35,6 @@ export function createDefaultRuntimeConfig(projectRoot: string): RuntimeConfig { }, platforms: { - agiso: { - appSecret: "", - tradeDetail: { - endpoint: "https://gw-api.agiso.com/aldsIdle/Order/Detail", - apiVersion: "1", - timeoutMs: 5000, - }, - autoDelivery: { - enabled: true, - endpoint: "https://gw-api.agiso.com/aldsIdle/Order/DummySend", - apiVersion: "1", - }, - messaging: { - enabled: false, - sendMessageEndpoint: - "https://gw-api.agiso.com/aldsIdle/ImMsg/SendMsg", - apiVersion: "1", - messageTemplate: - "您的订单 {platformOrderId} 已创建领取链接,请在 {expiredAt} 前完成领取:{claimUrl}", - autoDeliveryMessageTemplate: - "您的订单 {platformOrderId} 已完成自动发货,请注意查收。", - shops: {}, - }, - }, ninetyone: { userId: "", secret: "", @@ -120,8 +79,8 @@ export function createDefaultRuntimeConfig(projectRoot: string): RuntimeConfig { bindUrlProbeReferer: "https://gp.qq.com/", bindUrlProbeExtraCookie: "", clientSource: "ct-client", - deviceId: "-", - deviceType: 0, + deviceId: "08bc9d8c-fd15-48ea-bc00-8d754076cafc", + deviceType: 1, publicKeyPem: `-----BEGIN PUBLIC KEY----- MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAsSzjk+9st2f8IpYqYaM5 F7K27ZR2IvzxTr2w4zgZAS2+BRdgMvXUOITdFQT+ytYF/r3pIgTkL7MF9UQuylmb @@ -137,9 +96,5 @@ PfQTPNA++KsXtwRX9M6Re3vkTDRsutIIWKtj8jqhUbbYS3vzS8GJnAWavUFVkR15 cors: { allowedOrigins: ["*"], }, - - redeem: { - proofMode: "full", - }, }; } diff --git a/apps/backend/src/config/env-documentation.test.ts b/apps/backend/src/config/env-documentation.test.ts index dce702ae..609c84e6 100644 --- a/apps/backend/src/config/env-documentation.test.ts +++ b/apps/backend/src/config/env-documentation.test.ts @@ -21,17 +21,13 @@ const DEPLOYMENT_ONLY_ENV_NAMES = [ 'CADDY_SITE_ADDR', 'CHOKIDAR_USEPOLLING', 'NPM_CONFIG_REGISTRY', - 'OCR_IMAGE', - 'OCR_PORT', 'POSTGRES_DB', 'POSTGRES_PASSWORD', 'POSTGRES_PORT', 'POSTGRES_USER', - 'TENCENT_BROWSER_NOVNC_ENABLED', - 'TENCENT_BROWSER_NOVNC_PORT', - 'TENCENT_BROWSER_VNC_PORT', 'TZ', 'VITE_API_TARGET', + 'VITE_ALLOWED_HOSTS', ] const KNOWN_ENV_NAMES = new Set([ diff --git a/apps/backend/src/config/env-overrides.test.ts b/apps/backend/src/config/env-overrides.test.ts index f63256a5..eb1fc7d2 100644 --- a/apps/backend/src/config/env-overrides.test.ts +++ b/apps/backend/src/config/env-overrides.test.ts @@ -10,32 +10,27 @@ test('applyEnvOverrides maps typed environment values without mutating base conf const config = applyEnvOverrides(baseConfig, { PORT: '4000', - TENCENT_BROWSER_HEADLESS: 'true', - TENCENT_BROWSER_SLOW_MO: '250', - TENCENT_BROWSER_VNC_RESOLUTION: '1920x1080x24', DATA_ROOT: './tmp/backend-data', DATABASE_SSL: 'yes', + LOG_RETENTION_DAYS: '45', ADMIN_DEFAULT_USERS_JSON: '[{"username":"admin","password":"secret","role":"admin"}]', - AGISO_TRADE_DETAIL_TIMEOUT_MS: '7000', + KAQUAN91_USER_ID: 'kaquan-user', + KAQUAN91_SECRET: 'kaquan-secret', CLOUDTENTACLES_DEVICE_TYPE: '3', - TENCENT_REDEEM_PROOF_MODE: 'basic', }) assert.equal(config.server.port, 4000) - assert.equal(config.browser.headless, true) - assert.equal(config.browser.slowMoMs, 250) - assert.equal(config.browser.vncResolution, '1920x1080x24') assert.equal(config.data.root, path.resolve('./tmp/backend-data')) assert.equal(config.database.ssl, true) + assert.equal(config.logging.retentionDays, 45) assert.deepEqual(config.admin.defaultUsers, [ { username: 'admin', password: 'secret', role: 'admin' }, ]) - assert.equal(config.platforms.agiso.tradeDetail.timeoutMs, 7000) + assert.equal(config.platforms.ninetyone.userId, 'kaquan-user') + assert.equal(config.platforms.ninetyone.secret, 'kaquan-secret') assert.equal(config.platforms.cloudtentacles.deviceType, 3) - assert.equal(config.redeem.proofMode, 'basic') assert.equal(baseConfig.server.port, 3000) - assert.equal(baseConfig.browser.headless, null) assert.deepEqual(baseConfig.admin.defaultUsers, []) }) @@ -62,12 +57,14 @@ test('applyEnvOverrides ignores empty or invalid values', () => { DATABASE_SSL: 'maybe', ADMIN_DEFAULT_USERS_JSON: '{"username":"admin"}', LOG_LEVEL: ' ', + LOG_RETENTION_DAYS: 'not-a-number', }) assert.equal(config.server.port, 3000) assert.equal(config.database.ssl, false) assert.deepEqual(config.admin.defaultUsers, []) assert.equal(config.logging.level, 'info') + assert.equal(config.logging.retentionDays, 30) }) test('ENV_OVERRIDES keeps environment variable names unique', () => { @@ -80,26 +77,12 @@ function createRuntimeConfig(): RuntimeConfig { server: { port: 3000, }, - browser: { - chromePath: '', - headless: null, - devtools: null, - keepAlive: null, - prewarm: null, - slowMoMs: null, - vncResolution: '1600x900x24', - }, - session: { - debug: false, - }, - ocr: { - baseUrl: '', - }, data: { root: '/tmp/order-site', }, logging: { level: 'info', + retentionDays: 30, }, database: { url: 'postgres://postgres:postgres@127.0.0.1:5432/order_site', @@ -116,27 +99,6 @@ function createRuntimeConfig(): RuntimeConfig { defaultUsers: [], }, platforms: { - agiso: { - appSecret: '', - tradeDetail: { - endpoint: 'https://gw-api.agiso.com/aldsIdle/Order/Detail', - apiVersion: '1', - timeoutMs: 5000, - }, - autoDelivery: { - enabled: true, - endpoint: 'https://gw-api.agiso.com/aldsIdle/Order/DummySend', - apiVersion: '1', - }, - messaging: { - enabled: false, - sendMessageEndpoint: 'https://gw-api.agiso.com/aldsIdle/ImMsg/SendMsg', - apiVersion: '1', - messageTemplate: '', - autoDeliveryMessageTemplate: '', - shops: {}, - }, - }, ninetyone: { userId: '', secret: '', @@ -180,12 +142,9 @@ function createRuntimeConfig(): RuntimeConfig { bindUrlProbeExtraCookie: '', publicKeyPem: '', clientSource: 'ct-client', - deviceId: '-', - deviceType: 0, + deviceId: '08bc9d8c-fd15-48ea-bc00-8d754076cafc', + deviceType: 1, }, }, - redeem: { - proofMode: 'full', - }, } } diff --git a/apps/backend/src/config/env-overrides.ts b/apps/backend/src/config/env-overrides.ts index e47e5222..13dc2498 100644 --- a/apps/backend/src/config/env-overrides.ts +++ b/apps/backend/src/config/env-overrides.ts @@ -9,7 +9,6 @@ import { parseBoolean, parseInteger, parseJsonArray, - parseCommaSeparatedStrings, } from "./runtime-env.js"; import { deepMerge } from "./runtime-merge.js"; @@ -30,17 +29,9 @@ type EnvOverride = { export const ENV_OVERRIDES: readonly EnvOverride[] = [ integerEnv("PORT", ["server", "port"]), - stringEnv("CHROME_PATH", ["browser", "chromePath"]), - booleanEnv("TENCENT_BROWSER_HEADLESS", ["browser", "headless"]), - booleanEnv("TENCENT_BROWSER_DEVTOOLS", ["browser", "devtools"]), - booleanEnv("TENCENT_BROWSER_KEEP_ALIVE", ["browser", "keepAlive"]), - booleanEnv("TENCENT_BROWSER_PREWARM", ["browser", "prewarm"]), - integerEnv("TENCENT_BROWSER_SLOW_MO", ["browser", "slowMoMs"]), - stringEnv("TENCENT_BROWSER_VNC_RESOLUTION", ["browser", "vncResolution"]), - booleanEnv("TENCENT_SESSION_DEBUG", ["session", "debug"]), - stringEnv("OCR_BASE_URL", ["ocr", "baseUrl"]), stringEnv("DATA_ROOT", ["data", "root"], path.resolve), stringEnv("LOG_LEVEL", ["logging", "level"]), + integerEnv("LOG_RETENTION_DAYS", ["logging", "retentionDays"]), stringEnv("DATABASE_URL", ["database", "url"]), booleanEnv("DATABASE_SSL", ["database", "ssl"]), integerEnv("DATABASE_MAX_CONNECTIONS", ["database", "maxConnections"]), @@ -48,72 +39,17 @@ export const ENV_OVERRIDES: readonly EnvOverride[] = [ stringEnv("ADMIN_SESSION_SECRET", ["admin", "sessionSecret"]), integerEnv("ADMIN_SESSION_TTL_HOURS", ["admin", "sessionTtlHours"]), adminUsersJsonEnv("ADMIN_DEFAULT_USERS_JSON", ["admin", "defaultUsers"]), - stringEnv("AGISO_APP_SECRET", ["platforms", "agiso", "appSecret"]), - stringEnv("AGISO_TRADE_DETAIL_ENDPOINT", [ - "platforms", - "agiso", - "tradeDetail", - "endpoint", - ]), - stringEnv("AGISO_TRADE_DETAIL_API_VERSION", [ - "platforms", - "agiso", - "tradeDetail", - "apiVersion", - ]), - integerEnv("AGISO_TRADE_DETAIL_TIMEOUT_MS", [ - "platforms", - "agiso", - "tradeDetail", - "timeoutMs", - ]), - booleanEnv("AGISO_AUTO_DELIVERY_ENABLED", [ - "platforms", - "agiso", - "autoDelivery", - "enabled", - ]), - stringEnv("AGISO_AUTO_DELIVERY_ENDPOINT", [ - "platforms", - "agiso", - "autoDelivery", - "endpoint", - ]), - stringEnv("AGISO_AUTO_DELIVERY_API_VERSION", [ - "platforms", - "agiso", - "autoDelivery", - "apiVersion", - ]), - stringEnv("AGISO_MESSAGE_API_VERSION", [ - "platforms", - "agiso", - "messaging", - "apiVersion", - ]), - stringEnv("AGISO_SEND_MESSAGE_ENDPOINT", [ - "platforms", - "agiso", - "messaging", - "sendMessageEndpoint", - ]), - booleanEnv("AGISO_MESSAGING_ENABLED", [ - "platforms", - "agiso", - "messaging", - "enabled", - ]), - stringEnv("NINETYONE_USER_ID", ["platforms", "ninetyone", "userId"]), - stringEnv("NINETYONE_SECRET", ["platforms", "ninetyone", "secret"]), - stringEnv("NINETYONE_VERSION", ["platforms", "ninetyone", "version"]), - stringEnv("NINETYONE_SHOP_ID", ["platforms", "ninetyone", "shopId"]), - stringEnv("NINETYONE_SHOP_NAME", ["platforms", "ninetyone", "shopName"]), - integerEnv("NINETYONE_TIMESTAMP_TOLERANCE_SECONDS", [ + stringEnv("KAQUAN91_USER_ID", ["platforms", "ninetyone", "userId"]), + stringEnv("KAQUAN91_SECRET", ["platforms", "ninetyone", "secret"]), + stringEnv("KAQUAN91_VERSION", ["platforms", "ninetyone", "version"]), + stringEnv("KAQUAN91_SHOP_ID", ["platforms", "ninetyone", "shopId"]), + stringEnv("KAQUAN91_SHOP_NAME", ["platforms", "ninetyone", "shopName"]), + integerEnv("KAQUAN91_TIMESTAMP_TOLERANCE_SECONDS", [ "platforms", "ninetyone", "timestampToleranceSeconds", ]), - stringEnv("NINETYONE_CARDS_ENCODING", [ + stringEnv("KAQUAN91_CARDS_ENCODING", [ "platforms", "ninetyone", "cardsEncoding", @@ -288,7 +224,6 @@ export const ENV_OVERRIDES: readonly EnvOverride[] = [ "cloudtentacles", "deviceType", ]), - stringEnv("TENCENT_REDEEM_PROOF_MODE", ["redeem", "proofMode"]), corsOriginsEnv("CORS_ALLOWED_ORIGINS", ["cors", "allowedOrigins"]), ]; @@ -370,17 +305,6 @@ function adminUsersJsonEnv( }; } -function stringArrayEnv( - env: string, - configPath: RuntimeConfigPath -): EnvOverride { - return { - env, - path: configPath, - read: parseCommaSeparatedStrings, - }; -} - function corsOriginsEnv( env: string, configPath: RuntimeConfigPath @@ -431,5 +355,10 @@ function setConfigValue( current = current[key] as Record; } - current[configPath[configPath.length - 1]] = value; + const lastKey = configPath[configPath.length - 1]; + if (!lastKey) { + return; + } + + current[lastKey] = value; } diff --git a/apps/backend/src/config/runtime-env.ts b/apps/backend/src/config/runtime-env.ts index 699fa5ff..706dd5aa 100644 --- a/apps/backend/src/config/runtime-env.ts +++ b/apps/backend/src/config/runtime-env.ts @@ -96,18 +96,3 @@ export function parseJsonArray(rawValue: unknown): T[] | null { return null; } } - -export function parseCommaSeparatedStrings(rawValue: unknown): string[] | null { - const normalized = String(rawValue || "").trim(); - - if (!normalized) { - return null; - } - - const items = normalized - .split(",") - .map((item) => item.trim()) - .filter((item) => item.length > 0); - - return items.length > 0 ? items : null; -} diff --git a/apps/backend/src/config/runtime-validation.test.ts b/apps/backend/src/config/runtime-validation.test.ts index 6def1efc..e59d47e9 100644 --- a/apps/backend/src/config/runtime-validation.test.ts +++ b/apps/backend/src/config/runtime-validation.test.ts @@ -112,21 +112,6 @@ function createRuntimeConfig(overrides: Record = {}): RuntimeCo server: { port: 3000, }, - browser: { - chromePath: '', - headless: null, - devtools: null, - keepAlive: null, - prewarm: null, - slowMoMs: null, - vncResolution: '1600x900x24', - }, - session: { - debug: false, - }, - ocr: { - baseUrl: '', - }, data: { root: '/tmp/order-site', }, @@ -154,27 +139,6 @@ function createRuntimeConfig(overrides: Record = {}): RuntimeCo ], }, platforms: { - agiso: { - appSecret: '', - tradeDetail: { - endpoint: 'https://gw-api.agiso.com/aldsIdle/Order/Detail', - apiVersion: '1', - timeoutMs: 5000, - }, - autoDelivery: { - enabled: true, - endpoint: 'https://gw-api.agiso.com/aldsIdle/Order/DummySend', - apiVersion: '1', - }, - messaging: { - enabled: false, - sendMessageEndpoint: 'https://gw-api.agiso.com/aldsIdle/ImMsg/SendMsg', - apiVersion: '1', - messageTemplate: '', - autoDeliveryMessageTemplate: '', - shops: {}, - }, - }, ninetyone: { userId: '', secret: '', @@ -218,12 +182,9 @@ function createRuntimeConfig(overrides: Record = {}): RuntimeCo bindUrlProbeExtraCookie: '', publicKeyPem: '', clientSource: 'ct-client', - deviceId: '-', - deviceType: 0, + deviceId: '08bc9d8c-fd15-48ea-bc00-8d754076cafc', + deviceType: 1, }, }, - redeem: { - proofMode: 'full', - }, }, overrides) as RuntimeConfig } diff --git a/apps/backend/src/config/runtime-validation.ts b/apps/backend/src/config/runtime-validation.ts index 0e3fcebb..6a68eb80 100644 --- a/apps/backend/src/config/runtime-validation.ts +++ b/apps/backend/src/config/runtime-validation.ts @@ -47,7 +47,6 @@ export function validateRuntimeConfig( requireInteger(issues, 'database.maxConnections', config.database?.maxConnections, { min: 1 }) requireInteger(issues, 'orders.tokenTtlHours', config.orders?.tokenTtlHours, { min: 1 }) requireInteger(issues, 'admin.sessionTtlHours', config.admin?.sessionTtlHours, { min: 1 }) - requireInteger(issues, 'platforms.agiso.tradeDetail.timeoutMs', config.platforms?.agiso?.tradeDetail?.timeoutMs, { min: 1 }) requireInteger(issues, 'platforms.cloudtentacles.timeoutMs', config.platforms?.cloudtentacles?.timeoutMs, { min: 1 }) requireInteger(issues, 'platforms.cloudtentacles.bindUrlTtlSeconds', config.platforms?.cloudtentacles?.bindUrlTtlSeconds, { min: 1 }) requireInteger( diff --git a/apps/backend/src/db/migrations/001_fulfillment_rebuild.sql b/apps/backend/src/db/migrations/001_init.sql similarity index 58% rename from apps/backend/src/db/migrations/001_fulfillment_rebuild.sql rename to apps/backend/src/db/migrations/001_init.sql index 64bba773..b17e4efb 100644 --- a/apps/backend/src/db/migrations/001_fulfillment_rebuild.sql +++ b/apps/backend/src/db/migrations/001_init.sql @@ -6,6 +6,7 @@ CREATE TABLE IF NOT EXISTS admin_users ( password_hash TEXT NOT NULL, role TEXT NOT NULL, status TEXT NOT NULL, + session_version INTEGER NOT NULL DEFAULT 1, created_at TIMESTAMPTZ NOT NULL, updated_at TIMESTAMPTZ NOT NULL ); @@ -51,23 +52,6 @@ CREATE TABLE IF NOT EXISTS fulfillment_profile_requirements ( UNIQUE(profile_id, role_key) ); -CREATE TABLE IF NOT EXISTS sku_fulfillment_bindings ( - id BIGSERIAL PRIMARY KEY, - sku_code TEXT NOT NULL, - provider TEXT NOT NULL DEFAULT '', - platform TEXT NOT NULL DEFAULT '', - shop_id TEXT NOT NULL DEFAULT '', - profile_id BIGINT NOT NULL REFERENCES fulfillment_profiles(id) ON DELETE CASCADE, - enabled BOOLEAN NOT NULL DEFAULT TRUE, - priority INTEGER NOT NULL DEFAULT 100, - config_json JSONB NOT NULL DEFAULT '{}'::jsonb, - created_at TIMESTAMPTZ NOT NULL, - updated_at TIMESTAMPTZ NOT NULL -); - -CREATE INDEX IF NOT EXISTS idx_sku_fulfillment_bindings_lookup - ON sku_fulfillment_bindings(sku_code, provider, platform, shop_id, enabled, priority); - CREATE TABLE IF NOT EXISTS orders ( id BIGSERIAL PRIMARY KEY, provider TEXT NOT NULL, @@ -89,6 +73,11 @@ CREATE TABLE IF NOT EXISTS orders ( UNIQUE(provider, platform, shop_id, platform_order_id) ); +CREATE INDEX IF NOT EXISTS idx_orders_platform_order_lookup + ON orders(provider, platform, platform_order_id, id DESC); +CREATE INDEX IF NOT EXISTS idx_orders_created_at_desc + ON orders(created_at DESC); + CREATE TABLE IF NOT EXISTS order_items ( id BIGSERIAL PRIMARY KEY, order_id BIGINT NOT NULL REFERENCES orders(id) ON DELETE CASCADE, @@ -104,27 +93,6 @@ CREATE TABLE IF NOT EXISTS order_items ( CREATE INDEX IF NOT EXISTS idx_order_items_order_id ON order_items(order_id); CREATE INDEX IF NOT EXISTS idx_order_items_sku_code ON order_items(sku_code); -CREATE TABLE IF NOT EXISTS inventory_items ( - id BIGSERIAL PRIMARY KEY, - batch_no TEXT NOT NULL DEFAULT '', - sku_code TEXT NOT NULL, - credential_type TEXT NOT NULL, - display_value TEXT NOT NULL DEFAULT '', - payload_json JSONB NOT NULL DEFAULT '{}'::jsonb, - source_type TEXT NOT NULL DEFAULT 'static_import', - status TEXT NOT NULL DEFAULT 'available', - invalid_reason TEXT NOT NULL DEFAULT '', - metadata_json JSONB NOT NULL DEFAULT '{}'::jsonb, - consumed_at TIMESTAMPTZ, - created_at TIMESTAMPTZ NOT NULL, - updated_at TIMESTAMPTZ NOT NULL -); - -CREATE UNIQUE INDEX IF NOT EXISTS idx_inventory_items_unique_payload - ON inventory_items(sku_code, credential_type, md5(payload_json::text)); -CREATE INDEX IF NOT EXISTS idx_inventory_items_pool - ON inventory_items(sku_code, credential_type, status, id); - CREATE TABLE IF NOT EXISTS fulfillment_tasks ( id BIGSERIAL PRIMARY KEY, order_id BIGINT NOT NULL REFERENCES orders(id) ON DELETE CASCADE, @@ -139,7 +107,6 @@ CREATE TABLE IF NOT EXISTS fulfillment_tasks ( profile_id BIGINT NOT NULL REFERENCES fulfillment_profiles(id) ON DELETE RESTRICT, executor_key TEXT NOT NULL, task_status TEXT NOT NULL DEFAULT 'pending_payment', - inventory_status TEXT NOT NULL DEFAULT 'pending', delivery_status TEXT NOT NULL DEFAULT 'pending', result_code TEXT NOT NULL DEFAULT '', result_message TEXT NOT NULL DEFAULT '', @@ -162,6 +129,10 @@ CREATE TABLE IF NOT EXISTS fulfillment_tasks ( CREATE INDEX IF NOT EXISTS idx_fulfillment_tasks_status ON fulfillment_tasks(task_status, delivery_status); CREATE INDEX IF NOT EXISTS idx_fulfillment_tasks_order ON fulfillment_tasks(order_id, order_item_id); CREATE INDEX IF NOT EXISTS idx_fulfillment_tasks_claim_token ON fulfillment_tasks(claim_token); +CREATE INDEX IF NOT EXISTS idx_fulfillment_tasks_platform_order_lookup + ON fulfillment_tasks(provider, platform, platform_order_id, id DESC); +CREATE INDEX IF NOT EXISTS idx_fulfillment_tasks_created_at_desc + ON fulfillment_tasks(created_at DESC); CREATE TABLE IF NOT EXISTS claim_tokens ( id BIGSERIAL PRIMARY KEY, @@ -178,24 +149,6 @@ CREATE TABLE IF NOT EXISTS claim_tokens ( CREATE INDEX IF NOT EXISTS idx_claim_tokens_status ON claim_tokens(status, expired_at); -CREATE TABLE IF NOT EXISTS task_inventory_bindings ( - id BIGSERIAL PRIMARY KEY, - task_id BIGINT NOT NULL REFERENCES fulfillment_tasks(id) ON DELETE CASCADE, - inventory_item_id BIGINT NOT NULL REFERENCES inventory_items(id) ON DELETE RESTRICT, - role_key TEXT NOT NULL, - quantity INTEGER NOT NULL DEFAULT 1, - binding_status TEXT NOT NULL DEFAULT 'reserved', - consumed_at TIMESTAMPTZ, - released_at TIMESTAMPTZ, - metadata_json JSONB NOT NULL DEFAULT '{}'::jsonb, - created_at TIMESTAMPTZ NOT NULL, - updated_at TIMESTAMPTZ NOT NULL, - UNIQUE(task_id, role_key, inventory_item_id) -); - -CREATE INDEX IF NOT EXISTS idx_task_inventory_bindings_task ON task_inventory_bindings(task_id); -CREATE INDEX IF NOT EXISTS idx_task_inventory_bindings_inventory ON task_inventory_bindings(inventory_item_id); - CREATE TABLE IF NOT EXISTS task_events ( id BIGSERIAL PRIMARY KEY, task_id BIGINT NOT NULL REFERENCES fulfillment_tasks(id) ON DELETE CASCADE, @@ -206,10 +159,10 @@ CREATE TABLE IF NOT EXISTS task_events ( CREATE INDEX IF NOT EXISTS idx_task_events_task_created_at ON task_events(task_id, created_at DESC); -CREATE TABLE IF NOT EXISTS tencent_browser_contexts ( +CREATE TABLE IF NOT EXISTS task_runtime_contexts ( id BIGSERIAL PRIMARY KEY, task_id BIGINT NOT NULL UNIQUE REFERENCES fulfillment_tasks(id) ON DELETE CASCADE, - browser_session_id TEXT NOT NULL DEFAULT '', + runtime_session_id TEXT NOT NULL DEFAULT '', login_type TEXT NOT NULL DEFAULT '', nickname TEXT NOT NULL DEFAULT '', role_id TEXT NOT NULL DEFAULT '', @@ -223,50 +176,27 @@ CREATE TABLE IF NOT EXISTS tencent_browser_contexts ( updated_at TIMESTAMPTZ NOT NULL ); -CREATE TABLE IF NOT EXISTS message_deliveries ( - id BIGSERIAL PRIMARY KEY, - provider TEXT NOT NULL DEFAULT '', - platform TEXT NOT NULL DEFAULT '', - shop_id TEXT NOT NULL DEFAULT '', - shop_name TEXT NOT NULL DEFAULT '', - channel TEXT NOT NULL, - order_id BIGINT REFERENCES orders(id) ON DELETE SET NULL, - task_id BIGINT REFERENCES fulfillment_tasks(id) ON DELETE SET NULL, - platform_order_id TEXT NOT NULL DEFAULT '', - recipient_key TEXT NOT NULL DEFAULT '', - message_content TEXT NOT NULL DEFAULT '', - claim_url TEXT NOT NULL DEFAULT '', - status TEXT NOT NULL DEFAULT 'pending', - request_url TEXT NOT NULL DEFAULT '', - request_headers_json JSONB NOT NULL DEFAULT '{}'::jsonb, - request_body_json JSONB NOT NULL DEFAULT '{}'::jsonb, - response_status INTEGER NOT NULL DEFAULT 0, - response_json JSONB NOT NULL DEFAULT '{}'::jsonb, - error_message TEXT NOT NULL DEFAULT '', - sent_at TIMESTAMPTZ, - created_at TIMESTAMPTZ NOT NULL, +CREATE TABLE IF NOT EXISTS kuaishou_cloud_task_states ( + task_id BIGINT PRIMARY KEY REFERENCES fulfillment_tasks(id) ON DELETE CASCADE, + ticket_status TEXT NOT NULL DEFAULT 'pending', + ticket_code_masked TEXT NOT NULL DEFAULT '', + bind_url TEXT NOT NULL DEFAULT '', + vn_phone TEXT NOT NULL DEFAULT '', + role_name TEXT NOT NULL DEFAULT '', + role_id TEXT NOT NULL DEFAULT '', + dispatch_status TEXT NOT NULL DEFAULT 'pending', + consume_status TEXT NOT NULL DEFAULT 'pending', + source_key TEXT NOT NULL DEFAULT '', updated_at TIMESTAMPTZ NOT NULL ); -CREATE INDEX IF NOT EXISTS idx_message_deliveries_task_channel ON message_deliveries(task_id, channel, created_at DESC); - -CREATE TABLE IF NOT EXISTS webhook_events ( - id BIGSERIAL PRIMARY KEY, - provider TEXT NOT NULL DEFAULT '', - platform TEXT NOT NULL DEFAULT '', - shop_id TEXT NOT NULL DEFAULT '', - shop_name TEXT NOT NULL DEFAULT '', - event_type TEXT NOT NULL, - event_key TEXT NOT NULL, - signature_valid BOOLEAN NOT NULL DEFAULT FALSE, - headers_json JSONB NOT NULL DEFAULT '{}'::jsonb, - query_json JSONB NOT NULL DEFAULT '{}'::jsonb, - body_json JSONB NOT NULL DEFAULT '{}'::jsonb, - processed BOOLEAN NOT NULL DEFAULT FALSE, - process_error TEXT NOT NULL DEFAULT '', - related_order_id BIGINT REFERENCES orders(id) ON DELETE SET NULL, - created_at TIMESTAMPTZ NOT NULL -); - -CREATE INDEX IF NOT EXISTS idx_webhook_events_lookup ON webhook_events(provider, platform, shop_id, event_key); -CREATE INDEX IF NOT EXISTS idx_webhook_events_processed ON webhook_events(processed, created_at DESC); +CREATE INDEX IF NOT EXISTS idx_kuaishou_cloud_task_states_role_id + ON kuaishou_cloud_task_states(role_id); +CREATE INDEX IF NOT EXISTS idx_kuaishou_cloud_task_states_source_key + ON kuaishou_cloud_task_states(source_key); +CREATE INDEX IF NOT EXISTS idx_kuaishou_cloud_task_states_dispatch_status + ON kuaishou_cloud_task_states(dispatch_status); +CREATE INDEX IF NOT EXISTS idx_kuaishou_cloud_task_states_consume_status + ON kuaishou_cloud_task_states(consume_status); +CREATE INDEX IF NOT EXISTS idx_kuaishou_cloud_task_states_updated_at_desc + ON kuaishou_cloud_task_states(updated_at DESC); diff --git a/apps/backend/src/db/migrations/002_product_match_rules.sql b/apps/backend/src/db/migrations/002_product_match_rules.sql deleted file mode 100644 index b500c726..00000000 --- a/apps/backend/src/db/migrations/002_product_match_rules.sql +++ /dev/null @@ -1,30 +0,0 @@ -CREATE TABLE IF NOT EXISTS product_match_rules ( - id BIGSERIAL PRIMARY KEY, - provider TEXT NOT NULL DEFAULT '', - platform TEXT NOT NULL DEFAULT '', - shop_id TEXT NOT NULL DEFAULT '', - external_item_id TEXT NOT NULL DEFAULT '', - external_sku_code TEXT NOT NULL DEFAULT '', - external_sku_name TEXT NOT NULL DEFAULT '', - external_sku_name_normalized TEXT NOT NULL DEFAULT '', - resolved_sku_code TEXT NOT NULL, - enabled BOOLEAN NOT NULL DEFAULT TRUE, - priority INTEGER NOT NULL DEFAULT 100, - config_json JSONB NOT NULL DEFAULT '{}'::jsonb, - created_at TIMESTAMPTZ NOT NULL, - updated_at TIMESTAMPTZ NOT NULL -); - -CREATE UNIQUE INDEX IF NOT EXISTS idx_product_match_rules_unique - ON product_match_rules( - provider, - platform, - shop_id, - external_item_id, - external_sku_code, - external_sku_name_normalized, - resolved_sku_code - ); - -CREATE INDEX IF NOT EXISTS idx_product_match_rules_lookup - ON product_match_rules(provider, platform, shop_id, enabled, priority, id); diff --git a/apps/backend/src/db/migrations/003_message_delivery_dedupe_index.sql b/apps/backend/src/db/migrations/003_message_delivery_dedupe_index.sql deleted file mode 100644 index a201d627..00000000 --- a/apps/backend/src/db/migrations/003_message_delivery_dedupe_index.sql +++ /dev/null @@ -1,3 +0,0 @@ -CREATE INDEX IF NOT EXISTS idx_message_deliveries_dedupe_success -ON message_deliveries(provider, platform, shop_id, platform_order_id, channel, claim_url, created_at DESC) -WHERE status = 'success'; diff --git a/apps/backend/src/db/migrations/004_inventory_group_bindings.sql b/apps/backend/src/db/migrations/004_inventory_group_bindings.sql deleted file mode 100644 index 6a3a7a27..00000000 --- a/apps/backend/src/db/migrations/004_inventory_group_bindings.sql +++ /dev/null @@ -1,17 +0,0 @@ -ALTER TABLE inventory_items - ADD COLUMN IF NOT EXISTS inventory_group_code TEXT NOT NULL DEFAULT ''; - -CREATE INDEX IF NOT EXISTS idx_inventory_items_group_lookup - ON inventory_items(sku_code, credential_type, inventory_group_code, status, id); - -CREATE TABLE IF NOT EXISTS admin_user_inventory_group_bindings ( - id BIGSERIAL PRIMARY KEY, - admin_user_id BIGINT NOT NULL REFERENCES admin_users(id) ON DELETE CASCADE, - inventory_group_code TEXT NOT NULL, - created_at TIMESTAMPTZ NOT NULL, - updated_at TIMESTAMPTZ NOT NULL, - UNIQUE(admin_user_id, inventory_group_code) -); - -CREATE INDEX IF NOT EXISTS idx_admin_user_inventory_group_bindings_user_id - ON admin_user_inventory_group_bindings(admin_user_id, inventory_group_code); diff --git a/apps/backend/src/domain/task-status.test.ts b/apps/backend/src/domain/task-status.test.ts new file mode 100644 index 00000000..0b29282f --- /dev/null +++ b/apps/backend/src/domain/task-status.test.ts @@ -0,0 +1,27 @@ +import test from 'node:test' +import assert from 'node:assert/strict' + +import { + TASK_STATUS, + canRedeemKuaishouCloudClaimStatus, + canTaskTransition, + isClaimInactiveTaskStatus, + isOpen91FailedTaskStatus, + shouldEnsureKuaishouCloudClaimLink, +} from './task-status.js' + +test('任务状态机声明快手 Cloud 主流程跳转', () => { + assert.equal(canTaskTransition(TASK_STATUS.PENDING_BINDING_PREPARE, TASK_STATUS.WAITING_BINDING), true) + assert.equal(canTaskTransition(TASK_STATUS.WAITING_BINDING, TASK_STATUS.ROLE_CONFIRMED), true) + assert.equal(canTaskTransition(TASK_STATUS.ROLE_CONFIRMED, TASK_STATUS.REDEEMING), true) + assert.equal(canTaskTransition(TASK_STATUS.REDEEMING, TASK_STATUS.DISPATCHED_PENDING_RETURN), true) + assert.equal(canTaskTransition(TASK_STATUS.DISPATCHED_PENDING_RETURN, TASK_STATUS.COMPLETED), true) +}) + +test('任务状态机收敛领取和 91 查询的业务判断', () => { + assert.equal(shouldEnsureKuaishouCloudClaimLink(TASK_STATUS.PENDING_BINDING_PREPARE), true) + assert.equal(canRedeemKuaishouCloudClaimStatus(TASK_STATUS.ROLE_CONFIRMED), true) + assert.equal(canRedeemKuaishouCloudClaimStatus(TASK_STATUS.WAITING_BINDING), false) + assert.equal(isClaimInactiveTaskStatus(TASK_STATUS.MANUAL_REVIEW), true) + assert.equal(isOpen91FailedTaskStatus(TASK_STATUS.CLOSED), true) +}) diff --git a/apps/backend/src/domain/task-status.ts b/apps/backend/src/domain/task-status.ts new file mode 100644 index 00000000..50683d4a --- /dev/null +++ b/apps/backend/src/domain/task-status.ts @@ -0,0 +1,326 @@ +export const TASK_STATUS = { + PENDING_PAYMENT: 'pending_payment', + PAID: 'paid', + LINK_GENERATED: 'link_generated', + CLAIMED: 'claimed', + PENDING_BINDING_PREPARE: 'pending_binding_prepare', + WAITING_BINDING: 'waiting_binding', + ROLE_CONFIRMED: 'role_confirmed', + REDEEMING: 'redeeming', + DISPATCHED_PENDING_RETURN: 'dispatched_pending_return', + COMPLETED: 'completed', + REDEEMED: 'redeemed', + RETRY_PENDING: 'retry_pending', + MANUAL_REVIEW: 'manual_review', + FAILED: 'failed', + EXPIRED: 'expired', + CLOSED: 'closed', +} as const + +export type KnownTaskStatus = (typeof TASK_STATUS)[keyof typeof TASK_STATUS] +export type TaskStatus = KnownTaskStatus | (string & {}) + +const TASK_TRANSITIONS: Record = { + [TASK_STATUS.PENDING_PAYMENT]: [ + TASK_STATUS.PAID, + TASK_STATUS.PENDING_BINDING_PREPARE, + TASK_STATUS.MANUAL_REVIEW, + TASK_STATUS.CLOSED, + ], + [TASK_STATUS.PAID]: [ + TASK_STATUS.LINK_GENERATED, + TASK_STATUS.PENDING_BINDING_PREPARE, + TASK_STATUS.MANUAL_REVIEW, + TASK_STATUS.CLOSED, + ], + [TASK_STATUS.LINK_GENERATED]: [ + TASK_STATUS.CLAIMED, + TASK_STATUS.WAITING_BINDING, + TASK_STATUS.ROLE_CONFIRMED, + TASK_STATUS.RETRY_PENDING, + TASK_STATUS.MANUAL_REVIEW, + TASK_STATUS.EXPIRED, + TASK_STATUS.CLOSED, + ], + [TASK_STATUS.CLAIMED]: [ + TASK_STATUS.WAITING_BINDING, + TASK_STATUS.ROLE_CONFIRMED, + TASK_STATUS.RETRY_PENDING, + TASK_STATUS.MANUAL_REVIEW, + TASK_STATUS.EXPIRED, + TASK_STATUS.CLOSED, + ], + [TASK_STATUS.PENDING_BINDING_PREPARE]: [ + TASK_STATUS.WAITING_BINDING, + TASK_STATUS.RETRY_PENDING, + TASK_STATUS.MANUAL_REVIEW, + TASK_STATUS.EXPIRED, + TASK_STATUS.CLOSED, + ], + [TASK_STATUS.WAITING_BINDING]: [ + TASK_STATUS.ROLE_CONFIRMED, + TASK_STATUS.REDEEMING, + TASK_STATUS.RETRY_PENDING, + TASK_STATUS.MANUAL_REVIEW, + TASK_STATUS.EXPIRED, + TASK_STATUS.CLOSED, + ], + [TASK_STATUS.ROLE_CONFIRMED]: [ + TASK_STATUS.WAITING_BINDING, + TASK_STATUS.REDEEMING, + TASK_STATUS.RETRY_PENDING, + TASK_STATUS.MANUAL_REVIEW, + TASK_STATUS.EXPIRED, + TASK_STATUS.CLOSED, + ], + [TASK_STATUS.REDEEMING]: [ + TASK_STATUS.DISPATCHED_PENDING_RETURN, + TASK_STATUS.COMPLETED, + TASK_STATUS.REDEEMED, + TASK_STATUS.RETRY_PENDING, + TASK_STATUS.MANUAL_REVIEW, + ], + [TASK_STATUS.DISPATCHED_PENDING_RETURN]: [ + TASK_STATUS.COMPLETED, + TASK_STATUS.REDEEMED, + TASK_STATUS.MANUAL_REVIEW, + ], + [TASK_STATUS.RETRY_PENDING]: [ + TASK_STATUS.PENDING_BINDING_PREPARE, + TASK_STATUS.WAITING_BINDING, + TASK_STATUS.ROLE_CONFIRMED, + TASK_STATUS.REDEEMING, + TASK_STATUS.MANUAL_REVIEW, + TASK_STATUS.CLOSED, + ], + [TASK_STATUS.MANUAL_REVIEW]: [ + TASK_STATUS.PENDING_BINDING_PREPARE, + TASK_STATUS.WAITING_BINDING, + TASK_STATUS.ROLE_CONFIRMED, + TASK_STATUS.REDEEMING, + TASK_STATUS.DISPATCHED_PENDING_RETURN, + TASK_STATUS.COMPLETED, + TASK_STATUS.REDEEMED, + TASK_STATUS.CLOSED, + ], + [TASK_STATUS.FAILED]: [TASK_STATUS.RETRY_PENDING, TASK_STATUS.MANUAL_REVIEW, TASK_STATUS.CLOSED], + [TASK_STATUS.COMPLETED]: [], + [TASK_STATUS.REDEEMED]: [], + [TASK_STATUS.EXPIRED]: [], + [TASK_STATUS.CLOSED]: [], +} + +const FINAL_STATUSES = new Set([ + TASK_STATUS.COMPLETED, + TASK_STATUS.REDEEMED, + TASK_STATUS.EXPIRED, + TASK_STATUS.CLOSED, +]) + +const OPEN_91_FAILED_STATUSES = new Set([ + TASK_STATUS.FAILED, + TASK_STATUS.MANUAL_REVIEW, + TASK_STATUS.CLOSED, +]) + +const CLAIM_INACTIVE_STATUSES = new Set([ + TASK_STATUS.COMPLETED, + TASK_STATUS.REDEEMED, + TASK_STATUS.MANUAL_REVIEW, + TASK_STATUS.FAILED, + TASK_STATUS.EXPIRED, + TASK_STATUS.CLOSED, +]) + +const PAID_PREPARATION_STABLE_STATUSES = new Set([ + TASK_STATUS.LINK_GENERATED, + TASK_STATUS.CLAIMED, + TASK_STATUS.ROLE_CONFIRMED, + TASK_STATUS.REDEEMING, + TASK_STATUS.DISPATCHED_PENDING_RETURN, + TASK_STATUS.COMPLETED, + TASK_STATUS.REDEEMED, + TASK_STATUS.EXPIRED, + TASK_STATUS.CLOSED, +]) + +const KUAISHOU_CLOUD_CLAIM_LINK_STATUSES = new Set([ + TASK_STATUS.PENDING_BINDING_PREPARE, + TASK_STATUS.WAITING_BINDING, + TASK_STATUS.DISPATCHED_PENDING_RETURN, + TASK_STATUS.COMPLETED, + TASK_STATUS.MANUAL_REVIEW, + TASK_STATUS.FAILED, +]) + +const KUAISHOU_CLOUD_ROLE_CONFIRM_SETTLED_STATUSES = new Set([ + TASK_STATUS.ROLE_CONFIRMED, + TASK_STATUS.REDEEMING, + TASK_STATUS.DISPATCHED_PENDING_RETURN, + TASK_STATUS.COMPLETED, + TASK_STATUS.REDEEMED, + TASK_STATUS.MANUAL_REVIEW, +]) + +const KUAISHOU_CLOUD_REDEEM_SETTLED_STATUSES = new Set([ + TASK_STATUS.REDEEMING, + TASK_STATUS.DISPATCHED_PENDING_RETURN, + TASK_STATUS.COMPLETED, + TASK_STATUS.REDEEMED, + TASK_STATUS.MANUAL_REVIEW, +]) + +const CLAIM_LINK_REGENERATABLE_STATUSES = new Set([ + TASK_STATUS.LINK_GENERATED, + TASK_STATUS.CLAIMED, + TASK_STATUS.ROLE_CONFIRMED, + TASK_STATUS.RETRY_PENDING, + TASK_STATUS.MANUAL_REVIEW, +]) + +const KUAISHOU_CLOUD_RESULT_STATUSES = new Set([ + TASK_STATUS.DISPATCHED_PENDING_RETURN, + TASK_STATUS.COMPLETED, + TASK_STATUS.REDEEMED, + TASK_STATUS.MANUAL_REVIEW, + TASK_STATUS.FAILED, +]) + +const FULFILLMENT_COMPLETED_STATUSES = new Set([ + TASK_STATUS.DISPATCHED_PENDING_RETURN, + TASK_STATUS.COMPLETED, + TASK_STATUS.REDEEMED, +]) + +const RETRYABLE_TASK_STATUSES = new Set([ + TASK_STATUS.RETRY_PENDING, + TASK_STATUS.MANUAL_REVIEW, +]) + +const KUAISHOU_CLOUD_PREPARABLE_STATUSES = new Set([ + TASK_STATUS.PENDING_BINDING_PREPARE, + TASK_STATUS.MANUAL_REVIEW, + TASK_STATUS.FAILED, +]) + +const KUAISHOU_CLOUD_REBINDABLE_STATUSES = new Set([ + TASK_STATUS.WAITING_BINDING, + TASK_STATUS.ROLE_CONFIRMED, + TASK_STATUS.MANUAL_REVIEW, + TASK_STATUS.RETRY_PENDING, +]) + +export function normalizeTaskStatus(value: unknown): TaskStatus { + return String(value || '').trim() as TaskStatus +} + +export function canTaskTransition(from: unknown, to: unknown): boolean { + const current = normalizeTaskStatus(from) as KnownTaskStatus + const next = normalizeTaskStatus(to) as KnownTaskStatus + if (!current || !next || current === next) { + return Boolean(current && next) + } + + return Boolean(TASK_TRANSITIONS[current]?.includes(next)) +} + +export function getTaskNextStatuses(status: unknown): readonly KnownTaskStatus[] { + return TASK_TRANSITIONS[normalizeTaskStatus(status) as KnownTaskStatus] || [] +} + +export function isTaskFinalStatus(status: unknown): boolean { + return FINAL_STATUSES.has(normalizeTaskStatus(status)) +} + +export function isOpen91FailedTaskStatus(status: unknown): boolean { + return OPEN_91_FAILED_STATUSES.has(normalizeTaskStatus(status)) +} + +export function isClaimInactiveTaskStatus(status: unknown): boolean { + return CLAIM_INACTIVE_STATUSES.has(normalizeTaskStatus(status)) +} + +export function isPaidPreparationStableStatus(status: unknown): boolean { + return PAID_PREPARATION_STABLE_STATUSES.has(normalizeTaskStatus(status)) +} + +export function shouldEnsureKuaishouCloudClaimLink(status: unknown): boolean { + return KUAISHOU_CLOUD_CLAIM_LINK_STATUSES.has(normalizeTaskStatus(status)) +} + +export function isKuaishouCloudRoleConfirmSettledStatus(status: unknown): boolean { + return KUAISHOU_CLOUD_ROLE_CONFIRM_SETTLED_STATUSES.has(normalizeTaskStatus(status)) +} + +export function isKuaishouCloudRedeemSettledStatus(status: unknown): boolean { + return KUAISHOU_CLOUD_REDEEM_SETTLED_STATUSES.has(normalizeTaskStatus(status)) +} + +export function canRedeemKuaishouCloudClaimStatus(status: unknown): boolean { + return normalizeTaskStatus(status) === TASK_STATUS.ROLE_CONFIRMED +} + +export function canRegenerateClaimLinkStatus(status: unknown): boolean { + return CLAIM_LINK_REGENERATABLE_STATUSES.has(normalizeTaskStatus(status)) +} + +export function hasKuaishouCloudRedeemResultStatus(status: unknown): boolean { + return KUAISHOU_CLOUD_RESULT_STATUSES.has(normalizeTaskStatus(status)) +} + +export function isTaskFulfillmentCompletedStatus(status: unknown): boolean { + return FULFILLMENT_COMPLETED_STATUSES.has(normalizeTaskStatus(status)) +} + +export function canRetryTaskStatus(status: unknown): boolean { + return RETRYABLE_TASK_STATUSES.has(normalizeTaskStatus(status)) +} + +export function canMarkManualReviewTaskStatus(status: unknown): boolean { + const normalized = normalizeTaskStatus(status) + return normalized !== TASK_STATUS.MANUAL_REVIEW && !isTaskFinalStatus(normalized) +} + +export function canCompleteManualDispatchTaskStatus(status: unknown): boolean { + return !isTaskFinalStatus(status) +} + +export function canPrepareKuaishouCloudFulfillmentStatus(status: unknown): boolean { + return KUAISHOU_CLOUD_PREPARABLE_STATUSES.has(normalizeTaskStatus(status)) +} + +export function canRebindKuaishouCloudRoleStatus(status: unknown): boolean { + return KUAISHOU_CLOUD_REBINDABLE_STATUSES.has(normalizeTaskStatus(status)) +} + +export function canDispatchKuaishouCloudFulfillmentStatus(status: unknown): boolean { + return normalizeTaskStatus(status) === TASK_STATUS.WAITING_BINDING +} + +export function canReturnKuaishouCloudFulfillmentStatus(status: unknown): boolean { + return normalizeTaskStatus(status) === TASK_STATUS.DISPATCHED_PENDING_RETURN +} + +export function resolveInitialPaidTaskStatus( + profile: + | { + requires_claim?: boolean + executor_key?: unknown + } + | null + | undefined, +): TaskStatus { + if (Boolean(profile?.requires_claim)) { + return TASK_STATUS.PAID + } + + if (String(profile?.executor_key || '').trim() === 'kuaishou_ct_assisted') { + return TASK_STATUS.PENDING_BINDING_PREPARE + } + + if (String(profile?.executor_key || '').trim() === 'manual_dispatch') { + return TASK_STATUS.MANUAL_REVIEW + } + + return TASK_STATUS.PAID +} diff --git a/apps/backend/src/index.ts b/apps/backend/src/index.ts index f0bbbae0..ffb144c5 100644 --- a/apps/backend/src/index.ts +++ b/apps/backend/src/index.ts @@ -20,6 +20,7 @@ try { logError("[startup]", "运行时配置校验失败,服务停止启动", error); process.exit(1); } + const app = createApp({ startupState, isShutdownStarted: () => shutdownController?.isShutdownStarted() || false, @@ -27,7 +28,10 @@ const app = createApp({ }); const server = app.listen(port, host, () => { - logInfo("[startup]", `order-site-backend listening on http://${host}:${port}`); + logInfo( + "[startup]", + `order-site-backend kuaishou-lite listening on http://${host}:${port}` + ); void bootstrapCoreServices( startupState, () => shutdownController?.isShutdownStarted() || false diff --git a/apps/backend/src/middleware/access-log.ts b/apps/backend/src/middleware/access-log.ts index cd3caa36..feb0ce56 100644 --- a/apps/backend/src/middleware/access-log.ts +++ b/apps/backend/src/middleware/access-log.ts @@ -43,7 +43,7 @@ function resolveRequestId(req: Request): string { } function shouldSkipAccessLog(originalUrl: string, statusCode: number): boolean { - const pathname = String(originalUrl || "").split("?")[0]; + const pathname = String(originalUrl || "").split("?")[0] || ""; return ( ["/health", "/health/live", "/health/ready"].includes(pathname) && Number(statusCode) < 400 diff --git a/apps/backend/src/middleware/rate-limit.test.ts b/apps/backend/src/middleware/rate-limit.test.ts new file mode 100644 index 00000000..9da2755f --- /dev/null +++ b/apps/backend/src/middleware/rate-limit.test.ts @@ -0,0 +1,116 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import type { NextFunction, Request, Response } from "express"; + +import { + createRateLimitMiddleware, + resetRateLimitBucketsForTest, +} from "./rate-limit.js"; + +test("createRateLimitMiddleware allows requests within the window", () => { + resetRateLimitBucketsForTest(); + + const limiter = createRateLimitMiddleware({ + scope: "test:allow", + windowMs: 60_000, + max: 2, + }); + const req = createMockRequest(); + const res = createMockResponse(); + let nextCount = 0; + const next: NextFunction = () => { + nextCount += 1; + }; + + limiter(req, res, next); + limiter(req, res, next); + + assert.equal(nextCount, 2); + assert.equal(res.statusCode, 200); +}); + +test("createRateLimitMiddleware blocks requests over the limit", () => { + resetRateLimitBucketsForTest(); + + const limiter = createRateLimitMiddleware({ + scope: "test:block", + windowMs: 60_000, + max: 1, + }); + const req = createMockRequest(); + const res = createMockResponse(); + let nextCount = 0; + const next: NextFunction = () => { + nextCount += 1; + }; + + limiter(req, res, next); + limiter(req, res, next); + + assert.equal(nextCount, 1); + assert.equal(res.statusCode, 429); + assert.equal(res.headers["Retry-After"], "60"); + assert.equal(res.body.errorCode, "rate_limited"); +}); + +test("createRateLimitMiddleware supports custom limit responses", () => { + resetRateLimitBucketsForTest(); + + const limiter = createRateLimitMiddleware({ + scope: "test:custom", + windowMs: 60_000, + max: 1, + onLimit: (_req, res) => { + res.status(200).json({ code: 429, message: "limited" }); + }, + }); + const req = createMockRequest(); + const res = createMockResponse(); + + limiter(req, res, () => {}); + limiter(req, res, () => {}); + + assert.equal(res.statusCode, 200); + assert.deepEqual(res.body, { code: 429, message: "limited" }); +}); + +function createMockRequest(): Request { + return { + ip: "127.0.0.1", + originalUrl: "/test", + headers: {}, + socket: { + remoteAddress: "127.0.0.1", + }, + } as Request; +} + +function createMockResponse(): Response & { + statusCode: number; + headers: Record; + body: any; +} { + const res = { + statusCode: 200, + headers: {} as Record, + body: null as any, + setHeader(name: string, value: string) { + this.headers[name] = value; + return this; + }, + status(statusCode: number) { + this.statusCode = statusCode; + return this; + }, + json(body: any) { + this.body = body; + return this; + }, + }; + + return res as Response & { + statusCode: number; + headers: Record; + body: any; + }; +} diff --git a/apps/backend/src/middleware/rate-limit.ts b/apps/backend/src/middleware/rate-limit.ts new file mode 100644 index 00000000..2b9a2d41 --- /dev/null +++ b/apps/backend/src/middleware/rate-limit.ts @@ -0,0 +1,122 @@ +import type { NextFunction, Request, Response } from "express"; + +import { buildErrorPayload, createHttpError } from "../utils/http.js"; +import { logWarn } from "../utils/logger.js"; + +type RateLimitKeyResolver = (req: Request) => string; + +type RateLimitExceededContext = { + scope: string; + retryAfterSeconds: number; +}; + +type RateLimitOptions = { + scope: string; + windowMs: number; + max: number; + key?: RateLimitKeyResolver; + onLimit?: (req: Request, res: Response, context: RateLimitExceededContext) => void; +}; + +type RateLimitBucket = { + count: number; + resetAt: number; +}; + +const buckets = new Map(); +let lastCleanupAt = 0; + +export function createRateLimitMiddleware({ + scope, + windowMs, + max, + key = defaultRateLimitKey, + onLimit, +}: RateLimitOptions) { + const normalizedScope = String(scope || "default").trim() || "default"; + const normalizedWindowMs = Math.max(1000, Number(windowMs || 0)); + const normalizedMax = Math.max(1, Number(max || 0)); + + return (req: Request, res: Response, next: NextFunction): void => { + const now = Date.now(); + cleanupExpiredBuckets(now); + + const bucketKey = `${normalizedScope}:${key(req)}`; + const current = buckets.get(bucketKey); + const bucket = current && current.resetAt > now + ? current + : { count: 0, resetAt: now + normalizedWindowMs }; + + bucket.count += 1; + buckets.set(bucketKey, bucket); + + if (bucket.count <= normalizedMax) { + next(); + return; + } + + const retryAfterSeconds = Math.max(1, Math.ceil((bucket.resetAt - now) / 1000)); + res.setHeader("Retry-After", String(retryAfterSeconds)); + + logWarn("[rate-limit]", "请求触发限流", { + scope: normalizedScope, + ip: req.ip, + originalUrl: req.originalUrl, + retryAfterSeconds, + }); + + if (onLimit) { + onLimit(req, res, { + scope: normalizedScope, + retryAfterSeconds, + }); + return; + } + + const error = createHttpError("请求过于频繁,请稍后再试", { + statusCode: 429, + errorCode: "rate_limited", + }); + res.status(429).json(buildErrorPayload(error, "请求过于频繁,请稍后再试")); + }; +} + +export function resetRateLimitBucketsForTest(): void { + buckets.clear(); + lastCleanupAt = 0; +} + +export function getBodyFieldRateLimitKey(fieldName: string): RateLimitKeyResolver { + return (req) => [defaultRateLimitKey(req), normalizeKeyPart(req.body?.[fieldName])].join(":"); +} + +export function getParamRateLimitKey(paramName: string): RateLimitKeyResolver { + return (req) => [defaultRateLimitKey(req), normalizeKeyPart(req.params?.[paramName])].join(":"); +} + +function defaultRateLimitKey(req: Request): string { + return normalizeKeyPart( + req.ip || + String(req.headers["x-forwarded-for"] || "").split(",")[0] || + req.socket.remoteAddress || + "unknown", + ); +} + +function normalizeKeyPart(value: unknown): string { + const normalized = String(value || "").trim().toLowerCase(); + return normalized || "unknown"; +} + +function cleanupExpiredBuckets(now: number): void { + if (now - lastCleanupAt < 60_000) { + return; + } + + lastCleanupAt = now; + for (const [key, bucket] of buckets.entries()) { + if (bucket.resetAt <= now) { + buckets.delete(key); + } + } +} diff --git a/apps/backend/src/repositories/admin-user-repo.ts b/apps/backend/src/repositories/admin-user-repo.ts index db686581..b8f0f99d 100644 --- a/apps/backend/src/repositories/admin-user-repo.ts +++ b/apps/backend/src/repositories/admin-user-repo.ts @@ -1,4 +1,4 @@ -import { query, withTransaction } from '../db/client.js' +import { query } from '../db/client.js' type AdminUserRow = { id: number @@ -6,9 +6,9 @@ type AdminUserRow = { password_hash: string role: string status: string + session_version: number created_at: string updated_at: string - inventory_group_codes: string[] } type AdminUserCreateInput = { @@ -22,7 +22,7 @@ type AdminUserCreateInput = { type AdminUserPatch = Partial> type AdminUserListInput = { @@ -39,15 +39,8 @@ type AdminUserListResult = { } const ADMIN_USER_SELECT = ` - SELECT - au.*, - COALESCE(bindings.inventory_group_codes, ARRAY[]::text[]) AS inventory_group_codes + SELECT au.* FROM admin_users au - LEFT JOIN LATERAL ( - SELECT ARRAY_AGG(auigb.inventory_group_code ORDER BY auigb.inventory_group_code) AS inventory_group_codes - FROM admin_user_inventory_group_bindings auigb - WHERE auigb.admin_user_id = au.id - ) bindings ON TRUE ` export async function getAdminUserById(userId: number | string): Promise { @@ -74,9 +67,10 @@ export async function createAdminUser(input: AdminUserCreateInput): Promise { ) return Number(result.rows[0]?.total || 0) } - -export async function replaceAdminUserInventoryGroupBindings( - userId: number | string, - inventoryGroupCodes: unknown[] = [], - timestamp: string, -): Promise { - const normalizedUserId = Number(userId) - const normalizedCodes = Array.from(new Set((Array.isArray(inventoryGroupCodes) ? inventoryGroupCodes : []) - .map((value) => String(value || '').trim()) - .filter(Boolean))) - - await withTransaction(async (client) => { - await client.query( - 'DELETE FROM admin_user_inventory_group_bindings WHERE admin_user_id = $1', - [normalizedUserId], - ) - - for (const inventoryGroupCode of normalizedCodes) { - await client.query( - ` - INSERT INTO admin_user_inventory_group_bindings ( - admin_user_id, - inventory_group_code, - created_at, - updated_at - ) VALUES ($1, $2, $3, $4) - ON CONFLICT (admin_user_id, inventory_group_code) DO UPDATE - SET updated_at = EXCLUDED.updated_at - `, - [normalizedUserId, inventoryGroupCode, timestamp, timestamp], - ) - } - }) - - return getAdminUserById(normalizedUserId) -} diff --git a/apps/backend/src/repositories/fulfillment-profile-repo.ts b/apps/backend/src/repositories/fulfillment-profile-repo.ts index f1027ace..901fc01f 100644 --- a/apps/backend/src/repositories/fulfillment-profile-repo.ts +++ b/apps/backend/src/repositories/fulfillment-profile-repo.ts @@ -27,28 +27,6 @@ type FulfillmentProfileRequirementRow = { updated_at: string } -type SkuFulfillmentBindingRow = { - id: number - sku_code: string - provider: string - platform: string - shop_id: string - profile_id: number - enabled: boolean - priority: number - config_json: string | Record - created_at: string - updated_at: string - profile_key?: string - name?: string - profile_name?: string - executor_key?: string - requires_claim?: boolean - auto_dispatch?: boolean - inventory_strategy?: string - profile_config_json?: string | Record -} - type FulfillmentProfileUpsertInput = { profileKey: string name: string @@ -61,7 +39,7 @@ type FulfillmentProfileUpsertInput = { updatedAt: string } -type FulfillmentProfileRequirementInput = { +export type FulfillmentProfileRequirementInput = { roleKey: string credentialType: string quantityPerUnit?: number | string @@ -69,26 +47,6 @@ type FulfillmentProfileRequirementInput = { configJson?: string | Record } -type SkuFulfillmentBindingUpsertInput = { - skuCode: string - provider?: string - platform?: string - shopId?: string - profileId: number | string - enabled?: boolean - priority?: number | string - configJson?: string | Record - createdAt: string - updatedAt: string -} - -type FulfillmentBindingResolveInput = { - skuCode: string - provider?: string - platform?: string - shopId?: string -} - export async function getFulfillmentProfileByKey(profileKey: string): Promise { const result = await query( 'SELECT * FROM fulfillment_profiles WHERE profile_key = $1 LIMIT 1', @@ -195,113 +153,3 @@ export async function listFulfillmentProfileRequirements( return result.rows } - -export async function upsertSkuFulfillmentBinding( - input: SkuFulfillmentBindingUpsertInput, -): Promise { - const existing = await query( - ` - SELECT * - FROM sku_fulfillment_bindings - WHERE sku_code = $1 AND provider = $2 AND platform = $3 AND shop_id = $4 - LIMIT 1 - `, - [input.skuCode, input.provider || '', input.platform || '', input.shopId || ''], - ) - - if (!existing.rows[0]) { - const inserted = await query( - ` - INSERT INTO sku_fulfillment_bindings ( - sku_code, - provider, - platform, - shop_id, - profile_id, - enabled, - priority, - config_json, - created_at, - updated_at - ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8::jsonb, $9, $10) - RETURNING * - `, - [ - input.skuCode, - input.provider || '', - input.platform || '', - input.shopId || '', - Number(input.profileId), - input.enabled !== false, - Number(input.priority || 100), - input.configJson || '{}', - input.createdAt, - input.updatedAt, - ], - ) - - return inserted.rows[0] || null - } - - const updated = await query( - ` - UPDATE sku_fulfillment_bindings - SET - profile_id = $1, - enabled = $2, - priority = $3, - config_json = $4::jsonb, - updated_at = $5 - WHERE id = $6 - RETURNING * - `, - [ - Number(input.profileId), - input.enabled !== false, - Number(input.priority || 100), - input.configJson || '{}', - input.updatedAt, - Number(existing.rows[0].id), - ], - ) - - return updated.rows[0] || null -} - -export async function resolveFulfillmentBinding({ - skuCode, - provider = '', - platform = '', - shopId = '', -}: FulfillmentBindingResolveInput): Promise { - const result = await query( - ` - SELECT - sfb.*, - fp.profile_key, - fp.name AS profile_name, - fp.executor_key, - fp.requires_claim, - fp.auto_dispatch, - fp.inventory_strategy, - fp.config_json AS profile_config_json - FROM sku_fulfillment_bindings sfb - JOIN fulfillment_profiles fp ON fp.id = sfb.profile_id - WHERE sfb.enabled = TRUE - AND sfb.sku_code = $1 - AND (sfb.provider = '' OR sfb.provider = $2) - AND (sfb.platform = '' OR sfb.platform = $3) - AND (sfb.shop_id = '' OR sfb.shop_id = $4) - ORDER BY - CASE WHEN sfb.shop_id = '' THEN 1 ELSE 0 END, - CASE WHEN sfb.platform = '' THEN 1 ELSE 0 END, - CASE WHEN sfb.provider = '' THEN 1 ELSE 0 END, - sfb.priority ASC, - sfb.id ASC - LIMIT 1 - `, - [skuCode, provider, platform, shopId], - ) - - return result.rows[0] || null -} diff --git a/apps/backend/src/repositories/inventory-repo.ts b/apps/backend/src/repositories/inventory-repo.ts deleted file mode 100644 index 8bd30167..00000000 --- a/apps/backend/src/repositories/inventory-repo.ts +++ /dev/null @@ -1,497 +0,0 @@ -import { query, withTransaction } from '../db/client.js' -import type { - InventoryCreateItemInput, - InventoryListQueryInput, - InventorySkuSuggestionQueryInput, -} from '../types/repository-inputs.js' -import type { - InventoryItemRow, - InventoryListQueryResult, - InventorySkuSuggestionRow, -} from '../types/repository-rows.js' - -type InventoryGroupCodesInput = string | string[] | null | undefined - -type InventoryGroupListInput = { - keyword?: string - limit?: number | string -} - -const INVENTORY_ITEM_SELECT = ` - SELECT - ii.id, - ii.sku_code, - ii.batch_no, - ii.credential_type, - ii.inventory_group_code, - ii.display_value, - ii.status, - ii.invalid_reason, - ii.consumed_at AS delivered_at, - ii.created_at, - ii.updated_at, - tib.task_id AS reserved_by_task_id, - ft.task_no AS reserved_by_task_no, - ft.platform_order_id - FROM inventory_items ii - LEFT JOIN LATERAL ( - SELECT * - FROM task_inventory_bindings - WHERE inventory_item_id = ii.id AND binding_status IN ('reserved', 'consumed') - ORDER BY id DESC - LIMIT 1 - ) tib ON TRUE - LEFT JOIN fulfillment_tasks ft ON ft.id = tib.task_id -` - -export async function findFirstAvailableInventoryItemBySkuCode( - skuCode: string, - credentialType = 'tencent_code', - inventoryGroupCodes: InventoryGroupCodesInput = null, -): Promise { - const normalizedInventoryGroupCodes = normalizeInventoryGroupCodes(inventoryGroupCodes) - if (normalizedInventoryGroupCodes && normalizedInventoryGroupCodes.length === 0) { - return null - } - - const params: unknown[] = [skuCode, credentialType] - const filters = [ - 'ii.sku_code = $1', - 'ii.credential_type = $2', - `ii.status = 'available'`, - ] - - if (normalizedInventoryGroupCodes) { - params.push(normalizedInventoryGroupCodes) - filters.push(`ii.inventory_group_code = ANY($${params.length}::text[])`) - } - - const result = await query( - `${INVENTORY_ITEM_SELECT} - WHERE ${filters.join(' AND ')} - ORDER BY ii.id ASC - LIMIT 1`, - params, - ) - - return result.rows[0] || null -} - -export async function assignReservedInventoryItem( - inventoryItemId: number | string, - taskId: number | string, - updatedAt: string, - roleKey = 'primary_code', -): Promise { - return withTransaction(async (client) => { - const inventoryResult = await client.query( - ` - UPDATE inventory_items - SET status = 'reserved', updated_at = $1 - WHERE id = $2 AND status = 'available' - RETURNING id - `, - [updatedAt, Number(inventoryItemId)], - ) - - if (!inventoryResult.rows[0]) { - return getInventoryItemById(inventoryItemId) - } - - await client.query( - ` - INSERT INTO task_inventory_bindings ( - task_id, - inventory_item_id, - role_key, - quantity, - binding_status, - metadata_json, - created_at, - updated_at - ) VALUES ($1, $2, $3, 1, 'reserved', '{}'::jsonb, $4, $4) - ON CONFLICT (task_id, role_key, inventory_item_id) DO UPDATE - SET binding_status = 'reserved', updated_at = EXCLUDED.updated_at, released_at = NULL - `, - [Number(taskId), Number(inventoryItemId), roleKey, updatedAt], - ) - - return getInventoryItemById(inventoryItemId) - }) -} - -export async function getInventoryItemById(inventoryItemId: number | string): Promise { - const result = await query( - `${INVENTORY_ITEM_SELECT} - WHERE ii.id = $1 - LIMIT 1`, - [Number(inventoryItemId)], - ) - - return result.rows[0] || null -} - -export async function markInventoryItemDelivered( - inventoryItemId: number | string, - deliveredAt: string, -): Promise { - return withTransaction(async (client) => { - await client.query( - ` - UPDATE inventory_items - SET status = 'consumed', consumed_at = $1, updated_at = $1 - WHERE id = $2 - `, - [deliveredAt, Number(inventoryItemId)], - ) - - await client.query( - ` - UPDATE task_inventory_bindings - SET binding_status = 'consumed', consumed_at = $1, updated_at = $1 - WHERE inventory_item_id = $2 AND binding_status = 'reserved' - `, - [deliveredAt, Number(inventoryItemId)], - ) - - return getInventoryItemById(inventoryItemId) - }) -} - -export async function markInventoryItemConsumed( - inventoryItemId: number | string, - reason: string, - consumedAt: string, -): Promise { - return withTransaction(async (client) => { - await client.query( - ` - UPDATE inventory_items - SET - status = 'consumed', - invalid_reason = $1, - consumed_at = $2, - updated_at = $2 - WHERE id = $3 - `, - [String(reason || '').trim(), consumedAt, Number(inventoryItemId)], - ) - - await client.query( - ` - UPDATE task_inventory_bindings - SET binding_status = 'consumed', consumed_at = $1, updated_at = $1 - WHERE inventory_item_id = $2 AND binding_status = 'reserved' - `, - [consumedAt, Number(inventoryItemId)], - ) - - return getInventoryItemById(inventoryItemId) - }) -} - -export async function listInventoryItems({ - page = 1, - pageSize = 20, - skuCode = '', - credentialType = '', - status = '', - batchNo = '', - inventoryGroupCode = '', - allowedInventoryGroupCodes = null, -}: InventoryListQueryInput = {}): Promise { - const normalizedAllowedInventoryGroupCodes = normalizeInventoryGroupCodes(allowedInventoryGroupCodes) - if (normalizedAllowedInventoryGroupCodes && normalizedAllowedInventoryGroupCodes.length === 0) { - return { - items: [], - total: 0, - } - } - - const offset = (page - 1) * pageSize - const filters: string[] = [] - const params: unknown[] = [] - - if (skuCode) { - params.push(skuCode) - filters.push(`ii.sku_code = $${params.length}`) - } - - if (status) { - params.push(status === 'delivered' ? 'consumed' : status) - filters.push(`ii.status = $${params.length}`) - } - - if (credentialType) { - params.push(credentialType) - filters.push(`ii.credential_type = $${params.length}`) - } - - if (batchNo) { - params.push(`%${batchNo}%`) - filters.push(`ii.batch_no ILIKE $${params.length}`) - } - - if (inventoryGroupCode) { - params.push(inventoryGroupCode) - filters.push(`ii.inventory_group_code = $${params.length}`) - } - - if (normalizedAllowedInventoryGroupCodes) { - params.push(normalizedAllowedInventoryGroupCodes) - filters.push(`ii.inventory_group_code = ANY($${params.length}::text[])`) - } - - const whereClause = filters.length > 0 ? `WHERE ${filters.join(' AND ')}` : '' - const totalResult = await query<{ [column: string]: unknown, total: number }>( - `SELECT COUNT(*)::int AS total FROM inventory_items ii ${whereClause}`, - params, - ) - - params.push(pageSize) - params.push(offset) - const itemsResult = await query( - `${INVENTORY_ITEM_SELECT} - ${whereClause} - ORDER BY ii.id DESC - LIMIT $${params.length - 1} OFFSET $${params.length}`, - params, - ) - - return { - items: itemsResult.rows, - total: Number(totalResult.rows[0]?.total || 0), - } -} - -export async function listInventorySkuSuggestions({ - credentialType = '', - keyword = '', - limit = 50, - inventoryGroupCode = '', - allowedInventoryGroupCodes = null, -}: InventorySkuSuggestionQueryInput = {}): Promise { - const normalizedAllowedInventoryGroupCodes = normalizeInventoryGroupCodes(allowedInventoryGroupCodes) - if (normalizedAllowedInventoryGroupCodes && normalizedAllowedInventoryGroupCodes.length === 0) { - return [] - } - - const filters: string[] = [] - const params: unknown[] = [] - - if (credentialType) { - params.push(credentialType) - filters.push(`credential_type = $${params.length}`) - } - - if (keyword) { - params.push(`%${keyword}%`) - filters.push(`sku_code ILIKE $${params.length}`) - } - - if (inventoryGroupCode) { - params.push(inventoryGroupCode) - filters.push(`inventory_group_code = $${params.length}`) - } - - if (normalizedAllowedInventoryGroupCodes) { - params.push(normalizedAllowedInventoryGroupCodes) - filters.push(`inventory_group_code = ANY($${params.length}::text[])`) - } - - const normalizedLimit = Number.isFinite(Number(limit)) - ? Math.max(1, Math.min(Number(limit), 100)) - : 50 - - params.push(normalizedLimit) - const whereClause = filters.length > 0 ? `WHERE ${filters.join(' AND ')}` : '' - const result = await query( - ` - SELECT - sku_code, - credential_type, - inventory_group_code, - COUNT(*)::int AS total_count, - COUNT(*) FILTER (WHERE status = 'available')::int AS available_count, - MAX(updated_at) AS latest_updated_at - FROM inventory_items - ${whereClause} - GROUP BY sku_code, credential_type, inventory_group_code - ORDER BY - COUNT(*) FILTER (WHERE status = 'available') DESC, - COUNT(*) DESC, - MAX(updated_at) DESC, - sku_code ASC, - inventory_group_code ASC - LIMIT $${params.length} - `, - params, - ) - - return result.rows -} - -export async function createInventoryItems(rows: InventoryCreateItemInput[]): Promise { - let created = 0 - - for (const row of rows) { - const payloadJson = JSON.stringify(row.payload || { code: row.displayValue }) - const displayValue = String(row.displayValue || '').trim() - const inventoryGroupCode = String(row.inventoryGroupCode || '').trim() - const result = await query<{ [column: string]: unknown, id: number }>( - ` - INSERT INTO inventory_items ( - batch_no, - sku_code, - credential_type, - inventory_group_code, - display_value, - payload_json, - source_type, - status, - invalid_reason, - metadata_json, - created_at, - updated_at - ) VALUES ($1, $2, $3, $4, $5, $6::jsonb, 'static_import', 'available', '', '{}'::jsonb, $7, $8) - ON CONFLICT DO NOTHING - RETURNING id - `, - [ - row.batchNo || '', - row.skuCode, - row.credentialType || 'tencent_code', - inventoryGroupCode, - displayValue, - payloadJson, - row.createdAt, - row.updatedAt, - ], - ) - - if (result.rows[0]?.id) { - created += 1 - } - } - - return created -} - -export async function listInventoryGroupCodes({ - keyword = '', - limit = 100, -}: InventoryGroupListInput = {}): Promise { - const params: unknown[] = [] - const filters = [`inventory_group_code <> ''`] - - if (keyword) { - params.push(`%${String(keyword || '').trim()}%`) - filters.push(`inventory_group_code ILIKE $${params.length}`) - } - - const normalizedLimit = Number.isFinite(Number(limit)) - ? Math.max(1, Math.min(Number(limit), 200)) - : 100 - - params.push(normalizedLimit) - const result = await query<{ [column: string]: unknown, inventory_group_code: string }>( - ` - SELECT inventory_group_code - FROM inventory_items - WHERE ${filters.join(' AND ')} - GROUP BY inventory_group_code - ORDER BY inventory_group_code ASC - LIMIT $${params.length} - `, - params, - ) - - return result.rows.map((row) => String(row.inventory_group_code || '').trim()).filter(Boolean) -} - -function normalizeInventoryGroupCodes(inventoryGroupCodes: InventoryGroupCodesInput): string[] | null { - if (inventoryGroupCodes == null) { - return null - } - - return Array.from(new Set((Array.isArray(inventoryGroupCodes) ? inventoryGroupCodes : [inventoryGroupCodes]) - .map((value) => String(value || '').trim()) - .filter(Boolean))) -} - -export async function releaseReservedInventoryItem( - inventoryItemId: number | string, - updatedAt: string, -): Promise { - return withTransaction(async (client) => { - await client.query( - ` - UPDATE task_inventory_bindings - SET binding_status = 'released', released_at = $1, updated_at = $1 - WHERE inventory_item_id = $2 AND binding_status = 'reserved' - `, - [updatedAt, Number(inventoryItemId)], - ) - - await client.query( - ` - UPDATE inventory_items - SET status = 'available', updated_at = $1 - WHERE id = $2 AND status = 'reserved' - `, - [updatedAt, Number(inventoryItemId)], - ) - - return getInventoryItemById(inventoryItemId) - }) -} - -export async function invalidateInventoryItem( - inventoryItemId: number | string, - invalidReason: string, - updatedAt: string, -): Promise { - const result = await query<{ [column: string]: unknown, id: number }>( - ` - UPDATE inventory_items - SET status = 'invalid', invalid_reason = $1, updated_at = $2 - WHERE id = $3 AND status = 'available' - RETURNING id - `, - [invalidReason, updatedAt, Number(inventoryItemId)], - ) - - if (!result.rows[0]) { - return getInventoryItemById(inventoryItemId) - } - - return getInventoryItemById(inventoryItemId) -} - -export async function invalidateReservedInventoryItem( - inventoryItemId: number | string, - invalidReason: string, - updatedAt: string, -): Promise { - return withTransaction(async (client) => { - await client.query( - ` - UPDATE task_inventory_bindings - SET binding_status = 'released', released_at = $1, updated_at = $1 - WHERE inventory_item_id = $2 AND binding_status = 'reserved' - `, - [updatedAt, Number(inventoryItemId)], - ) - - await client.query( - ` - UPDATE inventory_items - SET status = 'invalid', invalid_reason = $1, updated_at = $2 - WHERE id = $3 - `, - [String(invalidReason || '').trim(), updatedAt, Number(inventoryItemId)], - ) - - return getInventoryItemById(inventoryItemId) - }) -} diff --git a/apps/backend/src/repositories/kuaishou-cloud-task-state-repo.test.ts b/apps/backend/src/repositories/kuaishou-cloud-task-state-repo.test.ts new file mode 100644 index 00000000..76b0620c --- /dev/null +++ b/apps/backend/src/repositories/kuaishou-cloud-task-state-repo.test.ts @@ -0,0 +1,43 @@ +import assert from 'node:assert/strict' +import test from 'node:test' + +import { syncKuaishouCloudTaskStateForTask } from './kuaishou-cloud-task-state-repo.js' + +test('syncKuaishouCloudTaskStateForTask 将 Date 更新时间规范化为 ISO 字符串', async () => { + const calls: Array<{ text: string; params?: unknown[] }> = [] + const executor = async (text: string, params?: unknown[]) => { + calls.push({ text, params }) + return { rows: [] } + } + + await syncKuaishouCloudTaskStateForTask({ + taskId: 9, + executorKey: 'kuaishou_ct_assisted', + contextJson: { + kuaishouCloudFulfillment: { + ticket: { + status: 'verified', + code: '597172E91CF18417', + }, + binding: { + bindUrl: 'https://example.test/bind', + vnPhone: '14062706492', + roleName: '测试角色', + roleId: '4212063544', + resolvedSourceKey: 'account_test', + }, + dispatch: { + status: 'pending', + }, + consume: { + status: 'pending', + }, + }, + }, + updatedAt: new Date('2026-05-29T09:05:46.658Z'), + }, executor) + + assert.equal(calls.length, 1) + assert.match(calls[0].text, /INSERT INTO kuaishou_cloud_task_states/) + assert.equal(calls[0].params?.[10], '2026-05-29T09:05:46.658Z') +}) diff --git a/apps/backend/src/repositories/kuaishou-cloud-task-state-repo.ts b/apps/backend/src/repositories/kuaishou-cloud-task-state-repo.ts new file mode 100644 index 00000000..a605a945 --- /dev/null +++ b/apps/backend/src/repositories/kuaishou-cloud-task-state-repo.ts @@ -0,0 +1,181 @@ +import { query } from '../db/client.js' +import { TASK_STATUS } from '../domain/task-status.js' +import { maskCode } from '../utils/masking.js' +import { parseTaskContext } from '../utils/task-json.js' +import { normalizeTimestampIso } from '../utils/time.js' + +type QueryExecutor = (text: string, params?: unknown[]) => Promise<{ rows: unknown[] }> +type JsonRecord = Record + +export type KuaishouCloudSourceLoadStat = { + sourceKey: string + activeCount: number + redeemingCount: number +} + +export type KuaishouCloudTaskStateSyncInput = { + taskId: number | string + executorKey?: unknown + contextJson?: unknown + updatedAt?: unknown +} + +export async function syncKuaishouCloudTaskStateForTask( + input: KuaishouCloudTaskStateSyncInput, + executor: QueryExecutor = query, +): Promise { + const taskId = Number(input.taskId || 0) + if (!Number.isFinite(taskId) || taskId <= 0) { + return + } + + if (String(input.executorKey || '').trim() !== 'kuaishou_ct_assisted') { + await executor('DELETE FROM kuaishou_cloud_task_states WHERE task_id = $1', [taskId]) + return + } + + const flow = resolveKuaishouCloudFlow(input.contextJson) + if (!flow) { + await executor('DELETE FROM kuaishou_cloud_task_states WHERE task_id = $1', [taskId]) + return + } + + const ticket = asRecord(flow.ticket) + const binding = asRecord(flow.binding) + const role = asRecord(flow.role) + const dispatch = asRecord(flow.dispatch) + const consume = asRecord(flow.consume) + const roleName = pickFirstNonEmpty([role.name, binding.roleName]) + const roleId = pickFirstNonEmpty([role.rid, binding.roleId]) + const updatedAt = normalizeTimestampIso(input.updatedAt) + + await executor( + ` + INSERT INTO kuaishou_cloud_task_states ( + task_id, + ticket_status, + ticket_code_masked, + bind_url, + vn_phone, + role_name, + role_id, + dispatch_status, + consume_status, + source_key, + updated_at + ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11) + ON CONFLICT (task_id) DO UPDATE + SET + ticket_status = EXCLUDED.ticket_status, + ticket_code_masked = EXCLUDED.ticket_code_masked, + bind_url = EXCLUDED.bind_url, + vn_phone = EXCLUDED.vn_phone, + role_name = EXCLUDED.role_name, + role_id = EXCLUDED.role_id, + dispatch_status = EXCLUDED.dispatch_status, + consume_status = EXCLUDED.consume_status, + source_key = EXCLUDED.source_key, + updated_at = EXCLUDED.updated_at + `, + [ + taskId, + normalizeStatus(ticket.status), + maskCode(ticket.code), + String(binding.bindUrl || '').trim(), + String(binding.vnPhone || '').trim(), + roleName, + roleId, + normalizeStatus(dispatch.status), + normalizeStatus(consume.status), + String(binding.resolvedSourceKey || '').trim(), + updatedAt, + ], + ) +} + +export async function listKuaishouCloudSourceLoadStats( + sourceKeys: unknown[] = [], + executor: QueryExecutor = query, +): Promise { + const candidates = normalizeStringArray(sourceKeys) + if (candidates.length === 0) { + return [] + } + + const activeStatuses = [ + TASK_STATUS.WAITING_BINDING, + TASK_STATUS.ROLE_CONFIRMED, + TASK_STATUS.REDEEMING, + TASK_STATUS.DISPATCHED_PENDING_RETURN, + ] + const result = await executor( + ` + SELECT + kcts.source_key, + COUNT(*) FILTER (WHERE ft.task_status = ANY($2::text[])) AS active_count, + COUNT(*) FILTER (WHERE ft.task_status = $3) AS redeeming_count + FROM fulfillment_tasks ft + JOIN kuaishou_cloud_task_states kcts ON kcts.task_id = ft.id + WHERE ft.executor_key = 'kuaishou_ct_assisted' + AND kcts.source_key = ANY($1::text[]) + AND ft.task_status = ANY($2::text[]) + GROUP BY kcts.source_key + `, + [candidates, activeStatuses, TASK_STATUS.REDEEMING], + ) + + return result.rows + .map((row) => { + const source = asRecord(row) + return { + sourceKey: String(source.source_key || '').trim(), + activeCount: Number(source.active_count || 0) || 0, + redeemingCount: Number(source.redeeming_count || 0) || 0, + } + }) + .filter((row) => row.sourceKey) +} + +function resolveKuaishouCloudFlow(contextJson: unknown): JsonRecord | null { + const context = parseTaskContext({ context_json: contextJson }) + const flow = asRecord(context.kuaishouCloudFulfillment) + return Object.keys(flow).length > 0 ? flow : null +} + +function asRecord(value: unknown): JsonRecord { + return value && typeof value === 'object' && !Array.isArray(value) ? (value as JsonRecord) : {} +} + +function normalizeStatus(value: unknown): string { + return String(value || 'pending').trim() || 'pending' +} + +function normalizeStringArray(value: unknown): string[] { + if (Array.isArray(value)) { + return Array.from(new Set(value.map((item) => String(item || '').trim()).filter(Boolean))) + } + + if (typeof value === 'string') { + return Array.from( + new Set( + value + .split(',') + .map((item) => item.trim()) + .filter(Boolean), + ), + ) + } + + return [] +} + +function pickFirstNonEmpty(values: unknown[]): string { + for (const value of values) { + const normalized = String(value || '').trim() + if (normalized) { + return normalized + } + } + + return '' +} diff --git a/apps/backend/src/repositories/message-delivery-repo.ts b/apps/backend/src/repositories/message-delivery-repo.ts deleted file mode 100644 index 0dcd5525..00000000 --- a/apps/backend/src/repositories/message-delivery-repo.ts +++ /dev/null @@ -1,314 +0,0 @@ -import { query } from '../db/client.js' -import type { - MessageDeliveryListQueryResult, - MessageDeliveryRow, -} from '../types/repository-rows.js' - -type MessageDeliveryCreateInput = { - provider: string - platform: string - shopId?: string - shopName?: string - channel: string - orderId?: number | string | null - taskId?: number | string | null - platformOrderId?: string - recipientKey?: string - messageContent?: string - claimUrl?: string - status?: string - requestUrl?: string - requestHeadersJson?: string | Record - requestBodyJson?: string | Record - responseStatus?: number | string - responseJson?: string | Record - errorMessage?: string - sentAt?: string | null - createdAt: string - updatedAt: string -} - -type MessageDeliveryPatch = Partial> - -type LatestSuccessfulMessageDeliveryQuery = { - provider?: string - platform?: string - shopId?: string - platformOrderId?: string - channel?: string - claimUrl?: string -} - -type MessageDeliveryListQuery = { - page?: number - pageSize?: number - provider?: string - platform?: string - status?: string - shopId?: string - platformOrderId?: string - taskNo?: string - dateFrom?: string - dateTo?: string -} - -export async function createMessageDelivery( - input: MessageDeliveryCreateInput, -): Promise { - const result = await query( - ` - INSERT INTO message_deliveries ( - provider, - platform, - shop_id, - shop_name, - channel, - order_id, - task_id, - platform_order_id, - recipient_key, - message_content, - claim_url, - status, - request_url, - request_headers_json, - request_body_json, - response_status, - response_json, - error_message, - sent_at, - created_at, - updated_at - ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14::jsonb, $15::jsonb, $16, $17::jsonb, $18, $19, $20, $21) - RETURNING * - `, - [ - input.provider, - input.platform, - input.shopId || '', - input.shopName || '', - input.channel, - input.orderId || null, - input.taskId || null, - input.platformOrderId || '', - input.recipientKey || '', - input.messageContent || '', - input.claimUrl || '', - input.status || 'pending', - input.requestUrl || '', - input.requestHeadersJson || '{}', - input.requestBodyJson || '{}', - Number(input.responseStatus || 0), - input.responseJson || '{}', - input.errorMessage || '', - input.sentAt || null, - input.createdAt, - input.updatedAt, - ], - ) - - return result.rows[0] || null -} - -export async function getMessageDeliveryById(deliveryId: number | string): Promise { - const result = await query('SELECT * FROM message_deliveries WHERE id = $1 LIMIT 1', [ - Number(deliveryId), - ]) - return result.rows[0] || null -} - -export async function updateMessageDelivery( - deliveryId: number | string, - patch: MessageDeliveryPatch = {}, -): Promise { - const current = await getMessageDeliveryById(deliveryId) - if (!current) { - return null - } - - const next = { ...current, ...patch } - const result = await query( - ` - UPDATE message_deliveries - SET - status = $1, - request_url = $2, - request_headers_json = $3::jsonb, - request_body_json = $4::jsonb, - response_status = $5, - response_json = $6::jsonb, - error_message = $7, - sent_at = $8, - updated_at = $9 - WHERE id = $10 - RETURNING * - `, - [ - next.status, - next.request_url || '', - next.request_headers_json || '{}', - next.request_body_json || '{}', - Number(next.response_status || 0), - next.response_json || '{}', - next.error_message || '', - next.sent_at || null, - next.updated_at, - Number(deliveryId), - ], - ) - - return result.rows[0] || null -} - -export async function findLatestSuccessfulMessageDeliveryByTask( - taskId: number | string, - channel: string, -): Promise { - const result = await query( - ` - SELECT * - FROM message_deliveries - WHERE task_id = $1 AND channel = $2 AND status = 'success' - ORDER BY id DESC - LIMIT 1 - `, - [Number(taskId), channel], - ) - - return result.rows[0] || null -} - -export async function findLatestSuccessfulMessageDelivery({ - provider = '', - platform = '', - shopId = '', - platformOrderId = '', - channel = '', - claimUrl = '', -}: LatestSuccessfulMessageDeliveryQuery = {}): Promise { - const result = await query( - ` - SELECT * - FROM message_deliveries - WHERE provider = $1 - AND platform = $2 - AND shop_id = $3 - AND platform_order_id = $4 - AND channel = $5 - AND claim_url = $6 - AND status = 'success' - ORDER BY id DESC - LIMIT 1 - `, - [ - provider, - platform, - shopId, - platformOrderId, - channel, - claimUrl, - ], - ) - - return result.rows[0] || null -} - -export async function listMessageDeliveries({ - page = 1, - pageSize = 20, - provider = '', - platform = '', - status = '', - shopId = '', - platformOrderId = '', - taskNo = '', - dateFrom = '', - dateTo = '', -}: MessageDeliveryListQuery = {}): Promise { - const offset = (page - 1) * pageSize - const filters: string[] = [] - const params: unknown[] = [] - - if (provider) { - params.push(provider) - filters.push(`md.provider = $${params.length}`) - } - - if (platform) { - params.push(platform) - filters.push(`md.platform = $${params.length}`) - } - - if (status) { - params.push(status) - filters.push(`md.status = $${params.length}`) - } - - if (shopId) { - params.push(shopId) - filters.push(`md.shop_id = $${params.length}`) - } - - if (platformOrderId) { - params.push(`%${platformOrderId}%`) - filters.push(`md.platform_order_id ILIKE $${params.length}`) - } - - if (taskNo) { - params.push(`%${taskNo}%`) - filters.push(`ft.task_no ILIKE $${params.length}`) - } - - if (dateFrom) { - params.push(dateFrom) - filters.push(`md.created_at >= $${params.length}`) - } - - if (dateTo) { - params.push(dateTo) - filters.push(`md.created_at <= $${params.length}`) - } - - const whereClause = filters.length > 0 ? `WHERE ${filters.join(' AND ')}` : '' - const fromClause = ` - FROM message_deliveries md - LEFT JOIN fulfillment_tasks ft ON ft.id = md.task_id - ` - - const totalResult = await query<{ [column: string]: unknown, total: number }>( - `SELECT COUNT(*)::int AS total ${fromClause} ${whereClause}`, - params, - ) - - params.push(pageSize) - params.push(offset) - const itemsResult = await query( - ` - SELECT - md.*, - ft.task_no, - ft.task_status - ${fromClause} - ${whereClause} - ORDER BY md.id DESC - LIMIT $${params.length - 1} OFFSET $${params.length} - `, - params, - ) - - return { - items: itemsResult.rows, - total: Number(totalResult.rows[0]?.total || 0), - } -} diff --git a/apps/backend/src/repositories/order-item-repo.test.ts b/apps/backend/src/repositories/order-item-repo.test.ts index f9d5de1c..4dbfc258 100644 --- a/apps/backend/src/repositories/order-item-repo.test.ts +++ b/apps/backend/src/repositories/order-item-repo.test.ts @@ -3,7 +3,7 @@ import assert from 'node:assert/strict' import { resolveOrderItemSyncPlan } from './order-item-repo.js' -test('resolveOrderItemSyncPlan preserves matching order item ids across repeated webhook syncs', () => { +test('resolveOrderItemSyncPlan preserves matching order item ids across repeated source syncs', () => { const existingItems = [ { id: 15, diff --git a/apps/backend/src/repositories/order-item-repo.ts b/apps/backend/src/repositories/order-item-repo.ts index 7752dd24..10299618 100644 --- a/apps/backend/src/repositories/order-item-repo.ts +++ b/apps/backend/src/repositories/order-item-repo.ts @@ -1,8 +1,8 @@ import type { PoolClient, QueryResult } from 'pg' import { query, withTransaction } from '../db/client.js' -import type { OrderItemReplaceInput } from '../types/repository-inputs.js' -import type { OrderItemRow } from '../types/repository-rows.js' +import type { OrderItemReplaceInput } from '../types/repository/inputs.js' +import type { OrderItemRow } from '../types/repository/rows.js' type QueryExecutor = (text: string, params?: unknown[]) => Promise> @@ -109,6 +109,10 @@ export function resolveOrderItemSyncPlan( if (matchedIndex >= 0) { const matched = unmatchedExisting.splice(matchedIndex, 1)[0] + if (!matched) { + creates.push(item) + continue + } updates.push({ orderItemId: matched.id, item, diff --git a/apps/backend/src/repositories/order-repo.ts b/apps/backend/src/repositories/order-repo.ts index 66d8944e..dfe67fad 100644 --- a/apps/backend/src/repositories/order-repo.ts +++ b/apps/backend/src/repositories/order-repo.ts @@ -3,12 +3,12 @@ import type { OrderCreateInput, OrderListQueryInput, OrderUpdateInput, -} from '../types/repository-inputs.js' +} from '../types/repository/inputs.js' import type { OrderListQueryResult, OrderListRow, OrderRow, -} from '../types/repository-rows.js' +} from '../types/repository/rows.js' type OrderPlatformLookupInput = { provider?: string @@ -17,15 +17,8 @@ type OrderPlatformLookupInput = { platformOrderId: string } -type OrderPlatformCandidateLookupInput = { - provider?: string - platform?: string - shopIds?: unknown[] - platformOrderId?: string -} - export async function findOrderByPlatformOrderId({ - provider = 'agiso', + provider = '91kaquan', platform, shopId = '', platformOrderId, @@ -43,29 +36,20 @@ export async function findOrderByPlatformOrderId({ return result.rows[0] || null } -export async function findOrderByPlatformOrderIdCandidates({ - provider = 'agiso', +export async function findLatestOrderByPlatformOrderId({ + provider = '91kaquan', platform, - shopIds = [], platformOrderId, -}: OrderPlatformCandidateLookupInput): Promise { - const normalizedShopIds = [...new Set((Array.isArray(shopIds) ? shopIds : []) - .map((item) => String(item || '').trim()) - .filter(Boolean))] - - if (!platform || !platformOrderId || normalizedShopIds.length === 0) { - return null - } - +}: Omit): Promise { const result = await query( ` SELECT * FROM orders - WHERE provider = $1 AND platform = $2 AND shop_id = ANY($3::text[]) AND platform_order_id = $4 + WHERE provider = $1 AND platform = $2 AND platform_order_id = $3 ORDER BY id DESC LIMIT 1 `, - [provider, platform, normalizedShopIds, platformOrderId], + [provider, platform, platformOrderId], ) return result.rows[0] || null diff --git a/apps/backend/src/repositories/product-match-rule-repo.ts b/apps/backend/src/repositories/product-match-rule-repo.ts deleted file mode 100644 index 9c77b611..00000000 --- a/apps/backend/src/repositories/product-match-rule-repo.ts +++ /dev/null @@ -1,169 +0,0 @@ -import { query } from '../db/client.js' - -type ProductMatchRuleRow = { - id: number - provider: string - platform: string - shop_id: string - external_item_id: string - external_sku_code: string - external_sku_name: string - external_sku_name_normalized: string - resolved_sku_code: string - enabled: boolean - priority: number - config_json: string | Record - created_at: string - updated_at: string - matched_by?: string - match_score?: number -} - -type ProductMatchRuleUpsertInput = { - provider?: string - platform?: string - shopId?: string - externalItemId?: string - externalSkuCode?: string - externalSkuName?: string - externalSkuNameNormalized?: string - resolvedSkuCode: string - enabled?: boolean - priority?: number | string - configJson?: string | Record - createdAt: string - updatedAt: string -} - -type ProductMatchRuleResolveInput = { - provider?: string - platform?: string - shopId?: string - externalItemId?: string - externalSkuCode?: string - externalSkuNameNormalized?: string -} - -export async function upsertProductMatchRule( - input: ProductMatchRuleUpsertInput, -): Promise { - const result = await query( - ` - INSERT INTO product_match_rules ( - provider, - platform, - shop_id, - external_item_id, - external_sku_code, - external_sku_name, - external_sku_name_normalized, - resolved_sku_code, - enabled, - priority, - config_json, - created_at, - updated_at - ) VALUES ( - $1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11::jsonb, $12, $13 - ) - ON CONFLICT ( - provider, - platform, - shop_id, - external_item_id, - external_sku_code, - external_sku_name_normalized, - resolved_sku_code - ) DO UPDATE - SET - external_sku_name = EXCLUDED.external_sku_name, - enabled = EXCLUDED.enabled, - priority = EXCLUDED.priority, - config_json = EXCLUDED.config_json, - updated_at = EXCLUDED.updated_at - RETURNING * - `, - [ - input.provider || '', - input.platform || '', - input.shopId || '', - input.externalItemId || '', - input.externalSkuCode || '', - input.externalSkuName || '', - input.externalSkuNameNormalized || '', - input.resolvedSkuCode, - input.enabled !== false, - Number(input.priority || 100), - input.configJson || '{}', - input.createdAt, - input.updatedAt, - ], - ) - - return result.rows[0] || null -} - -export async function resolveProductMatchRule({ - provider = '', - platform = '', - shopId = '', - externalItemId = '', - externalSkuCode = '', - externalSkuNameNormalized = '', -}: ProductMatchRuleResolveInput): Promise { - const result = await query( - ` - SELECT - pmr.*, - CASE - WHEN $4 != '' AND pmr.external_sku_code = $4 THEN 'external_sku_code' - WHEN $5 != '' AND pmr.external_item_id = $5 THEN 'external_item_id' - WHEN $6 != '' AND pmr.external_sku_name_normalized = $6 THEN 'external_sku_name_exact' - WHEN $6 != '' AND pmr.external_sku_name_normalized != '' AND ( - position(pmr.external_sku_name_normalized in $6) > 0 - OR position($6 in pmr.external_sku_name_normalized) > 0 - ) THEN 'external_sku_name_contains' - ELSE '' - END AS matched_by, - CASE - WHEN $4 != '' AND pmr.external_sku_code = $4 THEN 400 - WHEN $5 != '' AND pmr.external_item_id = $5 THEN 300 - WHEN $6 != '' AND pmr.external_sku_name_normalized = $6 THEN 200 - WHEN $6 != '' AND pmr.external_sku_name_normalized != '' AND ( - position(pmr.external_sku_name_normalized in $6) > 0 - OR position($6 in pmr.external_sku_name_normalized) > 0 - ) THEN 100 - ELSE 0 - END AS match_score - FROM product_match_rules pmr - WHERE pmr.enabled = TRUE - AND (pmr.provider = '' OR pmr.provider = $1) - AND (pmr.platform = '' OR pmr.platform = $2) - AND (pmr.shop_id = '' OR pmr.shop_id = $3) - AND ( - ($4 != '' AND pmr.external_sku_code = $4) - OR ($5 != '' AND pmr.external_item_id = $5) - OR ( - $6 != '' - AND pmr.external_sku_name_normalized != '' - AND ( - pmr.external_sku_name_normalized = $6 - OR position(pmr.external_sku_name_normalized in $6) > 0 - OR position($6 in pmr.external_sku_name_normalized) > 0 - ) - ) - ) - ORDER BY - match_score DESC, - CASE WHEN pmr.shop_id = '' THEN 1 ELSE 0 END, - CASE WHEN pmr.platform = '' THEN 1 ELSE 0 END, - CASE WHEN pmr.provider = '' THEN 1 ELSE 0 END, - pmr.priority ASC, - pmr.id ASC - LIMIT 1 - `, - [provider, platform, shopId, externalSkuCode, externalItemId, externalSkuNameNormalized], - ) - - return result.rows[0] || null -} diff --git a/apps/backend/src/repositories/task-inventory-binding-repo.ts b/apps/backend/src/repositories/task-inventory-binding-repo.ts deleted file mode 100644 index 497f3710..00000000 --- a/apps/backend/src/repositories/task-inventory-binding-repo.ts +++ /dev/null @@ -1,105 +0,0 @@ -import { query } from '../db/client.js' -import type { - TaskInventoryBindingRow, - TaskInventoryBindingSummaryRow, -} from '../types/repository-rows.js' - -export async function listTaskInventoryBindingsByTaskId( - taskId: number | string, -): Promise { - const result = await query( - ` - SELECT - tib.id, - tib.task_id, - tib.inventory_item_id, - tib.role_key, - tib.quantity, - tib.binding_status, - tib.consumed_at, - tib.released_at, - tib.metadata_json, - tib.created_at, - tib.updated_at, - ii.sku_code, - ii.batch_no, - ii.credential_type, - ii.inventory_group_code, - ii.display_value, - ii.status AS inventory_item_status, - ii.invalid_reason - FROM task_inventory_bindings tib - JOIN inventory_items ii ON ii.id = tib.inventory_item_id - WHERE tib.task_id = $1 - ORDER BY tib.id ASC - `, - [Number(taskId)], - ) - - return result.rows -} - -export async function getTaskInventoryBindingById( - bindingId: number | string, -): Promise { - const result = await query( - ` - SELECT - tib.id, - tib.task_id, - tib.inventory_item_id, - tib.role_key, - tib.quantity, - tib.binding_status, - tib.consumed_at, - tib.released_at, - tib.metadata_json, - tib.created_at, - tib.updated_at, - ii.sku_code, - ii.batch_no, - ii.credential_type, - ii.inventory_group_code, - ii.display_value, - ii.status AS inventory_item_status, - ii.invalid_reason - FROM task_inventory_bindings tib - JOIN inventory_items ii ON ii.id = tib.inventory_item_id - WHERE tib.id = $1 - LIMIT 1 - `, - [Number(bindingId)], - ) - - return result.rows[0] || null -} - -export async function listTaskInventoryBindingSummariesByTaskIds( - taskIds: unknown[] = [], -): Promise { - const normalizedTaskIds = Array.from(new Set((Array.isArray(taskIds) ? taskIds : []) - .map((value) => Number(value)) - .filter((value) => Number.isFinite(value) && value > 0))) - - if (normalizedTaskIds.length === 0) { - return [] - } - - const result = await query( - ` - SELECT - tib.task_id, - COUNT(*)::int AS total_binding_count, - COUNT(*) FILTER (WHERE tib.binding_status = 'reserved')::int AS reserved_binding_count, - COUNT(*) FILTER (WHERE tib.binding_status = 'consumed')::int AS consumed_binding_count, - COUNT(*) FILTER (WHERE tib.binding_status = 'released')::int AS released_binding_count, - ARRAY_AGG(DISTINCT tib.role_key ORDER BY tib.role_key) AS role_keys - FROM task_inventory_bindings tib - WHERE tib.task_id = ANY($1::bigint[]) - GROUP BY tib.task_id - `, - [normalizedTaskIds], - ) - - return result.rows -} diff --git a/apps/backend/src/repositories/task-repo.ts b/apps/backend/src/repositories/task-repo.ts index 687d2e18..ac9fd781 100644 --- a/apps/backend/src/repositories/task-repo.ts +++ b/apps/backend/src/repositories/task-repo.ts @@ -1,21 +1,23 @@ import { query, withTransaction } from '../db/client.js' import type { PoolClient } from 'pg' +import { syncKuaishouCloudTaskStateForTask } from './kuaishou-cloud-task-state-repo.js' +import { normalizeTimestampIso } from '../utils/time.js' import type { TaskCreateInput, TaskListQueryInput, - TaskTencentContextPatch, + TaskRuntimeContextPatch, TaskUpdatePatch, -} from '../types/repository-inputs.js' +} from '../types/repository/inputs.js' import type { TaskListQueryResult, TaskRow, -} from '../types/repository-rows.js' +} from '../types/repository/rows.js' type TaskQueryExecutor = (text: string, params?: unknown[]) => Promise<{ rows: unknown[] }> -type TencentBrowserContextRow = { - browser_session_id: string +type TaskRuntimeContextRow = { + runtime_session_id: string login_type: string nickname: string role_id: string @@ -33,7 +35,7 @@ const TASK_FIELDS = ` ct.token AS primary_claim_token, ct.status AS primary_claim_token_status, ct.expired_at AS primary_claim_expires_at, - ctx.browser_session_id, + ctx.runtime_session_id, ctx.login_type, ctx.nickname, ctx.role_id, @@ -43,34 +45,23 @@ const TASK_FIELDS = ` ctx.screenshot_path, ctx.artifacts_json, ctx.state_json, - inv.inventory_item_id AS primary_inventory_item_id, - inv.display_value AS primary_inventory_display_value, - inv.credential_type AS primary_inventory_credential_type, - inv.binding_status AS primary_inventory_binding_status + kcts.ticket_status AS kuaishou_ticket_status, + kcts.ticket_code_masked AS kuaishou_ticket_code_masked, + kcts.bind_url AS kuaishou_bind_url, + kcts.vn_phone AS kuaishou_vn_phone, + kcts.role_name AS kuaishou_role_name, + kcts.role_id AS kuaishou_role_id, + kcts.dispatch_status AS kuaishou_dispatch_status, + kcts.consume_status AS kuaishou_consume_status, + kcts.source_key AS kuaishou_source_key, + kcts.updated_at AS kuaishou_state_updated_at ` const TASK_JOINS = ` FROM fulfillment_tasks ft LEFT JOIN claim_tokens ct ON ct.task_id = ft.id - LEFT JOIN tencent_browser_contexts ctx ON ctx.task_id = ft.id - LEFT JOIN LATERAL ( - SELECT - tib.inventory_item_id, - tib.binding_status, - ii.display_value, - ii.credential_type - FROM task_inventory_bindings tib - JOIN inventory_items ii ON ii.id = tib.inventory_item_id - WHERE tib.task_id = ft.id AND tib.binding_status IN ('reserved', 'consumed') - ORDER BY - CASE tib.binding_status - WHEN 'reserved' THEN 0 - WHEN 'consumed' THEN 1 - ELSE 2 - END ASC, - tib.id DESC - LIMIT 1 - ) inv ON TRUE + LEFT JOIN task_runtime_contexts ctx ON ctx.task_id = ft.id + LEFT JOIN kuaishou_cloud_task_states kcts ON kcts.task_id = ft.id ` function buildTaskSelect(extraFields = ''): string { @@ -106,7 +97,6 @@ export async function createTask(input: TaskCreateInput): Promise { + const now = normalizeTimestampIso(patch.updated_at, '') + return withTransaction(async (client) => { + const result = await client.query<{ + id: number + executor_key: string + context_json: string | Record + updated_at: string + }>( + ` + UPDATE fulfillment_tasks + SET + task_status = $1, + delivery_status = COALESCE($2, delivery_status), + result_code = COALESCE($3, result_code), + result_message = COALESCE($4, result_message), + user_action_status = COALESCE($5, user_action_status), + last_error = COALESCE($6, last_error), + context_json = COALESCE($7::jsonb, context_json), + redeemed_at = COALESCE($8, redeemed_at), + updated_at = COALESCE($9, updated_at) + WHERE id = $10 + AND task_status = $11 + RETURNING id, executor_key, context_json, updated_at + `, + [ + patch.task_status, + patch.delivery_status ?? null, + patch.result_code ?? null, + patch.result_message ?? null, + patch.user_action_status ?? null, + patch.last_error ?? null, + typeof patch.context_json === 'undefined' + ? null + : typeof patch.context_json === 'string' + ? patch.context_json + : JSON.stringify(patch.context_json || {}), + patch.redeemed_at ?? null, + now || null, + Number(taskId), + currentStatus, + ], + ) + + const updated = result.rows[0] + if (!updated) { + return null + } + + await syncKuaishouCloudTaskStateForTask({ + taskId: updated.id, + executorKey: updated.executor_key, + contextJson: updated.context_json, + updatedAt: updated.updated_at, + }, client.query.bind(client) as TaskQueryExecutor) + return getTaskByIdWithExecutor(client.query.bind(client) as TaskQueryExecutor, taskId) }) } @@ -291,7 +357,7 @@ export async function listTasks({ if (roleId) { params.push(`%${roleId}%`) - filters.push(`ctx.role_id ILIKE $${params.length}`) + filters.push(`COALESCE(NULLIF(kcts.role_id, ''), ctx.role_id) ILIKE $${params.length}`) } if (dateFrom) { @@ -310,7 +376,8 @@ export async function listTasks({ SELECT COUNT(*)::int AS total FROM fulfillment_tasks ft LEFT JOIN order_items oi ON oi.id = ft.order_item_id - LEFT JOIN tencent_browser_contexts ctx ON ctx.task_id = ft.id + LEFT JOIN task_runtime_contexts ctx ON ctx.task_id = ft.id + LEFT JOIN kuaishou_cloud_task_states kcts ON kcts.task_id = ft.id ${whereClause} `, params, @@ -333,20 +400,20 @@ export async function listTasks({ } } -async function upsertTencentBrowserContextWithClient( +async function upsertTaskRuntimeContextWithClient( client: PoolClient, taskId: number | string, - patch: TaskTencentContextPatch = {}, + patch: TaskRuntimeContextPatch = {}, timestamp: string | undefined, ): Promise { - const currentResult = await client.query( - 'SELECT * FROM tencent_browser_contexts WHERE task_id = $1 LIMIT 1', + const currentResult = await client.query( + 'SELECT * FROM task_runtime_contexts WHERE task_id = $1 LIMIT 1', [Number(taskId)], ) const current = currentResult.rows[0] || null const next = { - browser_session_id: patch.browserSessionId ?? current?.browser_session_id ?? '', + runtime_session_id: patch.runtimeSessionId ?? current?.runtime_session_id ?? '', login_type: patch.loginType ?? current?.login_type ?? '', nickname: patch.nickname ?? current?.nickname ?? '', role_id: patch.roleId ?? current?.role_id ?? '', @@ -362,9 +429,9 @@ async function upsertTencentBrowserContextWithClient( if (!current) { await client.query( ` - INSERT INTO tencent_browser_contexts ( + INSERT INTO task_runtime_contexts ( task_id, - browser_session_id, + runtime_session_id, login_type, nickname, role_id, @@ -380,7 +447,7 @@ async function upsertTencentBrowserContextWithClient( `, [ Number(taskId), - next.browser_session_id, + next.runtime_session_id, next.login_type, next.nickname, next.role_id, @@ -399,9 +466,9 @@ async function upsertTencentBrowserContextWithClient( await client.query( ` - UPDATE tencent_browser_contexts + UPDATE task_runtime_contexts SET - browser_session_id = $1, + runtime_session_id = $1, login_type = $2, nickname = $3, role_id = $4, @@ -415,7 +482,7 @@ async function upsertTencentBrowserContextWithClient( WHERE task_id = $12 `, [ - next.browser_session_id, + next.runtime_session_id, next.login_type, next.nickname, next.role_id, @@ -444,9 +511,9 @@ async function getTaskByIdWithExecutor( return (result.rows[0] as TaskRow | undefined) || null } -function containsTencentPatch(patch: TaskUpdatePatch = {}): boolean { +function containsRuntimeContextPatch(patch: TaskUpdatePatch = {}): boolean { return [ - 'browser_session_id', + 'runtime_session_id', 'login_type', 'nickname', 'role_id', diff --git a/apps/backend/src/repositories/webhook-event-repo.ts b/apps/backend/src/repositories/webhook-event-repo.ts deleted file mode 100644 index 4b8220ef..00000000 --- a/apps/backend/src/repositories/webhook-event-repo.ts +++ /dev/null @@ -1,181 +0,0 @@ -import { query } from '../db/client.js' -import type { - WebhookEventCreateInput, - WebhookEventListQueryInput, - WebhookEventUpdatePatch, -} from '../types/repository-inputs.js' -import type { - WebhookEventListQueryResult, - WebhookEventRow, -} from '../types/repository-rows.js' - -export async function createWebhookEvent(input: WebhookEventCreateInput): Promise { - const result = await query( - ` - INSERT INTO webhook_events ( - provider, - platform, - shop_id, - shop_name, - event_type, - event_key, - signature_valid, - headers_json, - query_json, - body_json, - processed, - process_error, - related_order_id, - created_at - ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8::jsonb, $9::jsonb, $10::jsonb, $11, $12, $13, $14) - RETURNING * - `, - [ - input.provider, - input.platform, - input.shopId || '', - input.shopName || '', - input.eventType, - input.eventKey, - Boolean(input.signatureValid), - input.headersJson || '{}', - input.queryJson || '{}', - input.bodyJson || '{}', - Boolean(input.processed), - input.processError || '', - input.relatedOrderId || null, - input.createdAt, - ], - ) - - return result.rows[0] || null -} - -export async function updateWebhookEvent( - eventId: number | string, - patch: WebhookEventUpdatePatch, -): Promise { - const current = await getWebhookEventById(eventId) - if (!current) { - return null - } - - const next = { ...current, ...patch } - const result = await query( - ` - UPDATE webhook_events - SET - processed = $1, - process_error = $2, - related_order_id = $3 - WHERE id = $4 - RETURNING * - `, - [Boolean(next.processed), next.process_error || '', next.related_order_id || null, Number(eventId)], - ) - - return result.rows[0] || null -} - -export async function getWebhookEventById(eventId: number | string): Promise { - const result = await query('SELECT * FROM webhook_events WHERE id = $1 LIMIT 1', [ - Number(eventId), - ]) - return result.rows[0] || null -} - -export async function listWebhookEvents({ - page = 1, - pageSize = 20, - provider = '', - platform = '', - platformOrderId = '', - processed = '', - visibility = 'important', - relatedOrderId = '', - dateFrom = '', - dateTo = '', -}: WebhookEventListQueryInput = {}): Promise { - const offset = (page - 1) * pageSize - const filters: string[] = [] - const params: unknown[] = [] - - if (provider) { - params.push(provider) - filters.push(`provider = $${params.length}`) - } - - if (platform) { - params.push(platform) - filters.push(`platform = $${params.length}`) - } - - if (platformOrderId) { - params.push(`%${platformOrderId}%`) - filters.push(`(event_key ILIKE $${params.length} OR body_json::text ILIKE $${params.length})`) - } - - if (processed === '0' || processed === '1') { - params.push(processed === '1') - filters.push(`processed = $${params.length}`) - } - - if (visibility === 'ignored') { - filters.push(`process_error LIKE 'ignored_%'`) - } else if (visibility === 'important') { - filters.push(`(process_error = '' OR process_error NOT LIKE 'ignored_%')`) - } - - if (relatedOrderId) { - params.push(Number(relatedOrderId)) - filters.push(`related_order_id = $${params.length}`) - } - - if (dateFrom) { - params.push(dateFrom) - filters.push(`created_at >= $${params.length}`) - } - - if (dateTo) { - params.push(dateTo) - filters.push(`created_at <= $${params.length}`) - } - - const whereClause = filters.length > 0 ? `WHERE ${filters.join(' AND ')}` : '' - const totalResult = await query<{ [column: string]: unknown, total: number }>( - `SELECT COUNT(*)::int AS total FROM webhook_events ${whereClause}`, - params, - ) - - params.push(pageSize) - params.push(offset) - const itemsResult = await query( - ` - SELECT * - FROM webhook_events - ${whereClause} - ORDER BY id DESC - LIMIT $${params.length - 1} OFFSET $${params.length} - `, - params, - ) - - return { - items: itemsResult.rows, - total: Number(totalResult.rows[0]?.total || 0), - } -} - -export async function listWebhookEventsByOrderId(orderId: number | string): Promise { - const result = await query( - ` - SELECT * - FROM webhook_events - WHERE related_order_id = $1 - ORDER BY id DESC - `, - [Number(orderId)], - ) - - return result.rows -} diff --git a/apps/backend/src/routes/admin.ts b/apps/backend/src/routes/admin.ts index a0e80c04..26c36483 100644 --- a/apps/backend/src/routes/admin.ts +++ b/apps/backend/src/routes/admin.ts @@ -1,36 +1,30 @@ -import { Router } from 'express' +import { Router } from "express"; -import authRouter from './admin/auth.js' -import auditLogsRouter from './admin/audit-logs.js' -import dashboardRouter from './admin/dashboard.js' -import inventoryRouter from './admin/inventory.js' -import manualRedeemRouter from './admin/manual-redeem.js' -import messageDeliveriesRouter from './admin/message-deliveries.js' -import ordersRouter from './admin/orders.js' -import platformConfigRouter from './admin/platform-config.js' -import { requireAdminSession } from './admin/shared.js' -import tasksRouter from './admin/tasks.js' -import usersRouter from './admin/users.js' -import webhookEventsRouter from './admin/webhook-events.js' -import { buildNotFoundPayload } from '../utils/http.js' +import authRouter from "./admin/auth.js"; +import auditLogsRouter from "./admin/audit-logs.js"; +import cloudtentaclesRecordsRouter from "./admin/cloudtentacles-records.js"; +import dashboardRouter from "./admin/dashboard.js"; +import ordersRouter from "./admin/orders.js"; +import platformConfigRouter from "./admin/platform-config.js"; +import { requireAdminSession } from "./admin/session.js"; +import tasksRouter from "./admin/tasks.js"; +import usersRouter from "./admin/users.js"; +import { buildNotFoundPayload } from "../utils/http.js"; -const router = Router() +const router = Router(); -router.use(authRouter) -router.use(requireAdminSession) -router.use(dashboardRouter) -router.use(usersRouter) -router.use(auditLogsRouter) -router.use(platformConfigRouter) -router.use(ordersRouter) -router.use(tasksRouter) -router.use(manualRedeemRouter) -router.use(inventoryRouter) -router.use(messageDeliveriesRouter) -router.use(webhookEventsRouter) +router.use(authRouter); +router.use(requireAdminSession); +router.use(dashboardRouter); +router.use(usersRouter); +router.use(auditLogsRouter); +router.use(platformConfigRouter); +router.use(ordersRouter); +router.use(tasksRouter); +router.use(cloudtentaclesRecordsRouter); router.use((req, res) => { - res.status(404).json(buildNotFoundPayload(req)) -}) + res.status(404).json(buildNotFoundPayload(req)); +}); -export default router +export default router; diff --git a/apps/backend/src/routes/admin/audit-logs.ts b/apps/backend/src/routes/admin/audit-logs.ts index 1e9b7076..bbb6d7fb 100644 --- a/apps/backend/src/routes/admin/audit-logs.ts +++ b/apps/backend/src/routes/admin/audit-logs.ts @@ -1,7 +1,7 @@ import { Router } from 'express' import { getAdminAuditLogs } from '../../services/admin/admin-audit-service.js' -import { createJsonHandler, requireAdminRoles } from './shared.js' +import { createJsonHandler, requireAdminRoles } from './session.js' type AdminAuditLogRouteQuery = Record diff --git a/apps/backend/src/routes/admin/auth.ts b/apps/backend/src/routes/admin/auth.ts index ed210f73..dd039764 100644 --- a/apps/backend/src/routes/admin/auth.ts +++ b/apps/backend/src/routes/admin/auth.ts @@ -1,11 +1,17 @@ import { Router } from 'express' +import { createRateLimitMiddleware, getBodyFieldRateLimitKey } from '../../middleware/rate-limit.js' import { getAdminSessionSummary, loginAdmin } from '../../services/admin/admin-auth-service.js' -import { createJsonHandler, extractBearerToken } from './shared.js' +import { createJsonHandler, extractBearerToken } from './session.js' const router = Router() -router.post('/auth/login', createJsonHandler( +router.post('/auth/login', createRateLimitMiddleware({ + scope: 'admin:login', + windowMs: 60_000, + max: 10, + key: getBodyFieldRateLimitKey('username'), +}), createJsonHandler( (req) => loginAdmin(req.body?.username, req.body?.password), { successMessage: '登录成功', diff --git a/apps/backend/src/routes/admin/cloudtentacles-records.ts b/apps/backend/src/routes/admin/cloudtentacles-records.ts new file mode 100644 index 00000000..1e98061d --- /dev/null +++ b/apps/backend/src/routes/admin/cloudtentacles-records.ts @@ -0,0 +1,176 @@ +import crypto from 'node:crypto' +import fs from 'node:fs/promises' +import path from 'node:path' + +import { Router } from 'express' + +import { PROJECT_ROOT } from '../../config/runtime.js' +import { + listAdminCloudtentaclesDeliveryRecords, + listAdminCloudtentaclesRecordSources, +} from '../../services/admin/platform-config/cloudtentacles/index.js' +import { createJsonHandler, requireAdminRoles } from './session.js' +import { createHttpError, sendRouteError } from '../../utils/http.js' +import type { AdminCloudtentaclesDeliveryRecordQueryRouteBody } from '../../types/admin/route-inputs.js' + +const router = Router() +const RECORD_IMAGE_CACHE_DIR = path.join(PROJECT_ROOT, 'data', 'cache', 'cloudtentacles-record-images') + +router.get( + '/cloudtentacles-records/sources', + requireAdminRoles(['admin', 'operator', 'support']), + createJsonHandler(() => listAdminCloudtentaclesRecordSources(), { + successMessage: 'ok', + errorMessage: '读取 cloudtentacles 账号列表失败', + scope: '[admin/cloudtentacles-records/sources]', + }), +) + +router.post( + '/cloudtentacles-records', + requireAdminRoles(['admin', 'operator', 'support']), + createJsonHandler( + (req) => + listAdminCloudtentaclesDeliveryRecords( + req.body as AdminCloudtentaclesDeliveryRecordQueryRouteBody, + ), + { + successMessage: 'cloudtentacles 发货记录查询成功', + errorMessage: 'cloudtentacles 发货记录查询失败', + scope: '[admin/cloudtentacles-records]', + }, + ), +) + +router.get( + '/cloudtentacles-records/image', + requireAdminRoles(['admin', 'operator', 'support']), + async (req, res) => { + try { + const imageUrl = normalizeAllowedImageUrl(req.query.url) + const cachedImage = await getCachedRecordImage(imageUrl) + res.setHeader('Content-Type', cachedImage.contentType) + res.setHeader('Cache-Control', 'public, max-age=86400') + res.send(cachedImage.buffer) + } catch (error) { + sendRouteError( + res, + error, + '读取 cloudtentacles 商品图片失败', + '[admin/cloudtentacles-records/image]', + ) + } + }, +) + +export default router + +function normalizeAllowedImageUrl(value: unknown) { + const raw = String(value || '').trim() + if (!raw) { + throw createHttpError('缺少商品图片地址', { + statusCode: 400, + errorCode: 'cloudtentacles_record_image_url_required', + }) + } + + const url = new URL(raw) + if (!['https:', 'http:'].includes(url.protocol) || url.hostname !== 'gp.playinjoy.com') { + throw createHttpError('商品图片地址不在允许范围内', { + statusCode: 400, + errorCode: 'cloudtentacles_record_image_url_not_allowed', + }) + } + + return url.toString() +} + +async function getCachedRecordImage(imageUrl: string) { + const cacheMeta = buildRecordImageCacheMeta(imageUrl) + const cached = await readCachedRecordImage(cacheMeta) + if (cached) { + return cached + } + + const downloaded = await downloadRecordImage(imageUrl) + const filePath = cacheMeta.filePathForContentType(downloaded.contentType) + await fs.mkdir(RECORD_IMAGE_CACHE_DIR, { recursive: true }) + await fs.writeFile(filePath, downloaded.buffer) + return downloaded +} + +async function readCachedRecordImage(cacheMeta: ReturnType) { + for (const candidate of cacheMeta.candidateFilePaths) { + try { + const buffer = await fs.readFile(candidate.filePath) + return { + buffer, + contentType: candidate.contentType, + } + } catch (error) { + const code = String((error as NodeJS.ErrnoException).code || '') + if (code !== 'ENOENT') { + throw error + } + } + } + + return null +} + +async function downloadRecordImage(imageUrl: string) { + const response = await fetch(imageUrl, { + headers: { + accept: 'image/avif,image/webp,image/apng,image/svg+xml,image/*,*/*;q=0.8', + referer: 'https://gp.playinjoy.com/', + 'user-agent': + 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/122.0 Safari/537.36', + }, + }) + + if (!response.ok) { + throw createHttpError(`商品图片读取失败,HTTP ${response.status}`, { + statusCode: 502, + errorCode: 'cloudtentacles_record_image_fetch_failed', + }) + } + + const contentType = response.headers.get('content-type') || 'image/jpeg' + if (!contentType.startsWith('image/')) { + throw createHttpError('商品图片响应格式无效', { + statusCode: 502, + errorCode: 'cloudtentacles_record_image_invalid_content_type', + }) + } + + return { + buffer: Buffer.from(await response.arrayBuffer()), + contentType: normalizeImageContentType(contentType), + } +} + +function buildRecordImageCacheMeta(imageUrl: string) { + const digest = crypto.createHash('sha256').update(imageUrl).digest('hex') + const candidateTypes = ['image/jpeg', 'image/png', 'image/webp', 'image/gif'] + return { + candidateFilePaths: candidateTypes.map((contentType) => ({ + contentType, + filePath: path.join(RECORD_IMAGE_CACHE_DIR, `${digest}.${extensionForContentType(contentType)}`), + })), + filePathForContentType(contentType: string) { + return path.join(RECORD_IMAGE_CACHE_DIR, `${digest}.${extensionForContentType(contentType)}`) + }, + } +} + +function normalizeImageContentType(contentType: string) { + return String(contentType || 'image/jpeg').split(';')[0]?.trim().toLowerCase() || 'image/jpeg' +} + +function extensionForContentType(contentType: string) { + const normalized = normalizeImageContentType(contentType) + if (normalized === 'image/png') return 'png' + if (normalized === 'image/webp') return 'webp' + if (normalized === 'image/gif') return 'gif' + return 'jpg' +} diff --git a/apps/backend/src/routes/admin/dashboard.ts b/apps/backend/src/routes/admin/dashboard.ts index 5924be92..5ffc5d69 100644 --- a/apps/backend/src/routes/admin/dashboard.ts +++ b/apps/backend/src/routes/admin/dashboard.ts @@ -1,7 +1,7 @@ import { Router } from 'express' import { getAdminDashboardSummary } from '../../services/admin/admin-dashboard-service.js' -import { createJsonHandler } from './shared.js' +import { createJsonHandler } from './session.js' const router = Router() diff --git a/apps/backend/src/routes/admin/inventory.ts b/apps/backend/src/routes/admin/inventory.ts deleted file mode 100644 index 8f135c02..00000000 --- a/apps/backend/src/routes/admin/inventory.ts +++ /dev/null @@ -1,117 +0,0 @@ -import { Router } from 'express' - -import { - createAdminInventoryItem, - importAdminInventoryItems, - invalidateAdminInventoryItem, - releaseAdminInventoryItem, -} from '../../services/admin/admin-write-service.js' -import { - getAdminInventoryItems, - getAdminInventorySkuSuggestions, -} from '../../services/admin/admin-read-service.js' -import { createJsonHandler, requireAdminRoles } from './shared.js' -import type { - AdminInventoryCreateRouteBody, - AdminInventoryImportRouteBody, - AdminInventoryInvalidateRouteBody, - AdminInventoryRouteParams, - AdminInventoryRouteQuery, - AdminInventorySkuSuggestionRouteQuery, -} from '../../types/admin-route-inputs.js' -import type { AdminInventoryMutationResponse } from '../../types/admin-write-models.js' - -const router = Router() - -router.use('/inventory', requireAdminRoles(['admin', 'operator'])) - -router.get('/inventory', createJsonHandler( - (req) => getAdminInventoryItems( - req.query as AdminInventoryRouteQuery, - req.adminSession || null, - ), - { - successMessage: 'ok', - errorMessage: '读取库存列表失败', - scope: '[admin/inventory]', - }, -)) - -router.get('/inventory/sku-suggestions', createJsonHandler( - (req) => getAdminInventorySkuSuggestions( - req.query as AdminInventorySkuSuggestionRouteQuery, - req.adminSession || null, - ), - { - successMessage: 'ok', - errorMessage: '读取内部 SKU 建议失败', - scope: '[admin/inventory/sku-suggestions]', - }, -)) - -router.post('/inventory', createJsonHandler( - (req) => createAdminInventoryItem(req.body as AdminInventoryCreateRouteBody), - { - successMessage: '库存项已新增', - errorMessage: '新增库存项失败', - scope: '[admin/inventory]', - }, -)) - -router.post('/inventory/import', createJsonHandler( - (req) => importAdminInventoryItems(req.body as AdminInventoryImportRouteBody), - { - successMessage: '导入完成', - errorMessage: '导入库存凭据失败', - scope: '[admin/inventory/import]', - }, -)) - -router.post('/inventory/:inventoryItemId/release', requireAdminRoles(['admin']), createJsonHandler( - (req) => releaseAdminInventoryItem((req.params as AdminInventoryRouteParams).inventoryItemId), - { - successMessage: '库存项已释放', - errorMessage: '释放库存项失败', - scope: '[admin/inventory/:inventoryItemId/release]', - audit: (req, data) => { - const result = data as AdminInventoryMutationResponse - return { - action: 'inventory_item_released', - targetType: 'inventory_item', - targetId: String((req.params as AdminInventoryRouteParams).inventoryItemId), - data: { - inventoryItemId: result.inventoryItem?.inventoryItemId, - skuCode: result.inventoryItem?.skuCode, - displayValue: result.inventoryItem?.displayValue, - }, - } - }, - }, -)) - -router.post('/inventory/:inventoryItemId/invalidate', requireAdminRoles(['admin']), createJsonHandler( - (req) => invalidateAdminInventoryItem( - (req.params as AdminInventoryRouteParams).inventoryItemId, - req.body as AdminInventoryInvalidateRouteBody, - ), - { - successMessage: '库存项已作废', - errorMessage: '作废库存项失败', - scope: '[admin/inventory/:inventoryItemId/invalidate]', - audit: (req, data) => { - const result = data as AdminInventoryMutationResponse - return { - action: 'inventory_item_invalidated', - targetType: 'inventory_item', - targetId: String((req.params as AdminInventoryRouteParams).inventoryItemId), - data: { - inventoryItemId: result.inventoryItem?.inventoryItemId, - skuCode: result.inventoryItem?.skuCode, - invalidReason: result.inventoryItem?.invalidReason, - }, - } - }, - }, -)) - -export default router diff --git a/apps/backend/src/routes/admin/manual-redeem.ts b/apps/backend/src/routes/admin/manual-redeem.ts deleted file mode 100644 index dd4af358..00000000 --- a/apps/backend/src/routes/admin/manual-redeem.ts +++ /dev/null @@ -1,218 +0,0 @@ -import { Router } from 'express' - -import { - closeAdminManualRedeemSession, - closeAdminManualRedeemTask, - confirmAdminManualRedeemRole, - createAdminManualRedeemSession, - createAdminManualRedeemTask, - getAdminManualRedeemDetail, - getAdminManualRedeemSessionSummary, - reloadAdminManualRedeemSession, - redeemAdminManualRedeemTask, -} from '../../services/admin/admin-manual-redeem-service.js' -import { getAdminInventorySkuSuggestions } from '../../services/admin/admin-read-service.js' -import { createJsonHandler, requireAdminRoles } from './shared.js' -import type { - AdminInventorySkuSuggestionRouteQuery, - AdminManualRedeemCreateRouteBody, - AdminManualRedeemRouteParams, -} from '../../types/admin-route-inputs.js' - -type AdminManualRedeemSessionRouteBody = { - loginType?: string - forceRecreate?: boolean -} - -type AdminManualRedeemMutationResult = { - task?: { - taskId?: number - taskNo?: string - status?: string - deliveryStatus?: string - } - manualRequest?: { proofValue?: string } - order?: { platformOrderId?: string } - orderItem?: { skuCode?: string } -} - -const router = Router() - -router.use('/manual-redeem', requireAdminRoles(['admin', 'operator', 'support'])) - -router.get('/manual-redeem/sku-suggestions', createJsonHandler( - (req) => getAdminInventorySkuSuggestions( - req.query as AdminInventorySkuSuggestionRouteQuery, - req.adminSession || null, - ), - { - successMessage: 'ok', - errorMessage: '读取人工兑换 SKU 建议失败', - scope: '[admin/manual-redeem/sku-suggestions]', - }, -)) - -router.post('/manual-redeem', createJsonHandler( - (req) => createAdminManualRedeemTask( - req.body as AdminManualRedeemCreateRouteBody, - req.adminSession || null, - ), - { - successMessage: '人工兑换任务已创建', - errorMessage: '创建人工兑换任务失败', - scope: '[admin/manual-redeem]', - audit: (req, data) => { - const result = data as AdminManualRedeemMutationResult - return { - action: 'manual_redeem_created', - targetType: 'task', - targetId: String(result.task?.taskId || ''), - data: { - taskId: result.task?.taskId, - taskNo: result.task?.taskNo, - proofValue: result.manualRequest?.proofValue || result.order?.platformOrderId || '', - skuCode: result.orderItem?.skuCode || '', - }, - } - }, - }, -)) - -router.get('/manual-redeem/:taskId', createJsonHandler( - (req) => getAdminManualRedeemDetail( - (req.params as AdminManualRedeemRouteParams).taskId, - req.adminSession || null, - ), - { - successMessage: 'ok', - errorMessage: '读取人工兑换详情失败', - scope: '[admin/manual-redeem/:taskId]', - }, -)) - -router.post('/manual-redeem/:taskId/session', createJsonHandler( - (req) => createAdminManualRedeemSession( - (req.params as AdminManualRedeemRouteParams).taskId, - req.body as AdminManualRedeemSessionRouteBody, - req.adminSession || null, - ), - { - successMessage: '人工兑换登录会话已创建', - errorMessage: '创建人工兑换登录会话失败', - scope: '[admin/manual-redeem/:taskId/session]', - }, -)) - -router.get('/manual-redeem/:taskId/session/summary', createJsonHandler( - (req) => getAdminManualRedeemSessionSummary( - (req.params as AdminManualRedeemRouteParams).taskId, - req.adminSession || null, - ), - { - successMessage: 'ok', - errorMessage: '读取人工兑换会话摘要失败', - scope: '[admin/manual-redeem/:taskId/session/summary]', - }, -)) - -router.post('/manual-redeem/:taskId/session/refresh', createJsonHandler( - (req) => reloadAdminManualRedeemSession( - (req.params as AdminManualRedeemRouteParams).taskId, - req.adminSession || null, - ), - { - successMessage: '人工兑换会话已刷新', - errorMessage: '刷新人工兑换会话失败', - scope: '[admin/manual-redeem/:taskId/session/refresh]', - }, -)) - -router.delete('/manual-redeem/:taskId/session', createJsonHandler( - (req) => closeAdminManualRedeemSession( - (req.params as AdminManualRedeemRouteParams).taskId, - req.adminSession || null, - ), - { - successMessage: '人工兑换会话已关闭', - errorMessage: '关闭人工兑换会话失败', - scope: '[admin/manual-redeem/:taskId/session]', - }, -)) - -router.post('/manual-redeem/:taskId/confirm-role', createJsonHandler( - (req) => confirmAdminManualRedeemRole( - (req.params as AdminManualRedeemRouteParams).taskId, - req.adminSession || null, - ), - { - successMessage: '角色已确认', - errorMessage: '确认人工兑换角色失败', - scope: '[admin/manual-redeem/:taskId/confirm-role]', - audit: (req, data) => { - const result = data as AdminManualRedeemMutationResult - return { - action: 'manual_redeem_role_confirmed', - targetType: 'task', - targetId: String(result.task?.taskId || ''), - data: { - taskId: result.task?.taskId, - taskNo: result.task?.taskNo, - status: result.task?.status, - }, - } - }, - }, -)) - -router.post('/manual-redeem/:taskId/redeem', createJsonHandler( - (req) => redeemAdminManualRedeemTask( - (req.params as AdminManualRedeemRouteParams).taskId, - req.adminSession || null, - ), - { - successMessage: '人工兑换任务已启动', - errorMessage: '执行人工兑换失败', - scope: '[admin/manual-redeem/:taskId/redeem]', - audit: (req, data) => { - const result = data as AdminManualRedeemMutationResult - return { - action: 'manual_redeem_started', - targetType: 'task', - targetId: String(result.task?.taskId || ''), - data: { - taskId: result.task?.taskId, - taskNo: result.task?.taskNo, - status: result.task?.status, - deliveryStatus: result.task?.deliveryStatus, - }, - } - }, - }, -)) - -router.post('/manual-redeem/:taskId/close', createJsonHandler( - (req) => closeAdminManualRedeemTask( - (req.params as AdminManualRedeemRouteParams).taskId, - req.adminSession || null, - ), - { - successMessage: '人工兑换任务已关闭', - errorMessage: '关闭人工兑换任务失败', - scope: '[admin/manual-redeem/:taskId/close]', - audit: (req, data) => { - const result = data as AdminManualRedeemMutationResult - return { - action: 'manual_redeem_closed', - targetType: 'task', - targetId: String(result.task?.taskId || ''), - data: { - taskId: result.task?.taskId, - taskNo: result.task?.taskNo, - status: result.task?.status, - }, - } - }, - }, -)) - -export default router diff --git a/apps/backend/src/routes/admin/message-deliveries.ts b/apps/backend/src/routes/admin/message-deliveries.ts deleted file mode 100644 index e818e906..00000000 --- a/apps/backend/src/routes/admin/message-deliveries.ts +++ /dev/null @@ -1,18 +0,0 @@ -import { Router } from 'express' - -import { getAdminMessageDeliveries } from '../../services/admin/admin-message-delivery-service.js' -import { createJsonHandler } from './shared.js' -import type { AdminMessageDeliveryRouteQuery } from '../../types/admin-route-inputs.js' - -const router = Router() - -router.get('/message-deliveries', createJsonHandler( - (req) => getAdminMessageDeliveries(req.query as AdminMessageDeliveryRouteQuery), - { - successMessage: 'ok', - errorMessage: '读取消息发送记录失败', - scope: '[admin/message-deliveries]', - }, -)) - -export default router diff --git a/apps/backend/src/routes/admin/orders.ts b/apps/backend/src/routes/admin/orders.ts index bbdbc471..b45e6513 100644 --- a/apps/backend/src/routes/admin/orders.ts +++ b/apps/backend/src/routes/admin/orders.ts @@ -1,11 +1,11 @@ import { Router } from 'express' import { getAdminOrderDetail, getAdminOrders } from '../../services/admin/admin-read-service.js' -import { createJsonHandler } from './shared.js' +import { createJsonHandler } from './session.js' import type { AdminOrderRouteParams, AdminOrderRouteQuery, -} from '../../types/admin-route-inputs.js' +} from '../../types/admin/route-inputs.js' const router = Router() @@ -19,7 +19,7 @@ router.get('/orders', createJsonHandler( )) router.get('/orders/:orderId', createJsonHandler( - (req) => getAdminOrderDetail((req.params as AdminOrderRouteParams).orderId), + (req) => getAdminOrderDetail(String((req.params as AdminOrderRouteParams).orderId || '')), { successMessage: 'ok', errorMessage: '读取订单详情失败', diff --git a/apps/backend/src/routes/admin/platform-config.ts b/apps/backend/src/routes/admin/platform-config.ts index 3be79e58..c8555125 100644 --- a/apps/backend/src/routes/admin/platform-config.ts +++ b/apps/backend/src/routes/admin/platform-config.ts @@ -1,10 +1,7 @@ import { Router } from "express"; -import { requireAdminRoles } from "./shared.js"; -import agisoRouter from "./platform-config/agiso.js"; +import { requireAdminRoles } from "./session.js"; import cloudtentaclesRouter from "./platform-config/cloudtentacles.js"; -import fulfillmentBindingsRouter from "./platform-config/fulfillment-bindings.js"; -import kuaishouCloudFulfillmentRouter from "./platform-config/kuaishou-cloud-fulfillment.js"; import kuaishouEticketRouter from "./platform-config/kuaishou-eticket.js"; import ninetyoneRouter from "./platform-config/ninetyone.js"; import notificationsRouter from "./platform-config/notifications.js"; @@ -12,12 +9,9 @@ import notificationsRouter from "./platform-config/notifications.js"; const router = Router(); router.use("/platform-config", requireAdminRoles(["admin"])); -router.use("/platform-config", agisoRouter); router.use("/platform-config", notificationsRouter); router.use("/platform-config", kuaishouEticketRouter); router.use("/platform-config", ninetyoneRouter); router.use("/platform-config", cloudtentaclesRouter); -router.use("/platform-config", fulfillmentBindingsRouter); -router.use("/platform-config", kuaishouCloudFulfillmentRouter); export default router; diff --git a/apps/backend/src/routes/admin/platform-config/agiso.ts b/apps/backend/src/routes/admin/platform-config/agiso.ts deleted file mode 100644 index d92a7738..00000000 --- a/apps/backend/src/routes/admin/platform-config/agiso.ts +++ /dev/null @@ -1,47 +0,0 @@ -import { Router } from "express"; - -import { - getAdminAgisoShopConfigs, - updateAdminAgisoShopConfigs, -} from "../../../services/admin/platform-config/service.js"; -import type { AdminAgisoShopConfigRouteBody } from "../../../types/admin-route-inputs.js"; -import type { AdminAgisoShopConfigSaveResponse } from "../../../types/admin-write-models.js"; -import { createJsonHandler } from "../shared.js"; - -const router = Router(); - -router.get( - "/agiso-shops", - createJsonHandler(() => getAdminAgisoShopConfigs(), { - successMessage: "ok", - errorMessage: "读取 Agiso 店铺配置失败", - scope: "[admin/platform-config/agiso-shops]", - }) -); - -router.post( - "/agiso-shops", - createJsonHandler( - (req) => - updateAdminAgisoShopConfigs(req.body as AdminAgisoShopConfigRouteBody), - { - successMessage: "Agiso 店铺配置已保存", - errorMessage: "保存 Agiso 店铺配置失败", - scope: "[admin/platform-config/agiso-shops]", - audit: (_req, data) => { - const result = data as AdminAgisoShopConfigSaveResponse; - return { - action: "platform_shop_config_updated", - targetType: "platform_config", - targetId: "agiso_shops", - data: { - shopCount: result.shops.length, - filePath: result.filePath, - }, - }; - }, - } - ) -); - -export default router; diff --git a/apps/backend/src/routes/admin/platform-config/cloudtentacles.ts b/apps/backend/src/routes/admin/platform-config/cloudtentacles.ts index 26852a12..7edd814d 100644 --- a/apps/backend/src/routes/admin/platform-config/cloudtentacles.ts +++ b/apps/backend/src/routes/admin/platform-config/cloudtentacles.ts @@ -7,6 +7,7 @@ import { deleteAdminCloudtentaclesSource, fetchAdminCloudtentaclesVirtualNumberCode, generateAdminCloudtentaclesLoginCode, + getAdminCloudtentaclesOverrideRules, getAdminCloudtentaclesAsset, getAdminCloudtentaclesBindUrl, getAdminCloudtentaclesCategories, @@ -17,14 +18,16 @@ import { runAdminCloudtentaclesFullFlow, sendAdminCloudtentaclesSmsCode, testAdminCloudtentaclesLogin, + updateAdminCloudtentaclesOverrideRules, updateAdminCloudtentaclesSourceConfig, useAdminCloudtentaclesSku, validateAdminCloudtentaclesSession, verifyAdminCloudtentaclesLoginCode, -} from "../../../services/admin/platform-config/service.js"; +} from "../../../services/admin/platform-config/cloudtentacles/index.js"; import type { AdminCloudtentaclesCatalogQueryRouteBody, AdminCloudtentaclesFullFlowRouteBody, + AdminCloudtentaclesOverrideRuleConfigRouteBody, AdminCloudtentaclesSendSmsCodeRouteBody, AdminCloudtentaclesSkuBuyRouteBody, AdminCloudtentaclesSkuUseRouteBody, @@ -32,9 +35,9 @@ import type { AdminCloudtentaclesTestLoginRouteBody, AdminCloudtentaclesValidateSessionRouteBody, AdminCloudtentaclesVirtualNumberRouteBody, -} from "../../../types/admin-route-inputs.js"; -import { createJsonHandler } from "../shared.js"; -import type { JsonRecord } from "./shared.js"; +} from "../../../types/admin/route-inputs.js"; +import { createJsonHandler } from "../session.js"; +import type { JsonRecord } from "../../../types/json.js"; const router = Router(); @@ -96,6 +99,43 @@ router.post( ) ); +router.get( + "/cloudtentacles/override-rules", + createJsonHandler(() => getAdminCloudtentaclesOverrideRules(), { + successMessage: "ok", + errorMessage: "读取 cloudtentacles 覆盖规则失败", + scope: "[admin/platform-config/cloudtentacles/override-rules]", + }) +); + +router.post( + "/cloudtentacles/override-rules", + createJsonHandler( + (req) => + updateAdminCloudtentaclesOverrideRules( + req.body as AdminCloudtentaclesOverrideRuleConfigRouteBody + ), + { + successMessage: "cloudtentacles 覆盖规则已保存", + errorMessage: "保存 cloudtentacles 覆盖规则失败", + scope: "[admin/platform-config/cloudtentacles/override-rules]", + audit: (_req, data) => { + const result = data as JsonRecord; + return { + action: "platform_cloudtentacles_override_rules_updated", + targetType: "platform_config", + targetId: "cloudtentacles_override_rules", + data: { + filePath: String(result.filePath || "").trim(), + enabled: result.enabled !== false, + ruleCount: Array.isArray(result.rules) ? result.rules.length : 0, + }, + }; + }, + } + ) +); + router.post( "/cloudtentacles/send-sms-code", createJsonHandler( diff --git a/apps/backend/src/routes/admin/platform-config/fulfillment-bindings.ts b/apps/backend/src/routes/admin/platform-config/fulfillment-bindings.ts deleted file mode 100644 index 054b92dc..00000000 --- a/apps/backend/src/routes/admin/platform-config/fulfillment-bindings.ts +++ /dev/null @@ -1,98 +0,0 @@ -import { Router } from "express"; - -import { - getAdminFulfillmentBindingConfigs, - lookupAdminFulfillmentBindingOrder, - updateAdminFulfillmentBindingConfigs, -} from "../../../services/admin/platform-config/service.js"; -import type { - AdminFulfillmentBindingConfigRouteBody, - AdminFulfillmentBindingLookupRouteBody, -} from "../../../types/admin-route-inputs.js"; -import { createJsonHandler } from "../shared.js"; -import type { JsonRecord } from "./shared.js"; - -const router = Router(); - -router.get( - "/fulfillment-bindings", - createJsonHandler(() => getAdminFulfillmentBindingConfigs(), { - successMessage: "ok", - errorMessage: "读取履约配置失败", - scope: "[admin/platform-config/fulfillment-bindings]", - }) -); - -router.post( - "/fulfillment-bindings/lookup-order", - createJsonHandler( - (req) => - lookupAdminFulfillmentBindingOrder( - req.body as AdminFulfillmentBindingLookupRouteBody - ), - { - successMessage: "订单商品查询成功", - errorMessage: "手动查询订单商品失败", - scope: "[admin/platform-config/fulfillment-bindings/lookup-order]", - audit: (req, data) => { - const result = data as JsonRecord; - return { - action: "platform_fulfillment_order_lookup", - targetType: "platform_config", - targetId: [ - result.order?.provider || "unknown", - result.order?.platform || "unknown", - result.order?.shopId || "unknown", - result.order?.platformOrderId || "unknown", - ].join(":"), - data: { - itemCount: Array.isArray(result.items) ? result.items.length : 0, - shopId: - result.order?.shopId || - String( - (req.body as AdminFulfillmentBindingLookupRouteBody)?.shopId || - "" - ).trim(), - platformOrderId: - result.order?.platformOrderId || - String( - (req.body as AdminFulfillmentBindingLookupRouteBody) - ?.platformOrderId || "" - ).trim(), - }, - }; - }, - } - ) -); - -router.post( - "/fulfillment-bindings", - createJsonHandler( - (req) => - updateAdminFulfillmentBindingConfigs( - req.body as AdminFulfillmentBindingConfigRouteBody - ), - { - successMessage: "履约配置已保存", - errorMessage: "保存履约配置失败", - scope: "[admin/platform-config/fulfillment-bindings]", - audit: (_req, data) => { - const result = data as JsonRecord; - return { - action: "platform_fulfillment_config_updated", - targetType: "platform_config", - targetId: "fulfillment_bindings", - data: { - bindingCount: Array.isArray(result.bindings) - ? result.bindings.length - : 0, - filePath: result.filePath || "", - }, - }; - }, - } - ) -); - -export default router; diff --git a/apps/backend/src/routes/admin/platform-config/kuaishou-cloud-fulfillment.ts b/apps/backend/src/routes/admin/platform-config/kuaishou-cloud-fulfillment.ts deleted file mode 100644 index c76b106f..00000000 --- a/apps/backend/src/routes/admin/platform-config/kuaishou-cloud-fulfillment.ts +++ /dev/null @@ -1,51 +0,0 @@ -import { Router } from "express"; - -import { - getAdminKuaishouCloudFulfillmentConfig, - updateAdminKuaishouCloudFulfillmentConfig, -} from "../../../services/admin/platform-config/service.js"; -import type { AdminKuaishouCloudFulfillmentConfigRouteBody } from "../../../types/admin-route-inputs.js"; -import { createJsonHandler } from "../shared.js"; -import type { JsonRecord } from "./shared.js"; - -const router = Router(); - -router.get( - "/kuaishou-cloud-fulfillment", - createJsonHandler(() => getAdminKuaishouCloudFulfillmentConfig(), { - successMessage: "ok", - errorMessage: "读取新履约配置失败", - scope: "[admin/platform-config/kuaishou-cloud-fulfillment]", - }) -); - -router.post( - "/kuaishou-cloud-fulfillment", - createJsonHandler( - (req) => - updateAdminKuaishouCloudFulfillmentConfig( - req.body as AdminKuaishouCloudFulfillmentConfigRouteBody - ), - { - successMessage: "新履约配置已保存", - errorMessage: "保存新履约配置失败", - scope: "[admin/platform-config/kuaishou-cloud-fulfillment]", - audit: (_req, data) => { - const result = data as JsonRecord; - return { - action: "platform_kuaishou_cloud_fulfillment_updated", - targetType: "platform_config", - targetId: "kuaishou_cloud_fulfillment", - data: { - itemCount: Array.isArray(result.source?.items) - ? result.source.items.length - : 0, - filePath: String(result.filePath || "").trim(), - }, - }; - }, - } - ) -); - -export default router; diff --git a/apps/backend/src/routes/admin/platform-config/kuaishou-eticket.ts b/apps/backend/src/routes/admin/platform-config/kuaishou-eticket.ts index 0979b248..656d34c0 100644 --- a/apps/backend/src/routes/admin/platform-config/kuaishou-eticket.ts +++ b/apps/backend/src/routes/admin/platform-config/kuaishou-eticket.ts @@ -6,15 +6,15 @@ import { queryAdminKuaishouEticketDetail, queryAdminKuaishouEticketShopInfo, updateAdminKuaishouEticketSourceConfig, -} from "../../../services/admin/platform-config/service.js"; +} from "../../../services/admin/platform-config/kuaishou-eticket-service.js"; import type { AdminKuaishouEticketConsumeRouteBody, AdminKuaishouEticketDetailQueryRouteBody, AdminKuaishouEticketShopInfoRouteBody, AdminKuaishouEticketSourceConfigRouteBody, -} from "../../../types/admin-route-inputs.js"; -import { createJsonHandler } from "../shared.js"; -import type { JsonRecord } from "./shared.js"; +} from "../../../types/admin/route-inputs.js"; +import { createJsonHandler } from "../session.js"; +import type { JsonRecord } from "../../../types/json.js"; const router = Router(); diff --git a/apps/backend/src/routes/admin/platform-config/ninetyone.ts b/apps/backend/src/routes/admin/platform-config/ninetyone.ts index 51ffb0bb..d36db6ab 100644 --- a/apps/backend/src/routes/admin/platform-config/ninetyone.ts +++ b/apps/backend/src/routes/admin/platform-config/ninetyone.ts @@ -4,10 +4,10 @@ import { failAdminNinetyoneOrder, getAdminNinetyoneOrders, retryAdminNinetyoneOrder, -} from "../../../services/admin/platform-config/service.js"; -import type { AdminEntityRouteParams } from "../../../types/admin-route-inputs.js"; -import { createJsonHandler } from "../shared.js"; -import type { JsonRecord } from "./shared.js"; +} from "../../../services/admin/platform-config/ninetyone-service.js"; +import type { AdminEntityRouteParams } from "../../../types/admin/route-inputs.js"; +import { createJsonHandler } from "../session.js"; +import type { JsonRecord } from "../../../types/json.js"; const router = Router(); @@ -23,7 +23,7 @@ router.get( router.post( "/ninetyone/orders/:id/retry", createJsonHandler( - (req) => retryAdminNinetyoneOrder((req.params as AdminEntityRouteParams).id), + (req) => retryAdminNinetyoneOrder(String((req.params as AdminEntityRouteParams).id || "")), { successMessage: "91卡券订单已重试", errorMessage: "重试 91卡券订单失败", @@ -49,7 +49,7 @@ router.post( createJsonHandler( (req) => failAdminNinetyoneOrder( - (req.params as AdminEntityRouteParams).id, + String((req.params as AdminEntityRouteParams).id || ""), req.body as { reason?: string } ), { diff --git a/apps/backend/src/routes/admin/platform-config/notifications.ts b/apps/backend/src/routes/admin/platform-config/notifications.ts index 3a94680a..07f20686 100644 --- a/apps/backend/src/routes/admin/platform-config/notifications.ts +++ b/apps/backend/src/routes/admin/platform-config/notifications.ts @@ -7,15 +7,15 @@ import { testAdminNotification, updateAdminNotificationConfig, updateAdminScheduledJobsConfig, -} from "../../../services/admin/platform-config/service.js"; +} from "../../../services/admin/platform-config/notification-service.js"; import type { AdminEntityRouteParams, AdminNotificationConfigRouteBody, AdminNotificationTestRouteBody, AdminScheduledJobsConfigRouteBody, -} from "../../../types/admin-route-inputs.js"; -import { createJsonHandler } from "../shared.js"; -import type { JsonRecord } from "./shared.js"; +} from "../../../types/admin/route-inputs.js"; +import { createJsonHandler } from "../session.js"; +import type { JsonRecord } from "../../../types/json.js"; const router = Router(); diff --git a/apps/backend/src/routes/admin/shared.ts b/apps/backend/src/routes/admin/session.ts similarity index 87% rename from apps/backend/src/routes/admin/shared.ts rename to apps/backend/src/routes/admin/session.ts index a00e302f..11cc5c3d 100644 --- a/apps/backend/src/routes/admin/shared.ts +++ b/apps/backend/src/routes/admin/session.ts @@ -73,6 +73,17 @@ export function requireAdminRoles(allowedRoles: string[]) { } } +export function getRequiredAdminSession(req: Request): AdminSession { + if (req.adminSession) { + return req.adminSession + } + + throw createHttpError('未登录或登录已失效', { + statusCode: 401, + errorCode: 'admin_auth_required', + }) +} + async function recordAdminAudit( session: AdminSession | null | undefined, req: Request, @@ -112,5 +123,13 @@ export function extractBearerToken(req: Request): string { }) } - return matched[1] + const token = matched[1]?.trim() + if (!token) { + throw createHttpError('未登录或登录已失效', { + statusCode: 401, + errorCode: 'admin_auth_required', + }) + } + + return token } diff --git a/apps/backend/src/routes/admin/tasks.ts b/apps/backend/src/routes/admin/tasks.ts index f60f7b1d..5af2e215 100644 --- a/apps/backend/src/routes/admin/tasks.ts +++ b/apps/backend/src/routes/admin/tasks.ts @@ -1,433 +1,166 @@ -import { Router } from "express"; +import type { Request } from 'express' +import { Router } from 'express' +import { getAdminTaskDetail, getAdminTasks } from '../../services/admin/admin-read-service.js' +import { closeAdminTask } from '../../services/admin/write/task-actions.js' import { - closeAdminTask, - completeAdminTaskManualDispatch, - confirmAdminTaskAssistedRole, dispatchAdminTaskKuaishouCloudFulfillment, - markAdminTaskManualReview, prepareAdminTaskKuaishouCloudFulfillment, + rebindAdminTaskKuaishouCloudRole, refreshAdminTaskKuaishouCloudRoleInfo, - regenerateAdminTaskClaimLink, - redeemAdminTaskAssisted, - releaseAdminTaskInventoryBinding, - releaseAdminTaskInventory, returnNumberAdminTaskKuaishouCloudFulfillment, - retryAdminTask, -} from "../../services/admin/admin-write-service.js"; -import { - getAdminTaskDetail, - getAdminTaskScreenshotPath, - getAdminTasks, -} from "../../services/admin/admin-read-service.js"; -import { - createFileHandler, - createJsonHandler, - requireAdminRoles, -} from "./shared.js"; -import type { Request } from "express"; +} from '../../services/admin/write/kuaishou-cloud-actions.js' +import { createJsonHandler, requireAdminRoles } from './session.js' import type { AdminTaskKuaishouCloudDispatchRouteBody, - AdminTaskManualDispatchRouteBody, AdminTaskRouteParams, AdminTaskRouteQuery, -} from "../../types/admin-route-inputs.js"; -import type { - AdminTaskActionResponse, - AdminTaskBindingReleaseResponse, - AdminTaskManualDispatchResponse, -} from "../../types/admin-write-models.js"; +} from '../../types/admin/route-inputs.js' +import type { AdminTaskActionResponse } from '../../types/admin/write-models.js' -const router = Router(); +const router = Router() -function getTaskId(req: Request): string | undefined { - return (req.params as AdminTaskRouteParams).taskId; -} - -function getBindingId(req: Request): string | undefined { - return (req.params as AdminTaskRouteParams).bindingId; +function getTaskId(req: Request): string { + return String((req.params as AdminTaskRouteParams).taskId || '') } router.get( - "/tasks", + '/tasks', createJsonHandler( - (req) => - getAdminTasks(req.query as AdminTaskRouteQuery, req.adminSession || null), + (req) => getAdminTasks(req.query as AdminTaskRouteQuery, req.adminSession || null), { - successMessage: "ok", - errorMessage: "读取任务列表失败", - scope: "[admin/tasks]", - } - ) -); + successMessage: 'ok', + errorMessage: '读取任务列表失败', + scope: '[admin/tasks]', + }, + ), +) router.get( - "/tasks/:taskId", + '/tasks/:taskId', + createJsonHandler((req) => getAdminTaskDetail(getTaskId(req), req.adminSession || null), { + successMessage: 'ok', + errorMessage: '读取任务详情失败', + scope: '[admin/tasks/:taskId]', + }), +) + +router.post( + '/tasks/:taskId/close', + requireAdminRoles(['admin', 'operator', 'support']), + createJsonHandler((req) => closeAdminTask(getTaskId(req), req.adminSession || null), { + successMessage: '任务已关闭', + errorMessage: '关闭任务失败', + scope: '[admin/tasks/:taskId/close]', + audit: (req, data) => buildTaskAudit('task_closed', req, data), + }), +) + +router.post( + '/tasks/:taskId/kuaishou-cloud/prepare', + requireAdminRoles(['admin', 'operator']), createJsonHandler( - (req) => getAdminTaskDetail(getTaskId(req), req.adminSession || null), + (req) => prepareAdminTaskKuaishouCloudFulfillment(getTaskId(req), req.adminSession || null), { - successMessage: "ok", - errorMessage: "读取任务详情失败", - scope: "[admin/tasks/:taskId]", - } - ) -); - -router.get( - "/tasks/:taskId/screenshot", - createFileHandler( - (req) => - getAdminTaskScreenshotPath(getTaskId(req), req.adminSession || null), - { - errorMessage: "读取任务截图失败", - scope: "[admin/tasks/:taskId/screenshot]", - } - ) -); - -router.post( - "/tasks/:taskId/retry", - requireAdminRoles(["admin", "operator"]), - createJsonHandler((req) => retryAdminTask(getTaskId(req)), { - successMessage: "任务已重试", - errorMessage: "重试任务失败", - scope: "[admin/tasks/:taskId/retry]", - }) -); - -router.post( - "/tasks/:taskId/release-inventory", - requireAdminRoles(["admin"]), - createJsonHandler((req) => releaseAdminTaskInventory(getTaskId(req)), { - successMessage: "库存凭据已释放", - errorMessage: "释放库存凭据失败", - scope: "[admin/tasks/:taskId/release-inventory]", - audit: (req, data) => { - const result = data as AdminTaskActionResponse; - return { - action: "task_release_inventory", - targetType: "task", - targetId: String(getTaskId(req)), - data: { - taskId: result.task.taskId, - taskNo: result.task.taskNo, - inventoryItemId: result.task.inventoryItemId, - }, - }; + successMessage: '绑定资源已准备完成', + errorMessage: '准备绑定资源失败', + scope: '[admin/tasks/:taskId/kuaishou-cloud/prepare]', + audit: (req, data) => buildTaskAudit('task_kuaishou_cloud_prepare', req, data), }, - }) -); + ), +) router.post( - "/tasks/:taskId/inventory-bindings/:bindingId/release", - requireAdminRoles(["admin"]), + '/tasks/:taskId/kuaishou-cloud/refresh-role-info', + requireAdminRoles(['admin', 'operator', 'support']), createJsonHandler( - (req) => - releaseAdminTaskInventoryBinding(getTaskId(req), getBindingId(req)), + (req) => refreshAdminTaskKuaishouCloudRoleInfo(getTaskId(req), req.adminSession || null), { - successMessage: "库存绑定已释放", - errorMessage: "释放库存绑定失败", - scope: "[admin/tasks/:taskId/inventory-bindings/:bindingId/release]", - audit: (req, data) => { - const result = data as AdminTaskBindingReleaseResponse; - return { - action: "task_release_inventory_binding", - targetType: "task_inventory_binding", - targetId: String(getBindingId(req)), - data: { - taskId: result.task.taskId, - taskNo: result.task.taskNo, - bindingId: result.bindingId, - inventoryItemId: result.inventoryItemId, - }, - }; - }, - } - ) -); - -router.post( - "/tasks/:taskId/regenerate-claim-link", - requireAdminRoles(["admin", "operator", "support"]), - createJsonHandler( - (req) => - regenerateAdminTaskClaimLink(getTaskId(req), req.adminSession || null), - { - successMessage: "领取链接已重新生成", - errorMessage: "重新生成领取链接失败", - scope: "[admin/tasks/:taskId/regenerate-claim-link]", - audit: (req, data) => { - const result = data as AdminTaskActionResponse; - return { - action: "task_regenerate_claim_link", - targetType: "task", - targetId: String(getTaskId(req)), - data: { - taskId: result.task.taskId, - taskNo: result.task.taskNo, - primaryClaimTokenId: result.task.primaryClaimTokenId, - }, - }; - }, - } - ) -); - -router.post( - "/tasks/:taskId/support-confirm-role", - requireAdminRoles(["admin", "operator", "support"]), - createJsonHandler( - (req) => - confirmAdminTaskAssistedRole(getTaskId(req), req.adminSession || null), - { - successMessage: "角色已确认", - errorMessage: "客服确认角色失败", - scope: "[admin/tasks/:taskId/support-confirm-role]", - audit: (req, data) => { - const result = data as AdminTaskActionResponse; - return { - action: "task_support_confirm_role", - targetType: "task", - targetId: String(getTaskId(req)), - data: { - taskId: result.task.taskId, - taskNo: result.task.taskNo, - status: result.task.status, - }, - }; - }, - } - ) -); - -router.post( - "/tasks/:taskId/support-redeem", - requireAdminRoles(["admin", "operator", "support"]), - createJsonHandler( - (req) => redeemAdminTaskAssisted(getTaskId(req), req.adminSession || null), - { - successMessage: "兑换任务已启动", - errorMessage: "客服发起兑换失败", - scope: "[admin/tasks/:taskId/support-redeem]", - audit: (req, data) => { - const result = data as AdminTaskActionResponse; - return { - action: "task_support_redeem", - targetType: "task", - targetId: String(getTaskId(req)), - data: { - taskId: result.task.taskId, - taskNo: result.task.taskNo, - status: result.task.status, - deliveryStatus: result.task.deliveryStatus, - }, - }; - }, - } - ) -); - -router.post( - "/tasks/:taskId/close", - requireAdminRoles(["admin", "operator", "support"]), - createJsonHandler((req) => closeAdminTask(getTaskId(req)), { - successMessage: "任务已关闭", - errorMessage: "关闭任务失败", - scope: "[admin/tasks/:taskId/close]", - audit: (req, data) => { - const result = data as AdminTaskActionResponse; - return { - action: "task_closed", - targetType: "task", - targetId: String(getTaskId(req)), - data: { - taskId: result.task.taskId, - taskNo: result.task.taskNo, - status: result.task.status, - }, - }; + successMessage: '角色信息已刷新', + errorMessage: '刷新角色信息失败', + scope: '[admin/tasks/:taskId/kuaishou-cloud/refresh-role-info]', + audit: (req, data) => buildTaskAudit('task_kuaishou_cloud_role_info_refreshed', req, data), }, - }) -); + ), +) router.post( - "/tasks/:taskId/mark-manual-review", - requireAdminRoles(["admin", "operator"]), - createJsonHandler((req) => markAdminTaskManualReview(getTaskId(req)), { - successMessage: "任务已转人工处理", - errorMessage: "标记人工处理失败", - scope: "[admin/tasks/:taskId/mark-manual-review]", - audit: (req, data) => { - const result = data as AdminTaskActionResponse; - return { - action: "task_mark_manual_review", - targetType: "task", - targetId: String(getTaskId(req)), - data: { - taskId: result.task.taskId, - taskNo: result.task.taskNo, - status: result.task.status, - }, - }; + '/tasks/:taskId/kuaishou-cloud/rebind-role', + requireAdminRoles(['admin', 'operator']), + createJsonHandler( + (req) => rebindAdminTaskKuaishouCloudRole(getTaskId(req), req.adminSession || null), + { + successMessage: '角色换绑资源已准备完成', + errorMessage: '换绑角色失败', + scope: '[admin/tasks/:taskId/kuaishou-cloud/rebind-role]', + audit: (req, data) => buildTaskAudit('task_kuaishou_cloud_rebind_role', req, data), }, - }) -); + ), +) router.post( - "/tasks/:taskId/complete-manual-dispatch", - requireAdminRoles(["admin", "operator"]), - createJsonHandler( - (req) => - completeAdminTaskManualDispatch( - getTaskId(req), - req.body as AdminTaskManualDispatchRouteBody, - req.adminSession || null - ), - { - successMessage: "人工履约结果已回写", - errorMessage: "回写人工履约结果失败", - scope: "[admin/tasks/:taskId/complete-manual-dispatch]", - audit: (req, data) => { - const result = data as AdminTaskManualDispatchResponse; - return { - action: "task_complete_manual_dispatch", - targetType: "task", - targetId: String(getTaskId(req)), - data: { - taskId: result.task.taskId, - taskNo: result.task.taskNo, - outcome: result.outcome, - deliveryStatus: result.task.deliveryStatus, - resultCode: result.task.resultCode, - }, - }; - }, - } - ) -); - -router.post( - "/tasks/:taskId/kuaishou-cloud/prepare", - requireAdminRoles(["admin", "operator"]), - createJsonHandler( - (req) => - prepareAdminTaskKuaishouCloudFulfillment( - getTaskId(req), - req.adminSession || null - ), - { - successMessage: "绑定资源已准备完成", - errorMessage: "准备绑定资源失败", - scope: "[admin/tasks/:taskId/kuaishou-cloud/prepare]", - audit: (req, data) => { - const result = data as AdminTaskActionResponse; - return { - action: "task_kuaishou_cloud_prepare", - targetType: "task", - targetId: String(getTaskId(req)), - data: { - taskId: result.task.taskId, - taskNo: result.task.taskNo, - status: result.task.status, - }, - }; - }, - } - ) -); - -router.post( - "/tasks/:taskId/kuaishou-cloud/refresh-role-info", - requireAdminRoles(["admin", "operator", "support"]), - createJsonHandler( - (req) => - refreshAdminTaskKuaishouCloudRoleInfo( - getTaskId(req), - req.adminSession || null - ), - { - successMessage: "角色信息已刷新", - errorMessage: "刷新角色信息失败", - scope: "[admin/tasks/:taskId/kuaishou-cloud/refresh-role-info]", - audit: (req, data) => { - const result = data as AdminTaskActionResponse; - return { - action: "task_kuaishou_cloud_role_info_refreshed", - targetType: "task", - targetId: String(getTaskId(req)), - data: { - taskId: result.task.taskId, - taskNo: result.task.taskNo, - status: result.task.status, - }, - }; - }, - } - ) -); - -router.post( - "/tasks/:taskId/kuaishou-cloud/dispatch", - requireAdminRoles(["admin", "operator"]), + '/tasks/:taskId/kuaishou-cloud/dispatch', + requireAdminRoles(['admin', 'operator']), createJsonHandler( (req) => dispatchAdminTaskKuaishouCloudFulfillment( getTaskId(req), req.body as AdminTaskKuaishouCloudDispatchRouteBody, - req.adminSession || null + req.adminSession || null, ), { - successMessage: "已完成绑定确认并发货", - errorMessage: "执行发货失败", - scope: "[admin/tasks/:taskId/kuaishou-cloud/dispatch]", + successMessage: '已完成绑定确认并发货', + errorMessage: '执行发货失败', + scope: '[admin/tasks/:taskId/kuaishou-cloud/dispatch]', audit: (req, data) => { - const result = data as AdminTaskActionResponse; - const body = req.body as AdminTaskKuaishouCloudDispatchRouteBody; + const result = data as AdminTaskActionResponse + const body = req.body as AdminTaskKuaishouCloudDispatchRouteBody + return { - action: "task_kuaishou_cloud_dispatch", - targetType: "task", + action: 'task_kuaishou_cloud_dispatch', + targetType: 'task', targetId: String(getTaskId(req)), data: { taskId: result.task.taskId, taskNo: result.task.taskNo, status: result.task.status, deliveryStatus: result.task.deliveryStatus, - ticketCodeProvided: Boolean(String(body.ticketCode || "").trim()), + ticketCodeProvided: Boolean(String(body.ticketCode || '').trim()), }, - }; + } }, - } - ) -); + }, + ), +) router.post( - "/tasks/:taskId/kuaishou-cloud/return-number", - requireAdminRoles(["admin", "operator"]), + '/tasks/:taskId/kuaishou-cloud/return-number', + requireAdminRoles(['admin', 'operator']), createJsonHandler( (req) => - returnNumberAdminTaskKuaishouCloudFulfillment( - getTaskId(req), - req.adminSession || null - ), + returnNumberAdminTaskKuaishouCloudFulfillment(getTaskId(req), req.adminSession || null), { - successMessage: "号码已退还", - errorMessage: "退还号码失败", - scope: "[admin/tasks/:taskId/kuaishou-cloud/return-number]", - audit: (req, data) => { - const result = data as AdminTaskActionResponse; - return { - action: "task_kuaishou_cloud_return_number", - targetType: "task", - targetId: String(getTaskId(req)), - data: { - taskId: result.task.taskId, - taskNo: result.task.taskNo, - status: result.task.status, - }, - }; - }, - } - ) -); + successMessage: '号码已退还', + errorMessage: '退还号码失败', + scope: '[admin/tasks/:taskId/kuaishou-cloud/return-number]', + audit: (req, data) => buildTaskAudit('task_kuaishou_cloud_return_number', req, data), + }, + ), +) -export default router; +function buildTaskAudit(action: string, req: Request, data: unknown) { + const result = data as AdminTaskActionResponse + + return { + action, + targetType: 'task', + targetId: String(getTaskId(req)), + data: { + taskId: result.task.taskId, + taskNo: result.task.taskNo, + status: result.task.status, + }, + } +} + +export default router diff --git a/apps/backend/src/routes/admin/users.ts b/apps/backend/src/routes/admin/users.ts index bf55f15f..6f3c9b2f 100644 --- a/apps/backend/src/routes/admin/users.ts +++ b/apps/backend/src/routes/admin/users.ts @@ -4,11 +4,10 @@ import { createManagedAdminUser, getAdminUserList, resetManagedAdminUserPassword, - updateManagedAdminUserInventoryGroups, updateManagedAdminUserRole, updateManagedAdminUserStatus, } from '../../services/admin/admin-auth-service.js' -import { createJsonHandler, requireAdminRoles } from './shared.js' +import { createJsonHandler, getRequiredAdminSession, requireAdminRoles } from './session.js' type AdminUserMutationResult = { user: { @@ -16,7 +15,6 @@ type AdminUserMutationResult = { username: string role: string status?: string - inventoryGroupCodes?: string[] } } @@ -56,7 +54,7 @@ router.post('/users', createJsonHandler( )) router.post('/users/:userId/role', createJsonHandler( - (req) => updateManagedAdminUserRole(String(req.params.userId), req.body, req.adminSession), + (req) => updateManagedAdminUserRole(String(req.params.userId || ''), req.body, getRequiredAdminSession(req)), { successMessage: '用户角色已更新', errorMessage: '更新用户角色失败', @@ -77,7 +75,7 @@ router.post('/users/:userId/role', createJsonHandler( )) router.post('/users/:userId/status', createJsonHandler( - (req) => updateManagedAdminUserStatus(String(req.params.userId), req.body, req.adminSession), + (req) => updateManagedAdminUserStatus(String(req.params.userId || ''), req.body, getRequiredAdminSession(req)), { successMessage: '用户状态已更新', errorMessage: '更新用户状态失败', @@ -97,29 +95,8 @@ router.post('/users/:userId/status', createJsonHandler( }, )) -router.post('/users/:userId/inventory-groups', createJsonHandler( - (req) => updateManagedAdminUserInventoryGroups(String(req.params.userId), req.body, req.adminSession), - { - successMessage: '用户库存组已更新', - errorMessage: '更新用户库存组失败', - scope: '[admin/users/:userId/inventory-groups]', - audit: (_req, data) => { - const result = data as AdminUserMutationResult - return { - action: 'admin_user_inventory_groups_updated', - targetType: 'admin_user', - targetId: String(result.user.userId), - data: { - username: result.user.username, - inventoryGroupCodes: result.user.inventoryGroupCodes, - }, - } - }, - }, -)) - router.post('/users/:userId/reset-password', createJsonHandler( - (req) => resetManagedAdminUserPassword(String(req.params.userId), req.body), + (req) => resetManagedAdminUserPassword(String(req.params.userId || ''), req.body), { successMessage: '用户密码已重置', errorMessage: '重置用户密码失败', diff --git a/apps/backend/src/routes/admin/webhook-events.ts b/apps/backend/src/routes/admin/webhook-events.ts deleted file mode 100644 index 212b6aee..00000000 --- a/apps/backend/src/routes/admin/webhook-events.ts +++ /dev/null @@ -1,60 +0,0 @@ -import { Router } from 'express' - -import { - replayAdminWebhookEvent, -} from '../../services/admin/admin-write-service.js' -import { - getAdminWebhookEventDetail, - getAdminWebhookEvents, -} from '../../services/admin/admin-read-service.js' -import { createJsonHandler, requireAdminRoles } from './shared.js' -import type { - AdminWebhookEventRouteParams, - AdminWebhookEventRouteQuery, -} from '../../types/admin-route-inputs.js' -import type { AdminWebhookReplayResponse } from '../../types/admin-write-models.js' - -const router = Router() - -router.use('/webhook-events', requireAdminRoles(['admin', 'operator'])) - -router.get('/webhook-events', createJsonHandler( - (req) => getAdminWebhookEvents(req.query as AdminWebhookEventRouteQuery), - { - successMessage: 'ok', - errorMessage: '读取 webhook 列表失败', - scope: '[admin/webhook-events]', - }, -)) - -router.get('/webhook-events/:eventId', createJsonHandler( - (req) => getAdminWebhookEventDetail((req.params as AdminWebhookEventRouteParams).eventId), - { - successMessage: 'ok', - errorMessage: '读取 webhook 详情失败', - scope: '[admin/webhook-events/:eventId]', - }, -)) - -router.post('/webhook-events/:eventId/replay', requireAdminRoles(['admin']), createJsonHandler( - (req) => replayAdminWebhookEvent((req.params as AdminWebhookEventRouteParams).eventId), - { - successMessage: 'Webhook 已重放', - errorMessage: '重放 webhook 失败', - scope: '[admin/webhook-events/:eventId/replay]', - audit: (req, data) => { - const result = data as AdminWebhookReplayResponse - return { - action: 'webhook_replayed', - targetType: 'webhook_event', - targetId: String((req.params as AdminWebhookEventRouteParams).eventId), - data: { - eventId: result.eventId, - replayed: result.replayed, - }, - } - }, - }, -)) - -export default router diff --git a/apps/backend/src/routes/claims.ts b/apps/backend/src/routes/claims.ts index f1654d19..9e866c55 100644 --- a/apps/backend/src/routes/claims.ts +++ b/apps/backend/src/routes/claims.ts @@ -1,94 +1,86 @@ import { Router } from 'express' -import { - closeClaimSession, - confirmClaimRole, - createClaimSession, - getClaimDetail, - getClaimScreenshotPath, - getClaimSessionSummary, - reloadClaimSession, - redeemClaimTask, -} from '../services/claim/claim-session-service.js' +import { createRateLimitMiddleware, getParamRateLimitKey } from '../middleware/rate-limit.js' import { confirmKuaishouCloudClaimRole, + getKuaishouCloudClaimDetail, getKuaishouCloudClaimGuideAssetPath, + rebindKuaishouCloudClaimRole, redeemKuaishouCloudClaim, verifyKuaishouCloudClaimTicket, } from '../services/claim/kuaishou-cloud-claim-service.js' -import { - buildNotFoundPayload, - buildSuccessPayload, - createRouteFileHandler, - createRouteHandler, - sendRouteError, -} from '../utils/http.js' +import { buildNotFoundPayload, createRouteFileHandler, createRouteHandler } from '../utils/http.js' const router = Router() - -router.get('/:token', createRouteHandler( - (req) => getClaimDetail(req.params.token), - { errorMessage: '查询领取详情失败', scope: '[claims/:token]' }, -)) - -router.post('/:token/session', createRouteHandler( - (req) => createClaimSession(req.params.token, req.body), - { successMessage: '浏览器会话已创建', errorMessage: '创建领取会话失败', scope: '[claims/:token/session]' }, -)) - -router.get('/:token/session/summary', createRouteHandler( - (req) => getClaimSessionSummary(req.params.token), - { errorMessage: '查询领取会话摘要失败', scope: '[claims/:token/session/summary]' }, -)) - -router.post('/:token/session/refresh', async (req, res) => { - try { - const data = await reloadClaimSession(req.params.token) - res.json(buildSuccessPayload(data, data.session ? (data.session.notice || '后端页面已刷新') : '领取会话已重置')) - } catch (error) { - sendRouteError(res, error, '刷新领取会话失败', '[claims/:token/session/refresh]') - } +const claimReadRateLimit = createRateLimitMiddleware({ + scope: 'claim:read', + windowMs: 60_000, + max: 120, + key: getParamRateLimitKey('token'), +}) +const claimWriteRateLimit = createRateLimitMiddleware({ + scope: 'claim:write', + windowMs: 60_000, + max: 30, + key: getParamRateLimitKey('token'), }) -router.delete('/:token/session', createRouteHandler( - (req) => closeClaimSession(req.params.token), - { successMessage: '领取会话已关闭', errorMessage: '关闭领取会话失败', scope: '[claims/:token/session]' }, -)) +router.get( + '/:token', + claimReadRateLimit, + createRouteHandler((req) => getKuaishouCloudClaimDetail(req.params.token), { + errorMessage: '查询快手领取详情失败', + scope: '[claims/:token]', + }), +) -router.post('/:token/confirm-role', createRouteHandler( - (req) => confirmClaimRole(req.params.token), - { successMessage: '角色已确认', errorMessage: '确认角色失败', scope: '[claims/:token/confirm-role]' }, -)) +router.post( + '/:token/kuaishou-cloud/verify-ticket', + claimWriteRateLimit, + createRouteHandler((req) => verifyKuaishouCloudClaimTicket(req.params.token, req.body), { + successMessage: '核销码提交并核销成功', + errorMessage: '提交核销码失败', + scope: '[claims/:token/kuaishou-cloud/verify-ticket]', + }), +) -router.post('/:token/redeem', createRouteHandler( - (req) => redeemClaimTask(req.params.token), - { successMessage: '兑换完成', errorMessage: '执行领取兑换失败', scope: '[claims/:token/redeem]' }, -)) +router.post( + '/:token/kuaishou-cloud/confirm-role', + claimWriteRateLimit, + createRouteHandler((req) => confirmKuaishouCloudClaimRole(req.params.token), { + successMessage: '角色已确认', + errorMessage: '确认角色失败', + scope: '[claims/:token/kuaishou-cloud/confirm-role]', + }), +) -router.post('/:token/kuaishou-cloud/verify-ticket', createRouteHandler( - (req) => verifyKuaishouCloudClaimTicket(req.params.token, req.body), - { successMessage: '核销码校验成功', errorMessage: '校验核销码失败', scope: '[claims/:token/kuaishou-cloud/verify-ticket]' }, -)) +router.post( + '/:token/kuaishou-cloud/rebind-role', + claimWriteRateLimit, + createRouteHandler((req) => rebindKuaishouCloudClaimRole(req.params.token), { + successMessage: '角色换绑资源已准备完成', + errorMessage: '换绑角色失败', + scope: '[claims/:token/kuaishou-cloud/rebind-role]', + }), +) -router.post('/:token/kuaishou-cloud/confirm-role', createRouteHandler( - (req) => confirmKuaishouCloudClaimRole(req.params.token), - { successMessage: '角色已确认', errorMessage: '确认角色失败', scope: '[claims/:token/kuaishou-cloud/confirm-role]' }, -)) +router.post( + '/:token/kuaishou-cloud/redeem', + claimWriteRateLimit, + createRouteHandler((req) => redeemKuaishouCloudClaim(req.params.token), { + successMessage: '兑换请求已提交', + errorMessage: '兑换失败', + scope: '[claims/:token/kuaishou-cloud/redeem]', + }), +) -router.post('/:token/kuaishou-cloud/redeem', createRouteHandler( - (req) => redeemKuaishouCloudClaim(req.params.token), - { successMessage: '兑换请求已提交', errorMessage: '兑换失败', scope: '[claims/:token/kuaishou-cloud/redeem]' }, -)) - -router.get('/:token/screenshot', createRouteFileHandler( - (req) => getClaimScreenshotPath(req.params.token), - { errorMessage: '读取领取截图失败', scope: '[claims/:token/screenshot]' }, -)) - -router.get('/assets/kuaishou-cloud/:filename', createRouteFileHandler( - (req) => getKuaishouCloudClaimGuideAssetPath(req.params.filename), - { errorMessage: '读取指引图片失败', scope: '[claims/assets/kuaishou-cloud/:filename]' }, -)) +router.get( + '/assets/kuaishou-cloud/:filename', + createRouteFileHandler((req) => getKuaishouCloudClaimGuideAssetPath(req.params.filename), { + errorMessage: '读取指引图片失败', + scope: '[claims/assets/kuaishou-cloud/:filename]', + }), +) router.use((req, res) => { res.status(404).json(buildNotFoundPayload(req)) diff --git a/apps/backend/src/routes/open-91.ts b/apps/backend/src/routes/open-91.ts index 86fb9e66..4320e4ee 100644 --- a/apps/backend/src/routes/open-91.ts +++ b/apps/backend/src/routes/open-91.ts @@ -1,17 +1,34 @@ import { Router } from 'express' +import { createRateLimitMiddleware } from '../middleware/rate-limit.js' import { createOpen91Order } from '../services/open-91/order-create-service.js' import { queryOpen91Order } from '../services/open-91/order-query-service.js' -import { buildOpen91ErrorResponse } from '../services/open-91/shared.js' -import { createRequestId, logWebhook } from '../utils/logger.js' +import { buildOpen91ErrorResponse } from '../services/open-91/response.js' +import { createRequestId, logIntegration } from '../utils/logger.js' const router = Router() +const open91RateLimit = createRateLimitMiddleware({ + scope: 'open91', + windowMs: 60_000, + max: 120, + onLimit: (_req, res) => { + res.status(200).json(buildOpen91ErrorResponse('请求过于频繁,请稍后再试', 429)) + }, +}) -router.post('/orders/create', async (req, res) => { +function resolveErrorStatusCode(error: unknown): number { + if (!error || typeof error !== 'object' || !('statusCode' in error)) { + return 500 + } + + return Number(error.statusCode || 500) +} + +router.post('/orders/create', open91RateLimit, async (req, res) => { const requestId = createRequestId('91') const startedAt = Date.now() - logWebhook('[open-91/create]', '收到 91卡券异步下单请求', { + logIntegration('[open-91/create]', '收到 91卡券异步下单请求', { requestId, method: req.method, originalUrl: req.originalUrl, @@ -21,7 +38,7 @@ router.post('/orders/create', async (req, res) => { try { const result = await createOpen91Order(req.body, { requestId }) - logWebhook('[open-91/create]', '91卡券异步下单处理完成', { + logIntegration('[open-91/create]', '91卡券异步下单处理完成', { requestId, durationMs: Date.now() - startedAt, result, @@ -29,8 +46,8 @@ router.post('/orders/create', async (req, res) => { res.status(200).json(result) } catch (error) { const message = error instanceof Error ? error.message : '系统错误' - const code = Number(error?.statusCode || 500) >= 500 ? 500 : 400 - logWebhook('[open-91/create]', '91卡券异步下单处理失败', { + const code = resolveErrorStatusCode(error) >= 500 ? 500 : 400 + logIntegration('[open-91/create]', '91卡券异步下单处理失败', { requestId, durationMs: Date.now() - startedAt, error, @@ -39,11 +56,11 @@ router.post('/orders/create', async (req, res) => { } }) -router.post('/orders/query', async (req, res) => { +router.post('/orders/query', open91RateLimit, async (req, res) => { const requestId = createRequestId('91') const startedAt = Date.now() - logWebhook('[open-91/query]', '收到 91卡券订单查询请求', { + logIntegration('[open-91/query]', '收到 91卡券订单查询请求', { requestId, method: req.method, originalUrl: req.originalUrl, @@ -53,7 +70,7 @@ router.post('/orders/query', async (req, res) => { try { const result = await queryOpen91Order(req.body, { requestId }) - logWebhook('[open-91/query]', '91卡券订单查询处理完成', { + logIntegration('[open-91/query]', '91卡券订单查询处理完成', { requestId, durationMs: Date.now() - startedAt, result, @@ -61,8 +78,8 @@ router.post('/orders/query', async (req, res) => { res.status(200).json(result) } catch (error) { const message = error instanceof Error ? error.message : '系统错误' - const code = Number(error?.statusCode || 500) >= 500 ? 500 : 400 - logWebhook('[open-91/query]', '91卡券订单查询处理失败', { + const code = resolveErrorStatusCode(error) >= 500 ? 500 : 400 + logIntegration('[open-91/query]', '91卡券订单查询处理失败', { requestId, durationMs: Date.now() - startedAt, error, diff --git a/apps/backend/src/routes/tencent.ts b/apps/backend/src/routes/tencent.ts deleted file mode 100644 index a4125e5d..00000000 --- a/apps/backend/src/routes/tencent.ts +++ /dev/null @@ -1,77 +0,0 @@ -import { Router } from 'express' - -import { - closeTencentBrowserSession, - createTencentBrowserSession, - getTencentBrowserSession, - getTencentBrowserSessionSummary, - getTencentBrowserSessionScreenshotPath, - reloadTencentBrowserSession, - redeemTencentBrowserSession, -} from '../services/session/session.js' -import { - buildNotFoundPayload, - buildSuccessPayload, - createRouteFileHandler, - createRouteHandler, - sendRouteError, -} from '../utils/http.js' - -const router = Router() - -router.post('/browser/session', createRouteHandler( - (req) => createTencentBrowserSession(req.body), - { successMessage: '浏览器会话已创建', errorMessage: '创建浏览器会话失败', scope: '[browser/session]' }, -)) - -router.get('/browser/session/:sessionId', async (req, res) => { - try { - const data = await getTencentBrowserSession(req.params.sessionId) - res.json(buildSuccessPayload(data, data.notice || 'ok')) - } catch (error) { - sendRouteError(res, error, '查询浏览器会话失败', '[browser/session/:sessionId]') - } -}) - -router.get('/browser/session/:sessionId/summary', async (req, res) => { - try { - const data = await getTencentBrowserSessionSummary(req.params.sessionId) - res.json(buildSuccessPayload(data, data.notice || 'ok')) - } catch (error) { - sendRouteError(res, error, '查询浏览器会话摘要失败', '[browser/session/:sessionId/summary]') - } -}) - -router.post('/browser/session/:sessionId/refresh', async (req, res) => { - try { - const data = await reloadTencentBrowserSession(req.params.sessionId) - res.json(buildSuccessPayload(data, data.notice || '后端页面已刷新')) - } catch (error) { - sendRouteError(res, error, '刷新后端页面失败', '[browser/session/:sessionId/refresh]') - } -}) - -router.post('/browser/session/:sessionId/redeem', async (req, res) => { - try { - const data = await redeemTencentBrowserSession(req.params.sessionId, req.body) - res.json(buildSuccessPayload(data, data.notice || '兑换完成')) - } catch (error) { - sendRouteError(res, error, '浏览器会话兑换失败', '[browser/session/:sessionId/redeem]') - } -}) - -router.get('/browser/session/:sessionId/screenshot', createRouteFileHandler( - (req) => getTencentBrowserSessionScreenshotPath(req.params.sessionId), - { errorMessage: '读取兑换截图失败', scope: '[browser/session/:sessionId/screenshot]' }, -)) - -router.delete('/browser/session/:sessionId', createRouteHandler( - (req) => closeTencentBrowserSession(req.params.sessionId), - { successMessage: '浏览器会话已关闭', errorMessage: '关闭浏览器会话失败', scope: '[browser/session/:sessionId]' }, -)) - -router.use((req, res) => { - res.status(404).json(buildNotFoundPayload(req)) -}) - -export default router diff --git a/apps/backend/src/routes/webhooks.ts b/apps/backend/src/routes/webhooks.ts deleted file mode 100644 index 9c2c3aee..00000000 --- a/apps/backend/src/routes/webhooks.ts +++ /dev/null @@ -1,57 +0,0 @@ -import { Router } from 'express' - -import { processAgisoTradeWebhook } from '../services/order/webhook-service.js' -import { createRequestId, logWebhook } from '../utils/logger.js' -import { buildNotFoundPayload, buildSuccessPayload, sendRouteError } from '../utils/http.js' - -const router = Router() - -router.post('/agiso/trade', async (req, res) => { - const requestId = createRequestId('wh') - const startedAt = Date.now() - - logWebhook('[webhooks/agiso/trade]', '收到 Agiso webhook 请求', { - requestId, - method: req.method, - originalUrl: req.originalUrl, - ip: req.ip, - headers: req.headers, - query: req.query, - body: req.body, - }) - - try { - const data = await processAgisoTradeWebhook({ - requestId, - headers: req.headers, - query: req.query, - body: req.body, - }) - - logWebhook('[webhooks/agiso/trade]', 'Agiso webhook 处理完成', { - requestId, - durationMs: Date.now() - startedAt, - result: data, - }) - - res.json(buildSuccessPayload(data, 'success')) - } catch (error) { - logWebhook( - '[webhooks/agiso/trade]', - 'Agiso webhook 处理失败', - { - requestId, - durationMs: Date.now() - startedAt, - error, - }, - { level: 'error' }, - ) - sendRouteError(res, error, '处理 Agiso 订单通知失败', '[webhooks/agiso/trade]') - } -}) - -router.use((req, res) => { - res.status(404).json(buildNotFoundPayload(req)) -}) - -export default router diff --git a/apps/backend/src/services/admin/admin-auth-service.ts b/apps/backend/src/services/admin/admin-auth-service.ts index eba3150b..4fb558ba 100644 --- a/apps/backend/src/services/admin/admin-auth-service.ts +++ b/apps/backend/src/services/admin/admin-auth-service.ts @@ -7,7 +7,6 @@ import { getAdminUserById, getAdminUserByUsername, listAdminUsers, - replaceAdminUserInventoryGroupBindings, updateAdminUser, } from '../../repositories/admin-user-repo.js' import { addHours, nowIso } from '../../utils/time.js' @@ -23,8 +22,8 @@ export type AdminSession = { userId: number username: string role: AdminRole - allowedInventoryGroups: string[] expiresAt: string + sessionVersion: number } type AdminRole = 'admin' | 'operator' | 'support' @@ -139,13 +138,22 @@ export async function verifyAdminSessionToken(token: unknown): Promise { - const user = await getRequiredAdminUser(userId) - const inventoryGroupCodes = normalizeInventoryGroupCodes(payload.inventoryGroupCodes) - - await ensureAdminUserChangeAllowed(user, {}, session) - const updated = await replaceAdminUserInventoryGroupBindings(user.id, inventoryGroupCodes, nowIso()) - - return { - user: mapAdminUser(updated), - } -} - export async function updateManagedAdminUserRole( userId: number | string, payload: JsonObject = {}, @@ -268,8 +265,15 @@ export async function updateManagedAdminUserRole( await ensureAdminUserChangeAllowed(user, { nextRole: role }, session) const updated = await updateAdminUser(user.id, { role, + session_version: nextAdminSessionVersion(user), updated_at: nowIso(), }) + if (!updated) { + throw createHttpError('后台用户更新失败', { + statusCode: 500, + errorCode: 'admin_user_update_failed', + }) + } return { user: mapAdminUser(updated), @@ -293,8 +297,15 @@ export async function updateManagedAdminUserStatus( await ensureAdminUserChangeAllowed(user, { nextStatus: status }, session) const updated = await updateAdminUser(user.id, { status, + session_version: nextAdminSessionVersion(user), updated_at: nowIso(), }) + if (!updated) { + throw createHttpError('后台用户更新失败', { + statusCode: 500, + errorCode: 'admin_user_update_failed', + }) + } return { user: mapAdminUser(updated), @@ -315,8 +326,15 @@ export async function resetManagedAdminUserPassword(userId: number | string, pay validatePassword(password) const updated = await updateAdminUser(user.id, { password_hash: hashAdminPassword(password), + session_version: nextAdminSessionVersion(user), updated_at: nowIso(), }) + if (!updated) { + throw createHttpError('后台用户更新失败', { + statusCode: 500, + errorCode: 'admin_user_update_failed', + }) + } return { user: mapAdminUser(updated), @@ -345,6 +363,7 @@ function createAdminSession(user: AdminUserRow): JsonObject { uid: Number(user.id), usr: String(user.username || ''), role: normalizeAdminRole(user.role), + ver: normalizeAdminSessionVersion(user.session_version), iat: issuedAt, exp: expiresAt, } @@ -358,7 +377,6 @@ function createAdminSession(user: AdminUserRow): JsonObject { userId: Number(user.id), username: String(user.username || ''), role: normalizeAdminRole(user.role), - inventoryGroupCodes: normalizeInventoryGroupCodes(user.inventory_group_codes), }, } } @@ -451,6 +469,15 @@ function validatePassword(password: string): void { } } +function normalizeAdminSessionVersion(value: unknown): number { + const parsed = Number(value) + return Number.isInteger(parsed) && parsed > 0 ? parsed : 1 +} + +function nextAdminSessionVersion(user: AdminUserRow): number { + return normalizeAdminSessionVersion(user.session_version) + 1 +} + async function getRequiredAdminUser(userId: number | string): Promise { const user = await getAdminUserById(Number(userId)) @@ -487,20 +514,13 @@ async function ensureAdminUserChangeAllowed( } } -function mapAdminUser(user: AdminUserRow | null): JsonObject { +function mapAdminUser(user: AdminUserRow): JsonObject { return { userId: Number(user.id), username: String(user.username || ''), role: normalizeAdminRole(user.role), status: normalizeAdminUserStatus(user.status), - inventoryGroupCodes: normalizeInventoryGroupCodes(user.inventory_group_codes), createdAt: user.created_at, updatedAt: user.updated_at, } } - -function normalizeInventoryGroupCodes(values: unknown): string[] { - return Array.from(new Set((Array.isArray(values) ? values : []) - .map((value) => String(value || '').trim()) - .filter(Boolean))) -} diff --git a/apps/backend/src/services/admin/admin-dashboard-service.ts b/apps/backend/src/services/admin/admin-dashboard-service.ts index 79dc6dc4..11933c89 100644 --- a/apps/backend/src/services/admin/admin-dashboard-service.ts +++ b/apps/backend/src/services/admin/admin-dashboard-service.ts @@ -1,10 +1,26 @@ import { query } from '../../db/client.js' +import { TASK_STATUS } from '../../domain/task-status.js' import { nowIso } from '../../utils/time.js' type JsonObject = Record export async function getAdminDashboardSummary() { const todayPrefix = nowIso().slice(0, 10) + const paidPendingClaimStatuses = [ + TASK_STATUS.PAID, + TASK_STATUS.LINK_GENERATED, + TASK_STATUS.PENDING_BINDING_PREPARE, + TASK_STATUS.WAITING_BINDING, + ] + const claimingStatuses = [ + TASK_STATUS.CLAIMED, + TASK_STATUS.ROLE_CONFIRMED, + TASK_STATUS.REDEEMING, + ] + const abnormalStatuses = [ + TASK_STATUS.RETRY_PENDING, + TASK_STATUS.MANUAL_REVIEW, + ] const result = await query( ` SELECT @@ -12,32 +28,32 @@ export async function getAdminDashboardSummary() { ( SELECT COUNT(*)::int FROM fulfillment_tasks - WHERE task_status IN ('paid', 'link_generated') - OR (inventory_status = 'reserved' AND task_status NOT IN ('claimed', 'role_confirmed', 'redeeming', 'redeemed', 'closed', 'expired')) + WHERE task_status = ANY($2::text[]) ) AS paid_pending_claim, ( SELECT COUNT(*)::int FROM fulfillment_tasks - WHERE task_status IN ('claimed', 'role_confirmed', 'redeeming') + WHERE task_status = ANY($3::text[]) ) AS claiming_tasks, ( SELECT COUNT(*)::int FROM fulfillment_tasks - WHERE task_status = 'redeemed' + WHERE task_status = $4 AND to_char(updated_at AT TIME ZONE 'Asia/Shanghai', 'YYYY-MM-DD') = $1 ) AS redeemed_today, ( SELECT COUNT(*)::int FROM fulfillment_tasks - WHERE task_status IN ('retry_pending', 'manual_review', 'waiting_inventory') - ) AS abnormal_tasks, - ( - SELECT COUNT(DISTINCT sku_code)::int - FROM inventory_items - WHERE status = 'available' - ) AS sku_with_inventory + WHERE task_status = ANY($5::text[]) + ) AS abnormal_tasks `, - [todayPrefix], + [ + todayPrefix, + paidPendingClaimStatuses, + claimingStatuses, + TASK_STATUS.REDEEMED, + abnormalStatuses, + ], ) const summary: JsonObject = result.rows[0] || {} @@ -47,6 +63,5 @@ export async function getAdminDashboardSummary() { claimingTasks: Number(summary?.claiming_tasks || 0), redeemedToday: Number(summary?.redeemed_today || 0), abnormalTasks: Number(summary?.abnormal_tasks || 0), - skuWithInventory: Number(summary?.sku_with_inventory || 0), } } diff --git a/apps/backend/src/services/admin/admin-inventory-read-helpers.ts b/apps/backend/src/services/admin/admin-inventory-read-helpers.ts deleted file mode 100644 index 1f9c8aec..00000000 --- a/apps/backend/src/services/admin/admin-inventory-read-helpers.ts +++ /dev/null @@ -1,46 +0,0 @@ -import { getInventoryItemById } from '../../repositories/inventory-repo.js' -import { getOrderById } from '../../repositories/order-repo.js' -import { getTaskById } from '../../repositories/task-repo.js' -import { createHttpError } from '../../utils/http.js' -import { mapAdminTaskSummary } from './admin-task-read-helpers.js' - -import type { AdminInventoryItemListItem } from '../../types/admin-read-models.js' -import type { InventoryItemRow } from '../../types/repository-rows.js' - -export async function mapAdminInventoryListItem(item: InventoryItemRow): Promise { - const task = item.reserved_by_task_id ? await getTaskById(item.reserved_by_task_id) : null - const order = task?.order_id ? await getOrderById(task.order_id) : null - const taskSummary = task ? mapAdminTaskSummary(task) : null - - return { - inventoryItemId: item.id, - skuCode: item.sku_code, - batchNo: item.batch_no, - credentialType: item.credential_type || 'tencent_code', - inventoryGroupCode: String(item.inventory_group_code || '').trim(), - displayValue: item.display_value, - status: item.status, - reservedByTaskId: item.reserved_by_task_id, - reservedByTaskNo: task?.task_no || '', - platformOrderId: order?.platform_order_id || '', - systemBindingStatus: item.status === 'consumed' ? 'system_bound' : (taskSummary?.systemBindingStatus || 'pending_binding'), - userBindingStatus: item.status === 'consumed' ? 'binding_completed' : (taskSummary?.userBindingStatus || 'not_started'), - invalidReason: item.invalid_reason || '', - deliveredAt: item.delivered_at, - createdAt: item.created_at, - updatedAt: item.updated_at, - } -} - -export async function getRequiredInventoryItem(inventoryItemId: number | string): Promise { - const inventoryItem = await getInventoryItemById(Number(inventoryItemId)) - - if (!inventoryItem) { - throw createHttpError('库存项不存在', { - statusCode: 404, - errorCode: 'admin_inventory_not_found', - }) - } - - return inventoryItem -} diff --git a/apps/backend/src/services/admin/admin-manual-redeem-service.ts b/apps/backend/src/services/admin/admin-manual-redeem-service.ts deleted file mode 100644 index bd58651a..00000000 --- a/apps/backend/src/services/admin/admin-manual-redeem-service.ts +++ /dev/null @@ -1,434 +0,0 @@ -// @ts-check - -import { createTaskClaimToken } from '../claim/claim-service.js' -import { - closeClaimSessionForAdminTask, - createClaimSessionForAdminTask, - getClaimDetailForAdminTask, - getClaimSessionSummaryForAdminTask, - reloadClaimSessionForAdminTask, -} from '../claim/claim-session-service.js' -import { reserveInventoryForTask } from '../order/inventory-service.js' -import { createHttpError } from '../../utils/http.js' -import { replaceOrderItems } from '../../repositories/order-item-repo.js' -import { createOrder, findOrderByPlatformOrderId } from '../../repositories/order-repo.js' -import { getFulfillmentProfileByKey, listFulfillmentProfileRequirements } from '../../repositories/fulfillment-profile-repo.js' -import { findFirstAvailableInventoryItemBySkuCode } from '../../repositories/inventory-repo.js' -import { createTask, getTaskById, updateTask } from '../../repositories/task-repo.js' -import { createTaskEvent } from '../../repositories/task-event-repo.js' -import { nowIso } from '../../utils/time.js' -import { randomId } from '../../utils/random.js' -import { closeAdminTask, confirmAdminTaskAssistedRole, redeemAdminTaskAssisted } from './admin-write-service.js' -import { createAdminViewerContext, isAssistedClaimTask, parseTaskContext } from './admin-read-shared-helpers.js' - -import type { - AdminEntityIdInput, - AdminViewerSessionInput, -} from '../../types/admin-read-inputs.js' -import type { AdminManualRedeemCreateInput } from '../../types/admin-write-inputs.js' -import type { TaskRow } from '../../types/repository-rows.js' - -const MANUAL_PROVIDER = 'manual' -const MANUAL_PLATFORM = 'manual_redeem' -const MANUAL_SOURCE_TYPE = 'admin_manual_redeem' -const ASSISTED_PROFILE_KEY = 'tencent_claim_assisted' - -type JsonObject = Record - -export async function createAdminManualRedeemTask( - payload: AdminManualRedeemCreateInput = {}, - session: AdminViewerSessionInput | null = null, -): Promise { - const viewerContext = createAdminViewerContext(session) - const proofValue = normalizeManualProofValue(payload.proofValue) - const skuCode = String(payload.skuCode || '').trim() - const skuName = String(payload.skuName || '').trim() || skuCode - const remark = String(payload.remark || '').trim() - - if (!proofValue) { - throw createHttpError('请先输入唯一凭据', { - statusCode: 400, - errorCode: 'admin_manual_redeem_missing_proof', - }) - } - - if (!skuCode) { - throw createHttpError('请先选择内部履约 SKU', { - statusCode: 400, - errorCode: 'admin_manual_redeem_missing_sku', - }) - } - - const existingOrder = await findOrderByPlatformOrderId({ - provider: MANUAL_PROVIDER, - platform: MANUAL_PLATFORM, - shopId: '', - platformOrderId: proofValue, - }) - - if (existingOrder) { - throw createHttpError('该唯一凭据已经创建过人工兑换任务,请勿重复提交', { - statusCode: 409, - errorCode: 'admin_manual_redeem_duplicate_proof', - }) - } - - const profile = await getFulfillmentProfileByKey(ASSISTED_PROFILE_KEY) - if (!profile) { - throw createHttpError('人工兑换所需的履约档案不存在,请先检查系统初始化', { - statusCode: 500, - errorCode: 'admin_manual_redeem_profile_missing', - }) - } - - const requirements = await listFulfillmentProfileRequirements(profile.id) - const primaryRequirement = requirements.find((item) => item.is_required !== false) || requirements[0] || null - const credentialType = String(primaryRequirement?.credential_type || primaryRequirement?.credentialType || 'tencent_code').trim() || 'tencent_code' - const roleKey = String(primaryRequirement?.role_key || primaryRequirement?.roleKey || 'primary_code').trim() || 'primary_code' - const availableInventory = await findFirstAvailableInventoryItemBySkuCode( - skuCode, - credentialType, - viewerContext.allowedInventoryGroupCodes, - ) - - if (!availableInventory) { - if (viewerContext.restrictInventoryGroups && viewerContext.allowedInventoryGroupCodes?.length === 0) { - throw createHttpError('当前客服未绑定任何库存组,无法创建人工兑换任务', { - statusCode: 403, - errorCode: 'admin_manual_redeem_inventory_group_unbound', - }) - } - - throw createHttpError('当前 SKU 没有可用库存,无法创建人工兑换任务', { - statusCode: 409, - errorCode: 'admin_manual_redeem_inventory_unavailable', - }) - } - - const now = nowIso() - const manualContext = { - sourceType: MANUAL_SOURCE_TYPE, - proofValue, - remark, - createdAt: now, - createdBy: session - ? { - userId: Number(session.userId || 0) || 0, - username: String(session.username || '').trim(), - role: String(session.role || '').trim(), - } - : null, - } - - let order - try { - order = await createOrder({ - provider: MANUAL_PROVIDER, - platform: MANUAL_PLATFORM, - shopId: '', - shopName: '人工兑换', - platformOrderId: proofValue, - orderStatus: 'manual_created', - payStatus: 'paid', - buyerId: '', - buyerName: '', - receiverContact: '', - totalAmount: 0, - currency: 'CNY', - rawPayloadJson: JSON.stringify({ - manualRedeem: manualContext, - }), - paidAt: now, - createdAt: now, - updatedAt: now, - }) - } catch (error) { - if (String(error?.code || '') === '23505') { - throw createHttpError('该唯一凭据已经创建过人工兑换任务,请勿重复提交', { - statusCode: 409, - errorCode: 'admin_manual_redeem_duplicate_proof', - }) - } - - throw error - } - - if (!order) { - throw createHttpError('人工兑换订单创建失败', { - statusCode: 500, - errorCode: 'admin_manual_redeem_order_create_failed', - }) - } - - const orderItems = await replaceOrderItems(order.id, [ - { - skuCode, - skuName, - quantity: 1, - specJson: JSON.stringify({ - title: skuName, - sourceType: MANUAL_SOURCE_TYPE, - }), - itemSnapshotJson: JSON.stringify({ - manualRedeem: manualContext, - skuCode, - skuName, - }), - createdAt: now, - updatedAt: now, - }, - ]) - - const orderItem = orderItems[0] - if (!orderItem) { - throw createHttpError('人工兑换订单商品创建失败', { - statusCode: 500, - errorCode: 'admin_manual_redeem_order_item_create_failed', - }) - } - - const createdTask = await createTask({ - orderId: order.id, - orderItemId: orderItem.id, - unitIndex: 1, - provider: MANUAL_PROVIDER, - platform: MANUAL_PLATFORM, - shopId: '', - shopName: '人工兑换', - platformOrderId: proofValue, - taskNo: randomId('MR'), - profileId: Number(profile.id), - executorKey: String(profile.executor_key || ASSISTED_PROFILE_KEY), - taskStatus: 'paid', - inventoryStatus: 'pending', - deliveryStatus: 'pending', - resultCode: '', - resultMessage: '', - claimToken: '', - claimExpiresAt: null, - automationMode: 'manual', - requiresClaim: true, - userActionStatus: 'pending_claim', - attemptCount: 0, - lastError: '', - contextJson: JSON.stringify({ - profileKey: String(profile.profile_key || ASSISTED_PROFILE_KEY), - profileName: String(profile.name || ''), - skuCode, - skuName, - inventorySkuCode: skuCode, - primaryRequirement: primaryRequirement - ? { - roleKey, - credentialType, - } - : null, - inventoryGroupCode: String(availableInventory.inventory_group_code || '').trim(), - manualRedeem: manualContext, - }), - createdAt: now, - updatedAt: now, - }) - - if (!createdTask) { - throw createHttpError('人工兑换任务创建失败', { - statusCode: 500, - errorCode: 'admin_manual_redeem_task_create_failed', - }) - } - - const reservedInventory = await reserveInventoryForTask({ - skuCode, - taskId: createdTask.id, - credentialType, - roleKey, - inventoryGroupCodes: viewerContext.allowedInventoryGroupCodes, - }) - - if (!reservedInventory) { - await updateTask(createdTask.id, { - task_status: 'waiting_inventory', - inventory_status: 'pending', - last_error: '库存不足,无法为人工兑换任务预占库存', - updated_at: nowIso(), - }) - - throw createHttpError('库存刚刚被其他任务占用,请重新选择或稍后再试', { - statusCode: 409, - errorCode: 'admin_manual_redeem_inventory_race_lost', - }) - } - - const claimToken = await createTaskClaimToken(createdTask.id) - await updateTask(createdTask.id, { - task_status: 'link_generated', - inventory_status: 'reserved', - claim_token: claimToken.token, - claim_expires_at: claimToken.expired_at, - user_action_status: 'pending_claim', - last_error: '', - context_json: JSON.stringify({ - ...parseTaskContext(createdTask), - inventoryGroupCode: String(reservedInventory.inventory_group_code || '').trim(), - }), - updated_at: nowIso(), - }) - - await createTaskEvent(createdTask.id, 'manual_redeem_created', { - sourceType: MANUAL_SOURCE_TYPE, - proofValue, - skuCode, - skuName, - inventoryItemId: Number(reservedInventory.id || 0) || null, - inventoryGroupCode: String(reservedInventory.inventory_group_code || '').trim(), - createdBy: manualContext.createdBy, - remark, - }, nowIso()) - - return getAdminManualRedeemDetail(createdTask.id, session) -} - -export async function getAdminManualRedeemDetail( - taskId: AdminEntityIdInput, - session: AdminViewerSessionInput | null = null, -): Promise { - const task = await getRequiredManualRedeemTask(taskId, session) - const detail = await getClaimDetailForAdminTask(task.id) - return decorateManualRedeemDetail(task, detail) -} - -export async function createAdminManualRedeemSession( - taskId: AdminEntityIdInput, - payload: { loginType?: string, forceRecreate?: boolean } = {}, - session: AdminViewerSessionInput | null = null, -): Promise { - const task = await getRequiredManualRedeemTask(taskId, session) - const detail = await createClaimSessionForAdminTask(task.id, payload) - return decorateManualRedeemDetail(task, detail) -} - -export async function getAdminManualRedeemSessionSummary( - taskId: AdminEntityIdInput, - session: AdminViewerSessionInput | null = null, -): Promise { - const task = await getRequiredManualRedeemTask(taskId, session) - const detail = await getClaimSessionSummaryForAdminTask(task.id) - return decorateManualRedeemDetail(task, detail) -} - -export async function reloadAdminManualRedeemSession( - taskId: AdminEntityIdInput, - session: AdminViewerSessionInput | null = null, -): Promise { - const task = await getRequiredManualRedeemTask(taskId, session) - const detail = await reloadClaimSessionForAdminTask(task.id) - return decorateManualRedeemDetail(task, detail) -} - -export async function closeAdminManualRedeemSession( - taskId: AdminEntityIdInput, - session: AdminViewerSessionInput | null = null, -): Promise { - const task = await getRequiredManualRedeemTask(taskId, session) - const detail = await closeClaimSessionForAdminTask(task.id) - return decorateManualRedeemDetail(task, detail) -} - -export async function closeAdminManualRedeemTask( - taskId: AdminEntityIdInput, - session: AdminViewerSessionInput | null = null, -): Promise { - const task = await getRequiredManualRedeemTask(taskId, session) - await closeAdminTask(task.id) - return getAdminManualRedeemDetail(task.id, session) -} - -export async function confirmAdminManualRedeemRole( - taskId: AdminEntityIdInput, - session: AdminViewerSessionInput | null = null, -): Promise { - const task = await getRequiredManualRedeemTask(taskId, session) - await confirmAdminTaskAssistedRole(task.id, session) - return getAdminManualRedeemDetail(task.id, session) -} - -export async function redeemAdminManualRedeemTask( - taskId: AdminEntityIdInput, - session: AdminViewerSessionInput | null = null, -): Promise { - const task = await getRequiredManualRedeemTask(taskId, session) - await redeemAdminTaskAssisted(task.id, session) - return getAdminManualRedeemDetail(task.id, session) -} - -async function getRequiredManualRedeemTask( - taskId: AdminEntityIdInput, - session: AdminViewerSessionInput | null = null, -): Promise { - const task = await getTaskById(Number(taskId)) - - if (!task) { - throw createHttpError('人工兑换任务不存在', { - statusCode: 404, - errorCode: 'admin_manual_redeem_task_not_found', - }) - } - - const taskContext = parseTaskContext(task) - const sourceType = String(taskContext?.manualRedeem?.sourceType || '').trim() - - if (!isAssistedClaimTask(task) || sourceType !== MANUAL_SOURCE_TYPE) { - throw createHttpError('当前任务不是人工兑换任务', { - statusCode: 409, - errorCode: 'admin_manual_redeem_task_invalid', - }) - } - - const viewerContext = createAdminViewerContext(session) - if (viewerContext.restrictInventoryGroups) { - if (!Array.isArray(viewerContext.allowedInventoryGroupCodes) || viewerContext.allowedInventoryGroupCodes.length === 0) { - throw createHttpError('当前客服未绑定任何库存组,无法操作人工兑换任务', { - statusCode: 403, - errorCode: 'admin_manual_redeem_inventory_group_unbound', - }) - } - - const inventoryGroupCode = String(taskContext?.inventoryGroupCode || '').trim() - - if (inventoryGroupCode && !viewerContext.allowedInventoryGroupCodes.includes(inventoryGroupCode)) { - throw createHttpError('当前客服无权操作该库存组下的人工兑换任务', { - statusCode: 403, - errorCode: 'admin_manual_redeem_inventory_group_denied', - }) - } - } - - return task -} - -function decorateManualRedeemDetail(task: TaskRow, detail: any): JsonObject { - const taskContext = parseTaskContext(task) - const manualRedeem = taskContext.manualRedeem && typeof taskContext.manualRedeem === 'object' - ? taskContext.manualRedeem - : {} - - return { - ...detail, - claimUrl: '', - manualRequest: { - sourceType: String(manualRedeem.sourceType || MANUAL_SOURCE_TYPE).trim() || MANUAL_SOURCE_TYPE, - proofValue: String(manualRedeem.proofValue || detail?.order?.platformOrderId || '').trim(), - remark: String(manualRedeem.remark || '').trim(), - }, - result: detail.result - ? { - ...detail.result, - screenshotUrl: detail.result.screenshotReady ? `/api/v1/admin/tasks/${detail.task.taskId}/screenshot` : '', - } - : null, - } -} - -function normalizeManualProofValue(value: unknown): string { - return String(value || '').replace(/\s+/g, ' ').trim() -} diff --git a/apps/backend/src/services/admin/admin-message-delivery-service.ts b/apps/backend/src/services/admin/admin-message-delivery-service.ts deleted file mode 100644 index 09013950..00000000 --- a/apps/backend/src/services/admin/admin-message-delivery-service.ts +++ /dev/null @@ -1,55 +0,0 @@ -import { listMessageDeliveries } from '../../repositories/message-delivery-repo.js' -import { normalizeDateQuery, normalizePage, normalizePageSize, safeParseJson } from './admin-query-utils.js' - -import type { AdminMessageDeliveryListQueryInput } from '../../types/admin-read-inputs.js' - -type JsonObject = Record - -export async function getAdminMessageDeliveries(query: AdminMessageDeliveryListQueryInput = {}) { - const page = normalizePage(query.page) - const pageSize = normalizePageSize(query.pageSize) - const { items, total } = await listMessageDeliveries({ - page, - pageSize, - provider: String(query.provider || '').trim(), - platform: String(query.platform || '').trim(), - status: String(query.status || '').trim(), - shopId: String(query.shopId || '').trim(), - platformOrderId: String(query.platformOrderId || '').trim(), - taskNo: String(query.taskNo || '').trim(), - dateFrom: normalizeDateQuery(query.dateFrom), - dateTo: normalizeDateQuery(query.dateTo, true), - }) - - return { - items: items.map(mapAdminMessageDeliveryListItem), - pagination: { page, pageSize, total }, - } -} - -function mapAdminMessageDeliveryListItem(item: JsonObject) { - return { - deliveryId: Number(item.id), - provider: String(item.provider || '').trim(), - platform: String(item.platform || '').trim(), - shopId: String(item.shop_id || '').trim(), - shopName: String(item.shop_name || '').trim(), - channel: String(item.channel || '').trim(), - orderId: item.order_id ? Number(item.order_id) : null, - taskId: item.task_id ? Number(item.task_id) : null, - taskNo: String(item.task_no || '').trim(), - taskStatus: String(item.task_status || '').trim(), - platformOrderId: String(item.platform_order_id || '').trim(), - recipientKey: String(item.recipient_key || '').trim(), - messageContent: String(item.message_content || '').trim(), - claimUrl: String(item.claim_url || '').trim(), - status: String(item.status || '').trim(), - requestUrl: String(item.request_url || '').trim(), - responseStatus: Number(item.response_status || 0), - response: safeParseJson(item.response_json), - errorMessage: String(item.error_message || '').trim(), - sentAt: item.sent_at || null, - createdAt: item.created_at, - updatedAt: item.updated_at, - } -} diff --git a/apps/backend/src/services/admin/admin-order-read-helpers.ts b/apps/backend/src/services/admin/admin-order-read-helpers.ts index f1db9eb9..17481412 100644 --- a/apps/backend/src/services/admin/admin-order-read-helpers.ts +++ b/apps/backend/src/services/admin/admin-order-read-helpers.ts @@ -2,23 +2,22 @@ import { listOrderItemsByOrderId } from '../../repositories/order-item-repo.js' import { listTasksByOrderId } from '../../repositories/task-repo.js' import { formatFenToAmount, normalizeFen } from '../../utils/money.js' import { resolveDisplayShopName, resolveOrderItemTitle } from './admin-read-shared-helpers.js' -import { buildOrderBindingSummary, getTaskBindingSummaryMap } from './admin-task-read-helpers.js' +import { buildOrderFulfillmentProgress } from './admin-task-read-helpers.js' -import type { AdminOrderListItem } from '../../types/admin-read-models.js' -import type { OrderItemRow, OrderListRow } from '../../types/repository-rows.js' +import type { AdminOrderListItem } from '../../types/admin/read-models.js' +import type { OrderItemRow, OrderListRow } from '../../types/repository/rows.js' export async function mapAdminOrderListItem(item: OrderListRow): Promise { const [tasks, orderItems] = await Promise.all([ listTasksByOrderId(item.id), listOrderItemsByOrderId(item.id), ]) - const taskBindingSummaryMap = await getTaskBindingSummaryMap(tasks.map((task) => task.id)) - const bindingSummary = buildOrderBindingSummary(tasks, taskBindingSummaryMap) + const fulfillmentProgress = buildOrderFulfillmentProgress(tasks) const itemSummary = summarizeOrderItems(orderItems) return { orderId: item.id, - provider: item.provider || 'agiso', + provider: item.provider || '', platform: item.platform, shopId: item.shop_id || '', shopName: resolveDisplayShopName(item.provider, item.shop_id, item.shop_name), @@ -38,14 +37,10 @@ export async function mapAdminOrderListItem(item: OrderListRow): Promise sum + Math.max(1, Number(orderItem.quantity || 1)), 0), itemSummary, taskCount: Number(item.task_count || tasks.length || 0), - systemBindingStatus: bindingSummary.systemBindingStatus, - userBindingStatus: bindingSummary.userBindingStatus, - systemBoundTaskCount: bindingSummary.systemBoundTaskCount, - completedBindingTaskCount: bindingSummary.completedBindingTaskCount, - totalBindingCount: bindingSummary.totalBindingCount, - reservedBindingCount: bindingSummary.reservedBindingCount, - consumedBindingCount: bindingSummary.consumedBindingCount, - releasedBindingCount: bindingSummary.releasedBindingCount, + resourceStatus: fulfillmentProgress.resourceStatus, + customerStatus: fulfillmentProgress.customerStatus, + preparedTaskCount: fulfillmentProgress.preparedTaskCount, + completedTaskCount: fulfillmentProgress.completedTaskCount, } } diff --git a/apps/backend/src/services/admin/admin-read-service.ts b/apps/backend/src/services/admin/admin-read-service.ts index 7569b79e..a74a0236 100644 --- a/apps/backend/src/services/admin/admin-read-service.ts +++ b/apps/backend/src/services/admin/admin-read-service.ts @@ -1,31 +1,35 @@ import { buildClaimUrl } from '../claim/claim-service.js' import { getClaimTokenById } from '../../repositories/claim-token-repo.js' -import { - getInventoryItemById, - listInventoryItems, - listInventorySkuSuggestions, -} from '../../repositories/inventory-repo.js' import { listOrderItemsByOrderId } from '../../repositories/order-item-repo.js' import { getOrderById, listOrders } from '../../repositories/order-repo.js' import { getTaskById, listTasks, listTasksByOrderId } from '../../repositories/task-repo.js' -import { listTaskInventoryBindingsByTaskId } from '../../repositories/task-inventory-binding-repo.js' import { listTaskEventsByTaskId } from '../../repositories/task-event-repo.js' -import { getWebhookEventById, listWebhookEvents, listWebhookEventsByOrderId } from '../../repositories/webhook-event-repo.js' +import { listCloudtentaclesSources } from '../platforms/cloudtentacles/source-config-service.js' import { createHttpError } from '../../utils/http.js' import { formatFenToAmount, normalizeFen } from '../../utils/money.js' -import { getTencentBrowserSessionReviewScreenshotPath } from '../session/session.js' -import { normalizeDateQuery, normalizePage, normalizePageSize, safeParseJson } from './admin-query-utils.js' +import { + normalizeDateQuery, + normalizePage, + normalizePageSize, + safeParseJson, +} from './admin-query-utils.js' +import { + canCompleteManualDispatchTaskStatus, + canDispatchKuaishouCloudFulfillmentStatus, + canMarkManualReviewTaskStatus, + canPrepareKuaishouCloudFulfillmentStatus, + canRebindKuaishouCloudRoleStatus, + canReturnKuaishouCloudFulfillmentStatus, + canRetryTaskStatus, +} from '../../domain/task-status.js' import { canViewerCloseTask, canRegenerateClaimLinkForViewer, - canViewerConfirmAssistedRole, - canViewerRedeemAssistedTask, createAdminViewerContext, getTaskPrimaryClaimTokenId, - getTaskPrimaryInventoryItemId, - isAssistedClaimTask, isManualDispatchTask, mapKuaishouCloudFulfillmentContext, + mapKuaishouCloudTaskStateProjection, mapManualDispatchContext, mapRedeemResolutionContext, parseTaskContext, @@ -35,42 +39,31 @@ import { resolveOrderItemDeliveryMode, resolveOrderItemTitle, } from './admin-read-shared-helpers.js' -import { mapAdminInventoryListItem } from './admin-inventory-read-helpers.js' import { mapAdminOrderListItem, summarizeOrderItems } from './admin-order-read-helpers.js' -import { mapAdminWebhookEvent } from './admin-webhook-read-helpers.js' import { - buildOrderAgisoAutoDeliverySummary, - buildOrderBindingSummary, - createTaskBindingSummaryFromBindings, + buildOrderFulfillmentProgress, getRequiredTask, - getTaskBindingSummary, - getTaskBindingSummaryMap, mapAdminTaskEvent, - mapAdminTaskInventoryBinding, mapAdminTaskListItem, mapAdminTaskSummary, } from './admin-task-read-helpers.js' import type { - AdminInventoryListResponse, - AdminInventorySkuSuggestionResponse, AdminOrderListResponse, AdminTaskListResponse, - AdminWebhookEventListResponse, -} from '../../types/admin-read-models.js' +} from '../../types/admin/read-models.js' import type { AdminEntityIdInput, - AdminInventoryListQueryInput, - AdminInventorySkuSuggestionQueryInput, AdminOrderListQueryInput, AdminTaskListQueryInput, AdminViewerSessionInput, - AdminWebhookEventListQueryInput, -} from '../../types/admin-read-inputs.js' +} from '../../types/admin/read-inputs.js' type JsonRecord = Record -export async function getAdminOrders(query: AdminOrderListQueryInput = {}): Promise { +export async function getAdminOrders( + query: AdminOrderListQueryInput = {}, +): Promise { const page = normalizePage(query.page) const pageSize = normalizePageSize(query.pageSize) const { items, total } = await listOrders({ @@ -99,18 +92,16 @@ export async function getAdminOrderDetail(orderId: AdminEntityIdInput): Promise< }) } - const [items, tasks, webhookEvents] = await Promise.all([ + const [items, tasks] = await Promise.all([ listOrderItemsByOrderId(order.id), listTasksByOrderId(order.id), - listWebhookEventsByOrderId(order.id), ]) - const taskBindingSummaryMap = await getTaskBindingSummaryMap(tasks.map((task) => task.id)) const itemSummary = summarizeOrderItems(items) return { order: { orderId: order.id, - provider: order.provider || 'agiso', + provider: order.provider || '', platform: order.platform, shopId: order.shop_id || '', shopName: resolveDisplayShopName(order.provider, order.shop_id, order.shop_name), @@ -127,11 +118,11 @@ export async function getAdminOrderDetail(orderId: AdminEntityIdInput): Promise< createdAt: order.created_at, updatedAt: order.updated_at, itemSummary, - rawPayload: order.raw_payload_json && typeof order.raw_payload_json === 'object' - ? order.raw_payload_json - : JSON.parse(String(order.raw_payload_json || '{}')), - bindingSummary: buildOrderBindingSummary(tasks, taskBindingSummaryMap), - agisoAutoDelivery: buildOrderAgisoAutoDeliverySummary(order, tasks), + rawPayload: + order.raw_payload_json && typeof order.raw_payload_json === 'object' + ? order.raw_payload_json + : JSON.parse(String(order.raw_payload_json || '{}')), + fulfillmentProgress: buildOrderFulfillmentProgress(tasks), }, items: items.map((item) => ({ orderItemId: item.id, @@ -140,24 +131,12 @@ export async function getAdminOrderDetail(orderId: AdminEntityIdInput): Promise< itemTitle: resolveOrderItemTitle(item), quantity: item.quantity, deliveryMode: resolveOrderItemDeliveryMode(tasks, item.id), - spec: item.spec_json && typeof item.spec_json === 'object' - ? item.spec_json - : JSON.parse(String(item.spec_json || '{}')), - })), - tasks: tasks.map((task) => mapAdminTaskSummary(task, getTaskBindingSummary(taskBindingSummaryMap, task.id))), - webhookEvents: webhookEvents.map((event) => ({ - eventId: event.id, - provider: event.provider || 'agiso', - platform: event.platform, - shopId: event.shop_id || '', - shopName: resolveDisplayShopName(event.provider, event.shop_id, event.shop_name), - eventType: event.event_type, - eventKey: event.event_key, - signatureValid: Boolean(event.signature_valid), - processed: Boolean(event.processed), - processError: event.process_error, - createdAt: event.created_at, + spec: + item.spec_json && typeof item.spec_json === 'object' + ? item.spec_json + : JSON.parse(String(item.spec_json || '{}')), })), + tasks: tasks.map((task) => mapAdminTaskSummary(task)), } } @@ -178,15 +157,10 @@ export async function getAdminTasks( dateFrom: normalizeDateQuery(query.dateFrom), dateTo: normalizeDateQuery(query.dateTo, true), }) - const taskBindingSummaryMap = await getTaskBindingSummaryMap(items.map((item) => item.id)) const viewerContext = createAdminViewerContext(session) return { - items: items.map((item) => mapAdminTaskListItem( - item, - getTaskBindingSummary(taskBindingSummaryMap, item.id), - viewerContext, - )), + items: items.map((item) => mapAdminTaskListItem(item, viewerContext)), pagination: { page, pageSize, total }, } } @@ -204,76 +178,68 @@ export async function getAdminTaskDetail( }) } - const [order, claimToken, inventoryItem, taskEvents, inventoryBindings] = await Promise.all([ + const primaryClaimTokenId = getTaskPrimaryClaimTokenId(task) + const [order, claimToken, taskEvents] = await Promise.all([ getOrderById(task.order_id), - getTaskPrimaryClaimTokenId(task) ? getClaimTokenById(getTaskPrimaryClaimTokenId(task)) : Promise.resolve(null), - getTaskPrimaryInventoryItemId(task) ? getInventoryItemById(getTaskPrimaryInventoryItemId(task)) : Promise.resolve(null), + primaryClaimTokenId ? getClaimTokenById(primaryClaimTokenId) : Promise.resolve(null), listTaskEventsByTaskId(task.id), - listTaskInventoryBindingsByTaskId(task.id), ]) const orderItems = order ? await listOrderItemsByOrderId(order.id) : [] const orderItem = orderItems.find((item) => item.id === task.order_item_id) || null const taskContext = parseTaskContext(task) const taskState = parseTaskState(task) - const taskBindingSummary = createTaskBindingSummaryFromBindings(inventoryBindings) const viewerContext = createAdminViewerContext(session) - const mappedInventoryBindings = inventoryBindings.map((binding) => mapAdminTaskInventoryBinding(binding, task, viewerContext)) const claimUrl = claimToken ? buildClaimUrl(claimToken.token) : '' const screenshotUrl = await resolveAdminTaskScreenshotUrl(task, viewerContext) - const kuaishouCloudFulfillment = mapKuaishouCloudFulfillmentContext(taskContext.kuaishouCloudFulfillment) + const cloudSourceLabelMap = buildCloudSourceLabelMap() + const kuaishouCloudFulfillment = + mapKuaishouCloudFulfillmentContext(taskContext.kuaishouCloudFulfillment, { + cloudSourceLabelMap, + }) || mapKuaishouCloudTaskStateProjection(task, { cloudSourceLabelMap }) return { - task: mapAdminTaskListItem({ - ...task, - sku_code: orderItem?.sku_code || '', - sku_name: orderItem?.sku_name || '', - inventory_display_value: inventoryItem?.display_value || '', - claim_token: claimToken?.token || '', - }, taskBindingSummary, viewerContext), + task: mapAdminTaskListItem( + { + ...task, + sku_code: orderItem?.sku_code || '', + sku_name: orderItem?.sku_name || '', + claim_token: claimToken?.token || '', + }, + viewerContext, + ), order: order ? { - orderId: order.id, - provider: order.provider || 'agiso', - platform: order.platform, - shopId: order.shop_id || '', - shopName: order.shop_name || '', - platformOrderId: order.platform_order_id, - payStatus: order.pay_status, - orderStatus: order.order_status, - } + orderId: order.id, + provider: order.provider || '', + platform: order.platform, + shopId: order.shop_id || '', + shopName: order.shop_name || '', + platformOrderId: order.platform_order_id, + payStatus: order.pay_status, + orderStatus: order.order_status, + } : null, orderItem: orderItem ? { - orderItemId: orderItem.id, - skuCode: orderItem.sku_code, - skuName: orderItem.sku_name, - quantity: orderItem.quantity, - } + orderItemId: orderItem.id, + skuCode: orderItem.sku_code, + skuName: orderItem.sku_name, + quantity: orderItem.quantity, + } : null, claimToken: claimToken ? { - primaryClaimTokenId: claimToken.id, - token: viewerContext.canViewSensitiveTaskData ? claimToken.token : '', - status: claimToken.status, - expiredAt: claimToken.expired_at, - claimUrl, - } + primaryClaimTokenId: claimToken.id, + token: viewerContext.canViewSensitiveTaskData ? claimToken.token : '', + status: claimToken.status, + expiredAt: claimToken.expired_at, + claimUrl, + } : null, - inventory: inventoryItem - ? { - inventoryItemId: inventoryItem.id, - skuCode: inventoryItem.sku_code, - batchNo: inventoryItem.batch_no, - credentialType: inventoryItem.credential_type || 'tencent_code', - displayValue: viewerContext.canViewSensitiveTaskData ? inventoryItem.display_value : '', - status: inventoryItem.status, - } - : null, - inventoryBindings: mappedInventoryBindings, artifacts: viewerContext.canViewSensitiveTaskData ? safeParseJson(task.artifacts_json) : {}, screenshotUrl, review: { - required: isAssistedClaimTask(task), + required: false, screenshotCapturedAt: String(taskState.reviewCapturedAt || '').trim() || null, roleId: String(taskState.reviewRoleId || '').trim() || '', roleName: String(taskState.reviewRoleName || '').trim() || '', @@ -283,35 +249,61 @@ export async function getAdminTaskDetail( manualDispatch: mapManualDispatchContext(taskContext.manualDispatch, viewerContext), events: taskEvents.map(mapAdminTaskEvent), operations: { - canRetry: viewerContext.canManageTaskLifecycle - && !isManualDispatchTask(task) - && ['retry_pending', 'manual_review', 'waiting_inventory'].includes(task.task_status), - canReleaseInventory: mappedInventoryBindings.some((binding) => binding.canRelease), + canRetry: + viewerContext.canManageTaskLifecycle && + !isManualDispatchTask(task) && + canRetryTaskStatus(task.task_status), canRegenerateClaimLink: canRegenerateClaimLinkForViewer(task, viewerContext), canClose: canViewerCloseTask(task, viewerContext), - canMarkManualReview: viewerContext.canManageTaskLifecycle && !['redeemed', 'closed', 'manual_review'].includes(task.task_status), - canCompleteManualDispatch: viewerContext.canManageTaskLifecycle && isManualDispatchTask(task) && !['redeemed', 'closed'].includes(task.task_status), - canPrepareKuaishouCloudFulfillment: viewerContext.canManageTaskLifecycle - && String(task.executor_key || '').trim() === 'kuaishou_ct_assisted' - && String(kuaishouCloudFulfillment?.dispatch.status || 'pending').trim() === 'pending' - && String(kuaishouCloudFulfillment?.returnNumber.status || 'pending').trim() === 'pending' - && ['pending_binding_prepare', 'manual_review', 'failed'].includes(String(task.task_status || '').trim()), - canRefreshKuaishouCloudRoleInfo: viewerContext.canOperateAssistedTask - && String(task.executor_key || '').trim() === 'kuaishou_ct_assisted' - && Number(kuaishouCloudFulfillment?.binding.vnId || 0) > 0, - canDispatchKuaishouCloudFulfillment: viewerContext.canManageTaskLifecycle - && String(task.executor_key || '').trim() === 'kuaishou_ct_assisted' - && ['waiting_binding'].includes(String(task.task_status || '').trim()), - canReturnKuaishouCloudFulfillment: viewerContext.canManageTaskLifecycle - && String(task.executor_key || '').trim() === 'kuaishou_ct_assisted' - && ['dispatched_pending_return'].includes(String(task.task_status || '').trim()), - canSupportConfirmRole: canViewerConfirmAssistedRole(task, viewerContext), - canSupportRedeem: canViewerRedeemAssistedTask(task, viewerContext), + canMarkManualReview: + viewerContext.canManageTaskLifecycle && canMarkManualReviewTaskStatus(task.task_status), + canCompleteManualDispatch: + viewerContext.canManageTaskLifecycle && + isManualDispatchTask(task) && + canCompleteManualDispatchTaskStatus(task.task_status), + canPrepareKuaishouCloudFulfillment: + viewerContext.canManageTaskLifecycle && + String(task.executor_key || '').trim() === 'kuaishou_ct_assisted' && + String(kuaishouCloudFulfillment?.dispatch.status || 'pending').trim() === 'pending' && + String(kuaishouCloudFulfillment?.returnNumber.status || 'pending').trim() === 'pending' && + canPrepareKuaishouCloudFulfillmentStatus(task.task_status), + canRefreshKuaishouCloudRoleInfo: + viewerContext.canOperateAssistedTask && + String(task.executor_key || '').trim() === 'kuaishou_ct_assisted' && + Number(kuaishouCloudFulfillment?.binding.vnId || 0) > 0, + canRebindKuaishouCloudRole: + viewerContext.canManageTaskLifecycle && + String(task.executor_key || '').trim() === 'kuaishou_ct_assisted' && + canRebindKuaishouCloudRoleStatus(task.task_status) && + String(kuaishouCloudFulfillment?.dispatch.status || 'pending').trim() !== 'success' && + Number(kuaishouCloudFulfillment?.binding.vnId || 0) > 0, + canDispatchKuaishouCloudFulfillment: + viewerContext.canManageTaskLifecycle && + String(task.executor_key || '').trim() === 'kuaishou_ct_assisted' && + canDispatchKuaishouCloudFulfillmentStatus(task.task_status), + canReturnKuaishouCloudFulfillment: + viewerContext.canManageTaskLifecycle && + String(task.executor_key || '').trim() === 'kuaishou_ct_assisted' && + canReturnKuaishouCloudFulfillmentStatus(task.task_status), canViewSensitiveTaskData: viewerContext.canViewSensitiveTaskData, }, } } +function buildCloudSourceLabelMap() { + const labelMap = new Map() + + for (const source of listCloudtentaclesSources().sources) { + const key = String(source.key || '').trim() + if (!key) continue + + const label = String(source.label || source.username || key).trim() || key + labelMap.set(key, label) + } + + return labelMap +} + export async function getAdminTaskScreenshotPath( taskId: AdminEntityIdInput, session: AdminViewerSessionInput | null = null, @@ -322,105 +314,8 @@ export async function getAdminTaskScreenshotPath( return task.screenshot_path } - if (task.browser_session_id) { - return getTencentBrowserSessionReviewScreenshotPath(task.browser_session_id) - } - throw createHttpError('当前任务还没有可查看截图', { statusCode: 404, errorCode: 'admin_task_screenshot_not_found', }) } - -export async function getAdminInventoryItems( - query: AdminInventoryListQueryInput = {}, - session: AdminViewerSessionInput | null = null, -): Promise { - const page = normalizePage(query.page) - const pageSize = normalizePageSize(query.pageSize) - const viewerContext = createAdminViewerContext(session) - const { items, total } = await listInventoryItems({ - page, - pageSize, - skuCode: String(query.skuCode || '').trim(), - credentialType: String(query.credentialType || '').trim(), - status: String(query.status || '').trim(), - batchNo: String(query.batchNo || '').trim(), - inventoryGroupCode: String(query.inventoryGroupCode || '').trim(), - allowedInventoryGroupCodes: viewerContext.allowedInventoryGroupCodes, - }) - - return { - items: await Promise.all(items.map((item) => mapAdminInventoryListItem(item))), - pagination: { page, pageSize, total }, - } -} - -export async function getAdminInventorySkuSuggestions( - query: AdminInventorySkuSuggestionQueryInput = {}, - session: AdminViewerSessionInput | null = null, -): Promise { - const viewerContext = createAdminViewerContext(session) - const credentialType = String(query.credentialType || '').trim() - const keyword = String(query.keyword || '').trim() - const inventoryGroupCode = String(query.inventoryGroupCode || '').trim() - const requestedLimit = Number(query.limit) - const limit = Number.isFinite(requestedLimit) && requestedLimit > 0 - ? Math.min(100, Math.floor(requestedLimit)) - : 50 - const items = await listInventorySkuSuggestions({ - credentialType, - keyword, - limit, - inventoryGroupCode, - allowedInventoryGroupCodes: viewerContext.allowedInventoryGroupCodes, - }) - - return { - items: items.map((item) => ({ - skuCode: String(item.sku_code || '').trim(), - credentialType: String(item.credential_type || '').trim() || 'tencent_code', - inventoryGroupCode: String(item.inventory_group_code || '').trim(), - totalCount: Number(item.total_count || 0), - availableCount: Number(item.available_count || 0), - latestUpdatedAt: item.latest_updated_at || null, - })), - } -} - -export async function getAdminWebhookEvents( - query: AdminWebhookEventListQueryInput = {}, -): Promise { - const page = normalizePage(query.page) - const pageSize = normalizePageSize(query.pageSize) - const { items, total } = await listWebhookEvents({ - page, - pageSize, - provider: String(query.provider || '').trim(), - platform: String(query.platform || '').trim(), - platformOrderId: String(query.platformOrderId || '').trim(), - processed: String(query.processed || '').trim(), - visibility: String(query.visibility || '').trim() || 'important', - relatedOrderId: String(query.relatedOrderId || '').trim(), - dateFrom: normalizeDateQuery(query.dateFrom), - dateTo: normalizeDateQuery(query.dateTo, true), - }) - - return { - items: await Promise.all(items.map((item) => mapAdminWebhookEvent(item))), - pagination: { page, pageSize, total }, - } -} - -export async function getAdminWebhookEventDetail(eventId: AdminEntityIdInput): Promise { - const event = await getWebhookEventById(Number(eventId)) - - if (!event) { - throw createHttpError('Webhook 事件不存在', { - statusCode: 404, - errorCode: 'admin_webhook_not_found', - }) - } - - return mapAdminWebhookEvent(event, { includeRaw: true }) -} diff --git a/apps/backend/src/services/admin/admin-read-shared-helpers.test.ts b/apps/backend/src/services/admin/admin-read-shared-helpers.test.ts index 1f3047e8..5a1496ae 100644 --- a/apps/backend/src/services/admin/admin-read-shared-helpers.test.ts +++ b/apps/backend/src/services/admin/admin-read-shared-helpers.test.ts @@ -2,9 +2,9 @@ import test from 'node:test' import assert from 'node:assert/strict' import { - canViewerConfirmAssistedRole, canViewerCloseTask, createAdminViewerContext, + mapKuaishouCloudFulfillmentContext, resolveAdminTaskScreenshotUrl, } from './admin-read-shared-helpers.js' @@ -12,7 +12,7 @@ test('resolveAdminTaskScreenshotUrl lets support view final redeemed screenshot' const task = { id: 12, screenshot_path: '/tmp/redeemed-12.png', - browser_session_id: '', + runtime_session_id: '', } const screenshotUrl = await resolveAdminTaskScreenshotUrl(task, createAdminViewerContext({ role: 'support' })) @@ -20,11 +20,11 @@ test('resolveAdminTaskScreenshotUrl lets support view final redeemed screenshot' assert.equal(screenshotUrl, '/api/v1/admin/tasks/12/screenshot') }) -test('resolveAdminTaskScreenshotUrl falls back to review screenshot when only browser session exists', async () => { +test('resolveAdminTaskScreenshotUrl falls back to review screenshot when runtime session exists', async () => { const task = { id: 13, screenshot_path: '', - browser_session_id: 'browser-session-13', + runtime_session_id: 'runtime-session-13', } const screenshotUrl = await resolveAdminTaskScreenshotUrl(task, createAdminViewerContext({ role: 'support' })) @@ -39,17 +39,23 @@ test('canViewerCloseTask allows support to close active task but not redeemed ta assert.equal(canViewerCloseTask({ task_status: 'redeemed' }, viewerContext), false) }) -test('canViewerConfirmAssistedRole blocks support when task inventory group is not bound', () => { - const viewerContext = createAdminViewerContext({ - role: 'support', - allowedInventoryGroups: ['A组'], - }) - - assert.equal(canViewerConfirmAssistedRole({ - task_status: 'claimed', - executor_key: 'tencent_claim_assisted', - context_json: { - inventoryGroupCode: 'B组', +test('mapKuaishouCloudFulfillmentContext exposes cloud account display labels', () => { + const flow = mapKuaishouCloudFulfillmentContext( + { + binding: { + cloudSourceKeys: [' account_a ', 'account_b'], + resolvedSourceKey: 'account_b', + }, }, - }, viewerContext), false) + { + cloudSourceLabelMap: new Map([ + ['account_a', '白班账号'], + ['account_b', '夜班账号'], + ]), + }, + ) + + assert.deepEqual(flow?.binding.cloudSourceKeys, ['account_a', 'account_b']) + assert.deepEqual(flow?.binding.cloudSourceLabels, ['白班账号', '夜班账号']) + assert.equal(flow?.binding.resolvedSourceLabel, '夜班账号') }) diff --git a/apps/backend/src/services/admin/admin-read-shared-helpers.ts b/apps/backend/src/services/admin/admin-read-shared-helpers.ts index 2b442d67..05ccda0e 100644 --- a/apps/backend/src/services/admin/admin-read-shared-helpers.ts +++ b/apps/backend/src/services/admin/admin-read-shared-helpers.ts @@ -1,15 +1,20 @@ -import { getAgisoShopConfig } from '../platforms/agiso/shop-config-service.js' import { safeParseJson } from './admin-query-utils.js' import { normalizeAdminRole } from './admin-auth-service.js' import { parseTaskContext as parseTaskContextValue, parseTaskState as parseTaskStateValue, } from '../../utils/task-json.js' +import { canRegenerateClaimLinkStatus, isTaskFinalStatus } from '../../domain/task-status.js' -import type { AdminViewerSessionInput } from '../../types/admin-read-inputs.js' -import type { OrderItemRow, TaskRow } from '../../types/repository-rows.js' +import type { AdminViewerSessionInput } from '../../types/admin/read-inputs.js' +import type { OrderItemRow, TaskRow } from '../../types/repository/rows.js' type JsonRecord = Record +type CloudSourceLabelMap = Map | Record + +type KuaishouCloudFulfillmentMapOptions = { + cloudSourceLabelMap?: CloudSourceLabelMap +} type TaskLike = Partial & { state_json?: string | JsonRecord @@ -17,17 +22,13 @@ type TaskLike = Partial & { export type AdminViewerContext = { role: string - allowedInventoryGroupCodes: string[] | null - restrictInventoryGroups: boolean canViewSensitiveTaskData: boolean - canManageSensitiveInventory: boolean canManageTaskLifecycle: boolean canOperateAssistedTask: boolean } type RedeemResolutionAttempt = { attempt: number - inventoryItemId: number | null codeMasked: string credentialType: string outcome: string @@ -47,20 +48,26 @@ export function mapManualDispatchContext( return { outcome: String(record.outcome || '').trim(), deliveryReference: String(record.deliveryReference || '').trim(), - deliveredCredential: viewerContext.canViewSensitiveTaskData ? String(record.deliveredCredential || '').trim() : '', + deliveredCredential: viewerContext.canViewSensitiveTaskData + ? String(record.deliveredCredential || '').trim() + : '', resultMessage: String(record.resultMessage || '').trim(), completedAt: record.completedAt || null, - completedBy: record.completedBy && typeof record.completedBy === 'object' - ? { - userId: Number(record.completedBy.userId || 0) || 0, - username: String(record.completedBy.username || '').trim(), - role: String(record.completedBy.role || '').trim(), - } - : null, + completedBy: + record.completedBy && typeof record.completedBy === 'object' + ? { + userId: Number(record.completedBy.userId || 0) || 0, + username: String(record.completedBy.username || '').trim(), + role: String(record.completedBy.role || '').trim(), + } + : null, } } -export function mapKuaishouCloudFulfillmentContext(value: unknown): JsonRecord | null { +export function mapKuaishouCloudFulfillmentContext( + value: unknown, + options: KuaishouCloudFulfillmentMapOptions = {}, +): JsonRecord | null { if (!value || typeof value !== 'object') { return null } @@ -71,8 +78,13 @@ export function mapKuaishouCloudFulfillmentContext(value: unknown): JsonRecord | const role = record.role && typeof record.role === 'object' ? record.role : {} const purchase = record.purchase && typeof record.purchase === 'object' ? record.purchase : {} const dispatch = record.dispatch && typeof record.dispatch === 'object' ? record.dispatch : {} - const returnNumber = record.returnNumber && typeof record.returnNumber === 'object' ? record.returnNumber : {} + const returnNumber = + record.returnNumber && typeof record.returnNumber === 'object' ? record.returnNumber : {} const consume = record.consume && typeof record.consume === 'object' ? record.consume : {} + const cloudSourceKeys = Array.isArray(binding.cloudSourceKeys) + ? binding.cloudSourceKeys.map((value: unknown) => String(value || '').trim()).filter(Boolean) + : [] + const resolvedSourceKey = String(binding.resolvedSourceKey || '').trim() return { flowType: String(record.flowType || '').trim(), @@ -89,11 +101,14 @@ export function mapKuaishouCloudFulfillmentContext(value: unknown): JsonRecord | }, binding: { prepareStatus: String(binding.prepareStatus || 'pending').trim() || 'pending', - cloudSourceKey: String(binding.cloudSourceKey || '').trim(), - cloudSourceKeyFallbacks: Array.isArray(binding.cloudSourceKeyFallbacks) - ? binding.cloudSourceKeyFallbacks.map((value) => String(value || '').trim()).filter(Boolean) - : [], - resolvedSourceKey: String(binding.resolvedSourceKey || '').trim(), + cloudSourceKeys, + cloudSourceLabels: cloudSourceKeys.map((sourceKey: string) => + resolveCloudSourceLabel(sourceKey, options.cloudSourceLabelMap), + ), + resolvedSourceKey, + resolvedSourceLabel: resolvedSourceKey + ? resolveCloudSourceLabel(resolvedSourceKey, options.cloudSourceLabelMap) + : '', skuId: Number(binding.skuId || 0) || 0, skuName: String(binding.skuName || '').trim(), vnKey: String(binding.vnKey || '').trim(), @@ -142,10 +157,183 @@ export function mapKuaishouCloudFulfillmentContext(value: unknown): JsonRecord | consumedAt: consume.consumedAt || null, errorMessage: String(consume.errorMessage || '').trim(), }, + rebind: mapKuaishouCloudRebindContext(record.rebind), notes: String(record.notes || '').trim(), } } +export function mapKuaishouCloudTaskStateProjection( + task: TaskLike | null | undefined, + options: KuaishouCloudFulfillmentMapOptions = {}, +): JsonRecord | null { + if (!task || String(task.executor_key || '').trim() !== 'kuaishou_ct_assisted') { + return null + } + + const hasProjection = [ + task.kuaishou_ticket_status, + task.kuaishou_ticket_code_masked, + task.kuaishou_bind_url, + task.kuaishou_vn_phone, + task.kuaishou_role_name, + task.kuaishou_role_id, + task.kuaishou_dispatch_status, + task.kuaishou_consume_status, + task.kuaishou_source_key, + ].some((value) => String(value || '').trim()) + + if (!hasProjection) { + return null + } + + const sourceKey = String(task.kuaishou_source_key || '').trim() + const roleName = String(task.kuaishou_role_name || '').trim() + const roleId = String(task.kuaishou_role_id || '').trim() + + return { + flowType: 'kuaishou_cloud_fulfillment', + configId: '', + internalSkuCode: '', + internalSkuName: '', + ticket: { + code: String(task.kuaishou_ticket_code_masked || '').trim(), + capturedAt: null, + status: String(task.kuaishou_ticket_status || 'pending').trim() || 'pending', + verifiedAt: null, + goodsTitle: '', + leftCount: 0, + }, + binding: { + prepareStatus: task.kuaishou_bind_url ? 'ready' : 'pending', + cloudSourceKeys: sourceKey ? [sourceKey] : [], + cloudSourceLabels: sourceKey + ? [resolveCloudSourceLabel(sourceKey, options.cloudSourceLabelMap)] + : [], + resolvedSourceKey: sourceKey, + resolvedSourceLabel: sourceKey + ? resolveCloudSourceLabel(sourceKey, options.cloudSourceLabelMap) + : '', + skuId: 0, + skuName: '', + vnKey: '', + vnId: 0, + vnPhone: String(task.kuaishou_vn_phone || '').trim(), + bindUrl: String(task.kuaishou_bind_url || '').trim(), + bindPreparedAt: null, + bindExpiresAt: null, + bindProbeAt: null, + bindProbeStatus: '', + bindProbeMessage: '', + }, + role: { + status: roleName || roleId ? 'ready' : 'pending', + name: roleName, + rid: roleId, + refreshedAt: null, + errorMessage: '', + rawInfo: null, + }, + purchase: { + autoBuyEnabled: true, + minAssetReserve: 0, + usedKnapsack: false, + purchaseTriggered: false, + assetBefore: 0, + assetAfter: 0, + purchaseAt: null, + }, + dispatch: { + status: String(task.kuaishou_dispatch_status || 'pending').trim() || 'pending', + dispatchAt: null, + sendType: 0, + note: '', + }, + returnNumber: { + status: 'pending', + returnedAt: null, + autoReturnEnabled: false, + }, + consume: { + status: String(task.kuaishou_consume_status || 'pending').trim() || 'pending', + shopId: '', + shopName: '', + autoConsumeEnabled: false, + consumedAt: null, + errorMessage: '', + }, + rebind: { + currentAttempt: 0, + history: [], + }, + notes: '', + } +} + +function resolveCloudSourceLabel(sourceKey: string, labelMap: CloudSourceLabelMap | undefined) { + const key = String(sourceKey || '').trim() + if (!key) return '' + + const label = labelMap instanceof Map ? labelMap.get(key) : labelMap?.[key] + + return String(label || key).trim() || key +} + +function mapKuaishouCloudRebindContext(value: unknown): JsonRecord { + const record = value && typeof value === 'object' ? (value as JsonRecord) : {} + const history = Array.isArray(record.history) ? record.history : [] + + return { + currentAttempt: Math.max(0, Number(record.currentAttempt || 0) || 0), + history: history.map((item) => mapKuaishouCloudRebindHistoryItem(item)).filter(Boolean), + } +} + +function mapKuaishouCloudRebindHistoryItem(value: unknown): JsonRecord | null { + if (!value || typeof value !== 'object') { + return null + } + + const record = value as JsonRecord + const oldBinding = + record.oldBinding && typeof record.oldBinding === 'object' + ? (record.oldBinding as JsonRecord) + : {} + const newBinding = + record.newBinding && typeof record.newBinding === 'object' + ? (record.newBinding as JsonRecord) + : null + + return { + attempt: Math.max(1, Number(record.attempt || 1) || 1), + source: String(record.source || '').trim(), + requestedAt: record.requestedAt || null, + requestedBy: + record.requestedBy && typeof record.requestedBy === 'object' ? record.requestedBy : null, + status: String(record.status || '').trim(), + errorMessage: String(record.errorMessage || '').trim(), + oldBinding: { + vnKey: String(oldBinding.vnKey || '').trim(), + vnId: Number(oldBinding.vnId || 0) || 0, + vnPhone: String(oldBinding.vnPhone || '').trim(), + bindUrl: String(oldBinding.bindUrl || '').trim(), + bindPreparedAt: oldBinding.bindPreparedAt || null, + bindExpiresAt: oldBinding.bindExpiresAt || null, + roleName: String(oldBinding.roleName || '').trim(), + roleId: String(oldBinding.roleId || '').trim(), + }, + newBinding: newBinding + ? { + vnKey: String(newBinding.vnKey || '').trim(), + vnId: Number(newBinding.vnId || 0) || 0, + vnPhone: String(newBinding.vnPhone || '').trim(), + bindUrl: String(newBinding.bindUrl || '').trim(), + bindPreparedAt: newBinding.bindPreparedAt || null, + bindExpiresAt: newBinding.bindExpiresAt || null, + } + : null, + } +} + export function mapRedeemResolutionContext(value: unknown): JsonRecord | null { if (!value || typeof value !== 'object') { return null @@ -154,8 +342,8 @@ export function mapRedeemResolutionContext(value: unknown): JsonRecord | null { const record = value as JsonRecord const attempts = Array.isArray(record.attempts) ? record.attempts - .map((attempt) => mapRedeemResolutionAttempt(attempt)) - .filter((attempt): attempt is RedeemResolutionAttempt => Boolean(attempt)) + .map((attempt) => mapRedeemResolutionAttempt(attempt)) + .filter((attempt): attempt is RedeemResolutionAttempt => Boolean(attempt)) : [] return { @@ -167,28 +355,19 @@ export function mapRedeemResolutionContext(value: unknown): JsonRecord | null { } } -export function getTaskPrimaryInventoryItemId(task: TaskLike | null | undefined): number | null { - const value = Number(task?.primary_inventory_item_id || 0) - return Number.isFinite(value) && value > 0 ? value : null -} - export function getTaskPrimaryClaimTokenId(task: TaskLike | null | undefined): number | null { const value = Number(task?.primary_claim_token_id || 0) return Number.isFinite(value) && value > 0 ? value : null } -export function createAdminViewerContext(session: AdminViewerSessionInput | null = null): AdminViewerContext { +export function createAdminViewerContext( + session: AdminViewerSessionInput | null = null, +): AdminViewerContext { const role = normalizeAdminRole(session?.role) - const allowedInventoryGroupCodes = role === 'support' - ? normalizeInventoryGroupCodes(session?.allowedInventoryGroups) - : null return { role, - allowedInventoryGroupCodes, - restrictInventoryGroups: role === 'support', canViewSensitiveTaskData: role === 'admin' || role === 'operator', - canManageSensitiveInventory: role === 'admin', canManageTaskLifecycle: role === 'admin' || role === 'operator', canOperateAssistedTask: role === 'admin' || role === 'operator' || role === 'support', } @@ -198,7 +377,7 @@ export async function resolveAdminTaskScreenshotUrl( task: TaskLike, viewerContext: AdminViewerContext, ): Promise { - if (task.screenshot_path || task.browser_session_id) { + if (task.screenshot_path || task.runtime_session_id) { return `/api/v1/admin/tasks/${task.id}/screenshot` } @@ -210,10 +389,14 @@ export function canViewerCloseTask(task: TaskLike, viewerContext: AdminViewerCon return false } - return !['redeemed', 'completed', 'closed'].includes(String(task?.task_status || '').trim()) + return !isTaskFinalStatus(task?.task_status) } -export function resolveDisplayShopName(provider: unknown, shopId: unknown, shopName: unknown): string { +export function resolveDisplayShopName( + provider: unknown, + shopId: unknown, + shopName: unknown, +): string { const normalizedShopName = String(shopName || '').trim() if (normalizedShopName) { return normalizedShopName @@ -224,13 +407,6 @@ export function resolveDisplayShopName(provider: unknown, shopId: unknown, shopN return '' } - if (String(provider || '').trim().toLowerCase() === 'agiso') { - const configuredName = String(getAgisoShopConfig(normalizedShopId)?.shopName || '').trim() - if (configuredName) { - return configuredName - } - } - return normalizedShopId } @@ -261,8 +437,13 @@ export function resolveOrderItemTitle(item: Partial | null | undef ]) } -export function resolveOrderItemDeliveryMode(tasks: TaskLike[] | null | undefined, orderItemId: number | string): string { - const task = (Array.isArray(tasks) ? tasks : []).find((item) => item.order_item_id === orderItemId) +export function resolveOrderItemDeliveryMode( + tasks: TaskLike[] | null | undefined, + orderItemId: number | string, +): string { + const task = (Array.isArray(tasks) ? tasks : []).find( + (item) => item.order_item_id === orderItemId, + ) if (!task) { return '' @@ -276,7 +457,12 @@ export function resolveOrderItemDeliveryMode(tasks: TaskLike[] | null | undefine return 'kuaishou_cloud' } - if (task.requires_claim || getTaskPrimaryClaimTokenId(task) || task.primary_claim_token || task.claim_token) { + if ( + task.requires_claim || + getTaskPrimaryClaimTokenId(task) || + task.primary_claim_token || + task.claim_token + ) { return 'claim_link' } @@ -287,16 +473,15 @@ export function isManualDispatchTask(task: TaskLike | null | undefined): boolean return String(task?.executor_key || '').trim() === 'manual_dispatch' } -export function isAssistedClaimTask(task: TaskLike | null | undefined): boolean { - return String(task?.executor_key || '').trim() === 'tencent_claim_assisted' -} - -export function canRegenerateClaimLinkForViewer(task: TaskLike, viewerContext: AdminViewerContext): boolean { +export function canRegenerateClaimLinkForViewer( + task: TaskLike, + viewerContext: AdminViewerContext, +): boolean { if (isManualDispatchTask(task)) { return false } - const baseAllowed = ['link_generated', 'claimed', 'role_confirmed', 'retry_pending', 'manual_review'].includes(task.task_status) + const baseAllowed = canRegenerateClaimLinkStatus(task.task_status) if (!baseAllowed) { return false @@ -306,57 +491,7 @@ export function canRegenerateClaimLinkForViewer(task: TaskLike, viewerContext: A return true } - return viewerContext.canOperateAssistedTask - && isAssistedClaimTask(task) - && canViewerAccessTaskInventoryGroup(task, viewerContext) -} - -export function canViewerConfirmAssistedRole(task: TaskLike, viewerContext: AdminViewerContext): boolean { - if (!viewerContext.canOperateAssistedTask || !isAssistedClaimTask(task) || !canViewerAccessTaskInventoryGroup(task, viewerContext)) { - return false - } - - return String(task?.task_status || '').trim() === 'claimed' -} - -export function canViewerRedeemAssistedTask(task: TaskLike, viewerContext: AdminViewerContext): boolean { - if (!viewerContext.canOperateAssistedTask || !isAssistedClaimTask(task) || !canViewerAccessTaskInventoryGroup(task, viewerContext)) { - return false - } - - return ['role_confirmed', 'redeeming'].includes(String(task?.task_status || '').trim()) -} - -export function canViewerAccessTaskInventoryGroup( - task: TaskLike, - viewerContext: AdminViewerContext = createAdminViewerContext(), -): boolean { - if (!viewerContext.restrictInventoryGroups) { - return true - } - - const allowedInventoryGroupCodes = Array.isArray(viewerContext.allowedInventoryGroupCodes) - ? viewerContext.allowedInventoryGroupCodes - : [] - - if (allowedInventoryGroupCodes.length === 0) { - return false - } - - const taskContext = parseTaskContext(task) - const inventoryGroupCode = String(taskContext?.inventoryGroupCode || '').trim() - - if (!inventoryGroupCode) { - return true - } - - return allowedInventoryGroupCodes.includes(inventoryGroupCode) -} - -export function normalizeInventoryGroupCodes(values: unknown): string[] { - return Array.from(new Set((Array.isArray(values) ? values : []) - .map((value) => String(value || '').trim()) - .filter(Boolean))) + return false } function mapRedeemResolutionAttempt(value: unknown): RedeemResolutionAttempt | null { @@ -367,7 +502,6 @@ function mapRedeemResolutionAttempt(value: unknown): RedeemResolutionAttempt | n const record = value as JsonRecord return { attempt: Math.max(1, Number(record.attempt || 1)), - inventoryItemId: Number(record.inventoryItemId || 0) || null, codeMasked: String(record.codeMasked || '').trim(), credentialType: String(record.credentialType || '').trim(), outcome: String(record.outcome || '').trim(), diff --git a/apps/backend/src/services/admin/admin-service.ts b/apps/backend/src/services/admin/admin-service.ts deleted file mode 100644 index 6a5767ab..00000000 --- a/apps/backend/src/services/admin/admin-service.ts +++ /dev/null @@ -1,86 +0,0 @@ -export { - getRequiredInventoryItem, - mapAdminInventoryListItem, -} from './admin-inventory-read-helpers.js' -export { - mapAdminOrderListItem, - summarizeOrderItems, -} from './admin-order-read-helpers.js' -export { mapAdminWebhookEvent } from './admin-webhook-read-helpers.js' - -export { - buildOrderAgisoAutoDeliverySummary, - buildOrderBindingSummary, - createEmptyTaskBindingSummary, - createTaskBindingSummaryFromBindings, - getRequiredTask, - getTaskBindingSummary, - getTaskBindingSummaryMap, - mapAdminTaskEvent, - mapAdminTaskInventoryBinding, - mapAdminTaskListItem, - mapAdminTaskSummary, - mapTaskActionPayload, -} from './admin-task-read-helpers.js' - -export { - canRegenerateClaimLinkForViewer, - canViewerConfirmAssistedRole, - canViewerRedeemAssistedTask, - createAdminViewerContext, - getTaskPrimaryClaimTokenId, - getTaskPrimaryInventoryItemId, - isAssistedClaimTask, - isManualDispatchTask, - mapManualDispatchContext, - mapRedeemResolutionContext, - parseTaskContext, - parseTaskState, - resolveOrderItemDeliveryMode, - resolveOrderItemTitle, - resolveAdminTaskScreenshotUrl, - resolveDisplayShopName, -} from './admin-read-shared-helpers.js' - -export { - getAdminInventoryItems, - getAdminInventorySkuSuggestions, - getAdminOrderDetail, - getAdminOrders, - getAdminTaskDetail, - getAdminTasks, - getAdminTaskScreenshotPath, - getAdminWebhookEventDetail, - getAdminWebhookEvents, -} from './admin-read-service.js' - -export { - closeAdminTask, - completeAdminTaskManualDispatch, - confirmAdminTaskAssistedRole, - createAdminInventoryItem, - dispatchAdminTaskKuaishouCloudFulfillment, - importAdminInventoryItems, - invalidateAdminInventoryItem, - markAdminTaskManualReview, - prepareAdminTaskKuaishouCloudFulfillment, - redeemAdminTaskAssisted, - regenerateAdminTaskClaimLink, - releaseAdminInventoryItem, - releaseAdminTaskInventory, - releaseAdminTaskInventoryBinding, - replayAdminWebhookEvent, - returnNumberAdminTaskKuaishouCloudFulfillment, - retryAdminTask, -} from './admin-write-service.js' - -export { - getAdminAgisoShopConfigs, - getAdminFulfillmentBindingConfigs, - lookupAdminFulfillmentBindingOrder, - updateAdminAgisoShopConfigs, - updateAdminFulfillmentBindingConfigs, -} from './platform-config/service.js' - -export { getAdminDashboardSummary } from './admin-dashboard-service.js' -export { getAdminMessageDeliveries } from './admin-message-delivery-service.js' diff --git a/apps/backend/src/services/admin/admin-task-read-helpers.ts b/apps/backend/src/services/admin/admin-task-read-helpers.ts index ae9092db..022372e9 100644 --- a/apps/backend/src/services/admin/admin-task-read-helpers.ts +++ b/apps/backend/src/services/admin/admin-task-read-helpers.ts @@ -1,94 +1,59 @@ import { getTaskById } from '../../repositories/task-repo.js' -import { listTaskInventoryBindingSummariesByTaskIds } from '../../repositories/task-inventory-binding-repo.js' import { createHttpError } from '../../utils/http.js' import { safeParseJson } from './admin-query-utils.js' +import { + TASK_STATUS, + isTaskFulfillmentCompletedStatus, + normalizeTaskStatus, +} from '../../domain/task-status.js' import { createAdminViewerContext, getTaskPrimaryClaimTokenId, - getTaskPrimaryInventoryItemId, - parseTaskContext, } from './admin-read-shared-helpers.js' import type { - AdminAgisoAutoDeliveryStatus, - AdminTaskBindingSummary, + AdminOrderFulfillmentProgress, AdminTaskListItem, -} from '../../types/admin-read-models.js' -import type { AdminTaskActionPayload } from '../../types/admin-write-models.js' +} from '../../types/admin/read-models.js' +import type { AdminTaskActionPayload } from '../../types/admin/write-models.js' import type { - OrderRow, TaskEventRow, - TaskInventoryBindingRow, - TaskInventoryBindingSummaryRow, TaskRow, -} from '../../types/repository-rows.js' +} from '../../types/repository/rows.js' import type { AdminViewerContext } from './admin-read-shared-helpers.js' type JsonRecord = Record -type TaskBindingState = { - systemBindingStatus: string - userBindingStatus: string -} - -type TaskInventoryBindingLike = TaskInventoryBindingRow & { - inventory_item_status?: string -} - -type OrderBindingSummary = { - totalTaskCount: number - systemBoundTaskCount: number - completedBindingTaskCount: number - totalBindingCount: number - reservedBindingCount: number - consumedBindingCount: number - releasedBindingCount: number - systemBindingStatus: string - userBindingStatus: string -} - -type OrderAgisoAutoDeliverySummary = AdminAgisoAutoDeliveryStatus & { - sourceTaskId: number | null - sourceTaskNo: string - totalTaskCount: number - deliveredTaskCount: number -} - -type OrderAgisoAutoDeliveryCandidate = AdminAgisoAutoDeliveryStatus & { - sourceTaskId: number | null - sourceTaskNo: string +type TaskFulfillmentState = { + resourceStatus: string + customerStatus: string } export function mapAdminTaskSummary( task: TaskRow, - bindingSummary: AdminTaskBindingSummary = createEmptyTaskBindingSummary(), ): JsonRecord { - const binding = buildTaskBindingState(task) - const taskContext = parseTaskContext(task) + const fulfillment = buildTaskFulfillmentState(task) return { taskId: task.id, taskNo: task.task_no, deliveryStatus: task.delivery_status || '', status: task.task_status, - systemBindingStatus: binding.systemBindingStatus, - userBindingStatus: binding.userBindingStatus, + resourceStatus: fulfillment.resourceStatus, + customerStatus: fulfillment.customerStatus, loginType: task.login_type, - browserSessionId: task.browser_session_id, + runtimeSessionId: task.runtime_session_id, claimedAt: task.claimed_at, roleConfirmedAt: task.role_confirmed_at, redeemedAt: task.redeemed_at, lastError: task.last_error, retryCount: getTaskRetryCount(task), - bindingSummary, - agisoAutoDelivery: mapAgisoAutoDeliveryContext(taskContext.agisoAutoDelivery), createdAt: task.created_at, updatedAt: task.updated_at, } } export function mapTaskActionPayload(task: any): AdminTaskActionPayload { - const inventoryItemId = getTaskPrimaryInventoryItemId(task) const primaryClaimTokenId = getTaskPrimaryClaimTokenId(task) return { @@ -98,7 +63,6 @@ export function mapTaskActionPayload(task: any): AdminTaskActionPayload { deliveryStatus: task.delivery_status, resultCode: task.result_code, resultMessage: task.result_message, - inventoryItemId, primaryClaimTokenId, lastError: task.last_error, updatedAt: task.updated_at, @@ -120,11 +84,9 @@ export async function getRequiredTask(taskId: number | string): Promise export function mapAdminTaskListItem( task: TaskRow, - bindingSummary: AdminTaskBindingSummary = createEmptyTaskBindingSummary(), viewerContext: AdminViewerContext = createAdminViewerContext(), ): AdminTaskListItem { - const binding = buildTaskBindingState(task) - const taskContext = parseTaskContext(task) + const fulfillment = buildTaskFulfillmentState(task) return { taskId: task.id, @@ -137,23 +99,19 @@ export function mapAdminTaskListItem( deliveryStatus: task.delivery_status || '', resultCode: task.result_code || '', resultMessage: task.result_message || '', - systemBindingStatus: binding.systemBindingStatus, - userBindingStatus: binding.userBindingStatus, + resourceStatus: fulfillment.resourceStatus, + customerStatus: fulfillment.customerStatus, loginType: task.login_type, - roleName: task.role_name, - roleId: task.role_id, - browserSessionId: task.browser_session_id, + roleName: resolveTaskDisplayRoleName(task), + roleId: resolveTaskDisplayRoleId(task), + runtimeSessionId: task.runtime_session_id, claimedAt: task.claimed_at, roleConfirmedAt: task.role_confirmed_at, redeemedAt: task.redeemed_at, retryCount: getTaskRetryCount(task), - bindingSummary, - agisoAutoDelivery: mapAgisoAutoDeliveryContext(taskContext.agisoAutoDelivery), lastError: task.last_error, createdAt: task.created_at, updatedAt: task.updated_at, - inventoryDisplayMasked: viewerContext.canViewSensitiveTaskData ? maskCode(task.primary_inventory_display_value) : '', - inventoryCredentialType: String(task.primary_inventory_credential_type || '').trim(), claimToken: viewerContext.canViewSensitiveTaskData ? (task.primary_claim_token || task.claim_token || '') : '', screenshotPath: viewerContext.role === 'support' ? '' : (task.screenshot_path || ''), } @@ -170,338 +128,152 @@ export function mapAdminTaskEvent(event: TaskEventRow): JsonRecord { } } -export function createEmptyTaskBindingSummary(): AdminTaskBindingSummary { - return { - totalBindingCount: 0, - reservedBindingCount: 0, - consumedBindingCount: 0, - releasedBindingCount: 0, - roleKeys: [], - } -} - -export async function getTaskBindingSummaryMap(taskIds: Array = []): Promise> { - const rows = await listTaskInventoryBindingSummariesByTaskIds(taskIds) - const output = new Map() - - for (const row of rows as TaskInventoryBindingSummaryRow[]) { - output.set(Number(row.task_id), { - totalBindingCount: Number(row.total_binding_count || 0), - reservedBindingCount: Number(row.reserved_binding_count || 0), - consumedBindingCount: Number(row.consumed_binding_count || 0), - releasedBindingCount: Number(row.released_binding_count || 0), - roleKeys: Array.isArray(row.role_keys) - ? row.role_keys.map((value) => String(value || '').trim()).filter(Boolean) - : [], - }) - } - - return output -} - -export function getTaskBindingSummary( - summaryMap: Map, - taskId: number | string, -): AdminTaskBindingSummary { - return summaryMap.get(Number(taskId)) || createEmptyTaskBindingSummary() -} - -export function createTaskBindingSummaryFromBindings( - bindings: Array> | null | undefined = [], -): AdminTaskBindingSummary { - const normalizedBindings = Array.isArray(bindings) ? bindings : [] - const roleKeys = Array.from(new Set(normalizedBindings - .map((binding) => String(binding?.role_key || '').trim()) - .filter(Boolean))) - - return { - totalBindingCount: normalizedBindings.length, - reservedBindingCount: normalizedBindings.filter((binding) => String(binding?.binding_status || '') === 'reserved').length, - consumedBindingCount: normalizedBindings.filter((binding) => String(binding?.binding_status || '') === 'consumed').length, - releasedBindingCount: normalizedBindings.filter((binding) => String(binding?.binding_status || '') === 'released').length, - roleKeys, - } -} - -export function mapAdminTaskInventoryBinding( - binding: TaskInventoryBindingLike, - task: TaskRow, - viewerContext: AdminViewerContext = createAdminViewerContext(), -): JsonRecord { - const metadata = safeParseJson(binding.metadata_json) - - return { - bindingId: binding.id, - inventoryItemId: binding.inventory_item_id, - roleKey: String(binding.role_key || '').trim(), - quantity: Math.max(1, Number(binding.quantity || 1)), - bindingStatus: String(binding.binding_status || '').trim(), - inventoryStatus: String(binding.inventory_item_status || '').trim(), - skuCode: String(binding.sku_code || '').trim(), - batchNo: String(binding.batch_no || '').trim(), - credentialType: String(binding.credential_type || 'tencent_code').trim() || 'tencent_code', - displayValue: viewerContext.canViewSensitiveTaskData ? String(binding.display_value || '').trim() : '', - invalidReason: String(binding.invalid_reason || '').trim(), - consumedAt: binding.consumed_at || null, - releasedAt: binding.released_at || null, - createdAt: binding.created_at, - updatedAt: binding.updated_at, - metadata: normalizeRecord(metadata), - isPrimary: Number(binding.inventory_item_id || 0) === getTaskPrimaryInventoryItemId(task), - canRelease: viewerContext.canManageSensitiveInventory && canReleaseTaskInventoryBinding(task, binding), - } -} - -export function buildOrderBindingSummary( +export function buildOrderFulfillmentProgress( tasks: TaskRow[] | null | undefined, - taskBindingSummaryMap: Map = new Map(), -): OrderBindingSummary { +): AdminOrderFulfillmentProgress { const normalizedTasks = Array.isArray(tasks) ? tasks : [] const totalTaskCount = normalizedTasks.length - const taskBindings = normalizedTasks.map((task) => buildTaskBindingState(task)) - const systemBoundTaskCount = normalizedTasks.filter((task) => isTaskSystemBound(task)).length - const completedBindingTaskCount = normalizedTasks.filter((task) => String(task?.task_status || '') === 'redeemed').length - const bindingSummaries = normalizedTasks.map((task) => getTaskBindingSummary(taskBindingSummaryMap, task.id)) - const totalBindingCount = bindingSummaries.reduce((sum, item) => sum + item.totalBindingCount, 0) - const reservedBindingCount = bindingSummaries.reduce((sum, item) => sum + item.reservedBindingCount, 0) - const consumedBindingCount = bindingSummaries.reduce((sum, item) => sum + item.consumedBindingCount, 0) - const releasedBindingCount = bindingSummaries.reduce((sum, item) => sum + item.releasedBindingCount, 0) + const taskFulfillments = normalizedTasks.map((task) => buildTaskFulfillmentState(task)) + const preparedTaskCount = normalizedTasks.filter((task) => isTaskResourcePrepared(task)).length + const completedTaskCount = normalizedTasks.filter((task) => isTaskFulfillmentCompleted(task)).length - let systemBindingStatus = 'pending_binding' - let userBindingStatus = 'not_started' + let resourceStatus = 'pending_prepare' + let customerStatus = 'not_started' if (totalTaskCount === 0) { return { totalTaskCount, - systemBoundTaskCount, - completedBindingTaskCount, - totalBindingCount, - reservedBindingCount, - consumedBindingCount, - releasedBindingCount, - systemBindingStatus, - userBindingStatus, + preparedTaskCount, + completedTaskCount, + resourceStatus, + customerStatus, } } - if (normalizedTasks.every((task) => String(task.task_status || '') === 'redeemed')) { - systemBindingStatus = 'system_bound' - userBindingStatus = 'binding_completed' - } else if (taskBindings.some((item) => ['binding_in_progress', 'binding_confirmed', 'link_opened'].includes(item.userBindingStatus))) { - systemBindingStatus = systemBoundTaskCount > 0 ? 'system_bound' : 'pending_binding' - userBindingStatus = 'user_binding' - } else if (taskBindings.some((item) => item.userBindingStatus === 'waiting_user_claim')) { - systemBindingStatus = systemBoundTaskCount > 0 ? 'system_bound' : 'pending_binding' - userBindingStatus = 'waiting_user_claim' - } else if (taskBindings.some((item) => item.userBindingStatus === 'binding_exception')) { - systemBindingStatus = systemBoundTaskCount > 0 ? 'system_bound' : 'pending_binding' - userBindingStatus = 'binding_exception' - } else if (systemBoundTaskCount > 0) { - systemBindingStatus = 'system_bound' - } else if (taskBindings.some((item) => ['manual_review', 'retry_pending', 'waiting_inventory'].includes(item.systemBindingStatus))) { - systemBindingStatus = 'binding_exception' + if (normalizedTasks.every((task) => isTaskFulfillmentCompleted(task))) { + resourceStatus = 'resource_ready' + customerStatus = 'customer_completed' + } else if (taskFulfillments.some((item) => ['customer_processing', 'customer_confirmed', 'link_opened'].includes(item.customerStatus))) { + resourceStatus = preparedTaskCount > 0 ? 'resource_ready' : 'pending_prepare' + customerStatus = 'customer_processing' + } else if (taskFulfillments.some((item) => item.customerStatus === 'waiting_customer')) { + resourceStatus = preparedTaskCount > 0 ? 'resource_ready' : 'pending_prepare' + customerStatus = 'waiting_customer' + } else if (taskFulfillments.some((item) => item.customerStatus === 'customer_exception')) { + resourceStatus = preparedTaskCount > 0 ? 'resource_ready' : 'pending_prepare' + customerStatus = 'customer_exception' + } else if (preparedTaskCount > 0) { + resourceStatus = 'resource_ready' + } else if (taskFulfillments.some((item) => ['manual_review', 'retry_pending'].includes(item.resourceStatus))) { + resourceStatus = 'resource_exception' } return { totalTaskCount, - systemBoundTaskCount, - completedBindingTaskCount, - totalBindingCount, - reservedBindingCount, - consumedBindingCount, - releasedBindingCount, - systemBindingStatus, - userBindingStatus, + preparedTaskCount, + completedTaskCount, + resourceStatus, + customerStatus, } } -export function buildOrderAgisoAutoDeliverySummary( - order: Partial | null | undefined, - tasks: TaskRow[] = [], -): OrderAgisoAutoDeliverySummary | null { - if (String(order?.provider || '').trim() !== 'agiso' || String(order?.platform || '').trim() !== 'xianyu') { - return null - } - - const normalizedTasks = Array.isArray(tasks) ? tasks.filter(Boolean) : [] - const totalTaskCount = normalizedTasks.length - const deliveredTaskCount = normalizedTasks.filter((task) => String(task?.delivery_status || '').trim() === 'delivered').length - const latest = normalizedTasks.reduce((best, task) => { - const autoDelivery = mapAgisoAutoDeliveryContext(parseTaskContext(task).agisoAutoDelivery) - - if (!autoDelivery) { - return best - } - - const candidate = { - ...autoDelivery, - sourceTaskId: Number(task.id || 0) || null, - sourceTaskNo: String(task.task_no || '').trim(), - } - const candidateTime = Date.parse(String(candidate.updatedAt || task.updated_at || '')) - const bestTime = Date.parse(String(best?.updatedAt || '')) - - if (!best || (Number.isFinite(candidateTime) && (!Number.isFinite(bestTime) || candidateTime >= bestTime))) { - return candidate - } - - return best - }, null) - - if (latest) { - return { - ...latest, - totalTaskCount, - deliveredTaskCount, - } - } - - return { - status: totalTaskCount === 0 ? 'not_started' : deliveredTaskCount >= totalTaskCount ? 'pending' : 'waiting', - trigger: '', - reason: deliveredTaskCount >= totalTaskCount ? '' : 'waiting_other_tasks', - platformOrderId: String(order?.platform_order_id || '').trim(), - responseStatus: 0, - errorMessage: '', - requestId: '', - updatedAt: null, - sourceTaskId: null, - sourceTaskNo: '', - totalTaskCount, - deliveredTaskCount, - } -} - -function canReleaseTaskInventoryBinding(task: TaskRow | null | undefined, binding: TaskInventoryBindingLike | null | undefined): boolean { - if (!task || !binding) { - return false - } - - if (String(binding.binding_status || '').trim() !== 'reserved') { - return false - } - - return !['redeemed', 'expired'].includes(String(task.task_status || '').trim()) -} - -function buildTaskBindingState(task: TaskRow | null | undefined): TaskBindingState { - const normalizedStatus = String(task?.task_status || '').trim() +function buildTaskFulfillmentState(task: TaskRow | null | undefined): TaskFulfillmentState { + const normalizedStatus = normalizeTaskStatus(task?.task_status) if (!normalizedStatus) { return { - systemBindingStatus: 'pending_binding', - userBindingStatus: 'not_started', + resourceStatus: 'pending_prepare', + customerStatus: 'not_started', } } - if (normalizedStatus === 'pending_payment') { - return { systemBindingStatus: 'pending_payment', userBindingStatus: 'not_started' } + if (normalizedStatus === TASK_STATUS.PENDING_PAYMENT) { + return { resourceStatus: 'pending_payment', customerStatus: 'not_started' } } - if (normalizedStatus === 'paid') { - return { systemBindingStatus: 'pending_binding', userBindingStatus: 'not_started' } + if (normalizedStatus === TASK_STATUS.PAID) { + return { resourceStatus: 'pending_prepare', customerStatus: 'not_started' } } - if (normalizedStatus === 'waiting_inventory') { - return { systemBindingStatus: 'waiting_inventory', userBindingStatus: 'not_started' } + if (normalizedStatus === TASK_STATUS.PENDING_BINDING_PREPARE) { + return { resourceStatus: 'pending_prepare', customerStatus: 'not_started' } } - if (normalizedStatus === 'pending_binding_prepare') { - return { systemBindingStatus: 'pending_binding', userBindingStatus: 'not_started' } + if (normalizedStatus === TASK_STATUS.WAITING_BINDING) { + return { resourceStatus: 'resource_ready', customerStatus: 'waiting_customer' } } - if (normalizedStatus === 'waiting_binding') { - return { systemBindingStatus: 'system_bound', userBindingStatus: 'waiting_user_claim' } + if (normalizedStatus === TASK_STATUS.DISPATCHED_PENDING_RETURN) { + return { resourceStatus: 'resource_ready', customerStatus: 'customer_completed' } } - if (normalizedStatus === 'dispatched_pending_return') { - return { systemBindingStatus: 'system_bound', userBindingStatus: 'binding_completed' } + if (normalizedStatus === TASK_STATUS.COMPLETED) { + return { resourceStatus: 'resource_ready', customerStatus: 'customer_completed' } } - if (normalizedStatus === 'completed') { - return { systemBindingStatus: 'system_bound', userBindingStatus: 'binding_completed' } + if (normalizedStatus === TASK_STATUS.MANUAL_REVIEW) { + return { resourceStatus: 'manual_review', customerStatus: 'customer_exception' } } - if (normalizedStatus === 'manual_review') { - return { systemBindingStatus: 'manual_review', userBindingStatus: 'binding_exception' } + if (normalizedStatus === TASK_STATUS.RETRY_PENDING) { + return { resourceStatus: 'retry_pending', customerStatus: 'customer_exception' } } - if (normalizedStatus === 'retry_pending') { - return { systemBindingStatus: 'retry_pending', userBindingStatus: 'binding_exception' } + if (normalizedStatus === TASK_STATUS.CLOSED) { + return { resourceStatus: 'closed', customerStatus: 'closed' } } - if (normalizedStatus === 'closed') { - return { systemBindingStatus: 'closed', userBindingStatus: 'closed' } + if (normalizedStatus === TASK_STATUS.EXPIRED) { + return { resourceStatus: 'expired', customerStatus: 'expired' } } - if (normalizedStatus === 'expired') { - return { systemBindingStatus: 'expired', userBindingStatus: 'expired' } + if (normalizedStatus === TASK_STATUS.LINK_GENERATED) { + return { resourceStatus: 'resource_ready', customerStatus: 'waiting_customer' } } - if (normalizedStatus === 'link_generated') { - return { systemBindingStatus: 'system_bound', userBindingStatus: 'waiting_user_claim' } + if (normalizedStatus === TASK_STATUS.CLAIMED) { + return { resourceStatus: 'resource_ready', customerStatus: 'link_opened' } } - if (normalizedStatus === 'claimed') { - return { systemBindingStatus: 'system_bound', userBindingStatus: 'link_opened' } + if (normalizedStatus === TASK_STATUS.ROLE_CONFIRMED) { + return { resourceStatus: 'resource_ready', customerStatus: 'customer_confirmed' } } - if (normalizedStatus === 'role_confirmed') { - return { systemBindingStatus: 'system_bound', userBindingStatus: 'binding_confirmed' } + if (normalizedStatus === TASK_STATUS.REDEEMING) { + return { resourceStatus: 'resource_ready', customerStatus: 'customer_processing' } } - if (normalizedStatus === 'redeeming') { - return { systemBindingStatus: 'system_bound', userBindingStatus: 'binding_in_progress' } + if (normalizedStatus === TASK_STATUS.REDEEMED) { + return { resourceStatus: 'resource_ready', customerStatus: 'customer_completed' } } - if (normalizedStatus === 'redeemed') { - return { systemBindingStatus: 'system_bound', userBindingStatus: 'binding_completed' } + if (isTaskResourcePrepared(task)) { + return { resourceStatus: 'resource_ready', customerStatus: 'waiting_customer' } } - if (isTaskSystemBound(task)) { - return { systemBindingStatus: 'system_bound', userBindingStatus: 'waiting_user_claim' } - } - - return { systemBindingStatus: 'pending_binding', userBindingStatus: 'not_started' } + return { resourceStatus: 'pending_prepare', customerStatus: 'not_started' } } -function isTaskSystemBound(task: TaskRow | null | undefined): boolean { - return Boolean(task && (getTaskPrimaryInventoryItemId(task) || getTaskPrimaryClaimTokenId(task))) +function isTaskResourcePrepared(task: TaskRow | null | undefined): boolean { + return Boolean(task && getTaskPrimaryClaimTokenId(task)) } -function mapAgisoAutoDeliveryContext(value: unknown): AdminAgisoAutoDeliveryStatus | null { - if (!value || typeof value !== 'object') { - return null - } - - const record = value as JsonRecord - return { - status: String(record.status || '').trim(), - trigger: String(record.trigger || '').trim(), - reason: String(record.reason || '').trim(), - platformOrderId: String(record.platformOrderId || '').trim(), - responseStatus: Number(record.responseStatus || 0), - errorMessage: String(record.errorMessage || '').trim(), - requestId: String(record.requestId || '').trim(), - updatedAt: record.updatedAt || null, - } +function isTaskFulfillmentCompleted(task: TaskRow | null | undefined): boolean { + return isTaskFulfillmentCompletedStatus(task?.task_status) } function normalizeRecord(value: unknown): JsonRecord { return value && typeof value === 'object' && !Array.isArray(value) ? value : {} } -function maskCode(value: unknown): string { - const text = String(value || '').trim() - if (!text) { - return '' - } - if (text.length <= 8) { - return `${text.slice(0, 2)}****${text.slice(-2)}` - } - return `${text.slice(0, 4)}****${text.slice(-4)}` -} - function getTaskRetryCount(task: TaskRow | null | undefined): number { return Number(task?.attempt_count || 0) } + +function resolveTaskDisplayRoleName(task: TaskRow): string { + return String(task.kuaishou_role_name || task.role_name || '').trim() +} + +function resolveTaskDisplayRoleId(task: TaskRow): string { + return String(task.kuaishou_role_id || task.role_id || '').trim() +} diff --git a/apps/backend/src/services/admin/admin-webhook-read-helpers.test.ts b/apps/backend/src/services/admin/admin-webhook-read-helpers.test.ts deleted file mode 100644 index 496d098c..00000000 --- a/apps/backend/src/services/admin/admin-webhook-read-helpers.test.ts +++ /dev/null @@ -1,14 +0,0 @@ -import test from 'node:test' -import assert from 'node:assert/strict' - -import { resolveWebhookVisibilityLevel } from './admin-webhook-read-helpers.js' - -test('resolveWebhookVisibilityLevel marks ignored process errors as ignored', () => { - assert.equal(resolveWebhookVisibilityLevel('ignored_unconfigured_product'), 'ignored') - assert.equal(resolveWebhookVisibilityLevel('ignored_duplicate_event'), 'ignored') -}) - -test('resolveWebhookVisibilityLevel keeps normal and failed events as important', () => { - assert.equal(resolveWebhookVisibilityLevel(''), 'important') - assert.equal(resolveWebhookVisibilityLevel('invalid_signature'), 'important') -}) diff --git a/apps/backend/src/services/admin/admin-webhook-read-helpers.ts b/apps/backend/src/services/admin/admin-webhook-read-helpers.ts deleted file mode 100644 index 76827730..00000000 --- a/apps/backend/src/services/admin/admin-webhook-read-helpers.ts +++ /dev/null @@ -1,199 +0,0 @@ -import { listTasksByOrderId } from '../../repositories/task-repo.js' -import { formatFenToAmount, parseAmountToFen } from '../../utils/money.js' -import { extractAgisoTradePayload, resolveAgisoTradePlatformOrderId } from '../order/agiso-trade-parsing.js' -import { safeParseJson } from './admin-query-utils.js' -import { resolveDisplayShopName } from './admin-read-shared-helpers.js' - -import type { AdminWebhookEventListItem } from '../../types/admin-read-models.js' -import type { WebhookEventRow } from '../../types/repository-rows.js' - -type JsonRecord = Record - -type MapAdminWebhookEventOptions = { - includeRaw?: boolean -} - -export async function mapAdminWebhookEvent( - item: WebhookEventRow, - { includeRaw = false }: MapAdminWebhookEventOptions = {}, -): Promise { - const headers = normalizeRecord(safeParseJson(item.headers_json)) - const query = normalizeRecord(safeParseJson(item.query_json)) - const body = normalizeRecord(safeParseJson(item.body_json)) - const payload = extractWebhookPayload(body) - const itemSources = extractWebhookItemSources(payload) - const rawAmount = pickFirstNonEmpty([ - payload.total_fee, - payload.totalFee, - payload.TotalFee, - payload.pay_fee, - payload.payFee, - payload.Payment, - payload.payment, - payload.buy_amount, - payload.buyAmount, - ]) - const totalAmountFen = parseAmountToFen(rawAmount) - const taskCount = item.related_order_id ? (await listTasksByOrderId(item.related_order_id)).length : 0 - const shopId = item.shop_id || pickFirstNonEmpty([ - payload.PlatformUserId, - payload.platformUserId, - payload.platform_user_id, - payload.seller_id, - payload.sellerId, - payload.SellerId, - payload.seller_nick, - payload.sellerNick, - payload.SellerNick, - ]) - - const mapped = { - eventId: item.id, - provider: item.provider || 'agiso', - platform: item.platform, - platformRaw: pickFirstNonEmpty([ - query.fromPlatform, - query.from_platform, - body.fromPlatform, - body.from_platform, - payload.fromPlatform, - payload.FromPlatform, - payload.platform, - payload.Platform, - ]), - shopId, - shopName: resolveDisplayShopName( - item.provider, - shopId, - item.shop_name || pickFirstNonEmpty([ - payload.shopName, - payload.shop_name, - payload.ShopName, - payload.seller_nick, - payload.sellerNick, - payload.SellerNick, - ]), - ), - eventType: item.event_type, - eventKey: item.event_key, - signatureValid: Boolean(item.signature_valid), - processed: Boolean(item.processed), - processError: item.process_error, - visibilityLevel: resolveWebhookVisibilityLevel(item.process_error), - relatedOrderId: item.related_order_id, - createdAt: item.created_at, - platformOrderId: resolveAgisoTradePlatformOrderId(payload), - buyerId: pickFirstNonEmpty([ - payload.buyer_id, - payload.buyerId, - payload.BuyerId, - payload.openid, - payload.BuyerOpenUid, - payload.buyer_open_uid, - payload.buyerOpenUid, - ]), - buyerName: pickFirstNonEmpty([ - payload.buyer_name, - payload.buyerName, - payload.BuyerName, - payload.nick, - payload.BuyerNick, - payload.buyer_nick, - payload.buyerNick, - ]), - totalAmount: rawAmount ? formatFenToAmount(totalAmountFen) : '', - totalAmountFen, - currency: pickFirstNonEmpty([payload.currency, payload.Currency, 'CNY']) || 'CNY', - itemCount: itemSources.length, - taskCount, - messageText: pickFirstNonEmpty([ - payload.MsgTypeDes, - payload.msgTypeDes, - payload.msg_type_desc, - payload.message, - payload.msg, - ]), - requestTimestamp: pickFirstNonEmpty([query.timestamp]), - requestSign: pickFirstNonEmpty([query.sign]), - aopic: pickFirstNonEmpty([query.aopic]), - sourceHost: pickFirstNonEmpty([headers['x-forwarded-host'], headers.host]), - sourceIp: pickFirstNonEmpty([headers['x-forwarded-for'], headers['x-real-ip']]), - userAgent: pickFirstNonEmpty([headers['user-agent'], headers['User-Agent']]), - } - - if (!includeRaw) { - return mapped - } - - return { - ...mapped, - headers, - query, - body, - payload, - } -} - -function extractWebhookPayload(body: JsonRecord): JsonRecord { - return extractAgisoTradePayload(body) -} - -function extractWebhookItemSources(payload: unknown): JsonRecord[] { - const normalizedPayload = normalizeRecord(payload) - const candidates = [ - normalizedPayload.orders, - normalizedPayload.Orders, - normalizedPayload.items, - normalizedPayload.Items, - normalizedPayload.order_items, - ] - - for (const candidate of candidates) { - if (Array.isArray(candidate) && candidate.length > 0) { - return candidate.map((item) => normalizeRecord(item)).filter((item) => Object.keys(item).length > 0) - } - } - - if ( - pickFirstNonEmpty([ - normalizedPayload.item_id, - normalizedPayload.itemId, - normalizedPayload.ItemId, - normalizedPayload.num_iid, - normalizedPayload.numIid, - normalizedPayload.NumIid, - ]) - ) { - return [normalizedPayload] - } - - return [] -} - -export function resolveWebhookVisibilityLevel(processError: unknown): string { - const normalized = String(processError || '').trim() - - if (normalized.startsWith('ignored_')) { - return 'ignored' - } - - return 'important' -} - -function normalizeRecord(value: unknown): JsonRecord { - return value && typeof value === 'object' && !Array.isArray(value) ? value : {} -} - -function pickFirstNonEmpty(values: unknown[]): string { - for (const value of values) { - if (typeof value === 'string' && value.trim()) { - return value.trim() - } - - if (typeof value === 'number' && Number.isFinite(value)) { - return String(value) - } - } - - return '' -} diff --git a/apps/backend/src/services/admin/admin-write-service.test.ts b/apps/backend/src/services/admin/admin-write-service.test.ts deleted file mode 100644 index 0ada829a..00000000 --- a/apps/backend/src/services/admin/admin-write-service.test.ts +++ /dev/null @@ -1,105 +0,0 @@ -import test from 'node:test' -import assert from 'node:assert/strict' - -import { closeAdminTaskWithDeps } from './admin-write-service.js' - -test('closeAdminTaskWithDeps revokes claim link, releases reserved inventory, and closes browser session', async () => { - const calls = { - updateToken: [], - releaseReserved: [], - closeSession: [], - updateTask: [], - createEvent: [], - } - const task = { - id: 30, - task_status: 'link_generated', - delivery_status: 'pending', - inventory_status: 'reserved', - user_action_status: 'pending_claim', - browser_session_id: 'browser-session-30', - primary_claim_token_id: 9, - claim_expires_at: '2026-04-15T10:00:00.000Z', - last_error: '', - updated_at: '2026-04-14T10:00:00.000Z', - } - const now = '2026-04-14T10:16:18.000Z' - - const result = await closeAdminTaskWithDeps(task.id, { - getRequiredTask: async () => task, - listTaskInventoryBindingsByTaskId: async () => ([ - { inventory_item_id: 17, binding_status: 'reserved' }, - { inventory_item_id: 18, binding_status: 'released' }, - { inventory_item_id: 17, binding_status: 'reserved' }, - ]), - releaseReservedInventoryItem: async (inventoryItemId, updatedAt) => { - calls.releaseReserved.push({ inventoryItemId, updatedAt }) - return { id: inventoryItemId } - }, - updateClaimToken: async (tokenId, patch) => { - calls.updateToken.push({ tokenId, patch }) - return { id: tokenId, ...patch } - }, - closeTencentBrowserSession: async (sessionId) => { - calls.closeSession.push(sessionId) - return { sessionId, closed: true } - }, - updateTask: async (taskId, patch) => { - calls.updateTask.push({ taskId, patch }) - return { ...task, ...patch } - }, - createTaskEvent: async (taskId, eventType, payload, createdAt) => { - calls.createEvent.push({ taskId, eventType, payload, createdAt }) - return null - }, - nowIso: () => now, - }) - - assert.equal(calls.updateToken.length, 1) - assert.deepEqual(calls.updateToken[0], { - tokenId: 9, - patch: { - status: 'revoked', - expired_at: now, - updated_at: now, - }, - }) - assert.deepEqual(calls.releaseReserved, [ - { inventoryItemId: 17, updatedAt: now }, - ]) - assert.deepEqual(calls.closeSession, ['browser-session-30']) - assert.equal(calls.updateTask.length, 1) - assert.equal(calls.updateTask[0].patch.task_status, 'closed') - assert.equal(calls.updateTask[0].patch.delivery_status, 'closed') - assert.equal(calls.updateTask[0].patch.inventory_status, 'pending') - assert.equal(calls.updateTask[0].patch.user_action_status, 'closed') - assert.equal(calls.updateTask[0].patch.browser_session_id, '') - assert.equal(calls.updateTask[0].patch.claim_expires_at, now) - assert.match(calls.updateTask[0].patch.last_error, /领取链接已失效/) - assert.match(calls.updateTask[0].patch.last_error, /预占库存已释放/) - assert.equal(calls.createEvent.length, 1) - assert.deepEqual(calls.createEvent[0], { - taskId: 30, - eventType: 'task_closed', - payload: { - claimTokenRevoked: true, - releasedInventoryItemIds: [17], - releasedInventoryCount: 1, - browserSessionClosed: true, - }, - createdAt: now, - }) - assert.equal(result.task.status, 'closed') -}) - -test('closeAdminTaskWithDeps rejects redeemed tasks', async () => { - await assert.rejects( - () => closeAdminTaskWithDeps(99, { - getRequiredTask: async () => ({ - id: 99, - task_status: 'redeemed', - }), - }), - /已兑换任务不能关闭/, - ) -}) diff --git a/apps/backend/src/services/admin/admin-write-service.ts b/apps/backend/src/services/admin/admin-write-service.ts deleted file mode 100644 index 9deebe15..00000000 --- a/apps/backend/src/services/admin/admin-write-service.ts +++ /dev/null @@ -1,30 +0,0 @@ -export { - createAdminInventoryItem, - importAdminInventoryItems, - releaseAdminInventoryItem, - invalidateAdminInventoryItem, -} from './write/inventory.js' - -export { - replayAdminWebhookEvent, -} from './write/webhook-events.js' - -export { - releaseAdminTaskInventory, - releaseAdminTaskInventoryBinding, - regenerateAdminTaskClaimLink, - confirmAdminTaskAssistedRole, - redeemAdminTaskAssisted, - closeAdminTask, - closeAdminTaskWithDeps, - markAdminTaskManualReview, - retryAdminTask, - completeAdminTaskManualDispatch, -} from './write/task-actions.js' - -export { - prepareAdminTaskKuaishouCloudFulfillment, - dispatchAdminTaskKuaishouCloudFulfillment, - refreshAdminTaskKuaishouCloudRoleInfo, - returnNumberAdminTaskKuaishouCloudFulfillment, -} from './write/kuaishou-cloud-actions.js' diff --git a/apps/backend/src/services/admin/platform-config/agiso-service.ts b/apps/backend/src/services/admin/platform-config/agiso-service.ts deleted file mode 100644 index 2c7cdcd6..00000000 --- a/apps/backend/src/services/admin/platform-config/agiso-service.ts +++ /dev/null @@ -1,65 +0,0 @@ -import { query } from '../../../db/client.js' -import { - getAgisoMessagingDefaults, - getAgisoShopConfigMap, - getAgisoShopsFilePath, - saveAgisoMessagingConfig, -} from '../../platforms/agiso/shop-config-service.js' -import { resolveDisplayShopName } from '../admin-read-shared-helpers.js' -import { - mapAdminAgisoMessagingDefaults, - mapAdminAgisoShopConfigItem, -} from './domain.js' -import { buildAdminAgisoMessagingSavePayload } from './writes.js' - -type JsonObject = Record - -export async function getAdminAgisoShopConfigs() { - const configMap = getAgisoShopConfigMap() - const defaults = getAgisoMessagingDefaults() - const rowsResult = await query( - ` - SELECT - shop_id, - MAX(CASE WHEN trim(shop_name) != '' THEN shop_name ELSE '' END) AS detected_shop_name, - MAX(created_at) AS latest_seen_at, - COUNT(*)::int AS webhook_event_count - FROM webhook_events - WHERE provider = 'agiso' AND trim(shop_id) != '' - GROUP BY shop_id - ORDER BY latest_seen_at DESC, shop_id DESC - `, - ) - const rows = rowsResult.rows - - return { - filePath: getAgisoShopsFilePath(), - defaults: mapAdminAgisoMessagingDefaults(defaults), - shops: Object.entries(configMap) - .sort(([left], [right]) => left.localeCompare(right)) - .map(([shopId, config]) => mapAdminAgisoShopConfigItem(shopId, config)), - observedShops: rows.map((row) => ({ - shopId: String(row.shop_id || '').trim(), - detectedShopName: String(row.detected_shop_name || '').trim(), - displayShopName: resolveDisplayShopName('agiso', row.shop_id, row.detected_shop_name), - latestSeenAt: row.latest_seen_at || null, - webhookEventCount: Number(row.webhook_event_count || 0), - configured: Boolean(configMap[String(row.shop_id || '').trim()]), - })), - } -} - -export function updateAdminAgisoShopConfigs(payload: JsonObject = {}) { - const saved = saveAgisoMessagingConfig(buildAdminAgisoMessagingSavePayload(payload, { - currentDefaults: getAgisoMessagingDefaults(), - currentMap: getAgisoShopConfigMap(), - })) - - return { - filePath: getAgisoShopsFilePath(), - defaults: mapAdminAgisoMessagingDefaults(saved.defaults), - shops: Object.entries(saved.shops) - .sort(([left], [right]) => left.localeCompare(right)) - .map(([shopId, config]) => mapAdminAgisoShopConfigItem(shopId, config)), - } -} diff --git a/apps/backend/src/services/admin/platform-config/cloudtentacles-service.ts b/apps/backend/src/services/admin/platform-config/cloudtentacles-service.ts deleted file mode 100644 index 94e4a9e8..00000000 --- a/apps/backend/src/services/admin/platform-config/cloudtentacles-service.ts +++ /dev/null @@ -1,371 +0,0 @@ -import { - getCloudtentaclesSourceByKey, - getCloudtentaclesSourcesFilePath, - listCloudtentaclesSources, - saveCloudtentaclesSourceByKey, - saveCloudtentaclesSourcesList, - deleteCloudtentaclesSourceByKey, -} from "../../platforms/cloudtentacles/source-config-service.js"; -import { - clearCloudtentaclesSessionStateByKey, - deleteCloudtentaclesSessionStateByKey, - getCloudtentaclesSessionFilePath, - getCloudtentaclesSessionStateByKey, - saveCloudtentaclesSessionStateByKey, - getAllCloudtentaclesSessionStates, - pruneCloudtentaclesSessionStates, -} from "../../platforms/cloudtentacles/session-state-service.js"; -import { - loginCloudtentaclesSession, - sendCloudtentaclesSmsCode, - validateCloudtentaclesSession, -} from "../../platforms/cloudtentacles/session-service.js"; -import { - buyCloudtentaclesSku, - getCloudtentaclesAsset, - getCloudtentaclesCategories, - listCloudtentaclesSku, - useCloudtentaclesSku, -} from "../../platforms/cloudtentacles/catalog-service.js"; -import { getCloudtentaclesKnapsack } from "../../platforms/cloudtentacles/knapsack-service.js"; -import { - appointCloudtentaclesVirtualNumber, - backCloudtentaclesVirtualNumber, - fetchCloudtentaclesVirtualNumberCode, - generateCloudtentaclesLoginCode, - getCloudtentaclesBindUrl, - listCloudtentaclesVirtualNumbers, - verifyCloudtentaclesLoginCode, -} from "../../platforms/cloudtentacles/virtual-number-service.js"; -import { runCloudtentaclesFullDebugFlow } from "../../platforms/cloudtentacles/debug-flow-service.js"; -import { - hasCloudtentaclesCredentialContextChanged, - pickFirstNonEmpty, -} from "./context.js"; -import { - buildAdminCloudtentaclesLoginResult, - buildAdminCloudtentaclesPersistedSessionPayload, - buildAdminCloudtentaclesValidateResult, - resolveAdminCloudtentaclesCredentialPayload, - resolveAdminCloudtentaclesSessionPayload, -} from "./cloudtentacles.js"; -import { normalizeAdminCloudtentaclesSourceConfigPayload } from "./writes.js"; -import { - mapAdminCloudtentaclesSession, - mapAdminCloudtentaclesSourceConfig, - maskPhone, -} from "./mappers.js"; - -import type { - AdminCloudtentaclesCatalogQueryInput, - AdminCloudtentaclesFullFlowInput, - AdminCloudtentaclesSendSmsCodeInput, - AdminCloudtentaclesSkuBuyInput, - AdminCloudtentaclesSkuUseInput, - AdminCloudtentaclesSourceConfigInput, - AdminCloudtentaclesTestLoginInput, - AdminCloudtentaclesValidateSessionInput, - AdminCloudtentaclesVirtualNumberInput, -} from "../../../types/admin-write-inputs.js"; - -type JsonObject = Record; - -export function listAdminCloudtentaclesSources() { - const list = listCloudtentaclesSources(); - const sessions = getAllCloudtentaclesSessionStates(); - - const sources = Array.isArray(list.sources) - ? list.sources.map((s) => mapAdminCloudtentaclesSourceConfig(s)) - : []; - - const mappedSessions: JsonObject = {}; - for (const [key, session] of Object.entries(sessions.sessions || {})) { - mappedSessions[key] = mapAdminCloudtentaclesSession(session); - } - - return { - filePath: getCloudtentaclesSourcesFilePath(), - sessionFilePath: getCloudtentaclesSessionFilePath(), - enabled: list.enabled, - sources, - sessions: mappedSessions, - }; -} - -export function updateAdminCloudtentaclesSourceConfig( - payload: AdminCloudtentaclesSourceConfigInput = {} -) { - // Batch save: if payload.sources is an array, save the whole list - if (Array.isArray(payload.sources)) { - const normalizedList = payload.sources.map((item) => - normalizeAdminCloudtentaclesSourceConfigPayload(item) - ); - const savedList = saveCloudtentaclesSourcesList({ - enabled: payload.enabled !== false, - sources: normalizedList, - }); - const sessions = pruneCloudtentaclesSessionStates( - normalizedList.map((item) => item.key) - ); - - const sources = Array.isArray(savedList.sources) - ? savedList.sources.map((s) => mapAdminCloudtentaclesSourceConfig(s)) - : []; - const mappedSessions: JsonObject = {}; - for (const [key, session] of Object.entries(sessions.sessions || {})) { - mappedSessions[key] = mapAdminCloudtentaclesSession(session); - } - - return { - filePath: getCloudtentaclesSourcesFilePath(), - sessionFilePath: getCloudtentaclesSessionFilePath(), - enabled: savedList.enabled, - sources, - sessions: mappedSessions, - }; - } - - // Single source save - const sourceKey = String(payload.sourceKey || "").trim() || "default"; - const current = getCloudtentaclesSourceByKey(sourceKey); - const normalized = normalizeAdminCloudtentaclesSourceConfigPayload({ - ...payload, - sourceKey, - }); - const saved = saveCloudtentaclesSourceByKey(sourceKey, normalized); - const shouldClearSession = hasCloudtentaclesCredentialContextChanged( - current, - saved - ); - const session = shouldClearSession - ? (clearCloudtentaclesSessionStateByKey(sourceKey), - getCloudtentaclesSessionStateByKey(sourceKey)) - : getCloudtentaclesSessionStateByKey(sourceKey); - - return { - filePath: getCloudtentaclesSourcesFilePath(), - sessionFilePath: getCloudtentaclesSessionFilePath(), - source: mapAdminCloudtentaclesSourceConfig(saved), - session: mapAdminCloudtentaclesSession(session), - }; -} - -export function deleteAdminCloudtentaclesSource(sourceKey: string) { - const key = String(sourceKey || "").trim(); - if (!key) { - throw new Error("sourceKey is required for deletion"); - } - deleteCloudtentaclesSessionStateByKey(key); - deleteCloudtentaclesSourceByKey(key); -} - -export async function sendAdminCloudtentaclesSmsCode( - payload: AdminCloudtentaclesSendSmsCodeInput = {} -) { - const result = await sendCloudtentaclesSmsCode( - resolveAdminCloudtentaclesCredentialPayload(payload) - ); - - return { - ...result, - phoneMasked: maskPhone(result.phone), - }; -} - -export async function testAdminCloudtentaclesLogin( - payload: AdminCloudtentaclesTestLoginInput = {} -) { - const sourceKey = String(payload.sourceKey || "").trim() || "default"; - const savedSource = getCloudtentaclesSourceByKey(sourceKey) || {}; - const credentialContext = resolveAdminCloudtentaclesCredentialPayload( - payload, - { savedSource } - ); - const session = await loginCloudtentaclesSession({ - ...credentialContext, - code: String(payload.code || "").trim(), - }); - const savedSession = saveCloudtentaclesSessionStateByKey( - sourceKey, - buildAdminCloudtentaclesPersistedSessionPayload(session, { - deviceId: credentialContext.deviceId, - deviceType: credentialContext.deviceType, - }) - ); - - return buildAdminCloudtentaclesLoginResult(session, savedSession, sourceKey); -} - -export async function validateAdminCloudtentaclesSession( - payload: AdminCloudtentaclesValidateSessionInput = {} -) { - const sourceKey = String(payload.sourceKey || "").trim() || "default"; - const savedSource: JsonObject = getCloudtentaclesSourceByKey(sourceKey) || {}; - const persistedSession: JsonObject = getCloudtentaclesSessionStateByKey(sourceKey) || {}; - const sessionContext = resolveAdminCloudtentaclesSessionPayload(payload, { - savedSource, - persistedSession, - }); - const session = await validateCloudtentaclesSession(sessionContext); - const savedSession = saveCloudtentaclesSessionStateByKey( - sourceKey, - buildAdminCloudtentaclesPersistedSessionPayload(session, { - username: pickFirstNonEmpty([ - persistedSession.username, - savedSource.username, - ]), - phone: pickFirstNonEmpty([persistedSession.phone, savedSource.phone]), - deviceId: sessionContext.deviceId, - deviceType: sessionContext.deviceType, - }) - ); - - return buildAdminCloudtentaclesValidateResult( - session, - savedSession, - sourceKey - ); -} - -export async function getAdminCloudtentaclesAsset( - payload: AdminCloudtentaclesCatalogQueryInput = {} -) { - return getCloudtentaclesAsset( - resolveAdminCloudtentaclesSessionPayload(payload) - ); -} - -export async function getAdminCloudtentaclesCategories( - payload: AdminCloudtentaclesCatalogQueryInput = {} -) { - return getCloudtentaclesCategories( - resolveAdminCloudtentaclesSessionPayload(payload) - ); -} - -export async function getAdminCloudtentaclesSkuList( - payload: AdminCloudtentaclesCatalogQueryInput = {} -) { - return listCloudtentaclesSku( - resolveAdminCloudtentaclesSessionPayload(payload) - ); -} - -export async function buyAdminCloudtentaclesSku( - payload: AdminCloudtentaclesSkuBuyInput = {} -) { - const context = resolveAdminCloudtentaclesSessionPayload(payload); - return buyCloudtentaclesSku({ - ...context, - id: payload.id, - count: payload.count, - }); -} - -export async function useAdminCloudtentaclesSku( - payload: AdminCloudtentaclesSkuUseInput = {} -) { - const context = resolveAdminCloudtentaclesSessionPayload(payload); - return useCloudtentaclesSku({ - ...context, - id: payload.id, - virtualNumberId: payload.virtualNumberId, - phone: payload.phone, - }); -} - -export async function getAdminCloudtentaclesKnapsack( - payload: AdminCloudtentaclesCatalogQueryInput = {} -) { - return getCloudtentaclesKnapsack( - resolveAdminCloudtentaclesSessionPayload(payload) - ); -} - -export async function listAdminCloudtentaclesVirtualNumbers( - payload: AdminCloudtentaclesVirtualNumberInput = {} -) { - const context = resolveAdminCloudtentaclesSessionPayload(payload); - return listCloudtentaclesVirtualNumbers({ - ...context, - key: payload.key, - }); -} - -export async function appointAdminCloudtentaclesVirtualNumber( - payload: AdminCloudtentaclesVirtualNumberInput = {} -) { - const context = resolveAdminCloudtentaclesSessionPayload(payload); - return appointCloudtentaclesVirtualNumber({ - ...context, - key: payload.key, - }); -} - -export async function generateAdminCloudtentaclesLoginCode( - payload: AdminCloudtentaclesVirtualNumberInput = {} -) { - const context = resolveAdminCloudtentaclesSessionPayload(payload); - return generateCloudtentaclesLoginCode({ - ...context, - key: payload.key, - id: payload.id, - }); -} - -export async function fetchAdminCloudtentaclesVirtualNumberCode( - payload: AdminCloudtentaclesVirtualNumberInput = {} -) { - const context = resolveAdminCloudtentaclesSessionPayload(payload); - return fetchCloudtentaclesVirtualNumberCode({ - ...context, - key: payload.key, - phone: payload.phone, - }); -} - -export async function verifyAdminCloudtentaclesLoginCode( - payload: AdminCloudtentaclesVirtualNumberInput = {} -) { - const context = resolveAdminCloudtentaclesSessionPayload(payload); - return verifyCloudtentaclesLoginCode({ - ...context, - key: payload.key, - id: payload.id, - code: payload.code, - }); -} - -export async function getAdminCloudtentaclesBindUrl( - payload: AdminCloudtentaclesVirtualNumberInput = {} -) { - const context = resolveAdminCloudtentaclesSessionPayload(payload); - return getCloudtentaclesBindUrl({ - ...context, - key: payload.key, - id: payload.id, - }); -} - -export async function backAdminCloudtentaclesVirtualNumber( - payload: AdminCloudtentaclesVirtualNumberInput = {} -) { - const context = resolveAdminCloudtentaclesSessionPayload(payload); - return backCloudtentaclesVirtualNumber({ - ...context, - key: payload.key, - id: payload.id, - }); -} - -export async function runAdminCloudtentaclesFullFlow( - payload: AdminCloudtentaclesFullFlowInput = {} -) { - const context = resolveAdminCloudtentaclesSessionPayload(payload); - return runCloudtentaclesFullDebugFlow({ - ...context, - skuId: payload.skuId, - skuCount: payload.skuCount, - vnKey: payload.vnKey, - }); -} diff --git a/apps/backend/src/services/admin/platform-config/context.test.ts b/apps/backend/src/services/admin/platform-config/cloudtentacles/context.test.ts similarity index 100% rename from apps/backend/src/services/admin/platform-config/context.test.ts rename to apps/backend/src/services/admin/platform-config/cloudtentacles/context.test.ts diff --git a/apps/backend/src/services/admin/platform-config/context.ts b/apps/backend/src/services/admin/platform-config/cloudtentacles/context.ts similarity index 79% rename from apps/backend/src/services/admin/platform-config/context.ts rename to apps/backend/src/services/admin/platform-config/cloudtentacles/context.ts index 43677f5d..80bf3f03 100644 --- a/apps/backend/src/services/admin/platform-config/context.ts +++ b/apps/backend/src/services/admin/platform-config/cloudtentacles/context.ts @@ -41,8 +41,12 @@ export function resolveKuaishouEticketAdminContext(payload: JsonObject = {}, opt }; } -import { getCloudtentaclesSourceByKey } from "../../platforms/cloudtentacles/source-config-service.js"; -import { getCloudtentaclesSessionStateByKey } from "../../platforms/cloudtentacles/session-state-service.js"; +import { getCloudtentaclesSourceByKey } from "../../../platforms/cloudtentacles/source-config-service.js"; +import { getCloudtentaclesSessionStateByKey } from "../../../platforms/cloudtentacles/session-state-service.js"; +import { + normalizeCloudtentaclesDeviceId, + normalizeCloudtentaclesDeviceType, +} from "../../../platforms/cloudtentacles/defaults.js"; export function resolveCloudtentaclesAdminContext(payload: JsonObject = {}, options: JsonObject = {}) { const sourceKey = String( @@ -63,16 +67,16 @@ export function resolveCloudtentaclesAdminContext(payload: JsonObject = {}, opti defaultBaseUrl, ]), token: pickFirstNonEmpty([payload.token, persistedSession.token]), - deviceId: pickFirstNonEmpty([ - payload.deviceId, - savedSource.deviceId, - persistedSession.deviceId, - "-", - ]), - deviceType: - payload.deviceType ?? - savedSource.deviceType ?? - persistedSession.deviceType, + deviceId: normalizeCloudtentaclesDeviceId( + pickFirstNonEmpty([ + payload.deviceId, + savedSource.deviceId, + persistedSession.deviceId, + ]) + ), + deviceType: normalizeCloudtentaclesDeviceType( + payload.deviceType ?? savedSource.deviceType ?? persistedSession.deviceType + ), }; } diff --git a/apps/backend/src/services/admin/platform-config/cloudtentacles/index.ts b/apps/backend/src/services/admin/platform-config/cloudtentacles/index.ts new file mode 100644 index 00000000..f1a001cd --- /dev/null +++ b/apps/backend/src/services/admin/platform-config/cloudtentacles/index.ts @@ -0,0 +1,915 @@ +import { + getCloudtentaclesSourceByKey, + getCloudtentaclesSourcesFilePath, + listCloudtentaclesSources, + saveCloudtentaclesSourceByKey, + saveCloudtentaclesSourcesList, + deleteCloudtentaclesSourceByKey, +} from "../../../platforms/cloudtentacles/source-config-service.js"; +import { + clearCloudtentaclesSessionStateByKey, + deleteCloudtentaclesSessionStateByKey, + getCloudtentaclesSessionFilePath, + getCloudtentaclesSessionStateByKey, + saveCloudtentaclesSessionStateByKey, + getAllCloudtentaclesSessionStates, + pruneCloudtentaclesSessionStates, +} from "../../../platforms/cloudtentacles/session-state-service.js"; +import { + loginCloudtentaclesSession, + sendCloudtentaclesSmsCode, + validateCloudtentaclesSession, +} from "../../../platforms/cloudtentacles/session-service.js"; +import { + buyCloudtentaclesSku, + getCloudtentaclesAsset, + getCloudtentaclesCategories, + listCloudtentaclesSku, + useCloudtentaclesSku, +} from "../../../platforms/cloudtentacles/catalog-service.js"; +import { getCloudtentaclesKnapsack } from "../../../platforms/cloudtentacles/knapsack-service.js"; +import { listCloudtentaclesDeliveryRecords } from "../../../platforms/cloudtentacles/record-service.js"; +import { + appointCloudtentaclesVirtualNumber, + backCloudtentaclesVirtualNumber, + fetchCloudtentaclesVirtualNumberCode, + generateCloudtentaclesLoginCode, + getCloudtentaclesBindUrl, + listCloudtentaclesVirtualNumbers, + verifyCloudtentaclesLoginCode, +} from "../../../platforms/cloudtentacles/virtual-number-service.js"; +import { runCloudtentaclesFullDebugFlow } from "../../../platforms/cloudtentacles/debug-flow-service.js"; +import { + getCloudtentaclesOverrideRuleConfig, + getCloudtentaclesOverrideRuleFilePath, + saveCloudtentaclesOverrideRuleConfig, +} from "../../../order/cloudtentacles-override-rule-service.js"; +import { + hasCloudtentaclesCredentialContextChanged, + pickFirstNonEmpty, +} from "./context.js"; +import { + buildAdminCloudtentaclesLoginResult, + buildAdminCloudtentaclesPersistedSessionPayload, + buildAdminCloudtentaclesValidateResult, + resolveAdminCloudtentaclesCredentialPayload, + resolveAdminCloudtentaclesSessionPayload, +} from "./payloads.js"; +import { normalizeAdminCloudtentaclesSourceConfigPayload } from "./writes.js"; +import { + mapAdminCloudtentaclesSession, + mapAdminCloudtentaclesSourceConfig, + maskPhone, +} from "./mappers.js"; +import { createHttpError } from "../../../../utils/http.js"; +import { query as dbQuery } from "../../../../db/client.js"; +import { parseJsonObject } from "../../../../utils/task-json.js"; + +import type { + AdminCloudtentaclesCatalogQueryInput, + AdminCloudtentaclesDeliveryRecordQueryInput, + AdminCloudtentaclesFullFlowInput, + AdminCloudtentaclesOverrideRuleConfigInput, + AdminCloudtentaclesSendSmsCodeInput, + AdminCloudtentaclesSkuBuyInput, + AdminCloudtentaclesSkuUseInput, + AdminCloudtentaclesSourceConfigInput, + AdminCloudtentaclesTestLoginInput, + AdminCloudtentaclesValidateSessionInput, + AdminCloudtentaclesVirtualNumberInput, +} from "../../../../types/admin/write-inputs.js"; + +type JsonObject = Record; + +type LocalCloudtentaclesTask = { + taskId: number; + taskNo: string; + platformOrderId: string; + orderShopId: string; + orderShopName: string; + taskStatus: string; + deliveryStatus: string; + resultCode: string; + resultMessage: string; + createdAt: string; + updatedAt: string; + skuCode: string; + skuName: string; + sourceKey: string; + vnId: number; + vnPhone: string; + roleName: string; + roleId: string; + localDispatchStatus: string; + localDispatchAt: string; + consumeShopId: string; + consumeShopName: string; +}; + +type LocalCloudtentaclesTaskRow = { + id: number; + task_no: string; + platform_order_id: string; + shop_id: string; + shop_name: string; + task_status: string; + delivery_status: string; + result_code: string; + result_message: string; + context_json: unknown; + created_at: string; + updated_at: string; + sku_code?: string; + sku_name?: string; + kuaishou_source_key?: string; + kuaishou_vn_phone?: string; + kuaishou_role_name?: string; + kuaishou_role_id?: string; + kuaishou_dispatch_status?: string; + kuaishou_consume_status?: string; +}; + +export function listAdminCloudtentaclesSources() { + const list = listCloudtentaclesSources(); + const sessions = getAllCloudtentaclesSessionStates(); + + const sources = Array.isArray(list.sources) + ? list.sources.map((s) => mapAdminCloudtentaclesSourceConfig(s)) + : []; + + const mappedSessions: JsonObject = {}; + for (const [key, session] of Object.entries(sessions.sessions || {})) { + mappedSessions[key] = mapAdminCloudtentaclesSession(session); + } + + return { + filePath: getCloudtentaclesSourcesFilePath(), + sessionFilePath: getCloudtentaclesSessionFilePath(), + enabled: list.enabled, + sources, + sessions: mappedSessions, + }; +} + +export function listAdminCloudtentaclesRecordSources() { + const list = listCloudtentaclesSources(); + const sessions = getAllCloudtentaclesSessionStates(); + const sources = Array.isArray(list.sources) ? list.sources : []; + + return { + sources: sources.map((source) => { + const key = String(source.key || "").trim(); + const session = (sessions.sessions?.[key] || {}) as JsonObject; + return { + key, + label: String(source.label || source.username || key || "未命名账号").trim(), + enabled: source.enabled !== false, + hasToken: Boolean(String(session.token || "").trim()), + loggedInAt: String(session.loggedInAt || "").trim(), + }; + }), + }; +} + +export function getAdminCloudtentaclesOverrideRules() { + return { + filePath: getCloudtentaclesOverrideRuleFilePath(), + ...getCloudtentaclesOverrideRuleConfig(), + }; +} + +export function updateAdminCloudtentaclesOverrideRules( + payload: AdminCloudtentaclesOverrideRuleConfigInput = {} +) { + return { + filePath: getCloudtentaclesOverrideRuleFilePath(), + ...saveCloudtentaclesOverrideRuleConfig(payload), + }; +} + +export function updateAdminCloudtentaclesSourceConfig( + payload: AdminCloudtentaclesSourceConfigInput = {} +) { + // Batch save: if payload.sources is an array, save the whole list + if (Array.isArray(payload.sources)) { + const normalizedList = payload.sources.map((item) => + normalizeAdminCloudtentaclesSourceConfigPayload(item) + ); + const savedList = saveCloudtentaclesSourcesList({ + enabled: payload.enabled !== false, + sources: normalizedList, + }); + const sessions = pruneCloudtentaclesSessionStates( + normalizedList.map((item) => item.key) + ); + + const sources = Array.isArray(savedList.sources) + ? savedList.sources.map((s) => mapAdminCloudtentaclesSourceConfig(s)) + : []; + const mappedSessions: JsonObject = {}; + for (const [key, session] of Object.entries(sessions.sessions || {})) { + mappedSessions[key] = mapAdminCloudtentaclesSession(session); + } + + return { + filePath: getCloudtentaclesSourcesFilePath(), + sessionFilePath: getCloudtentaclesSessionFilePath(), + enabled: savedList.enabled, + sources, + sessions: mappedSessions, + }; + } + + // Single source save + const sourceKey = String(payload.sourceKey || "").trim() || "default"; + const current = getCloudtentaclesSourceByKey(sourceKey); + const normalized = normalizeAdminCloudtentaclesSourceConfigPayload({ + ...payload, + sourceKey, + }); + const saved = saveCloudtentaclesSourceByKey(sourceKey, normalized); + const shouldClearSession = hasCloudtentaclesCredentialContextChanged( + current || undefined, + saved + ); + const session = shouldClearSession + ? (clearCloudtentaclesSessionStateByKey(sourceKey), + getCloudtentaclesSessionStateByKey(sourceKey)) + : getCloudtentaclesSessionStateByKey(sourceKey); + + return { + filePath: getCloudtentaclesSourcesFilePath(), + sessionFilePath: getCloudtentaclesSessionFilePath(), + source: mapAdminCloudtentaclesSourceConfig(saved), + session: mapAdminCloudtentaclesSession(session || undefined), + }; +} + +export function deleteAdminCloudtentaclesSource(sourceKey: string) { + const key = String(sourceKey || "").trim(); + if (!key) { + throw createHttpError("cloudtentacles sourceKey 不能为空", { + statusCode: 400, + errorCode: "cloudtentacles_source_key_required", + }); + } + deleteCloudtentaclesSessionStateByKey(key); + deleteCloudtentaclesSourceByKey(key); +} + +export async function sendAdminCloudtentaclesSmsCode( + payload: AdminCloudtentaclesSendSmsCodeInput = {} +) { + const result = await sendCloudtentaclesSmsCode( + resolveAdminCloudtentaclesCredentialPayload(payload) + ); + + return { + ...result, + phoneMasked: maskPhone(result.phone), + }; +} + +export async function testAdminCloudtentaclesLogin( + payload: AdminCloudtentaclesTestLoginInput = {} +) { + const sourceKey = String(payload.sourceKey || "").trim() || "default"; + const savedSource = getCloudtentaclesSourceByKey(sourceKey) || {}; + const credentialContext = resolveAdminCloudtentaclesCredentialPayload( + payload, + { savedSource } + ); + const session = await loginCloudtentaclesSession({ + ...credentialContext, + code: String(payload.code || "").trim(), + }); + const savedSession = saveCloudtentaclesSessionStateByKey( + sourceKey, + buildAdminCloudtentaclesPersistedSessionPayload(session, { + deviceId: credentialContext.deviceId, + deviceType: credentialContext.deviceType, + }) + ); + + return buildAdminCloudtentaclesLoginResult(session, savedSession, sourceKey); +} + +export async function validateAdminCloudtentaclesSession( + payload: AdminCloudtentaclesValidateSessionInput = {} +) { + const sourceKey = String(payload.sourceKey || "").trim() || "default"; + const savedSource: JsonObject = getCloudtentaclesSourceByKey(sourceKey) || {}; + const persistedSession: JsonObject = getCloudtentaclesSessionStateByKey(sourceKey) || {}; + const sessionContext = resolveAdminCloudtentaclesSessionPayload(payload, { + savedSource, + persistedSession, + }); + const session = await validateCloudtentaclesSession(sessionContext); + const savedSession = saveCloudtentaclesSessionStateByKey( + sourceKey, + buildAdminCloudtentaclesPersistedSessionPayload(session, { + username: pickFirstNonEmpty([ + persistedSession.username, + savedSource.username, + ]), + phone: pickFirstNonEmpty([persistedSession.phone, savedSource.phone]), + deviceId: sessionContext.deviceId, + deviceType: sessionContext.deviceType, + }) + ); + + return buildAdminCloudtentaclesValidateResult( + session, + savedSession, + sourceKey + ); +} + +export async function getAdminCloudtentaclesAsset( + payload: AdminCloudtentaclesCatalogQueryInput = {} +) { + return getCloudtentaclesAsset( + resolveAdminCloudtentaclesSessionPayload(payload) + ); +} + +export async function getAdminCloudtentaclesCategories( + payload: AdminCloudtentaclesCatalogQueryInput = {} +) { + return getCloudtentaclesCategories( + resolveAdminCloudtentaclesSessionPayload(payload) + ); +} + +export async function getAdminCloudtentaclesSkuList( + payload: AdminCloudtentaclesCatalogQueryInput = {} +) { + return listCloudtentaclesSku( + resolveAdminCloudtentaclesSessionPayload(payload) + ); +} + +export async function buyAdminCloudtentaclesSku( + payload: AdminCloudtentaclesSkuBuyInput = {} +) { + const context = resolveAdminCloudtentaclesSessionPayload(payload); + return buyCloudtentaclesSku({ + ...context, + id: payload.id, + count: payload.count, + }); +} + +export async function useAdminCloudtentaclesSku( + payload: AdminCloudtentaclesSkuUseInput = {} +) { + const context = resolveAdminCloudtentaclesSessionPayload(payload); + return useCloudtentaclesSku({ + ...context, + id: payload.id, + virtualNumberId: payload.virtualNumberId, + phone: payload.phone, + }); +} + +export async function getAdminCloudtentaclesKnapsack( + payload: AdminCloudtentaclesCatalogQueryInput = {} +) { + return getCloudtentaclesKnapsack( + resolveAdminCloudtentaclesSessionPayload(payload) + ); +} + +export async function listAdminCloudtentaclesDeliveryRecords( + payload: AdminCloudtentaclesDeliveryRecordQueryInput = {} +) { + const platformOrderId = normalizeText(payload.platformOrderId); + const preferredSourceKey = normalizeText(payload.sourceKey); + const orderMatchedTasks = platformOrderId + ? await listLocalCloudtentaclesTasks({ + platformOrderId, + limit: 50, + }) + : []; + const orderSourceKey = + orderMatchedTasks.find((task) => !preferredSourceKey || task.sourceKey === preferredSourceKey) + ?.sourceKey || orderMatchedTasks.find((task) => task.sourceKey)?.sourceKey; + const resolvedSourceKey = + orderSourceKey || + preferredSourceKey || + "default"; + const context = resolveAdminCloudtentaclesSessionPayload({ + ...payload, + sourceKey: resolvedSourceKey, + }); + const recordWindow = resolveRecordQueryWindow(payload, orderMatchedTasks); + const recordPage = platformOrderId ? 1 : payload.page; + const recordSize = platformOrderId ? 1000 : payload.size; + const query = { + ...context, + ...pickDefined({ + page: recordPage, + size: recordSize, + startDate: recordWindow.startDate, + endDate: recordWindow.endDate, + recordCode: payload.recordCode, + }), + }; + const [records, skuList] = await Promise.all([ + listCloudtentaclesDeliveryRecords(query), + listCloudtentaclesSku(context), + ]); + const skuImageByName = new Map(); + for (const sku of Array.isArray(skuList.items) ? skuList.items : []) { + const name = normalizeSkuName(sku.name); + const skuImage = String(sku.image || "").trim(); + if (!name || !skuImage) { + continue; + } + + skuImageByName.set(name, { + skuId: Number(sku.id || 0), + skuName: String(sku.name || "").trim(), + skuImage, + }); + } + + const recordItems: JsonObject[] = Array.isArray(records.items) ? records.items : []; + const localTasks = + platformOrderId || recordItems.length === 0 + ? orderMatchedTasks + : await listLocalCloudtentaclesTasks({ + sourceKey: resolvedSourceKey, + startDate: recordWindow.startDate, + endDate: recordWindow.endDate, + limit: 800, + }); + const enrichedItems = recordItems + .map((record) => { + const localMatch = platformOrderId + ? findStrictLocalTaskForOrderRecord(record, localTasks) + : findBestLocalTaskForRecord(record, localTasks); + return { + ...record, + ...(skuImageByName.get(normalizeSkuName(record.name)) || { + skuId: 0, + skuName: "", + skuImage: "", + }), + ...mapLocalTaskRecordEnhancement(localMatch?.task || null, localMatch?.confidence || ""), + }; + }) + .filter((record) => { + if (!platformOrderId) { + return true; + } + return normalizeText(record.platformOrderId) === platformOrderId; + }); + const fallbackItems = platformOrderId + ? orderMatchedTasks + .filter((task) => !enrichedItems.some((record) => Number(record.taskId || 0) === task.taskId)) + .map((task) => ({ + ...createLocalTaskFallbackRecord(task), + ...(skuImageByName.get(normalizeSkuName(task.skuName)) || { + skuId: 0, + skuName: "", + skuImage: "", + }), + ...mapLocalTaskRecordEnhancement(task, "local"), + })) + : []; + const finalItems = platformOrderId ? [...enrichedItems, ...fallbackItems] : enrichedItems; + + return { + ...records, + sourceKey: resolvedSourceKey, + queryStartDate: recordWindow.startDate, + queryEndDate: recordWindow.endDate, + total: platformOrderId ? finalItems.length : records.total, + page: platformOrderId ? 1 : records.page, + size: platformOrderId ? recordSize : records.size, + localOrderMatched: platformOrderId ? orderMatchedTasks.length > 0 : false, + items: finalItems, + }; +} + +export async function listAdminCloudtentaclesVirtualNumbers( + payload: AdminCloudtentaclesVirtualNumberInput = {} +) { + const context = resolveAdminCloudtentaclesSessionPayload(payload); + return listCloudtentaclesVirtualNumbers({ + ...context, + key: payload.key, + }); +} + +export async function appointAdminCloudtentaclesVirtualNumber( + payload: AdminCloudtentaclesVirtualNumberInput = {} +) { + const context = resolveAdminCloudtentaclesSessionPayload(payload); + return appointCloudtentaclesVirtualNumber({ + ...context, + key: payload.key, + }); +} + +export async function generateAdminCloudtentaclesLoginCode( + payload: AdminCloudtentaclesVirtualNumberInput = {} +) { + const context = resolveAdminCloudtentaclesSessionPayload(payload); + return generateCloudtentaclesLoginCode({ + ...context, + key: payload.key, + id: payload.id, + }); +} + +export async function fetchAdminCloudtentaclesVirtualNumberCode( + payload: AdminCloudtentaclesVirtualNumberInput = {} +) { + const context = resolveAdminCloudtentaclesSessionPayload(payload); + return fetchCloudtentaclesVirtualNumberCode({ + ...context, + key: payload.key, + phone: payload.phone, + }); +} + +export async function verifyAdminCloudtentaclesLoginCode( + payload: AdminCloudtentaclesVirtualNumberInput = {} +) { + const context = resolveAdminCloudtentaclesSessionPayload(payload); + return verifyCloudtentaclesLoginCode({ + ...context, + key: payload.key, + id: payload.id, + code: payload.code, + }); +} + +export async function getAdminCloudtentaclesBindUrl( + payload: AdminCloudtentaclesVirtualNumberInput = {} +) { + const context = resolveAdminCloudtentaclesSessionPayload(payload); + return getCloudtentaclesBindUrl({ + ...context, + key: payload.key, + id: payload.id, + }); +} + +export async function backAdminCloudtentaclesVirtualNumber( + payload: AdminCloudtentaclesVirtualNumberInput = {} +) { + const context = resolveAdminCloudtentaclesSessionPayload(payload); + return backCloudtentaclesVirtualNumber({ + ...context, + key: payload.key, + id: payload.id, + }); +} + +export async function runAdminCloudtentaclesFullFlow( + payload: AdminCloudtentaclesFullFlowInput = {} +) { + const context = resolveAdminCloudtentaclesSessionPayload(payload); + return runCloudtentaclesFullDebugFlow({ + ...context, + skuId: payload.skuId, + skuCount: payload.skuCount, + vnKey: payload.vnKey, + }); +} + +function pickDefined(values: JsonObject = {}) { + return Object.fromEntries( + Object.entries(values).filter(([, value]) => typeof value !== "undefined") + ); +} + +function normalizeSkuName(value: unknown) { + return String(value || "").trim().replace(/\s+/g, "").toLowerCase(); +} + +function normalizeText(value: unknown) { + return String(value || "").trim(); +} + +function normalizePhone(value: unknown) { + return normalizeText(value).replace(/\D+/g, ""); +} + +function normalizeNumber(value: unknown) { + const parsed = Number(value); + return Number.isFinite(parsed) && parsed > 0 ? parsed : 0; +} + +function asRecord(value: unknown): JsonObject { + return value && typeof value === "object" && !Array.isArray(value) + ? (value as JsonObject) + : {}; +} + +function pickFirstNonEmptyText(values: unknown[]) { + for (const value of values) { + const normalized = normalizeText(value); + if (normalized) { + return normalized; + } + } + + return ""; +} + +function resolveRecordQueryWindow( + payload: AdminCloudtentaclesDeliveryRecordQueryInput = {}, + tasks: LocalCloudtentaclesTask[] = [] +) { + if (tasks.length === 0) { + return { + startDate: payload.startDate, + endDate: payload.endDate, + }; + } + + const timestamps = tasks + .flatMap((task) => [task.createdAt, task.updatedAt, task.localDispatchAt]) + .map((value) => new Date(value).getTime()) + .filter((value) => Number.isFinite(value)); + if (timestamps.length === 0) { + return { + startDate: payload.startDate, + endDate: payload.endDate, + }; + } + + const hourMs = 60 * 60 * 1000; + return { + startDate: new Date(Math.min(...timestamps) - hourMs).toISOString(), + endDate: new Date(Math.max(...timestamps) + 6 * hourMs).toISOString(), + }; +} + +async function listLocalCloudtentaclesTasks(options: { + platformOrderId?: string | undefined; + sourceKey?: string | undefined; + startDate?: string | undefined; + endDate?: string | undefined; + limit?: number; +} = {}): Promise { + const filters: string[] = ["ft.executor_key = 'kuaishou_ct_assisted'"]; + const params: unknown[] = []; + const platformOrderId = normalizeText(options.platformOrderId); + const sourceKey = normalizeText(options.sourceKey); + const startDate = normalizeText(options.startDate); + const endDate = normalizeText(options.endDate); + + if (platformOrderId) { + params.push(`%${platformOrderId}%`); + filters.push(`ft.platform_order_id ILIKE $${params.length}`); + } + + if (sourceKey) { + params.push(sourceKey); + filters.push( + `COALESCE(NULLIF(kcts.source_key, ''), ft.context_json #>> '{kuaishouCloudFulfillment,binding,resolvedSourceKey}', '') = $${params.length}` + ); + } + + if (startDate) { + params.push(startDate); + filters.push(`ft.updated_at >= ($${params.length})::timestamptz - interval '1 day'`); + } + + if (endDate) { + params.push(endDate); + filters.push(`ft.created_at <= ($${params.length})::timestamptz + interval '1 day'`); + } + + params.push(Math.min(Math.max(Number(options.limit || 500), 1), 1000)); + const result = await dbQuery( + ` + SELECT + ft.id, + ft.task_no, + ft.platform_order_id, + ft.shop_id, + ft.shop_name, + ft.task_status, + ft.delivery_status, + ft.result_code, + ft.result_message, + ft.context_json, + ft.created_at, + ft.updated_at, + oi.sku_code, + oi.sku_name, + kcts.source_key AS kuaishou_source_key, + kcts.vn_phone AS kuaishou_vn_phone, + kcts.role_name AS kuaishou_role_name, + kcts.role_id AS kuaishou_role_id, + kcts.dispatch_status AS kuaishou_dispatch_status, + kcts.consume_status AS kuaishou_consume_status + FROM fulfillment_tasks ft + LEFT JOIN order_items oi ON oi.id = ft.order_item_id + LEFT JOIN kuaishou_cloud_task_states kcts ON kcts.task_id = ft.id + WHERE ${filters.join(" AND ")} + ORDER BY ft.id DESC + LIMIT $${params.length} + `, + params + ); + + return result.rows.map(mapLocalCloudtentaclesTask); +} + +function mapLocalCloudtentaclesTask(row: LocalCloudtentaclesTaskRow): LocalCloudtentaclesTask { + const context = parseJsonObject(row.context_json); + const flow = asRecord(context.kuaishouCloudFulfillment); + const binding = asRecord(flow.binding); + const role = asRecord(flow.role); + const dispatch = asRecord(flow.dispatch); + const consume = asRecord(flow.consume); + + return { + taskId: Number(row.id || 0), + taskNo: normalizeText(row.task_no), + platformOrderId: normalizeText(row.platform_order_id), + orderShopId: normalizeText(row.shop_id), + orderShopName: normalizeText(row.shop_name), + taskStatus: normalizeText(row.task_status), + deliveryStatus: normalizeText(row.delivery_status), + resultCode: normalizeText(row.result_code), + resultMessage: normalizeText(row.result_message), + createdAt: normalizeText(row.created_at), + updatedAt: normalizeText(row.updated_at), + skuCode: normalizeText(row.sku_code), + skuName: pickFirstNonEmptyText([row.sku_name, flow.internalSkuName]), + sourceKey: pickFirstNonEmptyText([row.kuaishou_source_key, binding.resolvedSourceKey]), + vnId: normalizeNumber(binding.vnId), + vnPhone: pickFirstNonEmptyText([row.kuaishou_vn_phone, binding.vnPhone]), + roleName: pickFirstNonEmptyText([row.kuaishou_role_name, role.name, binding.roleName]), + roleId: pickFirstNonEmptyText([row.kuaishou_role_id, role.rid, binding.roleId]), + localDispatchStatus: pickFirstNonEmptyText([row.kuaishou_dispatch_status, dispatch.status]), + localDispatchAt: normalizeText(dispatch.dispatchAt), + consumeShopId: normalizeText(consume.shopId), + consumeShopName: normalizeText(consume.shopName), + }; +} + +function findBestLocalTaskForRecord( + record: JsonObject, + tasks: LocalCloudtentaclesTask[] = [] +) { + let best: { task: LocalCloudtentaclesTask; score: number; confidence: string } | null = null; + + for (const task of tasks) { + const score = scoreLocalTaskRecordMatch(record, task); + if (score <= 0 || (best && score <= best.score)) { + continue; + } + + best = { + task, + score, + confidence: score >= 80 ? "high" : score >= 45 ? "medium" : "low", + }; + } + + return best && best.score >= 35 ? best : null; +} + +function findStrictLocalTaskForOrderRecord( + record: JsonObject, + tasks: LocalCloudtentaclesTask[] = [] +) { + let best: { task: LocalCloudtentaclesTask; score: number; confidence: string } | null = null; + + for (const task of tasks) { + if (!isSameDispatchedResource(record, task)) { + continue; + } + + const score = scoreLocalTaskRecordMatch(record, task); + if (score <= 0 || (best && score <= best.score)) { + continue; + } + + best = { + task, + score, + confidence: score >= 80 ? "high" : "medium", + }; + } + + return best; +} + +function isSameDispatchedResource(record: JsonObject, task: LocalCloudtentaclesTask) { + const recordVnId = normalizeNumber(record.virtualNumberId); + const recordPhone = normalizePhone(record.phone); + const taskPhone = normalizePhone(task.vnPhone); + + if (recordVnId > 0 && task.vnId > 0 && recordVnId === task.vnId) { + return true; + } + + return Boolean(recordPhone && taskPhone && recordPhone === taskPhone); +} + +function scoreLocalTaskRecordMatch(record: JsonObject, task: LocalCloudtentaclesTask) { + let score = 0; + const recordVnId = normalizeNumber(record.virtualNumberId); + const recordPhone = normalizePhone(record.phone); + const taskPhone = normalizePhone(task.vnPhone); + const recordName = normalizeSkuName(record.name); + const taskSkuName = normalizeSkuName(task.skuName); + const recordRoleName = normalizeSkuName(record.fulfillUser); + const taskRoleName = normalizeSkuName(task.roleName); + + if (recordVnId > 0 && task.vnId > 0 && recordVnId === task.vnId) { + score += 50; + } + if (recordPhone && taskPhone && recordPhone === taskPhone) { + score += 30; + } + if (recordName && taskSkuName && recordName === taskSkuName) { + score += 20; + } + if (recordRoleName && taskRoleName && recordRoleName.includes(taskRoleName)) { + score += 10; + } + + const recordTime = new Date(record.createdAt).getTime(); + const dispatchTime = new Date(task.localDispatchAt || task.updatedAt || task.createdAt).getTime(); + if (Number.isFinite(recordTime) && Number.isFinite(dispatchTime)) { + const distanceHours = Math.abs(recordTime - dispatchTime) / (60 * 60 * 1000); + if (distanceHours <= 6) { + score += 15; + } else if (distanceHours <= 24) { + score += 6; + } + } + + return score; +} + +function mapLocalTaskRecordEnhancement( + task: LocalCloudtentaclesTask | null, + confidence = "" +) { + return { + platformOrderId: task?.platformOrderId || "", + taskId: task?.taskId || 0, + taskNo: task?.taskNo || "", + orderShopId: task?.orderShopId || "", + orderShopName: task?.orderShopName || "", + consumeShopId: task?.consumeShopId || "", + consumeShopName: task?.consumeShopName || "", + roleName: task?.roleName || "", + roleId: task?.roleId || "", + localTaskStatus: task?.taskStatus || "", + localDeliveryStatus: task?.deliveryStatus || "", + localDispatchStatus: task?.localDispatchStatus || "", + localDispatchAt: task?.localDispatchAt || "", + localResultCode: task?.resultCode || "", + localResultMessage: task?.resultMessage || "", + matchConfidence: confidence, + }; +} + +function createLocalTaskFallbackRecord(task: LocalCloudtentaclesTask) { + return { + createdAt: task.localDispatchAt || task.updatedAt || task.createdAt, + recordId: "", + virtualNumberId: task.vnId, + userId: 0, + count: 1, + cdk: "", + exchangeUrl: "", + name: task.skuName, + phone: task.vnPhone, + status: mapLocalDispatchStatusToCloudStatus(task.localDispatchStatus), + fulfillUser: task.roleName, + operatorName: "", + raw: {}, + }; +} + +function mapLocalDispatchStatusToCloudStatus(status: string) { + const normalized = normalizeText(status); + if (normalized === "success") { + return 4; + } + if (normalized === "failed") { + return 3; + } + if (normalized === "pending") { + return 2; + } + return 0; +} diff --git a/apps/backend/src/services/admin/platform-config/mappers.test.ts b/apps/backend/src/services/admin/platform-config/cloudtentacles/mappers.test.ts similarity index 59% rename from apps/backend/src/services/admin/platform-config/mappers.test.ts rename to apps/backend/src/services/admin/platform-config/cloudtentacles/mappers.test.ts index 64aa9747..eefbcc05 100644 --- a/apps/backend/src/services/admin/platform-config/mappers.test.ts +++ b/apps/backend/src/services/admin/platform-config/cloudtentacles/mappers.test.ts @@ -3,7 +3,6 @@ import assert from 'node:assert/strict' import { mapAdminCloudtentaclesSession, - mapAdminKuaishouCloudFulfillmentSource, mapAdminKuaishouEticketSourceConfig, maskPhone, maskSecret, @@ -69,54 +68,3 @@ test('mapAdminCloudtentaclesSession derives masked fields and token presence', ( }, ) }) - -test('mapAdminKuaishouCloudFulfillmentSource normalizes items and defaults', () => { - assert.deepEqual( - mapAdminKuaishouCloudFulfillmentSource({ - enabled: true, - items: [ - { - id: ' item-1 ', - provider: '', - platform: '', - cloudSourceKey: '', - cloudSkuId: '0', - autoConsumeAfterDispatch: true, - kuaishouConsumeShopId: ' ks1 ', - notes: ' note ', - }, - ], - }), - { - enabled: true, - items: [ - { - id: 'item-1', - enabled: true, - priority: 100, - provider: '91kaquan', - platform: 'kuaishou', - shopId: '', - internalSkuCode: '', - internalSkuName: '', - externalSkuCode: '', - externalItemId: '', - externalSkuName: '', - resolvedSkuName: '', - cloudSourceKey: 'default', - cloudSourceKeyFallbacks: [], - cloudSkuId: 0, - cloudSkuName: '', - vnKey: '', - autoBuyEnabled: true, - minAssetReserve: 0, - autoReturnNumberAfterDispatch: false, - autoConsumeAfterDispatch: true, - kuaishouConsumeShopId: 'ks1', - kuaishouConsumeShopName: '', - notes: 'note', - }, - ], - }, - ) -}) diff --git a/apps/backend/src/services/admin/platform-config/cloudtentacles/mappers.ts b/apps/backend/src/services/admin/platform-config/cloudtentacles/mappers.ts new file mode 100644 index 00000000..494f4b3a --- /dev/null +++ b/apps/backend/src/services/admin/platform-config/cloudtentacles/mappers.ts @@ -0,0 +1,66 @@ +import { + maskPhone as maskPhoneValue, + maskSecret as maskSecretValue, +} from "../../../../utils/masking.js"; +import { + normalizeCloudtentaclesDeviceId, + normalizeCloudtentaclesDeviceType, +} from "../../../platforms/cloudtentacles/defaults.js"; + +type JsonObject = Record + +export function maskSecret(value: unknown) { + return maskSecretValue(value); +} + +export function maskPhone(value: unknown) { + return maskPhoneValue(value, { maskShort: false }); +} + +export function mapAdminKuaishouEticketShopItem(item: JsonObject = {}) { + return { + shopId: String(item.shopId || "").trim(), + kshopName: String(item.kshopName || "").trim(), + cookie: String(item.cookie || "").trim(), + cookieMasked: maskSecret(item.cookie), + hasCookie: Boolean(String(item.cookie || "").trim()), + userAvatar: String(item.userAvatar || "").trim(), + enabled: item.enabled !== false, + }; +} + +export function mapAdminKuaishouEticketSourceConfig(config: JsonObject = {}, shops: JsonObject[] = []) { + return { + enabled: config.enabled !== false, + baseUrl: String(config.baseUrl || "").trim(), + shops: shops.map((item) => mapAdminKuaishouEticketShopItem(item)), + }; +} + +export function mapAdminCloudtentaclesSourceConfig(config: JsonObject = {}) { + return { + key: String(config.key || "").trim(), + label: String(config.label || "").trim(), + enabled: config.enabled !== false, + baseUrl: String(config.baseUrl || "").trim(), + username: String(config.username || "").trim(), + password: String(config.password || "").trim(), + phone: String(config.phone || "").trim(), + deviceId: normalizeCloudtentaclesDeviceId(config.deviceId), + deviceType: normalizeCloudtentaclesDeviceType(config.deviceType), + }; +} + +export function mapAdminCloudtentaclesSession(session: JsonObject = {}) { + return { + token: String(session.token || "").trim(), + tokenMasked: maskSecret(session.token), + baseUrl: String(session.baseUrl || "").trim(), + username: String(session.username || "").trim(), + phoneMasked: maskPhone(session.phone), + loggedInAt: String(session.loggedInAt || "").trim(), + deviceId: normalizeCloudtentaclesDeviceId(session.deviceId), + deviceType: normalizeCloudtentaclesDeviceType(session.deviceType), + hasToken: Boolean(String(session.token || "").trim()), + }; +} diff --git a/apps/backend/src/services/admin/platform-config/cloudtentacles.test.ts b/apps/backend/src/services/admin/platform-config/cloudtentacles/payloads.test.ts similarity index 99% rename from apps/backend/src/services/admin/platform-config/cloudtentacles.test.ts rename to apps/backend/src/services/admin/platform-config/cloudtentacles/payloads.test.ts index 7c3a8cf7..a7612bda 100644 --- a/apps/backend/src/services/admin/platform-config/cloudtentacles.test.ts +++ b/apps/backend/src/services/admin/platform-config/cloudtentacles/payloads.test.ts @@ -8,7 +8,7 @@ import { buildAdminCloudtentaclesValidateResult, resolveAdminCloudtentaclesCredentialPayload, resolveAdminCloudtentaclesSessionPayload, -} from './cloudtentacles.js' +} from './payloads.js' test('resolveAdminCloudtentaclesCredentialPayload merges payload and saved source defaults', () => { assert.deepEqual( diff --git a/apps/backend/src/services/admin/platform-config/cloudtentacles.ts b/apps/backend/src/services/admin/platform-config/cloudtentacles/payloads.ts similarity index 81% rename from apps/backend/src/services/admin/platform-config/cloudtentacles.ts rename to apps/backend/src/services/admin/platform-config/cloudtentacles/payloads.ts index d61d7aa4..56b6bfa3 100644 --- a/apps/backend/src/services/admin/platform-config/cloudtentacles.ts +++ b/apps/backend/src/services/admin/platform-config/cloudtentacles/payloads.ts @@ -1,5 +1,9 @@ -import { getCloudtentaclesSourceByKey } from "../../platforms/cloudtentacles/source-config-service.js"; -import { getCloudtentaclesSessionStateByKey } from "../../platforms/cloudtentacles/session-state-service.js"; +import { getCloudtentaclesSourceByKey } from "../../../platforms/cloudtentacles/source-config-service.js"; +import { getCloudtentaclesSessionStateByKey } from "../../../platforms/cloudtentacles/session-state-service.js"; +import { + normalizeCloudtentaclesDeviceId, + normalizeCloudtentaclesDeviceType, +} from "../../../platforms/cloudtentacles/defaults.js"; import { pickFirstNonEmpty, resolveCloudtentaclesAdminContext, @@ -34,8 +38,12 @@ export function resolveAdminCloudtentaclesCredentialPayload( username: pickFirstNonEmpty([payload.username, savedSource.username]), password: pickFirstNonEmpty([payload.password, savedSource.password]), phone: pickFirstNonEmpty([payload.phone, savedSource.phone]), - deviceId: pickFirstNonEmpty([payload.deviceId, savedSource.deviceId, "-"]), - deviceType: payload.deviceType ?? savedSource.deviceType, + deviceId: normalizeCloudtentaclesDeviceId( + pickFirstNonEmpty([payload.deviceId, savedSource.deviceId]) + ), + deviceType: normalizeCloudtentaclesDeviceType( + payload.deviceType ?? savedSource.deviceType + ), }; } @@ -67,8 +75,12 @@ export function buildAdminCloudtentaclesPersistedSessionPayload( username: pickFirstNonEmpty([options.username, session.username]), phone: pickFirstNonEmpty([options.phone, session.phone]), loggedInAt: String(session.loggedInAt || "").trim(), - deviceId: pickFirstNonEmpty([options.deviceId, session.deviceId, "-"]), - deviceType: options.deviceType ?? session.deviceType, + deviceId: normalizeCloudtentaclesDeviceId( + pickFirstNonEmpty([options.deviceId, session.deviceId]) + ), + deviceType: normalizeCloudtentaclesDeviceType( + options.deviceType ?? session.deviceType + ), }; } diff --git a/apps/backend/src/services/admin/platform-config/cloudtentacles/writes.ts b/apps/backend/src/services/admin/platform-config/cloudtentacles/writes.ts new file mode 100644 index 00000000..d72de37a --- /dev/null +++ b/apps/backend/src/services/admin/platform-config/cloudtentacles/writes.ts @@ -0,0 +1,24 @@ +import { + normalizeCloudtentaclesDeviceId, + normalizeCloudtentaclesDeviceType, +} from "../../../platforms/cloudtentacles/defaults.js"; + +type JsonObject = Record; + +export function normalizeAdminCloudtentaclesSourceConfigPayload( + payload: JsonObject = {} +) { + const sourceKey = + String(payload.sourceKey || payload.key || "").trim() || "default"; + return { + key: sourceKey, + label: String(payload.label || "").trim(), + enabled: payload.enabled !== false, + baseUrl: String(payload.baseUrl || "").trim() || "https://123.207.217.176", + username: String(payload.username || "").trim(), + password: String(payload.password || "").trim(), + phone: String(payload.phone || "").trim(), + deviceId: normalizeCloudtentaclesDeviceId(payload.deviceId), + deviceType: normalizeCloudtentaclesDeviceType(payload.deviceType), + }; +} diff --git a/apps/backend/src/services/admin/platform-config/domain.test.ts b/apps/backend/src/services/admin/platform-config/domain.test.ts deleted file mode 100644 index 3f69a6a6..00000000 --- a/apps/backend/src/services/admin/platform-config/domain.test.ts +++ /dev/null @@ -1,173 +0,0 @@ -import test from 'node:test' -import assert from 'node:assert/strict' - -import { - applyOptionalStringField, - findMatchingObservedBinding, - mapAdminAgisoMessagingDefaults, - mapAdminAgisoShopConfigItem, - mapAdminFulfillmentBindingConfigItem, - mapAdminObservedProductItem, - matchesObservedProduct, -} from './domain.js' - -test('mapAdminAgisoMessagingDefaults normalizes escaped newlines', () => { - assert.deepEqual( - mapAdminAgisoMessagingDefaults({ - messageTemplate: ' hello\\nworld ', - autoDeliveryMessageTemplate: ' done\\nnow ', - }), - { - messageTemplate: 'hello\nworld', - autoDeliveryMessageTemplate: 'done\nnow', - }, - ) -}) - -test('mapAdminAgisoShopConfigItem masks secrets and reports configured flags', () => { - assert.deepEqual( - mapAdminAgisoShopConfigItem('1001', { - shopName: ' 店铺A ', - accessToken: 'abcdef1234567890', - enabled: true, - messageTemplate: ' hi\\nall ', - autoDeliveryMessageTemplate: ' ok ', - appSecret: 'secret-1', - apiVersion: ' v2 ', - sendMessageEndpoint: ' /send ', - }), - { - shopId: '1001', - shopName: '店铺A', - accessToken: 'abcdef1234567890', - accessTokenMasked: 'abcdef****567890', - enabled: true, - messageTemplate: 'hi\nall', - autoDeliveryMessageTemplate: 'ok', - appSecretConfigured: true, - apiVersion: 'v2', - sendMessageEndpoint: '/send', - }, - ) -}) - -test('applyOptionalStringField updates and clears normalized template fields', () => { - const target = { messageTemplate: 'old' } - applyOptionalStringField(target, 'messageTemplate', { messageTemplate: ' next\\nline ' }) - assert.deepEqual(target, { messageTemplate: 'next\nline' }) - - applyOptionalStringField(target, 'messageTemplate', { messageTemplate: ' ' }) - assert.deepEqual(target, {}) -}) - -test('mapAdminFulfillmentBindingConfigItem normalizes binding shape', () => { - assert.deepEqual( - mapAdminFulfillmentBindingConfigItem({ - provider: ' agiso ', - platform: ' xianyu ', - shopId: ' shop-1 ', - skuCode: ' sku-1 ', - priority: '80', - match: { - externalSkuCode: ' ext-1 ', - }, - }), - { - provider: 'agiso', - platform: 'xianyu', - shopId: 'shop-1', - shopName: '', - skuCode: 'sku-1', - skuName: '', - profileKey: '', - enabled: true, - priority: 80, - config: {}, - match: { - externalSkuCode: 'ext-1', - externalItemId: '', - externalSkuName: '', - config: {}, - }, - }, - ) -}) - -test('matchesObservedProduct honors provider platform shop and external fields', () => { - const binding = { - provider: 'agiso', - platform: 'xianyu', - shopId: 'shop-1', - match: { - externalSkuCode: 'sku-ext-1', - externalItemId: '', - externalSkuName: '', - }, - } - - assert.equal( - matchesObservedProduct(binding, { - provider: 'agiso', - platform: 'xianyu', - shopId: 'shop-1', - externalSkuCode: 'sku-ext-1', - }), - true, - ) - - assert.equal( - matchesObservedProduct(binding, { - provider: 'agiso', - platform: 'xianyu', - shopId: 'shop-2', - externalSkuCode: 'sku-ext-1', - }), - false, - ) -}) - -test('mapAdminObservedProductItem attaches matched binding summary', () => { - const bindings = [ - { - provider: 'agiso', - platform: 'xianyu', - shopId: 'shop-1', - skuCode: 'sku-1', - skuName: 'SKU 1', - profileKey: 'profile-1', - match: { - externalSkuCode: 'ext-1', - }, - }, - ] - const observed = { - provider: 'agiso', - platform: 'xianyu', - shopId: 'shop-1', - shopName: '店铺1', - externalItemId: '', - externalSkuCode: 'ext-1', - externalSkuName: '礼包1', - latestSeenAt: '2026-05-04T10:00:00.000Z', - orderItemCount: 2, - } - - assert.deepEqual(findMatchingObservedBinding(bindings, observed), bindings[0]) - assert.deepEqual(mapAdminObservedProductItem(observed, bindings), { - provider: 'agiso', - platform: 'xianyu', - shopId: 'shop-1', - shopName: '店铺1', - externalItemId: '', - externalSkuCode: 'ext-1', - externalSkuName: '礼包1', - latestSeenAt: '2026-05-04T10:00:00.000Z', - orderItemCount: 2, - configured: true, - matchedBinding: { - skuCode: 'sku-1', - skuName: 'SKU 1', - profileKey: 'profile-1', - }, - }) -}) diff --git a/apps/backend/src/services/admin/platform-config/domain.ts b/apps/backend/src/services/admin/platform-config/domain.ts deleted file mode 100644 index 303ca50d..00000000 --- a/apps/backend/src/services/admin/platform-config/domain.ts +++ /dev/null @@ -1,122 +0,0 @@ -import { normalizeAgisoMessageTemplate } from '../../platforms/agiso/xianyu/message-service.js' -import { maskSecret } from './mappers.js' - -type JsonObject = Record - -export function mapAdminAgisoMessagingDefaults(defaults: JsonObject = {}) { - return { - messageTemplate: normalizeAgisoMessageTemplate(String(defaults.messageTemplate || '').trim()), - autoDeliveryMessageTemplate: normalizeAgisoMessageTemplate( - String(defaults.autoDeliveryMessageTemplate || '').trim(), - ), - } -} - -export function mapAdminAgisoShopConfigItem(shopId: unknown, config: JsonObject = {}) { - return { - shopId, - shopName: String(config.shopName || '').trim(), - accessToken: String(config.accessToken || '').trim(), - accessTokenMasked: maskSecret(config.accessToken), - enabled: typeof config.enabled === 'boolean' ? config.enabled : null, - messageTemplate: normalizeAgisoMessageTemplate(String(config.messageTemplate || '').trim()), - autoDeliveryMessageTemplate: normalizeAgisoMessageTemplate( - String(config.autoDeliveryMessageTemplate || '').trim(), - ), - appSecretConfigured: Boolean(String(config.appSecret || '').trim()), - apiVersion: String(config.apiVersion || '').trim(), - sendMessageEndpoint: String(config.sendMessageEndpoint || '').trim(), - } -} - -export function applyOptionalStringField(target: JsonObject, key: string, source: JsonObject) { - if (!source || typeof source[key] !== 'string') { - return - } - - const value = String(source[key] || '').trim() - if (value) { - target[key] = normalizeAgisoMessageTemplate(value) - return - } - - delete target[key] -} - -export function mapAdminFulfillmentBindingConfigItem(item: JsonObject) { - const match = item?.match || {} - return { - provider: String(item?.provider || '').trim(), - platform: String(item?.platform || '').trim(), - shopId: String(item?.shopId || '').trim(), - shopName: String(item?.shopName || '').trim(), - skuCode: String(item?.skuCode || '').trim(), - skuName: String(item?.skuName || '').trim(), - profileKey: String(item?.profileKey || '').trim(), - enabled: item?.enabled !== false, - priority: Number(item?.priority || 100), - config: item?.config || {}, - match: { - externalSkuCode: String(match.externalSkuCode || '').trim(), - externalItemId: String(match.externalItemId || '').trim(), - externalSkuName: String(match.externalSkuName || '').trim(), - config: match.config || {}, - }, - } -} - -export function matchesObservedProduct(binding: JsonObject, observed: JsonObject) { - const provider = String(binding?.provider || '').trim() - const platform = String(binding?.platform || '').trim() - const shopId = String(binding?.shopId || '').trim() - const match = binding?.match || {} - const externalSkuCode = String(match.externalSkuCode || '').trim() - const externalItemId = String(match.externalItemId || '').trim() - const externalSkuName = String(match.externalSkuName || '').trim() - - if (provider && provider !== String(observed?.provider || '').trim()) { - return false - } - - if (platform && platform !== String(observed?.platform || '').trim()) { - return false - } - - if (shopId && shopId !== String(observed?.shopId || '').trim()) { - return false - } - - return ( - (externalSkuCode && externalSkuCode === String(observed?.externalSkuCode || '').trim()) - || (externalItemId && externalItemId === String(observed?.externalItemId || '').trim()) - || (externalSkuName && externalSkuName === String(observed?.externalSkuName || '').trim()) - ) -} - -export function findMatchingObservedBinding(bindings: JsonObject[], observed: JsonObject) { - return (Array.isArray(bindings) ? bindings : []).find((binding) => matchesObservedProduct(binding, observed)) || null -} - -export function mapAdminObservedProductItem(item: JsonObject, bindings: JsonObject[] = []) { - const matchedBinding = findMatchingObservedBinding(bindings, item) - - return { - provider: String(item?.provider || '').trim(), - platform: String(item?.platform || '').trim(), - shopId: String(item?.shopId || '').trim(), - shopName: String(item?.shopName || '').trim(), - externalItemId: String(item?.externalItemId || '').trim(), - externalSkuCode: String(item?.externalSkuCode || '').trim(), - externalSkuName: String(item?.externalSkuName || '').trim(), - latestSeenAt: item?.latestSeenAt || null, - orderItemCount: Number(item?.orderItemCount || 0), - configured: Boolean(matchedBinding), - matchedBinding: matchedBinding - ? { - skuCode: String(matchedBinding.skuCode || '').trim(), - skuName: String(matchedBinding.skuName || '').trim(), - profileKey: String(matchedBinding.profileKey || '').trim(), - } - : null, - } -} diff --git a/apps/backend/src/services/admin/platform-config/fulfillment-bindings-service.ts b/apps/backend/src/services/admin/platform-config/fulfillment-bindings-service.ts deleted file mode 100644 index 8606a908..00000000 --- a/apps/backend/src/services/admin/platform-config/fulfillment-bindings-service.ts +++ /dev/null @@ -1,103 +0,0 @@ -import { getAgisoShopConfig } from '../../platforms/agiso/shop-config-service.js' -import { - filterLegacyOrderFulfillmentBindings, - getOrderFulfillmentBindingsFilePath, - getLegacyOrderFulfillmentBindingConfigs, - saveOrderFulfillmentBindingConfigs, -} from '../../order/fulfillment-binding-config-service.js' -import { enrichAgisoXianyuTradeOrder } from '../../platforms/agiso/xianyu/order-detail-service.js' -import { syncConfiguredFulfillmentBindings } from '../../bootstrap/fulfillment-bootstrap-service.js' -import { getFulfillmentProfileByKey } from '../../../repositories/fulfillment-profile-repo.js' -import { createHttpError } from '../../../utils/http.js' -import { - buildFulfillmentLookupResult, - normalizeFulfillmentLookupPayload, - resolveFulfillmentLookupDetail, -} from './fulfillment.js' -import { listAdminObservedProducts } from './observed-products.js' -import { validateAdminFulfillmentBindingConfigs } from './validation.js' -import { mapAdminFulfillmentBindingConfigItem } from './domain.js' - -import type { - AdminFulfillmentBindingConfigSaveInput, - AdminFulfillmentBindingLookupInput, -} from '../../../types/admin-write-inputs.js' - -type JsonObject = Record - -export async function getAdminFulfillmentBindingConfigs() { - const bindings = getLegacyOrderFulfillmentBindingConfigs() - - return { - filePath: getOrderFulfillmentBindingsFilePath(), - bindings: bindings.map(mapAdminFulfillmentBindingConfigItem), - observedProducts: await listAdminObservedProducts(bindings), - } -} - -export async function lookupAdminFulfillmentBindingOrder( - payload: AdminFulfillmentBindingLookupInput = {}, -) { - const { provider, platform, shopId, platformOrderId } = normalizeFulfillmentLookupPayload(payload) - - const detailResult = await enrichAgisoXianyuTradeOrder({ - provider, - platform, - shopId, - shopName: '', - platformOrderId, - orderStatus: 'created', - payStatus: 'unpaid', - buyerId: '', - buyerName: '', - receiverContact: '', - totalAmount: 0, - currency: 'CNY', - paidAt: null, - rawPayload: {}, - items: [], - }, { - requestId: `admin-fulfillment-lookup:${shopId}:${platformOrderId}`, - }) - - const { detail } = resolveFulfillmentLookupDetail(detailResult, platformOrderId) - const bindings = getLegacyOrderFulfillmentBindingConfigs() - return buildFulfillmentLookupResult({ - provider, - platform, - shopId, - platformOrderId, - detail, - detailResult, - bindings, - fallbackShopName: getAgisoShopConfig(shopId)?.shopName, - }) -} - -export async function updateAdminFulfillmentBindingConfigs( - payload: AdminFulfillmentBindingConfigSaveInput = {}, -) { - const bindingsInput = Array.isArray(payload.bindings) ? payload.bindings : [] - const normalizedBindings = await validateAdminFulfillmentBindingConfigs(bindingsInput, { - getFulfillmentProfileByKey, - }) - assertLegacyBindingConfigsOnly(normalizedBindings) - const saved = saveOrderFulfillmentBindingConfigs(normalizedBindings) - await syncConfiguredFulfillmentBindings() - - return { - filePath: getOrderFulfillmentBindingsFilePath(), - bindings: saved.map(mapAdminFulfillmentBindingConfigItem), - } -} - -function assertLegacyBindingConfigsOnly(bindings: JsonObject[] = []) { - if (filterLegacyOrderFulfillmentBindings(bindings).length === bindings.length) { - return - } - - throw createHttpError('快手履约规则请改到“快手 Cloud 新履约”页维护,旧履约规则仅保留给 Agiso 等历史场景', { - statusCode: 400, - errorCode: 'admin_fulfillment_bindings_kuaishou_cloud_moved', - }) -} diff --git a/apps/backend/src/services/admin/platform-config/fulfillment.test.ts b/apps/backend/src/services/admin/platform-config/fulfillment.test.ts deleted file mode 100644 index 1f43e4df..00000000 --- a/apps/backend/src/services/admin/platform-config/fulfillment.test.ts +++ /dev/null @@ -1,124 +0,0 @@ -import test from 'node:test' -import assert from 'node:assert/strict' - -import { - buildFulfillmentLookupResult, - normalizeFulfillmentLookupPayload, - resolveFulfillmentLookupDetail, -} from './fulfillment.js' - -test('normalizeFulfillmentLookupPayload validates required fields and defaults provider/platform', () => { - assert.deepEqual( - normalizeFulfillmentLookupPayload({ - shopId: ' shop-1 ', - platformOrderId: ' order-1 ', - }), - { - provider: 'agiso', - platform: 'xianyu', - shopId: 'shop-1', - platformOrderId: 'order-1', - }, - ) - - assert.throws( - () => normalizeFulfillmentLookupPayload({ platformOrderId: 'order-1' }), - /请先填写店铺 ID/, - ) -}) - -test('resolveFulfillmentLookupDetail maps missing config into readable error', () => { - assert.throws( - () => - resolveFulfillmentLookupDetail( - { - reason: 'missing_config', - parsed: { items: [] }, - }, - 'order-1', - ), - /当前店铺缺少订单详情查询配置/, - ) -}) - -test('buildFulfillmentLookupResult assembles order and item matching payload', () => { - const result = buildFulfillmentLookupResult({ - provider: 'agiso', - platform: 'xianyu', - shopId: 'shop-1', - platformOrderId: 'order-1', - detail: { - shopName: '店铺A', - buyerName: '买家A', - totalAmount: 12345, - paidAt: '2026-05-04T10:00:00.000Z', - items: [ - { - externalItemId: 'item-1', - externalSkuCode: 'sku-1', - externalSkuName: '礼包A', - skuName: '礼包A', - quantity: 2, - }, - ], - }, - detailResult: { - enriched: true, - reason: 'ok', - errorMessage: '', - }, - bindings: [ - { - provider: 'agiso', - platform: 'xianyu', - shopId: 'shop-1', - skuCode: 'internal-1', - skuName: '内部商品', - profileKey: 'profile-1', - match: { - externalSkuCode: 'sku-1', - }, - }, - ], - fallbackShopName: '备用店铺', - }) - - assert.deepEqual(result, { - order: { - provider: 'agiso', - platform: 'xianyu', - shopId: 'shop-1', - shopName: '店铺A', - platformOrderId: 'order-1', - buyerName: '买家A', - totalAmountFen: 12345, - totalAmount: '123.45', - paidAt: '2026-05-04T10:00:00.000Z', - enriched: true, - enrichReason: 'ok', - errorMessage: '', - }, - items: [ - { - lineId: 'order-1:1:sku-1:item-1', - itemTitle: '礼包A', - quantity: 2, - provider: 'agiso', - platform: 'xianyu', - shopId: 'shop-1', - shopName: '店铺A', - externalItemId: 'item-1', - externalSkuCode: 'sku-1', - externalSkuName: '礼包A', - latestSeenAt: null, - orderItemCount: 2, - configured: true, - matchedBinding: { - skuCode: 'internal-1', - skuName: '内部商品', - profileKey: 'profile-1', - }, - }, - ], - }) -}) diff --git a/apps/backend/src/services/admin/platform-config/fulfillment.ts b/apps/backend/src/services/admin/platform-config/fulfillment.ts deleted file mode 100644 index 76806ae7..00000000 --- a/apps/backend/src/services/admin/platform-config/fulfillment.ts +++ /dev/null @@ -1,137 +0,0 @@ -import { createHttpError } from '../../../utils/http.js' -import { formatFenToAmount } from '../../../utils/money.js' -import { isPlainObject, pickFirstNonEmpty } from './context.js' -import { mapAdminObservedProductItem } from './domain.js' - -type JsonObject = Record - -export function normalizeFulfillmentLookupPayload(payload: JsonObject = {}) { - const provider = String(payload.provider || 'agiso').trim() || 'agiso' - const platform = String(payload.platform || 'xianyu').trim() || 'xianyu' - const shopId = String(payload.shopId || '').trim() - const platformOrderId = String(payload.platformOrderId || '').trim() - - if (!shopId) { - throw createHttpError('请先填写店铺 ID', { - statusCode: 400, - errorCode: 'admin_fulfillment_lookup_missing_shop_id', - }) - } - - if (!platformOrderId) { - throw createHttpError('请先填写平台订单号', { - statusCode: 400, - errorCode: 'admin_fulfillment_lookup_missing_platform_order_id', - }) - } - - if (provider !== 'agiso' || platform !== 'xianyu') { - throw createHttpError('目前仅支持 Agiso 咸鱼订单手动查询', { - statusCode: 400, - errorCode: 'admin_fulfillment_lookup_platform_not_supported', - }) - } - - return { provider, platform, shopId, platformOrderId } -} - -export function resolveFulfillmentLookupDetail(detailResult: JsonObject, platformOrderId: unknown) { - const detail = isPlainObject(detailResult?.parsed) ? detailResult.parsed : {} - const items = Array.isArray(detail.items) ? detail.items : [] - - if (items.length > 0) { - return { detail, items } - } - - const detailReason = String(detailResult?.reason || '').trim() - const detailMessage = String(detailResult?.errorMessage || '').trim() - let message = detailMessage - - if (!message && detailReason === 'missing_config') { - message = '当前店铺缺少订单详情查询配置,请先检查 accessToken、appSecret 和详情接口地址' - } - - if (!message) { - message = `未查询到订单 ${platformOrderId} 的商品明细` - } - - throw createHttpError(message, { - statusCode: 404, - errorCode: 'admin_fulfillment_lookup_order_items_not_found', - }) -} - -export function buildFulfillmentLookupResult({ - provider = '', - platform = '', - shopId = '', - platformOrderId = '', - detail = {}, - detailResult = {}, - bindings = [], - fallbackShopName = '', -}: { - provider?: string - platform?: string - shopId?: string - platformOrderId?: string - detail?: JsonObject - detailResult?: JsonObject - bindings?: JsonObject[] - fallbackShopName?: string -} = {}) { - const resolvedShopName = pickFirstNonEmpty([detail.shopName, fallbackShopName]) - const items = Array.isArray(detail.items) ? detail.items : [] - - return { - order: { - provider, - platform, - shopId, - shopName: resolvedShopName, - platformOrderId, - buyerName: String(detail.buyerName || '').trim(), - totalAmountFen: Number(detail.totalAmount || 0), - totalAmount: formatFenToAmount(detail.totalAmount), - paidAt: detail.paidAt || null, - enriched: Boolean(detailResult?.enriched), - enrichReason: String(detailResult?.reason || '').trim(), - errorMessage: String(detailResult?.errorMessage || '').trim(), - }, - items: items.map((item, index) => { - const observed = { - provider, - platform, - shopId, - shopName: resolvedShopName, - externalItemId: pickFirstNonEmpty([item?.externalItemId, item?.itemId]), - externalSkuCode: pickFirstNonEmpty([ - item?.externalSkuCode, - item?.skuCode, - item?.externalItemId, - item?.itemId, - ]), - externalSkuName: pickFirstNonEmpty([item?.externalSkuName, item?.skuName]), - latestSeenAt: null, - orderItemCount: Math.max(1, Number(item?.quantity || 0) || 1), - } - - return { - lineId: [ - platformOrderId, - index + 1, - observed.externalSkuCode || 'na', - observed.externalItemId || 'na', - ].join(':'), - itemTitle: pickFirstNonEmpty([ - item?.skuName, - item?.externalSkuName, - item?.externalSkuCode, - item?.externalItemId, - ]), - quantity: observed.orderItemCount, - ...mapAdminObservedProductItem(observed, bindings), - } - }), - } -} diff --git a/apps/backend/src/services/admin/platform-config/kuaishou-cloud-fulfillment-service.ts b/apps/backend/src/services/admin/platform-config/kuaishou-cloud-fulfillment-service.ts deleted file mode 100644 index 4bdff610..00000000 --- a/apps/backend/src/services/admin/platform-config/kuaishou-cloud-fulfillment-service.ts +++ /dev/null @@ -1,35 +0,0 @@ -import { - getKuaishouCloudFulfillmentConfig, - getKuaishouCloudFulfillmentFilePath, - saveKuaishouCloudFulfillmentConfig, -} from '../../order/kuaishou-cloud-fulfillment-config-service.js' -import { syncConfiguredFulfillmentBindings } from '../../bootstrap/fulfillment-bootstrap-service.js' -import { mapAdminKuaishouCloudFulfillmentSource } from './mappers.js' - -type JsonObject = Record - -export function getAdminKuaishouCloudFulfillmentConfig() { - const config = getKuaishouCloudFulfillmentConfig() - - return { - filePath: getKuaishouCloudFulfillmentFilePath(), - source: mapAdminKuaishouCloudFulfillmentSource(config), - } -} - -/** @param {AdminKuaishouCloudFulfillmentConfigInput} [payload] */ -export async function updateAdminKuaishouCloudFulfillmentConfig( - payload: JsonObject = {}, -) { - const saved = saveKuaishouCloudFulfillmentConfig({ - enabled: payload.enabled !== false, - items: Array.isArray(payload.items) ? payload.items : [], - }) - - await syncConfiguredFulfillmentBindings() - - return { - filePath: getKuaishouCloudFulfillmentFilePath(), - source: mapAdminKuaishouCloudFulfillmentSource(saved), - } -} diff --git a/apps/backend/src/services/admin/platform-config/kuaishou-eticket-service.ts b/apps/backend/src/services/admin/platform-config/kuaishou-eticket-service.ts index 924c5d0a..bbc209d8 100644 --- a/apps/backend/src/services/admin/platform-config/kuaishou-eticket-service.ts +++ b/apps/backend/src/services/admin/platform-config/kuaishou-eticket-service.ts @@ -11,8 +11,8 @@ import { saveKuaishouEticketSourceConfig, } from '../../platforms/kuaishou-eticket/source-config-service.js' import { queryKuaishouEticketStableInfo } from '../../platforms/kuaishou-eticket/info-service.js' -import { resolveKuaishouEticketAdminContext } from './context.js' -import { mapAdminKuaishouEticketSourceConfig } from './mappers.js' +import { resolveKuaishouEticketAdminContext } from './cloudtentacles/context.js' +import { mapAdminKuaishouEticketSourceConfig } from './cloudtentacles/mappers.js' type JsonObject = Record diff --git a/apps/backend/src/services/admin/platform-config/mappers.ts b/apps/backend/src/services/admin/platform-config/mappers.ts deleted file mode 100644 index 4c8cb1ba..00000000 --- a/apps/backend/src/services/admin/platform-config/mappers.ts +++ /dev/null @@ -1,105 +0,0 @@ -import { - maskPhone as maskPhoneValue, - maskSecret as maskSecretValue, -} from "../../../utils/masking.js"; - -type JsonObject = Record - -export function maskSecret(value: unknown) { - return maskSecretValue(value); -} - -export function maskPhone(value: unknown) { - return maskPhoneValue(value, { maskShort: false }); -} - -export function mapAdminKuaishouEticketShopItem(item: JsonObject = {}) { - return { - shopId: String(item.shopId || "").trim(), - kshopName: String(item.kshopName || "").trim(), - cookie: String(item.cookie || "").trim(), - cookieMasked: maskSecret(item.cookie), - hasCookie: Boolean(String(item.cookie || "").trim()), - userAvatar: String(item.userAvatar || "").trim(), - enabled: item.enabled !== false, - }; -} - -export function mapAdminKuaishouEticketSourceConfig(config: JsonObject = {}, shops: JsonObject[] = []) { - return { - enabled: config.enabled !== false, - baseUrl: String(config.baseUrl || "").trim(), - shops: shops.map((item) => mapAdminKuaishouEticketShopItem(item)), - }; -} - -export function mapAdminCloudtentaclesSourceConfig(config: JsonObject = {}) { - return { - key: String(config.key || "").trim(), - label: String(config.label || "").trim(), - enabled: config.enabled !== false, - baseUrl: String(config.baseUrl || "").trim(), - username: String(config.username || "").trim(), - password: String(config.password || "").trim(), - phone: String(config.phone || "").trim(), - deviceId: String(config.deviceId || "-").trim() || "-", - deviceType: Number(config.deviceType || 0), - }; -} - -export function mapAdminCloudtentaclesSession(session: JsonObject = {}) { - return { - token: String(session.token || "").trim(), - tokenMasked: maskSecret(session.token), - baseUrl: String(session.baseUrl || "").trim(), - username: String(session.username || "").trim(), - phoneMasked: maskPhone(session.phone), - loggedInAt: String(session.loggedInAt || "").trim(), - deviceId: String(session.deviceId || "-").trim() || "-", - deviceType: Number(session.deviceType || 0), - hasToken: Boolean(String(session.token || "").trim()), - }; -} - -export function mapAdminKuaishouCloudFulfillmentItem(item: JsonObject = {}) { - return { - id: String(item.id || "").trim(), - enabled: item.enabled !== false, - priority: Number(item.priority || 100) || 100, - provider: String(item.provider || "91kaquan").trim() || "91kaquan", - platform: String(item.platform || "kuaishou").trim() || "kuaishou", - shopId: String(item.shopId || "").trim(), - internalSkuCode: String(item.internalSkuCode || "").trim(), - internalSkuName: String(item.internalSkuName || "").trim(), - externalSkuCode: String(item.externalSkuCode || "").trim(), - externalItemId: String(item.externalItemId || "").trim(), - externalSkuName: String(item.externalSkuName || "").trim(), - resolvedSkuName: String(item.resolvedSkuName || "").trim(), - cloudSourceKey: - String(item.cloudSourceKey || "default").trim() || "default", - cloudSourceKeyFallbacks: Array.isArray(item.cloudSourceKeyFallbacks) - ? item.cloudSourceKeyFallbacks - .map((value) => String(value || "").trim()) - .filter(Boolean) - : [], - cloudSkuId: Number(item.cloudSkuId || 0) || 0, - cloudSkuName: String(item.cloudSkuName || "").trim(), - vnKey: String(item.vnKey || "").trim(), - autoBuyEnabled: item.autoBuyEnabled !== false, - minAssetReserve: Number(item.minAssetReserve || 0) || 0, - autoReturnNumberAfterDispatch: item.autoReturnNumberAfterDispatch === true, - autoConsumeAfterDispatch: item.autoConsumeAfterDispatch === true, - kuaishouConsumeShopId: String(item.kuaishouConsumeShopId || "").trim(), - kuaishouConsumeShopName: String(item.kuaishouConsumeShopName || "").trim(), - notes: String(item.notes || "").trim(), - }; -} - -export function mapAdminKuaishouCloudFulfillmentSource(config: JsonObject = {}) { - return { - enabled: config.enabled !== false, - items: (Array.isArray(config.items) ? config.items : []).map((item) => - mapAdminKuaishouCloudFulfillmentItem(item) - ), - }; -} diff --git a/apps/backend/src/services/admin/platform-config/notification-service.ts b/apps/backend/src/services/admin/platform-config/notification-service.ts index b90e51e8..04937424 100644 --- a/apps/backend/src/services/admin/platform-config/notification-service.ts +++ b/apps/backend/src/services/admin/platform-config/notification-service.ts @@ -16,13 +16,13 @@ import { reloadScheduledJobs, runScheduledJobNow, } from '../../scheduler/scheduler-service.js' -import { maskPhone, maskSecret } from './mappers.js' +import { maskPhone, maskSecret } from './cloudtentacles/mappers.js' import type { AdminNotificationConfigInput, AdminNotificationTestInput, AdminScheduledJobsConfigInput, -} from '../../../types/admin-write-inputs.js' +} from '../../../types/admin/write-inputs.js' type JsonObject = Record @@ -95,7 +95,7 @@ function mapAdminNotificationConfig(config: JsonObject = {}) { bark: { enabled: bark.enabled !== false, serverUrl: String(bark.serverUrl || 'https://api.day.app').trim(), - recipients: (Array.isArray(bark.recipients) ? bark.recipients : []).map((item) => ({ + recipients: (Array.isArray(bark.recipients) ? bark.recipients : []).map((item: JsonObject) => ({ id: String(item.id || '').trim(), name: String(item.name || '').trim(), deviceKey: String(item.deviceKey || '').trim(), @@ -105,7 +105,7 @@ function mapAdminNotificationConfig(config: JsonObject = {}) { }, wpush: { enabled: wpush.enabled !== false, - recipients: (Array.isArray(wpush.recipients) ? wpush.recipients : []).map((item) => ({ + recipients: (Array.isArray(wpush.recipients) ? wpush.recipients : []).map((item: JsonObject) => ({ id: String(item.id || '').trim(), name: String(item.name || '').trim(), apiKey: String(item.apiKey || item.apikey || '').trim(), @@ -146,7 +146,7 @@ function mapAdminScheduledJobsConfig(config: JsonObject = {}) { function listAdminCloudtentaclesMonitorAccounts() { const sourcesConfig = listCloudtentaclesSources() const sessionsConfig = getAllCloudtentaclesSessionStates() - const sessions = sessionsConfig.sessions || {} + const sessions: Record = sessionsConfig.sessions || {} return (Array.isArray(sourcesConfig.sources) ? sourcesConfig.sources : []).map((source) => { const sourceKey = String(source.key || '').trim() diff --git a/apps/backend/src/services/admin/platform-config/observed-products.test.ts b/apps/backend/src/services/admin/platform-config/observed-products.test.ts deleted file mode 100644 index 8f1a61ce..00000000 --- a/apps/backend/src/services/admin/platform-config/observed-products.test.ts +++ /dev/null @@ -1,97 +0,0 @@ -import test from 'node:test' -import assert from 'node:assert/strict' - -import { - ADMIN_OBSERVED_PRODUCTS_QUERY, - listAdminObservedProducts, - mapAdminObservedProductRow, -} from './observed-products.js' - -test('mapAdminObservedProductRow normalizes query row fields', () => { - assert.deepEqual( - mapAdminObservedProductRow({ - provider: ' agiso ', - platform: ' xianyu ', - shop_id: ' shop-1 ', - shop_name: ' 店铺A ', - external_item_id: ' item-1 ', - external_sku_code: ' sku-1 ', - external_sku_name: ' 商品A ', - latest_seen_at: '2026-05-04T12:00:00.000Z', - order_item_count: '3', - }), - { - provider: 'agiso', - platform: 'xianyu', - shopId: 'shop-1', - shopName: '店铺A', - externalItemId: 'item-1', - externalSkuCode: 'sku-1', - externalSkuName: '商品A', - latestSeenAt: '2026-05-04T12:00:00.000Z', - orderItemCount: 3, - }, - ) -}) - -test('listAdminObservedProducts queries rows and maps matched binding summary', async () => { - /** @type {string[]} */ - const sqlCalls = [] - const result = await listAdminObservedProducts( - [ - { - provider: 'agiso', - platform: 'xianyu', - shopId: 'shop-1', - skuCode: 'inner-1', - skuName: '内部商品A', - profileKey: 'manual_review', - match: { - externalSkuCode: 'sku-1', - }, - }, - ], - { - async query(sql) { - sqlCalls.push(sql) - return { - rows: [ - { - provider: 'agiso', - platform: 'xianyu', - shop_id: 'shop-1', - shop_name: '店铺A', - external_item_id: '', - external_sku_code: 'sku-1', - external_sku_name: '商品A', - latest_seen_at: '2026-05-04T12:00:00.000Z', - order_item_count: 2, - }, - ], - } - }, - }, - ) - - assert.equal(sqlCalls.length, 1) - assert.equal(sqlCalls[0], ADMIN_OBSERVED_PRODUCTS_QUERY) - assert.deepEqual(result, [ - { - provider: 'agiso', - platform: 'xianyu', - shopId: 'shop-1', - shopName: '店铺A', - externalItemId: '', - externalSkuCode: 'sku-1', - externalSkuName: '商品A', - latestSeenAt: '2026-05-04T12:00:00.000Z', - orderItemCount: 2, - configured: true, - matchedBinding: { - skuCode: 'inner-1', - skuName: '内部商品A', - profileKey: 'manual_review', - }, - }, - ]) -}) diff --git a/apps/backend/src/services/admin/platform-config/observed-products.ts b/apps/backend/src/services/admin/platform-config/observed-products.ts deleted file mode 100644 index 295dcdc2..00000000 --- a/apps/backend/src/services/admin/platform-config/observed-products.ts +++ /dev/null @@ -1,52 +0,0 @@ -import { query } from '../../../db/client.js' -import { mapAdminObservedProductItem } from './domain.js' - -type JsonObject = Record - -export const ADMIN_OBSERVED_PRODUCTS_QUERY = ` - SELECT - o.provider, - o.platform, - o.shop_id, - MAX(CASE WHEN trim(o.shop_name) != '' THEN o.shop_name ELSE '' END) AS shop_name, - COALESCE(NULLIF(oi.item_snapshot_json ->> 'externalItemId', ''), '') AS external_item_id, - COALESCE(NULLIF(oi.item_snapshot_json ->> 'externalSkuCode', ''), '') AS external_sku_code, - COALESCE(NULLIF(oi.item_snapshot_json ->> 'externalSkuName', ''), '') AS external_sku_name, - MAX(oi.created_at) AS latest_seen_at, - COUNT(*)::int AS order_item_count - FROM order_items oi - JOIN orders o ON o.id = oi.order_id - WHERE - COALESCE(NULLIF(oi.item_snapshot_json ->> 'externalItemId', ''), '') != '' - OR COALESCE(NULLIF(oi.item_snapshot_json ->> 'externalSkuCode', ''), '') != '' - OR COALESCE(NULLIF(oi.item_snapshot_json ->> 'externalSkuName', ''), '') != '' - GROUP BY - o.provider, - o.platform, - o.shop_id, - external_item_id, - external_sku_code, - external_sku_name - ORDER BY latest_seen_at DESC, o.platform ASC, o.shop_id ASC, external_sku_code ASC - LIMIT 200 -` - -export function mapAdminObservedProductRow(row: JsonObject = {}) { - return { - provider: String(row.provider || '').trim(), - platform: String(row.platform || '').trim(), - shopId: String(row.shop_id || '').trim(), - shopName: String(row.shop_name || '').trim(), - externalItemId: String(row.external_item_id || '').trim(), - externalSkuCode: String(row.external_sku_code || '').trim(), - externalSkuName: String(row.external_sku_name || '').trim(), - latestSeenAt: row.latest_seen_at || null, - orderItemCount: Number(row.order_item_count || 0), - } -} - -export async function listAdminObservedProducts(bindings: JsonObject[] = [], options: JsonObject = {}) { - const queryImpl = (options.query || query) as (sql: string) => Promise<{ rows: JsonObject[] }> - const rowsResult = await queryImpl(ADMIN_OBSERVED_PRODUCTS_QUERY) - return rowsResult.rows.map((row) => mapAdminObservedProductItem(mapAdminObservedProductRow(row), bindings)) -} diff --git a/apps/backend/src/services/admin/platform-config/service.ts b/apps/backend/src/services/admin/platform-config/service.ts deleted file mode 100644 index 273c4f35..00000000 --- a/apps/backend/src/services/admin/platform-config/service.ts +++ /dev/null @@ -1,61 +0,0 @@ -export { - getAdminAgisoShopConfigs, - updateAdminAgisoShopConfigs, -} from "./agiso-service.js"; - -export { - getAdminNinetyoneOrders, - retryAdminNinetyoneOrder, - failAdminNinetyoneOrder, -} from "./ninetyone-service.js"; - -export { - getAdminKuaishouEticketSourceConfig, - updateAdminKuaishouEticketSourceConfig, - queryAdminKuaishouEticketDetail, - queryAdminKuaishouEticketShopInfo, - consumeAdminKuaishouEticket, -} from "./kuaishou-eticket-service.js"; - -export { - listAdminCloudtentaclesSources, - deleteAdminCloudtentaclesSource, - updateAdminCloudtentaclesSourceConfig, - sendAdminCloudtentaclesSmsCode, - testAdminCloudtentaclesLogin, - validateAdminCloudtentaclesSession, - getAdminCloudtentaclesAsset, - getAdminCloudtentaclesCategories, - getAdminCloudtentaclesSkuList, - buyAdminCloudtentaclesSku, - useAdminCloudtentaclesSku, - getAdminCloudtentaclesKnapsack, - listAdminCloudtentaclesVirtualNumbers, - appointAdminCloudtentaclesVirtualNumber, - generateAdminCloudtentaclesLoginCode, - fetchAdminCloudtentaclesVirtualNumberCode, - verifyAdminCloudtentaclesLoginCode, - getAdminCloudtentaclesBindUrl, - backAdminCloudtentaclesVirtualNumber, - runAdminCloudtentaclesFullFlow, -} from "./cloudtentacles-service.js"; - -export { - getAdminFulfillmentBindingConfigs, - lookupAdminFulfillmentBindingOrder, - updateAdminFulfillmentBindingConfigs, -} from "./fulfillment-bindings-service.js"; - -export { - getAdminKuaishouCloudFulfillmentConfig, - updateAdminKuaishouCloudFulfillmentConfig, -} from "./kuaishou-cloud-fulfillment-service.js"; - -export { - getAdminNotificationConfig, - updateAdminNotificationConfig, - testAdminNotification, - getAdminScheduledJobsConfig, - updateAdminScheduledJobsConfig, - runAdminScheduledJobNow, -} from "./notification-service.js"; diff --git a/apps/backend/src/services/admin/platform-config/validation.test.ts b/apps/backend/src/services/admin/platform-config/validation.test.ts deleted file mode 100644 index 9be59f24..00000000 --- a/apps/backend/src/services/admin/platform-config/validation.test.ts +++ /dev/null @@ -1,175 +0,0 @@ -import test from 'node:test' -import assert from 'node:assert/strict' - -import { - assertAdminFulfillmentBindingsInput, - buildAdminFulfillmentBindingUniqueKey, - normalizeAdminFulfillmentBindingItem, - validateAdminFulfillmentBindingConfigs, -} from './validation.js' - -test('assertAdminFulfillmentBindingsInput rejects non-array payloads', () => { - assert.doesNotThrow(() => assertAdminFulfillmentBindingsInput([])) - assert.throws(() => assertAdminFulfillmentBindingsInput({}), /履约配置格式不正确/) -}) - -test('normalizeAdminFulfillmentBindingItem normalizes generic binding fields', () => { - const result = normalizeAdminFulfillmentBindingItem( - { - provider: ' agiso ', - platform: ' xianyu ', - shopId: ' shop-1 ', - shopName: ' 店铺 ', - skuCode: 'sku-1', - match: { - externalSkuCode: 'ext-1', - }, - config: { - kuaishouShop: { - note: 'keep', - }, - }, - }, - { index: 0 }, - ) - - assert.deepEqual(result, { - provider: 'agiso', - platform: 'xianyu', - shopId: 'shop-1', - shopName: '店铺', - skuCode: 'sku-1', - skuName: '', - profileKey: 'manual_review', - enabled: true, - priority: undefined, - config: { - kuaishouShop: { - note: 'keep', - }, - }, - match: { - externalSkuCode: 'ext-1', - externalItemId: '', - externalSkuName: '', - config: {}, - }, - }) -}) - -test('normalizeAdminFulfillmentBindingItem rejects missing sku and match conditions', () => { - assert.throws( - () => - normalizeAdminFulfillmentBindingItem( - { - provider: 'agiso', - platform: 'xianyu', - match: { - externalSkuCode: 'ext-1', - }, - }, - { index: 1 }, - ), - /第 2 条规则缺少内部履约 SKU/, - ) - - assert.throws( - () => - normalizeAdminFulfillmentBindingItem( - { - provider: 'agiso', - platform: 'xianyu', - skuCode: 'sku-1', - }, - { index: 2 }, - ), - /第 3 条规则至少需要一种外部匹配条件/, - ) -}) - -test('buildAdminFulfillmentBindingUniqueKey uses normalized external sku name and shop id rules', () => { - assert.equal( - buildAdminFulfillmentBindingUniqueKey({ - provider: '91kaquan', - platform: 'kuaishou', - shopId: 'shop-a', - skuCode: 'sku-1', - match: { - externalItemId: '', - externalSkuCode: '', - externalSkuName: ' 礼包 A ', - }, - }), - '91kaquan::kuaishou::shop-a::::::礼包 a::sku-1', - ) -}) - -test('validateAdminFulfillmentBindingConfigs rejects missing fulfillment profile', async () => { - await assert.rejects( - () => - validateAdminFulfillmentBindingConfigs( - [ - { - provider: 'agiso', - platform: 'xianyu', - shopId: 'shop-1', - skuCode: 'sku-1', - profileKey: 'missing-profile', - match: { - externalSkuCode: 'ext-1', - }, - }, - ], - { - kuaishouEticketSource: {}, - resolveKuaishouEticketShopConfig() { - return null - }, - async getFulfillmentProfileByKey() { - return null - }, - }, - ), - /第 1 条规则使用了不存在的履约方式: missing-profile/, - ) -}) - -test('validateAdminFulfillmentBindingConfigs rejects duplicate rules after normalization', async () => { - await assert.rejects( - () => - validateAdminFulfillmentBindingConfigs( - [ - { - provider: 'agiso', - platform: 'xianyu', - shopId: 'shop-1', - skuCode: 'sku-1', - profileKey: 'manual_review', - match: { - externalSkuCode: ' ext-1 ', - }, - }, - { - provider: 'agiso', - platform: 'xianyu', - shopId: ' shop-1 ', - skuCode: 'sku-1', - profileKey: 'manual_review', - match: { - externalSkuCode: 'ext-1', - }, - }, - ], - { - kuaishouEticketSource: {}, - resolveKuaishouEticketShopConfig() { - return null - }, - async getFulfillmentProfileByKey() { - return { key: 'manual_review' } - }, - }, - ), - /第 2 条规则与其它规则重复/, - ) -}) diff --git a/apps/backend/src/services/admin/platform-config/validation.ts b/apps/backend/src/services/admin/platform-config/validation.ts deleted file mode 100644 index 49a51cb2..00000000 --- a/apps/backend/src/services/admin/platform-config/validation.ts +++ /dev/null @@ -1,125 +0,0 @@ -import { createHttpError } from '../../../utils/http.js' -import { normalizeProductName } from '../../order/product-match-service.js' -import { isPlainObject } from './context.js' - -type JsonObject = Record - -export function assertAdminFulfillmentBindingsInput(bindings: unknown) { - if (Array.isArray(bindings)) { - return - } - - throw createHttpError('履约配置格式不正确', { - statusCode: 400, - errorCode: 'admin_fulfillment_bindings_invalid_payload', - }) -} - -export function normalizeAdminFulfillmentBindingItem( - rawBinding: unknown, - options: JsonObject = {}, -) { - const index = Number(options.index || 0) - if (!isPlainObject(rawBinding)) { - throw createHttpError(`第 ${index + 1} 条规则格式不正确`, { - statusCode: 400, - errorCode: 'admin_fulfillment_bindings_invalid_item', - }) - } - - const provider = String(rawBinding.provider || 'agiso').trim() || 'agiso' - const platform = String(rawBinding.platform || '').trim() - const shopId = String(rawBinding.shopId || '').trim() - const shopName = String(rawBinding.shopName || '').trim() - const skuCode = String(rawBinding.skuCode || '').trim() - const skuName = String(rawBinding.skuName || '').trim() - const profileKey = String(rawBinding.profileKey || '').trim() || 'manual_review' - const match = isPlainObject(rawBinding.match) ? rawBinding.match : {} - const externalItemId = String(match.externalItemId || '').trim() - const externalSkuCode = String(match.externalSkuCode || '').trim() - const externalSkuName = String(match.externalSkuName || '').trim() - const config = isPlainObject(rawBinding.config) ? { ...rawBinding.config } : {} - - if (!skuCode) { - throw createHttpError(`第 ${index + 1} 条规则缺少内部履约 SKU`, { - statusCode: 400, - errorCode: 'admin_fulfillment_bindings_missing_sku_code', - }) - } - - if (!externalItemId && !externalSkuCode && !externalSkuName) { - throw createHttpError(`第 ${index + 1} 条规则至少需要一种外部匹配条件`, { - statusCode: 400, - errorCode: 'admin_fulfillment_bindings_missing_match_condition', - }) - } - - return { - provider, - platform, - shopId, - shopName, - skuCode, - skuName, - profileKey, - enabled: rawBinding.enabled !== false, - priority: rawBinding.priority, - config, - match: { - externalSkuCode, - externalItemId, - externalSkuName, - config: isPlainObject(match.config) ? match.config : {}, - }, - } -} - -export function buildAdminFulfillmentBindingUniqueKey(binding) { - return [ - String(binding?.provider || '').trim(), - String(binding?.platform || '').trim(), - String(binding?.shopId || '').trim(), - String(binding?.match?.externalItemId || '').trim(), - String(binding?.match?.externalSkuCode || '').trim(), - normalizeProductName(String(binding?.match?.externalSkuName || '').trim()), - String(binding?.skuCode || '').trim(), - ].join('::') -} - -export async function validateAdminFulfillmentBindingConfigs( - bindings: JsonObject[], - options: JsonObject = {}, -) { - assertAdminFulfillmentBindingsInput(bindings) - - const getFulfillmentProfileByKey = ( - options.getFulfillmentProfileByKey || (async () => null) - ) as (profileKey: string) => Promise - const seenKeys = new Set() - const normalizedBindings: JsonObject[] = [] - - for (const [index, rawBinding] of bindings.entries()) { - const normalized = normalizeAdminFulfillmentBindingItem(rawBinding, { index }) - - const profile = await getFulfillmentProfileByKey(normalized.profileKey) - if (!profile) { - throw createHttpError(`第 ${index + 1} 条规则使用了不存在的履约方式: ${normalized.profileKey}`, { - statusCode: 400, - errorCode: 'admin_fulfillment_bindings_invalid_profile_key', - }) - } - - const uniqueKey = buildAdminFulfillmentBindingUniqueKey(normalized) - if (seenKeys.has(uniqueKey)) { - throw createHttpError(`第 ${index + 1} 条规则与其它规则重复,请调整匹配条件或内部履约 SKU`, { - statusCode: 409, - errorCode: 'admin_fulfillment_bindings_duplicate_rule', - }) - } - - seenKeys.add(uniqueKey) - normalizedBindings.push(normalized) - } - - return normalizedBindings -} diff --git a/apps/backend/src/services/admin/platform-config/writes.test.ts b/apps/backend/src/services/admin/platform-config/writes.test.ts deleted file mode 100644 index cb63cd67..00000000 --- a/apps/backend/src/services/admin/platform-config/writes.test.ts +++ /dev/null @@ -1,127 +0,0 @@ -import test from 'node:test' -import assert from 'node:assert/strict' - -import { - buildAdminAgisoMessagingSavePayload, - buildAdminAgisoShopConfigUpdate, - normalizeAdminCloudtentaclesSourceConfigPayload, -} from './writes.js' - -test('buildAdminAgisoShopConfigUpdate merges item fields and drops explicit empty templates', () => { - assert.deepEqual( - buildAdminAgisoShopConfigUpdate( - { - shopId: ' shop-1 ', - shopName: ' ', - accessToken: ' new-token ', - messageTemplate: ' ', - autoDeliveryMessageTemplate: ' done ', - enabled: false, - }, - { - shopName: '旧店铺', - accessToken: 'old-token', - messageTemplate: 'old-template', - autoDeliveryMessageTemplate: 'old-auto', - appSecret: 'keep-secret', - }, - ), - { - shopId: 'shop-1', - config: { - accessToken: 'new-token', - autoDeliveryMessageTemplate: 'done', - appSecret: 'keep-secret', - enabled: false, - }, - }, - ) -}) - -test('buildAdminAgisoMessagingSavePayload updates defaults and keeps only shops with resulting access token', () => { - assert.deepEqual( - buildAdminAgisoMessagingSavePayload( - { - defaults: { - messageTemplate: ' next\\nline ', - autoDeliveryMessageTemplate: ' ', - }, - shops: [ - { - shopId: ' shop-1 ', - shopName: '店铺一', - accessToken: '', - enabled: true, - }, - { - shopId: 'shop-2', - shopName: ' 店铺二 ', - accessToken: ' token-2 ', - messageTemplate: ' hi ', - apiVersion: ' v2 ', - }, - { - shopId: '', - accessToken: 'skip', - }, - ], - }, - { - currentDefaults: { - messageTemplate: 'old', - autoDeliveryMessageTemplate: 'old-auto', - }, - currentMap: { - 'shop-1': { - accessToken: 'token-1', - autoDeliveryMessageTemplate: 'keep-auto', - }, - }, - }, - ), - { - defaults: { - messageTemplate: 'next\nline', - }, - shops: { - 'shop-1': { - accessToken: 'token-1', - autoDeliveryMessageTemplate: 'keep-auto', - enabled: true, - shopName: '店铺一', - }, - 'shop-2': { - accessToken: 'token-2', - apiVersion: 'v2', - messageTemplate: 'hi', - shopName: '店铺二', - }, - }, - }, - ) -}) - -test('normalizeAdminCloudtentaclesSourceConfigPayload applies defaults', () => { - assert.deepEqual( - normalizeAdminCloudtentaclesSourceConfigPayload({ - enabled: false, - baseUrl: ' ', - username: ' user ', - password: ' pass ', - phone: ' 13800138000 ', - deviceId: ' ', - deviceType: '2', - }), - { - key: 'default', - label: '', - enabled: false, - baseUrl: 'https://123.207.217.176', - username: 'user', - password: 'pass', - phone: '13800138000', - deviceId: '-', - deviceType: 2, - }, - ) -}) diff --git a/apps/backend/src/services/admin/platform-config/writes.ts b/apps/backend/src/services/admin/platform-config/writes.ts deleted file mode 100644 index 8886455d..00000000 --- a/apps/backend/src/services/admin/platform-config/writes.ts +++ /dev/null @@ -1,116 +0,0 @@ -import { applyOptionalStringField } from "./domain.js"; - -type JsonObject = Record - -export function buildAdminAgisoShopConfigUpdate(item: JsonObject, current: JsonObject = {}) { - const shopId = String(item?.shopId || "").trim(); - if (!shopId) { - return null; - } - - const next: JsonObject = { ...current }; - const shopName = String(item?.shopName || "").trim(); - const accessToken = String(item?.accessToken || "").trim(); - const messageTemplate = String(item?.messageTemplate || "").trim(); - const autoDeliveryMessageTemplate = String( - item?.autoDeliveryMessageTemplate || "" - ).trim(); - const appSecret = String(item?.appSecret || "").trim(); - const apiVersion = String(item?.apiVersion || "").trim(); - const sendMessageEndpoint = String(item?.sendMessageEndpoint || "").trim(); - - if (shopName) { - next.shopName = shopName; - } else { - delete next.shopName; - } - if (accessToken) { - next.accessToken = accessToken; - } - if (messageTemplate) { - next.messageTemplate = messageTemplate; - } else if (typeof item?.messageTemplate === "string") { - delete next.messageTemplate; - } - if (autoDeliveryMessageTemplate) { - next.autoDeliveryMessageTemplate = autoDeliveryMessageTemplate; - } else if (typeof item?.autoDeliveryMessageTemplate === "string") { - delete next.autoDeliveryMessageTemplate; - } - if (appSecret) { - next.appSecret = appSecret; - } - if (apiVersion) { - next.apiVersion = apiVersion; - } - if (sendMessageEndpoint) { - next.sendMessageEndpoint = sendMessageEndpoint; - } - if (typeof item?.enabled === "boolean") { - next.enabled = item.enabled; - } - - if (!next.accessToken) { - return null; - } - - return { shopId, config: next }; -} - -export function buildAdminAgisoMessagingSavePayload( - payload: JsonObject = {}, - options: JsonObject = {} -) { - const rawItems = Array.isArray(payload.shops) ? payload.shops : []; - const currentDefaults: JsonObject = options.currentDefaults || {}; - const currentMap: Record = options.currentMap || {}; - const nextDefaults: JsonObject = { - ...currentDefaults, - }; - const nextMap: Record = {}; - - applyOptionalStringField(nextDefaults, "messageTemplate", payload.defaults); - applyOptionalStringField( - nextDefaults, - "autoDeliveryMessageTemplate", - payload.defaults - ); - - for (const item of rawItems) { - const shopId = String(item?.shopId || "").trim(); - if (!shopId) { - continue; - } - - const updated = buildAdminAgisoShopConfigUpdate( - item, - currentMap[shopId] || {} - ); - if (!updated) { - continue; - } - - nextMap[updated.shopId] = updated.config; - } - - return { - defaults: nextDefaults, - shops: nextMap, - }; -} - -export function normalizeAdminCloudtentaclesSourceConfigPayload(payload: JsonObject = {}) { - const sourceKey = - String(payload.sourceKey || payload.key || "").trim() || "default"; - return { - key: sourceKey, - label: String(payload.label || "").trim(), - enabled: payload.enabled !== false, - baseUrl: String(payload.baseUrl || "").trim() || "https://123.207.217.176", - username: String(payload.username || "").trim(), - password: String(payload.password || "").trim(), - phone: String(payload.phone || "").trim(), - deviceId: String(payload.deviceId || "-").trim() || "-", - deviceType: Number(payload.deviceType || 0), - }; -} diff --git a/apps/backend/src/services/admin/write/shared.ts b/apps/backend/src/services/admin/write-helpers.ts similarity index 53% rename from apps/backend/src/services/admin/write/shared.ts rename to apps/backend/src/services/admin/write-helpers.ts index 281ee355..ab9370df 100644 --- a/apps/backend/src/services/admin/write/shared.ts +++ b/apps/backend/src/services/admin/write-helpers.ts @@ -1,20 +1,10 @@ -import { buildClaimUrl, createTaskClaimToken } from '../../claim/claim-service.js' -import { createHttpError } from '../../../utils/http.js' +import { buildClaimUrl, createTaskClaimToken } from '../claim/claim-service.js' import { maskCode as maskCodeValue, maskPhone as maskPhoneValue, -} from '../../../utils/masking.js' -import { - canViewerConfirmAssistedRole, - canViewerRedeemAssistedTask, - isAssistedClaimTask, - parseTaskContext, -} from '../admin-read-shared-helpers.js' +} from '../../utils/masking.js' -import type { AdminViewerContext } from '../admin-read-shared-helpers.js' -import type { TaskRow } from '../../../types/repository-rows.js' - -type AssistedTaskAction = 'confirm' | 'redeem' +import type { TaskRow } from '../../types/repository/rows.js' type TaskClaimLink = { token: string @@ -35,39 +25,6 @@ export function maskCode(value: unknown): string { return maskCodeValue(value) } -export function resolveTaskInventoryGroupCodes(task: TaskRow): string[] | null { - const taskContext = parseTaskContext(task) - const inventoryGroupCode = String(taskContext?.inventoryGroupCode || '').trim() - return inventoryGroupCode ? [inventoryGroupCode] : null -} - -export function ensureViewerCanOperateAssistedTask( - task: TaskRow, - viewerContext: AdminViewerContext, - action: AssistedTaskAction, -): void { - if (!viewerContext.canOperateAssistedTask || !isAssistedClaimTask(task)) { - throw createHttpError('当前账号没有此操作权限', { - statusCode: 403, - errorCode: 'admin_task_assisted_permission_denied', - }) - } - - if (action === 'confirm' && !canViewerConfirmAssistedRole(task, viewerContext)) { - throw createHttpError('当前任务状态还不能确认角色', { - statusCode: 409, - errorCode: 'admin_task_assisted_confirm_not_allowed', - }) - } - - if (action === 'redeem' && !canViewerRedeemAssistedTask(task, viewerContext)) { - throw createHttpError('当前任务状态还不能开始兑换', { - statusCode: 409, - errorCode: 'admin_task_assisted_redeem_not_allowed', - }) - } -} - export function isRecoverableTaskSessionCloseError(error: ErrorLike | null | undefined): boolean { const errorCode = String(error?.errorCode || error?.code || '').trim() return errorCode === 'session_not_found' || errorCode === 'session_closed' diff --git a/apps/backend/src/services/admin/write/inventory.ts b/apps/backend/src/services/admin/write/inventory.ts deleted file mode 100644 index 61f37642..00000000 --- a/apps/backend/src/services/admin/write/inventory.ts +++ /dev/null @@ -1,213 +0,0 @@ -import { - createInventoryItems, - invalidateInventoryItem, - listInventoryItems, - releaseReservedInventoryItem, -} from '../../../repositories/inventory-repo.js' -import { createHttpError } from '../../../utils/http.js' -import { nowIso } from '../../../utils/time.js' -import { - getRequiredInventoryItem, - mapAdminInventoryListItem, -} from '../admin-inventory-read-helpers.js' - -import type { AdminEntityIdInput } from '../../../types/admin-read-inputs.js' -import type { - AdminInventoryCreateInput, - AdminInventoryImportInput, - AdminInventoryImportRowInput, - AdminInventoryInvalidateInput, -} from '../../../types/admin-write-inputs.js' -import type { - AdminInventoryImportResponse, - AdminInventoryMutationResponse, -} from '../../../types/admin-write-models.js' -import type { InventoryItemRow } from '../../../types/repository-rows.js' - -type NormalizedInventoryImportRow = Required> - -export async function createAdminInventoryItem( - payload: AdminInventoryCreateInput = {}, -): Promise { - const skuCode = String(payload.skuCode || '').trim() - const displayValue = String(payload.displayValue || '').trim() - const batchNo = String(payload.batchNo || '').trim() - const credentialType = String(payload.credentialType || 'tencent_code').trim() || 'tencent_code' - const inventoryGroupCode = String(payload.inventoryGroupCode || '').trim() - - if (!skuCode || !displayValue) { - throw createHttpError('缺少 skuCode 或 displayValue', { - statusCode: 400, - errorCode: 'admin_inventory_create_invalid', - }) - } - - const now = nowIso() - const created = await createInventoryItems([ - { - skuCode, - displayValue, - batchNo, - credentialType, - inventoryGroupCode, - createdAt: now, - updatedAt: now, - }, - ]) - - if (created === 0) { - throw createHttpError('库存凭据已存在,不能重复新增', { - statusCode: 409, - errorCode: 'admin_inventory_duplicate', - }) - } - - const { items } = await listInventoryItems({ - page: 1, - pageSize: 20, - skuCode, - inventoryGroupCode, - }) - const createdItem = items.find((item) => item.display_value === displayValue && item.inventory_group_code === inventoryGroupCode) || null - - return { - inventoryItem: createdItem ? await mapAdminInventoryListItem(createdItem) : null, - } -} - -export async function importAdminInventoryItems( - payload: AdminInventoryImportInput = {}, -): Promise { - const rows = normalizeInventoryImportRows(payload) - - if (rows.length === 0) { - throw createHttpError('没有可导入的库存凭据数据', { - statusCode: 400, - errorCode: 'admin_inventory_import_empty', - }) - } - - const now = nowIso() - const normalizedRows = rows.map((row) => ({ - skuCode: row.skuCode, - batchNo: row.batchNo, - displayValue: row.displayValue, - credentialType: row.credentialType, - inventoryGroupCode: row.inventoryGroupCode, - createdAt: now, - updatedAt: now, - })) - - const created = await createInventoryItems(normalizedRows) - - return { - total: normalizedRows.length, - created, - duplicated: normalizedRows.length - created, - } -} - -export async function releaseAdminInventoryItem( - inventoryItemId: AdminEntityIdInput, -): Promise { - const inventoryItem = await getRequiredInventoryItem(inventoryItemId) - - if (inventoryItem.status !== 'reserved') { - throw createHttpError('当前库存项不是预占状态,不能释放', { - statusCode: 409, - errorCode: 'admin_inventory_release_not_allowed', - }) - } - - const updated = await releaseReservedInventoryItem(inventoryItem.id, nowIso()) - - return { - inventoryItem: await mapAdminInventoryListItem(updated as InventoryItemRow), - } -} - -export async function invalidateAdminInventoryItem( - inventoryItemId: AdminEntityIdInput, - payload: AdminInventoryInvalidateInput = {}, -): Promise { - const inventoryItem = await getRequiredInventoryItem(inventoryItemId) - - if (inventoryItem.status !== 'available') { - throw createHttpError('只有可用库存项才能作废,请先释放预占', { - statusCode: 409, - errorCode: 'admin_inventory_invalidate_not_allowed', - }) - } - - const reason = String(payload.reason || '').trim() || '后台手动作废' - const updated = await invalidateInventoryItem(inventoryItem.id, reason, nowIso()) - - return { - inventoryItem: await mapAdminInventoryListItem(updated as InventoryItemRow), - } -} - -function normalizeInventoryImportRows(payload: AdminInventoryImportInput): NormalizedInventoryImportRow[] { - const rows: NormalizedInventoryImportRow[] = [] - const directRows = Array.isArray(payload.rows) ? payload.rows : [] - const bulkCodes = Array.isArray(payload.codes) ? payload.codes : [] - - if (directRows.length > 0) { - for (const row of directRows) { - const skuCode = String(row?.skuCode || '').trim() - const displayValue = String(row?.displayValue || '').trim() - const batchNo = String(row?.batchNo || '').trim() - const credentialType = String(row?.credentialType || payload.credentialType || 'tencent_code').trim() || 'tencent_code' - const inventoryGroupCode = String(row?.inventoryGroupCode || payload.inventoryGroupCode || '').trim() - - if (!skuCode || !displayValue) { - continue - } - - rows.push({ skuCode, displayValue, batchNo, credentialType, inventoryGroupCode }) - } - } - - if (bulkCodes.length > 0) { - const skuCode = String(payload.skuCode || '').trim() - const batchNo = String(payload.batchNo || '').trim() - const credentialType = String(payload.credentialType || 'tencent_code').trim() || 'tencent_code' - const inventoryGroupCode = String(payload.inventoryGroupCode || '').trim() - - if (!skuCode) { - throw createHttpError('批量导入时缺少 skuCode', { - statusCode: 400, - errorCode: 'admin_inventory_import_missing_sku', - }) - } - - for (const rawValue of bulkCodes) { - const displayValue = String(rawValue || '').trim() - if (!displayValue) { - continue - } - rows.push({ skuCode, displayValue, batchNo, credentialType, inventoryGroupCode }) - } - } - - return dedupeRows(rows) -} - -function dedupeRows(rows: NormalizedInventoryImportRow[]): NormalizedInventoryImportRow[] { - const seen = new Set() - const output: NormalizedInventoryImportRow[] = [] - - for (const row of rows) { - const key = `${row.skuCode}::${row.credentialType || 'tencent_code'}::${row.displayValue}` - if (seen.has(key)) { - continue - } - seen.add(key) - output.push(row) - } - - return output -} diff --git a/apps/backend/src/services/admin/write/kuaishou-cloud-actions.ts b/apps/backend/src/services/admin/write/kuaishou-cloud-actions.ts index 20b270c3..854f690a 100644 --- a/apps/backend/src/services/admin/write/kuaishou-cloud-actions.ts +++ b/apps/backend/src/services/admin/write/kuaishou-cloud-actions.ts @@ -13,26 +13,23 @@ import { getCloudtentaclesBindInfo, } from '../../platforms/cloudtentacles/virtual-number-service.js' import { consumeKuaishouEticket } from '../../platforms/kuaishou-eticket/consume-service.js' +import { isKuaishouEticketMockTicketCode } from '../../platforms/kuaishou-eticket/mock-ticket-service.js' import { getKuaishouEticketSourceConfig, resolveKuaishouEticketShopConfig, } from '../../platforms/kuaishou-eticket/source-config-service.js' import { createHttpError } from '../../../utils/http.js' import { nowIso } from '../../../utils/time.js' -import { - createAdminViewerContext, - parseTaskContext, -} from '../admin-read-shared-helpers.js' -import { - getRequiredTask, - mapTaskActionPayload, -} from '../admin-task-read-helpers.js' +import { TASK_STATUS } from '../../../domain/task-status.js' +import { createAdminViewerContext, parseTaskContext } from '../admin-read-shared-helpers.js' +import { getRequiredTask, mapTaskActionPayload } from '../admin-task-read-helpers.js' import { ensureTaskClaimLink, getTaskClaimExpiresAt, maskCode, maskPhone, -} from './shared.js' +} from '../write-helpers.js' +import { rebindKuaishouCloudTaskRole } from '../../fulfillment/kuaishou-cloud/index.js' import { isKuaishouCloudTask, normalizeKuaishouCloudFlow, @@ -43,13 +40,14 @@ import { resolveKuaishouCloudVnKeyCandidates, resolvePersistedCloudtentaclesContext, } from './kuaishou-cloud-helpers.js' +import { syncKuaishouCloudRoleInfoBeforeDispatch } from '../../fulfillment/kuaishou-cloud/dispatch-role-sync.js' import type { AdminEntityIdInput, AdminViewerSessionInput, -} from '../../../types/admin-read-inputs.js' -import type { AdminTaskKuaishouCloudDispatchInput } from '../../../types/admin-write-inputs.js' -import type { AdminTaskActionResponse } from '../../../types/admin-write-models.js' +} from '../../../types/admin/read-inputs.js' +import type { AdminTaskKuaishouCloudDispatchInput } from '../../../types/admin/write-inputs.js' +import type { AdminTaskActionResponse } from '../../../types/admin/write-models.js' export async function prepareAdminTaskKuaishouCloudFulfillment( taskId: AdminEntityIdInput, @@ -75,7 +73,7 @@ export async function prepareAdminTaskKuaishouCloudFulfillment( const taskContext = parseTaskContext(task) const flow = normalizeKuaishouCloudFlow(taskContext.kuaishouCloudFulfillment) - const cloudContext = resolvePersistedCloudtentaclesContext() + const cloudContext = resolvePersistedCloudtentaclesContext(flow.binding.cloudSourceKeys) const [knapsack, skuList] = await Promise.all([ getCloudtentaclesKnapsack(cloudContext), listCloudtentaclesSku(cloudContext), @@ -164,6 +162,7 @@ export async function prepareAdminTaskKuaishouCloudFulfillment( ...flowWithResolvedBinding, binding: { ...flowWithResolvedBinding.binding, + resolvedSourceKey: cloudContext.resolvedSourceKey, vnKey: preparedBinding.vnKey, prepareStatus: 'ready', vnId, @@ -171,7 +170,7 @@ export async function prepareAdminTaskKuaishouCloudFulfillment( bindUrl: preparedBinding.bindUrl, bindPreparedAt: now, bindExpiresAt: resolveKuaishouCloudBindUrlExpiresAt(now), - bindProbeAt: null, + bindProbeAt: null as null, bindProbeStatus: 'pending', bindProbeMessage: '', roleName: '', @@ -181,9 +180,9 @@ export async function prepareAdminTaskKuaishouCloudFulfillment( status: 'pending', name: '', rid: '', - refreshedAt: null, + refreshedAt: null as null, errorMessage: '', - rawInfo: null, + rawInfo: null as null, }, purchase: { ...flowWithResolvedBinding.purchase, @@ -197,8 +196,7 @@ export async function prepareAdminTaskKuaishouCloudFulfillment( } const updatedTask = await updateTask(task.id, { - task_status: 'waiting_binding', - inventory_status: 'not_required', + task_status: TASK_STATUS.WAITING_BINDING, user_action_status: 'pending', claim_token: claimLinkState.token || task.claim_token || '', claim_expires_at: claimLinkState.expiredAt || getTaskClaimExpiresAt(task), @@ -207,17 +205,22 @@ export async function prepareAdminTaskKuaishouCloudFulfillment( updated_at: now, }) - await createTaskEvent(task.id, 'kuaishou_cloud_binding_prepared', { - skuId: flowWithResolvedBinding.binding.skuId, - skuName: flowWithResolvedBinding.binding.skuName, - vnKey: preparedBinding.vnKey, - vnId, - vnPhoneMasked: maskPhone(vnPhone), - bindUrl: preparedBinding.bindUrl, - purchaseTriggered, - usedKnapsack, - resolvedByName: resolvedBinding.resolvedByName, - }, now) + await createTaskEvent( + task.id, + 'kuaishou_cloud_binding_prepared', + { + skuId: flowWithResolvedBinding.binding.skuId, + skuName: flowWithResolvedBinding.binding.skuName, + vnKey: preparedBinding.vnKey, + vnId, + vnPhoneMasked: maskPhone(vnPhone), + bindUrl: preparedBinding.bindUrl, + purchaseTriggered, + usedKnapsack, + resolvedByName: resolvedBinding.resolvedByName, + }, + now, + ) return { task: mapTaskActionPayload(updatedTask), @@ -251,7 +254,10 @@ export async function dispatchAdminTaskKuaishouCloudFulfillment( const taskContext = parseTaskContext(task) const flow = normalizeKuaishouCloudFlow(taskContext.kuaishouCloudFulfillment) - const cloudContext = resolvePersistedCloudtentaclesContext() + const cloudContext = resolvePersistedCloudtentaclesContext([ + flow.binding.resolvedSourceKey, + ...flow.binding.cloudSourceKeys, + ]) if (!flow.binding.skuId || !flow.binding.vnId || !flow.binding.vnPhone) { throw createHttpError('当前任务还没有准备好绑定资源,请先准备绑定资源', { @@ -270,39 +276,58 @@ export async function dispatchAdminTaskKuaishouCloudFulfillment( }) } + const synced = await syncKuaishouCloudRoleInfoBeforeDispatch(task, { + now, + actor: session + ? { + userId: Number(session.userId || 0) || 0, + username: String(session.username || '').trim(), + role: String(session.role || '').trim(), + } + : null, + source: 'admin_task_dispatch', + errorCodePrefix: 'admin_task_kuaishou_cloud', + cloudContext, + taskContext, + flow, + }) + const syncedFlow = synced.flow + const syncedTaskContext = synced.taskContext + const dispatchResult = await useCloudtentaclesSku({ ...cloudContext, - id: flow.binding.skuId, - virtualNumberId: flow.binding.vnId, - phone: flow.binding.vnPhone, + id: syncedFlow.binding.skuId, + virtualNumberId: syncedFlow.binding.vnId, + phone: syncedFlow.binding.vnPhone, }) const nextContext = { - ...taskContext, + ...syncedTaskContext, kuaishouCloudFulfillment: { - ...flow, + ...syncedFlow, ticket: { - ...flow.ticket, + ...syncedFlow.ticket, code: ticketCode || persistedTicketCode, - capturedAt: ticketCode ? now : flow.ticket.capturedAt, - capturedBy: ticketCode && session - ? { - userId: Number(session.userId || 0) || 0, - username: String(session.username || '').trim(), - role: String(session.role || '').trim(), - } - : flow.ticket.capturedBy, + capturedAt: ticketCode ? now : syncedFlow.ticket.capturedAt, + capturedBy: + ticketCode && session + ? { + userId: Number(session.userId || 0) || 0, + username: String(session.username || '').trim(), + role: String(session.role || '').trim(), + } + : syncedFlow.ticket.capturedBy, }, dispatch: { - ...flow.dispatch, + ...syncedFlow.dispatch, status: 'success', dispatchAt: now, dispatchBy: session ? { - userId: Number(session.userId || 0) || 0, - username: String(session.username || '').trim(), - role: String(session.role || '').trim(), - } + userId: Number(session.userId || 0) || 0, + username: String(session.username || '').trim(), + role: String(session.role || '').trim(), + } : null, sendType: Number(dispatchResult.sendType || 0) || 0, note: String(dispatchResult.note || dispatchResult.responseMessage || '').trim(), @@ -311,23 +336,30 @@ export async function dispatchAdminTaskKuaishouCloudFulfillment( } const updatedTask = await updateTask(task.id, { - task_status: 'dispatched_pending_return', + task_status: TASK_STATUS.DISPATCHED_PENDING_RETURN, delivery_status: 'delivered', result_code: 'kuaishou_cloud_dispatched', - result_message: String(dispatchResult.responseMessage || dispatchResult.note || 'cloudtentacles 发货成功').trim(), + result_message: String( + dispatchResult.responseMessage || dispatchResult.note || 'cloudtentacles 发货成功', + ).trim(), last_error: '', context_json: JSON.stringify(nextContext), updated_at: now, }) - await createTaskEvent(task.id, 'kuaishou_cloud_dispatched', { - ticketCodeMasked: maskCode(ticketCode || persistedTicketCode), - skuId: flow.binding.skuId, - vnId: flow.binding.vnId, - vnPhoneMasked: maskPhone(flow.binding.vnPhone), - sendType: dispatchResult.sendType, - note: dispatchResult.note, - }, now) + await createTaskEvent( + task.id, + 'kuaishou_cloud_dispatched', + { + ticketCodeMasked: maskCode(ticketCode || persistedTicketCode), + skuId: syncedFlow.binding.skuId, + vnId: syncedFlow.binding.vnId, + vnPhoneMasked: maskPhone(syncedFlow.binding.vnPhone), + sendType: dispatchResult.sendType, + note: dispatchResult.note, + }, + now, + ) return { task: mapTaskActionPayload(updatedTask), @@ -365,7 +397,10 @@ export async function refreshAdminTaskKuaishouCloudRoleInfo( }) } - const cloudContext = resolvePersistedCloudtentaclesContext() + const cloudContext = resolvePersistedCloudtentaclesContext([ + flow.binding.resolvedSourceKey, + ...flow.binding.cloudSourceKeys, + ]) const bindInfoResult = await getCloudtentaclesBindInfo({ ...cloudContext, key: flow.binding.vnKey, @@ -387,7 +422,8 @@ export async function refreshAdminTaskKuaishouCloudRoleInfo( name: bindInfo.name, rid: bindInfo.rid, refreshedAt: now, - errorMessage: bindInfo.name || bindInfo.rid ? '' : '当前还没有查询到角色信息,请让客户完成绑定后再刷新', + errorMessage: + bindInfo.name || bindInfo.rid ? '' : '当前还没有查询到角色信息,请让客户完成绑定后再刷新', rawInfo: bindInfo.rawInfo, }, }, @@ -400,24 +436,61 @@ export async function refreshAdminTaskKuaishouCloudRoleInfo( updated_at: now, }) - await createTaskEvent(task.id, 'kuaishou_cloud_role_info_refreshed', { - roleName: bindInfo.name, - roleId: bindInfo.rid, - vnId: flow.binding.vnId, - refreshedBy: session - ? { - userId: Number(session.userId || 0) || 0, - username: String(session.username || '').trim(), - role: String(session.role || '').trim(), - } - : null, - }, now) + await createTaskEvent( + task.id, + 'kuaishou_cloud_role_info_refreshed', + { + roleName: bindInfo.name, + roleId: bindInfo.rid, + vnId: flow.binding.vnId, + refreshedBy: session + ? { + userId: Number(session.userId || 0) || 0, + username: String(session.username || '').trim(), + role: String(session.role || '').trim(), + } + : null, + }, + now, + ) return { task: mapTaskActionPayload(updatedTask), } } +export async function rebindAdminTaskKuaishouCloudRole( + taskId: AdminEntityIdInput, + session: AdminViewerSessionInput | null = null, +): Promise { + const task = await getRequiredTask(taskId) + const viewerContext = createAdminViewerContext(session) + + if (!viewerContext.canManageTaskLifecycle) { + throw createHttpError('当前账号没有权限换绑角色', { + statusCode: 403, + errorCode: 'admin_task_rebind_kuaishou_cloud_forbidden', + }) + } + + const result = await rebindKuaishouCloudTaskRole(task, { + source: 'admin_task_rebind_role', + actor: session + ? { + userId: Number(session.userId || 0) || 0, + username: String(session.username || '').trim(), + role: String(session.role || '').trim(), + } + : null, + }) + + return { + task: mapTaskActionPayload(result.task), + claimUrl: result.claimUrl, + token: result.token, + } +} + export async function returnNumberAdminTaskKuaishouCloudFulfillment( taskId: AdminEntityIdInput, session: AdminViewerSessionInput | null = null, @@ -442,7 +515,10 @@ export async function returnNumberAdminTaskKuaishouCloudFulfillment( const taskContext = parseTaskContext(task) const flow = normalizeKuaishouCloudFlow(taskContext.kuaishouCloudFulfillment) - const cloudContext = resolvePersistedCloudtentaclesContext() + const cloudContext = resolvePersistedCloudtentaclesContext([ + flow.binding.resolvedSourceKey, + ...flow.binding.cloudSourceKeys, + ]) if (!flow.binding.vnId || !flow.binding.vnKey) { throw createHttpError('当前任务缺少可退还的虚拟号信息', { @@ -461,6 +537,7 @@ export async function returnNumberAdminTaskKuaishouCloudFulfillment( const ticketCode = String(flow.ticket.code || '').trim() const shopId = String(flow.consume.shopId || order?.shop_id || '').trim() const shopName = String(flow.consume.shopName || order?.shop_name || '').trim() + const mockTicketCode = isKuaishouEticketMockTicketCode(ticketCode) const eticketSource = getKuaishouEticketSourceConfig() const shopConfig = resolveKuaishouEticketShopConfig({ shopId, @@ -472,14 +549,31 @@ export async function returnNumberAdminTaskKuaishouCloudFulfillment( let nextTaskStatus = 'completed' let nextResultCode = 'kuaishou_cloud_completed' let nextResultMessage = 'cloudtentacles 发货、退号并完成快手核销' + const consumeAlreadyCompleted = flow.consume.status === 'success' - if (!order) { + if (consumeAlreadyCompleted) { + consumeStatus = 'success' + consumedAt = flow.consume.consumedAt || now + } else if (!order) { consumeStatus = 'failed' consumeErrorMessage = '任务关联订单不存在,无法执行快手核销' } else if (!ticketCode) { consumeStatus = 'failed' consumeErrorMessage = '客户未提交有效核销码,无法执行快手核销' - } else if (!shopConfig || shopConfig.enabled === false || !String(shopConfig.cookie || '').trim()) { + } else if (mockTicketCode) { + const consumeResult = await consumeKuaishouEticket({ + eTicketId: ticketCode, + oid: String(flow.ticket.oid || '').trim(), + formToken: String(flow.ticket.formToken || '').trim(), + }) + consumeStatus = consumeResult.consumed ? 'success' : 'failed' + consumedAt = consumeResult.consumed ? now : null + consumeErrorMessage = String(consumeResult.errorMessage || '').trim() + } else if ( + !shopConfig || + shopConfig.enabled === false || + !String(shopConfig.cookie || '').trim() + ) { consumeStatus = 'failed' consumeErrorMessage = '订单对应快手小店缺少可用 Cookie,无法执行快手核销' } else { @@ -521,10 +615,10 @@ export async function returnNumberAdminTaskKuaishouCloudFulfillment( returnedAt: now, returnedBy: session ? { - userId: Number(session.userId || 0) || 0, - username: String(session.username || '').trim(), - role: String(session.role || '').trim(), - } + userId: Number(session.userId || 0) || 0, + username: String(session.username || '').trim(), + role: String(session.role || '').trim(), + } : null, }, consume: { @@ -550,14 +644,23 @@ export async function returnNumberAdminTaskKuaishouCloudFulfillment( updated_at: now, }) - await createTaskEvent(task.id, 'kuaishou_cloud_number_returned', { - vnId: flow.binding.vnId, - vnPhoneMasked: maskPhone(flow.binding.vnPhone), - }, now) + await createTaskEvent( + task.id, + 'kuaishou_cloud_number_returned', + { + vnId: flow.binding.vnId, + vnPhoneMasked: maskPhone(flow.binding.vnPhone), + }, + now, + ) await createTaskEvent( task.id, - consumeStatus === 'success' ? 'kuaishou_cloud_consumed' : 'kuaishou_cloud_consume_failed', + consumeAlreadyCompleted + ? 'kuaishou_cloud_consume_already_completed' + : consumeStatus === 'success' + ? 'kuaishou_cloud_consumed' + : 'kuaishou_cloud_consume_failed', { ticketCodeMasked: maskCode(ticketCode), shopId, diff --git a/apps/backend/src/services/admin/write/kuaishou-cloud-helpers.ts b/apps/backend/src/services/admin/write/kuaishou-cloud-helpers.ts index 1400f423..3358e966 100644 --- a/apps/backend/src/services/admin/write/kuaishou-cloud-helpers.ts +++ b/apps/backend/src/services/admin/write/kuaishou-cloud-helpers.ts @@ -1,7 +1,7 @@ import { createHttpError } from '../../../utils/http.js' import type { HttpErrorLike } from '../../../utils/http.js' import { - resolvePersistedCloudtentaclesContext as resolveFulfillmentPersistedCloudtentaclesContext, + resolvePersistedCloudtentaclesContextBySourceKeys, } from '../../fulfillment/kuaishou-cloud/cloudtentacles-context.js' export { @@ -23,6 +23,7 @@ export type CloudtentaclesContext = { token: string deviceId: string deviceType: number + resolvedSourceKey: string } export type CloudSkuLikeItem = { @@ -62,9 +63,9 @@ export type PreparedKuaishouCloudBindResource = { bindUrl: string } -export function resolvePersistedCloudtentaclesContext(): CloudtentaclesContext { +export function resolvePersistedCloudtentaclesContext(sourceKeys: unknown[]): CloudtentaclesContext { try { - return resolveFulfillmentPersistedCloudtentaclesContext() + return resolvePersistedCloudtentaclesContextBySourceKeys(sourceKeys) } catch (error) { if (isMissingCloudtentaclesTokenError(error)) { throw createHttpError('当前 cloudtentacles 没有可用 token,请先到平台配置完成登录校验', { diff --git a/apps/backend/src/services/admin/write/task-actions.ts b/apps/backend/src/services/admin/write/task-actions.ts index b65f6ad3..790b5bad 100644 --- a/apps/backend/src/services/admin/write/task-actions.ts +++ b/apps/backend/src/services/admin/write/task-actions.ts @@ -1,520 +1,79 @@ -import { - markInventoryItemDelivered, - releaseReservedInventoryItem, -} from '../../../repositories/inventory-repo.js' import { updateClaimToken } from '../../../repositories/claim-token-repo.js' -import { listOrderItemsByOrderId } from '../../../repositories/order-item-repo.js' -import { getOrderById } from '../../../repositories/order-repo.js' import { updateTask } from '../../../repositories/task-repo.js' -import { - getTaskInventoryBindingById, - listTaskInventoryBindingsByTaskId, -} from '../../../repositories/task-inventory-binding-repo.js' import { createTaskEvent } from '../../../repositories/task-event-repo.js' -import { createTaskClaimToken } from '../../claim/claim-service.js' -import { confirmClaimRoleForAdminTask, redeemClaimTaskForAdminTask } from '../../claim/claim-session-service.js' -import { reserveInventoryForTask } from '../../order/inventory-service.js' -import { ensureAgisoXianyuAutoDeliveryForDeliveredTask } from '../../platforms/agiso/xianyu/auto-delivery-service.js' -import { closeTencentBrowserSession } from '../../session/session.js' import { createHttpError } from '../../../utils/http.js' import { nowIso } from '../../../utils/time.js' +import { TASK_STATUS } from '../../../domain/task-status.js' import { + canViewerCloseTask, createAdminViewerContext, - getTaskPrimaryClaimTokenId, - getTaskPrimaryInventoryItemId, - isAssistedClaimTask, - isManualDispatchTask, - parseTaskContext, } from '../admin-read-shared-helpers.js' import { getRequiredTask, mapTaskActionPayload, } from '../admin-task-read-helpers.js' -import { - ensureViewerCanOperateAssistedTask, - getTaskClaimExpiresAt, - isRecoverableTaskSessionCloseError, - maskCode, - normalizeManualDispatchOutcome, - resolveTaskInventoryGroupCodes, -} from './shared.js' import type { AdminEntityIdInput, AdminViewerSessionInput, -} from '../../../types/admin-read-inputs.js' -import type { AdminTaskManualDispatchInput } from '../../../types/admin-write-inputs.js' -import type { - AdminTaskActionResponse, - AdminTaskBindingReleaseResponse, - AdminTaskManualDispatchResponse, -} from '../../../types/admin-write-models.js' -import type { TaskRow } from '../../../types/repository-rows.js' -import type { TaskUpdatePatch } from '../../../types/repository-inputs.js' +} from '../../../types/admin/read-inputs.js' +import type { AdminTaskActionResponse } from '../../../types/admin/write-models.js' -type CloseAdminTaskDeps = { - getRequiredTask?: (taskId: AdminEntityIdInput) => Promise - listTaskInventoryBindingsByTaskId?: (taskId: AdminEntityIdInput) => Promise - releaseReservedInventoryItem?: (inventoryItemId: AdminEntityIdInput, updatedAt: string) => Promise - updateClaimToken?: (tokenId: AdminEntityIdInput, patch: Record) => Promise - updateTask?: (taskId: AdminEntityIdInput, patch: TaskUpdatePatch) => Promise - createTaskEvent?: (taskId: AdminEntityIdInput, eventType: string, payload: Record, createdAt: string) => Promise - closeTencentBrowserSession?: (sessionId: string) => Promise - nowIso?: () => string -} - -export async function releaseAdminTaskInventory(taskId: AdminEntityIdInput): Promise { - const task = await getRequiredTask(taskId) - const primaryInventoryItemId = getTaskPrimaryInventoryItemId(task) - - if (!primaryInventoryItemId) { - throw createHttpError('当前任务没有预占库存项', { - statusCode: 409, - errorCode: 'admin_task_no_reserved_inventory', - }) - } - - if (task.task_status === 'redeemed') { - throw createHttpError('已兑换任务不能释放库存项', { - statusCode: 409, - errorCode: 'admin_task_release_not_allowed', - }) - } - - const now = nowIso() - await releaseReservedInventoryItem(primaryInventoryItemId, now) - const updatedTask = await updateTask(task.id, { - task_status: 'waiting_inventory', - inventory_status: 'pending', - last_error: '已手动释放预占库存项', - updated_at: now, - }) - - return { - task: mapTaskActionPayload(updatedTask), - } -} - -export async function releaseAdminTaskInventoryBinding( - taskId: AdminEntityIdInput, - bindingId: AdminEntityIdInput, -): Promise { - const task = await getRequiredTask(taskId) - const binding = await getTaskInventoryBindingById(Number(bindingId)) - - if (!binding || Number(binding.task_id) !== Number(task.id)) { - throw createHttpError('任务库存绑定不存在', { - statusCode: 404, - errorCode: 'admin_task_inventory_binding_not_found', - }) - } - - if (String(binding.binding_status || '').trim() !== 'reserved') { - throw createHttpError('当前库存绑定不是预占状态,不能释放', { - statusCode: 409, - errorCode: 'admin_task_inventory_binding_release_not_allowed', - }) - } - - if (task.task_status === 'redeemed') { - throw createHttpError('已兑换任务不能释放库存绑定', { - statusCode: 409, - errorCode: 'admin_task_release_not_allowed', - }) - } - - const now = nowIso() - await releaseReservedInventoryItem(binding.inventory_item_id, now) - - const remainingBindings = await listTaskInventoryBindingsByTaskId(task.id) - const activeBindings = remainingBindings.filter((item) => ['reserved', 'consumed'].includes(String(item.binding_status || '').trim())) - const hasReservedBindings = activeBindings.some((item) => String(item.binding_status || '').trim() === 'reserved') - const hasConsumedBindings = activeBindings.some((item) => String(item.binding_status || '').trim() === 'consumed') - const nextInventoryStatus = hasReservedBindings ? 'reserved' : (hasConsumedBindings ? 'consumed' : 'pending') - const nextTaskStatus = !activeBindings.length && !['closed', 'expired'].includes(String(task.task_status || '').trim()) - ? 'waiting_inventory' - : task.task_status - - if (!hasReservedBindings) { - const primaryClaimTokenId = getTaskPrimaryClaimTokenId(task) - if (primaryClaimTokenId) { - await updateClaimToken(primaryClaimTokenId, { - status: 'revoked', - updated_at: now, - }) - } - } - - const updatedTask = await updateTask(task.id, { - task_status: nextTaskStatus, - inventory_status: nextInventoryStatus, - last_error: !activeBindings.length ? '已手动释放预占库存绑定' : (task.last_error || ''), - updated_at: now, - }) - - await createTaskEvent(task.id, 'inventory_binding_released', { - bindingId: Number(binding.id), - inventoryItemId: Number(binding.inventory_item_id), - roleKey: String(binding.role_key || '').trim(), - }, now) - - return { - task: mapTaskActionPayload(updatedTask), - bindingId: Number(binding.id), - inventoryItemId: Number(binding.inventory_item_id), - } -} - -export async function regenerateAdminTaskClaimLink( - taskId: AdminEntityIdInput, - session: AdminViewerSessionInput | null = null, -): Promise { - const task = await getRequiredTask(taskId) - const primaryClaimTokenId = getTaskPrimaryClaimTokenId(task) - const viewerContext = createAdminViewerContext(session) - - if (isManualDispatchTask(task)) { - throw createHttpError('人工履约任务不需要领取链接,请直接回写人工履约结果', { - statusCode: 409, - errorCode: 'admin_task_manual_dispatch_claim_not_allowed', - }) - } - - if (['redeemed', 'closed'].includes(task.task_status)) { - throw createHttpError('当前任务状态不允许重新生成领取链接', { - statusCode: 409, - errorCode: 'admin_task_regenerate_not_allowed', - }) - } - - if (!viewerContext.canManageTaskLifecycle && !isAssistedClaimTask(task)) { - throw createHttpError('当前账号只能重发半自动客服任务的领取链接', { - statusCode: 403, - errorCode: 'admin_task_regenerate_permission_denied', - }) - } - - const now = nowIso() - if (primaryClaimTokenId) { - await updateClaimToken(primaryClaimTokenId, { - status: 'revoked', - updated_at: now, - }) - } - - const claimToken = await createTaskClaimToken(task.id) - const updatedTask = await updateTask(task.id, { - claim_token: claimToken.token, - claim_expires_at: claimToken.expired_at, - task_status: 'link_generated', - last_error: '', - updated_at: now, - }) - - return { - task: mapTaskActionPayload(updatedTask), - claimUrl: claimToken.claimUrl, - token: claimToken.token, - } -} - -export async function confirmAdminTaskAssistedRole( +export async function closeAdminTask( taskId: AdminEntityIdInput, session: AdminViewerSessionInput | null = null, ): Promise { const task = await getRequiredTask(taskId) const viewerContext = createAdminViewerContext(session) - ensureViewerCanOperateAssistedTask(task, viewerContext, 'confirm') - await confirmClaimRoleForAdminTask(task.id) - const updatedTask = await getRequiredTask(task.id) - return { - task: mapTaskActionPayload(updatedTask), - } -} - -export async function redeemAdminTaskAssisted( - taskId: AdminEntityIdInput, - session: AdminViewerSessionInput | null = null, -): Promise { - const task = await getRequiredTask(taskId) - const viewerContext = createAdminViewerContext(session) - ensureViewerCanOperateAssistedTask(task, viewerContext, 'redeem') - await redeemClaimTaskForAdminTask(task.id) - const updatedTask = await getRequiredTask(task.id) - - return { - task: mapTaskActionPayload(updatedTask), - } -} - -export async function closeAdminTask(taskId: AdminEntityIdInput): Promise { - return closeAdminTaskWithDeps(taskId) -} - -export async function closeAdminTaskWithDeps( - taskId: AdminEntityIdInput, - { - getRequiredTask: getTask = getRequiredTask, - listTaskInventoryBindingsByTaskId: listBindings = listTaskInventoryBindingsByTaskId, - releaseReservedInventoryItem: releaseReserved = releaseReservedInventoryItem, - updateClaimToken: updateToken = updateClaimToken, - updateTask: updateTaskRecord = updateTask, - createTaskEvent: createEvent = createTaskEvent, - closeTencentBrowserSession: closeSession = closeTencentBrowserSession, - nowIso: getNowIso = nowIso, - }: CloseAdminTaskDeps = {}, -): Promise { - const task = await getTask(taskId) - - if (task.task_status === 'redeemed') { - throw createHttpError('已兑换任务不能关闭', { + if (!canViewerCloseTask(task, viewerContext)) { + throw createHttpError('当前任务不可关闭', { statusCode: 409, errorCode: 'admin_task_close_not_allowed', }) } - const now = getNowIso() - const bindings = await listBindings(task.id) - const reservedBindings = bindings.filter((binding) => String(binding.binding_status || '').trim() === 'reserved') - const releasedInventoryItemIds = Array.from(new Set(reservedBindings.map((binding) => Number(binding.inventory_item_id)).filter((id) => id > 0))) - const hasConsumedBindings = bindings.some((binding) => String(binding.binding_status || '').trim() === 'consumed') - const primaryClaimTokenId = getTaskPrimaryClaimTokenId(task) - let browserSessionClosed = false + const now = nowIso() + const claimTokenId = Number(task.primary_claim_token_id || 0) - if (primaryClaimTokenId) { - await updateToken(primaryClaimTokenId, { - status: 'revoked', + if (claimTokenId > 0 && String(task.primary_claim_token_status || '').trim() !== 'closed') { + await updateClaimToken(claimTokenId, { + status: 'closed', expired_at: now, updated_at: now, }) } - if (task.browser_session_id) { - try { - await closeSession(task.browser_session_id) - browserSessionClosed = true - } catch (error) { - if (!isRecoverableTaskSessionCloseError(error)) { - throw error - } - } - } - - for (const inventoryItemId of releasedInventoryItemIds) { - await releaseReserved(inventoryItemId, now) - } - - const closeReasonParts = ['已手动关闭任务'] - if (primaryClaimTokenId) { - closeReasonParts.push('领取链接已失效') - } - if (releasedInventoryItemIds.length > 0) { - closeReasonParts.push('预占库存已释放') - } - - const updatedTask = await updateTaskRecord(task.id, { - task_status: 'closed', - inventory_status: hasConsumedBindings ? 'consumed' : 'pending', + const updatedTask = await updateTask(task.id, { + task_status: TASK_STATUS.CLOSED, delivery_status: 'closed', + result_code: 'closed_by_admin', + result_message: '任务已关闭,领取链接已失效', user_action_status: 'closed', - claim_expires_at: primaryClaimTokenId ? now : task.claim_expires_at, - browser_session_id: '', - last_error: task.last_error || closeReasonParts.join(','), + last_error: '', + claim_expires_at: now, updated_at: now, }) - await createEvent(task.id, 'task_closed', { - claimTokenRevoked: Boolean(primaryClaimTokenId), - releasedInventoryItemIds, - releasedInventoryCount: releasedInventoryItemIds.length, - browserSessionClosed, - }, now) - - return { - task: mapTaskActionPayload(updatedTask), - } -} - -export async function markAdminTaskManualReview(taskId: AdminEntityIdInput): Promise { - const task = await getRequiredTask(taskId) - const updatedTask = await updateTask(task.id, { - task_status: 'manual_review', - delivery_status: task.delivery_status || 'pending', - last_error: task.last_error || '已转人工处理', - updated_at: nowIso(), - }) - - return { - task: mapTaskActionPayload(updatedTask), - } -} - -export async function retryAdminTask(taskId: AdminEntityIdInput): Promise { - const task = await getRequiredTask(taskId) - const now = nowIso() - - if (isManualDispatchTask(task)) { - throw createHttpError('人工履约任务不能走自动重试,请在详情页直接回写人工履约结果', { - statusCode: 409, - errorCode: 'admin_task_manual_dispatch_retry_not_allowed', + if (!updatedTask) { + throw createHttpError('任务关闭失败', { + statusCode: 500, + errorCode: 'admin_task_close_failed', }) } - if (!['retry_pending', 'manual_review', 'waiting_inventory'].includes(task.task_status)) { - throw createHttpError('当前任务状态不允许重试', { - statusCode: 409, - errorCode: 'admin_task_retry_not_allowed', - }) - } - - const taskContext = parseTaskContext(task) - const primaryRequirement = taskContext.primaryRequirement || null - const orderItems = await listOrderItemsByOrderId(task.order_id) - const orderItem = orderItems.find((item) => item.id === task.order_item_id) || null - let reservedInventoryItemId = getTaskPrimaryInventoryItemId(task) - let claimTokenId = getTaskPrimaryClaimTokenId(task) - let nextStatus = 'link_generated' - let lastError = '' - let claimExpiresAt = getTaskClaimExpiresAt(task) - let claimUrl = '' - let token = '' - - if (!reservedInventoryItemId) { - const reserved = await reserveInventoryForTask({ - skuCode: orderItem?.sku_code || '', - taskId: task.id, - credentialType: primaryRequirement?.credentialType || 'tencent_code', - roleKey: primaryRequirement?.roleKey || 'primary_code', - inventoryGroupCodes: resolveTaskInventoryGroupCodes(task), - }) - - if (!reserved) { - nextStatus = 'waiting_inventory' - lastError = '库存不足,等待可用库存凭据' - } else { - reservedInventoryItemId = reserved.id - } - } - - if (nextStatus === 'link_generated' && !claimTokenId) { - const claimToken = await createTaskClaimToken(task.id) - claimTokenId = claimToken.id - claimExpiresAt = claimToken.expired_at - claimUrl = claimToken.claimUrl - token = claimToken.token - } - - const updatedTask = await updateTask(task.id, { - task_status: nextStatus, - inventory_status: reservedInventoryItemId ? 'reserved' : 'pending', - claim_token: token || task.claim_token || '', - claim_expires_at: claimExpiresAt, - last_error: lastError, - updated_at: now, - }) - - const response: AdminTaskActionResponse = { - task: mapTaskActionPayload(updatedTask), - } - - if (claimUrl) { - response.claimUrl = claimUrl - response.token = token - } - - return response -} - -export async function completeAdminTaskManualDispatch( - taskId: AdminEntityIdInput, - payload: AdminTaskManualDispatchInput = {}, - session: AdminViewerSessionInput | null = null, -): Promise { - const task = await getRequiredTask(taskId) - - if (!isManualDispatchTask(task)) { - throw createHttpError('当前任务不是人工履约任务', { - statusCode: 409, - errorCode: 'admin_task_not_manual_dispatch', - }) - } - - if (['redeemed', 'closed'].includes(task.task_status)) { - throw createHttpError('当前任务已经完结,不能重复回写人工履约结果', { - statusCode: 409, - errorCode: 'admin_task_manual_dispatch_already_completed', - }) - } - - const now = nowIso() - const outcome = normalizeManualDispatchOutcome(payload.outcome) - const resultMessage = String(payload.resultMessage || '').trim() - const deliveryReference = String(payload.deliveryReference || '').trim() - const deliveredCredential = String(payload.deliveredCredential || '').trim() - const context = parseTaskContext(task) - const inventoryItemId = getTaskPrimaryInventoryItemId(task) - const resultCode = outcome === 'failed' ? 'manual_dispatch_failed' : 'manual_dispatch_delivered' - const fallbackMessage = outcome === 'failed' ? '人工履约失败' : '人工履约已完成' - const nextTaskStatus = outcome === 'failed' ? 'closed' : 'redeemed' - const nextDeliveryStatus = outcome === 'failed' ? 'failed' : 'delivered' - const nextInventoryStatus = outcome === 'delivered' && inventoryItemId - ? 'consumed' - : task.inventory_status || 'not_required' - const manualDispatch = { - outcome, - deliveryReference, - deliveredCredential, - resultMessage: resultMessage || fallbackMessage, - completedAt: now, - completedBy: session + await createTaskEvent(task.id, 'task_closed', { + closedBy: session ? { - userId: Number(session.userId || 0), - username: String(session.username || ''), - role: String(session.role || ''), + userId: session.userId, + username: session.username, + role: session.role, } : null, - } - - if (outcome === 'delivered' && inventoryItemId) { - await markInventoryItemDelivered(inventoryItemId, now) - } - - const updatedTask = await updateTask(task.id, { - task_status: nextTaskStatus, - inventory_status: nextInventoryStatus, - delivery_status: nextDeliveryStatus, - result_code: resultCode, - result_message: resultMessage || fallbackMessage, - user_action_status: 'not_required', - last_error: outcome === 'failed' ? (resultMessage || fallbackMessage) : '', - redeemed_at: outcome === 'delivered' ? now : task.redeemed_at || null, - context_json: JSON.stringify({ - ...context, - manualDispatch, - }), - updated_at: now, - }) - - await createTaskEvent(task.id, 'manual_dispatch_completed', { - outcome, - resultCode, - resultMessage: resultMessage || fallbackMessage, - deliveryReference, - deliveredCredentialMasked: maskCode(deliveredCredential), - completedBy: manualDispatch.completedBy, + claimTokenClosed: claimTokenId > 0, }, now) - const taskAfterAutoDelivery = outcome === 'delivered' - ? (await ensureAgisoXianyuAutoDeliveryForDeliveredTask({ - order: await getOrderById(task.order_id), - task: updatedTask, - trigger: 'manual_dispatch_completed', - })).task || updatedTask - : updatedTask - return { - outcome, - task: mapTaskActionPayload(taskAfterAutoDelivery), + task: mapTaskActionPayload(updatedTask), } } diff --git a/apps/backend/src/services/admin/write/webhook-events.ts b/apps/backend/src/services/admin/write/webhook-events.ts deleted file mode 100644 index b0f7bdef..00000000 --- a/apps/backend/src/services/admin/write/webhook-events.ts +++ /dev/null @@ -1,32 +0,0 @@ -import { getWebhookEventById } from '../../../repositories/webhook-event-repo.js' -import { replayAgisoTradeWebhookEvent } from '../../order/webhook-service.js' -import { createHttpError } from '../../../utils/http.js' - -import type { AdminEntityIdInput } from '../../../types/admin-read-inputs.js' -import type { AdminWebhookReplayResponse } from '../../../types/admin-write-models.js' - -export async function replayAdminWebhookEvent(eventId: AdminEntityIdInput): Promise { - const event = await getWebhookEventById(Number(eventId)) - - if (!event) { - throw createHttpError('Webhook 事件不存在', { - statusCode: 404, - errorCode: 'admin_webhook_not_found', - }) - } - - if (String(event.provider || event.platform || '').trim() !== 'agiso') { - throw createHttpError('当前只支持重放 agiso webhook', { - statusCode: 409, - errorCode: 'admin_webhook_replay_not_supported', - }) - } - - const result = await replayAgisoTradeWebhookEvent(event) - - return { - eventId: event.id, - replayed: true, - result, - } -} diff --git a/apps/backend/src/services/bootstrap/fulfillment-bootstrap-service.test.ts b/apps/backend/src/services/bootstrap/fulfillment-bootstrap-service.test.ts deleted file mode 100644 index 0fb21e92..00000000 --- a/apps/backend/src/services/bootstrap/fulfillment-bootstrap-service.test.ts +++ /dev/null @@ -1,26 +0,0 @@ -import test from 'node:test' -import assert from 'node:assert/strict' - -import { resolveBindingMatchShopIds } from './fulfillment-bootstrap-service.js' - -test('resolveBindingMatchShopIds returns configured shop id for 91kaquan kuaishou bindings', () => { - assert.deepEqual( - resolveBindingMatchShopIds({ - provider: '91kaquan', - platform: 'kuaishou', - shopId: '91kaquan', - }), - ['91kaquan'], - ) -}) - -test('resolveBindingMatchShopIds preserves wildcard rules for empty shop id', () => { - assert.deepEqual( - resolveBindingMatchShopIds({ - provider: 'agiso', - platform: 'xianyu', - shopId: '', - }), - [''], - ) -}) diff --git a/apps/backend/src/services/bootstrap/fulfillment-bootstrap-service.ts b/apps/backend/src/services/bootstrap/fulfillment-bootstrap-service.ts index 8779d538..8f514764 100644 --- a/apps/backend/src/services/bootstrap/fulfillment-bootstrap-service.ts +++ b/apps/backend/src/services/bootstrap/fulfillment-bootstrap-service.ts @@ -1,21 +1,23 @@ import { - getFulfillmentProfileByKey, listFulfillmentProfileRequirements, replaceFulfillmentProfileRequirements, upsertFulfillmentProfile, - upsertSkuFulfillmentBinding, + type FulfillmentProfileRequirementInput, } from '../../repositories/fulfillment-profile-repo.js' -import { upsertProductMatchRule } from '../../repositories/product-match-rule-repo.js' -import { normalizeProductName } from '../order/product-match-service.js' -import { query } from '../../db/client.js' -import { getLegacyOrderFulfillmentBindingConfigs } from '../order/fulfillment-binding-config-service.js' -import { - getKuaishouCloudFulfillmentConfig, - mapKuaishouCloudFulfillmentItemsToBindings, -} from '../order/kuaishou-cloud-fulfillment-config-service.js' import { nowIso } from '../../utils/time.js' -const CORE_PROFILES = [ +type JsonObject = Record +type CoreProfile = { + profileKey: string + name: string + executorKey: string + requiresClaim: boolean + autoDispatch: boolean + inventoryStrategy: string + requirements: FulfillmentProfileRequirementInput[] +} + +const CORE_PROFILES: CoreProfile[] = [ { profileKey: 'manual_review', name: '人工发货', @@ -25,38 +27,6 @@ const CORE_PROFILES = [ inventoryStrategy: 'static_pool', requirements: [], }, - { - profileKey: 'tencent_claim_redeem', - name: '腾讯领取兑换', - executorKey: 'tencent_claim_redeem', - requiresClaim: true, - autoDispatch: false, - inventoryStrategy: 'static_pool', - requirements: [ - { - roleKey: 'primary_code', - credentialType: 'tencent_code', - quantityPerUnit: 1, - isRequired: true, - }, - ], - }, - { - profileKey: 'tencent_claim_assisted', - name: '腾讯领取兑换(半自动+人工)', - executorKey: 'tencent_claim_assisted', - requiresClaim: true, - autoDispatch: false, - inventoryStrategy: 'static_pool', - requirements: [ - { - roleKey: 'primary_code', - credentialType: 'tencent_code', - quantityPerUnit: 1, - isRequired: true, - }, - ], - }, { profileKey: 'kuaishou_ct_assisted', name: '快手 cloud 履约', @@ -68,16 +38,13 @@ const CORE_PROFILES = [ }, ] -type JsonObject = Record - export async function ensureFulfillmentCatalogBootstrapped() { - const profileMap = await ensureCoreProfiles() - await syncConfiguredFulfillmentBindings(profileMap) + await ensureCoreProfiles() } async function ensureCoreProfiles() { const timestamp = nowIso() - const profileMap = {} + const profileMap: JsonObject = {} for (const profile of CORE_PROFILES) { const saved = await upsertFulfillmentProfile({ @@ -105,85 +72,3 @@ async function ensureCoreProfiles() { return profileMap } - -export async function syncConfiguredFulfillmentBindings(profileMap: JsonObject = {}) { - const timestamp = nowIso() - const bindingsToApply = [ - ...getLegacyOrderFulfillmentBindingConfigs(), - ...mapKuaishouCloudFulfillmentItemsToBindings(getKuaishouCloudFulfillmentConfig()), - ] - - await query('DELETE FROM product_match_rules') - await query('DELETE FROM sku_fulfillment_bindings') - - for (const binding of bindingsToApply) { - const profile = profileMap[binding.profileKey] || await getFulfillmentProfileByKey(binding.profileKey) - if (!profile) { - continue - } - - const bindingConfig = resolveBindingRuntimeConfig(binding) - const matchShopIds = resolveBindingMatchShopIds(binding) - const match = binding.match || {} - const externalSkuName = String(match.externalSkuName || '').trim() - const externalItemId = String(match.externalItemId || '').trim() - const externalSkuCode = String(match.externalSkuCode || '').trim() - - for (const matchShopId of matchShopIds) { - await upsertSkuFulfillmentBinding({ - skuCode: binding.skuCode, - provider: binding.provider, - platform: binding.platform, - shopId: matchShopId, - profileId: profile.id, - enabled: binding.enabled, - priority: binding.priority, - configJson: JSON.stringify(bindingConfig), - createdAt: timestamp, - updatedAt: timestamp, - }) - - if (!externalSkuName && !externalItemId && !externalSkuCode) { - continue - } - - await upsertProductMatchRule({ - provider: binding.provider, - platform: binding.platform, - shopId: matchShopId, - externalItemId, - externalSkuCode, - externalSkuName, - externalSkuNameNormalized: normalizeProductName(externalSkuName), - resolvedSkuCode: binding.skuCode, - enabled: binding.enabled, - priority: binding.priority, - configJson: JSON.stringify(match.config || {}), - createdAt: timestamp, - updatedAt: timestamp, - }) - } - } -} - -export function resolveBindingMatchShopIds(binding: JsonObject = {}) { - return [String(binding.shopId || '').trim()] -} - -function resolveBindingRuntimeConfig(binding: JsonObject = {}) { - const baseConfig = isPlainObject(binding.config) ? { ...binding.config } : {} - - if (String(binding.provider || '').trim() === '91kaquan' && String(binding.platform || '').trim() === 'kuaishou') { - baseConfig.kuaishouShop = { - ...(isPlainObject(baseConfig.kuaishouShop) ? baseConfig.kuaishouShop : {}), - shopId: String(binding.shopId || '').trim(), - shopName: String(binding.shopName || '').trim(), - } - } - - return baseConfig -} - -function isPlainObject(value) { - return Object.prototype.toString.call(value) === '[object Object]' -} diff --git a/apps/backend/src/services/claim/claim-service.ts b/apps/backend/src/services/claim/claim-service.ts index 5c32645e..b45951b5 100644 --- a/apps/backend/src/services/claim/claim-service.ts +++ b/apps/backend/src/services/claim/claim-service.ts @@ -1,5 +1,6 @@ import { runtimeConfig } from '../../config/runtime.js' import { createClaimToken } from '../../repositories/claim-token-repo.js' +import { createHttpError } from '../../utils/http.js' import { addHours, nowIso } from '../../utils/time.js' import { randomToken } from '../../utils/random.js' @@ -23,7 +24,10 @@ export async function createTaskClaimToken(taskId: number | string): Promise { - const events = [] - const invalidated = [] - const reserveCalls = [] - const reloadCalls = [] - - const context = { - task: { - id: 42, - browser_session_id: 'txbs-test', - }, - orderItem: { - sku_code: 'df-cdk', - }, - } - const initialInventoryItem = { - id: 1, - display_value: 'DJQFf7HgONCL4XD4EH', - credential_type: 'tencent_code', - inventory_group_code: 'A组', - } - const replacementInventoryItem = { - id: 2, - display_value: 'ABCD1234EFGH5678', - credential_type: 'tencent_code', - inventory_group_code: 'A组', - } - - let redeemCount = 0 - const result = await redeemClaimTaskWithInventoryFallbackWithDeps( - context, - initialInventoryItem, - { - reloadTencentBrowserSession: async (sessionId) => { - reloadCalls.push(sessionId) - return { sessionId, status: 'ready_to_redeem' } - }, - redeemTencentBrowserSession: async (sessionId, payload) => { - redeemCount += 1 - if (redeemCount === 1) { - assert.equal(sessionId, 'txbs-test') - assert.equal(payload.code, 'DJQFf7HgONCL4XD4EH') - return { - redeem: { - final: { - redeem: { - iRet: -183, - sMsg: '该CDKEY不存在,请您确认后输入!', - }, - }, - }, - } - } - - assert.equal(payload.code, 'ABCD1234EFGH5678') - return { - redeem: { - final: { - redeem: { - iRet: 0, - sMsg: '领取成功', - }, - }, - }, - } - }, - invalidateReservedInventoryItem: async (inventoryItemId, reason, updatedAt) => { - invalidated.push({ inventoryItemId, reason, updatedAt }) - return { id: inventoryItemId, status: 'invalid', invalid_reason: reason, updated_at: updatedAt } - }, - reserveInventoryForTask: async (payload) => { - reserveCalls.push(payload) - return replacementInventoryItem - }, - createTaskEvent: async (taskId, eventType, payload, createdAt) => { - events.push({ taskId, eventType, payload, createdAt }) - }, - nowIso: (() => { - const values = [ - '2026-04-14T12:03:34.000Z', - '2026-04-14T12:03:35.000Z', - ] - let index = 0 - return () => values[index++] || values[values.length - 1] - })(), - }, - ) - - assert.equal(result.inventoryItem.id, 2) - assert.equal(result.classification.outcome, 'success') - assert.equal(result.attempts.length, 2) - assert.deepEqual( - result.attempts.map((attempt) => ({ - attempt: attempt.attempt, - inventoryItemId: attempt.inventoryItemId, - outcome: attempt.outcome, - resultCode: attempt.resultCode, - })), - [ - { - attempt: 1, - inventoryItemId: 1, - outcome: 'code_invalid', - resultCode: '-183', - }, - { - attempt: 2, - inventoryItemId: 2, - outcome: 'success', - resultCode: '0', - }, - ], - ) - assert.deepEqual(invalidated, [ - { - inventoryItemId: 1, - reason: '该CDKEY不存在,请您确认后输入!', - updatedAt: '2026-04-14T12:03:34.000Z', - }, - ]) - assert.deepEqual(reserveCalls, [ - { - skuCode: 'df-cdk', - taskId: 42, - credentialType: 'tencent_code', - roleKey: 'primary_code', - inventoryGroupCodes: ['A组'], - }, - ]) - assert.deepEqual(reloadCalls, ['txbs-test']) - assert.deepEqual( - events.map((event) => ({ - eventType: event.eventType, - taskId: event.taskId, - createdAt: event.createdAt, - payload: event.payload, - })), - [ - { - eventType: 'claim_redeem_code_invalid', - taskId: 42, - createdAt: '2026-04-14T12:03:34.000Z', - payload: { - inventoryItemId: 1, - codeMasked: 'DJQF****D4EH', - resultCode: '-183', - resultMessage: '该CDKEY不存在,请您确认后输入!', - }, - }, - { - eventType: 'claim_redeem_inventory_replaced', - taskId: 42, - createdAt: '2026-04-14T12:03:35.000Z', - payload: { - previousInventoryItemId: 1, - previousCodeMasked: 'DJQF****D4EH', - previousOutcome: 'code_invalid', - nextInventoryItemId: 2, - nextCodeMasked: 'ABCD****5678', - credentialType: 'tencent_code', - inventoryGroupCode: 'A组', - }, - }, - ], - ) -}) - -test('redeemClaimTaskWithInventoryFallbackWithDeps waits for inventory when used code has no replacement', async () => { - const events = [] - const consumed = [] - const reserveCalls = [] - - const context = { - task: { - id: 57, - browser_session_id: 'txbs-used', - }, - orderItem: { - sku_code: 'df-cdk-used', - }, - } - const initialInventoryItem = { - id: 7, - display_value: 'USED1234CODE5678', - credential_type: 'tencent_code', - inventory_group_code: 'B组', - } - - let thrown = null - try { - await redeemClaimTaskWithInventoryFallbackWithDeps( - context, - initialInventoryItem, - { - redeemTencentBrowserSession: async () => ({ - redeem: { - final: { - redeem: { - popup: { - text: '兑换结果', - detail: '兑换码已使用。', - }, - }, - }, - }, - }), - markInventoryItemConsumed: async (inventoryItemId, reason, updatedAt) => { - consumed.push({ inventoryItemId, reason, updatedAt }) - return { id: inventoryItemId, status: 'consumed', invalid_reason: reason, updated_at: updatedAt } - }, - reserveInventoryForTask: async (payload) => { - reserveCalls.push(payload) - return null - }, - createTaskEvent: async (taskId, eventType, payload, createdAt) => { - events.push({ taskId, eventType, payload, createdAt }) - }, - nowIso: () => '2026-04-14T12:05:00.000Z', - }, - ) - } catch (error) { - thrown = error - } - - assert.ok(thrown) - assert.equal(thrown.errorCode, 'claim_inventory_replacement_exhausted') - assert.equal(thrown.redeemTaskState.taskStatus, 'waiting_inventory') - assert.equal(thrown.redeemTaskState.inventoryStatus, 'pending') - assert.equal(thrown.redeemTaskState.deliveryStatus, 'pending') - assert.equal(thrown.redeemTaskState.attempts.length, 1) - assert.deepEqual(consumed, [ - { - inventoryItemId: 7, - reason: '兑换码已使用。', - updatedAt: '2026-04-14T12:05:00.000Z', - }, - ]) - assert.deepEqual(reserveCalls, [ - { - skuCode: 'df-cdk-used', - taskId: 57, - credentialType: 'tencent_code', - roleKey: 'primary_code', - inventoryGroupCodes: ['B组'], - }, - ]) - assert.deepEqual( - events.map((event) => ({ - taskId: event.taskId, - eventType: event.eventType, - payload: event.payload, - createdAt: event.createdAt, - })), - [ - { - taskId: 57, - eventType: 'claim_redeem_code_used', - payload: { - inventoryItemId: 7, - codeMasked: 'USED****5678', - resultCode: '', - resultMessage: '兑换码已使用。', - }, - createdAt: '2026-04-14T12:05:00.000Z', - }, - ], - ) -}) diff --git a/apps/backend/src/services/claim/claim-session-service.ts b/apps/backend/src/services/claim/claim-session-service.ts deleted file mode 100644 index aa0764e7..00000000 --- a/apps/backend/src/services/claim/claim-session-service.ts +++ /dev/null @@ -1,28 +0,0 @@ -export { - getClaimDetail, - getClaimDetailForAdminTask, - getClaimSessionSummary, - getClaimSessionSummaryForAdminTask, - confirmClaimRole, - confirmClaimRoleForAdminTask, - redeemClaimTask, - redeemClaimTaskForAdminTask, - getClaimScreenshotPath, -} from './session/actions.js' - -export { - createClaimSession, - createClaimSessionForAdminTask, - reloadClaimSession, - reloadClaimSessionForAdminTask, - closeClaimSession, - closeClaimSessionForAdminTask, -} from './session/lifecycle.js' - -export { - getClaimContext, -} from './session/context.js' - -export { - redeemClaimTaskWithInventoryFallbackWithDeps, -} from './session/redeem.js' diff --git a/apps/backend/src/services/claim/kuaishou-cloud-claim-context.ts b/apps/backend/src/services/claim/kuaishou-cloud-claim-context.ts new file mode 100644 index 00000000..dfa48539 --- /dev/null +++ b/apps/backend/src/services/claim/kuaishou-cloud-claim-context.ts @@ -0,0 +1,475 @@ +import { findClaimTokenByToken, updateClaimToken } from '../../repositories/claim-token-repo.js' +import { getOrderItemById } from '../../repositories/order-item-repo.js' +import { getOrderById } from '../../repositories/order-repo.js' +import { findTaskByClaimTokenId, updateTask } from '../../repositories/task-repo.js' +import { createHttpError } from '../../utils/http.js' +import { formatFenToAmount, normalizeFen } from '../../utils/money.js' +import { parseTaskContext as parseTaskContextValue } from '../../utils/task-json.js' +import { nowIso } from '../../utils/time.js' +import { TASK_STATUS, isTaskFinalStatus } from '../../domain/task-status.js' +import { buildClaimUrl } from './claim-service.js' +import type { ClaimTokenRow, OrderItemRow, OrderRow, TaskRow } from '../../types/repository/rows.js' + +export const CLAIM_TERMINAL_STATUSES = new Set([TASK_STATUS.EXPIRED, TASK_STATUS.CLOSED]) +export const KUAISHOU_CLOUD_CLAIM_GUIDE_BASE_PATH = '/kuaishou-cloud-guide' + +type JsonObject = Record +type ClaimContext = { + claimToken: ClaimTokenRow + task: TaskRow + order: OrderRow + orderItem: OrderItemRow +} + +export async function getClaimContext(token: unknown) { + const normalized = String(token || '').trim() + + if (!normalized) { + throw createHttpError('缺少领取 token', { + statusCode: 400, + errorCode: 'missing_claim_token', + }) + } + + const claimToken = await findClaimTokenByToken(normalized) + + if (!claimToken) { + throw createHttpError('领取链接无效或不存在', { + statusCode: 404, + errorCode: 'claim_token_not_found', + }) + } + + const task = await findTaskByClaimTokenId(claimToken.id) + + if (!task) { + throw createHttpError('领取任务不存在', { + statusCode: 404, + errorCode: 'claim_task_not_found', + }) + } + + if (claimToken.status !== 'active') { + throw createHttpError('领取链接当前不可用', { + statusCode: 410, + errorCode: 'claim_token_inactive', + }) + } + + if (claimToken.expired_at && new Date(claimToken.expired_at).getTime() <= Date.now()) { + const expiredContext = await expireClaimContext(claimToken, task) + + throw createHttpError('领取链接已过期', { + statusCode: 410, + errorCode: 'claim_token_expired', + context: expiredContext, + }) + } + + const [order, orderItem] = await Promise.all([ + getOrderById(task.order_id), + getOrderItemById(task.order_item_id), + ]) + + if (!order || !orderItem) { + throw createHttpError('领取任务关联订单不完整', { + statusCode: 500, + errorCode: 'claim_order_incomplete', + }) + } + + return { + claimToken, + task, + order, + orderItem, + } +} + +export function buildClaimDetailPayload({ claimToken, task, order, orderItem }: ClaimContext) { + const kuaishouCloudSource = resolveClaimKuaishouCloudSource(task) + const kuaishouCloudFulfillment = mapClaimKuaishouCloudFulfillment(task, order) + const displaySkuName = resolveClaimOrderItemDisplaySkuName( + orderItem, + kuaishouCloudFulfillment, + kuaishouCloudSource, + ) + const product = buildClaimProductPayload(displaySkuName, orderItem, kuaishouCloudSource) + + return { + tokenStatus: claimToken.status, + claimUrl: buildClaimUrl(claimToken.token), + flowType: 'kuaishou_cloud', + task: { + taskId: task.id, + taskNo: task.task_no, + status: task.task_status, + executorKey: task.executor_key || '', + requiresSupportReview: false, + expiresAt: claimToken.expired_at, + claimedAt: task.claimed_at, + roleConfirmedAt: task.role_confirmed_at, + redeemedAt: task.redeemed_at, + loginType: task.login_type, + lastError: task.last_error, + runtimeSessionId: task.runtime_session_id, + }, + order: { + orderId: order.id, + platform: order.platform, + platformOrderId: order.platform_order_id, + payStatus: order.pay_status, + orderStatus: order.order_status, + totalAmount: formatFenToAmount(order.total_amount), + totalAmountFen: normalizeFen(order.total_amount), + currency: order.currency, + }, + orderItem: { + orderItemId: orderItem.id, + skuCode: orderItem.sku_code, + skuName: displaySkuName, + quantity: orderItem.quantity, + }, + product, + session: null as null, + kuaishouCloudFulfillment, + result: task.redeemed_at + ? { + resultCode: String(task.result_code || ''), + resultMessage: String(task.result_message || ''), + screenshotReady: false, + screenshotUrl: '', + } + : null, + } +} + +function resolveClaimOrderItemDisplaySkuName( + orderItem: OrderItemRow, + kuaishouCloudFulfillment: JsonObject | null, + kuaishouCloudSource: JsonObject = {}, +) { + const fulfillment = isPlainObject(kuaishouCloudFulfillment) ? kuaishouCloudFulfillment : {} + const binding = isPlainObject(fulfillment.binding) ? fulfillment.binding : {} + const ticket = isPlainObject(fulfillment.ticket) ? fulfillment.ticket : {} + const skuCode = String(orderItem.sku_code || '').trim() + const rawSkuName = String(orderItem.sku_name || '').trim() + const candidates = [ + kuaishouCloudSource.internalSkuName, + rawSkuName, + ticket.goodsTitle, + binding.skuName, + skuCode, + ] + + for (const value of candidates) { + const normalized = String(value || '').trim() + if (normalized && !isOrderItemIdentifierName(normalized, skuCode)) { + return normalized + } + } + + return rawSkuName || skuCode +} + +function buildClaimProductPayload( + displaySkuName: string, + orderItem: OrderItemRow, + kuaishouCloudSource: JsonObject = {}, +) { + const binding = isPlainObject(kuaishouCloudSource.binding) ? kuaishouCloudSource.binding : {} + const deliveryItems = normalizeClaimDeliveryItems(kuaishouCloudSource.deliveryItems, binding) + .map((item) => ({ + cloudSkuId: item.cloudSkuId, + name: item.cloudSkuName, + quantity: item.quantity, + })) + .filter((item) => item.name) + const normalizedDeliveryItems = deliveryItems.length > 0 + ? mergeClaimProductItems(deliveryItems) + : [{ + cloudSkuId: 0, + name: displaySkuName, + quantity: Math.max(1, Number(orderItem.quantity || 1) || 1), + }] + + return { + title: displaySkuName, + skuCode: String(orderItem.sku_code || '').trim(), + quantity: Math.max(1, Number(orderItem.quantity || 1) || 1), + isBundle: normalizedDeliveryItems.length > 1, + items: normalizedDeliveryItems, + } +} + +function mergeClaimProductItems( + items: Array<{ cloudSkuId: number; name: string; quantity: number }>, +) { + const merged = new Map() + + for (const item of items) { + const key = item.cloudSkuId > 0 ? `id:${item.cloudSkuId}` : `name:${item.name}` + const existing = merged.get(key) + if (existing) { + existing.quantity += item.quantity + existing.name = existing.name || item.name + continue + } + + merged.set(key, { ...item }) + } + + return Array.from(merged.values()) +} + +function resolveClaimKuaishouCloudSource(task: TaskRow): JsonObject { + if (String(task?.executor_key || '').trim() !== 'kuaishou_ct_assisted') { + return {} + } + + const source = parseTaskContext(task).kuaishouCloudFulfillment + return isPlainObject(source) ? source : {} +} + +export function mapClaimKuaishouCloudFulfillment(task: TaskRow, order: OrderRow) { + if (String(task?.executor_key || '').trim() !== 'kuaishou_ct_assisted') { + return null + } + + const source = parseTaskContext(task).kuaishouCloudFulfillment + if (!source || typeof source !== 'object') { + return null + } + + const binding = source.binding && typeof source.binding === 'object' ? source.binding : {} + const role = source.role && typeof source.role === 'object' ? source.role : {} + const purchase = source.purchase && typeof source.purchase === 'object' ? source.purchase : {} + const ticket = source.ticket && typeof source.ticket === 'object' ? source.ticket : {} + const dispatch = source.dispatch && typeof source.dispatch === 'object' ? source.dispatch : {} + const returnNumber = source.returnNumber && typeof source.returnNumber === 'object' ? source.returnNumber : {} + const consume = source.consume && typeof source.consume === 'object' ? source.consume : {} + const roleName = String(role.name || binding.roleName || '').trim() + const roleId = String(role.rid || binding.roleId || '').trim() + const defaultRoleName = String(role.defaultName || role.defaultRoleName || '').trim() + const defaultRoleId = String(role.defaultRid || role.defaultRoleId || '').trim() + const isDefaultRole = + Boolean(roleName || roleId) && + isSameClaimKuaishouCloudRole( + { name: roleName, rid: roleId }, + { name: defaultRoleName, rid: defaultRoleId }, + ) + + return { + flowType: 'kuaishou_cloud', + shopId: String(order?.shop_id || '').trim(), + shopName: String(order?.shop_name || '').trim(), + guideImages: [ + `${KUAISHOU_CLOUD_CLAIM_GUIDE_BASE_PATH}/1.png`, + `${KUAISHOU_CLOUD_CLAIM_GUIDE_BASE_PATH}/2.png`, + `${KUAISHOU_CLOUD_CLAIM_GUIDE_BASE_PATH}/3.png`, + ], + ticket: { + code: String(ticket.code || '').trim(), + status: String(ticket.status || 'pending').trim() || 'pending', + capturedAt: ticket.capturedAt || null, + verifiedAt: ticket.verifiedAt || null, + goodsTitle: String(ticket.goodsTitle || '').trim(), + leftCount: Number(ticket.leftCount || 0) || 0, + }, + binding: { + prepareStatus: String(binding.prepareStatus || 'pending').trim() || 'pending', + cloudSourceKeys: Array.isArray(binding.cloudSourceKeys) + ? binding.cloudSourceKeys.map((value: unknown) => String(value || '').trim()).filter(Boolean) + : [], + resolvedSourceKey: String(binding.resolvedSourceKey || '').trim(), + skuId: Number(binding.skuId || 0) || 0, + skuName: String(binding.skuName || '').trim(), + vnKey: String(binding.vnKey || '').trim(), + vnId: Number(binding.vnId || 0) || 0, + bindUrl: String(binding.bindUrl || '').trim(), + bindPreparedAt: binding.bindPreparedAt || null, + bindExpiresAt: binding.bindExpiresAt || null, + bindProbeAt: binding.bindProbeAt || null, + bindProbeStatus: String(binding.bindProbeStatus || '').trim(), + bindProbeMessage: String(binding.bindProbeMessage || '').trim(), + vnPhone: String(binding.vnPhone || '').trim(), + roleName: String(binding.roleName || '').trim(), + roleId: String(binding.roleId || '').trim(), + }, + role: { + status: String(role.status || 'pending').trim() || 'pending', + name: roleName, + rid: roleId, + refreshedAt: role.refreshedAt || null, + errorMessage: String(role.errorMessage || '').trim(), + defaultName: defaultRoleName, + defaultRid: defaultRoleId, + defaultCapturedAt: role.defaultCapturedAt || null, + defaultCaptureStatus: String(role.defaultCaptureStatus || '').trim(), + defaultErrorMessage: String(role.defaultErrorMessage || '').trim(), + isDefaultRole, + }, + purchase: { + autoBuyEnabled: purchase.autoBuyEnabled !== false, + minAssetReserve: Number(purchase.minAssetReserve || 0) || 0, + usedKnapsack: purchase.usedKnapsack === true, + purchaseTriggered: purchase.purchaseTriggered === true, + assetBefore: Number(purchase.assetBefore || 0) || 0, + assetAfter: Number(purchase.assetAfter || 0) || 0, + purchaseAt: purchase.purchaseAt || null, + }, + dispatch: { + status: String(dispatch.status || 'pending').trim() || 'pending', + dispatchAt: dispatch.dispatchAt || null, + failedAt: dispatch.failedAt || null, + failedStage: String(dispatch.failedStage || '').trim(), + errorCode: String(dispatch.errorCode || '').trim(), + errorMessage: String(dispatch.errorMessage || '').trim(), + note: String(dispatch.note || '').trim(), + }, + returnNumber: { + status: String(returnNumber.status || 'pending').trim() || 'pending', + returnedAt: returnNumber.returnedAt || null, + }, + consume: { + status: String(consume.status || 'pending').trim() || 'pending', + shopId: String(consume.shopId || '').trim(), + shopName: String(consume.shopName || '').trim(), + consumedAt: consume.consumedAt || null, + errorMessage: String(consume.errorMessage || '').trim(), + }, + } +} + +function parseTaskContext(task: TaskRow): JsonObject { + return parseTaskContextValue(task) +} + +function isOrderItemIdentifierName(value: string, skuCode: string) { + const normalized = String(value || '').trim() + if (!normalized) { + return true + } + + if (isClaimPlaceholderSkuName(normalized)) { + return true + } + + const normalizedSkuCode = String(skuCode || '').trim() + if (normalizedSkuCode && normalized === normalizedSkuCode && /^\d{8,}$/.test(normalizedSkuCode)) { + return true + } + + return /^\d{8,}$/.test(normalized) +} + +function isPlainObject(value: unknown): value is JsonObject { + return Object.prototype.toString.call(value) === '[object Object]' +} + +function isSameClaimKuaishouCloudRole( + left: { name?: unknown; rid?: unknown }, + right: { name?: unknown; rid?: unknown }, +) { + const leftRid = String(left.rid || '').trim() + const rightRid = String(right.rid || '').trim() + if (leftRid && rightRid) { + return leftRid === rightRid + } + + const leftName = normalizeClaimRoleNameForCompare(left.name) + const rightName = normalizeClaimRoleNameForCompare(right.name) + return Boolean(leftName && rightName && leftName === rightName) +} + +function normalizeClaimRoleNameForCompare(value: unknown) { + return String(value || '') + .trim() + .toLowerCase() + .replace(/\s+/g, '') +} + +function normalizeClaimDeliveryItems(value: unknown, binding: JsonObject) { + const rawItems = Array.isArray(value) ? value : [] + const items = rawItems + .map((item) => normalizeClaimDeliveryItem(item)) + .filter((item): item is { cloudSkuId: number; cloudSkuName: string; quantity: number } => Boolean(item)) + + if (items.length > 0) { + return mergeClaimDeliveryItems(items) + } + + const cloudSkuId = Number(binding.skuId || 0) || 0 + if (!cloudSkuId) { + return [] + } + + return [{ + cloudSkuId, + cloudSkuName: String(binding.skuName || '').trim(), + quantity: 1, + }] +} + +function normalizeClaimDeliveryItem(value: unknown) { + const source = isPlainObject(value) ? value : {} + const cloudSkuId = Number(source.cloudSkuId || source.skuId || 0) || 0 + + if (!Number.isInteger(cloudSkuId) || cloudSkuId <= 0) { + return null + } + + const quantity = Number(source.quantity || 1) || 1 + return { + cloudSkuId, + cloudSkuName: String(source.cloudSkuName || source.skuName || '').trim(), + quantity: Number.isInteger(quantity) && quantity > 0 ? quantity : 1, + } +} + +function mergeClaimDeliveryItems( + items: Array<{ cloudSkuId: number; cloudSkuName: string; quantity: number }>, +) { + const merged = new Map() + + for (const item of items) { + const existing = merged.get(item.cloudSkuId) + if (existing) { + existing.quantity += item.quantity + existing.cloudSkuName = existing.cloudSkuName || item.cloudSkuName + continue + } + + merged.set(item.cloudSkuId, { ...item }) + } + + return Array.from(merged.values()) +} + +function isClaimPlaceholderSkuName(value: string) { + return ['测试链接专用', '商品领取', '测试商品'].includes(String(value || '').trim()) +} + +async function expireClaimContext(claimToken: ClaimTokenRow, task: TaskRow) { + const now = nowIso() + const nextClaimToken = await updateClaimToken(claimToken.id, { + status: TASK_STATUS.EXPIRED, + updated_at: now, + }) + + let nextTask = task + + if (!isTaskFinalStatus(task.task_status)) { + nextTask = await updateTask(task.id, { + task_status: TASK_STATUS.EXPIRED, + user_action_status: TASK_STATUS.EXPIRED, + last_error: '领取链接已过期', + updated_at: now, + }) || task + } + + return { + claimToken: nextClaimToken, + task: nextTask, + } +} diff --git a/apps/backend/src/services/claim/kuaishou-cloud-claim-service.ts b/apps/backend/src/services/claim/kuaishou-cloud-claim-service.ts index f31ab5af..487751ef 100644 --- a/apps/backend/src/services/claim/kuaishou-cloud-claim-service.ts +++ b/apps/backend/src/services/claim/kuaishou-cloud-claim-service.ts @@ -3,22 +3,40 @@ import path from 'node:path' import { PROJECT_ROOT } from '../../config/runtime.js' import { createTaskEvent } from '../../repositories/task-event-repo.js' -import { updateTask } from '../../repositories/task-repo.js' +import { getTaskById, updateTask, updateTaskStatusIfCurrent } from '../../repositories/task-repo.js' import { createHttpError } from '../../utils/http.js' -import { nowIso } from '../../utils/time.js' +import { addHours, nowIso } from '../../utils/time.js' +import { + TASK_STATUS, + canRedeemKuaishouCloudClaimStatus, + isKuaishouCloudRedeemSettledStatus, + isKuaishouCloudRoleConfirmSettledStatus, + normalizeTaskStatus, +} from '../../domain/task-status.js' import { getKuaishouEticketSourceConfig, + listKuaishouEticketShopConfigs, resolveKuaishouEticketShopConfig, + type KuaishouEticketShopConfig, } from '../platforms/kuaishou-eticket/source-config-service.js' -import { queryKuaishouEticketConsumeDetail } from '../platforms/kuaishou-eticket/consume-service.js' +import { + consumeKuaishouEticket, + queryKuaishouEticketConsumeDetail, +} from '../platforms/kuaishou-eticket/consume-service.js' +import { isKuaishouEticketMockTicketCode } from '../platforms/kuaishou-eticket/mock-ticket-service.js' import { dispatchKuaishouCloudFulfillmentTask, + hasKuaishouCloudCustomerRole, + hasKuaishouCloudDefaultRoleSnapshot, maskCode, normalizeKuaishouCloudFlow, prepareKuaishouCloudFulfillmentTask, + rebindKuaishouCloudTaskRole, refreshKuaishouCloudTaskRoleInfo, -} from '../fulfillment/kuaishou-cloud-task-service.js' -import { getClaimContext, getClaimDetail } from './claim-session-service.js' +} from '../fulfillment/kuaishou-cloud/index.js' +import { buildClaimDetailPayload, getClaimContext } from './kuaishou-cloud-claim-context.js' +import { syncKuaishouCloudRoleInfo } from './kuaishou-cloud-sync-service.js' +import type { TaskRow } from '../../types/repository/rows.js' const KUAISHOU_CLOUD_GUIDE_DIR = path.resolve(PROJECT_ROOT, '../../tems/imgs') const ALLOWED_GUIDE_FILES = new Set(['1.png', '2.png', '3.png']) @@ -46,24 +64,46 @@ export async function verifyKuaishouCloudClaimTicket(token: unknown, payload: Js }) } - const shopId = String(flow.consume.shopId || context.order.shop_id || '').trim() - const shopConfig = resolveKuaishouEticketShopConfig({ - shopId, - shopName: String(context.order.shop_name || '').trim(), - }) - if (!shopConfig || shopConfig.enabled === false || !String(shopConfig.cookie || '').trim()) { - throw createHttpError('这笔订单对应的快手小店还没有配置可用 Cookie,请联系客服处理', { + const persistedTicketCode = String(flow.ticket.code || '').trim() + if (flow.consume.status === 'success') { + if (!persistedTicketCode || persistedTicketCode === ticketCode) { + return getKuaishouCloudClaimDetail(token) + } + + throw createHttpError('当前领取链接已完成核销,不能更换核销码', { statusCode: 409, - errorCode: 'claim_kuaishou_cloud_shop_cookie_missing', + errorCode: 'claim_kuaishou_cloud_ticket_already_consumed', }) } - const eticketSource = getKuaishouEticketSourceConfig() - const detailResult = await queryKuaishouEticketConsumeDetail({ - baseUrl: eticketSource.baseUrl, - cookie: shopConfig.cookie, - eTicketId: ticketCode, - }) + const mockTicketCode = isKuaishouEticketMockTicketCode(ticketCode) + let shopId = String( + flow.consume.shopId || context.order.shop_id || (mockTicketCode ? 'mock' : ''), + ).trim() + let shopName = String(flow.consume.shopName || context.order.shop_name || '').trim() + let detailResult + let shopCookie = '' + let consumeBaseUrl = '' + + if (mockTicketCode) { + detailResult = await queryKuaishouEticketConsumeDetail({ + eTicketId: ticketCode, + goodsTitle: context.orderItem.sku_name || context.orderItem.sku_code, + }) + } else { + const matched = await queryKuaishouEticketConsumeDetailWithShopFallback({ + ticketCode, + shopId, + shopName, + fallbackShopName: String(context.order.shop_name || '').trim(), + }) + + detailResult = matched.detailResult + shopId = matched.shopConfig.shopId || shopId + shopName = matched.shopConfig.kshopName || shopName + shopCookie = matched.shopConfig.cookie + consumeBaseUrl = getKuaishouEticketSourceConfig().baseUrl + } if (!detailResult.ok || detailResult.alreadyConsumed || !detailResult.detail) { throw createHttpError(detailResult.errorMessage || '核销码校验失败,请确认是否复制完整', { @@ -74,8 +114,25 @@ export async function verifyKuaishouCloudClaimTicket(token: unknown, payload: Js }) } + const consumePayload = { + baseUrl: consumeBaseUrl, + cookie: shopCookie, + eTicketId: detailResult.eTicketId || ticketCode, + oid: String(detailResult.detail.oid || '').trim(), + formToken: String(detailResult.detail.formToken || '').trim(), + num: Number(detailResult.detail.leftCount || 0) || 1, + } + const consumeResult = await consumeKuaishouEticket(consumePayload) + if (!consumeResult.consumed) { + throw createHttpError(consumeResult.errorMessage || '核销失败,请确认核销码状态后重试', { + statusCode: 409, + errorCode: 'claim_kuaishou_cloud_consume_failed', + }) + } + + const taskContext = parseTaskContext(context.task) const nextContext = { - ...parseTaskContext(context.task), + ...taskContext, kuaishouCloudFulfillment: { ...flow, ticket: { @@ -94,48 +151,93 @@ export async function verifyKuaishouCloudClaimTicket(token: unknown, payload: Js }, consume: { ...flow.consume, + status: 'success', shopId, + shopName, + consumedAt: now, + errorMessage: '', }, }, } - const preparedFlow = normalizeKuaishouCloudFlow(nextContext.kuaishouCloudFulfillment) - if (preparedFlow.binding.prepareStatus !== 'ready' || !preparedFlow.binding.bindUrl) { - await prepareKuaishouCloudFulfillmentTask({ - ...context.task, - context_json: JSON.stringify(nextContext), - }, { - source: 'claim_page_ticket_verified', - actor: { source: 'claim_page' }, - }) - } else { + if (isKuaishouCloudMockContext(nextContext)) { + const mockFlow = buildMockVerifiedKuaishouCloudFlow(nextContext.kuaishouCloudFulfillment, now) await updateTask(context.task.id, { claim_token: context.claimToken.token, claim_expires_at: context.claimToken.expired_at, - context_json: JSON.stringify(nextContext), + context_json: JSON.stringify({ + ...nextContext, + kuaishouCloudFulfillment: mockFlow, + }), + claimed_at: context.task.claimed_at || now, + task_status: TASK_STATUS.WAITING_BINDING, + user_action_status: 'pending_claim', last_error: '', updated_at: now, }) - } - - if (!context.task.claimed_at) { - await updateTask(context.task.id, { - claimed_at: now, + } else { + const preparedFlow = normalizeKuaishouCloudFlow(nextContext.kuaishouCloudFulfillment) + const taskWithConsumedTicket = (await updateTask(context.task.id, { + claim_token: context.claimToken.token, + claim_expires_at: context.claimToken.expired_at, + context_json: JSON.stringify(nextContext), + claimed_at: context.task.claimed_at || now, + task_status: TASK_STATUS.WAITING_BINDING, + user_action_status: 'pending_claim', + last_error: '', updated_at: now, - }) + })) || { + ...context.task, + context_json: JSON.stringify(nextContext), + claimed_at: context.task.claimed_at || now, + task_status: TASK_STATUS.WAITING_BINDING, + user_action_status: 'pending_claim', + last_error: '', + updated_at: now, + } + + if (preparedFlow.binding.prepareStatus !== 'ready' || !preparedFlow.binding.bindUrl) { + await prepareKuaishouCloudFulfillmentTask(taskWithConsumedTicket, { + source: 'claim_page_ticket_verified', + actor: { source: 'claim_page' }, + }) + } } - await createTaskEvent(context.task.id, 'kuaishou_cloud_ticket_verified', { - ticketCodeMasked: maskCode(detailResult.eTicketId || ticketCode), - shopId, - shopName: String(context.order.shop_name || '').trim(), - goodsTitle: String(detailResult.goods?.itemTitle || '').trim(), - }, now) + await createTaskEvent( + context.task.id, + 'kuaishou_cloud_ticket_verified', + { + ticketCodeMasked: maskCode(detailResult.eTicketId || ticketCode), + shopId, + shopName, + goodsTitle: String(detailResult.goods?.itemTitle || '').trim(), + mock: mockTicketCode, + consumedAt: now, + consumeAlreadyCompleted: consumeResult.alreadyConsumed === true, + }, + now, + ) - return getClaimDetail(token, { includeQrImage: false }) + await createTaskEvent( + context.task.id, + 'kuaishou_cloud_consumed', + { + source: 'claim_page_ticket_submit', + ticketCodeMasked: maskCode(detailResult.eTicketId || ticketCode), + shopId, + shopName, + consumeStatus: 'success', + consumedAt: now, + mock: mockTicketCode, + }, + now, + ) + + return getKuaishouCloudClaimDetail(token) } -export async function getKuaishouCloudClaimGuideAssetPath(filename) { +export async function getKuaishouCloudClaimGuideAssetPath(filename: unknown) { const normalized = String(filename || '').trim() if (!ALLOWED_GUIDE_FILES.has(normalized)) { throw createHttpError('指引图片不存在', { @@ -155,7 +257,44 @@ export async function getKuaishouCloudClaimGuideAssetPath(filename) { return filePath } -function parseTaskContext(task) { +export async function getKuaishouCloudClaimDetail(token: unknown) { + const context = await getClaimContext(token) + let task = context.task + + if ( + String(task.executor_key || '').trim() === 'kuaishou_ct_assisted' && + !isKuaishouCloudMockTask(task) + ) { + task = (await syncKuaishouCloudRoleInfo(task)) || task + } + + return buildClaimDetailPayload({ + claimToken: context.claimToken, + task, + order: context.order, + orderItem: context.orderItem, + }) +} + +export async function rebindKuaishouCloudClaimRole(token: unknown) { + const context = await getClaimContext(token) + + if (String(context.task.executor_key || '').trim() !== 'kuaishou_ct_assisted') { + throw createHttpError('当前领取链接不是快手 Cloud 客户领取流程', { + statusCode: 409, + errorCode: 'claim_not_kuaishou_cloud', + }) + } + + await rebindKuaishouCloudTaskRole(context.task, { + source: 'claim_page_rebind_role', + actor: { source: 'claim_page' }, + }) + + return getKuaishouCloudClaimDetail(token) +} + +function parseTaskContext(task: Partial | null | undefined): JsonObject { const rawValue = task?.context_json if (!rawValue) { return {} @@ -172,7 +311,135 @@ function parseTaskContext(task) { } } -export async function confirmKuaishouCloudClaimRole(token) { +async function queryKuaishouEticketConsumeDetailWithShopFallback({ + ticketCode, + shopId, + shopName, + fallbackShopName = '', +}: { + ticketCode: string + shopId: string + shopName: string + fallbackShopName?: string +}) { + const eticketSource = getKuaishouEticketSourceConfig() + const shopConfigs = resolveKuaishouEticketDetailCandidateShops({ + shopId, + shopName, + fallbackShopName, + }) + + if (shopConfigs.length === 0) { + throw createHttpError('还没有配置可用的快手小店 Cookie,请联系客服处理', { + statusCode: 409, + errorCode: 'claim_kuaishou_cloud_shop_cookie_missing', + }) + } + + let lastResult: JsonObject | null = null + let lastError: unknown = null + + for (const shopConfig of shopConfigs) { + try { + const detailResult = await queryKuaishouEticketConsumeDetail({ + baseUrl: eticketSource.baseUrl, + cookie: shopConfig.cookie, + eTicketId: ticketCode, + }) + + lastResult = detailResult + if (detailResult.ok || detailResult.alreadyConsumed) { + return { + shopConfig, + detailResult, + } + } + } catch (error) { + lastError = error + } + } + + if (lastResult) { + const fallbackShopConfig = shopConfigs[0] + if (!fallbackShopConfig) { + throw createHttpError('还没有配置可用的快手小店 Cookie,请联系客服处理', { + statusCode: 409, + errorCode: 'claim_kuaishou_cloud_shop_cookie_missing', + }) + } + + return { + shopConfig: fallbackShopConfig, + detailResult: lastResult, + } + } + + throw createHttpError( + lastError instanceof Error ? lastError.message : '核销码校验失败,请确认是否复制完整', + { + statusCode: 409, + errorCode: 'claim_kuaishou_cloud_ticket_invalid', + }, + ) +} + +function resolveKuaishouEticketDetailCandidateShops({ + shopId, + shopName, + fallbackShopName = '', +}: { + shopId: string + shopName: string + fallbackShopName?: string +}) { + const candidates: KuaishouEticketShopConfig[] = [] + const configuredShop = resolveKuaishouEticketShopConfig({ + shopId, + shopName: shopName || fallbackShopName, + }) + + if (isUsableKuaishouEticketShopConfig(configuredShop)) { + candidates.push(configuredShop) + } + + for (const shopConfig of listKuaishouEticketShopConfigs()) { + if (!isUsableKuaishouEticketShopConfig(shopConfig)) { + continue + } + + if (candidates.some((item) => isSameKuaishouEticketShopConfig(item, shopConfig))) { + continue + } + + candidates.push(shopConfig) + } + + return candidates +} + +function isUsableKuaishouEticketShopConfig( + shopConfig: KuaishouEticketShopConfig | null | undefined, +): shopConfig is KuaishouEticketShopConfig { + return Boolean( + shopConfig && shopConfig.enabled !== false && String(shopConfig.cookie || '').trim(), + ) +} + +function isSameKuaishouEticketShopConfig( + left: KuaishouEticketShopConfig, + right: KuaishouEticketShopConfig, +) { + const leftShopId = String(left.shopId || '').trim() + const rightShopId = String(right.shopId || '').trim() + + if (leftShopId && rightShopId) { + return leftShopId === rightShopId + } + + return String(left.kshopName || '').trim() === String(right.kshopName || '').trim() +} + +export async function confirmKuaishouCloudClaimRole(token: unknown) { const context = await getClaimContext(token) const now = nowIso() @@ -183,16 +450,20 @@ export async function confirmKuaishouCloudClaimRole(token) { }) } - if (['role_confirmed', 'dispatched_pending_return', 'completed'].includes(String(context.task.task_status || '').trim())) { - return getClaimDetail(token, { includeQrImage: false }) + if (isKuaishouCloudRoleConfirmSettledStatus(context.task.task_status)) { + return getKuaishouCloudClaimDetail(token) } - const refreshed = await refreshKuaishouCloudTaskRoleInfo(context.task, { - source: 'claim_page_role_confirm', - actor: { source: 'claim_page' }, - recordEvent: false, - forceProbe: true, - }) + const contextSource = parseTaskContext(context.task) + const mockMode = isKuaishouCloudMockContext(contextSource) + const refreshed = mockMode + ? { task: context.task } + : await refreshKuaishouCloudTaskRoleInfo(context.task, { + source: 'claim_page_role_confirm', + actor: { source: 'claim_page' }, + recordEvent: false, + forceProbe: true, + }) const flow = normalizeKuaishouCloudFlow(parseTaskContext(refreshed.task).kuaishouCloudFulfillment) if (flow.ticket.status !== 'verified') { @@ -209,26 +480,44 @@ export async function confirmKuaishouCloudClaimRole(token) { }) } + const hasDefaultRoleSnapshot = hasKuaishouCloudDefaultRoleSnapshot(flow) + if (!mockMode && !hasKuaishouCloudCustomerRole(flow)) { + const message = hasDefaultRoleSnapshot + ? '当前仍是虚拟机默认角色,请先重新绑定自己的角色信息' + : '系统还未获取到虚拟机默认角色信息,请稍后刷新角色信息后再试' + throw createHttpError(message, { + statusCode: 409, + errorCode: hasDefaultRoleSnapshot + ? 'claim_kuaishou_cloud_default_role_not_rebound' + : 'claim_kuaishou_cloud_default_role_not_ready', + }) + } + await updateTask(context.task.id, { - task_status: 'role_confirmed', + task_status: TASK_STATUS.ROLE_CONFIRMED, role_id: flow.binding.roleId, role_name: flow.binding.roleName, - user_action_status: 'role_confirmed', + user_action_status: TASK_STATUS.ROLE_CONFIRMED, role_confirmed_at: now, last_error: '', updated_at: now, }) - await createTaskEvent(context.task.id, 'kuaishou_cloud_role_confirmed', { - vnPhone: flow.binding.vnPhone, - roleName: flow.binding.roleName, - roleId: flow.binding.roleId, - }, now) + await createTaskEvent( + context.task.id, + 'kuaishou_cloud_role_confirmed', + { + vnPhone: flow.binding.vnPhone, + roleName: flow.binding.roleName, + roleId: flow.binding.roleId, + }, + now, + ) - return getClaimDetail(token, { includeQrImage: false }) + return getKuaishouCloudClaimDetail(token) } -export async function redeemKuaishouCloudClaim(token) { +export async function redeemKuaishouCloudClaim(token: unknown) { const context = await getClaimContext(token) const now = nowIso() @@ -239,18 +528,194 @@ export async function redeemKuaishouCloudClaim(token) { }) } - if (context.task.task_status !== 'role_confirmed') { + const currentStatus = normalizeTaskStatus(context.task.task_status) + if (isKuaishouCloudRedeemSettledStatus(currentStatus)) { + return getKuaishouCloudClaimDetail(token) + } + + if (!canRedeemKuaishouCloudClaimStatus(currentStatus)) { throw createHttpError('请先确认角色信息', { statusCode: 409, errorCode: 'claim_kuaishou_cloud_role_not_confirmed', }) } - await dispatchKuaishouCloudFulfillmentTask(context.task, { - source: 'claim_page_redeem', - actor: { source: 'claim_page' }, - autoFinalize: true, + const lockedTask = await updateTaskStatusIfCurrent(context.task.id, TASK_STATUS.ROLE_CONFIRMED, { + task_status: TASK_STATUS.REDEEMING, + user_action_status: 'not_required', + last_error: '', + updated_at: now, }) - return getClaimDetail(token, { includeQrImage: false }) + if (!lockedTask) { + return getKuaishouCloudClaimDetail(token) + } + + if (isKuaishouCloudMockTask(lockedTask)) { + await completeMockKuaishouCloudClaimTask(lockedTask, now) + return getKuaishouCloudClaimDetail(token) + } + + try { + await dispatchKuaishouCloudFulfillmentTask(lockedTask, { + source: 'claim_page_redeem', + actor: { source: 'claim_page' }, + autoFinalize: true, + }) + } catch (error) { + const latestTask = await getTaskById(lockedTask.id) + const latestStatus = latestTask ? normalizeTaskStatus(latestTask.task_status) : '' + if (latestTask && latestStatus !== TASK_STATUS.REDEEMING) { + if ( + latestStatus === TASK_STATUS.DISPATCHED_PENDING_RETURN || + latestStatus === TASK_STATUS.COMPLETED || + latestStatus === TASK_STATUS.REDEEMED + ) { + return getKuaishouCloudClaimDetail(token) + } + + throw error + } + + const message = error instanceof Error ? error.message : '兑换请求提交失败,请联系客服处理' + await updateTask(lockedTask.id, { + task_status: TASK_STATUS.MANUAL_REVIEW, + user_action_status: 'not_required', + last_error: message, + result_code: 'kuaishou_cloud_redeem_failed', + result_message: message, + updated_at: nowIso(), + }) + + await createTaskEvent( + lockedTask.id, + 'kuaishou_cloud_redeem_failed', + { + source: 'claim_page_redeem', + errorMessage: message, + }, + nowIso(), + ) + + throw error + } + + return getKuaishouCloudClaimDetail(token) +} + +function isKuaishouCloudMockTask(task: Partial | null | undefined) { + return isKuaishouCloudMockContext(parseTaskContext(task)) +} + +function isKuaishouCloudMockContext(context: JsonObject = {}) { + const flow = + context.kuaishouCloudFulfillment && typeof context.kuaishouCloudFulfillment === 'object' + ? context.kuaishouCloudFulfillment + : {} + const mock = flow.mock && typeof flow.mock === 'object' ? flow.mock : context.mock + + return Boolean(mock && typeof mock === 'object' && mock.enabled === true) +} + +function buildMockVerifiedKuaishouCloudFlow(value: unknown, timestamp: string) { + const flow = normalizeKuaishouCloudFlow(value) + const source = flow as JsonObject + const bindUrl = + flow.binding.bindUrl || + `https://example.com/mock-kuaishou-cloud-bind?task=mock&ts=${encodeURIComponent(timestamp)}` + const vnPhone = flow.binding.vnPhone || '13800000000' + const roleName = flow.binding.roleName || flow.role.name || '测试角色' + const roleId = flow.binding.roleId || flow.role.rid || '10001' + + return { + ...flow, + mock: { + ...(source.mock && typeof source.mock === 'object' ? source.mock : {}), + enabled: true, + }, + binding: { + ...flow.binding, + prepareStatus: 'ready', + vnId: flow.binding.vnId || 900001, + vnPhone, + bindUrl, + bindPreparedAt: flow.binding.bindPreparedAt || timestamp, + bindExpiresAt: flow.binding.bindExpiresAt || addHours(timestamp, 24), + roleName, + roleId, + }, + role: { + ...flow.role, + status: 'ready', + name: roleName, + rid: roleId, + refreshedAt: flow.role.refreshedAt || timestamp, + errorMessage: '', + rawInfo: flow.role.rawInfo || { + mock: true, + }, + }, + } +} + +async function completeMockKuaishouCloudClaimTask(task: TaskRow, timestamp: string) { + const taskContext = parseTaskContext(task) + const flow = normalizeKuaishouCloudFlow(taskContext.kuaishouCloudFulfillment) + const source = flow as JsonObject + const nextFlow = { + ...flow, + mock: { + ...(source.mock && typeof source.mock === 'object' ? source.mock : {}), + enabled: true, + }, + dispatch: { + ...flow.dispatch, + status: 'success', + dispatchAt: timestamp, + dispatchBy: { + source: 'claim_page_mock', + }, + note: '开发 mock 已模拟发货成功', + items: flow.deliveryItems, + }, + returnNumber: { + ...flow.returnNumber, + status: 'success', + returnedAt: timestamp, + returnedBy: { + source: 'claim_page_mock', + }, + }, + consume: { + ...flow.consume, + status: 'success', + consumedAt: timestamp, + errorMessage: '', + }, + } + + await updateTask(task.id, { + task_status: TASK_STATUS.COMPLETED, + delivery_status: 'success', + result_code: 'mock_success', + result_message: '开发 mock 已模拟兑换成功', + user_action_status: 'not_required', + last_error: '', + context_json: JSON.stringify({ + ...taskContext, + kuaishouCloudFulfillment: nextFlow, + }), + redeemed_at: timestamp, + updated_at: timestamp, + }) + + await createTaskEvent( + task.id, + 'kuaishou_cloud_mock_redeemed', + { + source: 'claim_page_mock', + deliveryItems: flow.deliveryItems, + }, + timestamp, + ) } diff --git a/apps/backend/src/services/claim/kuaishou-cloud-sync-service.ts b/apps/backend/src/services/claim/kuaishou-cloud-sync-service.ts index f9290902..b947afa9 100644 --- a/apps/backend/src/services/claim/kuaishou-cloud-sync-service.ts +++ b/apps/backend/src/services/claim/kuaishou-cloud-sync-service.ts @@ -3,9 +3,10 @@ import { prepareKuaishouCloudFulfillmentTask, probeKuaishouCloudTaskBindUrl, refreshKuaishouCloudTaskRoleInfo, -} from '../fulfillment/kuaishou-cloud-task-service.js' +} from '../fulfillment/kuaishou-cloud/index.js' +import type { TaskRow } from '../../types/repository/rows.js' -export async function syncKuaishouCloudRoleInfo(task) { +export async function syncKuaishouCloudRoleInfo(task: TaskRow) { const flow = normalizeKuaishouCloudFlow(parseTaskContext(task).kuaishouCloudFulfillment) if (!flow.binding.vnId || !flow.binding.vnKey || !flow.binding.vnPhone) { @@ -29,20 +30,21 @@ export async function syncKuaishouCloudRoleInfo(task) { source: 'claim_page_polling_bind_url_probe', actor: { source: 'system' }, }) - task = probed.task + task = probed.task || task const result = await refreshKuaishouCloudTaskRoleInfo(task, { source: 'claim_page_polling', actor: { source: 'system' }, recordEvent: false, + forceProbe: flow.role.isDefaultRole === true, }) - return result.task + return result.task || task } catch { return task } } -function parseTaskContext(task) { +function parseTaskContext(task: Partial | null | undefined): Record { const rawValue = task?.context_json if (!rawValue) { diff --git a/apps/backend/src/services/claim/session/actions.ts b/apps/backend/src/services/claim/session/actions.ts deleted file mode 100644 index 7c7bcf0e..00000000 --- a/apps/backend/src/services/claim/session/actions.ts +++ /dev/null @@ -1,312 +0,0 @@ -import { - getTencentBrowserSession, - getTencentBrowserSessionScreenshotPath, -} from '../../session/session.js' -import { createTaskEvent } from '../../../repositories/task-event-repo.js' -import { updateTask } from '../../../repositories/task-repo.js' -import { - getInventoryItemById, - markInventoryItemDelivered, -} from '../../../repositories/inventory-repo.js' -import { ensureAgisoXianyuAutoDeliveryForDeliveredTask } from '../../platforms/agiso/xianyu/auto-delivery-service.js' -import { syncKuaishouCloudRoleInfo } from '../kuaishou-cloud-sync-service.js' -import { notifyClaimRedeemNeedsAttention } from '../../notification/domain-notifications.js' -import { createHttpError } from '../../../utils/http.js' -import { nowIso } from '../../../utils/time.js' -import { getClaimContext, getClaimContextByTaskId } from './context.js' -import { loadTaskSession, syncTaskWithSession } from './runtime.js' -import { redeemClaimTaskWithInventoryFallback, resolveTencentRedeemResultCode } from './redeem.js' -import { - assertPublicClaimActionAllowed, - assertTaskCanProceed, - buildClaimDetailPayload, - maskCode, - mergeTaskContext, -} from './shared.js' - -export async function getClaimDetail(token, { includeQrImage = true } = {}) { - const context = await getClaimContext(token) - let { task, session } = await loadTaskSession(context.task, { includeQrImage }) - - if (String(task.executor_key || '').trim() === 'kuaishou_ct_assisted') { - task = await syncKuaishouCloudRoleInfo(task) - } - - const syncedTask = session ? await syncTaskWithSession(task, session) : task - - return buildClaimDetailPayload({ - claimToken: context.claimToken, - task: syncedTask, - order: context.order, - orderItem: context.orderItem, - session, - }) -} - -export async function getClaimDetailForAdminTask(taskId, { includeQrImage = true } = {}) { - const context = await getClaimContextByTaskId(taskId) - const { task, session } = await loadTaskSession(context.task, { includeQrImage }) - const syncedTask = session ? await syncTaskWithSession(task, session) : task - - return buildClaimDetailPayload({ - claimToken: context.claimToken, - task: syncedTask, - order: context.order, - orderItem: context.orderItem, - session, - }) -} - -export async function getClaimSessionSummary(token) { - return getClaimSessionSummaryByContextLoader(() => getClaimContext(token)) -} - -export async function getClaimSessionSummaryForAdminTask(taskId) { - return getClaimSessionSummaryByContextLoader(() => getClaimContextByTaskId(taskId)) -} - -export async function confirmClaimRole(token) { - const context = await getClaimContext(token) - assertPublicClaimActionAllowed(context.task, 'confirm') - - return finalizeClaimRoleConfirmation(context) -} - -export async function confirmClaimRoleForAdminTask(taskId) { - const context = await getClaimContextByTaskId(taskId) - - return finalizeClaimRoleConfirmation(context) -} - -export async function redeemClaimTask(token) { - const context = await getClaimContext(token) - assertTaskCanProceed(context.task) - assertPublicClaimActionAllowed(context.task, 'redeem') - - return finalizeClaimTaskRedeem(context) -} - -export async function redeemClaimTaskForAdminTask(taskId) { - const context = await getClaimContextByTaskId(taskId) - assertTaskCanProceed(context.task) - - return finalizeClaimTaskRedeem(context) -} - -export async function getClaimScreenshotPath(token) { - const context = await getClaimContext(token) - - if (context.task.screenshot_path) { - return context.task.screenshot_path - } - - if (!context.task.browser_session_id) { - throw createHttpError('当前任务还没有兑换截图', { - statusCode: 404, - errorCode: 'claim_screenshot_not_ready', - }) - } - - return getTencentBrowserSessionScreenshotPath(context.task.browser_session_id) -} - -async function getClaimSessionSummaryByContextLoader(loadContext) { - const context = await loadContext() - const { task, session } = await loadTaskSession(context.task, { - includeQrImage: false, - }) - - if (!session) { - return buildClaimDetailPayload({ - claimToken: context.claimToken, - task, - order: context.order, - orderItem: context.orderItem, - session: null, - }) - } - - const syncedTask = await syncTaskWithSession(task, session) - - return buildClaimDetailPayload({ - claimToken: context.claimToken, - task: syncedTask, - order: context.order, - orderItem: context.orderItem, - session, - }) -} - -async function finalizeClaimRoleConfirmation(context) { - if (!context.task.browser_session_id) { - throw createHttpError('当前任务还没有创建浏览器会话', { - statusCode: 409, - errorCode: 'claim_session_not_created', - }) - } - - const session = await getTencentBrowserSession(context.task.browser_session_id) - const activityInfo = session.activityInfo || null - - if (!activityInfo?.role?.ready) { - throw createHttpError('当前角色信息还没有准备好', { - statusCode: 409, - errorCode: 'claim_role_not_ready', - }) - } - - const updatedTask = await updateTask(context.task.id, { - task_status: 'role_confirmed', - nickname: String(activityInfo.nickname || ''), - role_id: String(activityInfo.role.roleId || ''), - role_name: String(activityInfo.role.roleName || ''), - area: String(activityInfo.role.area || ''), - partition_name: String(activityInfo.role.partition || ''), - user_action_status: 'role_confirmed', - role_confirmed_at: nowIso(), - updated_at: nowIso(), - last_error: '', - }) - - return buildClaimDetailPayload({ - claimToken: context.claimToken, - task: updatedTask, - order: context.order, - orderItem: context.orderItem, - session, - }) -} - -async function finalizeClaimTaskRedeem(context) { - if (!context.task.browser_session_id) { - throw createHttpError('当前任务还没有创建浏览器会话', { - statusCode: 409, - errorCode: 'claim_session_not_created', - }) - } - - if (context.task.task_status !== 'role_confirmed' && context.task.task_status !== 'redeeming') { - throw createHttpError('当前任务还未确认角色,不能开始兑换', { - statusCode: 409, - errorCode: 'claim_role_not_confirmed', - }) - } - - const inventoryItem = context.task.primary_inventory_item_id - ? await getInventoryItemById(context.task.primary_inventory_item_id) - : null - - if ( - !inventoryItem || - String(inventoryItem.status || '').trim() !== 'reserved' || - !String(inventoryItem.display_value || '').trim() - ) { - throw createHttpError('当前任务没有可用的预占库存凭据', { - statusCode: 409, - errorCode: 'claim_inventory_not_reserved', - }) - } - - await updateTask(context.task.id, { - task_status: 'redeeming', - delivery_status: 'processing', - updated_at: nowIso(), - last_error: '', - }) - - try { - const redeemResult = await redeemClaimTaskWithInventoryFallback(context, inventoryItem) - const { session, inventoryItem: deliveredInventoryItem, classification, attempts } = redeemResult - const finalRedeem = session.redeem?.final?.redeem || null - const finishedAt = nowIso() - const updatedTask = await updateTask(context.task.id, { - task_status: 'redeemed', - inventory_status: 'consumed', - delivery_status: 'delivered', - result_code: resolveTencentRedeemResultCode(finalRedeem, classification), - result_message: classification.message, - screenshot_path: session.artifacts?.hasScreenshot ? await getTencentBrowserSessionScreenshotPath(session.sessionId) : '', - artifacts_json: JSON.stringify(session.artifacts || {}), - context_json: JSON.stringify(mergeTaskContext(context.task, { - redeemResolution: { - status: 'success', - attempts, - replacementCount: Math.max(0, attempts.length - 1), - finishedAt, - }, - })), - redeemed_at: finishedAt, - updated_at: finishedAt, - last_error: '', - }) - - await markInventoryItemDelivered(deliveredInventoryItem.id, finishedAt) - await createTaskEvent(context.task.id, 'claim_redeem_completed', { - inventoryItemId: deliveredInventoryItem.id, - codeMasked: maskCode(deliveredInventoryItem.display_value), - replacementCount: Math.max(0, attempts.length - 1), - resultCode: resolveTencentRedeemResultCode(finalRedeem, classification), - resultMessage: classification.message, - }, finishedAt) - const autoDeliveryResult = await ensureAgisoXianyuAutoDeliveryForDeliveredTask({ - order: context.order, - task: updatedTask, - trigger: 'claim_redeemed', - }) - - return buildClaimDetailPayload({ - claimToken: context.claimToken, - task: autoDeliveryResult.task || updatedTask, - order: context.order, - orderItem: context.orderItem, - session, - }) - } catch (error) { - const now = nowIso() - const nextRetryCount = Number(context.task.attempt_count || 0) + 1 - const failureState = ( - error && typeof error === 'object' - ? /** @type {{ redeemTaskState?: { taskStatus: string, inventoryStatus: string, deliveryStatus: string, lastError: string, attempts: unknown[], classification: { retCode?: unknown } | null } }} */ (error).redeemTaskState - : null - ) || { - taskStatus: 'retry_pending', - inventoryStatus: inventoryItem ? 'reserved' : 'pending', - deliveryStatus: 'pending', - lastError: error instanceof Error ? error.message : String(error || ''), - attempts: [], - classification: null, - } - const failedTask = await updateTask(context.task.id, { - task_status: failureState.taskStatus, - inventory_status: failureState.inventoryStatus, - delivery_status: failureState.deliveryStatus, - result_code: failureState.classification?.retCode != null ? String(failureState.classification.retCode) : '', - result_message: String(failureState.lastError || ''), - attempt_count: nextRetryCount, - last_error: String(failureState.lastError || ''), - context_json: JSON.stringify(mergeTaskContext(context.task, { - redeemResolution: { - status: 'failed', - taskStatus: failureState.taskStatus, - attempts: failureState.attempts || [], - replacementCount: Math.max(0, Number(failureState.attempts?.length || 1) - 1), - finishedAt: now, - }, - })), - updated_at: now, - }) - - if (['retry_pending', 'waiting_inventory', 'manual_review'].includes(String(failureState.taskStatus || '').trim())) { - await notifyClaimRedeemNeedsAttention({ - task: failedTask, - order: context.order, - status: failureState.taskStatus, - errorMessage: failureState.lastError, - }) - } - - throw Object.assign(error instanceof Error ? error : new Error(String(error || '兑换失败')), { - task: failedTask, - }) - } -} diff --git a/apps/backend/src/services/claim/session/context.ts b/apps/backend/src/services/claim/session/context.ts deleted file mode 100644 index de77bca6..00000000 --- a/apps/backend/src/services/claim/session/context.ts +++ /dev/null @@ -1,150 +0,0 @@ -import { findClaimTokenByToken, getClaimTokenById, updateClaimToken } from '../../../repositories/claim-token-repo.js' -import { getOrderById } from '../../../repositories/order-repo.js' -import { getOrderItemById } from '../../../repositories/order-item-repo.js' -import { findTaskByClaimTokenId, getTaskById, updateTask } from '../../../repositories/task-repo.js' -import { releaseReservedInventoryItem } from '../../../repositories/inventory-repo.js' -import { createHttpError } from '../../../utils/http.js' -import { nowIso } from '../../../utils/time.js' -import { CLAIM_TERMINAL_STATUSES } from './shared.js' - -export async function getClaimContext(token) { - const normalized = String(token || '').trim() - - if (!normalized) { - throw createHttpError('缺少领取 token', { - statusCode: 400, - errorCode: 'missing_claim_token', - }) - } - - const claimToken = await findClaimTokenByToken(normalized) - - if (!claimToken) { - throw createHttpError('领取链接无效或不存在', { - statusCode: 404, - errorCode: 'claim_token_not_found', - }) - } - - const task = await findTaskByClaimTokenId(claimToken.id) - - if (!task) { - throw createHttpError('领取任务不存在', { - statusCode: 404, - errorCode: 'claim_task_not_found', - }) - } - - if (claimToken.status !== 'active') { - throw createHttpError('领取链接当前不可用', { - statusCode: 410, - errorCode: 'claim_token_inactive', - }) - } - - if (claimToken.expired_at && new Date(claimToken.expired_at).getTime() <= Date.now()) { - const expiredContext = await expireClaimContext(claimToken, task) - - throw createHttpError('领取链接已过期', { - statusCode: 410, - errorCode: 'claim_token_expired', - context: expiredContext, - }) - } - - const [order, orderItem] = await Promise.all([ - getOrderById(task.order_id), - getOrderItemById(task.order_item_id), - ]) - - if (!order || !orderItem) { - throw createHttpError('领取任务关联订单不完整', { - statusCode: 500, - errorCode: 'claim_order_incomplete', - }) - } - - return { - claimToken, - task, - order, - orderItem, - } -} - -export async function getClaimContextByTaskId(taskId) { - const task = await getTaskById(Number(taskId)) - - if (!task) { - throw createHttpError('领取任务不存在', { - statusCode: 404, - errorCode: 'claim_task_not_found', - }) - } - - const claimTokenId = Number(task.primary_claim_token_id || 0) - - if (!claimTokenId) { - throw createHttpError('当前任务还没有领取链接', { - statusCode: 409, - errorCode: 'claim_token_missing', - }) - } - - const claimToken = await getClaimTokenById(claimTokenId) - - if (!claimToken) { - throw createHttpError('领取链接不存在', { - statusCode: 404, - errorCode: 'claim_token_not_found', - }) - } - - const [order, orderItem] = await Promise.all([ - getOrderById(task.order_id), - getOrderItemById(task.order_item_id), - ]) - - if (!order || !orderItem) { - throw createHttpError('领取任务关联订单不完整', { - statusCode: 500, - errorCode: 'claim_order_incomplete', - }) - } - - return { - claimToken, - task, - order, - orderItem, - } -} - -async function expireClaimContext(claimToken, task) { - const now = nowIso() - const nextClaimToken = await updateClaimToken(claimToken.id, { - status: 'expired', - updated_at: now, - }) - - let nextTask = task - - if (!CLAIM_TERMINAL_STATUSES.has(String(task.task_status || '')) && task.task_status !== 'redeemed') { - if (task.primary_inventory_item_id) { - await releaseReservedInventoryItem(task.primary_inventory_item_id, now) - } - - nextTask = await updateTask(task.id, { - task_status: 'expired', - inventory_status: 'pending', - user_action_status: 'expired', - last_error: '领取链接已过期,预占库存项已释放', - updated_at: now, - }) - } - - return { - claimToken: nextClaimToken, - task: nextTask, - } -} diff --git a/apps/backend/src/services/claim/session/lifecycle.ts b/apps/backend/src/services/claim/session/lifecycle.ts deleted file mode 100644 index 3b39f57b..00000000 --- a/apps/backend/src/services/claim/session/lifecycle.ts +++ /dev/null @@ -1,175 +0,0 @@ -import { - closeTencentBrowserSession, - createTencentBrowserSession, - reloadTencentBrowserSession, -} from '../../session/session.js' -import { updateTask } from '../../../repositories/task-repo.js' -import { nowIso } from '../../../utils/time.js' -import { getClaimContext, getClaimContextByTaskId } from './context.js' -import { clearTaskSession, loadTaskSession, syncTaskWithSession } from './runtime.js' -import { - assertTaskCanProceed, - buildClaimDetailPayload, - isRecoverableSessionError, - normalizeClaimLoginType, -} from './shared.js' - -type JsonObject = Record - -export async function createClaimSession(token: unknown, payload: JsonObject = {}) { - return createClaimSessionWithContextLoader( - () => getClaimContext(token), - payload, - ) -} - -export async function createClaimSessionForAdminTask(taskId: unknown, payload: JsonObject = {}) { - return createClaimSessionWithContextLoader( - () => getClaimContextByTaskId(taskId), - payload, - ) -} - -export async function reloadClaimSession(token: unknown) { - return reloadClaimSessionWithContextLoader(() => getClaimContext(token)) -} - -export async function reloadClaimSessionForAdminTask(taskId: unknown) { - return reloadClaimSessionWithContextLoader(() => getClaimContextByTaskId(taskId)) -} - -export async function closeClaimSession(token: unknown) { - return closeClaimSessionWithContextLoader(() => getClaimContext(token)) -} - -export async function closeClaimSessionForAdminTask(taskId: unknown) { - return closeClaimSessionWithContextLoader(() => getClaimContextByTaskId(taskId)) -} - -async function createClaimSessionWithContextLoader(loadContext: () => Promise, payload: JsonObject = {}) { - const context = await loadContext() - assertTaskCanProceed(context.task) - const requestedLoginType = normalizeClaimLoginType(payload.loginType) - const forceRecreate = Boolean(payload.forceRecreate) - let task = context.task - - if (task.browser_session_id) { - const existing = await loadTaskSession(task, { includeQrImage: true }) - task = existing.task - - if (existing.session) { - const existingLoginType = normalizeClaimLoginType(existing.session.loginType || task.login_type) - - if (!forceRecreate && existingLoginType === requestedLoginType) { - const syncedTask = await syncTaskWithSession(task, existing.session) - - return buildClaimDetailPayload({ - claimToken: context.claimToken, - task: syncedTask, - order: context.order, - orderItem: context.orderItem, - session: existing.session, - }) - } - - try { - await closeTencentBrowserSession(existing.session.sessionId) - } catch (error) { - if (!isRecoverableSessionError(error)) { - throw error - } - } - - task = await clearTaskSession(task, { - lastError: '', - }) - } - } - - const session = await createTencentBrowserSession({ - loginType: requestedLoginType, - }) - const updatedTask = await updateTask(task.id, { - task_status: 'claimed', - browser_session_id: session.sessionId, - login_type: session.loginType, - user_action_status: 'claimed', - claimed_at: task.claimed_at || nowIso(), - updated_at: nowIso(), - last_error: '', - }) - - return buildClaimDetailPayload({ - claimToken: context.claimToken, - task: updatedTask, - order: context.order, - orderItem: context.orderItem, - session, - }) -} - -async function reloadClaimSessionWithContextLoader(loadContext: () => Promise) { - const context = await loadContext() - assertTaskCanProceed(context.task) - - const active = await loadTaskSession(context.task, { - includeQrImage: true, - }) - - if (!active.session) { - return buildClaimDetailPayload({ - claimToken: context.claimToken, - task: active.task, - order: context.order, - orderItem: context.orderItem, - session: null, - }) - } - - const session = await reloadTencentBrowserSession(active.session.sessionId) - const syncedTask = await syncTaskWithSession(active.task, session) - - return buildClaimDetailPayload({ - claimToken: context.claimToken, - task: syncedTask, - order: context.order, - orderItem: context.orderItem, - session, - }) -} - -async function closeClaimSessionWithContextLoader(loadContext: () => Promise) { - const context = await loadContext() - assertTaskCanProceed(context.task) - let task = context.task - - if (!task.browser_session_id) { - return buildClaimDetailPayload({ - claimToken: context.claimToken, - task, - order: context.order, - orderItem: context.orderItem, - session: null, - }) - } - - try { - await closeTencentBrowserSession(task.browser_session_id) - } catch (error) { - if (!isRecoverableSessionError(error)) { - throw error - } - } - - task = await clearTaskSession(task, { - lastError: '', - }) - - return buildClaimDetailPayload({ - claimToken: context.claimToken, - task, - order: context.order, - orderItem: context.orderItem, - session: null, - }) -} diff --git a/apps/backend/src/services/claim/session/redeem.ts b/apps/backend/src/services/claim/session/redeem.ts deleted file mode 100644 index cb50ecc2..00000000 --- a/apps/backend/src/services/claim/session/redeem.ts +++ /dev/null @@ -1,301 +0,0 @@ -import { reloadTencentBrowserSession, redeemTencentBrowserSession } from '../../session/session.js' -import { classifyTencentRedeemResult } from '../../session/session-redeem.js' -import { createTaskEvent } from '../../../repositories/task-event-repo.js' -import { - invalidateReservedInventoryItem, - markInventoryItemConsumed, -} from '../../../repositories/inventory-repo.js' -import { reserveInventoryForTask } from '../../order/inventory-service.js' -import { createHttpError } from '../../../utils/http.js' -import { nowIso } from '../../../utils/time.js' -import { maskCode, REDEEM_REPLACEMENT_LIMIT } from './shared.js' -import type { InventoryItemRow, OrderItemRow, TaskRow } from '../../../types/repository-rows.js' - -type ClaimRedeemContext = { - task: Pick - orderItem: Pick -} - -type TencentRedeemPayload = { - [key: string]: unknown - iRet?: unknown - ret?: unknown -} - -type TencentRedeemSession = { - [key: string]: unknown - sessionId?: string - artifacts?: { - [key: string]: unknown - hasScreenshot?: boolean - } | null - redeem?: { - final?: { - redeem?: TencentRedeemPayload | null - } | null - } | null -} - -type TencentRedeemClassification = { - success: boolean - outcome: string - message: string - retCode?: unknown - [key: string]: unknown -} - -type RedeemAttemptSummary = { - attempt: number - inventoryItemId: number - codeMasked: string - credentialType: string - outcome: string - resultCode: string - resultMessage: string -} - -type RedeemTaskState = { - taskStatus: string - inventoryStatus: string - deliveryStatus: string - lastError: string - attempts: RedeemAttemptSummary[] - classification: TencentRedeemClassification | null -} - -type RedeemTaskStateError = Error & { - statusCode?: number - errorCode?: string - redeemTaskState: RedeemTaskState -} - -type RedeemTaskStateErrorPayload = { - statusCode?: number - errorCode?: string - taskStatus?: string - inventoryStatus?: string - deliveryStatus?: string - attempts?: RedeemAttemptSummary[] - classification?: TencentRedeemClassification | null -} - -type RedeemClaimTaskDeps = { - reloadTencentBrowserSession?: (sessionId: string) => Promise - redeemTencentBrowserSession?: ( - sessionId: string, - payload: { code: string }, - ) => Promise - classifyTencentRedeemResult?: (finalRedeem: TencentRedeemPayload | null) => TencentRedeemClassification - markInventoryItemConsumed?: ( - inventoryItemId: number | string, - reason: string, - consumedAt: string, - ) => Promise - createTaskEvent?: ( - taskId: number | string, - eventType: string, - payload?: unknown, - createdAt?: string, - ) => Promise - invalidateReservedInventoryItem?: ( - inventoryItemId: number | string, - reason: string, - updatedAt: string, - ) => Promise - reserveInventoryForTask?: (payload: { - skuCode: string - taskId: number | string - credentialType?: string - roleKey?: string - inventoryGroupCodes?: string[] | null - }) => Promise - nowIso?: () => string - redeemReplacementLimit?: number -} - -type RedeemClaimTaskResult = { - session: TencentRedeemSession - inventoryItem: InventoryItemRow - classification: TencentRedeemClassification - attempts: RedeemAttemptSummary[] -} - -export async function redeemClaimTaskWithInventoryFallback( - context: ClaimRedeemContext, - initialInventoryItem: InventoryItemRow, -): Promise { - return redeemClaimTaskWithInventoryFallbackWithDeps(context, initialInventoryItem) -} - -export async function redeemClaimTaskWithInventoryFallbackWithDeps( - context: ClaimRedeemContext, - initialInventoryItem: InventoryItemRow, - { - reloadTencentBrowserSession: reloadRedeemSession = reloadTencentBrowserSession, - redeemTencentBrowserSession: redeemSession = redeemTencentBrowserSession, - classifyTencentRedeemResult: classifyRedeemResult = classifyTencentRedeemResult, - markInventoryItemConsumed: markConsumedInventoryItem = markInventoryItemConsumed, - createTaskEvent: createRedeemTaskEvent = createTaskEvent, - invalidateReservedInventoryItem: invalidateReservedItem = invalidateReservedInventoryItem, - reserveInventoryForTask: reserveReplacementInventory = reserveInventoryForTask, - nowIso: getNowIso = nowIso, - redeemReplacementLimit = REDEEM_REPLACEMENT_LIMIT, - }: RedeemClaimTaskDeps = {}, -): Promise { - const attempts: RedeemAttemptSummary[] = [] - let currentInventoryItem = initialInventoryItem - let shouldReloadBeforeNextAttempt = false - - for (let attemptIndex = 1; attemptIndex <= redeemReplacementLimit; attemptIndex += 1) { - if (shouldReloadBeforeNextAttempt) { - await reloadRedeemSession(context.task.browser_session_id) - shouldReloadBeforeNextAttempt = false - } - - const session = await redeemSession(context.task.browser_session_id, { - code: currentInventoryItem.display_value, - }) - const finalRedeem = session.redeem?.final?.redeem || null - const classification = classifyRedeemResult(finalRedeem) - const attemptSummary = { - attempt: attemptIndex, - inventoryItemId: currentInventoryItem.id, - codeMasked: maskCode(currentInventoryItem.display_value), - credentialType: String(currentInventoryItem.credential_type || ''), - outcome: classification.outcome, - resultCode: resolveTencentRedeemResultCode(finalRedeem, classification), - resultMessage: classification.message, - } - - attempts.push(attemptSummary) - - if (classification.success) { - return { - session, - inventoryItem: currentInventoryItem, - classification, - attempts, - } - } - - if (classification.outcome === 'code_used') { - const updatedAt = getNowIso() - await markConsumedInventoryItem(currentInventoryItem.id, classification.message, updatedAt) - await createRedeemTaskEvent(context.task.id, 'claim_redeem_code_used', { - inventoryItemId: currentInventoryItem.id, - codeMasked: attemptSummary.codeMasked, - resultCode: attemptSummary.resultCode, - resultMessage: classification.message, - }, updatedAt) - } else if (classification.outcome === 'code_invalid') { - const updatedAt = getNowIso() - await invalidateReservedItem(currentInventoryItem.id, classification.message, updatedAt) - await createRedeemTaskEvent(context.task.id, 'claim_redeem_code_invalid', { - inventoryItemId: currentInventoryItem.id, - codeMasked: attemptSummary.codeMasked, - resultCode: attemptSummary.resultCode, - resultMessage: classification.message, - }, updatedAt) - } else { - throw createRedeemTaskStateError(classification.message, { - errorCode: 'claim_redeem_failed', - taskStatus: 'retry_pending', - inventoryStatus: 'reserved', - deliveryStatus: 'pending', - attempts, - classification, - }) - } - - if (attemptIndex >= redeemReplacementLimit) { - throw createRedeemTaskStateError('连续更换兑换码后仍未成功,请联系人工处理', { - errorCode: 'claim_redeem_replacement_limit_reached', - taskStatus: 'retry_pending', - inventoryStatus: 'pending', - deliveryStatus: 'pending', - attempts, - classification, - }) - } - - const replacement = await reserveReplacementInventory({ - skuCode: context.orderItem.sku_code, - taskId: context.task.id, - credentialType: currentInventoryItem.credential_type || 'tencent_code', - roleKey: 'primary_code', - inventoryGroupCodes: currentInventoryItem.inventory_group_code - ? [String(currentInventoryItem.inventory_group_code).trim()] - : null, - }) - - if (!replacement || !String(replacement.display_value || '').trim()) { - const exhaustedMessage = classification.outcome === 'code_used' - ? '兑换码已使用,且没有更多同类型可用 CDK 可继续重试' - : '兑换码错误,请确认库存数据;当前没有更多同类型可用 CDK 可继续重试' - - throw createRedeemTaskStateError(exhaustedMessage, { - errorCode: 'claim_inventory_replacement_exhausted', - taskStatus: 'waiting_inventory', - inventoryStatus: 'pending', - deliveryStatus: 'pending', - attempts, - classification, - }) - } - - const replacedAt = getNowIso() - await createRedeemTaskEvent(context.task.id, 'claim_redeem_inventory_replaced', { - previousInventoryItemId: currentInventoryItem.id, - previousCodeMasked: attemptSummary.codeMasked, - previousOutcome: classification.outcome, - nextInventoryItemId: replacement.id, - nextCodeMasked: maskCode(replacement.display_value), - credentialType: String(replacement.credential_type || currentInventoryItem.credential_type || ''), - inventoryGroupCode: String(replacement.inventory_group_code || currentInventoryItem.inventory_group_code || '').trim(), - }, replacedAt) - currentInventoryItem = replacement - shouldReloadBeforeNextAttempt = true - } - - throw createRedeemTaskStateError('兑换失败,请稍后重试', { - errorCode: 'claim_redeem_failed', - taskStatus: 'retry_pending', - inventoryStatus: 'pending', - deliveryStatus: 'pending', - attempts, - classification: null, - }) -} - -function createRedeemTaskStateError( - message: string, - payload: RedeemTaskStateErrorPayload = {}, -): RedeemTaskStateError { - const error = createHttpError(message, { - statusCode: payload.statusCode || 409, - errorCode: payload.errorCode || 'claim_redeem_failed', - }) as unknown as RedeemTaskStateError - - error.redeemTaskState = { - taskStatus: payload.taskStatus || 'retry_pending', - inventoryStatus: payload.inventoryStatus || 'pending', - deliveryStatus: payload.deliveryStatus || 'pending', - lastError: String(message || ''), - attempts: payload.attempts || [], - classification: payload.classification || null, - } - - return error -} - -export function resolveTencentRedeemResultCode( - finalRedeem: TencentRedeemPayload | null, - classification: Pick | null, -): string { - if (classification?.retCode != null) { - return String(classification.retCode) - } - - const value = finalRedeem?.iRet ?? finalRedeem?.ret - return value == null ? '' : String(value) -} diff --git a/apps/backend/src/services/claim/session/runtime.ts b/apps/backend/src/services/claim/session/runtime.ts deleted file mode 100644 index 7b3c0cfc..00000000 --- a/apps/backend/src/services/claim/session/runtime.ts +++ /dev/null @@ -1,107 +0,0 @@ -import { - getTencentBrowserSession, - getTencentBrowserSessionSummary, -} from '../../session/session.js' -import { updateTask } from '../../../repositories/task-repo.js' -import { nowIso } from '../../../utils/time.js' -import { isRecoverableSessionError, parseTaskState } from './shared.js' - -type JsonObject = Record - -export async function loadTaskSession(task: JsonObject, { includeQrImage = false }: { includeQrImage?: boolean } = {}) { - if (!task.browser_session_id) { - return { - task, - session: null, - } - } - - try { - const session = includeQrImage - ? await getTencentBrowserSession(task.browser_session_id) - : await getTencentBrowserSessionSummary(task.browser_session_id) - - return { - task, - session, - } - } catch (error) { - if (!isRecoverableSessionError(error)) { - throw error - } - - const nextTask = await clearTaskSession(task, { - lastError: '浏览器会话已失效,请重新初始化登录', - }) - - return { - task: nextTask, - session: null, - } - } -} - -export async function syncTaskWithSession(task: JsonObject, session: JsonObject) { - const activityInfo = session.activityInfo || null - const patch: JsonObject = { - login_type: String(session.loginType || task.login_type || ''), - updated_at: nowIso(), - } - - if (activityInfo?.nickname) { - patch.nickname = String(activityInfo.nickname) - } - - if (activityInfo?.role?.ready) { - patch.role_id = String(activityInfo.role.roleId || '') - patch.role_name = String(activityInfo.role.roleName || '') - patch.area = String(activityInfo.role.area || '') - patch.partition_name = String(activityInfo.role.partition || '') - } - - if (task.task_status === 'link_generated') { - patch.task_status = 'claimed' - patch.user_action_status = 'claimed' - patch.claimed_at = task.claimed_at || nowIso() - } - - if (session.review?.capturedAt) { - patch.state_json = JSON.stringify({ - ...parseTaskState(task), - reviewScreenshotReady: true, - reviewCapturedAt: String(session.review.capturedAt || ''), - reviewRoleId: String(session.review.roleId || ''), - reviewRoleName: String(session.review.roleName || ''), - }) - } - - if (session.status === 'redeemed' && session.artifacts?.hasScreenshot) { - patch.screenshot_path = task.screenshot_path || '' - } - - return updateTask(task.id, patch) -} - -export async function clearTaskSession(task: JsonObject, { lastError = '' }: { lastError?: string } = {}) { - const shouldResetClaimProgress = ['link_generated', 'claimed', 'role_confirmed'].includes(String(task.task_status || '')) - const nextTaskStatus = shouldResetClaimProgress ? 'link_generated' : task.task_status - const nextUserActionStatus = shouldResetClaimProgress ? 'pending_claim' : task.user_action_status - const patch = { - task_status: nextTaskStatus, - user_action_status: nextUserActionStatus, - browser_session_id: '', - login_type: '', - nickname: '', - role_id: '', - role_name: '', - area: '', - partition_name: '', - artifacts_json: '{}', - state_json: '{}', - role_confirmed_at: nextTaskStatus === 'link_generated' ? null : task.role_confirmed_at, - last_error: String(lastError || ''), - updated_at: nowIso(), - } - - return updateTask(task.id, patch) -} diff --git a/apps/backend/src/services/claim/session/shared.ts b/apps/backend/src/services/claim/session/shared.ts deleted file mode 100644 index b71d661a..00000000 --- a/apps/backend/src/services/claim/session/shared.ts +++ /dev/null @@ -1,208 +0,0 @@ -import { buildClaimUrl } from '../claim-service.js' -import { createHttpError } from '../../../utils/http.js' -import { maskCode as maskCodeValue } from '../../../utils/masking.js' -import { formatFenToAmount, normalizeFen } from '../../../utils/money.js' -import { - parseTaskContext as parseTaskContextValue, - parseTaskState as parseTaskStateValue, -} from '../../../utils/task-json.js' - -export const CLAIM_TERMINAL_STATUSES = new Set(['expired', 'closed']) -export const REDEEM_REPLACEMENT_LIMIT = 10 -export const KUAISHOU_CLOUD_CLAIM_GUIDE_BASE_PATH = '/kuaishou-cloud-guide' - -export function buildClaimDetailPayload({ claimToken, task, order, orderItem, session }) { - const screenshotReady = Boolean(task.screenshot_path) || Boolean(session?.artifacts?.hasScreenshot) - const screenshotUrl = screenshotReady ? `/api/v1/claim/${claimToken.token}/screenshot` : '' - const finalRedeem = session?.redeem?.final?.redeem || null - const kuaishouCloudFulfillment = mapClaimKuaishouCloudFulfillment(task, order) - - return { - tokenStatus: claimToken.status, - claimUrl: buildClaimUrl(claimToken.token), - flowType: kuaishouCloudFulfillment ? 'kuaishou_cloud' : 'tencent_claim', - task: { - taskId: task.id, - taskNo: task.task_no, - status: task.task_status, - executorKey: task.executor_key || '', - requiresSupportReview: isAssistedClaimTask(task), - expiresAt: claimToken.expired_at, - claimedAt: task.claimed_at, - roleConfirmedAt: task.role_confirmed_at, - redeemedAt: task.redeemed_at, - loginType: task.login_type, - lastError: task.last_error, - browserSessionId: task.browser_session_id, - }, - order: { - orderId: order.id, - platform: order.platform, - platformOrderId: order.platform_order_id, - payStatus: order.pay_status, - orderStatus: order.order_status, - totalAmount: formatFenToAmount(order.total_amount), - totalAmountFen: normalizeFen(order.total_amount), - currency: order.currency, - }, - orderItem: { - orderItemId: orderItem.id, - skuCode: orderItem.sku_code, - skuName: orderItem.sku_name, - quantity: orderItem.quantity, - }, - session, - kuaishouCloudFulfillment, - result: task.redeemed_at || session?.status === 'redeemed' - ? { - resultCode: String(task.result_code || finalRedeem?.iRet || finalRedeem?.ret || ''), - resultMessage: String(task.result_message || finalRedeem?.sMsg || finalRedeem?.msg || session?.notice || ''), - screenshotReady, - screenshotUrl, - } - : null, - } -} - -export function mapClaimKuaishouCloudFulfillment(task, order) { - if (String(task?.executor_key || '').trim() !== 'kuaishou_ct_assisted') { - return null - } - - const source = parseTaskContext(task).kuaishouCloudFulfillment - if (!source || typeof source !== 'object') { - return null - } - - const binding = source.binding && typeof source.binding === 'object' ? source.binding : {} - const role = source.role && typeof source.role === 'object' ? source.role : {} - const purchase = source.purchase && typeof source.purchase === 'object' ? source.purchase : {} - const ticket = source.ticket && typeof source.ticket === 'object' ? source.ticket : {} - const dispatch = source.dispatch && typeof source.dispatch === 'object' ? source.dispatch : {} - const returnNumber = source.returnNumber && typeof source.returnNumber === 'object' ? source.returnNumber : {} - const consume = source.consume && typeof source.consume === 'object' ? source.consume : {} - - return { - flowType: 'kuaishou_cloud', - shopId: String(order?.shop_id || '').trim(), - shopName: String(order?.shop_name || '').trim(), - guideImages: [ - `${KUAISHOU_CLOUD_CLAIM_GUIDE_BASE_PATH}/1.png`, - `${KUAISHOU_CLOUD_CLAIM_GUIDE_BASE_PATH}/2.png`, - `${KUAISHOU_CLOUD_CLAIM_GUIDE_BASE_PATH}/3.png`, - ], - ticket: { - code: String(ticket.code || '').trim(), - status: String(ticket.status || 'pending').trim() || 'pending', - capturedAt: ticket.capturedAt || null, - verifiedAt: ticket.verifiedAt || null, - goodsTitle: String(ticket.goodsTitle || '').trim(), - leftCount: Number(ticket.leftCount || 0) || 0, - }, - binding: { - prepareStatus: String(binding.prepareStatus || 'pending').trim() || 'pending', - cloudSourceKey: String(binding.cloudSourceKey || '').trim(), - skuId: Number(binding.skuId || 0) || 0, - skuName: String(binding.skuName || '').trim(), - vnKey: String(binding.vnKey || '').trim(), - vnId: Number(binding.vnId || 0) || 0, - bindUrl: String(binding.bindUrl || '').trim(), - bindPreparedAt: binding.bindPreparedAt || null, - bindExpiresAt: binding.bindExpiresAt || null, - bindProbeAt: binding.bindProbeAt || null, - bindProbeStatus: String(binding.bindProbeStatus || '').trim(), - bindProbeMessage: String(binding.bindProbeMessage || '').trim(), - vnPhone: String(binding.vnPhone || '').trim(), - roleName: String(binding.roleName || '').trim(), - roleId: String(binding.roleId || '').trim(), - }, - role: { - status: String(role.status || 'pending').trim() || 'pending', - name: String(role.name || binding.roleName || '').trim(), - rid: String(role.rid || binding.roleId || '').trim(), - refreshedAt: role.refreshedAt || null, - errorMessage: String(role.errorMessage || '').trim(), - }, - purchase: { - autoBuyEnabled: purchase.autoBuyEnabled !== false, - minAssetReserve: Number(purchase.minAssetReserve || 0) || 0, - usedKnapsack: purchase.usedKnapsack === true, - purchaseTriggered: purchase.purchaseTriggered === true, - assetBefore: Number(purchase.assetBefore || 0) || 0, - assetAfter: Number(purchase.assetAfter || 0) || 0, - purchaseAt: purchase.purchaseAt || null, - }, - dispatch: { - status: String(dispatch.status || 'pending').trim() || 'pending', - dispatchAt: dispatch.dispatchAt || null, - note: String(dispatch.note || '').trim(), - }, - returnNumber: { - status: String(returnNumber.status || 'pending').trim() || 'pending', - returnedAt: returnNumber.returnedAt || null, - }, - consume: { - status: String(consume.status || 'pending').trim() || 'pending', - shopId: String(consume.shopId || '').trim(), - shopName: String(consume.shopName || '').trim(), - consumedAt: consume.consumedAt || null, - errorMessage: String(consume.errorMessage || '').trim(), - }, - } -} - -export function assertTaskCanProceed(task) { - if (CLAIM_TERMINAL_STATUSES.has(String(task.task_status || ''))) { - throw createHttpError('当前任务已经结束,不能继续操作', { - statusCode: 410, - errorCode: 'claim_task_closed', - }) - } -} - -export function assertPublicClaimActionAllowed(task, action) { - if (!isAssistedClaimTask(task)) { - return - } - - const message = action === 'confirm' - ? '当前商品需要客服复核角色,请登录后联系人工继续' - : '当前商品需要客服确认后再执行兑换,请联系人工继续' - - throw createHttpError(message, { - statusCode: 409, - errorCode: 'claim_support_review_required', - }) -} - -export function mergeTaskContext(task, patch = {}) { - return { - ...parseTaskContext(task), - ...patch, - } -} - -export function parseTaskState(task) { - return parseTaskStateValue(task) -} - -export function isAssistedClaimTask(task) { - return String(task?.executor_key || '').trim() === 'tencent_claim_assisted' -} - -export function parseTaskContext(task) { - return parseTaskContextValue(task) -} - -export function normalizeClaimLoginType(loginType) { - return String(loginType || '').trim() === 'wx' ? 'wx' : 'qq' -} - -export function isRecoverableSessionError(error) { - const errorCode = String(error?.errorCode || error?.code || '').trim() - return errorCode === 'session_not_found' || errorCode === 'session_closed' -} - -export function maskCode(value) { - return maskCodeValue(value, { shortMask: '****' }) -} diff --git a/apps/backend/src/services/fulfillment/kuaishou-cloud-task-service.ts b/apps/backend/src/services/fulfillment/kuaishou-cloud-task-service.ts deleted file mode 100644 index 702f6d75..00000000 --- a/apps/backend/src/services/fulfillment/kuaishou-cloud-task-service.ts +++ /dev/null @@ -1,778 +0,0 @@ -import { createTaskEvent } from "../../repositories/task-event-repo.js"; -import { updateTask } from "../../repositories/task-repo.js"; -import { buildClaimUrl, createTaskClaimToken } from "../claim/claim-service.js"; -import { - buyCloudtentaclesSku, - getCloudtentaclesAsset, - listCloudtentaclesSku, -} from "../platforms/cloudtentacles/catalog-service.js"; -import { getCloudtentaclesKnapsack } from "../platforms/cloudtentacles/knapsack-service.js"; -import { - backCloudtentaclesVirtualNumber, - getCloudtentaclesBindInfo, - probeCloudtentaclesBindUrl, -} from "../platforms/cloudtentacles/virtual-number-service.js"; -import { resolveCloudtentaclesConfig } from "../platforms/cloudtentacles/shared.js"; -import { - notifyKuaishouCloudAssetNotEnough, - notifyKuaishouCloudBindUrlRefreshFailed, -} from "../notification/domain-notifications.js"; -import { createHttpError } from "../../utils/http.js"; -import { nowIso } from "../../utils/time.js"; -import { - KUAISHOU_CLOUD_FIXED_VN_KEY, - isKuaishouCloudBindUrlFresh, - isKuaishouCloudTask, - maskPhone, - normalizeKuaishouCloudFlow, - normalizeKuaishouCloudRoleInfo, - resolveKuaishouCloudBindUrlExpiresAt, - type JsonObject, -} from "./kuaishou-cloud/domain.js"; -import { - prepareKuaishouCloudBindResourceWithFallback, - resolveKuaishouCloudBindingResources, - resolveKuaishouCloudVnKeyCandidates, -} from "./kuaishou-cloud/binding-resources.js"; -import { - resolvePersistedCloudtentaclesContextWithFallback, -} from "./kuaishou-cloud/cloudtentacles-context.js"; -import { - getTaskClaimExpiresAt, - isClaimExpired, - normalizeActor, - parseTaskContext, -} from "./kuaishou-cloud/task-context.js"; - -export { - isKuaishouCloudBindUrlFresh, - isKuaishouCloudTask, - maskCode, - maskPhone, - normalizeKuaishouCloudFlow, - normalizeKuaishouCloudRoleInfo, - resolveKuaishouCloudBindUrlExpiresAt, -} from "./kuaishou-cloud/domain.js"; -export { - resolvePersistedCloudtentaclesContext, - resolvePersistedCloudtentaclesContextWithFallback, -} from "./kuaishou-cloud/cloudtentacles-context.js"; - -export async function ensureTaskClaimLink(task) { - const tokenStatus = String(task?.primary_claim_token_status || "").trim(); - const token = String( - task?.primary_claim_token || task?.claim_token || "" - ).trim(); - const expiredAt = getTaskClaimExpiresAt(task); - - if (tokenStatus === "active" && token && !isClaimExpired(expiredAt)) { - return { - token, - expiredAt, - claimUrl: buildClaimUrl(token), - }; - } - - const claimToken = await createTaskClaimToken(task.id); - return { - token: claimToken.token, - expiredAt: claimToken.expired_at, - claimUrl: claimToken.claimUrl, - }; -} - -export async function prepareKuaishouCloudFulfillmentTask( - task, - options: JsonObject = {} -) { - if (!isKuaishouCloudTask(task)) { - throw createHttpError("当前任务不是快手 Cloud 履约任务", { - statusCode: 409, - errorCode: "kuaishou_cloud_task_invalid", - }); - } - - const now = nowIso(); - const actor = normalizeActor(options.actor); - const force = options.force === true; - const taskContext = parseTaskContext(task); - const flow = normalizeKuaishouCloudFlow(taskContext.kuaishouCloudFulfillment); - const claimLinkState = await ensureTaskClaimLink(task); - - if ( - !force && - flow.binding.prepareStatus === "ready" && - flow.binding.vnId > 0 && - flow.binding.vnPhone && - flow.binding.bindUrl - ) { - if (!isKuaishouCloudBindUrlFresh(flow)) { - return refreshKuaishouCloudTaskBindUrl(task, { - source: options.source || "system_refresh_expired_bind_url", - actor, - claimLinkState, - }); - } - - let readyTask = task; - - if (String(task.task_status || "").trim() !== "waiting_binding") { - readyTask = await updateTask(task.id, { - task_status: "waiting_binding", - claim_token: claimLinkState.token || task.claim_token || "", - claim_expires_at: - claimLinkState.expiredAt || getTaskClaimExpiresAt(task), - updated_at: now, - }); - } - - return { - task: readyTask, - claimUrl: claimLinkState.claimUrl, - token: claimLinkState.token, - flow, - }; - } - - const cloudContext = resolvePersistedCloudtentaclesContextWithFallback( - flow.binding.cloudSourceKey || "default", - flow.binding.cloudSourceKeyFallbacks || [] - ); - const [knapsack, skuList] = await Promise.all([ - getCloudtentaclesKnapsack(cloudContext), - listCloudtentaclesSku(cloudContext), - ]); - const resolvedBinding = resolveKuaishouCloudBindingResources(flow, { - skuItems: Array.isArray(skuList.items) ? skuList.items : [], - knapsackItems: Array.isArray(knapsack.items) ? knapsack.items : [], - }); - const vnKeyCandidates = resolveKuaishouCloudVnKeyCandidates({ - flow, - binding: resolvedBinding, - }); - - if (!resolvedBinding.skuId || vnKeyCandidates.length === 0) { - throw createHttpError("当前任务缺少可用 cloud 资源,无法自动准备绑定", { - statusCode: 409, - errorCode: "kuaishou_cloud_missing_binding_config", - }); - } - - const flowWithResolvedBinding = { - ...flow, - binding: { - ...flow.binding, - skuId: resolvedBinding.skuId, - skuName: resolvedBinding.skuName, - vnKey: KUAISHOU_CLOUD_FIXED_VN_KEY, - }, - }; - - const knapsackItem = resolvedBinding.knapsackItem; - let usedKnapsack = Number(knapsackItem?.count || 0) > 0; - let purchaseTriggered = false; - let assetBefore = 0; - let assetAfter = 0; - - if (!usedKnapsack) { - if (!flowWithResolvedBinding.purchase.autoBuyEnabled) { - throw createHttpError("背包中没有现成库存,且当前配置未开启自动购买", { - statusCode: 409, - errorCode: "kuaishou_cloud_auto_buy_disabled", - }); - } - - const asset = await getCloudtentaclesAsset(cloudContext); - const targetSku = resolvedBinding.skuItem; - - if (!targetSku) { - throw createHttpError( - `cloudtentacles 未找到 SKU ${flowWithResolvedBinding.binding.skuId}`, - { - statusCode: 404, - errorCode: "kuaishou_cloud_sku_not_found", - } - ); - } - - assetBefore = Number(asset.asset || 0) || 0; - const targetPrice = Number(targetSku.price || 0) || 0; - const requiredAsset = - targetPrice + flowWithResolvedBinding.purchase.minAssetReserve; - if (assetBefore < requiredAsset) { - await notifyKuaishouCloudAssetNotEnough({ - task, - flow: flowWithResolvedBinding, - assetBefore, - requiredAsset, - skuName: targetSku.name, - }); - throw createHttpError( - `余额不足,当前 ${assetBefore},至少需要 ${requiredAsset}`, - { - statusCode: 409, - errorCode: "kuaishou_cloud_asset_not_enough", - } - ); - } - - await buyCloudtentaclesSku({ - ...cloudContext, - id: flowWithResolvedBinding.binding.skuId, - count: 1, - }); - purchaseTriggered = true; - - const assetResult = await getCloudtentaclesAsset(cloudContext); - assetAfter = Number(assetResult.asset || 0) || 0; - } - - const preparedBinding = await prepareKuaishouCloudBindResourceWithFallback({ - cloudContext, - vnKeyCandidates, - }); - - const nextContext = { - ...taskContext, - kuaishouCloudFulfillment: { - ...flowWithResolvedBinding, - binding: { - ...flowWithResolvedBinding.binding, - resolvedSourceKey: cloudContext.resolvedSourceKey, - vnKey: preparedBinding.vnKey, - prepareStatus: "ready", - vnId: preparedBinding.vnId, - vnPhone: preparedBinding.vnPhone, - bindUrl: preparedBinding.bindUrl, - bindPreparedAt: now, - bindExpiresAt: resolveKuaishouCloudBindUrlExpiresAt(now), - bindProbeAt: null, - bindProbeStatus: "pending", - bindProbeMessage: "", - roleName: "", - roleId: "", - }, - role: { - status: "pending", - name: "", - rid: "", - refreshedAt: null, - errorMessage: "", - rawInfo: null, - }, - purchase: { - ...flowWithResolvedBinding.purchase, - usedKnapsack, - purchaseTriggered, - assetBefore, - assetAfter, - purchaseAt: purchaseTriggered - ? now - : flowWithResolvedBinding.purchase.purchaseAt, - }, - }, - }; - - const updatedTask = await updateTask(task.id, { - task_status: "waiting_binding", - inventory_status: "not_required", - user_action_status: "pending_claim", - claim_token: claimLinkState.token || task.claim_token || "", - claim_expires_at: claimLinkState.expiredAt || getTaskClaimExpiresAt(task), - role_id: "", - role_name: "", - last_error: "", - context_json: JSON.stringify(nextContext), - updated_at: now, - }); - - await createTaskEvent( - task.id, - "kuaishou_cloud_binding_prepared", - { - source: String(options.source || "system").trim() || "system", - skuId: flowWithResolvedBinding.binding.skuId, - skuName: flowWithResolvedBinding.binding.skuName, - vnKey: preparedBinding.vnKey, - vnId: preparedBinding.vnId, - vnPhoneMasked: maskPhone(preparedBinding.vnPhone), - purchaseTriggered, - usedKnapsack, - resolvedByName: resolvedBinding.resolvedByName, - actor, - }, - now - ); - - return { - task: updatedTask, - claimUrl: claimLinkState.claimUrl, - token: claimLinkState.token, - flow: normalizeKuaishouCloudFlow(nextContext.kuaishouCloudFulfillment), - }; -} - -export async function refreshKuaishouCloudTaskBindUrl( - task, - options: JsonObject = {} -) { - if (!isKuaishouCloudTask(task)) { - throw createHttpError("当前任务不是快手 Cloud 履约任务", { - statusCode: 409, - errorCode: "kuaishou_cloud_task_invalid", - }); - } - - const now = nowIso(); - const actor = normalizeActor(options.actor); - const taskContext = parseTaskContext(task); - const flow = normalizeKuaishouCloudFlow(taskContext.kuaishouCloudFulfillment); - - if (!flow.binding.vnId || !flow.binding.vnKey) { - throw createHttpError("当前任务缺少可刷新绑定链接的虚拟号信息", { - statusCode: 409, - errorCode: "kuaishou_cloud_missing_bind_url_context", - }); - } - - const effectiveSourceKey = - flow.binding.resolvedSourceKey || flow.binding.cloudSourceKey || "default"; - const cloudContext = resolvePersistedCloudtentaclesContextWithFallback( - effectiveSourceKey, - flow.binding.cloudSourceKeyFallbacks || [] - ); - const oldVnKey = flow.binding.vnKey; - const oldVnId = flow.binding.vnId; - const oldVnPhone = flow.binding.vnPhone; - - await backCloudtentaclesVirtualNumber({ - ...cloudContext, - key: oldVnKey, - id: oldVnId, - }); - - await createTaskEvent( - task.id, - "kuaishou_cloud_expired_bind_number_returned", - { - source: String(options.source || "system").trim() || "system", - vnKey: oldVnKey, - vnId: oldVnId, - vnPhoneMasked: maskPhone(oldVnPhone), - actor, - }, - now - ); - - let preparedBinding; - try { - preparedBinding = await prepareKuaishouCloudBindResourceWithFallback({ - cloudContext, - vnKeyCandidates: resolveKuaishouCloudVnKeyCandidates({ - flow, - binding: flow.binding, - }), - }); - - if (!String(preparedBinding.bindUrl || "").trim()) { - throw createHttpError("cloudtentacles 未返回新的绑定链接", { - statusCode: 502, - errorCode: "kuaishou_cloud_empty_bind_url", - }); - } - } catch (error) { - const failedTask = await markKuaishouCloudBindUrlRefreshFailed(task, { - taskContext, - flow, - now, - actor, - error, - }); - - return { - task: failedTask, - claimUrl: buildClaimUrl( - String(task.primary_claim_token || task.claim_token || "") - ), - token: String(task.primary_claim_token || task.claim_token || ""), - flow: normalizeKuaishouCloudFlow( - parseTaskContext(failedTask).kuaishouCloudFulfillment - ), - }; - } - - const nextContext = { - ...taskContext, - kuaishouCloudFulfillment: { - ...flow, - binding: { - ...flow.binding, - prepareStatus: "ready", - vnKey: preparedBinding.vnKey, - vnId: preparedBinding.vnId, - vnPhone: preparedBinding.vnPhone, - bindUrl: preparedBinding.bindUrl, - bindPreparedAt: now, - bindExpiresAt: resolveKuaishouCloudBindUrlExpiresAt(now), - bindProbeAt: null, - bindProbeStatus: "pending", - bindProbeMessage: "", - roleName: - String(task.task_status || "").trim() === "role_confirmed" - ? flow.binding.roleName - : "", - roleId: - String(task.task_status || "").trim() === "role_confirmed" - ? flow.binding.roleId - : "", - }, - role: - String(task.task_status || "").trim() === "role_confirmed" - ? flow.role - : { - status: "pending", - name: "", - rid: "", - refreshedAt: null, - errorMessage: "", - rawInfo: null, - }, - }, - }; - - const claimLinkState = - options.claimLinkState || (await ensureTaskClaimLink(task)); - const updatedTask = await updateTask(task.id, { - task_status: - String(task.task_status || "").trim() === "role_confirmed" - ? task.task_status - : "waiting_binding", - claim_token: claimLinkState.token || task.claim_token || "", - claim_expires_at: claimLinkState.expiredAt || getTaskClaimExpiresAt(task), - role_id: - String(task.task_status || "").trim() === "role_confirmed" - ? task.role_id - : "", - role_name: - String(task.task_status || "").trim() === "role_confirmed" - ? task.role_name - : "", - last_error: "", - context_json: JSON.stringify(nextContext), - updated_at: now, - }); - - await createTaskEvent( - task.id, - "kuaishou_cloud_bind_url_refreshed", - { - source: String(options.source || "system").trim() || "system", - oldVnId, - oldVnPhoneMasked: maskPhone(oldVnPhone), - vnKey: preparedBinding.vnKey, - vnId: preparedBinding.vnId, - vnPhoneMasked: maskPhone(preparedBinding.vnPhone), - actor, - }, - now - ); - - return { - task: updatedTask, - claimUrl: claimLinkState.claimUrl, - token: claimLinkState.token, - flow: normalizeKuaishouCloudFlow(nextContext.kuaishouCloudFulfillment), - }; -} - -export async function probeKuaishouCloudTaskBindUrl( - task, - options: JsonObject = {} -) { - if (!isKuaishouCloudTask(task)) { - throw createHttpError("当前任务不是快手 Cloud 履约任务", { - statusCode: 409, - errorCode: "kuaishou_cloud_task_invalid", - }); - } - - const now = nowIso(); - const taskContext = parseTaskContext(task); - const flow = normalizeKuaishouCloudFlow(taskContext.kuaishouCloudFulfillment); - - if (!flow.binding.bindUrl) { - return { - task, - flow, - probe: null, - }; - } - - const probeIntervalMs = - Number(resolveCloudtentaclesConfig().bindUrlProbeIntervalSeconds || 30) * - 1000; - if ( - !options.force && - flow.binding.bindProbeAt && - Date.now() - Date.parse(flow.binding.bindProbeAt) < probeIntervalMs - ) { - return { - task, - flow, - probe: null, - }; - } - - const probe = await probeCloudtentaclesBindUrl({ - bindUrl: flow.binding.bindUrl, - }); - - if (probe.expired || !isKuaishouCloudBindUrlFresh(flow)) { - const refreshed = await refreshKuaishouCloudTaskBindUrl(task, { - source: options.source || "claim_page_bind_url_expired", - actor: options.actor || { source: "system" }, - }); - return { - ...refreshed, - probe, - }; - } - - const probeRoleInfo = normalizeKuaishouCloudRoleInfo(probe.roleInfo); - const hasRoleInfo = Boolean(probeRoleInfo.name || probeRoleInfo.rid); - const nextContext = { - ...taskContext, - kuaishouCloudFulfillment: { - ...flow, - binding: { - ...flow.binding, - bindProbeAt: now, - bindProbeStatus: probe.valid ? "valid" : "invalid", - bindProbeMessage: String(probe.message || probe.reason || "").trim(), - roleName: hasRoleInfo ? probeRoleInfo.name : flow.binding.roleName, - roleId: hasRoleInfo ? probeRoleInfo.rid : flow.binding.roleId, - }, - role: { - ...flow.role, - status: hasRoleInfo ? "ready" : flow.role.status, - name: hasRoleInfo ? probeRoleInfo.name : flow.role.name, - rid: hasRoleInfo ? probeRoleInfo.rid : flow.role.rid, - refreshedAt: hasRoleInfo ? now : flow.role.refreshedAt, - errorMessage: hasRoleInfo ? "" : flow.role.errorMessage, - rawInfo: hasRoleInfo ? probeRoleInfo.rawInfo : flow.role.rawInfo, - }, - }, - }; - - const updatedTask = await updateTask(task.id, { - role_id: hasRoleInfo ? probeRoleInfo.rid : task.role_id, - role_name: hasRoleInfo ? probeRoleInfo.name : task.role_name, - context_json: JSON.stringify(nextContext), - updated_at: now, - }); - - return { - task: updatedTask, - flow: normalizeKuaishouCloudFlow(nextContext.kuaishouCloudFulfillment), - probe, - }; -} - -/** - * @param {any} task - * @param {{ taskContext?: any, flow?: any, now?: string, actor?: any, error?: unknown }} [input] - */ -async function markKuaishouCloudBindUrlRefreshFailed( - task, - { taskContext, flow, now, actor, error }: JsonObject = {} -) { - const errorMessage = - error instanceof Error - ? error.message - : String(error || "新绑定链接准备失败"); - const nextContext = { - ...taskContext, - kuaishouCloudFulfillment: { - ...flow, - binding: { - ...flow.binding, - prepareStatus: "pending", - vnId: 0, - vnPhone: "", - bindUrl: "", - bindPreparedAt: null, - bindExpiresAt: null, - bindProbeAt: now, - bindProbeStatus: "refresh_failed", - bindProbeMessage: errorMessage, - roleName: "", - roleId: "", - }, - role: { - status: "pending", - name: "", - rid: "", - refreshedAt: now, - errorMessage: - "绑定链接已过期,旧号码已退还,新链接准备失败,请稍后刷新或联系客服处理", - rawInfo: null, - }, - }, - }; - - const updatedTask = await updateTask(task.id, { - task_status: "pending_binding_prepare", - role_id: "", - role_name: "", - last_error: `绑定链接过期,旧号码已退还,新链接准备失败:${errorMessage}`, - context_json: JSON.stringify(nextContext), - updated_at: now, - }); - - await createTaskEvent( - task.id, - "kuaishou_cloud_bind_url_refresh_failed", - { - source: "system_refresh_expired_bind_url", - errorMessage, - actor, - }, - now - ); - - await notifyKuaishouCloudBindUrlRefreshFailed({ - task: updatedTask, - errorMessage, - }); - - return updatedTask; -} - -export async function refreshKuaishouCloudTaskRoleInfo( - task, - options: JsonObject = {} -) { - if (!isKuaishouCloudTask(task)) { - throw createHttpError("当前任务不是快手 Cloud 履约任务", { - statusCode: 409, - errorCode: "kuaishou_cloud_task_invalid", - }); - } - - const now = nowIso(); - const actor = normalizeActor(options.actor); - const taskContext = parseTaskContext(task); - const flow = normalizeKuaishouCloudFlow(taskContext.kuaishouCloudFulfillment); - - if (!flow.binding.vnId || !flow.binding.vnKey) { - throw createHttpError( - "当前任务还没有可查询的绑定角色信息,请先准备绑定资源", - { - statusCode: 409, - errorCode: "kuaishou_cloud_missing_bind_info_context", - } - ); - } - - if (flow.binding.bindUrl) { - const probed = await probeKuaishouCloudTaskBindUrl(task, { - source: options.source || "system_role_refresh_bind_url_probe", - actor, - force: options.forceProbe === true, - }); - const probedFlow = normalizeKuaishouCloudFlow( - parseTaskContext(probed.task).kuaishouCloudFulfillment - ); - if (probed.probe?.expired) { - return { - task: probed.task, - roleInfo: normalizeKuaishouCloudRoleInfo(null), - flow: probedFlow, - }; - } - - if ( - (probed.probe === null || probed.probe?.valid) && - (probedFlow.binding.roleName || probedFlow.binding.roleId) - ) { - return { - task: probed.task, - roleInfo: { - name: probedFlow.binding.roleName, - rid: probedFlow.binding.roleId, - rawInfo: probedFlow.role.rawInfo, - }, - flow: probedFlow, - }; - } - } - - const effectiveSourceKey = - flow.binding.resolvedSourceKey || flow.binding.cloudSourceKey || "default"; - const cloudContext = resolvePersistedCloudtentaclesContextWithFallback( - effectiveSourceKey, - flow.binding.cloudSourceKeyFallbacks || [] - ); - const bindInfoResult = await getCloudtentaclesBindInfo({ - ...cloudContext, - key: flow.binding.vnKey, - id: flow.binding.vnId, - }); - - const bindInfo = normalizeKuaishouCloudRoleInfo(bindInfoResult.bindInfo); - const hasRoleInfo = Boolean(bindInfo.name || bindInfo.rid); - const nextContext = { - ...taskContext, - kuaishouCloudFulfillment: { - ...flow, - binding: { - ...flow.binding, - roleName: hasRoleInfo ? bindInfo.name : "", - roleId: hasRoleInfo ? bindInfo.rid : "", - }, - role: { - status: hasRoleInfo ? "ready" : "pending", - name: hasRoleInfo ? bindInfo.name : "", - rid: hasRoleInfo ? bindInfo.rid : "", - refreshedAt: now, - errorMessage: hasRoleInfo - ? "" - : "当前还没有查询到角色信息,请完成绑定后稍等片刻再试", - rawInfo: bindInfo.rawInfo, - }, - }, - }; - - const updatedTask = await updateTask(task.id, { - role_id: hasRoleInfo ? bindInfo.rid : "", - role_name: hasRoleInfo ? bindInfo.name : "", - context_json: JSON.stringify(nextContext), - updated_at: now, - }); - - if (options.recordEvent !== false) { - await createTaskEvent( - task.id, - "kuaishou_cloud_role_info_refreshed", - { - source: String(options.source || "system").trim() || "system", - roleName: bindInfo.name, - roleId: bindInfo.rid, - vnId: flow.binding.vnId, - actor, - }, - now - ); - } - - return { - task: updatedTask, - roleInfo: bindInfo, - flow: normalizeKuaishouCloudFlow(nextContext.kuaishouCloudFulfillment), - }; -} - -export { - dispatchKuaishouCloudFulfillmentTask, - returnKuaishouCloudFulfillmentTask, -} from "./kuaishou-cloud/task-finalization.js"; diff --git a/apps/backend/src/services/fulfillment/kuaishou-cloud/account-selector.test.ts b/apps/backend/src/services/fulfillment/kuaishou-cloud/account-selector.test.ts new file mode 100644 index 00000000..7ead69ee --- /dev/null +++ b/apps/backend/src/services/fulfillment/kuaishou-cloud/account-selector.test.ts @@ -0,0 +1,124 @@ +import assert from 'node:assert/strict' +import test from 'node:test' + +import { selectCloudtentaclesSourceForFulfillment } from './account-selector.js' + +const contexts = { + 'account-a': { + baseUrl: 'https://cloud.example.com', + token: 'token-a', + deviceId: '-', + deviceType: 0, + resolvedSourceKey: 'account-a', + }, + 'account-b': { + baseUrl: 'https://cloud.example.com', + token: 'token-b', + deviceId: '-', + deviceType: 0, + resolvedSourceKey: 'account-b', + }, +} + +test('selectCloudtentaclesSourceForFulfillment 已固定账号时不按压力切换', async () => { + const selection = await selectCloudtentaclesSourceForFulfillment( + { + binding: { + resolvedSourceKey: 'account-a', + cloudSourceKeys: ['account-a', 'account-b'], + }, + }, + { + resolveContextBySourceKeys: (sourceKeys) => { + const key = String(sourceKeys[0] || '').trim() + return contexts[key as keyof typeof contexts] + }, + listSourceLoadStats: async () => [ + { sourceKey: 'account-a', activeCount: 99, redeemingCount: 99 }, + { sourceKey: 'account-b', activeCount: 0, redeemingCount: 0 }, + ], + }, + ) + + assert.equal(selection.sourceKey, 'account-a') + assert.equal(selection.fixed, true) +}) + +test('selectCloudtentaclesSourceForFulfillment 多候选时选择压力最低账号', async () => { + const selection = await selectCloudtentaclesSourceForFulfillment( + { + binding: { + cloudSourceKeys: ['account-a', 'account-b'], + }, + }, + { + resolveContextBySourceKeys: (sourceKeys) => { + const key = String(sourceKeys[0] || '').trim() + return contexts[key as keyof typeof contexts] + }, + listSourceLoadStats: async () => [ + { sourceKey: 'account-a', activeCount: 3, redeemingCount: 1 }, + { sourceKey: 'account-b', activeCount: 0, redeemingCount: 0 }, + ], + }, + ) + + try { + assert.equal(selection.sourceKey, 'account-b') + assert.equal(selection.fixed, false) + } finally { + selection.release() + } +}) + +test('selectCloudtentaclesSourceForFulfillment 会跳过不可用账号', async () => { + const selection = await selectCloudtentaclesSourceForFulfillment( + { + binding: { + cloudSourceKeys: ['account-a', 'account-b'], + }, + }, + { + resolveContextBySourceKeys: (sourceKeys) => { + const key = String(sourceKeys[0] || '').trim() + if (key === 'account-a') { + throw new Error('账号已停用') + } + return contexts[key as keyof typeof contexts] + }, + listSourceLoadStats: async () => [], + }, + ) + + try { + assert.equal(selection.sourceKey, 'account-b') + assert.equal(selection.candidateCount, 1) + } finally { + selection.release() + } +}) + +test('selectCloudtentaclesSourceForFulfillment 用进程内占位分散并发准备', async () => { + const deps = { + resolveContextBySourceKeys: (sourceKeys: unknown[] = []) => { + const key = String(sourceKeys[0] || '').trim() + return contexts[key as keyof typeof contexts] + }, + listSourceLoadStats: async () => [], + } + const flow = { + binding: { + cloudSourceKeys: ['account-a', 'account-b'], + }, + } + const first = await selectCloudtentaclesSourceForFulfillment(flow, deps) + const second = await selectCloudtentaclesSourceForFulfillment(flow, deps) + + try { + assert.equal(first.sourceKey, 'account-a') + assert.equal(second.sourceKey, 'account-b') + } finally { + first.release() + second.release() + } +}) diff --git a/apps/backend/src/services/fulfillment/kuaishou-cloud/account-selector.ts b/apps/backend/src/services/fulfillment/kuaishou-cloud/account-selector.ts new file mode 100644 index 00000000..5165f2ed --- /dev/null +++ b/apps/backend/src/services/fulfillment/kuaishou-cloud/account-selector.ts @@ -0,0 +1,182 @@ +import { listKuaishouCloudSourceLoadStats } from '../../../repositories/kuaishou-cloud-task-state-repo.js' +import { normalizeKuaishouCloudFlow, normalizeStringArray, type JsonObject } from './domain.js' +import { resolvePersistedCloudtentaclesContextBySourceKeys } from './cloudtentacles-context.js' + +type CloudtentaclesContext = ReturnType + +type CloudtentaclesSourceSelectionDeps = { + resolveContextBySourceKeys?: typeof resolvePersistedCloudtentaclesContextBySourceKeys + listSourceLoadStats?: typeof listKuaishouCloudSourceLoadStats +} + +export type CloudtentaclesSourceSelection = { + context: CloudtentaclesContext + sourceKey: string + candidateCount: number + activeCount: number + redeemingCount: number + inProcessCount: number + score: number + fixed: boolean + release: () => void +} + +const inProcessSelections = new Map() +let selectionLockTail: Promise = Promise.resolve() + +export async function selectCloudtentaclesSourceForFulfillment( + flowLike: unknown, + deps: CloudtentaclesSourceSelectionDeps = {}, +): Promise { + const flow = normalizeKuaishouCloudFlow(flowLike) + const resolveContext = + deps.resolveContextBySourceKeys || resolvePersistedCloudtentaclesContextBySourceKeys + const listSourceLoadStats = deps.listSourceLoadStats || listKuaishouCloudSourceLoadStats + const fixedSourceKey = String(flow.binding.resolvedSourceKey || '').trim() + const candidates = normalizeCloudtentaclesSourceCandidates( + fixedSourceKey + ? [fixedSourceKey, ...flow.binding.cloudSourceKeys] + : flow.binding.cloudSourceKeys, + ) + + if (fixedSourceKey) { + const context = resolveContext([fixedSourceKey, ...flow.binding.cloudSourceKeys]) + return { + context, + sourceKey: context.resolvedSourceKey, + candidateCount: candidates.length, + activeCount: 0, + redeemingCount: 0, + inProcessCount: 0, + score: 0, + fixed: true, + release: () => {}, + } + } + + return withCloudtentaclesSourceSelectionLock(async () => { + const contexts = [] + let lastError: unknown = null + + for (const sourceKey of candidates) { + try { + contexts.push(resolveContext([sourceKey])) + } catch (error) { + lastError = error + } + } + + if (contexts.length === 0) { + if (lastError) { + throw lastError + } + resolveContext(candidates) + throw new Error('所有 cloudtentacles 账号均不可用') + } + + const stats = await listSourceLoadStats(contexts.map((context) => context.resolvedSourceKey)) + const statsBySourceKey = new Map(stats.map((stat) => [stat.sourceKey, stat])) + const ranked = contexts + .map((context, index) => { + const stat = statsBySourceKey.get(context.resolvedSourceKey) || { + sourceKey: context.resolvedSourceKey, + activeCount: 0, + redeemingCount: 0, + } + const activeCount = normalizeCount(stat.activeCount) + const redeemingCount = normalizeCount(stat.redeemingCount) + const inProcessCount = normalizeCount(inProcessSelections.get(context.resolvedSourceKey)) + const score = (activeCount + inProcessCount) * 10 + redeemingCount * 20 + + return { + context, + sourceKey: context.resolvedSourceKey, + index, + activeCount, + redeemingCount, + inProcessCount, + score, + } + }) + .sort( + (left, right) => + left.score - right.score || + left.activeCount - right.activeCount || + left.redeemingCount - right.redeemingCount || + left.index - right.index || + left.sourceKey.localeCompare(right.sourceKey), + ) + + const selected = ranked[0] + if (!selected) { + throw new Error('所有 cloudtentacles 账号均不可用') + } + + reserveCloudtentaclesSource(selected.sourceKey) + + return { + context: selected.context, + sourceKey: selected.sourceKey, + candidateCount: contexts.length, + activeCount: selected.activeCount, + redeemingCount: selected.redeemingCount, + inProcessCount: selected.inProcessCount, + score: selected.score, + fixed: false, + release: () => releaseCloudtentaclesSource(selected.sourceKey), + } + }) +} + +export function buildCloudtentaclesSourceSelectionEventDetail( + selection: CloudtentaclesSourceSelection, +): JsonObject { + return { + sourceKey: selection.sourceKey, + candidateCount: selection.candidateCount, + activeCount: selection.activeCount, + redeemingCount: selection.redeemingCount, + inProcessCount: selection.inProcessCount, + score: selection.score, + fixed: selection.fixed, + } +} + +function normalizeCloudtentaclesSourceCandidates(sourceKeys: unknown[] = []) { + return Array.from(new Set(normalizeStringArray(sourceKeys))) +} + +async function withCloudtentaclesSourceSelectionLock(fn: () => Promise): Promise { + let releaseLock: () => void = () => {} + const waitForTurn = selectionLockTail + selectionLockTail = new Promise((resolve) => { + releaseLock = resolve + }) + + await waitForTurn + + try { + return await fn() + } finally { + releaseLock() + } +} + +function reserveCloudtentaclesSource(sourceKey: string) { + inProcessSelections.set(sourceKey, normalizeCount(inProcessSelections.get(sourceKey)) + 1) +} + +function releaseCloudtentaclesSource(sourceKey: string) { + const nextCount = normalizeCount(inProcessSelections.get(sourceKey)) - 1 + if (nextCount > 0) { + inProcessSelections.set(sourceKey, nextCount) + return + } + + inProcessSelections.delete(sourceKey) +} + +function normalizeCount(value: unknown) { + const count = Number(value || 0) || 0 + return Number.isFinite(count) && count > 0 ? count : 0 +} diff --git a/apps/backend/src/services/fulfillment/kuaishou-cloud/binding-resources.ts b/apps/backend/src/services/fulfillment/kuaishou-cloud/binding-resources.ts index 69b22b04..92cb3120 100644 --- a/apps/backend/src/services/fulfillment/kuaishou-cloud/binding-resources.ts +++ b/apps/backend/src/services/fulfillment/kuaishou-cloud/binding-resources.ts @@ -11,8 +11,8 @@ import { import { KUAISHOU_CLOUD_FIXED_VN_KEY, type JsonObject } from "./domain.js"; export function resolveKuaishouCloudBindingResources( - flow, - { skuItems = [], knapsackItems = [] } = {} + flow: JsonObject, + { skuItems = [], knapsackItems = [] }: { skuItems?: unknown[], knapsackItems?: unknown[] } = {} ) { const normalizedSkuItems = Array.isArray(skuItems) ? skuItems.filter(isCloudSkuLikeItem) @@ -70,7 +70,7 @@ export function resolveKuaishouCloudBindingResources( }; } -export function resolveKuaishouCloudVnKeyCandidates(_input = {}) { +export function resolveKuaishouCloudVnKeyCandidates(_input: JsonObject = {}) { return [KUAISHOU_CLOUD_FIXED_VN_KEY]; } @@ -159,7 +159,7 @@ export async function prepareKuaishouCloudBindResourceWithFallback(input: JsonOb ); } -function collectKuaishouCloudNameCandidates(flow) { +function collectKuaishouCloudNameCandidates(flow: JsonObject) { return Array.from( new Set( [ @@ -171,10 +171,10 @@ function collectKuaishouCloudNameCandidates(flow) { ); } -function findCloudItemByNames(items, nameCandidates, preferredId = 0) { +function findCloudItemByNames(items: JsonObject[], nameCandidates: string[], preferredId = 0) { const normalizedItems = Array.isArray(items) ? items : []; const normalizedNames = nameCandidates - .map((item) => ({ + .map((item: string) => ({ raw: String(item || "").trim(), normalized: normalizeProductName(item), })) @@ -182,34 +182,34 @@ function findCloudItemByNames(items, nameCandidates, preferredId = 0) { if (normalizedNames.length === 0 || normalizedItems.length === 0) { return preferredId > 0 - ? normalizedItems.find((item) => Number(item.id || 0) === preferredId) || + ? normalizedItems.find((item: JsonObject) => Number(item.id || 0) === preferredId) || null : null; } if (preferredId > 0) { const preferred = - normalizedItems.find((item) => Number(item.id || 0) === preferredId) || + normalizedItems.find((item: JsonObject) => Number(item.id || 0) === preferredId) || null; if (preferred) { return preferred; } } - const exactMatches = normalizedItems.filter((item) => { + const exactMatches = normalizedItems.filter((item: JsonObject) => { const itemName = normalizeProductName(item.name); return normalizedNames.some( - (candidate) => candidate.normalized === itemName + (candidate: { normalized: string }) => candidate.normalized === itemName ); }); if (exactMatches.length > 0) { return exactMatches[0]; } - const partialMatches = normalizedItems.filter((item) => { + const partialMatches = normalizedItems.filter((item: JsonObject) => { const itemName = normalizeProductName(item.name); return normalizedNames.some( - (candidate) => + (candidate: { normalized: string }) => itemName.includes(candidate.normalized) || candidate.normalized.includes(itemName) ); @@ -224,13 +224,15 @@ function findCloudItemByNames(items, nameCandidates, preferredId = 0) { return null; } -function isCloudSkuLikeItem(item) { - return Boolean(item) && typeof item === "object" && Number(item.id || 0) > 0; +function isCloudSkuLikeItem(item: unknown): item is JsonObject { + const current = item && typeof item === "object" ? item as JsonObject : {}; + return Number(current.id || 0) > 0; } -function isRecoverableKuaishouCloudVnKeyError(error) { - const errorCode = String(error?.errorCode || error?.code || "").trim(); - const errorMessage = String(error?.message || "").trim(); +function isRecoverableKuaishouCloudVnKeyError(error: unknown) { + const current = error && typeof error === "object" ? error as JsonObject : {}; + const errorCode = String(current.errorCode || current.code || "").trim(); + const errorMessage = String(current.message || "").trim(); return ( errorCode === "cloudtentacles_vn_bind_url_failed" && errorMessage.includes("不支持的游戏类型") diff --git a/apps/backend/src/services/fulfillment/kuaishou-cloud/cloudtentacles-context.ts b/apps/backend/src/services/fulfillment/kuaishou-cloud/cloudtentacles-context.ts index d6977a6c..50d0e000 100644 --- a/apps/backend/src/services/fulfillment/kuaishou-cloud/cloudtentacles-context.ts +++ b/apps/backend/src/services/fulfillment/kuaishou-cloud/cloudtentacles-context.ts @@ -1,56 +1,22 @@ import { createHttpError } from "../../../utils/http.js"; import { - getCloudtentaclesSourceConfig, - getCloudtentaclesSourceByKey, -} from "../../platforms/cloudtentacles/source-config-service.js"; -import { - getCloudtentaclesSessionState, - getCloudtentaclesSessionStateByKey, -} from "../../platforms/cloudtentacles/session-state-service.js"; + normalizeCloudtentaclesDeviceId, + normalizeCloudtentaclesDeviceType, +} from "../../platforms/cloudtentacles/defaults.js"; +import { getCloudtentaclesSourceByKey } from "../../platforms/cloudtentacles/source-config-service.js"; +import { getCloudtentaclesSessionStateByKey } from "../../platforms/cloudtentacles/session-state-service.js"; import { normalizeStringArray } from "./domain.js"; -export function resolvePersistedCloudtentaclesContext(sourceKey = "default") { - const source = - getCloudtentaclesSourceByKey(sourceKey) || getCloudtentaclesSourceConfig(); - const session = - getCloudtentaclesSessionStateByKey(sourceKey) || - getCloudtentaclesSessionState(); - const token = String(session.token || "").trim(); - - if (!token) { - throw createHttpError( - "当前 cloudtentacles 没有可用 token,请先到平台配置完成登录校验", - { - statusCode: 409, - errorCode: "kuaishou_cloud_missing_cloud_token", - } - ); - } - - return { - baseUrl: - String(session.baseUrl || source.baseUrl || "").trim() || - "https://123.207.217.176", - token, - deviceId: String(session.deviceId || source.deviceId || "-").trim() || "-", - deviceType: Number(session.deviceType ?? source.deviceType ?? 0), - }; -} - /** - * 按优先级依次尝试 cloudSourceKey 和 fallbacks 列表中的账号, + * 按优先级依次尝试 cloudSourceKeys 列表中的账号, * 找到第一个有可用 token 的账号返回其 context。 * 将实际使用的 resolvedSourceKey 也返回,确保后续操作(退号、发货等) * 使用同一个 sourceKey,防止跨账号操作导致数据不一致。 */ -export function resolvePersistedCloudtentaclesContextWithFallback( - primarySourceKey = "default", - fallbacks = [] +export function resolvePersistedCloudtentaclesContextBySourceKeys( + sourceKeys: unknown[] = [] ) { - const candidates = [ - String(primarySourceKey || "default").trim() || "default", - ...normalizeStringArray(fallbacks), - ]; + const candidates = normalizeStringArray(sourceKeys); const uniqueCandidates = [...new Set(candidates)]; @@ -69,7 +35,15 @@ export function resolvePersistedCloudtentaclesContextWithFallback( continue; } - const token = String(session?.token || "").trim(); + if (!session) { + lastError = createHttpError( + `cloudtentacles 账号 ${sourceKey} 没有可用 token`, + { statusCode: 409, errorCode: "kuaishou_cloud_missing_cloud_token" } + ); + continue; + } + + const token = String(session.token || "").trim(); if (!token) { lastError = createHttpError( `cloudtentacles 账号 ${sourceKey} 没有可用 token`, @@ -83,9 +57,12 @@ export function resolvePersistedCloudtentaclesContextWithFallback( String(session.baseUrl || source.baseUrl || "").trim() || "https://123.207.217.176", token, - deviceId: - String(session.deviceId || source.deviceId || "-").trim() || "-", - deviceType: Number(session.deviceType ?? source.deviceType ?? 0), + deviceId: normalizeCloudtentaclesDeviceId( + session.deviceId || source.deviceId + ), + deviceType: normalizeCloudtentaclesDeviceType( + session.deviceType ?? source.deviceType + ), resolvedSourceKey: sourceKey, }; } @@ -93,7 +70,7 @@ export function resolvePersistedCloudtentaclesContextWithFallback( throw ( lastError || createHttpError( - "所有 cloudtentacles 备选账号均不可用,请先到平台配置完成登录校验", + "所有 cloudtentacles 账号均不可用,请先到平台配置完成登录校验", { statusCode: 409, errorCode: "kuaishou_cloud_all_source_keys_exhausted" } ) ); diff --git a/apps/backend/src/services/fulfillment/kuaishou-cloud/dispatch-role-sync.ts b/apps/backend/src/services/fulfillment/kuaishou-cloud/dispatch-role-sync.ts new file mode 100644 index 00000000..f7e9f461 --- /dev/null +++ b/apps/backend/src/services/fulfillment/kuaishou-cloud/dispatch-role-sync.ts @@ -0,0 +1,216 @@ +import { createTaskEvent } from "../../../repositories/task-event-repo.js"; +import { updateTask } from "../../../repositories/task-repo.js"; +import type { TaskRow } from "../../../types/repository/rows.js"; +import { createHttpError } from "../../../utils/http.js"; +import { getCloudtentaclesBindInfo } from "../../platforms/cloudtentacles/virtual-number-service.js"; +import { + normalizeKuaishouCloudFlow, + normalizeKuaishouCloudRoleInfo, + type JsonObject, +} from "./domain.js"; + +export async function syncKuaishouCloudRoleInfoBeforeDispatch( + task: TaskRow, + input: JsonObject = {} +) { + const now = String(input.now || "").trim() || new Date().toISOString(); + const source = + String(input.source || "system_before_dispatch").trim() || + "system_before_dispatch"; + const errorCodePrefix = + String(input.errorCodePrefix || "kuaishou_cloud").trim() || + "kuaishou_cloud"; + const cloudContext = + input.cloudContext && typeof input.cloudContext === "object" + ? input.cloudContext + : {}; + const taskContext = + input.taskContext && typeof input.taskContext === "object" + ? input.taskContext + : {}; + const flow = normalizeKuaishouCloudFlow( + input.flow || taskContext.kuaishouCloudFulfillment + ); + + if (!flow.binding.vnId || !flow.binding.vnKey) { + throw createHttpError("当前任务缺少可同步角色的虚拟号信息,暂时不能发货", { + statusCode: 409, + errorCode: `${errorCodePrefix}_dispatch_missing_bind_info_context`, + }); + } + + const bindInfoResult = await getCloudtentaclesBindInfo({ + ...cloudContext, + key: flow.binding.vnKey, + id: flow.binding.vnId, + }); + const roleInfo = normalizeKuaishouCloudRoleInfo(bindInfoResult.bindInfo); + + if (!roleInfo.name || !roleInfo.rid) { + throw createHttpError( + "cloudtentacles 还没有同步到客户绑定角色,请稍后刷新角色信息后再发货", + { + statusCode: 409, + errorCode: `${errorCodePrefix}_dispatch_role_not_ready`, + } + ); + } + + const confirmedRoleName = String( + flow.binding.roleName || flow.role.name || task.role_name || "" + ).trim(); + const confirmedRoleId = String( + flow.binding.roleId || flow.role.rid || task.role_id || "" + ).trim(); + const roleMatches = isSameKuaishouCloudRole( + { + name: confirmedRoleName, + rid: confirmedRoleId, + }, + { + name: roleInfo.name, + rid: roleInfo.rid, + } + ); + + if (!roleMatches) { + await createTaskEvent( + task.id, + "kuaishou_cloud_dispatch_role_mismatch", + { + source, + vnId: flow.binding.vnId, + confirmedRoleName, + confirmedRoleId, + cloudtentaclesRoleName: roleInfo.name, + cloudtentaclesRoleId: roleInfo.rid, + actor: input.actor || null, + }, + now + ); + + throw createHttpError( + `cloudtentacles 当前绑定角色(${roleInfo.name}/${roleInfo.rid})与客户确认角色(${confirmedRoleName || "-"}/${confirmedRoleId || "-"})不一致,请重新刷新角色后再发货`, + { + statusCode: 409, + errorCode: `${errorCodePrefix}_dispatch_role_mismatch`, + } + ); + } + + const nextFlow = normalizeKuaishouCloudFlow({ + ...flow, + binding: { + ...flow.binding, + roleName: roleInfo.name, + roleId: roleInfo.rid, + }, + role: { + ...flow.role, + status: "ready", + name: roleInfo.name, + rid: roleInfo.rid, + refreshedAt: now, + errorMessage: "", + rawInfo: roleInfo.rawInfo, + }, + }); + const nextContext = { + ...taskContext, + kuaishouCloudFulfillment: nextFlow, + }; + + const updatedTask = await updateTask(task.id, { + role_id: roleInfo.rid, + role_name: roleInfo.name, + context_json: JSON.stringify(nextContext), + updated_at: now, + }); + + if (!updatedTask) { + throw createHttpError("发货前角色信息同步失败", { + statusCode: 500, + errorCode: `${errorCodePrefix}_dispatch_role_update_failed`, + }); + } + + await createTaskEvent( + task.id, + "kuaishou_cloud_role_info_synced_before_dispatch", + { + source, + roleName: roleInfo.name, + roleId: roleInfo.rid, + vnId: flow.binding.vnId, + actor: input.actor || null, + }, + now + ); + + return { + task: updatedTask, + taskContext: nextContext, + flow: nextFlow, + roleInfo, + }; +} + +function isSameKuaishouCloudRole( + confirmedRole: { name: string; rid: string }, + cloudtentaclesRole: { name: string; rid: string } +) { + if (confirmedRole.rid && cloudtentaclesRole.rid) { + return confirmedRole.rid === cloudtentaclesRole.rid; + } + + const confirmedName = normalizeKuaishouCloudRoleNameForCompare( + confirmedRole.name + ); + const cloudtentaclesName = normalizeKuaishouCloudRoleNameForCompare( + cloudtentaclesRole.name + ); + + if (!confirmedName || !cloudtentaclesName) { + return true; + } + + return confirmedName === cloudtentaclesName; +} + +function normalizeKuaishouCloudRoleNameForCompare(value: unknown) { + const name = String(value || "").trim(); + if (!name) { + return ""; + } + + const parts = name + .split("-") + .map((part) => part.trim()) + .filter(Boolean); + const devicePart = parts[0] || ""; + const channelPart = parts[1] || ""; + if ( + parts.length >= 3 && + isKuaishouCloudDevicePart(devicePart) && + isKuaishouCloudChannelPart(channelPart) + ) { + return normalizeTextForCompare(parts.slice(2).join("-")); + } + + return normalizeTextForCompare(name); +} + +function isKuaishouCloudDevicePart(value: string) { + return ["安卓", "android", "ios", "苹果"].includes(value.toLowerCase()); +} + +function isKuaishouCloudChannelPart(value: string) { + return ["qq", "微信", "vx", "wechat", "v"].includes(value.toLowerCase()); +} + +function normalizeTextForCompare(value: unknown) { + return String(value || "") + .trim() + .toLowerCase() + .replace(/\s+/g, ""); +} diff --git a/apps/backend/src/services/fulfillment/kuaishou-cloud/domain.test.ts b/apps/backend/src/services/fulfillment/kuaishou-cloud/domain.test.ts new file mode 100644 index 00000000..7e6bb267 --- /dev/null +++ b/apps/backend/src/services/fulfillment/kuaishou-cloud/domain.test.ts @@ -0,0 +1,46 @@ +import assert from 'node:assert/strict' +import test from 'node:test' + +import { + hasKuaishouCloudCustomerRole, + normalizeKuaishouCloudFlow, +} from './domain.js' + +test('hasKuaishouCloudCustomerRole 要求当前角色不同于虚拟机默认角色', () => { + const defaultRoleFlow = normalizeKuaishouCloudFlow({ + binding: { + vnPhone: '13800000000', + roleName: '默认角色', + roleId: '10001', + }, + role: { + name: '默认角色', + rid: '10001', + defaultName: '默认角色', + defaultRid: '10001', + defaultCapturedAt: '2026-05-31T00:00:00.000Z', + isDefaultRole: false, + }, + }) + + assert.equal(defaultRoleFlow.role.isDefaultRole, true) + assert.equal(hasKuaishouCloudCustomerRole(defaultRoleFlow), false) + + const customerRoleFlow = normalizeKuaishouCloudFlow({ + ...defaultRoleFlow, + binding: { + ...defaultRoleFlow.binding, + roleName: '用户角色', + roleId: '20002', + }, + role: { + ...defaultRoleFlow.role, + name: '用户角色', + rid: '20002', + isDefaultRole: true, + }, + }) + + assert.equal(customerRoleFlow.role.isDefaultRole, false) + assert.equal(hasKuaishouCloudCustomerRole(customerRoleFlow), true) +}) diff --git a/apps/backend/src/services/fulfillment/kuaishou-cloud/domain.ts b/apps/backend/src/services/fulfillment/kuaishou-cloud/domain.ts index 57668f5e..e1702757 100644 --- a/apps/backend/src/services/fulfillment/kuaishou-cloud/domain.ts +++ b/apps/backend/src/services/fulfillment/kuaishou-cloud/domain.ts @@ -1,99 +1,93 @@ -import { resolveCloudtentaclesConfig } from "../../platforms/cloudtentacles/shared.js"; -import { - maskCode as maskCodeValue, - maskPhone as maskPhoneValue, -} from "../../../utils/masking.js"; +import { resolveCloudtentaclesConfig } from '../../platforms/cloudtentacles/helpers.js' +import { maskCode as maskCodeValue, maskPhone as maskPhoneValue } from '../../../utils/masking.js' -export const KUAISHOU_CLOUD_FIXED_VN_KEY = "1"; +export const KUAISHOU_CLOUD_FIXED_VN_KEY = '1' -export type JsonObject = Record; +export type JsonObject = Record -export function isKuaishouCloudTask(task) { - return String(task?.executor_key || "").trim() === "kuaishou_ct_assisted"; +export function isKuaishouCloudTask(task: unknown) { + const source = task && typeof task === 'object' ? (task as JsonObject) : {} + return String(source.executor_key || '').trim() === 'kuaishou_ct_assisted' } -export function normalizeKuaishouCloudFlow(value) { - const source = value && typeof value === "object" ? value : {}; - const binding = - source.binding && typeof source.binding === "object" ? source.binding : {}; - const role = - source.role && typeof source.role === "object" ? source.role : {}; - const purchase = - source.purchase && typeof source.purchase === "object" - ? source.purchase - : {}; - const dispatch = - source.dispatch && typeof source.dispatch === "object" - ? source.dispatch - : {}; +export function normalizeKuaishouCloudFlow(value: unknown) { + const source: JsonObject = value && typeof value === 'object' ? (value as JsonObject) : {} + const binding = source.binding && typeof source.binding === 'object' ? source.binding : {} + const role = source.role && typeof source.role === 'object' ? source.role : {} + const purchase = source.purchase && typeof source.purchase === 'object' ? source.purchase : {} + const dispatch = source.dispatch && typeof source.dispatch === 'object' ? source.dispatch : {} const returnNumber = - source.returnNumber && typeof source.returnNumber === "object" - ? source.returnNumber - : {}; - const consume = - source.consume && typeof source.consume === "object" ? source.consume : {}; - const ticket = - source.ticket && typeof source.ticket === "object" ? source.ticket : {}; + source.returnNumber && typeof source.returnNumber === 'object' ? source.returnNumber : {} + const consume = source.consume && typeof source.consume === 'object' ? source.consume : {} + const ticket = source.ticket && typeof source.ticket === 'object' ? source.ticket : {} + const rebind = source.rebind && typeof source.rebind === 'object' ? source.rebind : {} + const deliveryItems = normalizeDeliveryItems(source, binding) - const roleName = String(role.name || binding.roleName || "").trim(); - const roleId = String(role.rid || binding.roleId || "").trim(); - const bindPreparedAt = binding.bindPreparedAt || null; + const roleName = String(role.name || binding.roleName || '').trim() + const roleId = String(role.rid || binding.roleId || '').trim() + const defaultRoleName = String(role.defaultName || role.defaultRoleName || '').trim() + const defaultRoleId = String(role.defaultRid || role.defaultRoleId || '').trim() + const hasDefaultRole = Boolean(defaultRoleName || defaultRoleId) + const isDefaultRole = + Boolean(roleName || roleId) && + hasDefaultRole && + isSameKuaishouCloudRoleIdentity( + { name: roleName, rid: roleId }, + { name: defaultRoleName, rid: defaultRoleId }, + ) + const bindPreparedAt = binding.bindPreparedAt || null const bindExpiresAt = - binding.bindExpiresAt || - resolveKuaishouCloudBindUrlExpiresAt(bindPreparedAt); + binding.bindExpiresAt || resolveKuaishouCloudBindUrlExpiresAt(bindPreparedAt) return { ...source, - configId: String(source.configId || "").trim(), - internalSkuCode: String(source.internalSkuCode || "").trim(), - internalSkuName: String(source.internalSkuName || "").trim(), + configId: String(source.configId || '').trim(), + internalSkuCode: String(source.internalSkuCode || '').trim(), + internalSkuName: String(source.internalSkuName || '').trim(), + deliveryItems, ticket: { - code: String(ticket.code || "").trim(), - status: String(ticket.status || "pending").trim() || "pending", + code: String(ticket.code || '').trim(), + status: String(ticket.status || 'pending').trim() || 'pending', capturedAt: ticket.capturedAt || null, capturedBy: ticket.capturedBy || null, verifiedAt: ticket.verifiedAt || null, - oid: String(ticket.oid || "").trim(), - formToken: String(ticket.formToken || "").trim(), + oid: String(ticket.oid || '').trim(), + formToken: String(ticket.formToken || '').trim(), leftCount: Number(ticket.leftCount || 0) || 0, - goodsTitle: String(ticket.goodsTitle || "").trim(), + goodsTitle: String(ticket.goodsTitle || '').trim(), }, binding: { - prepareStatus: - String(binding.prepareStatus || "pending").trim() || "pending", - cloudSourceKey: - String(binding.cloudSourceKey || "default").trim() || "default", - cloudSourceKeyFallbacks: normalizeStringArray( - binding.cloudSourceKeyFallbacks - ), - resolvedSourceKey: String(binding.resolvedSourceKey || "").trim(), + prepareStatus: String(binding.prepareStatus || 'pending').trim() || 'pending', + cloudSourceKeys: normalizeStringArray(binding.cloudSourceKeys), + resolvedSourceKey: String(binding.resolvedSourceKey || '').trim(), skuId: Number(binding.skuId || 0) || 0, - skuName: String(binding.skuName || "").trim(), + skuName: String(binding.skuName || '').trim(), vnKey: - String(binding.vnKey || KUAISHOU_CLOUD_FIXED_VN_KEY).trim() || - KUAISHOU_CLOUD_FIXED_VN_KEY, + String(binding.vnKey || KUAISHOU_CLOUD_FIXED_VN_KEY).trim() || KUAISHOU_CLOUD_FIXED_VN_KEY, vnId: Number(binding.vnId || 0) || 0, - vnPhone: String(binding.vnPhone || "").trim(), - bindUrl: String(binding.bindUrl || "").trim(), + vnPhone: String(binding.vnPhone || '').trim(), + bindUrl: String(binding.bindUrl || '').trim(), bindPreparedAt, bindExpiresAt, bindProbeAt: binding.bindProbeAt || null, - bindProbeStatus: String(binding.bindProbeStatus || "").trim(), - bindProbeMessage: String(binding.bindProbeMessage || "").trim(), + bindProbeStatus: String(binding.bindProbeStatus || '').trim(), + bindProbeMessage: String(binding.bindProbeMessage || '').trim(), roleName, roleId, }, role: { - status: - String( - role.status || (roleName || roleId ? "ready" : "pending") - ).trim() || "pending", + status: String(role.status || (roleName || roleId ? 'ready' : 'pending')).trim() || 'pending', name: roleName, rid: roleId, refreshedAt: role.refreshedAt || null, - errorMessage: String(role.errorMessage || "").trim(), - rawInfo: - role.rawInfo && typeof role.rawInfo === "object" ? role.rawInfo : null, + errorMessage: String(role.errorMessage || '').trim(), + rawInfo: role.rawInfo && typeof role.rawInfo === 'object' ? role.rawInfo : null, + defaultName: defaultRoleName, + defaultRid: defaultRoleId, + defaultCapturedAt: role.defaultCapturedAt || null, + defaultCaptureStatus: String(role.defaultCaptureStatus || '').trim(), + defaultErrorMessage: String(role.defaultErrorMessage || '').trim(), + isDefaultRole, }, purchase: { autoBuyEnabled: purchase.autoBuyEnabled !== false, @@ -103,107 +97,212 @@ export function normalizeKuaishouCloudFlow(value) { assetBefore: Number(purchase.assetBefore || 0) || 0, assetAfter: Number(purchase.assetAfter || 0) || 0, purchaseAt: purchase.purchaseAt || null, + items: Array.isArray(purchase.items) ? purchase.items : [], }, dispatch: { - status: String(dispatch.status || "pending").trim() || "pending", + status: String(dispatch.status || 'pending').trim() || 'pending', dispatchAt: dispatch.dispatchAt || null, dispatchBy: dispatch.dispatchBy || null, sendType: Number(dispatch.sendType || 0) || 0, - note: String(dispatch.note || "").trim(), + note: String(dispatch.note || '').trim(), + items: Array.isArray(dispatch.items) ? dispatch.items : [], }, returnNumber: { - status: String(returnNumber.status || "pending").trim() || "pending", + status: String(returnNumber.status || 'pending').trim() || 'pending', returnedAt: returnNumber.returnedAt || null, returnedBy: returnNumber.returnedBy || null, autoReturnEnabled: returnNumber.autoReturnEnabled === true, }, consume: { - status: String(consume.status || "pending").trim() || "pending", - shopId: String(consume.shopId || "").trim(), - shopName: String(consume.shopName || "").trim(), + status: String(consume.status || 'pending').trim() || 'pending', + shopId: String(consume.shopId || '').trim(), + shopName: String(consume.shopName || '').trim(), autoConsumeEnabled: consume.autoConsumeEnabled === true, consumedAt: consume.consumedAt || null, - errorMessage: String(consume.errorMessage || "").trim(), + errorMessage: String(consume.errorMessage || '').trim(), }, - notes: String(source.notes || "").trim(), - }; + rebind: { + currentAttempt: Math.max(0, Number(rebind.currentAttempt || 0) || 0), + history: Array.isArray(rebind.history) ? rebind.history : [], + }, + notes: String(source.notes || '').trim(), + } } -export function normalizeKuaishouCloudRoleInfo(value) { - const rawInfo = value && typeof value === "object" ? value : null; +export function hasKuaishouCloudDefaultRoleSnapshot(flowLike: unknown) { + const flow = normalizeKuaishouCloudFlow(flowLike) + return Boolean(flow.role.defaultName || flow.role.defaultRid) +} + +export function hasKuaishouCloudCustomerRole(flowLike: unknown) { + const flow = normalizeKuaishouCloudFlow(flowLike) + if (!flow.binding.vnPhone || !flow.binding.roleName || !flow.binding.roleId) { + return false + } + + if (!hasKuaishouCloudDefaultRoleSnapshot(flow)) { + return false + } + + return !isSameKuaishouCloudRoleIdentity( + { name: flow.binding.roleName, rid: flow.binding.roleId }, + { name: flow.role.defaultName, rid: flow.role.defaultRid }, + ) +} + +export function isSameKuaishouCloudRoleIdentity( + left: { name?: unknown; rid?: unknown }, + right: { name?: unknown; rid?: unknown }, +) { + const leftRid = String(left.rid || '').trim() + const rightRid = String(right.rid || '').trim() + if (leftRid && rightRid) { + return leftRid === rightRid + } + + const leftName = normalizeRoleNameForCompare(left.name) + const rightName = normalizeRoleNameForCompare(right.name) + return Boolean(leftName && rightName && leftName === rightName) +} + +function normalizeRoleNameForCompare(value: unknown) { + return String(value || '') + .trim() + .toLowerCase() + .replace(/\s+/g, '') +} + +export function normalizeKuaishouCloudDeliveryItems(value: unknown) { + const source: JsonObject = value && typeof value === 'object' ? (value as JsonObject) : {} + const binding = source.binding && typeof source.binding === 'object' ? source.binding : {} + return normalizeDeliveryItems(source, binding) +} + +export function normalizeKuaishouCloudRoleInfo(value: unknown) { + const rawInfo: JsonObject | null = + value && typeof value === 'object' ? (value as JsonObject) : null const nestedBindInfo = - rawInfo?.sBindInfo && typeof rawInfo.sBindInfo === "object" - ? rawInfo.sBindInfo - : null; - const source = nestedBindInfo || rawInfo; + rawInfo?.sBindInfo && typeof rawInfo.sBindInfo === 'object' ? rawInfo.sBindInfo : null + const source = nestedBindInfo || rawInfo return { name: String( - source?.name || - source?.roleName || - source?.nickname || - source?.sRoleName || - "" + source?.name || source?.roleName || source?.nickname || source?.sRoleName || '', ).trim(), rid: String( - source?.rid || - source?.roleId || - source?.uid || - source?.sRoleId || - source?.sUserId || - "" + source?.rid || source?.roleId || source?.uid || source?.sRoleId || source?.sUserId || '', ).trim(), rawInfo, - }; + } } -export function maskPhone(value) { - return maskPhoneValue(value); +export function maskPhone(value: unknown) { + return maskPhoneValue(value) } -export function maskCode(value) { - return maskCodeValue(value); +export function maskCode(value: unknown) { + return maskCodeValue(value) } -export function resolveKuaishouCloudBindUrlExpiresAt(preparedAt) { - const preparedTime = Date.parse(String(preparedAt || "")); +export function resolveKuaishouCloudBindUrlExpiresAt(preparedAt: unknown) { + const preparedTime = Date.parse(String(preparedAt || '')) if (!Number.isFinite(preparedTime)) { - return null; + return null } - const config = resolveCloudtentaclesConfig(); - const ttlSeconds = Number(config.bindUrlTtlSeconds || 600); - return new Date(preparedTime + Math.max(1, ttlSeconds) * 1000).toISOString(); + const config = resolveCloudtentaclesConfig() + const ttlSeconds = Number(config.bindUrlTtlSeconds || 600) + return new Date(preparedTime + Math.max(1, ttlSeconds) * 1000).toISOString() } -export function isKuaishouCloudBindUrlFresh(flow, now = new Date()) { - const normalizedFlow = normalizeKuaishouCloudFlow(flow); +export function isKuaishouCloudBindUrlFresh(flow: unknown, now = new Date()) { + const normalizedFlow = normalizeKuaishouCloudFlow(flow) if (!normalizedFlow.binding.bindUrl) { - return false; + return false } - const expiresAt = normalizedFlow.binding.bindExpiresAt; + const expiresAt = normalizedFlow.binding.bindExpiresAt if (!expiresAt) { - return false; + return false } - const expiresTime = Date.parse(String(expiresAt || "")); + const expiresTime = Date.parse(String(expiresAt || '')) if (!Number.isFinite(expiresTime)) { - return false; + return false } - return expiresTime > now.getTime(); + return expiresTime > now.getTime() } -export function normalizeStringArray(value) { +export function normalizeStringArray(value: unknown) { if (Array.isArray(value)) { - return value.map((v) => String(v || "").trim()).filter(Boolean); + return value.map((v) => String(v || '').trim()).filter(Boolean) } - if (typeof value === "string") { + if (typeof value === 'string') { return value - .split(",") + .split(',') .map((v) => v.trim()) - .filter(Boolean); + .filter(Boolean) } - return []; + return [] +} + +function normalizeDeliveryItems(source: JsonObject, binding: JsonObject) { + const rawItems = Array.isArray(source.deliveryItems) ? source.deliveryItems : [] + const items = rawItems.map((item) => normalizeDeliveryItem(item)).filter(Boolean) as Array<{ + cloudSkuId: number + cloudSkuName: string + quantity: number + }> + + if (items.length > 0) { + return mergeDeliveryItems(items) + } + + const skuId = Number(binding.skuId || 0) || 0 + if (!skuId) { + return [] + } + + return [ + { + cloudSkuId: skuId, + cloudSkuName: String(binding.skuName || '').trim(), + quantity: 1, + }, + ] +} + +function normalizeDeliveryItem(value: unknown) { + const source = value && typeof value === 'object' ? (value as JsonObject) : {} + const cloudSkuId = Number(source.cloudSkuId || source.skuId || 0) || 0 + if (!Number.isInteger(cloudSkuId) || cloudSkuId <= 0) { + return null + } + + const quantity = Number(source.quantity || 1) || 1 + return { + cloudSkuId, + cloudSkuName: String(source.cloudSkuName || source.skuName || '').trim(), + quantity: Number.isInteger(quantity) && quantity > 0 ? quantity : 1, + } +} + +function mergeDeliveryItems( + items: Array<{ cloudSkuId: number; cloudSkuName: string; quantity: number }>, +) { + const merged = new Map() + + for (const item of items) { + const existing = merged.get(item.cloudSkuId) + if (existing) { + existing.quantity += item.quantity + existing.cloudSkuName = existing.cloudSkuName || item.cloudSkuName + continue + } + + merged.set(item.cloudSkuId, { ...item }) + } + + return Array.from(merged.values()) } diff --git a/apps/backend/src/services/fulfillment/kuaishou-cloud/index.ts b/apps/backend/src/services/fulfillment/kuaishou-cloud/index.ts new file mode 100644 index 00000000..5e4bdd86 --- /dev/null +++ b/apps/backend/src/services/fulfillment/kuaishou-cloud/index.ts @@ -0,0 +1,1171 @@ +import { createTaskEvent } from '../../../repositories/task-event-repo.js' +import { updateTask } from '../../../repositories/task-repo.js' +import { buildClaimUrl, createTaskClaimToken } from '../../claim/claim-service.js' +import { listCloudtentaclesSku } from '../../platforms/cloudtentacles/catalog-service.js' +import { getCloudtentaclesKnapsack } from '../../platforms/cloudtentacles/knapsack-service.js' +import { + backCloudtentaclesVirtualNumber, + getCloudtentaclesBindInfo, + probeCloudtentaclesBindUrl, +} from '../../platforms/cloudtentacles/virtual-number-service.js' +import { resolveCloudtentaclesConfig } from '../../platforms/cloudtentacles/helpers.js' +import { notifyKuaishouCloudBindUrlRefreshFailed } from '../../notification/domain-notifications.js' +import { createHttpError } from '../../../utils/http.js' +import { nowIso } from '../../../utils/time.js' +import { TASK_STATUS, normalizeTaskStatus } from '../../../domain/task-status.js' +import { + KUAISHOU_CLOUD_FIXED_VN_KEY, + hasKuaishouCloudCustomerRole, + hasKuaishouCloudDefaultRoleSnapshot, + isKuaishouCloudBindUrlFresh, + isKuaishouCloudTask, + isSameKuaishouCloudRoleIdentity, + maskPhone, + normalizeKuaishouCloudFlow, + normalizeKuaishouCloudRoleInfo, + resolveKuaishouCloudBindUrlExpiresAt, + type JsonObject, +} from './domain.js' +import { + prepareKuaishouCloudBindResourceWithFallback, + resolveKuaishouCloudBindingResources, + resolveKuaishouCloudVnKeyCandidates, +} from './binding-resources.js' +import { resolvePersistedCloudtentaclesContextBySourceKeys } from './cloudtentacles-context.js' +import { + buildCloudtentaclesSourceSelectionEventDetail, + selectCloudtentaclesSourceForFulfillment, +} from './account-selector.js' +import { + getTaskClaimExpiresAt, + isClaimExpired, + normalizeActor, + parseTaskContext, +} from './task-context.js' +import type { TaskRow } from '../../../types/repository/rows.js' + +type CloudDeliveryPlanItem = { + cloudSkuId: number + cloudSkuName: string + quantity: number + skuItem: JsonObject | null + knapsackItem: JsonObject | null + knapsackCount: number + missingCount: number +} + +type KuaishouCloudDefaultRoleSnapshot = { + defaultName: string + defaultRid: string + defaultCapturedAt: string | null + defaultCaptureStatus: string + defaultErrorMessage: string +} + +export { + isKuaishouCloudBindUrlFresh, + isKuaishouCloudTask, + hasKuaishouCloudCustomerRole, + hasKuaishouCloudDefaultRoleSnapshot, + maskCode, + maskPhone, + normalizeKuaishouCloudFlow, + normalizeKuaishouCloudRoleInfo, + isSameKuaishouCloudRoleIdentity, + resolveKuaishouCloudBindUrlExpiresAt, +} from './domain.js' +export { resolvePersistedCloudtentaclesContextBySourceKeys } from './cloudtentacles-context.js' + +async function captureKuaishouCloudDefaultRoleSnapshot({ + cloudContext, + preparedBinding, + now, +}: { + cloudContext: JsonObject + preparedBinding: JsonObject + now: string +}): Promise { + try { + const bindInfoResult = await getCloudtentaclesBindInfo({ + ...cloudContext, + key: preparedBinding.vnKey, + id: preparedBinding.vnId, + }) + const roleInfo = normalizeKuaishouCloudRoleInfo(bindInfoResult.bindInfo) + const hasRole = Boolean(roleInfo.name || roleInfo.rid) + + return { + defaultName: roleInfo.name, + defaultRid: roleInfo.rid, + defaultCapturedAt: hasRole ? now : null, + defaultCaptureStatus: hasRole ? 'captured' : 'empty', + defaultErrorMessage: hasRole ? '' : '未获取到虚拟机默认角色信息,请稍后刷新', + } + } catch (error) { + return { + defaultName: '', + defaultRid: '', + defaultCapturedAt: null, + defaultCaptureStatus: 'failed', + defaultErrorMessage: + error instanceof Error ? error.message : '获取虚拟机默认角色信息失败,请稍后刷新', + } + } +} + +function buildPendingRoleWithDefaultSnapshot(snapshot: KuaishouCloudDefaultRoleSnapshot) { + return { + status: 'pending', + name: '', + rid: '', + refreshedAt: null as null, + errorMessage: snapshot.defaultErrorMessage || '', + rawInfo: null as null, + defaultName: snapshot.defaultName, + defaultRid: snapshot.defaultRid, + defaultCapturedAt: snapshot.defaultCapturedAt, + defaultCaptureStatus: snapshot.defaultCaptureStatus, + defaultErrorMessage: snapshot.defaultErrorMessage, + isDefaultRole: false, + } +} + +function buildRoleStateFromCurrentInfo({ + flow, + roleInfo, + now, + emptyMessage, +}: { + flow: ReturnType + roleInfo: { name: string; rid: string; rawInfo?: unknown } + now: string + emptyMessage: string +}) { + const hasRoleInfo = Boolean(roleInfo.name || roleInfo.rid) + const hasDefaultRole = hasKuaishouCloudDefaultRoleSnapshot(flow) + const isDefaultRole = + hasRoleInfo && + hasDefaultRole && + isSameKuaishouCloudRoleIdentity( + { name: roleInfo.name, rid: roleInfo.rid }, + { name: flow.role.defaultName, rid: flow.role.defaultRid }, + ) + + return { + hasRoleInfo, + isDefaultRole, + bindingRoleName: hasRoleInfo ? roleInfo.name : '', + bindingRoleId: hasRoleInfo ? roleInfo.rid : '', + role: { + ...flow.role, + status: hasRoleInfo && !isDefaultRole ? 'ready' : 'pending', + name: hasRoleInfo ? roleInfo.name : '', + rid: hasRoleInfo ? roleInfo.rid : '', + refreshedAt: now, + errorMessage: hasRoleInfo + ? isDefaultRole + ? '当前仍是虚拟机默认角色,请重新绑定自己的角色信息' + : '' + : emptyMessage, + rawInfo: roleInfo.rawInfo || null, + isDefaultRole, + }, + } +} + +export async function ensureTaskClaimLink(task: TaskRow) { + const tokenStatus = String(task?.primary_claim_token_status || '').trim() + const token = String(task?.primary_claim_token || task?.claim_token || '').trim() + const expiredAt = getTaskClaimExpiresAt(task) + + if (tokenStatus === 'active' && token && !isClaimExpired(expiredAt)) { + return { + token, + expiredAt, + claimUrl: buildClaimUrl(token), + } + } + + const claimToken = await createTaskClaimToken(task.id) + return { + token: claimToken.token, + expiredAt: claimToken.expired_at, + claimUrl: claimToken.claimUrl, + } +} + +export async function prepareKuaishouCloudFulfillmentTask(task: TaskRow, options: JsonObject = {}) { + if (!isKuaishouCloudTask(task)) { + throw createHttpError('当前任务不是快手 Cloud 履约任务', { + statusCode: 409, + errorCode: 'kuaishou_cloud_task_invalid', + }) + } + + const now = nowIso() + const actor = normalizeActor(options.actor) + const force = options.force === true + const taskContext = parseTaskContext(task) + const flow = normalizeKuaishouCloudFlow(taskContext.kuaishouCloudFulfillment) + const claimLinkState = await ensureTaskClaimLink(task) + + if ( + !force && + flow.binding.prepareStatus === 'ready' && + flow.binding.vnId > 0 && + flow.binding.vnPhone && + flow.binding.bindUrl + ) { + if (!isKuaishouCloudBindUrlFresh(flow)) { + return refreshKuaishouCloudTaskBindUrl(task, { + source: options.source || 'system_refresh_expired_bind_url', + actor, + claimLinkState, + }) + } + + let readyTask = task + + if (normalizeTaskStatus(task.task_status) !== TASK_STATUS.WAITING_BINDING) { + readyTask = + (await updateTask(task.id, { + task_status: TASK_STATUS.WAITING_BINDING, + claim_token: claimLinkState.token || task.claim_token || '', + claim_expires_at: claimLinkState.expiredAt || getTaskClaimExpiresAt(task), + updated_at: now, + })) || task + } + + return { + task: readyTask, + claimUrl: claimLinkState.claimUrl, + token: claimLinkState.token, + flow, + } + } + + const selectedCloud = await selectCloudtentaclesSourceForFulfillment(flow) + try { + const cloudContext = selectedCloud.context + const [knapsack, skuList] = await Promise.all([ + getCloudtentaclesKnapsack(cloudContext), + listCloudtentaclesSku(cloudContext), + ]) + const resolvedBinding = resolveKuaishouCloudBindingResources(flow, { + skuItems: Array.isArray(skuList.items) ? skuList.items : [], + knapsackItems: Array.isArray(knapsack.items) ? knapsack.items : [], + }) + const vnKeyCandidates = resolveKuaishouCloudVnKeyCandidates({ + flow, + binding: resolvedBinding, + }) + + if (!resolvedBinding.skuId || vnKeyCandidates.length === 0) { + throw createHttpError('当前任务缺少可用 cloud 资源,无法自动准备绑定', { + statusCode: 409, + errorCode: 'kuaishou_cloud_missing_binding_config', + }) + } + + const flowWithResolvedBinding = { + ...flow, + binding: { + ...flow.binding, + skuId: resolvedBinding.skuId, + skuName: resolvedBinding.skuName, + vnKey: KUAISHOU_CLOUD_FIXED_VN_KEY, + }, + } + + const deliveryPlan = resolveKuaishouCloudDeliveryPlan(flowWithResolvedBinding, { + skuItems: Array.isArray(skuList.items) ? skuList.items : [], + knapsackItems: Array.isArray(knapsack.items) ? knapsack.items : [], + }) + if (deliveryPlan.items.length === 0) { + throw createHttpError('当前任务缺少 cloud 发货物品配置', { + statusCode: 409, + errorCode: 'kuaishou_cloud_missing_delivery_items', + }) + } + + const usedKnapsack = deliveryPlan.items.every((item) => item.missingCount <= 0) + if (!usedKnapsack && !flowWithResolvedBinding.purchase.autoBuyEnabled) { + throw createHttpError('背包中没有现成库存,且当前配置未开启自动购买', { + statusCode: 409, + errorCode: 'kuaishou_cloud_auto_buy_disabled', + }) + } + + const missingSku = deliveryPlan.items.find((item) => item.missingCount > 0 && !item.skuItem) + if (missingSku) { + throw createHttpError(`cloudtentacles 未找到 SKU ${missingSku.cloudSkuId}`, { + statusCode: 404, + errorCode: 'kuaishou_cloud_sku_not_found', + }) + } + + const purchaseTriggered = false + const assetBefore = 0 + const assetAfter = 0 + + const preparedBinding = await prepareKuaishouCloudBindResourceWithFallback({ + cloudContext, + vnKeyCandidates, + }) + const defaultRoleSnapshot = await captureKuaishouCloudDefaultRoleSnapshot({ + cloudContext, + preparedBinding, + now, + }) + + const nextContext = { + ...taskContext, + kuaishouCloudFulfillment: { + ...flowWithResolvedBinding, + binding: { + ...flowWithResolvedBinding.binding, + resolvedSourceKey: cloudContext.resolvedSourceKey, + vnKey: preparedBinding.vnKey, + prepareStatus: 'ready', + vnId: preparedBinding.vnId, + vnPhone: preparedBinding.vnPhone, + bindUrl: preparedBinding.bindUrl, + bindPreparedAt: now, + bindExpiresAt: resolveKuaishouCloudBindUrlExpiresAt(now), + bindProbeAt: null as null, + bindProbeStatus: 'pending', + bindProbeMessage: '', + roleName: '', + roleId: '', + }, + role: buildPendingRoleWithDefaultSnapshot(defaultRoleSnapshot), + purchase: { + ...flowWithResolvedBinding.purchase, + usedKnapsack, + purchaseTriggered, + assetBefore, + assetAfter, + items: deliveryPlan.items.map((item) => ({ + cloudSkuId: item.cloudSkuId, + cloudSkuName: item.cloudSkuName, + requiredCount: item.quantity, + knapsackCount: item.knapsackCount, + purchasedCount: 0, + })), + purchaseAt: flowWithResolvedBinding.purchase.purchaseAt, + }, + }, + } + + const updatedTask = await updateTask(task.id, { + task_status: TASK_STATUS.WAITING_BINDING, + user_action_status: 'pending_claim', + claim_token: claimLinkState.token || task.claim_token || '', + claim_expires_at: claimLinkState.expiredAt || getTaskClaimExpiresAt(task), + role_id: '', + role_name: '', + last_error: '', + context_json: JSON.stringify(nextContext), + updated_at: now, + }) + + await createTaskEvent( + task.id, + 'kuaishou_cloud_binding_prepared', + { + source: String(options.source || 'system').trim() || 'system', + skuId: flowWithResolvedBinding.binding.skuId, + skuName: flowWithResolvedBinding.binding.skuName, + vnKey: preparedBinding.vnKey, + vnId: preparedBinding.vnId, + vnPhoneMasked: maskPhone(preparedBinding.vnPhone), + purchaseTriggered, + usedKnapsack, + deliveryItems: deliveryPlan.items.map((item) => ({ + cloudSkuId: item.cloudSkuId, + cloudSkuName: item.cloudSkuName, + quantity: item.quantity, + knapsackCount: item.knapsackCount, + purchasedCount: 0, + })), + resolvedByName: resolvedBinding.resolvedByName, + accountSelection: buildCloudtentaclesSourceSelectionEventDetail(selectedCloud), + defaultRoleName: defaultRoleSnapshot.defaultName, + defaultRoleId: defaultRoleSnapshot.defaultRid, + defaultRoleCaptureStatus: defaultRoleSnapshot.defaultCaptureStatus, + actor, + }, + now, + ) + + return { + task: updatedTask, + claimUrl: claimLinkState.claimUrl, + token: claimLinkState.token, + flow: normalizeKuaishouCloudFlow(nextContext.kuaishouCloudFulfillment), + } + } finally { + selectedCloud.release() + } +} + +export async function refreshKuaishouCloudTaskBindUrl(task: TaskRow, options: JsonObject = {}) { + if (!isKuaishouCloudTask(task)) { + throw createHttpError('当前任务不是快手 Cloud 履约任务', { + statusCode: 409, + errorCode: 'kuaishou_cloud_task_invalid', + }) + } + + const now = nowIso() + const actor = normalizeActor(options.actor) + const taskContext = parseTaskContext(task) + const flow = normalizeKuaishouCloudFlow(taskContext.kuaishouCloudFulfillment) + + if (!flow.binding.vnId || !flow.binding.vnKey) { + throw createHttpError('当前任务缺少可刷新绑定链接的虚拟号信息', { + statusCode: 409, + errorCode: 'kuaishou_cloud_missing_bind_url_context', + }) + } + + const cloudContext = resolvePersistedCloudtentaclesContextBySourceKeys([ + flow.binding.resolvedSourceKey, + ...flow.binding.cloudSourceKeys, + ]) + const oldVnKey = flow.binding.vnKey + const oldVnId = flow.binding.vnId + const oldVnPhone = flow.binding.vnPhone + + await backCloudtentaclesVirtualNumber({ + ...cloudContext, + key: oldVnKey, + id: oldVnId, + }) + + await createTaskEvent( + task.id, + 'kuaishou_cloud_expired_bind_number_returned', + { + source: String(options.source || 'system').trim() || 'system', + vnKey: oldVnKey, + vnId: oldVnId, + vnPhoneMasked: maskPhone(oldVnPhone), + actor, + }, + now, + ) + + let preparedBinding + try { + preparedBinding = await prepareKuaishouCloudBindResourceWithFallback({ + cloudContext, + vnKeyCandidates: resolveKuaishouCloudVnKeyCandidates({ + flow, + binding: flow.binding, + }), + }) + + if (!String(preparedBinding.bindUrl || '').trim()) { + throw createHttpError('cloudtentacles 未返回新的绑定链接', { + statusCode: 502, + errorCode: 'kuaishou_cloud_empty_bind_url', + }) + } + } catch (error) { + const failedTask = await markKuaishouCloudBindUrlRefreshFailed(task, { + taskContext, + flow, + now, + actor, + error, + }) + + return { + task: failedTask, + claimUrl: buildClaimUrl(String(task.primary_claim_token || task.claim_token || '')), + token: String(task.primary_claim_token || task.claim_token || ''), + flow: normalizeKuaishouCloudFlow(parseTaskContext(failedTask).kuaishouCloudFulfillment), + } + } + + const defaultRoleSnapshot = + normalizeTaskStatus(task.task_status) === TASK_STATUS.ROLE_CONFIRMED + ? null + : await captureKuaishouCloudDefaultRoleSnapshot({ + cloudContext, + preparedBinding, + now, + }) + const nextContext = { + ...taskContext, + kuaishouCloudFulfillment: { + ...flow, + binding: { + ...flow.binding, + prepareStatus: 'ready', + vnKey: preparedBinding.vnKey, + vnId: preparedBinding.vnId, + vnPhone: preparedBinding.vnPhone, + bindUrl: preparedBinding.bindUrl, + bindPreparedAt: now, + bindExpiresAt: resolveKuaishouCloudBindUrlExpiresAt(now), + bindProbeAt: null as null, + bindProbeStatus: 'pending', + bindProbeMessage: '', + roleName: + normalizeTaskStatus(task.task_status) === TASK_STATUS.ROLE_CONFIRMED + ? flow.binding.roleName + : '', + roleId: + normalizeTaskStatus(task.task_status) === TASK_STATUS.ROLE_CONFIRMED + ? flow.binding.roleId + : '', + }, + role: + normalizeTaskStatus(task.task_status) === TASK_STATUS.ROLE_CONFIRMED + ? flow.role + : buildPendingRoleWithDefaultSnapshot(defaultRoleSnapshot!), + }, + } + + const claimLinkState = options.claimLinkState || (await ensureTaskClaimLink(task)) + const updatedTask = await updateTask(task.id, { + task_status: + normalizeTaskStatus(task.task_status) === TASK_STATUS.ROLE_CONFIRMED + ? task.task_status + : TASK_STATUS.WAITING_BINDING, + claim_token: claimLinkState.token || task.claim_token || '', + claim_expires_at: claimLinkState.expiredAt || getTaskClaimExpiresAt(task), + role_id: + normalizeTaskStatus(task.task_status) === TASK_STATUS.ROLE_CONFIRMED ? task.role_id : '', + role_name: + normalizeTaskStatus(task.task_status) === TASK_STATUS.ROLE_CONFIRMED ? task.role_name : '', + last_error: '', + context_json: JSON.stringify(nextContext), + updated_at: now, + }) + + await createTaskEvent( + task.id, + 'kuaishou_cloud_bind_url_refreshed', + { + source: String(options.source || 'system').trim() || 'system', + oldVnId, + oldVnPhoneMasked: maskPhone(oldVnPhone), + vnKey: preparedBinding.vnKey, + vnId: preparedBinding.vnId, + vnPhoneMasked: maskPhone(preparedBinding.vnPhone), + actor, + }, + now, + ) + + return { + task: updatedTask, + claimUrl: claimLinkState.claimUrl, + token: claimLinkState.token, + flow: normalizeKuaishouCloudFlow(nextContext.kuaishouCloudFulfillment), + } +} + +export async function rebindKuaishouCloudTaskRole(task: TaskRow, options: JsonObject = {}) { + if (!isKuaishouCloudTask(task)) { + throw createHttpError('当前任务不是快手 Cloud 履约任务', { + statusCode: 409, + errorCode: 'kuaishou_cloud_task_invalid', + }) + } + + const normalizedStatus = normalizeTaskStatus(task.task_status) + if ( + ![ + TASK_STATUS.WAITING_BINDING, + TASK_STATUS.ROLE_CONFIRMED, + TASK_STATUS.MANUAL_REVIEW, + TASK_STATUS.RETRY_PENDING, + ].includes(normalizedStatus as any) + ) { + throw createHttpError('当前任务状态不可换绑角色', { + statusCode: 409, + errorCode: 'kuaishou_cloud_rebind_not_allowed', + }) + } + + const now = nowIso() + const actor = normalizeActor(options.actor) + const taskContext = parseTaskContext(task) + const flow = normalizeKuaishouCloudFlow(taskContext.kuaishouCloudFulfillment) + + if ( + flow.dispatch.status === 'success' || + normalizeTaskStatus(task.task_status) === TASK_STATUS.DISPATCHED_PENDING_RETURN + ) { + throw createHttpError('当前任务已经发货,不能换绑角色', { + statusCode: 409, + errorCode: 'kuaishou_cloud_rebind_after_dispatch_forbidden', + }) + } + + if (!flow.binding.vnId || !flow.binding.vnKey) { + throw createHttpError('当前任务缺少可退还的虚拟号信息,请先准备绑定资源', { + statusCode: 409, + errorCode: 'kuaishou_cloud_rebind_missing_binding_context', + }) + } + + const source = String(options.source || 'system_rebind_role').trim() || 'system_rebind_role' + const cloudContext = resolvePersistedCloudtentaclesContextBySourceKeys([ + flow.binding.resolvedSourceKey, + ...flow.binding.cloudSourceKeys, + ]) + const oldBinding = { + vnKey: flow.binding.vnKey, + vnId: flow.binding.vnId, + vnPhone: flow.binding.vnPhone, + bindUrl: flow.binding.bindUrl, + bindPreparedAt: flow.binding.bindPreparedAt, + bindExpiresAt: flow.binding.bindExpiresAt, + roleName: flow.binding.roleName || flow.role.name || task.role_name || '', + roleId: flow.binding.roleId || flow.role.rid || task.role_id || '', + } + const previousRebind: JsonObject = + flow.rebind && typeof flow.rebind === 'object' ? (flow.rebind as JsonObject) : {} + const history = Array.isArray(previousRebind.history) ? previousRebind.history : [] + const attempt = Math.max(1, Number(previousRebind.currentAttempt || history.length || 0) + 1) + const baseHistoryItem = { + attempt, + source, + requestedAt: now, + requestedBy: actor, + oldBinding, + } + + await createTaskEvent( + task.id, + 'kuaishou_cloud_rebind_requested', + { + source, + attempt, + oldVnId: oldBinding.vnId, + oldVnPhoneMasked: maskPhone(oldBinding.vnPhone), + oldRoleName: oldBinding.roleName, + oldRoleId: oldBinding.roleId, + actor, + }, + now, + ) + + await backCloudtentaclesVirtualNumber({ + ...cloudContext, + key: oldBinding.vnKey, + id: oldBinding.vnId, + }) + + await createTaskEvent( + task.id, + 'kuaishou_cloud_rebind_old_number_returned', + { + source, + attempt, + vnKey: oldBinding.vnKey, + vnId: oldBinding.vnId, + vnPhoneMasked: maskPhone(oldBinding.vnPhone), + actor, + }, + now, + ) + + let preparedBinding + try { + preparedBinding = await prepareKuaishouCloudBindResourceWithFallback({ + cloudContext, + vnKeyCandidates: resolveKuaishouCloudVnKeyCandidates({ + flow, + binding: flow.binding, + }), + }) + } catch (error) { + const errorMessage = error instanceof Error ? error.message : '新绑定资源准备失败' + const failedContext = { + ...taskContext, + kuaishouCloudFulfillment: { + ...flow, + binding: { + ...flow.binding, + prepareStatus: 'pending', + vnId: 0, + vnPhone: '', + bindUrl: '', + bindPreparedAt: null, + bindExpiresAt: null, + bindProbeAt: now, + bindProbeStatus: 'rebind_failed', + bindProbeMessage: errorMessage, + roleName: '', + roleId: '', + }, + role: { + status: 'pending', + name: '', + rid: '', + refreshedAt: now, + errorMessage: `旧虚拟号已退还,新绑定资源准备失败:${errorMessage}`, + rawInfo: null, + }, + rebind: { + ...previousRebind, + currentAttempt: attempt, + history: [ + ...history, + { + ...baseHistoryItem, + status: 'failed', + errorMessage, + }, + ], + }, + }, + } + const failedTask = await updateTask(task.id, { + task_status: TASK_STATUS.PENDING_BINDING_PREPARE, + role_id: '', + role_name: '', + role_confirmed_at: null, + last_error: `换绑失败,旧虚拟号已退还,新绑定资源准备失败:${errorMessage}`, + context_json: JSON.stringify(failedContext), + updated_at: now, + }) + + await createTaskEvent( + task.id, + 'kuaishou_cloud_rebind_failed', + { + source, + attempt, + errorMessage, + actor, + }, + now, + ) + + return { + task: failedTask || task, + claimUrl: buildClaimUrl(String(task.primary_claim_token || task.claim_token || '')), + token: String(task.primary_claim_token || task.claim_token || ''), + flow: normalizeKuaishouCloudFlow( + parseTaskContext(failedTask || task).kuaishouCloudFulfillment, + ), + } + } + + const claimLinkState = await ensureTaskClaimLink(task) + const nextBindExpiresAt = resolveKuaishouCloudBindUrlExpiresAt(now) + const defaultRoleSnapshot = await captureKuaishouCloudDefaultRoleSnapshot({ + cloudContext, + preparedBinding, + now, + }) + const nextContext = { + ...taskContext, + kuaishouCloudFulfillment: { + ...flow, + binding: { + ...flow.binding, + prepareStatus: 'ready', + vnKey: preparedBinding.vnKey, + vnId: preparedBinding.vnId, + vnPhone: preparedBinding.vnPhone, + bindUrl: preparedBinding.bindUrl, + bindPreparedAt: now, + bindExpiresAt: nextBindExpiresAt, + bindProbeAt: null, + bindProbeStatus: 'pending', + bindProbeMessage: '', + roleName: '', + roleId: '', + }, + role: buildPendingRoleWithDefaultSnapshot(defaultRoleSnapshot), + rebind: { + ...previousRebind, + currentAttempt: attempt, + history: [ + ...history, + { + ...baseHistoryItem, + newBinding: { + vnKey: preparedBinding.vnKey, + vnId: preparedBinding.vnId, + vnPhone: preparedBinding.vnPhone, + bindUrl: preparedBinding.bindUrl, + bindPreparedAt: now, + bindExpiresAt: nextBindExpiresAt, + }, + status: 'success', + errorMessage: '', + }, + ], + }, + }, + } + + const updatedTask = await updateTask(task.id, { + task_status: TASK_STATUS.WAITING_BINDING, + user_action_status: 'pending_claim', + claim_token: claimLinkState.token || task.claim_token || '', + claim_expires_at: claimLinkState.expiredAt || getTaskClaimExpiresAt(task), + role_id: '', + role_name: '', + role_confirmed_at: null, + last_error: '', + context_json: JSON.stringify(nextContext), + updated_at: now, + }) + + await createTaskEvent( + task.id, + 'kuaishou_cloud_rebind_prepared', + { + source, + attempt, + oldVnId: oldBinding.vnId, + oldVnPhoneMasked: maskPhone(oldBinding.vnPhone), + vnKey: preparedBinding.vnKey, + vnId: preparedBinding.vnId, + vnPhoneMasked: maskPhone(preparedBinding.vnPhone), + bindUrl: preparedBinding.bindUrl, + defaultRoleName: defaultRoleSnapshot.defaultName, + defaultRoleId: defaultRoleSnapshot.defaultRid, + defaultRoleCaptureStatus: defaultRoleSnapshot.defaultCaptureStatus, + actor, + }, + now, + ) + + return { + task: updatedTask, + claimUrl: claimLinkState.claimUrl, + token: claimLinkState.token, + flow: normalizeKuaishouCloudFlow(nextContext.kuaishouCloudFulfillment), + } +} + +export async function probeKuaishouCloudTaskBindUrl(task: TaskRow, options: JsonObject = {}) { + if (!isKuaishouCloudTask(task)) { + throw createHttpError('当前任务不是快手 Cloud 履约任务', { + statusCode: 409, + errorCode: 'kuaishou_cloud_task_invalid', + }) + } + + const now = nowIso() + const taskContext = parseTaskContext(task) + const flow = normalizeKuaishouCloudFlow(taskContext.kuaishouCloudFulfillment) + + if (!flow.binding.bindUrl) { + return { + task, + flow, + probe: null as null, + } + } + + const probeIntervalMs = + Number(resolveCloudtentaclesConfig().bindUrlProbeIntervalSeconds || 30) * 1000 + if ( + !options.force && + flow.binding.bindProbeAt && + Date.now() - Date.parse(flow.binding.bindProbeAt) < probeIntervalMs + ) { + return { + task, + flow, + probe: null as null, + } + } + + const probe = await probeCloudtentaclesBindUrl({ + bindUrl: flow.binding.bindUrl, + }) + + if (probe.expired || !isKuaishouCloudBindUrlFresh(flow)) { + const refreshed = await refreshKuaishouCloudTaskBindUrl(task, { + source: options.source || 'claim_page_bind_url_expired', + actor: options.actor || { source: 'system' }, + }) + return { + ...refreshed, + probe, + } + } + + const probeRoleInfo = normalizeKuaishouCloudRoleInfo(probe.roleInfo) + const hasRoleInfo = Boolean(probeRoleInfo.name || probeRoleInfo.rid) + const roleState = hasRoleInfo + ? buildRoleStateFromCurrentInfo({ + flow, + roleInfo: probeRoleInfo, + now, + emptyMessage: '当前还没有查询到角色信息,请完成绑定后稍等片刻再试', + }) + : null + const nextContext = { + ...taskContext, + kuaishouCloudFulfillment: { + ...flow, + binding: { + ...flow.binding, + bindProbeAt: now, + bindProbeStatus: probe.valid ? 'valid' : 'invalid', + bindProbeMessage: String(probe.message || probe.reason || '').trim(), + roleName: roleState ? roleState.bindingRoleName : flow.binding.roleName, + roleId: roleState ? roleState.bindingRoleId : flow.binding.roleId, + }, + role: roleState ? roleState.role : flow.role, + }, + } + + const updatedTask = await updateTask(task.id, { + role_id: roleState && !roleState.isDefaultRole ? probeRoleInfo.rid : task.role_id, + role_name: roleState && !roleState.isDefaultRole ? probeRoleInfo.name : task.role_name, + context_json: JSON.stringify(nextContext), + updated_at: now, + }) + + return { + task: updatedTask, + flow: normalizeKuaishouCloudFlow(nextContext.kuaishouCloudFulfillment), + probe, + } +} + +/** + * @param {any} task + * @param {{ taskContext?: any, flow?: any, now?: string, actor?: any, error?: unknown }} [input] + */ +async function markKuaishouCloudBindUrlRefreshFailed( + task: TaskRow, + { taskContext, flow, now, actor, error }: JsonObject = {}, +) { + const errorMessage = + error instanceof Error ? error.message : String(error || '新绑定链接准备失败') + const nextContext = { + ...taskContext, + kuaishouCloudFulfillment: { + ...flow, + binding: { + ...flow.binding, + prepareStatus: 'pending', + vnId: 0, + vnPhone: '', + bindUrl: '', + bindPreparedAt: null, + bindExpiresAt: null, + bindProbeAt: now, + bindProbeStatus: 'refresh_failed', + bindProbeMessage: errorMessage, + roleName: '', + roleId: '', + }, + role: { + status: 'pending', + name: '', + rid: '', + refreshedAt: now, + errorMessage: '绑定链接已过期,旧号码已退还,新链接准备失败,请稍后刷新或联系客服处理', + rawInfo: null, + }, + }, + } + + const updatedTask = await updateTask(task.id, { + task_status: TASK_STATUS.PENDING_BINDING_PREPARE, + role_id: '', + role_name: '', + last_error: `绑定链接过期,旧号码已退还,新链接准备失败:${errorMessage}`, + context_json: JSON.stringify(nextContext), + updated_at: now, + }) + + await createTaskEvent( + task.id, + 'kuaishou_cloud_bind_url_refresh_failed', + { + source: 'system_refresh_expired_bind_url', + errorMessage, + actor, + }, + now, + ) + + await notifyKuaishouCloudBindUrlRefreshFailed({ + task: updatedTask, + errorMessage, + }) + + return updatedTask +} + +export async function refreshKuaishouCloudTaskRoleInfo(task: TaskRow, options: JsonObject = {}) { + if (!isKuaishouCloudTask(task)) { + throw createHttpError('当前任务不是快手 Cloud 履约任务', { + statusCode: 409, + errorCode: 'kuaishou_cloud_task_invalid', + }) + } + + const now = nowIso() + const actor = normalizeActor(options.actor) + const taskContext = parseTaskContext(task) + const flow = normalizeKuaishouCloudFlow(taskContext.kuaishouCloudFulfillment) + + if (!flow.binding.vnId || !flow.binding.vnKey) { + throw createHttpError('当前任务还没有可查询的绑定角色信息,请先准备绑定资源', { + statusCode: 409, + errorCode: 'kuaishou_cloud_missing_bind_info_context', + }) + } + + if (flow.binding.bindUrl) { + const probed = await probeKuaishouCloudTaskBindUrl(task, { + source: options.source || 'system_role_refresh_bind_url_probe', + actor, + force: options.forceProbe === true, + }) + const probedFlow = normalizeKuaishouCloudFlow( + parseTaskContext(probed.task).kuaishouCloudFulfillment, + ) + if (probed.probe?.expired) { + return { + task: probed.task, + roleInfo: normalizeKuaishouCloudRoleInfo(null), + flow: probedFlow, + } + } + + if ( + (probed.probe === null || probed.probe?.valid) && + (probedFlow.binding.roleName || probedFlow.binding.roleId) + ) { + return { + task: probed.task, + roleInfo: { + name: probedFlow.binding.roleName, + rid: probedFlow.binding.roleId, + rawInfo: probedFlow.role.rawInfo, + }, + flow: probedFlow, + } + } + } + + const cloudContext = resolvePersistedCloudtentaclesContextBySourceKeys([ + flow.binding.resolvedSourceKey, + ...flow.binding.cloudSourceKeys, + ]) + const bindInfoResult = await getCloudtentaclesBindInfo({ + ...cloudContext, + key: flow.binding.vnKey, + id: flow.binding.vnId, + }) + + const bindInfo = normalizeKuaishouCloudRoleInfo(bindInfoResult.bindInfo) + const roleState = buildRoleStateFromCurrentInfo({ + flow, + roleInfo: bindInfo, + now, + emptyMessage: '当前还没有查询到角色信息,请完成绑定后稍等片刻再试', + }) + const nextContext = { + ...taskContext, + kuaishouCloudFulfillment: { + ...flow, + binding: { + ...flow.binding, + roleName: roleState.bindingRoleName, + roleId: roleState.bindingRoleId, + }, + role: roleState.role, + }, + } + + const updatedTask = await updateTask(task.id, { + role_id: roleState.hasRoleInfo && !roleState.isDefaultRole ? bindInfo.rid : '', + role_name: roleState.hasRoleInfo && !roleState.isDefaultRole ? bindInfo.name : '', + context_json: JSON.stringify(nextContext), + updated_at: now, + }) + + if (options.recordEvent !== false) { + await createTaskEvent( + task.id, + 'kuaishou_cloud_role_info_refreshed', + { + source: String(options.source || 'system').trim() || 'system', + roleName: bindInfo.name, + roleId: bindInfo.rid, + vnId: flow.binding.vnId, + actor, + }, + now, + ) + } + + return { + task: updatedTask, + roleInfo: bindInfo, + flow: normalizeKuaishouCloudFlow(nextContext.kuaishouCloudFulfillment), + } +} + +function resolveKuaishouCloudDeliveryPlan( + flow: JsonObject, + { skuItems = [], knapsackItems = [] }: { skuItems?: unknown[]; knapsackItems?: unknown[] } = {}, +): { items: CloudDeliveryPlanItem[] } { + const normalizedSkuItems = Array.isArray(skuItems) ? skuItems.filter(isCloudSkuLikeItem) : [] + const normalizedKnapsackItems = Array.isArray(knapsackItems) + ? knapsackItems.filter(isCloudSkuLikeItem) + : [] + const deliveryItems = Array.isArray(flow.deliveryItems) ? flow.deliveryItems : [] + + return { + items: deliveryItems + .map((item: unknown) => { + const source = item && typeof item === 'object' ? (item as JsonObject) : {} + const cloudSkuId = Number(source.cloudSkuId || source.skuId || 0) || 0 + const quantity = Number(source.quantity || 1) || 1 + if (!cloudSkuId || !Number.isInteger(quantity) || quantity <= 0) { + return null + } + + const skuItem = normalizedSkuItems.find((sku) => Number(sku.id || 0) === cloudSkuId) || null + const knapsackItem = + normalizedKnapsackItems.find((sku) => Number(sku.id || 0) === cloudSkuId) || null + const knapsackCount = Math.max(0, Number(knapsackItem?.count || 0) || 0) + const cloudSkuName = String( + source.cloudSkuName || source.skuName || skuItem?.name || knapsackItem?.name || '', + ).trim() + + return { + cloudSkuId, + cloudSkuName, + quantity, + skuItem, + knapsackItem, + knapsackCount, + missingCount: Math.max(0, quantity - knapsackCount), + } + }) + .filter((item): item is CloudDeliveryPlanItem => Boolean(item)), + } +} + +function isCloudSkuLikeItem(item: unknown): item is JsonObject { + const current = item && typeof item === 'object' ? (item as JsonObject) : {} + return Number(current.id || 0) > 0 +} + +export { + dispatchKuaishouCloudFulfillmentTask, + returnKuaishouCloudFulfillmentTask, +} from './task-finalization.js' diff --git a/apps/backend/src/services/fulfillment/kuaishou-cloud/task-context.ts b/apps/backend/src/services/fulfillment/kuaishou-cloud/task-context.ts index dc28bada..bd091073 100644 --- a/apps/backend/src/services/fulfillment/kuaishou-cloud/task-context.ts +++ b/apps/backend/src/services/fulfillment/kuaishou-cloud/task-context.ts @@ -1,14 +1,18 @@ import { parseTaskContext as parseTaskContextValue } from "../../../utils/task-json.js"; +import type { TaskRow } from "../../../types/repository/rows.js"; -export function normalizeActor(actor) { +type JsonObject = Record; + +export function normalizeActor(actor: unknown) { if (!actor || typeof actor !== "object") { return null; } - const source = String(actor.source || "").trim(); - const userId = Number(actor.userId || 0) || 0; - const username = String(actor.username || "").trim(); - const role = String(actor.role || "").trim(); + const current = actor as JsonObject; + const source = String(current.source || "").trim(); + const userId = Number(current.userId || 0) || 0; + const username = String(current.username || "").trim(); + const role = String(current.role || "").trim(); if (!source && !userId && !username && !role) { return null; @@ -22,19 +26,19 @@ export function normalizeActor(actor) { }; } -export function parseTaskContext(task) { +export function parseTaskContext(task: Partial | null | undefined) { return parseTaskContextValue(task); } -export function getTaskClaimExpiresAt(task) { +export function getTaskClaimExpiresAt(task: Partial | null | undefined) { return task?.claim_expires_at || task?.primary_claim_expires_at || null; } -export function isClaimExpired(expiredAt) { +export function isClaimExpired(expiredAt: unknown) { if (!expiredAt) { return false; } - const timestamp = new Date(expiredAt).getTime(); + const timestamp = new Date(expiredAt instanceof Date ? expiredAt : String(expiredAt || "")).getTime(); return Number.isFinite(timestamp) && timestamp <= Date.now(); } diff --git a/apps/backend/src/services/fulfillment/kuaishou-cloud/task-finalization.test.ts b/apps/backend/src/services/fulfillment/kuaishou-cloud/task-finalization.test.ts new file mode 100644 index 00000000..1844b674 --- /dev/null +++ b/apps/backend/src/services/fulfillment/kuaishou-cloud/task-finalization.test.ts @@ -0,0 +1,67 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { buildDispatchStockItems } from "./task-finalization.js"; + +test("buildDispatchStockItems 只计算兑换阶段需要补买的库存缺口", () => { + const result = buildDispatchStockItems( + [ + { + cloudSkuId: 101, + cloudSkuName: "", + quantity: 3, + }, + ], + { + skuItems: [ + { + id: 101, + name: "套装-暗影哥特", + price: 88, + }, + ], + knapsackItems: [ + { + id: 101, + name: "套装-暗影哥特", + count: 1, + }, + ], + } + ); + + assert.deepEqual(result, [ + { + cloudSkuId: 101, + cloudSkuName: "套装-暗影哥特", + quantity: 3, + requiredCount: 3, + knapsackCount: 1, + purchasedCount: 2, + }, + ]); +}); + +test("buildDispatchStockItems 背包足够时不需要补买", () => { + const result = buildDispatchStockItems( + [ + { + cloudSkuId: 202, + cloudSkuName: "套装-浪漫天命", + quantity: 1, + }, + ], + { + knapsackItems: [ + { + id: 202, + name: "套装-浪漫天命", + count: 5, + }, + ], + } + ); + + assert.equal(result[0]?.knapsackCount, 5); + assert.equal(result[0]?.purchasedCount, 0); +}); diff --git a/apps/backend/src/services/fulfillment/kuaishou-cloud/task-finalization.ts b/apps/backend/src/services/fulfillment/kuaishou-cloud/task-finalization.ts index 90414207..e78be0d1 100644 --- a/apps/backend/src/services/fulfillment/kuaishou-cloud/task-finalization.ts +++ b/apps/backend/src/services/fulfillment/kuaishou-cloud/task-finalization.ts @@ -3,10 +3,21 @@ import { createTaskEvent } from "../../../repositories/task-event-repo.js"; import { updateTask } from "../../../repositories/task-repo.js"; import { createHttpError } from "../../../utils/http.js"; import { nowIso } from "../../../utils/time.js"; -import { notifyKuaishouCloudConsumeFailed } from "../../notification/domain-notifications.js"; -import { useCloudtentaclesSku } from "../../platforms/cloudtentacles/catalog-service.js"; +import { TASK_STATUS, normalizeTaskStatus, type TaskStatus } from "../../../domain/task-status.js"; +import { + notifyKuaishouCloudAssetNotEnough, + notifyKuaishouCloudConsumeFailed, +} from "../../notification/domain-notifications.js"; +import { + buyCloudtentaclesSku, + getCloudtentaclesAsset, + listCloudtentaclesSku, + useCloudtentaclesSku, +} from "../../platforms/cloudtentacles/catalog-service.js"; +import { getCloudtentaclesKnapsack } from "../../platforms/cloudtentacles/knapsack-service.js"; import { backCloudtentaclesVirtualNumber } from "../../platforms/cloudtentacles/virtual-number-service.js"; import { consumeKuaishouEticket } from "../../platforms/kuaishou-eticket/consume-service.js"; +import { isKuaishouEticketMockTicketCode } from "../../platforms/kuaishou-eticket/mock-ticket-service.js"; import { getKuaishouEticketSourceConfig, resolveKuaishouEticketShopConfig, @@ -18,11 +29,42 @@ import { normalizeKuaishouCloudFlow, type JsonObject, } from "./domain.js"; -import { resolvePersistedCloudtentaclesContextWithFallback } from "./cloudtentacles-context.js"; +import { resolvePersistedCloudtentaclesContextBySourceKeys } from "./cloudtentacles-context.js"; +import { syncKuaishouCloudRoleInfoBeforeDispatch } from "./dispatch-role-sync.js"; import { normalizeActor, parseTaskContext } from "./task-context.js"; +import type { TaskRow } from "../../../types/repository/rows.js"; + +type DispatchDeliveryItem = { + cloudSkuId: number; + cloudSkuName: string; + quantity: number; +}; + +type DispatchResultItem = DispatchDeliveryItem & { + unitIndex: number; + sendType: number; + note: string; + responseMessage: string; +}; + +type DispatchStockItem = DispatchDeliveryItem & { + requiredCount: number; + knapsackCount: number; + purchasedCount: number; +}; + +type DispatchStockResult = { + usedKnapsack: boolean; + purchaseTriggered: boolean; + assetBefore: number; + assetAfter: number; + items: DispatchStockItem[]; +}; + +const cloudSkuDispatchLocks = new Map>(); export async function dispatchKuaishouCloudFulfillmentTask( - task, + task: TaskRow, options: JsonObject = {} ) { if (!isKuaishouCloudTask(task)) { @@ -36,12 +78,10 @@ export async function dispatchKuaishouCloudFulfillmentTask( const now = nowIso(); const taskContext = parseTaskContext(task); const flow = normalizeKuaishouCloudFlow(taskContext.kuaishouCloudFulfillment); - const effectiveSourceKey = - flow.binding.resolvedSourceKey || flow.binding.cloudSourceKey || "default"; - const cloudContext = resolvePersistedCloudtentaclesContextWithFallback( - effectiveSourceKey, - flow.binding.cloudSourceKeyFallbacks || [] - ); + const cloudContext = resolvePersistedCloudtentaclesContextBySourceKeys([ + flow.binding.resolvedSourceKey, + ...flow.binding.cloudSourceKeys, + ]); if (!flow.binding.skuId || !flow.binding.vnId || !flow.binding.vnPhone) { throw createHttpError( @@ -64,57 +104,116 @@ export async function dispatchKuaishouCloudFulfillmentTask( if ( flow.dispatch.status === "success" && - String(task.task_status || "").trim() === "dispatched_pending_return" + normalizeTaskStatus(task.task_status) === TASK_STATUS.DISPATCHED_PENDING_RETURN ) { return { task, flow }; } - const dispatchResult = await useCloudtentaclesSku({ - ...cloudContext, - id: flow.binding.skuId, - virtualNumberId: flow.binding.vnId, - phone: flow.binding.vnPhone, + const synced = await syncKuaishouCloudRoleInfoBeforeDispatch(task, { + now, + actor, + source: options.source || "system_before_dispatch", + cloudContext, + taskContext, + flow, }); + const syncedFlow = synced.flow; + const syncedTaskContext = synced.taskContext; + + const deliveryItems = resolveDispatchDeliveryItems(syncedFlow); + if (deliveryItems.length === 0) { + throw createHttpError("当前任务缺少 cloud 发货物品配置", { + statusCode: 409, + errorCode: "kuaishou_cloud_missing_delivery_items", + }); + } + + let dispatchResults: DispatchResultItem[]; + let stockResult: DispatchStockResult; + try { + ({ dispatchResults, stockResult } = await withCloudSkuDispatchLocks( + resolveCloudSkuDispatchLockKeys(cloudContext.resolvedSourceKey, deliveryItems), + () => prepareStockAndDispatch({ + task, + flow: syncedFlow, + cloudContext, + deliveryItems, + }) + )); + } catch (error) { + await markKuaishouCloudDispatchFailed(task, syncedTaskContext, syncedFlow, error, { + actor, + source: options.source || "system", + }); + throw error; + } + const firstDispatchResult: DispatchResultItem = dispatchResults[0] || { + cloudSkuId: syncedFlow.binding.skuId, + cloudSkuName: syncedFlow.binding.skuName, + quantity: 1, + unitIndex: 1, + sendType: 0, + note: "", + responseMessage: "", + }; + const dispatchSummary = + dispatchResults.length > 1 + ? `cloudtentacles 发货成功,共 ${dispatchResults.length} 次` + : String( + firstDispatchResult.responseMessage || + firstDispatchResult.note || + "cloudtentacles 发货成功" + ).trim(); const nextContext = { - ...taskContext, + ...syncedTaskContext, kuaishouCloudFulfillment: { - ...flow, + ...syncedFlow, ticket: { - ...flow.ticket, + ...syncedFlow.ticket, code: ticketCode || persistedTicketCode, - capturedAt: ticketCode ? now : flow.ticket.capturedAt, - capturedBy: ticketCode && actor ? actor : flow.ticket.capturedBy, + capturedAt: ticketCode ? now : syncedFlow.ticket.capturedAt, + capturedBy: ticketCode && actor ? actor : syncedFlow.ticket.capturedBy, }, dispatch: { - ...flow.dispatch, + ...syncedFlow.dispatch, status: "success", dispatchAt: now, dispatchBy: actor, - sendType: Number(dispatchResult.sendType || 0) || 0, - note: String( - dispatchResult.note || - dispatchResult.responseMessage || - "cloudtentacles 发货成功" - ).trim(), + sendType: Number(firstDispatchResult.sendType || 0) || 0, + note: dispatchSummary, + items: dispatchResults, + }, + purchase: { + ...syncedFlow.purchase, + usedKnapsack: stockResult.usedKnapsack, + purchaseTriggered: stockResult.purchaseTriggered, + assetBefore: stockResult.assetBefore, + assetAfter: stockResult.assetAfter, + purchaseAt: stockResult.purchaseTriggered + ? now + : syncedFlow.purchase.purchaseAt, + items: stockResult.items, }, }, }; let updatedTask = await updateTask(task.id, { - task_status: "dispatched_pending_return", + task_status: TASK_STATUS.DISPATCHED_PENDING_RETURN, delivery_status: "delivered", result_code: "kuaishou_cloud_dispatched", - result_message: String( - dispatchResult.responseMessage || - dispatchResult.note || - "cloudtentacles 发货成功" - ).trim(), + result_message: dispatchSummary, user_action_status: "not_required", last_error: "", context_json: JSON.stringify(nextContext), updated_at: now, }); + if (!updatedTask) { + throw createHttpError("快手 Cloud 发货状态更新失败", { + statusCode: 500, + errorCode: "kuaishou_cloud_dispatch_update_failed", + }); + } await createTaskEvent( task.id, @@ -122,11 +221,14 @@ export async function dispatchKuaishouCloudFulfillmentTask( { source: String(options.source || "system").trim() || "system", ticketCodeMasked: maskCode(ticketCode || persistedTicketCode), - skuId: flow.binding.skuId, - vnId: flow.binding.vnId, - vnPhoneMasked: maskPhone(flow.binding.vnPhone), - sendType: dispatchResult.sendType, - note: dispatchResult.note, + skuId: syncedFlow.binding.skuId, + vnId: syncedFlow.binding.vnId, + vnPhoneMasked: maskPhone(syncedFlow.binding.vnPhone), + sendType: firstDispatchResult.sendType, + note: dispatchSummary, + deliveryItems, + dispatchResults, + stockResult, actor, }, now @@ -156,8 +258,482 @@ export async function dispatchKuaishouCloudFulfillmentTask( }; } -export async function returnKuaishouCloudFulfillmentTask( +async function markKuaishouCloudDispatchFailed( + task: TaskRow, + taskContext: JsonObject, + flow: JsonObject, + error: unknown, + options: JsonObject = {} +) { + const now = nowIso(); + const errorMessage = resolveErrorMessage(error); + const errorCode = resolveErrorCode(error) || "kuaishou_cloud_dispatch_failed"; + const failureContext = resolveDispatchFailureContext(error); + const stockResult = isPlainObject(failureContext.stockResult) + ? (failureContext.stockResult as Partial) + : null; + const nextContext = { + ...taskContext, + kuaishouCloudFulfillment: { + ...flow, + dispatch: { + ...flow.dispatch, + status: "failed", + failedAt: now, + failedStage: String(failureContext.stage || "").trim(), + errorCode, + errorMessage, + note: buildDispatchFailedMessage(errorMessage), + }, + purchase: { + ...flow.purchase, + ...(stockResult + ? { + usedKnapsack: stockResult.usedKnapsack, + purchaseTriggered: stockResult.purchaseTriggered, + assetBefore: stockResult.assetBefore, + assetAfter: stockResult.assetAfter, + items: stockResult.items, + } + : {}), + }, + }, + }; + + await updateTask(task.id, { + task_status: TASK_STATUS.MANUAL_REVIEW, + user_action_status: "not_required", + last_error: errorMessage, + result_code: errorCode, + result_message: buildDispatchFailedMessage(errorMessage), + context_json: JSON.stringify(nextContext), + updated_at: now, + }); + + await createTaskEvent( + task.id, + "kuaishou_cloud_dispatch_failed", + { + source: String(options.source || "system").trim() || "system", + actor: options.actor, + errorCode, + errorMessage, + failureContext, + }, + now + ); +} + +async function prepareStockAndDispatch({ task, + flow, + cloudContext, + deliveryItems, +}: { + task: TaskRow; + flow: JsonObject; + cloudContext: JsonObject; + deliveryItems: DispatchDeliveryItem[]; +}) { + const [knapsack, skuList] = await Promise.all([ + getCloudtentaclesKnapsack(cloudContext), + listCloudtentaclesSku(cloudContext), + ]); + const skuItems = Array.isArray(skuList.items) ? skuList.items : []; + const knapsackItems = Array.isArray(knapsack.items) ? knapsack.items : []; + const stockItems = buildDispatchStockItems(deliveryItems, { + skuItems, + knapsackItems, + }); + const missingItems = stockItems.filter((item) => item.purchasedCount > 0); + + let assetBefore = 0; + let assetAfter = 0; + let purchaseTriggered = false; + + if (missingItems.length > 0) { + if (flow.purchase?.autoBuyEnabled === false) { + throw createHttpError("背包中没有现成库存,且当前配置未开启自动购买", { + statusCode: 409, + errorCode: "kuaishou_cloud_auto_buy_disabled", + }); + } + + const missingSku = missingItems.find((item) => !findCloudSkuItem(skuItems, item.cloudSkuId)); + if (missingSku) { + throw createHttpError(`cloudtentacles 未找到 SKU ${missingSku.cloudSkuId}`, { + statusCode: 404, + errorCode: "kuaishou_cloud_sku_not_found", + }); + } + + const asset = await getCloudtentaclesAsset(cloudContext); + assetBefore = Number(asset.asset || 0) || 0; + const targetPrice = missingItems.reduce((sum, item) => { + const skuItem = findCloudSkuItem(skuItems, item.cloudSkuId); + return sum + item.purchasedCount * Number(skuItem?.price || 0); + }, 0); + const requiredAsset = targetPrice + Number(flow.purchase?.minAssetReserve || 0); + + if (assetBefore < requiredAsset) { + await notifyKuaishouCloudAssetNotEnough({ + task, + flow, + assetBefore, + requiredAsset, + skuName: missingItems + .map((item) => item.cloudSkuName || `SKU ${item.cloudSkuId}`) + .join("、"), + }); + throw createHttpError(`余额不足,当前 ${assetBefore},至少需要 ${requiredAsset}`, { + statusCode: 409, + errorCode: "kuaishou_cloud_asset_not_enough", + }); + } + + for (const item of missingItems) { + await createTaskEvent( + task.id, + "cloudtentacles_sku_buy_started", + { + source: "kuaishou_cloud_dispatch", + cloudSkuId: item.cloudSkuId, + cloudSkuName: item.cloudSkuName, + count: item.purchasedCount, + assetBefore, + }, + nowIso() + ); + + try { + const buyResult = await buyCloudtentaclesSku({ + ...cloudContext, + id: item.cloudSkuId, + count: item.purchasedCount, + }); + await createTaskEvent( + task.id, + "cloudtentacles_sku_buy_succeeded", + { + source: "kuaishou_cloud_dispatch", + cloudSkuId: item.cloudSkuId, + cloudSkuName: item.cloudSkuName, + count: item.purchasedCount, + responseMessage: buyResult.responseMessage, + }, + nowIso() + ); + } catch (error) { + await createTaskEvent( + task.id, + "cloudtentacles_sku_buy_failed", + { + source: "kuaishou_cloud_dispatch", + cloudSkuId: item.cloudSkuId, + cloudSkuName: item.cloudSkuName, + count: item.purchasedCount, + errorCode: resolveErrorCode(error), + errorMessage: resolveErrorMessage(error), + }, + nowIso() + ); + throw enrichCloudtentaclesDispatchError(error, { + stage: "purchase", + stockResult: buildCurrentStockResult({ + stockItems, + purchaseTriggered, + assetBefore, + assetAfter, + }), + item, + }); + } + } + + purchaseTriggered = true; + const assetResult = await getCloudtentaclesAsset(cloudContext); + assetAfter = Number(assetResult.asset || 0) || 0; + } + + const dispatchResults: DispatchResultItem[] = []; + for (const item of deliveryItems) { + for (let index = 0; index < item.quantity; index += 1) { + await createTaskEvent( + task.id, + "cloudtentacles_sku_use_started", + { + source: "kuaishou_cloud_dispatch", + cloudSkuId: item.cloudSkuId, + cloudSkuName: item.cloudSkuName, + unitIndex: index + 1, + quantity: item.quantity, + vnId: flow.binding.vnId, + vnPhoneMasked: maskPhone(flow.binding.vnPhone), + }, + nowIso() + ); + + let dispatchResult: Awaited>; + try { + dispatchResult = await useCloudtentaclesSku({ + ...cloudContext, + id: item.cloudSkuId, + virtualNumberId: flow.binding.vnId, + phone: flow.binding.vnPhone, + }); + } catch (error) { + await createTaskEvent( + task.id, + "cloudtentacles_sku_use_failed", + { + source: "kuaishou_cloud_dispatch", + cloudSkuId: item.cloudSkuId, + cloudSkuName: item.cloudSkuName, + unitIndex: index + 1, + quantity: item.quantity, + vnId: flow.binding.vnId, + vnPhoneMasked: maskPhone(flow.binding.vnPhone), + errorCode: resolveErrorCode(error), + errorMessage: resolveErrorMessage(error), + }, + nowIso() + ); + throw enrichCloudtentaclesDispatchError(error, { + stage: "dispatch", + stockResult: buildCurrentStockResult({ + stockItems, + purchaseTriggered, + assetBefore, + assetAfter, + }), + item, + unitIndex: index + 1, + vnId: flow.binding.vnId, + vnPhoneMasked: maskPhone(flow.binding.vnPhone), + }); + } + + await createTaskEvent( + task.id, + "cloudtentacles_sku_use_succeeded", + { + source: "kuaishou_cloud_dispatch", + cloudSkuId: item.cloudSkuId, + cloudSkuName: item.cloudSkuName, + unitIndex: index + 1, + quantity: item.quantity, + vnId: flow.binding.vnId, + vnPhoneMasked: maskPhone(flow.binding.vnPhone), + sendType: Number(dispatchResult.sendType || 0) || 0, + note: String(dispatchResult.note || "").trim(), + responseMessage: String(dispatchResult.responseMessage || "").trim(), + }, + nowIso() + ); + dispatchResults.push({ + cloudSkuId: item.cloudSkuId, + cloudSkuName: item.cloudSkuName, + unitIndex: index + 1, + quantity: item.quantity, + sendType: Number(dispatchResult.sendType || 0) || 0, + note: String(dispatchResult.note || "").trim(), + responseMessage: String(dispatchResult.responseMessage || "").trim(), + }); + } + } + + return { + dispatchResults, + stockResult: { + usedKnapsack: stockItems.every((item) => item.purchasedCount <= 0), + purchaseTriggered, + assetBefore, + assetAfter, + items: stockItems, + }, + }; +} + +function buildCurrentStockResult({ + stockItems, + purchaseTriggered, + assetBefore, + assetAfter, +}: { + stockItems: DispatchStockItem[]; + purchaseTriggered: boolean; + assetBefore: number; + assetAfter: number; +}): DispatchStockResult { + return { + usedKnapsack: stockItems.every((item) => item.purchasedCount <= 0), + purchaseTriggered, + assetBefore, + assetAfter, + items: stockItems, + }; +} + +function enrichCloudtentaclesDispatchError(error: unknown, context: JsonObject) { + const currentError = error instanceof Error + ? (error as Error & { context?: unknown }) + : createHttpError(resolveErrorMessage(error), { + statusCode: 500, + errorCode: "kuaishou_cloud_dispatch_failed", + }) as Error & { context?: unknown }; + const currentContext = isPlainObject(currentError.context) ? currentError.context : {}; + currentError.context = { + ...currentContext, + dispatchFailure: { + ...(isPlainObject((currentContext as JsonObject).dispatchFailure) + ? (currentContext as JsonObject).dispatchFailure + : {}), + ...context, + }, + }; + + return currentError; +} + +function resolveDispatchFailureContext(error: unknown): JsonObject { + if (!error || typeof error !== "object") { + return {}; + } + + const context = (error as { context?: unknown }).context; + if (!isPlainObject(context)) { + return {}; + } + + const dispatchFailure = context.dispatchFailure; + return isPlainObject(dispatchFailure) ? dispatchFailure : {}; +} + +function resolveErrorCode(error: unknown) { + if (!error || typeof error !== "object") { + return ""; + } + + return String((error as { errorCode?: unknown }).errorCode || "").trim(); +} + +function resolveErrorMessage(error: unknown) { + return error instanceof Error ? error.message : String(error || "CloudTentacles 发货失败"); +} + +function buildDispatchFailedMessage(message: unknown) { + const reason = String(message || "").trim() || "未知错误"; + return `CloudTentacles 发货失败:${reason}`; +} + +function isPlainObject(value: unknown): value is JsonObject { + return Boolean(value) && typeof value === "object" && !Array.isArray(value); +} + +export function buildDispatchStockItems( + deliveryItems: DispatchDeliveryItem[], + { skuItems = [], knapsackItems = [] }: { skuItems?: JsonObject[]; knapsackItems?: JsonObject[] } = {} +): DispatchStockItem[] { + return deliveryItems.map((item) => { + const knapsackItem = findCloudSkuItem(knapsackItems, item.cloudSkuId); + const skuItem = findCloudSkuItem(skuItems, item.cloudSkuId); + const knapsackCount = Math.max(0, Number(knapsackItem?.count || 0) || 0); + + return { + ...item, + cloudSkuName: item.cloudSkuName || String(skuItem?.name || knapsackItem?.name || "").trim(), + requiredCount: item.quantity, + knapsackCount, + purchasedCount: Math.max(0, item.quantity - knapsackCount), + }; + }); +} + +function findCloudSkuItem(items: JsonObject[], cloudSkuId: number) { + return items.find((item) => Number(item.id || 0) === cloudSkuId) || null; +} + +function resolveCloudSkuDispatchLockKeys(sourceKey: unknown, deliveryItems: DispatchDeliveryItem[]) { + const normalizedSourceKey = String(sourceKey || "default").trim() || "default"; + return deliveryItems.map((item) => `${normalizedSourceKey}:${item.cloudSkuId}`); +} + +async function withCloudSkuDispatchLocks(keys: string[], callback: () => Promise): Promise { + const normalizedKeys = [...new Set(keys.map((key) => String(key || "").trim()).filter(Boolean))].sort(); + + async function run(index: number): Promise { + const key = normalizedKeys[index]; + if (!key) { + return callback(); + } + + return withCloudSkuDispatchLock(key, () => run(index + 1)); + } + + return run(0); +} + +async function withCloudSkuDispatchLock(key: string, callback: () => Promise): Promise { + const previous = cloudSkuDispatchLocks.get(key) || Promise.resolve(); + let release: () => void = () => {}; + const current = new Promise((resolve) => { + release = resolve; + }); + const queued = previous.catch(() => {}).then(() => current); + + cloudSkuDispatchLocks.set(key, queued); + + await previous.catch(() => {}); + + try { + return await callback(); + } finally { + release(); + if (cloudSkuDispatchLocks.get(key) === queued) { + cloudSkuDispatchLocks.delete(key); + } + } +} + +function resolveDispatchDeliveryItems(flow: JsonObject): DispatchDeliveryItem[] { + const rawItems = Array.isArray(flow.deliveryItems) ? flow.deliveryItems : []; + const items = rawItems + .map((item: unknown) => { + const source = item && typeof item === "object" ? item as JsonObject : {}; + const cloudSkuId = Number(source.cloudSkuId || source.skuId || 0) || 0; + const quantity = Number(source.quantity || 1) || 1; + if (!Number.isInteger(cloudSkuId) || cloudSkuId <= 0) { + return null; + } + + return { + cloudSkuId, + cloudSkuName: String(source.cloudSkuName || source.skuName || "").trim(), + quantity: Number.isInteger(quantity) && quantity > 0 ? quantity : 1, + }; + }) + .filter((item): item is DispatchDeliveryItem => Boolean(item)); + + if (items.length > 0) { + return items; + } + + const fallbackSkuId = Number(flow?.binding?.skuId || 0) || 0; + if (!fallbackSkuId) { + return []; + } + + return [ + { + cloudSkuId: fallbackSkuId, + cloudSkuName: String(flow?.binding?.skuName || "").trim(), + quantity: 1, + }, + ]; +} + +export async function returnKuaishouCloudFulfillmentTask( + task: TaskRow, options: JsonObject = {} ) { if (!isKuaishouCloudTask(task)) { @@ -171,12 +747,10 @@ export async function returnKuaishouCloudFulfillmentTask( const now = nowIso(); const taskContext = parseTaskContext(task); const flow = normalizeKuaishouCloudFlow(taskContext.kuaishouCloudFulfillment); - const effectiveSourceKey = - flow.binding.resolvedSourceKey || flow.binding.cloudSourceKey || "default"; - const cloudContext = resolvePersistedCloudtentaclesContextWithFallback( - effectiveSourceKey, - flow.binding.cloudSourceKeyFallbacks || [] - ); + const cloudContext = resolvePersistedCloudtentaclesContextBySourceKeys([ + flow.binding.resolvedSourceKey, + ...flow.binding.cloudSourceKeys, + ]); if (!flow.binding.vnId || !flow.binding.vnKey) { throw createHttpError("当前任务缺少可退还的虚拟号信息", { @@ -185,11 +759,10 @@ export async function returnKuaishouCloudFulfillmentTask( }); } + const taskStatus = normalizeTaskStatus(task.task_status); if ( flow.returnNumber.status === "success" && - ["completed", "manual_review"].includes( - String(task.task_status || "").trim() - ) + (taskStatus === TASK_STATUS.COMPLETED || taskStatus === TASK_STATUS.MANUAL_REVIEW) ) { return { task, flow }; } @@ -206,6 +779,7 @@ export async function returnKuaishouCloudFulfillmentTask( const shopName = String( flow.consume.shopName || order?.shop_name || "" ).trim(); + const mockTicketCode = isKuaishouEticketMockTicketCode(ticketCode); const eticketSource = getKuaishouEticketSourceConfig(); const shopConfig = resolveKuaishouEticketShopConfig({ shopId, @@ -215,16 +789,29 @@ export async function returnKuaishouCloudFulfillmentTask( let consumeStatus = "pending"; let consumeErrorMessage = ""; let consumedAt = null; - let nextTaskStatus = "completed"; + let nextTaskStatus: TaskStatus = TASK_STATUS.COMPLETED; let nextResultCode = "kuaishou_cloud_completed"; let nextResultMessage = "cloudtentacles 发货、退号并完成快手核销"; + const consumeAlreadyCompleted = flow.consume.status === "success"; - if (!order) { + if (consumeAlreadyCompleted) { + consumeStatus = "success"; + consumedAt = flow.consume.consumedAt || now; + } else if (!order) { consumeStatus = "failed"; consumeErrorMessage = "任务关联订单不存在,无法执行快手核销"; } else if (!ticketCode) { consumeStatus = "failed"; consumeErrorMessage = "客户未提交有效核销码,无法执行快手核销"; + } else if (mockTicketCode) { + const consumeResult = await consumeKuaishouEticket({ + eTicketId: ticketCode, + oid: String(flow.ticket.oid || "").trim(), + formToken: String(flow.ticket.formToken || "").trim(), + }); + consumeStatus = consumeResult.consumed ? "success" : "failed"; + consumedAt = consumeResult.consumed ? now : null; + consumeErrorMessage = String(consumeResult.errorMessage || "").trim(); } else if ( !shopConfig || shopConfig.enabled === false || @@ -259,7 +846,7 @@ export async function returnKuaishouCloudFulfillmentTask( } if (consumeStatus !== "success") { - nextTaskStatus = "manual_review"; + nextTaskStatus = TASK_STATUS.MANUAL_REVIEW; nextResultCode = "kuaishou_cloud_consume_failed"; nextResultMessage = consumeErrorMessage || "号码已退还,但快手核销未完成,请人工处理"; @@ -312,9 +899,11 @@ export async function returnKuaishouCloudFulfillmentTask( await createTaskEvent( task.id, - consumeStatus === "success" - ? "kuaishou_cloud_consumed" - : "kuaishou_cloud_consume_failed", + consumeAlreadyCompleted + ? "kuaishou_cloud_consume_already_completed" + : consumeStatus === "success" + ? "kuaishou_cloud_consumed" + : "kuaishou_cloud_consume_failed", { source: String(options.source || "system").trim() || "system", ticketCodeMasked: maskCode(ticketCode), diff --git a/apps/backend/src/services/notification/bark-service.ts b/apps/backend/src/services/notification/bark-service.ts index a160ee3c..7cf771d8 100644 --- a/apps/backend/src/services/notification/bark-service.ts +++ b/apps/backend/src/services/notification/bark-service.ts @@ -2,7 +2,7 @@ import { createHttpError } from '../../utils/http.js' const DEFAULT_TIMEOUT_MS = 10000 -type JsonObject = Record +type JsonObject = Record type BarkSendInput = { serverUrl?: string recipient?: { diff --git a/apps/backend/src/services/notification/config-service.ts b/apps/backend/src/services/notification/config-service.ts index cdd03559..56472ee8 100644 --- a/apps/backend/src/services/notification/config-service.ts +++ b/apps/backend/src/services/notification/config-service.ts @@ -7,6 +7,13 @@ const NOTIFICATION_CONFIG_FILE_PATH = path.join(PROJECT_ROOT, 'data', 'notificat const DEFAULT_BARK_SERVER_URL = 'https://api.day.app' type JsonObject = Record +type NotificationRecipient = { + id: string + name: string + deviceKey?: string + apiKey?: string + enabled: boolean +} export function getNotificationConfigFilePath() { return NOTIFICATION_CONFIG_FILE_PATH @@ -26,7 +33,7 @@ export function listEnabledBarkRecipients(config: JsonObject = getNotificationCo } return (Array.isArray(config.channels?.bark?.recipients) ? config.channels.bark.recipients : []) - .filter((item) => item.enabled !== false && String(item.deviceKey || '').trim()) + .filter((item: JsonObject) => item.enabled !== false && String(item.deviceKey || '').trim()) } export function listEnabledWpushRecipients(config: JsonObject = getNotificationConfig()) { @@ -35,7 +42,7 @@ export function listEnabledWpushRecipients(config: JsonObject = getNotificationC } return (Array.isArray(config.channels?.wpush?.recipients) ? config.channels.wpush.recipients : []) - .filter((item) => item.enabled !== false && String(item.apiKey || item.apikey || '').trim()) + .filter((item: JsonObject) => item.enabled !== false && String(item.apiKey || item.apikey || '').trim()) } function loadNotificationConfigFromFile() { @@ -58,20 +65,20 @@ export function normalizeNotificationConfig(rawValue: unknown) { enabled: typeof bark.enabled === 'boolean' ? bark.enabled : true, serverUrl: normalizeBarkServerUrl(bark.serverUrl), recipients: (Array.isArray(bark.recipients) ? bark.recipients : []) - .map((item) => normalizeBarkRecipient(item)) + .map((item: unknown) => normalizeBarkRecipient(item)) .filter(Boolean), }, wpush: { enabled: typeof wpush.enabled === 'boolean' ? wpush.enabled : true, recipients: (Array.isArray(wpush.recipients) ? wpush.recipients : []) - .map((item) => normalizeWpushRecipient(item)) + .map((item: unknown) => normalizeWpushRecipient(item)) .filter(Boolean), }, }, } } -function normalizeBarkRecipient(rawValue: unknown) { +function normalizeBarkRecipient(rawValue: unknown): NotificationRecipient | null { if (!isPlainObject(rawValue)) { return null } @@ -97,7 +104,7 @@ function normalizeBarkServerUrl(value: unknown) { return normalized.replace(/\/+$/, '') } -function normalizeWpushRecipient(rawValue: unknown) { +function normalizeWpushRecipient(rawValue: unknown): NotificationRecipient | null { if (!isPlainObject(rawValue)) { return null } @@ -125,11 +132,11 @@ export function createDefaultNotificationConfig() { bark: { enabled: true, serverUrl: DEFAULT_BARK_SERVER_URL, - recipients: [], + recipients: [] as NotificationRecipient[], }, wpush: { enabled: true, - recipients: [], + recipients: [] as NotificationRecipient[], }, }, } diff --git a/apps/backend/src/services/notification/domain-notifications.ts b/apps/backend/src/services/notification/domain-notifications.ts index 86e531c9..f14c17ad 100644 --- a/apps/backend/src/services/notification/domain-notifications.ts +++ b/apps/backend/src/services/notification/domain-notifications.ts @@ -4,7 +4,7 @@ import { sendInternalNotification } from './notification-service.js' const DEFAULT_COOLDOWN_MS = 10 * 60 * 1000 const notificationCooldownMap = new Map() -type JsonObject = Record +type JsonObject = Record type InternalNotificationPayload = { title: string body?: string @@ -13,6 +13,55 @@ type InternalNotificationPayload = { cooldownKey?: string cooldownMs?: number } +type KuaishouCloudAssetNotEnoughPayload = { + task?: unknown + flow?: unknown + assetBefore?: unknown + requiredAsset?: unknown + skuName?: unknown +} +type CloudtentaclesAuthExpiredPayload = { + pathname?: unknown + errorCode?: unknown + message?: unknown + cooldownSeconds?: unknown + sourceKey?: unknown + accountLabel?: unknown +} +type CloudtentaclesAssetLowPayload = { + asset?: unknown + threshold?: unknown + cooldownSeconds?: unknown + sourceKey?: unknown + accountLabel?: unknown +} +type Open91PendingConfigPayload = { + order?: unknown + orderNo?: unknown + productNo?: unknown + buyNum?: unknown + reason?: unknown +} +type TaskNotificationPayload = { + task?: unknown + reason?: unknown + source?: unknown + errorMessage?: unknown +} +type KuaishouCloudConsumeFailedPayload = { + task?: unknown + order?: unknown + ticketCodeMasked?: unknown + shopId?: unknown + shopName?: unknown + errorMessage?: unknown +} +type ClaimRedeemNeedsAttentionPayload = { + task?: unknown + order?: unknown + status?: unknown + errorMessage?: unknown +} export async function notifyInternalSafely(payload: InternalNotificationPayload) { const cooldownKey = String(payload.cooldownKey || '').trim() @@ -26,9 +75,9 @@ export async function notifyInternalSafely(payload: InternalNotificationPayload) try { const result = await sendInternalNotification({ title: payload.title, - body: payload.body, - category: payload.category, - url: payload.url, + ...(payload.body !== undefined ? { body: payload.body } : {}), + ...(payload.category !== undefined ? { category: payload.category } : {}), + ...(payload.url !== undefined ? { url: payload.url } : {}), }) markNotificationCooldown(cooldownKey) return result @@ -52,7 +101,7 @@ export function notifyKuaishouCloudAssetNotEnough({ assetBefore = 0, requiredAsset = 0, skuName = '', -}: JsonObject = {}) { +}: KuaishouCloudAssetNotEnoughPayload = {}) { const taskRecord = toRecord(task) const flowRecord = toRecord(flow) const binding = toRecord(flowRecord.binding) @@ -81,7 +130,7 @@ export function notifyCloudtentaclesAuthExpired({ cooldownSeconds = 600, sourceKey = '', accountLabel = '', -}: JsonObject = {}) { +}: CloudtentaclesAuthExpiredPayload = {}) { const normalizedSourceKey = String(sourceKey || '').trim() const normalizedAccountLabel = String(accountLabel || '').trim() @@ -107,7 +156,7 @@ export function notifyCloudtentaclesAssetLow({ cooldownSeconds = 1800, sourceKey = '', accountLabel = '', -}: JsonObject = {}) { +}: CloudtentaclesAssetLowPayload = {}) { const normalizedSourceKey = String(sourceKey || '').trim() const normalizedAccountLabel = String(accountLabel || '').trim() @@ -133,7 +182,7 @@ export function notifyOpen91PendingConfig({ productNo = '', buyNum = 0, reason = '未命中履约配置', -}: JsonObject = {}) { +}: Open91PendingConfigPayload = {}) { const orderRecord = toRecord(order) return notifyInternalSafely({ @@ -152,7 +201,7 @@ export function notifyOpen91PendingConfig({ export function notifyKuaishouCloudBindUrlRefreshFailed({ task = {}, errorMessage = '', -}: JsonObject = {}) { +}: TaskNotificationPayload = {}) { const taskRecord = toRecord(task) return notifyInternalSafely({ @@ -174,7 +223,7 @@ export function notifyKuaishouCloudConsumeFailed({ shopId = '', shopName = '', errorMessage = '', -}: JsonObject = {}) { +}: KuaishouCloudConsumeFailedPayload = {}) { const taskRecord = toRecord(task) const orderRecord = toRecord(order) @@ -196,7 +245,7 @@ export function notifyTaskAutoManualReview({ task = {}, reason = '', source = '', -}: JsonObject = {}) { +}: TaskNotificationPayload = {}) { const taskRecord = toRecord(task) return notifyInternalSafely({ @@ -216,19 +265,19 @@ export function notifyClaimRedeemNeedsAttention({ order = {}, status = '', errorMessage = '', -}: JsonObject = {}) { +}: ClaimRedeemNeedsAttentionPayload = {}) { const taskRecord = toRecord(task) const orderRecord = toRecord(order) return notifyInternalSafely({ - title: status === 'waiting_inventory' ? '库存不足,任务等待补货' : '兑换失败,任务需关注', + title: '兑换失败,任务需关注', body: [ formatTaskLine(taskRecord), `订单:${String(orderRecord.platform_order_id || taskRecord.platform_order_id || '').trim() || '-'}`, `状态:${String(status || taskRecord.task_status || '').trim() || '-'}`, `原因:${String(errorMessage || taskRecord.last_error || '').trim() || '-'}`, ].join('\n'), - category: status === 'waiting_inventory' ? 'task_waiting_inventory' : 'claim_redeem_failed', + category: 'claim_redeem_failed', cooldownKey: ['claim_redeem_needs_attention', taskRecord.id || '', status].join(':'), }) } diff --git a/apps/backend/src/services/notification/notification-service.ts b/apps/backend/src/services/notification/notification-service.ts index 07646fba..c4e667cd 100644 --- a/apps/backend/src/services/notification/notification-service.ts +++ b/apps/backend/src/services/notification/notification-service.ts @@ -8,6 +8,7 @@ import { sendBarkNotification } from './bark-service.js' import { sendWpushNotification } from './wpush-service.js' type JsonObject = Record +type NotificationResult = ReturnType type NotificationInput = { title?: string body?: string @@ -31,20 +32,20 @@ export async function sendInternalNotification(input: NotificationInput = {}) { successCount: 0, failedCount: 0, skippedCount: barkRecipients.length + wpushRecipients.length, - results: [], + results: [] as NotificationResult[], } } const results = [ - ...(await Promise.all(barkRecipients.map(async (recipient) => { + ...(await Promise.all(barkRecipients.map(async (recipient: JsonObject) => { try { const result = await sendBarkNotification({ - serverUrl: bark.serverUrl, recipient, title, body, group: `订单系统/${category}`, - url: input.url, + ...(bark.serverUrl !== undefined ? { serverUrl: bark.serverUrl } : {}), + ...(input.url !== undefined ? { url: input.url } : {}), }) return mapNotificationResult('bark', recipient, recipient.deviceKey, true, '', result.status, result.response) } catch (error) { @@ -64,7 +65,7 @@ export async function sendInternalNotification(input: NotificationInput = {}) { ) } }))), - ...(await Promise.all(wpushRecipients.map(async (recipient) => { + ...(await Promise.all(wpushRecipients.map(async (recipient: JsonObject) => { try { const result = await sendWpushNotification({ recipient, diff --git a/apps/backend/src/services/notification/wpush-service.ts b/apps/backend/src/services/notification/wpush-service.ts index 592ed59d..69ae72a2 100644 --- a/apps/backend/src/services/notification/wpush-service.ts +++ b/apps/backend/src/services/notification/wpush-service.ts @@ -6,7 +6,7 @@ import { createHttpError } from '../../utils/http.js' const DEFAULT_WPUSH_ENDPOINT = 'https://api.wpush.cn/api/v1/send' const DEFAULT_TIMEOUT_MS = 10000 -type JsonObject = Record +type JsonObject = Record type NodeHttpResponse = { ok: boolean status: number diff --git a/apps/backend/src/services/open-91/config.ts b/apps/backend/src/services/open-91/config.ts new file mode 100644 index 00000000..af4a3781 --- /dev/null +++ b/apps/backend/src/services/open-91/config.ts @@ -0,0 +1,48 @@ +import { runtimeConfig } from '../../config/runtime.js' +import { createHttpError } from '../../utils/http.js' +import type { RuntimeConfig } from '../../types/runtime-config.js' + +export const OPEN_91_PROVIDER = '91kaquan' +export const OPEN_91_PLATFORM = 'kuaishou' +export const OPEN_91_SUCCESS_MESSAGE = '接口调用成功' +export const OPEN_91_DEFAULT_FAIL_CODE = 1204 +export const OPEN_91_COST_EXCEED_FAIL_CODE = 1220 + +type Open91RuntimeConfig = RuntimeConfig['platforms']['ninetyone'] + +export function getOpen91Config(overrides: Partial = {}) { + const config = { + ...(runtimeConfig.platforms?.ninetyone || {}), + ...overrides, + } + + return { + userId: String(config.userId || '').trim(), + secret: String(config.secret || '').trim(), + version: String(config.version || '1.0').trim() || '1.0', + shopId: String(config.shopId || OPEN_91_PROVIDER).trim() || OPEN_91_PROVIDER, + shopName: String(config.shopName || '91卡券').trim() || '91卡券', + timestampToleranceSeconds: Number(config.timestampToleranceSeconds || 600) || 600, + cardsEncoding: String(config.cardsEncoding || 'aes-256-ecb-base64').trim() || 'aes-256-ecb-base64', + } +} + +export function assertOpen91Config() { + const config = getOpen91Config() + + if (!config.secret) { + throw createHttpError('91卡券 secret 未配置', { + statusCode: 500, + errorCode: 'open91_missing_secret', + }) + } + + if (config.secret.length !== 32) { + throw createHttpError('91卡券 secret 长度必须为 32 个字符', { + statusCode: 500, + errorCode: 'open91_invalid_secret_length', + }) + } + + return config +} diff --git a/apps/backend/src/services/open-91/crypto.ts b/apps/backend/src/services/open-91/crypto.ts new file mode 100644 index 00000000..6b4be93d --- /dev/null +++ b/apps/backend/src/services/open-91/crypto.ts @@ -0,0 +1,91 @@ +import crypto from 'node:crypto' + +import { createHttpError } from '../../utils/http.js' +import { assertOpen91Config } from './config.js' +import { normalizeOpen91String } from './payload.js' + +type JsonObject = Record + +export function buildOpen91SignSource(params: JsonObject = {}, { secret } = assertOpen91Config()) { + const entries = Object.entries(params) + .filter(([key]) => key !== 'sign') + .sort(([left], [right]) => { + if (left === right) { + return 0 + } + return left < right ? -1 : 1 + }) + + const queryString = entries + .map(([key, value]) => `${key}=${stringifyOpen91SignValue(value)}`) + .join('&') + + return `${secret}${queryString}${secret}` +} + +export function signOpen91Payload(params: JsonObject = {}, config = assertOpen91Config()) { + return crypto + .createHash('md5') + .update(buildOpen91SignSource(params, config), 'utf8') + .digest('hex') + .toUpperCase() +} + +export function verifyOpen91Signature(params: JsonObject = {}, config = assertOpen91Config()) { + const expected = signOpen91Payload(params, config) + const actual = normalizeOpen91String(params.sign).toUpperCase() + return expected === actual +} + +export function assertOpen91Signature(params: JsonObject = {}, config = assertOpen91Config()) { + if (!verifyOpen91Signature(params, config)) { + throw createHttpError('验签失败', { + statusCode: 400, + errorCode: 'open91_invalid_signature', + }) + } +} + +export function encryptOpen91Cards(cards: unknown[] = [], secret = assertOpen91Config().secret) { + const normalizedSecret = normalizeOpen91String(secret) + if (normalizedSecret.length !== 32) { + throw createHttpError('91卡券 cards 加密密钥长度必须为 32 个字符', { + statusCode: 500, + errorCode: 'open91_invalid_cards_secret_length', + }) + } + + const plainText = JSON.stringify(Array.isArray(cards) ? cards : []) + const cipher = crypto.createCipheriv('aes-256-ecb', Buffer.from(normalizedSecret, 'utf8'), null) + cipher.setAutoPadding(true) + + return `${cipher.update(plainText, 'utf8', 'base64')}${cipher.final('base64')}` +} + +export function buildOpen91Cards(cards: unknown[] = [], config = assertOpen91Config()) { + const encoding = normalizeOpen91String(config.cardsEncoding).toLowerCase() + if (encoding && encoding !== 'aes-256-ecb-base64') { + throw createHttpError(`暂不支持的 cardsEncoding: ${config.cardsEncoding}`, { + statusCode: 500, + errorCode: 'open91_unsupported_cards_encoding', + }) + } + + return encryptOpen91Cards(cards, config.secret) +} + +export function stringifyOpen91SignValue(value: unknown) { + if (typeof value === 'number' && Number.isFinite(value)) { + return String(value) + } + + if (typeof value === 'boolean') { + return value ? 'true' : 'false' + } + + if (value == null) { + return '' + } + + return String(value) +} diff --git a/apps/backend/src/services/open-91/order-create-service.ts b/apps/backend/src/services/open-91/order-create-service.ts index 7b7cc553..03547abc 100644 --- a/apps/backend/src/services/open-91/order-create-service.ts +++ b/apps/backend/src/services/open-91/order-create-service.ts @@ -1,5 +1,5 @@ import { upsertOrderFromSource } from '../order/order-service.js' -import { logWebhook } from '../../utils/logger.js' +import { logIntegration } from '../../utils/logger.js' import { parseAmountToFen } from '../../utils/money.js' import { buildOpen91SourceEvent, @@ -8,16 +8,20 @@ import { import { notifyOpen91PendingConfig } from '../notification/domain-notifications.js' import { assertOpen91Config, + OPEN_91_COST_EXCEED_FAIL_CODE, + OPEN_91_DEFAULT_FAIL_CODE, +} from './config.js' +import { assertOpen91CreatePayload, - assertOpen91Signature, assertOpen91Timestamp, + normalizeOpen91CreatePayload, +} from './payload.js' +import { assertOpen91Signature } from './crypto.js' +import { buildOpen91OrderCost, buildOpen91OutTradeNo, buildOpen91SuccessResponse, - normalizeOpen91CreatePayload, - OPEN_91_COST_EXCEED_FAIL_CODE, - OPEN_91_DEFAULT_FAIL_CODE, -} from './shared.js' +} from './response.js' export async function createOpen91Order(payload = {}, { requestId = '' } = {}) { const config = assertOpen91Config() @@ -45,7 +49,7 @@ export async function createOpen91Order(payload = {}, { requestId = '' } = {}) { sourceLabel: 'open91', }) - logWebhook('[open-91/create]', '91卡券下单已处理', { + logIntegration('[open-91/create]', '91卡券下单已处理', { requestId, orderNo: normalized.orderNo, productNo: normalized.productNo, @@ -58,7 +62,7 @@ export async function createOpen91Order(payload = {}, { requestId = '' } = {}) { if (result.ignored) { const pending = await upsertOpen91PendingOrder(normalized, config) - logWebhook('[open-91/create]', '91卡券订单已进入待补全队列', { + logIntegration('[open-91/create]', '91卡券订单已进入待补全队列', { requestId, orderNo: normalized.orderNo, productNo: normalized.productNo, diff --git a/apps/backend/src/services/open-91/order-query-service.ts b/apps/backend/src/services/open-91/order-query-service.ts index a0202494..758d9407 100644 --- a/apps/backend/src/services/open-91/order-query-service.ts +++ b/apps/backend/src/services/open-91/order-query-service.ts @@ -1,30 +1,36 @@ -import { findOrderByPlatformOrderId } from '../../repositories/order-repo.js' +import { findLatestOrderByPlatformOrderId } from '../../repositories/order-repo.js' import { listTasksByOrderId } from '../../repositories/task-repo.js' import { buildClaimUrl } from '../claim/claim-service.js' -import { ensureTaskClaimLink } from '../fulfillment/kuaishou-cloud-task-service.js' -import { logWebhook } from '../../utils/logger.js' +import { ensureTaskClaimLink } from '../fulfillment/kuaishou-cloud/index.js' +import { logIntegration } from '../../utils/logger.js' import { OPEN_91_MANUAL_FAILED_STATUS, OPEN_91_PENDING_CONFIG_STATUS, } from '../platforms/ninetyone/order-service.js' import { assertOpen91Config, - assertOpen91QueryPayload, - assertOpen91Signature, - assertOpen91Timestamp, - buildOpen91CardItem, - buildOpen91Cards, - buildOpen91OrderCost, - buildOpen91OutTradeNo, - buildOpen91SuccessResponse, - normalizeOpen91QueryPayload, OPEN_91_DEFAULT_FAIL_CODE, OPEN_91_PLATFORM, OPEN_91_PROVIDER, +} from './config.js' +import { + assertOpen91QueryPayload, + assertOpen91Timestamp, + normalizeOpen91QueryPayload, +} from './payload.js' +import { assertOpen91Signature, buildOpen91Cards } from './crypto.js' +import { + buildOpen91CardItem, + buildOpen91OrderCost, + buildOpen91OutTradeNo, + buildOpen91SuccessResponse, resolveOpen91QueryState, -} from './shared.js' +} from './response.js' +import type { OrderRow } from '../../types/repository/rows.js' -export async function queryOpen91Order(payload = {}, { requestId = '' } = {}) { +type JsonObject = Record + +export async function queryOpen91Order(payload: JsonObject = {}, { requestId = '' } = {}) { const config = assertOpen91Config() const normalized = normalizeOpen91QueryPayload(payload) @@ -32,10 +38,9 @@ export async function queryOpen91Order(payload = {}, { requestId = '' } = {}) { assertOpen91Timestamp(normalized.timestamp, config.timestampToleranceSeconds) assertOpen91Signature(payload, config) - const order = await findOrderByPlatformOrderId({ + const order = await findLatestOrderByPlatformOrderId({ provider: OPEN_91_PROVIDER, platform: OPEN_91_PLATFORM, - shopId: config.shopId, platformOrderId: normalized.orderNo, }) @@ -80,7 +85,7 @@ export async function queryOpen91Order(payload = {}, { requestId = '' } = {}) { for (const task of tasks) { let claimUrl = '' - let expireTime = String(task.primary_claim_expires_at || task.claim_expires_at || '').trim() + let expireTime: unknown = task.primary_claim_expires_at || task.claim_expires_at || '' const primaryToken = String(task.primary_claim_token || task.claim_token || '').trim() if (primaryToken) { @@ -89,7 +94,7 @@ export async function queryOpen91Order(payload = {}, { requestId = '' } = {}) { try { const ensured = await ensureTaskClaimLink(task) claimUrl = String(ensured.claimUrl || '').trim() - expireTime = String(ensured.expiredAt || expireTime).trim() + expireTime = ensured.expiredAt || expireTime } catch { claimUrl = '' } @@ -111,7 +116,7 @@ export async function queryOpen91Order(payload = {}, { requestId = '' } = {}) { readyTaskIds, }) - logWebhook('[open-91/query]', '91卡券订单查询已处理', { + logIntegration('[open-91/query]', '91卡券订单查询已处理', { requestId, orderNo: normalized.orderNo, orderId: order.id, @@ -154,7 +159,7 @@ export async function queryOpen91Order(payload = {}, { requestId = '' } = {}) { cards: encryptedCards, } - logWebhook('[open-91/query]', '91卡券订单查询返回 cards 已生成', { + logIntegration('[open-91/query]', '91卡券订单查询返回 cards 已生成', { requestId, orderNo: normalized.orderNo, orderId: order.id, @@ -168,12 +173,12 @@ export async function queryOpen91Order(payload = {}, { requestId = '' } = {}) { return buildOpen91SuccessResponse(responseData) } -function resolveOpen91OrderFailReason(order) { +function resolveOpen91OrderFailReason(order: OrderRow) { const payload = parseJsonObject(order?.raw_payload_json) return String(payload.manualFailedReason || '').trim() } -function parseJsonObject(value) { +function parseJsonObject(value: unknown): JsonObject { if (!value) { return {} } diff --git a/apps/backend/src/services/open-91/payload.ts b/apps/backend/src/services/open-91/payload.ts new file mode 100644 index 00000000..80edbf6e --- /dev/null +++ b/apps/backend/src/services/open-91/payload.ts @@ -0,0 +1,136 @@ +import { createHttpError } from '../../utils/http.js' +import { assertOpen91Config } from './config.js' + +type JsonObject = Record + +export function normalizeOpen91String(value: unknown) { + return String(value || '').trim() +} + +export function normalizeOpen91CreatePayload(payload: JsonObject = {}) { + return { + orderNo: normalizeOpen91String(payload.orderNo), + productNo: normalizeOpen91String(payload.productNo), + buyNum: normalizeOpen91BuyNum(payload.buyNum), + maxAmount: normalizeOpen91OptionalAmount(payload.maxAmount), + callbackUrl: normalizeOpen91String(payload.callbackUrl), + timestamp: normalizeOpen91Timestamp(payload.timestamp), + version: normalizeOpen91String(payload.version), + sign: normalizeOpen91String(payload.sign).toUpperCase(), + } +} + +export function normalizeOpen91QueryPayload(payload: JsonObject = {}) { + return { + orderNo: normalizeOpen91String(payload.orderNo), + timestamp: normalizeOpen91Timestamp(payload.timestamp), + version: normalizeOpen91String(payload.version), + sign: normalizeOpen91String(payload.sign).toUpperCase(), + } +} + +export function assertOpen91CreatePayload(payload: JsonObject, config = assertOpen91Config()) { + if (!payload.orderNo) { + throw createHttpError('缺少 orderNo', { + statusCode: 400, + errorCode: 'open91_missing_order_no', + }) + } + + if (!payload.productNo) { + throw createHttpError('缺少 productNo', { + statusCode: 400, + errorCode: 'open91_missing_product_no', + }) + } + + if (!Number.isInteger(payload.buyNum) || payload.buyNum <= 0) { + throw createHttpError('buyNum 必须是大于 0 的整数', { + statusCode: 400, + errorCode: 'open91_invalid_buy_num', + }) + } + + assertOpen91CommonPayload(payload, config) +} + +export function assertOpen91QueryPayload(payload: JsonObject, config = assertOpen91Config()) { + if (!payload.orderNo) { + throw createHttpError('缺少 orderNo', { + statusCode: 400, + errorCode: 'open91_missing_order_no', + }) + } + + assertOpen91CommonPayload(payload, config) +} + +export function assertOpen91Timestamp(timestamp: unknown, toleranceSeconds = assertOpen91Config().timestampToleranceSeconds) { + const nowSeconds = Math.floor(Date.now() / 1000) + if (Math.abs(nowSeconds - Number(timestamp)) > Math.max(1, Number(toleranceSeconds) || 600)) { + throw createHttpError('timestamp 已过期', { + statusCode: 400, + errorCode: 'open91_expired_timestamp', + }) + } +} + +function assertOpen91CommonPayload(payload: JsonObject, config: JsonObject) { + if (!payload.timestamp) { + throw createHttpError('缺少 timestamp', { + statusCode: 400, + errorCode: 'open91_missing_timestamp', + }) + } + + if (!payload.version) { + throw createHttpError('缺少 version', { + statusCode: 400, + errorCode: 'open91_missing_version', + }) + } + + if (payload.version !== config.version) { + throw createHttpError(`version 不匹配,当前仅支持 ${config.version}`, { + statusCode: 400, + errorCode: 'open91_invalid_version', + }) + } + + if (!payload.sign) { + throw createHttpError('缺少 sign', { + statusCode: 400, + errorCode: 'open91_missing_sign', + }) + } +} + +function normalizeOpen91BuyNum(value: unknown) { + const parsed = Number(value) + return Number.isInteger(parsed) ? parsed : 0 +} + +function normalizeOpen91OptionalAmount(value: unknown) { + const normalized = normalizeOpen91String(value) + if (!normalized) { + return '' + } + + if (!/^\d+(?:\.\d+)?$/.test(normalized)) { + throw createHttpError('maxAmount 格式无效', { + statusCode: 400, + errorCode: 'open91_invalid_max_amount', + }) + } + + return normalized +} + +function normalizeOpen91Timestamp(value: unknown) { + const normalized = normalizeOpen91String(value) + if (!/^\d{10}$/.test(normalized)) { + return 0 + } + + return Number(normalized) +} diff --git a/apps/backend/src/services/open-91/shared.test.ts b/apps/backend/src/services/open-91/protocol.test.ts similarity index 94% rename from apps/backend/src/services/open-91/shared.test.ts rename to apps/backend/src/services/open-91/protocol.test.ts index ddaba561..b72625d3 100644 --- a/apps/backend/src/services/open-91/shared.test.ts +++ b/apps/backend/src/services/open-91/protocol.test.ts @@ -2,14 +2,16 @@ import test from 'node:test' import assert from 'node:assert/strict' import { - buildOpen91CardItem, buildOpen91SignSource, encryptOpen91Cards, - formatOpen91ExpireTime, - resolveOpen91QueryState, signOpen91Payload, verifyOpen91Signature, -} from './shared.js' +} from './crypto.js' +import { + buildOpen91CardItem, + formatOpen91ExpireTime, + resolveOpen91QueryState, +} from './response.js' test('signOpen91Payload signs only request body fields', () => { const config = { @@ -113,14 +115,14 @@ test('resolveOpen91QueryState returns success only when every task is ready', () test('buildOpen91CardItem mirrors claim link into jumpLink and leaves password empty', () => { assert.deepEqual( buildOpen91CardItem({ - claimUrl: 'https://221329.cc.cd/#/claim/abc123xyz', + claimUrl: 'https://example.com/#/claim/abc123xyz', expireTime: '2026-05-05 12:00:00', }), { - cardNo: 'https://221329.cc.cd/#/claim/abc123xyz', + cardNo: 'https://example.com/#/claim/abc123xyz', cardPwd: '', expireTime: '2026-05-05 12:00:00', - jumpLink: 'https://221329.cc.cd/#/claim/abc123xyz', + jumpLink: 'https://example.com/#/claim/abc123xyz', }, ) }) diff --git a/apps/backend/src/services/open-91/response.ts b/apps/backend/src/services/open-91/response.ts new file mode 100644 index 00000000..3d9f5ece --- /dev/null +++ b/apps/backend/src/services/open-91/response.ts @@ -0,0 +1,116 @@ +import { + OPEN_91_DEFAULT_FAIL_CODE, + OPEN_91_SUCCESS_MESSAGE, +} from './config.js' +import { isOpen91FailedTaskStatus } from '../../domain/task-status.js' +import { normalizeOpen91String } from './payload.js' + +type JsonObject = Record + +export function buildOpen91SuccessResponse(data: unknown, message = OPEN_91_SUCCESS_MESSAGE) { + return { + code: 200, + message, + data, + } +} + +export function buildOpen91ErrorResponse(message: unknown, code = 500) { + return { + code, + message: normalizeOpen91String(message) || '系统错误', + data: null as null, + } +} + +export function buildOpen91OutTradeNo(order: JsonObject) { + const orderId = Number(order?.id || 0) + return orderId > 0 ? `OS${String(orderId).padStart(10, '0')}` : '' +} + +export function buildOpen91OrderCost(value = 0) { + const amount = Number(value) + return Number.isFinite(amount) ? amount.toFixed(4) : '0.0000' +} + +export function buildOpen91CardItem({ claimUrl = '', expireTime = '' }: { claimUrl?: unknown, expireTime?: unknown } = {}) { + const normalizedClaimUrl = normalizeOpen91String(claimUrl) + return { + cardNo: normalizedClaimUrl, + cardPwd: '', + expireTime: formatOpen91ExpireTime(expireTime), + jumpLink: normalizedClaimUrl, + } +} + +export function formatOpen91ExpireTime(value: unknown = '') { + const normalized = normalizeOpen91String(value) + if (!normalized) { + return '' + } + + if (/^\d{10}$/.test(normalized) || /^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}$/.test(normalized)) { + return normalized + } + + const date = value instanceof Date ? value : new Date(normalized) + if (Number.isNaN(date.getTime())) { + return normalized + } + + const parts = new Intl.DateTimeFormat('zh-CN', { + timeZone: 'Asia/Shanghai', + year: 'numeric', + month: '2-digit', + day: '2-digit', + hour: '2-digit', + minute: '2-digit', + second: '2-digit', + hourCycle: 'h23', + }).formatToParts(date) + + const byType = Object.fromEntries(parts.map((part) => [part.type, part.value])) + return `${byType.year}-${byType.month}-${byType.day} ${byType.hour}:${byType.minute}:${byType.second}` +} + +export function resolveOpen91QueryState( + { tasks = [], readyTaskIds = [] }: { tasks?: JsonObject[], readyTaskIds?: unknown[] } = {}, +) { + const normalizedTasks = Array.isArray(tasks) ? tasks : [] + const readyTaskIdSet = new Set((Array.isArray(readyTaskIds) ? readyTaskIds : []).map((item) => Number(item))) + + if (normalizedTasks.length === 0) { + return { + orderStatus: 30, + failCode: OPEN_91_DEFAULT_FAIL_CODE, + failReason: '订单未生成履约任务', + } + } + + const failedTask = normalizedTasks.find((task) => { + const status = normalizeOpen91String(task?.task_status) + return isOpen91FailedTaskStatus(status) && !readyTaskIdSet.has(Number(task?.id || 0)) + }) + + if (failedTask) { + return { + orderStatus: 30, + failCode: OPEN_91_DEFAULT_FAIL_CODE, + failReason: normalizeOpen91String(failedTask.last_error) || '订单无法履约', + } + } + + if (readyTaskIdSet.size < normalizedTasks.length) { + return { + orderStatus: 10, + failCode: 0, + failReason: '', + } + } + + return { + orderStatus: 20, + failCode: 0, + failReason: '', + } +} diff --git a/apps/backend/src/services/open-91/shared.ts b/apps/backend/src/services/open-91/shared.ts deleted file mode 100644 index 096b28ca..00000000 --- a/apps/backend/src/services/open-91/shared.ts +++ /dev/null @@ -1,370 +0,0 @@ -import crypto from 'node:crypto' - -import { runtimeConfig } from '../../config/runtime.js' -import { createHttpError } from '../../utils/http.js' - -export const OPEN_91_PROVIDER = '91kaquan' -export const OPEN_91_PLATFORM = 'kuaishou' -export const OPEN_91_SUCCESS_MESSAGE = '接口调用成功' -export const OPEN_91_DEFAULT_FAIL_CODE = 1204 -export const OPEN_91_COST_EXCEED_FAIL_CODE = 1220 - -type JsonObject = Record - -export function getOpen91Config() { - const config = (runtimeConfig.platforms?.ninetyone || {}) as JsonObject - - return { - userId: String(config.userId || '').trim(), - secret: String(config.secret || '').trim(), - version: String(config.version || '1.0').trim() || '1.0', - shopId: String(config.shopId || OPEN_91_PROVIDER).trim() || OPEN_91_PROVIDER, - shopName: String(config.shopName || '91卡券').trim() || '91卡券', - timestampToleranceSeconds: Number(config.timestampToleranceSeconds || 600) || 600, - cardsEncoding: String(config.cardsEncoding || 'aes-256-ecb-base64').trim() || 'aes-256-ecb-base64', - } -} - -export function assertOpen91Config() { - const config = getOpen91Config() - - if (!config.secret) { - throw createHttpError('91卡券 secret 未配置', { - statusCode: 500, - errorCode: 'open91_missing_secret', - }) - } - - if (config.secret.length !== 32) { - throw createHttpError('91卡券 secret 长度必须为 32 个字符', { - statusCode: 500, - errorCode: 'open91_invalid_secret_length', - }) - } - - return config -} - -export function normalizeOpen91String(value) { - return String(value || '').trim() -} - -export function normalizeOpen91CreatePayload(payload: JsonObject = {}) { - return { - orderNo: normalizeOpen91String(payload.orderNo), - productNo: normalizeOpen91String(payload.productNo), - buyNum: normalizeOpen91BuyNum(payload.buyNum), - maxAmount: normalizeOpen91OptionalAmount(payload.maxAmount), - callbackUrl: normalizeOpen91String(payload.callbackUrl), - timestamp: normalizeOpen91Timestamp(payload.timestamp), - version: normalizeOpen91String(payload.version), - sign: normalizeOpen91String(payload.sign).toUpperCase(), - } -} - -export function normalizeOpen91QueryPayload(payload: JsonObject = {}) { - return { - orderNo: normalizeOpen91String(payload.orderNo), - timestamp: normalizeOpen91Timestamp(payload.timestamp), - version: normalizeOpen91String(payload.version), - sign: normalizeOpen91String(payload.sign).toUpperCase(), - } -} - -export function assertOpen91CreatePayload(payload: JsonObject, config = assertOpen91Config()) { - if (!payload.orderNo) { - throw createHttpError('缺少 orderNo', { - statusCode: 400, - errorCode: 'open91_missing_order_no', - }) - } - - if (!payload.productNo) { - throw createHttpError('缺少 productNo', { - statusCode: 400, - errorCode: 'open91_missing_product_no', - }) - } - - if (!Number.isInteger(payload.buyNum) || payload.buyNum <= 0) { - throw createHttpError('buyNum 必须是大于 0 的整数', { - statusCode: 400, - errorCode: 'open91_invalid_buy_num', - }) - } - - assertOpen91CommonPayload(payload, config) -} - -export function assertOpen91QueryPayload(payload: JsonObject, config = assertOpen91Config()) { - if (!payload.orderNo) { - throw createHttpError('缺少 orderNo', { - statusCode: 400, - errorCode: 'open91_missing_order_no', - }) - } - - assertOpen91CommonPayload(payload, config) -} - -function assertOpen91CommonPayload(payload: JsonObject, config: JsonObject) { - if (!payload.timestamp) { - throw createHttpError('缺少 timestamp', { - statusCode: 400, - errorCode: 'open91_missing_timestamp', - }) - } - - if (!payload.version) { - throw createHttpError('缺少 version', { - statusCode: 400, - errorCode: 'open91_missing_version', - }) - } - - if (payload.version !== config.version) { - throw createHttpError(`version 不匹配,当前仅支持 ${config.version}`, { - statusCode: 400, - errorCode: 'open91_invalid_version', - }) - } - - if (!payload.sign) { - throw createHttpError('缺少 sign', { - statusCode: 400, - errorCode: 'open91_missing_sign', - }) - } -} - -export function assertOpen91Timestamp(timestamp: unknown, toleranceSeconds = assertOpen91Config().timestampToleranceSeconds) { - const nowSeconds = Math.floor(Date.now() / 1000) - if (Math.abs(nowSeconds - Number(timestamp)) > Math.max(1, Number(toleranceSeconds) || 600)) { - throw createHttpError('timestamp 已过期', { - statusCode: 400, - errorCode: 'open91_expired_timestamp', - }) - } -} - -export function buildOpen91SignSource(params: JsonObject = {}, { secret } = assertOpen91Config()) { - const entries = Object.entries(params) - .filter(([key]) => key !== 'sign') - .sort(([left], [right]) => { - if (left === right) { - return 0 - } - return left < right ? -1 : 1 - }) - - const queryString = entries - .map(([key, value]) => `${key}=${stringifyOpen91SignValue(value)}`) - .join('&') - - return `${secret}${queryString}${secret}` -} - -export function signOpen91Payload(params: JsonObject = {}, config = assertOpen91Config()) { - return crypto - .createHash('md5') - .update(buildOpen91SignSource(params, config), 'utf8') - .digest('hex') - .toUpperCase() -} - -export function verifyOpen91Signature(params: JsonObject = {}, config = assertOpen91Config()) { - const expected = signOpen91Payload(params, config) - const actual = normalizeOpen91String(params.sign).toUpperCase() - return expected === actual -} - -export function assertOpen91Signature(params: JsonObject = {}, config = assertOpen91Config()) { - if (!verifyOpen91Signature(params, config)) { - throw createHttpError('验签失败', { - statusCode: 400, - errorCode: 'open91_invalid_signature', - }) - } -} - -export function encryptOpen91Cards(cards = [], secret = assertOpen91Config().secret) { - const normalizedSecret = normalizeOpen91String(secret) - if (normalizedSecret.length !== 32) { - throw createHttpError('91卡券 cards 加密密钥长度必须为 32 个字符', { - statusCode: 500, - errorCode: 'open91_invalid_cards_secret_length', - }) - } - - const plainText = JSON.stringify(Array.isArray(cards) ? cards : []) - const cipher = crypto.createCipheriv('aes-256-ecb', Buffer.from(normalizedSecret, 'utf8'), null) - cipher.setAutoPadding(true) - - return `${cipher.update(plainText, 'utf8', 'base64')}${cipher.final('base64')}` -} - -export function buildOpen91Cards(cards: unknown[] = [], config = assertOpen91Config()) { - const encoding = normalizeOpen91String(config.cardsEncoding).toLowerCase() - if (encoding && encoding !== 'aes-256-ecb-base64') { - throw createHttpError(`暂不支持的 cardsEncoding: ${config.cardsEncoding}`, { - statusCode: 500, - errorCode: 'open91_unsupported_cards_encoding', - }) - } - - return encryptOpen91Cards(cards, config.secret) -} - -export function buildOpen91SuccessResponse(data: unknown, message = OPEN_91_SUCCESS_MESSAGE) { - return { - code: 200, - message, - data, - } -} - -export function buildOpen91ErrorResponse(message: unknown, code = 500) { - return { - code, - message: normalizeOpen91String(message) || '系统错误', - data: null, - } -} - -export function buildOpen91OutTradeNo(order: JsonObject) { - const orderId = Number(order?.id || 0) - return orderId > 0 ? `OS${String(orderId).padStart(10, '0')}` : '' -} - -export function buildOpen91OrderCost(value = 0) { - const amount = Number(value) - return Number.isFinite(amount) ? amount.toFixed(4) : '0.0000' -} - -export function buildOpen91CardItem({ claimUrl = '', expireTime = '' }: { claimUrl?: unknown, expireTime?: unknown } = {}) { - const normalizedClaimUrl = normalizeOpen91String(claimUrl) - return { - cardNo: normalizedClaimUrl, - cardPwd: '', - expireTime: formatOpen91ExpireTime(expireTime), - jumpLink: normalizedClaimUrl, - } -} - -export function formatOpen91ExpireTime(value: unknown = '') { - const normalized = normalizeOpen91String(value) - if (!normalized) { - return '' - } - - if (/^\d{10}$/.test(normalized) || /^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}$/.test(normalized)) { - return normalized - } - - const date = value instanceof Date ? value : new Date(normalized) - if (Number.isNaN(date.getTime())) { - return normalized - } - - const parts = new Intl.DateTimeFormat('zh-CN', { - timeZone: 'Asia/Shanghai', - year: 'numeric', - month: '2-digit', - day: '2-digit', - hour: '2-digit', - minute: '2-digit', - second: '2-digit', - hourCycle: 'h23', - }).formatToParts(date) - - const byType = Object.fromEntries(parts.map((part) => [part.type, part.value])) - return `${byType.year}-${byType.month}-${byType.day} ${byType.hour}:${byType.minute}:${byType.second}` -} - -export function resolveOpen91QueryState( - { tasks = [], readyTaskIds = [] }: { tasks?: JsonObject[], readyTaskIds?: unknown[] } = {}, -) { - const normalizedTasks = Array.isArray(tasks) ? tasks : [] - const readyTaskIdSet = new Set((Array.isArray(readyTaskIds) ? readyTaskIds : []).map((item) => Number(item))) - - if (normalizedTasks.length === 0) { - return { - orderStatus: 30, - failCode: OPEN_91_DEFAULT_FAIL_CODE, - failReason: '订单未生成履约任务', - } - } - - const failedTask = normalizedTasks.find((task) => { - const status = normalizeOpen91String(task?.task_status) - return ['failed', 'manual_review', 'closed'].includes(status) && !readyTaskIdSet.has(Number(task?.id || 0)) - }) - - if (failedTask) { - return { - orderStatus: 30, - failCode: OPEN_91_DEFAULT_FAIL_CODE, - failReason: normalizeOpen91String(failedTask.last_error) || '订单无法履约', - } - } - - if (readyTaskIdSet.size < normalizedTasks.length) { - return { - orderStatus: 10, - failCode: 0, - failReason: '', - } - } - - return { - orderStatus: 20, - failCode: 0, - failReason: '', - } -} - -export function stringifyOpen91SignValue(value) { - if (typeof value === 'number' && Number.isFinite(value)) { - return String(value) - } - - if (typeof value === 'boolean') { - return value ? 'true' : 'false' - } - - if (value == null) { - return '' - } - - return String(value) -} - -function normalizeOpen91BuyNum(value) { - const parsed = Number(value) - return Number.isInteger(parsed) ? parsed : 0 -} - -function normalizeOpen91OptionalAmount(value) { - const normalized = normalizeOpen91String(value) - if (!normalized) { - return '' - } - - if (!/^\d+(?:\.\d+)?$/.test(normalized)) { - throw createHttpError('maxAmount 格式无效', { - statusCode: 400, - errorCode: 'open91_invalid_max_amount', - }) - } - - return normalized -} - -function normalizeOpen91Timestamp(value) { - const normalized = normalizeOpen91String(value) - if (!/^\d{10}$/.test(normalized)) { - return 0 - } - - return Number(normalized) -} diff --git a/apps/backend/src/services/order/agiso-trade-parsing.test.ts b/apps/backend/src/services/order/agiso-trade-parsing.test.ts deleted file mode 100644 index 4f85b9d5..00000000 --- a/apps/backend/src/services/order/agiso-trade-parsing.test.ts +++ /dev/null @@ -1,31 +0,0 @@ -import test from 'node:test' -import assert from 'node:assert/strict' - -import { extractAgisoTradePayload, resolveAgisoTradePlatformOrderId } from './agiso-trade-parsing.js' - -test('extractAgisoTradePayload preserves large integer order ids from raw json', () => { - const payload = extractAgisoTradePayload({ - json: '{"biz_order_id":4502259793206016214,"item_id":978102431355,"order_status":4}', - }) - - assert.equal(payload.biz_order_id, '4502259793206016214') - assert.equal(typeof payload.biz_order_id, 'string') -}) - -test('resolveAgisoTradePlatformOrderId keeps exact webhook order id', () => { - const payload = extractAgisoTradePayload({ - json: '{"biz_order_id":4502259793206016214,"item_id":978102431355,"order_status":4}', - }) - - assert.equal(resolveAgisoTradePlatformOrderId(payload), '4502259793206016214') -}) - -test('resolveAgisoTradePlatformOrderId falls back to tid when biz_order_id is absent', () => { - assert.equal( - resolveAgisoTradePlatformOrderId({ - tid: '4502251008019011224', - orders: [{ oid: '4502251008019011888' }], - }), - '4502251008019011224', - ) -}) diff --git a/apps/backend/src/services/order/agiso-trade-parsing.ts b/apps/backend/src/services/order/agiso-trade-parsing.ts deleted file mode 100644 index c21167f1..00000000 --- a/apps/backend/src/services/order/agiso-trade-parsing.ts +++ /dev/null @@ -1,73 +0,0 @@ -import { parseJsonObject } from '../../utils/json.js' - -type JsonObject = Record - -export function extractAgisoTradePayload(body: unknown): JsonObject { - const normalizedBody = normalizeRecord(body) - const rawJson = String(normalizedBody.json || normalizedBody.JSON || '').trim() - - if (rawJson) { - return normalizeRecord(parseJsonObject(rawJson, { preserveLargeIntegers: true })) - } - - return normalizedBody -} - -export function resolveAgisoTradePlatformOrderId(payload: unknown): string { - const normalizedPayload = normalizeRecord(payload) - const firstItem = extractAgisoTradeOrderItemSources(normalizedPayload)[0] || {} - - return pickFirstNonEmpty([ - normalizedPayload.biz_order_id, - normalizedPayload.Tid, - normalizedPayload.tid, - normalizedPayload.Oid, - normalizedPayload.oid, - normalizedPayload.order_id, - normalizedPayload.orderId, - firstItem.Oid, - firstItem.oid, - ]) -} - -export function extractAgisoTradeOrderItemSources(payload: unknown): JsonObject[] { - const normalizedPayload = normalizeRecord(payload) - const candidates = [ - normalizedPayload.items, - normalizedPayload.Items, - normalizedPayload.orders, - normalizedPayload.Orders, - normalizedPayload.order_list, - normalizedPayload.OrderList, - ] - - for (const current of candidates) { - if (Array.isArray(current) && current.length > 0) { - return current.map((item) => normalizeRecord(item)).filter((item) => Object.keys(item).length > 0) - } - } - - if (Object.keys(normalizedPayload).length > 0) { - return [normalizedPayload] - } - - return [] -} - -function normalizeRecord(value: unknown): JsonObject { - return value && typeof value === 'object' && !Array.isArray(value) ? value as JsonObject : {} -} - -function pickFirstNonEmpty(values: unknown[]): string { - for (const value of values) { - if (typeof value === 'string' && value.trim()) { - return value.trim() - } - - if (typeof value === 'number' && Number.isFinite(value)) { - return String(value) - } - } - - return '' -} diff --git a/apps/backend/src/services/order/cloudtentacles-match-utils.ts b/apps/backend/src/services/order/cloudtentacles-match-utils.ts new file mode 100644 index 00000000..7fabd04d --- /dev/null +++ b/apps/backend/src/services/order/cloudtentacles-match-utils.ts @@ -0,0 +1,9 @@ +export function normalizeCloudtentaclesMatchName(value: unknown) { + return String(value || '') + .toLowerCase() + .replace(/[【】\[\]()()]/g, ' ') + .replace(/(自动发货|秒发|极速发货|官方直充|官方充值)/gi, ' ') + .replace(/[^\p{L}\p{N}]+/gu, ' ') + .replace(/\s+/g, ' ') + .trim() +} diff --git a/apps/backend/src/services/order/cloudtentacles-name-match-service.test.ts b/apps/backend/src/services/order/cloudtentacles-name-match-service.test.ts new file mode 100644 index 00000000..04d24d70 --- /dev/null +++ b/apps/backend/src/services/order/cloudtentacles-name-match-service.test.ts @@ -0,0 +1,147 @@ +import test from 'node:test' +import assert from 'node:assert/strict' + +import { + normalizeCloudtentaclesMatchName, + resolveCloudtentaclesSkuByProductName, +} from './cloudtentacles-name-match-service.js' + +test('normalizeCloudtentaclesMatchName normalizes punctuation and spaces', () => { + assert.equal( + normalizeCloudtentaclesMatchName(' 荣耀勋章礼包(30个) '), + normalizeCloudtentaclesMatchName('荣耀勋章礼包(30个)'), + ) +}) + +test('resolveCloudtentaclesSkuByProductName matches SKU name with logged-in sources', async () => { + const result = await resolveCloudtentaclesSkuByProductName('荣耀勋章礼包(30个)', { + listCloudtentaclesSources: () => ({ + enabled: true, + sources: [ + { + key: 'account-a', + label: '账号 A', + enabled: true, + baseUrl: 'https://cloud.example.com', + deviceId: '-', + deviceType: 0, + }, + ], + }), + getCloudtentaclesSessionStateByKey: () => ({ + token: 'token-a', + baseUrl: 'https://cloud.example.com', + username: 'user-a', + phone: '', + loggedInAt: '2026-05-27T12:00:00.000Z', + deviceId: '-', + deviceType: 0, + }), + listCloudtentaclesSku: async () => ({ + baseUrl: 'https://cloud.example.com', + itemCount: 1, + rawItems: [], + items: [ + { + id: 74, + name: '荣耀勋章礼包(30个)', + inventory: 13837, + price: 300, + }, + ], + }), + }) + + assert.equal(result?.cloudSkuId, 74) + assert.equal(result?.cloudSkuName, '荣耀勋章礼包(30个)') + assert.deepEqual(result?.cloudSourceKeys, ['account-a']) + assert.equal(result?.matchMode, 'cloudtentacles_name') +}) + +test('resolveCloudtentaclesSkuByProductName prefers override rule with multiple delivery items', async () => { + const result = await resolveCloudtentaclesSkuByProductName('套装1', { + listCloudtentaclesSources: () => ({ + enabled: true, + sources: [ + { + key: 'account-a', + label: '账号 A', + enabled: true, + baseUrl: 'https://cloud.example.com', + deviceId: '-', + deviceType: 0, + }, + ], + }), + getCloudtentaclesSessionStateByKey: () => ({ + token: 'token-a', + baseUrl: 'https://cloud.example.com', + username: 'user-a', + phone: '', + loggedInAt: '2026-05-27T12:00:00.000Z', + deviceId: '-', + deviceType: 0, + }), + listCloudtentaclesSku: async () => ({ + baseUrl: 'https://cloud.example.com', + itemCount: 3, + rawItems: [], + items: [ + { + id: 28, + name: '商品A', + inventory: 10, + price: 360, + }, + { + id: 29, + name: '商品B', + inventory: 20, + price: 888, + }, + { + id: 30, + name: '套装1', + inventory: 1, + price: 999, + }, + ], + }), + resolveCloudtentaclesOverrideRule: () => ({ + id: 'combo-1', + enabled: true, + productName: '套装1', + normalizedProductName: normalizeCloudtentaclesMatchName('套装1'), + sourceKey: '', + deliveryItems: [ + { + cloudSkuId: 28, + cloudSkuName: '商品A', + quantity: 1, + }, + { + cloudSkuId: 0, + cloudSkuName: '商品B', + quantity: 20, + }, + ], + notes: '', + }), + }) + + assert.equal(result?.matchMode, 'cloudtentacles_override') + assert.equal(result?.cloudSkuId, 28) + assert.equal(result?.cloudSkuName, '商品A') + assert.deepEqual(result?.deliveryItems, [ + { + cloudSkuId: 28, + cloudSkuName: '商品A', + quantity: 1, + }, + { + cloudSkuId: 29, + cloudSkuName: '商品B', + quantity: 20, + }, + ]) +}) diff --git a/apps/backend/src/services/order/cloudtentacles-name-match-service.ts b/apps/backend/src/services/order/cloudtentacles-name-match-service.ts new file mode 100644 index 00000000..ba57a025 --- /dev/null +++ b/apps/backend/src/services/order/cloudtentacles-name-match-service.ts @@ -0,0 +1,288 @@ +import { + listCloudtentaclesSources, +} from '../platforms/cloudtentacles/source-config-service.js' +import { + getCloudtentaclesSessionStateByKey, +} from '../platforms/cloudtentacles/session-state-service.js' +import { + normalizeCloudtentaclesDeviceId, + normalizeCloudtentaclesDeviceType, +} from '../platforms/cloudtentacles/defaults.js' +import { + listCloudtentaclesSku, +} from '../platforms/cloudtentacles/catalog-service.js' +import { + resolveCloudtentaclesOverrideRule, + type CloudtentaclesOverrideRule, +} from './cloudtentacles-override-rule-service.js' +import { normalizeCloudtentaclesMatchName } from './cloudtentacles-match-utils.js' + +type JsonObject = Record + +type CloudtentaclesSource = { + key?: string + label?: string + enabled?: boolean + baseUrl?: string + deviceId?: string + deviceType?: number +} + +type CloudtentaclesSession = { + token?: string + baseUrl?: string + deviceId?: string + deviceType?: number +} + +type CloudtentaclesSku = { + id?: number + name?: string + inventory?: number + price?: number + [key: string]: unknown +} + +type CloudtentaclesNameMatchDeps = { + listCloudtentaclesSources?: typeof listCloudtentaclesSources + getCloudtentaclesSessionStateByKey?: typeof getCloudtentaclesSessionStateByKey + listCloudtentaclesSku?: typeof listCloudtentaclesSku + resolveCloudtentaclesOverrideRule?: typeof resolveCloudtentaclesOverrideRule +} + +export type CloudtentaclesResolvedDeliveryItem = { + cloudSkuId: number + cloudSkuName: string + quantity: number +} + +export type CloudtentaclesNameMatchResult = { + matchMode: 'cloudtentacles_name' | 'cloudtentacles_override' + productName: string + normalizedProductName: string + cloudSkuId: number + cloudSkuName: string + cloudSkuPrice: number + cloudSkuInventory: number + cloudSourceKeys: string[] + resolvedSourceKey: string + deliveryItems: CloudtentaclesResolvedDeliveryItem[] + skuSnapshot: JsonObject +} + +export async function resolveCloudtentaclesSkuByProductName( + productName: unknown, + deps: CloudtentaclesNameMatchDeps = {}, +): Promise { + const normalizedProductName = normalizeCloudtentaclesMatchName(productName) + if (!normalizedProductName) { + return null + } + + const listSources = deps.listCloudtentaclesSources || listCloudtentaclesSources + const getSessionByKey = deps.getCloudtentaclesSessionStateByKey || getCloudtentaclesSessionStateByKey + const listSku = deps.listCloudtentaclesSku || listCloudtentaclesSku + const resolveOverrideRule = deps.resolveCloudtentaclesOverrideRule || resolveCloudtentaclesOverrideRule + const sourcesConfig = listSources() + + if (sourcesConfig.enabled === false) { + return null + } + + const sourceContexts = (Array.isArray(sourcesConfig.sources) ? sourcesConfig.sources : []) + .map((source: CloudtentaclesSource) => buildCloudtentaclesSourceContext(source, getSessionByKey)) + .filter((context): context is NonNullable> => Boolean(context)) + + if (sourceContexts.length === 0) { + return null + } + + const overrideRule = resolveOverrideRule(productName) + const overrideMatch = overrideRule + ? await resolveOverrideMatch(productName, normalizedProductName, overrideRule, sourceContexts, listSku) + : null + if (overrideMatch) { + return overrideMatch + } + + const cloudSourceKeys = sourceContexts.map((context) => context.sourceKey) + + for (const context of sourceContexts) { + try { + const skuList = await listSku(context) + const matchedSku = findCloudtentaclesSkuByName( + Array.isArray(skuList.items) ? skuList.items : [], + productName, + ) + + if (!matchedSku) { + continue + } + + const cloudSkuId = Number(matchedSku.id || 0) + const cloudSkuName = String(matchedSku.name || '').trim() + if (!Number.isInteger(cloudSkuId) || cloudSkuId <= 0 || !cloudSkuName) { + continue + } + + return { + matchMode: 'cloudtentacles_name', + productName: String(productName || '').trim(), + normalizedProductName, + cloudSkuId, + cloudSkuName, + cloudSkuPrice: Number(matchedSku.price || 0) || 0, + cloudSkuInventory: Number(matchedSku.inventory || 0) || 0, + cloudSourceKeys, + resolvedSourceKey: context.sourceKey, + deliveryItems: [ + { + cloudSkuId, + cloudSkuName, + quantity: 1, + }, + ], + skuSnapshot: { ...matchedSku }, + } + } catch { + // 当前账号不可用时继续尝试下一个已登录账号。 + } + } + + return null +} + +function buildCloudtentaclesSourceContext( + source: CloudtentaclesSource, + getSessionByKey: typeof getCloudtentaclesSessionStateByKey, +) { + const sourceKey = String(source.key || '').trim() + if (!sourceKey || source.enabled === false) { + return null + } + + const session = getSessionByKey(sourceKey) as CloudtentaclesSession | null + const token = String(session?.token || '').trim() + if (!token) { + return null + } + + return { + sourceKey, + baseUrl: String(session?.baseUrl || source.baseUrl || '').trim(), + token, + deviceId: normalizeCloudtentaclesDeviceId(session?.deviceId || source.deviceId), + deviceType: normalizeCloudtentaclesDeviceType(session?.deviceType ?? source.deviceType), + } +} + +function findCloudtentaclesSkuByName(items: CloudtentaclesSku[], productName: unknown) { + const rawName = String(productName || '').trim() + const normalizedName = normalizeCloudtentaclesMatchName(rawName) + + return ( + items.find((item) => String(item.name || '').trim() === rawName) || + items.find((item) => normalizeCloudtentaclesMatchName(item.name) === normalizedName) || + null + ) +} + +async function resolveOverrideMatch( + productName: unknown, + normalizedProductName: string, + rule: CloudtentaclesOverrideRule, + sourceContexts: Array>>, + listSku: typeof listCloudtentaclesSku, +): Promise { + const candidates = rule.sourceKey + ? sourceContexts.filter((context) => context.sourceKey === rule.sourceKey) + : sourceContexts + + if (candidates.length === 0) { + return null + } + + for (const context of candidates) { + try { + const skuList = await listSku(context) + const items = Array.isArray(skuList.items) ? skuList.items : [] + const deliveryItems = resolveOverrideDeliveryItems(rule, items) + + if (deliveryItems.length !== rule.deliveryItems.length || !deliveryItems[0]) { + continue + } + + const firstDeliveryItem = deliveryItems[0] + const firstSku = items.find((item) => Number(item.id || 0) === firstDeliveryItem.cloudSkuId) || {} + const cloudSourceKeys = rule.sourceKey + ? [context.sourceKey] + : sourceContexts.map((sourceContext) => sourceContext.sourceKey) + + return { + matchMode: 'cloudtentacles_override', + productName: String(productName || '').trim(), + normalizedProductName, + cloudSkuId: firstDeliveryItem.cloudSkuId, + cloudSkuName: firstDeliveryItem.cloudSkuName, + cloudSkuPrice: Number(firstSku.price || 0) || 0, + cloudSkuInventory: Number(firstSku.inventory || 0) || 0, + cloudSourceKeys, + resolvedSourceKey: context.sourceKey, + deliveryItems, + skuSnapshot: { + overrideRuleId: rule.id, + overrideRuleProductName: rule.productName, + deliveryItems, + firstSku, + }, + } + } catch { + // 当前账号不可用时继续尝试下一个已登录账号。 + } + } + + return null +} + +function resolveOverrideDeliveryItems(rule: CloudtentaclesOverrideRule, skuItems: CloudtentaclesSku[]) { + const resolvedItems: CloudtentaclesResolvedDeliveryItem[] = [] + + for (const ruleItem of rule.deliveryItems) { + const matchedSku = ruleItem.cloudSkuId > 0 + ? skuItems.find((item) => Number(item.id || 0) === ruleItem.cloudSkuId) + : findCloudtentaclesSkuByName(skuItems, ruleItem.cloudSkuName) + const cloudSkuId = Number(matchedSku?.id || 0) + const cloudSkuName = String(matchedSku?.name || '').trim() + + if (!Number.isInteger(cloudSkuId) || cloudSkuId <= 0 || !cloudSkuName) { + return [] + } + + resolvedItems.push({ + cloudSkuId, + cloudSkuName, + quantity: Math.max(1, Number(ruleItem.quantity || 1)), + }) + } + + return mergeResolvedDeliveryItems(resolvedItems) +} + +function mergeResolvedDeliveryItems(items: CloudtentaclesResolvedDeliveryItem[]) { + const merged = new Map() + + for (const item of items) { + const existing = merged.get(item.cloudSkuId) + if (existing) { + existing.quantity += item.quantity + existing.cloudSkuName = existing.cloudSkuName || item.cloudSkuName + continue + } + + merged.set(item.cloudSkuId, { ...item }) + } + + return Array.from(merged.values()) +} + +export { normalizeCloudtentaclesMatchName } diff --git a/apps/backend/src/services/order/cloudtentacles-override-rule-service.test.ts b/apps/backend/src/services/order/cloudtentacles-override-rule-service.test.ts new file mode 100644 index 00000000..50306db4 --- /dev/null +++ b/apps/backend/src/services/order/cloudtentacles-override-rule-service.test.ts @@ -0,0 +1,70 @@ +import test from 'node:test' +import assert from 'node:assert/strict' + +import { + normalizeCloudtentaclesOverrideRuleConfig, +} from './cloudtentacles-override-rule-service.js' + +test('normalizeCloudtentaclesOverrideRuleConfig merges duplicated delivery items', () => { + const config = normalizeCloudtentaclesOverrideRuleConfig({ + enabled: true, + rules: [ + { + productName: '套装1', + deliveryItems: [ + { + cloudSkuId: 28, + cloudSkuName: '商品A', + quantity: 1, + }, + { + cloudSkuId: 28, + cloudSkuName: '商品A', + quantity: 2, + }, + { + cloudSkuName: '商品B', + quantity: 20, + }, + ], + }, + ], + }) + + assert.equal(config.enabled, true) + assert.equal(config.rules.length, 1) + assert.deepEqual(config.rules[0]?.deliveryItems, [ + { + cloudSkuId: 28, + cloudSkuName: '商品A', + quantity: 3, + }, + { + cloudSkuId: 0, + cloudSkuName: '商品B', + quantity: 20, + }, + ]) +}) + +test('normalizeCloudtentaclesOverrideRuleConfig drops invalid rules', () => { + const config = normalizeCloudtentaclesOverrideRuleConfig({ + rules: [ + { + productName: '', + deliveryItems: [ + { + cloudSkuId: 28, + quantity: 1, + }, + ], + }, + { + productName: '有效商品', + deliveryItems: [], + }, + ], + }) + + assert.deepEqual(config.rules, []) +}) diff --git a/apps/backend/src/services/order/cloudtentacles-override-rule-service.ts b/apps/backend/src/services/order/cloudtentacles-override-rule-service.ts new file mode 100644 index 00000000..8c04b2c4 --- /dev/null +++ b/apps/backend/src/services/order/cloudtentacles-override-rule-service.ts @@ -0,0 +1,173 @@ +import path from 'node:path' + +import { PROJECT_ROOT } from '../../config/runtime.js' +import { readJsonFile, writeJsonFile } from '../../utils/json-file-store.js' +import { normalizeCloudtentaclesMatchName } from './cloudtentacles-match-utils.js' + +const CLOUDTENTACLES_OVERRIDE_RULE_FILE_PATH = path.join( + PROJECT_ROOT, + 'data', + 'cloudtentacles-override-rules.json', +) + +type JsonObject = Record + +export type CloudtentaclesOverrideDeliveryItem = { + cloudSkuId: number + cloudSkuName: string + quantity: number +} + +export type CloudtentaclesOverrideRule = { + id: string + enabled: boolean + productName: string + normalizedProductName: string + sourceKey: string + deliveryItems: CloudtentaclesOverrideDeliveryItem[] + notes: string +} + +export type CloudtentaclesOverrideRuleConfig = { + enabled: boolean + rules: CloudtentaclesOverrideRule[] +} + +export function getCloudtentaclesOverrideRuleFilePath() { + return CLOUDTENTACLES_OVERRIDE_RULE_FILE_PATH +} + +export function getCloudtentaclesOverrideRuleConfig(): CloudtentaclesOverrideRuleConfig { + return readJsonFile( + CLOUDTENTACLES_OVERRIDE_RULE_FILE_PATH, + createDefaultCloudtentaclesOverrideRuleConfig, + normalizeCloudtentaclesOverrideRuleConfig, + ) +} + +export function saveCloudtentaclesOverrideRuleConfig(rawValue: unknown): CloudtentaclesOverrideRuleConfig { + return writeJsonFile( + CLOUDTENTACLES_OVERRIDE_RULE_FILE_PATH, + rawValue, + normalizeCloudtentaclesOverrideRuleConfig, + ) +} + +export function resolveCloudtentaclesOverrideRule(productName: unknown) { + const config = getCloudtentaclesOverrideRuleConfig() + if (config.enabled === false) { + return null + } + + const normalizedProductName = normalizeCloudtentaclesMatchName(productName) + if (!normalizedProductName) { + return null + } + + return config.rules.find((rule) => + rule.enabled !== false && + rule.normalizedProductName === normalizedProductName && + rule.deliveryItems.length > 0 + ) || null +} + +export function normalizeCloudtentaclesOverrideRuleConfig(rawValue: unknown): CloudtentaclesOverrideRuleConfig { + const source = isPlainObject(rawValue) ? rawValue : {} + const rules = Array.isArray(source.rules) ? source.rules : [] + + return { + enabled: source.enabled !== false, + rules: rules + .map((item: unknown) => normalizeCloudtentaclesOverrideRule(item)) + .filter((item): item is CloudtentaclesOverrideRule => Boolean(item)), + } +} + +function normalizeCloudtentaclesOverrideRule(rawValue: unknown): CloudtentaclesOverrideRule | null { + if (!isPlainObject(rawValue)) { + return null + } + + const productName = String(rawValue.productName || rawValue.name || '').trim() + const normalizedProductName = normalizeCloudtentaclesMatchName(productName) + const deliveryItems = normalizeDeliveryItems(rawValue.deliveryItems) + + if (!productName || !normalizedProductName || deliveryItems.length === 0) { + return null + } + + return { + id: String(rawValue.id || normalizedProductName).trim() || normalizedProductName, + enabled: rawValue.enabled !== false, + productName, + normalizedProductName, + sourceKey: String(rawValue.sourceKey || '').trim(), + deliveryItems, + notes: String(rawValue.notes || '').trim(), + } +} + +function normalizeDeliveryItems(value: unknown) { + const rawItems = Array.isArray(value) ? value : [] + const items = rawItems + .map((item) => normalizeDeliveryItem(item)) + .filter((item): item is CloudtentaclesOverrideDeliveryItem => Boolean(item)) + + return mergeDeliveryItems(items) +} + +function normalizeDeliveryItem(value: unknown): CloudtentaclesOverrideDeliveryItem | null { + const source = isPlainObject(value) ? value : {} + const cloudSkuId = normalizePositiveInteger(source.cloudSkuId || source.skuId, 0) + const cloudSkuName = String(source.cloudSkuName || source.skuName || '').trim() + const quantity = normalizePositiveInteger(source.quantity, 1) + + if (!cloudSkuId && !cloudSkuName) { + return null + } + + return { + cloudSkuId, + cloudSkuName, + quantity, + } +} + +function mergeDeliveryItems(items: CloudtentaclesOverrideDeliveryItem[]) { + const merged = new Map() + + for (const item of items) { + const key = item.cloudSkuId > 0 ? `id:${item.cloudSkuId}` : `name:${normalizeCloudtentaclesMatchName(item.cloudSkuName)}` + const existing = merged.get(key) + if (existing) { + existing.quantity += item.quantity + existing.cloudSkuName = existing.cloudSkuName || item.cloudSkuName + existing.cloudSkuId = existing.cloudSkuId || item.cloudSkuId + continue + } + + merged.set(key, { ...item }) + } + + return Array.from(merged.values()) +} + +function normalizePositiveInteger(value: unknown, fallback: number) { + const parsed = Number(value) + if (!Number.isInteger(parsed) || parsed <= 0) { + return fallback + } + + return parsed +} + +function createDefaultCloudtentaclesOverrideRuleConfig(): CloudtentaclesOverrideRuleConfig { + return { + enabled: true, + rules: [], + } +} + +function isPlainObject(value: unknown): value is JsonObject { + return Object.prototype.toString.call(value) === '[object Object]' +} diff --git a/apps/backend/src/services/order/delivery-task-service.test.ts b/apps/backend/src/services/order/delivery-task-service.test.ts index 5935aeff..ea56e3db 100644 --- a/apps/backend/src/services/order/delivery-task-service.test.ts +++ b/apps/backend/src/services/order/delivery-task-service.test.ts @@ -1,162 +1,129 @@ -import test from 'node:test' import assert from 'node:assert/strict' +import test from 'node:test' import { syncDeliveryTasksForOrderWithDeps } from './delivery-task-service.js' -const paidOrder = { - id: 10, - provider: 'agiso', - platform: 'xianyu', - shop_id: 'shop-1', - shop_name: '测试店铺', - platform_order_id: 'P10001', - pay_status: 'paid', -} +test('syncDeliveryTasksForOrder 多账号候选时不提前固定 cloudtentacles 账号', async () => { + const createdInputs: any[] = [] -const orderItems = [ - { - id: 20, - sku_code: 'dnf-cdk-a', - sku_name: 'DNF CDK A', - quantity: 1, - }, -] - -function createTaskFixture(patch = {}) { - return { - id: 30, - order_item_id: 20, - task_status: 'paid', - executor_key: 'tencent_claim_assisted', - requires_claim: true, - claim_token: '', - primary_claim_token_id: null, - primary_inventory_item_id: null, - last_error: '', - context_json: JSON.stringify({ - inventorySkuCode: 'dnf-cdk-a', - primaryRequirement: { - roleKey: 'primary_code', - credentialType: 'tencent_code', - }, - }), - ...patch, - } -} - -test('syncDeliveryTasksForOrderWithDeps moves paid claim task to waiting inventory when reservation fails', async () => { - const updates = [] - const reserveCalls = [] - - const result = await syncDeliveryTasksForOrderWithDeps(paidOrder, orderItems, { - listTasksByOrderId: async () => [createTaskFixture()], - reserveInventoryForTask: async (payload) => { - reserveCalls.push(payload) - return null - }, - updateTask: async (taskId, patch) => { - updates.push({ taskId, patch }) - return { id: taskId, ...patch } - }, - nowIso: () => '2026-04-14T12:00:00.000Z', - }) - - assert.deepEqual(reserveCalls, [ + await syncDeliveryTasksForOrderWithDeps( { - skuCode: 'dnf-cdk-a', - taskId: 30, - credentialType: 'tencent_code', - roleKey: 'primary_code', - }, - ]) - assert.deepEqual(updates, [ - { - taskId: 30, - patch: { - task_status: 'waiting_inventory', - inventory_status: 'pending', - last_error: '库存不足,等待可用库存凭据', - updated_at: '2026-04-14T12:00:00.000Z', - }, - }, - ]) - assert.equal(result[0]?.task_status, 'waiting_inventory') -}) - -test('syncDeliveryTasksForOrderWithDeps sends manual dispatch tasks to manual review', async () => { - const notifications = [] - const updates = [] - - const result = await syncDeliveryTasksForOrderWithDeps(paidOrder, orderItems, { - listTasksByOrderId: async () => [createTaskFixture({ - executor_key: 'manual_dispatch', - requires_claim: false, - context_json: '{}', - })], - updateTask: async (taskId, patch) => { - updates.push({ taskId, patch }) - return { id: taskId, ...patch } - }, - notifyTaskAutoManualReview: async (payload) => { - notifications.push(payload) - return { ok: true } - }, - nowIso: () => '2026-04-14T12:01:00.000Z', - }) - - assert.equal(result[0]?.task_status, 'manual_review') - assert.equal(updates[0]?.patch.last_error, '当前任务需要人工履约处理') - assert.deepEqual( - notifications.map((item) => ({ - reason: item.reason, - source: item.source, - taskStatus: item.task.task_status, - })), + id: 1, + pay_status: 'pending_payment', + provider: 'open_91', + platform: 'kuaishou', + shop_id: 'shop-1', + shop_name: '测试店铺', + platform_order_id: '2614900602069169', + } as any, [ { - reason: '当前任务需要人工履约处理', - source: 'manual_dispatch_profile', - taskStatus: 'manual_review', + id: 10, + order_id: 1, + sku_code: 'SKU-1', + sku_name: '荣耀勋章礼包(30个)', + quantity: 1, + item_snapshot_json: { + cloudtentacles: { + cloudSourceKeys: ['account-a', 'account-b'], + resolvedSourceKey: 'account-a', + cloudSkuId: 74, + cloudSkuName: '荣耀勋章礼包(30个)', + deliveryItems: [ + { + cloudSkuId: 74, + cloudSkuName: '荣耀勋章礼包(30个)', + quantity: 1, + }, + ], + }, + }, }, - ], - ) -}) - -test('syncDeliveryTasksForOrderWithDeps prepares kuaishou cloud task with claim token', async () => { - const claimToken = { - token: 'claim-token', - expired_at: '2026-04-15T12:00:00.000Z', - } - const updates = [] - - const result = await syncDeliveryTasksForOrderWithDeps(paidOrder, orderItems, { - listTasksByOrderId: async () => [createTaskFixture({ - executor_key: 'kuaishou_ct_assisted', - task_status: 'paid', - requires_claim: true, - context_json: '{}', - })], - createTaskClaimToken: async (taskId) => ({ ...claimToken, taskId }), - updateTask: async (taskId, patch) => { - updates.push({ taskId, patch }) - return { id: taskId, ...patch } - }, - nowIso: () => '2026-04-14T12:02:00.000Z', - }) - - assert.equal(result[0]?.task_status, 'pending_binding_prepare') - assert.deepEqual(updates, [ + ] as any, { - taskId: 30, - patch: { - task_status: 'pending_binding_prepare', - inventory_status: 'not_required', - claim_token: 'claim-token', - claim_expires_at: '2026-04-15T12:00:00.000Z', - user_action_status: 'pending_claim', - last_error: '领取链接已生成,等待客户提交核销码', - updated_at: '2026-04-14T12:02:00.000Z', + listTasksByOrderId: async () => [], + getFulfillmentProfileByKey: async () => ({ + id: 9, + profile_key: 'kuaishou_ct_assisted', + profile_name: '快手 cloud 履约', + executor_key: 'kuaishou_ct_assisted', + }), + createTask: async (input: any) => { + createdInputs.push(input) + return { + id: 100, + order_item_id: input.orderItemId, + context_json: input.contextJson, + task_status: input.taskStatus, + executor_key: input.executorKey, + } as any }, + nowIso: () => '2026-05-29T09:05:46.658Z', + randomId: () => 'DT-test', }, + ) + + const context = JSON.parse(createdInputs[0].contextJson) + assert.deepEqual(context.kuaishouCloudFulfillment.binding.cloudSourceKeys, [ + 'account-a', + 'account-b', ]) + assert.equal(context.kuaishouCloudFulfillment.binding.resolvedSourceKey, '') +}) + +test('syncDeliveryTasksForOrder 单账号候选时保留固定 cloudtentacles 账号', async () => { + const createdInputs: any[] = [] + + await syncDeliveryTasksForOrderWithDeps( + { + id: 2, + pay_status: 'pending_payment', + provider: 'open_91', + platform: 'kuaishou', + shop_id: 'shop-1', + shop_name: '测试店铺', + platform_order_id: '2614900602069170', + } as any, + [ + { + id: 11, + order_id: 2, + sku_code: 'SKU-1', + sku_name: '荣耀勋章礼包(30个)', + quantity: 1, + item_snapshot_json: { + cloudtentacles: { + cloudSourceKeys: ['account-a'], + cloudSkuId: 74, + cloudSkuName: '荣耀勋章礼包(30个)', + }, + }, + }, + ] as any, + { + listTasksByOrderId: async () => [], + getFulfillmentProfileByKey: async () => ({ + id: 9, + profile_key: 'kuaishou_ct_assisted', + profile_name: '快手 cloud 履约', + executor_key: 'kuaishou_ct_assisted', + }), + createTask: async (input: any) => { + createdInputs.push(input) + return { + id: 101, + order_item_id: input.orderItemId, + context_json: input.contextJson, + task_status: input.taskStatus, + executor_key: input.executorKey, + } as any + }, + nowIso: () => '2026-05-29T09:05:46.658Z', + randomId: () => 'DT-test', + }, + ) + + const context = JSON.parse(createdInputs[0].contextJson) + assert.deepEqual(context.kuaishouCloudFulfillment.binding.cloudSourceKeys, ['account-a']) + assert.equal(context.kuaishouCloudFulfillment.binding.resolvedSourceKey, 'account-a') }) diff --git a/apps/backend/src/services/order/delivery-task-service.ts b/apps/backend/src/services/order/delivery-task-service.ts index 2a469509..297f98d3 100644 --- a/apps/backend/src/services/order/delivery-task-service.ts +++ b/apps/backend/src/services/order/delivery-task-service.ts @@ -1,14 +1,16 @@ import { createTask, listTasksByOrderId, updateTask } from '../../repositories/task-repo.js' -import { - listFulfillmentProfileRequirements, - resolveFulfillmentBinding, -} from '../../repositories/fulfillment-profile-repo.js' -import { reserveInventoryForTask } from './inventory-service.js' +import { getFulfillmentProfileByKey } from '../../repositories/fulfillment-profile-repo.js' import { createTaskClaimToken } from '../claim/claim-service.js' import { notifyTaskAutoManualReview } from '../notification/domain-notifications.js' import { nowIso } from '../../utils/time.js' import { randomId } from '../../utils/random.js' -import type { InventoryItemRow, OrderItemRow, OrderRow, TaskRow } from '../../types/repository-rows.js' +import { + TASK_STATUS, + isPaidPreparationStableStatus, + resolveInitialPaidTaskStatus, + shouldEnsureKuaishouCloudClaimLink, +} from '../../domain/task-status.js' +import type { OrderItemRow, OrderRow, TaskRow } from '../../types/repository/rows.js' type JsonObject = Record @@ -31,15 +33,6 @@ type FulfillmentBindingLike = { [key: string]: unknown } -type FulfillmentRequirementLike = { - role_key?: string - roleKey?: string - credential_type?: string - credentialType?: string - is_required?: boolean - [key: string]: unknown -} - type DeliveryTaskRow = TaskRow & { skuCode?: string skuName?: string @@ -49,21 +42,7 @@ type DeliveryTaskDeps = { createTask?: typeof createTask listTasksByOrderId?: typeof listTasksByOrderId updateTask?: typeof updateTask - listFulfillmentProfileRequirements?: ( - profileId: number | string, - ) => Promise - resolveFulfillmentBinding?: (input: { - skuCode: string - provider?: string - platform?: string - shopId?: string - }) => Promise - reserveInventoryForTask?: (input: { - skuCode: string - taskId: number | string - credentialType?: string - roleKey?: string - }) => Promise + getFulfillmentProfileByKey?: (profileKey: string) => Promise createTaskClaimToken?: (taskId: number | string) => Promise notifyTaskAutoManualReview?: (payload: { task: unknown @@ -74,28 +53,21 @@ type DeliveryTaskDeps = { randomId?: (prefix?: string) => string } -type RuntimeDeliveryTaskDeps = Required> +type RuntimeDeliveryTaskDeps = Required< + Pick< + DeliveryTaskDeps, + 'updateTask' | 'createTaskClaimToken' | 'notifyTaskAutoManualReview' | 'nowIso' + > +> type TaskContext = { - inventorySkuCode?: string - primaryRequirement?: { - roleKey?: string - credentialType?: string - } | null [key: string]: unknown } export async function syncDeliveryTasksForOrder( order: OrderRow, orderItems: OrderItemRow[], -): Promise> { +): Promise { return syncDeliveryTasksForOrderWithDeps(order, orderItems) } @@ -103,14 +75,12 @@ export async function syncDeliveryTasksForOrderWithDeps( order: OrderRow, orderItems: OrderItemRow[], deps: DeliveryTaskDeps = {}, -): Promise> { +): Promise { const { createTask: createDeliveryTask = createTask, listTasksByOrderId: listTasks = listTasksByOrderId, updateTask: updateDeliveryTask = updateTask, - listFulfillmentProfileRequirements: listProfileRequirements = listFulfillmentProfileRequirements, - resolveFulfillmentBinding: resolveBinding = resolveFulfillmentBinding, - reserveInventoryForTask: reserveInventory = reserveInventoryForTask, + getFulfillmentProfileByKey: getProfileByKey = getFulfillmentProfileByKey, createTaskClaimToken: createClaimToken = createTaskClaimToken, notifyTaskAutoManualReview: notifyManualReview = notifyTaskAutoManualReview, nowIso: getNowIso = nowIso, @@ -119,7 +89,6 @@ export async function syncDeliveryTasksForOrderWithDeps( const runtimeDeps: RuntimeDeliveryTaskDeps = { updateTask: updateDeliveryTask, - reserveInventoryForTask: reserveInventory, createTaskClaimToken: createClaimToken, notifyTaskAutoManualReview: notifyManualReview, nowIso: getNowIso, @@ -133,40 +102,51 @@ export async function syncDeliveryTasksForOrderWithDeps( } const itemMap = new Map(orderItems.map((item) => [item.id, item])) - return Promise.all(existingTasks.map((task) => preparePaidTask({ - ...task, - skuCode: itemMap.get(task.order_item_id)?.sku_code || '', - skuName: itemMap.get(task.order_item_id)?.sku_name || '', - }, runtimeDeps))) + const preparedTasks = await Promise.all( + existingTasks.map((task) => + preparePaidTask( + { + ...task, + skuCode: itemMap.get(task.order_item_id)?.sku_code || '', + skuName: itemMap.get(task.order_item_id)?.sku_name || '', + }, + runtimeDeps, + ), + ), + ) + return preparedTasks.filter(isTaskRow) } const tasks: DeliveryTaskRow[] = [] for (const item of orderItems) { - const binding = await resolveBinding({ - skuCode: item.sku_code, - provider: order.provider, - platform: order.platform, - shopId: order.shop_id, - }) + const profile = await resolveDynamicCloudtentaclesProfile(item, getProfileByKey) - if (!binding) { + if (!profile) { continue } - const profile = binding - const requirements = await listProfileRequirements(profile.profile_id || profile.id) - const primaryRequirement = requirements.find((requirement) => requirement.is_required !== false) || requirements[0] || null + const itemSnapshot = parseJsonObject(item.item_snapshot_json) const quantity = Math.max(1, Number(item.quantity || 1)) const fulfillmentConfig = parseJsonObject(profile.config_json) const cloudtentaclesConfig = parseJsonObject(fulfillmentConfig.cloudtentacles) const kuaishouConsumeConfig = parseJsonObject(fulfillmentConfig.kuaishouConsume) const kuaishouShopConfig = parseJsonObject(fulfillmentConfig.kuaishouShop) + const cloudSourceKeys = normalizeStringArray(cloudtentaclesConfig.cloudSourceKeys) + const resolvedCloudSourceKey = + cloudSourceKeys.length === 1 + ? String(cloudtentaclesConfig.resolvedSourceKey || cloudSourceKeys[0] || '').trim() + : '' + const deliveryItems = normalizeCloudDeliveryItems(cloudtentaclesConfig) + const primaryDeliveryItem = deliveryItems[0] || { + cloudSkuId: Number(cloudtentaclesConfig.skuId || 0) || 0, + cloudSkuName: String(cloudtentaclesConfig.skuName || '').trim(), + quantity: 1, + } for (let index = 0; index < quantity; index += 1) { const createdAt = getNowIso() - const initialStatus = order.pay_status === 'paid' - ? resolvePaidTaskStatus(profile) - : 'pending_payment' + const initialStatus = + order.pay_status === 'paid' ? resolvePaidTaskStatus(profile) : TASK_STATUS.PENDING_PAYMENT const task = await createDeliveryTask({ orderId: order.id, @@ -181,7 +161,6 @@ export async function syncDeliveryTasksForOrderWithDeps( profileId: Number(profile.profile_id || profile.id), executorKey: String(profile.executor_key || 'manual_dispatch'), taskStatus: initialStatus, - inventoryStatus: profile.requires_claim ? 'pending' : 'not_required', deliveryStatus: 'pending', resultCode: '', resultMessage: '', @@ -197,96 +176,105 @@ export async function syncDeliveryTasksForOrderWithDeps( profileName: String(profile.profile_name || profile.name || ''), skuCode: item.sku_code, skuName: item.sku_name, - inventorySkuCode: item.sku_code, - primaryRequirement: primaryRequirement - ? { - roleKey: String(primaryRequirement.role_key || primaryRequirement.roleKey || 'primary_code'), - credentialType: String(primaryRequirement.credential_type || primaryRequirement.credentialType || 'tencent_code'), - } - : null, kuaishouCloudFulfillment: isKuaishouCloudExecutor(profile.executor_key) ? { - flowType: 'kuaishou_cloud_fulfillment', - configId: String(fulfillmentConfig.configId || '').trim(), - internalSkuCode: item.sku_code, - internalSkuName: item.sku_name, - ticket: { - code: '', - status: 'pending', - capturedAt: null, - capturedBy: null, - verifiedAt: null, - oid: '', - formToken: '', - leftCount: 0, - goodsTitle: '', - }, - binding: { - prepareStatus: 'pending', - cloudSourceKey: String(cloudtentaclesConfig.cloudSourceKey || 'default').trim() || 'default', - skuId: Number(cloudtentaclesConfig.skuId || 0) || 0, - skuName: String(cloudtentaclesConfig.skuName || '').trim(), - vnKey: '1', - vnId: 0, - vnPhone: '', - bindUrl: '', - bindPreparedAt: null, - bindExpiresAt: null, - bindProbeAt: null, - bindProbeStatus: '', - bindProbeMessage: '', - }, - role: { - status: 'pending', - name: '', - rid: '', - refreshedAt: null, - errorMessage: '', - rawInfo: null, - }, - purchase: { - autoBuyEnabled: cloudtentaclesConfig.autoBuyEnabled !== false, - minAssetReserve: Number(cloudtentaclesConfig.minAssetReserve || 0) || 0, - usedKnapsack: false, - purchaseTriggered: false, - assetBefore: 0, - assetAfter: 0, - purchaseAt: null, - }, - dispatch: { - status: 'pending', - dispatchAt: null, - dispatchBy: null, - sendType: 0, - note: '', - }, - returnNumber: { - status: 'pending', - returnedAt: null, - returnedBy: null, - autoReturnEnabled: cloudtentaclesConfig.autoReturnNumberAfterDispatch === true, - }, - consume: { - status: 'pending', - shopId: String(kuaishouConsumeConfig.shopId || kuaishouShopConfig.shopId || '').trim(), - shopName: String(kuaishouConsumeConfig.shopName || kuaishouShopConfig.kshopName || order.shop_name || '').trim(), - autoConsumeEnabled: kuaishouConsumeConfig.autoConsumeAfterDispatch === true, - consumedAt: null, - errorMessage: '', - }, - notes: String(fulfillmentConfig.notes || '').trim(), - } + flowType: 'kuaishou_cloud_fulfillment', + configId: String(fulfillmentConfig.configId || '').trim(), + internalSkuCode: item.sku_code, + internalSkuName: item.sku_name, + deliveryItems, + ticket: { + code: '', + status: 'pending', + capturedAt: null, + capturedBy: null, + verifiedAt: null, + oid: '', + formToken: '', + leftCount: 0, + goodsTitle: '', + }, + binding: { + prepareStatus: 'pending', + cloudSourceKeys, + resolvedSourceKey: resolvedCloudSourceKey, + skuId: primaryDeliveryItem.cloudSkuId, + skuName: primaryDeliveryItem.cloudSkuName, + vnKey: '1', + vnId: 0, + vnPhone: '', + bindUrl: '', + bindPreparedAt: null, + bindExpiresAt: null, + bindProbeAt: null, + bindProbeStatus: '', + bindProbeMessage: '', + }, + role: { + status: 'pending', + name: '', + rid: '', + refreshedAt: null, + errorMessage: '', + rawInfo: null, + }, + purchase: { + autoBuyEnabled: cloudtentaclesConfig.autoBuyEnabled !== false, + minAssetReserve: Number(cloudtentaclesConfig.minAssetReserve || 0) || 0, + usedKnapsack: false, + purchaseTriggered: false, + assetBefore: 0, + assetAfter: 0, + purchaseAt: null, + }, + dispatch: { + status: 'pending', + dispatchAt: null, + dispatchBy: null, + sendType: 0, + note: '', + }, + returnNumber: { + status: 'pending', + returnedAt: null, + returnedBy: null, + autoReturnEnabled: cloudtentaclesConfig.autoReturnNumberAfterDispatch === true, + }, + consume: { + status: 'pending', + shopId: String( + kuaishouConsumeConfig.shopId || + kuaishouShopConfig.shopId || + itemSnapshot.shopId || + order.shop_id || + '', + ).trim(), + shopName: String( + kuaishouConsumeConfig.shopName || + kuaishouShopConfig.kshopName || + itemSnapshot.shopName || + order.shop_name || + '', + ).trim(), + autoConsumeEnabled: kuaishouConsumeConfig.autoConsumeAfterDispatch === true, + consumedAt: null, + errorMessage: '', + }, + notes: String(fulfillmentConfig.notes || '').trim(), + } : null, }), createdAt, updatedAt: createdAt, }) - tasks.push({ - ...task, - skuCode: item.sku_code, - skuName: item.sku_name, - }) + if (task) { + tasks.push({ + ...task, + skuCode: item.sku_code, + skuName: item.sku_name, + }) + } } } @@ -294,7 +282,8 @@ export async function syncDeliveryTasksForOrderWithDeps( return tasks } - return Promise.all(tasks.map((task) => preparePaidTask(task, runtimeDeps))) + const preparedTasks = await Promise.all(tasks.map((task) => preparePaidTask(task, runtimeDeps))) + return preparedTasks.filter(isTaskRow) } async function preparePaidTask( @@ -303,30 +292,19 @@ async function preparePaidTask( ): Promise { const { updateTask: updateDeliveryTask = updateTask, - reserveInventoryForTask: reserveInventory = reserveInventoryForTask, createTaskClaimToken: createClaimToken = createTaskClaimToken, notifyTaskAutoManualReview: notifyManualReview = notifyTaskAutoManualReview, nowIso: getNowIso = nowIso, } = deps const now = getNowIso() - const taskContext = parseTaskContext(task) - const primaryRequirement = taskContext.primaryRequirement || null - const inventorySkuCode = String(taskContext.inventorySkuCode || task.skuCode || '').trim() - if (['link_generated', 'claimed', 'role_confirmed', 'redeeming', 'redeemed', 'closed'].includes(task.task_status)) { + if (isPaidPreparationStableStatus(task.task_status)) { return task } if (isKuaishouCloudExecutor(task.executor_key)) { - if ([ - 'pending_binding_prepare', - 'waiting_binding', - 'dispatched_pending_return', - 'completed', - 'manual_review', - 'failed', - ].includes(String(task.task_status || '').trim())) { + if (shouldEnsureKuaishouCloudClaimLink(task.task_status)) { if (!task.primary_claim_token_id && !String(task.claim_token || '').trim()) { const claimToken = await createClaimToken(task.id) return updateDeliveryTask(task.id, { @@ -343,8 +321,7 @@ async function preparePaidTask( const claimToken = await createClaimToken(task.id) return updateDeliveryTask(task.id, { - task_status: 'pending_binding_prepare', - inventory_status: 'not_required', + task_status: TASK_STATUS.PENDING_BINDING_PREPARE, claim_token: claimToken.token, claim_expires_at: claimToken.expired_at, user_action_status: 'pending_claim', @@ -356,8 +333,7 @@ async function preparePaidTask( if (!task.requires_claim || String(task.executor_key || '').trim() === 'manual_dispatch') { const lastError = task.last_error || '当前任务需要人工履约处理' const updatedTask = await updateDeliveryTask(task.id, { - task_status: 'manual_review', - inventory_status: 'not_required', + task_status: TASK_STATUS.MANUAL_REVIEW, user_action_status: 'not_required', last_error: lastError, updated_at: now, @@ -370,87 +346,11 @@ async function preparePaidTask( return updatedTask } - if (!inventorySkuCode) { - const lastError = '未匹配到 SKU,无法为任务分配库存凭据' - const updatedTask = await updateDeliveryTask(task.id, { - task_status: 'manual_review', - last_error: lastError, - updated_at: now, - }) - await notifyManualReview({ - task: updatedTask || task, - reason: lastError, - source: 'missing_sku', - }) - return updatedTask - } - - if (task.primary_inventory_item_id && task.primary_claim_token_id) { - return updateDeliveryTask(task.id, { - task_status: 'link_generated', - last_error: '', - updated_at: now, - }) - } - - if (!primaryRequirement?.credentialType) { - const lastError = '履约档案未配置库存要求,无法自动分配库存' - const updatedTask = await updateDeliveryTask(task.id, { - task_status: 'manual_review', - last_error: lastError, - updated_at: now, - }) - await notifyManualReview({ - task: updatedTask || task, - reason: lastError, - source: 'missing_inventory_requirement', - }) - return updatedTask - } - - const reserved = await reserveInventory({ - skuCode: inventorySkuCode, - taskId: task.id, - credentialType: primaryRequirement.credentialType, - roleKey: primaryRequirement.roleKey || 'primary_code', - }) - - if (!reserved) { - return updateDeliveryTask(task.id, { - task_status: 'waiting_inventory', - inventory_status: 'pending', - last_error: '库存不足,等待可用库存凭据', - updated_at: now, - }) - } - - const claimToken = await createClaimToken(task.id) - - return updateDeliveryTask(task.id, { - task_status: 'link_generated', - inventory_status: 'reserved', - claim_token: claimToken.token, - claim_expires_at: claimToken.expired_at, - user_action_status: 'pending_claim', - last_error: '', - updated_at: now, - }) + return task } function resolvePaidTaskStatus(profile: FulfillmentBindingLike | null | undefined): string { - if (Boolean(profile?.requires_claim)) { - return 'paid' - } - - if (isKuaishouCloudExecutor(profile?.executor_key)) { - return 'pending_binding_prepare' - } - - if (String(profile?.executor_key || '').trim() === 'manual_dispatch') { - return 'manual_review' - } - - return 'paid' + return resolveInitialPaidTaskStatus(profile) } function parseTaskContext(task: { context_json?: unknown } | null | undefined): TaskContext { @@ -466,7 +366,9 @@ function parseTaskContext(task: { context_json?: unknown } | null | undefined): try { const parsed = JSON.parse(String(value || '{}')) - return parsed && typeof parsed === 'object' && !Array.isArray(parsed) ? parsed as TaskContext : {} + return parsed && typeof parsed === 'object' && !Array.isArray(parsed) + ? (parsed as TaskContext) + : {} } catch { return {} } @@ -483,12 +385,160 @@ function parseJsonObject(value: unknown): JsonObject { try { const parsed = JSON.parse(String(value || '{}')) - return parsed && typeof parsed === 'object' && !Array.isArray(parsed) ? parsed as JsonObject : {} + return parsed && typeof parsed === 'object' && !Array.isArray(parsed) + ? (parsed as JsonObject) + : {} } catch { return {} } } +async function resolveDynamicCloudtentaclesProfile( + item: OrderItemRow, + getProfileByKey: (profileKey: string) => Promise, +): Promise { + const snapshot = parseJsonObject(item.item_snapshot_json) + const cloudtentacles = parseJsonObject(snapshot.cloudtentacles) + const cloudSourceKeys = normalizeStringArray(cloudtentacles.cloudSourceKeys) + const deliveryItems = normalizeCloudDeliveryItems({ + deliveryItems: cloudtentacles.deliveryItems, + skuId: cloudtentacles.cloudSkuId, + skuName: cloudtentacles.cloudSkuName || item.sku_name, + }) + const primaryDeliveryItem = deliveryItems[0] || { + cloudSkuId: 0, + cloudSkuName: '', + quantity: 1, + } + + if ( + !primaryDeliveryItem.cloudSkuId || + !primaryDeliveryItem.cloudSkuName || + cloudSourceKeys.length === 0 + ) { + return null + } + + const profile = await getProfileByKey('kuaishou_ct_assisted') + if (!profile) { + return null + } + + return { + ...profile, + profile_id: Number(profile.id || profile.profile_id || 0), + profile_key: 'kuaishou_ct_assisted', + profile_name: String(profile.profile_name || profile.name || '快手 cloud 履约').trim(), + executor_key: 'kuaishou_ct_assisted', + requires_claim: false, + auto_dispatch: false, + config_json: { + flowType: 'kuaishou_cloud_fulfillment', + configId: `${String(cloudtentacles.matchMode || 'cloudtentacles_name').trim()}:${String(cloudtentacles.normalizedProductName || primaryDeliveryItem.cloudSkuId).trim()}`, + cloudtentacles: { + cloudSourceKeys, + skuId: primaryDeliveryItem.cloudSkuId, + skuName: primaryDeliveryItem.cloudSkuName, + resolvedSourceKey: + cloudSourceKeys.length === 1 + ? String(cloudtentacles.resolvedSourceKey || cloudSourceKeys[0] || '').trim() + : '', + deliveryItems, + vnKey: '1', + autoBuyEnabled: true, + minAssetReserve: 0, + autoReturnNumberAfterDispatch: true, + }, + kuaishouConsume: { + shopId: String(snapshot.shopId || '').trim(), + shopName: String(snapshot.shopName || '').trim(), + autoConsumeAfterDispatch: false, + }, + notes: + cloudtentacles.matchMode === 'cloudtentacles_override' + ? '91卡券商品名命中 cloudtentacles 覆盖规则' + : '91卡券商品名自动匹配 cloudtentacles 商品', + }, + } +} + +function normalizeStringArray(value: unknown): string[] { + if (!Array.isArray(value)) { + return [] + } + + return Array.from(new Set(value.map((item) => String(item || '').trim()).filter(Boolean))) +} + +function normalizeCloudDeliveryItems(value: JsonObject): Array<{ + cloudSkuId: number + cloudSkuName: string + quantity: number +}> { + const rawItems = Array.isArray(value.deliveryItems) ? value.deliveryItems : [] + const items = rawItems + .map((item) => normalizeCloudDeliveryItem(item)) + .filter((item): item is { cloudSkuId: number; cloudSkuName: string; quantity: number } => + Boolean(item), + ) + + if (items.length > 0) { + return mergeCloudDeliveryItems(items) + } + + const cloudSkuId = Number(value.skuId || 0) || 0 + if (!cloudSkuId) { + return [] + } + + return [ + { + cloudSkuId, + cloudSkuName: String(value.skuName || '').trim(), + quantity: 1, + }, + ] +} + +function normalizeCloudDeliveryItem(value: unknown) { + const source = + value && typeof value === 'object' && !Array.isArray(value) ? (value as JsonObject) : {} + const cloudSkuId = Number(source.cloudSkuId || source.skuId || 0) || 0 + if (!Number.isInteger(cloudSkuId) || cloudSkuId <= 0) { + return null + } + + const quantity = Number(source.quantity || 1) || 1 + return { + cloudSkuId, + cloudSkuName: String(source.cloudSkuName || source.skuName || '').trim(), + quantity: Number.isInteger(quantity) && quantity > 0 ? quantity : 1, + } +} + +function mergeCloudDeliveryItems( + items: Array<{ cloudSkuId: number; cloudSkuName: string; quantity: number }>, +) { + const merged = new Map() + + for (const item of items) { + const existing = merged.get(item.cloudSkuId) + if (existing) { + existing.quantity += item.quantity + existing.cloudSkuName = existing.cloudSkuName || item.cloudSkuName + continue + } + + merged.set(item.cloudSkuId, { ...item }) + } + + return Array.from(merged.values()) +} + +function isTaskRow(task: TaskRow | DeliveryTaskRow | null | undefined): task is TaskRow { + return Boolean(task && Number(task.id || 0) > 0) +} + function isKuaishouCloudExecutor(value: unknown): boolean { return String(value || '').trim() === 'kuaishou_ct_assisted' } diff --git a/apps/backend/src/services/order/fulfillment-binding-config-service.test.ts b/apps/backend/src/services/order/fulfillment-binding-config-service.test.ts deleted file mode 100644 index a886995c..00000000 --- a/apps/backend/src/services/order/fulfillment-binding-config-service.test.ts +++ /dev/null @@ -1,52 +0,0 @@ -import test from 'node:test' -import assert from 'node:assert/strict' - -import { - filterLegacyOrderFulfillmentBindings, - isKuaishouCloudFulfillmentBinding, -} from './fulfillment-binding-config-service.js' - -test('isKuaishouCloudFulfillmentBinding only marks 91kaquan kuaishou rules as cloud rules', () => { - assert.equal( - isKuaishouCloudFulfillmentBinding({ - provider: '91kaquan', - platform: 'kuaishou', - }), - true, - ) - - assert.equal( - isKuaishouCloudFulfillmentBinding({ - provider: 'agiso', - platform: 'xianyu', - }), - false, - ) -}) - -test('filterLegacyOrderFulfillmentBindings keeps agiso rules and removes 91kaquan kuaishou rules', () => { - assert.deepEqual( - filterLegacyOrderFulfillmentBindings([ - { - provider: 'agiso', - platform: 'xianyu', - shopId: 'shop-1', - skuCode: 'sku-a', - }, - { - provider: '91kaquan', - platform: 'kuaishou', - shopId: '91kaquan', - skuCode: 'sku-b', - }, - ]), - [ - { - provider: 'agiso', - platform: 'xianyu', - shopId: 'shop-1', - skuCode: 'sku-a', - }, - ], - ) -}) diff --git a/apps/backend/src/services/order/fulfillment-binding-config-service.ts b/apps/backend/src/services/order/fulfillment-binding-config-service.ts deleted file mode 100644 index ed8511fa..00000000 --- a/apps/backend/src/services/order/fulfillment-binding-config-service.ts +++ /dev/null @@ -1,129 +0,0 @@ -import path from 'node:path' - -import { PROJECT_ROOT } from '../../config/runtime.js' -import { readJsonFile, writeJsonFile } from '../../utils/json-file-store.js' - -const ORDER_FULFILLMENT_BINDINGS_FILE_PATH = path.join(PROJECT_ROOT, 'data', 'order-fulfillment-bindings.json') - -type JsonObject = Record - -export function getOrderFulfillmentBindingsFilePath() { - return ORDER_FULFILLMENT_BINDINGS_FILE_PATH -} - -export function getOrderFulfillmentBindingConfigs() { - return loadOrderFulfillmentBindingConfigsFromFile() -} - -export function getLegacyOrderFulfillmentBindingConfigs() { - return filterLegacyOrderFulfillmentBindings(getOrderFulfillmentBindingConfigs()) -} - -export function saveOrderFulfillmentBindingConfigs(rawValue) { - return writeJsonFile( - ORDER_FULFILLMENT_BINDINGS_FILE_PATH, - rawValue, - normalizeOrderFulfillmentBindingConfigs, - ) -} - -function loadOrderFulfillmentBindingConfigsFromFile() { - return readJsonFile( - ORDER_FULFILLMENT_BINDINGS_FILE_PATH, - [], - normalizeOrderFulfillmentBindingConfigs, - ) -} - -function normalizeOrderFulfillmentBindingConfigs(rawValue) { - if (!Array.isArray(rawValue)) { - return [] - } - - return rawValue - .map((item) => normalizeOrderFulfillmentBinding(item)) - .filter(Boolean) -} - -export function filterLegacyOrderFulfillmentBindings(bindings: any[] = []) { - return (Array.isArray(bindings) ? bindings : []).filter((item) => !isKuaishouCloudFulfillmentBinding(item)) -} - -export function isKuaishouCloudFulfillmentBinding(binding: JsonObject = {}) { - return String(binding?.provider || '').trim() === '91kaquan' - && String(binding?.platform || '').trim() === 'kuaishou' -} - -function normalizeOrderFulfillmentBinding(rawValue) { - if (!isPlainObject(rawValue)) { - return null - } - - const provider = String(rawValue.provider || 'agiso').trim() || 'agiso' - const platform = String(rawValue.platform || '').trim() - const shopId = String(rawValue.shopId || '').trim() - const shopName = String(rawValue.shopName || '').trim() - const skuCode = String(rawValue.skuCode || '').trim() - const skuName = String(rawValue.skuName || '').trim() - const profileKey = String(rawValue.profileKey || '').trim() || 'manual_review' - const priority = normalizePriority(rawValue.priority) - const enabled = rawValue.enabled !== false - const config = normalizeJsonObject(rawValue.config) - const match = normalizeOrderFulfillmentMatch(rawValue.match) - - if (!skuCode || !match) { - return null - } - - return { - provider, - platform, - shopId, - shopName, - skuCode, - skuName, - profileKey, - enabled, - priority, - config, - match, - } -} - -function normalizeOrderFulfillmentMatch(rawValue) { - if (!isPlainObject(rawValue)) { - return null - } - - const externalSkuCode = String(rawValue.externalSkuCode || '').trim() - const externalItemId = String(rawValue.externalItemId || '').trim() - const externalSkuName = String(rawValue.externalSkuName || '').trim() - - if (!externalSkuCode && !externalItemId && !externalSkuName) { - return null - } - - return { - externalSkuCode, - externalItemId, - externalSkuName, - config: normalizeJsonObject(rawValue.config), - } -} - -function normalizePriority(value) { - const parsed = Number(value) - if (!Number.isFinite(parsed)) { - return 100 - } - - return Math.max(1, Math.round(parsed)) -} - -function normalizeJsonObject(value) { - return isPlainObject(value) ? value : {} -} - -function isPlainObject(value) { - return Object.prototype.toString.call(value) === '[object Object]' -} diff --git a/apps/backend/src/services/order/inventory-service.test.ts b/apps/backend/src/services/order/inventory-service.test.ts deleted file mode 100644 index 0fa76a6c..00000000 --- a/apps/backend/src/services/order/inventory-service.test.ts +++ /dev/null @@ -1,110 +0,0 @@ -import test from 'node:test' -import assert from 'node:assert/strict' - -import { - releaseReservedInventoryItemsWithDeps, - reserveInventoryForTaskWithDeps, -} from './inventory-service.js' - -test('reserveInventoryForTaskWithDeps returns null when skuCode is missing', async () => { - let findCalled = false - - const result = await reserveInventoryForTaskWithDeps( - { skuCode: '', taskId: 12 }, - { - findFirstAvailableInventoryItemBySkuCode: async () => { - findCalled = true - return null - }, - }, - ) - - assert.equal(result, null) - assert.equal(findCalled, false) -}) - -test('reserveInventoryForTaskWithDeps reserves first available inventory item with defaults', async () => { - const calls = [] - const result = await reserveInventoryForTaskWithDeps( - { skuCode: 'dnf-cdk-a', taskId: 88 }, - { - findFirstAvailableInventoryItemBySkuCode: async (skuCode, credentialType, inventoryGroupCodes) => { - calls.push(['find', skuCode, credentialType, inventoryGroupCodes]) - return { id: 321 } - }, - assignReservedInventoryItem: async (inventoryItemId, taskId, updatedAt, roleKey) => { - calls.push(['assign', inventoryItemId, taskId, updatedAt, roleKey]) - return { id: inventoryItemId, reserved_by_task_id: taskId, role_key: roleKey, updated_at: updatedAt } - }, - nowIso: () => '2026-04-14T12:00:00.000Z', - }, - ) - - assert.deepEqual(calls, [ - ['find', 'dnf-cdk-a', 'tencent_code', null], - ['assign', 321, 88, '2026-04-14T12:00:00.000Z', 'primary_code'], - ]) - assert.equal(result?.id, 321) - assert.equal(result?.reserved_by_task_id, 88) -}) - -test('reserveInventoryForTaskWithDeps forwards inventory group filters', async () => { - const calls = [] - - await reserveInventoryForTaskWithDeps( - { skuCode: 'dnf-cdk-a', taskId: 88, inventoryGroupCodes: ['A组', 'B组'] }, - { - findFirstAvailableInventoryItemBySkuCode: async (skuCode, credentialType, inventoryGroupCodes) => { - calls.push(['find', skuCode, credentialType, inventoryGroupCodes]) - return { id: 321 } - }, - assignReservedInventoryItem: async () => ({ id: 321 }), - nowIso: () => '2026-04-14T12:00:00.000Z', - }, - ) - - assert.deepEqual(calls, [ - ['find', 'dnf-cdk-a', 'tencent_code', ['A组', 'B组']], - ]) -}) - -test('reserveInventoryForTaskWithDeps returns null when restricted inventory groups are empty', async () => { - let findCalled = false - - const result = await reserveInventoryForTaskWithDeps( - { skuCode: 'dnf-cdk-a', taskId: 12, inventoryGroupCodes: [] }, - { - findFirstAvailableInventoryItemBySkuCode: async () => { - findCalled = true - return null - }, - }, - ) - - assert.equal(result, null) - assert.equal(findCalled, false) -}) - -test('releaseReservedInventoryItemsWithDeps skips empty ids and keeps successful releases only', async () => { - const calls = [] - const result = await releaseReservedInventoryItemsWithDeps( - [0, null, 11, 12, '', 13], - '2026-04-14T13:00:00.000Z', - { - releaseReservedInventoryItem: async (inventoryItemId, updatedAt) => { - calls.push([inventoryItemId, updatedAt]) - return inventoryItemId === 12 ? null : { id: inventoryItemId } - }, - }, - ) - - assert.deepEqual(calls, [ - [11, '2026-04-14T13:00:00.000Z'], - [12, '2026-04-14T13:00:00.000Z'], - [13, '2026-04-14T13:00:00.000Z'], - ]) - assert.deepEqual(result, [ - { id: 11 }, - { id: 13 }, - ]) -}) diff --git a/apps/backend/src/services/order/inventory-service.ts b/apps/backend/src/services/order/inventory-service.ts deleted file mode 100644 index e9c59aff..00000000 --- a/apps/backend/src/services/order/inventory-service.ts +++ /dev/null @@ -1,122 +0,0 @@ -import { - assignReservedInventoryItem, - findFirstAvailableInventoryItemBySkuCode, - releaseReservedInventoryItem, -} from '../../repositories/inventory-repo.js' -import type { InventoryItemRow } from '../../types/repository-rows.js' -import { nowIso } from '../../utils/time.js' - -type InventoryItemId = number | string | null | undefined -type InventoryGroupCodesInput = string | string[] | null | undefined - -type ReserveInventoryForTaskInput = { - skuCode?: string - taskId: number | string - credentialType?: string - roleKey?: string - inventoryGroupCodes?: InventoryGroupCodesInput -} - -type ReserveInventoryForTaskDeps = { - assignReservedInventoryItem?: ( - inventoryItemId: number | string, - taskId: number | string, - updatedAt: string, - roleKey?: string, - ) => Promise - findFirstAvailableInventoryItemBySkuCode?: ( - skuCode: string, - credentialType?: string, - inventoryGroupCodes?: InventoryGroupCodesInput, - ) => Promise - nowIso?: () => string -} - -type ReleaseReservedInventoryItemsDeps = { - releaseReservedInventoryItem?: ( - inventoryItemId: number | string, - updatedAt: string, - ) => Promise -} - -export async function reserveInventoryForTask({ - skuCode, - taskId, - credentialType = 'tencent_code', - roleKey = 'primary_code', - inventoryGroupCodes = null, -}: ReserveInventoryForTaskInput): Promise { - return reserveInventoryForTaskWithDeps( - { skuCode, taskId, credentialType, roleKey, inventoryGroupCodes }, - { - assignReservedInventoryItem, - findFirstAvailableInventoryItemBySkuCode, - nowIso, - }, - ) -} - -export async function reserveInventoryForTaskWithDeps( - { - skuCode, - taskId, - credentialType = 'tencent_code', - roleKey = 'primary_code', - inventoryGroupCodes = null, - }: ReserveInventoryForTaskInput, - { - assignReservedInventoryItem: assignReserved = assignReservedInventoryItem, - findFirstAvailableInventoryItemBySkuCode: findAvailable = findFirstAvailableInventoryItemBySkuCode, - nowIso: getNowIso = nowIso, - }: ReserveInventoryForTaskDeps = {}, -): Promise { - if (!skuCode) { - return null - } - - if (Array.isArray(inventoryGroupCodes) && inventoryGroupCodes.length === 0) { - return null - } - - const available = await findAvailable(skuCode, credentialType, inventoryGroupCodes) - - if (!available) { - return null - } - - return assignReserved(available.id, taskId, getNowIso(), roleKey) -} - -export async function releaseReservedInventoryItems( - inventoryItemIds: InventoryItemId[] = [], - updatedAt = nowIso(), -): Promise { - return releaseReservedInventoryItemsWithDeps( - inventoryItemIds, - updatedAt, - { releaseReservedInventoryItem }, - ) -} - -export async function releaseReservedInventoryItemsWithDeps( - inventoryItemIds: InventoryItemId[] = [], - updatedAt = nowIso(), - { - releaseReservedInventoryItem: releaseReserved = releaseReservedInventoryItem, - }: ReleaseReservedInventoryItemsDeps = {}, -): Promise { - const released: InventoryItemRow[] = [] - - for (const inventoryItemId of inventoryItemIds) { - if (!inventoryItemId) { - continue - } - - const item = await releaseReserved(inventoryItemId, updatedAt) - if (item) { - released.push(item) - } - } - - return released -} diff --git a/apps/backend/src/services/order/kuaishou-cloud-fulfillment-config-service.test.ts b/apps/backend/src/services/order/kuaishou-cloud-fulfillment-config-service.test.ts deleted file mode 100644 index 080333a6..00000000 --- a/apps/backend/src/services/order/kuaishou-cloud-fulfillment-config-service.test.ts +++ /dev/null @@ -1,65 +0,0 @@ -import test from 'node:test' -import assert from 'node:assert/strict' - -import { mapKuaishouCloudFulfillmentItemsToBindings } from './kuaishou-cloud-fulfillment-config-service.js' - -test('mapKuaishouCloudFulfillmentItemsToBindings maps 91kaquan rules', () => { - assert.deepEqual( - mapKuaishouCloudFulfillmentItemsToBindings({ - items: [ - { - enabled: true, - provider: '91kaquan', - platform: 'kuaishou', - shopId: '91kaquan', - internalSkuCode: 'sku-1', - internalSkuName: '内部商品', - externalSkuCode: '952', - externalItemId: '952', - externalSkuName: '测试2', - cloudSkuId: 28, - }, - ], - }), - [ - { - provider: '91kaquan', - platform: 'kuaishou', - shopId: '91kaquan', - skuCode: 'sku-1', - skuName: '内部商品', - profileKey: 'kuaishou_ct_assisted', - enabled: true, - priority: 100, - config: { - flowType: 'kuaishou_cloud_fulfillment', - configId: '', - cloudtentacles: { - cloudSourceKey: 'default', - cloudSourceKeyFallbacks: [], - skuId: 28, - skuName: '', - vnKey: '1', - autoBuyEnabled: true, - minAssetReserve: 0, - autoReturnNumberAfterDispatch: false, - }, - kuaishouConsume: { - shopId: '', - shopName: '', - autoConsumeAfterDispatch: false, - }, - notes: '', - }, - match: { - externalSkuCode: '952', - externalItemId: '952', - externalSkuName: '测试2', - config: { - resolvedSkuName: '内部商品', - }, - }, - }, - ], - ) -}) diff --git a/apps/backend/src/services/order/kuaishou-cloud-fulfillment-config-service.ts b/apps/backend/src/services/order/kuaishou-cloud-fulfillment-config-service.ts deleted file mode 100644 index dea4e65d..00000000 --- a/apps/backend/src/services/order/kuaishou-cloud-fulfillment-config-service.ts +++ /dev/null @@ -1,215 +0,0 @@ -import path from "node:path"; - -import { PROJECT_ROOT } from "../../config/runtime.js"; -import { readJsonFile, writeJsonFile } from "../../utils/json-file-store.js"; -import { resolveKuaishouEticketShopConfig } from "../platforms/kuaishou-eticket/source-config-service.js"; - -const KUAISHOU_CLOUD_FULFILLMENT_FILE_PATH = path.join( - PROJECT_ROOT, - "data", - "kuaishou-cloud-fulfillment.json" -); - -type JsonObject = Record; - -export function getKuaishouCloudFulfillmentFilePath() { - return KUAISHOU_CLOUD_FULFILLMENT_FILE_PATH; -} - -export function getKuaishouCloudFulfillmentConfig() { - return loadKuaishouCloudFulfillmentConfigFromFile(); -} - -export function saveKuaishouCloudFulfillmentConfig(rawValue) { - return writeJsonFile( - KUAISHOU_CLOUD_FULFILLMENT_FILE_PATH, - rawValue, - normalizeKuaishouCloudFulfillmentConfig - ); -} - -export function mapKuaishouCloudFulfillmentItemsToBindings(config: JsonObject = {}) { - const items = Array.isArray(config.items) ? config.items : []; - - return items - .filter((item) => item && item.enabled !== false) - .map((item) => ({ - provider: String(item.provider || "91kaquan").trim() || "91kaquan", - platform: String(item.platform || "kuaishou").trim() || "kuaishou", - shopId: String(item.shopId || "").trim(), - skuCode: String(item.internalSkuCode || "").trim(), - skuName: String(item.internalSkuName || "").trim(), - profileKey: "kuaishou_ct_assisted", - enabled: item.enabled !== false, - priority: normalizePriority(item.priority), - config: { - flowType: "kuaishou_cloud_fulfillment", - configId: String(item.id || "").trim(), - cloudtentacles: { - cloudSourceKey: - String(item.cloudSourceKey || "default").trim() || "default", - cloudSourceKeyFallbacks: Array.isArray(item.cloudSourceKeyFallbacks) - ? item.cloudSourceKeyFallbacks - : [], - skuId: normalizePositiveInteger(item.cloudSkuId), - skuName: String(item.cloudSkuName || "").trim(), - vnKey: "1", - autoBuyEnabled: item.autoBuyEnabled !== false, - minAssetReserve: normalizeNonNegativeInteger(item.minAssetReserve, 0), - autoReturnNumberAfterDispatch: - item.autoReturnNumberAfterDispatch === true, - }, - kuaishouConsume: { - shopId: String(item.kuaishouConsumeShopId || "").trim(), - shopName: String(item.kuaishouConsumeShopName || "").trim(), - autoConsumeAfterDispatch: item.autoConsumeAfterDispatch === true, - }, - notes: String(item.notes || "").trim(), - }, - match: { - externalSkuCode: String(item.externalSkuCode || "").trim(), - externalItemId: String(item.externalItemId || "").trim(), - externalSkuName: String(item.externalSkuName || "").trim(), - config: { - resolvedSkuName: String( - item.resolvedSkuName || item.internalSkuName || "" - ).trim(), - }, - }, - })) - .filter( - (item) => - item.skuCode && - (item.match.externalSkuCode || - item.match.externalItemId || - item.match.externalSkuName) - ); -} - -function loadKuaishouCloudFulfillmentConfigFromFile() { - return readJsonFile( - KUAISHOU_CLOUD_FULFILLMENT_FILE_PATH, - () => normalizeKuaishouCloudFulfillmentConfig({}), - normalizeKuaishouCloudFulfillmentConfig - ); -} - -function normalizeKuaishouCloudFulfillmentConfig(rawValue) { - const source = isPlainObject(rawValue) ? rawValue : {}; - return { - enabled: source.enabled !== false, - items: Array.isArray(source.items) - ? source.items - .map((item) => normalizeKuaishouCloudFulfillmentItem(item)) - .filter(Boolean) - : [], - }; -} - -function normalizeKuaishouCloudFulfillmentItem(rawValue) { - if (!isPlainObject(rawValue)) { - return null; - } - - const internalSkuCode = String(rawValue.internalSkuCode || "").trim(); - const cloudSkuId = normalizePositiveInteger(rawValue.cloudSkuId); - const externalSkuCode = String(rawValue.externalSkuCode || "").trim(); - const externalItemId = String(rawValue.externalItemId || "").trim(); - const externalSkuName = String(rawValue.externalSkuName || "").trim(); - const kuaishouConsumeShopId = String( - rawValue.kuaishouConsumeShopId || "" - ).trim(); - const kuaishouConsumeShopName = String( - rawValue.kuaishouConsumeShopName || "" - ).trim(); - const kuaishouShopConfig = resolveKuaishouEticketShopConfig({ - shopId: kuaishouConsumeShopId, - shopName: kuaishouConsumeShopName, - }); - - if (!internalSkuCode) { - return null; - } - - if (!cloudSkuId) { - return null; - } - - if (!externalSkuCode && !externalItemId && !externalSkuName) { - return null; - } - - return { - id: String(rawValue.id || internalSkuCode).trim() || internalSkuCode, - enabled: rawValue.enabled !== false, - priority: normalizePriority(rawValue.priority), - provider: String(rawValue.provider || "91kaquan").trim() || "91kaquan", - platform: String(rawValue.platform || "kuaishou").trim() || "kuaishou", - shopId: String(rawValue.shopId || "").trim(), - internalSkuCode, - internalSkuName: - String(rawValue.internalSkuName || "").trim() || internalSkuCode, - externalSkuCode, - externalItemId, - externalSkuName, - resolvedSkuName: String( - rawValue.resolvedSkuName || rawValue.internalSkuName || "" - ).trim(), - cloudSourceKey: - String(rawValue.cloudSourceKey || "default").trim() || "default", - cloudSourceKeyFallbacks: normalizeStringArray( - rawValue.cloudSourceKeyFallbacks - ), - cloudSkuId, - cloudSkuName: String(rawValue.cloudSkuName || "").trim(), - vnKey: "1", - autoBuyEnabled: rawValue.autoBuyEnabled !== false, - minAssetReserve: normalizeNonNegativeInteger(rawValue.minAssetReserve, 0), - autoReturnNumberAfterDispatch: - rawValue.autoReturnNumberAfterDispatch === true, - autoConsumeAfterDispatch: rawValue.autoConsumeAfterDispatch === true, - kuaishouConsumeShopId: String( - kuaishouShopConfig?.shopId || kuaishouConsumeShopId - ).trim(), - kuaishouConsumeShopName: String( - kuaishouShopConfig?.kshopName || kuaishouConsumeShopName - ).trim(), - notes: String(rawValue.notes || "").trim(), - }; -} - -function normalizePositiveInteger(value) { - const parsed = Number(value); - return Number.isInteger(parsed) && parsed > 0 ? parsed : 0; -} - -function normalizeNonNegativeInteger(value, fallback) { - const parsed = Number(value); - return Number.isInteger(parsed) && parsed >= 0 ? parsed : fallback; -} - -function normalizePriority(value) { - const parsed = Number(value); - if (!Number.isFinite(parsed)) { - return 100; - } - - return Math.max(1, Math.round(parsed)); -} -function isPlainObject(value) { - return Object.prototype.toString.call(value) === "[object Object]"; -} - -function normalizeStringArray(value) { - if (Array.isArray(value)) { - return value.map((v) => String(v || "").trim()).filter(Boolean); - } - // 兼容旧格式:字符串用逗号分隔 - if (typeof value === "string") { - return value - .split(",") - .map((v) => v.trim()) - .filter(Boolean); - } - return []; -} diff --git a/apps/backend/src/services/order/order-service.test.ts b/apps/backend/src/services/order/order-service.test.ts index 50be87b9..c1ba1b3c 100644 --- a/apps/backend/src/services/order/order-service.test.ts +++ b/apps/backend/src/services/order/order-service.test.ts @@ -1,10 +1,10 @@ import test from 'node:test' import assert from 'node:assert/strict' -import { mergeWebhookOrderState } from './order-service.js' +import { mergeSourceOrderState } from './order-service.js' -test('mergeWebhookOrderState keeps paid state when a late trade_create webhook arrives', () => { - const merged = mergeWebhookOrderState( +test('mergeSourceOrderState keeps paid state when a late created source event arrives', () => { + const merged = mergeSourceOrderState( { order_status: 'paid', pay_status: 'paid', @@ -22,8 +22,8 @@ test('mergeWebhookOrderState keeps paid state when a late trade_create webhook a assert.equal(merged.paidAt, '2026-04-14T10:06:10.000Z') }) -test('mergeWebhookOrderState upgrades unpaid orders when payment webhook arrives later', () => { - const merged = mergeWebhookOrderState( +test('mergeSourceOrderState upgrades unpaid orders when payment source event arrives later', () => { + const merged = mergeSourceOrderState( { order_status: 'created', pay_status: 'unpaid', @@ -41,8 +41,8 @@ test('mergeWebhookOrderState upgrades unpaid orders when payment webhook arrives assert.equal(merged.paidAt, '2026-04-14T10:06:12.000Z') }) -test('mergeWebhookOrderState preserves refunded status over older paid webhook', () => { - const merged = mergeWebhookOrderState( +test('mergeSourceOrderState preserves refunded status over older paid source event', () => { + const merged = mergeSourceOrderState( { order_status: 'refunded', pay_status: 'refunded', diff --git a/apps/backend/src/services/order/order-service.ts b/apps/backend/src/services/order/order-service.ts index b1290a4d..e82c6134 100644 --- a/apps/backend/src/services/order/order-service.ts +++ b/apps/backend/src/services/order/order-service.ts @@ -1,18 +1,15 @@ import { createOrder, findOrderByPlatformOrderId, - findOrderByPlatformOrderIdCandidates, updateOrder, } from '../../repositories/order-repo.js' import { replaceOrderItems } from '../../repositories/order-item-repo.js' -import { getClaimTokenById } from '../../repositories/claim-token-repo.js' -import { buildClaimUrl } from '../claim/claim-service.js' import { syncDeliveryTasksForOrder } from './delivery-task-service.js' -import { ensureAgisoXianyuClaimMessageDeliveredForTask } from '../platforms/agiso/xianyu/message-service.js' import { resolveOrderItemForFulfillment } from './product-match-service.js' import { nowIso } from '../../utils/time.js' -import { logWebhook } from '../../utils/logger.js' -import type { OrderItemRow, OrderRow, TaskRow } from '../../types/repository-rows.js' +import { logIntegration } from '../../utils/logger.js' +import { createHttpError } from '../../utils/http.js' +import type { OrderItemRow, OrderRow, TaskRow } from '../../types/repository/rows.js' type SourceOrderItem = { quantity?: number @@ -34,7 +31,6 @@ type SourceOrderEvent = { provider: string platform: string shopId: string - shopIdAliases?: string[] shopName: string platformOrderId: string orderStatus: string @@ -53,18 +49,12 @@ type UpsertOrderSourceOptions = { sourceLabel?: string } -type MessageDeliveryResult = { - taskId: number - [key: string]: unknown -} - type UpsertOrderIgnoredResult = { ignored: true ignoreReason: string order: null orderItems: [] tasks: [] - messageDeliveries: [] } type UpsertOrderResult = { @@ -73,7 +63,6 @@ type UpsertOrderResult = { order: OrderRow orderItems: OrderItemRow[] tasks: TaskRow[] - messageDeliveries: MessageDeliveryResult[] } | UpsertOrderIgnoredResult type OrderStateInput = { @@ -93,29 +82,19 @@ type OrderStateMergeResult = { type StatusPriorityMap = Record -export async function upsertOrderFromWebhook(event: SourceOrderEvent): Promise { - return upsertOrderFromSource(event, { sourceLabel: 'webhook' }) -} - export async function upsertOrderFromSource( event: SourceOrderEvent, { sourceLabel = 'source' }: UpsertOrderSourceOptions = {}, ): Promise { const now = nowIso() - const exactExisting = await findOrderByPlatformOrderId({ + const existing = await findOrderByPlatformOrderId({ provider: event.provider, platform: event.platform, shopId: event.shopId, platformOrderId: event.platformOrderId, }) - const existing = exactExisting || await findOrderByPlatformOrderIdCandidates({ - provider: event.provider, - platform: event.platform, - shopIds: resolveEventShopIdCandidates(event), - platformOrderId: event.platformOrderId, - }) - logWebhook('[order-service]', `开始处理 ${sourceLabel} 订单 upsert`, { + logIntegration('[order-service]', `开始处理 ${sourceLabel} 订单 upsert`, { provider: event.provider, platform: event.platform, shopId: event.shopId, @@ -128,15 +107,13 @@ export async function upsertOrderFromSource( event.items.map((item) => resolveOrderItemForFulfillment({ provider: event.provider, platform: event.platform, - shopId: event.shopId, - shopIdAliases: event.shopIdAliases, item, })), ) const configuredItems = (resolvedItems as FulfillmentOrderItem[]).filter((item) => item.isConfigured) if (configuredItems.length === 0) { - logWebhook('[order-service]', `${sourceLabel} 订单已忽略:未命中任何已配置履约商品`, { + logIntegration('[order-service]', `${sourceLabel} 订单已忽略:未命中任何已配置履约商品`, { provider: event.provider, platform: event.platform, shopId: event.shopId, @@ -152,7 +129,6 @@ export async function upsertOrderFromSource( order: null, orderItems: [], tasks: [], - messageDeliveries: [], } } @@ -170,9 +146,9 @@ export async function upsertOrderFromSource( totalAmount: event.totalAmount, currency: event.currency, rawPayloadJson: JSON.stringify(event.rawPayload), - paidAt: event.paidAt, + ...(event.paidAt !== undefined ? { paidAt: event.paidAt } : {}), } - const mergedPayload = mergeWebhookOrderState(existing, basePayload) + const mergedPayload = mergeSourceOrderState(existing, basePayload) const order = existing ? await updateOrder(existing.id, { @@ -188,7 +164,10 @@ export async function upsertOrderFromSource( }) if (!order) { - throw new Error('订单写入失败') + throw createHttpError('订单写入失败', { + statusCode: 500, + errorCode: 'order_write_failed', + }) } const orderItems = await replaceOrderItems( @@ -205,9 +184,8 @@ export async function upsertOrderFromSource( ) const tasks = await syncDeliveryTasksForOrder(order, orderItems) - const messageDeliveries: MessageDeliveryResult[] = [] - logWebhook('[order-service]', `${sourceLabel} 订单 upsert 完成`, { + logIntegration('[order-service]', `${sourceLabel} 订单 upsert 完成`, { orderId: order.id, provider: order.provider, platform: order.platform, @@ -219,54 +197,13 @@ export async function upsertOrderFromSource( resolvedSkuCodes: configuredItems.map((item) => item.skuCode), }) - for (const task of tasks) { - const isClaimLinkTask = String(task.task_status || '') === 'link_generated' - || ( - String(task.executor_key || '').trim() === 'kuaishou_ct_assisted' - && ['pending_binding_prepare', 'waiting_binding', 'role_confirmed', 'dispatched_pending_return'].includes(String(task.task_status || '').trim()) - ) - - if ( - event.provider !== 'agiso' - || event.platform !== 'xianyu' - || !isClaimLinkTask - || !task.primary_claim_token_id - ) { - continue - } - - const claimToken = await getClaimTokenById(task.primary_claim_token_id) - if (!claimToken) { - continue - } - - const result = await ensureAgisoXianyuClaimMessageDeliveredForTask({ - order, - task, - claimUrl: buildClaimUrl(claimToken.token), - expiredAt: claimToken.expired_at, - }) - messageDeliveries.push({ - taskId: task.id, - ...result, - }) - } - return { order, orderItems, tasks, - messageDeliveries, } } -function resolveEventShopIdCandidates(event: Pick): string[] { - return [...new Set([ - String(event?.shopId || '').trim(), - ...(Array.isArray(event?.shopIdAliases) ? event.shopIdAliases : []).map((item) => String(item || '').trim()), - ].filter(Boolean))] -} - const ORDER_STATUS_PRIORITY = { created: 0, paid: 1, @@ -281,7 +218,7 @@ const PAY_STATUS_PRIORITY = { refunded: 2, } -export function mergeWebhookOrderState( +export function mergeSourceOrderState( existing: OrderStateInput | null | undefined, incoming: OrderStateInput | null | undefined, ): OrderStateMergeResult { diff --git a/apps/backend/src/services/order/product-match-service.test.ts b/apps/backend/src/services/order/product-match-service.test.ts deleted file mode 100644 index a98c0b7d..00000000 --- a/apps/backend/src/services/order/product-match-service.test.ts +++ /dev/null @@ -1,18 +0,0 @@ -import test from 'node:test' -import assert from 'node:assert/strict' - -import { resolveShopIdCandidates } from './product-match-service.js' - -test('resolveShopIdCandidates merges primary and alias shop ids', () => { - assert.deepEqual( - resolveShopIdCandidates({ - shopId: '10', - shopIdAliases: ['4269276762', '10', ''], - }), - ['10', '4269276762'], - ) -}) - -test('resolveShopIdCandidates preserves wildcard fallback when ids are empty', () => { - assert.deepEqual(resolveShopIdCandidates({}), ['']) -}) diff --git a/apps/backend/src/services/order/product-match-service.ts b/apps/backend/src/services/order/product-match-service.ts index b4ab6a93..4802415d 100644 --- a/apps/backend/src/services/order/product-match-service.ts +++ b/apps/backend/src/services/order/product-match-service.ts @@ -1,5 +1,8 @@ -import { resolveFulfillmentBinding } from '../../repositories/fulfillment-profile-repo.js' -import { resolveProductMatchRule } from '../../repositories/product-match-rule-repo.js' +import { + normalizeCloudtentaclesMatchName, + resolveCloudtentaclesSkuByProductName, + type CloudtentaclesNameMatchResult, +} from './cloudtentacles-name-match-service.js' export type FulfillmentItem = { itemId?: string @@ -17,8 +20,6 @@ export type FulfillmentItem = { type ResolveOrderItemForFulfillmentInput = { provider?: string platform?: string - shopId?: string - shopIdAliases?: string[] item?: FulfillmentItem } @@ -31,9 +32,8 @@ type FulfillmentItemCandidate = { externalSkuCode: string externalSkuName: string externalSkuNameNormalized: string - matchedRule: Awaited> resolvedSkuCode: string - binding: Awaited> + cloudtentaclesNameMatch: CloudtentaclesNameMatchResult | null isConfigured: boolean } @@ -48,23 +48,14 @@ export type ResolvedFulfillmentItem = FulfillmentItem & { isConfigured: boolean } -type ShopIdCandidatesInput = { - shopId?: string - shopIdAliases?: string[] -} - export async function resolveOrderItemForFulfillment({ provider = '', platform = '', - shopId = '', - shopIdAliases = [], item = {}, }: ResolveOrderItemForFulfillmentInput): Promise { const candidate = await resolveConfiguredItemCandidate({ provider, platform, - shopId, - shopIdAliases, item, }) const { @@ -72,18 +63,16 @@ export async function resolveOrderItemForFulfillment({ externalSkuCode, externalSkuName, externalSkuNameNormalized, - matchedRule, - binding, + cloudtentaclesNameMatch, } = candidate const resolvedSkuCode = pickFirstNonEmpty([ - matchedRule?.resolved_sku_code, + cloudtentaclesNameMatch?.cloudSkuName, externalSkuCode, externalItemId, ]) const resolvedSkuName = pickFirstNonEmpty([ - readConfigValue(matchedRule?.config_json, 'resolvedSkuName'), - readConfigValue(matchedRule?.config_json, 'internalProductName'), + cloudtentaclesNameMatch?.cloudSkuName, item.skuName, externalSkuName, resolvedSkuCode, @@ -95,10 +84,22 @@ export async function resolveOrderItemForFulfillment({ externalSkuName, externalSkuNameNormalized, resolvedSkuCode, - matchedProductRuleId: matchedRule ? Number(matchedRule.id) : null, - matchedProductRuleBy: String(matchedRule?.matched_by || '').trim(), - matchedFulfillmentBindingId: binding ? Number(binding.id) : null, - matchedFulfillmentProfileKey: String(binding?.profile_key || '').trim(), + matchMode: cloudtentaclesNameMatch?.matchMode || '', + cloudtentacles: cloudtentaclesNameMatch + ? { + matchMode: cloudtentaclesNameMatch.matchMode, + productName: cloudtentaclesNameMatch.productName, + normalizedProductName: cloudtentaclesNameMatch.normalizedProductName, + cloudSkuId: cloudtentaclesNameMatch.cloudSkuId, + cloudSkuName: cloudtentaclesNameMatch.cloudSkuName, + cloudSkuPrice: cloudtentaclesNameMatch.cloudSkuPrice, + cloudSkuInventory: cloudtentaclesNameMatch.cloudSkuInventory, + cloudSourceKeys: cloudtentaclesNameMatch.cloudSourceKeys, + resolvedSourceKey: cloudtentaclesNameMatch.resolvedSourceKey, + deliveryItems: cloudtentaclesNameMatch.deliveryItems, + skuSnapshot: cloudtentaclesNameMatch.skuSnapshot, + } + : null, isConfigured: candidate.isConfigured, } @@ -118,16 +119,12 @@ export async function resolveOrderItemForFulfillment({ export async function hasConfiguredOrderItems({ provider = '', platform = '', - shopId = '', - shopIdAliases = [], items = [], }: HasConfiguredOrderItemsInput): Promise { const candidates = await Promise.all( (Array.isArray(items) ? items : []).map((item) => resolveConfiguredItemCandidate({ provider, platform, - shopId, - shopIdAliases, item, })), ) @@ -136,13 +133,7 @@ export async function hasConfiguredOrderItems({ } export function normalizeProductName(value: unknown): string { - return String(value || '') - .toLowerCase() - .replace(/[【】\[\]()()]/g, ' ') - .replace(/(自动发货|秒发|极速发货|官方直充|官方充值)/gi, ' ') - .replace(/[^\p{L}\p{N}]+/gu, ' ') - .replace(/\s+/g, ' ') - .trim() + return normalizeCloudtentaclesMatchName(value) } function pickFirstNonEmpty(values: unknown[]): string { @@ -159,8 +150,6 @@ function pickFirstNonEmpty(values: unknown[]): string { async function resolveConfiguredItemCandidate({ provider = '', platform = '', - shopId = '', - shopIdAliases = [], item = {}, }: ResolveOrderItemForFulfillmentInput): Promise { const externalItemId = pickFirstNonEmpty([ @@ -178,44 +167,27 @@ async function resolveConfiguredItemCandidate({ externalSkuCode, ]) const externalSkuNameNormalized = normalizeProductName(externalSkuName) - const shopIdCandidates = resolveShopIdCandidates({ shopId, shopIdAliases }) - let matchedRule = null - let binding = null - - for (const candidateShopId of shopIdCandidates) { - const candidateRule = await resolveProductMatchRule({ - provider, - platform, - shopId: candidateShopId, - externalItemId, - externalSkuCode, - externalSkuNameNormalized, - }) - if (candidateRule) { - matchedRule = candidateRule - } + if (isOpen91KuaishouOrder(provider, platform)) { + const cloudtentaclesNameMatch = await resolveCloudtentaclesSkuByProductName(externalSkuName) const resolvedSkuCode = pickFirstNonEmpty([ - candidateRule?.resolved_sku_code, + cloudtentaclesNameMatch?.cloudSkuName, externalSkuCode, externalItemId, ]) - binding = resolvedSkuCode - ? await resolveFulfillmentBinding({ - skuCode: resolvedSkuCode, - provider, - platform, - shopId: candidateShopId, - }) - : null - if (binding) { - break + return { + externalItemId, + externalSkuCode, + externalSkuName, + externalSkuNameNormalized, + resolvedSkuCode, + cloudtentaclesNameMatch, + isConfigured: Boolean(cloudtentaclesNameMatch), } } const resolvedSkuCode = pickFirstNonEmpty([ - matchedRule?.resolved_sku_code, externalSkuCode, externalItemId, ]) @@ -225,44 +197,15 @@ async function resolveConfiguredItemCandidate({ externalSkuCode, externalSkuName, externalSkuNameNormalized, - matchedRule, resolvedSkuCode, - binding, - isConfigured: Boolean(binding), + cloudtentaclesNameMatch: null, + isConfigured: false, } } -export function resolveShopIdCandidates({ - shopId = '', - shopIdAliases = [], -}: ShopIdCandidatesInput = {}): string[] { - const candidates = [...new Set([ - String(shopId || '').trim(), - ...(Array.isArray(shopIdAliases) ? shopIdAliases : []).map((item) => String(item || '').trim()), - ].filter(Boolean))] - - return candidates.length > 0 ? candidates : [''] -} - -function readConfigValue(rawValue: unknown, key: string): string { - if (!rawValue) { - return '' - } - - const parsed = typeof rawValue === 'string' ? safeParseJson(rawValue) : rawValue - if (!isPlainObject(parsed)) { - return '' - } - - return String(parsed[key] || '').trim() -} - -function safeParseJson(rawValue: unknown): unknown { - try { - return JSON.parse(String(rawValue || '{}')) - } catch { - return null - } +function isOpen91KuaishouOrder(provider: unknown, platform: unknown) { + return String(provider || '').trim() === '91kaquan' + && String(platform || '').trim() === 'kuaishou' } function isPlainObject(value: unknown): value is Record { diff --git a/apps/backend/src/services/order/webhook-service.test.ts b/apps/backend/src/services/order/webhook-service.test.ts deleted file mode 100644 index 8bbf2b9a..00000000 --- a/apps/backend/src/services/order/webhook-service.test.ts +++ /dev/null @@ -1,387 +0,0 @@ -import test from 'node:test' -import assert from 'node:assert/strict' -import crypto from 'node:crypto' - -import { runtimeConfig } from '../../config/runtime.js' -import { - buildWebhookEventInput, - executeAgisoTradeWebhookWithDeps, - parseAgisoTradeRequest, - resolveAgisoTradeIgnoreReason, - shouldEnrichAgisoXianyuTrade, - verifyAgisoSignatureWithSecret, -} from './webhook-service.js' - -function createParsedTrade(patch = {}) { - return { - provider: 'agiso', - platform: 'xianyu', - platformRaw: 'xianyu', - eventType: 'payment_success', - eventKey: 'agiso:xianyu:shop-9:4502259793206016214:payment_success:1713097840', - signatureValid: true, - platformOrderId: '4502259793206016214', - shopId: 'shop-9', - shopName: '咸鱼店铺', - shopIdAliases: ['shop-9'], - orderStatus: 'paid', - payStatus: 'paid', - buyerId: 'buyer-1', - buyerName: '测试买家', - receiverContact: '', - totalAmount: 2550, - currency: 'CNY', - paidAt: '2026-04-14T12:30:40.000Z', - rawPayload: {}, - items: [ - { - skuCode: 'dnf-cdk-a', - skuName: 'DNF礼包', - quantity: 1, - spec: { title: 'DNF礼包' }, - }, - ], - ...patch, - } -} - -test('parseAgisoTradeRequest maps Agiso payment_success webhook into stable order fields', () => { - const rawJson = '{"biz_order_id":4502259793206016214,"seller_id":"shop-9","seller_nick":"咸鱼店铺","buyer_id":"buyer-1","buyer_name":"测试买家","pay_time":"2026-04-14 20:30:40","total_fee":"25.50","currency":"CNY","items":[{"item_id":978102431355,"goods_name":"DNF礼包","quantity":2,"sku":"dnf-cdk-a|商品名称:DNF礼包"}]}' - const timestamp = '1713097840' - const appSecret = String(runtimeConfig.platforms?.agiso?.appSecret || '').trim() - const sign = appSecret - ? crypto.createHash('md5').update(`${appSecret}json${rawJson}timestamp${timestamp}${appSecret}`, 'utf8').digest('hex') - : '' - - const parsed = parseAgisoTradeRequest({ - query: { - aopic: '1', - timestamp, - sign, - }, - body: { - json: rawJson, - fromPlatform: 'xianyu', - }, - }) - - assert.equal(parsed.provider, 'agiso') - assert.equal(parsed.platform, 'xianyu') - assert.equal(parsed.eventType, 'payment_success') - assert.equal(parsed.signatureValid, true) - assert.equal(parsed.platformOrderId, '4502259793206016214') - assert.equal(parsed.shopId, 'shop-9') - assert.equal(parsed.shopName, '咸鱼店铺') - assert.equal(parsed.payStatus, 'paid') - assert.equal(parsed.orderStatus, 'paid') - assert.equal(parsed.totalAmount, 2550) - assert.equal(parsed.items.length, 1) - assert.equal(parsed.items[0]?.skuCode, 'dnf-cdk-a') - assert.equal(parsed.items[0]?.quantity, 2) -}) - -test('shouldEnrichAgisoXianyuTrade only enriches when xianyu trade fields are incomplete', () => { - assert.equal(shouldEnrichAgisoXianyuTrade({ - provider: 'agiso', - platform: 'xianyu', - totalAmount: 0, - buyerName: '', - shopName: '', - items: [], - }), true) - - assert.equal(shouldEnrichAgisoXianyuTrade({ - provider: 'agiso', - platform: 'xianyu', - totalAmount: 2550, - buyerName: '测试买家', - shopName: '咸鱼店铺', - items: [ - { - skuCode: 'dnf-cdk-a', - spec: { - title: 'DNF礼包', - }, - }, - ], - }), false) - - assert.equal(shouldEnrichAgisoXianyuTrade({ - provider: 'agiso', - platform: 'taobao', - totalAmount: 0, - items: [], - }), false) -}) - -test('parseAgisoTradeRequest maps buyer confirm goods webhook into ignored event type', () => { - const rawJson = '{"biz_order_id":2701836516013026052,"seller_id":"shop-9","seller_nick":"大锤商行","buyer_id":"buyer-1","buyer_name":"测试买家","order_status":4,"Status":"TRADE_FINISHED","total_fee":"95.88","items":[{"item_id":978102431355,"goods_name":"DNF礼包","quantity":1,"sku":"dnf-cdk-a|商品名称:DNF礼包"}]}' - const timestamp = '1776096226' - const appSecret = String(runtimeConfig.platforms?.agiso?.appSecret || '').trim() - const sign = appSecret - ? crypto.createHash('md5').update(`${appSecret}json${rawJson}timestamp${timestamp}${appSecret}`, 'utf8').digest('hex') - : '' - - const parsed = parseAgisoTradeRequest({ - query: { - aopic: '16', - timestamp, - sign, - }, - body: { - json: rawJson, - fromPlatform: 'AldsIdle', - }, - }) - - assert.equal(parsed.platform, 'xianyu') - assert.equal(parsed.eventType, 'buyer_confirm_goods') - assert.equal(resolveAgisoTradeIgnoreReason(parsed), 'buyer_confirm_goods') -}) - -test('trade finished payload is still treated as buyer confirm goods when aopic drifts', () => { - const parsed = parseAgisoTradeRequest({ - query: { - aopic: '1', - timestamp: '1776096226', - sign: '', - }, - body: { - json: '{"biz_order_id":2701836516013026052,"order_status":4,"Status":"TRADE_FINISHED"}', - fromPlatform: 'xianyu', - }, - }) - - assert.equal(parsed.eventType, 'buyer_confirm_goods') - assert.equal(resolveAgisoTradeIgnoreReason(parsed), 'buyer_confirm_goods') -}) - -test('verifyAgisoSignatureWithSecret supports documented and legacy signatures', () => { - const appSecret = 'unit-test-secret' - const rawJson = '{"biz_order_id":"4502259793206016214"}' - const timestamp = '1713097840' - const documentedSign = crypto - .createHash('md5') - .update(`${appSecret}json${rawJson}timestamp${timestamp}${appSecret}`, 'utf8') - .digest('hex') - const legacySign = crypto - .createHash('md5') - .update(`${appSecret}${rawJson}${timestamp}`, 'utf8') - .digest('hex') - - assert.equal( - verifyAgisoSignatureWithSecret({ rawJson, timestamp, sign: documentedSign, appSecret }), - true, - ) - assert.equal( - verifyAgisoSignatureWithSecret({ rawJson, timestamp, sign: legacySign, appSecret }), - true, - ) - assert.equal( - verifyAgisoSignatureWithSecret({ rawJson, timestamp, sign: 'bad-sign', appSecret }), - false, - ) -}) - -test('buildWebhookEventInput preserves raw request payload for later replay', () => { - const input = buildWebhookEventInput( - { - headers: { 'x-request-id': 'req-1' }, - query: { timestamp: '1713097840', sign: 'bad-sign' }, - body: { json: '{"biz_order_id":"4502259793206016214"}' }, - }, - { - provider: 'agiso', - platform: 'xianyu', - shopId: 'shop-9', - shopName: '咸鱼店铺', - eventType: 'payment_success', - eventKey: 'agiso:xianyu:4502259793206016214:payment_success', - signatureValid: false, - }, - ) - - assert.equal(input.provider, 'agiso') - assert.equal(input.platform, 'xianyu') - assert.equal(input.eventType, 'payment_success') - assert.equal(input.eventKey, 'agiso:xianyu:4502259793206016214:payment_success') - assert.equal(input.signatureValid, false) - assert.equal(input.processed, false) - assert.equal(input.processError, '') - assert.equal(input.relatedOrderId, null) - assert.deepEqual(JSON.parse(input.headersJson), { 'x-request-id': 'req-1' }) - assert.deepEqual(JSON.parse(input.queryJson), { timestamp: '1713097840', sign: 'bad-sign' }) - assert.deepEqual(JSON.parse(input.bodyJson), { json: '{"biz_order_id":"4502259793206016214"}' }) - assert.match(input.createdAt, /^\d{4}-\d{2}-\d{2}T/) -}) - -test('executeAgisoTradeWebhookWithDeps records invalid signature failure', async () => { - const updates = [] - - await assert.rejects( - () => executeAgisoTradeWebhookWithDeps( - createParsedTrade({ signatureValid: false }), - 701, - { requestId: 'req-invalid-signature' }, - { - updateWebhookEvent: async (webhookEventId, patch) => { - updates.push({ webhookEventId, patch }) - return { id: webhookEventId, ...patch } - }, - hasConfiguredOrderItems: async () => { - throw new Error('should not check configured items when signature is invalid') - }, - }, - ), - /验签失败/, - ) - - assert.deepEqual(updates, [ - { - webhookEventId: 701, - patch: { - processed: false, - process_error: '验签失败', - related_order_id: null, - }, - }, - ]) -}) - -test('executeAgisoTradeWebhookWithDeps ignores unconfigured products before upsert', async () => { - const updates = [] - let upsertCalled = false - - const result = await executeAgisoTradeWebhookWithDeps( - createParsedTrade(), - 702, - { requestId: 'req-unconfigured' }, - { - hasConfiguredOrderItems: async (input) => { - assert.equal(input.provider, 'agiso') - assert.equal(input.platform, 'xianyu') - assert.equal(input.shopId, 'shop-9') - assert.equal(input.items.length, 1) - return false - }, - updateWebhookEvent: async (webhookEventId, patch) => { - updates.push({ webhookEventId, patch }) - return { id: webhookEventId, ...patch } - }, - upsertOrderFromWebhook: async () => { - upsertCalled = true - return {} - }, - }, - ) - - assert.equal(result.accepted, true) - assert.equal(result.ignored, true) - assert.equal(result.ignoreReason, 'unconfigured_product') - assert.equal(result.orderId, null) - assert.equal(upsertCalled, false) - assert.deepEqual(updates, [ - { - webhookEventId: 702, - patch: { - processed: true, - process_error: 'ignored_unconfigured_product', - related_order_id: null, - }, - }, - ]) -}) - -test('executeAgisoTradeWebhookWithDeps enriches incomplete xianyu trade and upserts order', async () => { - const updates = [] - const enrichCalls = [] - const upsertCalls = [] - const parsed = createParsedTrade({ - totalAmount: 0, - buyerName: '', - shopName: '', - items: [ - { - skuCode: 'dnf-cdk-a', - skuName: 'dnf-cdk-a', - quantity: 1, - spec: {}, - }, - ], - }) - - const result = await executeAgisoTradeWebhookWithDeps( - parsed, - 703, - { requestId: 'req-success' }, - { - hasConfiguredOrderItems: async () => true, - enrichAgisoXianyuTradeOrder: async (input, options) => { - enrichCalls.push({ input, options }) - return { - parsed: { - ...input, - totalAmount: 1990, - buyerName: '补查买家', - shopName: '补查店铺', - items: [ - { - skuCode: 'dnf-cdk-a', - skuName: 'DNF礼包', - quantity: 1, - spec: { title: 'DNF礼包' }, - }, - ], - }, - enriched: true, - reason: '', - } - }, - upsertOrderFromWebhook: async (input) => { - upsertCalls.push(input) - return { - ignored: false, - ignoreReason: '', - order: { id: 801 }, - tasks: [ - { id: 901, task_no: 'DT901', task_status: 'paid' }, - ], - messageDeliveries: [{ id: 1001 }], - } - }, - updateWebhookEvent: async (webhookEventId, patch) => { - updates.push({ webhookEventId, patch }) - return { id: webhookEventId, ...patch } - }, - }, - ) - - assert.equal(enrichCalls.length, 1) - assert.equal(enrichCalls[0].options.requestId, 'req-success') - assert.equal(upsertCalls.length, 1) - assert.equal(upsertCalls[0].totalAmount, 1990) - assert.equal(upsertCalls[0].buyerName, '补查买家') - assert.equal(result.accepted, true) - assert.equal(result.ignored, false) - assert.equal(result.enriched, true) - assert.equal(result.orderId, 801) - assert.equal(result.totalAmountFen, 1990) - assert.equal(result.taskCount, 1) - assert.deepEqual(result.tasks, [ - { - taskId: 901, - taskNo: 'DT901', - status: 'paid', - }, - ]) - assert.deepEqual(updates, [ - { - webhookEventId: 703, - patch: { - processed: true, - process_error: '', - related_order_id: 801, - }, - }, - ]) -}) diff --git a/apps/backend/src/services/order/webhook-service.ts b/apps/backend/src/services/order/webhook-service.ts deleted file mode 100644 index 7b8a1628..00000000 --- a/apps/backend/src/services/order/webhook-service.ts +++ /dev/null @@ -1,1016 +0,0 @@ -import crypto from 'node:crypto' - -import { runtimeConfig } from '../../config/runtime.js' -import { createWebhookEvent, updateWebhookEvent } from '../../repositories/webhook-event-repo.js' -import { enrichAgisoXianyuTradeOrder } from '../platforms/agiso/xianyu/order-detail-service.js' -import { - extractAgisoTradeOrderItemSources, - extractAgisoTradePayload, - resolveAgisoTradePlatformOrderId, -} from './agiso-trade-parsing.js' -import { upsertOrderFromWebhook } from './order-service.js' -import { hasConfiguredOrderItems } from './product-match-service.js' -import { createHttpError } from '../../utils/http.js' -import { parseJsonObject } from '../../utils/json.js' -import { logWebhook } from '../../utils/logger.js' -import { parseAmountToFen } from '../../utils/money.js' -import { nowIso } from '../../utils/time.js' - -type AgisoTradeWebhookDeps = { - updateWebhookEvent?: typeof updateWebhookEvent - hasConfiguredOrderItems?: typeof hasConfiguredOrderItems - upsertOrderFromWebhook?: typeof upsertOrderFromWebhook - enrichAgisoXianyuTradeOrder?: typeof enrichAgisoXianyuTradeOrder -} - -export async function processAgisoTradeWebhook(requestLike) { - const parsed = parseAgisoTradeRequest(requestLike) - const webhookEvent = await createWebhookEvent(buildWebhookEventInput(requestLike, parsed)) - - logWebhook('[webhook-service/agiso]', 'Webhook 已解析并写入 webhook_events', { - requestId: requestLike.requestId || '', - webhookEventId: webhookEvent.id, - provider: parsed.provider, - platform: parsed.platform, - shopId: parsed.shopId, - shopName: parsed.shopName, - eventKey: parsed.eventKey, - eventType: parsed.eventType, - platformOrderId: parsed.platformOrderId, - signatureValid: parsed.signatureValid, - }) - - return executeAgisoTradeWebhook(parsed, webhookEvent.id, { requestId: requestLike.requestId || '' }) -} - -export async function replayAgisoTradeWebhookEvent(webhookEvent) { - const requestLike = { - requestId: `replay-${webhookEvent.id}`, - headers: safeParseJson(webhookEvent.headers_json), - query: safeParseJson(webhookEvent.query_json), - body: safeParseJson(webhookEvent.body_json), - } - const parsed = parseAgisoTradeRequest(requestLike) - - logWebhook('[webhook-service/agiso]', '开始重放 webhook 事件', { - requestId: requestLike.requestId, - webhookEventId: webhookEvent.id, - provider: parsed.provider, - platform: parsed.platform, - shopId: parsed.shopId, - shopName: parsed.shopName, - eventKey: parsed.eventKey, - eventType: parsed.eventType, - platformOrderId: parsed.platformOrderId, - }) - - return executeAgisoTradeWebhook(parsed, webhookEvent.id, { requestId: requestLike.requestId }) -} - -export function buildWebhookEventInput(requestLike, parsed) { - return { - provider: parsed.provider, - platform: parsed.platform, - shopId: parsed.shopId, - shopName: parsed.shopName, - eventType: parsed.eventType, - eventKey: parsed.eventKey, - signatureValid: parsed.signatureValid, - headersJson: JSON.stringify(requestLike.headers || {}), - queryJson: JSON.stringify(requestLike.query || {}), - bodyJson: JSON.stringify(requestLike.body || {}), - processed: false, - processError: '', - relatedOrderId: null, - createdAt: nowIso(), - } -} - -async function executeAgisoTradeWebhook(parsed, webhookEventId, { requestId = '' } = {}) { - return executeAgisoTradeWebhookWithDeps(parsed, webhookEventId, { requestId }) -} - -export async function executeAgisoTradeWebhookWithDeps( - parsed, - webhookEventId, - { requestId = '' } = {}, - deps: AgisoTradeWebhookDeps = {}, -) { - const updateEvent = deps.updateWebhookEvent || updateWebhookEvent - const hasConfiguredItems = deps.hasConfiguredOrderItems || hasConfiguredOrderItems - const upsertOrder = deps.upsertOrderFromWebhook || upsertOrderFromWebhook - - try { - if (!parsed.signatureValid) { - throw createHttpError('验签失败', { - statusCode: 400, - errorCode: 'invalid_signature', - }) - } - - if (!parsed.platformOrderId) { - throw createHttpError('缺少平台订单号', { - statusCode: 400, - errorCode: 'missing_platform_order_id', - }) - } - - const ignoreReason = resolveAgisoTradeIgnoreReason(parsed) - if (ignoreReason) { - await updateEvent(webhookEventId, { - processed: true, - process_error: `ignored_${ignoreReason}`, - related_order_id: null, - }) - - logWebhook('[webhook-service/agiso]', 'Webhook 已忽略:当前通知不应再次触发履约发送', { - requestId, - webhookEventId, - provider: parsed.provider, - platform: parsed.platform, - shopId: parsed.shopId, - shopName: parsed.shopName, - eventKey: parsed.eventKey, - eventType: parsed.eventType, - platformOrderId: parsed.platformOrderId, - ignoreReason, - }) - - return { - accepted: true, - ignored: true, - ignoreReason, - provider: parsed.provider, - platform: parsed.platform, - shopId: parsed.shopId, - shopName: parsed.shopName, - platformRaw: parsed.platformRaw, - eventType: parsed.eventType, - platformOrderId: parsed.platformOrderId, - orderId: null, - totalAmountFen: Number(parsed.totalAmount || 0), - enriched: false, - enrichReason: 'skipped_ignored_event', - taskCount: 0, - messageDeliveries: [], - tasks: [], - } - } - - const shouldProcess = await hasConfiguredItems({ - provider: parsed.provider, - platform: parsed.platform, - shopId: parsed.shopId, - shopIdAliases: parsed.shopIdAliases, - items: parsed.items, - }) - - if (!shouldProcess) { - await updateEvent(webhookEventId, { - processed: true, - process_error: 'ignored_unconfigured_product', - related_order_id: null, - }) - - logWebhook('[webhook-service/agiso]', 'Webhook 已忽略:未命中任何已配置履约商品', { - requestId, - webhookEventId, - provider: parsed.provider, - platform: parsed.platform, - shopId: parsed.shopId, - shopName: parsed.shopName, - platformOrderId: parsed.platformOrderId, - rawItemCount: Array.isArray(parsed.items) ? parsed.items.length : 0, - }) - - return { - accepted: true, - ignored: true, - ignoreReason: 'unconfigured_product', - provider: parsed.provider, - platform: parsed.platform, - shopId: parsed.shopId, - shopName: parsed.shopName, - platformRaw: parsed.platformRaw, - eventType: parsed.eventType, - platformOrderId: parsed.platformOrderId, - orderId: null, - totalAmountFen: Number(parsed.totalAmount || 0), - enriched: false, - enrichReason: 'skipped_unconfigured_product', - taskCount: 0, - messageDeliveries: [], - tasks: [], - } - } - - const enriched = await enrichTradeBeforeUpsert(parsed, { - requestId, - webhookEventId, - }, deps) - const result = await upsertOrder(enriched.parsed) - await updateEvent(webhookEventId, { - processed: true, - process_error: result.ignored ? 'ignored_unconfigured_product' : '', - related_order_id: result.order?.id || null, - }) - - logWebhook('[webhook-service/agiso]', 'Webhook 业务处理成功', { - requestId, - webhookEventId, - provider: parsed.provider, - platform: parsed.platform, - shopId: parsed.shopId, - shopName: parsed.shopName, - eventKey: parsed.eventKey, - eventType: parsed.eventType, - platformOrderId: enriched.parsed.platformOrderId, - orderId: result.order?.id || null, - ignored: Boolean(result.ignored), - ignoreReason: String(result.ignoreReason || ''), - enriched: enriched.enriched, - enrichReason: enriched.reason || '', - totalAmountFen: Number(enriched.parsed.totalAmount || 0), - taskCount: result.tasks.length, - messageDeliveryCount: Array.isArray(result.messageDeliveries) ? result.messageDeliveries.length : 0, - }) - - return { - accepted: true, - provider: enriched.parsed.provider, - platform: enriched.parsed.platform, - shopId: enriched.parsed.shopId, - shopName: enriched.parsed.shopName, - platformRaw: enriched.parsed.platformRaw, - eventType: enriched.parsed.eventType, - platformOrderId: enriched.parsed.platformOrderId, - orderId: result.order?.id || null, - totalAmountFen: Number(enriched.parsed.totalAmount || 0), - ignored: Boolean(result.ignored), - ignoreReason: String(result.ignoreReason || ''), - enriched: enriched.enriched, - enrichReason: enriched.reason || '', - taskCount: result.tasks.length, - messageDeliveries: result.messageDeliveries || [], - tasks: result.tasks.map((task) => ({ - taskId: task.id, - taskNo: task.task_no, - status: task.task_status, - })), - } - } catch (error) { - await updateEvent(webhookEventId, { - processed: false, - process_error: error instanceof Error ? error.message : String(error || ''), - related_order_id: null, - }) - - logWebhook( - '[webhook-service/agiso]', - 'Webhook 业务处理失败', - { - requestId, - webhookEventId, - provider: parsed.provider, - platform: parsed.platform, - shopId: parsed.shopId, - shopName: parsed.shopName, - eventKey: parsed.eventKey, - eventType: parsed.eventType, - platformOrderId: parsed.platformOrderId, - signatureValid: parsed.signatureValid, - error, - }, - { level: 'error' }, - ) - throw error - } -} - -async function enrichTradeBeforeUpsert( - parsed, - { requestId = '', webhookEventId = null } = {}, - deps: AgisoTradeWebhookDeps = {}, -) { - const enrichOrder = deps.enrichAgisoXianyuTradeOrder || enrichAgisoXianyuTradeOrder - - if (parsed.provider !== 'agiso' || parsed.platform !== 'xianyu') { - return { parsed, enriched: false, reason: 'not_supported' } - } - - if (!shouldEnrichAgisoXianyuTrade(parsed)) { - return { parsed, enriched: false, reason: 'enough_fields_present' } - } - - const detailResult = await enrichOrder(parsed, { requestId }) - const nextParsed = detailResult?.parsed || parsed - - logWebhook('[webhook-service/agiso]', 'Agiso webhook 订单补查结束', { - requestId, - webhookEventId, - provider: nextParsed.provider, - platform: nextParsed.platform, - shopId: nextParsed.shopId, - platformOrderId: nextParsed.platformOrderId, - enriched: Boolean(detailResult?.enriched), - reason: String(detailResult?.reason || ''), - totalAmountFen: Number(nextParsed.totalAmount || 0), - paidAt: nextParsed.paidAt || null, - errorMessage: String(detailResult?.errorMessage || ''), - }) - - return { - parsed: nextParsed, - enriched: Boolean(detailResult?.enriched), - reason: String(detailResult?.reason || ''), - errorMessage: String(detailResult?.errorMessage || ''), - } -} - -export function shouldEnrichAgisoXianyuTrade(parsed) { - if (!parsed || parsed.provider !== 'agiso' || parsed.platform !== 'xianyu') { - return false - } - - if (Number(parsed.totalAmount || 0) <= 0) { - return true - } - - if (!String(parsed.buyerName || '').trim()) { - return true - } - - if (!String(parsed.shopName || '').trim()) { - return true - } - - if (!hasDetailedOrderItems(parsed.items)) { - return true - } - - return false -} - -function hasDetailedOrderItems(items) { - const normalizedItems = Array.isArray(items) ? items : [] - - if (normalizedItems.length === 0) { - return false - } - - return normalizedItems.some((item) => { - const spec = isPlainObject(item?.spec) ? item.spec : {} - const title = pickFirstNonEmpty([ - spec.title, - spec.Title, - spec.itemTitle, - spec.item_title, - spec.goods_name, - spec.goodsName, - item?.skuName, - ]) - const skuCode = String(item?.skuCode || '').trim() - - return Boolean(title && title !== skuCode) - }) -} - -export function parseAgisoTradeRequest(requestLike) { - const query = normalizeRecord(requestLike.query) - const body = normalizeRecord(requestLike.body) - const rawJson = String(body.json || '').trim() - const payload = rawJson ? parsePayloadJson(rawJson) : extractAgisoTradePayload(body) - const timestamp = String(query.timestamp || '').trim() - const sign = String(query.sign || '').trim().toLowerCase() - const provider = 'agiso' - const platformRaw = pickFirstNonEmpty([ - query.fromPlatform, - query.from_platform, - body.fromPlatform, - body.from_platform, - payload.fromPlatform, - payload.FromPlatform, - payload.platform, - payload.Platform, - ]) - const platform = resolveBusinessPlatform(platformRaw) - const shop = resolveShop(payload) - const eventType = resolveEventType(query.aopic, payload) - const signatureValid = verifyAgisoSignature({ rawJson, timestamp, sign }) - const platformOrderId = resolveAgisoTradePlatformOrderId(payload) - - return { - provider, - platform, - platformRaw, - eventType, - eventKey: [provider, platform || 'unknown', shop.shopId || 'unknown', platformOrderId || 'unknown', eventType, timestamp || 'na'].join(':'), - signatureValid, - platformOrderId, - shopId: shop.shopId, - shopName: shop.shopName, - orderStatus: resolveOrderStatus(eventType, payload), - payStatus: resolvePayStatus(eventType, payload), - buyerId: pickFirstNonEmpty([ - payload.buyer_id, - payload.buyerId, - payload.BuyerId, - payload.openid, - payload.BuyerOpenUid, - payload.buyer_open_uid, - payload.buyerOpenUid, - ]), - buyerName: pickFirstNonEmpty([ - payload.buyer_name, - payload.buyerName, - payload.BuyerName, - payload.nick, - payload.BuyerNick, - payload.buyer_nick, - payload.buyerNick, - ]), - receiverContact: pickFirstNonEmpty([ - payload.receiver_contact, - payload.receiverContact, - payload.mobile, - payload.phone, - payload.receiver_mobile, - payload.receiverMobile, - ]), - totalAmount: parseAmountToFen( - pickFirstNonEmpty([ - payload.total_fee, - payload.totalFee, - payload.TotalFee, - payload.pay_fee, - payload.payFee, - payload.Payment, - payload.payment, - ]), - ), - currency: pickFirstNonEmpty([payload.currency, 'CNY']) || 'CNY', - paidAt: resolvePaidAt(eventType, payload), - rawPayload: payload, - items: normalizeOrderItems(payload), - } -} - -export function verifyAgisoSignatureWithSecret({ rawJson, timestamp, sign, appSecret }) { - const normalizedSecret = String(appSecret || '').trim() - - if (!normalizedSecret) { - return true - } - - if (!rawJson || !timestamp || !sign) { - return false - } - - const documentedDigest = crypto - .createHash('md5') - .update(`${normalizedSecret}json${rawJson}timestamp${timestamp}${normalizedSecret}`, 'utf8') - .digest('hex') - .toLowerCase() - - if (documentedDigest === sign) { - return true - } - - const legacyDigest = crypto - .createHash('md5') - .update(`${normalizedSecret}${rawJson}${timestamp}`, 'utf8') - .digest('hex') - .toLowerCase() - - return legacyDigest === sign -} - -function verifyAgisoSignature({ rawJson, timestamp, sign }) { - const appSecret = String(runtimeConfig.platforms.agiso.appSecret || '').trim() - return verifyAgisoSignatureWithSecret({ rawJson, timestamp, sign, appSecret }) -} - -function resolveEventType(aopic, payload) { - const normalized = String(aopic || '').trim() - - if (isTradeFinishedPayload(payload)) { - return 'buyer_confirm_goods' - } - - if (normalized === '1') { - return 'payment_success' - } - - if (normalized === '21') { - return 'trade_create' - } - - if (normalized === '32') { - return 'trade_create' - } - - if (normalized === '16' || normalized === '256') { - return 'buyer_confirm_goods' - } - - if (normalized === '128') { - return 'trade_closed' - } - - const status = resolveProviderStatus(payload) - if (status === 'closed') { - return 'trade_closed' - } - - return 'trade_event' -} - -export function resolveAgisoTradeIgnoreReason(parsed) { - if (!parsed || parsed.provider !== 'agiso') { - return '' - } - - if (parsed.eventType === 'buyer_confirm_goods') { - return 'buyer_confirm_goods' - } - - if (parsed.platform === 'xianyu' && isTradeFinishedPayload(parsed.rawPayload)) { - return 'buyer_confirm_goods' - } - - return '' -} - -function resolveOrderStatus(eventType, payload) { - const providerStatus = resolveProviderStatus(payload) - - if (providerStatus === 'paid') { - return 'paid' - } - - if (providerStatus === 'closed') { - return 'closed' - } - - if (providerStatus === 'refunded') { - return 'refunded' - } - - if (eventType === 'payment_success') { - return 'paid' - } - - if (eventType === 'trade_closed') { - return 'closed' - } - - const rawStatus = normalizeInteger( - pickFirstNonEmpty([payload.order_status, payload.orderStatus, payload.status]), - ) - - if (rawStatus === 3 || rawStatus === 4) { - return 'paid' - } - - if (rawStatus === 6) { - return 'closed' - } - - if (rawStatus === 5) { - return 'refunded' - } - - return 'created' -} - -function resolvePayStatus(eventType, payload) { - const providerStatus = resolveProviderStatus(payload) - - if (providerStatus === 'paid') { - return 'paid' - } - - if (providerStatus === 'closed') { - return 'unpaid' - } - - if (providerStatus === 'refunded') { - return 'refunded' - } - - if (eventType === 'payment_success') { - return 'paid' - } - - if (eventType === 'trade_closed') { - return 'unpaid' - } - - const rawStatus = normalizeInteger( - pickFirstNonEmpty([payload.order_status, payload.orderStatus, payload.status]), - ) - - if (rawStatus === 3 || rawStatus === 4) { - return 'paid' - } - - if (rawStatus === 5) { - return 'refunded' - } - - return 'unpaid' -} - -function resolvePaidAt(eventType, payload) { - const providerPaidAt = normalizeProviderDateTime( - pickFirstNonEmpty([ - payload.paid_at, - payload.paidAt, - payload.pay_time, - payload.payTime, - payload.PayTime, - ]), - ) - - if (providerPaidAt) { - return providerPaidAt - } - - if (resolveProviderStatus(payload) === 'paid') { - return nowIso() - } - - if (eventType === 'payment_success') { - return nowIso() - } - - const rawStatus = normalizeInteger( - pickFirstNonEmpty([payload.order_status, payload.orderStatus, payload.status]), - ) - - if (rawStatus === 3 || rawStatus === 4) { - return nowIso() - } - - return null -} - -function parsePayloadJson(rawJson) { - if (!rawJson) { - return {} - } - - return parseJsonObject(rawJson, { - preserveLargeIntegers: true, - throwOnError: true, - }) -} - -function normalizeOrderItems(payload) { - const items = extractAgisoTradeOrderItemSources(payload) - - return items.map((item) => { - const source = isPlainObject(item) ? item : {} - const skuDescriptor = parseExternalSkuDescriptor( - pickFirstNonEmpty([ - source.sku, - source.Sku, - payload.sku, - payload.Sku, - payload._agisoTradeDetail?.sku, - ]), - ) - const rawSkuCandidates = [ - skuDescriptor.externalSkuCode, - source.OuterSkuId, - source.outerSkuId, - source.outer_sku_id, - source.OuterIid, - source.outerIid, - source.outer_iid, - source.sku_code, - source.skuCode, - source.goods_sku, - source.item_id, - source.itemId, - source.goods_id, - source.goodsId, - source.NumIid, - source.num_iid, - payload.item_id, - payload.itemId, - ] - const skuCode = resolveSkuCode(rawSkuCandidates) - const externalItemId = pickFirstNonEmpty([ - source.item_id, - source.itemId, - source.goods_id, - source.goodsId, - payload.item_id, - payload.itemId, - ]) - const externalSkuName = pickFirstNonEmpty([ - skuDescriptor.externalSkuName, - source.Title, - source.title, - source.sku_name, - source.skuName, - source.goods_name, - source.goodsName, - skuCode, - ]) - - return { - skuCode, - skuName: externalSkuName, - externalItemId, - externalSkuCode: pickFirstNonEmpty([skuDescriptor.externalSkuCode, skuCode, externalItemId]), - externalSkuName, - quantity: Math.max( - 1, - normalizeInteger( - pickFirstNonEmpty([ - source.quantity, - source.num, - source.Num, - source.buy_amount, - payload.quantity, - payload.num, - payload.Num, - ]), - ) || 1, - ), - spec: source, - snapshot: { - externalItemId, - externalSkuCode: pickFirstNonEmpty([skuDescriptor.externalSkuCode, skuCode, externalItemId]), - externalSkuName, - }, - } - }) -} - -function resolveBusinessPlatform(rawPlatform) { - const normalized = normalizePlatformKey(rawPlatform) - - if (!normalized) { - return 'unknown' - } - - const aliases = { - xianyu: 'xianyu', - idlefish: 'xianyu', - idle: 'xianyu', - aldsidle: 'xianyu', - taobaoidle: 'xianyu', - taobaoxianyu: 'xianyu', - taobao: 'taobao', - tb: 'taobao', - pdd: 'pdd', - pinduoduo: 'pdd', - douyin: 'douyin', - weidian: 'weidian', - jd: 'jd', - jingdong: 'jd', - } - - return aliases[normalized] || normalized -} - -function normalizePlatformKey(value) { - return String(value || '') - .trim() - .toLowerCase() - .replace(/[\s_-]+/g, '') -} - -function resolveShop(payload) { - const shopName = pickFirstNonEmpty([ - payload.shop_name, - payload.shopName, - payload.ShopName, - payload.seller_name, - payload.sellerName, - payload.seller_nick, - payload.sellerNick, - payload.SellerNick, - ]) - - return { - shopId: pickFirstNonEmpty([ - payload.shop_id, - payload.shopId, - payload.ShopId, - payload.seller_id, - payload.sellerId, - payload.SellerId, - payload.PlatformUserId, - payload.platformUserId, - shopName, - ]), - shopName, - } -} - -function resolveProviderStatus(payload) { - const rawStatus = normalizeInteger( - pickFirstNonEmpty([payload.order_status, payload.orderStatus, payload.status]), - ) - - if (rawStatus === 3 || rawStatus === 4) { - return 'paid' - } - - if (rawStatus === 6) { - return 'closed' - } - - if (rawStatus === 5) { - return 'refunded' - } - - const statusText = pickFirstNonEmpty([ - payload.Status, - payload.status, - payload.order_status_text, - payload.orderStatusText, - ]).toUpperCase() - - if (!statusText) { - return pickFirstNonEmpty([payload.PayTime, payload.payTime, payload.pay_time]) ? 'paid' : 'unknown' - } - - if (['WAIT_SELLER_SEND_GOODS', 'WAIT_BUYER_CONFIRM_GOODS', 'TRADE_FINISHED', 'SUCCESS', 'PAID'].includes(statusText)) { - return 'paid' - } - - if (['TRADE_CLOSED', 'TRADE_CLOSED_BY_TAOBAO', 'CLOSED'].includes(statusText)) { - return 'closed' - } - - if (['REFUND_SUCCESS', 'TRADE_REFUND', 'REFUNDED'].includes(statusText)) { - return 'refunded' - } - - if (['WAIT_BUYER_PAY', 'CREATED', 'NEW'].includes(statusText)) { - return 'unpaid' - } - - return pickFirstNonEmpty([payload.PayTime, payload.payTime, payload.pay_time]) ? 'paid' : 'unknown' -} - -function isTradeFinishedPayload(payload) { - const rawStatus = normalizeInteger( - pickFirstNonEmpty([payload?.order_status, payload?.orderStatus, payload?.status]), - ) - - if (rawStatus === 4) { - return true - } - - const statusText = pickFirstNonEmpty([ - payload?.Status, - payload?.status, - payload?.order_status_text, - payload?.orderStatusText, - ]).toUpperCase() - - return statusText === 'TRADE_FINISHED' -} - -function normalizeProviderDateTime(value) { - const normalized = String(value || '').trim() - - if (!normalized) { - return null - } - - const isoText = normalized.replace(' ', 'T') - const utcMatch = isoText.match(/^(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2}):(\d{2})$/) - - if (utcMatch) { - const [, year, month, day, hours, minutes, seconds] = utcMatch - return new Date( - Date.UTC( - Number(year), - Number(month) - 1, - Number(day), - Number(hours) - 8, - Number(minutes), - Number(seconds), - ), - ).toISOString() - } - - const date = new Date(normalized) - return Number.isNaN(date.getTime()) ? null : date.toISOString() -} - -function resolveSkuCode(rawKey) { - const candidates = Array.isArray(rawKey) ? rawKey : [rawKey] - - for (const candidate of candidates) { - if (typeof candidate === 'string' && candidate.trim()) { - return candidate.trim() - } - - if (typeof candidate === 'number' && Number.isFinite(candidate)) { - return String(candidate) - } - } - - return '' -} - -function parseExternalSkuDescriptor(rawValue) { - const raw = String(rawValue || '').trim() - if (!raw) { - return { - raw, - externalSkuCode: '', - externalSkuName: '', - } - } - - const parts = raw.split('|').map((part) => String(part || '').trim()).filter(Boolean) - let externalSkuCode = '' - let externalSkuName = '' - - for (const part of parts) { - if (!externalSkuCode && !part.includes(':') && !part.includes(':')) { - externalSkuCode = part - continue - } - - const separatorIndex = Math.max(part.indexOf(':'), part.indexOf(':')) - if (separatorIndex >= 0) { - const label = part.slice(0, separatorIndex).trim() - const value = part.slice(separatorIndex + 1).trim() - if (value && ['商品名称', '商品名', 'sku名称', '规格名称', '名称', '商品'].includes(label)) { - externalSkuName = value - } - } - } - - if (!externalSkuCode && parts[0]) { - externalSkuCode = parts[0] - } - - if (!externalSkuName) { - externalSkuName = parts - .map((part) => { - const separatorIndex = Math.max(part.indexOf(':'), part.indexOf(':')) - return separatorIndex >= 0 ? part.slice(separatorIndex + 1).trim() : '' - }) - .find(Boolean) || '' - } - - return { - raw, - externalSkuCode, - externalSkuName, - } -} - -function normalizeRecord(value) { - return isPlainObject(value) ? value : {} -} - -function normalizeInteger(value) { - const parsed = Number(value) - return Number.isFinite(parsed) ? Math.round(parsed) : 0 -} - -function pickFirstNonEmpty(values) { - for (const value of values) { - const normalized = String(value || '').trim() - if (normalized) { - return normalized - } - } - - return '' -} - -function isPlainObject(value) { - return Object.prototype.toString.call(value) === '[object Object]' -} - -function safeParseJson(rawValue) { - if (rawValue && typeof rawValue === 'object' && !Array.isArray(rawValue)) { - return rawValue - } - - const normalized = String(rawValue || '').trim() - - if (!normalized) { - return {} - } - - try { - const parsed = JSON.parse(normalized) - return isPlainObject(parsed) ? parsed : {} - } catch { - return {} - } -} diff --git a/apps/backend/src/services/platforms/agiso/README.md b/apps/backend/src/services/platforms/agiso/README.md deleted file mode 100644 index 8ad79c0a..00000000 --- a/apps/backend/src/services/platforms/agiso/README.md +++ /dev/null @@ -1,9 +0,0 @@ -## Agiso Platform Services - -Keep Agiso-wide shared helpers in this directory. - -Per-platform integrations live in subdirectories: - -- `xianyu/` -- `pdd/` -- `taobao/` diff --git a/apps/backend/src/services/platforms/agiso/pdd/README.md b/apps/backend/src/services/platforms/agiso/pdd/README.md deleted file mode 100644 index fc40152b..00000000 --- a/apps/backend/src/services/platforms/agiso/pdd/README.md +++ /dev/null @@ -1,3 +0,0 @@ -## PDD - -Put all Agiso PDD-specific service integrations here. diff --git a/apps/backend/src/services/platforms/agiso/shop-config-service.ts b/apps/backend/src/services/platforms/agiso/shop-config-service.ts deleted file mode 100644 index decce936..00000000 --- a/apps/backend/src/services/platforms/agiso/shop-config-service.ts +++ /dev/null @@ -1,181 +0,0 @@ -import fs from 'node:fs' -import path from 'node:path' - -import { PROJECT_ROOT, runtimeConfig } from '../../../config/runtime.js' - -const AGISO_SHOPS_FILE_PATH = path.join(PROJECT_ROOT, 'data', 'agiso-shops.json') -const AGISO_MESSAGING_DEFAULT_KEYS = ['messageTemplate', 'autoDeliveryMessageTemplate'] -const AGISO_SHOP_CONFIG_KEYS = [ - 'shopName', - 'accessToken', - 'messageTemplate', - 'autoDeliveryMessageTemplate', - 'appSecret', - 'apiVersion', - 'sendMessageEndpoint', - 'tradeDetailEndpoint', - 'tradeDetailApiVersion', - 'tradeDetailTimeoutMs', -] - -type JsonObject = Record - -export type AgisoMessagingDefaults = { - messageTemplate?: string - autoDeliveryMessageTemplate?: string -} - -export type AgisoShopConfig = { - enabled?: boolean - shopName?: string - accessToken?: string - messageTemplate?: string - autoDeliveryMessageTemplate?: string - appSecret?: string - apiVersion?: string - sendMessageEndpoint?: string - tradeDetailEndpoint?: string - tradeDetailApiVersion?: string - tradeDetailTimeoutMs?: string -} - -export type AgisoMessagingConfigDocument = { - defaults: AgisoMessagingDefaults - shops: Record -} - -export function getAgisoShopsFilePath(): string { - return AGISO_SHOPS_FILE_PATH -} - -export function getAgisoShopConfigMap(): Record { - const envConfig = normalizeAgisoShopConfigMap(runtimeConfig.platforms?.agiso?.messaging?.shops || {}) - const fileConfig = loadAgisoMessagingConfigDocumentFromFile() - - return { - ...envConfig, - ...fileConfig.shops, - } -} - -export function getAgisoShopConfig(shopId: unknown): AgisoShopConfig | null { - const normalizedShopId = String(shopId || '').trim() - if (!normalizedShopId) { - return null - } - - return getAgisoShopConfigMap()[normalizedShopId] || null -} - -export function getAgisoMessagingDefaults(): AgisoMessagingDefaults { - return loadAgisoMessagingConfigDocumentFromFile().defaults -} - -export function saveAgisoMessagingConfig(rawValue: unknown): AgisoMessagingConfigDocument { - const normalized = normalizeAgisoMessagingConfigDocument(rawValue) - fs.mkdirSync(path.dirname(AGISO_SHOPS_FILE_PATH), { recursive: true }) - fs.writeFileSync(AGISO_SHOPS_FILE_PATH, `${JSON.stringify(normalized, null, 2)}\n`, 'utf8') - return normalized -} - -function loadAgisoMessagingConfigDocumentFromFile(): AgisoMessagingConfigDocument { - if (!fs.existsSync(AGISO_SHOPS_FILE_PATH)) { - return { - defaults: {}, - shops: {}, - } - } - - try { - const rawText = fs.readFileSync(AGISO_SHOPS_FILE_PATH, 'utf8') - const parsed = JSON.parse(rawText) - return normalizeAgisoMessagingConfigDocument(parsed) - } catch { - return { - defaults: {}, - shops: {}, - } - } -} - -function normalizeAgisoShopConfigMap(rawValue: unknown): Record { - const output: Record = {} - - const entries = isPlainObject(rawValue) ? Object.entries(rawValue) : [] - for (const [shopId, config] of entries) { - const normalizedShopId = String(shopId || '').trim() - if (!normalizedShopId || !isPlainObject(config)) { - continue - } - - const next: AgisoShopConfig = {} - const enabled = normalizeBooleanLike(config.enabled) - if (enabled !== null) { - next.enabled = enabled - } - - for (const key of AGISO_SHOP_CONFIG_KEYS) { - const value = String(config[key] || '').trim() - if (value) { - next[key] = value - } - } - - output[normalizedShopId] = next - } - - return output -} - -function normalizeAgisoMessagingDefaults(rawValue: unknown): AgisoMessagingDefaults { - const output: AgisoMessagingDefaults = {} - - if (!isPlainObject(rawValue)) { - return output - } - - for (const key of AGISO_MESSAGING_DEFAULT_KEYS) { - const value = String(rawValue[key] || '').trim() - if (value) { - output[key] = value - } - } - - return output -} - -function normalizeAgisoMessagingConfigDocument(rawValue: unknown): AgisoMessagingConfigDocument { - const normalizedValue = isPlainObject(rawValue) ? rawValue : {} - const hasStructuredShape = Object.prototype.hasOwnProperty.call(normalizedValue, 'defaults') - || Object.prototype.hasOwnProperty.call(normalizedValue, 'shops') - - return { - defaults: normalizeAgisoMessagingDefaults(hasStructuredShape ? normalizedValue.defaults : {}), - shops: normalizeAgisoShopConfigMap(hasStructuredShape ? normalizedValue.shops : normalizedValue), - } -} - -function normalizeBooleanLike(value: unknown): boolean | null { - if (typeof value === 'boolean') { - return value - } - - const normalized = String(value || '').trim().toLowerCase() - if (!normalized) { - return null - } - - if (['1', 'true', 'yes', 'on'].includes(normalized)) { - return true - } - - if (['0', 'false', 'no', 'off'].includes(normalized)) { - return false - } - - return null -} - -function isPlainObject(value: unknown): value is JsonObject { - return Object.prototype.toString.call(value) === '[object Object]' -} diff --git a/apps/backend/src/services/platforms/agiso/taobao/README.md b/apps/backend/src/services/platforms/agiso/taobao/README.md deleted file mode 100644 index c685793c..00000000 --- a/apps/backend/src/services/platforms/agiso/taobao/README.md +++ /dev/null @@ -1,3 +0,0 @@ -## Taobao - -Put all Agiso Taobao-specific service integrations here. diff --git a/apps/backend/src/services/platforms/agiso/xianyu/README.md b/apps/backend/src/services/platforms/agiso/xianyu/README.md deleted file mode 100644 index b0a28579..00000000 --- a/apps/backend/src/services/platforms/agiso/xianyu/README.md +++ /dev/null @@ -1,3 +0,0 @@ -## Xianyu - -Put all Agiso Xianyu-specific service integrations here. diff --git a/apps/backend/src/services/platforms/agiso/xianyu/auto-delivery-service.test.ts b/apps/backend/src/services/platforms/agiso/xianyu/auto-delivery-service.test.ts deleted file mode 100644 index 5284b13f..00000000 --- a/apps/backend/src/services/platforms/agiso/xianyu/auto-delivery-service.test.ts +++ /dev/null @@ -1,264 +0,0 @@ -import test from 'node:test' -import assert from 'node:assert/strict' - -import { runtimeConfig } from '../../../../config/runtime.js' -import { - hasAgisoAutoDeliverySucceeded, - ensureAgisoXianyuAutoDeliveryForDeliveredTaskWithDeps, - isOrderReadyForAgisoAutoDelivery, - isAgisoAutoDeliverySuccess, - resolveAgisoAutoDeliveryEndpoint, - resolveAgisoAutoDeliveryErrorMessage, -} from './auto-delivery-service.js' - -const agisoOrder = { - id: 101, - provider: 'agiso', - platform: 'xianyu', - shop_id: 'shop-auto-delivery-test', - shop_name: '自动发货测试店', - platform_order_id: 'P-AUTO-10001', -} - -function createDeliveredTask(patch = {}) { - return { - id: 201, - order_id: agisoOrder.id, - task_status: 'completed', - delivery_status: 'delivered', - context_json: '{}', - ...patch, - } -} - -test('isOrderReadyForAgisoAutoDelivery requires every task to be delivered', () => { - assert.equal(isOrderReadyForAgisoAutoDelivery([]), false) - assert.equal(isOrderReadyForAgisoAutoDelivery([ - { delivery_status: 'delivered' }, - { delivery_status: 'delivered' }, - ]), true) - assert.equal(isOrderReadyForAgisoAutoDelivery([ - { delivery_status: 'delivered' }, - { delivery_status: 'processing' }, - ]), false) -}) - -test('hasAgisoAutoDeliverySucceeded detects successful context on any task', () => { - assert.equal(hasAgisoAutoDeliverySucceeded([ - { context_json: {} }, - { context_json: { agisoAutoDelivery: { status: 'success' } } }, - ]), true) - - assert.equal(hasAgisoAutoDeliverySucceeded([ - { context_json: { agisoAutoDelivery: { status: 'failed' } } }, - ]), false) -}) - -test('hasAgisoAutoDeliverySucceeded handles string and invalid task context payloads', () => { - assert.equal(hasAgisoAutoDeliverySucceeded([ - { context_json: '{bad-json' }, - { context_json: JSON.stringify({ agisoAutoDelivery: { status: 'success' } }) }, - ]), true) - - assert.equal(hasAgisoAutoDeliverySucceeded([ - { context_json: '{bad-json' }, - { context_json: JSON.stringify({ agisoAutoDelivery: { status: 'skipped' } }) }, - ]), false) -}) - -test('isAgisoAutoDeliverySuccess accepts empty successful payloads and explicit success codes', () => { - assert.equal(isAgisoAutoDeliverySuccess(200, {}), true) - assert.equal(isAgisoAutoDeliverySuccess(200, { IsSuccess: true }), true) - assert.equal(isAgisoAutoDeliverySuccess(200, { Error_Code: 0 }), true) - assert.equal(isAgisoAutoDeliverySuccess(500, { IsSuccess: true }), false) - assert.equal(isAgisoAutoDeliverySuccess(200, { Error_Code: 500 }), false) -}) - -test('resolveAgisoAutoDeliveryErrorMessage prefers structured payload message before raw text', () => { - assert.equal( - resolveAgisoAutoDeliveryErrorMessage( - { Error_Msg: '库存不足' }, - 'raw body', - 400, - ), - '库存不足', - ) - - assert.equal( - resolveAgisoAutoDeliveryErrorMessage( - {}, - 'fallback raw body', - 400, - ), - 'fallback raw body', - ) -}) - -test('resolveAgisoAutoDeliveryEndpoint falls back to DummySend', () => { - assert.equal( - resolveAgisoAutoDeliveryEndpoint(''), - 'https://gw-api.agiso.com/aldsIdle/Order/DummySend', - ) - - assert.equal( - resolveAgisoAutoDeliveryEndpoint('https://gw-api.agiso.com/aldsIdle/Order/DummySend'), - 'https://gw-api.agiso.com/aldsIdle/Order/DummySend', - ) -}) - -test('ensureAgisoXianyuAutoDeliveryForDeliveredTaskWithDeps skips while other order tasks are not delivered', async () => { - const calls = { - update: 0, - fetch: 0, - } - - const result = await ensureAgisoXianyuAutoDeliveryForDeliveredTaskWithDeps({ - order: agisoOrder, - task: createDeliveredTask(), - }, { - listTasksByOrderId: async () => [ - createDeliveredTask(), - createDeliveredTask({ id: 202, delivery_status: 'processing' }), - ], - updateTask: async () => { - calls.update += 1 - return null - }, - fetch: async () => { - calls.fetch += 1 - return { status: 200, text: async () => '{}' } - }, - }) - - assert.equal(result.sent, false) - assert.equal(result.skipped, true) - assert.equal(result.reason, 'waiting_other_tasks') - assert.equal(calls.update, 0) - assert.equal(calls.fetch, 0) -}) - -test('ensureAgisoXianyuAutoDeliveryForDeliveredTaskWithDeps persists missing config as skipped', async () => { - const originalAppSecret = runtimeConfig.platforms.agiso.appSecret - const originalShops = runtimeConfig.platforms.agiso.messaging.shops - const updates = [] - const events = [] - - runtimeConfig.platforms.agiso.appSecret = '' - runtimeConfig.platforms.agiso.messaging.shops = {} - - try { - const task = createDeliveredTask({ context_json: JSON.stringify({ existing: true }) }) - const result = await ensureAgisoXianyuAutoDeliveryForDeliveredTaskWithDeps({ - order: { - ...agisoOrder, - shop_id: 'missing-auto-delivery-config-shop', - }, - task, - trigger: 'unit_test', - }, { - listTasksByOrderId: async () => [task], - updateTask: async (taskId, patch) => { - updates.push({ taskId, patch }) - return { ...task, ...patch } - }, - createTaskEvent: async (taskId, eventType, payload, createdAt) => { - events.push({ taskId, eventType, payload, createdAt }) - return { id: 1 } - }, - fetch: async () => { - throw new Error('fetch should not be called without config') - }, - nowIso: () => '2026-05-21T12:00:00.000Z', - }) - - assert.equal(result.sent, false) - assert.equal(result.skipped, true) - assert.equal(result.reason, 'missing_config') - assert.equal(updates.length, 1) - assert.equal(events[0]?.eventType, 'agiso_auto_delivery_skipped') - - const context = JSON.parse(updates[0].patch.context_json) - assert.equal(context.existing, true) - assert.equal(context.agisoAutoDelivery.status, 'skipped') - assert.equal(context.agisoAutoDelivery.reason, 'missing_config') - assert.deepEqual({ - hasEndpoint: context.agisoAutoDelivery.hasEndpoint, - hasAccessToken: context.agisoAutoDelivery.hasAccessToken, - hasAppSecret: context.agisoAutoDelivery.hasAppSecret, - }, { - hasEndpoint: true, - hasAccessToken: false, - hasAppSecret: false, - }) - } finally { - runtimeConfig.platforms.agiso.appSecret = originalAppSecret - runtimeConfig.platforms.agiso.messaging.shops = originalShops - } -}) - -test('ensureAgisoXianyuAutoDeliveryForDeliveredTaskWithDeps fails when accepted delivery is not confirmed as shipped', async () => { - const originalAppSecret = runtimeConfig.platforms.agiso.appSecret - const originalShops = runtimeConfig.platforms.agiso.messaging.shops - const updates = [] - const events = [] - const messages = [] - - runtimeConfig.platforms.agiso.appSecret = 'runtime-secret' - runtimeConfig.platforms.agiso.messaging.shops = { - [agisoOrder.shop_id]: { - accessToken: 'access-token', - appSecret: 'shop-secret', - apiVersion: '1', - }, - } - - try { - const task = createDeliveredTask() - const result = await ensureAgisoXianyuAutoDeliveryForDeliveredTaskWithDeps({ - order: agisoOrder, - task, - trigger: 'unit_test', - }, { - listTasksByOrderId: async () => [task], - fetch: async () => ({ - status: 200, - text: async () => JSON.stringify({ IsSuccess: true, RequestId: 'req-1' }), - }), - confirmAgisoXianyuAutoDeliveryShipped: async (input) => ({ - shipped: false, - orderStatus: 2, - shipTime: 0, - reason: `not shipped: ${input.requestId}`, - }), - updateTask: async (taskId, patch) => { - updates.push({ taskId, patch }) - return { ...task, ...patch } - }, - createTaskEvent: async (taskId, eventType, payload, createdAt) => { - events.push({ taskId, eventType, payload, createdAt }) - return { id: 2 } - }, - ensureAgisoXianyuAutoDeliveryMessageDeliveredForTask: async (payload) => { - messages.push(payload) - return { sent: true } - }, - nowIso: () => '2026-05-21T12:01:00.000Z', - }) - - assert.equal(result.sent, false) - assert.equal(result.skipped, false) - assert.equal(result.reason, 'delivery_not_confirmed') - assert.equal(messages.length, 0) - assert.equal(events[0]?.eventType, 'agiso_auto_delivery_failed') - - const context = JSON.parse(updates[0].patch.context_json) - assert.equal(context.agisoAutoDelivery.status, 'failed') - assert.equal(context.agisoAutoDelivery.reason, 'delivery_not_confirmed') - assert.equal(context.agisoAutoDelivery.requestId, 'req-1') - assert.equal(context.agisoAutoDelivery.confirmOrderStatus, 2) - assert.match(context.agisoAutoDelivery.errorMessage, /订单仍未进入已发货/) - } finally { - runtimeConfig.platforms.agiso.appSecret = originalAppSecret - runtimeConfig.platforms.agiso.messaging.shops = originalShops - } -}) diff --git a/apps/backend/src/services/platforms/agiso/xianyu/auto-delivery-service.ts b/apps/backend/src/services/platforms/agiso/xianyu/auto-delivery-service.ts deleted file mode 100644 index a8aa9537..00000000 --- a/apps/backend/src/services/platforms/agiso/xianyu/auto-delivery-service.ts +++ /dev/null @@ -1,640 +0,0 @@ -import crypto from 'node:crypto' - -import { runtimeConfig } from '../../../../config/runtime.js' -import { listTasksByOrderId, updateTask } from '../../../../repositories/task-repo.js' -import { createTaskEvent } from '../../../../repositories/task-event-repo.js' -import { getAgisoShopConfig } from '../shop-config-service.js' -import { queryAgisoXianyuOrderDetail } from './order-detail-service.js' -import { ensureAgisoXianyuAutoDeliveryMessageDeliveredForTask } from './message-service.js' -import { parseJsonObject } from '../../../../utils/json.js' -import { logWebhook } from '../../../../utils/logger.js' -import { nowIso } from '../../../../utils/time.js' -import type { TaskRow } from '../../../../types/repository-rows.js' - -const AGISO_DUMMY_SEND_ENDPOINT = 'https://gw-api.agiso.com/aldsIdle/Order/DummySend' - -type JsonObject = Record - -type AutoDeliveryOrder = { - id?: number | string - provider?: string - platform?: string - shop_id?: string - shop_name?: string - platform_order_id?: string - [key: string]: unknown -} - -type SupportedAgisoOrder = AutoDeliveryOrder & { - id: number | string - provider: 'agiso' - platform: 'xianyu' - platform_order_id: string -} - -type AutoDeliveryTask = Partial & { - id?: number | string - delivery_status?: string - task_status?: string - context_json?: string | JsonObject | null - [key: string]: unknown -} - -type AutoDeliveryTaskWithId = AutoDeliveryTask & { - id: number | string -} - -type EnsureAgisoXianyuAutoDeliveryInput = { - order?: AutoDeliveryOrder | null - task?: AutoDeliveryTask | null - trigger?: string -} - -type AutoDeliveryStatus = 'success' | 'failed' | 'skipped' - -type FetchResponseLike = { - status: number - text: () => Promise -} - -type FetchLike = ( - input: string | URL, - init?: RequestInit, -) => Promise - -type AgisoAutoDeliveryConfirmInput = { - shopId?: string - platformOrderId?: string - requestId?: string -} - -type AgisoAutoDeliveryConfirmResult = { - shipped: boolean - orderStatus: number - shipTime: number - reason: string -} - -type AutoDeliveryMessageResult = { - sent: boolean - skipped?: boolean - reason?: string - [key: string]: unknown -} - -type EnsureAgisoXianyuAutoDeliveryDeps = { - listTasksByOrderId?: (orderId: number | string) => Promise - fetch?: FetchLike - confirmAgisoXianyuAutoDeliveryShipped?: ( - input: AgisoAutoDeliveryConfirmInput, - ) => Promise - updateTask?: ( - taskId: number | string, - patch: { context_json: string; updated_at: string }, - ) => Promise - createTaskEvent?: ( - taskId: number | string, - eventType: string, - payload?: unknown, - createdAt?: string, - ) => Promise - nowIso?: () => string - ensureAgisoXianyuAutoDeliveryMessageDeliveredForTask?: ( - input: { order?: AutoDeliveryOrder | null; task?: AutoDeliveryTask | null }, - ) => Promise -} - -type PersistAgisoAutoDeliveryResultInput = { - status: AutoDeliveryStatus - trigger?: string - reason?: string - order?: AutoDeliveryOrder | null - responseStatus?: number - response?: unknown - errorMessage?: string - detail?: JsonObject -} - -type AgisoAutoDeliveryConfig = { - enabled: boolean - endpoint: string - apiVersion: string - appSecret: string - accessToken: string -} - -export async function ensureAgisoXianyuAutoDeliveryForDeliveredTask({ - order, - task, - trigger = 'task_delivered', -}: EnsureAgisoXianyuAutoDeliveryInput = {}) { - return ensureAgisoXianyuAutoDeliveryForDeliveredTaskWithDeps({ order, task, trigger }) -} - -export async function ensureAgisoXianyuAutoDeliveryForDeliveredTaskWithDeps({ - order, - task, - trigger = 'task_delivered', -}: EnsureAgisoXianyuAutoDeliveryInput = {}, deps: EnsureAgisoXianyuAutoDeliveryDeps = {}) { - const listOrderTasks = deps.listTasksByOrderId || listTasksByOrderId - const sendRequest = deps.fetch || (fetch as FetchLike) - const confirmShipped = deps.confirmAgisoXianyuAutoDeliveryShipped || confirmAgisoXianyuAutoDeliveryShipped - - if (!isAgisoXianyuOrder(order) || !task?.id) { - return { sent: false, skipped: true, reason: 'not_supported', task } - } - - const currentTask = task as AutoDeliveryTaskWithId - if (String(currentTask.delivery_status || '').trim() !== 'delivered') { - return { sent: false, skipped: true, reason: 'task_not_delivered', task: currentTask } - } - - const orderTasks = await listOrderTasks(order.id) - if (hasAgisoAutoDeliverySucceeded(orderTasks)) { - return { sent: false, skipped: true, reason: 'already_sent', task: currentTask } - } - - if (!isOrderReadyForAgisoAutoDelivery(orderTasks)) { - logWebhook('[agiso/xianyu/auto-delivery]', '跳过自动发货:订单下仍有任务未完成交付', { - trigger, - orderId: order.id, - taskId: currentTask.id, - platformOrderId: order.platform_order_id, - taskStatuses: orderTasks.map((current) => ({ - taskId: current.id, - taskStatus: current.task_status, - deliveryStatus: current.delivery_status, - })), - }) - - return { sent: false, skipped: true, reason: 'waiting_other_tasks', task: currentTask } - } - - const config = resolveAgisoXianyuAutoDeliveryConfig(order) - if (!config.enabled) { - return persistAgisoAutoDeliveryResult(currentTask, { - status: 'skipped', - trigger, - reason: 'auto_delivery_disabled', - order, - }, deps) - } - - if (!config.endpoint || !config.accessToken || !config.appSecret) { - return persistAgisoAutoDeliveryResult(currentTask, { - status: 'skipped', - trigger, - reason: 'missing_config', - order, - detail: { - hasEndpoint: Boolean(config.endpoint), - hasAccessToken: Boolean(config.accessToken), - hasAppSecret: Boolean(config.appSecret), - }, - }, deps) - } - - const requestHeaders = buildRequestHeaders({ - accessToken: config.accessToken, - apiVersion: config.apiVersion, - }) - const requestBody = buildRequestBody({ - platformOrderId: String(order.platform_order_id || '').trim(), - appSecret: config.appSecret, - }) - - logWebhook('[agiso/xianyu/auto-delivery]', '开始执行 Agiso 咸鱼自动发货', { - trigger, - orderId: order.id, - taskId: currentTask.id, - shopId: order.shop_id, - platformOrderId: order.platform_order_id, - endpoint: config.endpoint, - }) - - try { - const response = await sendRequest(config.endpoint, { - method: 'POST', - headers: requestHeaders, - body: new URLSearchParams(requestBody).toString(), - }) - const rawText = await response.text() - const parsed = parseJsonObject(rawText) as JsonObject - const success = isAgisoAutoDeliverySuccess(response.status, parsed) - - if (success) { - const requestId = String(parsed?.RequestId || '').trim() - - // 发货接口返回成功后,再查一次 Order/Detail 确认订单真的进入已发货状态。 - const confirmResult = await confirmShipped({ - shopId: order.shop_id, - platformOrderId: order.platform_order_id, - requestId, - }) - - if (!confirmResult.shipped) { - logWebhook('[agiso/xianyu/auto-delivery]', 'Agiso 咸鱼自动发货接口返回成功,但订单状态未确认进入已发货', { - trigger, - orderId: order.id, - taskId: currentTask.id, - shopId: order.shop_id, - platformOrderId: order.platform_order_id, - responseStatus: response.status, - requestId, - confirmOrderStatus: confirmResult.orderStatus, - confirmShipTime: confirmResult.shipTime, - confirmReason: confirmResult.reason, - }, { level: 'warn' }) - - return persistAgisoAutoDeliveryResult(currentTask, { - status: 'failed', - trigger, - reason: 'delivery_not_confirmed', - order, - responseStatus: response.status, - response: parsed, - errorMessage: `Agiso 发货状态更新接口已受理,但订单仍未进入已发货,请检查该订单是否允许无物流发货 (orderStatus=${confirmResult.orderStatus}, shipTime=${confirmResult.shipTime})`, - detail: { - requestId, - confirmOrderStatus: confirmResult.orderStatus, - confirmShipTime: confirmResult.shipTime, - }, - }, deps) - } - - logWebhook('[agiso/xianyu/auto-delivery]', 'Agiso 咸鱼自动发货成功,订单已确认进入已发货状态', { - trigger, - orderId: order.id, - taskId: currentTask.id, - shopId: order.shop_id, - platformOrderId: order.platform_order_id, - responseStatus: response.status, - requestId, - confirmOrderStatus: confirmResult.orderStatus, - confirmShipTime: confirmResult.shipTime, - }) - - const result = await persistAgisoAutoDeliveryResult(currentTask, { - status: 'success', - trigger, - reason: '', - order, - responseStatus: response.status, - response: parsed, - detail: { - requestId, - confirmOrderStatus: confirmResult.orderStatus, - confirmShipTime: confirmResult.shipTime, - }, - }, deps) - - // 发货确认成功后,发送自定义消息通知。 - await sendAutoDeliveryMessage({ order, task: currentTask }, deps) - - return result - } - - const errorMessage = resolveAgisoAutoDeliveryErrorMessage(parsed, rawText, response.status) - logWebhook('[agiso/xianyu/auto-delivery]', 'Agiso 咸鱼自动发货失败', { - trigger, - orderId: order.id, - taskId: currentTask.id, - shopId: order.shop_id, - platformOrderId: order.platform_order_id, - responseStatus: response.status, - errorMessage, - response: parsed, - }, { level: 'warn' }) - - return persistAgisoAutoDeliveryResult(currentTask, { - status: 'failed', - trigger, - reason: 'request_failed', - order, - responseStatus: response.status, - response: parsed, - errorMessage, - }, deps) - } catch (error) { - const errorMessage = error instanceof Error ? error.message : String(error || 'Agiso 咸鱼自动发货失败') - logWebhook('[agiso/xianyu/auto-delivery]', 'Agiso 咸鱼自动发货异常', { - trigger, - orderId: order.id, - taskId: currentTask.id, - shopId: order.shop_id, - platformOrderId: order.platform_order_id, - errorMessage, - }, { level: 'warn' }) - - return persistAgisoAutoDeliveryResult(currentTask, { - status: 'failed', - trigger, - reason: 'request_error', - order, - responseStatus: 0, - response: {}, - errorMessage, - }, deps) - } -} - -export function hasAgisoAutoDeliverySucceeded(tasks: AutoDeliveryTask[] = []): boolean { - return (Array.isArray(tasks) ? tasks : []).some((task) => { - const context = parseTaskContext(task) - const autoDelivery = isPlainObject(context.agisoAutoDelivery) ? context.agisoAutoDelivery : {} - return String(autoDelivery.status || '').trim() === 'success' - }) -} - -export function isOrderReadyForAgisoAutoDelivery(tasks: AutoDeliveryTask[] = []): boolean { - const normalizedTasks = Array.isArray(tasks) ? tasks.filter(Boolean) : [] - return normalizedTasks.length > 0 - && normalizedTasks.every((task) => String(task.delivery_status || '').trim() === 'delivered') -} - -function resolveAgisoXianyuAutoDeliveryConfig(order: AutoDeliveryOrder): AgisoAutoDeliveryConfig { - const baseConfig = runtimeConfig.platforms.agiso.autoDelivery - const shopConfig = getAgisoShopConfig(String(order?.shop_id || '').trim()) || {} - - return { - enabled: normalizeBooleanLike(baseConfig.enabled, true), - endpoint: resolveAgisoAutoDeliveryEndpoint(baseConfig.endpoint), - apiVersion: String(baseConfig.apiVersion || shopConfig.apiVersion || '1').trim() || '1', - appSecret: String(shopConfig.appSecret || runtimeConfig.platforms?.agiso?.appSecret || '').trim(), - accessToken: String(shopConfig.accessToken || '').trim(), - } -} - -function buildRequestHeaders({ - accessToken, - apiVersion, -}: { accessToken: string; apiVersion: string }): Record { - return { - Authorization: `Bearer ${accessToken}`, - ApiVersion: String(apiVersion || '1').trim() || '1', - 'content-type': 'application/x-www-form-urlencoded; charset=utf-8', - } -} - -function buildRequestBody({ - platformOrderId, - appSecret, -}: { platformOrderId: string; appSecret: string }): Record { - const payload: Record = { - tid: String(platformOrderId || '').trim(), - timestamp: String(Math.floor(Date.now() / 1000)), - } - - payload.sign = generateSign(payload, appSecret) - return payload -} - -function generateSign(params: Record, appSecret: string): string { - const sortedEntries = Object.entries(params).sort(([left], [right]) => left.localeCompare(right)) - let raw = String(appSecret || '').trim() - - for (const [key, value] of sortedEntries) { - raw += `${key}${value}` - } - - raw += String(appSecret || '').trim() - - return crypto.createHash('md5').update(raw, 'utf8').digest('hex').toLowerCase() -} - -export function isAgisoAutoDeliverySuccess(statusCode: number, payload: unknown): boolean { - if (statusCode < 200 || statusCode >= 300) { - return false - } - - if (!payload || typeof payload !== 'object' || Object.keys(payload).length === 0) { - return true - } - - const normalizedPayload = payload as JsonObject - if (normalizedPayload.IsSuccess === true) { - return true - } - - if (Number(normalizedPayload.Error_Code) === 0) { - return true - } - - if (Number(normalizedPayload.code) === 0) { - return true - } - - return false -} - -export function resolveAgisoAutoDeliveryErrorMessage( - payload: unknown, - rawText: string, - statusCode: number, -): string { - if (payload && typeof payload === 'object') { - const normalizedPayload = payload as JsonObject - for (const value of [ - normalizedPayload.Error_Msg, - normalizedPayload.msg, - normalizedPayload.message, - normalizedPayload.error, - ]) { - const normalized = String(value || '').trim() - if (normalized) { - return normalized - } - } - } - - const text = String(rawText || '').trim() - return text || `Agiso 咸鱼自动发货失败,HTTP ${statusCode}` -} - -export function resolveAgisoAutoDeliveryEndpoint(value: unknown): string { - return AGISO_DUMMY_SEND_ENDPOINT -} - -async function persistAgisoAutoDeliveryResult(task: AutoDeliveryTaskWithId, { - status, - trigger, - reason, - order, - responseStatus = 0, - response = {}, - errorMessage = '', - detail = {}, -}: PersistAgisoAutoDeliveryResultInput, deps: EnsureAgisoXianyuAutoDeliveryDeps = {}) { - const getNowIso = deps.nowIso || nowIso - const patchTask = deps.updateTask || updateTask - const insertTaskEvent = deps.createTaskEvent || createTaskEvent - const now = getNowIso() - const currentContext = parseTaskContext(task) - const nextContext = { - ...currentContext, - agisoAutoDelivery: { - status, - trigger: String(trigger || '').trim(), - reason: String(reason || '').trim(), - platformOrderId: String(order?.platform_order_id || '').trim(), - responseStatus: Number(responseStatus || 0), - errorMessage: String(errorMessage || '').trim(), - response: isPlainObject(response) ? response : {}, - updatedAt: now, - ...detail, - }, - } - const updatedTask = await patchTask(task.id, { - context_json: JSON.stringify(nextContext), - updated_at: now, - }) - - if (status === 'success' || status === 'failed' || status === 'skipped') { - await insertTaskEvent(task.id, `agiso_auto_delivery_${status}`, { - trigger: String(trigger || '').trim(), - reason: String(reason || '').trim(), - platformOrderId: String(order?.platform_order_id || '').trim(), - responseStatus: Number(responseStatus || 0), - errorMessage: String(errorMessage || '').trim(), - ...detail, - }, now) - } - - return { - sent: status === 'success', - skipped: status === 'skipped', - reason: String(reason || '').trim(), - errorMessage: String(errorMessage || '').trim(), - responseStatus: Number(responseStatus || 0), - response, - task: updatedTask || task, - } -} - -function parseTaskContext(task: AutoDeliveryTask | null | undefined): JsonObject { - const value = task?.context_json - - if (!value) { - return {} - } - - if (isPlainObject(value)) { - return value - } - - try { - return JSON.parse(String(value || '{}')) - } catch { - return {} - } -} - -function isAgisoXianyuOrder(order: AutoDeliveryOrder | null | undefined): order is SupportedAgisoOrder { - return String(order?.provider || '').trim() === 'agiso' - && String(order?.platform || '').trim() === 'xianyu' - && Boolean(String(order?.platform_order_id || '').trim()) - && Number(order?.id || 0) > 0 -} - -function normalizeBooleanLike(value: unknown, fallbackValue: boolean): boolean { - if (typeof value === 'boolean') { - return value - } - - if (typeof value === 'undefined' || value === null || value === '') { - return fallbackValue - } - - const normalized = String(value || '').trim().toLowerCase() - if (['1', 'true', 'yes', 'on'].includes(normalized)) { - return true - } - - if (['0', 'false', 'no', 'off'].includes(normalized)) { - return false - } - - return fallbackValue -} - -function isPlainObject(value: unknown): value is JsonObject { - return Object.prototype.toString.call(value) === '[object Object]' -} - -/** - * 发货接口返回成功后,再查一次 Order/Detail 确认订单是否真的进入已发货状态。 - * 只有 ship_time > 0 或 orderStatus >= 3 才算发货确认通过 - */ -async function confirmAgisoXianyuAutoDeliveryShipped({ - shopId = '', - platformOrderId = '', - requestId = '', -}: AgisoAutoDeliveryConfirmInput = {}): Promise { - try { - const detailResult = await queryAgisoXianyuOrderDetail({ shopId, platformOrderId, requestId }) - - if (detailResult.success && detailResult.shipped) { - return { - shipped: true, - orderStatus: detailResult.orderStatus, - shipTime: detailResult.shipTime, - reason: '', - } - } - - return { - shipped: false, - orderStatus: detailResult.orderStatus || 0, - shipTime: detailResult.shipTime || 0, - reason: detailResult.success ? 'order_not_shipped' : detailResult.reason, - } - } catch (error) { - const message = error instanceof Error ? error.message : String(error || '订单发货状态确认失败') - logWebhook('[agiso/xianyu/auto-delivery]', '自动发货确认查询异常', { - requestId, - shopId, - platformOrderId, - errorMessage: message, - }, { level: 'warn' }) - - return { - shipped: false, - orderStatus: 0, - shipTime: 0, - reason: 'confirm_query_failed', - } - } -} - -/** - * 自动发货成功后发送消息通知。 - */ -async function sendAutoDeliveryMessage( - { order, task }: { order?: AutoDeliveryOrder | null; task?: AutoDeliveryTask | null } = {}, - deps: EnsureAgisoXianyuAutoDeliveryDeps = {}, -): Promise { - try { - const deliverMessage = deps.ensureAgisoXianyuAutoDeliveryMessageDeliveredForTask - || ensureAgisoXianyuAutoDeliveryMessageDeliveredForTask - const result = await deliverMessage({ order, task }) - logWebhook('[agiso/xianyu/auto-delivery]', '自动发货消息通知结果', { - orderId: order?.id, - taskId: task?.id, - platformOrderId: order?.platform_order_id, - sent: result.sent, - skipped: result.skipped, - reason: result.reason, - }) - return result - } catch (error) { - const message = error instanceof Error ? error.message : String(error || '发送自动发货消息通知失败') - logWebhook('[agiso/xianyu/auto-delivery]', '自动发货消息通知异常', { - orderId: order?.id, - taskId: task?.id, - platformOrderId: order?.platform_order_id, - errorMessage: message, - }, { level: 'warn' }) - - return { sent: false, skipped: true, reason: 'message_send_error' } - } -} diff --git a/apps/backend/src/services/platforms/agiso/xianyu/message-service.test.ts b/apps/backend/src/services/platforms/agiso/xianyu/message-service.test.ts deleted file mode 100644 index 7d6395e1..00000000 --- a/apps/backend/src/services/platforms/agiso/xianyu/message-service.test.ts +++ /dev/null @@ -1,103 +0,0 @@ -import test from 'node:test' -import assert from 'node:assert/strict' - -import { runtimeConfig } from '../../../../config/runtime.js' -import { - deliverAgisoXianyuMessageForTaskWithDeps, - normalizeAgisoMessageTemplate, - renderAgisoAutoDeliveryMessage, -} from './message-service.js' - -test('deliverAgisoXianyuMessageForTaskWithDeps skips duplicate successful claim message by order scope', async () => { - const originalMessaging = runtimeConfig.platforms.agiso.messaging - const originalAppSecret = runtimeConfig.platforms.agiso.appSecret - - runtimeConfig.platforms.agiso.messaging = { - ...originalMessaging, - enabled: true, - sendMessageEndpoint: 'https://example.com/send', - apiVersion: '1', - accessToken: 'access-token', - } - runtimeConfig.platforms.agiso.appSecret = 'app-secret' - - const calls = { - find: [], - create: 0, - fetch: 0, - } - - try { - const result = await deliverAgisoXianyuMessageForTaskWithDeps({ - order: { - id: 15, - platform: 'xianyu', - shop_id: '2209880145223', - platform_order_id: '4502280133178028841', - }, - task: { - id: 36, - task_no: 'DT7af76e9b2438', - }, - channel: 'agiso_im', - messageContent: '测试消息', - claimUrl: 'https://221329.cc.cd/#/claim/same-token', - }, { - findLatestSuccessfulMessageDelivery: async (input) => { - calls.find.push(input) - return { id: 16, task_id: null } - }, - createMessageDelivery: async () => { - calls.create += 1 - return { id: 999 } - }, - fetch: async () => { - calls.fetch += 1 - return { status: 200, text: async () => '{}' } - }, - }) - - assert.deepEqual(calls.find, [ - { - provider: 'agiso', - platform: 'xianyu', - shopId: '2209880145223', - platformOrderId: '4502280133178028841', - channel: 'agiso_im', - claimUrl: 'https://221329.cc.cd/#/claim/same-token', - }, - ]) - assert.equal(calls.create, 0) - assert.equal(calls.fetch, 0) - assert.equal(result.sent, false) - assert.equal(result.skipped, true) - assert.equal(result.reason, 'already_sent') - assert.equal(result.deliveryId, 16) - } finally { - runtimeConfig.platforms.agiso.messaging = originalMessaging - runtimeConfig.platforms.agiso.appSecret = originalAppSecret - } -}) - -test('normalizeAgisoMessageTemplate converts escaped newline sequences into real line breaks', () => { - assert.equal( - normalizeAgisoMessageTemplate('第一行\\n第二行\\r\\n第三行'), - '第一行\n第二行\n第三行', - ) -}) - -test('renderAgisoAutoDeliveryMessage supports escaped newlines in configured template', () => { - const message = renderAgisoAutoDeliveryMessage({ - order: { - platform_order_id: '4502280133178028841', - shop_id: '2209880145223', - shop_name: '大锤号商', - }, - task: { - task_no: 'DT7af76e9b2438', - }, - template: '订单 {platformOrderId}\\n结果:{resultMessage}', - }) - - assert.equal(message, '订单 4502280133178028841\n结果:自动发货成功') -}) diff --git a/apps/backend/src/services/platforms/agiso/xianyu/message-service.ts b/apps/backend/src/services/platforms/agiso/xianyu/message-service.ts deleted file mode 100644 index 5a9ccd14..00000000 --- a/apps/backend/src/services/platforms/agiso/xianyu/message-service.ts +++ /dev/null @@ -1,545 +0,0 @@ -import crypto from 'node:crypto' - -import { runtimeConfig } from '../../../../config/runtime.js' -import { getAgisoMessagingDefaults, getAgisoShopConfigMap } from '../shop-config-service.js' -import { - createMessageDelivery, - findLatestSuccessfulMessageDelivery, - updateMessageDelivery, -} from '../../../../repositories/message-delivery-repo.js' -import { nowIso } from '../../../../utils/time.js' - -const AGISO_XIANYU_MESSAGE_CHANNEL = 'agiso_im' -const AGISO_XIANYU_AUTO_DELIVERY_MESSAGE_CHANNEL = 'agiso_im_auto_delivery' - -type JsonObject = Record - -type MessageOrder = { - id?: number | string - platform?: string - shop_id?: string - shop_name?: string - platform_order_id?: string - [key: string]: unknown -} - -type MessageTask = { - id?: number | string - task_no?: string - [key: string]: unknown -} - -type AgisoMessagingConfig = { - enabled?: boolean - sendMessageEndpoint?: string - accessToken?: string - appSecret?: string - apiVersion?: string - messageTemplate?: string - autoDeliveryMessageTemplate?: string - shopName?: string - [key: string]: unknown -} - -type EnsureClaimMessageInput = { - order?: MessageOrder | null - task?: MessageTask | null - claimUrl?: string - expiredAt?: string -} - -type EnsureAutoDeliveryMessageInput = { - order?: MessageOrder | null - task?: MessageTask | null -} - -type DeliverMessageInput = EnsureAutoDeliveryMessageInput & { - channel?: string - messageContent?: string - claimUrl?: string -} - -type FetchResponseLike = { - status: number - text: () => Promise -} - -type FetchLike = ( - input: string | URL, - init?: RequestInit, -) => Promise - -type MessageDeliveryLike = { - id: number | string - [key: string]: unknown -} - -type MessageDeliveryCreateInput = { - provider: string - platform: string - shopId: string - shopName: string - channel: string - orderId?: number | string | null - taskId?: number | string | null - platformOrderId?: string - recipientKey?: string - messageContent: string - claimUrl: string - status: string - requestUrl: string - requestHeadersJson: string - requestBodyJson: string - responseStatus: number - responseJson: string - errorMessage: string - sentAt: string | null - createdAt: string - updatedAt: string -} - -type MessageDeliveryPatch = { - status?: string - response_status?: number - response_json?: string - error_message?: string - sent_at?: string | null - updated_at?: string -} - -type FindSuccessfulDeliveryInput = { - provider: string - platform: string - shopId: string - platformOrderId: string - channel: string - claimUrl: string -} - -type DeliverMessageDeps = { - nowIso?: () => string - findLatestSuccessfulMessageDelivery?: ( - input: FindSuccessfulDeliveryInput, - ) => Promise - createMessageDelivery?: ( - input: MessageDeliveryCreateInput, - ) => Promise - updateMessageDelivery?: ( - deliveryId: number | string, - patch: MessageDeliveryPatch, - ) => Promise - fetch?: FetchLike -} - -type RenderClaimMessageInput = EnsureClaimMessageInput & { - template?: string - shopName?: string -} - -type RenderAutoDeliveryMessageInput = EnsureAutoDeliveryMessageInput & { - template?: string - shopName?: string -} - -type RenderMessageTemplateInput = RenderAutoDeliveryMessageInput & { - template?: string - claimUrl?: string - expiredAt?: string - resultMessage?: string -} - -export async function ensureAgisoXianyuClaimMessageDeliveredForTask({ - order, - task, - claimUrl, - expiredAt, -}: EnsureClaimMessageInput = {}) { - const config = resolveAgisoXianyuMessagingConfig(order) - const messageContent = renderAgisoClaimMessage({ - order, - task, - claimUrl, - expiredAt, - template: config.messageTemplate, - shopName: config.shopName, - }) - - return deliverAgisoXianyuMessageForTask({ - order, - task, - channel: AGISO_XIANYU_MESSAGE_CHANNEL, - messageContent, - claimUrl, - }) -} - -export async function ensureAgisoXianyuAutoDeliveryMessageDeliveredForTask({ - order, - task, -}: EnsureAutoDeliveryMessageInput = {}) { - const config = resolveAgisoXianyuMessagingConfig(order) - const messageContent = renderAgisoAutoDeliveryMessage({ - order, - task, - template: config.autoDeliveryMessageTemplate, - shopName: config.shopName, - }) - - return deliverAgisoXianyuMessageForTask({ - order, - task, - channel: AGISO_XIANYU_AUTO_DELIVERY_MESSAGE_CHANNEL, - messageContent, - }) -} - -export function normalizeAgisoMessageTemplate(value: unknown): string { - return String(value || '') - .replaceAll('\\r\\n', '\n') - .replaceAll('\\n', '\n') - .replaceAll('\r\n', '\n') -} - -async function deliverAgisoXianyuMessageForTask({ - order, - task, - channel, - messageContent, - claimUrl = '', -}: DeliverMessageInput = {}) { - return deliverAgisoXianyuMessageForTaskWithDeps({ - order, - task, - channel, - messageContent, - claimUrl, - }) -} - -export async function deliverAgisoXianyuMessageForTaskWithDeps({ - order, - task, - channel, - messageContent, - claimUrl = '', -}: DeliverMessageInput = {}, deps: DeliverMessageDeps = {}) { - const now = deps.nowIso || nowIso - const findSuccessfulDelivery = deps.findLatestSuccessfulMessageDelivery || findLatestSuccessfulMessageDelivery - const insertMessageDelivery = deps.createMessageDelivery || createMessageDelivery - const patchMessageDelivery = deps.updateMessageDelivery || updateMessageDelivery - const sendRequest = deps.fetch || (fetch as FetchLike) - - if (!order || !task || !messageContent) { - return { sent: false, skipped: true, reason: 'missing_message_context' } - } - - const config = resolveAgisoXianyuMessagingConfig(order) - if (!config.enabled) { - return { sent: false, skipped: true, reason: 'messaging_disabled' } - } - - const endpoint = String(config.sendMessageEndpoint || '').trim() - const accessToken = String(config.accessToken || '').trim() - const appSecret = String(config.appSecret || runtimeConfig.platforms?.agiso?.appSecret || '').trim() - if (!endpoint || !accessToken || !appSecret) { - return { sent: false, skipped: true, reason: 'missing_endpoint_or_access_token_or_app_secret' } - } - - const successful = await findSuccessfulDelivery({ - provider: 'agiso', - platform: String(order.platform || '').trim() || 'unknown', - shopId: String(order.shop_id || '').trim(), - platformOrderId: String(order.platform_order_id || '').trim(), - channel, - claimUrl: String(claimUrl || ''), - }) - if (successful) { - return { sent: false, skipped: true, reason: 'already_sent', deliveryId: successful.id } - } - - const url = buildRequestUrl(endpoint) - const requestBody = buildRequestBody({ - tid: String(order.platform_order_id || ''), - msg: messageContent, - appSecret, - }) - const requestHeaders = buildRequestHeaders({ - accessToken, - apiVersion: String(config.apiVersion || '1').trim() || '1', - }) - const createdAt = now() - const delivery = await insertMessageDelivery({ - provider: 'agiso', - platform: String(order.platform || '').trim() || 'unknown', - shopId: String(order.shop_id || '').trim(), - shopName: String(order.shop_name || '').trim(), - channel, - orderId: order.id, - taskId: task.id, - platformOrderId: order.platform_order_id, - recipientKey: order.platform_order_id, - messageContent, - claimUrl, - status: 'pending', - requestUrl: url, - requestHeadersJson: JSON.stringify(maskHeadersForStorage(requestHeaders)), - requestBodyJson: JSON.stringify(maskBodyForStorage(requestBody)), - responseStatus: 0, - responseJson: '{}', - errorMessage: '', - sentAt: null, - createdAt, - updatedAt: createdAt, - }) - if (!delivery) { - return { sent: false, skipped: false, reason: 'delivery_create_failed' } - } - - try { - const response = await sendRequest(url, { - method: 'POST', - headers: requestHeaders, - body: new URLSearchParams(requestBody).toString(), - }) - const rawText = await response.text() - const parsed = safeParseJson(rawText) - const success = isAgisoSendSuccess(response.status, parsed) - const errorMessage = success ? '' : resolveAgisoErrorMessage(parsed, rawText, response.status) - const updated = await patchMessageDelivery(delivery.id, { - status: success ? 'success' : 'failed', - response_status: response.status, - response_json: JSON.stringify(parsed ?? { rawText }), - error_message: errorMessage, - sent_at: success ? now() : null, - updated_at: now(), - }) - - return { - sent: success, - skipped: false, - deliveryId: updated?.id || delivery.id, - responseStatus: response.status, - response: parsed ?? { rawText }, - errorMessage, - } - } catch (error) { - const message = error instanceof Error ? error.message : String(error || '发送消息失败') - await patchMessageDelivery(delivery.id, { - status: 'failed', - response_status: 0, - response_json: '{}', - error_message: message, - sent_at: null, - updated_at: now(), - }) - - return { - sent: false, - skipped: false, - deliveryId: delivery.id, - errorMessage: message, - } - } -} - -function resolveAgisoXianyuMessagingConfig(order: MessageOrder | null | undefined): AgisoMessagingConfig { - const baseConfig = runtimeConfig.platforms.agiso.messaging - const fileDefaults = getAgisoMessagingDefaults() - const shopId = String(order?.shop_id || '').trim() - const shopConfigs = getAgisoShopConfigMap() - const shopConfig = shopId && isPlainObject(shopConfigs[shopId]) ? shopConfigs[shopId] : {} - - return { - ...baseConfig, - ...fileDefaults, - ...shopConfig, - } -} - -function buildRequestUrl(endpoint: string): string { - return new URL(endpoint).toString() -} - -function buildRequestHeaders({ - accessToken, - apiVersion, -}: { accessToken: string; apiVersion: string }): Record { - return { - Authorization: `Bearer ${accessToken}`, - ApiVersion: apiVersion, - 'content-type': 'application/x-www-form-urlencoded; charset=utf-8', - } -} - -function buildRequestBody({ - tid, - msg, - appSecret, -}: { tid: string; msg: string; appSecret: string }): Record { - const timestamp = String(Math.floor(Date.now() / 1000)) - const payload: Record = { - tid, - msg, - timestamp, - } - - payload.sign = generateSign(payload, appSecret) - - return payload -} - -function generateSign(params: Record, appSecret: string): string { - const sortedEntries = Object.entries(params).sort(([left], [right]) => left.localeCompare(right)) - let raw = appSecret - - for (const [key, value] of sortedEntries) { - raw += `${key}${value}` - } - - raw += appSecret - - return crypto.createHash('md5').update(raw, 'utf8').digest('hex').toLowerCase() -} - -function renderAgisoClaimMessage({ - order, - task, - claimUrl, - expiredAt, - template, - shopName = '', -}: RenderClaimMessageInput): string { - const source = normalizeAgisoMessageTemplate(String(template || '').trim()) - || '您的订单 {platformOrderId} 已创建领取链接,请在 {expiredAt} 前完成领取:{claimUrl}' - - return renderAgisoMessageTemplate({ - order, - task, - template: source, - shopName, - claimUrl, - expiredAt: String(expiredAt || '尽快'), - }) -} - -export function renderAgisoAutoDeliveryMessage({ - order, - task, - template, - shopName = '', -}: RenderAutoDeliveryMessageInput): string { - const source = normalizeAgisoMessageTemplate(String(template || '').trim()) - || '您的订单 {platformOrderId} 已完成自动发货,请注意查收。' - - return renderAgisoMessageTemplate({ - order, - task, - template: source, - shopName, - resultMessage: '自动发货成功', - }) -} - -function renderAgisoMessageTemplate({ - order, - task, - template, - shopName = '', - claimUrl = '', - expiredAt = '', - resultMessage = '', -}: RenderMessageTemplateInput = {}): string { - const resolvedShopName = String(shopName || order?.shop_name || order?.shop_id || '').trim() - - return normalizeAgisoMessageTemplate(template) - .replaceAll('{platformOrderId}', String(order?.platform_order_id || '')) - .replaceAll('{taskNo}', String(task?.task_no || '')) - .replaceAll('{shopName}', resolvedShopName) - .replaceAll('{shopId}', String(order?.shop_id || '')) - .replaceAll('{claimUrl}', String(claimUrl || '')) - .replaceAll('{expiredAt}', String(expiredAt || '')) - .replaceAll('{resultMessage}', String(resultMessage || '')) -} - -function isAgisoSendSuccess(statusCode: number, payload: unknown): boolean { - if (statusCode < 200 || statusCode >= 300) { - return false - } - - if (!payload || typeof payload !== 'object') { - return true - } - - const normalizedPayload = payload as JsonObject - if (normalizedPayload.IsSuccess === true) { - return true - } - - if (Number(normalizedPayload.Error_Code) === 0) { - return true - } - - if (Number(normalizedPayload.code) === 0) { - return true - } - - return false -} - -function resolveAgisoErrorMessage(payload: unknown, rawText: string, statusCode: number): string { - if (payload && typeof payload === 'object') { - const normalizedPayload = payload as JsonObject - for (const value of [ - normalizedPayload.Error_Msg, - normalizedPayload.msg, - normalizedPayload.message, - normalizedPayload.error, - ]) { - const normalized = String(value || '').trim() - if (normalized) { - return normalized - } - } - } - - const text = String(rawText || '').trim() - return text || `Agiso 咸鱼发消息失败,HTTP ${statusCode}` -} - -function safeParseJson(rawText: string): JsonObject | null { - const normalized = String(rawText || '').trim() - - if (!normalized) { - return null - } - - try { - return JSON.parse(normalized) - } catch { - return null - } -} - -function maskHeadersForStorage(headers: Record): Record { - const output = { ...headers } - - if (output.Authorization) { - output.Authorization = '[masked]' - } - - return output -} - -function maskBodyForStorage(body: Record): Record { - const output = { ...body } - - if (output.sign) { - output.sign = '[masked]' - } - - return output -} - -function isPlainObject(value: unknown): value is JsonObject { - return Object.prototype.toString.call(value) === '[object Object]' -} diff --git a/apps/backend/src/services/platforms/agiso/xianyu/order-detail-service.test.ts b/apps/backend/src/services/platforms/agiso/xianyu/order-detail-service.test.ts deleted file mode 100644 index 6277808f..00000000 --- a/apps/backend/src/services/platforms/agiso/xianyu/order-detail-service.test.ts +++ /dev/null @@ -1,219 +0,0 @@ -import test from 'node:test' -import assert from 'node:assert/strict' - -import { runtimeConfig } from '../../../../config/runtime.js' -import { - enrichAgisoXianyuTradeOrderWithDeps, - queryAgisoXianyuOrderDetailWithDeps, - resolveAgisoXianyuOrderDeliveryState, -} from './order-detail-service.js' - -test('resolveAgisoXianyuOrderDeliveryState detects shipped order by ship time or status', () => { - assert.deepEqual(resolveAgisoXianyuOrderDeliveryState({ ship_time: 1713097840 }), { - shipped: true, - shipTime: 1713097840, - orderStatus: 0, - }) - - assert.deepEqual(resolveAgisoXianyuOrderDeliveryState({ order_status: 3 }), { - shipped: true, - shipTime: 0, - orderStatus: 3, - }) - - assert.deepEqual(resolveAgisoXianyuOrderDeliveryState({ order_status: 2 }), { - shipped: false, - shipTime: 0, - orderStatus: 2, - }) -}) - -test('queryAgisoXianyuOrderDetailWithDeps parses successful detail payload and masks network dependency', async () => { - const originalShops = runtimeConfig.platforms.agiso.messaging.shops - const originalAppSecret = runtimeConfig.platforms.agiso.appSecret - const calls = [] - - runtimeConfig.platforms.agiso.appSecret = '' - runtimeConfig.platforms.agiso.messaging.shops = { - 'shop-detail-test': { - accessToken: 'access-token', - appSecret: 'shop-secret', - tradeDetailEndpoint: 'https://example.com/detail', - tradeDetailApiVersion: '2', - tradeDetailTimeoutMs: '3000', - }, - } - - try { - const result = await queryAgisoXianyuOrderDetailWithDeps({ - shopId: 'shop-detail-test', - platformOrderId: 'P-DETAIL-10001', - requestId: 'req-detail', - }, { - fetchWithTimeout: async (url, options, timeoutMs) => { - calls.push({ url, options, timeoutMs }) - return { - status: 200, - text: async () => JSON.stringify({ - IsSuccess: true, - Data: { - total_fee: '25.50', - pay_time: '2026-04-14 20:30:40', - ship_time: 1713097840, - order_status: 3, - item: { - item_id: 'item-1', - sku: 'dnf-cdk-a|商品名称:DNF礼包', - quantity: 2, - }, - }, - }), - } - }, - }) - - assert.equal(result.success, true) - assert.equal(result.responseStatus, 200) - assert.equal(result.totalAmountFen, 2550) - assert.equal(result.shipped, true) - assert.equal(result.shipTime, 1713097840) - assert.equal(result.orderStatus, 3) - assert.equal(calls[0]?.url, 'https://example.com/detail') - assert.equal(calls[0]?.timeoutMs, 3000) - assert.equal(calls[0]?.options.headers.Authorization, 'Bearer access-token') - assert.equal(calls[0]?.options.headers.ApiVersion, '2') - assert.match(String(calls[0]?.options.body), /tid=P-DETAIL-10001/) - assert.match(String(calls[0]?.options.body), /sign=/) - } finally { - runtimeConfig.platforms.agiso.messaging.shops = originalShops - runtimeConfig.platforms.agiso.appSecret = originalAppSecret - } -}) - -test('queryAgisoXianyuOrderDetailWithDeps returns business_error with structured message', async () => { - const originalShops = runtimeConfig.platforms.agiso.messaging.shops - - runtimeConfig.platforms.agiso.messaging.shops = { - 'shop-detail-test': { - accessToken: 'access-token', - appSecret: 'shop-secret', - tradeDetailEndpoint: 'https://example.com/detail', - }, - } - - try { - const result = await queryAgisoXianyuOrderDetailWithDeps({ - shopId: 'shop-detail-test', - platformOrderId: 'P-DETAIL-10002', - }, { - fetchWithTimeout: async () => ({ - status: 200, - text: async () => JSON.stringify({ - IsSuccess: false, - Error_Msg: '订单不存在', - }), - }), - }) - - assert.equal(result.success, false) - assert.equal(result.reason, 'business_error') - assert.equal(result.errorMessage, '订单不存在') - assert.equal(result.responseStatus, 200) - } finally { - runtimeConfig.platforms.agiso.messaging.shops = originalShops - } -}) - -test('queryAgisoXianyuOrderDetailWithDeps skips missing config without fetching', async () => { - const originalShops = runtimeConfig.platforms.agiso.messaging.shops - const originalAppSecret = runtimeConfig.platforms.agiso.appSecret - let fetchCalled = false - - runtimeConfig.platforms.agiso.messaging.shops = {} - runtimeConfig.platforms.agiso.appSecret = '' - - try { - const result = await queryAgisoXianyuOrderDetailWithDeps({ - shopId: 'missing-shop', - platformOrderId: 'P-DETAIL-10003', - }, { - fetchWithTimeout: async () => { - fetchCalled = true - return { status: 200, text: async () => '{}' } - }, - }) - - assert.equal(result.success, false) - assert.equal(result.reason, 'missing_config') - assert.equal(fetchCalled, false) - } finally { - runtimeConfig.platforms.agiso.messaging.shops = originalShops - runtimeConfig.platforms.agiso.appSecret = originalAppSecret - } -}) - -test('enrichAgisoXianyuTradeOrderWithDeps merges detail payload into incomplete trade', async () => { - const parsed = { - provider: 'agiso', - platform: 'xianyu', - shopId: 'shop-detail-test', - shopName: '', - platformOrderId: 'P-DETAIL-10004', - totalAmount: 0, - paidAt: null, - buyerId: '', - buyerName: '', - receiverContact: '', - rawPayload: {}, - items: [], - } - - const result = await enrichAgisoXianyuTradeOrderWithDeps(parsed, { requestId: 'req-enrich' }, { - queryAgisoXianyuOrderDetail: async (input) => { - assert.deepEqual(input, { - shopId: 'shop-detail-test', - platformOrderId: 'P-DETAIL-10004', - requestId: 'req-enrich', - }) - - return { - success: true, - reason: '', - errorMessage: '', - responseStatus: 200, - payload: {}, - detailPayload: { - total_fee: '19.90', - pay_time: '2026-04-14 20:30:40', - buyer_id: 'buyer-1', - buyer_name: '测试买家', - receiver_mobile: '13800138000', - shop_name: '详情店铺', - items: [ - { - item_id: 'item-1', - sku: 'dnf-cdk-a|商品名称:DNF礼包', - quantity: 2, - }, - ], - }, - totalAmountFen: 1990, - shipped: false, - shipTime: 0, - orderStatus: 2, - } - }, - }) - - assert.equal(result.enriched, true) - assert.equal(result.totalAmountFen, 1990) - assert.equal(result.parsed.totalAmount, 1990) - assert.equal(result.parsed.buyerId, 'buyer-1') - assert.equal(result.parsed.buyerName, '测试买家') - assert.equal(result.parsed.receiverContact, '13800138000') - assert.equal(result.parsed.shopName, '详情店铺') - assert.equal(result.parsed.items.length, 1) - assert.equal(result.parsed.items[0]?.skuCode, 'dnf-cdk-a') - assert.equal(result.parsed.items[0]?.quantity, 2) - assert.deepEqual(result.parsed.rawPayload._agisoTradeDetail.total_fee, '19.90') -}) diff --git a/apps/backend/src/services/platforms/agiso/xianyu/order-detail-service.ts b/apps/backend/src/services/platforms/agiso/xianyu/order-detail-service.ts deleted file mode 100644 index 3905e842..00000000 --- a/apps/backend/src/services/platforms/agiso/xianyu/order-detail-service.ts +++ /dev/null @@ -1,803 +0,0 @@ -import crypto from 'node:crypto' - -import { runtimeConfig } from '../../../../config/runtime.js' -import { getAgisoShopConfig } from '../shop-config-service.js' -import { logWebhook } from '../../../../utils/logger.js' -import { nowIso } from '../../../../utils/time.js' -import { parseAmountToFen } from '../../../../utils/money.js' -import { parseJsonObject } from '../../../../utils/json.js' - -const DEFAULT_DETAIL_TIMEOUT_MS = 5000 - -type JsonObject = Record - -type AgisoParsedTrade = { - provider?: string - platform?: string - shopId?: string - shopName?: string - platformOrderId?: string - totalAmount?: number - paidAt?: string | null - buyerId?: string - buyerName?: string - receiverContact?: string - rawPayload?: JsonObject - items?: AgisoOrderItem[] - [key: string]: unknown -} - -type AgisoOrderItem = { - skuCode: string - skuName: string - externalItemId: string - externalSkuCode: string - externalSkuName: string - quantity: number - spec: JsonObject - snapshot: { - externalItemId: string - externalSkuCode: string - externalSkuName: string - } -} - -type AgisoDetailQueryInput = { - shopId?: string - platformOrderId?: string - requestId?: string -} - -type FetchResponseLike = { - status: number - text: () => Promise -} - -type FetchWithTimeout = ( - url: string, - options: RequestInit, - timeoutMs: number, -) => Promise - -type AgisoDetailDeps = { - fetchWithTimeout?: FetchWithTimeout - queryAgisoXianyuOrderDetail?: ( - input: Required, - ) => Promise -} - -type AgisoTradeDetailConfig = { - endpoint: string - apiVersion: string - accessToken: string - appSecret: string - timeoutMs: number -} - -type AgisoShopConfig = { - accessToken?: string - appSecret?: string - tradeDetailEndpoint?: string - tradeDetailApiVersion?: string - tradeDetailTimeoutMs?: string | number -} - -type DeliveryState = { - shipped: boolean - shipTime: number - orderStatus: number -} - -type AgisoDetailResult = { - success: boolean - reason: string - errorMessage: string - responseStatus: number - payload: JsonObject - detailPayload: JsonObject - totalAmountFen: number - shipped: boolean - shipTime: number - orderStatus: number -} - -type ExternalSkuDescriptor = { - raw: string - externalSkuCode: string - externalSkuName: string -} - -export async function enrichAgisoXianyuTradeOrder(parsed: AgisoParsedTrade, { requestId = '' } = {}) { - return enrichAgisoXianyuTradeOrderWithDeps(parsed, { requestId }) -} - -export async function enrichAgisoXianyuTradeOrderWithDeps( - parsed: AgisoParsedTrade, - { requestId = '' } = {}, - deps: AgisoDetailDeps = {}, -) { - const queryOrderDetail = deps.queryAgisoXianyuOrderDetail || queryAgisoXianyuOrderDetail - - if (!shouldHydrateAgisoXianyuTradeOrder(parsed)) { - return { parsed, enriched: false, reason: 'not_needed' } - } - - const detailResult = await queryOrderDetail({ - shopId: parsed.shopId, - platformOrderId: parsed.platformOrderId, - requestId, - }) - - if (!detailResult.success) { - return { - parsed, - enriched: false, - reason: detailResult.reason, - errorMessage: detailResult.errorMessage, - } - } - - const detailPayload = detailResult.detailPayload - if (detailResult.totalAmountFen <= 0) { - return { - parsed: mergeEnrichedTrade(parsed, detailPayload, { keepOriginalAmount: true }), - enriched: false, - reason: 'amount_still_missing', - } - } - - const merged = mergeEnrichedTrade(parsed, detailPayload) - logWebhook('[agiso/xianyu/order-detail]', 'Agiso 咸鱼订单详情补查成功', { - requestId, - platform: parsed.platform, - shopId: parsed.shopId, - platformOrderId: parsed.platformOrderId, - totalAmountFen: merged.totalAmount, - paidAt: merged.paidAt, - }) - - return { - parsed: merged, - enriched: true, - totalAmountFen: merged.totalAmount, - } -} - -export async function queryAgisoXianyuOrderDetail({ shopId = '', platformOrderId = '', requestId = '' } = {}) { - return queryAgisoXianyuOrderDetailWithDeps({ shopId, platformOrderId, requestId }) -} - -export async function queryAgisoXianyuOrderDetailWithDeps( - { shopId = '', platformOrderId = '', requestId = '' } = {}, - deps: AgisoDetailDeps = {}, -): Promise { - const requestWithTimeout = deps.fetchWithTimeout || fetchWithTimeout - const normalizedShopId = String(shopId || '').trim() - const normalizedPlatformOrderId = String(platformOrderId || '').trim() - const config = resolveAgisoXianyuTradeDetailConfig(normalizedShopId) - - if (!config.endpoint || !config.accessToken || !config.appSecret) { - logWebhook('[agiso/xianyu/order-detail]', '跳过 Agiso 咸鱼订单详情补查:缺少必要配置', { - requestId, - platform: 'xianyu', - shopId: normalizedShopId, - platformOrderId: normalizedPlatformOrderId, - hasEndpoint: Boolean(config.endpoint), - hasAccessToken: Boolean(config.accessToken), - hasAppSecret: Boolean(config.appSecret), - }, { level: 'warn' }) - - return createFailedDetailResult('missing_config') - } - - const requestBody = buildRequestBody({ - platformOrderId: normalizedPlatformOrderId, - appSecret: config.appSecret, - }) - const requestHeaders = buildRequestHeaders({ - accessToken: config.accessToken, - apiVersion: config.apiVersion, - }) - - logWebhook('[agiso/xianyu/order-detail]', '开始补查 Agiso 咸鱼订单详情', { - requestId, - platform: 'xianyu', - shopId: normalizedShopId, - platformOrderId: normalizedPlatformOrderId, - endpoint: config.endpoint, - }) - - try { - const response = await requestWithTimeout(config.endpoint, { - method: 'POST', - headers: requestHeaders, - body: new URLSearchParams(requestBody).toString(), - }, config.timeoutMs) - const rawText = await response.text() - const payload = parseJsonObject(rawText, { preserveLargeIntegers: true }) as JsonObject - const detailPayload = extractAgisoDetailPayload(payload) - const delivery = resolveAgisoXianyuOrderDeliveryState(detailPayload) - const totalAmountFen = resolveTotalAmountFen(detailPayload) - - if (response.status < 200 || response.status >= 300) { - const message = resolveAgisoDetailErrorMessage(payload, rawText, response.status) - logWebhook('[agiso/xianyu/order-detail]', 'Agiso 咸鱼订单详情补查失败', { - requestId, - platform: 'xianyu', - shopId: normalizedShopId, - platformOrderId: normalizedPlatformOrderId, - responseStatus: response.status, - errorMessage: message, - }, { level: 'warn' }) - - return createFailedDetailResult('http_error', { - responseStatus: response.status, - payload, - detailPayload, - errorMessage: message, - totalAmountFen, - delivery, - }) - } - - if (!isAgisoDetailSuccess(payload)) { - const message = resolveAgisoDetailErrorMessage(payload, rawText, response.status) - logWebhook('[agiso/xianyu/order-detail]', 'Agiso 咸鱼订单详情补查返回业务失败', { - requestId, - platform: 'xianyu', - shopId: normalizedShopId, - platformOrderId: normalizedPlatformOrderId, - responseStatus: response.status, - errorMessage: message, - response: payload, - }, { level: 'warn' }) - - return createFailedDetailResult('business_error', { - responseStatus: response.status, - payload, - detailPayload, - errorMessage: message, - totalAmountFen, - delivery, - }) - } - - if (!isPlainObject(detailPayload) || Object.keys(detailPayload).length === 0) { - logWebhook('[agiso/xianyu/order-detail]', 'Agiso 咸鱼订单详情补查未返回可用订单体', { - requestId, - platform: 'xianyu', - shopId: normalizedShopId, - platformOrderId: normalizedPlatformOrderId, - responseStatus: response.status, - }, { level: 'warn' }) - - return createFailedDetailResult('empty_detail_payload', { - responseStatus: response.status, - payload, - }) - } - - if (totalAmountFen <= 0) { - logWebhook('[agiso/xianyu/order-detail]', 'Agiso 咸鱼订单详情已返回,但仍未解析出金额', { - requestId, - platform: 'xianyu', - shopId: normalizedShopId, - platformOrderId: normalizedPlatformOrderId, - responseStatus: response.status, - detailKeys: Object.keys(detailPayload), - }, { level: 'warn' }) - } - - return { - success: true, - reason: '', - errorMessage: '', - responseStatus: response.status, - payload: isPlainObject(payload) ? payload : {}, - detailPayload, - totalAmountFen, - shipped: delivery.shipped, - shipTime: delivery.shipTime, - orderStatus: delivery.orderStatus, - } - } catch (error) { - const message = error instanceof Error ? error.message : String(error || 'Agiso 咸鱼订单详情补查失败') - logWebhook('[agiso/xianyu/order-detail]', 'Agiso 咸鱼订单详情补查异常', { - requestId, - platform: 'xianyu', - shopId: normalizedShopId, - platformOrderId: normalizedPlatformOrderId, - errorMessage: message, - }, { level: 'warn' }) - - return createFailedDetailResult('request_failed', { - errorMessage: message, - }) - } -} - -function shouldHydrateAgisoXianyuTradeOrder(parsed: AgisoParsedTrade | null | undefined): boolean { - return parsed?.provider === 'agiso' - && parsed?.platform === 'xianyu' - && String(parsed?.platformOrderId || '').trim() - && Number(parsed?.totalAmount || 0) <= 0 -} - -function resolveAgisoXianyuTradeDetailConfig(shopId: string): AgisoTradeDetailConfig { - const baseConfig = runtimeConfig.platforms.agiso.tradeDetail - const shopConfig = (getAgisoShopConfig(shopId) || {}) as AgisoShopConfig - - return { - endpoint: String(shopConfig.tradeDetailEndpoint || baseConfig.endpoint || '').trim(), - apiVersion: String(shopConfig.tradeDetailApiVersion || baseConfig.apiVersion || '1').trim() || '1', - accessToken: String(shopConfig.accessToken || '').trim(), - appSecret: String(shopConfig.appSecret || runtimeConfig.platforms?.agiso?.appSecret || '').trim(), - timeoutMs: normalizePositiveInteger(shopConfig.tradeDetailTimeoutMs || baseConfig.timeoutMs, DEFAULT_DETAIL_TIMEOUT_MS), - } -} - -function buildRequestHeaders({ - accessToken, - apiVersion, -}: { accessToken: string; apiVersion: string }): Record { - return { - Authorization: `Bearer ${accessToken}`, - ApiVersion: apiVersion, - 'content-type': 'application/x-www-form-urlencoded; charset=utf-8', - } -} - -function buildRequestBody({ - platformOrderId, - appSecret, -}: { platformOrderId: string; appSecret: string }): Record { - const timestamp = String(Math.floor(Date.now() / 1000)) - const payload: Record = { - tid: String(platformOrderId || '').trim(), - timestamp, - } - - payload.sign = generateSign(payload, appSecret) - return payload -} - -function generateSign(params: Record, appSecret: string): string { - const sortedEntries = Object.entries(params).sort(([left], [right]) => left.localeCompare(right)) - let raw = appSecret - - for (const [key, value] of sortedEntries) { - raw += `${key}${value}` - } - - raw += appSecret - - return crypto.createHash('md5').update(raw, 'utf8').digest('hex').toLowerCase() -} - -function extractAgisoDetailPayload(payload) { - if (!isPlainObject(payload)) { - return {} - } - - for (const key of ['Data', 'data', 'Result', 'result', 'Trade', 'trade', 'Order', 'order']) { - if (isPlainObject(payload[key])) { - return payload[key] - } - } - - return payload -} - -export function resolveAgisoXianyuOrderDeliveryState(payload) { - const shipTime = normalizeTimestampValue(pickFirstNonEmpty([ - payload?.ship_time, - payload?.shipTime, - payload?.ShipTime, - payload?.delivery_time, - payload?.deliveryTime, - ])) - const orderStatus = normalizeInteger( - pickFirstNonEmpty([ - payload?.order_status, - payload?.orderStatus, - payload?.status, - ]), - ) - - return { - shipped: shipTime > 0 || orderStatus >= 3, - shipTime, - orderStatus, - } -} - -function resolveTotalAmountFen(payload) { - const fenAmount = normalizeFenInteger(pickFirstNonEmpty([ - payload.payment, - payload.Payment, - payload.post_fee, - payload.postFee, - payload.item?.price, - ])) - - if (fenAmount > 0) { - return fenAmount - } - - return parseAmountToFen(pickFirstNonEmpty([ - payload.total_fee, - payload.totalFee, - payload.TotalFee, - payload.pay_fee, - payload.payFee, - payload.PayFee, - payload.actual_fee, - payload.actualFee, - payload.ActualFee, - payload.total_amount, - payload.totalAmount, - payload.Amount, - payload.amount, - ])) -} - -function resolvePaidAt(payload, fallbackValue) { - const providerPaidAt = normalizeProviderDateTime( - pickFirstNonEmpty([ - payload.paid_at, - payload.paidAt, - payload.pay_time, - payload.payTime, - payload.PayTime, - fallbackValue, - ]), - ) - - if (providerPaidAt) { - return providerPaidAt - } - - const rawStatus = normalizeInteger( - pickFirstNonEmpty([payload.order_status, payload.orderStatus, payload.status]), - ) - - if (rawStatus === 2 || rawStatus === 3 || rawStatus === 4) { - return nowIso() - } - - return null -} - -function mergeEnrichedTrade(parsed, detailPayload, { keepOriginalAmount = false } = {}) { - const mergedPayload = { - ...parsed.rawPayload, - _agisoTradeDetail: detailPayload, - } - const totalAmountFen = resolveTotalAmountFen(detailPayload) - const mergedItems = normalizeOrderItems(detailPayload, normalizeOrderItems(parsed.rawPayload, parsed.items)) - - return { - ...parsed, - buyerId: pickFirstNonEmpty([ - parsed.buyerId, - detailPayload.encryption_buyer_id, - detailPayload.buyer_id, - detailPayload.buyerId, - detailPayload.BuyerId, - detailPayload.BuyerOpenUid, - detailPayload.buyer_open_uid, - ]), - buyerName: pickFirstNonEmpty([ - parsed.buyerName, - detailPayload.buyer_name, - detailPayload.buyerName, - detailPayload.BuyerName, - detailPayload.buyer_nick, - detailPayload.nick, - detailPayload.BuyerNick, - ]), - receiverContact: pickFirstNonEmpty([ - parsed.receiverContact, - detailPayload.receiver_contact, - detailPayload.receiverContact, - detailPayload.receiver_mobile, - detailPayload.receiverMobile, - detailPayload.mobile, - detailPayload.phone, - ]), - shopName: pickFirstNonEmpty([ - parsed.shopName, - detailPayload.shop_name, - detailPayload.shopName, - detailPayload.ShopName, - detailPayload.seller_name, - detailPayload.sellerName, - detailPayload.seller_nick, - detailPayload.sellerNick, - detailPayload.SellerNick, - ]), - totalAmount: keepOriginalAmount ? parsed.totalAmount : (totalAmountFen || parsed.totalAmount), - paidAt: parsed.paidAt || resolvePaidAt(detailPayload, parsed.paidAt), - rawPayload: mergedPayload, - items: mergedItems, - } -} - -function normalizeOrderItems(payload, fallbackItems = []) { - const candidates = [ - payload.item ? [payload.item] : null, - payload.items, - payload.Items, - payload.orders, - payload.Orders, - payload.order_list, - payload.OrderList, - ] - const items = candidates.find((item) => Array.isArray(item) && item.length > 0) - - if (!Array.isArray(items) || items.length === 0) { - return Array.isArray(fallbackItems) ? fallbackItems : [] - } - - return items.map((item) => { - const source = isPlainObject(item) ? item : {} - const skuDescriptor = parseExternalSkuDescriptor( - pickFirstNonEmpty([ - source.sku, - source.Sku, - payload.sku, - payload.Sku, - ]), - ) - const skuCode = pickFirstNonEmpty([ - skuDescriptor.externalSkuCode, - source.OuterSkuId, - source.outerSkuId, - source.outer_sku_id, - source.OuterIid, - source.outerIid, - source.outer_iid, - source.sku_code, - source.skuCode, - source.goods_sku, - source.item_id, - source.itemId, - source.goods_id, - source.goodsId, - source.NumIid, - source.num_iid, - payload.item_id, - payload.itemId, - ]) - const externalItemId = pickFirstNonEmpty([ - source.item_id, - source.itemId, - source.goods_id, - source.goodsId, - payload.item_id, - payload.itemId, - ]) - const externalSkuName = pickFirstNonEmpty([ - skuDescriptor.externalSkuName, - source.Title, - source.title, - source.sku_name, - source.skuName, - source.goods_name, - source.goodsName, - skuCode, - ]) - - return { - skuCode, - skuName: externalSkuName, - externalItemId, - externalSkuCode: pickFirstNonEmpty([skuDescriptor.externalSkuCode, skuCode, externalItemId]), - externalSkuName, - quantity: Math.max( - 1, - normalizeInteger( - pickFirstNonEmpty([ - source.quantity, - source.num, - source.Num, - source.buy_amount, - payload.quantity, - payload.num, - payload.Num, - ]), - ) || 1, - ), - spec: source, - snapshot: { - externalItemId, - externalSkuCode: pickFirstNonEmpty([skuDescriptor.externalSkuCode, skuCode, externalItemId]), - externalSkuName, - }, - } - }) -} - -function parseExternalSkuDescriptor(rawValue) { - const raw = String(rawValue || '').trim() - if (!raw) { - return { - raw, - externalSkuCode: '', - externalSkuName: '', - } - } - - const parts = raw.split('|').map((part) => String(part || '').trim()).filter(Boolean) - let externalSkuCode = '' - let externalSkuName = '' - - for (const part of parts) { - if (!externalSkuCode && !part.includes(':') && !part.includes(':')) { - externalSkuCode = part - continue - } - - const separatorIndex = Math.max(part.indexOf(':'), part.indexOf(':')) - if (separatorIndex >= 0) { - const label = part.slice(0, separatorIndex).trim() - const value = part.slice(separatorIndex + 1).trim() - if (value && ['商品名称', '商品名', 'sku名称', '规格名称', '名称', '商品'].includes(label)) { - externalSkuName = value - } - } - } - - if (!externalSkuCode && parts[0]) { - externalSkuCode = parts[0] - } - - if (!externalSkuName) { - externalSkuName = parts - .map((part) => { - const separatorIndex = Math.max(part.indexOf(':'), part.indexOf(':')) - return separatorIndex >= 0 ? part.slice(separatorIndex + 1).trim() : '' - }) - .find(Boolean) || '' - } - - return { - raw, - externalSkuCode, - externalSkuName, - } -} - -function isAgisoDetailSuccess(payload) { - if (!payload || typeof payload !== 'object' || Object.keys(payload).length === 0) { - return false - } - - if (payload.IsSuccess === true) { - return true - } - - if (Number(payload.Error_Code) === 0) { - return true - } - - if (Number(payload.code) === 0) { - return true - } - - if (Number(payload.success) === 1 || payload.success === true) { - return true - } - - return false -} - -function resolveAgisoDetailErrorMessage(payload, rawText, statusCode) { - if (payload && typeof payload === 'object') { - for (const value of [payload.Error_Msg, payload.msg, payload.message, payload.error]) { - const normalized = String(value || '').trim() - if (normalized) { - return normalized - } - } - } - - const text = String(rawText || '').trim() - return text || `Agiso 咸鱼订单详情接口失败,HTTP ${statusCode}` -} - -async function fetchWithTimeout(url, options, timeoutMs) { - const controller = new AbortController() - const timer = setTimeout(() => controller.abort(new Error('Agiso 咸鱼订单详情请求超时')), timeoutMs) - - try { - return await fetch(url, { - ...options, - signal: controller.signal, - }) - } finally { - clearTimeout(timer) - } -} - -function normalizeProviderDateTime(value) { - const normalized = String(value || '').trim() - - if (!normalized) { - return null - } - - if (/^\d{10,13}$/.test(normalized)) { - const timestamp = normalized.length === 13 ? Number(normalized) : Number(normalized) * 1000 - - if (Number.isFinite(timestamp)) { - return new Date(timestamp).toISOString() - } - } - - const isoLike = normalized.replace(' ', 'T') - const parsed = Date.parse(isoLike) - - if (Number.isNaN(parsed)) { - return null - } - - return new Date(parsed).toISOString() -} - -function normalizeInteger(value) { - const parsed = Number(value) - return Number.isFinite(parsed) ? Math.round(parsed) : 0 -} - -function normalizePositiveInteger(value, fallbackValue) { - const parsed = Number(value) - return Number.isFinite(parsed) && parsed > 0 ? Math.round(parsed) : fallbackValue -} - -function normalizeFenInteger(value) { - const parsed = Number(value) - return Number.isFinite(parsed) && parsed > 0 ? Math.round(parsed) : 0 -} - -function normalizeTimestampValue(value) { - const parsed = Number(value) - return Number.isFinite(parsed) && parsed > 0 ? Math.round(parsed) : 0 -} - -function createFailedDetailResult(reason, { - responseStatus = 0, - payload = {}, - detailPayload = {}, - errorMessage = '', - totalAmountFen = 0, - delivery = { shipped: false, shipTime: 0, orderStatus: 0 }, -} = {}) { - return { - success: false, - reason: String(reason || '').trim(), - errorMessage: String(errorMessage || '').trim(), - responseStatus: Number(responseStatus || 0), - payload: isPlainObject(payload) ? payload : {}, - detailPayload: isPlainObject(detailPayload) ? detailPayload : {}, - totalAmountFen: Number(totalAmountFen || 0), - shipped: Boolean(delivery?.shipped), - shipTime: Number(delivery?.shipTime || 0), - orderStatus: Number(delivery?.orderStatus || 0), - } -} - -function pickFirstNonEmpty(values) { - for (const value of values) { - const normalized = String(value || '').trim() - if (normalized) { - return normalized - } - } - - return '' -} - -function isPlainObject(value) { - return Object.prototype.toString.call(value) === '[object Object]' -} diff --git a/apps/backend/src/services/platforms/cloudtentacles/catalog-service.ts b/apps/backend/src/services/platforms/cloudtentacles/catalog-service.ts index 114cb835..9b86e2d0 100644 --- a/apps/backend/src/services/platforms/cloudtentacles/catalog-service.ts +++ b/apps/backend/src/services/platforms/cloudtentacles/catalog-service.ts @@ -1,6 +1,6 @@ -import { createHttpError } from '../../../utils/http.js' +import { createHttpError, type HttpErrorLike } from '../../../utils/http.js' import { cloudtentaclesRequest } from './http-client.js' -import { resolveCloudtentaclesConfig } from './shared.js' +import { resolveCloudtentaclesConfig } from './helpers.js' type JsonObject = Record @@ -86,6 +86,13 @@ export async function buyCloudtentaclesSku(payload: JsonObject = {}) { }, businessErrorStatusCode: 401, businessErrorCode: 'cloudtentacles_sku_buy_failed', + context: { + operation: 'sku_buy', + skuId, + count, + sourceKey: payload.sourceKey, + accountLabel: payload.accountLabel, + }, }) return { @@ -113,17 +120,14 @@ export async function useCloudtentaclesSku(payload: JsonObject = {}) { } const config = resolveCloudtentaclesConfig(payload) - const result = await cloudtentaclesRequest(config.skuUsePath, { + const result = await useCloudtentaclesSkuRequest({ ...config, - method: 'POST', token, - body: { - id: skuId, - virtual_number_id: virtualNumberId, - phone, - }, - businessErrorStatusCode: 401, - businessErrorCode: 'cloudtentacles_sku_use_failed', + skuId, + virtualNumberId, + phone, + sourceKey: payload.sourceKey, + accountLabel: payload.accountLabel, }) const responseData = isPlainObject(result.payload?.data) ? result.payload.data : {} @@ -140,6 +144,53 @@ export async function useCloudtentaclesSku(payload: JsonObject = {}) { } } +async function useCloudtentaclesSkuRequest(payload: JsonObject) { + try { + return await cloudtentaclesRequest(payload.skuUsePath, { + ...payload, + method: 'POST', + token: payload.token, + body: { + id: payload.skuId, + virtual_number_id: payload.virtualNumberId, + phone: payload.phone, + }, + businessErrorStatusCode: 401, + businessErrorCode: 'cloudtentacles_sku_use_failed', + context: { + operation: 'sku_use', + skuId: payload.skuId, + virtualNumberId: payload.virtualNumberId, + phoneMasked: maskPhone(payload.phone), + sourceKey: payload.sourceKey, + accountLabel: payload.accountLabel, + }, + }) + } catch (error) { + if (isCloudtentaclesSkuUseLimitMessage(error instanceof Error ? error.message : '')) { + const currentError = error as Error & HttpErrorLike + currentError.statusCode = 409 + currentError.errorCode = 'cloudtentacles_sku_use_limit_reached' + } + + throw error + } +} + +function isCloudtentaclesSkuUseLimitMessage(message: unknown) { + const normalized = String(message || '').trim() + return normalized.includes('最大领取次数') || normalized.includes('领取次数') +} + +function maskPhone(value: unknown) { + const text = String(value || '').trim() + if (text.length <= 4) { + return text ? '****' : '' + } + + return `${text.slice(0, 3)}****${text.slice(-4)}` +} + function mapCategoryItem(item: unknown) { const source = isPlainObject(item) ? item : {} diff --git a/apps/backend/src/services/platforms/cloudtentacles/crypto-service.ts b/apps/backend/src/services/platforms/cloudtentacles/crypto-service.ts index 5c905738..22148d77 100644 --- a/apps/backend/src/services/platforms/cloudtentacles/crypto-service.ts +++ b/apps/backend/src/services/platforms/cloudtentacles/crypto-service.ts @@ -1,7 +1,7 @@ import { createHash, constants, publicEncrypt } from 'node:crypto' import { createHttpError } from '../../../utils/http.js' -import { resolveCloudtentaclesConfig } from './shared.js' +import { resolveCloudtentaclesConfig } from './helpers.js' type JsonObject = Record diff --git a/apps/backend/src/services/platforms/cloudtentacles/debug-flow-service.ts b/apps/backend/src/services/platforms/cloudtentacles/debug-flow-service.ts index 0c22d88f..4f933f65 100644 --- a/apps/backend/src/services/platforms/cloudtentacles/debug-flow-service.ts +++ b/apps/backend/src/services/platforms/cloudtentacles/debug-flow-service.ts @@ -37,7 +37,7 @@ export async function runCloudtentaclesFullDebugFlow(payload: JsonObject = {}) { const beforeAsset = await runFlowStep('查询购买前余额', () => getCloudtentaclesAsset(payload)) await sleep(350) const skuList = await runFlowStep('查询 SKU 列表', () => listCloudtentaclesSku(payload)) - const targetSku = skuList.items.find((item) => Number(item.id || 0) === skuId) || null + const targetSku = skuList.items.find((item: JsonObject) => Number(item.id || 0) === skuId) || null if (!targetSku) { throw createHttpError(`cloudtentacles 未找到 SKU ${skuId}`, { @@ -181,8 +181,10 @@ async function runFlowStep( runner: () => Promise, options: FlowStepOptions = {}, ) { - const retries = Number.isInteger(options.retries) && options.retries > 0 ? options.retries : 0 - const retryDelayMs = Number.isFinite(options.retryDelayMs) && options.retryDelayMs > 0 ? options.retryDelayMs : 1000 + const rawRetries = Number(options.retries) + const rawRetryDelayMs = Number(options.retryDelayMs) + const retries = Number.isInteger(rawRetries) && rawRetries > 0 ? rawRetries : 0 + const retryDelayMs = Number.isFinite(rawRetryDelayMs) && rawRetryDelayMs > 0 ? rawRetryDelayMs : 1000 for (let attempt = 0; attempt <= retries; attempt += 1) { try { diff --git a/apps/backend/src/services/platforms/cloudtentacles/defaults.test.ts b/apps/backend/src/services/platforms/cloudtentacles/defaults.test.ts new file mode 100644 index 00000000..80b56534 --- /dev/null +++ b/apps/backend/src/services/platforms/cloudtentacles/defaults.test.ts @@ -0,0 +1,21 @@ +import test from 'node:test' +import assert from 'node:assert/strict' + +import { + DEFAULT_CLOUDTENTACLES_DEVICE_ID, + DEFAULT_CLOUDTENTACLES_DEVICE_TYPE, + normalizeCloudtentaclesDeviceId, + normalizeCloudtentaclesDeviceType, +} from './defaults.js' + +test('normalizeCloudtentaclesDeviceId maps empty and legacy placeholder to default device', () => { + assert.equal(normalizeCloudtentaclesDeviceId(''), DEFAULT_CLOUDTENTACLES_DEVICE_ID) + assert.equal(normalizeCloudtentaclesDeviceId('-'), DEFAULT_CLOUDTENTACLES_DEVICE_ID) + assert.equal(normalizeCloudtentaclesDeviceId(' custom-device '), 'custom-device') +}) + +test('normalizeCloudtentaclesDeviceType maps empty and legacy zero to default type', () => { + assert.equal(normalizeCloudtentaclesDeviceType(undefined), DEFAULT_CLOUDTENTACLES_DEVICE_TYPE) + assert.equal(normalizeCloudtentaclesDeviceType(0), DEFAULT_CLOUDTENTACLES_DEVICE_TYPE) + assert.equal(normalizeCloudtentaclesDeviceType('2'), 2) +}) diff --git a/apps/backend/src/services/platforms/cloudtentacles/defaults.ts b/apps/backend/src/services/platforms/cloudtentacles/defaults.ts new file mode 100644 index 00000000..784759e2 --- /dev/null +++ b/apps/backend/src/services/platforms/cloudtentacles/defaults.ts @@ -0,0 +1,17 @@ +export const DEFAULT_CLOUDTENTACLES_DEVICE_ID = + "08bc9d8c-fd15-48ea-bc00-8d754076cafc"; +export const DEFAULT_CLOUDTENTACLES_DEVICE_TYPE = 1; + +export function normalizeCloudtentaclesDeviceId(value: unknown) { + const normalized = String(value || "").trim(); + return normalized && normalized !== "-" + ? normalized + : DEFAULT_CLOUDTENTACLES_DEVICE_ID; +} + +export function normalizeCloudtentaclesDeviceType(value: unknown) { + const parsed = Number(value); + return Number.isInteger(parsed) && parsed > 0 + ? parsed + : DEFAULT_CLOUDTENTACLES_DEVICE_TYPE; +} diff --git a/apps/backend/src/services/platforms/cloudtentacles/shared.ts b/apps/backend/src/services/platforms/cloudtentacles/helpers.ts similarity index 91% rename from apps/backend/src/services/platforms/cloudtentacles/shared.ts rename to apps/backend/src/services/platforms/cloudtentacles/helpers.ts index 3676db98..a97ffcc9 100644 --- a/apps/backend/src/services/platforms/cloudtentacles/shared.ts +++ b/apps/backend/src/services/platforms/cloudtentacles/helpers.ts @@ -1,6 +1,12 @@ import { runtimeConfig } from '../../../config/runtime.js' import type { RuntimeConfig } from '../../../types/runtime-config.js' +import { + DEFAULT_CLOUDTENTACLES_DEVICE_ID, + DEFAULT_CLOUDTENTACLES_DEVICE_TYPE, + normalizeCloudtentaclesDeviceId, + normalizeCloudtentaclesDeviceType, +} from './defaults.js' type CloudtentaclesRuntimeConfig = RuntimeConfig['platforms']['cloudtentacles'] type JsonObject = Record @@ -40,8 +46,8 @@ export function resolveCloudtentaclesConfig(overrides: Partial { + let callCount = 0 + const server = await createMockServer(() => { + callCount += 1 + return { + body: callCount === 1 + ? { code: 1, message: 'high-frequency Request' } + : { code: 0, data: 'ok' }, + } + }) + + try { + const result = await cloudtentaclesRequest('/user/get_asset', { + baseUrl: server.baseUrl, + token: 'retry-token', + sourceKey: 'retry-source', + rateLimitIntervalMs: 0, + rateLimitRetries: 1, + rateLimitRetryDelayMs: 1, + }) + + assert.equal(callCount, 2) + assert.equal(result.payload?.data, 'ok') + } finally { + await server.close() + } +}) + +test('cloudtentaclesRequest maps high-frequency exhaustion to 429', async () => { + const server = await createMockServer(() => ({ + body: { code: 1, message: 'high-frequency Request' }, + })) + + try { + await assert.rejects( + cloudtentaclesRequest('/user/get_asset', { + baseUrl: server.baseUrl, + token: 'limited-token', + sourceKey: 'limited-source', + rateLimitIntervalMs: 0, + rateLimitRetries: 0, + }), + (error) => { + assert.equal(error instanceof Error, true) + assert.equal(Reflect.get(error as object, 'statusCode'), 429) + assert.equal(Reflect.get(error as object, 'errorCode'), 'cloudtentacles_rate_limited') + return true + }, + ) + } finally { + await server.close() + } +}) + +test('cloudtentaclesRequest spaces concurrent requests in the same bucket', async () => { + const requestTimes: number[] = [] + const server = await createMockServer(() => { + requestTimes.push(Date.now()) + return { body: { code: 0, data: 'ok' } } + }) + + try { + await Promise.all([ + cloudtentaclesRequest('/sku/list', { + baseUrl: server.baseUrl, + token: 'throttle-token', + sourceKey: 'throttle-source', + rateLimitIntervalMs: 25, + rateLimitRetries: 0, + }), + cloudtentaclesRequest('/user/get_knapsack', { + baseUrl: server.baseUrl, + token: 'throttle-token', + sourceKey: 'throttle-source', + rateLimitIntervalMs: 25, + rateLimitRetries: 0, + }), + ]) + + assert.equal(requestTimes.length, 2) + assert.ok(Math.abs(Number(requestTimes[1]) - Number(requestTimes[0])) >= 20) + } finally { + await server.close() + } +}) + +async function createMockServer(handler: () => MockResponse) { + const server = http.createServer((_request, response) => { + const result = handler() + response.statusCode = result.status || 200 + response.setHeader('content-type', 'application/json') + response.end(JSON.stringify(result.body ?? { code: 0, data: null })) + }) + + await new Promise((resolve) => { + server.listen(0, '127.0.0.1', resolve) + }) + + const address = server.address() + assert.equal(typeof address, 'object') + assert.notEqual(address, null) + + return { + baseUrl: `http://127.0.0.1:${address?.port}`, + close: () => new Promise((resolve, reject) => { + server.close((error) => { + if (error) { + reject(error) + return + } + + resolve() + }) + }), + } +} diff --git a/apps/backend/src/services/platforms/cloudtentacles/http-client.ts b/apps/backend/src/services/platforms/cloudtentacles/http-client.ts index cf0063f9..f2aee1ce 100644 --- a/apps/backend/src/services/platforms/cloudtentacles/http-client.ts +++ b/apps/backend/src/services/platforms/cloudtentacles/http-client.ts @@ -2,9 +2,9 @@ import http from 'node:http' import https from 'node:https' import { createHttpError } from '../../../utils/http.js' -import { logInfo } from '../../../utils/logger.js' +import { logInfo, logWarn } from '../../../utils/logger.js' import { notifyCloudtentaclesAuthExpired } from '../../notification/domain-notifications.js' -import { buildCloudtentaclesHeaders, buildCloudtentaclesUrl, resolveCloudtentaclesConfig } from './shared.js' +import { buildCloudtentaclesHeaders, buildCloudtentaclesUrl, resolveCloudtentaclesConfig } from './helpers.js' type JsonObject = Record type HeaderAdapter = { @@ -17,13 +17,18 @@ type NodeHttpResponse = { bodyText: string } +const DEFAULT_RATE_LIMIT_INTERVAL_MS = 300 +const DEFAULT_RATE_LIMIT_RETRIES = 2 +const DEFAULT_RATE_LIMIT_RETRY_DELAY_MS = 800 +const MAX_RETRY_JITTER_MS = 180 +const bucketNextAvailableAt = new Map() +let rateLimitQueue = Promise.resolve() + export async function cloudtentaclesRequest(pathname: unknown, options: JsonObject = {}) { const normalizedPathname = String(pathname || '').trim() const config = resolveCloudtentaclesConfig(options) const url = buildCloudtentaclesUrl(config.baseUrl, normalizedPathname, options.searchParams) const timeoutMs = Number(options.timeoutMs || config.timeoutMs || 5000) - const controller = new AbortController() - const timer = setTimeout(() => controller.abort(), timeoutMs) const method = String(options.method || 'GET').trim().toUpperCase() const headers = buildCloudtentaclesHeaders({ token: options.token, @@ -32,70 +37,153 @@ export async function cloudtentaclesRequest(pathname: unknown, options: JsonObje deviceType: options.deviceType ?? config.deviceType, extra: options.headers, }) + const rateLimitBucketKey = resolveRateLimitBucketKey({ + baseUrl: config.baseUrl, + token: options.token, + sourceKey: options.sourceKey, + deviceId: options.deviceId ?? config.deviceId, + }) + const rateLimitIntervalMs = normalizeNonNegativeInteger(options.rateLimitIntervalMs, DEFAULT_RATE_LIMIT_INTERVAL_MS) + const maxRetries = normalizeNonNegativeInteger(options.rateLimitRetries, DEFAULT_RATE_LIMIT_RETRIES) + const retryDelayMs = normalizeNonNegativeInteger(options.rateLimitRetryDelayMs, DEFAULT_RATE_LIMIT_RETRY_DELAY_MS) - try { - const response = await requestViaNodeHttp(url, { - method, - headers, - body: normalizeRequestBody(options.body, headers['content-type']), - signal: controller.signal, - }) - - const rawText = response.bodyText - const payload = tryParseJson(rawText) - - if (!response.ok) { - throw createHttpError(`cloudtentacles 请求失败,HTTP ${response.status}`, { - statusCode: 502, - errorCode: 'cloudtentacles_http_error', + for (let attempt = 0; attempt <= maxRetries; attempt += 1) { + try { + await waitForCloudtentaclesSlot(rateLimitBucketKey, rateLimitIntervalMs) + const body = normalizeRequestBody(options.body, headers['content-type']) + const response = await executeCloudtentaclesRequest({ + url, + method, + headers, + timeoutMs, + ...(body !== undefined ? { body } : {}), }) - } + const rawText = response.bodyText + const payload = tryParseJson(rawText) - if (options.requireBusinessSuccess !== false && Number(payload?.code ?? 1) !== 0) { - const message = String(payload?.message || payload?.msg || 'cloudtentacles 接口返回失败') - const statusCode = Number(options.businessErrorStatusCode || 400) - const errorCode = String(options.businessErrorCode || 'cloudtentacles_business_error') + if (!response.ok) { + if (isCloudtentaclesHttpRateLimited(response.status) && attempt < maxRetries) { + await waitBeforeRateLimitRetry(attempt, normalizedPathname, method, response.status, retryDelayMs) + continue + } - if (statusCode === 401 || isCloudtentaclesExpiredMessage(message)) { - await notifyCloudtentaclesAuthExpired({ + logWarn('[cloudtentacles/http]', '请求 HTTP 失败', { + method, pathname: normalizedPathname, - errorCode, - message, + status: response.status, + attempt: attempt + 1, sourceKey: options.sourceKey, accountLabel: options.accountLabel, + context: options.context, + upstreamPayload: payload, + rawText: truncateText(rawText), + }) + + throw createHttpError(`cloudtentacles 请求失败,HTTP ${response.status}`, { + statusCode: isCloudtentaclesHttpRateLimited(response.status) ? 429 : 502, + errorCode: isCloudtentaclesHttpRateLimited(response.status) + ? 'cloudtentacles_rate_limited' + : 'cloudtentacles_http_error', + context: { + method, + pathname: normalizedPathname, + upstreamStatus: response.status, + upstreamPayload: payload, + requestContext: options.context, + }, }) } - throw createHttpError(message, { - statusCode, - errorCode, - }) - } + if (options.requireBusinessSuccess !== false && Number(payload?.code ?? 1) !== 0) { + const message = String(payload?.message || payload?.msg || 'cloudtentacles 接口返回失败') + const statusCode = Number(options.businessErrorStatusCode || 400) + const errorCode = String(options.businessErrorCode || 'cloudtentacles_business_error') - logInfo('[cloudtentacles/http]', '请求完成', { - method, - pathname: normalizedPathname, - status: response.status, - }) + if (isHighFrequencyMessage(message)) { + if (attempt < maxRetries) { + await waitBeforeRateLimitRetry(attempt, normalizedPathname, method, response.status, retryDelayMs) + continue + } - return { - url: url.toString(), - status: response.status, - headers: response.headers, - payload, - rawText, - } - } catch (error) { - if (error instanceof Error && error.name === 'AbortError') { - throw createHttpError(`cloudtentacles 请求超时(${timeoutMs}ms)`, { - statusCode: 504, - errorCode: 'cloudtentacles_request_timeout', + throw createHttpError('cloudtentacles 请求过于频繁,请稍后重试', { + statusCode: 429, + errorCode: 'cloudtentacles_rate_limited', + context: { + pathname: normalizedPathname, + upstreamMessage: message, + }, + }) + } + + if (isCloudtentaclesExpiredMessage(message)) { + await notifyCloudtentaclesAuthExpired({ + pathname: normalizedPathname, + errorCode, + message, + sourceKey: options.sourceKey, + accountLabel: options.accountLabel, + }) + } + + logWarn('[cloudtentacles/http]', '请求业务失败', { + method, + pathname: normalizedPathname, + status: response.status, + attempt: attempt + 1, + errorCode, + upstreamCode: payload?.code, + upstreamMessage: message, + sourceKey: options.sourceKey, + accountLabel: options.accountLabel, + context: options.context, + upstreamPayload: payload, + }) + + throw createHttpError(message, { + statusCode, + errorCode, + context: { + method, + pathname: normalizedPathname, + upstreamStatus: response.status, + upstreamCode: payload?.code, + upstreamMessage: message, + upstreamPayload: payload, + requestContext: options.context, + }, + }) + } + + logInfo('[cloudtentacles/http]', '请求完成', { + method, + pathname: normalizedPathname, + status: response.status, + attempt: attempt + 1, }) + + return { + url: url.toString(), + status: response.status, + headers: response.headers, + payload, + rawText, + } + } catch (error) { + if (error instanceof Error && error.name === 'AbortError') { + throw createHttpError(`cloudtentacles 请求超时(${timeoutMs}ms)`, { + statusCode: 504, + errorCode: 'cloudtentacles_request_timeout', + }) + } + + throw error } - throw error - } finally { - clearTimeout(timer) } + + throw createHttpError('cloudtentacles 请求过于频繁,请稍后重试', { + statusCode: 429, + errorCode: 'cloudtentacles_rate_limited', + }) } function isCloudtentaclesExpiredMessage(message: unknown) { @@ -103,6 +191,130 @@ function isCloudtentaclesExpiredMessage(message: unknown) { return normalized.includes('expired') || normalized.includes('过期') || normalized.includes('失效') } +function isHighFrequencyMessage(message: unknown) { + return String(message || '').trim().toLowerCase().includes('high-frequency request') +} + +function isCloudtentaclesHttpRateLimited(status: unknown) { + return Number(status) === 429 +} + +function truncateText(value: unknown, maxLength = 1000) { + const text = String(value || '') + if (text.length <= maxLength) { + return text + } + + return `${text.slice(0, maxLength)}...` +} + +async function executeCloudtentaclesRequest({ + url, + method, + headers, + body, + timeoutMs, +}: { + url: URL + method: string + headers: JsonObject + body?: string | URLSearchParams + timeoutMs: number +}) { + const controller = new AbortController() + const timer = setTimeout(() => controller.abort(), timeoutMs) + + try { + return await requestViaNodeHttp(url, { + method, + headers, + signal: controller.signal, + ...(body !== undefined ? { body } : {}), + }) + } finally { + clearTimeout(timer) + } +} + +async function waitForCloudtentaclesSlot(bucketKey: string, intervalMs: number) { + if (intervalMs <= 0) { + return + } + + const previousQueue = rateLimitQueue + let releaseQueue: () => void = () => {} + rateLimitQueue = new Promise((resolve) => { + releaseQueue = resolve + }) + + await previousQueue + + let waitMs = 0 + try { + const now = Date.now() + const nextAvailableAt = bucketNextAvailableAt.get(bucketKey) || 0 + waitMs = Math.max(0, nextAvailableAt - now) + bucketNextAvailableAt.set(bucketKey, Math.max(now, nextAvailableAt) + intervalMs) + } finally { + releaseQueue() + } + + if (waitMs > 0) { + await sleep(waitMs) + } +} + +async function waitBeforeRateLimitRetry( + attempt: number, + pathname: string, + method: string, + status: number, + retryDelayMs: number, +) { + const delayMs = retryDelayMs * (attempt + 1) + Math.floor(Math.random() * MAX_RETRY_JITTER_MS) + logWarn('[cloudtentacles/http]', '请求触发云触手限频,准备重试', { + method, + pathname, + status, + attempt: attempt + 1, + retryDelayMs: delayMs, + }) + await sleep(delayMs) +} + +function resolveRateLimitBucketKey({ + baseUrl, + token, + sourceKey, + deviceId, +}: { + baseUrl: unknown + token: unknown + sourceKey: unknown + deviceId: unknown +}) { + return [ + String(baseUrl || '').trim(), + String(sourceKey || '').trim() || String(token || '').trim() || 'anonymous', + String(deviceId || '').trim(), + ].join('|') +} + +function normalizeNonNegativeInteger(value: unknown, fallback: number) { + const numericValue = Number(value) + if (Number.isInteger(numericValue) && numericValue >= 0) { + return numericValue + } + + return fallback +} + +function sleep(ms: number) { + return new Promise((resolve) => { + setTimeout(resolve, ms) + }) +} + function inferContentType(body: unknown) { if (body == null) { return '' diff --git a/apps/backend/src/services/platforms/cloudtentacles/knapsack-service.ts b/apps/backend/src/services/platforms/cloudtentacles/knapsack-service.ts index 339ceb71..b36232c5 100644 --- a/apps/backend/src/services/platforms/cloudtentacles/knapsack-service.ts +++ b/apps/backend/src/services/platforms/cloudtentacles/knapsack-service.ts @@ -1,6 +1,6 @@ import { createHttpError } from '../../../utils/http.js' import { cloudtentaclesRequest } from './http-client.js' -import { resolveCloudtentaclesConfig } from './shared.js' +import { resolveCloudtentaclesConfig } from './helpers.js' type JsonObject = Record diff --git a/apps/backend/src/services/platforms/cloudtentacles/record-service.ts b/apps/backend/src/services/platforms/cloudtentacles/record-service.ts new file mode 100644 index 00000000..e0b56429 --- /dev/null +++ b/apps/backend/src/services/platforms/cloudtentacles/record-service.ts @@ -0,0 +1,144 @@ +import { createHttpError } from '../../../utils/http.js' +import { logInfo } from '../../../utils/logger.js' +import { cloudtentaclesRequest } from './http-client.js' +import { resolveCloudtentaclesConfig } from './helpers.js' + +type JsonObject = Record + +export type CloudtentaclesDeliveryRecordQuery = JsonObject & { + baseUrl?: string + timeoutMs?: number | string + token?: string + deviceId?: string + deviceType?: number | string + page?: number | string + size?: number | string + startDate?: string + endDate?: string + recordCode?: number | string +} + +export async function listCloudtentaclesDeliveryRecords( + payload: CloudtentaclesDeliveryRecordQuery = {}, +) { + const config = resolveCloudtentaclesConfig({ + ...pickDefined({ + baseUrl: payload.baseUrl, + deviceId: payload.deviceId, + deviceType: payload.deviceType, + }), + ...( + typeof payload.timeoutMs === 'undefined' + ? {} + : { timeoutMs: normalizePositiveInteger(payload.timeoutMs, 5000) } + ), + }) + const page = normalizePositiveInteger(payload.page, 1) + const size = normalizePageSize(payload.size) + const startDate = normalizeDateInput(payload.startDate) + const endDate = normalizeDateInput(payload.endDate) + + if (!String(payload.token || '').trim()) { + throw createHttpError('cloudtentacles 发货记录查询缺少 token', { + statusCode: 409, + errorCode: 'cloudtentacles_record_missing_token', + }) + } + + if (!startDate || !endDate) { + throw createHttpError('cloudtentacles 发货记录查询需要选择开始和结束时间', { + statusCode: 400, + errorCode: 'cloudtentacles_record_date_required', + }) + } + + const result = await cloudtentaclesRequest('/record/paging', { + ...payload, + method: 'POST', + body: { + record_code: normalizePositiveInteger(payload.recordCode, 4), + page, + size, + start_date: startDate, + end_date: endDate, + }, + businessErrorCode: 'cloudtentacles_record_query_failed', + timeoutMs: payload.timeoutMs || config.timeoutMs, + }) + + const data = isPlainObject(result.payload?.data) ? result.payload.data : {} + const values = Array.isArray(data.values) ? data.values : [] + const total = normalizeNonNegativeInteger(data.total, values.length) + + logInfo('[cloudtentacles/records]', '发货记录查询完成', { + page, + size, + recordCode: normalizePositiveInteger(payload.recordCode, 4), + startDate, + endDate, + total, + returnedCount: values.length, + }) + + return { + baseUrl: config.baseUrl, + page, + size, + total, + items: values.map(mapCloudtentaclesDeliveryRecord), + raw: data, + } +} + +function mapCloudtentaclesDeliveryRecord(raw: JsonObject) { + return { + createdAt: String(raw.c_time || '').trim(), + recordId: String(raw.record_id || '').trim(), + virtualNumberId: normalizeNonNegativeInteger(raw.virtual_number_id, 0), + userId: normalizeNonNegativeInteger(raw.user_id, 0), + count: normalizeNonNegativeInteger(raw.count, 0), + cdk: String(raw.cdk || '').trim(), + exchangeUrl: String(raw.exchange_url || '').trim(), + name: String(raw.name || '').trim(), + phone: String(raw.phone || '').trim(), + status: normalizeNonNegativeInteger(raw.status, 0), + fulfillUser: String(raw.fulfill_user || '').trim(), + operatorName: String(raw.u_name || '').trim(), + raw, + } +} + +function normalizePositiveInteger(value: unknown, fallback: number) { + const parsed = Number(value) + return Number.isInteger(parsed) && parsed > 0 ? parsed : fallback +} + +function normalizePageSize(value: unknown) { + const parsed = normalizePositiveInteger(value, 100) + return Math.min(Math.max(parsed, 100), 1000) +} + +function normalizeNonNegativeInteger(value: unknown, fallback: number) { + const parsed = Number(value) + return Number.isInteger(parsed) && parsed >= 0 ? parsed : fallback +} + +function normalizeDateInput(value: unknown) { + const raw = String(value || '').trim() + if (!raw) { + return '' + } + + const date = new Date(raw) + return Number.isNaN(date.getTime()) ? raw : date.toISOString() +} + +function isPlainObject(value: unknown): value is JsonObject { + return Object.prototype.toString.call(value) === '[object Object]' +} + +function pickDefined(values: JsonObject = {}) { + return Object.fromEntries( + Object.entries(values).filter(([, value]) => typeof value !== 'undefined'), + ) +} diff --git a/apps/backend/src/services/platforms/cloudtentacles/session-service.ts b/apps/backend/src/services/platforms/cloudtentacles/session-service.ts index dc47a801..32742700 100644 --- a/apps/backend/src/services/platforms/cloudtentacles/session-service.ts +++ b/apps/backend/src/services/platforms/cloudtentacles/session-service.ts @@ -2,7 +2,7 @@ import { createHttpError } from '../../../utils/http.js' import { logInfo } from '../../../utils/logger.js' import { encryptCloudtentaclesPayload, md5CloudtentaclesPassword } from './crypto-service.js' import { cloudtentaclesRequest } from './http-client.js' -import { resolveCloudtentaclesConfig } from './shared.js' +import { resolveCloudtentaclesConfig } from './helpers.js' type JsonObject = Record @@ -176,7 +176,7 @@ export async function validateCloudtentaclesSession(payload: JsonObject = {}) { userInfo: isPlainObject(userInfoResult.payload?.data) ? userInfoResult.payload.data : {}, asset: Number(assetResult.payload?.data || 0), permissions: Array.isArray(permissionResult.payload?.data) - ? permissionResult.payload.data.map((item) => String(item || '').trim()).filter(Boolean) + ? permissionResult.payload.data.map((item: unknown) => String(item || '').trim()).filter(Boolean) : [], } } diff --git a/apps/backend/src/services/platforms/cloudtentacles/session-state-service.ts b/apps/backend/src/services/platforms/cloudtentacles/session-state-service.ts index c55b7df3..31d6c545 100644 --- a/apps/backend/src/services/platforms/cloudtentacles/session-state-service.ts +++ b/apps/backend/src/services/platforms/cloudtentacles/session-state-service.ts @@ -2,10 +2,21 @@ import fs from 'node:fs' import path from 'node:path' import { PROJECT_ROOT } from '../../../config/runtime.js' +import { createHttpError } from '../../../utils/http.js' +import { + DEFAULT_CLOUDTENTACLES_DEVICE_ID, + DEFAULT_CLOUDTENTACLES_DEVICE_TYPE, + normalizeCloudtentaclesDeviceId, + normalizeCloudtentaclesDeviceType, +} from './defaults.js' const CLOUDTENTACLES_SESSION_FILE_PATH = path.join(PROJECT_ROOT, 'data', 'cloudtentacles-session.json') type JsonObject = Record +type CloudtentaclesSessionState = ReturnType +type CloudtentaclesSessionStatesFile = { + sessions: Record +} export function getCloudtentaclesSessionFilePath() { return CLOUDTENTACLES_SESSION_FILE_PATH @@ -54,7 +65,10 @@ export function saveCloudtentaclesSessionState(rawValue: unknown) { export function saveCloudtentaclesSessionStateByKey(sourceKey: unknown, rawValue: unknown) { const key = String(sourceKey || '').trim() if (!key) { - throw new Error('saveCloudtentaclesSessionStateByKey: sourceKey is required') + throw createHttpError('cloudtentacles sourceKey 不能为空', { + statusCode: 400, + errorCode: 'cloudtentacles_source_key_required', + }) } const states = loadCloudtentaclesSessionStatesFromFile() @@ -71,7 +85,10 @@ export function saveCloudtentaclesSessionStateByKey(sourceKey: unknown, rawValue export function deleteCloudtentaclesSessionStateByKey(sourceKey: unknown) { const key = String(sourceKey || '').trim() if (!key) { - throw new Error('deleteCloudtentaclesSessionStateByKey: sourceKey is required') + throw createHttpError('cloudtentacles sourceKey 不能为空', { + statusCode: 400, + errorCode: 'cloudtentacles_source_key_required', + }) } const states = loadCloudtentaclesSessionStatesFromFile() @@ -95,7 +112,10 @@ export function clearCloudtentaclesSessionState() { export function clearCloudtentaclesSessionStateByKey(sourceKey: unknown) { const key = String(sourceKey || '').trim() if (!key) { - throw new Error('clearCloudtentaclesSessionStateByKey: sourceKey is required') + throw createHttpError('cloudtentacles sourceKey 不能为空', { + statusCode: 400, + errorCode: 'cloudtentacles_source_key_required', + }) } const cleared = createDefaultCloudtentaclesSessionState() @@ -127,7 +147,7 @@ export function pruneCloudtentaclesSessionStates(sourceKeys: unknown[] = []) { // Internal helpers // --------------------------------------------------------------------------- -function loadCloudtentaclesSessionStatesFromFile() { +function loadCloudtentaclesSessionStatesFromFile(): CloudtentaclesSessionStatesFile { if (!fs.existsSync(CLOUDTENTACLES_SESSION_FILE_PATH)) { return createDefaultCloudtentaclesSessionStates() } @@ -152,8 +172,8 @@ function normalizeCloudtentaclesSessionState(rawValue: unknown) { username: String(source.username || '').trim(), phone: String(source.phone || '').trim(), loggedInAt: String(source.loggedInAt || '').trim(), - deviceId: String(source.deviceId || '-').trim() || '-', - deviceType: normalizeInteger(source.deviceType, 0), + deviceId: normalizeCloudtentaclesDeviceId(source.deviceId), + deviceType: normalizeCloudtentaclesDeviceType(source.deviceType), } } @@ -162,7 +182,7 @@ function normalizeCloudtentaclesSessionState(rawValue: unknown) { * Handles old format (single object without sessions key) by auto-wrapping * into { sessions: { 'default': ... } }. */ -function normalizeSessionStatesFile(rawValue: unknown) { +function normalizeSessionStatesFile(rawValue: unknown): CloudtentaclesSessionStatesFile { // Old format: { token: 'xxx', ... } (single object, no sessions key) if (isPlainObject(rawValue) && !rawValue.sessions) { return { @@ -189,12 +209,12 @@ function createDefaultCloudtentaclesSessionState() { username: '', phone: '', loggedInAt: '', - deviceId: '-', - deviceType: 0, + deviceId: DEFAULT_CLOUDTENTACLES_DEVICE_ID, + deviceType: DEFAULT_CLOUDTENTACLES_DEVICE_TYPE, } } -function createDefaultCloudtentaclesSessionStates() { +function createDefaultCloudtentaclesSessionStates(): CloudtentaclesSessionStatesFile { return { sessions: { 'default': createDefaultCloudtentaclesSessionState(), @@ -202,11 +222,6 @@ function createDefaultCloudtentaclesSessionStates() { } } -function normalizeInteger(value: unknown, fallback: number) { - const parsed = Number(value) - return Number.isInteger(parsed) ? parsed : fallback -} - function isPlainObject(value: unknown): value is JsonObject { return Object.prototype.toString.call(value) === '[object Object]' } diff --git a/apps/backend/src/services/platforms/cloudtentacles/source-config-service.ts b/apps/backend/src/services/platforms/cloudtentacles/source-config-service.ts index 5ec9fba9..35a9f621 100644 --- a/apps/backend/src/services/platforms/cloudtentacles/source-config-service.ts +++ b/apps/backend/src/services/platforms/cloudtentacles/source-config-service.ts @@ -2,6 +2,11 @@ import fs from 'node:fs' import path from 'node:path' import { PROJECT_ROOT } from '../../../config/runtime.js' +import { createHttpError } from '../../../utils/http.js' +import { + normalizeCloudtentaclesDeviceId, + normalizeCloudtentaclesDeviceType, +} from './defaults.js' const CLOUDTENTACLES_SOURCES_FILE_PATH = path.join(PROJECT_ROOT, 'data', 'cloudtentacles-sources.json') @@ -63,7 +68,10 @@ export function saveCloudtentaclesSourcesList(rawValue: unknown) { export function saveCloudtentaclesSourceByKey(sourceKey: unknown, data: JsonObject = {}) { const key = String(sourceKey || '').trim() if (!key) { - throw new Error('saveCloudtentaclesSourceByKey: sourceKey is required') + throw createHttpError('cloudtentacles sourceKey 不能为空', { + statusCode: 400, + errorCode: 'cloudtentacles_source_key_required', + }) } const config = loadCloudtentaclesSourcesConfigFromFile() @@ -82,15 +90,15 @@ export function saveCloudtentaclesSourceByKey(sourceKey: unknown, data: JsonObje } /** - * Delete a single source by key. Throws if key is 'default' (cannot delete default source). + * Delete a single source by key. */ export function deleteCloudtentaclesSourceByKey(sourceKey: unknown) { const key = String(sourceKey || '').trim() if (!key) { - throw new Error('deleteCloudtentaclesSourceByKey: sourceKey is required') - } - if (key === 'default') { - throw new Error('deleteCloudtentaclesSourceByKey: cannot delete the default source') + throw createHttpError('cloudtentacles sourceKey 不能为空', { + statusCode: 400, + errorCode: 'cloudtentacles_source_key_required', + }) } const config = loadCloudtentaclesSourcesConfigFromFile() @@ -138,8 +146,8 @@ function normalizeCloudtentaclesSourceItem(rawValue: unknown) { username: String(source.username || '').trim(), password: String(source.password || '').trim(), phone: String(source.phone || '').trim(), - deviceId: String(source.deviceId || '-').trim() || '-', - deviceType: normalizeInteger(source.deviceType, 0), + deviceId: normalizeCloudtentaclesDeviceId(source.deviceId), + deviceType: normalizeCloudtentaclesDeviceType(source.deviceType), } } @@ -180,11 +188,6 @@ function createDefaultCloudtentaclesSourcesConfig() { } } -function normalizeInteger(value: unknown, fallback: number) { - const parsed = Number(value) - return Number.isInteger(parsed) ? parsed : fallback -} - function isPlainObject(value: unknown): value is JsonObject { return Object.prototype.toString.call(value) === '[object Object]' } diff --git a/apps/backend/src/services/platforms/cloudtentacles/virtual-number-service.ts b/apps/backend/src/services/platforms/cloudtentacles/virtual-number-service.ts index 9d2c3263..d6f445be 100644 --- a/apps/backend/src/services/platforms/cloudtentacles/virtual-number-service.ts +++ b/apps/backend/src/services/platforms/cloudtentacles/virtual-number-service.ts @@ -3,7 +3,7 @@ import https from 'node:https' import { createHttpError } from '../../../utils/http.js' import { cloudtentaclesRequest } from './http-client.js' -import { resolveCloudtentaclesConfig } from './shared.js' +import { resolveCloudtentaclesConfig } from './helpers.js' const BIND_URL_PROBE_MAX_BODY_BYTES = 64 * 1024 const AMS_SIGNATURE_EXPIRED_CODE = '99998' @@ -200,7 +200,7 @@ export async function probeCloudtentaclesBindUrl(payload: JsonObject = {}) { finalUrl: bindUrl, reason: 'missing_signature_params', roleInfo: normalizeAmsBindRoleInfo(null), - raw: null, + raw: null as null, } } @@ -247,7 +247,7 @@ export async function probeCloudtentaclesBindUrl(payload: JsonObject = {}) { finalUrl: bindUrl, reason: error instanceof Error ? error.message : String(error || 'probe_failed'), roleInfo: normalizeAmsBindRoleInfo(null), - raw: null, + raw: null as null, durationMs: Date.now() - startedAt, } } @@ -269,7 +269,7 @@ export async function getCloudtentaclesBindInfo(payload: JsonObject = {}) { }) const items = Array.isArray(result.payload?.data) ? result.payload.data : [] - const matchedItem = items.find((item) => Number(item?.id || 0) === id) || items[0] || {} + const matchedItem = items.find((item: JsonObject) => Number(item?.id || 0) === id) || items[0] || {} const bindInfo = parseBindInfo(matchedItem?.bind_info) return { diff --git a/apps/backend/src/services/platforms/kuaishou-eticket/consume-service.test.ts b/apps/backend/src/services/platforms/kuaishou-eticket/consume-service.test.ts index 555b154d..8fb34abe 100644 --- a/apps/backend/src/services/platforms/kuaishou-eticket/consume-service.test.ts +++ b/apps/backend/src/services/platforms/kuaishou-eticket/consume-service.test.ts @@ -2,8 +2,10 @@ import test from 'node:test' import assert from 'node:assert/strict' import { + consumeKuaishouEticket, mapKuaishouEticketDetailResult, normalizeKuaishouEticketApiResult, + queryKuaishouEticketConsumeDetail, resolveKuaishouEticketConsumePayload, } from './consume-service.js' @@ -70,3 +72,50 @@ test('resolveKuaishouEticketConsumePayload falls back to detail result', () => { formToken: '1777716306154', }) }) + +test('queryKuaishouEticketConsumeDetail accepts mock ticket code outside production', async () => { + const originalNodeEnv = process.env.NODE_ENV + process.env.NODE_ENV = 'development' + + try { + const result = await queryKuaishouEticketConsumeDetail({ + eTicketId: 'MOCK-CLAIM-001', + goodsTitle: '套装-浪漫天命', + }) + + assert.equal(result.ok, true) + assert.equal(result.eTicketId, 'MOCK-CLAIM-001') + assert.equal(result.detail?.leftCount, 1) + assert.equal(result.goods?.itemTitle, '套装-浪漫天命') + } finally { + if (originalNodeEnv === undefined) { + delete process.env.NODE_ENV + } else { + process.env.NODE_ENV = originalNodeEnv + } + } +}) + +test('consumeKuaishouEticket consumes mock ticket code outside production', async () => { + const originalNodeEnv = process.env.NODE_ENV + process.env.NODE_ENV = 'development' + + try { + const result = await consumeKuaishouEticket({ + eTicketId: 'MOCK-CLAIM-002', + oid: 'MOCK-OID-002', + formToken: 'MOCK-FORM-002', + }) + + assert.equal(result.consumed, true) + assert.equal(result.eTicketId, 'MOCK-CLAIM-002') + assert.equal(result.oid, 'MOCK-OID-002') + assert.equal(result.formToken, 'MOCK-FORM-002') + } finally { + if (originalNodeEnv === undefined) { + delete process.env.NODE_ENV + } else { + process.env.NODE_ENV = originalNodeEnv + } + } +}) diff --git a/apps/backend/src/services/platforms/kuaishou-eticket/consume-service.ts b/apps/backend/src/services/platforms/kuaishou-eticket/consume-service.ts index 006d7732..6102f9ad 100644 --- a/apps/backend/src/services/platforms/kuaishou-eticket/consume-service.ts +++ b/apps/backend/src/services/platforms/kuaishou-eticket/consume-service.ts @@ -1,6 +1,11 @@ import { createHttpError } from '../../../utils/http.js' import { kuaishouEticketRequest } from './http-client.js' -import { resolveKuaishouEticketConfig } from './shared.js' +import { resolveKuaishouEticketConfig } from './helpers.js' +import { + buildMockKuaishouEticketConsumeResult, + buildMockKuaishouEticketDetailResult, + isKuaishouEticketMockTicketCode, +} from './mock-ticket-service.js' type JsonObject = Record @@ -18,6 +23,14 @@ type KuaishouEticketDetailResult = { } export async function queryKuaishouEticketConsumeDetail(payload: JsonObject = {}) { + if (isKuaishouEticketMockTicketCode(payload.eTicketId)) { + return buildMockKuaishouEticketDetailResult({ + baseUrl: String(payload.baseUrl || '').trim(), + eTicketId: String(payload.eTicketId || '').trim(), + goodsTitle: String(payload.goodsTitle || '').trim(), + }) + } + const context = resolveKuaishouEticketActionContext(payload) const response = await kuaishouEticketRequest(context.detailPath, { baseUrl: context.baseUrl, @@ -35,6 +48,17 @@ export async function queryKuaishouEticketConsumeDetail(payload: JsonObject = {} } export async function consumeKuaishouEticket(payload: JsonObject = {}) { + if (isKuaishouEticketMockTicketCode(payload.eTicketId)) { + return buildMockKuaishouEticketConsumeResult({ + baseUrl: String(payload.baseUrl || '').trim(), + eTicketId: String(payload.eTicketId || '').trim(), + oid: String(payload.oid || '').trim(), + formToken: String(payload.formToken || '').trim(), + num: payload.num, + storeId: String(payload.storeId || '').trim(), + }) + } + const context = resolveKuaishouEticketActionContext(payload) const detailResult = shouldResolveConsumeDetail(payload) ? await queryKuaishouEticketConsumeDetail({ diff --git a/apps/backend/src/services/platforms/kuaishou-eticket/shared.ts b/apps/backend/src/services/platforms/kuaishou-eticket/helpers.ts similarity index 100% rename from apps/backend/src/services/platforms/kuaishou-eticket/shared.ts rename to apps/backend/src/services/platforms/kuaishou-eticket/helpers.ts diff --git a/apps/backend/src/services/platforms/kuaishou-eticket/http-client.ts b/apps/backend/src/services/platforms/kuaishou-eticket/http-client.ts index f9e7fe0e..8fe8fd2f 100644 --- a/apps/backend/src/services/platforms/kuaishou-eticket/http-client.ts +++ b/apps/backend/src/services/platforms/kuaishou-eticket/http-client.ts @@ -5,7 +5,7 @@ import type { IncomingHttpHeaders } from 'node:http' import { parseJsonObject } from '../../../utils/json.js' import { createHttpError } from '../../../utils/http.js' import { logInfo } from '../../../utils/logger.js' -import { buildKuaishouEticketHeaders, buildKuaishouEticketUrl, resolveKuaishouEticketConfig } from './shared.js' +import { buildKuaishouEticketHeaders, buildKuaishouEticketUrl, resolveKuaishouEticketConfig } from './helpers.js' type JsonObject = Record @@ -42,15 +42,16 @@ export async function kuaishouEticketRequest(pathname: string, options: Kuaishou userAgent: config.userAgent, cookie: options.cookie ?? config.cookie, contentType: options.contentType === null ? '' : options.contentType || inferContentType(options.body), - extra: options.headers, + ...(options.headers !== undefined ? { extra: options.headers } : {}), }) try { + const body = normalizeRequestBody(options.body, headers['content-type']) const response = await requestViaNodeHttp(url, { method, headers, - body: normalizeRequestBody(options.body, headers['content-type']), signal: controller.signal, + ...(body !== undefined ? { body } : {}), }) const rawText = response.bodyText diff --git a/apps/backend/src/services/platforms/kuaishou-eticket/info-service.ts b/apps/backend/src/services/platforms/kuaishou-eticket/info-service.ts index f180f44a..dc58faf6 100644 --- a/apps/backend/src/services/platforms/kuaishou-eticket/info-service.ts +++ b/apps/backend/src/services/platforms/kuaishou-eticket/info-service.ts @@ -1,6 +1,6 @@ import { createHttpError } from '../../../utils/http.js' import { kuaishouEticketRequest } from './http-client.js' -import { resolveKuaishouEticketConfig } from './shared.js' +import { resolveKuaishouEticketConfig } from './helpers.js' type JsonObject = Record diff --git a/apps/backend/src/services/platforms/kuaishou-eticket/mock-ticket-service.ts b/apps/backend/src/services/platforms/kuaishou-eticket/mock-ticket-service.ts new file mode 100644 index 00000000..4db1dc6c --- /dev/null +++ b/apps/backend/src/services/platforms/kuaishou-eticket/mock-ticket-service.ts @@ -0,0 +1,123 @@ +import process from 'node:process' + +import { isProductionLike } from '../../../config/runtime-validation.js' + +type RuntimeEnvironment = { + NODE_ENV?: string +} + +type MockTicketDetailOptions = { + eTicketId: string + baseUrl?: string + oid?: string + formToken?: string + leftCount?: number + goodsTitle?: string +} + +type MockTicketConsumeOptions = { + eTicketId: string + baseUrl?: string + oid?: string + formToken?: string + num?: number + storeId?: string +} + +export function isKuaishouEticketMockTicketCode( + ticketCode: unknown, + env: RuntimeEnvironment = process.env, +): boolean { + const normalized = String(ticketCode || '').trim().toUpperCase() + + if (!normalized || isProductionLike(env)) { + return false + } + + return normalized === 'MOCK' || normalized.startsWith('MOCK-') || normalized.startsWith('MOCK_') +} + +export function buildMockKuaishouEticketDetailResult(options: MockTicketDetailOptions) { + const eTicketId = String(options.eTicketId || '').trim() + const oid = String(options.oid || `MOCK-OID-${eTicketId}`).trim() + const formToken = String(options.formToken || `MOCK-FORM-${eTicketId}`).trim() + const leftCount = normalizePositiveInteger(options.leftCount, 1) + const goodsTitle = String(options.goodsTitle || 'MOCK 快手商品').trim() + + return { + baseUrl: String(options.baseUrl || 'mock://kuaishou-eticket').trim(), + eTicketId, + ok: true, + alreadyConsumed: false, + result: 1, + errorMessage: '', + requestId: `mock-${Date.now()}`, + serverTimestamp: new Date().toISOString(), + detail: { + uid: 'mock', + fulfillDetailId: `MOCK-FULFILL-${eTicketId}`, + sellerId: 'mock', + formToken, + validEndTime: '', + validStartTime: '', + leftReverseCount: 0, + eTicketId, + oid, + totalCount: leftCount, + leftCount, + status: 'MOCK', + }, + goods: { + itemId: 'mock', + itemPicUrl: '', + itemTitle: goodsTitle, + price: '', + skuDesc: goodsTitle, + skuId: 'mock', + }, + raw: { + mock: true, + }, + } +} + +export function buildMockKuaishouEticketConsumeResult(options: MockTicketConsumeOptions) { + const eTicketId = String(options.eTicketId || '').trim() + const oid = String(options.oid || `MOCK-OID-${eTicketId}`).trim() + const formToken = String(options.formToken || `MOCK-FORM-${eTicketId}`).trim() + const num = normalizePositiveInteger(options.num, 1) + + return { + baseUrl: String(options.baseUrl || 'mock://kuaishou-eticket').trim(), + eTicketId, + oid, + formToken, + num, + storeId: String(options.storeId || '0').trim() || '0', + ok: true, + consumed: true, + alreadyConsumed: false, + result: 1, + errorMessage: '', + requestId: `mock-${Date.now()}`, + serverTimestamp: new Date().toISOString(), + detail: { + eTicketId, + oid, + formToken, + leftCount: num, + }, + raw: { + mock: true, + }, + } +} + +function normalizePositiveInteger(value: unknown, fallback: number) { + const normalized = Number(value) + if (!Number.isFinite(normalized) || normalized <= 0) { + return fallback + } + + return Math.floor(normalized) +} diff --git a/apps/backend/src/services/platforms/kuaishou-eticket/source-config-service.ts b/apps/backend/src/services/platforms/kuaishou-eticket/source-config-service.ts index 2e2a2c08..716ce122 100644 --- a/apps/backend/src/services/platforms/kuaishou-eticket/source-config-service.ts +++ b/apps/backend/src/services/platforms/kuaishou-eticket/source-config-service.ts @@ -100,7 +100,7 @@ function normalizeKuaishouEticketSourceConfig(rawValue: unknown): KuaishouEticke baseUrl: String(source.baseUrl || DEFAULT_BASE_URL).trim() || DEFAULT_BASE_URL, shops: shops .map((item) => normalizeKuaishouEticketShopConfig(item)) - .filter(Boolean), + .filter((item): item is KuaishouEticketShopConfig => Boolean(item)), } } diff --git a/apps/backend/src/services/platforms/ninetyone/order-service.test.ts b/apps/backend/src/services/platforms/ninetyone/order-service.test.ts index 27a8cb52..c3488bca 100644 --- a/apps/backend/src/services/platforms/ninetyone/order-service.test.ts +++ b/apps/backend/src/services/platforms/ninetyone/order-service.test.ts @@ -1,7 +1,10 @@ import test from 'node:test' import assert from 'node:assert/strict' -import { buildOpen91SourceEvent } from './order-service.js' +import { + buildOpen91SourceEvent, + parseOpen91ProductNo, +} from './order-service.js' test('buildOpen91SourceEvent maps 91 productNo into source item identities', () => { const event = buildOpen91SourceEvent({ @@ -29,3 +32,41 @@ test('buildOpen91SourceEvent maps 91 productNo into source item identities', () assert.equal(event.items[0].quantity, 2) assert.equal(event.items[0].snapshot.productNo, 'KS-CLOUD-SKU-001') }) + +test('parseOpen91ProductNo splits product name and kuaishou shop id by four dashes', () => { + assert.deepEqual( + parseOpen91ProductNo('测试-关联商品1----3676797936'), + { + rawProductNo: '测试-关联商品1----3676797936', + productName: '测试-关联商品1', + shopId: '3676797936', + }, + ) +}) + +test('buildOpen91SourceEvent keeps productNo suffix for kuaishou consume only', () => { + const event = buildOpen91SourceEvent({ + orderNo: 'P91KS202605130002', + productNo: '测试-关联商品1----3676797936', + buyNum: 1, + maxAmount: '0.01', + timestamp: 1778670308, + version: '1.0', + sign: 'SIGN', + }, { + shopId: '91kaquan', + shopName: '91卡券', + }) + + assert.equal(event.shopId, '91kaquan') + assert.equal(event.shopName, '91卡券') + assert.equal(event.items[0].externalItemId, '测试-关联商品1----3676797936') + assert.equal(event.items[0].externalSkuCode, '测试-关联商品1----3676797936') + assert.equal(event.items[0].externalSkuName, '测试-关联商品1') + assert.equal(event.items[0].skuCode, '测试-关联商品1') + assert.equal(event.items[0].skuName, '测试-关联商品1') + assert.equal(event.items[0].snapshot.productNo, '测试-关联商品1----3676797936') + assert.equal(event.items[0].snapshot.productName, '测试-关联商品1') + assert.equal(event.items[0].snapshot.shopId, '3676797936') + assert.equal(event.items[0].spec.shopId, '3676797936') +}) diff --git a/apps/backend/src/services/platforms/ninetyone/order-service.ts b/apps/backend/src/services/platforms/ninetyone/order-service.ts index 797fa4d8..5b12ecd5 100644 --- a/apps/backend/src/services/platforms/ninetyone/order-service.ts +++ b/apps/backend/src/services/platforms/ninetyone/order-service.ts @@ -4,13 +4,15 @@ import { listOrderItemsByOrderId, replaceOrderItems } from '../../../repositorie import { listTasksByOrderId } from '../../../repositories/task-repo.js' import { upsertOrderFromSource } from '../../order/order-service.js' import { - buildOpen91OutTradeNo, - normalizeOpen91CreatePayload, OPEN_91_PLATFORM, OPEN_91_PROVIDER, -} from '../../open-91/shared.js' +} from '../../open-91/config.js' +import { normalizeOpen91CreatePayload } from '../../open-91/payload.js' +import { buildOpen91OutTradeNo } from '../../open-91/response.js' import { createHttpError } from '../../../utils/http.js' +import { parseAmountToFen } from '../../../utils/money.js' import { nowIso } from '../../../utils/time.js' +import type { OrderItemRow, OrderRow } from '../../../types/repository/rows.js' export const OPEN_91_PENDING_CONFIG_STATUS = 'pending_config' export const OPEN_91_MANUAL_FAILED_STATUS = 'manual_failed' @@ -19,20 +21,25 @@ type JsonObject = Record export function buildOpen91SourceEvent(payload: JsonObject = {}, config: JsonObject = {}) { const normalized = normalizeOpen91CreatePayload(payload) - const productNo = normalized.productNo + const productInfo = parseOpen91ProductNo(normalized.productNo) + const productNo = productInfo.rawProductNo + const productName = productInfo.productName + const shopId = String(config.shopId || OPEN_91_PROVIDER).trim() || OPEN_91_PROVIDER + const shopName = String(config.shopName || '91卡券').trim() || '91卡券' + const maxAmountFen = parseAmountToFen(normalized.maxAmount) return { provider: OPEN_91_PROVIDER, platform: OPEN_91_PLATFORM, - shopId: String(config.shopId || OPEN_91_PROVIDER).trim() || OPEN_91_PROVIDER, - shopName: String(config.shopName || '91卡券').trim() || '91卡券', + shopId, + shopName, platformOrderId: normalized.orderNo, orderStatus: 'paid', payStatus: 'paid', buyerId: '', buyerName: '', receiverContact: '', - totalAmount: 0, + totalAmount: maxAmountFen, currency: 'CNY', paidAt: nowIso(), rawPayload: { @@ -45,22 +52,29 @@ export function buildOpen91SourceEvent(payload: JsonObject = {}, config: JsonObj itemId: productNo, externalItemId: productNo, externalSkuCode: productNo, - externalSkuName: productNo, - skuCode: productNo, - skuName: productNo, + externalSkuName: productName, + skuCode: productName, + skuName: productName, quantity: normalized.buyNum, spec: { source: OPEN_91_PROVIDER, orderNo: normalized.orderNo, productNo, + productName, + shopId: productInfo.shopId, + maxAmount: normalized.maxAmount, }, snapshot: { source: OPEN_91_PROVIDER, orderNo: normalized.orderNo, productNo, + productName, + shopId: productInfo.shopId, + maxAmount: normalized.maxAmount, + maxAmountFen, externalItemId: productNo, externalSkuCode: productNo, - externalSkuName: productNo, + externalSkuName: productName, callbackUrl: normalized.callbackUrl, }, }, @@ -68,7 +82,38 @@ export function buildOpen91SourceEvent(payload: JsonObject = {}, config: JsonObj } } -export async function upsertOpen91PendingOrder(payload = {}, config = {}) { +export function parseOpen91ProductNo(value: unknown) { + const rawProductNo = String(value || '').trim() + const delimiter = '----' + const delimiterIndex = rawProductNo.lastIndexOf(delimiter) + + if (delimiterIndex <= 0) { + return { + rawProductNo, + productName: rawProductNo, + shopId: '', + } + } + + const productName = rawProductNo.slice(0, delimiterIndex).trim() + const shopId = rawProductNo.slice(delimiterIndex + delimiter.length).trim() + + if (!productName || !shopId) { + return { + rawProductNo, + productName: rawProductNo, + shopId: '', + } + } + + return { + rawProductNo, + productName, + shopId, + } +} + +export async function upsertOpen91PendingOrder(payload: JsonObject = {}, config: JsonObject = {}) { const event = buildOpen91SourceEvent(payload, config) const now = nowIso() const existing = await findOrderByPlatformOrderId({ @@ -94,7 +139,7 @@ export async function upsertOpen91PendingOrder(payload = {}, config = {}) { buyerId: '', buyerName: '', receiverContact: '', - totalAmount: 0, + totalAmount: event.totalAmount, currency: 'CNY', rawPayloadJson, paidAt: existing?.paid_at || now, @@ -112,6 +157,13 @@ export async function upsertOpen91PendingOrder(payload = {}, config = {}) { } const [item] = event.items + if (!item) { + throw createHttpError('91卡券订单商品明细为空', { + statusCode: 400, + errorCode: 'open91_order_item_required', + }) + } + const orderItems = await replaceOrderItems(order.id, [ { skuCode: item.skuCode, @@ -127,11 +179,11 @@ export async function upsertOpen91PendingOrder(payload = {}, config = {}) { return { order, orderItems, - tasks: [], + tasks: [] as JsonObject[], } } -export async function retryOpen91Order(orderId) { +export async function retryOpen91Order(orderId: string | number) { const order = await getRequiredOpen91Order(orderId) const items = await listOrderItemsByOrderId(order.id) const event = buildOpen91SourceEventFromOrder(order, items) @@ -154,7 +206,7 @@ export async function retryOpen91Order(orderId) { } } -export async function failOpen91Order(orderId, reason = '') { +export async function failOpen91Order(orderId: string | number, reason = '') { const order = await getRequiredOpen91Order(orderId) const now = nowIso() const rawPayload = parseJsonObject(order.raw_payload_json) @@ -245,7 +297,7 @@ export async function listOpen91Orders({ page = 1, pageSize = 20, status = 'pend } } -export async function getRequiredOpen91Order(orderId) { +export async function getRequiredOpen91Order(orderId: string | number) { const order = await getOrderById(Number(orderId)) if (!order || order.provider !== OPEN_91_PROVIDER || order.platform !== OPEN_91_PLATFORM) { @@ -258,11 +310,11 @@ export async function getRequiredOpen91Order(orderId) { return order } -function buildOpen91SourceEventFromOrder(order, items = []) { +function buildOpen91SourceEventFromOrder(order: OrderRow, items: OrderItemRow[] = []) { const rawPayload = parseJsonObject(order.raw_payload_json) const body = parseJsonObject(rawPayload.body) const normalizedItems = Array.isArray(items) ? items : [] - const eventItems = normalizedItems.map((item) => { + const eventItems = normalizedItems.map((item: OrderItemRow) => { const snapshot = parseJsonObject(item.item_snapshot_json) const productNo = String(snapshot.productNo || snapshot.externalSkuCode || item.sku_code || '').trim() @@ -282,8 +334,10 @@ function buildOpen91SourceEventFromOrder(order, items = []) { return { provider: order.provider, platform: order.platform, - shopId: order.shop_id, - shopName: order.shop_name, + shopId: OPEN_91_PROVIDER, + shopName: String(order.shop_id || '').trim() === OPEN_91_PROVIDER + ? order.shop_name + : '91卡券', platformOrderId: order.platform_order_id, orderStatus: 'paid', payStatus: 'paid', @@ -301,7 +355,7 @@ function buildOpen91SourceEventFromOrder(order, items = []) { } } -function mapOpen91AdminOrderRow(row) { +function mapOpen91AdminOrderRow(row: JsonObject) { const rawPayload = parseJsonObject(row.raw_payload_json) const items = Array.isArray(row.items_json) ? row.items_json : [] const firstItem = items[0] || {} @@ -327,7 +381,7 @@ function mapOpen91AdminOrderRow(row) { } } -function parseJsonObject(value) { +function parseJsonObject(value: unknown): JsonObject { if (!value) { return {} } diff --git a/apps/backend/src/services/scheduler/cloudtentacles-health-job.ts b/apps/backend/src/services/scheduler/cloudtentacles-health-job.ts index 57083d3e..05b9b9f0 100644 --- a/apps/backend/src/services/scheduler/cloudtentacles-health-job.ts +++ b/apps/backend/src/services/scheduler/cloudtentacles-health-job.ts @@ -1,4 +1,8 @@ import { getCloudtentaclesAsset } from '../platforms/cloudtentacles/catalog-service.js' +import { + normalizeCloudtentaclesDeviceId, + normalizeCloudtentaclesDeviceType, +} from '../platforms/cloudtentacles/defaults.js' import { getCloudtentaclesSourceByKey, listCloudtentaclesSources, @@ -65,7 +69,7 @@ async function runCloudtentaclesHealthAccount( ): Promise { const sourceKey = String(account.sourceKey || '').trim() || 'default' const source = getCloudtentaclesSourceByKey(sourceKey) - const session = getCloudtentaclesSessionStateByKey(sourceKey) || {} + const session: JsonObject = getCloudtentaclesSessionStateByKey(sourceKey) || {} const label = String(account.label || source?.label || source?.username || sourceKey).trim() || sourceKey const threshold = normalizeNonNegativeInteger(account.assetThreshold, 500) const checkedAt = new Date().toISOString() @@ -134,8 +138,8 @@ async function runCloudtentaclesHealthAccount( accountLabel: label, baseUrl: String(session.baseUrl || source.baseUrl || '').trim() || 'https://123.207.217.176', token, - deviceId: String(session.deviceId || source.deviceId || '-').trim() || '-', - deviceType: Number(session.deviceType ?? source.deviceType ?? 0), + deviceId: normalizeCloudtentaclesDeviceId(session.deviceId || source.deviceId), + deviceType: normalizeCloudtentaclesDeviceType(session.deviceType ?? source.deviceType), }) const asset = Number(assetResult.asset || 0) diff --git a/apps/backend/src/services/scheduler/config-service.ts b/apps/backend/src/services/scheduler/config-service.ts index 3aa053da..21a11a05 100644 --- a/apps/backend/src/services/scheduler/config-service.ts +++ b/apps/backend/src/services/scheduler/config-service.ts @@ -37,7 +37,9 @@ function loadScheduledJobsConfigFromFile() { export function normalizeScheduledJobsConfig(rawValue: unknown) { const source = isPlainObject(rawValue) ? rawValue : {} const rawJobs = Array.isArray(source.jobs) ? source.jobs : [] - const jobs = rawJobs.map((item) => normalizeScheduledJob(item)).filter(Boolean) + const jobs = rawJobs + .map((item) => normalizeScheduledJob(item)) + .filter((item): item is ReturnType => Boolean(item)) const hasCloudtentaclesHealth = jobs.some((item) => item.id === CLOUDTENTACLES_HEALTH_JOB_ID) if (!hasCloudtentaclesHealth) { diff --git a/apps/backend/src/services/scheduler/scheduler-service.ts b/apps/backend/src/services/scheduler/scheduler-service.ts index 2468a926..bac8939b 100644 --- a/apps/backend/src/services/scheduler/scheduler-service.ts +++ b/apps/backend/src/services/scheduler/scheduler-service.ts @@ -1,4 +1,5 @@ import { logError, logInfo, logWarn } from '../../utils/logger.js' +import { createHttpError } from '../../utils/http.js' import { getCloudtentaclesHealthJob, getScheduledJobsConfig, @@ -53,7 +54,10 @@ export async function runScheduledJobNow(jobId: unknown) { const job = config.jobs.find((item) => String(item.id || '').trim() === String(jobId || '').trim()) if (!job) { - throw new Error('定时任务不存在') + throw createHttpError('定时任务不存在', { + statusCode: 404, + errorCode: 'scheduled_job_not_found', + }) } const currentTimer = timers.get(job.id) @@ -171,7 +175,10 @@ function dispatchJob(job: JsonObject) { return runCloudtentaclesHealthJob(job) } - throw new Error(`不支持的定时任务类型:${job.type}`) + throw createHttpError(`不支持的定时任务类型:${job.type}`, { + statusCode: 400, + errorCode: 'unsupported_scheduled_job_type', + }) } function updateJobState(jobId: string, patch: JsonObject) { diff --git a/apps/backend/src/services/session/ocr.ts b/apps/backend/src/services/session/ocr.ts deleted file mode 100644 index 9c46b480..00000000 --- a/apps/backend/src/services/session/ocr.ts +++ /dev/null @@ -1,101 +0,0 @@ -import { runtimeConfig } from '../../config/runtime.js' - -const DEFAULT_TIMEOUT_MS = 60_000 - -type OcrAction = 'recognize' | 'batch' - -type OcrRequestPayload = Record - -type OcrHttpResult = Record & { - code: number - msg?: string -} - -export async function recognizeTencentCaptcha(payload: OcrRequestPayload): Promise { - return callOcrViaHttp('recognize', payload) -} - -export async function recognizeImageCaptcha(payload: OcrRequestPayload): Promise { - return callOcrViaHttp('recognize', payload) -} - -export async function batchRecognizeTencentCaptcha(payload: OcrRequestPayload): Promise { - return callOcrViaHttp('batch', payload, { timeoutMs: 180_000 }) -} - -export async function warmupOcrService(): Promise { - await warmupOcrViaHttp() -} - -export async function closeOcrService(): Promise { - // HTTP 模式无持久连接,无需关闭。 -} - -// ── HTTP Call ─────────────────────────────────────────────────────────────── - -async function callOcrViaHttp( - action: OcrAction, - payload: OcrRequestPayload, - { timeoutMs = DEFAULT_TIMEOUT_MS }: { timeoutMs?: number } = {}, -): Promise { - const baseUrl = String(runtimeConfig.ocr.baseUrl).trim().replace(/\/+$/, '') - - if (!baseUrl) { - throw new Error('OCR_BASE_URL 未配置,无法调用 OCR 服务') - } - - const url = `${baseUrl}/ocr/${action}` - - let response - try { - response = await fetch(url, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(payload), - signal: AbortSignal.timeout(timeoutMs), - }) - } catch (err) { - if (err instanceof Error && err.name === 'TimeoutError') { - throw new Error(`OCR 请求超时(${timeoutMs}ms),请检查 OCR 服务状态`) - } - throw new Error(`OCR 服务不可用(${baseUrl}),请检查 OCR_BASE_URL 或外部 OCR 服务状态`) - } - - if (!response.ok) { - throw new Error(`OCR 服务返回错误:HTTP ${response.status}`) - } - - const result = await response.json() - if (!result || typeof result !== 'object' || Array.isArray(result)) { - throw new Error('OCR 服务返回了无效响应') - } - const ocrResult = result as OcrHttpResult - if (ocrResult.code !== 0) { - throw new Error(`OCR 服务返回错误:${ocrResult.msg || '未知错误'}`) - } - - return ocrResult -} - -async function warmupOcrViaHttp(): Promise { - const baseUrl = String(runtimeConfig.ocr.baseUrl).trim().replace(/\/+$/, '') - - if (!baseUrl) { - throw new Error('OCR_BASE_URL 未配置,无法启动 OCR 健康检查') - } - - let response - try { - response = await fetch(`${baseUrl}/health`, { - signal: AbortSignal.timeout(30_000), - }) - } catch (err) { - if (err instanceof Error && err.name === 'TimeoutError') { - throw new Error(`OCR 服务健康检查超时,请检查 ${baseUrl} 是否可访问`) - } - throw new Error(`OCR 服务不可用(${baseUrl}),请检查 OCR_BASE_URL 或外部 OCR 服务状态`) - } - if (!response.ok) { - throw new Error(`OCR 服务健康检查失败:HTTP ${response.status}`) - } -} diff --git a/apps/backend/src/services/session/session-activity.ts b/apps/backend/src/services/session/session-activity.ts deleted file mode 100644 index 5010cf5a..00000000 --- a/apps/backend/src/services/session/session-activity.ts +++ /dev/null @@ -1,308 +0,0 @@ -/// - -import { extractHostState, reloadActivityPageForPresentation } from './session-page.js' -import { shouldRefreshLoggedInPresentation } from './session-state.js' - -type BrowserPageLike = { - evaluate: (callback: any) => Promise - waitForTimeout: (milliseconds: number) => Promise -} - -type HostState = { - loginText?: string - unloginVisible?: boolean - loginedVisible?: boolean -} - -export type ActivityInfo = { - nickname: string - role: { - ready: boolean - roleName: string - roleId: string - area: string - partition: string - platId: string - md5str: string - checkparam: string - } - form: { - cdkeyInputId: string - cdkeyValue: string - verifyInputId: string - verifyValue: string - verifyImgId: string - submitId: string - } - verify: { - visible: boolean - src: string - naturalWidth: number - naturalHeight: number - } - popup: { - visible: boolean - text: string - detail: string - } -} - -type SessionForPresentation = { - page: BrowserPageLike - presentationSyncAttempts: number -} - -type EnsureLoggedInPresentationOptions = { - hostState?: HostState | null - credentialReady?: boolean - activityUrl?: string - extractHostState?: (page: BrowserPageLike) => Promise - reloadActivityPageForPresentation?: (page: BrowserPageLike, activityUrl: string) => Promise - shouldRefreshLoggedInPresentation?: (hostState: HostState) => boolean -} - -type EnsureActivityInfoReadyOptions = { - triggerRender?: boolean - timeoutMs?: number - extractActivityInfo?: (page: BrowserPageLike) => Promise - triggerActivityRoleRender?: (page: BrowserPageLike) => Promise - now?: () => number -} - -declare global { - interface Window { - renderCdkey?: () => void - roleData?: Record - isRole?: unknown - } -} - -export function createEmptyActivityInfo(): ActivityInfo { - return { - nickname: '', - role: { - ready: false, - roleName: '', - roleId: '', - area: '', - partition: '', - platId: '', - md5str: '', - checkparam: '', - }, - form: { - cdkeyInputId: '', - cdkeyValue: '', - verifyInputId: '', - verifyValue: '', - verifyImgId: '', - submitId: '', - }, - verify: { - visible: false, - src: '', - naturalWidth: 0, - naturalHeight: 0, - }, - popup: { - visible: false, - text: '', - detail: '', - }, - } -} - -export async function ensureLoggedInPresentation( - session: SessionForPresentation, - { - hostState = null, - credentialReady = false, - activityUrl = '', - extractHostState: extractHostStateImpl = extractHostState, - reloadActivityPageForPresentation: reloadActivityPageForPresentationImpl = reloadActivityPageForPresentation, - shouldRefreshLoggedInPresentation: shouldRefreshLoggedInPresentationImpl = shouldRefreshLoggedInPresentation, - }: EnsureLoggedInPresentationOptions = {}, -): Promise<{ reloaded: boolean; hostState: HostState }> { - let nextHostState = hostState || (await extractHostStateImpl(session.page)) - - if (!credentialReady || !shouldRefreshLoggedInPresentationImpl(nextHostState)) { - return { - reloaded: false, - hostState: nextHostState, - } - } - - if (session.presentationSyncAttempts >= 2) { - return { - reloaded: false, - hostState: nextHostState, - } - } - - session.presentationSyncAttempts += 1 - await reloadActivityPageForPresentationImpl(session.page, activityUrl) - nextHostState = await extractHostStateImpl(session.page) - - return { - reloaded: true, - hostState: nextHostState, - } -} - -export async function ensureActivityInfoReady( - page: BrowserPageLike, - { - triggerRender = false, - timeoutMs = 8_000, - extractActivityInfo: extractActivityInfoImpl = extractActivityInfo, - triggerActivityRoleRender: triggerActivityRoleRenderImpl = triggerActivityRoleRender, - now = () => Date.now(), - }: EnsureActivityInfoReadyOptions = {}, -): Promise { - if (triggerRender) { - await triggerActivityRoleRenderImpl(page) - } - - const startedAt = now() - let lastInfo = await extractActivityInfoImpl(page) - - while (now() - startedAt < timeoutMs) { - if (lastInfo?.role?.ready) { - return lastInfo - } - - await page.waitForTimeout(400) - lastInfo = await extractActivityInfoImpl(page) - } - - return lastInfo -} - -export async function triggerActivityRoleRender(page: BrowserPageLike): Promise { - try { - await page.evaluate(() => { - const maybeRender = window.renderCdkey - - if (typeof maybeRender === 'function') { - maybeRender() - } - }) - } catch { - // ignore render trigger failures; polling will still inspect the page - } -} - -export async function extractActivityInfo(page: BrowserPageLike): Promise { - try { - return await page.evaluate(() => { - const asString = (value: unknown) => (typeof value === 'string' ? value.trim() : '') - const isVisible = (element: Element | null) => { - if (!element) { - return false - } - - const style = window.getComputedStyle(element) - return style.display !== 'none' && style.visibility !== 'hidden' && style.opacity !== '0' - } - - const pick = (selector: string) => document.querySelector(selector) - const pickBest = ( - selector: string, - predicate: ((element: Element) => boolean) | null = null, - ): Element | null => { - const matches = [...document.querySelectorAll(selector)] - const preferred = matches.find((element) => { - if (predicate && !predicate(element)) { - return false - } - - return isVisible(element) - }) - - if (preferred) { - return preferred - } - - if (predicate) { - return matches.find((element) => predicate(element)) || null - } - - return matches[0] || null - } - const textOf = (selector) => asString(pick(selector)?.textContent || '') - const inputOf = (selector) => { - const element = pickBest( - selector, - (node) => node instanceof HTMLInputElement || node instanceof HTMLTextAreaElement, - ) - - if (!(element instanceof HTMLInputElement) && !(element instanceof HTMLTextAreaElement)) { - return '' - } - - return asString(element.value || '') - } - - const roleData = window.roleData && typeof window.roleData === 'object' ? window.roleData : {} - const roleSelector = pick('[id^="milo_role_selector_"]') - const roleNameText = - textOf('#role_name') || - asString(roleSelector instanceof HTMLSelectElement ? roleSelector.selectedOptions?.[0]?.textContent || '' : '') - const verifyImg = pickBest('[id^="milo_verifyImg_"]', (node) => node instanceof HTMLImageElement) - const popup = pick('#pop2') - const cdkeyInput = pickBest( - '[id^="milo_cdkeyInfo_"]', - (node) => - (node instanceof HTMLInputElement || node instanceof HTMLTextAreaElement) && - !node.disabled && - node.type !== 'hidden', - ) - const verifyInput = pickBest( - '[id^="milo_verifyInput_"]', - (node) => - (node instanceof HTMLInputElement || node instanceof HTMLTextAreaElement) && - !node.disabled && - node.type !== 'hidden', - ) - const submitButton = pickBest( - '#milo_cdkey_submit, [id^="milo_cdkey_submit"]', - (node) => node instanceof HTMLElement && !node.hasAttribute('disabled'), - ) - - return { - nickname: textOf('#login_nickname_span'), - role: { - ready: Boolean(window.isRole || roleNameText), - roleName: roleNameText, - roleId: asString(roleData.sRoleId), - area: asString(roleData.sArea || '36'), - partition: asString(roleData.sPartition), - platId: asString(roleData.sPlatId), - md5str: asString(roleData.sMd5str || roleData.md5str || roleData.sMdStr), - checkparam: asString(roleData.sCheckparam), - }, - form: { - cdkeyInputId: cdkeyInput?.id || '', - cdkeyValue: inputOf('[id^="milo_cdkeyInfo_"]'), - verifyInputId: verifyInput?.id || '', - verifyValue: inputOf('[id^="milo_verifyInput_"]'), - verifyImgId: verifyImg?.id || '', - submitId: submitButton?.id || '', - }, - verify: { - visible: isVisible(verifyImg), - src: asString(verifyImg?.getAttribute('src')), - naturalWidth: verifyImg instanceof HTMLImageElement ? Number(verifyImg.naturalWidth || 0) : 0, - naturalHeight: verifyImg instanceof HTMLImageElement ? Number(verifyImg.naturalHeight || 0) : 0, - }, - popup: { - visible: isVisible(popup), - text: textOf('#PopText'), - detail: textOf('#PopText2'), - }, - } - }) - } catch { - return createEmptyActivityInfo() - } -} diff --git a/apps/backend/src/services/session/session-browser-config.ts b/apps/backend/src/services/session/session-browser-config.ts deleted file mode 100644 index 2504431c..00000000 --- a/apps/backend/src/services/session/session-browser-config.ts +++ /dev/null @@ -1,135 +0,0 @@ -import process from 'node:process' - -export type LoginType = 'qq' | 'wx' - -export const DEFAULT_LOGIN_TYPE: LoginType = 'qq' - -type BrowserConfigInput = { - headless?: boolean - devtools?: boolean - keepAlive?: boolean - prewarm?: boolean - slowMoMs?: number -} - -type ResolveBrowserLaunchOptionsInput = { - browserConfig?: BrowserConfigInput - chromePath?: string - nodeEnv?: string - ci?: string -} - -export type BrowserLaunchOptions = { - headless: boolean - devtools: boolean - usesBundledChromium: boolean - keepAlive: boolean - prewarm: boolean - slowMoMs: number -} - -export type ViewportSize = { - width: number - height: number -} - -export function resolveBrowserLaunchOptions(options: ResolveBrowserLaunchOptionsInput = {}): BrowserLaunchOptions { - const browserConfig = options.browserConfig || {} - const chromePath = options.chromePath || '' - const nodeEnv = options.nodeEnv ?? process.env.NODE_ENV - const ci = options.ci ?? process.env.CI - const explicitHeadless = browserConfig.headless - const explicitDevtools = browserConfig.devtools - const explicitKeepAlive = browserConfig.keepAlive - const explicitPrewarm = browserConfig.prewarm - const explicitSlowMo = browserConfig.slowMoMs - const isProductionLike = nodeEnv === 'production' || ci === '1' - - const headless = typeof explicitHeadless === 'boolean' ? explicitHeadless : isProductionLike - - const devtools = typeof explicitDevtools === 'boolean' ? explicitDevtools : !headless - - const slowMoMs = Number.isFinite(explicitSlowMo) - ? Math.max(0, Math.min(explicitSlowMo, 2_000)) - : headless - ? 0 - : 150 - - const keepAlive = typeof explicitKeepAlive === 'boolean' ? explicitKeepAlive : !isProductionLike - const prewarm = typeof explicitPrewarm === 'boolean' ? explicitPrewarm : keepAlive - - return { - headless, - devtools, - usesBundledChromium: !chromePath, - keepAlive, - prewarm, - slowMoMs, - } -} - -export function buildChromiumLaunchOptions( - launchOptions: BrowserLaunchOptions, - { chromePath = '' }: { chromePath?: string } = {}, -) { - const options = { - headless: launchOptions.headless, - devtools: launchOptions.devtools, - slowMo: launchOptions.slowMoMs, - } - - if (chromePath) { - return { - ...options, - executablePath: chromePath, - } - } - - return options -} - -export function resolveDefaultViewport(rawValue?: unknown): ViewportSize { - const fromVncResolution = parseViewportSpec(rawValue) - if (fromVncResolution) { - return fromVncResolution - } - - return { width: 1600, height: 900 } -} - -export function parseViewportSpec(rawValue: unknown): ViewportSize | null { - const text = String(rawValue || '').trim() - - if (!text) { - return null - } - - const match = text.match(/^(\d+)x(\d+)(?:x\d+)?$/i) - if (!match) { - return null - } - - const width = Number(match[1]) - const height = Number(match[2]) - - if (!Number.isFinite(width) || !Number.isFinite(height) || width < 800 || height < 600) { - return null - } - - return { width, height } -} - -export function normalizeLoginType(value: unknown, fallback: LoginType = DEFAULT_LOGIN_TYPE): LoginType { - const normalized = String(value || fallback).trim().toLowerCase() - if (['qq', 'qc'].includes(normalized)) { - return 'qq' - } - if (['wx', 'vx', 'wechat', 'weixin'].includes(normalized)) { - return 'wx' - } - return fallback -} - -export function getLoginTypeLabel(loginType: LoginType | string): string { - return loginType === 'wx' ? '微信' : 'QQ' -} diff --git a/apps/backend/src/services/session/session-cookie-map.ts b/apps/backend/src/services/session/session-cookie-map.ts deleted file mode 100644 index f39da4c8..00000000 --- a/apps/backend/src/services/session/session-cookie-map.ts +++ /dev/null @@ -1,49 +0,0 @@ -const TENCENT_COOKIE_URLS = [ - 'https://df.qq.com', - 'https://ssl.captcha.qq.com', - 'https://graph.qq.com', - 'https://milo.qq.com', -] - -type BrowserCookie = { - name?: string - value?: string -} - -type BrowserContextLike = { - cookies: (urls?: string[]) => Promise -} - -export type TencentCookieMap = Record - -export function normalizeTencentCookieMap(cookies: BrowserCookie[] = []): TencentCookieMap { - const cookieMap: TencentCookieMap = {} - - for (const cookie of cookies) { - if (!cookie?.name) { - continue - } - - cookieMap[String(cookie.name)] = String(cookie.value || '') - } - - if (!cookieMap.acctype) { - cookieMap.acctype = 'qc' - } - - if (!cookieMap.appid) { - cookieMap.appid = '101491592' - } - - if (cookieMap.openid && cookieMap.access_token && !cookieMap.iegams_milo_proxylogin_qc) { - cookieMap.iegams_milo_proxylogin_qc = - `${cookieMap.appid}_$$_${cookieMap.openid}_$$_${cookieMap.access_token}` - } - - return cookieMap -} - -export async function loadCookieMapFromContext(browserContext: BrowserContextLike): Promise { - const cookies = await browserContext.cookies(TENCENT_COOKIE_URLS) - return normalizeTencentCookieMap(cookies) -} diff --git a/apps/backend/src/services/session/session-login-shared.ts b/apps/backend/src/services/session/session-login-shared.ts deleted file mode 100644 index d59ca960..00000000 --- a/apps/backend/src/services/session/session-login-shared.ts +++ /dev/null @@ -1,113 +0,0 @@ -import { runtimeConfig } from '../../config/runtime.js' -import { logDebug } from '../../utils/logger.js' - -const SESSION_DEBUG_ENABLED = Boolean(runtimeConfig.session.debug) - -type FrameLike = unknown - -type PageWithFrames = { - frames: () => FrameLike[] - waitForTimeout: (milliseconds: number) => Promise -} - -type QrDownloadResponse = { - ok: () => boolean - status: () => number - body: () => Promise -} - -type PageWithRequestContext = { - context: () => { - request: { - get: (url: string, options?: { - failOnStatusCode?: boolean - headers?: Record - }) => Promise - } - } -} - -export function logBrowserSessionDebug(scope: string, detail?: unknown): void { - if (!SESSION_DEBUG_ENABLED) { - return - } - - logDebug('[browser/session]', scope, detail) -} - -export async function waitForFrame( - page: PageWithFrames, - predicate: (frame: TFrame) => boolean, - timeoutMs = 15_000, -): Promise { - const startedAt = Date.now() - - while (Date.now() - startedAt < timeoutMs) { - const matched = page.frames().find((frame): frame is TFrame => { - try { - return predicate(frame as TFrame) - } catch { - return false - } - }) - - if (matched) { - return matched - } - - await page.waitForTimeout(300) - } - - throw new Error('未找到登录二维码 frame') -} - -export async function downloadRemoteQrImage( - page: PageWithRequestContext, - qrUrl: string, - qrImagePath: string, - referer = '', - loginType = 'unknown', -): Promise { - try { - const response = await page.context().request.get(qrUrl, { - failOnStatusCode: false, - headers: { - referer, - }, - }) - - if (!response.ok()) { - logBrowserSessionDebug(`${loginType}.capture.downloadNotOk`, { - qrUrl, - status: response.status(), - }) - return false - } - - const body = await response.body() - - if (!body?.length) { - return false - } - - return body - } catch (error) { - logBrowserSessionDebug(`${loginType}.capture.downloadError`, { - qrUrl, - message: error instanceof Error ? error.message : String(error), - }) - return null - } -} - -export function isRetryableQrCaptureError(error: unknown): boolean { - const message = error instanceof Error ? error.message : String(error || '') - - return ( - message.includes('Frame was detached') || - message.includes('Execution context was destroyed') || - message.includes('Target page, context or browser has been closed') || - message.includes('waiting for') || - message.includes('Timeout') - ) -} diff --git a/apps/backend/src/services/session/session-page.ts b/apps/backend/src/services/session/session-page.ts deleted file mode 100644 index bc423f5b..00000000 --- a/apps/backend/src/services/session/session-page.ts +++ /dev/null @@ -1,244 +0,0 @@ -/// - -import fs from 'node:fs/promises' -import path from 'node:path' - -import { isRetryableQrCaptureError, logBrowserSessionDebug } from './session-login-shared.js' -import { captureQqQrImage, ensureQqLoginReady, extractQqQrState } from './session-qq.js' -import { captureWxQrImage, ensureWxLoginReady, extractWxQrState } from './session-wx.js' - -type LoginType = 'qq' | 'wx' | string - -type LocatorLike = { - count: () => Promise - first: () => LocatorLike - click: (options?: { timeout?: number }) => Promise - waitFor: (options?: { state?: string; timeout?: number }) => Promise - screenshot: (options: { path: string }) => Promise -} - -type BrowserPageLike = { - frames: () => any[] - locator: (selector: string) => LocatorLike - waitForTimeout: (milliseconds: number) => Promise - evaluate: (callback: any) => Promise - reload: (options?: { waitUntil?: string }) => Promise - goto: (url: string, options?: { waitUntil?: string }) => Promise - context: () => any -} - -type SessionForQrCapture = { - page: BrowserPageLike - loginType: LoginType - sessionDir: string - qrImagePath?: string - qrImageBase64?: string - qrUpdatedAt?: string - updatedAt?: string -} - -type CaptureSessionQrOptions = { - ensureLoginReady?: boolean - ensureLoginTab?: typeof ensureLoginTab - captureQrImageWithRetry?: typeof captureQrImageWithRetry - readFile?: (path: string, encoding: BufferEncoding) => Promise - now?: () => string -} - -type CaptureQrImageWithRetryOptions = { - captureQqQrImage?: (page: BrowserPageLike, qrImagePath: string) => Promise - captureWxQrImage?: (page: BrowserPageLike, qrImagePath: string) => Promise - ensureLoginTab?: typeof ensureLoginTab - isRetryableQrCaptureError?: (error: unknown) => boolean - logBrowserSessionDebug?: (scope: string, detail?: unknown) => void -} - -type EnsureLoginTabOptions = { - ensureQqLoginReady?: (page: BrowserPageLike) => Promise - ensureWxLoginReady?: (page: BrowserPageLike) => Promise -} - -type ExtractQrStateOptions = { - extractQqQrState?: (page: BrowserPageLike) => Promise - extractWxQrState?: (page: BrowserPageLike) => Promise -} - -export async function captureSessionQr( - session: SessionForQrCapture, - { - ensureLoginReady = true, - ensureLoginTab: ensureLoginTabImpl = ensureLoginTab, - captureQrImageWithRetry: captureQrImageWithRetryImpl = captureQrImageWithRetry, - readFile = fs.readFile, - now = () => new Date().toISOString(), - }: CaptureSessionQrOptions = {}, -): Promise { - if (ensureLoginReady) { - await ensureLoginTabImpl(session.page, session.loginType) - } - - const qrImagePath = path.join(session.sessionDir, `${session.loginType}-qr.png`) - await captureQrImageWithRetryImpl(session.page, session.loginType, qrImagePath, { - ensureLoginTab: ensureLoginTabImpl, - }) - - session.qrImagePath = qrImagePath - session.qrImageBase64 = await readFile(qrImagePath, 'base64') - session.qrUpdatedAt = now() - session.updatedAt = session.qrUpdatedAt -} - -export async function captureQrImageWithRetry( - page: BrowserPageLike, - loginType: LoginType, - qrImagePath: string, - { - captureQqQrImage: captureQqQrImageImpl = captureQqQrImage, - captureWxQrImage: captureWxQrImageImpl = captureWxQrImage, - ensureLoginTab: ensureLoginTabImpl = ensureLoginTab, - isRetryableQrCaptureError: isRetryableQrCaptureErrorImpl = isRetryableQrCaptureError, - logBrowserSessionDebug: logBrowserSessionDebugImpl = logBrowserSessionDebug, - }: CaptureQrImageWithRetryOptions = {}, -): Promise { - const maxAttempts = loginType === 'qq' ? 4 : 3 - let lastError: unknown = null - - for (let attempt = 1; attempt <= maxAttempts; attempt += 1) { - try { - logBrowserSessionDebugImpl('captureQrImageWithRetry.start', { - loginType, - attempt, - maxAttempts, - qrImagePath, - }) - - if (loginType === 'qq') { - await captureQqQrImageImpl(page, qrImagePath) - } else { - await captureWxQrImageImpl(page, qrImagePath) - } - - logBrowserSessionDebugImpl('captureQrImageWithRetry.success', { - loginType, - attempt, - qrImagePath, - }) - - return - } catch (error) { - lastError = error - logBrowserSessionDebugImpl('captureQrImageWithRetry.error', { - loginType, - attempt, - message: error instanceof Error ? error.message : String(error), - }) - - if (!isRetryableQrCaptureErrorImpl(error) || attempt === maxAttempts) { - throw error - } - - await page.waitForTimeout(500) - await ensureLoginTabImpl(page, loginType) - } - } - - if (lastError) { - throw lastError - } - - throw new Error('二维码截图失败') -} - -export async function ensureLoginTab( - page: BrowserPageLike, - loginType: LoginType, - { - ensureQqLoginReady: ensureQqLoginReadyImpl = ensureQqLoginReady, - ensureWxLoginReady: ensureWxLoginReadyImpl = ensureWxLoginReady, - }: EnsureLoginTabOptions = {}, -): Promise { - const tabSelector = loginType === 'wx' ? '.wx-tab' : '.qc-tab' - const loginButton = page.locator('#unlogin') - let interacted = false - - if (await loginButton.count()) { - try { - await loginButton.first().click({ timeout: 2_000 }) - interacted = true - } catch { - // ignore when the login layer is already open - } - } - - const tab = page.locator(tabSelector).first() - - if (await tab.count()) { - try { - await tab.click({ timeout: 2_000 }) - interacted = true - } catch { - // ignore tab click failures - } - } - - if (interacted) { - await page.waitForTimeout(250) - } - - if (loginType === 'qq') { - await ensureQqLoginReadyImpl(page) - return - } - - if (loginType === 'wx') { - await ensureWxLoginReadyImpl(page) - } -} - -export async function extractHostState(page: BrowserPageLike) { - return page.evaluate(() => { - const isVisible = (element: Element | null) => { - if (!element) { - return false - } - - const style = window.getComputedStyle(element) - - return style.display !== 'none' && style.visibility !== 'hidden' && style.opacity !== '0' - } - - const unlogin = document.querySelector('#unlogin') - const logined = document.querySelector('#logined') - - return { - unloginVisible: isVisible(unlogin), - loginedVisible: isVisible(logined), - loginText: String(logined?.textContent || '').trim(), - } - }) -} - -export async function reloadActivityPageForPresentation(page: BrowserPageLike, activityUrl: string): Promise { - try { - await page.reload({ waitUntil: 'domcontentloaded' }) - } catch { - await page.goto(activityUrl, { waitUntil: 'domcontentloaded' }) - } - - await page.waitForTimeout(2_500) -} - -export async function extractQrState( - page: BrowserPageLike, - loginType: LoginType, - { - extractQqQrState: extractQqQrStateImpl = extractQqQrState, - extractWxQrState: extractWxQrStateImpl = extractWxQrState, - }: ExtractQrStateOptions = {}, -): Promise { - if (loginType === 'qq') { - return extractQqQrStateImpl(page) - } - - return extractWxQrStateImpl(page) -} diff --git a/apps/backend/src/services/session/session-payload.ts b/apps/backend/src/services/session/session-payload.ts deleted file mode 100644 index 595e09ad..00000000 --- a/apps/backend/src/services/session/session-payload.ts +++ /dev/null @@ -1,190 +0,0 @@ -export function buildSessionPayload( - session: TencentBrowserSessionPayloadSource, - { - activityUrl = '', - includeQrImage = true, - sessionDebugEnabled = false, - browserDebug = null, - }: BuildSessionPayloadOptions = {}, -): any { - const activityInfo = session.lastState?.activityInfo || null - const payload: Record = { - sessionId: session.sessionId, - loginType: session.loginType, - activityUrl, - status: session.status, - notice: session.notice, - createdAt: session.createdAt, - updatedAt: session.updatedAt, - expiresAt: new Date(session.expiresAt).toISOString(), - qrUpdatedAt: session.qrUpdatedAt, - credentialReady: Boolean(session.lastState?.credentialReady), - activityInfo, - review: buildReviewPayload(session.lastReview), - redeem: buildRedeemPayload(session.lastRedeem), - artifacts: buildArtifactsPayload(session), - } - - if (includeQrImage) { - payload.qrImageBase64 = session.qrImageBase64 - } - - if (!sessionDebugEnabled) { - return payload - } - - return { - ...payload, - cookieKeys: Array.isArray(session.lastState?.cookieKeys) ? session.lastState.cookieKeys : [], - state: session.lastState, - browserDebug, - qrImagePath: session.qrImagePath, - review: buildReviewPayload(session.lastReview, { includeInternalPaths: true }), - redeem: buildRedeemPayload(session.lastRedeem, { includeInternalPaths: true }), - artifacts: buildArtifactsPayload(session, { includeInternalPaths: true }), - } -} - -type BuildSessionPayloadOptions = { - activityUrl?: string - includeQrImage?: boolean - sessionDebugEnabled?: boolean - browserDebug?: unknown -} - -type InternalPathOptions = { - includeInternalPaths?: boolean -} - -type TencentBrowserSessionPayloadSource = { - sessionId: string - loginType?: string - status?: string - notice?: string - createdAt?: string - updatedAt?: string - expiresAt: string | number | Date - qrUpdatedAt?: string - qrImageBase64?: string - qrImagePath?: string - sessionDir?: string - lastState?: { - credentialReady?: boolean - cookieKeys?: string[] - activityInfo?: any - [key: string]: unknown - } | null - lastReview?: ReviewPayloadSource | null - lastRedeem?: RedeemPayloadSource | null -} - -type ReviewPayloadSource = { - capturedAt?: string - roleId?: string - roleName?: string - screenshotPath?: string - signature?: string -} - -type RedeemAttemptPayloadSource = { - attempt?: number - redeem?: unknown -} - -type RedeemPayloadSource = { - code?: string - area?: string - proofMode?: string - attempts?: RedeemAttemptPayloadSource[] - final?: RedeemAttemptPayloadSource | null - finishedAt?: string - screenshotPath?: string - htmlPath?: string - resultPath?: string -} - -export function buildReviewPayload(review: ReviewPayloadSource | null | undefined, { includeInternalPaths = false }: InternalPathOptions = {}) { - if (!review) { - return null - } - - const payload = { - capturedAt: review.capturedAt, - roleId: review.roleId || '', - roleName: review.roleName || '', - } - - if (!includeInternalPaths) { - return payload - } - - return { - ...payload, - screenshotPath: review.screenshotPath || '', - signature: review.signature || '', - } -} - -export function buildRedeemPayload(redeem: RedeemPayloadSource | null | undefined, { includeInternalPaths = false }: InternalPathOptions = {}) { - if (!redeem) { - return null - } - - const payload = { - code: redeem.code, - area: redeem.area, - proofMode: redeem.proofMode || 'full', - attempts: Array.isArray(redeem.attempts) - ? redeem.attempts.map((attempt) => ({ - attempt: attempt.attempt, - redeem: attempt.redeem || null, - })) - : [], - final: redeem.final - ? { - attempt: redeem.final.attempt, - redeem: redeem.final.redeem || null, - } - : null, - finishedAt: redeem.finishedAt, - } - - if (!includeInternalPaths) { - return payload - } - - return { - ...payload, - screenshotPath: redeem.screenshotPath || '', - htmlPath: redeem.htmlPath || '', - resultPath: redeem.resultPath || '', - } -} - -export function buildArtifactsPayload( - session: Pick< - TencentBrowserSessionPayloadSource, - 'sessionDir' | 'qrImagePath' | 'qrImageBase64' | 'lastReview' | 'lastRedeem' - >, - { includeInternalPaths = false }: InternalPathOptions = {}, -) { - const payload = { - hasQrImage: Boolean(session.qrImageBase64), - hasReviewScreenshot: Boolean(session.lastReview?.screenshotPath), - hasScreenshot: Boolean(session.lastRedeem?.screenshotPath), - } - - if (!includeInternalPaths) { - return payload - } - - return { - ...payload, - sessionDir: session.sessionDir, - qrImagePath: session.qrImagePath, - screenshotPath: session.lastRedeem?.screenshotPath || '', - htmlPath: session.lastRedeem?.htmlPath || '', - resultPath: session.lastRedeem?.resultPath || '', - reviewScreenshotPath: session.lastReview?.screenshotPath || '', - } -} diff --git a/apps/backend/src/services/session/session-proof-beijing-time.ts b/apps/backend/src/services/session/session-proof-beijing-time.ts deleted file mode 100644 index 4805a51b..00000000 --- a/apps/backend/src/services/session/session-proof-beijing-time.ts +++ /dev/null @@ -1,55 +0,0 @@ -/// - -import { - BAIDU_BEIJING_TIME_URL, - BEIJING_TIME_PROOF_CLIP, - BEIJING_TIME_PROOF_VIEWPORT, -} from './session-proof-constants.js' - -type BrowserPageLike = { - setViewportSize: (viewport: { width: number; height: number }) => Promise - goto: (url: string, options?: { waitUntil?: string }) => Promise - waitForTimeout: (milliseconds: number) => Promise - evaluate: (callback: () => unknown) => Promise - screenshot: (options: { - path: string - clip: { x: number; y: number; width: number; height: number } - }) => Promise - close: () => Promise -} - -type BrowserContextLike = { - newPage: () => Promise -} - -export type BeijingTimeProof = { - screenshotPath: string - pageUrl: string -} - -export async function captureBeijingTimeProof( - browserContext: BrowserContextLike, - { screenshotPath }: { screenshotPath: string }, -): Promise { - const proofPage = await browserContext.newPage() - - try { - await proofPage.setViewportSize(BEIJING_TIME_PROOF_VIEWPORT) - await proofPage.goto(BAIDU_BEIJING_TIME_URL, { waitUntil: 'domcontentloaded' }) - await proofPage.waitForTimeout(3_000) - await proofPage.evaluate(() => { - window.scrollTo(0, 0) - }) - await proofPage.screenshot({ - path: screenshotPath, - clip: BEIJING_TIME_PROOF_CLIP, - }) - - return { - screenshotPath, - pageUrl: BAIDU_BEIJING_TIME_URL, - } - } finally { - await proofPage.close().catch(() => null) - } -} diff --git a/apps/backend/src/services/session/session-proof-constants.ts b/apps/backend/src/services/session/session-proof-constants.ts deleted file mode 100644 index 0b80f9f0..00000000 --- a/apps/backend/src/services/session/session-proof-constants.ts +++ /dev/null @@ -1,11 +0,0 @@ -export const BAIDU_BEIJING_TIME_URL = 'https://www.baidu.com/s?ie=utf-8&f=3&rsv_bp=1&rsv_idx=1&tn=baidu&wd=%E5%8C%97%E4%BA%AC%E6%97%B6%E9%97%B4&fenlei=256&rsv_pq=0xa7d529b30000a6eb&rsv_t=2d404COdLspTa8jnKQl%2FyCU%2BSCmRyb%2BvSqxxjk%2B7E1scokLYTuDbehaQ0Dvq&rqlang=en&rsv_enter=1&rsv_dl=ih_0&rsv_sug3=1&rsv_sug1=1&rsv_sug7=001&rsv_sug2=1&rsv_btype=i&rsp=0&rsv_sug9=es_2_1&rsv_sug=1' -export type RedeemProofMode = 'full' | 'basic' | 'off' - -export const BEIJING_TIME_PROOF_VIEWPORT = { width: 1440, height: 860 } -export const BEIJING_TIME_PROOF_CLIP = { - x: 0, - y: 0, - width: BEIJING_TIME_PROOF_VIEWPORT.width, - height: 620, -} -export const DEFAULT_REDEEM_PROOF_MODE: RedeemProofMode = 'full' diff --git a/apps/backend/src/services/session/session-proof-html.ts b/apps/backend/src/services/session/session-proof-html.ts deleted file mode 100644 index 31f5d32b..00000000 --- a/apps/backend/src/services/session/session-proof-html.ts +++ /dev/null @@ -1,180 +0,0 @@ -export type ProofHtmlInput = { - redeemImageBase64: string - timeImageBase64: string - redeemMessage?: string - timePageUrl: string -} - -export function buildProofHtml({ - redeemImageBase64, - timeImageBase64, - redeemMessage, - timePageUrl, -}: ProofHtmlInput): string { - const capturedAt = new Date().toLocaleString('zh-CN', { - hour12: false, - timeZone: 'Asia/Shanghai', - }) - - return ` - - - - - 兑换与北京时间凭证 - - - -
-
-
-

Tencent Redeem Proof

-

兑换截图与北京时间截图

-

用于留存兑换结果与北京时间检索页的组合凭证。

-
-
- Captured At - ${escapeHtml(capturedAt)} -
-
-
-

兑换结果截图

-

${escapeHtml(redeemMessage || '兑换完成')}

-
- 兑换结果截图 -
-
-
-

百度检索“北京时间”截图

-

单独标签页打开百度搜索结果并截图。

-
${escapeHtml(timePageUrl)}
-
- 北京时间截图 -
-
-
- -` -} - -export function escapeHtml(value: unknown): string { - return String(value || '') - .replaceAll('&', '&') - .replaceAll('<', '<') - .replaceAll('>', '>') - .replaceAll('"', '"') -} diff --git a/apps/backend/src/services/session/session-proof-mode.ts b/apps/backend/src/services/session/session-proof-mode.ts deleted file mode 100644 index 790ef3a6..00000000 --- a/apps/backend/src/services/session/session-proof-mode.ts +++ /dev/null @@ -1,38 +0,0 @@ -import { runtimeConfig } from '../../config/runtime.js' - -import { DEFAULT_REDEEM_PROOF_MODE, type RedeemProofMode } from './session-proof-constants.js' - -type RedeemResultForProof = { - classification?: { - success?: boolean - } - redeem?: { - iRet?: number | string - ret?: number | string - } -} | null | undefined - -export function resolveRedeemProofMode(rawMode: unknown = runtimeConfig.redeem.proofMode): RedeemProofMode { - const normalized = String(rawMode || '') - .trim() - .toLowerCase() - - if (normalized === 'basic' || normalized === 'off') { - return normalized - } - - return DEFAULT_REDEEM_PROOF_MODE -} - -export function shouldCaptureBeijingTimeProof(finalResult: RedeemResultForProof): boolean { - if (finalResult?.classification?.success === true) { - return true - } - - if (finalResult?.classification?.success === false) { - return false - } - - const retCode = Number(finalResult?.redeem?.iRet ?? finalResult?.redeem?.ret) - return Number.isFinite(retCode) && retCode === 0 -} diff --git a/apps/backend/src/services/session/session-proof-paths.ts b/apps/backend/src/services/session/session-proof-paths.ts deleted file mode 100644 index e8202379..00000000 --- a/apps/backend/src/services/session/session-proof-paths.ts +++ /dev/null @@ -1,19 +0,0 @@ -import path from 'node:path' - -export type RedeemArtifactPaths = { - redeemPageScreenshotPath: string - beijingTimeScreenshotPath: string - screenshotPath: string - htmlPath: string - resultPath: string -} - -export function buildArtifactPaths(outputDir: string): RedeemArtifactPaths { - return { - redeemPageScreenshotPath: path.join(outputDir, 'redeem-page.png'), - beijingTimeScreenshotPath: path.join(outputDir, 'beijing-time.png'), - screenshotPath: path.join(outputDir, 'redeem-result.png'), - htmlPath: path.join(outputDir, 'page.html'), - resultPath: path.join(outputDir, 'result.json'), - } -} diff --git a/apps/backend/src/services/session/session-proof-renderer.ts b/apps/backend/src/services/session/session-proof-renderer.ts deleted file mode 100644 index 891d5735..00000000 --- a/apps/backend/src/services/session/session-proof-renderer.ts +++ /dev/null @@ -1,131 +0,0 @@ -/// - -import fs from 'node:fs/promises' - -import { buildProofHtml } from './session-proof-html.js' - -type BrowserPageLike = { - evaluate: (callback: any, arg?: unknown) => Promise - waitForTimeout: (milliseconds: number) => Promise - setViewportSize: (viewport: { width: number; height: number }) => Promise - setContent: (html: string, options?: { waitUntil?: string }) => Promise - screenshot: (options: { path: string; fullPage?: boolean }) => Promise - close: () => Promise -} - -type BrowserContextLike = { - newPage: () => Promise -} - -type ComposeProofScreenshotOptions = { - outputPath: string - redeemImagePath: string - timeImagePath: string - redeemMessage?: string - timePageUrl: string -} - -export async function showResultDialog(page: BrowserPageLike, message: string): Promise { - await page.evaluate((text) => { - document.querySelectorAll('iframe').forEach((element) => element.remove()) - document.querySelectorAll('.pop').forEach((element) => { - if (element instanceof HTMLElement) { - element.style.display = 'none' - } - }) - - let card = document.getElementById('codex-redeem-result') - - if (!card) { - card = document.createElement('div') - card.id = 'codex-redeem-result' - card.innerHTML = ` -
Tencent Browser Session
-
兑换结果
-
- ` - document.body.appendChild(card) - } - - const messageNode = card.querySelector('.codex-redeem-result__message') - - if (messageNode) { - messageNode.textContent = text - } - - Object.assign(card.style, { - position: 'fixed', - left: '50%', - top: '50%', - transform: 'translate(-50%, -50%)', - zIndex: '99999', - width: 'min(560px, calc(100vw - 80px))', - padding: '32px 36px', - borderRadius: '24px', - background: 'rgba(10, 18, 28, 0.92)', - boxShadow: '0 24px 80px rgba(0, 0, 0, 0.35)', - border: '1px solid rgba(109, 241, 202, 0.25)', - color: '#f4fbff', - fontFamily: '"PingFang SC", "Microsoft YaHei", sans-serif', - }) - - const styleId = 'codex-redeem-result-style' - - if (!document.getElementById(styleId)) { - const style = document.createElement('style') - style.id = styleId - style.textContent = ` - #codex-redeem-result .codex-redeem-result__eyebrow { - font-size: 12px; - letter-spacing: 0.18em; - text-transform: uppercase; - color: #6df1ca; - margin-bottom: 12px; - } - #codex-redeem-result .codex-redeem-result__title { - font-size: 34px; - font-weight: 700; - margin-bottom: 16px; - } - #codex-redeem-result .codex-redeem-result__message { - font-size: 24px; - line-height: 1.5; - color: #ffffff; - } - ` - document.head.appendChild(style) - } - - window.scrollTo(0, 0) - }, message) - - await page.waitForTimeout(600) -} - -export async function composeProofScreenshot( - browserContext: BrowserContextLike, - { outputPath, redeemImagePath, timeImagePath, redeemMessage, timePageUrl }: ComposeProofScreenshotOptions, -): Promise { - const [redeemImageBase64, timeImageBase64] = await Promise.all([ - fs.readFile(redeemImagePath, 'base64'), - fs.readFile(timeImagePath, 'base64'), - ]) - - const composePage = await browserContext.newPage() - - try { - await composePage.setViewportSize({ width: 1520, height: 1900 }) - await composePage.setContent( - buildProofHtml({ - redeemImageBase64, - timeImageBase64, - redeemMessage, - timePageUrl, - }), - { waitUntil: 'domcontentloaded' }, - ) - await composePage.screenshot({ path: outputPath, fullPage: true }) - } finally { - await composePage.close().catch(() => null) - } -} diff --git a/apps/backend/src/services/session/session-proof-result-writer.ts b/apps/backend/src/services/session/session-proof-result-writer.ts deleted file mode 100644 index 2910933e..00000000 --- a/apps/backend/src/services/session/session-proof-result-writer.ts +++ /dev/null @@ -1,65 +0,0 @@ -import fs from 'node:fs/promises' - -import { BAIDU_BEIJING_TIME_URL, type RedeemProofMode } from './session-proof-constants.js' - -type RedeemResultFilePayload = { - proofMode: RedeemProofMode - sessionId?: string - activityUrl: string - code: string - finalResult?: { - role?: { - area?: string - } - [key: string]: unknown - } | null - attempts?: unknown[] - screenshotPath: string - htmlPath?: string - redeemPageScreenshotPath?: string - beijingTimeScreenshotPath?: string - beijingTimePageUrl?: string - beijingTimeError?: string -} - -export async function writeRedeemResultFile( - resultPath: string, - { - proofMode, - sessionId, - activityUrl, - code, - finalResult, - attempts, - screenshotPath, - htmlPath = '', - redeemPageScreenshotPath = '', - beijingTimeScreenshotPath = '', - beijingTimePageUrl = BAIDU_BEIJING_TIME_URL, - beijingTimeError = '', - }: RedeemResultFilePayload, -): Promise { - await fs.writeFile( - resultPath, - JSON.stringify( - { - proofMode, - sessionId, - activityUrl, - code, - area: finalResult?.role?.area || '', - final: finalResult, - attempts, - redeemPageScreenshotPath, - beijingTimeScreenshotPath, - beijingTimePageUrl, - beijingTimeError, - screenshotPath, - htmlPath, - }, - null, - 2, - ), - 'utf8', - ) -} diff --git a/apps/backend/src/services/session/session-proof.test.ts b/apps/backend/src/services/session/session-proof.test.ts deleted file mode 100644 index ff4ef220..00000000 --- a/apps/backend/src/services/session/session-proof.test.ts +++ /dev/null @@ -1,81 +0,0 @@ -import test from 'node:test' -import assert from 'node:assert/strict' -import path from 'node:path' - -import { resolveRedeemProofMode, shouldCaptureBeijingTimeProof } from './session-proof.js' -import { buildProofHtml } from './session-proof-html.js' -import { buildArtifactPaths } from './session-proof-paths.js' - -test('resolveRedeemProofMode normalizes configured modes and falls back to full', () => { - assert.equal(resolveRedeemProofMode('basic'), 'basic') - assert.equal(resolveRedeemProofMode(' OFF '), 'off') - assert.equal(resolveRedeemProofMode('full'), 'full') - assert.equal(resolveRedeemProofMode('unexpected'), 'full') - assert.equal(resolveRedeemProofMode(''), 'full') -}) - -test('shouldCaptureBeijingTimeProof only returns true for successful redeem results', () => { - assert.equal( - shouldCaptureBeijingTimeProof({ - classification: { - success: true, - }, - }), - true, - ) - - assert.equal( - shouldCaptureBeijingTimeProof({ - classification: { - success: false, - }, - }), - false, - ) - - assert.equal( - shouldCaptureBeijingTimeProof({ - redeem: { - iRet: 0, - }, - }), - true, - ) - - assert.equal( - shouldCaptureBeijingTimeProof({ - redeem: { - iRet: -183, - }, - }), - false, - ) - - assert.equal(shouldCaptureBeijingTimeProof(null), false) -}) - -test('buildArtifactPaths keeps redeem proof artifact filenames stable', () => { - const artifactPaths = buildArtifactPaths('/tmp/redeem-proof') - - assert.equal(artifactPaths.redeemPageScreenshotPath, path.join('/tmp/redeem-proof', 'redeem-page.png')) - assert.equal(artifactPaths.beijingTimeScreenshotPath, path.join('/tmp/redeem-proof', 'beijing-time.png')) - assert.equal(artifactPaths.screenshotPath, path.join('/tmp/redeem-proof', 'redeem-result.png')) - assert.equal(artifactPaths.htmlPath, path.join('/tmp/redeem-proof', 'page.html')) - assert.equal(artifactPaths.resultPath, path.join('/tmp/redeem-proof', 'result.json')) -}) - -test('buildProofHtml escapes injected text while keeping proof sections stable', () => { - const html = buildProofHtml({ - redeemImageBase64: 'redeem-base64', - timeImageBase64: 'time-base64', - redeemMessage: '兑换成功 & 已到账', - timePageUrl: 'https://example.com/?q=<北京时间>', - }) - - assert.match(html, /Tencent Redeem Proof/) - assert.match(html, /兑换截图与北京时间截图/) - assert.match(html, /<b>兑换成功 & 已到账<\/b>/) - assert.match(html, /https:\/\/example\.com\/\?q=<北京时间>/) - assert.match(html, /data:image\/png;base64,redeem-base64/) - assert.match(html, /data:image\/png;base64,time-base64/) -}) diff --git a/apps/backend/src/services/session/session-proof.ts b/apps/backend/src/services/session/session-proof.ts deleted file mode 100644 index c132ce44..00000000 --- a/apps/backend/src/services/session/session-proof.ts +++ /dev/null @@ -1,205 +0,0 @@ -import fs from 'node:fs/promises' - -import { - BAIDU_BEIJING_TIME_URL, - DEFAULT_REDEEM_PROOF_MODE, - type RedeemProofMode, -} from './session-proof-constants.js' -import { captureBeijingTimeProof } from './session-proof-beijing-time.js' -import { showResultDialog, composeProofScreenshot } from './session-proof-renderer.js' -import { resolveRedeemProofMode, shouldCaptureBeijingTimeProof } from './session-proof-mode.js' -import { buildArtifactPaths } from './session-proof-paths.js' -import { writeRedeemResultFile } from './session-proof-result-writer.js' - -type BrowserPageLike = { - evaluate: (callback: any, arg?: unknown) => Promise - waitForTimeout: (milliseconds: number) => Promise - setViewportSize: (viewport: { width: number; height: number }) => Promise - setContent: (html: string, options?: { waitUntil?: string }) => Promise - goto: (url: string, options?: { waitUntil?: string }) => Promise - screenshot: (options: { - path: string - fullPage?: boolean - clip?: { x: number; y: number; width: number; height: number } - }) => Promise - content: () => Promise - close: () => Promise -} - -type BrowserContextLike = { - newPage: () => Promise -} - -type RedeemFinalResult = { - role?: { - area?: string - } - classification?: { - success?: boolean - } - redeem?: { - iRet?: number | string - ret?: number | string - sMsg?: string - } - [key: string]: unknown -} - -type RedeemArtifactsInput = { - browserContext: BrowserContextLike - page: BrowserPageLike - sessionDir: string - sessionId?: string - activityUrl: string - code: string - finalResult: RedeemFinalResult - attempts?: unknown[] -} - -type WriteRedeemArtifactFilesInput = Omit & { - outputDir: string - proofMode?: RedeemProofMode | string -} - -type RedeemArtifactsResult = { - proofMode: RedeemProofMode - screenshotPath: string - htmlPath: string - resultPath: string -} - -export async function saveRedeemArtifacts({ - browserContext, - page, - sessionDir, - sessionId, - activityUrl, - code, - finalResult, - attempts, -}: RedeemArtifactsInput): Promise { - return writeRedeemArtifactFiles({ - browserContext, - page, - outputDir: sessionDir, - sessionId, - activityUrl, - code, - finalResult, - attempts, - proofMode: resolveRedeemProofMode(), - }) -} - -export async function writeRedeemArtifactFiles({ - browserContext, - page, - outputDir, - sessionId = '', - activityUrl, - code, - finalResult, - attempts, - proofMode = DEFAULT_REDEEM_PROOF_MODE, -}: WriteRedeemArtifactFilesInput): Promise { - const effectiveProofMode = resolveRedeemProofMode(proofMode) - const { - redeemPageScreenshotPath, - beijingTimeScreenshotPath, - screenshotPath, - htmlPath, - resultPath, - } = buildArtifactPaths(outputDir) - - if (effectiveProofMode === 'off') { - return { - proofMode: effectiveProofMode, - screenshotPath: '', - htmlPath: '', - resultPath: '', - } - } - - await showResultDialog(page, finalResult.redeem?.sMsg || '兑换完成') - - if (effectiveProofMode === 'basic') { - await page.screenshot({ path: screenshotPath, fullPage: true }) - await writeRedeemResultFile(resultPath, { - proofMode: effectiveProofMode, - sessionId, - activityUrl, - code, - finalResult, - attempts, - screenshotPath, - }) - - return { - proofMode: effectiveProofMode, - screenshotPath, - htmlPath: '', - resultPath, - } - } - - await page.screenshot({ path: redeemPageScreenshotPath, fullPage: true }) - - let beijingTimeProof: { - screenshotPath: string - pageUrl: string - error?: string - } = { - screenshotPath: '', - pageUrl: BAIDU_BEIJING_TIME_URL, - error: '', - } - - if (shouldCaptureBeijingTimeProof(finalResult)) { - try { - beijingTimeProof = await captureBeijingTimeProof(browserContext, { - screenshotPath: beijingTimeScreenshotPath, - }) - await composeProofScreenshot(browserContext, { - outputPath: screenshotPath, - redeemImagePath: redeemPageScreenshotPath, - timeImagePath: beijingTimeProof.screenshotPath, - redeemMessage: String(finalResult.redeem?.sMsg || '兑换完成'), - timePageUrl: beijingTimeProof.pageUrl, - }) - } catch (error) { - beijingTimeProof = { - screenshotPath: '', - pageUrl: BAIDU_BEIJING_TIME_URL, - error: error instanceof Error ? error.message : '北京时间截图生成失败', - } - await fs.copyFile(redeemPageScreenshotPath, screenshotPath) - } - } else { - await fs.copyFile(redeemPageScreenshotPath, screenshotPath) - } - - await fs.writeFile(htmlPath, await page.content(), 'utf8') - await writeRedeemResultFile(resultPath, { - proofMode: effectiveProofMode, - sessionId, - activityUrl, - code, - finalResult, - attempts, - screenshotPath, - htmlPath, - redeemPageScreenshotPath, - beijingTimeScreenshotPath: beijingTimeProof.screenshotPath || '', - beijingTimePageUrl: beijingTimeProof.pageUrl, - beijingTimeError: beijingTimeProof.error || '', - }) - - return { - proofMode: effectiveProofMode, - screenshotPath, - htmlPath, - resultPath, - } -} - -export { resolveRedeemProofMode, shouldCaptureBeijingTimeProof } diff --git a/apps/backend/src/services/session/session-qq.ts b/apps/backend/src/services/session/session-qq.ts deleted file mode 100644 index bf96d042..00000000 --- a/apps/backend/src/services/session/session-qq.ts +++ /dev/null @@ -1,240 +0,0 @@ -/// - -import fs from 'node:fs/promises' - -import { downloadRemoteQrImage, logBrowserSessionDebug, waitForFrame } from './session-login-shared.js' - -const QQ_QR_TARGET_SIZE = 144 - -type LocatorLike = { - count: () => Promise - click: (options?: { timeout?: number }) => Promise - waitFor: (options?: { state?: string; timeout?: number }) => Promise - screenshot: (options: { path: string }) => Promise - first: () => LocatorLike -} - -type FrameLike = { - url: () => string - locator: (selector: string) => LocatorLike - waitForTimeout: (milliseconds: number) => Promise - evaluate: (callback: any) => Promise -} - -type PageLike = { - frames: () => FrameLike[] - waitForTimeout: (milliseconds: number) => Promise - locator: (selector: string) => LocatorLike - context: () => { - request: { - get: (url: string, options?: any) => Promise<{ - ok: () => boolean - status: () => number - body: () => Promise - }> - } - } -} - -type QqQrState = { - visible: boolean - qrVisible?: boolean - scanned: boolean - expired: boolean - message: string - frameUrl?: string -} - -export async function ensureQqLoginReady(page: PageLike): Promise { - try { - const frame = await waitForFrame( - page, - (item) => item.url().includes('xui.ptlogin2.qq.com/cgi-bin/xlogin'), - 10_000, - ) - const switcher = frame.locator('#switcher_qlogin') - - if (!(await switcher.count())) { - return - } - - await switcher.click({ timeout: 3_000 }).catch(() => null) - await frame.waitForTimeout(250) - } catch { - // ignore qq inner tab switch failures; the screenshot retry path will try again - } -} - -export async function captureQqQrImage(page: PageLike, qrImagePath: string): Promise { - const frame = await waitForFrame( - page, - (item) => item.url().includes('xui.ptlogin2.qq.com/cgi-bin/xlogin'), - 10_000, - ) - const qrUrl = await resolveQqQrImageUrl(frame) - const effectiveQrUrl = upgradeQqQrImageUrl(qrUrl) - - logBrowserSessionDebug('qq.capture.qrUrl', { - qrUrl, - effectiveQrUrl, - frameUrl: frame.url(), - }) - - if (effectiveQrUrl) { - const body = await downloadRemoteQrImage(page, effectiveQrUrl, qrImagePath, frame.url(), 'qq') - logBrowserSessionDebug('qq.capture.downloadResult', { - qrUrl: effectiveQrUrl, - downloaded: Buffer.isBuffer(body) && body.length > 0, - qrImagePath, - }) - - if (Buffer.isBuffer(body) && body.length > 0) { - await fs.writeFile(qrImagePath, body) - return - } - } - - logBrowserSessionDebug('qq.capture.fallbackScreenshot', { - qrImagePath, - }) - - const qrLocator = await findQqQrLocator(page, frame) - - try { - await qrLocator.screenshot({ path: qrImagePath }) - return - } catch { - const iframeLocator = page.locator('#milo-qcwx-frame-qc').first() - await iframeLocator.waitFor({ state: 'visible', timeout: 15_000 }) - await iframeLocator.screenshot({ path: qrImagePath }) - } -} - -export async function findQqQrLocator(page: PageLike, frame: FrameLike | null = null): Promise { - const effectiveFrame = - frame || - await waitForFrame(page, (item) => item.url().includes('xui.ptlogin2.qq.com/cgi-bin/xlogin')) - const locator = effectiveFrame.locator('#qrlogin_img') - await locator.waitFor({ state: 'visible', timeout: 15_000 }) - logBrowserSessionDebug('qq.findQrLocator.visibleReady', { - frameUrl: effectiveFrame.url(), - }) - return locator -} - -export async function extractQqQrState(page: Pick): Promise { - const frame = page.frames().find((item) => item.url().includes('xui.ptlogin2.qq.com/cgi-bin/xlogin')) - - if (!frame) { - return { - visible: false, - scanned: false, - expired: false, - message: '', - } - } - - try { - const { qrVisible, bodyText } = await frame - .evaluate(() => { - const isVisible = (element: Element | null) => { - if (!(element instanceof HTMLElement)) { - return false - } - - const style = window.getComputedStyle(element) - const rect = element.getBoundingClientRect() - return ( - style.display !== 'none' && - style.visibility !== 'hidden' && - style.opacity !== '0' && - rect.width > 0 && - rect.height > 0 - ) - } - - const qrImage = document.querySelector('#qrlogin_img') - const bodyText = String(document.body?.textContent || '').replace(/\s+/g, ' ').trim() - - return { - qrVisible: - qrImage instanceof HTMLImageElement && - isVisible(qrImage) && - Number(qrImage.naturalWidth || 0) > 0 && - Number(qrImage.naturalHeight || 0) > 0, - bodyText, - } - }) - .catch(() => ({ - qrVisible: false, - bodyText: '', - })) - - return { - visible: true, - qrVisible, - scanned: !qrVisible && /扫描成功|请在手机上确认登录/.test(bodyText), - expired: qrVisible ? false : /二维码失效|已失效/.test(bodyText), - message: bodyText.slice(0, 200), - frameUrl: frame.url(), - } - } catch { - return { - visible: true, - qrVisible: false, - scanned: false, - expired: false, - message: '', - frameUrl: frame.url(), - } - } -} - -async function resolveQqQrImageUrl(frame: FrameLike): Promise { - return frame - .evaluate(() => { - const qrImage = document.querySelector('#qrlogin_img') - - if (!(qrImage instanceof HTMLImageElement)) { - return '' - } - - return String(qrImage.currentSrc || qrImage.src || qrImage.getAttribute('src') || '').trim() - }) - .then((src) => { - if (!src) { - return '' - } - - try { - return new URL(src, frame.url()).toString() - } catch { - return '' - } - }) - .catch(() => '') -} - -function upgradeQqQrImageUrl(qrUrl: string): string { - if (!qrUrl) { - return '' - } - - try { - const url = new URL(qrUrl) - - if (!/xui\.ptlogin2\.qq\.com$/i.test(url.hostname) || !/\/ptqrshow$/i.test(url.pathname)) { - return qrUrl - } - - const currentSize = Number(url.searchParams.get('d') || 0) - - if (!Number.isFinite(currentSize) || currentSize < QQ_QR_TARGET_SIZE) { - url.searchParams.set('d', String(QQ_QR_TARGET_SIZE)) - } - - return url.toString() - } catch { - return qrUrl - } -} diff --git a/apps/backend/src/services/session/session-redeem.test.ts b/apps/backend/src/services/session/session-redeem.test.ts deleted file mode 100644 index e22f4a89..00000000 --- a/apps/backend/src/services/session/session-redeem.test.ts +++ /dev/null @@ -1,89 +0,0 @@ -import test from 'node:test' -import assert from 'node:assert/strict' - -import { - classifyTencentRedeemResult, - isCaptchaRejectedResult, - resolveTencentRedeemMessage, -} from './session-redeem.js' - -test('resolveTencentRedeemMessage prefers popup detail over generic title', () => { - assert.equal( - resolveTencentRedeemMessage({ - popup: { - text: '兑换结果', - detail: '兑换码错误,请确认兑换码信息是否准确。', - }, - }), - '兑换码错误,请确认兑换码信息是否准确。', - ) -}) - -test('classifyTencentRedeemResult marks used codes as replacement retries', () => { - const result = classifyTencentRedeemResult({ - popup: { - text: '兑换结果', - detail: '兑换码已使用。', - }, - }) - - assert.equal(result.outcome, 'code_used') - assert.equal(result.success, false) - assert.equal(result.retryWithReplacementCode, true) -}) - -test('classifyTencentRedeemResult marks invalid codes as replacement retries', () => { - const result = classifyTencentRedeemResult({ - popup: { - text: '兑换结果', - detail: '兑换码错误,请确认兑换码信息是否准确。', - }, - }) - - assert.equal(result.outcome, 'code_invalid') - assert.equal(result.success, false) - assert.equal(result.retryWithReplacementCode, true) -}) - -test('classifyTencentRedeemResult treats missing CDKEY responses as invalid replacement retries', () => { - const result = classifyTencentRedeemResult({ - iRet: -183, - sMsg: '该CDKEY不存在,请您确认后输入!', - }) - - assert.equal(result.outcome, 'code_invalid') - assert.equal(result.success, false) - assert.equal(result.retryWithReplacementCode, true) -}) - -test('classifyTencentRedeemResult recognizes successful gift popup', () => { - const result = classifyTencentRedeemResult({ - popup: { - text: '恭喜您获得了礼包:动作-干员庆生,请注意:游戏虚拟道具奖品将会在24小时内到账,请登录游戏查看邮件。', - detail: '', - }, - }) - - assert.equal(result.outcome, 'success') - assert.equal(result.success, true) -}) - -test('isCaptchaRejectedResult only treats captcha problems as same-code retries', () => { - assert.equal( - isCaptchaRejectedResult({ - iRet: -100, - sMsg: '验证码错误', - }), - true, - ) - - assert.equal( - isCaptchaRejectedResult({ - popup: { - text: '兑换结果', - detail: '兑换码已使用。', - }, - }), - false, - ) -}) diff --git a/apps/backend/src/services/session/session-redeem.ts b/apps/backend/src/services/session/session-redeem.ts deleted file mode 100644 index af6be4bc..00000000 --- a/apps/backend/src/services/session/session-redeem.ts +++ /dev/null @@ -1,718 +0,0 @@ -/// - -import fs from 'node:fs/promises' - -type JsonObject = Record - -type BrowserResponseLike = { - url: () => string - request: () => { method: () => string } - text: () => Promise - status: () => number -} - -type BrowserLocatorLike = { - screenshot: (options: { path: string }) => Promise - getAttribute: (name: string) => Promise - click: (options?: JsonObject) => Promise - evaluate: (callback: (element: Element) => T | Promise) => Promise -} - -type BrowserPageLike = { - waitForFunction: (callback: any, argOrOptions?: any, options?: JsonObject) => Promise - waitForResponse: ( - predicate: (response: BrowserResponseLike) => boolean, - options?: JsonObject, - ) => Promise - waitForTimeout: (milliseconds: number) => Promise - locator: (selector: string) => BrowserLocatorLike - evaluate: (callback: any, arg?: any) => Promise -} - -type ActivityInfo = { - role?: JsonObject & { - ready?: boolean - area?: string - } - form?: { - cdkeyInputId?: string - verifyInputId?: string - verifyImgId?: string - submitId?: string - } -} - -type TencentRedeemPopup = { - visible?: boolean - text?: string - detail?: string -} - -type TencentRedeemResult = JsonObject & { - iRet?: number | string - sMsg?: string - msg?: string - popup?: TencentRedeemPopup | null -} - -type TencentRedeemOutcome = 'success' | 'captcha_rejected' | 'code_used' | 'code_invalid' | 'failed' - -type TencentRedeemClassification = { - outcome: TencentRedeemOutcome - success: boolean - retryWithSameCode: boolean - retryWithReplacementCode: boolean - retCode: number | null - message: string -} - -type RedeemAttemptRecord = { - attempt: number - verifyCode: string - verifysession: string - ocrSample: unknown - redeem: TencentRedeemResult - classification: TencentRedeemClassification - role: ActivityInfo['role'] -} - -type TencentBrowserRedeemSession = JsonObject & { - status: string - notice: string - updatedAt: string - lastError?: string - lastRedeem?: JsonObject - page: BrowserPageLike - browserContext: unknown - sessionDir: string - sessionId: string -} - -type RunTencentBrowserRedeemOptions = { - session: TencentBrowserRedeemSession - code: string - maxAttempts: number - ensureLoggedInPresentation: (session: TencentBrowserRedeemSession, options?: JsonObject) => Promise - ensureActivityInfoReady: (page: BrowserPageLike, options?: JsonObject) => Promise - fillRedeemCodeInBrowser: (page: BrowserPageLike, code: string, options?: JsonObject) => Promise - capturePageCaptchaForOcr: ( - page: BrowserPageLike, - options: { sessionDir: string; attempt: number }, - ) => Promise<{ - imageBuffer: Buffer - imagePath: string - imageExtension: string - contentType: string - verifyImgId: string - verifysession: string - }> - recognizeTencentCaptcha: (payload: JsonObject) => Promise - submitRedeemInBrowser: ( - page: BrowserPageLike, - payload: { code: string; verifyCode: string }, - ) => Promise - isCaptchaRejectedResult: (result: TencentRedeemResult) => boolean - refreshPageCaptcha: (page: BrowserPageLike, verifyImgId?: string) => Promise - persistSessionState: (session: TencentBrowserRedeemSession) => Promise - buildSessionPayload: (session: TencentBrowserRedeemSession) => JsonObject - saveRedeemArtifacts: (payload: JsonObject) => Promise - activityUrl: string -} - -type ActivityInfoProvider = { - ensureActivityInfoReady: (page: BrowserPageLike, options?: JsonObject) => Promise -} - -declare global { - interface Window { - Milo?: { - get?: (key: string) => unknown - } - closeDialog?: () => void - } -} - -export async function runTencentBrowserRedeem({ - session, - code, - maxAttempts, - ensureLoggedInPresentation, - ensureActivityInfoReady, - fillRedeemCodeInBrowser, - capturePageCaptchaForOcr, - recognizeTencentCaptcha, - submitRedeemInBrowser, - isCaptchaRejectedResult, - refreshPageCaptcha, - persistSessionState, - buildSessionPayload, - saveRedeemArtifacts, - activityUrl, -}: RunTencentBrowserRedeemOptions): Promise { - session.status = 'redeeming' - session.notice = '正在识别验证码并提交兑换' - session.updatedAt = new Date().toISOString() - await persistSessionState(session) - - const attempts: RedeemAttemptRecord[] = [] - let finalResult: RedeemAttemptRecord | null = null - - try { - await ensureLoggedInPresentation(session, { credentialReady: true }) - const activityInfo = await ensureActivityInfoReady(session.page, { triggerRender: true }) - - if (!activityInfo?.role?.ready) { - throw new Error('浏览器会话尚未拿到角色信息,请稍后重试') - } - - for (let attempt = 1; attempt <= maxAttempts; attempt += 1) { - await fillRedeemCodeInBrowser(session.page, code) - - const captcha = await capturePageCaptchaForOcr(session.page, { - sessionDir: session.sessionDir, - attempt, - }) - const ocr = await recognizeTencentCaptcha({ - imageBase64: captcha.imageBuffer.toString('base64'), - imageExtension: captcha.imageExtension, - imageContentType: captcha.contentType, - finalUrl: captcha.imagePath, - saveSample: true, - tag: `browser-session-${session.sessionId}-attempt-${attempt}`, - }) - - if (ocr?.code !== 0) { - throw new Error(ocr?.msg || 'OCR 识别失败') - } - - const verifyCode = String(ocr?.data?.text || ocr?.data?.recognizedText || '').trim() - - if (!verifyCode) { - throw new Error('OCR 没有识别出验证码') - } - - const redeem = await submitRedeemInBrowser(session.page, { - code, - verifyCode, - }) - const classification = classifyTencentRedeemResult(redeem) - - const attemptRecord = { - attempt, - verifyCode, - verifysession: captcha.verifysession, - ocrSample: ocr?.data?.saved || null, - redeem, - classification, - role: activityInfo.role, - } - - attempts.push(attemptRecord) - finalResult = attemptRecord - - if (!isCaptchaRejectedResult(redeem)) { - break - } - - await dismissRedeemRetryPopup(session.page) - await refreshPageCaptcha(session.page, captcha.verifyImgId) - } - - if (!finalResult) { - throw new Error('浏览器会话兑换没有拿到结果') - } - - const artifacts = await saveRedeemArtifacts({ - browserContext: session.browserContext, - page: session.page, - sessionDir: session.sessionDir, - sessionId: session.sessionId, - activityUrl, - code, - finalResult, - attempts, - }) - - session.lastRedeem = { - code, - area: finalResult?.role?.area || '', - attempts, - final: finalResult, - proofMode: artifacts.proofMode, - screenshotPath: artifacts.screenshotPath, - htmlPath: artifacts.htmlPath, - resultPath: artifacts.resultPath, - finishedAt: new Date().toISOString(), - } - session.status = 'redeemed' - session.notice = resolveTencentRedeemMessage(finalResult?.redeem) - session.updatedAt = new Date().toISOString() - await persistSessionState(session) - - return { - ...buildSessionPayload(session), - redeem: session.lastRedeem, - } - } catch (error) { - session.status = 'failed' - session.notice = error instanceof Error ? error.message : '浏览器会话兑换失败' - session.lastError = session.notice - session.updatedAt = new Date().toISOString() - await persistSessionState(session) - throw error - } -} - -export async function capturePageCaptchaForOcr( - page: BrowserPageLike, - { sessionDir, attempt, ensureActivityInfoReady }: { sessionDir: string; attempt: number } & ActivityInfoProvider, -) { - const activityInfo = await ensureActivityInfoReady(page, { timeoutMs: 5_000 }) - - if (!activityInfo?.form?.verifyImgId) { - throw new Error('页面里没有找到验证码图片节点') - } - - const verifySelector = `#${activityInfo.form.verifyImgId}` - - await page.waitForFunction( - (selector) => { - const element = document.querySelector(selector) - - if (!(element instanceof HTMLImageElement)) { - return false - } - - const style = window.getComputedStyle(element) - return ( - style.display !== 'none' && - style.visibility !== 'hidden' && - style.opacity !== '0' && - element.naturalWidth > 0 - ) - }, - verifySelector, - { timeout: 8_000 }, - ) - - const imagePath = `${sessionDir}/captcha-attempt-${attempt}.png` - await page.locator(verifySelector).screenshot({ path: imagePath }) - - const verifysession = await page.evaluate(() => { - try { - if (window.Milo && typeof window.Milo.get === 'function') { - return String(window.Milo.get('verifysession') || '') - } - } catch { - // ignore Milo access failures - } - - return '' - }) - - return { - imageBuffer: await fs.readFile(imagePath), - imagePath, - imageExtension: '.png', - contentType: 'image/png', - verifyImgId: activityInfo.form.verifyImgId, - verifysession, - } -} - -export async function submitRedeemInBrowser( - page: BrowserPageLike, - payload: { code: string; verifyCode: string }, - { ensureActivityInfoReady }: ActivityInfoProvider, -): Promise { - const activityInfo = await ensureActivityInfoReady(page, { timeoutMs: 2_000 }) - - if (!activityInfo?.form?.verifyInputId || !activityInfo?.form?.submitId) { - throw new Error('页面里没有找到兑换输入框或提交按钮') - } - - const verifySelector = `#${activityInfo.form.verifyInputId}` - const submitSelector = `#${activityInfo.form.submitId}` - - await setFormControlValue(page, verifySelector, payload.verifyCode) - await resetRedeemPopup(page) - await normalizeRedeemOverlay(page) - - const responsePromise = page - .waitForResponse( - (response) => - response.url().includes('dfm.ams.game.qq.com/ide/') && - response.request().method().toUpperCase() === 'POST', - { timeout: 10_000 }, - ) - .catch(() => null) - const popupPromise = waitForRedeemPopup(page).catch(() => null) - - await clickRedeemSubmit(page, submitSelector) - - const networkResponse = await responsePromise - const popup = await popupPromise - const parsedNetwork = networkResponse ? tryParseJson(await networkResponse.text()) : null - - if (parsedNetwork && typeof parsedNetwork === 'object') { - return { - httpStatus: networkResponse.status(), - popup: popup || null, - ...parsedNetwork, - } - } - - if (!popup) { - throw new Error('页面内兑换没有等到结果弹窗或接口响应') - } - - return { - httpStatus: networkResponse?.status?.() || 0, - iRet: inferRedeemCodeFromPopup(popup), - sMsg: popup.text || popup.detail || '兑换完成', - popup, - } -} - -export function fillRedeemCodeInBrowser( - page: BrowserPageLike, - code: string, - { activityInfo }: { activityInfo?: ActivityInfo } = {}, -) { - const cdkeySelector = activityInfo?.form?.cdkeyInputId - ? `#${activityInfo.form.cdkeyInputId}` - : '[id^="milo_cdkeyInfo_"]' - - const verifySelector = activityInfo?.form?.verifyInputId - ? `#${activityInfo.form.verifyInputId}` - : '[id^="milo_verifyInput_"]' - - return Promise.all([ - setFormControlValue(page, cdkeySelector, code), - setFormControlValue(page, verifySelector, ''), - ]) -} - -export async function prepareRedeemCodeFill( - page: BrowserPageLike, - code: string, - { ensureActivityInfoReady }: ActivityInfoProvider, -) { - const activityInfo = await ensureActivityInfoReady(page, { triggerRender: true, timeoutMs: 2_000 }) - await fillRedeemCodeInBrowser(page, code, { activityInfo }) -} - -export async function refreshPageCaptcha(page: BrowserPageLike, verifyImgId?: string) { - if (!verifyImgId) { - return - } - - const selector = `#${verifyImgId}` - const currentSrc = await page.locator(selector).getAttribute('src').catch(() => '') - await page.locator(selector).click({ timeout: 3_000 }).catch(() => null) - await page.waitForFunction( - ({ selector: targetSelector, previousSrc }) => { - const element = document.querySelector(targetSelector) - - if (!(element instanceof HTMLImageElement)) { - return false - } - - return element.naturalWidth > 0 && String(element.getAttribute('src') || '') !== String(previousSrc || '') - }, - { selector, previousSrc: currentSrc || '' }, - { timeout: 5_000 }, - ).catch(() => null) -} - -export function isCaptchaRejectedResult(result: TencentRedeemResult): boolean { - if (classifyTencentRedeemResult(result).outcome === 'captcha_rejected') { - return true - } - - return false -} - -export function classifyTencentRedeemResult(result: TencentRedeemResult): TencentRedeemClassification { - const retCode = Number(result?.iRet) - const message = resolveTencentRedeemMessage(result) - const normalized = normalizeTencentRedeemText(message) - - if (Number.isFinite(retCode) && retCode === -100) { - return buildTencentRedeemClassification('captcha_rejected', retCode, message) - } - - if (/兑换码已使用|cdk已使用|cdkey已使用|已被使用/.test(normalized)) { - return buildTencentRedeemClassification('code_used', retCode, message || '兑换码已使用') - } - - if ( - retCode === -165 || - retCode === -183 || - /兑换码错误|cdk错误|cdkey错误|请确认兑换码信息是否准确|兑换码信息是否准确|兑换码不存在|cdk不存在|cdkey不存在|不存在请您确认后输入/.test(normalized) - ) { - return buildTencentRedeemClassification('code_invalid', retCode, message || '兑换码错误,请确认兑换码信息是否准确') - } - - if (/验证码|校验码/.test(normalized)) { - return buildTencentRedeemClassification('captcha_rejected', retCode, message || '验证码错误,请稍后重试') - } - - if ( - retCode === 0 || - /成功|已兑换|领取成功|恭喜您获得了礼包|查看邮件|到账/.test(normalized) - ) { - return buildTencentRedeemClassification('success', retCode, message || '兑换成功') - } - - return buildTencentRedeemClassification('failed', retCode, message || '兑换失败') -} - -function resetRedeemPopup(page: BrowserPageLike) { - return page.evaluate(() => { - const popup = document.querySelector('#pop2') - const popupText = document.querySelector('#PopText') - const popupDetail = document.querySelector('#PopText2') - - if (popup instanceof HTMLElement) { - popup.style.display = 'none' - } - - if (popupText instanceof HTMLElement) { - popupText.textContent = '' - } - - if (popupDetail instanceof HTMLElement) { - popupDetail.classList.add('hide') - popupDetail.textContent = '' - } - }) -} - -async function dismissRedeemRetryPopup(page: BrowserPageLike) { - await page.evaluate(() => { - try { - if (typeof window.closeDialog === 'function') { - window.closeDialog() - } - } catch { - // ignore page close hook failures - } - - const closeButton = document.querySelector('#pop2 .pop_close') - - if (closeButton instanceof HTMLElement) { - closeButton.click() - } - - const popup = document.querySelector('#pop2') - - if (popup instanceof HTMLElement) { - popup.style.display = 'none' - popup.style.visibility = 'hidden' - } - }) - - await normalizeRedeemOverlay(page) - await page.waitForTimeout(150) -} - -async function normalizeRedeemOverlay(page: BrowserPageLike) { - await page.evaluate(() => { - const overlayIds = ['_overlay_', 'overlay_mask', 'overlay'] - - for (const id of overlayIds) { - const element = document.getElementById(id) - - if (!(element instanceof HTMLElement)) { - continue - } - - element.style.pointerEvents = 'none' - element.style.display = 'none' - element.style.opacity = '0' - } - }) -} - -async function clickRedeemSubmit(page: BrowserPageLike, submitSelector: string) { - const locator = page.locator(submitSelector) - - try { - await locator.click({ timeout: 3_000 }) - return - } catch { - await normalizeRedeemOverlay(page) - } - - try { - await locator.click({ timeout: 3_000, force: true }) - return - } catch { - await locator.evaluate((element) => { - if (element instanceof HTMLElement) { - element.click() - } - }) - } -} - -async function setFormControlValue(page: BrowserPageLike, selector: string, value: unknown) { - const nextValue = String(value ?? '') - - const appliedValue = await page.evaluate( - ({ targetSelector, targetValue }) => { - const isVisible = (element: Element) => { - if (!(element instanceof HTMLElement)) { - return false - } - - const style = window.getComputedStyle(element) - return ( - style.display !== 'none' && - style.visibility !== 'hidden' && - style.opacity !== '0' && - element.getClientRects().length > 0 - ) - } - - const matches = [...document.querySelectorAll(targetSelector)] - const element = - matches.find( - (node) => - (node instanceof HTMLInputElement || node instanceof HTMLTextAreaElement) && - !node.disabled && - node.type !== 'hidden' && - isVisible(node), - ) || - matches.find( - (node) => - (node instanceof HTMLInputElement || node instanceof HTMLTextAreaElement) && - !node.disabled && - node.type !== 'hidden', - ) || - null - - if (!(element instanceof HTMLInputElement) && !(element instanceof HTMLTextAreaElement)) { - return { - found: false, - value: '', - } - } - - const prototype = - element instanceof HTMLTextAreaElement - ? window.HTMLTextAreaElement.prototype - : window.HTMLInputElement.prototype - const descriptor = Object.getOwnPropertyDescriptor(prototype, 'value') - - if (descriptor?.set) { - descriptor.set.call(element, targetValue) - } else { - element.value = targetValue - } - - element.setAttribute('value', targetValue) - element.focus() - element.dispatchEvent(new Event('input', { bubbles: true })) - element.dispatchEvent(new Event('change', { bubbles: true })) - element.dispatchEvent(new KeyboardEvent('keyup', { bubbles: true, key: 'Enter' })) - element.blur() - - return { - found: true, - value: String(element.value || ''), - } - }, - { targetSelector: selector, targetValue: nextValue }, - ) - - if (!appliedValue?.found) { - throw new Error(`页面里没有找到可填写的表单节点: ${selector}`) - } - - if (String(appliedValue.value || '') !== nextValue) { - throw new Error(`页面表单写值失败: ${selector}`) - } -} - -async function waitForRedeemPopup(page: BrowserPageLike): Promise { - await page.waitForFunction(() => { - const popup = document.querySelector('#pop2') - const popupText = document.querySelector('#PopText') - - if (!(popup instanceof HTMLElement) || !(popupText instanceof HTMLElement)) { - return false - } - - const style = window.getComputedStyle(popup) - return style.display !== 'none' && String(popupText.textContent || '').trim().length > 0 - }, { timeout: 10_000 }) - - return page.evaluate(() => ({ - visible: true, - text: String(document.querySelector('#PopText')?.textContent || '').trim(), - detail: String(document.querySelector('#PopText2')?.textContent || '').trim(), - })) -} - -function tryParseJson(text: string): any { - try { - return JSON.parse(text) - } catch { - return null - } -} - -function inferRedeemCodeFromPopup(popup: TencentRedeemPopup) { - const text = `${String(popup?.text || '')} ${String(popup?.detail || '')}`.trim() - - if (!text) { - return 1 - } - - if (/验证码|校验码/.test(text)) { - return -100 - } - - if (/成功|已兑换|领取成功/.test(text)) { - return 0 - } - - return 1 -} - -export function resolveTencentRedeemMessage(result: TencentRedeemResult): string { - const candidates = [ - result?.popup?.detail, - result?.popup?.text, - result?.sMsg, - result?.msg, - ] - .map((item) => String(item || '').trim()) - .filter(Boolean) - - return candidates[0] || '兑换完成' -} - -function buildTencentRedeemClassification( - outcome: TencentRedeemOutcome, - retCode: number, - message: string, -): TencentRedeemClassification { - return { - outcome, - success: outcome === 'success', - retryWithSameCode: outcome === 'captcha_rejected', - retryWithReplacementCode: outcome === 'code_used' || outcome === 'code_invalid', - retCode: Number.isFinite(retCode) ? retCode : null, - message: String(message || '').trim(), - } -} - -function normalizeTencentRedeemText(value: unknown): string { - return String(value || '') - .replace(/\s+/g, '') - .trim() - .toLowerCase() -} diff --git a/apps/backend/src/services/session/session-review.ts b/apps/backend/src/services/session/session-review.ts deleted file mode 100644 index bcef4bcf..00000000 --- a/apps/backend/src/services/session/session-review.ts +++ /dev/null @@ -1,68 +0,0 @@ -import path from 'node:path' - -import { logBrowserSessionDebug } from './session-login-shared.js' - -type BrowserPageLike = { - screenshot: (options: { path: string; fullPage?: boolean }) => Promise -} - -type ReviewSession = { - sessionId: string - loginType: string - sessionDir: string - page: BrowserPageLike - lastReview?: { - screenshotPath?: string - signature?: string - [key: string]: unknown - } | null -} - -type ActivityInfoForReview = { - role?: { - roleId?: string - roleName?: string - } -} - -export function resolveReviewSignature(session: Pick, activityInfo: ActivityInfoForReview) { - const roleId = String(activityInfo?.role?.roleId || '').trim() - const roleName = String(activityInfo?.role?.roleName || '').trim() - - return { - roleId, - roleName, - signature: `${session.loginType}|${roleId}|${roleName}`, - } -} - -export async function ensureReviewScreenshot(session: ReviewSession, activityInfo: ActivityInfoForReview): Promise { - const { roleId, roleName, signature } = resolveReviewSignature(session, activityInfo) - - if (session.lastReview?.screenshotPath && session.lastReview?.signature === signature) { - return false - } - - const screenshotPath = path.join(session.sessionDir, 'role-review.png') - - try { - await session.page.screenshot({ - path: screenshotPath, - fullPage: true, - }) - session.lastReview = { - screenshotPath, - capturedAt: new Date().toISOString(), - roleId, - roleName, - signature, - } - return true - } catch (error) { - logBrowserSessionDebug('reviewScreenshot.capture.error', { - sessionId: session.sessionId, - message: error instanceof Error ? error.message : String(error), - }) - return false - } -} diff --git a/apps/backend/src/services/session/session-service-config.ts b/apps/backend/src/services/session/session-service-config.ts deleted file mode 100644 index 2adb7d4b..00000000 --- a/apps/backend/src/services/session/session-service-config.ts +++ /dev/null @@ -1,11 +0,0 @@ -import path from 'node:path' - -import { runtimeConfig } from '../../config/runtime.js' -import { resolveDefaultViewport } from './session-browser-config.js' - -export const BROWSER_SESSION_DATA_ROOT = path.join(runtimeConfig.data.root, 'browser-sessions') -export const ACTIVITY_URL = 'https://df.qq.com/cp/a20240812cdk/index.html' -export const CHROME_PATH = String(runtimeConfig.browser.chromePath || '').trim() -export const REDEEM_SUCCESS_AUTO_CLOSE_MS = 3 * 60 * 1000 -export const DEFAULT_VIEWPORT = resolveDefaultViewport(runtimeConfig.browser.vncResolution) -export const SESSION_DEBUG_ENABLED = Boolean(runtimeConfig.session.debug) diff --git a/apps/backend/src/services/session/session-service-runtime.ts b/apps/backend/src/services/session/session-service-runtime.ts deleted file mode 100644 index 22e6ab17..00000000 --- a/apps/backend/src/services/session/session-service-runtime.ts +++ /dev/null @@ -1,102 +0,0 @@ -import { chromium } from 'playwright' - -import { runtimeConfig } from '../../config/runtime.js' -import { - buildChromiumLaunchOptions as buildChromiumLaunchOptionsBase, - resolveBrowserLaunchOptions as resolveBrowserLaunchOptionsBase, -} from './session-browser-config.js' -import { CHROME_PATH } from './session-service-config.js' - -type BrowserLike = { - on: (event: 'disconnected', listener: () => void) => void - close: () => Promise - newContext: (options?: Record) => Promise -} - -type SessionServiceErrorOptions = { - statusCode?: number - errorCode?: string -} - -let browserPromise: Promise | null = null - -export class SessionServiceError extends Error { - statusCode: number - errorCode: string - - constructor(message: string, { statusCode = 500, errorCode = 'session_error' }: SessionServiceErrorOptions = {}) { - super(message) - this.name = 'SessionServiceError' - this.statusCode = statusCode - this.errorCode = errorCode - } -} - -export async function warmupTencentBrowser() { - const browserLaunch = resolveBrowserLaunchOptions() - - if (!browserLaunch.prewarm) { - return { - warmed: false, - reason: 'disabled', - browserLaunch, - } - } - - await ensureBrowser() - - return { - warmed: true, - reason: 'ready', - browserLaunch, - } -} - -export async function ensureBrowser(): Promise { - if (!browserPromise) { - const launchOptions = resolveBrowserLaunchOptions() - browserPromise = (chromium.launch(buildChromiumLaunchOptions(launchOptions)) as Promise) - .then((browser) => { - browser.on('disconnected', () => { - browserPromise = null - }) - return browser - }) - .catch((error) => { - browserPromise = null - throw error - }) - } - - return browserPromise -} - -export function resolveBrowserLaunchOptions() { - return resolveBrowserLaunchOptionsBase({ - browserConfig: runtimeConfig.browser, - chromePath: CHROME_PATH, - }) -} - -export async function closeBrowserIfIdle({ activeSessionCount = 0 }: { activeSessionCount?: number } = {}): Promise { - if (activeSessionCount > 0 || !browserPromise) { - return - } - - if (resolveBrowserLaunchOptions().keepAlive) { - return - } - - try { - const browser = await browserPromise - await browser.close() - } catch { - // ignore browser close failures - } finally { - browserPromise = null - } -} - -function buildChromiumLaunchOptions(launchOptions: ReturnType) { - return buildChromiumLaunchOptionsBase(launchOptions, { chromePath: CHROME_PATH }) -} diff --git a/apps/backend/src/services/session/session-service-store.ts b/apps/backend/src/services/session/session-service-store.ts deleted file mode 100644 index 49e28e0c..00000000 --- a/apps/backend/src/services/session/session-service-store.ts +++ /dev/null @@ -1,205 +0,0 @@ -import crypto from 'node:crypto' -import fs from 'node:fs/promises' -import path from 'node:path' - -import { - BROWSER_SESSION_DATA_ROOT, - REDEEM_SUCCESS_AUTO_CLOSE_MS, -} from './session-service-config.js' -import { SessionServiceError } from './session-service-runtime.js' -import { - resolveInitialQrSessionState, - SESSION_TTL_MS, -} from './session-state.js' - -type BrowserContextLike = { - close?: () => Promise -} - -export type TencentBrowserSession = Record & { - sessionId: string - loginType: string - status: string - notice: string - createdAt: string - updatedAt: string - expiresAt: number - autoCloseAt: number - autoCloseTimer: ReturnType | null - sessionDir: string - browserContext: any - page: any - browserContextClosed?: boolean - presentationSyncAttempts: number -} - -type RegisterSessionInput = { - loginType: string - browserContext: BrowserContextLike - page: unknown -} - -type CloseSessionDependency = { - closeSession: (session: TencentBrowserSession, options: { markClosed: boolean }) => Promise -} - -type CloseSessionOptions = { - markClosed: boolean - persistSessionState: (session: TencentBrowserSession) => Promise - closeBrowserIfIdle: () => Promise -} - -type PersistSessionStateOptions = { - buildSessionPayload: (session: TencentBrowserSession) => unknown -} - -const sessions = new Map() - -export async function registerTencentBrowserSession({ - loginType, - browserContext, - page, -}: RegisterSessionInput): Promise { - const sessionId = createSessionId() - const sessionDir = path.join(BROWSER_SESSION_DATA_ROOT, sessionId) - await fs.mkdir(sessionDir, { recursive: true }) - - const now = new Date().toISOString() - const session = { - sessionId, - loginType, - status: 'created', - notice: '浏览器会话已创建', - createdAt: now, - updatedAt: now, - expiresAt: Date.now() + SESSION_TTL_MS, - autoCloseAt: 0, - autoCloseTimer: null, - sessionDir, - browserContext, - page, - qrImageBase64: '', - qrImagePath: '', - qrUpdatedAt: '', - lastState: null, - lastRedeem: null, - lastReview: null, - lastError: '', - presentationSyncAttempts: 0, - } - - sessions.set(sessionId, session) - return session -} - -export function listActiveTencentBrowserSessions(): TencentBrowserSession[] { - return [...sessions.values()] -} - -export async function getRequiredSession(sessionId: unknown): Promise { - const key = String(sessionId || '').trim() - - if (!key) { - throw new SessionServiceError('缺少 sessionId', { - statusCode: 400, - errorCode: 'missing_session_id', - }) - } - - const session = sessions.get(key) - - if (!session) { - throw new SessionServiceError('浏览器会话不存在或已过期', { - statusCode: 404, - errorCode: 'session_not_found', - }) - } - - return session -} - -export function applyInitialQrSessionState(session: TencentBrowserSession): void { - resetTencentSessionAutoClose(session) - Object.assign(session, resolveInitialQrSessionState(session.loginType)) -} - -export function armRedeemedSessionAutoClose(session: TencentBrowserSession, { closeSession }: CloseSessionDependency): void { - resetTencentSessionAutoClose(session) - - const autoCloseAt = Date.now() + REDEEM_SUCCESS_AUTO_CLOSE_MS - session.autoCloseAt = autoCloseAt - session.expiresAt = autoCloseAt - session.autoCloseTimer = setTimeout(() => { - void closeSession(session, { markClosed: true }).catch(() => { - // ignore delayed auto-close failures - }) - }, REDEEM_SUCCESS_AUTO_CLOSE_MS) -} - -export function resetTencentSessionAutoClose(session?: TencentBrowserSession | null): void { - if (session?.autoCloseTimer) { - clearTimeout(session.autoCloseTimer) - session.autoCloseTimer = null - } - - if (session) { - session.autoCloseAt = 0 - } -} - -export async function cleanupExpiredTencentBrowserSessions({ closeSession }: CloseSessionDependency): Promise { - const expired = listActiveTencentBrowserSessions().filter((session) => session.expiresAt <= Date.now()) - - await Promise.all(expired.map((session) => closeSession(session, { markClosed: false }))) -} - -export async function closeSession( - session: TencentBrowserSession, - { - markClosed, - persistSessionState, - closeBrowserIfIdle, - }: CloseSessionOptions, -): Promise { - resetTencentSessionAutoClose(session) - sessions.delete(session.sessionId) - - try { - await session.browserContext?.close() - } catch { - // ignore browser context close failures - } - - session.browserContextClosed = true - session.updatedAt = new Date().toISOString() - - if (markClosed) { - session.status = 'closed' - session.notice = '浏览器会话已关闭' - await persistSessionState(session) - } - - await closeBrowserIfIdle() -} - -export async function persistSessionState( - session: TencentBrowserSession, - { buildSessionPayload }: PersistSessionStateOptions, -): Promise { - await fs.mkdir(session.sessionDir, { recursive: true }) - await fs.writeFile( - path.join(session.sessionDir, 'session.json'), - JSON.stringify(buildSessionPayload(session), null, 2), - 'utf8', - ) -} - -function createSessionId(): string { - let sessionId = '' - - do { - sessionId = `txbs-${crypto.randomBytes(6).toString('hex')}` - } while (sessions.has(sessionId)) - - return sessionId -} diff --git a/apps/backend/src/services/session/session-state.ts b/apps/backend/src/services/session/session-state.ts deleted file mode 100644 index 9fa29631..00000000 --- a/apps/backend/src/services/session/session-state.ts +++ /dev/null @@ -1,137 +0,0 @@ -import { getLoginTypeLabel, type LoginType } from './session-browser-config.js' - -export const SESSION_TTL_MS = 30 * 60 * 1000 - -type InitialQrSessionStateOptions = { - updatedAt?: string - nowMs?: number - sessionTtlMs?: number - loginTypeLabel?: string -} - -type HostState = { - loginText?: string - unloginVisible?: boolean - loginedVisible?: boolean -} - -type QrState = { - expired?: boolean - scanned?: boolean -} - -type ActivityInfo = { - role?: { - ready?: boolean - roleName?: string - } -} - -type SessionStatusInput = { - hostState: HostState - qrState: QrState - credentialReady?: boolean - activityInfo?: ActivityInfo | null -} - -type SessionNoticeInput = SessionStatusInput & { - loginType: LoginType | string -} - -type CookieMap = { - openid?: string - access_token?: string - appid?: string -} - -export function resolveInitialQrSessionState( - loginType: LoginType | string, - { - updatedAt = new Date().toISOString(), - nowMs = Date.now(), - sessionTtlMs = SESSION_TTL_MS, - loginTypeLabel = getLoginTypeLabel(loginType), - }: InitialQrSessionStateOptions = {}, -) { - return { - status: 'waiting_scan', - notice: `请使用${loginTypeLabel}扫码登录`, - updatedAt, - expiresAt: nowMs + sessionTtlMs, - } -} - -export function resolveTencentSessionExpiresAt( - session: { autoCloseAt?: number | string } | null | undefined, - nowMs = Date.now(), - sessionTtlMs = SESSION_TTL_MS, -): number { - const autoCloseAt = Number(session?.autoCloseAt || 0) - if (autoCloseAt > 0) { - return autoCloseAt - } - - return nowMs + sessionTtlMs -} - -export function shouldRefreshLoggedInPresentation(hostState: HostState): boolean { - const loginText = String(hostState?.loginText || '').trim() - - return Boolean(hostState?.unloginVisible || !hostState?.loginedVisible || !loginText) -} - -export function resolveSessionStatus({ hostState, qrState, credentialReady, activityInfo }: SessionStatusInput): string { - if (hostState.loginedVisible || credentialReady) { - if (credentialReady && activityInfo?.role?.ready) { - return 'ready_to_redeem' - } - - return 'logged_in' - } - - if (qrState.expired) { - return 'expired' - } - - if (qrState.scanned) { - return 'scanned' - } - - return 'waiting_scan' -} - -export function resolveSessionNotice({ - hostState, - qrState, - credentialReady, - activityInfo, - loginType, -}: SessionNoticeInput): string { - const loginTypeLabel = getLoginTypeLabel(loginType) - - if (credentialReady) { - if (activityInfo?.role?.ready && activityInfo.role.roleName) { - return `登录已完成,当前角色 ${activityInfo.role.roleName}` - } - - return '登录已完成,正在同步角色信息' - } - - if (hostState.loginedVisible) { - return '页面已识别登录态' - } - - if (qrState.expired) { - return '二维码已失效,准备刷新' - } - - if (qrState.scanned) { - return '二维码已扫描,请在手机上确认登录' - } - - return `请使用${loginTypeLabel}扫码登录` -} - -export function hasRedeemCredential(cookieMap: CookieMap): boolean { - return Boolean(cookieMap.openid && cookieMap.access_token && cookieMap.appid) -} diff --git a/apps/backend/src/services/session/session-wx.ts b/apps/backend/src/services/session/session-wx.ts deleted file mode 100644 index e66fcab7..00000000 --- a/apps/backend/src/services/session/session-wx.ts +++ /dev/null @@ -1,377 +0,0 @@ -/// - -import fs from 'node:fs/promises' - -import { downloadRemoteQrImage, logBrowserSessionDebug, waitForFrame } from './session-login-shared.js' - -type LocatorLike = { - first: () => LocatorLike - waitFor: (options?: { state?: string; timeout?: number }) => Promise - screenshot: (options: { path: string }) => Promise - isVisible: () => Promise - click: (options?: { timeout?: number }) => Promise - evaluate: (callback: (element: Element) => T | Promise) => Promise -} - -type FrameLike = { - url: () => string - locator: (selector: string) => LocatorLike - waitForTimeout: (milliseconds: number) => Promise - evaluate: (callback: any) => Promise -} - -type PageLike = { - frames: () => FrameLike[] - waitForTimeout: (milliseconds: number) => Promise - context: () => { - request: { - get: (url: string, options?: any) => Promise<{ - ok: () => boolean - status: () => number - body: () => Promise - }> - } - } -} - -type WxLoginViewState = { - qrVisible: boolean - quickLoginVisible: boolean - switchToNormalVisible: boolean - qrCandidates: Array<{ - index: number - visible: boolean - src: string - naturalWidth: number - }> - bodyText: string -} - -type WxQrState = { - visible: boolean - qrVisible?: boolean - scanned: boolean - expired: boolean - message: string - frameUrl?: string -} - -export async function ensureWxLoginReady(page: PageLike): Promise { - try { - const frame = await waitForFrame( - page, - (item) => item.url().includes('open.weixin.qq.com/connect/qrconnect'), - 10_000, - ) - - for (let attempt = 1; attempt <= 12; attempt += 1) { - const viewState = await inspectWxLoginView(frame) - logBrowserSessionDebug('wx.ensureQrMode.viewState', { - attempt, - frameUrl: frame.url(), - qrVisible: viewState.qrVisible, - quickLoginVisible: viewState.quickLoginVisible, - switchToNormalVisible: viewState.switchToNormalVisible, - qrCandidates: viewState.qrCandidates, - bodyText: viewState.bodyText.slice(0, 160), - }) - - if (viewState.qrVisible) { - return - } - - if ( - viewState.quickLoginVisible || - viewState.switchToNormalVisible || - /使用其他头像、昵称或账号|微信快捷登录/.test(viewState.bodyText) - ) { - const switched = await switchWxQuickLoginToQr(frame) - logBrowserSessionDebug('wx.ensureQrMode.switchToNormal', { - attempt, - switched, - }) - - if (switched) { - await frame.waitForTimeout(700) - continue - } - } - - await frame.waitForTimeout(400) - } - } catch { - // ignore wx quick-login switch failures; the screenshot retry path will try again - } -} - -export async function captureWxQrImage(page: PageLike, qrImagePath: string): Promise { - const qrLocator = await findWxQrLocator(page) - const frame = await waitForFrame( - page, - (item) => item.url().includes('open.weixin.qq.com/connect/qrconnect'), - 10_000, - ) - - const qrUrl = await resolveWxQrImageUrl(frame) - logBrowserSessionDebug('wx.capture.qrUrl', { - qrUrl, - frameUrl: frame.url(), - }) - - if (qrUrl) { - const downloaded = await downloadRemoteQrImage(page, qrUrl, qrImagePath, frame.url(), 'wx') - logBrowserSessionDebug('wx.capture.downloadResult', { - qrUrl, - downloaded: Buffer.isBuffer(downloaded) && downloaded.length > 0, - qrImagePath, - }) - - if (Buffer.isBuffer(downloaded) && downloaded.length > 0) { - await fs.writeFile(qrImagePath, downloaded) - return - } - } - - logBrowserSessionDebug('wx.capture.fallbackScreenshot', { - qrImagePath, - }) - await qrLocator.screenshot({ path: qrImagePath }) -} - -export async function findWxQrLocator(page: PageLike): Promise { - await ensureWxLoginReady(page) - - const frame = await waitForFrame( - page, - (item) => item.url().includes('open.weixin.qq.com/connect/qrconnect'), - ) - const viewState = await inspectWxLoginView(frame) - logBrowserSessionDebug('wx.findQrLocator.beforeWait', { - frameUrl: frame.url(), - qrVisible: viewState.qrVisible, - qrCandidates: viewState.qrCandidates, - bodyText: viewState.bodyText.slice(0, 160), - }) - - const locator = frame.locator('.js_qrcode_img:visible').first() - await locator.waitFor({ state: 'visible', timeout: 15_000 }) - logBrowserSessionDebug('wx.findQrLocator.visibleReady', { - frameUrl: frame.url(), - }) - return locator -} - -export async function extractWxQrState(page: Pick): Promise { - const frame = page.frames().find((item) => item.url().includes('open.weixin.qq.com/connect/qrconnect')) - - if (!frame) { - return { - visible: false, - scanned: false, - expired: false, - message: '', - } - } - - try { - const viewState = await inspectWxLoginView(frame) - const qrVisible = viewState.qrVisible - const bodyText = viewState.bodyText - - return { - visible: true, - qrVisible, - scanned: !qrVisible && /扫描成功|请在手机上确认登录/.test(bodyText), - expired: qrVisible ? false : /二维码失效|已失效/.test(bodyText), - message: bodyText.slice(0, 200), - frameUrl: frame.url(), - } - } catch { - return { - visible: true, - qrVisible: false, - scanned: false, - expired: false, - message: '', - frameUrl: frame.url(), - } - } -} - -async function inspectWxLoginView(frame: FrameLike): Promise { - return frame - .evaluate(() => { - const isVisible = (element: Element | null) => { - if (!(element instanceof HTMLElement)) { - return false - } - - const style = window.getComputedStyle(element) - const rect = element.getBoundingClientRect() - return ( - style.display !== 'none' && - style.visibility !== 'hidden' && - style.opacity !== '0' && - rect.width > 0 && - rect.height > 0 - ) - } - - const quickLogin = document.querySelector('.js_quick_login') - const switchToNormal = document.querySelector('.js_switchToNormal') - const qrCandidates = Array.from(document.querySelectorAll('.js_qrcode_img')).map((item, index) => { - if (!(item instanceof HTMLImageElement)) { - return { - index, - visible: false, - src: '', - naturalWidth: 0, - } - } - - return { - index, - visible: isVisible(item), - src: String(item.currentSrc || item.src || item.getAttribute('src') || '').trim(), - naturalWidth: Number(item.naturalWidth || 0), - } - }) - const visibleQrCandidate = qrCandidates.find((item) => item.visible && item.naturalWidth > 0) - - return { - qrVisible: Boolean(visibleQrCandidate), - quickLoginVisible: isVisible(quickLogin), - switchToNormalVisible: isVisible(switchToNormal), - qrCandidates, - bodyText: String(document.body?.textContent || '').replace(/\s+/g, ' ').trim(), - } - }) - .catch(() => ({ - qrVisible: false, - quickLoginVisible: false, - switchToNormalVisible: false, - qrCandidates: [], - bodyText: '', - })) -} - -async function switchWxQuickLoginToQr(frame: FrameLike): Promise { - const switcherCandidates = [ - frame.locator('.js_switchToNormal:visible').first(), - frame.locator('.js_switchToNormal').first(), - frame.locator('button:has-text("使用其他头像、昵称或账号")').first(), - frame.locator('text=使用其他头像、昵称或账号').first(), - ] - - for (const candidate of switcherCandidates) { - const visible = await candidate.isVisible().catch(() => false) - - if (!visible) { - continue - } - - logBrowserSessionDebug('wx.switchToNormal.candidateVisible', { - candidateIndex: switcherCandidates.indexOf(candidate), - }) - - const clicked = - (await candidate - .click({ timeout: 2_000 }) - .then(() => true) - .catch(() => false)) || - (await candidate - .evaluate((element) => { - if (!(element instanceof HTMLElement)) { - return false - } - - element.click() - return true - }) - .catch(() => false)) - - if (clicked) { - logBrowserSessionDebug('wx.switchToNormal.clicked', { - candidateIndex: switcherCandidates.indexOf(candidate), - }) - return true - } - } - - return frame - .evaluate(() => { - const candidates = Array.from(document.querySelectorAll('button, a, div')).filter((element) => { - if (!(element instanceof HTMLElement)) { - return false - } - - const text = String(element.textContent || '').replace(/\s+/g, ' ').trim() - const style = window.getComputedStyle(element) - const rect = element.getBoundingClientRect() - - return ( - /使用其他头像、昵称或账号/.test(text) && - style.display !== 'none' && - style.visibility !== 'hidden' && - style.opacity !== '0' && - rect.width > 0 && - rect.height > 0 - ) - }) - - const target = candidates[0] - - if (!(target instanceof HTMLElement)) { - return false - } - - target.click() - return true - }) - .catch(() => false) -} - -async function resolveWxQrImageUrl(frame: FrameLike): Promise { - return frame - .evaluate(() => { - const isVisible = (element: Element | null) => { - if (!(element instanceof HTMLElement)) { - return false - } - - const style = window.getComputedStyle(element) - const rect = element.getBoundingClientRect() - return ( - style.display !== 'none' && - style.visibility !== 'hidden' && - style.opacity !== '0' && - rect.width > 0 && - rect.height > 0 - ) - } - - const qrImages = Array.from(document.querySelectorAll('.js_qrcode_img')) - const qrImage = - qrImages.find((item) => item instanceof HTMLImageElement && isVisible(item)) || - qrImages.find((item) => item instanceof HTMLImageElement) || - null - - if (!(qrImage instanceof HTMLImageElement)) { - return '' - } - - return String(qrImage.currentSrc || qrImage.src || qrImage.getAttribute('src') || '').trim() - }) - .then((src) => { - if (!src) { - return '' - } - - try { - return new URL(src, frame.url()).toString() - } catch { - return '' - } - }) - .catch(() => '') -} diff --git a/apps/backend/src/services/session/session.test.ts b/apps/backend/src/services/session/session.test.ts deleted file mode 100644 index c5143d8e..00000000 --- a/apps/backend/src/services/session/session.test.ts +++ /dev/null @@ -1,376 +0,0 @@ -import test from 'node:test' -import assert from 'node:assert/strict' - -import { - normalizeLoginType, - parseViewportSpec, -} from './session-browser-config.js' -import { normalizeTencentCookieMap } from './session-cookie-map.js' -import { - createEmptyActivityInfo, - ensureActivityInfoReady, - ensureLoggedInPresentation, - extractActivityInfo, -} from './session-activity.js' -import { - captureQrImageWithRetry, - ensureLoginTab, -} from './session-page.js' -import { buildArtifactsPayload, buildRedeemPayload } from './session-payload.js' -import { ensureReviewScreenshot } from './session-review.js' -import { resolveInitialQrSessionState } from './session-state.js' -import { - REDEEM_SUCCESS_AUTO_CLOSE_MS, - resolveTencentSessionExpiresAt, -} from './session.js' - -test('resolveTencentSessionExpiresAt uses normal ttl when no auto-close is armed', () => { - const nowMs = Date.UTC(2026, 3, 14, 9, 30, 0) - assert.equal( - resolveTencentSessionExpiresAt({ autoCloseAt: 0 }, nowMs), - nowMs + (30 * 60 * 1000), - ) -}) - -test('resolveTencentSessionExpiresAt keeps redeemed auto-close deadline once armed', () => { - const nowMs = Date.UTC(2026, 3, 14, 9, 30, 0) - const autoCloseAt = nowMs + REDEEM_SUCCESS_AUTO_CLOSE_MS - - assert.equal( - resolveTencentSessionExpiresAt({ autoCloseAt }, nowMs + 60_000), - autoCloseAt, - ) -}) - -test('normalizeLoginType collapses aliases and falls back to qq', () => { - assert.equal(normalizeLoginType(' qc '), 'qq') - assert.equal(normalizeLoginType('wechat'), 'wx') - assert.equal(normalizeLoginType('unknown'), 'qq') - assert.equal(normalizeLoginType(''), 'qq') -}) - -test('parseViewportSpec accepts valid viewport strings and rejects invalid values', () => { - assert.deepEqual(parseViewportSpec('1920x1080'), { width: 1920, height: 1080 }) - assert.deepEqual(parseViewportSpec('1920x1080x24'), { width: 1920, height: 1080 }) - assert.equal(parseViewportSpec('799x600'), null) - assert.equal(parseViewportSpec('broken'), null) -}) - -test('buildRedeemPayload strips internal paths unless explicitly requested', () => { - const redeem = { - code: 'ABC123', - area: '微信区', - proofMode: 'basic', - attempts: [ - { - attempt: 1, - redeem: { success: false, message: '验证码错误' }, - }, - ], - final: { - attempt: 2, - redeem: { success: true, message: '兑换成功' }, - }, - finishedAt: '2026-04-14T09:40:00.000Z', - screenshotPath: '/tmp/redeem.png', - htmlPath: '/tmp/redeem.html', - resultPath: '/tmp/redeem.json', - } - - assert.deepEqual(buildRedeemPayload(redeem), { - code: 'ABC123', - area: '微信区', - proofMode: 'basic', - attempts: [ - { - attempt: 1, - redeem: { success: false, message: '验证码错误' }, - }, - ], - final: { - attempt: 2, - redeem: { success: true, message: '兑换成功' }, - }, - finishedAt: '2026-04-14T09:40:00.000Z', - }) - - assert.deepEqual(buildRedeemPayload(redeem, { includeInternalPaths: true }), { - code: 'ABC123', - area: '微信区', - proofMode: 'basic', - attempts: [ - { - attempt: 1, - redeem: { success: false, message: '验证码错误' }, - }, - ], - final: { - attempt: 2, - redeem: { success: true, message: '兑换成功' }, - }, - finishedAt: '2026-04-14T09:40:00.000Z', - screenshotPath: '/tmp/redeem.png', - htmlPath: '/tmp/redeem.html', - resultPath: '/tmp/redeem.json', - }) -}) - -test('buildArtifactsPayload only exposes internal artifact paths in debug mode', () => { - const session = { - sessionDir: '/tmp/session', - qrImagePath: '/tmp/session/qq-qr.png', - qrImageBase64: 'base64data', - lastReview: { - screenshotPath: '/tmp/session/review.png', - }, - lastRedeem: { - screenshotPath: '/tmp/session/redeem.png', - htmlPath: '/tmp/session/redeem.html', - resultPath: '/tmp/session/redeem.json', - }, - } - - assert.deepEqual(buildArtifactsPayload(session), { - hasQrImage: true, - hasReviewScreenshot: true, - hasScreenshot: true, - }) - - assert.deepEqual(buildArtifactsPayload(session, { includeInternalPaths: true }), { - hasQrImage: true, - hasReviewScreenshot: true, - hasScreenshot: true, - sessionDir: '/tmp/session', - qrImagePath: '/tmp/session/qq-qr.png', - screenshotPath: '/tmp/session/redeem.png', - htmlPath: '/tmp/session/redeem.html', - resultPath: '/tmp/session/redeem.json', - reviewScreenshotPath: '/tmp/session/review.png', - }) -}) - -test('normalizeTencentCookieMap fills required defaults and derived proxy cookie', () => { - assert.deepEqual( - normalizeTencentCookieMap([ - { name: 'openid', value: 'openid-1' }, - { name: 'access_token', value: 'token-1' }, - { name: '', value: 'ignored' }, - ]), - { - openid: 'openid-1', - access_token: 'token-1', - acctype: 'qc', - appid: '101491592', - iegams_milo_proxylogin_qc: '101491592_$$_openid-1_$$_token-1', - }, - ) -}) - -test('resolveInitialQrSessionState creates waiting-scan snapshot with ttl', () => { - assert.deepEqual( - resolveInitialQrSessionState('wx', { - updatedAt: '2026-04-14T09:30:00.000Z', - nowMs: 1_700_000_000_000, - sessionTtlMs: 1_800_000, - }), - { - status: 'waiting_scan', - notice: '请使用微信扫码登录', - updatedAt: '2026-04-14T09:30:00.000Z', - expiresAt: 1_700_001_800_000, - }, - ) -}) - -test('ensureReviewScreenshot writes screenshot state and skips unchanged signature', async () => { - const screenshotCalls = [] - const session = { - sessionId: 'txbs-demo', - loginType: 'qq', - sessionDir: '/tmp/session-demo', - page: { - async screenshot(options) { - screenshotCalls.push(options) - }, - }, - lastReview: null, - } - - const activityInfo = { - role: { - roleId: '1001', - roleName: '测试角色', - }, - } - - assert.equal(await ensureReviewScreenshot(session, activityInfo), true) - assert.equal(screenshotCalls.length, 1) - assert.equal(session.lastReview.roleId, '1001') - assert.equal(session.lastReview.roleName, '测试角色') - assert.equal(session.lastReview.signature, 'qq|1001|测试角色') - assert.match(session.lastReview.screenshotPath, /role-review\.png$/) - - assert.equal(await ensureReviewScreenshot(session, activityInfo), false) - assert.equal(screenshotCalls.length, 1) -}) - -test('ensureLoginTab clicks login UI and delegates to qq readiness', async () => { - const calls = [] - const makeLocator = (name) => ({ - async count() { - return 1 - }, - first() { - const firstLocator = { - async count() { - return 1 - }, - async click(options) { - calls.push({ type: 'click', name, options }) - }, - } - return firstLocator - }, - }) - - const page = { - locator(selector) { - if (selector === '#unlogin') { - return makeLocator('loginButton') - } - if (selector === '.qc-tab') { - return makeLocator('qqTab') - } - throw new Error(`unexpected selector: ${selector}`) - }, - async waitForTimeout(ms) { - calls.push({ type: 'wait', ms }) - }, - } - - await ensureLoginTab(page, 'qq', { - async ensureQqLoginReady() { - calls.push({ type: 'ensure', name: 'qq' }) - }, - }) - - assert.deepEqual(calls, [ - { type: 'click', name: 'loginButton', options: { timeout: 2_000 } }, - { type: 'click', name: 'qqTab', options: { timeout: 2_000 } }, - { type: 'wait', ms: 250 }, - { type: 'ensure', name: 'qq' }, - ]) -}) - -test('captureQrImageWithRetry retries once after retryable error', async () => { - const calls = [] - const page = { - async waitForTimeout(ms) { - calls.push({ type: 'wait', ms }) - }, - } - - let attempt = 0 - await captureQrImageWithRetry(page, 'wx', '/tmp/wx-qr.png', { - async captureWxQrImage() { - attempt += 1 - calls.push({ type: 'capture', attempt }) - if (attempt === 1) { - throw new Error('Timeout while waiting for qr') - } - }, - async ensureLoginTab(_page, loginType) { - calls.push({ type: 'ensureLoginTab', loginType }) - }, - isRetryableQrCaptureError(error) { - return String(error.message).includes('Timeout') - }, - logBrowserSessionDebug() {}, - }) - - assert.equal(attempt, 2) - assert.deepEqual(calls, [ - { type: 'capture', attempt: 1 }, - { type: 'wait', ms: 500 }, - { type: 'ensureLoginTab', loginType: 'wx' }, - { type: 'capture', attempt: 2 }, - ]) -}) - -test('ensureLoggedInPresentation reloads page when logged-in host state is stale', async () => { - const session = { - page: { marker: 'page' }, - presentationSyncAttempts: 0, - } - const calls = [] - let hostCall = 0 - - const result = await ensureLoggedInPresentation(session, { - credentialReady: true, - activityUrl: 'https://example.com/activity', - async extractHostState() { - hostCall += 1 - return hostCall === 1 - ? { unloginVisible: true, loginedVisible: false, loginText: '' } - : { unloginVisible: false, loginedVisible: true, loginText: '已登录' } - }, - async reloadActivityPageForPresentation(_page, activityUrl) { - calls.push(activityUrl) - }, - }) - - assert.equal(session.presentationSyncAttempts, 1) - assert.deepEqual(calls, ['https://example.com/activity']) - assert.deepEqual(result, { - reloaded: true, - hostState: { unloginVisible: false, loginedVisible: true, loginText: '已登录' }, - }) -}) - -test('ensureActivityInfoReady triggers render and polls until role becomes ready', async () => { - const calls = [] - const page = { - async waitForTimeout(ms) { - calls.push({ type: 'wait', ms }) - }, - } - const snapshots = [ - { role: { ready: false, roleName: '' } }, - { role: { ready: true, roleName: '测试角色' } }, - ] - let nowValue = 0 - - const result = await ensureActivityInfoReady(page, { - triggerRender: true, - timeoutMs: 1000, - async triggerActivityRoleRender() { - calls.push({ type: 'trigger' }) - }, - async extractActivityInfo() { - calls.push({ type: 'extract' }) - return snapshots.shift() - }, - now() { - nowValue += 300 - return nowValue - }, - }) - - assert.deepEqual(calls, [ - { type: 'trigger' }, - { type: 'extract' }, - { type: 'wait', ms: 400 }, - { type: 'extract' }, - ]) - assert.deepEqual(result, { role: { ready: true, roleName: '测试角色' } }) -}) - -test('extractActivityInfo falls back to empty structure when page evaluation fails', async () => { - const page = { - async evaluate() { - throw new Error('page closed') - }, - } - - assert.deepEqual(await extractActivityInfo(page), createEmptyActivityInfo()) -}) diff --git a/apps/backend/src/services/session/session.ts b/apps/backend/src/services/session/session.ts deleted file mode 100644 index 928495b0..00000000 --- a/apps/backend/src/services/session/session.ts +++ /dev/null @@ -1,410 +0,0 @@ -import { - getLoginTypeLabel, - normalizeLoginType, -} from './session-browser-config.js' -import { logBrowserSessionDebug } from './session-login-shared.js' -import { - loadCookieMapFromContext, -} from './session-cookie-map.js' -import { - ensureActivityInfoReady, - ensureLoggedInPresentation, - extractActivityInfo, -} from './session-activity.js' -import { - buildSessionPayload as buildSessionPayloadBase, -} from './session-payload.js' -import { - captureSessionQr, - ensureLoginTab, - extractHostState, - extractQrState, - reloadActivityPageForPresentation, -} from './session-page.js' -import { - saveRedeemArtifacts, -} from './session-proof.js' -import { - ensureReviewScreenshot, -} from './session-review.js' -import { - capturePageCaptchaForOcr, - isCaptchaRejectedResult, - prepareRedeemCodeFill, - refreshPageCaptcha, - runTencentBrowserRedeem, - submitRedeemInBrowser, -} from './session-redeem.js' -import { - ensureWxLoginReady, -} from './session-wx.js' -import { recognizeTencentCaptcha } from './ocr.js' -import { - hasRedeemCredential, - resolveInitialQrSessionState, - resolveSessionNotice, - resolveSessionStatus, - resolveTencentSessionExpiresAt, -} from './session-state.js' -import { - ACTIVITY_URL, - DEFAULT_VIEWPORT, - REDEEM_SUCCESS_AUTO_CLOSE_MS, - SESSION_DEBUG_ENABLED, -} from './session-service-config.js' -import { - closeBrowserIfIdle, - ensureBrowser, - resolveBrowserLaunchOptions, - SessionServiceError, - warmupTencentBrowser, -} from './session-service-runtime.js' -import { - applyInitialQrSessionState, - armRedeemedSessionAutoClose, - cleanupExpiredTencentBrowserSessions, - closeSession as closeStoredSession, - getRequiredSession, - listActiveTencentBrowserSessions, - persistSessionState as persistStoredSessionState, - registerTencentBrowserSession, - resetTencentSessionAutoClose, -} from './session-service-store.js' -import type { TencentBrowserSession } from './session-service-store.js' - -type JsonObject = Record - -type IncludeQrImageOptions = { - includeQrImage?: boolean -} - -type CloseAllSessionsOptions = { - markClosed?: boolean -} - -type CloseManagedSessionOptions = { - markClosed: boolean -} - -export { resolveTencentSessionExpiresAt } -export { REDEEM_SUCCESS_AUTO_CLOSE_MS, warmupTencentBrowser } - -export async function createTencentBrowserSession(payload: JsonObject = {}): Promise { - await cleanupExpiredTencentBrowserSessions({ closeSession: closeManagedSession }) - - const loginType = normalizeLoginType(payload.loginType || payload.qrType) - - const browser = await ensureBrowser() - const browserContext = await browser.newContext({ - viewport: DEFAULT_VIEWPORT, - deviceScaleFactor: 1, - }) - - const page = await browserContext.newPage() - page.setDefaultTimeout(20_000) - - const session = await registerTencentBrowserSession({ - loginType, - browserContext, - page, - }) - const sessionId = session.sessionId - - try { - await page.goto(ACTIVITY_URL, { waitUntil: 'domcontentloaded' }) - await ensureLoginTab(page, loginType) - await captureSessionQr(session, { ensureLoginReady: false }) - applyInitialQrSessionState(session) - await persistSessionState(session) - void refreshTencentBrowserSession(sessionId).catch((error) => { - logBrowserSessionDebug('createSession.backgroundRefresh.error', { - sessionId, - message: error instanceof Error ? error.message : String(error), - }) - }) - return buildSessionPayload(session) - } catch (error) { - session.status = 'failed' - session.notice = error instanceof Error ? error.message : `${getLoginTypeLabel(loginType)} 浏览器会话初始化失败` - session.lastError = session.notice - session.updatedAt = new Date().toISOString() - await persistSessionState(session) - throw error - } -} - -export async function getTencentBrowserSession( - sessionId: unknown, - options: IncludeQrImageOptions = {}, -): Promise { - return refreshTencentBrowserSession(sessionId, options) -} - -export async function getTencentBrowserSessionSummary(sessionId: unknown): Promise { - return refreshTencentBrowserSession(sessionId, { includeQrImage: false }) -} - -export async function reloadTencentBrowserSession(sessionId: unknown): Promise { - await cleanupExpiredTencentBrowserSessions({ closeSession: closeManagedSession }) - - const session = await getRequiredSession(sessionId) - - if (session.browserContextClosed) { - throw new SessionServiceError('浏览器会话已关闭', { - statusCode: 410, - errorCode: 'session_closed', - }) - } - - try { - await reloadActivityPageForPresentation(session.page, ACTIVITY_URL) - - const cookieMap = await loadCookieMapFromContext(session.browserContext) - const credentialReady = hasRedeemCredential(cookieMap) - - if (!credentialReady) { - session.qrImageBase64 = '' - session.qrImagePath = '' - session.qrUpdatedAt = '' - await ensureLoginTab(session.page, session.loginType) - await captureSessionQr(session) - } - - session.notice = credentialReady - ? '后端页面已刷新,正在同步最新状态' - : `已刷新${getLoginTypeLabel(session.loginType)}登录页,准备重新抓取二维码` - session.updatedAt = new Date().toISOString() - - return await refreshTencentBrowserSession(sessionId) - } catch (error) { - session.status = 'failed' - session.notice = error instanceof Error ? error.message : '刷新后端页面失败' - session.lastError = session.notice - session.updatedAt = new Date().toISOString() - await persistSessionState(session) - throw error - } -} - -export async function refreshTencentBrowserSession( - sessionId: unknown, - { includeQrImage = true }: IncludeQrImageOptions = {}, -): Promise { - await cleanupExpiredTencentBrowserSessions({ closeSession: closeManagedSession }) - - const session = await getRequiredSession(sessionId) - - if (session.browserContextClosed) { - throw new SessionServiceError('浏览器会话已关闭', { - statusCode: 410, - errorCode: 'session_closed', - }) - } - - try { - let hostState = await extractHostState(session.page) - const cookieMap = await loadCookieMapFromContext(session.browserContext) - const credentialReady = hasRedeemCredential(cookieMap) - - if (credentialReady) { - const presentation = await ensureLoggedInPresentation(session, { - hostState, - credentialReady, - activityUrl: ACTIVITY_URL, - }) - hostState = presentation.hostState - } else { - session.presentationSyncAttempts = 0 - } - - if (!credentialReady && session.loginType === 'wx') { - await ensureWxLoginReady(session.page) - } - - const qrState = await extractQrState(session.page, session.loginType) - const activityInfo = credentialReady - ? await ensureActivityInfoReady(session.page, { triggerRender: true }) - : await extractActivityInfo(session.page) - - session.lastState = { - hostState, - qrState, - credentialReady, - activityInfo, - cookieKeys: Object.keys(cookieMap), - } - session.status = resolveSessionStatus({ hostState, qrState, credentialReady, activityInfo }) - session.notice = resolveSessionNotice({ - hostState, - qrState, - credentialReady, - activityInfo, - loginType: session.loginType, - }) - - if (credentialReady && activityInfo?.role?.ready) { - await ensureReviewScreenshot(session, activityInfo) - } - - session.updatedAt = new Date().toISOString() - session.expiresAt = resolveTencentSessionExpiresAt(session) - - if (!session.qrImageBase64 || session.status === 'expired') { - if (session.status === 'expired') { - await reloadActivityPageForPresentation(session.page, ACTIVITY_URL) - await ensureLoginTab(session.page, session.loginType) - } - - await captureSessionQr(session) - if (session.status === 'expired') { - session.notice = '二维码已刷新,请重新扫码' - session.status = 'waiting_scan' - } - } - - await persistSessionState(session) - return buildSessionPayload(session, { includeQrImage }) - } catch (error) { - session.status = 'failed' - session.notice = error instanceof Error ? error.message : `查询 ${getLoginTypeLabel(session.loginType)} 浏览器会话状态失败` - session.lastError = session.notice - session.updatedAt = new Date().toISOString() - await persistSessionState(session) - return buildSessionPayload(session, { includeQrImage }) - } -} - -export async function redeemTencentBrowserSession(sessionId: unknown, payload: JsonObject = {}): Promise { - const session = await getRequiredSession(sessionId) - const code = String(payload.code || payload.sCdKey || '').trim() - const maxAttempts = Math.max(1, Math.min(Number(payload.maxAttempts || 6), 12)) - - if (!code) { - throw new SessionServiceError('缺少兑换码', { - statusCode: 400, - errorCode: 'missing_redeem_code', - }) - } - - const refreshed = await refreshTencentBrowserSession(sessionId) - - if (!['logged_in', 'ready_to_redeem', 'redeemed'].includes(refreshed.status)) { - throw new SessionServiceError(`当前会话还不能兑换,状态为 ${refreshed.status}`, { - statusCode: 409, - errorCode: 'session_not_ready', - }) - } - - resetTencentSessionAutoClose(session) - - await runTencentBrowserRedeem({ - session, - code, - maxAttempts, - ensureLoggedInPresentation: (redeemSession, options) => - ensureLoggedInPresentation(redeemSession as any, options as any), - ensureActivityInfoReady, - fillRedeemCodeInBrowser, - capturePageCaptchaForOcr: (page, options) => - capturePageCaptchaForOcr(page, { ...options, ensureActivityInfoReady }), - recognizeTencentCaptcha, - submitRedeemInBrowser: (page, redeemPayload) => - submitRedeemInBrowser(page, redeemPayload, { ensureActivityInfoReady }), - isCaptchaRejectedResult, - refreshPageCaptcha, - persistSessionState, - buildSessionPayload, - saveRedeemArtifacts, - activityUrl: ACTIVITY_URL, - }) - - armRedeemedSessionAutoClose(session, { closeSession: closeManagedSession }) - await persistSessionState(session) - - return { - ...buildSessionPayload(session), - redeem: session.lastRedeem, - } -} - -export async function closeTencentBrowserSession(sessionId: unknown): Promise { - const session = await getRequiredSession(sessionId) - await closeManagedSession(session, { markClosed: true }) - return { - sessionId, - closed: true, - } -} - -export async function closeAllTencentBrowserSessions( - { markClosed = true }: CloseAllSessionsOptions = {}, -): Promise { - const activeSessions = listActiveTencentBrowserSessions() - - await Promise.all(activeSessions.map((session) => closeManagedSession(session, { markClosed }))) - - return { - closedCount: activeSessions.length, - } -} - -export async function getTencentBrowserSessionScreenshotPath(sessionId: unknown): Promise { - const session = await getRequiredSession(sessionId) - const screenshotPath = session.lastRedeem?.screenshotPath - - if (!screenshotPath) { - throw new SessionServiceError('当前会话还没有兑换截图', { - statusCode: 404, - errorCode: 'screenshot_not_ready', - }) - } - - return screenshotPath -} - -export async function getTencentBrowserSessionReviewScreenshotPath(sessionId: unknown): Promise { - const session = await getRequiredSession(sessionId) - const screenshotPath = session.lastReview?.screenshotPath - - if (!screenshotPath) { - throw new SessionServiceError('当前会话还没有客服复核截图', { - statusCode: 404, - errorCode: 'review_screenshot_not_ready', - }) - } - - return screenshotPath -} - -async function persistSessionState(session: TencentBrowserSession): Promise { - await persistStoredSessionState(session, { buildSessionPayload }) -} - -function buildSessionPayload( - session: TencentBrowserSession, - { includeQrImage = true }: IncludeQrImageOptions = {}, -): JsonObject { - return buildSessionPayloadBase(session, { - activityUrl: ACTIVITY_URL, - includeQrImage, - sessionDebugEnabled: SESSION_DEBUG_ENABLED, - browserDebug: SESSION_DEBUG_ENABLED ? resolveBrowserLaunchOptions() : null, - }) -} - -async function fillRedeemCodeInBrowser(page: any, code: string): Promise { - await prepareRedeemCodeFill(page, code, { ensureActivityInfoReady }) -} - -async function closeManagedSession( - session: TencentBrowserSession, - { markClosed }: CloseManagedSessionOptions, -): Promise { - await closeStoredSession(session, { - markClosed, - persistSessionState, - closeBrowserIfIdle: () => closeBrowserIfIdle({ - activeSessionCount: listActiveTencentBrowserSessions().length, - }), - }) -} diff --git a/apps/backend/src/startup/bootstrap.ts b/apps/backend/src/startup/bootstrap.ts index 4d831656..3ed38e95 100644 --- a/apps/backend/src/startup/bootstrap.ts +++ b/apps/backend/src/startup/bootstrap.ts @@ -1,13 +1,8 @@ -import process from "node:process"; - -import { runtimeConfig } from "../config/runtime.js"; import { runDatabaseMigrations } from "../db/migrate.js"; import { ensureAdminUsersBootstrapped } from "../services/admin/admin-auth-service.js"; import { ensureFulfillmentCatalogBootstrapped } from "../services/bootstrap/fulfillment-bootstrap-service.js"; import { startScheduledJobs } from "../services/scheduler/scheduler-service.js"; -import { warmupOcrService } from "../services/session/ocr.js"; -import { warmupTencentBrowser } from "../services/session/session.js"; -import { logError, logInfo, logWarn } from "../utils/logger.js"; +import { logError, logInfo } from "../utils/logger.js"; import { formatStartupError, type StartupState } from "./state.js"; const CORE_BOOT_RETRY_DELAY_MS = 5_000; @@ -23,12 +18,13 @@ export async function bootstrapCoreServices( startupState.core.running = true; while (!isShutdownStarted() && !startupState.core.ready) { - startupState.phase = startupState.core.attemptCount === 0 ? "starting" : "retrying"; + startupState.phase = + startupState.core.attemptCount === 0 ? "starting" : "retrying"; startupState.core.attemptCount += 1; startupState.core.lastAttemptAt = new Date().toISOString(); try { - logInfo("[startup]", "开始执行核心启动步骤", { + logInfo("[startup]", "开始执行快手核心启动步骤", { attempt: startupState.core.attemptCount, }); @@ -41,19 +37,17 @@ export async function bootstrapCoreServices( startupState.phase = "ready"; startupState.core.readyAt = new Date().toISOString(); - logInfo("[startup]", "核心启动步骤完成,服务已就绪", { + logInfo("[startup]", "快手核心启动步骤完成,服务已就绪", { attempt: startupState.core.attemptCount, }); startScheduledJobs(); - void bootstrapBrowser(startupState); - void bootstrapOcr(startupState); break; } catch (error) { const message = formatStartupError(error); startupState.phase = "retrying"; startupState.core.lastError = message; - logError("[startup]", "核心启动步骤失败,将自动重试", { + logError("[startup]", "快手核心启动步骤失败,将自动重试", { attempt: startupState.core.attemptCount, retryDelayMs: CORE_BOOT_RETRY_DELAY_MS, error, @@ -66,87 +60,6 @@ export async function bootstrapCoreServices( startupState.core.running = false; } -async function bootstrapBrowser(startupState: StartupState) { - startupState.browser.status = "starting"; - startupState.browser.lastAttemptAt = new Date().toISOString(); - - try { - const result = await warmupTencentBrowser(); - - if (!result.warmed) { - startupState.browser.status = "skipped"; - startupState.browser.message = "browser prewarm skipped"; - logInfo("[startup]", "browser prewarm skipped"); - return; - } - - startupState.browser.status = "ready"; - startupState.browser.message = "browser prewarm ready"; - logInfo("[startup]", "browser prewarm ready"); - } catch (error) { - if (isMissingPlaywrightBrowserError(error)) { - const installCommand = resolveBrowserInstallCommand(); - startupState.browser.status = "degraded"; - startupState.browser.message = formatErrorMessage(error); - logWarn("[startup]", `browser prewarm skipped: ${formatErrorMessage(error)}`); - logWarn("[startup]", `请先安装浏览器依赖: ${installCommand}`); - - if (String(runtimeConfig.browser.chromePath || "").trim()) { - logWarn("[startup]", "当前设置了 CHROME_PATH,也请确认该路径指向的浏览器可执行文件真实存在"); - } - - return; - } - - startupState.browser.status = "degraded"; - startupState.browser.message = formatStartupError(error); - logError("[startup]", "browser prewarm failed", error); - } -} - -async function bootstrapOcr(startupState: StartupState) { - startupState.ocr.status = "starting"; - startupState.ocr.lastAttemptAt = new Date().toISOString(); - - try { - await warmupOcrService(); - startupState.ocr.status = "ready"; - startupState.ocr.message = "OCR service ready"; - logInfo("[startup]", "OCR service ready"); - } catch (error) { - startupState.ocr.status = "degraded"; - startupState.ocr.message = formatStartupError(error); - logWarn("[startup]", `OCR service skipped: ${formatStartupError(error)}`); - } -} - -function isMissingPlaywrightBrowserError(error: unknown) { - const message = formatErrorMessage(error).toLowerCase(); - - return ( - message.includes("executable doesn't exist") || - message.includes("please run the following command to download new browsers") || - (message.includes("browserType.launch".toLowerCase()) && - message.includes("please run")) - ); -} - -function formatErrorMessage(error: unknown) { - if (error instanceof Error && error.message) { - return error.message.split("\n")[0].trim(); - } - - return String(error || "未知错误").trim(); -} - -function resolveBrowserInstallCommand() { - if (process.platform === "linux") { - return "npm run browser:install:linux"; - } - - return "npm run browser:install"; -} - function sleep(ms: number) { return new Promise((resolve) => { setTimeout(resolve, ms); diff --git a/apps/backend/src/startup/shutdown.ts b/apps/backend/src/startup/shutdown.ts index e87c36c4..2ee3134a 100644 --- a/apps/backend/src/startup/shutdown.ts +++ b/apps/backend/src/startup/shutdown.ts @@ -1,12 +1,13 @@ import type { Server } from "node:http"; import { stopScheduledJobs } from "../services/scheduler/scheduler-service.js"; -import { closeOcrService } from "../services/session/ocr.js"; -import { closeAllTencentBrowserSessions } from "../services/session/session.js"; import { logError, logInfo } from "../utils/logger.js"; import type { StartupState } from "./state.js"; -export function createShutdownController(server: Server, startupState: StartupState) { +export function createShutdownController( + server: Server, + startupState: StartupState +) { let shutdownStarted = false; async function shutdown(signal: string) { @@ -16,22 +17,10 @@ export function createShutdownController(server: Server, startupState: StartupSt shutdownStarted = true; startupState.phase = "shutting_down"; - logInfo("[shutdown]", `received ${signal}, closing browser sessions and HTTP server`); + logInfo("[shutdown]", `received ${signal}, closing HTTP server`); stopScheduledJobs(); - try { - await closeAllTencentBrowserSessions({ markClosed: true }); - } catch (error) { - logError("[shutdown]", "failed to close browser sessions", error); - } - - try { - await closeOcrService(); - } catch (error) { - logError("[shutdown]", "failed to close OCR service", error); - } - if (!server.listening) { return; } diff --git a/apps/backend/src/startup/state.ts b/apps/backend/src/startup/state.ts index 0f54f9c3..9cc3c531 100644 --- a/apps/backend/src/startup/state.ts +++ b/apps/backend/src/startup/state.ts @@ -1,6 +1,26 @@ -export type StartupState = ReturnType; +type StartupProcessError = { + time: string; + message: string; +}; -export function createStartupState() { +export type StartupState = { + phase: string; + startedAt: string; + core: { + ready: boolean; + running: boolean; + attemptCount: number; + readyAt: string; + lastAttemptAt: string; + lastError: string; + }; + process: { + lastUnhandledRejection: StartupProcessError | null; + lastUncaughtException: StartupProcessError | null; + }; +}; + +export function createStartupState(): StartupState { return { phase: "starting", startedAt: new Date().toISOString(), @@ -12,16 +32,6 @@ export function createStartupState() { lastAttemptAt: "", lastError: "", }, - browser: { - status: "pending", - message: "", - lastAttemptAt: "", - }, - ocr: { - status: "pending", - message: "", - lastAttemptAt: "", - }, process: { lastUnhandledRejection: null, lastUncaughtException: null, @@ -41,16 +51,6 @@ export function buildHealthPayload(startupState: StartupState, shutdownStarted: lastAttemptAt: startupState.core.lastAttemptAt, lastError: startupState.core.lastError, }, - browser: { - status: startupState.browser.status, - message: startupState.browser.message, - lastAttemptAt: startupState.browser.lastAttemptAt, - }, - ocr: { - status: startupState.ocr.status, - message: startupState.ocr.message, - lastAttemptAt: startupState.ocr.lastAttemptAt, - }, process: { pid: process.pid, shutdownStarted, @@ -62,7 +62,7 @@ export function buildHealthPayload(startupState: StartupState, shutdownStarted: export function formatStartupError(error: unknown) { if (error instanceof Error && error.message) { - return error.message.split("\n")[0].trim(); + return error.message.split("\n")[0]?.trim() || "未知错误"; } return String(error || "未知错误").trim(); diff --git a/apps/backend/src/types/admin-read-inputs.ts b/apps/backend/src/types/admin-read-inputs.ts deleted file mode 100644 index 3914eef9..00000000 --- a/apps/backend/src/types/admin-read-inputs.ts +++ /dev/null @@ -1,73 +0,0 @@ -export type AdminViewerSessionInput = { - userId?: number | string - username?: string - role?: string - allowedInventoryGroups?: string[] -} - -export type AdminOrderListQueryInput = { - page?: number | string - pageSize?: number | string - platformOrderId?: string - payStatus?: string - skuCode?: string - dateFrom?: string - dateTo?: string -} - -export type AdminTaskListQueryInput = { - page?: number | string - pageSize?: number | string - status?: string - platformOrderId?: string - taskNo?: string - skuCode?: string - roleId?: string - dateFrom?: string - dateTo?: string -} - -export type AdminInventoryListQueryInput = { - page?: number | string - pageSize?: number | string - skuCode?: string - credentialType?: string - status?: string - batchNo?: string - inventoryGroupCode?: string -} - -export type AdminInventorySkuSuggestionQueryInput = { - credentialType?: string - keyword?: string - limit?: number | string - inventoryGroupCode?: string -} - -export type AdminWebhookEventListQueryInput = { - page?: number | string - pageSize?: number | string - provider?: string - platform?: string - platformOrderId?: string - processed?: string - visibility?: string - relatedOrderId?: string - dateFrom?: string - dateTo?: string -} - -export type AdminMessageDeliveryListQueryInput = { - page?: number | string - pageSize?: number | string - provider?: string - platform?: string - status?: string - shopId?: string - platformOrderId?: string - taskNo?: string - dateFrom?: string - dateTo?: string -} - -export type AdminEntityIdInput = number | string diff --git a/apps/backend/src/types/admin-read-models.ts b/apps/backend/src/types/admin-read-models.ts deleted file mode 100644 index 582597f1..00000000 --- a/apps/backend/src/types/admin-read-models.ts +++ /dev/null @@ -1,176 +0,0 @@ -export type AdminTaskBindingSummary = { - totalBindingCount: number - reservedBindingCount: number - consumedBindingCount: number - releasedBindingCount: number - roleKeys: string[] -} - -export type AdminAgisoAutoDeliveryStatus = { - status: string - trigger: string - reason: string - platformOrderId: string - responseStatus: number - errorMessage: string - requestId: string - updatedAt: string | null -} - -export type AdminOrderListItem = { - orderId: number - provider: string - platform: string - shopId: string - shopName: string - platformOrderId: string - orderStatus: string - payStatus: string - buyerId: string - buyerName: string - receiverContact: string - totalAmount: string - totalAmountFen: number - currency: string - paidAt: string | null - createdAt: string - updatedAt: string - itemCount: number - totalQuantity: number - itemSummary: string - taskCount: number - systemBindingStatus: string - userBindingStatus: string - systemBoundTaskCount: number - completedBindingTaskCount: number - totalBindingCount: number - reservedBindingCount: number - consumedBindingCount: number - releasedBindingCount: number -} - -export type AdminTaskListItem = { - taskId: number - taskNo: string - platformOrderId: string - skuCode: string - skuName: string - status: string - executorKey: string - deliveryStatus: string - resultCode: string - resultMessage: string - systemBindingStatus: string - userBindingStatus: string - loginType: string - roleName: string - roleId: string - browserSessionId: string - claimedAt: string | null - roleConfirmedAt: string | null - redeemedAt: string | null - retryCount: number - bindingSummary: AdminTaskBindingSummary - lastError: string - createdAt: string - updatedAt: string - inventoryDisplayMasked: string - inventoryCredentialType: string - claimToken: string - screenshotPath: string - agisoAutoDelivery: AdminAgisoAutoDeliveryStatus | null -} - -export type AdminInventoryItemListItem = { - inventoryItemId: number - skuCode: string - batchNo: string - credentialType: string - inventoryGroupCode: string - displayValue: string - status: string - reservedByTaskId: number | null - reservedByTaskNo: string - platformOrderId: string - systemBindingStatus: string - userBindingStatus: string - invalidReason: string - deliveredAt: string | null - createdAt: string - updatedAt: string -} - -export type AdminWebhookEventListItem = { - eventId: number - provider: string - platform: string - platformRaw: string - shopId: string - shopName: string - eventType: string - eventKey: string - signatureValid: boolean - processed: boolean - processError: string - visibilityLevel: string - relatedOrderId: number | null - platformOrderId: string - buyerId: string - buyerName: string - totalAmount: string - totalAmountFen: number - currency: string - itemCount: number - taskCount: number - messageText: string - requestTimestamp: string - requestSign: string - aopic: string - sourceHost: string - sourceIp: string - userAgent: string - createdAt: string - headers?: Record - query?: Record - body?: Record - payload?: Record -} - -export type AdminInventorySkuSuggestion = { - skuCode: string - credentialType: string - inventoryGroupCode: string - totalCount: number - availableCount: number - latestUpdatedAt: string | null -} - -export type AdminPagination = { - page: number - pageSize: number - total: number -} - -export type AdminOrderListResponse = { - items: AdminOrderListItem[] - pagination: AdminPagination -} - -export type AdminTaskListResponse = { - items: AdminTaskListItem[] - pagination: AdminPagination -} - -export type AdminInventoryListResponse = { - items: AdminInventoryItemListItem[] - pagination: AdminPagination -} - -export type AdminWebhookEventListResponse = { - items: AdminWebhookEventListItem[] - pagination: AdminPagination -} - -export type AdminInventorySkuSuggestionResponse = { - items: AdminInventorySkuSuggestion[] -} diff --git a/apps/backend/src/types/admin-write-models.ts b/apps/backend/src/types/admin-write-models.ts deleted file mode 100644 index 674d86ad..00000000 --- a/apps/backend/src/types/admin-write-models.ts +++ /dev/null @@ -1,80 +0,0 @@ -import type { - AdminInventoryItemListItem, - AdminInventorySkuSuggestion, -} from './admin-read-models.js' - -export type { AdminInventoryItemListItem, AdminInventorySkuSuggestion } - -export type AdminInventoryMutationResponse = { - inventoryItem: AdminInventoryItemListItem | null -} - -export type AdminInventoryImportResponse = { - total: number - created: number - duplicated: number -} - -export type AdminInventoryGroupListResponse = { - items: AdminInventorySkuSuggestion[] -} - -export type AdminAgisoMessagingDefaults = { - messageTemplate: string - autoDeliveryMessageTemplate: string -} - -export type AdminAgisoShopConfigItem = { - shopId: string - shopName: string - accessToken: string - accessTokenMasked: string - enabled: boolean | null - messageTemplate: string - autoDeliveryMessageTemplate: string - appSecretConfigured: boolean - apiVersion: string - sendMessageEndpoint: string -} - -export type AdminAgisoShopConfigSaveResponse = { - filePath: string - defaults: AdminAgisoMessagingDefaults - shops: AdminAgisoShopConfigItem[] -} - -export type AdminWebhookReplayResponse = { - eventId: number - replayed: boolean - result: unknown -} - -export type AdminTaskActionPayload = { - taskId: number - taskNo: string - status: string - deliveryStatus: string - resultCode: string - resultMessage: string - inventoryItemId: number | null - primaryClaimTokenId: number | null - lastError: string - updatedAt: string -} - -export type AdminTaskActionResponse = { - task: AdminTaskActionPayload - claimUrl?: string - token?: string -} - -export type AdminTaskBindingReleaseResponse = AdminTaskActionResponse & { - bindingId: number - inventoryItemId: number -} - -export type AdminManualDispatchOutcome = 'delivered' | 'failed' - -export type AdminTaskManualDispatchResponse = AdminTaskActionResponse & { - outcome: AdminManualDispatchOutcome -} diff --git a/apps/backend/src/types/admin/read-inputs.ts b/apps/backend/src/types/admin/read-inputs.ts new file mode 100644 index 00000000..59802b47 --- /dev/null +++ b/apps/backend/src/types/admin/read-inputs.ts @@ -0,0 +1,29 @@ +export type AdminViewerSessionInput = { + userId?: number | string + username?: string + role?: string +} + +export type AdminOrderListQueryInput = { + page?: number | string + pageSize?: number | string + platformOrderId?: string + payStatus?: string + skuCode?: string + dateFrom?: string + dateTo?: string +} + +export type AdminTaskListQueryInput = { + page?: number | string + pageSize?: number | string + status?: string + platformOrderId?: string + taskNo?: string + skuCode?: string + roleId?: string + dateFrom?: string + dateTo?: string +} + +export type AdminEntityIdInput = number | string diff --git a/apps/backend/src/types/admin/read-models.ts b/apps/backend/src/types/admin/read-models.ts new file mode 100644 index 00000000..cc494b28 --- /dev/null +++ b/apps/backend/src/types/admin/read-models.ts @@ -0,0 +1,79 @@ +export type AdminOrderFulfillmentProgress = { + totalTaskCount: number + preparedTaskCount: number + completedTaskCount: number + resourceStatus: string + customerStatus: string +} + +export type AdminOrderListItem = { + orderId: number + provider: string + platform: string + shopId: string + shopName: string + platformOrderId: string + orderStatus: string + payStatus: string + buyerId: string + buyerName: string + receiverContact: string + totalAmount: string + totalAmountFen: number + currency: string + paidAt: string | null + createdAt: string + updatedAt: string + itemCount: number + totalQuantity: number + itemSummary: string + taskCount: number + resourceStatus: string + customerStatus: string + preparedTaskCount: number + completedTaskCount: number +} + +export type AdminTaskListItem = { + taskId: number + taskNo: string + platformOrderId: string + skuCode: string + skuName: string + status: string + executorKey: string + deliveryStatus: string + resultCode: string + resultMessage: string + resourceStatus: string + customerStatus: string + loginType: string + roleName: string + roleId: string + runtimeSessionId: string + claimedAt: string | null + roleConfirmedAt: string | null + redeemedAt: string | null + retryCount: number + lastError: string + createdAt: string + updatedAt: string + claimToken: string + screenshotPath: string +} + +export type AdminPagination = { + page: number + pageSize: number + total: number +} + +export type AdminOrderListResponse = { + items: AdminOrderListItem[] + pagination: AdminPagination +} + +export type AdminTaskListResponse = { + items: AdminTaskListItem[] + pagination: AdminPagination +} diff --git a/apps/backend/src/types/admin-route-inputs.ts b/apps/backend/src/types/admin/route-inputs.ts similarity index 61% rename from apps/backend/src/types/admin-route-inputs.ts rename to apps/backend/src/types/admin/route-inputs.ts index 328ee9be..ba0e22e8 100644 --- a/apps/backend/src/types/admin-route-inputs.ts +++ b/apps/backend/src/types/admin/route-inputs.ts @@ -1,16 +1,13 @@ import type { - AdminInventoryListQueryInput, - AdminInventorySkuSuggestionQueryInput, - AdminMessageDeliveryListQueryInput, AdminOrderListQueryInput, AdminTaskListQueryInput, AdminViewerSessionInput, - AdminWebhookEventListQueryInput, -} from './admin-read-inputs.js' +} from './read-inputs.js' import type { - AdminAgisoShopConfigSaveInput, AdminCloudtentaclesCatalogQueryInput, + AdminCloudtentaclesDeliveryRecordQueryInput, AdminCloudtentaclesFullFlowInput, + AdminCloudtentaclesOverrideRuleConfigInput, AdminCloudtentaclesSendSmsCodeInput, AdminCloudtentaclesSkuBuyInput, AdminCloudtentaclesSkuUseInput, @@ -18,65 +15,43 @@ import type { AdminCloudtentaclesTestLoginInput, AdminCloudtentaclesValidateSessionInput, AdminCloudtentaclesVirtualNumberInput, - AdminFulfillmentBindingConfigSaveInput, - AdminFulfillmentBindingLookupInput, - AdminInventoryCreateInput, - AdminInventoryImportInput, - AdminInventoryInvalidateInput, - AdminKuaishouCloudFulfillmentConfigInput, AdminKuaishouEticketConsumeInput, AdminKuaishouEticketDetailQueryInput, AdminKuaishouEticketShopInfoInput, AdminKuaishouEticketSourceConfigInput, - AdminManualRedeemCreateInput, AdminNotificationConfigInput, AdminNotificationTestInput, AdminScheduledJobsConfigInput, AdminTaskKuaishouCloudDispatchInput, AdminTaskManualDispatchInput, - AdminUserInventoryGroupsInput, -} from './admin-write-inputs.js' +} from './write-inputs.js' -export type AdminInventoryRouteQuery = AdminInventoryListQueryInput -export type AdminInventorySkuSuggestionRouteQuery = AdminInventorySkuSuggestionQueryInput -export type AdminMessageDeliveryRouteQuery = AdminMessageDeliveryListQueryInput export type AdminOrderRouteQuery = AdminOrderListQueryInput export type AdminTaskRouteQuery = AdminTaskListQueryInput export type AdminRouteAdminSession = AdminViewerSessionInput -export type AdminWebhookEventRouteQuery = AdminWebhookEventListQueryInput export type AdminKuaishouEticketSourceConfigRouteBody = AdminKuaishouEticketSourceConfigInput export type AdminNotificationConfigRouteBody = AdminNotificationConfigInput export type AdminNotificationTestRouteBody = AdminNotificationTestInput export type AdminScheduledJobsConfigRouteBody = AdminScheduledJobsConfigInput -export type AdminAgisoShopConfigRouteBody = AdminAgisoShopConfigSaveInput export type AdminKuaishouEticketDetailQueryRouteBody = AdminKuaishouEticketDetailQueryInput export type AdminKuaishouEticketConsumeRouteBody = AdminKuaishouEticketConsumeInput export type AdminKuaishouEticketShopInfoRouteBody = AdminKuaishouEticketShopInfoInput export type AdminCloudtentaclesSourceConfigRouteBody = AdminCloudtentaclesSourceConfigInput -export type AdminKuaishouCloudFulfillmentConfigRouteBody = AdminKuaishouCloudFulfillmentConfigInput +export type AdminCloudtentaclesOverrideRuleConfigRouteBody = AdminCloudtentaclesOverrideRuleConfigInput export type AdminCloudtentaclesSendSmsCodeRouteBody = AdminCloudtentaclesSendSmsCodeInput export type AdminCloudtentaclesTestLoginRouteBody = AdminCloudtentaclesTestLoginInput export type AdminCloudtentaclesValidateSessionRouteBody = AdminCloudtentaclesValidateSessionInput export type AdminCloudtentaclesCatalogQueryRouteBody = AdminCloudtentaclesCatalogQueryInput +export type AdminCloudtentaclesDeliveryRecordQueryRouteBody = AdminCloudtentaclesDeliveryRecordQueryInput export type AdminCloudtentaclesSkuBuyRouteBody = AdminCloudtentaclesSkuBuyInput export type AdminCloudtentaclesSkuUseRouteBody = AdminCloudtentaclesSkuUseInput export type AdminCloudtentaclesVirtualNumberRouteBody = AdminCloudtentaclesVirtualNumberInput export type AdminCloudtentaclesFullFlowRouteBody = AdminCloudtentaclesFullFlowInput -export type AdminFulfillmentBindingConfigRouteBody = AdminFulfillmentBindingConfigSaveInput -export type AdminFulfillmentBindingLookupRouteBody = AdminFulfillmentBindingLookupInput -export type AdminInventoryCreateRouteBody = AdminInventoryCreateInput -export type AdminInventoryImportRouteBody = AdminInventoryImportInput -export type AdminInventoryInvalidateRouteBody = AdminInventoryInvalidateInput -export type AdminUserInventoryGroupsRouteBody = AdminUserInventoryGroupsInput export type AdminTaskManualDispatchRouteBody = AdminTaskManualDispatchInput export type AdminTaskKuaishouCloudDispatchRouteBody = AdminTaskKuaishouCloudDispatchInput -export type AdminManualRedeemCreateRouteBody = AdminManualRedeemCreateInput -export type AdminInventoryRouteParams = { inventoryItemId?: string } export type AdminOrderRouteParams = { orderId?: string } export type AdminTaskRouteParams = { taskId?: string, bindingId?: string } -export type AdminManualRedeemRouteParams = { taskId?: string } -export type AdminWebhookEventRouteParams = { eventId?: string } export type AdminEntityRouteParams = { id?: string } export type AdminCloudtentaclesSourceRouteParams = { sourceKey?: string } diff --git a/apps/backend/src/types/admin-write-inputs.ts b/apps/backend/src/types/admin/write-inputs.ts similarity index 61% rename from apps/backend/src/types/admin-write-inputs.ts rename to apps/backend/src/types/admin/write-inputs.ts index 2abaca02..26bb6362 100644 --- a/apps/backend/src/types/admin-write-inputs.ts +++ b/apps/backend/src/types/admin/write-inputs.ts @@ -1,86 +1,3 @@ -export type AdminInventoryCreateInput = { - skuCode?: string - displayValue?: string - batchNo?: string - credentialType?: string - inventoryGroupCode?: string -} - -export type AdminInventoryImportRowInput = { - skuCode?: string - displayValue?: string - batchNo?: string - credentialType?: string - inventoryGroupCode?: string -} - -export type AdminInventoryImportInput = { - rows?: AdminInventoryImportRowInput[] - codes?: unknown[] - skuCode?: string - batchNo?: string - credentialType?: string - inventoryGroupCode?: string -} - -export type AdminUserInventoryGroupsInput = { - inventoryGroupCodes?: string[] -} - -export type AdminAgisoMessagingDefaultsInput = { - messageTemplate?: string - autoDeliveryMessageTemplate?: string -} - -export type AdminAgisoShopConfigWriteItemInput = { - shopId?: string - shopName?: string - accessToken?: string - enabled?: boolean | null - messageTemplate?: string - autoDeliveryMessageTemplate?: string - appSecret?: string - apiVersion?: string - sendMessageEndpoint?: string -} - -export type AdminAgisoShopConfigSaveInput = { - defaults?: AdminAgisoMessagingDefaultsInput - shops?: AdminAgisoShopConfigWriteItemInput[] -} - -export type AdminKuaishouCloudFulfillmentItemInput = { - id?: string - enabled?: boolean - priority?: number | string - provider?: string - platform?: string - shopId?: string - internalSkuCode?: string - internalSkuName?: string - externalSkuCode?: string - externalItemId?: string - externalSkuName?: string - resolvedSkuName?: string - cloudSourceKey?: string - cloudSourceKeyFallbacks?: string[] - cloudSkuId?: number | string - cloudSkuName?: string - vnKey?: string - autoBuyEnabled?: boolean - minAssetReserve?: number | string - autoReturnNumberAfterDispatch?: boolean - autoConsumeAfterDispatch?: boolean - kuaishouConsumeShopId?: string - kuaishouConsumeShopName?: string - notes?: string -} - -export type AdminKuaishouCloudFulfillmentConfigInput = { - enabled?: boolean - items?: AdminKuaishouCloudFulfillmentItemInput[] -} - export type AdminKuaishouEticketShopConfigWriteItemInput = { shopId?: string kshopName?: string @@ -191,6 +108,26 @@ export type AdminCloudtentaclesSourceConfigInput = { sources?: AdminCloudtentaclesSourceConfigInput[] } +export type AdminCloudtentaclesOverrideDeliveryItemInput = { + cloudSkuId?: number | string + cloudSkuName?: string + quantity?: number | string +} + +export type AdminCloudtentaclesOverrideRuleInput = { + id?: string + enabled?: boolean + productName?: string + sourceKey?: string + deliveryItems?: AdminCloudtentaclesOverrideDeliveryItemInput[] + notes?: string +} + +export type AdminCloudtentaclesOverrideRuleConfigInput = { + enabled?: boolean + rules?: AdminCloudtentaclesOverrideRuleInput[] +} + export type AdminCloudtentaclesSendSmsCodeInput = { sourceKey?: string baseUrl?: string @@ -227,6 +164,15 @@ export type AdminCloudtentaclesCatalogQueryInput = { deviceType?: number | string } +export type AdminCloudtentaclesDeliveryRecordQueryInput = AdminCloudtentaclesCatalogQueryInput & { + page?: number | string + size?: number | string + startDate?: string + endDate?: string + recordCode?: number | string + platformOrderId?: string +} + export type AdminCloudtentaclesSkuBuyInput = { sourceKey?: string baseUrl?: string @@ -271,40 +217,6 @@ export type AdminCloudtentaclesFullFlowInput = { vnKey?: string } -export type AdminFulfillmentBindingLookupInput = { - provider?: string - platform?: string - shopId?: string - platformOrderId?: string -} - -export type AdminFulfillmentBindingConfigWriteItemInput = { - provider?: string - platform?: string - shopId?: string - shopName?: string - skuCode?: string - skuName?: string - profileKey?: string - enabled?: boolean - priority?: number | string - config?: Record - match?: { - externalSkuCode?: string - externalItemId?: string - externalSkuName?: string - config?: Record - } -} - -export type AdminFulfillmentBindingConfigSaveInput = { - bindings?: AdminFulfillmentBindingConfigWriteItemInput[] -} - -export type AdminInventoryInvalidateInput = { - reason?: string -} - export type AdminTaskManualDispatchInput = { outcome?: string resultMessage?: string @@ -315,10 +227,3 @@ export type AdminTaskManualDispatchInput = { export type AdminTaskKuaishouCloudDispatchInput = { ticketCode?: string } - -export type AdminManualRedeemCreateInput = { - proofValue?: string - skuCode?: string - skuName?: string - remark?: string -} diff --git a/apps/backend/src/types/admin/write-models.ts b/apps/backend/src/types/admin/write-models.ts new file mode 100644 index 00000000..ed51e6b8 --- /dev/null +++ b/apps/backend/src/types/admin/write-models.ts @@ -0,0 +1,23 @@ +export type AdminTaskActionPayload = { + taskId: number + taskNo: string + status: string + deliveryStatus: string + resultCode: string + resultMessage: string + primaryClaimTokenId: number | null + lastError: string + updatedAt: string +} + +export type AdminTaskActionResponse = { + task: AdminTaskActionPayload + claimUrl?: string + token?: string +} + +export type AdminManualDispatchOutcome = 'delivered' | 'failed' + +export type AdminTaskManualDispatchResponse = AdminTaskActionResponse & { + outcome: AdminManualDispatchOutcome +} diff --git a/apps/backend/src/routes/admin/platform-config/shared.ts b/apps/backend/src/types/json.ts similarity index 100% rename from apps/backend/src/routes/admin/platform-config/shared.ts rename to apps/backend/src/types/json.ts diff --git a/apps/backend/src/types/repository-rows.ts b/apps/backend/src/types/repository-rows.ts deleted file mode 100644 index 9b8ab5ea..00000000 --- a/apps/backend/src/types/repository-rows.ts +++ /dev/null @@ -1,227 +0,0 @@ -export type OrderRow = { - id: number - provider: string - platform: string - shop_id: string - shop_name: string - platform_order_id: string - order_status: string - pay_status: string - buyer_id: string - buyer_name: string - receiver_contact: string - total_amount: number | string - currency: string - raw_payload_json: string | Record - paid_at: string | null - created_at: string - updated_at: string -} - -export type OrderListRow = OrderRow & { - task_count?: number -} - -export type OrderItemRow = { - id: number - order_id: number - sku_code: string - sku_name: string - quantity: number - spec_json: string | Record - item_snapshot_json?: string | Record - created_at?: string - updated_at?: string -} - -export type TaskRow = { - id: number - order_id: number - order_item_id: number - platform_order_id: string - profile_id: number - task_no: string - executor_key: string - task_status: string - delivery_status: string - result_code: string - result_message: string - inventory_status: string - automation_mode: string - requires_claim: boolean - user_action_status: string - attempt_count: number - browser_session_id: string - login_type: string - nickname: string - role_name: string - role_id: string - area: string - partition_name: string - claim_token: string - claim_expires_at?: string | null - primary_claim_token: string - primary_claim_token_id: number | null - primary_claim_token_status: string - primary_claim_expires_at?: string | null - primary_inventory_item_id: number | null - primary_inventory_binding_status: string - inventory_display_value: string - primary_inventory_display_value: string - inventory_credential_type?: string - primary_inventory_credential_type?: string - artifacts_json: string | Record - context_json: string | Record - screenshot_path: string - last_error: string - retry_count: number - created_at: string - updated_at: string - claimed_at: string | null - role_confirmed_at: string | null - redeemed_at: string | null - sku_code?: string - sku_name?: string -} - -export type InventoryItemRow = { - id: number - sku_code: string - batch_no: string - credential_type: string - inventory_group_code: string - display_value: string - status: string - invalid_reason: string - delivered_at: string | null - created_at: string - updated_at: string - reserved_by_task_id: number | null - reserved_by_task_no: string - platform_order_id: string -} - -export type InventorySkuSuggestionRow = { - sku_code: string - credential_type: string - inventory_group_code: string - total_count: number - available_count: number - latest_updated_at: string | null -} - -export type WebhookEventRow = { - id: number - provider: string - platform: string - shop_id: string - shop_name: string - event_type: string - event_key: string - signature_valid: boolean - headers_json: string | Record - query_json: string | Record - body_json: string | Record - processed: boolean - process_error: string - related_order_id: number | null - created_at: string -} - -export type MessageDeliveryRow = { - id: number - provider: string - platform: string - shop_id: string - shop_name: string - channel: string - order_id: number | null - task_id: number | null - platform_order_id: string - recipient_key: string - message_content: string - claim_url: string - status: string - request_url: string - request_headers_json: string | Record - request_body_json: string | Record - response_status: number - response_json: string | Record - error_message: string - sent_at: string | null - created_at: string - updated_at: string - task_no?: string - task_status?: string -} - -export type TaskInventoryBindingRow = { - id: number - task_id: number - inventory_item_id: number - role_key: string - quantity: number - binding_status: string - metadata_json: string | Record - invalid_reason: string - consumed_at: string | null - released_at: string | null - created_at: string - updated_at: string - sku_code: string - batch_no: string - credential_type: string - inventory_group_code: string - display_value: string - inventory_status: string -} - -export type TaskInventoryBindingSummaryRow = { - task_id: number - total_binding_count: number - reserved_binding_count: number - consumed_binding_count: number - released_binding_count: number - role_keys: string[] -} - -export type ClaimTokenRow = { - id: number - token: string - status: string - expired_at: string - created_at?: string - updated_at?: string -} - -export type TaskEventRow = { - id: number - event_type: string - payload_json: string | Record - created_at: string -} - -export type OrderListQueryResult = { - items: OrderListRow[] - total: number -} - -export type TaskListQueryResult = { - items: TaskRow[] - total: number -} - -export type InventoryListQueryResult = { - items: InventoryItemRow[] - total: number -} - -export type WebhookEventListQueryResult = { - items: WebhookEventRow[] - total: number -} - -export type MessageDeliveryListQueryResult = { - items: MessageDeliveryRow[] - total: number -} diff --git a/apps/backend/src/types/repository-inputs.ts b/apps/backend/src/types/repository/inputs.ts similarity index 62% rename from apps/backend/src/types/repository-inputs.ts rename to apps/backend/src/types/repository/inputs.ts index 0cec63d6..f8c3bc34 100644 --- a/apps/backend/src/types/repository-inputs.ts +++ b/apps/backend/src/types/repository/inputs.ts @@ -57,8 +57,8 @@ export type TaskListQueryInput = { dateTo?: string } -export type TaskTencentContextPatch = { - browserSessionId?: string +export type TaskRuntimeContextPatch = { + runtimeSessionId?: string loginType?: string nickname?: string roleId?: string @@ -83,7 +83,6 @@ export type TaskCreateInput = { profileId: number | string executorKey: string taskStatus?: string - inventoryStatus?: string deliveryStatus?: string resultCode?: string resultMessage?: string @@ -97,12 +96,11 @@ export type TaskCreateInput = { contextJson?: string | Record createdAt: string updatedAt: string - tencentContext?: TaskTencentContextPatch + runtimeContext?: TaskRuntimeContextPatch } export type TaskUpdatePatch = { task_status?: string - inventory_status?: string delivery_status?: string result_code?: string result_message?: string @@ -118,7 +116,7 @@ export type TaskUpdatePatch = { role_confirmed_at?: string | null redeemed_at?: string | null updated_at?: string - browser_session_id?: string + runtime_session_id?: string login_type?: string nickname?: string role_id?: string @@ -129,69 +127,3 @@ export type TaskUpdatePatch = { artifacts_json?: string | Record state_json?: string | Record } - -export type InventoryListQueryInput = { - page?: number - pageSize?: number - skuCode?: string - credentialType?: string - status?: string - batchNo?: string - inventoryGroupCode?: string - allowedInventoryGroupCodes?: string[] | null -} - -export type InventorySkuSuggestionQueryInput = { - credentialType?: string - keyword?: string - limit?: number - inventoryGroupCode?: string - allowedInventoryGroupCodes?: string[] | null -} - -export type InventoryCreateItemInput = { - batchNo?: string - skuCode: string - credentialType?: string - inventoryGroupCode?: string - displayValue: string - payload?: Record - createdAt: string - updatedAt: string -} - -export type WebhookEventListQueryInput = { - page?: number - pageSize?: number - provider?: string - platform?: string - platformOrderId?: string - processed?: string - visibility?: string - relatedOrderId?: string - dateFrom?: string - dateTo?: string -} - -export type WebhookEventCreateInput = { - provider: string - platform: string - shopId?: string - shopName?: string - eventType: string - eventKey: string - signatureValid?: boolean - headersJson?: string | Record - queryJson?: string | Record - bodyJson?: string | Record - processed?: boolean - processError?: string - relatedOrderId?: number | null - createdAt: string -} - -export type WebhookEventUpdatePatch = { - processed?: boolean - process_error?: string - related_order_id?: number | null -} diff --git a/apps/backend/src/types/repository/rows.ts b/apps/backend/src/types/repository/rows.ts new file mode 100644 index 00000000..9697c661 --- /dev/null +++ b/apps/backend/src/types/repository/rows.ts @@ -0,0 +1,114 @@ +export type OrderRow = { + id: number + provider: string + platform: string + shop_id: string + shop_name: string + platform_order_id: string + order_status: string + pay_status: string + buyer_id: string + buyer_name: string + receiver_contact: string + total_amount: number | string + currency: string + raw_payload_json: string | Record + paid_at: string | null + created_at: string + updated_at: string +} + +export type OrderListRow = OrderRow & { + task_count?: number +} + +export type OrderItemRow = { + id: number + order_id: number + sku_code: string + sku_name: string + quantity: number + spec_json: string | Record + item_snapshot_json?: string | Record + created_at?: string + updated_at?: string +} + +export type TaskRow = { + id: number + order_id: number + order_item_id: number + platform_order_id: string + profile_id: number + task_no: string + executor_key: string + task_status: string + delivery_status: string + result_code: string + result_message: string + automation_mode: string + requires_claim: boolean + user_action_status: string + attempt_count: number + runtime_session_id: string + login_type: string + nickname: string + role_name: string + role_id: string + area: string + partition_name: string + claim_token: string + claim_expires_at?: string | null + primary_claim_token: string + primary_claim_token_id: number | null + primary_claim_token_status: string + primary_claim_expires_at?: string | null + artifacts_json: string | Record + context_json: string | Record + screenshot_path: string + last_error: string + retry_count: number + created_at: string + updated_at: string + kuaishou_ticket_status?: string + kuaishou_ticket_code_masked?: string + kuaishou_bind_url?: string + kuaishou_vn_phone?: string + kuaishou_role_name?: string + kuaishou_role_id?: string + kuaishou_dispatch_status?: string + kuaishou_consume_status?: string + kuaishou_source_key?: string + kuaishou_state_updated_at?: string | null + claimed_at: string | null + role_confirmed_at: string | null + redeemed_at: string | null + sku_code?: string + sku_name?: string +} + +export type ClaimTokenRow = { + id: number + token: string + status: string + expired_at: string + created_at?: string + updated_at?: string +} + +export type TaskEventRow = { + id: number + event_type: string + payload_json: string | Record + created_at: string +} + +export type OrderListQueryResult = { + items: OrderListRow[] + total: number +} + +export type TaskListQueryResult = { + items: TaskRow[] + total: number +} diff --git a/apps/backend/src/types/runtime-config.ts b/apps/backend/src/types/runtime-config.ts index d14a32c4..f1b96c2e 100644 --- a/apps/backend/src/types/runtime-config.ts +++ b/apps/backend/src/types/runtime-config.ts @@ -5,46 +5,16 @@ export type AdminDefaultUser = { status?: string; }; -export type AgisoMessagingShopConfig = { - enabled?: boolean; - shopName?: string; - accessToken?: string; - messageTemplate?: string; - autoDeliveryMessageTemplate?: string; - appSecret?: string; - apiVersion?: string; - sendMessageEndpoint?: string; -}; - -export type AgisoMessagingShopsConfig = Record< - string, - AgisoMessagingShopConfig ->; - export type RuntimeConfig = { server: { port: number; }; - browser: { - chromePath: string; - headless: boolean | null; - devtools: boolean | null; - keepAlive: boolean | null; - prewarm: boolean | null; - slowMoMs: number | null; - vncResolution: string; - }; - session: { - debug: boolean; - }; - ocr: { - baseUrl: string; - }; data: { root: string; }; logging: { level: string; + retentionDays: number; }; database: { url: string; @@ -61,27 +31,6 @@ export type RuntimeConfig = { defaultUsers: AdminDefaultUser[]; }; platforms: { - agiso: { - appSecret: string; - tradeDetail: { - endpoint: string; - apiVersion: string; - timeoutMs: number; - }; - autoDelivery: { - enabled: boolean; - endpoint: string; - apiVersion: string; - }; - messaging: { - enabled: boolean; - apiVersion: string; - sendMessageEndpoint: string; - messageTemplate: string; - autoDeliveryMessageTemplate: string; - shops: AgisoMessagingShopsConfig; - }; - }; ninetyone: { userId: string; secret: string; @@ -132,7 +81,4 @@ export type RuntimeConfig = { cors: { allowedOrigins: string[]; }; - redeem: { - proofMode: string; - }; }; diff --git a/apps/backend/src/utils/http.ts b/apps/backend/src/utils/http.ts index 325dec22..7e1c0569 100644 --- a/apps/backend/src/utils/http.ts +++ b/apps/backend/src/utils/http.ts @@ -29,21 +29,24 @@ export function buildSuccessPayload(data: unknown, msg: string = "ok") { export function buildErrorPayload(error: unknown, fallbackMessage: string) { const classification = classifyRouteError(error); + const message = classification.statusCode >= 500 + ? fallbackMessage || "服务内部错误" + : error instanceof Error ? error.message : fallbackMessage; return { code: 1, - msg: error instanceof Error ? error.message : fallbackMessage, + msg: message, errorCode: classification.errorCode, time: Math.floor(Date.now() / 1000), - data: null, + data: null as null, }; } export function sendRouteError( res: Response, error: unknown, - fallbackMessage: string, - scope: string + fallbackMessage = "服务内部错误", + scope = "[route]" ): void { const classification = classifyRouteError(error); const logger = classification.statusCode >= 500 ? logError : logWarn; @@ -62,7 +65,7 @@ export function buildNotFoundPayload(req: Request) { code: 1, msg: `未实现接口: ${req.method} ${req.originalUrl}`, time: Math.floor(Date.now() / 1000), - data: null, + data: null as null, }; } @@ -125,18 +128,6 @@ function classifyRouteError(error: unknown): { return { statusCode: 409, errorCode: "conflict" }; } - if ( - message.includes("OCR") || - message.includes("uv sync") || - message.includes("spawn uv ENOENT") || - message.includes("Executable doesn't exist") || - message.includes( - "please run the following command to download new browsers" - ) - ) { - return { statusCode: 503, errorCode: "dependency_unavailable" }; - } - return { statusCode: 500, errorCode: "internal_error" }; } diff --git a/apps/backend/src/utils/logger.test.ts b/apps/backend/src/utils/logger.test.ts index a9a07d3a..0280c63a 100644 --- a/apps/backend/src/utils/logger.test.ts +++ b/apps/backend/src/utils/logger.test.ts @@ -27,27 +27,42 @@ test('shouldWriteLog respects configured minimum log level', () => { }) test('resolveLogFilePath uses daily log file names by channel', () => { - assert.match(resolveLogFilePath('app', '2026-04-14T10:00:00.000Z'), /app-2026-04-14\.log$/) - assert.match(resolveLogFilePath('webhook', '2026-04-14T10:00:00.000Z'), /webhook-2026-04-14\.log$/) + assert.match(resolveLogFilePath('app', '2026-05-28T00:00:19.194+08:00'), /app-2026-05-28\.log$/) + assert.match(resolveLogFilePath('integration', '2026-05-28T00:00:19.194+08:00'), /integration-2026-05-28\.log$/) }) -test('resolveExpiredLogFilenames keeps latest seven days and ignores unknown files', () => { +test('resolveLogFilePath uses local date for utc timestamps', () => { + assert.match(resolveLogFilePath('app', '2026-05-27T16:00:19.194Z'), /app-2026-05-28\.log$/) +}) + +test('resolveExpiredLogFilenames keeps latest thirty days by default and ignores unknown files', () => { + const expired = resolveExpiredLogFilenames([ + 'app-2026-05-30.log', + 'app-2026-05-01.log', + 'app-2026-04-30.log', + 'integration-2026-04-29.log', + 'app.log', + 'integration.log', + 'random.txt', + ], '2026-05-30') + + assert.deepEqual(expired, [ + 'app-2026-04-30.log', + 'integration-2026-04-29.log', + 'app.log', + 'integration.log', + ]) +}) + +test('resolveExpiredLogFilenames supports explicit retention days', () => { const expired = resolveExpiredLogFilenames([ 'app-2026-04-14.log', - 'app-2026-04-13.log', 'app-2026-04-08.log', 'app-2026-04-07.log', - 'webhook-2026-04-06.log', - 'app.log', - 'webhook.log', - 'random.txt', - ], '2026-04-14T12:00:00.000Z', 7) + ], '2026-04-14', 7) assert.deepEqual(expired, [ 'app-2026-04-07.log', - 'webhook-2026-04-06.log', - 'app.log', - 'webhook.log', ]) }) @@ -102,3 +117,27 @@ test('formatLogEntry prints nested detail blocks without ansi colors in file mod assert.match(text, /未登录或登录已失效/) assert.doesNotMatch(text, /\u001B\[/) }) + +test('formatLogEntry masks sensitive fields in inline and nested details', () => { + const text = formatLogEntry({ + time: '2026-04-14T10:36:24.974Z', + level: 'info', + scope: '[security]', + message: 'masked', + pid: 4671, + detail: { + token: 'abcdef1234567890', + authorization: 'Bearer abcdef1234567890', + nested: { + cookie: 'sessionid=abcdef1234567890', + note: 'password=super-secret-value', + }, + }, + }, { color: false }) + + assert.doesNotMatch(text, /abcdef1234567890/) + assert.doesNotMatch(text, /super-secret-value/) + assert.match(text, /abcdef\*\*\*\*567890/) + assert.match(text, /Bearer\*\*\*\*567890/) + assert.match(text, /password=super-\*\*\*\*-value/) +}) diff --git a/apps/backend/src/utils/logger.ts b/apps/backend/src/utils/logger.ts index 566663c9..f85f9a3a 100644 --- a/apps/backend/src/utils/logger.ts +++ b/apps/backend/src/utils/logger.ts @@ -5,15 +5,29 @@ import util from 'node:util' import { PROJECT_ROOT, runtimeConfig } from '../config/runtime.js' import type { HttpErrorLike } from './http.js' +import { maskSecret } from './masking.js' -const LOG_LEVEL_PRIORITY = { +type LogLevel = 'debug' | 'info' | 'warn' | 'error' +type LogChannel = 'app' | 'integration' +type LogDetail = unknown +type InlineFields = Record +type LogEntry = { + time: string + level: LogLevel + scope: string + message: string + pid: number + detail?: unknown +} + +const LOG_LEVEL_PRIORITY: Record = { debug: 10, info: 20, warn: 30, error: 40, } -const LEVEL_LABELS = { +const LEVEL_LABELS: Record = { debug: 'DEBUG', info: 'INFO ', warn: 'WARN ', @@ -34,37 +48,51 @@ const ANSI = { const DATA_ROOT = resolveDataRoot() const LOG_DIR = path.join(DATA_ROOT, 'logs') const ACTIVE_LOG_LEVEL = normalizeLogLevel(runtimeConfig.logging?.level) -const LOG_RETENTION_DAYS = 7 -const LEGACY_LOG_FILES = new Set(['app.log', 'webhook.log']) +const DEFAULT_LOG_RETENTION_DAYS = 30 +const LOG_RETENTION_DAYS = normalizeLogRetentionDays(runtimeConfig.logging?.retentionDays) +const LEGACY_LOG_FILES = new Set(['app.log', 'integration.log']) +const SENSITIVE_KEY_PATTERN = /token|secret|password|passwd|pwd|cookie|authorization|credential|card|cards|cardno|cardpwd|apikey|api_key|devicekey|session/i +const MAX_SANITIZE_DEPTH = 8 let writeQueue = Promise.resolve() let lastCleanupDateKey = '' -export function createRequestId(prefix = 'req') { +export function createRequestId(prefix = 'req'): string { return `${prefix}-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}` } -export function logDebug(scope, message, detail = undefined) { +export function logDebug(scope: unknown, message: unknown, detail: LogDetail = undefined) { return writeLog('debug', scope, message, detail) } -export function logInfo(scope, message, detail = undefined) { +export function logInfo(scope: unknown, message: unknown, detail: LogDetail = undefined) { return writeLog('info', scope, message, detail) } -export function logWarn(scope, message, detail = undefined) { +export function logWarn(scope: unknown, message: unknown, detail: LogDetail = undefined) { return writeLog('warn', scope, message, detail) } -export function logError(scope, message, detail = undefined) { +export function logError(scope: unknown, message: unknown, detail: LogDetail = undefined) { return writeLog('error', scope, message, detail) } -export function logWebhook(scope, message, detail = undefined, { level = 'info' } = {}) { - return writeLog(level, scope, message, detail, { channel: 'webhook' }) +export function logIntegration( + scope: unknown, + message: unknown, + detail: LogDetail = undefined, + { level = 'info' }: { level?: LogLevel } = {}, +) { + return writeLog(level, scope, message, detail, { channel: 'integration' }) } -function writeLog(level, scope, message, detail, { channel = 'app' } = {}) { +function writeLog( + level: LogLevel, + scope: unknown, + message: unknown, + detail: LogDetail, + { channel = 'app' }: { channel?: LogChannel } = {}, +): LogEntry | null { if (!shouldWriteLog(level, ACTIVE_LOG_LEVEL)) { return null } @@ -83,7 +111,7 @@ function writeLog(level, scope, message, detail, { channel = 'app' } = {}) { return entry } -function enqueueFileWrite(entry, filePath) { +function enqueueFileWrite(entry: LogEntry, filePath: string): void { const line = formatLogEntry(entry, { color: false }) const dateKey = extractLogDateKey(entry.time) @@ -99,12 +127,12 @@ function enqueueFileWrite(entry, filePath) { }) } -function writeConsole(entry) { +function writeConsole(entry: LogEntry): void { const logger = resolveConsoleMethod(entry.level) logger(formatLogEntry(entry, { color: supportsAnsiColor() })) } -function resolveConsoleMethod(level) { +function resolveConsoleMethod(level: unknown) { const normalized = normalizeLogLevel(level) if (normalized === 'error') { @@ -118,26 +146,26 @@ function resolveConsoleMethod(level) { return console.log } -export function normalizeLogLevel(level) { +export function normalizeLogLevel(level: unknown): LogLevel { const normalized = String(level || '').trim().toLowerCase() - return LOG_LEVEL_PRIORITY[normalized] != null ? normalized : 'info' + return isLogLevel(normalized) ? normalized : 'info' } -export function shouldWriteLog(level, configuredLevel = ACTIVE_LOG_LEVEL) { +export function shouldWriteLog(level: unknown, configuredLevel: unknown = ACTIVE_LOG_LEVEL): boolean { const normalizedLevel = normalizeLogLevel(level) const normalizedConfigured = normalizeLogLevel(configuredLevel) return LOG_LEVEL_PRIORITY[normalizedLevel] >= LOG_LEVEL_PRIORITY[normalizedConfigured] } -export function formatLogEntry(entry, { color = false } = {}) { +export function formatLogEntry(entry: Partial, { color = false }: { color?: boolean } = {}): string { const normalizedEntry = { time: String(entry?.time || new Date().toISOString()), level: normalizeLogLevel(entry?.level), scope: String(entry?.scope || 'app').trim() || 'app', message: String(entry?.message || '').trim() || '-', pid: Number(entry?.pid || process.pid), - detail: entry?.detail, + detail: sanitizeLogValue(entry?.detail), } const timestamp = colorize(formatLogTimestamp(normalizedEntry.time), ANSI.gray, color) const level = colorize(LEVEL_LABELS[normalizedEntry.level], resolveLevelColor(normalizedEntry.level), color) @@ -166,25 +194,25 @@ export function formatLogEntry(entry, { color = false } = {}) { return `${firstLine}\n${indentBlock(inspected, ' ')}` } -export function resolveLogFilePath(channel = 'app', time = new Date().toISOString()) { - const normalizedChannel = String(channel || '').trim() === 'webhook' ? 'webhook' : 'app' +export function resolveLogFilePath(channel = 'app', time = new Date().toISOString()): string { + const normalizedChannel = String(channel || '').trim() === 'integration' ? 'integration' : 'app' return path.join(LOG_DIR, `${normalizedChannel}-${extractLogDateKey(time)}.log`) } export function resolveExpiredLogFilenames( - fileNames = [], + fileNames: string[] = [], referenceTime = new Date().toISOString(), retentionDays = LOG_RETENTION_DAYS, -) { - const normalizedRetentionDays = Math.max(1, Number(retentionDays) || LOG_RETENTION_DAYS) +): string[] { + const normalizedRetentionDays = normalizeLogRetentionDays(retentionDays) const cutoffDateKey = toDateKey(offsetDate(referenceTime, -(normalizedRetentionDays - 1))) return (Array.isArray(fileNames) ? fileNames : []) .filter((fileName) => shouldDeleteLogFileByDate(fileName, cutoffDateKey)) } -export function formatLogTimestamp(value) { - const date = new Date(value) +export function formatLogTimestamp(value: unknown): string { + const date = new Date(value instanceof Date ? value : String(value || '')) if (Number.isNaN(date.getTime())) { return String(value || '').trim() || new Date().toISOString() @@ -193,27 +221,28 @@ export function formatLogTimestamp(value) { return `${formatLocalDate(date)} ${formatLocalTime(date)}.${String(date.getMilliseconds()).padStart(3, '0')} ${formatTimezoneOffset(date)}` } -function resolveDataRoot() { +function resolveDataRoot(): string { const configured = String(runtimeConfig.data?.root || '').trim() return configured ? path.resolve(configured) : path.resolve(PROJECT_ROOT, 'data') } -function normalizeLogValue(value) { +function normalizeLogValue(value: unknown): unknown { if (typeof value === 'undefined') { return undefined } try { - return JSON.parse( + return sanitizeLogValue(JSON.parse( JSON.stringify(value, (_key, current) => { if (current instanceof Error) { const currentError = current as Error & HttpErrorLike return { name: current.name, - message: current.message, - stack: current.stack, + message: sanitizeLogString(current.message), + stack: sanitizeLogString(current.stack), statusCode: currentError.statusCode, errorCode: currentError.errorCode, + context: sanitizeLogValue(currentError.context), } } @@ -223,13 +252,71 @@ function normalizeLogValue(value) { return current }), - ) + )) } catch { - return String(value) + return sanitizeLogString(String(value)) } } -function splitDetailPayload(detail) { +function sanitizeLogValue(value: unknown, key = '', depth = 0): unknown { + if (typeof value === 'undefined') { + return undefined + } + + if (value === null) { + return null + } + + if (isSensitiveLogKey(key)) { + return maskSecret(value) + } + + if (typeof value === 'string') { + return sanitizeLogString(value) + } + + if (typeof value === 'number' || typeof value === 'boolean') { + return value + } + + if (typeof value === 'bigint') { + return String(value) + } + + if (depth >= MAX_SANITIZE_DEPTH) { + return '[MaxDepth]' + } + + if (Array.isArray(value)) { + return value.map((item) => sanitizeLogValue(item, key, depth + 1)) + } + + if (value && typeof value === 'object') { + return Object.fromEntries( + Object.entries(value).map(([currentKey, currentValue]) => [ + currentKey, + sanitizeLogValue(currentValue, currentKey, depth + 1), + ]), + ) + } + + return sanitizeLogString(String(value)) +} + +function sanitizeLogString(value: unknown): string { + return String(value || '') + .replace(/(Bearer\s+)([A-Za-z0-9._~+/=-]+)/gi, (_matched, prefix, secret) => `${prefix}${maskSecret(secret)}`) + .replace( + /\b((?:token|secret|password|passwd|pwd|cookie|authorization|api[_-]?key|device[_-]?key)=)([^&\s,;]+)/gi, + (_matched, prefix, secret) => `${prefix}${maskSecret(secret)}`, + ) +} + +function isSensitiveLogKey(key: unknown): boolean { + return SENSITIVE_KEY_PATTERN.test(String(key || '').trim()) +} + +function splitDetailPayload(detail: unknown): { inline: InlineFields, block: unknown | null } { if (typeof detail === 'undefined') { return { inline: {}, @@ -252,8 +339,8 @@ function splitDetailPayload(detail) { } if (detail && typeof detail === 'object') { - const inline = {} - const block = {} + const inline: InlineFields = {} + const block: Record = {} for (const [key, value] of Object.entries(detail)) { if (isScalarLogValue(value)) { @@ -276,14 +363,14 @@ function splitDetailPayload(detail) { } } -function formatInlineFields(fields) { +function formatInlineFields(fields: InlineFields): string { return Object.entries(fields) .filter(([, value]) => typeof value !== 'undefined' && value !== '') .map(([key, value]) => `${key}=${formatInlineValue(value)}`) .join(' ') } -function formatInlineValue(value) { +function formatInlineValue(value: unknown): string { if (value === null) { return 'null' } @@ -296,22 +383,22 @@ function formatInlineValue(value) { return /^[A-Za-z0-9_./:@-]+$/.test(text) ? text : JSON.stringify(text) } -function isScalarLogValue(value) { +function isScalarLogValue(value: unknown): value is string | number | boolean | null { return value === null || ['string', 'number', 'boolean'].includes(typeof value) } -function indentBlock(text, indent) { +function indentBlock(text: unknown, indent: string): string { return String(text || '') .split('\n') .map((line) => `${indent}${line}`) .join('\n') } -function supportsAnsiColor() { +function supportsAnsiColor(): boolean { return process.env.NO_COLOR !== '1' && Boolean(process.stdout.isTTY || process.stderr.isTTY) } -function resolveLevelColor(level) { +function resolveLevelColor(level: unknown): string { switch (normalizeLogLevel(level)) { case 'debug': return ANSI.blue @@ -324,7 +411,7 @@ function resolveLevelColor(level) { } } -function colorize(text, ansiCode, enabled) { +function colorize(text: string, ansiCode: string, enabled: boolean): string { if (!enabled || !ansiCode) { return text } @@ -332,15 +419,15 @@ function colorize(text, ansiCode, enabled) { return `${ansiCode}${text}${ANSI.reset}` } -function formatLocalDate(date) { +function formatLocalDate(date: Date): string { return `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}-${String(date.getDate()).padStart(2, '0')}` } -function formatLocalTime(date) { +function formatLocalTime(date: Date): string { return `${String(date.getHours()).padStart(2, '0')}:${String(date.getMinutes()).padStart(2, '0')}:${String(date.getSeconds()).padStart(2, '0')}` } -function formatTimezoneOffset(date) { +function formatTimezoneOffset(date: Date): string { const totalMinutes = -date.getTimezoneOffset() const sign = totalMinutes >= 0 ? '+' : '-' const absoluteMinutes = Math.abs(totalMinutes) @@ -349,7 +436,7 @@ function formatTimezoneOffset(date) { return `${sign}${hours}:${minutes}` } -async function cleanupExpiredLogsIfNeeded(dateKey) { +async function cleanupExpiredLogsIfNeeded(dateKey: string): Promise { if (!dateKey || dateKey === lastCleanupDateKey) { return } @@ -358,7 +445,7 @@ async function cleanupExpiredLogsIfNeeded(dateKey) { try { const fileNames = await fs.readdir(LOG_DIR) - const expired = resolveExpiredLogFilenames(fileNames, `${dateKey}T00:00:00.000Z`) + const expired = resolveExpiredLogFilenames(fileNames, dateKey, LOG_RETENTION_DAYS) await Promise.all(expired.map((fileName) => fs.rm(path.join(LOG_DIR, fileName), { force: true }))) } catch (error) { const reason = error instanceof Error ? error.message : String(error || '未知错误') @@ -366,37 +453,71 @@ async function cleanupExpiredLogsIfNeeded(dateKey) { } } -function extractLogDateKey(time) { - const normalized = String(time || '').trim() - return /^\d{4}-\d{2}-\d{2}/.test(normalized) ? normalized.slice(0, 10) : toDateKey(normalized) +function extractLogDateKey(time: unknown): string { + return toDateKey(time) } -function toDateKey(value) { - const date = new Date(value) +function toDateKey(value: unknown): string { + const date = parseLogDate(value) if (Number.isNaN(date.getTime())) { - return new Date().toISOString().slice(0, 10) + return formatLocalDate(new Date()) } - return date.toISOString().slice(0, 10) + return formatLocalDate(date) } -function offsetDate(value, offsetDays) { - const date = new Date(value) - date.setUTCDate(date.getUTCDate() + Number(offsetDays || 0)) +function offsetDate(value: unknown, offsetDays: unknown): Date { + const date = parseLogDate(value) + if (Number.isNaN(date.getTime())) { + return new Date() + } + + date.setDate(date.getDate() + Number(offsetDays || 0)) return date } -function shouldDeleteLogFileByDate(fileName, cutoffDateKey) { +function parseLogDate(value: unknown): Date { + if (value instanceof Date) { + return new Date(value.getTime()) + } + + const text = String(value || '').trim() + const matchedDateKey = /^(\d{4})-(\d{2})-(\d{2})$/.exec(text) + if (matchedDateKey) { + return new Date( + Number(matchedDateKey[1]), + Number(matchedDateKey[2]) - 1, + Number(matchedDateKey[3]), + ) + } + + return new Date(text) +} + +function normalizeLogRetentionDays(value: unknown): number { + const parsed = Number(value) + if (!Number.isFinite(parsed)) { + return DEFAULT_LOG_RETENTION_DAYS + } + + return Math.max(1, Math.floor(parsed)) +} + +function shouldDeleteLogFileByDate(fileName: unknown, cutoffDateKey: string): boolean { if (LEGACY_LOG_FILES.has(String(fileName || '').trim())) { return true } - const matched = /^(app|webhook)-(\d{4}-\d{2}-\d{2})\.log$/.exec(String(fileName || '').trim()) + const matched = /^(app|integration)-(\d{4}-\d{2}-\d{2})\.log$/.exec(String(fileName || '').trim()) if (!matched) { return false } - return matched[2] < cutoffDateKey + return String(matched[2] || '') < cutoffDateKey +} + +function isLogLevel(value: string): value is LogLevel { + return ['debug', 'info', 'warn', 'error'].includes(value) } diff --git a/apps/backend/src/utils/time.ts b/apps/backend/src/utils/time.ts index 8e5a9fac..78122d0e 100644 --- a/apps/backend/src/utils/time.ts +++ b/apps/backend/src/utils/time.ts @@ -6,3 +6,18 @@ export function addHours(baseIso: string | null | undefined, hours: number): str const baseTime = baseIso ? new Date(baseIso).getTime() : Date.now() return new Date(baseTime + hours * 60 * 60 * 1000).toISOString() } + +export function normalizeTimestampIso(value: unknown, fallbackIso = nowIso()): string { + if (value instanceof Date) { + const timestamp = value.getTime() + return Number.isFinite(timestamp) ? value.toISOString() : fallbackIso + } + + const normalized = String(value || '').trim() + if (!normalized) { + return fallbackIso + } + + const timestamp = Date.parse(normalized) + return Number.isFinite(timestamp) ? new Date(timestamp).toISOString() : fallbackIso +} diff --git a/apps/backend/tsconfig.json b/apps/backend/tsconfig.json index e8bfad14..4a2c1a75 100644 --- a/apps/backend/tsconfig.json +++ b/apps/backend/tsconfig.json @@ -6,7 +6,9 @@ "allowJs": true, "checkJs": true, "noEmit": true, - "strict": false, + "strict": true, + "noUncheckedIndexedAccess": true, + "exactOptionalPropertyTypes": true, "skipLibCheck": true, "types": ["node"], "lib": ["ES2022"] @@ -19,7 +21,6 @@ "node_modules", "data", "src/**/*.test.js", - "src/**/*.test.ts", - "src/services/platforms/agiso/**/*.js" + "src/**/*.test.ts" ] } diff --git a/apps/frontend/README.md b/apps/frontend/README.md index b9501501..16b0db47 100644 --- a/apps/frontend/README.md +++ b/apps/frontend/README.md @@ -1,64 +1,32 @@ -# 腾讯浏览器兑换前端 +# 快手轻量前端 -这是一个聚焦腾讯浏览器会话兑换流程的 Vue 3 前端,当前只保留单一业务入口,不再承担历史迁移脚手架的职责。 +这是快手自动发货拆分后的 Vue 3 前端,只保留快手领取和运营后台相关入口。 ## 功能范围 -- 提供 `/tx/browser` 单页入口 -- 支持 QQ / 微信扫码登录切换 -- 支持浏览器会话创建、状态轮询、角色确认、兑换提交 -- 支持结果信息展示,以及在后端产物存在时预览截图 - -## 项目原则 - -- 保持页面职责单一,只服务腾讯浏览器兑换链路 -- 与本地后端接口保持一致,优先保证稳定性和可维护性 -- 页面层尽量薄,状态编排、轮询、展示映射分别收口 +- `/claim/:token` 快手 Cloud 领取页 +- `/admin` 运营后台 +- 91 卡券订单补全与任务生成 +- 快手小店核销配置 +- Cloudtentacles 履约配置与调试 +- 内部通知和定时任务配置 ## 本地开发 -安装依赖并启动开发环境: - ```bash -cd /Users/yml/codes/order-site-rewrite npm install npm run dev ``` -构建生产包: +开发环境默认通过 `VITE_API_TARGET` 代理后端: ```bash +VITE_API_TARGET=http://localhost:3000 npm run dev +``` + +## 验证 + +```bash +npm run typecheck npm run build ``` - -开发环境默认代理到本地后端: - -```bash -http://localhost:3000 -``` - -如需切换后端地址,启动前设置: - -```bash -VITE_API_TARGET=http://你的后端地址 npm run dev -``` - -## 结构说明 - -```text -src/ - lib/ HTTP 基础设施 - router/ 路由 - services/ 腾讯接口调用 - types/ 腾讯会话 / 活动 / 兑换类型 - composables/ 页面编排、轮询、展示映射 - components/ 腾讯页面子组件 - views/ 页面壳 - styles/ 全局样式 -``` - -## 当前状态 - -- 主页面已经拆成扫码卡片、兑换面板、结果面板三个组件 -- 会话轮询、错误提示、展示文案已分别从页面模板中抽离 -- 前端当前没有全局 store,所有状态都围绕单页流程局部管理 diff --git a/apps/frontend/public/kuaishou-cloud-guide/4.jpg b/apps/frontend/public/kuaishou-cloud-guide/4.jpg new file mode 100644 index 00000000..bcec284c Binary files /dev/null and b/apps/frontend/public/kuaishou-cloud-guide/4.jpg differ diff --git a/apps/frontend/src/components.d.ts b/apps/frontend/src/components.d.ts index a872235d..10668ffa 100644 --- a/apps/frontend/src/components.d.ts +++ b/apps/frontend/src/components.d.ts @@ -15,7 +15,6 @@ declare module 'vue' { AdminResultCard: typeof import('./components/admin/AdminResultCard.vue')['default'] AdminStatusTag: typeof import('./components/admin/AdminStatusTag.vue')['default'] ElAlert: typeof import('element-plus/es')['ElAlert'] - ElAutocomplete: typeof import('element-plus/es')['ElAutocomplete'] ElButton: typeof import('element-plus/es')['ElButton'] ElCard: typeof import('element-plus/es')['ElCard'] ElCheckbox: typeof import('element-plus/es')['ElCheckbox'] @@ -47,19 +46,14 @@ declare module 'vue' { ElSelect: typeof import('element-plus/es')['ElSelect'] ElSkeleton: typeof import('element-plus/es')['ElSkeleton'] ElSpace: typeof import('element-plus/es')['ElSpace'] - ElStatistic: typeof import('element-plus/es')['ElStatistic'] + ElSubMenu: typeof import('element-plus/es')['ElSubMenu'] ElSwitch: typeof import('element-plus/es')['ElSwitch'] ElTable: typeof import('element-plus/es')['ElTable'] ElTableColumn: typeof import('element-plus/es')['ElTableColumn'] - ElTabPane: typeof import('element-plus/es')['ElTabPane'] - ElTabs: typeof import('element-plus/es')['ElTabs'] ElTag: typeof import('element-plus/es')['ElTag'] ElTooltip: typeof import('element-plus/es')['ElTooltip'] RouterLink: typeof import('vue-router')['RouterLink'] RouterView: typeof import('vue-router')['RouterView'] - TencentAuthCard: typeof import('./components/tencent/TencentAuthCard.vue')['default'] - TencentRedeemPanel: typeof import('./components/tencent/TencentRedeemPanel.vue')['default'] - TencentResultPanel: typeof import('./components/tencent/TencentResultPanel.vue')['default'] } export interface GlobalDirectives { vLoading: typeof import('element-plus/es')['ElLoadingDirective'] diff --git a/apps/frontend/src/components/admin/AdminStatusTag.vue b/apps/frontend/src/components/admin/AdminStatusTag.vue index f985d497..134fb47d 100644 --- a/apps/frontend/src/components/admin/AdminStatusTag.vue +++ b/apps/frontend/src/components/admin/AdminStatusTag.vue @@ -59,13 +59,13 @@ function resolveType(status: string): ElTagType { if ( [ - 'waiting_inventory', 'retry_pending', 'manual_review', 'invalid', 'expired', 'operator', 'pending_binding', + 'pending_config', 'binding_exception', 'not_started', 'consumed', @@ -98,8 +98,7 @@ function resolveLabel(status: string) { refunding: '退款中', closed: '已关闭', pending_payment: '待支付', - waiting_inventory: '待库存', - cdk_reserved: '已预占', + cdk_reserved: '资源已准备', link_generated: '已生成链接', claimed: '已创建会话', role_confirmed: '已确认角色', @@ -109,9 +108,9 @@ function resolveLabel(status: string) { manual_review: '人工处理', expired: '已过期', available: '可用', - reserved: '已预占', + reserved: '资源已准备', delivered: '已发放', - consumed: '已消耗', + consumed: '已使用', invalid: '已作废', active: '生效中', admin: '管理员', @@ -124,20 +123,29 @@ function resolveLabel(status: string) { pending: '待触发', waiting: '等待中', processing: '处理中', - system_bound: '系统已绑定', - pending_binding: '待系统绑定', - pending_binding_prepare: '待准备绑定', + system_bound: '资源已准备', + pending_binding: '待准备资源', + resource_ready: '资源已准备', + pending_prepare: '待准备资源', + pending_config: '待配置', + pending_binding_prepare: '待准备资源', not_started: '未开始', waiting_binding: '待客户绑定', dispatched_pending_return: '待退还号码', completed: '已完成', - waiting_user_claim: '待用户打开链接', + waiting_user_claim: '待客户打开链接', + waiting_customer: '待客户绑定', link_opened: '已打开链接', - binding_confirmed: '已提交绑定', - binding_in_progress: '绑定处理中', - binding_completed: '完整绑定成功', - binding_exception: '绑定异常', - user_binding: '用户绑定中', + binding_confirmed: '已提交', + binding_in_progress: '处理中', + binding_completed: '履约完成', + binding_exception: '履约异常', + customer_confirmed: '已提交', + customer_processing: '处理中', + customer_completed: '履约完成', + customer_exception: '履约异常', + resource_exception: '资源异常', + user_binding: '客户处理中', } if (!props.showRaw) { diff --git a/apps/frontend/src/components/tencent/TencentAuthCard.vue b/apps/frontend/src/components/tencent/TencentAuthCard.vue deleted file mode 100644 index 3d0d106b..00000000 --- a/apps/frontend/src/components/tencent/TencentAuthCard.vue +++ /dev/null @@ -1,481 +0,0 @@ - - - - - diff --git a/apps/frontend/src/components/tencent/TencentRedeemPanel.vue b/apps/frontend/src/components/tencent/TencentRedeemPanel.vue deleted file mode 100644 index 0a2774d0..00000000 --- a/apps/frontend/src/components/tencent/TencentRedeemPanel.vue +++ /dev/null @@ -1,288 +0,0 @@ - - - - - diff --git a/apps/frontend/src/components/tencent/TencentResultPanel.vue b/apps/frontend/src/components/tencent/TencentResultPanel.vue deleted file mode 100644 index 1585aad4..00000000 --- a/apps/frontend/src/components/tencent/TencentResultPanel.vue +++ /dev/null @@ -1,158 +0,0 @@ - - - - - diff --git a/apps/frontend/src/composables/shared/index.ts b/apps/frontend/src/composables/shared/index.ts deleted file mode 100644 index ab934b7f..00000000 --- a/apps/frontend/src/composables/shared/index.ts +++ /dev/null @@ -1,17 +0,0 @@ -export { useSessionPolling } from './useSessionPolling' -export type { UseSessionPollingOptions } from './useSessionPolling' - -export { useSessionQrDisplay } from './useSessionQrDisplay' - -export { - useSessionRoleFacts, - useSessionResultFacts, - buildRedeemBlockedReason, - resolveTaskStatusLabel, - syncLoginTypeFromDetail, -} from './useSessionPresentation' -export type { TaskStatusLabelContext, FactItem, UseSessionPresentationOptions, UseSessionResultFactsOptions } from './useSessionPresentation' - -export { mergeSessionDetail, shouldRefreshQrImage, shouldKeepPolling } from './mergeSessionDetail' - -export { DEFAULT_LOGIN_TYPE, POLL_INTERVAL_MS, POLL_FAILURE_LIMIT, ACTIVE_TASK_STATUSES } from './sessionConstants' diff --git a/apps/frontend/src/composables/shared/mergeSessionDetail.ts b/apps/frontend/src/composables/shared/mergeSessionDetail.ts deleted file mode 100644 index d476a9df..00000000 --- a/apps/frontend/src/composables/shared/mergeSessionDetail.ts +++ /dev/null @@ -1,82 +0,0 @@ -import type { ClaimDetailData } from '@/types/claim' -import type { - TencentBrowserSessionData, - TencentBrowserSessionSummaryData, -} from '@/types/tencent/session' - -import type { ClaimTaskStatus } from '@/types/claim' -import { ACTIVE_TASK_STATUSES } from './sessionConstants' - -/** - * Generic merge of session detail data, preserving QR image and other - * fields that may be absent in summary responses. - * - * The `extraSessionFields` callback lets callers inject domain-specific - * session merge logic (e.g. `review` for admin, `redeem` for claim). - */ -export function mergeSessionDetail( - current: T | null, - next: T, - extraSessionFields?: ( - currentSession: NonNullable, - nextSession: NonNullable, - ) => Partial>, -): T { - if (next.session === null) { - return { - ...next, - session: null, - } as T - } - - if (!current?.session) { - return next - } - - const sessionBase = { - ...current.session, - ...next.session, - qrImageBase64: - typeof next.session.qrImageBase64 === 'string' - ? next.session.qrImageBase64 - : current.session.qrImageBase64, - activityInfo: next.session.activityInfo ?? current.session.activityInfo ?? null, - redeem: next.session.redeem ?? current.session.redeem ?? null, - artifacts: next.session.artifacts || current.session.artifacts, - } as NonNullable - - const extras = extraSessionFields - ? extraSessionFields(current.session as NonNullable, next.session as NonNullable) - : {} - - return { - ...next, - session: { - ...sessionBase, - ...extras, - }, - } as T -} - -export function shouldRefreshQrImage( - current: TencentBrowserSessionData | TencentBrowserSessionSummaryData | null, - next: TencentBrowserSessionData | TencentBrowserSessionSummaryData | null, -): boolean { - if (!current || !next || !next.artifacts?.hasQrImage) { - return false - } - - if (!current.qrImageBase64) { - return true - } - - return Boolean(next.qrUpdatedAt && next.qrUpdatedAt !== current.qrUpdatedAt) -} - -export function shouldKeepPolling(detail: T | null): boolean { - if (!detail?.session?.sessionId) { - return false - } - - return ACTIVE_TASK_STATUSES.has(detail.task.status as ClaimTaskStatus) -} diff --git a/apps/frontend/src/composables/shared/sessionConstants.ts b/apps/frontend/src/composables/shared/sessionConstants.ts deleted file mode 100644 index b328b05a..00000000 --- a/apps/frontend/src/composables/shared/sessionConstants.ts +++ /dev/null @@ -1,7 +0,0 @@ -import type { TencentLoginType } from '@/types/tencent/session' -import type { ClaimTaskStatus } from '@/types/claim' - -export const DEFAULT_LOGIN_TYPE: TencentLoginType = 'qq' -export const POLL_INTERVAL_MS = 2_500 -export const POLL_FAILURE_LIMIT = 3 -export const ACTIVE_TASK_STATUSES = new Set(['claimed', 'role_confirmed', 'redeeming']) diff --git a/apps/frontend/src/composables/shared/useSessionPolling.ts b/apps/frontend/src/composables/shared/useSessionPolling.ts deleted file mode 100644 index ed8ae613..00000000 --- a/apps/frontend/src/composables/shared/useSessionPolling.ts +++ /dev/null @@ -1,93 +0,0 @@ -import { computed, ref, type ComputedRef } from 'vue' - -import { POLL_INTERVAL_MS, POLL_FAILURE_LIMIT } from './sessionConstants' - -export interface UseSessionPollingOptions { - /** Whether there is an active session – used as a guard inside the poll loop. */ - hasSession: ComputedRef - /** Called on each poll tick (typically refreshSessionSummary). */ - onPollTick: () => Promise - /** Optional: determines whether polling should continue. Defaults to checking hasSession. */ - shouldKeepPolling?: ComputedRef - /** Context-specific warning message prefix when polling fails repeatedly. */ - pollWarningPrefix: string -} - -export function useSessionPolling(options: UseSessionPollingOptions) { - const { hasSession, onPollTick, pollWarningPrefix } = options - - const pollWarningMessage = ref('') - - let pollTimer: ReturnType | null = null - let pollToken = 0 - let pollFailureCount = 0 - - const sessionNotice = computed(() => pollWarningMessage.value) - - function startPolling() { - const tokenId = ++pollToken - - const loop = async () => { - if (tokenId !== pollToken || !hasSession.value) { - return - } - - await onPollTick() - - if (tokenId !== pollToken) { - return - } - - const keepPolling = options.shouldKeepPolling - ? options.shouldKeepPolling.value - : hasSession.value - - if (!keepPolling) { - return - } - - pollTimer = setTimeout(() => { - void loop() - }, POLL_INTERVAL_MS) - } - - pollTimer = setTimeout(() => { - void loop() - }, POLL_INTERVAL_MS) - } - - function resetPolling() { - pollToken += 1 - - if (pollTimer) { - clearTimeout(pollTimer) - pollTimer = null - } - } - - function resetPollingWarning() { - pollFailureCount = 0 - pollWarningMessage.value = '' - } - - function handleSilentPollingError(error: unknown) { - console.error(error) - pollFailureCount += 1 - - if (pollFailureCount < POLL_FAILURE_LIMIT) { - return - } - - pollWarningMessage.value = `${pollWarningPrefix},已暂停自动轮询,请手动刷新。` - resetPolling() - } - - return { - pollWarningMessage, - sessionNotice, - startPolling, - resetPolling, - resetPollingWarning, - handleSilentPollingError, - } -} diff --git a/apps/frontend/src/composables/shared/useSessionPresentation.ts b/apps/frontend/src/composables/shared/useSessionPresentation.ts deleted file mode 100644 index 57cdde8e..00000000 --- a/apps/frontend/src/composables/shared/useSessionPresentation.ts +++ /dev/null @@ -1,246 +0,0 @@ -import { computed, type ComputedRef, type Ref } from 'vue' - -import type { ClaimDetailData } from '@/types/claim' -import type { TencentLoginType } from '@/types/tencent/session' - -import { DEFAULT_LOGIN_TYPE } from './sessionConstants' - -type ReadableRef = Pick, 'value'> - -// --------------------------------------------------------------------------- -// syncLoginTypeFromDetail -// --------------------------------------------------------------------------- - -export function syncLoginTypeFromDetail( - detail: T, - fallback: TencentLoginType = DEFAULT_LOGIN_TYPE, -): TencentLoginType { - const nextLoginType = String(detail.session?.loginType || detail.task.loginType || '').trim() - if (nextLoginType === 'wx') { - return 'wx' - } - - if (nextLoginType === 'qq') { - return 'qq' - } - - return fallback -} - -// --------------------------------------------------------------------------- -// resolveTaskStatusLabel -// --------------------------------------------------------------------------- - -export type TaskStatusLabelContext = 'claim' | 'admin' - -/** - * Resolves a human-readable task status label. - * - * The `context` parameter controls wording differences between the - * consumer-facing claim page and the admin manual-redeem page. - */ -export function resolveTaskStatusLabel( - taskStatus: string | undefined, - sessionStatus: string | undefined, - context: TaskStatusLabelContext, -): string { - switch (taskStatus) { - case 'link_generated': - return context === 'admin' ? '待生成二维码' : '等待开始领取' - case 'claimed': - if (sessionStatus === 'scanned') { - return context === 'admin' ? '客户待确认' : '确认中' - } - if (sessionStatus === 'logged_in' || sessionStatus === 'ready_to_redeem') { - return context === 'admin' ? '已登录待复核' : '已登录' - } - return context === 'admin' ? '等待客户登录' : '领取中' - case 'role_confirmed': - return '角色已确认' - case 'redeeming': - return '正在兑换' - case 'redeemed': - return context === 'admin' ? '兑换完成' : '兑换成功' - case 'waiting_inventory': - return '等待库存' - case 'retry_pending': - return '等待重试' - case 'manual_review': - return '等待人工处理' - case 'expired': - return '链接已过期' - case 'closed': - return '任务已关闭' - default: - return sessionStatus || taskStatus || '等待中' - } -} - -// --------------------------------------------------------------------------- -// roleFacts / resultFacts helpers -// --------------------------------------------------------------------------- - -export interface FactItem { - label: string - value: string - accent?: boolean -} - -export interface UseSessionPresentationOptions { - activityInfo: ComputedRef<{ - nickname?: string - role?: { - roleId?: string - roleName?: string - area?: string - ready?: boolean - } | null - } | null> - statusLabel: ComputedRef - /** Context-specific default values for roleFacts */ - roleFactDefaults: { - nickname: string - } -} - -export function useSessionRoleFacts(options: UseSessionPresentationOptions) { - const { activityInfo, roleFactDefaults } = options - - const roleFacts = computed(() => [ - { - label: '登录昵称', - value: activityInfo.value?.nickname || roleFactDefaults.nickname, - }, - { - label: '当前角色', - value: activityInfo.value?.role?.roleName || '后端浏览器同步中', - accent: true, - }, - { - label: '角色 ID', - value: activityInfo.value?.role?.roleId || '未识别', - }, - { - label: '所在大区', - value: activityInfo.value?.role?.area || '未识别', - }, - ]) - - return { roleFacts } -} - -export interface UseSessionResultFactsOptions { - order: ComputedRef<{ platformOrderId?: string } | null> - orderItem: ComputedRef<{ skuName?: string } | null> - result: ComputedRef<{ resultCode?: string; resultMessage?: string } | null> - /** Admin context has manualRequest.proofValue as an alternative first label */ - resultFactOverrides?: { - firstLabel?: string - firstValue?: ComputedRef - } -} - -export function useSessionResultFacts(options: UseSessionResultFactsOptions) { - const { order, orderItem, result, resultFactOverrides } = options - - const resultFacts = computed(() => [ - { - label: resultFactOverrides?.firstLabel || '订单号', - value: resultFactOverrides?.firstValue?.value || order.value?.platformOrderId || '-', - }, - { - label: '商品', - value: orderItem.value?.skuName || '-', - }, - { - label: '业务返回码', - value: result.value?.resultCode || '-', - }, - { - label: '业务消息', - value: result.value?.resultMessage || '尚未兑换', - }, - ]) - - return { resultFacts } -} - -// --------------------------------------------------------------------------- -// redeemBlockedReason — builds the "why can't I redeem" explanation -// --------------------------------------------------------------------------- - -export function buildRedeemBlockedReason(options: { - detail: ReadableRef - hasSession: ReadableRef - loginTypeLabel: ReadableRef - redeemLoading: ReadableRef - roleReady: ReadableRef - roleConfirmed: ReadableRef - sessionNotice: ReadableRef - context: 'claim' | 'admin' - /** Claim-specific fields */ - tokenStatus?: ReadableRef - requiresSupportReview?: ReadableRef -}): string { - const { - detail, - hasSession, - loginTypeLabel, - redeemLoading, - roleReady, - roleConfirmed, - sessionNotice, - context, - tokenStatus, - requiresSupportReview, - } = options - - if (context === 'claim') { - if (!detail.value) { - return '正在加载领取信息' - } - if (tokenStatus && tokenStatus.value !== 'active') { - return '当前领取链接不可用' - } - } else { - // admin context - if (!detail.value) { - return '请先创建人工兑换任务并预占库存' - } - } - - if (!hasSession.value) { - return context === 'claim' - ? `请先选择${loginTypeLabel.value}并初始化登录会话` - : `请先生成${loginTypeLabel.value}二维码并等待客户登录` - } - - // Claim-specific: support review flow - if (context === 'claim' && requiresSupportReview?.value) { - if (!roleReady.value) { - return '扫码成功后,系统会自动同步登录信息,准备发给客服复核' - } - return '当前商品需要客服复核角色并代你发起兑换,请联系人工继续' - } - - if (redeemLoading.value) { - return '兑换任务正在执行中' - } - - if (!roleReady.value) { - return ( - sessionNotice.value || - (context === 'claim' - ? '扫码成功后,后端正在同步角色和大区信息' - : '客户登录后,系统会自动同步角色和大区信息') - ) - } - - if (!roleConfirmed.value) { - return context === 'claim' - ? '请先确认当前角色与大区无误,再开始兑换' - : '请先让客户确认角色截图,再点击确认当前角色' - } - - return '' -} diff --git a/apps/frontend/src/composables/shared/useSessionQrDisplay.ts b/apps/frontend/src/composables/shared/useSessionQrDisplay.ts deleted file mode 100644 index 802d805e..00000000 --- a/apps/frontend/src/composables/shared/useSessionQrDisplay.ts +++ /dev/null @@ -1,63 +0,0 @@ -import { computed, ref, watch, type ComputedRef } from 'vue' - -/** - * Shared QR code display logic: renders the base64 QR image, tracks its - * natural width after load, and derives display/preview widths. - */ -export function useSessionQrDisplay(options: { - session: ComputedRef<{ qrImageBase64?: string } | null> -}) { - const { session } = options - - const qrImageNaturalWidth = ref(0) - - const qrImage = computed(() => - session.value?.qrImageBase64 ? `data:image/png;base64,${session.value.qrImageBase64}` : '', - ) - - watch(qrImage, () => { - qrImageNaturalWidth.value = 0 - }) - - function handleQrImageLoad(event: Event) { - const target = event.target - - if (!(target instanceof HTMLImageElement)) { - return - } - - qrImageNaturalWidth.value = target.naturalWidth || 0 - } - - const qrDisplayWidth = computed(() => { - const naturalWidth = qrImageNaturalWidth.value - - if (!naturalWidth) { - return 220 - } - - if (naturalWidth < 160) { - return Math.min(naturalWidth * 2, 220) - } - - return Math.min(naturalWidth, 240) - }) - - const qrFigureStyle = computed(() => ({ - width: `${qrDisplayWidth.value}px`, - maxWidth: '100%', - })) - - const qrPreviewWidth = computed( - () => `${Math.min(Math.max(qrDisplayWidth.value + 120, 360), 480)}px`, - ) - - return { - qrImage, - qrImageNaturalWidth, - qrDisplayWidth, - qrFigureStyle, - qrPreviewWidth, - handleQrImageLoad, - } -} diff --git a/apps/frontend/src/composables/tencent/session-errors.ts b/apps/frontend/src/composables/tencent/session-errors.ts deleted file mode 100644 index 6bc31eb6..00000000 --- a/apps/frontend/src/composables/tencent/session-errors.ts +++ /dev/null @@ -1,48 +0,0 @@ -import { showError } from '@/lib/feedback' - -export type TencentActionError = Error & { - errorCode?: string - status?: number -} - -export function notifyTencentActionError(error: unknown, fallbackMessage: string) { - const message = resolveTencentActionMessage(error, fallbackMessage) - console.error(error) - showError(message) -} - -export function resolveTencentActionMessage(error: unknown, fallbackMessage: string) { - const normalizedError = error as TencentActionError - const errorCode = String(normalizedError?.errorCode || '').trim() - const message = normalizedError instanceof Error ? normalizedError.message.trim() : '' - - if (errorCode === 'missing_redeem_code') { - return '请输入兑换码' - } - - if (errorCode === 'session_not_ready') { - return '当前会话还不能兑换,请先完成扫码并确认角色信息' - } - - if (errorCode === 'session_not_found' || errorCode === 'not_found') { - return '当前会话不存在或已过期,请重新生成二维码' - } - - if (errorCode === 'session_closed' || errorCode === 'gone') { - return '当前会话已关闭,请重新生成二维码' - } - - if (errorCode === 'dependency_unavailable') { - return '后端依赖暂不可用,请检查浏览器或 OCR 服务是否已就绪' - } - - if (errorCode === 'invalid_request') { - return '请求参数不完整,请检查输入后重试' - } - - if (errorCode === 'conflict') { - return '当前会话状态已变化,请刷新后重试' - } - - return message || fallbackMessage -} diff --git a/apps/frontend/src/composables/tencent/useTencentBrowserSessionPolling.ts b/apps/frontend/src/composables/tencent/useTencentBrowserSessionPolling.ts deleted file mode 100644 index 4064734e..00000000 --- a/apps/frontend/src/composables/tencent/useTencentBrowserSessionPolling.ts +++ /dev/null @@ -1,193 +0,0 @@ -import { computed, ref, type Ref } from 'vue' - -import { - fetchTencentBrowserSession, - fetchTencentBrowserSessionSummary, -} from '@/services/tencent/session' -import type { - TencentBrowserSessionData, - TencentBrowserSessionStatus, - TencentBrowserSessionSummaryData, -} from '@/types/tencent/session' - -import { resolveTencentActionMessage } from './session-errors' - -const POLL_INTERVAL_MS = 2_500 -const POLL_FAILURE_LIMIT = 3 - -export const ACTIVE_TENCENT_SESSION_STATUSES = new Set([ - 'waiting_scan', - 'scanned', - 'logged_in', - 'ready_to_redeem', - 'redeeming', -]) - -export function useTencentBrowserSessionPolling(options: { - session: Ref - sessionLoading: Ref - notifyActionError: (error: unknown, fallbackMessage: string) => void -}) { - const { session, sessionLoading, notifyActionError } = options - const pollFailureCount = ref(0) - const pollWarningMessage = ref('') - - let pollTimer: ReturnType | null = null - let pollToken = 0 - - const sessionNotice = computed(() => pollWarningMessage.value || session.value?.notice || '') - - async function refreshSession({ silent = false } = {}) { - if (!session.value?.sessionId) { - return false - } - - if (!silent) { - sessionLoading.value = true - resetPollingWarning() - } - - try { - const currentSession = session.value - const response = await fetchTencentBrowserSessionSummary(currentSession.sessionId) - - const nextSession = mergeSessionData(currentSession, response.data) - session.value = nextSession - - if (shouldRefreshQrImage(currentSession, response.data)) { - const fullResponse = await fetchTencentBrowserSession(currentSession.sessionId) - - session.value = mergeSessionData(nextSession, fullResponse.data) - } - - if (silent) { - resetPollingWarning() - } - - return true - } catch (error) { - if (silent) { - handleSilentPollingError(error) - } else { - notifyActionError(error, '获取浏览器会话状态失败') - } - - return false - } finally { - if (!silent) { - sessionLoading.value = false - } - } - } - - function startPolling() { - const token = ++pollToken - - const loop = async () => { - if (token !== pollToken || !session.value?.sessionId) { - return - } - - await refreshSession({ silent: true }) - - if (token !== pollToken || !isActiveTencentSessionStatus(session.value?.status)) { - return - } - - pollTimer = setTimeout(() => { - void loop() - }, POLL_INTERVAL_MS) - } - - pollTimer = setTimeout(() => { - void loop() - }, POLL_INTERVAL_MS) - } - - function resetPolling() { - pollToken += 1 - - if (pollTimer) { - clearTimeout(pollTimer) - pollTimer = null - } - } - - function resetPollingWarning() { - pollFailureCount.value = 0 - pollWarningMessage.value = '' - } - - function restartPollingIfActive(nextSession = session.value) { - if (isActiveTencentSessionStatus(nextSession?.status)) { - startPolling() - } - } - - function handleSilentPollingError(error: unknown) { - console.error(error) - pollFailureCount.value += 1 - - if (pollFailureCount.value < POLL_FAILURE_LIMIT) { - return - } - - pollWarningMessage.value = `${resolveTencentActionMessage( - error, - '会话状态刷新失败', - )},已暂停自动轮询,请手动刷新或重新生成二维码。` - resetPolling() - } - - return { - refreshSession, - resetPolling, - resetPollingWarning, - restartPollingIfActive, - sessionNotice, - startPolling, - } -} - -export function isActiveTencentSessionStatus( - status: TencentBrowserSessionStatus | null | undefined, -) { - return Boolean(status && ACTIVE_TENCENT_SESSION_STATUSES.has(status)) -} - -function mergeSessionData( - current: TencentBrowserSessionData | null, - next: TencentBrowserSessionSummaryData | TencentBrowserSessionData, -) { - if (!current) { - return { - ...next, - qrImageBase64: next.qrImageBase64 || '', - } - } - - return { - ...current, - ...next, - qrImageBase64: - typeof next.qrImageBase64 === 'string' ? next.qrImageBase64 : current.qrImageBase64, - activityInfo: next.activityInfo ?? current.activityInfo ?? null, - redeem: next.redeem ?? current.redeem ?? null, - artifacts: next.artifacts || current.artifacts, - } -} - -function shouldRefreshQrImage( - current: TencentBrowserSessionData, - next: TencentBrowserSessionSummaryData, -) { - if (!next.artifacts?.hasQrImage) { - return false - } - - if (!current.qrImageBase64) { - return true - } - - return Boolean(next.qrUpdatedAt && next.qrUpdatedAt !== current.qrUpdatedAt) -} diff --git a/apps/frontend/src/composables/tencent/useTencentBrowserSessionPresentation.ts b/apps/frontend/src/composables/tencent/useTencentBrowserSessionPresentation.ts deleted file mode 100644 index b96c15a4..00000000 --- a/apps/frontend/src/composables/tencent/useTencentBrowserSessionPresentation.ts +++ /dev/null @@ -1,210 +0,0 @@ -import { computed, type ComputedRef, type Ref } from 'vue' - -import { buildTencentBrowserSessionScreenshotUrl } from '@/services/tencent/artifacts' -import type { TencentBrowserActivityInfo } from '@/types/tencent/activity' -import type { TencentBrowserSessionData } from '@/types/tencent/session' - -const REDEEM_ALLOWED_STATUSES = new Set(['logged_in', 'ready_to_redeem', 'redeemed']) - -export function useTencentBrowserSessionPresentation(options: { - activityInfo: ComputedRef - hasSession: ComputedRef - loginTypeLabel: ComputedRef - redeemLoading: Ref - roleConfirmed: Ref - roleReady: ComputedRef - session: Ref - sessionLoading: Ref - sessionNotice: ComputedRef -}) { - const { - activityInfo, - hasSession, - loginTypeLabel, - redeemLoading, - roleConfirmed, - roleReady, - session, - sessionLoading, - sessionNotice, - } = options - - const initButtonLabel = computed(() => `开始初始化 ${loginTypeLabel.value} 登录`) - const scanInstruction = computed( - () => `请使用${loginTypeLabel.value}扫描二维码,并在手机上确认登录`, - ) - - const qrImage = computed(() => - session.value?.qrImageBase64 ? `data:image/png;base64,${session.value.qrImageBase64}` : '', - ) - - const screenshotUrl = computed(() => { - if (!session.value?.sessionId || !session.value?.artifacts?.hasScreenshot) { - return '' - } - - return buildTencentBrowserSessionScreenshotUrl(session.value.sessionId, session.value.updatedAt) - }) - - const statusLabel = computed(() => { - if (!hasSession.value) { - return '待初始化' - } - - switch (session.value?.status) { - case 'waiting_scan': - return '等待扫码' - case 'scanned': - return '已扫码待确认' - case 'logged_in': - return '页面已登录' - case 'ready_to_redeem': - return '可以兑换' - case 'redeeming': - return '兑换中' - case 'redeemed': - return '已完成' - case 'failed': - return '会话异常' - default: - return '初始化中' - } - }) - - const canRedeem = computed(() => { - if (redeemLoading.value || sessionLoading.value || !session.value?.sessionId) { - return false - } - - return ( - REDEEM_ALLOWED_STATUSES.has(String(session.value.status || '')) && - roleReady.value && - roleConfirmed.value - ) - }) - - const redeemBlockedReason = computed(() => { - if (!session.value?.sessionId) { - return `请先选择${loginTypeLabel.value}登录,并手动开始初始化浏览器会话` - } - - if (redeemLoading.value) { - return '兑换任务正在执行中' - } - - if (sessionLoading.value) { - return '正在刷新浏览器会话状态' - } - - if (!roleReady.value) { - return sessionNotice.value || '扫码成功后,后端正在同步角色和大区信息' - } - - if (!roleConfirmed.value) { - return '请先确认当前角色与大区无误,再开始兑换' - } - - if (REDEEM_ALLOWED_STATUSES.has(String(session.value.status || ''))) { - return '' - } - - switch (session.value.status) { - case 'waiting_scan': - return `请先使用${loginTypeLabel.value}扫码` - case 'scanned': - return '请在手机上确认登录后再兑换' - case 'failed': - return sessionNotice.value || '浏览器会话异常,请重新生成二维码' - default: - return sessionNotice.value || '当前状态还不能开始兑换' - } - }) - - const finalRedeemMessage = computed(() => { - const result = session.value?.redeem?.final?.redeem - - if (!result || typeof result !== 'object') { - return '' - } - - const record = result as Record - return String(record.sMsg || record.msg || '') - }) - - const finalRetCode = computed(() => { - const result = session.value?.redeem?.final?.redeem - - if (!result || typeof result !== 'object') { - return '' - } - - const record = result as Record - return String(record.iRet ?? '') - }) - - const roleFacts = computed(() => [ - { - label: '登录昵称', - value: activityInfo.value?.nickname || '等待扫码登录', - }, - { - label: '当前角色', - value: activityInfo.value?.role?.roleName || '后端浏览器同步中', - accent: true, - }, - { - label: '角色 ID', - value: activityInfo.value?.role?.roleId || '未识别', - }, - { - label: '验证码', - value: - activityInfo.value?.form?.verifyValue || - (activityInfo.value?.verify?.visible ? '等待识别' : '等待显示'), - }, - ]) - - const resultFacts = computed(() => [ - { - label: '业务返回码', - value: finalRetCode.value || '-', - }, - { - label: '业务消息', - value: finalRedeemMessage.value || '尚未兑换', - }, - { - label: 'OCR 尝试次数', - value: String(session.value?.redeem?.attempts.length || 0), - }, - { - label: '证明截图', - value: session.value?.artifacts?.hasScreenshot ? '已生成' : '未生成', - }, - ]) - - const redeemButtonLabel = computed(() => '开始兑换') - const screenshotEmptyTitle = computed(() => - session.value?.status === 'redeemed' ? '本次没有可展示的截图' : '等待截图', - ) - const screenshotEmptyMessage = computed(() => - session.value?.status === 'redeemed' - ? '本次兑换可能未生成截图,或截图产物已被后端配置关闭。' - : '如本次兑换生成了结果截图,这里会展示。', - ) - - return { - canRedeem, - initButtonLabel, - qrImage, - redeemBlockedReason, - redeemButtonLabel, - resultFacts, - roleFacts, - scanInstruction, - screenshotEmptyMessage, - screenshotEmptyTitle, - screenshotUrl, - statusLabel, - } -} diff --git a/apps/frontend/src/composables/useAdminManualRedeemPage.ts b/apps/frontend/src/composables/useAdminManualRedeemPage.ts deleted file mode 100644 index 7e3446af..00000000 --- a/apps/frontend/src/composables/useAdminManualRedeemPage.ts +++ /dev/null @@ -1,714 +0,0 @@ -import { computed, onBeforeUnmount, onMounted, reactive, ref, watch } from 'vue' -import { useRoute, useRouter } from 'vue-router' - -import { showConfirm, showSuccess } from '@/lib/feedback' -import { - closeAdminManualRedeemTask, - confirmAdminManualRedeemRole, - createAdminManualRedeemSession, - createAdminManualRedeemTask, - fetchAdminManualRedeemDetail, - fetchAdminManualRedeemSessionSummary, - fetchAdminManualRedeemSkuSuggestions, - fetchAdminTaskScreenshot, - refreshAdminManualRedeemSession, - redeemAdminManualRedeem, - removeAdminManualRedeemSession, -} from '@/services/admin' -import type { AdminInventorySkuSuggestion, AdminManualRedeemDetail } from '@/types/admin' -import type { TencentLoginType } from '@/types/tencent/session' - -import { notifyTencentActionError } from './tencent/session-errors' -import { - useSessionPolling, - useSessionQrDisplay, - useSessionResultFacts, - buildRedeemBlockedReason, - resolveTaskStatusLabel, - syncLoginTypeFromDetail, - mergeSessionDetail, - shouldRefreshQrImage, - shouldKeepPolling, - DEFAULT_LOGIN_TYPE, -} from './shared' - -export function useAdminManualRedeemPage() { - const route = useRoute() - const router = useRouter() - - // ── Form state (admin-only) ──────────────────────────────────────── - const form = reactive({ - proofValue: '', - skuCode: '', - remark: '', - }) - const suggestions = ref([]) - - // ── Core state ──────────────────────────────────────────────────── - const detail = ref(null) - const detailLoading = ref(false) - const createLoading = ref(false) - const sessionLoading = ref(false) - const roleConfirmLoading = ref(false) - const redeemLoading = ref(false) - const closeLoading = ref(false) - const loginType = ref(DEFAULT_LOGIN_TYPE) - - // ── Screenshot state (admin-only) ────────────────────────────────── - const screenshotBlob = ref(null) - const screenshotUrl = ref('') - const screenshotLoading = ref(false) - - // ── Derived state ────────────────────────────────────────────────── - const session = computed(() => detail.value?.session || null) - const task = computed(() => detail.value?.task || null) - const order = computed(() => detail.value?.order || null) - const orderItem = computed(() => detail.value?.orderItem || null) - const result = computed(() => detail.value?.result || null) - const manualRequest = computed(() => detail.value?.manualRequest || null) - const activityInfo = computed(() => session.value?.activityInfo || null) - const roleReady = computed(() => Boolean(activityInfo.value?.role?.ready)) - const roleConfirmed = computed(() => - Boolean(task.value && ['role_confirmed', 'redeeming', 'redeemed'].includes(task.value.status)), - ) - const hasSession = computed(() => Boolean(session.value?.sessionId)) - const loginTypeLabel = computed(() => (loginType.value === 'wx' ? '微信' : 'QQ')) - const currentTaskId = computed(() => Number(task.value?.taskId || route.query.taskId || 0) || 0) - const reviewReady = computed( - () => Boolean(session.value?.review?.capturedAt) && task.value?.status !== 'redeemed', - ) - const resultReady = computed( - () => Boolean(result.value?.screenshotReady) && task.value?.status === 'redeemed', - ) - - // ── Polling ──────────────────────────────────────────────────────── - const { - pollWarningMessage, - startPolling, - resetPolling, - resetPollingWarning, - handleSilentPollingError, - } = useSessionPolling({ - hasSession: computed(() => Boolean(task.value?.taskId && hasSession.value)), - shouldKeepPolling: computed(() => shouldKeepPolling(detail.value)), - onPollTick: async () => { await refreshSessionSummary({ silent: true }) }, - pollWarningPrefix: '人工兑换状态刷新失败', - }) - - const sessionNotice = computed( - () => pollWarningMessage.value || session.value?.notice || task.value?.lastError || '', - ) - - // ── QR display ───────────────────────────────────────────────────── - const { - qrImage, - qrFigureStyle, - qrPreviewWidth, - handleQrImageLoad, - } = useSessionQrDisplay({ session }) - - // ── Presentation ─────────────────────────────────────────────────── - const loginTabs = [ - { value: 'qq' as const, label: 'QQ账号登录' }, - { value: 'wx' as const, label: '微信账号登录' }, - ] - - const statusLabel = computed(() => - resolveTaskStatusLabel(task.value?.status, session.value?.status, 'admin'), - ) - const initButtonLabel = computed(() => `生成${loginTypeLabel.value}二维码`) - const scanInstruction = computed( - () => `请让客户使用${loginTypeLabel.value}扫码,并在手机上确认登录`, - ) - - const proofValue = computed(() => manualRequest.value?.proofValue || order.value?.platformOrderId || '-') - - const { resultFacts } = useSessionResultFacts({ - order, - orderItem, - result, - resultFactOverrides: { - firstLabel: '唯一凭据', - firstValue: proofValue, - }, - }) - - // Admin overrides roleFacts to include "所在大区" and exclude "任务状态" - const adminRoleFacts = computed(() => [ - { - label: '登录昵称', - value: activityInfo.value?.nickname || '等待客户登录', - }, - { - label: '当前角色', - value: activityInfo.value?.role?.roleName || '后端浏览器同步中', - accent: true, - }, - { - label: '角色 ID', - value: activityInfo.value?.role?.roleId || '未识别', - }, - { - label: '所在大区', - value: activityInfo.value?.role?.area || '未识别', - }, - ]) - - const canConfirmRole = computed(() => - Boolean( - task.value && - task.value.status === 'claimed' && - hasSession.value && - roleReady.value && - !roleConfirmLoading.value, - ), - ) - const canRedeem = computed(() => - Boolean( - task.value && - ['role_confirmed', 'redeeming'].includes(task.value.status) && - !redeemLoading.value, - ), - ) - const redeemBlockedReason = computed(() => - buildRedeemBlockedReason({ - detail, - hasSession, - loginTypeLabel, - redeemLoading, - roleReady, - roleConfirmed, - sessionNotice, - context: 'admin', - }), - ) - const redeemButtonLabel = computed(() => (redeemLoading.value ? '兑换中...' : '开始兑换')) - const screenshotEmptyTitle = computed(() => - task.value?.status === 'redeemed' ? '未获取到结果图' : '等待角色截图', - ) - const screenshotEmptyMessage = computed(() => - task.value?.status === 'redeemed' - ? '本次兑换可能未生成最终截图,或截图仍在同步中。' - : '客户登录并识别到角色后,这里会展示给客服复核的角色截图。', - ) - const canCreateTask = computed( - () => Boolean(form.proofValue.trim() && form.skuCode.trim()) && !createLoading.value, - ) - const hasActiveTask = computed(() => Boolean(task.value?.taskId)) - - // ── Watchers ─────────────────────────────────────────────────────── - watch(qrImage, () => { - // QR image natural width reset handled by useSessionQrDisplay - }) - - watch( - () => currentTaskId.value, - (taskId, previousTaskId) => { - if (!taskId || taskId === previousTaskId) { - return - } - - void loadDetail(taskId) - }, - { immediate: true }, - ) - - onMounted(() => { - void loadSkuSuggestions() - }) - - onBeforeUnmount(() => { - resetPolling() - clearScreenshotPreview() - }) - - // ── Helpers ──────────────────────────────────────────────────────── - function restartPollingIfNeeded(nextDetail = detail.value) { - resetPolling() - - if (shouldKeepPolling(nextDetail)) { - startPolling() - } - } - - function resolveSkuName(skuCode: string) { - const normalized = String(skuCode || '').trim() - const matched = suggestions.value.find( - (item) => String(item.skuCode || '').trim() === normalized, - ) - return matched?.skuCode || normalized - } - - // ── Screenshot helpers (admin-only) ─────────────────────────────── - async function loadScreenshotPreview(nextDetail = detail.value) { - const taskId = Number(nextDetail?.task?.taskId || 0) - const shouldFetch = Boolean( - taskId && (nextDetail?.session?.review?.capturedAt || nextDetail?.result?.screenshotReady), - ) - - if (!shouldFetch) { - clearScreenshotPreview() - return - } - - screenshotLoading.value = true - - try { - const blob = await fetchAdminTaskScreenshot(taskId) - clearScreenshotPreview() - screenshotBlob.value = blob - screenshotUrl.value = URL.createObjectURL(blob) - } catch { - clearScreenshotPreview() - } finally { - screenshotLoading.value = false - } - } - - function clearScreenshotPreview() { - screenshotBlob.value = null - - if (screenshotUrl.value) { - URL.revokeObjectURL(screenshotUrl.value) - screenshotUrl.value = '' - } - } - - async function writeImageBlobToClipboard(blob: Blob, fallbackMessage: string) { - if ( - typeof navigator === 'undefined' || - !navigator.clipboard || - typeof window === 'undefined' || - !('ClipboardItem' in window) - ) { - throw new Error('当前浏览器不支持直接复制图片,请改用截图发送或下载') - } - - try { - const ClipboardItemCtor = window.ClipboardItem as typeof ClipboardItem - await navigator.clipboard.write([ - new ClipboardItemCtor({ - [blob.type || 'image/png']: blob, - }), - ]) - } catch (error) { - throw error instanceof Error ? error : new Error(fallbackMessage) - } - } - - // ── API flows ───────────────────────────────────────────────────── - async function loadSkuSuggestions(keyword = '') { - try { - const response = await fetchAdminManualRedeemSkuSuggestions({ - credentialType: 'tencent_code', - keyword: keyword.trim(), - }) - suggestions.value = response.data.items - } catch (error) { - notifyTencentActionError(error, '读取人工兑换 SKU 建议失败') - } - } - - async function loadDetail(taskId = currentTaskId.value) { - if (!taskId) { - detail.value = null - clearScreenshotPreview() - return false - } - - detailLoading.value = true - - try { - const response = await fetchAdminManualRedeemDetail(taskId) - detail.value = response.data - loginType.value = syncLoginTypeFromDetail(response.data, loginType.value) - await loadScreenshotPreview(response.data) - restartPollingIfNeeded() - return true - } catch (error) { - notifyTencentActionError(error, '读取人工兑换详情失败') - return false - } finally { - detailLoading.value = false - } - } - - async function createTaskNow() { - if (!canCreateTask.value) { - return - } - - createLoading.value = true - - try { - const response = await createAdminManualRedeemTask({ - proofValue: form.proofValue.trim(), - skuCode: form.skuCode.trim(), - skuName: resolveSkuName(form.skuCode), - remark: form.remark.trim(), - }) - - detail.value = response.data - loginType.value = syncLoginTypeFromDetail(response.data, loginType.value) - await loadScreenshotPreview(response.data) - await router.replace({ - path: route.path, - query: { - taskId: String(response.data.task.taskId), - }, - }) - showSuccess(response.msg || '人工兑换任务已创建') - } catch (error) { - notifyTencentActionError(error, '创建人工兑换任务失败') - } finally { - createLoading.value = false - } - } - - async function createSessionFlow(nextLoginType = loginType.value) { - if (!task.value?.taskId) { - return - } - - sessionLoading.value = true - resetPolling() - resetPollingWarning() - - try { - loginType.value = nextLoginType - const response = await createAdminManualRedeemSession(task.value.taskId, { - loginType: nextLoginType, - forceRecreate: hasSession.value, - }) - - detail.value = response.data - loginType.value = syncLoginTypeFromDetail(response.data, nextLoginType) - await loadScreenshotPreview(response.data) - startPolling() - } catch (error) { - notifyTencentActionError(error, '创建人工兑换登录会话失败') - } finally { - sessionLoading.value = false - } - } - - async function refreshSessionFlow() { - if (!task.value?.taskId) { - return false - } - - sessionLoading.value = true - resetPolling() - resetPollingWarning() - - try { - const response = await refreshAdminManualRedeemSession(task.value.taskId) - detail.value = response.data - loginType.value = syncLoginTypeFromDetail(response.data, loginType.value) - await loadScreenshotPreview(response.data) - restartPollingIfNeeded(response.data) - return true - } catch (error) { - notifyTencentActionError(error, '刷新人工兑换会话失败') - await refreshSessionSummary({ silent: true }) - restartPollingIfNeeded() - return false - } finally { - sessionLoading.value = false - } - } - - async function closeSessionFlow({ silent = false } = {}) { - if (!task.value?.taskId) { - return false - } - - if (!silent) { - sessionLoading.value = true - } - - resetPolling() - resetPollingWarning() - - try { - const response = await removeAdminManualRedeemSession(task.value.taskId) - detail.value = response.data - loginType.value = syncLoginTypeFromDetail(response.data, loginType.value) - await loadScreenshotPreview(response.data) - - if (!silent) { - showSuccess(response.msg || '人工兑换会话已关闭') - } - - return true - } catch (error) { - if (!silent) { - notifyTencentActionError(error, '关闭人工兑换会话失败') - } - return false - } finally { - if (!silent) { - sessionLoading.value = false - } - } - } - - async function refreshSessionSummary({ silent = false } = {}) { - if (!task.value?.taskId) { - return false - } - - if (!silent) { - sessionLoading.value = true - resetPollingWarning() - } - - try { - const currentSession = detail.value?.session || null - const response = await fetchAdminManualRedeemSessionSummary(task.value.taskId) - detail.value = mergeSessionDetail( - detail.value, - response.data, - (currentSession, nextSession) => ({ - review: nextSession.review ?? currentSession.review ?? null, - }), - ) - loginType.value = syncLoginTypeFromDetail(response.data, loginType.value) - - if (shouldRefreshQrImage(currentSession, response.data.session)) { - const fullResponse = await fetchAdminManualRedeemDetail(task.value.taskId!) - detail.value = mergeSessionDetail( - detail.value, - fullResponse.data, - (currentSession, nextSession) => ({ - review: nextSession.review ?? currentSession.review ?? null, - }), - ) - loginType.value = syncLoginTypeFromDetail(fullResponse.data, loginType.value) - } - - await loadScreenshotPreview(detail.value) - - if (silent) { - resetPollingWarning() - } - - return true - } catch (error) { - if (silent) { - handleSilentPollingError(error) - } else { - notifyTencentActionError(error, '刷新人工兑换会话状态失败') - } - - return false - } finally { - if (!silent) { - sessionLoading.value = false - } - } - } - - async function confirmRoleNow() { - if (!task.value?.taskId || !canConfirmRole.value) { - return - } - - roleConfirmLoading.value = true - - try { - const response = await confirmAdminManualRedeemRole(task.value.taskId) - detail.value = response.data - await loadScreenshotPreview(response.data) - showSuccess(response.msg || '角色已确认') - restartPollingIfNeeded(response.data) - } catch (error) { - notifyTencentActionError(error, '确认角色失败') - await refreshSessionSummary({ silent: true }) - } finally { - roleConfirmLoading.value = false - } - } - - async function redeemNow() { - if (!task.value?.taskId || !canRedeem.value) { - return - } - - redeemLoading.value = true - resetPolling() - resetPollingWarning() - - try { - const response = await redeemAdminManualRedeem(task.value.taskId) - detail.value = response.data - await loadScreenshotPreview(response.data) - showSuccess(response.msg || '兑换完成') - restartPollingIfNeeded(response.data) - } catch (error) { - notifyTencentActionError(error, '人工兑换失败') - await refreshSessionSummary({ silent: true }) - restartPollingIfNeeded() - } finally { - redeemLoading.value = false - } - } - - async function closeTaskNow() { - if (!task.value?.taskId) { - return - } - - try { - await showConfirm( - `确认关闭任务 ${task.value.taskNo} 吗?关闭后会释放未消耗的资源,并结束当前人工兑换流程。`, - '关闭人工兑换', - ) - } catch { - return - } - - closeLoading.value = true - - try { - const response = await closeAdminManualRedeemTask(task.value.taskId) - detail.value = response.data - await loadScreenshotPreview(response.data) - resetPolling() - showSuccess(response.msg || '人工兑换任务已关闭') - } catch (error) { - notifyTencentActionError(error, '关闭人工兑换任务失败') - } finally { - closeLoading.value = false - } - } - - async function switchLoginType(nextLoginType: TencentLoginType) { - if (nextLoginType === loginType.value) { - return - } - - if (hasSession.value) { - await createSessionFlow(nextLoginType) - return - } - - loginType.value = nextLoginType - } - - function resetCurrentTask() { - resetPolling() - detail.value = null - clearScreenshotPreview() - void router.replace({ - path: route.path, - query: {}, - }) - } - - async function copyQrImage() { - if (!qrImage.value) { - return false - } - - try { - const response = await fetch(qrImage.value) - const blob = await response.blob() - await writeImageBlobToClipboard(blob, '复制二维码失败') - showSuccess('二维码已复制到剪贴板') - return true - } catch (error) { - notifyTencentActionError(error, '复制二维码失败') - return false - } - } - - async function copyScreenshot() { - if (!screenshotBlob.value) { - return false - } - - try { - await writeImageBlobToClipboard(screenshotBlob.value, '复制截图失败') - showSuccess('截图已复制到剪贴板') - return true - } catch (error) { - notifyTencentActionError(error, '复制截图失败') - return false - } - } - - function downloadScreenshot() { - if (!screenshotBlob.value || !screenshotUrl.value) { - return false - } - - const anchor = document.createElement('a') - anchor.href = screenshotUrl.value - anchor.download = - task.value?.status === 'redeemed' - ? `manual-redeem-result-${task.value?.taskNo || 'task'}.png` - : `manual-redeem-review-${task.value?.taskNo || 'task'}.png` - anchor.click() - return true - } - - return { - form, - suggestions, - detail, - detailLoading, - createLoading, - sessionLoading, - roleConfirmLoading, - redeemLoading, - closeLoading, - loginType, - loginTypeLabel, - loginTabs, - session, - task, - order, - orderItem, - manualRequest, - activityInfo, - roleReady, - roleConfirmed, - hasSession, - qrImage, - qrFigureStyle, - qrPreviewWidth, - statusLabel, - sessionNotice, - roleFacts: adminRoleFacts, - resultFacts, - canConfirmRole, - canRedeem, - redeemBlockedReason, - redeemButtonLabel, - initButtonLabel, - scanInstruction, - screenshotEmptyTitle, - screenshotEmptyMessage, - screenshotUrl, - screenshotLoading, - reviewReady, - resultReady, - canCreateTask, - hasActiveTask, - createTaskNow, - loadSkuSuggestions, - createSessionFlow, - refreshSessionFlow, - closeSessionFlow, - switchLoginType, - confirmRoleNow, - redeemNow, - closeTaskNow, - resetCurrentTask, - handleQrImageLoad, - copyQrImage, - copyScreenshot, - downloadScreenshot, - } -} diff --git a/apps/frontend/src/composables/useClaimPage.ts b/apps/frontend/src/composables/useClaimPage.ts deleted file mode 100644 index 824fc642..00000000 --- a/apps/frontend/src/composables/useClaimPage.ts +++ /dev/null @@ -1,451 +0,0 @@ -import { computed, onBeforeUnmount, ref, watch } from 'vue' - -import { showSuccess } from '@/lib/feedback' -import { - confirmClaimRole, - createClaimSession, - fetchClaimDetail, - fetchClaimSessionSummary, - refreshClaimSession, - removeClaimSession, - redeemClaim, -} from '@/services/claim' -import type { ClaimDetailData } from '@/types/claim' -import type { TencentLoginType } from '@/types/tencent/session' - -import { notifyTencentActionError } from './tencent/session-errors' -import { - useSessionPolling, - useSessionQrDisplay, - useSessionResultFacts, - buildRedeemBlockedReason, - resolveTaskStatusLabel, - syncLoginTypeFromDetail, - mergeSessionDetail, - shouldRefreshQrImage, - shouldKeepPolling, - DEFAULT_LOGIN_TYPE, -} from './shared' - -export function useClaimPage(token: string) { - const detailLoading = ref(true) - const sessionLoading = ref(false) - const redeemLoading = ref(false) - const roleConfirmLoading = ref(false) - const loginType = ref(DEFAULT_LOGIN_TYPE) - const detail = ref(null) - const roleConfirmed = ref(false) - - // ── Derived state ────────────────────────────────────────────────── - const session = computed(() => detail.value?.session || null) - const task = computed(() => detail.value?.task || null) - const order = computed(() => detail.value?.order || null) - const orderItem = computed(() => detail.value?.orderItem || null) - const result = computed(() => detail.value?.result || null) - const tokenStatus = computed(() => detail.value?.tokenStatus || 'active') - const activityInfo = computed(() => session.value?.activityInfo || null) - const roleReady = computed(() => Boolean(activityInfo.value?.role?.ready)) - const hasSession = computed(() => Boolean(session.value?.sessionId)) - const loginTypeLabel = computed(() => (loginType.value === 'wx' ? '微信' : 'QQ')) - - // ── Polling ──────────────────────────────────────────────────────── - const { - pollWarningMessage, - startPolling, - resetPolling, - resetPollingWarning, - handleSilentPollingError, - } = useSessionPolling({ - hasSession, - shouldKeepPolling: computed(() => shouldKeepPolling(detail.value)), - onPollTick: async () => { await refreshSessionSummary({ silent: true }) }, - pollWarningPrefix: '领取状态刷新失败', - }) - - const sessionNotice = computed( - () => pollWarningMessage.value || session.value?.notice || task.value?.lastError || '', - ) - - // ── QR display ───────────────────────────────────────────────────── - const { qrImage, qrFigureStyle, qrPreviewWidth, handleQrImageLoad } = useSessionQrDisplay({ - session, - }) - - // ── Presentation ─────────────────────────────────────────────────── - const screenshotUrl = computed(() => result.value?.screenshotUrl || '') - const showScreenshot = computed(() => Boolean(screenshotUrl.value)) - const loginTabs = [ - { value: 'qq' as const, label: 'QQ账号登录' }, - { value: 'wx' as const, label: '微信账号登录' }, - ] - - const statusLabel = computed(() => - resolveTaskStatusLabel(task.value?.status, session.value?.status, 'claim'), - ) - const initButtonLabel = computed(() => `开始初始化 ${loginTypeLabel.value} 登录`) - const scanInstruction = computed( - () => `请使用${loginTypeLabel.value}扫描二维码,并在手机上确认登录`, - ) - - // Claim pages show 4 role facts, but original only had 3 (no area). - // Override roleFacts to match original exactly: - const claimRoleFacts = computed(() => [ - { - label: '登录昵称', - value: activityInfo.value?.nickname || '等待扫码登录', - }, - { - label: '当前角色', - value: activityInfo.value?.role?.roleName || '后端浏览器同步中', - accent: true, - }, - { - label: '角色 ID', - value: activityInfo.value?.role?.roleId || '未识别', - }, - { - label: '任务状态', - value: statusLabel.value, - }, - ]) - - const { resultFacts } = useSessionResultFacts({ - order, - orderItem, - result, - }) - - const canConfirmRole = computed(() => - Boolean( - task.value && - !task.value.requiresSupportReview && - hasSession.value && - roleReady.value && - task.value.status === 'claimed', - ), - ) - const canRedeem = computed(() => - Boolean( - task.value && - !task.value.requiresSupportReview && - hasSession.value && - roleConfirmed.value && - !redeemLoading.value && - (task.value.status === 'role_confirmed' || task.value.status === 'redeeming'), - ), - ) - const redeemBlockedReason = computed(() => - buildRedeemBlockedReason({ - detail, - hasSession, - loginTypeLabel, - redeemLoading, - roleReady, - roleConfirmed, - sessionNotice, - context: 'claim', - tokenStatus, - requiresSupportReview: computed(() => Boolean(task.value?.requiresSupportReview)), - }), - ) - const redeemButtonLabel = computed(() => - task.value?.requiresSupportReview ? '等待客服兑换' : '开始兑换', - ) - const screenshotEmptyTitle = computed(() => - task.value?.status === 'redeemed' ? '本次没有可展示的截图' : '等待截图', - ) - const screenshotEmptyMessage = computed(() => - task.value?.status === 'redeemed' - ? '本次兑换可能未生成截图,或截图产物还未同步完成。' - : '领取完成后,如本次生成了结果截图,这里会展示。', - ) - - // ── Watchers ─────────────────────────────────────────────────────── - watch( - () => - [ - task.value?.taskId || '', - activityInfo.value?.nickname || '', - activityInfo.value?.role?.roleId || '', - activityInfo.value?.role?.roleName || '', - activityInfo.value?.role?.area || '', - activityInfo.value?.role?.partition || '', - ].join('|'), - () => { - roleConfirmed.value = Boolean( - task.value?.status === 'role_confirmed' || - task.value?.status === 'redeeming' || - task.value?.status === 'redeemed', - ) - }, - { immediate: true }, - ) - - // ── Helpers ──────────────────────────────────────────────────────── - function applyLoginTypeFromDetail(nextDetail: ClaimDetailData) { - loginType.value = syncLoginTypeFromDetail(nextDetail, loginType.value) - } - - function restartPollingIfNeeded(nextDetail = detail.value) { - resetPolling() - - if (shouldKeepPolling(nextDetail)) { - startPolling() - } - } - - // ── API flows ───────────────────────────────────────────────────── - async function loadDetail() { - detailLoading.value = true - - try { - const response = await fetchClaimDetail(token) - - detail.value = mergeSessionDetail(detail.value, response.data) - applyLoginTypeFromDetail(response.data) - restartPollingIfNeeded() - } catch (error) { - notifyTencentActionError(error, '领取详情加载失败') - } finally { - detailLoading.value = false - } - } - - async function createSessionFlow(nextLoginType = loginType.value) { - sessionLoading.value = true - resetPolling() - resetPollingWarning() - - try { - loginType.value = nextLoginType - const response = await createClaimSession(token, nextLoginType, { - forceRecreate: hasSession.value, - }) - - detail.value = mergeSessionDetail(detail.value, response.data) - applyLoginTypeFromDetail(response.data) - startPolling() - } catch (error) { - notifyTencentActionError(error, '创建领取会话失败') - } finally { - sessionLoading.value = false - } - } - - async function reloadSessionPage() { - if (!hasSession.value) { - return false - } - - sessionLoading.value = true - resetPolling() - resetPollingWarning() - - try { - const response = await refreshClaimSession(token) - - detail.value = mergeSessionDetail(detail.value, response.data) - applyLoginTypeFromDetail(response.data) - restartPollingIfNeeded(response.data) - return true - } catch (error) { - notifyTencentActionError(error, '刷新后端页面失败') - await refreshSessionSummary({ silent: true }) - restartPollingIfNeeded() - return false - } finally { - sessionLoading.value = false - } - } - - async function closeSessionFlow({ silent = false } = {}) { - if (!hasSession.value) { - return false - } - - if (!silent) { - sessionLoading.value = true - } - - resetPolling() - resetPollingWarning() - - try { - const response = await removeClaimSession(token) - - detail.value = mergeSessionDetail(detail.value, response.data) - applyLoginTypeFromDetail(response.data) - - if (!silent) { - showSuccess(response.msg || '领取会话已关闭') - } - - return true - } catch (error) { - if (!silent) { - notifyTencentActionError(error, '关闭领取会话失败') - } - return false - } finally { - if (!silent) { - sessionLoading.value = false - } - } - } - - async function refreshSessionSummary({ silent = false } = {}) { - if (!hasSession.value) { - return false - } - - if (!silent) { - sessionLoading.value = true - resetPollingWarning() - } - - try { - const currentSession = detail.value?.session || null - const response = await fetchClaimSessionSummary(token) - - let nextDetail = mergeSessionDetail(detail.value, response.data) - detail.value = nextDetail - applyLoginTypeFromDetail(nextDetail) - - if (shouldRefreshQrImage(currentSession, response.data.session)) { - const fullResponse = await fetchClaimDetail(token) - - nextDetail = mergeSessionDetail(nextDetail, fullResponse.data) - detail.value = nextDetail - applyLoginTypeFromDetail(nextDetail) - } - - if (silent) { - resetPollingWarning() - } - - return true - } catch (error) { - if (silent) { - handleSilentPollingError(error) - } else { - notifyTencentActionError(error, '领取会话状态刷新失败') - } - - return false - } finally { - if (!silent) { - sessionLoading.value = false - } - } - } - - async function confirmRoleNow() { - if (!canConfirmRole.value) { - return - } - - roleConfirmLoading.value = true - - try { - const response = await confirmClaimRole(token) - - detail.value = mergeSessionDetail(detail.value, response.data) - roleConfirmed.value = true - showSuccess(response.msg || '角色已确认') - restartPollingIfNeeded(response.data) - } catch (error) { - notifyTencentActionError(error, '角色确认失败') - await refreshSessionSummary({ silent: true }) - } finally { - roleConfirmLoading.value = false - } - } - - async function redeemNow() { - if (!canRedeem.value) { - return - } - - redeemLoading.value = true - resetPolling() - resetPollingWarning() - - try { - const response = await redeemClaim(token) - - detail.value = mergeSessionDetail(detail.value, response.data) - showSuccess(response.msg || '兑换完成') - restartPollingIfNeeded(response.data) - } catch (error) { - notifyTencentActionError(error, '领取兑换失败') - await refreshSessionSummary({ silent: true }) - restartPollingIfNeeded() - } finally { - redeemLoading.value = false - } - } - - async function switchLoginType(nextLoginType: TencentLoginType) { - if (nextLoginType === loginType.value) { - return - } - - if (hasSession.value) { - await createSessionFlow(nextLoginType) - return - } - - loginType.value = nextLoginType - } - - // ── Lifecycle ────────────────────────────────────────────────────── - loadDetail() - - onBeforeUnmount(() => { - resetPolling() - }) - - return { - detailLoading, - sessionLoading, - redeemLoading, - roleConfirmLoading, - loginType, - loginTypeLabel, - loginTabs, - detail, - task, - order, - orderItem, - activityInfo, - hasSession, - qrImage, - qrFigureStyle, - qrPreviewWidth, - statusLabel, - session, - sessionNotice, - roleFacts: claimRoleFacts, - resultFacts, - roleConfirmed, - roleReady, - canConfirmRole, - canRedeem, - redeemBlockedReason, - redeemButtonLabel, - initButtonLabel, - scanInstruction, - screenshotEmptyTitle, - screenshotEmptyMessage, - screenshotUrl, - showScreenshot, - createSessionFlow, - reloadSessionPage, - closeSessionFlow, - refreshSessionSummary, - switchLoginType, - confirmRoleNow, - redeemNow, - handleQrImageLoad, - } -} diff --git a/apps/frontend/src/composables/useTencentBrowserSessionPage.ts b/apps/frontend/src/composables/useTencentBrowserSessionPage.ts deleted file mode 100644 index 9070d492..00000000 --- a/apps/frontend/src/composables/useTencentBrowserSessionPage.ts +++ /dev/null @@ -1,229 +0,0 @@ -import { computed, onBeforeUnmount, ref, watch } from 'vue' - -import { showError, showSuccess } from '@/lib/feedback' -import { - createTencentBrowserSession, - refreshTencentBrowserSessionPage, - removeTencentBrowserSession, -} from '@/services/tencent/session' -import { redeemTencentBrowserSession } from '@/services/tencent/redeem' -import type { TencentBrowserSessionData, TencentLoginType } from '@/types/tencent/session' - -import { notifyTencentActionError } from './tencent/session-errors' -import { useTencentBrowserSessionPolling } from './tencent/useTencentBrowserSessionPolling' -import { useTencentBrowserSessionPresentation } from './tencent/useTencentBrowserSessionPresentation' - -const DEFAULT_LOGIN_TYPE: TencentLoginType = 'qq' - -export function useTencentBrowserSessionPage() { - const sessionLoading = ref(false) - const redeemLoading = ref(false) - const loginType = ref(DEFAULT_LOGIN_TYPE) - const session = ref(null) - const redeemCode = ref('') - const maxAttempts = ref(6) - const roleConfirmed = ref(false) - - const activityInfo = computed(() => session.value?.activityInfo || null) - const roleReady = computed(() => Boolean(activityInfo.value?.role?.ready)) - const hasSession = computed(() => Boolean(session.value?.sessionId)) - const loginTypeLabel = computed(() => (loginType.value === 'wx' ? '微信' : 'QQ')) - - const loginTabs = [ - { value: 'qq' as const, label: 'QQ账号登录' }, - { value: 'wx' as const, label: '微信账号登录' }, - ] - - const { - refreshSession, - resetPolling, - resetPollingWarning, - restartPollingIfActive, - sessionNotice, - startPolling, - } = useTencentBrowserSessionPolling({ - session, - sessionLoading, - notifyActionError: notifyTencentActionError, - }) - - const { - canRedeem, - initButtonLabel, - qrImage, - redeemBlockedReason, - redeemButtonLabel, - resultFacts, - roleFacts, - scanInstruction, - screenshotEmptyMessage, - screenshotEmptyTitle, - screenshotUrl, - statusLabel, - } = useTencentBrowserSessionPresentation({ - activityInfo, - hasSession, - loginTypeLabel, - redeemLoading, - roleConfirmed, - roleReady, - session, - sessionLoading, - sessionNotice, - }) - - watch( - () => - [ - session.value?.sessionId || '', - activityInfo.value?.nickname || '', - activityInfo.value?.role?.roleId || '', - activityInfo.value?.role?.roleName || '', - activityInfo.value?.role?.area || '', - activityInfo.value?.role?.partition || '', - ].join('|'), - () => { - roleConfirmed.value = false - }, - ) - - async function createSessionFlow(nextLoginType = loginType.value) { - resetPolling() - resetPollingWarning() - sessionLoading.value = true - - try { - loginType.value = nextLoginType - await teardownSession() - - const response = await createTencentBrowserSession({ - loginType: nextLoginType, - }) - - session.value = response.data - startPolling() - } catch (error) { - notifyTencentActionError(error, '创建浏览器会话失败') - } finally { - sessionLoading.value = false - } - } - - async function reloadSessionPage() { - if (!session.value?.sessionId) { - return - } - - resetPolling() - resetPollingWarning() - sessionLoading.value = true - - try { - const response = await refreshTencentBrowserSessionPage(session.value.sessionId) - - session.value = response.data - restartPollingIfActive(response.data) - } catch (error) { - notifyTencentActionError(error, '刷新后端页面失败') - await refreshSession({ silent: true }) - restartPollingIfActive() - } finally { - sessionLoading.value = false - } - } - - async function switchLoginType(nextLoginType: TencentLoginType) { - if (nextLoginType === loginType.value) { - return - } - - loginType.value = nextLoginType - - if (session.value?.sessionId) { - await createSessionFlow(nextLoginType) - } - } - - async function redeemNow() { - if (!session.value?.sessionId) { - showError('请先创建浏览器会话') - return - } - - if (!redeemCode.value.trim()) { - showError('请输入兑换码') - return - } - - redeemLoading.value = true - resetPolling() - resetPollingWarning() - - try { - const response = await redeemTencentBrowserSession(session.value.sessionId, { - code: redeemCode.value.trim(), - maxAttempts: maxAttempts.value, - }) - - session.value = response.data - showSuccess(response.msg || '兑换完成') - } catch (error) { - notifyTencentActionError(error, '浏览器兑换失败') - await refreshSession({ silent: true }) - } finally { - redeemLoading.value = false - } - } - - async function teardownSession() { - if (!session.value?.sessionId) { - return - } - - const sessionId = session.value.sessionId - session.value = null - - try { - await removeTencentBrowserSession(sessionId) - } catch { - // ignore close failures on local teardown - } - } - - onBeforeUnmount(() => { - resetPolling() - void teardownSession() - }) - - return { - activityInfo, - canRedeem, - teardownSession, - createSessionFlow, - hasSession, - initButtonLabel, - loginTabs, - loginType, - loginTypeLabel, - maxAttempts, - qrImage, - redeemBlockedReason, - redeemButtonLabel, - redeemCode, - redeemLoading, - redeemNow, - reloadSessionPage, - resultFacts, - roleConfirmed, - roleFacts, - scanInstruction, - screenshotEmptyMessage, - screenshotEmptyTitle, - screenshotUrl, - session, - sessionLoading, - sessionNotice, - statusLabel, - switchLoginType, - } -} diff --git a/apps/frontend/src/domain/task-status.ts b/apps/frontend/src/domain/task-status.ts new file mode 100644 index 00000000..796e27da --- /dev/null +++ b/apps/frontend/src/domain/task-status.ts @@ -0,0 +1,59 @@ +export const TASK_STATUS = { + PENDING_PAYMENT: "pending_payment", + PAID: "paid", + LINK_GENERATED: "link_generated", + CLAIMED: "claimed", + PENDING_BINDING_PREPARE: "pending_binding_prepare", + WAITING_BINDING: "waiting_binding", + ROLE_CONFIRMED: "role_confirmed", + REDEEMING: "redeeming", + DISPATCHED_PENDING_RETURN: "dispatched_pending_return", + COMPLETED: "completed", + REDEEMED: "redeemed", + RETRY_PENDING: "retry_pending", + MANUAL_REVIEW: "manual_review", + FAILED: "failed", + EXPIRED: "expired", + CLOSED: "closed", +} as const; + +export type KnownTaskStatus = (typeof TASK_STATUS)[keyof typeof TASK_STATUS]; +export type TaskStatus = KnownTaskStatus | (string & {}); + +const CLAIM_INACTIVE_STATUSES = new Set([ + TASK_STATUS.COMPLETED, + TASK_STATUS.REDEEMED, + TASK_STATUS.MANUAL_REVIEW, + TASK_STATUS.FAILED, + TASK_STATUS.EXPIRED, + TASK_STATUS.CLOSED, +]); + +const KUAISHOU_CLOUD_RESULT_STATUSES = new Set([ + TASK_STATUS.DISPATCHED_PENDING_RETURN, + TASK_STATUS.COMPLETED, + TASK_STATUS.REDEEMED, + TASK_STATUS.MANUAL_REVIEW, + TASK_STATUS.FAILED, +]); + +export function normalizeTaskStatus(value: unknown): TaskStatus { + return String(value || "").trim() as TaskStatus; +} + +export function isClaimInactiveTaskStatus(status: unknown): boolean { + return CLAIM_INACTIVE_STATUSES.has(normalizeTaskStatus(status)); +} + +export function isKuaishouCloudRoleConfirmedStatus(status: unknown): boolean { + return normalizeTaskStatus(status) === TASK_STATUS.ROLE_CONFIRMED; +} + +export function isKuaishouCloudCompletedStatus(status: unknown): boolean { + const normalized = normalizeTaskStatus(status); + return normalized === TASK_STATUS.COMPLETED || normalized === TASK_STATUS.REDEEMED; +} + +export function hasKuaishouCloudRedeemResultStatus(status: unknown): boolean { + return KUAISHOU_CLOUD_RESULT_STATUSES.has(normalizeTaskStatus(status)); +} diff --git a/apps/frontend/src/lib/http.ts b/apps/frontend/src/lib/http.ts index 13e55903..dfb08252 100644 --- a/apps/frontend/src/lib/http.ts +++ b/apps/frontend/src/lib/http.ts @@ -106,6 +106,7 @@ function shouldClearAdminSession(errorCode: string) { 'admin_auth_invalid', 'admin_auth_expired', 'admin_auth_user_invalid', + 'admin_auth_stale', ].includes(errorCode) } diff --git a/apps/frontend/src/router/index.ts b/apps/frontend/src/router/index.ts index 3d75e82f..09f9c0f9 100644 --- a/apps/frontend/src/router/index.ts +++ b/apps/frontend/src/router/index.ts @@ -6,15 +6,7 @@ const router = createRouter({ routes: [ { path: '/', - redirect: '/tx/browser', - }, - { - path: '/tx', - redirect: '/tx/browser', - }, - { - path: '/tx/browser', - component: () => import('@/views/tx/TencentBrowserView.vue'), + redirect: '/admin/dashboard', }, { path: '/claim/:token', @@ -57,32 +49,14 @@ const router = createRouter({ path: 'tasks', component: () => import('@/views/admin/tasks/AdminTasksView.vue'), }, - { - path: 'manual-redeem', - component: () => import('@/views/admin/manual-redeem/AdminManualRedeemView.vue'), - }, { path: 'tasks/:taskId', component: () => import('@/views/admin/tasks/AdminTaskDetailView.vue'), }, { - path: 'cdks', - redirect: '/admin/inventory', - }, - { - path: 'inventory', - meta: { allowedRoles: ['admin', 'operator'] }, - component: () => import('@/views/admin/inventory/AdminInventoryView.vue'), - }, - { - path: 'message-deliveries', + path: 'cloudtentacles-records', component: () => - import('@/views/admin/message-deliveries/AdminMessageDeliveriesView.vue'), - }, - { - path: 'webhook-events', - meta: { allowedRoles: ['admin', 'operator'] }, - component: () => import('@/views/admin/webhook-events/AdminWebhookEventsView.vue'), + import('@/views/admin/cloudtentacles-records/AdminCloudtentaclesRecordsView.vue'), }, { path: 'platform-shops', @@ -94,16 +68,6 @@ const router = createRouter({ meta: { allowedRoles: ['admin'] }, component: () => import('@/views/admin/fulfillment/AdminFulfillmentConfigHubView.vue'), }, - { - path: 'platform-kuaishou-cloud-fulfillment', - meta: { allowedRoles: ['admin'] }, - redirect: '/admin/platform-fulfillment?tab=kuaishou-cloud', - }, - { - path: 'webhook-events/:eventId', - meta: { allowedRoles: ['admin', 'operator'] }, - component: () => import('@/views/admin/webhook-events/AdminWebhookEventDetailView.vue'), - }, { path: 'audit-logs', meta: { allowedRoles: ['admin'] }, diff --git a/apps/frontend/src/services/admin.ts b/apps/frontend/src/services/admin.ts deleted file mode 100644 index e8138fa3..00000000 --- a/apps/frontend/src/services/admin.ts +++ /dev/null @@ -1 +0,0 @@ -export * from './admin/index' diff --git a/apps/frontend/src/services/admin/index.ts b/apps/frontend/src/services/admin/index.ts index 5c0cdc09..99c97f94 100644 --- a/apps/frontend/src/services/admin/index.ts +++ b/apps/frontend/src/services/admin/index.ts @@ -5,7 +5,3 @@ export * from './audit-logs' export * from './platform-config' export * from './orders' export * from './tasks' -export * from './inventory' -export * from './manual-redeem' -export * from './message-deliveries' -export * from './webhook-events' diff --git a/apps/frontend/src/services/admin/inventory.ts b/apps/frontend/src/services/admin/inventory.ts deleted file mode 100644 index 32c13c67..00000000 --- a/apps/frontend/src/services/admin/inventory.ts +++ /dev/null @@ -1,51 +0,0 @@ -import { apiGet, apiPost } from '@/lib/http' -import type { - AdminInventoryItemListItem, - AdminInventorySkuSuggestion, - AdminPagination, -} from '@/types/admin' - -export function fetchAdminInventoryItems(params?: Record) { - return apiGet<{ items: AdminInventoryItemListItem[]; pagination: AdminPagination }>( - '/api/v1/admin/inventory', - params, - ) -} - -export function fetchAdminInventorySkuSuggestions(params?: Record) { - return apiGet<{ items: AdminInventorySkuSuggestion[] }>( - '/api/v1/admin/inventory/sku-suggestions', - params, - ) -} - -export function importAdminInventoryItems(payload: Record) { - return apiPost<{ total: number; created: number; duplicated: number }>( - '/api/v1/admin/inventory/import', - payload, - ) -} - -export function createAdminInventoryItem(payload: Record) { - return apiPost<{ inventoryItem: AdminInventoryItemListItem | null }>( - '/api/v1/admin/inventory', - payload, - ) -} - -export function releaseAdminInventoryItem(inventoryItemId: number | string) { - return apiPost<{ inventoryItem: AdminInventoryItemListItem | null }>( - `/api/v1/admin/inventory/${inventoryItemId}/release`, - {}, - ) -} - -export function invalidateAdminInventoryItem( - inventoryItemId: number | string, - payload?: Record, -) { - return apiPost<{ inventoryItem: AdminInventoryItemListItem | null }>( - `/api/v1/admin/inventory/${inventoryItemId}/invalidate`, - payload || {}, - ) -} diff --git a/apps/frontend/src/services/admin/manual-redeem.ts b/apps/frontend/src/services/admin/manual-redeem.ts deleted file mode 100644 index 6941c52f..00000000 --- a/apps/frontend/src/services/admin/manual-redeem.ts +++ /dev/null @@ -1,56 +0,0 @@ -import { apiDelete, apiGet, apiPost } from '@/lib/http' -import type { AdminInventorySkuSuggestion, AdminManualRedeemDetail } from '@/types/admin' - -export function fetchAdminManualRedeemSkuSuggestions(params?: Record) { - return apiGet<{ items: AdminInventorySkuSuggestion[] }>( - '/api/v1/admin/manual-redeem/sku-suggestions', - params, - ) -} - -export function createAdminManualRedeemTask(payload: { - proofValue: string - skuCode: string - skuName?: string - remark?: string -}) { - return apiPost('/api/v1/admin/manual-redeem', payload) -} - -export function fetchAdminManualRedeemDetail(taskId: number | string) { - return apiGet(`/api/v1/admin/manual-redeem/${taskId}`) -} - -export function createAdminManualRedeemSession( - taskId: number | string, - payload: { loginType: string; forceRecreate?: boolean }, -) { - return apiPost(`/api/v1/admin/manual-redeem/${taskId}/session`, payload) -} - -export function fetchAdminManualRedeemSessionSummary(taskId: number | string) { - return apiGet(`/api/v1/admin/manual-redeem/${taskId}/session/summary`) -} - -export function refreshAdminManualRedeemSession(taskId: number | string) { - return apiPost( - `/api/v1/admin/manual-redeem/${taskId}/session/refresh`, - {}, - ) -} - -export function removeAdminManualRedeemSession(taskId: number | string) { - return apiDelete(`/api/v1/admin/manual-redeem/${taskId}/session`) -} - -export function confirmAdminManualRedeemRole(taskId: number | string) { - return apiPost(`/api/v1/admin/manual-redeem/${taskId}/confirm-role`, {}) -} - -export function redeemAdminManualRedeem(taskId: number | string) { - return apiPost(`/api/v1/admin/manual-redeem/${taskId}/redeem`, {}) -} - -export function closeAdminManualRedeemTask(taskId: number | string) { - return apiPost(`/api/v1/admin/manual-redeem/${taskId}/close`, {}) -} diff --git a/apps/frontend/src/services/admin/message-deliveries.ts b/apps/frontend/src/services/admin/message-deliveries.ts deleted file mode 100644 index 60fe8697..00000000 --- a/apps/frontend/src/services/admin/message-deliveries.ts +++ /dev/null @@ -1,9 +0,0 @@ -import { apiGet } from '@/lib/http' -import type { AdminMessageDeliveryListItem, AdminPagination } from '@/types/admin' - -export function fetchAdminMessageDeliveries(params?: Record) { - return apiGet<{ items: AdminMessageDeliveryListItem[]; pagination: AdminPagination }>( - '/api/v1/admin/message-deliveries', - params, - ) -} diff --git a/apps/frontend/src/services/admin/platform-config/agiso.ts b/apps/frontend/src/services/admin/platform-config/agiso.ts deleted file mode 100644 index cc7ee38e..00000000 --- a/apps/frontend/src/services/admin/platform-config/agiso.ts +++ /dev/null @@ -1,26 +0,0 @@ -import { apiGet, apiPost } from '@/lib/http' -import type { - AdminAgisoMessagingDefaults, - AdminAgisoObservedShopItem, - AdminAgisoShopConfigItem, -} from '@/types/admin' - -export function fetchAdminAgisoShopConfigs() { - return apiGet<{ - filePath: string - defaults: AdminAgisoMessagingDefaults - shops: AdminAgisoShopConfigItem[] - observedShops: AdminAgisoObservedShopItem[] - }>('/api/v1/admin/platform-config/agiso-shops') -} - -export function saveAdminAgisoShopConfigs(payload: { - defaults: AdminAgisoMessagingDefaults - shops: Array> -}) { - return apiPost<{ - filePath: string - defaults: AdminAgisoMessagingDefaults - shops: AdminAgisoShopConfigItem[] - }>('/api/v1/admin/platform-config/agiso-shops', payload) -} diff --git a/apps/frontend/src/services/admin/platform-config/cloudtentacles.ts b/apps/frontend/src/services/admin/platform-config/cloudtentacles.ts index e325e5c5..30e5890a 100644 --- a/apps/frontend/src/services/admin/platform-config/cloudtentacles.ts +++ b/apps/frontend/src/services/admin/platform-config/cloudtentacles.ts @@ -1,6 +1,8 @@ import { apiDelete, apiGet, apiPost } from '@/lib/http' import type { AdminCloudtentaclesLoginTestResult, + AdminCloudtentaclesDeliveryRecordListResult, + AdminCloudtentaclesOverrideRuleConfig, AdminCloudtentaclesSkuListResult, AdminCloudtentaclesSendSmsResult, AdminCloudtentaclesSourceConfigResponse, @@ -32,6 +34,35 @@ export function deleteAdminCloudtentaclesSource(sourceKey: string) { ) } +export function fetchAdminCloudtentaclesOverrideRules() { + return apiGet( + '/api/v1/admin/platform-config/cloudtentacles/override-rules', + ) +} + +export function saveAdminCloudtentaclesOverrideRules( + payload: { + enabled: boolean + rules: Array<{ + id?: string + enabled?: boolean + productName?: string + sourceKey?: string + deliveryItems?: Array<{ + cloudSkuId?: number + cloudSkuName?: string + quantity?: number + }> + notes?: string + }> + }, +) { + return apiPost( + '/api/v1/admin/platform-config/cloudtentacles/override-rules', + payload, + ) +} + export function sendAdminCloudtentaclesSmsCode(payload: { baseUrl?: string username?: string @@ -158,6 +189,32 @@ export function fetchAdminCloudtentaclesKnapsack(payload: { ) } +export function fetchAdminCloudtentaclesDeliveryRecords(payload: { + sourceKey?: string + page?: number + size?: number + startDate?: string + endDate?: string + platformOrderId?: string +}) { + return apiPost( + '/api/v1/admin/cloudtentacles-records', + payload, + ) +} + +export function fetchAdminCloudtentaclesRecordSources() { + return apiGet<{ + sources: Array<{ + key: string + label: string + enabled: boolean + hasToken: boolean + loggedInAt: string + }> + }>('/api/v1/admin/cloudtentacles-records/sources') +} + export function fetchAdminCloudtentaclesVnList(payload: { baseUrl?: string token?: string diff --git a/apps/frontend/src/services/admin/platform-config/fulfillment-bindings.ts b/apps/frontend/src/services/admin/platform-config/fulfillment-bindings.ts deleted file mode 100644 index 918f347b..00000000 --- a/apps/frontend/src/services/admin/platform-config/fulfillment-bindings.ts +++ /dev/null @@ -1,35 +0,0 @@ -import { apiGet, apiPost } from '@/lib/http' -import type { - AdminFulfillmentBindingConfigItem, - AdminFulfillmentLookupResult, - AdminObservedProductItem, -} from '@/types/admin' - -export function fetchAdminFulfillmentBindingConfigs() { - return apiGet<{ - filePath: string - bindings: AdminFulfillmentBindingConfigItem[] - observedProducts: AdminObservedProductItem[] - }>('/api/v1/admin/platform-config/fulfillment-bindings') -} - -export function lookupAdminFulfillmentBindingOrder(payload: { - provider?: string - platform?: string - shopId: string - platformOrderId: string -}) { - return apiPost( - '/api/v1/admin/platform-config/fulfillment-bindings/lookup-order', - payload, - ) -} - -export function saveAdminFulfillmentBindingConfigs(payload: { - bindings: Array> -}) { - return apiPost<{ - filePath: string - bindings: AdminFulfillmentBindingConfigItem[] - }>('/api/v1/admin/platform-config/fulfillment-bindings', payload) -} diff --git a/apps/frontend/src/services/admin/platform-config/index.ts b/apps/frontend/src/services/admin/platform-config/index.ts index f7260eb5..73bc8a95 100644 --- a/apps/frontend/src/services/admin/platform-config/index.ts +++ b/apps/frontend/src/services/admin/platform-config/index.ts @@ -1,8 +1,5 @@ -export * from './agiso' export * from './notifications' export * from './scheduled-jobs' export * from './ninetyone' export * from './kuaishou-eticket' export * from './cloudtentacles' -export * from './fulfillment-bindings' -export * from './kuaishou-cloud-fulfillment' diff --git a/apps/frontend/src/services/admin/platform-config/kuaishou-cloud-fulfillment.ts b/apps/frontend/src/services/admin/platform-config/kuaishou-cloud-fulfillment.ts deleted file mode 100644 index 0680176c..00000000 --- a/apps/frontend/src/services/admin/platform-config/kuaishou-cloud-fulfillment.ts +++ /dev/null @@ -1,18 +0,0 @@ -import { apiGet, apiPost } from '@/lib/http' -import type { AdminKuaishouCloudFulfillmentConfig } from '@/types/admin' - -export function fetchAdminKuaishouCloudFulfillmentConfig() { - return apiGet<{ - filePath: string - source: AdminKuaishouCloudFulfillmentConfig - }>('/api/v1/admin/platform-config/kuaishou-cloud-fulfillment') -} - -export function saveAdminKuaishouCloudFulfillmentConfig( - payload: AdminKuaishouCloudFulfillmentConfig, -) { - return apiPost<{ - filePath: string - source: AdminKuaishouCloudFulfillmentConfig - }>('/api/v1/admin/platform-config/kuaishou-cloud-fulfillment', payload) -} diff --git a/apps/frontend/src/services/admin/tasks.ts b/apps/frontend/src/services/admin/tasks.ts index 4f56557a..4e715205 100644 --- a/apps/frontend/src/services/admin/tasks.ts +++ b/apps/frontend/src/services/admin/tasks.ts @@ -25,32 +25,10 @@ export function retryAdminTask(taskId: number | string) { return apiPost(`/api/v1/admin/tasks/${taskId}/retry`, {}) } -export function releaseAdminTaskInventory(taskId: number | string) { - return apiPost(`/api/v1/admin/tasks/${taskId}/release-inventory`, {}) -} - -export function releaseAdminTaskInventoryBinding( - taskId: number | string, - bindingId: number | string, -) { - return apiPost( - `/api/v1/admin/tasks/${taskId}/inventory-bindings/${bindingId}/release`, - {}, - ) -} - export function regenerateAdminTaskClaimLink(taskId: number | string) { return apiPost(`/api/v1/admin/tasks/${taskId}/regenerate-claim-link`, {}) } -export function confirmAdminTaskAssistedRole(taskId: number | string) { - return apiPost(`/api/v1/admin/tasks/${taskId}/support-confirm-role`, {}) -} - -export function redeemAdminTaskAssisted(taskId: number | string) { - return apiPost(`/api/v1/admin/tasks/${taskId}/support-redeem`, {}) -} - export function closeAdminTask(taskId: number | string) { return apiPost(`/api/v1/admin/tasks/${taskId}/close`, {}) } @@ -88,6 +66,13 @@ export function refreshAdminTaskKuaishouCloudRoleInfo(taskId: number | string) { ) } +export function rebindAdminTaskKuaishouCloudRole(taskId: number | string) { + return apiPost( + `/api/v1/admin/tasks/${taskId}/kuaishou-cloud/rebind-role`, + {}, + ) +} + export function dispatchAdminTaskKuaishouCloudFulfillment( taskId: number | string, payload: { diff --git a/apps/frontend/src/services/admin/users.ts b/apps/frontend/src/services/admin/users.ts index 7e66cea2..039a1ddb 100644 --- a/apps/frontend/src/services/admin/users.ts +++ b/apps/frontend/src/services/admin/users.ts @@ -25,16 +25,6 @@ export function updateAdminUserStatus(userId: number | string, payload: { status return apiPost<{ user: AdminUserListItem }>(`/api/v1/admin/users/${userId}/status`, payload) } -export function updateAdminUserInventoryGroups( - userId: number | string, - payload: { inventoryGroupCodes: string[] }, -) { - return apiPost<{ user: AdminUserListItem }>( - `/api/v1/admin/users/${userId}/inventory-groups`, - payload, - ) -} - export function resetAdminUserPassword(userId: number | string, payload: { password: string }) { return apiPost<{ user: AdminUserListItem }>( `/api/v1/admin/users/${userId}/reset-password`, diff --git a/apps/frontend/src/services/admin/webhook-events.ts b/apps/frontend/src/services/admin/webhook-events.ts deleted file mode 100644 index 22f1f798..00000000 --- a/apps/frontend/src/services/admin/webhook-events.ts +++ /dev/null @@ -1,22 +0,0 @@ -import { apiGet, apiPost } from '@/lib/http' -import type { - AdminPagination, - AdminWebhookEventDetail, - AdminWebhookEventListItem, - AdminWebhookReplayResponse, -} from '@/types/admin' - -export function fetchAdminWebhookEvents(params?: Record) { - return apiGet<{ items: AdminWebhookEventListItem[]; pagination: AdminPagination }>( - '/api/v1/admin/webhook-events', - params, - ) -} - -export function fetchAdminWebhookEventDetail(eventId: number | string) { - return apiGet(`/api/v1/admin/webhook-events/${eventId}`) -} - -export function replayAdminWebhookEvent(eventId: number | string) { - return apiPost(`/api/v1/admin/webhook-events/${eventId}/replay`, {}) -} diff --git a/apps/frontend/src/services/claim.ts b/apps/frontend/src/services/claim.ts index ddeb6d58..e5b508c2 100644 --- a/apps/frontend/src/services/claim.ts +++ b/apps/frontend/src/services/claim.ts @@ -1,26 +1,10 @@ -import { apiDelete, apiGet, apiPost } from '@/lib/http' +import { apiGet, apiPost } from '@/lib/http' import type { ClaimDetailData } from '@/types/claim' -import type { TencentLoginType } from '@/types/tencent/session' export function fetchClaimDetail(token: string) { return apiGet(`/api/v1/claim/${token}`) } -export function createClaimSession( - token: string, - loginType: TencentLoginType, - options: { forceRecreate?: boolean } = {}, -) { - return apiPost(`/api/v1/claim/${token}/session`, { - loginType, - forceRecreate: Boolean(options.forceRecreate), - }) -} - -export function fetchClaimSessionSummary(token: string) { - return apiGet(`/api/v1/claim/${token}/session/summary`) -} - export function verifyKuaishouCloudClaimTicket( token: string, payload: { @@ -34,22 +18,10 @@ export function confirmKuaishouCloudClaimRole(token: string) { return apiPost(`/api/v1/claim/${token}/kuaishou-cloud/confirm-role`, {}) } +export function rebindKuaishouCloudClaimRole(token: string) { + return apiPost(`/api/v1/claim/${token}/kuaishou-cloud/rebind-role`, {}) +} + export function redeemKuaishouCloudClaim(token: string) { return apiPost(`/api/v1/claim/${token}/kuaishou-cloud/redeem`, {}) } - -export function refreshClaimSession(token: string) { - return apiPost(`/api/v1/claim/${token}/session/refresh`, {}) -} - -export function removeClaimSession(token: string) { - return apiDelete(`/api/v1/claim/${token}/session`) -} - -export function confirmClaimRole(token: string) { - return apiPost(`/api/v1/claim/${token}/confirm-role`, { confirm: true }) -} - -export function redeemClaim(token: string) { - return apiPost(`/api/v1/claim/${token}/redeem`, {}) -} diff --git a/apps/frontend/src/services/tencent/artifacts.ts b/apps/frontend/src/services/tencent/artifacts.ts deleted file mode 100644 index 0387e34d..00000000 --- a/apps/frontend/src/services/tencent/artifacts.ts +++ /dev/null @@ -1,4 +0,0 @@ -export function buildTencentBrowserSessionScreenshotUrl(sessionId: string, cacheKey = '') { - const suffix = cacheKey ? `?t=${encodeURIComponent(cacheKey)}` : '' - return `/api/v1/tencent/browser/session/${sessionId}/screenshot${suffix}` -} diff --git a/apps/frontend/src/services/tencent/redeem.ts b/apps/frontend/src/services/tencent/redeem.ts deleted file mode 100644 index f63ad7c9..00000000 --- a/apps/frontend/src/services/tencent/redeem.ts +++ /dev/null @@ -1,17 +0,0 @@ -import { apiPost } from '@/lib/http' -import type { TencentBrowserSessionData } from '@/types/tencent/session' - -export interface TencentBrowserRedeemPayload { - code: string - maxAttempts?: number -} - -export function redeemTencentBrowserSession( - sessionId: string, - payload: TencentBrowserRedeemPayload, -) { - return apiPost( - `/api/v1/tencent/browser/session/${sessionId}/redeem`, - payload, - ) -} diff --git a/apps/frontend/src/services/tencent/session.ts b/apps/frontend/src/services/tencent/session.ts deleted file mode 100644 index a1864052..00000000 --- a/apps/frontend/src/services/tencent/session.ts +++ /dev/null @@ -1,35 +0,0 @@ -import { apiDelete, apiGet, apiPost } from '@/lib/http' -import type { - TencentBrowserSessionData, - TencentBrowserSessionSummaryData, - TencentLoginType, -} from '@/types/tencent/session' - -export interface CreateTencentBrowserSessionPayload { - loginType?: TencentLoginType -} - -export function createTencentBrowserSession(payload?: CreateTencentBrowserSessionPayload) { - return apiPost( - '/api/v1/tencent/browser/session', - payload as Record | undefined, - ) -} - -export function fetchTencentBrowserSession(sessionId: string) { - return apiGet(`/api/v1/tencent/browser/session/${sessionId}`) -} - -export function fetchTencentBrowserSessionSummary(sessionId: string) { - return apiGet( - `/api/v1/tencent/browser/session/${sessionId}/summary`, - ) -} - -export function refreshTencentBrowserSessionPage(sessionId: string) { - return apiPost(`/api/v1/tencent/browser/session/${sessionId}/refresh`) -} - -export function removeTencentBrowserSession(sessionId: string) { - return apiDelete>(`/api/v1/tencent/browser/session/${sessionId}`) -} diff --git a/apps/frontend/src/styles/admin-config-pages.css b/apps/frontend/src/styles/admin-config-pages.css index a582ab35..80a06eca 100644 --- a/apps/frontend/src/styles/admin-config-pages.css +++ b/apps/frontend/src/styles/admin-config-pages.css @@ -1,6 +1,6 @@ /* ============================================================ 管理员配置页面共享结构样式 - 引入: AdminFulfillmentBindings.css, AdminKuaishouCloudFulfillment.css + 引入: AdminKuaishouCloudFulfillment.css 各页面自行覆盖颜色、圆角、主题属性 ============================================================ */ diff --git a/apps/frontend/src/styles/admin-list-pages.css b/apps/frontend/src/styles/admin-list-pages.css index 1f61827c..fda6e0d9 100644 --- a/apps/frontend/src/styles/admin-list-pages.css +++ b/apps/frontend/src/styles/admin-list-pages.css @@ -1,7 +1,6 @@ /* ============================================================ 管理员列表页共享样式 - 引入: AdminOrdersView, AdminTasksView, AdminMessageDeliveriesView, - AdminWebhookEventsView, AdminUsersView, AdminInventoryView + 引入: AdminOrdersView, AdminTasksView, AdminUsersView 各页面自行覆盖 grid-template-columns 等布局差异 ============================================================ */ diff --git a/apps/frontend/src/types/admin/auth.ts b/apps/frontend/src/types/admin/auth.ts index 385922ab..d9d23fc7 100644 --- a/apps/frontend/src/types/admin/auth.ts +++ b/apps/frontend/src/types/admin/auth.ts @@ -8,7 +8,6 @@ export interface AdminLoginResponse { userId: number username: string role: AdminRole - inventoryGroupCodes?: string[] } } @@ -19,6 +18,5 @@ export interface AdminSessionSummary { userId: number username: string role: AdminRole - inventoryGroupCodes?: string[] } } diff --git a/apps/frontend/src/types/admin/dashboard.ts b/apps/frontend/src/types/admin/dashboard.ts index b360c3d7..7d666bf0 100644 --- a/apps/frontend/src/types/admin/dashboard.ts +++ b/apps/frontend/src/types/admin/dashboard.ts @@ -4,5 +4,4 @@ export interface AdminDashboardSummary { claimingTasks: number redeemedToday: number abnormalTasks: number - skuWithInventory: number } diff --git a/apps/frontend/src/types/admin/fulfillment.ts b/apps/frontend/src/types/admin/fulfillment.ts deleted file mode 100644 index 8f674424..00000000 --- a/apps/frontend/src/types/admin/fulfillment.ts +++ /dev/null @@ -1,27 +0,0 @@ -import type { AdminObservedProductItem } from './platform-config/fulfillment-bindings' - -export interface AdminFulfillmentLookupOrder { - provider: string - platform: string - shopId: string - shopName: string - platformOrderId: string - buyerName: string - totalAmount: string - totalAmountFen: number - paidAt: string | null - enriched: boolean - enrichReason: string - errorMessage: string -} - -export interface AdminFulfillmentLookupItem extends AdminObservedProductItem { - lineId: string - itemTitle: string - quantity: number -} - -export interface AdminFulfillmentLookupResult { - order: AdminFulfillmentLookupOrder - items: AdminFulfillmentLookupItem[] -} diff --git a/apps/frontend/src/types/admin/index.ts b/apps/frontend/src/types/admin/index.ts index 5c4c2761..7153287b 100644 --- a/apps/frontend/src/types/admin/index.ts +++ b/apps/frontend/src/types/admin/index.ts @@ -15,50 +15,21 @@ export type { AdminDashboardSummary } from './dashboard' // Orders types export type { - AdminOrderBindingSummary, - AdminOrderAgisoAutoDeliverySummary, + AdminOrderFulfillmentProgress, AdminOrderListItem, AdminOrderDetail, } from './orders' // Tasks types export type { - AdminTaskBindingSummary, - AdminAgisoAutoDeliveryStatus, AdminTaskListItem, AdminTaskOperations, AdminTaskActionResponse, AdminTaskDetail, } from './tasks' -// Inventory types -export type { AdminInventorySkuSuggestion, AdminInventoryItemListItem } from './inventory' - -// Manual redeem types -export type { AdminManualRedeemDetail } from './manual-redeem' - -// Webhook events types -export type { - AdminWebhookEventListItem, - AdminWebhookEventDetail, - AdminWebhookReplayResponse, -} from './webhook-events' - -// Message deliveries types -export type { AdminMessageDeliveryListItem } from './message-deliveries' - -// Fulfillment types -export type { - AdminFulfillmentLookupOrder, - AdminFulfillmentLookupItem, - AdminFulfillmentLookupResult, -} from './fulfillment' - // Platform config types export type { - AdminAgisoShopConfigItem, - AdminAgisoMessagingDefaults, - AdminAgisoObservedShopItem, AdminNotificationBarkRecipient, AdminNotificationWpushRecipient, AdminNotificationConfig, @@ -82,6 +53,9 @@ export type { AdminCloudtentaclesSourceItem, AdminCloudtentaclesSourcesConfig, AdminCloudtentaclesSourceConfigResponse, + AdminCloudtentaclesOverrideDeliveryItem, + AdminCloudtentaclesOverrideRule, + AdminCloudtentaclesOverrideRuleConfig, AdminCloudtentaclesSessionsMap, AdminCloudtentaclesSourceConfig, AdminCloudtentaclesPersistedSession, @@ -92,8 +66,6 @@ export type { AdminCloudtentaclesValidateSessionResult, AdminCloudtentaclesSkuItem, AdminCloudtentaclesSkuListResult, - AdminFulfillmentBindingConfigItem, - AdminObservedProductItem, - AdminKuaishouCloudFulfillmentItem, - AdminKuaishouCloudFulfillmentConfig, + AdminCloudtentaclesDeliveryRecordItem, + AdminCloudtentaclesDeliveryRecordListResult, } from './platform-config' diff --git a/apps/frontend/src/types/admin/inventory.ts b/apps/frontend/src/types/admin/inventory.ts deleted file mode 100644 index 74325a87..00000000 --- a/apps/frontend/src/types/admin/inventory.ts +++ /dev/null @@ -1,27 +0,0 @@ -export interface AdminInventorySkuSuggestion { - skuCode: string - credentialType: string - inventoryGroupCode: string - totalCount: number - availableCount: number - latestUpdatedAt: string | null -} - -export interface AdminInventoryItemListItem { - inventoryItemId: number - skuCode: string - batchNo: string - credentialType: string - inventoryGroupCode: string - displayValue: string - status: string - reservedByTaskId: number | null - reservedByTaskNo: string - platformOrderId: string - systemBindingStatus: string - userBindingStatus: string - invalidReason: string - deliveredAt: string | null - createdAt: string - updatedAt: string -} diff --git a/apps/frontend/src/types/admin/manual-redeem.ts b/apps/frontend/src/types/admin/manual-redeem.ts deleted file mode 100644 index 026f53e0..00000000 --- a/apps/frontend/src/types/admin/manual-redeem.ts +++ /dev/null @@ -1,9 +0,0 @@ -import type { ClaimDetailData } from '../claim' - -export interface AdminManualRedeemDetail extends ClaimDetailData { - manualRequest: { - sourceType: string - proofValue: string - remark: string - } -} diff --git a/apps/frontend/src/types/admin/message-deliveries.ts b/apps/frontend/src/types/admin/message-deliveries.ts deleted file mode 100644 index 893f2441..00000000 --- a/apps/frontend/src/types/admin/message-deliveries.ts +++ /dev/null @@ -1,24 +0,0 @@ -export interface AdminMessageDeliveryListItem { - deliveryId: number - provider: string - platform: string - shopId: string - shopName: string - channel: string - orderId: number | null - taskId: number | null - taskNo: string - taskStatus: string - platformOrderId: string - recipientKey: string - messageContent: string - claimUrl: string - status: string - requestUrl: string - responseStatus: number - response: Record - errorMessage: string - sentAt: string | null - createdAt: string - updatedAt: string -} diff --git a/apps/frontend/src/types/admin/orders.ts b/apps/frontend/src/types/admin/orders.ts index 8f16c568..c41d39aa 100644 --- a/apps/frontend/src/types/admin/orders.ts +++ b/apps/frontend/src/types/admin/orders.ts @@ -1,23 +1,11 @@ import type { AdminTaskListItem } from './tasks' -import type { AdminAgisoAutoDeliveryStatus } from './tasks' -export interface AdminOrderBindingSummary { +export interface AdminOrderFulfillmentProgress { totalTaskCount: number - systemBoundTaskCount: number - completedBindingTaskCount: number - totalBindingCount: number - reservedBindingCount: number - consumedBindingCount: number - releasedBindingCount: number - systemBindingStatus: string - userBindingStatus: string -} - -export interface AdminOrderAgisoAutoDeliverySummary extends AdminAgisoAutoDeliveryStatus { - totalTaskCount: number - deliveredTaskCount: number - sourceTaskId: number | null - sourceTaskNo: string + preparedTaskCount: number + completedTaskCount: number + resourceStatus: string + customerStatus: string } export interface AdminOrderListItem { @@ -42,14 +30,10 @@ export interface AdminOrderListItem { totalQuantity: number itemSummary: string taskCount: number - systemBindingStatus: string - userBindingStatus: string - systemBoundTaskCount: number - completedBindingTaskCount: number - totalBindingCount: number - reservedBindingCount: number - consumedBindingCount: number - releasedBindingCount: number + resourceStatus: string + customerStatus: string + preparedTaskCount: number + completedTaskCount: number } export interface AdminOrderDetail { @@ -73,8 +57,7 @@ export interface AdminOrderDetail { updatedAt: string itemSummary: string rawPayload: Record - bindingSummary: AdminOrderBindingSummary - agisoAutoDelivery: AdminOrderAgisoAutoDeliverySummary | null + fulfillmentProgress: AdminOrderFulfillmentProgress } items: Array<{ orderItemId: number @@ -86,17 +69,4 @@ export interface AdminOrderDetail { spec: Record }> tasks: AdminTaskListItem[] - webhookEvents: Array<{ - eventId: number - provider: string - platform: string - shopId: string - shopName: string - eventType: string - eventKey: string - signatureValid: boolean - processed: boolean - processError: string - createdAt: string - }> } diff --git a/apps/frontend/src/types/admin/platform-config/agiso.ts b/apps/frontend/src/types/admin/platform-config/agiso.ts deleted file mode 100644 index 7288a5e1..00000000 --- a/apps/frontend/src/types/admin/platform-config/agiso.ts +++ /dev/null @@ -1,26 +0,0 @@ -export interface AdminAgisoShopConfigItem { - shopId: string - shopName: string - AccessToken: string - accessTokenMasked: string - enabled: boolean | null - messageTemplate: string - autoDeliveryMessageTemplate: string - appSecretConfigured: boolean - apiVersion: string - sendMessageEndpoint: string -} - -export interface AdminAgisoMessagingDefaults { - messageTemplate: string - autoDeliveryMessageTemplate: string -} - -export interface AdminAgisoObservedShopItem { - shopId: string - detectedShopName: string - displayShopName: string - latestSeenAt: string | null - webhookEventCount: number - configured: boolean -} diff --git a/apps/frontend/src/types/admin/platform-config/cloudtentacles.ts b/apps/frontend/src/types/admin/platform-config/cloudtentacles.ts index 5b90b085..6442d35f 100644 --- a/apps/frontend/src/types/admin/platform-config/cloudtentacles.ts +++ b/apps/frontend/src/types/admin/platform-config/cloudtentacles.ts @@ -41,6 +41,28 @@ export interface AdminCloudtentaclesSourceConfigResponse { sessions: AdminCloudtentaclesSessionsMap } +export interface AdminCloudtentaclesOverrideDeliveryItem { + cloudSkuId: number + cloudSkuName: string + quantity: number +} + +export interface AdminCloudtentaclesOverrideRule { + id: string + enabled: boolean + productName: string + normalizedProductName: string + sourceKey: string + deliveryItems: AdminCloudtentaclesOverrideDeliveryItem[] + notes: string +} + +export interface AdminCloudtentaclesOverrideRuleConfig { + filePath: string + enabled: boolean + rules: AdminCloudtentaclesOverrideRule[] +} + export interface AdminCloudtentaclesSendSmsResult { baseUrl: string username: string @@ -99,6 +121,54 @@ export interface AdminCloudtentaclesSkuListResult { rawItems: Record[] } +export interface AdminCloudtentaclesDeliveryRecordItem { + createdAt: string + recordId: string + virtualNumberId: number + userId: number + count: number + cdk: string + exchangeUrl: string + name: string + skuId: number + skuName: string + skuImage: string + phone: string + status: number + fulfillUser: string + operatorName: string + platformOrderId: string + taskId: number + taskNo: string + orderShopId: string + orderShopName: string + consumeShopId: string + consumeShopName: string + roleName: string + roleId: string + localTaskStatus: string + localDeliveryStatus: string + localDispatchStatus: string + localDispatchAt: string + localResultCode: string + localResultMessage: string + matchConfidence: string + raw: Record +} + +export interface AdminCloudtentaclesDeliveryRecordListResult { + baseUrl: string + page: number + size: number + total: number + sourceKey?: string + queryStartDate?: string + queryEndDate?: string + localOrderMatched?: boolean + items: AdminCloudtentaclesDeliveryRecordItem[] + raw: Record +} + /** @deprecated Use AdminCloudtentaclesSourceItem + AdminCloudtentaclesSourcesConfig instead */ export interface AdminCloudtentaclesSourceConfig { enabled: boolean diff --git a/apps/frontend/src/types/admin/platform-config/fulfillment-bindings.ts b/apps/frontend/src/types/admin/platform-config/fulfillment-bindings.ts deleted file mode 100644 index 3aa383ea..00000000 --- a/apps/frontend/src/types/admin/platform-config/fulfillment-bindings.ts +++ /dev/null @@ -1,36 +0,0 @@ -export interface AdminFulfillmentBindingConfigItem { - provider: string - platform: string - shopId: string - shopName: string - skuCode: string - skuName: string - profileKey: string - enabled: boolean - priority: number - config: Record - match: { - externalSkuCode: string - externalItemId: string - externalSkuName: string - config: Record - } -} - -export interface AdminObservedProductItem { - provider: string - platform: string - shopId: string - shopName: string - externalItemId: string - externalSkuCode: string - externalSkuName: string - latestSeenAt: string | null - orderItemCount: number - configured: boolean - matchedBinding: null | { - skuCode: string - skuName: string - profileKey: string - } -} diff --git a/apps/frontend/src/types/admin/platform-config/index.ts b/apps/frontend/src/types/admin/platform-config/index.ts index 62af5fb4..7927b341 100644 --- a/apps/frontend/src/types/admin/platform-config/index.ts +++ b/apps/frontend/src/types/admin/platform-config/index.ts @@ -1,9 +1,3 @@ -export type { - AdminAgisoShopConfigItem, - AdminAgisoMessagingDefaults, - AdminAgisoObservedShopItem, -} from './agiso' - export type { AdminNotificationBarkRecipient, AdminNotificationWpushRecipient, @@ -40,6 +34,9 @@ export type { AdminCloudtentaclesSourceItem, AdminCloudtentaclesSourcesConfig, AdminCloudtentaclesSourceConfigResponse, + AdminCloudtentaclesOverrideDeliveryItem, + AdminCloudtentaclesOverrideRule, + AdminCloudtentaclesOverrideRuleConfig, AdminCloudtentaclesSessionsMap, AdminCloudtentaclesSourceConfig, AdminCloudtentaclesPersistedSession, @@ -50,14 +47,6 @@ export type { AdminCloudtentaclesValidateSessionResult, AdminCloudtentaclesSkuItem, AdminCloudtentaclesSkuListResult, + AdminCloudtentaclesDeliveryRecordItem, + AdminCloudtentaclesDeliveryRecordListResult, } from './cloudtentacles' - -export type { - AdminFulfillmentBindingConfigItem, - AdminObservedProductItem, -} from './fulfillment-bindings' - -export type { - AdminKuaishouCloudFulfillmentItem, - AdminKuaishouCloudFulfillmentConfig, -} from './kuaishou-cloud-fulfillment' diff --git a/apps/frontend/src/types/admin/platform-config/kuaishou-cloud-fulfillment.ts b/apps/frontend/src/types/admin/platform-config/kuaishou-cloud-fulfillment.ts deleted file mode 100644 index c08140b0..00000000 --- a/apps/frontend/src/types/admin/platform-config/kuaishou-cloud-fulfillment.ts +++ /dev/null @@ -1,31 +0,0 @@ -export interface AdminKuaishouCloudFulfillmentItem { - id: string - enabled: boolean - priority: number - provider: string - platform: string - shopId: string - internalSkuCode: string - internalSkuName: string - externalSkuCode: string - externalItemId: string - externalSkuName: string - resolvedSkuName: string - cloudSourceKey: string - cloudSkuId: number - cloudSkuName: string - vnKey: string - autoBuyEnabled: boolean - minAssetReserve: number - autoReturnNumberAfterDispatch: boolean - autoConsumeAfterDispatch: boolean - kuaishouConsumeShopId: string - kuaishouConsumeShopName: string - notes: string - cloudSourceKeyFallbacks?: string[] -} - -export interface AdminKuaishouCloudFulfillmentConfig { - enabled: boolean - items: AdminKuaishouCloudFulfillmentItem[] -} diff --git a/apps/frontend/src/types/admin/tasks.ts b/apps/frontend/src/types/admin/tasks.ts index 8e0c76dc..20193cb5 100644 --- a/apps/frontend/src/types/admin/tasks.ts +++ b/apps/frontend/src/types/admin/tasks.ts @@ -1,22 +1,3 @@ -export interface AdminTaskBindingSummary { - totalBindingCount: number - reservedBindingCount: number - consumedBindingCount: number - releasedBindingCount: number - roleKeys: string[] -} - -export interface AdminAgisoAutoDeliveryStatus { - status: string - trigger: string - reason: string - platformOrderId: string - responseStatus: number - errorMessage: string - requestId: string - updatedAt: string | null -} - export interface AdminTaskListItem { taskId: number taskNo: string @@ -28,40 +9,34 @@ export interface AdminTaskListItem { deliveryStatus: string resultCode: string resultMessage: string - systemBindingStatus: string - userBindingStatus: string + resourceStatus: string + customerStatus: string loginType: string roleName: string roleId: string - browserSessionId: string + runtimeSessionId: string claimedAt: string | null roleConfirmedAt: string | null redeemedAt: string | null retryCount: number - bindingSummary: AdminTaskBindingSummary lastError: string createdAt: string updatedAt: string - inventoryDisplayMasked: string - inventoryCredentialType: string claimToken: string screenshotPath: string - agisoAutoDelivery: AdminAgisoAutoDeliveryStatus | null } export interface AdminTaskOperations { canRetry: boolean - canReleaseInventory: boolean canRegenerateClaimLink: boolean canClose: boolean canMarkManualReview: boolean canCompleteManualDispatch: boolean canPrepareKuaishouCloudFulfillment: boolean canRefreshKuaishouCloudRoleInfo: boolean + canRebindKuaishouCloudRole: boolean canDispatchKuaishouCloudFulfillment: boolean canReturnKuaishouCloudFulfillment: boolean - canSupportConfirmRole: boolean - canSupportRedeem: boolean canViewSensitiveTaskData: boolean } @@ -73,7 +48,6 @@ export interface AdminTaskActionResponse { deliveryStatus: string resultCode: string resultMessage: string - inventoryItemId: number | null primaryClaimTokenId: number | null lastError: string updatedAt: string @@ -107,34 +81,6 @@ export interface AdminTaskDetail { expiredAt: string claimUrl: string } - inventory: null | { - inventoryItemId: number - skuCode: string - batchNo: string - credentialType: string - displayValue: string - status: string - } - inventoryBindings: Array<{ - bindingId: number - inventoryItemId: number - roleKey: string - quantity: number - bindingStatus: string - inventoryStatus: string - skuCode: string - batchNo: string - credentialType: string - displayValue: string - invalidReason: string - consumedAt: string | null - releasedAt: string | null - createdAt: string - updatedAt: string - metadata: Record - isPrimary: boolean - canRelease: boolean - }> artifacts: Record screenshotUrl: string review: { @@ -150,7 +96,6 @@ export interface AdminTaskDetail { finishedAt: string | null attempts: Array<{ attempt: number - inventoryItemId: number | null codeMasked: string credentialType: string outcome: string @@ -173,9 +118,10 @@ export interface AdminTaskDetail { } binding: { prepareStatus: string - cloudSourceKey: string - cloudSourceKeyFallbacks: string[] + cloudSourceKeys: string[] + cloudSourceLabels: string[] resolvedSourceKey: string + resolvedSourceLabel: string skuId: number skuName: string vnKey: string @@ -224,6 +170,35 @@ export interface AdminTaskDetail { consumedAt: string | null errorMessage: string } + rebind: { + currentAttempt: number + history: Array<{ + attempt: number + source: string + requestedAt: string | null + requestedBy: Record | null + status: string + errorMessage: string + oldBinding: { + vnKey: string + vnId: number + vnPhone: string + bindUrl: string + bindPreparedAt: string | null + bindExpiresAt: string | null + roleName: string + roleId: string + } + newBinding: null | { + vnKey: string + vnId: number + vnPhone: string + bindUrl: string + bindPreparedAt: string | null + bindExpiresAt: string | null + } + }> + } notes: string } manualDispatch: null | { diff --git a/apps/frontend/src/types/admin/users.ts b/apps/frontend/src/types/admin/users.ts index e8ad7774..7c5438a2 100644 --- a/apps/frontend/src/types/admin/users.ts +++ b/apps/frontend/src/types/admin/users.ts @@ -5,7 +5,6 @@ export interface AdminUserListItem { username: string role: AdminRole status: AdminUserStatus - inventoryGroupCodes: string[] createdAt: string updatedAt: string } diff --git a/apps/frontend/src/types/admin/webhook-events.ts b/apps/frontend/src/types/admin/webhook-events.ts deleted file mode 100644 index 7d11d078..00000000 --- a/apps/frontend/src/types/admin/webhook-events.ts +++ /dev/null @@ -1,60 +0,0 @@ -export interface AdminWebhookEventListItem { - eventId: number - provider: string - platform: string - platformRaw: string - shopId: string - shopName: string - eventType: string - eventKey: string - signatureValid: boolean - processed: boolean - processError: string - visibilityLevel: string - relatedOrderId: number | null - platformOrderId: string - buyerId: string - buyerName: string - totalAmount: string - totalAmountFen: number - currency: string - itemCount: number - taskCount: number - messageText: string - requestTimestamp: string - requestSign: string - aopic: string - sourceHost: string - sourceIp: string - userAgent: string - createdAt: string -} - -export interface AdminWebhookEventDetail extends AdminWebhookEventListItem { - headers: Record - query: Record - body: Record - payload: Record -} - -export interface AdminWebhookReplayResponse { - eventId: number - replayed: boolean - result: { - accepted: boolean - provider: string - platform: string - platformRaw: string - shopId: string - shopName: string - eventType: string - platformOrderId: string - orderId: number - taskCount: number - tasks: Array<{ - taskId: number - taskNo: string - status: string - }> - } -} diff --git a/apps/frontend/src/types/claim.ts b/apps/frontend/src/types/claim.ts index 59b7b087..2eb79f68 100644 --- a/apps/frontend/src/types/claim.ts +++ b/apps/frontend/src/types/claim.ts @@ -1,27 +1,8 @@ -import type { TencentBrowserSessionData, TencentBrowserSessionSummaryData } from './tencent/session' +import type { KnownTaskStatus } from '@/domain/task-status' export type ClaimTokenStatus = 'active' | 'used' | 'expired' | 'revoked' | (string & {}) -export type ClaimTaskStatus = - | 'pending_payment' - | 'paid' - | 'waiting_inventory' - | 'pending_binding_prepare' - | 'waiting_binding' - | 'dispatched_pending_return' - | 'completed' - | 'failed' - | 'cdk_reserved' - | 'link_generated' - | 'claimed' - | 'role_confirmed' - | 'redeeming' - | 'redeemed' - | 'retry_pending' - | 'manual_review' - | 'expired' - | 'closed' - | (string & {}) +export type ClaimTaskStatus = KnownTaskStatus | (string & {}) export interface ClaimTaskInfo { taskId: number @@ -35,7 +16,7 @@ export interface ClaimTaskInfo { redeemedAt: string | null loginType: string lastError: string - browserSessionId: string + runtimeSessionId: string } export interface ClaimOrderInfo { @@ -56,6 +37,18 @@ export interface ClaimOrderItemInfo { quantity: number } +export interface ClaimProductInfo { + title: string + skuCode: string + quantity: number + isBundle: boolean + items: Array<{ + cloudSkuId: number + name: string + quantity: number + }> +} + export interface ClaimResultInfo { resultCode: string resultMessage: string @@ -78,7 +71,8 @@ export interface ClaimKuaishouCloudFlowInfo { } binding: { prepareStatus: string - cloudSourceKey: string + cloudSourceKeys: string[] + resolvedSourceKey: string skuId: number skuName: string vnKey: string @@ -99,6 +93,12 @@ export interface ClaimKuaishouCloudFlowInfo { rid: string refreshedAt: string | null errorMessage: string + defaultName: string + defaultRid: string + defaultCapturedAt: string | null + defaultCaptureStatus: string + defaultErrorMessage: string + isDefaultRole: boolean } purchase: { autoBuyEnabled: boolean @@ -112,6 +112,10 @@ export interface ClaimKuaishouCloudFlowInfo { dispatch: { status: string dispatchAt: string | null + failedAt: string | null + failedStage: string + errorCode: string + errorMessage: string note: string } returnNumber: { @@ -120,19 +124,26 @@ export interface ClaimKuaishouCloudFlowInfo { } consume: { status: string + shopId: string + shopName: string consumedAt: string | null errorMessage: string } + rebind?: { + currentAttempt: number + history: Array> + } } export interface ClaimDetailData { tokenStatus: ClaimTokenStatus claimUrl: string - flowType: 'tencent_claim' | 'kuaishou_cloud' | (string & {}) + flowType: 'kuaishou_cloud' | 'kuaishou_ct_assisted' | (string & {}) task: ClaimTaskInfo order: ClaimOrderInfo orderItem: ClaimOrderItemInfo - session: TencentBrowserSessionData | TencentBrowserSessionSummaryData | null + product: ClaimProductInfo + session: unknown | null kuaishouCloudFulfillment: ClaimKuaishouCloudFlowInfo | null result: ClaimResultInfo | null } diff --git a/apps/frontend/src/types/tencent/activity.ts b/apps/frontend/src/types/tencent/activity.ts deleted file mode 100644 index dd66aac3..00000000 --- a/apps/frontend/src/types/tencent/activity.ts +++ /dev/null @@ -1,51 +0,0 @@ -export interface TencentBrowserSessionArtifacts { - hasQrImage: boolean - hasScreenshot: boolean -} - -export interface TencentBrowserReviewInfo { - capturedAt: string - roleId: string - roleName: string -} - -export interface TencentBrowserRoleInfo { - ready: boolean - roleName: string - roleId: string - area: string - partition: string - platId: string - md5str: string - checkparam: string -} - -export interface TencentBrowserFormInfo { - cdkeyInputId: string - cdkeyValue: string - verifyInputId: string - verifyValue: string - verifyImgId: string - submitId: string -} - -export interface TencentBrowserVerifyInfo { - visible: boolean - src: string - naturalWidth: number - naturalHeight: number -} - -export interface TencentBrowserPopupInfo { - visible: boolean - text: string - detail: string -} - -export interface TencentBrowserActivityInfo { - nickname: string - role: TencentBrowserRoleInfo - form: TencentBrowserFormInfo - verify: TencentBrowserVerifyInfo - popup: TencentBrowserPopupInfo -} diff --git a/apps/frontend/src/types/tencent/redeem.ts b/apps/frontend/src/types/tencent/redeem.ts deleted file mode 100644 index 4df77a64..00000000 --- a/apps/frontend/src/types/tencent/redeem.ts +++ /dev/null @@ -1,12 +0,0 @@ -export interface TencentBrowserRedeemAttempt { - attempt: number - redeem?: Record | null -} - -export interface TencentBrowserRedeemResult { - code: string - area: string - attempts: TencentBrowserRedeemAttempt[] - final?: TencentBrowserRedeemAttempt | null - finishedAt: string -} diff --git a/apps/frontend/src/types/tencent/session.ts b/apps/frontend/src/types/tencent/session.ts deleted file mode 100644 index 908bd2f0..00000000 --- a/apps/frontend/src/types/tencent/session.ts +++ /dev/null @@ -1,45 +0,0 @@ -import type { - TencentBrowserActivityInfo, - TencentBrowserReviewInfo, - TencentBrowserSessionArtifacts, -} from './activity' -import type { TencentBrowserRedeemResult } from './redeem' - -export type TencentLoginType = 'qq' | 'wx' - -export type TencentBrowserSessionStatus = - | 'waiting_scan' - | 'scanned' - | 'expired' - | 'logged_in' - | 'ready_to_redeem' - | 'redeeming' - | 'redeemed' - | 'closed' - | 'failed' - | (string & {}) - -export interface TencentBrowserSessionData { - sessionId: string - loginType: TencentLoginType | string - activityUrl: string - status: TencentBrowserSessionStatus - notice: string - createdAt: string - updatedAt: string - expiresAt: string - qrImageBase64: string - qrUpdatedAt: string - credentialReady: boolean - activityInfo?: TencentBrowserActivityInfo | null - review?: TencentBrowserReviewInfo | null - redeem?: TencentBrowserRedeemResult | null - artifacts: TencentBrowserSessionArtifacts -} - -export interface TencentBrowserSessionSummaryData extends Omit< - TencentBrowserSessionData, - 'qrImageBase64' -> { - qrImageBase64?: string -} diff --git a/apps/frontend/src/utils/admin-display.ts b/apps/frontend/src/utils/admin-display.ts index 9dead2ba..c579d923 100644 --- a/apps/frontend/src/utils/admin-display.ts +++ b/apps/frontend/src/utils/admin-display.ts @@ -1,234 +1,135 @@ -export function formatStatusWithRaw( - status: string, - labelMap: Record = {} -) { - const normalized = String(status || "").trim(); +export function formatStatusWithRaw(status: string, labelMap: Record = {}) { + const normalized = String(status || '').trim() if (!normalized) { - return "-"; + return '-' } - const key = normalized.toLowerCase(); - const label = labelMap[key]; + const key = normalized.toLowerCase() + const label = labelMap[key] if (!label || label === normalized) { - return normalized; + return normalized } - return `${label} (${normalized})`; + return `${label} (${normalized})` } export function formatAuditAction(action: string) { const labelMap: Record = { - admin_user_created: "创建后台用户", - admin_user_role_updated: "修改用户角色", - admin_user_status_updated: "修改用户状态", - admin_user_password_reset: "重置用户密码", - task_release_inventory: "释放任务库存", - task_release_inventory_binding: "释放任务库存绑定", - task_regenerate_claim_link: "重发领取链接", - task_closed: "关闭任务", - task_mark_manual_review: "转人工处理", - task_complete_manual_dispatch: "完成人工履约", - inventory_item_released: "释放库存项", - inventory_item_invalidated: "作废库存项", - webhook_replayed: "重放 Webhook", - platform_shop_config_updated: "更新店铺配置", - platform_fulfillment_config_updated: "更新履约配置", - }; + admin_user_created: '创建后台用户', + admin_user_role_updated: '修改用户角色', + admin_user_status_updated: '修改用户状态', + admin_user_password_reset: '重置用户密码', + task_regenerate_claim_link: '重发领取链接', + task_kuaishou_cloud_rebind_role: '换绑角色', + task_closed: '关闭任务', + task_mark_manual_review: '转人工处理', + task_complete_manual_dispatch: '完成人工履约', + platform_shop_config_updated: '更新店铺配置', + platform_fulfillment_config_updated: '更新履约配置', + } - return formatStatusWithRaw(action, labelMap); + return formatStatusWithRaw(action, labelMap) } export function formatAuditTargetType(targetType: string) { const labelMap: Record = { - admin_user: "后台用户", - task: "交付任务", - task_inventory_binding: "任务库存绑定", - inventory_item: "库存项", - webhook_event: "Webhook 事件", - platform_config: "平台配置", - }; + admin_user: '后台用户', + task: '交付任务', + platform_config: '平台配置', + } - return formatStatusWithRaw(targetType, labelMap); + return formatStatusWithRaw(targetType, labelMap) } export function formatTaskEventType(eventType: string) { const labelMap: Record = { - manual_dispatch_completed: "人工履约已回写", - inventory_binding_released: "库存绑定已释放", - claim_redeem_code_used: "兑换码已使用", - claim_redeem_code_invalid: "兑换码错误", - claim_redeem_inventory_replaced: "已切换新兑换码", - claim_redeem_completed: "兑换链路完成", - agiso_auto_delivery_success: "咸鱼自动发货成功", - agiso_auto_delivery_failed: "咸鱼自动发货失败", - agiso_auto_delivery_skipped: "咸鱼自动发货已跳过", - }; + manual_dispatch_completed: '人工履约已回写', + claim_redeem_code_used: '兑换码已使用', + claim_redeem_code_invalid: '兑换码错误', + claim_redeem_completed: '兑换链路完成', + kuaishou_cloud_rebind_requested: '发起角色换绑', + kuaishou_cloud_rebind_old_number_returned: '换绑旧号已退还', + kuaishou_cloud_rebind_prepared: '换绑资源已准备', + kuaishou_cloud_rebind_failed: '换绑失败', + } - return formatStatusWithRaw(eventType, labelMap); + return formatStatusWithRaw(eventType, labelMap) } export function formatRedeemOutcomeLabel(value: string) { - const normalized = String(value || "").trim(); + const normalized = String(value || '').trim() switch (normalized) { - case "success": - return "兑换成功"; - case "code_used": - return "兑换码已使用"; - case "code_invalid": - return "兑换码错误"; - case "captcha_rejected": - return "验证码错误"; - case "failed": - return "兑换失败"; + case 'success': + return '兑换成功' + case 'code_used': + return '兑换码已使用' + case 'code_invalid': + return '兑换码错误' + case 'captcha_rejected': + return '验证码错误' + case 'failed': + return '兑换失败' default: - return normalized || "-"; + return normalized || '-' } } export function formatRedeemResolutionStatus(value: string) { - return value === "success" - ? "已完成" - : value === "failed" - ? "失败收口" - : value || "-"; -} - -export function formatAutoDeliveryTrigger(value: string) { - const normalized = String(value || "").trim(); - - switch (normalized) { - case "claim_redeemed": - return "用户兑换完成后触发"; - case "manual_dispatch_completed": - return "人工履约完成后触发"; - case "task_delivered": - return "任务交付完成后触发"; - default: - return normalized || "-"; - } -} - -export function formatAutoDeliveryReason(value: string) { - const normalized = String(value || "").trim(); - - switch (normalized) { - case "waiting_other_tasks": - return "订单下仍有任务未完成交付"; - case "already_sent": - return "该订单已经执行过自动发货"; - case "auto_delivery_disabled": - return "自动发货开关已关闭"; - case "missing_config": - return "缺少 Agiso 自动发货配置"; - case "request_failed": - return "Agiso 接口返回失败"; - case "request_error": - return "请求 Agiso 接口时发生异常"; - case "delivery_not_confirmed": - return "接口返回成功,但订单状态未确认进入已发货"; - case "task_not_delivered": - return "当前任务尚未进入已交付状态"; - case "not_supported": - return "当前订单不支持 Agiso 咸鱼自动发货"; - default: - return normalized || "-"; - } + return value === 'success' ? '已完成' : value === 'failed' ? '失败收口' : value || '-' } export function formatKuaishouRoleInfoLabel(value: string) { - const normalized = String(value || "").trim(); + const normalized = String(value || '').trim() switch (normalized) { - case "name": - return "角色名称"; - case "rid": - return "角色 ID"; + case 'name': + return '角色名称' + case 'rid': + return '角色 ID' default: - return normalized || "-"; + return normalized || '-' } } export function formatTaskEventPayload(payload: Record) { const parts = [ - payload.outcome ? `结果 ${payload.outcome}` : "", - payload.resultCode ? `代码 ${payload.resultCode}` : "", - payload.resultMessage ? `说明 ${payload.resultMessage}` : "", - payload.codeMasked ? `凭据 ${payload.codeMasked}` : "", - payload.previousCodeMasked ? `旧码 ${payload.previousCodeMasked}` : "", - payload.nextCodeMasked ? `新码 ${payload.nextCodeMasked}` : "", - payload.inventoryItemId ? `库存项 #${payload.inventoryItemId}` : "", - payload.previousInventoryItemId - ? `旧库存 #${payload.previousInventoryItemId}` - : "", - payload.nextInventoryItemId ? `新库存 #${payload.nextInventoryItemId}` : "", - payload.ticketCodeMasked ? `卡券号 ${payload.ticketCodeMasked}` : "", - payload.vnId ? `虚拟号 #${payload.vnId}` : "", - payload.vnPhoneMasked ? `号码 ${payload.vnPhoneMasked}` : "", - payload.bindUrl ? `绑定链接 ${payload.bindUrl}` : "", - payload.sendType ? `发送类型 ${payload.sendType}` : "", - payload.note ? `备注 ${payload.note}` : "", + payload.outcome ? `结果 ${payload.outcome}` : '', + payload.resultCode ? `代码 ${payload.resultCode}` : '', + payload.resultMessage ? `说明 ${payload.resultMessage}` : '', + payload.codeMasked ? `凭据 ${payload.codeMasked}` : '', + payload.previousCodeMasked ? `旧码 ${payload.previousCodeMasked}` : '', + payload.nextCodeMasked ? `新码 ${payload.nextCodeMasked}` : '', + payload.ticketCodeMasked ? `卡券号 ${payload.ticketCodeMasked}` : '', + payload.vnId ? `虚拟号 #${payload.vnId}` : '', + payload.oldVnId ? `旧虚拟号 #${payload.oldVnId}` : '', + payload.vnPhoneMasked ? `号码 ${payload.vnPhoneMasked}` : '', + payload.oldVnPhoneMasked ? `旧号码 ${payload.oldVnPhoneMasked}` : '', + payload.oldRoleName ? `旧角色 ${payload.oldRoleName}` : '', + payload.oldRoleId ? `旧角色ID ${payload.oldRoleId}` : '', + payload.attempt ? `第 ${payload.attempt} 次` : '', + payload.bindUrl ? `绑定链接 ${payload.bindUrl}` : '', + payload.sendType ? `发送类型 ${payload.sendType}` : '', + payload.note ? `备注 ${payload.note}` : '', payload.previousOutcome - ? `切换原因 ${formatRedeemOutcomeLabel( - String(payload.previousOutcome || "") - )}` - : "", - payload.deliveryReference ? `单号 ${payload.deliveryReference}` : "", - payload.platformOrderId ? `平台单 ${payload.platformOrderId}` : "", - payload.trigger - ? `触发 ${formatAutoDeliveryTrigger(String(payload.trigger || ""))}` - : "", - payload.reason ? `原因 ${payload.reason}` : "", - payload.purchaseTriggered === true ? "已触发购买" : "", - payload.usedKnapsack === true ? "使用背包库存" : "", - payload.responseStatus ? `HTTP ${payload.responseStatus}` : "", - payload.requestId ? `请求 ${payload.requestId}` : "", - payload.errorMessage ? `错误 ${payload.errorMessage}` : "", - ].filter(Boolean); + ? `切换原因 ${formatRedeemOutcomeLabel(String(payload.previousOutcome || ''))}` + : '', + payload.deliveryReference ? `单号 ${payload.deliveryReference}` : '', + payload.platformOrderId ? `平台单 ${payload.platformOrderId}` : '', + payload.trigger ? `触发 ${payload.trigger}` : '', + payload.reason ? `原因 ${payload.reason}` : '', + payload.purchaseTriggered === true ? '已触发购买' : '', + payload.usedKnapsack === true ? '使用背包库存' : '', + payload.responseStatus ? `HTTP ${payload.responseStatus}` : '', + payload.requestId ? `请求 ${payload.requestId}` : '', + payload.errorMessage ? `错误 ${payload.errorMessage}` : '', + ].filter(Boolean) if (parts.length > 0) { - return parts.join(" · "); + return parts.join(' · ') } - return JSON.stringify(payload || {}); -} - -export function formatBindingRoleSummary( - roleKeys: string[] | undefined | null, - emptyLabel = "-" -) { - if (!Array.isArray(roleKeys) || roleKeys.length === 0) return emptyLabel; - return roleKeys.join(" / "); -} - -export function formatBindingCountSummary(summary: { - totalBindingCount: number; - reservedBindingCount: number; - consumedBindingCount: number; - releasedBindingCount: number; -}) { - return `共 ${summary.totalBindingCount} 条 · 预占 ${summary.reservedBindingCount} · 已消费 ${summary.consumedBindingCount} · 已释放 ${summary.releasedBindingCount}`; -} - -export function resolveTaskAutoDeliveryStatus(task: { - agisoAutoDelivery?: { status?: string } | null; - deliveryStatus: string; -}) { - if (task.agisoAutoDelivery?.status) return task.agisoAutoDelivery.status; - return task.deliveryStatus === "delivered" ? "pending" : "waiting"; -} - -export function formatTaskAutoDeliveryReason(task: { - agisoAutoDelivery?: { errorMessage?: string; reason?: string } | null; - deliveryStatus: string; -}) { - if (task.agisoAutoDelivery?.errorMessage) - return task.agisoAutoDelivery.errorMessage; - if (task.agisoAutoDelivery?.reason) - return formatAutoDeliveryReason(task.agisoAutoDelivery.reason); - return task.deliveryStatus === "delivered" - ? "当前任务已交付,等待订单统一自动发货" - : "当前任务尚未完成交付"; + return JSON.stringify(payload || {}) } diff --git a/apps/frontend/src/utils/admin-options.ts b/apps/frontend/src/utils/admin-options.ts index 0225b131..5abb1c10 100644 --- a/apps/frontend/src/utils/admin-options.ts +++ b/apps/frontend/src/utils/admin-options.ts @@ -10,8 +10,6 @@ export const adminTaskStatusOptions = [ { label: '全部', value: '' }, { label: '待支付', value: 'pending_payment' }, { label: '已支付', value: 'paid' }, - { label: '待库存', value: 'waiting_inventory' }, - { label: '已预占', value: 'cdk_reserved' }, { label: '已生成链接', value: 'link_generated' }, { label: '已创建会话', value: 'claimed' }, { label: '已确认角色', value: 'role_confirmed' }, @@ -23,43 +21,6 @@ export const adminTaskStatusOptions = [ { label: '已关闭', value: 'closed' }, ] -export const adminInventoryStatusOptions = [ - { label: '全部', value: '' }, - { label: '可用', value: 'available' }, - { label: '已预占', value: 'reserved' }, - { label: '已发放', value: 'delivered' }, - { label: '已消耗', value: 'consumed' }, - { label: '已作废', value: 'invalid' }, -] - -export const adminInventoryCredentialTypeOptions = [ - { label: '全部凭据类型', value: '' }, - { label: '腾讯兑换码(自动领取)', value: 'tencent_code' }, - { label: '卡号卡密', value: 'card_password' }, - { label: '账号密码', value: 'account_password' }, - { label: '激活链接', value: 'activation_link' }, - { label: '纯文本凭据', value: 'text_credential' }, -] - -export const adminMessageDeliveryStatusOptions = [ - { label: '全部状态', value: '' }, - { label: '发送成功', value: 'success' }, - { label: '发送失败', value: 'failed' }, - { label: '等待发送', value: 'pending' }, -] - -export const adminWebhookProcessedOptions = [ - { label: '全部', value: '' }, - { label: '处理成功', value: '1' }, - { label: '处理失败', value: '0' }, -] - -export const adminWebhookVisibilityOptions = [ - { label: '仅重要', value: 'important' }, - { label: '仅忽略', value: 'ignored' }, - { label: '全部', value: 'all' }, -] - export const adminUserRoleOptions = [ { label: '全部', value: '' }, { label: '管理员', value: 'admin' }, @@ -78,21 +39,14 @@ export const adminAuditActionOptions = [ { label: '创建后台用户', value: 'admin_user_created' }, { label: '修改用户角色', value: 'admin_user_role_updated' }, { label: '修改用户状态', value: 'admin_user_status_updated' }, - { label: '修改用户库存组', value: 'admin_user_inventory_groups_updated' }, { label: '重置用户密码', value: 'admin_user_password_reset' }, - { label: '释放任务库存', value: 'task_release_inventory' }, - { label: '重发领取链接', value: 'task_regenerate_claim_link' }, + { label: '换绑角色', value: 'task_kuaishou_cloud_rebind_role' }, { label: '关闭任务', value: 'task_closed' }, { label: '转人工处理', value: 'task_mark_manual_review' }, - { label: '释放库存项', value: 'inventory_item_released' }, - { label: '作废库存项', value: 'inventory_item_invalidated' }, - { label: '重放 Webhook', value: 'webhook_replayed' }, ] export const adminAuditTargetTypeOptions = [ { label: '全部', value: '' }, { label: '后台用户', value: 'admin_user' }, { label: '交付任务', value: 'task' }, - { label: '库存项', value: 'inventory_item' }, - { label: 'Webhook 事件', value: 'webhook_event' }, ] diff --git a/apps/frontend/src/utils/admin-webhook.ts b/apps/frontend/src/utils/admin-webhook.ts deleted file mode 100644 index 32af4328..00000000 --- a/apps/frontend/src/utils/admin-webhook.ts +++ /dev/null @@ -1,22 +0,0 @@ -import { formatStatusWithRaw } from '@/utils/admin-display' - -export function formatAdminWebhookEventType(eventType: string) { - const normalized = String(eventType || '').trim() - - if (!normalized) { - return '-' - } - - const labelMap: Record = { - payment_success: '支付成功', - trade_create: '订单创建', - trade_closed: '订单关闭', - buyer_confirm_goods: '确认收货', - auto_delivery_done: '自动发货完成', - auto_delivery_confirmed: '自动发货确认完成', - refund_apply: '买家申请退款', - trade_event: '交易事件', - } - - return formatStatusWithRaw(normalized, labelMap) -} diff --git a/apps/frontend/src/views/NotFoundView.vue b/apps/frontend/src/views/NotFoundView.vue index d6f04c77..50882789 100644 --- a/apps/frontend/src/views/NotFoundView.vue +++ b/apps/frontend/src/views/NotFoundView.vue @@ -3,8 +3,8 @@

404

页面不存在

-

当前前端只保留腾讯浏览器兑换页,这个地址没有对应页面。

- 前往兑换入口 +

当前快手轻量前端没有对应页面。

+ 前往后台
diff --git a/apps/frontend/src/views/admin/cloudtentacles-records/AdminCloudtentaclesRecordsView.vue b/apps/frontend/src/views/admin/cloudtentacles-records/AdminCloudtentaclesRecordsView.vue new file mode 100644 index 00000000..25a5f6f3 --- /dev/null +++ b/apps/frontend/src/views/admin/cloudtentacles-records/AdminCloudtentaclesRecordsView.vue @@ -0,0 +1,649 @@ + + + + + diff --git a/apps/frontend/src/views/admin/dashboard/AdminDashboardView.vue b/apps/frontend/src/views/admin/dashboard/AdminDashboardView.vue index 41a81e97..4d73b0ec 100644 --- a/apps/frontend/src/views/admin/dashboard/AdminDashboardView.vue +++ b/apps/frontend/src/views/admin/dashboard/AdminDashboardView.vue @@ -26,7 +26,6 @@ const cards = [ { key: 'claimingTasks', label: '领取中' }, { key: 'redeemedToday', label: '今日成功' }, { key: 'abnormalTasks', label: '异常任务' }, - { key: 'skuWithInventory', label: '有库存 SKU' }, ] as const diff --git a/apps/frontend/src/views/admin/fulfillment/AdminFulfillmentConfigHubView.vue b/apps/frontend/src/views/admin/fulfillment/AdminFulfillmentConfigHubView.vue index b2f58824..e4e151b6 100644 --- a/apps/frontend/src/views/admin/fulfillment/AdminFulfillmentConfigHubView.vue +++ b/apps/frontend/src/views/admin/fulfillment/AdminFulfillmentConfigHubView.vue @@ -1,45 +1,15 @@ @@ -48,21 +18,4 @@ const activeTab = computed({ display: grid; gap: var(--space-4); } - -:deep(.hub-tabs .el-tabs__header) { - margin-bottom: 0; -} - -:deep(.hub-tabs .el-tabs__content) { - padding-top: var(--space-4); -} - -:deep(.hub-tabs .el-tabs__item) { - font-weight: 500; - font-size: var(--text-base); -} - -:deep(.hub-tabs .el-tabs__item.is-active) { - font-weight: 600; -} diff --git a/apps/frontend/src/views/admin/fulfillment/bindings/AdminFulfillmentBindings.css b/apps/frontend/src/views/admin/fulfillment/bindings/AdminFulfillmentBindings.css deleted file mode 100644 index fbccea8a..00000000 --- a/apps/frontend/src/views/admin/fulfillment/bindings/AdminFulfillmentBindings.css +++ /dev/null @@ -1,290 +0,0 @@ -.admin-panel { - display: grid; - gap: var(--space-4); -} - -.section-card { - border-radius: var(--radius-lg); - border: 1px solid var(--border-default); - background: var(--bg-surface); -} - -.section-card :deep(.el-card__header) { - background: var(--bg-surface); - border-bottom: 1px solid var(--border-default); - padding: var(--space-4) var(--space-5); -} - -.section-card :deep(.el-card__body) { - padding: var(--space-5); -} - -.card-header { - display: flex; - flex-direction: column; - gap: var(--space-2); -} - -.card-header--split { - flex-direction: row; - justify-content: space-between; - align-items: flex-start; -} - -.card-title { - display: block; - font-size: var(--text-lg); - font-weight: 600; - color: var(--text-primary); - margin-bottom: var(--space-1); -} - -.card-desc { - display: block; - font-size: var(--text-sm); - color: var(--text-secondary); - line-height: 1.5; -} - -.overview-card { - display: grid; - gap: var(--space-5); -} - -.overview-file { - margin-top: var(--space-1); -} - -.overview-file code { - color: var(--text-primary); - word-break: break-all; - font-size: var(--text-sm); -} - -.overview-stats { - display: grid; - grid-template-columns: repeat(5, minmax(0, 1fr)); - gap: var(--space-4); -} - -.overview-stat { - min-width: 0; - padding: var(--space-4); - border: 1px solid var(--border-default); - border-radius: var(--radius-md); - background: var(--bg-surface); -} - -.overview-stat :deep(.el-statistic__head) { - margin-bottom: var(--space-2); - color: var(--text-secondary); - font-size: var(--text-xs); - font-weight: 600; - text-transform: uppercase; - letter-spacing: 0.5px; -} - -.overview-stat :deep(.el-statistic__content) { - color: var(--text-primary); - font-size: var(--text-2xl); - font-weight: 700; -} - -.overview-notes, -.section-mini-stats { - display: flex; - flex-wrap: wrap; - gap: var(--space-2); -} - -.lookup-toolbar, -.lookup-summary { - display: grid; - gap: var(--space-4); -} - -.lookup-toolbar { - grid-template-columns: 96px 110px minmax(140px, 1fr) minmax(180px, 1.2fr) 136px; - align-items: end; -} - -.lookup-summary { - margin-top: var(--space-1); -} - -.field-help { - display: block; - margin-top: var(--space-2); - color: var(--text-secondary); - font-size: var(--text-xs); - line-height: 1.6; -} - -.field-wide { - grid-column: 1 / -1; -} - -.text-input { - width: 100%; -} - -.admin-panel :deep(.text-input--invalid .el-input__wrapper), -.admin-panel :deep(.text-input--invalid .el-select__wrapper) { - border-color: var(--color-danger); - box-shadow: 0 0 0 1px var(--color-danger) inset; -} - -.admin-panel :deep(.el-form-item) { - margin-bottom: 0; -} - -.admin-panel :deep(.el-form-item__label) { - color: var(--text-secondary); - font-size: var(--text-sm); - font-weight: 600; - line-height: 1.5; - margin-bottom: var(--space-2); -} - -.cell-subtle { - color: var(--text-secondary); - display: block; - font-size: var(--text-xs); - line-height: 1.6; -} - -.cell-stack { - display: flex; - flex-direction: column; - gap: var(--space-1); -} - -.binding-card { - margin-top: var(--space-3); - background: var(--bg-surface); - border: 1px solid var(--border-default); - border-radius: var(--radius-md); -} - -.binding-card :deep(.el-card__header) { - padding: var(--space-3) var(--space-4); - background: var(--bg-surface); - border-bottom: 1px solid var(--border-default); -} - -.binding-card :deep(.el-card__body) { - display: grid; - gap: var(--space-4); - padding: var(--space-4); -} - -.binding-card--invalid { - border-color: var(--color-danger); - box-shadow: 0 0 0 3px rgba(220, 38, 38, 0.08); -} - -.binding-header { - display: flex; - justify-content: space-between; - gap: var(--space-4); - align-items: flex-start; -} - -.binding-summary { - display: grid; - gap: var(--space-2); - flex: 1; -} - -.binding-title-row { - display: flex; - gap: var(--space-2); - align-items: center; - flex-wrap: wrap; - color: var(--text-primary); - font-size: var(--text-base); -} - -.binding-subtle { - color: var(--text-secondary); - font-size: var(--text-sm); - line-height: 1.5; -} - -.binding-grid { - display: grid; - grid-template-columns: repeat(2, minmax(0, 1fr)); - gap: var(--space-4) var(--space-5); -} - -.binding-collapsed-preview { - color: var(--text-secondary); - font-size: var(--text-sm); -} - -.binding-collapsed-preview :deep(.el-descriptions__label) { - font-weight: 600; -} - -.binding-actions { - display: flex; - justify-content: space-between; - gap: var(--space-3); - align-items: center; - padding-top: var(--space-3); - border-top: 1px solid var(--border-default); -} - -@media (max-width: 900px) { - .card-header--split, - .binding-actions, - .binding-header { - display: grid; - grid-template-columns: 1fr; - align-items: stretch; - gap: var(--space-3); - } - - .overview-stats, - .lookup-toolbar, - .binding-grid { - grid-template-columns: 1fr; - } - - .binding-collapsed-preview :deep(.el-descriptions__body .el-descriptions__table) { - display: block; - } - - .section-card :deep(.el-card__header), - .section-card :deep(.el-card__body) { - padding: var(--space-4); - } - - .binding-card :deep(.el-card__header), - .binding-card :deep(.el-card__body) { - padding: var(--space-3); - } -} - -.data-table { - margin-top: var(--space-4); -} - -.data-table :deep(.el-table__header th) { - background: var(--bg-surface); - color: var(--text-secondary); - font-weight: 600; - font-size: var(--text-sm); -} - -.data-table :deep(.el-table__body td) { - padding: var(--space-3) var(--space-2); -} - -.mt-4 { - margin-top: var(--space-4); -} - -.w-full { - width: 100%; -} diff --git a/apps/frontend/src/views/admin/fulfillment/bindings/AdminFulfillmentBindingsView.vue b/apps/frontend/src/views/admin/fulfillment/bindings/AdminFulfillmentBindingsView.vue deleted file mode 100644 index a3cf2b6e..00000000 --- a/apps/frontend/src/views/admin/fulfillment/bindings/AdminFulfillmentBindingsView.vue +++ /dev/null @@ -1,73 +0,0 @@ - - - - - diff --git a/apps/frontend/src/views/admin/fulfillment/bindings/components/AdminBindingRuleCard.vue b/apps/frontend/src/views/admin/fulfillment/bindings/components/AdminBindingRuleCard.vue deleted file mode 100644 index 5f10455c..00000000 --- a/apps/frontend/src/views/admin/fulfillment/bindings/components/AdminBindingRuleCard.vue +++ /dev/null @@ -1,213 +0,0 @@ - - - - - diff --git a/apps/frontend/src/views/admin/fulfillment/bindings/components/AdminBindingsLookupSection.vue b/apps/frontend/src/views/admin/fulfillment/bindings/components/AdminBindingsLookupSection.vue deleted file mode 100644 index 397a2f38..00000000 --- a/apps/frontend/src/views/admin/fulfillment/bindings/components/AdminBindingsLookupSection.vue +++ /dev/null @@ -1,156 +0,0 @@ - - - - - diff --git a/apps/frontend/src/views/admin/fulfillment/bindings/components/AdminBindingsObservedSection.vue b/apps/frontend/src/views/admin/fulfillment/bindings/components/AdminBindingsObservedSection.vue deleted file mode 100644 index d1fe9e15..00000000 --- a/apps/frontend/src/views/admin/fulfillment/bindings/components/AdminBindingsObservedSection.vue +++ /dev/null @@ -1,90 +0,0 @@ - - - - - diff --git a/apps/frontend/src/views/admin/fulfillment/bindings/components/AdminBindingsOverviewSection.vue b/apps/frontend/src/views/admin/fulfillment/bindings/components/AdminBindingsOverviewSection.vue deleted file mode 100644 index 8c711113..00000000 --- a/apps/frontend/src/views/admin/fulfillment/bindings/components/AdminBindingsOverviewSection.vue +++ /dev/null @@ -1,48 +0,0 @@ - - - - - diff --git a/apps/frontend/src/views/admin/fulfillment/bindings/components/AdminBindingsRuleSection.vue b/apps/frontend/src/views/admin/fulfillment/bindings/components/AdminBindingsRuleSection.vue deleted file mode 100644 index a021340e..00000000 --- a/apps/frontend/src/views/admin/fulfillment/bindings/components/AdminBindingsRuleSection.vue +++ /dev/null @@ -1,106 +0,0 @@ - - - - - diff --git a/apps/frontend/src/views/admin/fulfillment/bindings/composables/types.ts b/apps/frontend/src/views/admin/fulfillment/bindings/composables/types.ts deleted file mode 100644 index bc5c17c0..00000000 --- a/apps/frontend/src/views/admin/fulfillment/bindings/composables/types.ts +++ /dev/null @@ -1,66 +0,0 @@ -import type { AdminObservedProductItem } from '@/types/admin' - -export type EditableBinding = { - id: string - provider: string - platform: string - shopId: string - shopName: string - skuCode: string - skuName: string - profileKey: string - enabled: boolean - priority: number - externalSkuCode: string - externalItemId: string - externalSkuName: string - resolvedSkuName: string -} - -export type SaveBindingPayload = { - provider: string - platform: string - shopId: string - shopName: string - skuCode: string - skuName: string - profileKey: string - enabled: boolean - priority: number - match: { - externalSkuCode: string - externalItemId: string - externalSkuName: string - config: { - resolvedSkuName: string - } - } -} - -export type ValidationField = 'skuCode' | 'match' | 'profileKey' | 'shopId' - -export type ValidationState = { - bindingId: string - field: ValidationField - message: string -} | null - -export type ImportableProductCandidate = Pick< - AdminObservedProductItem, - | 'provider' - | 'platform' - | 'shopId' - | 'shopName' - | 'externalSkuCode' - | 'externalItemId' - | 'externalSkuName' -> & {} - -export const PROFILE_OPTIONS = [ - { label: '腾讯领取兑换', value: 'tencent_claim_redeem' }, - { label: '腾讯领取兑换(半自动+人工)', value: 'tencent_claim_assisted' }, - { label: '快手 Cloud 履约', value: 'kuaishou_ct_assisted' }, - { label: '人工发货', value: 'manual_review' }, -] - -export const VALID_PROFILE_KEYS = new Set(PROFILE_OPTIONS.map((item) => item.value)) diff --git a/apps/frontend/src/views/admin/fulfillment/bindings/composables/useFulfillmentBindings.ts b/apps/frontend/src/views/admin/fulfillment/bindings/composables/useFulfillmentBindings.ts deleted file mode 100644 index b75019d8..00000000 --- a/apps/frontend/src/views/admin/fulfillment/bindings/composables/useFulfillmentBindings.ts +++ /dev/null @@ -1,453 +0,0 @@ -import { computed, nextTick, ref } from 'vue' - -import { showSuccess } from '@/lib/feedback' -import { - fetchAdminFulfillmentBindingConfigs, - saveAdminFulfillmentBindingConfigs, -} from '@/services/admin' -import type { - AdminFulfillmentBindingConfigItem, - AdminObservedProductItem, -} from '@/types/admin' -import { hasAdminRole } from '@/utils/admin-auth' - -import { - VALID_PROFILE_KEYS, - type EditableBinding, - type ImportableProductCandidate, - type SaveBindingPayload, - type ValidationField, - type ValidationState, -} from './types' - -export function useFulfillmentBindings() { - const loading = ref(true) - const saving = ref(false) - const errorMessage = ref('') - const filePath = ref('') - const bindings = ref([]) - const observedProducts = ref([]) - const validationState = ref(null) - const collapsedBindingIds = ref([]) - - // ── computed ────────────────────────────────────────── - - const ruleMetrics = computed(() => { - const completedCount = bindings.value.filter(isEditableBindingComplete).length - const manualCount = bindings.value.filter( - (item) => item.profileKey.trim() === 'manual_review', - ).length - const assistedCount = bindings.value.filter( - (item) => item.profileKey.trim() === 'tencent_claim_assisted', - ).length - const kuaishouCloudCount = bindings.value.filter( - (item) => item.profileKey.trim() === 'kuaishou_ct_assisted', - ).length - - return { - totalCount: bindings.value.length, - completedCount, - draftCount: Math.max(bindings.value.length - completedCount, 0), - manualCount, - assistedCount, - kuaishouCloudCount, - } - }) - - const pendingObservedCount = computed( - () => observedProducts.value.filter((item) => !item.configured).length, - ) - - // ── factories ──────────────────────────────────────── - - function createEmptyBinding(): EditableBinding { - return { - id: crypto.randomUUID(), - provider: 'agiso', - platform: '', - shopId: '', - shopName: '', - skuCode: '', - skuName: '', - profileKey: 'manual_review', - enabled: true, - priority: 100, - externalSkuCode: '', - externalItemId: '', - externalSkuName: '', - resolvedSkuName: '', - } - } - - function mapEditableBinding(item: AdminFulfillmentBindingConfigItem): EditableBinding { - return { - id: crypto.randomUUID(), - provider: item.provider || 'agiso', - platform: item.platform, - shopId: item.shopId, - shopName: item.shopName || '', - skuCode: item.skuCode, - skuName: item.skuName, - profileKey: item.profileKey || 'manual_review', - enabled: item.enabled !== false, - priority: item.priority || 100, - externalSkuCode: item.match.externalSkuCode, - externalItemId: item.match.externalItemId, - externalSkuName: item.match.externalSkuName, - resolvedSkuName: String(item.match.config?.resolvedSkuName || ''), - } - } - - // ── validation ──────────────────────────────────────── - - function hasMatchCondition( - item: Pick, - ) { - return Boolean( - item.externalSkuCode.trim() || item.externalItemId.trim() || item.externalSkuName.trim(), - ) - } - - function isEditableBindingComplete(item: EditableBinding) { - return Boolean( - item.skuCode.trim() && - hasMatchCondition(item) && - VALID_PROFILE_KEYS.has(item.profileKey.trim() || 'manual_review'), - ) - } - - // ── collapse state ──────────────────────────────────── - - function buildCollapsedIds(items: EditableBinding[]) { - return items.filter(isEditableBindingComplete).map((item) => item.id) - } - - function isBindingCollapsed(bindingId: string) { - return collapsedBindingIds.value.includes(bindingId) - } - - function setBindingCollapsed(bindingId: string, collapsed: boolean) { - const next = new Set(collapsedBindingIds.value) - - if (collapsed) { - next.add(bindingId) - } else { - next.delete(bindingId) - } - - collapsedBindingIds.value = Array.from(next) - } - - function toggleBindingCollapsed(bindingId: string) { - setBindingCollapsed(bindingId, !isBindingCollapsed(bindingId)) - } - - // ── display helpers ─────────────────────────────────── - - function getBindingTitle(binding: EditableBinding, index: number) { - const internalName = binding.skuName.trim() - const externalName = binding.externalSkuName.trim() - const skuCode = binding.skuCode.trim() - - return internalName || externalName || skuCode || `规则 ${index + 1}` - } - - function getBindingStatusLabel(binding: EditableBinding) { - return isEditableBindingComplete(binding) ? '已完成' : '待完善' - } - - function getBindingSummary(binding: EditableBinding) { - const parts = [ - binding.provider.trim() || 'agiso', - binding.platform.trim() || '未选平台', - binding.shopName.trim() || binding.shopId.trim() || '跨店铺', - binding.profileKey.trim() || 'manual_review', - ] - - return parts.join(' / ') - } - - // ── data loading ────────────────────────────────────── - - async function loadConfigs() { - if (!hasAdminRole('admin')) { - loading.value = false - return - } - - loading.value = true - errorMessage.value = '' - - try { - const response = await fetchAdminFulfillmentBindingConfigs() - filePath.value = response.data.filePath - const nextBindings = response.data.bindings.map(mapEditableBinding) - bindings.value = nextBindings - collapsedBindingIds.value = buildCollapsedIds(nextBindings) - observedProducts.value = response.data.observedProducts - } catch (error) { - errorMessage.value = error instanceof Error ? error.message : '读取履约配置失败' - } finally { - loading.value = false - } - } - - // ── mutation ────────────────────────────────────────── - - function addBinding() { - validationState.value = null - const next = createEmptyBinding() - setBindingCollapsed(next.id, false) - bindings.value.unshift(next) - } - - function removeBinding(id: string) { - if (validationState.value?.bindingId === id) { - validationState.value = null - } - setBindingCollapsed(id, false) - bindings.value = bindings.value.filter((item) => item.id !== id) - } - - // ── import from observed products ──────────────────── - - function createBindingFromProductCandidate(item: ImportableProductCandidate): EditableBinding { - const binding = { - id: crypto.randomUUID(), - provider: item.provider || 'agiso', - platform: item.platform, - shopId: item.shopId, - shopName: item.shopName, - skuCode: '', - skuName: item.externalSkuName, - profileKey: 'manual_review', - enabled: true, - priority: 100, - externalSkuCode: item.externalSkuCode, - externalItemId: item.externalItemId, - externalSkuName: item.externalSkuName, - resolvedSkuName: item.externalSkuName, - } - - return binding - } - - function importObservedProduct(item: ImportableProductCandidate) { - validationState.value = null - const nextBinding = createBindingFromProductCandidate(item) - setBindingCollapsed(nextBinding.id, false) - bindings.value.unshift(nextBinding) - } - - // ── save ────────────────────────────────────────────── - - function normalizeBindingForSave(item: EditableBinding): SaveBindingPayload { - return { - provider: item.provider.trim() || 'agiso', - platform: item.platform.trim(), - shopId: item.shopId.trim(), - shopName: item.shopName.trim(), - skuCode: item.skuCode.trim(), - skuName: item.skuName.trim(), - profileKey: item.profileKey.trim() || 'manual_review', - enabled: item.enabled, - priority: Number(item.priority || 100), - match: { - externalSkuCode: item.externalSkuCode.trim(), - externalItemId: item.externalItemId.trim(), - externalSkuName: item.externalSkuName.trim(), - config: { - resolvedSkuName: item.resolvedSkuName.trim(), - }, - }, - } - } - - function hasBindingContent(item: SaveBindingPayload) { - return Boolean( - item.platform || - item.shopId || - item.shopName || - item.skuCode || - item.skuName || - item.match.externalSkuCode || - item.match.externalItemId || - item.match.externalSkuName || - item.match.config.resolvedSkuName || - item.provider !== 'agiso' || - item.profileKey !== 'manual_review' || - item.priority !== 100 || - item.enabled !== true, - ) - } - - function resolveBindingValidationState( - item: SaveBindingPayload, - index: number, - bindingId: string, - ): ValidationState { - const label = `第 ${index + 1} 条规则` - - if (!item.skuCode) { - return { - bindingId, - field: 'skuCode', - message: `${label} 还没填写"内部履约 SKU"`, - } - } - - if (!item.match.externalSkuCode && !item.match.externalItemId && !item.match.externalSkuName) { - return { - bindingId, - field: 'match', - message: `${label} 至少填写一种外部匹配条件`, - } - } - - if (!VALID_PROFILE_KEYS.has(item.profileKey)) { - return { - bindingId, - field: 'profileKey', - message: `${label} 的履约方式无效`, - } - } - - return null - } - - async function focusValidationTarget(state: ValidationState) { - if (!state?.bindingId) { - return - } - - setBindingCollapsed(state.bindingId, false) - await nextTick() - - const card = document.querySelector(`[data-binding-id="${state.bindingId}"]`) - if (!card) { - return - } - - card.scrollIntoView({ behavior: 'smooth', block: 'center' }) - - const selectors: Record = { - shopId: '[data-field="shopId"]', - skuCode: '[data-field="skuCode"]', - match: '[data-field="externalSkuCode"]', - profileKey: '[data-field="profileKey"]', - } - - const target = card.querySelector(selectors[state.field]) - target?.focus() - } - - function clearValidationState() { - validationState.value = null - } - - function isBindingInvalid(bindingId: string) { - return validationState.value?.bindingId === bindingId - } - - function isFieldInvalid( - bindingId: string, - field: ValidationField | 'externalSkuCode' | 'externalItemId' | 'externalSkuName', - ) { - if (validationState.value?.bindingId !== bindingId) { - return false - } - - if (validationState.value.field === 'match') { - return ['externalSkuCode', 'externalItemId', 'externalSkuName'].includes(field) - } - - return validationState.value.field === field - } - - async function saveConfigs() { - const normalizedBindings = bindings.value.map(normalizeBindingForSave) - const nonEmptyBindings = normalizedBindings.filter(hasBindingContent) - const invalidState = normalizedBindings.reduce((state, item, index) => { - if (state || !hasBindingContent(item)) { - return state - } - - return resolveBindingValidationState(item, index, bindings.value[index]?.id || '') - }, null) - - if (invalidState) { - validationState.value = invalidState - await focusValidationTarget(invalidState) - return - } - - if (bindings.value.length > 0 && nonEmptyBindings.length === 0) { - validationState.value = { - bindingId: '', - field: 'skuCode', - message: '当前没有可保存的规则,请至少填写内部履约 SKU 和外部匹配条件', - } - return - } - - const payload = nonEmptyBindings - - saving.value = true - validationState.value = null - - try { - const response = await saveAdminFulfillmentBindingConfigs({ bindings: payload }) - filePath.value = response.data.filePath - bindings.value = response.data.bindings.map(mapEditableBinding) - showSuccess('履约配置已保存') - await loadConfigs() - } catch (error) { - validationState.value = { - bindingId: '', - field: 'skuCode', - message: error instanceof Error ? error.message : '保存履约配置失败', - } - } finally { - saving.value = false - } - } - - return { - // state - loading, - saving, - errorMessage, - filePath, - bindings, - observedProducts, - validationState, - collapsedBindingIds, - // computed - ruleMetrics, - pendingObservedCount, - // factories - createEmptyBinding, - mapEditableBinding, - // validation - isEditableBindingComplete, - clearValidationState, - isBindingInvalid, - isFieldInvalid, - // collapse - isBindingCollapsed, - setBindingCollapsed, - toggleBindingCollapsed, - // display - getBindingTitle, - getBindingStatusLabel, - getBindingSummary, - // data - loadConfigs, - addBinding, - removeBinding, - importObservedProduct, - createBindingFromProductCandidate, - normalizeBindingForSave, - saveConfigs, - } -} diff --git a/apps/frontend/src/views/admin/fulfillment/bindings/composables/useFulfillmentBindingsLookup.ts b/apps/frontend/src/views/admin/fulfillment/bindings/composables/useFulfillmentBindingsLookup.ts deleted file mode 100644 index 6b99cead..00000000 --- a/apps/frontend/src/views/admin/fulfillment/bindings/composables/useFulfillmentBindingsLookup.ts +++ /dev/null @@ -1,70 +0,0 @@ -import { reactive, ref } from 'vue' - -import { lookupAdminFulfillmentBindingOrder } from '@/services/admin' -import type { AdminFulfillmentLookupItem, AdminFulfillmentLookupResult } from '@/types/admin' - -import type { useFulfillmentBindings } from './useFulfillmentBindings' - -export function useFulfillmentBindingsLookup( - bindings: ReturnType, -) { - const lookupLoading = ref(false) - const lookupErrorMessage = ref('') - const lookupResult = ref(null) - const importedLookupLineIds = ref([]) - const lookupForm = reactive({ - shopId: '', - platformOrderId: '', - }) - - function importLookupProduct(item: AdminFulfillmentLookupItem) { - bindings.importObservedProduct(item) - if (!importedLookupLineIds.value.includes(item.lineId)) { - importedLookupLineIds.value = [...importedLookupLineIds.value, item.lineId] - } - } - - function isLookupProductImported(lineId: string) { - return importedLookupLineIds.value.includes(lineId) - } - - async function lookupOrderProducts() { - const shopId = lookupForm.shopId.trim() - const platformOrderId = lookupForm.platformOrderId.trim() - - if (!shopId || !platformOrderId) { - lookupErrorMessage.value = '请先填写店铺 ID 和平台订单号' - return - } - - lookupLoading.value = true - lookupErrorMessage.value = '' - - try { - const response = await lookupAdminFulfillmentBindingOrder({ - provider: 'agiso', - platform: 'xianyu', - shopId, - platformOrderId, - }) - lookupResult.value = response.data - importedLookupLineIds.value = [] - } catch (error) { - lookupResult.value = null - lookupErrorMessage.value = error instanceof Error ? error.message : '订单商品查询失败' - } finally { - lookupLoading.value = false - } - } - - return { - lookupLoading, - lookupErrorMessage, - lookupResult, - importedLookupLineIds, - lookupForm, - importLookupProduct, - isLookupProductImported, - lookupOrderProducts, - } -} diff --git a/apps/frontend/src/views/admin/fulfillment/kuaishou-cloud/AdminKuaishouCloudFulfillment.css b/apps/frontend/src/views/admin/fulfillment/kuaishou-cloud/AdminKuaishouCloudFulfillment.css index 06450e94..31127b27 100644 --- a/apps/frontend/src/views/admin/fulfillment/kuaishou-cloud/AdminKuaishouCloudFulfillment.css +++ b/apps/frontend/src/views/admin/fulfillment/kuaishou-cloud/AdminKuaishouCloudFulfillment.css @@ -3,66 +3,226 @@ gap: var(--space-4); } -.section-card { - border-radius: var(--radius-lg); +.fulfillment-simple-page { + min-width: 0; +} + +.fulfillment-overview { + display: grid; + grid-template-columns: repeat(4, minmax(0, 1fr)) minmax(120px, auto); + gap: var(--space-3); + align-items: stretch; +} + +.metric-card { + display: grid; + gap: var(--space-2); + min-width: 0; + padding: var(--space-4); border: 1px solid var(--border-default); + border-radius: var(--radius-md); background: var(--bg-surface); } -.section-card :deep(.el-card__header) { - background: var(--bg-surface); - border-bottom: 1px solid var(--border-default); - padding: var(--space-4) var(--space-5); +.metric-card span { + color: var(--text-secondary); + font-size: var(--text-sm); } -.section-card :deep(.el-card__body) { - padding: var(--space-5); +.metric-card strong { + color: var(--text-primary); + font-size: var(--text-2xl); + line-height: 1.1; } -.card-header { +.overview-actions { display: flex; - flex-direction: column; + justify-content: flex-end; + align-items: center; +} + +.fulfillment-layout { + display: grid; + grid-template-columns: minmax(240px, 300px) minmax(0, 1fr); + gap: var(--space-4); + align-items: start; +} + +.source-panel, +.catalog-panel { + min-width: 0; + border: 1px solid var(--border-default); + border-radius: var(--radius-md); + background: var(--bg-surface); +} + +.source-panel { + display: grid; + gap: var(--space-3); + padding: var(--space-4); +} + +.catalog-panel { + display: grid; + gap: var(--space-4); + padding: var(--space-4); +} + +.section-head { + display: grid; + gap: var(--space-1); +} + +.section-head h3 { + margin: 0; + color: var(--text-primary); + font-size: var(--text-lg); + font-weight: 700; +} + +.section-head p { + margin: 0; + color: var(--text-secondary); + font-size: var(--text-sm); +} + +.source-list { + display: grid; gap: var(--space-2); } -.card-header--split { - flex-direction: row; +.source-item { + display: flex; + width: 100%; + min-width: 0; justify-content: space-between; + gap: var(--space-3); + align-items: center; + padding: var(--space-3); + border: 1px solid var(--border-default); + border-radius: var(--radius-sm); + background: var(--bg-surface); + color: inherit; + text-align: left; + cursor: pointer; +} + +.source-item:hover, +.source-item--active { + border-color: var(--color-primary); + background: var(--bg-subtle); +} + +.source-item span { + display: grid; + gap: 4px; + min-width: 0; +} + +.source-item strong, +.source-item small { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.source-item strong { + color: var(--text-primary); +} + +.source-item small { + color: var(--text-secondary); +} + +.catalog-toolbar { + display: flex; + justify-content: space-between; + gap: var(--space-3); align-items: flex-start; } -.card-title { - display: block; - font-size: var(--text-lg); - font-weight: 600; - color: var(--text-primary); - margin-bottom: var(--space-1); +.catalog-toolbar-actions { + display: flex; + flex-wrap: wrap; + gap: var(--space-2); + justify-content: flex-end; } -.card-desc { - display: block; - font-size: var(--text-sm); +.match-checker { + display: grid; + grid-template-columns: minmax(260px, 1fr) auto; + gap: var(--space-3); + align-items: center; + padding: var(--space-3); + border: 1px solid var(--border-default); + border-radius: var(--radius-md); + background: var(--bg-subtle); +} + +.sku-table { + width: 100%; +} + +.sku-list-panel { + display: grid; + gap: var(--space-3); + min-width: 0; + padding-top: var(--space-2); + border-top: 1px solid var(--border-default); +} + +.sku-name-cell { + display: grid; + gap: 4px; + min-width: 0; +} + +.sku-name-cell strong { + color: var(--text-primary); +} + +.sku-name-cell small { color: var(--text-secondary); line-height: 1.5; } -.overview-card { - display: grid; - gap: var(--space-4); -} - -.overview-file code { +.match-name { + display: inline-block; + max-width: 100%; + padding: 2px 6px; + border-radius: var(--radius-sm); + background: var(--bg-subtle); color: var(--text-primary); + white-space: normal; word-break: break-all; } -.overview-stats { +.override-panel { + display: grid; + gap: var(--space-4); + min-width: 0; + padding: var(--space-4); + border: 1px solid var(--border-default); + border-radius: var(--radius-md); + background: var(--bg-subtle); +} + +.override-actions { + display: flex; + flex-wrap: wrap; + gap: var(--space-2); + justify-content: flex-end; + align-items: center; +} + +.override-rule-list { display: grid; - grid-template-columns: repeat(5, minmax(0, 1fr)); gap: var(--space-3); } -.overview-stat { +.override-rule-item { + display: grid; + gap: var(--space-3); min-width: 0; padding: var(--space-3); border: 1px solid var(--border-default); @@ -70,306 +230,42 @@ background: var(--bg-surface); } -.overview-stat :deep(.el-statistic__head) { - margin-bottom: 6px; - color: var(--text-secondary); - font-size: var(--text-xs); - font-weight: 700; -} - -.overview-stat :deep(.el-statistic__content) { - color: var(--text-primary); - font-size: var(--text-2xl); - font-weight: 700; -} - -.overview-notes { - display: flex; - flex-wrap: wrap; +.override-rule-head { + display: grid; + grid-template-columns: auto minmax(220px, 1fr) minmax(160px, 220px) auto; gap: var(--space-2); -} - -.lookup-panel { - display: grid; - gap: var(--space-3); - padding: var(--space-4); - border: 1px solid var(--border-default); - border-radius: var(--radius-md); - background: var(--bg-subtle); -} - -.lookup-toolbar { - display: grid; - grid-template-columns: - minmax(260px, 1.4fr) - minmax(132px, 0.36fr) - minmax(150px, 0.44fr) - minmax(210px, 0.5fr); - gap: var(--space-3) var(--space-4); - align-items: end; -} - -.lookup-field { - min-width: 0; -} - -.lookup-field--number :deep(.el-input-number) { - width: 100%; -} - -.lookup-action { - width: 100%; - min-height: 40px; -} - -.lookup-note { - margin-top: 0; -} - -.lookup-note :deep(.el-alert__content) { - min-width: 0; -} - -.lookup-note :deep(.el-alert__title) { - line-height: 1.6; -} - -.rule-action-row { - display: flex; - flex-wrap: wrap; - justify-content: space-between; - gap: var(--space-3); align-items: center; } -.rule-enabled-control { - display: flex; - justify-content: flex-end; -} - -.binding-card { - margin-top: var(--space-3); - background: var(--bg-surface); - border: 1px solid var(--border-default); - border-radius: var(--radius-md); -} - -.binding-card :deep(.el-card__header) { - padding: var(--space-3) var(--space-4); - background: var(--bg-surface); - border-bottom: 1px solid var(--border-default); -} - -.binding-card :deep(.el-card__body) { - display: grid; - gap: var(--space-4); - padding: var(--space-4); -} - -.binding-card--invalid { - border-color: var(--color-danger); - box-shadow: 0 0 0 3px rgba(220, 38, 38, 0.08); -} - -.binding-header { - display: flex; - justify-content: space-between; - gap: var(--space-3); - align-items: flex-start; -} - -.binding-summary { +.delivery-item-list { display: grid; gap: var(--space-2); } -.binding-title-row { - display: flex; +.delivery-item-row { + display: grid; + grid-template-columns: minmax(220px, 1.1fr) minmax(180px, 1fr) 120px auto; gap: var(--space-2); align-items: center; - flex-wrap: wrap; - color: var(--text-primary); } -.binding-subtle, -.cell-subtle { - color: var(--text-secondary); -} - -.cell-subtle { - display: block; - font-size: var(--text-xs); - line-height: 1.6; -} - -.cell-stack { - display: flex; - flex-direction: column; - gap: var(--space-1); -} - -.binding-collapsed-preview { - color: var(--text-secondary); - font-size: var(--text-sm); -} - -.mapping-editor { - display: grid; - grid-template-columns: minmax(0, 1fr) 74px minmax(0, 1fr); - gap: var(--space-3); - align-items: stretch; -} - -.mapping-panel { - min-width: 0; - background: var(--bg-surface); - border: 1px solid var(--border-default); - border-radius: var(--radius-md); -} - -.mapping-panel :deep(.el-card__header) { - padding: var(--space-3) var(--space-4); - background: var(--bg-surface); - border-bottom: 1px solid var(--border-default); -} - -.mapping-panel :deep(.el-card__body) { - padding: var(--space-4); -} - -.mapping-panel-head { - display: flex; - justify-content: space-between; - gap: var(--space-3); - align-items: baseline; -} - -.mapping-panel-head strong { - color: var(--text-primary); -} - -.mapping-panel-head span { - color: var(--text-secondary); - font-size: var(--text-xs); -} - -.mapping-grid, -.mapping-extra { - display: grid; - grid-template-columns: repeat(2, minmax(0, 1fr)); - gap: var(--space-4); -} - -.mapping-extra { - padding-top: var(--space-3); - border-top: 1px dashed var(--border-default); -} - -.mapping-arrow { - display: grid; - place-items: center; - color: var(--color-primary); - font-size: var(--text-xs); - font-weight: 700; -} - -.mapping-arrow span { - display: inline-flex; - align-items: center; - justify-content: center; - min-height: 30px; - min-width: 54px; - padding: 0 10px; - border-radius: var(--radius-full); - background: var(--bg-surface); - border: 1px solid var(--border-default); -} - -.field-wide { - grid-column: 1 / -1; -} - -.field-help { - display: block; - margin-top: var(--space-2); - color: var(--text-secondary); - font-size: var(--text-xs); - line-height: 1.6; -} - -.text-input { - width: 100%; -} - -.textarea-input { - min-height: 72px; -} - -.admin-panel :deep(.el-form-item) { - margin-bottom: 0; -} - -.admin-panel :deep(.el-form-item__label) { - color: var(--text-secondary); - font-size: var(--text-sm); - font-weight: 600; - line-height: 1.5; - margin-bottom: var(--space-2); -} - -.binding-actions { - display: flex; - flex-wrap: wrap; - gap: var(--space-3); - align-items: center; - padding-top: var(--space-3); - border-top: 1px solid var(--border-default); -} - -.data-table { - margin-top: var(--space-4); -} - -.data-table :deep(.el-table__header th) { - background: var(--bg-surface); - color: var(--text-secondary); - font-weight: 600; - font-size: var(--text-sm); -} - -.data-table :deep(.el-table__body td) { - padding: var(--space-3) var(--space-2); -} - -@media (max-width: 900px) { - .card-header--split, - .rule-action-row, - .binding-header, - .mapping-panel-head { - display: grid; - grid-template-columns: 1fr; - align-items: stretch; - } - - .overview-stats, - .lookup-toolbar, - .mapping-editor, - .mapping-grid, - .mapping-extra { +@media (max-width: 960px) { + .fulfillment-overview, + .fulfillment-layout, + .match-checker, + .override-rule-head, + .delivery-item-row { grid-template-columns: 1fr; } - .mapping-arrow { - display: none; + .overview-actions, + .catalog-toolbar, + .catalog-toolbar-actions, + .override-actions { + justify-content: stretch; } - .section-card :deep(.el-card__header), - .section-card :deep(.el-card__body) { - padding: var(--space-4); - } - - .binding-card :deep(.el-card__header), - .binding-card :deep(.el-card__body) { - padding: var(--space-3); + .catalog-toolbar { + flex-direction: column; } } diff --git a/apps/frontend/src/views/admin/fulfillment/kuaishou-cloud/AdminKuaishouCloudFulfillmentView.vue b/apps/frontend/src/views/admin/fulfillment/kuaishou-cloud/AdminKuaishouCloudFulfillmentView.vue index a3a1595d..f9653ff3 100644 --- a/apps/frontend/src/views/admin/fulfillment/kuaishou-cloud/AdminKuaishouCloudFulfillmentView.vue +++ b/apps/frontend/src/views/admin/fulfillment/kuaishou-cloud/AdminKuaishouCloudFulfillmentView.vue @@ -1,85 +1,542 @@ @@ -474,68 +414,85 @@ onMounted(loadConfigs) gap: var(--space-4); } -/* 概览卡片网格 */ -.overview-grid { +.platform-detail { + min-width: 0; display: grid; - grid-template-columns: repeat(auto-fit, minmax(210px, 1fr)); gap: var(--space-4); } -.overview-card { - cursor: pointer; - transition: - border-color 0.2s ease, - box-shadow 0.2s ease; -} - -.overview-card :deep(.el-card__body) { - padding: 18px; -} - -.overview-card.is-active { - border-color: var(--border-hover); - box-shadow: var(--shadow-lg); -} - -.card-head { +.active-summary { display: flex; - gap: var(--space-2); - margin-bottom: var(--space-3); + justify-content: space-between; + gap: var(--space-4); + align-items: center; + padding: var(--space-4) var(--space-5); + border: 1px solid var(--border-default); + border-radius: var(--radius-lg); + background: rgba(255, 255, 255, 0.92); + box-shadow: var(--shadow-sm); } -.card-value { - font-size: 26px; - font-weight: 700; - color: var(--text-primary); - line-height: 1.1; +.active-summary-copy { + min-width: 0; } -.card-unit { - color: var(--text-muted); - font-size: var(--text-sm); - margin-top: var(--space-1); -} - -.card-meta { - color: var(--text-secondary); +.summary-kicker { + color: #2563eb; font-size: var(--text-xs); - margin-top: var(--space-2); + font-weight: 800; } -/* Tab 切换 */ -.platform-tabs { - margin-top: var(--space-2); +.active-summary h2 { + margin: 4px 0 0; + color: var(--text-primary); + font-size: var(--text-xl); + line-height: 1.25; } -@media (max-width: 900px) { - .overview-grid { - grid-template-columns: repeat(2, 1fr); +.active-summary p { + margin: var(--space-2) 0 0; + color: var(--text-secondary); + font-size: var(--text-sm); + line-height: 1.6; +} + +.summary-metrics { + flex: 0 0 auto; + display: flex; + gap: var(--space-3); + align-items: center; +} + +.summary-metric { + min-width: 112px; + display: grid; + gap: 4px; + padding: 10px 12px; + border: 1px solid rgba(148, 163, 184, 0.18); + border-radius: var(--radius-sm); + background: #f8fafc; +} + +.summary-metric span { + color: var(--text-muted); + font-size: var(--text-xs); + font-weight: 700; +} + +.summary-metric strong { + color: var(--text-primary); + font-size: var(--text-xl); + line-height: 1; +} + +@media (max-width: 720px) { + .active-summary { + align-items: stretch; + flex-direction: column; } -} -@media (max-width: 640px) { - .overview-grid { - grid-template-columns: 1fr; + .summary-metrics { + justify-content: space-between; } } diff --git a/apps/frontend/src/views/admin/platform-shops/components/AdminPlatformAgisoSection.vue b/apps/frontend/src/views/admin/platform-shops/components/AdminPlatformAgisoSection.vue deleted file mode 100644 index 2e9c8f9d..00000000 --- a/apps/frontend/src/views/admin/platform-shops/components/AdminPlatformAgisoSection.vue +++ /dev/null @@ -1,264 +0,0 @@ - - - - - diff --git a/apps/frontend/src/views/admin/platform-shops/components/AdminPlatformNinetyoneSection.vue b/apps/frontend/src/views/admin/platform-shops/components/AdminPlatformNinetyoneSection.vue index 78b20e4a..3e4b4214 100644 --- a/apps/frontend/src/views/admin/platform-shops/components/AdminPlatformNinetyoneSection.vue +++ b/apps/frontend/src/views/admin/platform-shops/components/AdminPlatformNinetyoneSection.vue @@ -28,21 +28,6 @@ defineProps()