删除咸鱼旧链路
This commit is contained in:
@@ -24,41 +24,11 @@ POSTGRES_PORT=5432
|
|||||||
DATA_ROOT=/app/data
|
DATA_ROOT=/app/data
|
||||||
LOG_LEVEL=info
|
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
|
|
||||||
|
|
||||||
# Admin
|
# Admin
|
||||||
ADMIN_SESSION_SECRET=dev-local-session-secret
|
ADMIN_SESSION_SECRET=dev-local-session-secret
|
||||||
ADMIN_SESSION_TTL_HOURS=12
|
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"}]
|
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
|
# 91Kaquan
|
||||||
NINETYONE_USER_ID=
|
NINETYONE_USER_ID=
|
||||||
NINETYONE_SECRET=
|
NINETYONE_SECRET=
|
||||||
|
|||||||
@@ -23,37 +23,11 @@ DATABASE_MAX_CONNECTIONS=20
|
|||||||
DATA_ROOT=/app/data
|
DATA_ROOT=/app/data
|
||||||
LOG_LEVEL=info
|
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
|
|
||||||
|
|
||||||
# Admin
|
# Admin
|
||||||
ADMIN_SESSION_SECRET=change-me-long-random-session-secret
|
ADMIN_SESSION_SECRET=change-me-long-random-session-secret
|
||||||
ADMIN_SESSION_TTL_HOURS=12
|
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"}]
|
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
|
# 91Kaquan
|
||||||
NINETYONE_USER_ID=
|
NINETYONE_USER_ID=
|
||||||
NINETYONE_SECRET=
|
NINETYONE_SECRET=
|
||||||
|
|||||||
@@ -1,14 +1,13 @@
|
|||||||
# order_site
|
# order_site
|
||||||
|
|
||||||
订单自动兑换与履约管理系统。
|
快手自动发货与履约管理系统。
|
||||||
|
|
||||||
项目采用前后端分离架构,主要用于接收订单、匹配履约规则、生成领取链接、管理库存、执行自动/半自动兑换,并提供后台页面处理人工复核、平台配置和任务追踪。
|
项目采用前后端分离架构,用于接收订单、匹配快手履约规则、管理库存与云卡资源,并提供后台页面处理平台配置、任务追踪和人工复核。
|
||||||
|
|
||||||
## 技术栈
|
## 技术栈
|
||||||
|
|
||||||
- 前端:Vue 3、Vite、Element Plus、TypeScript
|
- 前端:Vue 3、Vite、Element Plus、TypeScript
|
||||||
- 后端:Node.js、Express、PostgreSQL、Playwright
|
- 后端:Node.js、Express、PostgreSQL
|
||||||
- OCR:外部镜像服务,后端通过 `OCR_BASE_URL` 调用
|
|
||||||
- 网关:Caddy
|
- 网关:Caddy
|
||||||
- 本地与生产运行:Docker Compose
|
- 本地与生产运行:Docker Compose
|
||||||
|
|
||||||
@@ -30,28 +29,18 @@ docker-compose.yml 生产/服务器 Compose
|
|||||||
|
|
||||||
## 本地开发
|
## 本地开发
|
||||||
|
|
||||||
推荐直接使用 Docker,本地不需要额外维护 Node、PostgreSQL、Playwright 浏览器和 OCR 运行环境。
|
推荐直接使用 Docker,本地不需要额外维护 Node 和 PostgreSQL。
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
cp .env.mac-docker.example .env
|
cp .env.mac-docker.example .env
|
||||||
docker compose -f docker-compose.dev.yml up -d --build
|
docker compose -f docker-compose.dev.yml up -d --build
|
||||||
```
|
```
|
||||||
|
|
||||||
如果只运行快手 Cloud / 91 卡券 / 快手小店核销链路,可以使用轻量 Compose。这个模式不会启动 OCR,也不会安装或预热 Playwright 浏览器:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
docker compose -f docker-compose.kuaishou.dev.yml up -d --build
|
|
||||||
```
|
|
||||||
|
|
||||||
启动后常用入口:
|
启动后常用入口:
|
||||||
|
|
||||||
- 前端、后台、领取页:`http://localhost`
|
- 前端、后台、领取页:`http://localhost`
|
||||||
- 后端健康检查:`http://localhost/health`
|
- 后端健康检查:`http://localhost/health`
|
||||||
- 后端 API 前缀:`http://localhost/api/v1/...`
|
- 后端 API 前缀:`http://localhost/api/v1/...`
|
||||||
- OCR worker:`http://127.0.0.1:8100/health`
|
|
||||||
- noVNC 浏览器画面:`http://127.0.0.1:6080/vnc.html`
|
|
||||||
|
|
||||||
快手轻量开发模式没有 noVNC 入口,前端首页默认进入后台。
|
|
||||||
|
|
||||||
查看容器状态:
|
查看容器状态:
|
||||||
|
|
||||||
@@ -64,7 +53,6 @@ docker compose -f docker-compose.dev.yml ps
|
|||||||
```bash
|
```bash
|
||||||
docker compose -f docker-compose.dev.yml logs -f backend
|
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 frontend
|
||||||
docker compose -f docker-compose.dev.yml logs -f ocr-worker
|
|
||||||
```
|
```
|
||||||
|
|
||||||
停止环境:
|
停止环境:
|
||||||
@@ -83,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 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 typecheck
|
||||||
docker compose -f docker-compose.dev.yml exec -T frontend npm run lint
|
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
|
docker compose -f docker-compose.dev.yml exec -T frontend npm run build
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -97,16 +80,6 @@ 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
|
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、系统依赖或基础镜像时再重新构建。
|
|
||||||
|
|
||||||
## 环境变量
|
## 环境变量
|
||||||
|
|
||||||
本地开发从根目录 `.env` 读取,模板为:
|
本地开发从根目录 `.env` 读取,模板为:
|
||||||
@@ -128,10 +101,7 @@ cp .env.server.example .env
|
|||||||
- `ADMIN_SESSION_SECRET`:后台登录态签名密钥
|
- `ADMIN_SESSION_SECRET`:后台登录态签名密钥
|
||||||
- `ADMIN_DEFAULT_USERS_JSON`:默认后台用户
|
- `ADMIN_DEFAULT_USERS_JSON`:默认后台用户
|
||||||
- `DATABASE_URL`:PostgreSQL 连接串
|
- `DATABASE_URL`:PostgreSQL 连接串
|
||||||
- `TENCENT_BROWSER_HEADLESS`:Playwright 是否无头运行
|
- `NINETYONE_*`:91 卡券对接配置
|
||||||
- `TENCENT_BROWSER_PREWARM`:启动时是否预热浏览器
|
|
||||||
- `OCR_IMAGE`:OCR 外部服务镜像,默认 `order_site-ocr-worker:latest`
|
|
||||||
- `OCR_BASE_URL`:后端访问 OCR 的 HTTP 地址,Docker 内默认 `http://ocr-worker:8100`
|
|
||||||
|
|
||||||
## 运行数据
|
## 运行数据
|
||||||
|
|
||||||
@@ -140,8 +110,6 @@ cp .env.server.example .env
|
|||||||
- 后端配置与运行产物:`apps/backend/data`
|
- 后端配置与运行产物:`apps/backend/data`
|
||||||
- PostgreSQL 数据:Docker volume `postgres_dev_data`
|
- PostgreSQL 数据:Docker volume `postgres_dev_data`
|
||||||
- 后端日志:`apps/backend/data/logs`
|
- 后端日志:`apps/backend/data/logs`
|
||||||
- 浏览器会话:`apps/backend/data/browser-sessions`
|
|
||||||
- 兑换截图:`apps/backend/data/redeem-screenshots`
|
|
||||||
|
|
||||||
生产部署数据:
|
生产部署数据:
|
||||||
|
|
||||||
@@ -161,13 +129,6 @@ mkdir -p deploy/data/backend
|
|||||||
docker compose up -d --build
|
docker compose up -d --build
|
||||||
```
|
```
|
||||||
|
|
||||||
只部署快手轻量服务时使用:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
mkdir -p deploy/data/backend-kuaishou
|
|
||||||
docker compose -f docker-compose.kuaishou.yml up -d --build
|
|
||||||
```
|
|
||||||
|
|
||||||
生产入口:
|
生产入口:
|
||||||
|
|
||||||
- 前端与领取页:`https://你的域名/`
|
- 前端与领取页:`https://你的域名/`
|
||||||
@@ -177,78 +138,21 @@ docker compose -f docker-compose.kuaishou.yml up -d --build
|
|||||||
生产 Compose 会自动:
|
生产 Compose 会自动:
|
||||||
|
|
||||||
- 启动 PostgreSQL
|
- 启动 PostgreSQL
|
||||||
- 启动 OCR worker
|
|
||||||
- 启动后端并执行 migration
|
- 启动后端并执行 migration
|
||||||
- 初始化默认后台管理员
|
- 初始化默认后台管理员
|
||||||
- 同步履约目录
|
- 同步履约目录
|
||||||
- 通过 Caddy 暴露前端和 API
|
- 通过 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/routes`:API 路由
|
||||||
- `apps/backend/src/services/order`:订单、webhook、库存和履约匹配
|
- `apps/backend/src/services/order`:订单、库存和履约匹配
|
||||||
- `apps/backend/src/services/fulfillment`:快手 Cloud 等履约编排
|
- `apps/backend/src/services/fulfillment`:快手 Cloud 履约编排
|
||||||
- `apps/backend/src/services/session`:腾讯登录、二维码、兑换和截图
|
|
||||||
- `apps/backend/src/services/admin`:后台读写、权限、配置管理
|
- `apps/backend/src/services/admin`:后台读写、权限、配置管理
|
||||||
- `apps/backend/src/services/platforms`:外部平台 API 封装
|
|
||||||
- `apps/backend/src/repositories`:数据库访问
|
- `apps/backend/src/repositories`:数据库访问
|
||||||
- `apps/frontend/src/views/admin`:后台页面
|
- `apps/frontend/src/views/admin`:后台页面
|
||||||
- `apps/frontend/src/views/claim`:用户领取页
|
- `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
|
```bash
|
||||||
@@ -264,4 +168,3 @@ docker compose -f docker-compose.dev.yml exec -T frontend npm run lint
|
|||||||
- `.env` 未被提交
|
- `.env` 未被提交
|
||||||
- 数据库 migration 与代码一起提交
|
- 数据库 migration 与代码一起提交
|
||||||
- 领取链接相关改动已在 Docker 环境验证
|
- 领取链接相关改动已在 Docker 环境验证
|
||||||
- 涉及 Playwright/OCR 的改动已看过 noVNC 或相关日志
|
|
||||||
|
|||||||
+35
-102
@@ -1,13 +1,8 @@
|
|||||||
# order-site-backend
|
# order-site-backend
|
||||||
|
|
||||||
腾讯活动浏览器会话后端。
|
快手轻量后端服务。
|
||||||
|
|
||||||
当前主线只有一条:
|
后端负责订单入库、履约任务编排、快手 Cloud / 91 卡券 / 快手小店核销配置、后台管理接口和数据库迁移。默认入口就是轻量后端,不再包含浏览器会话、OCR 或旧咸鱼自动发货链路。
|
||||||
|
|
||||||
- 后端托管 Playwright 浏览器
|
|
||||||
- 前端展示二维码并轮询会话状态
|
|
||||||
- 登录完成后,后端直接在活动页填写 CDK、识别验证码、执行兑换
|
|
||||||
- 兑换完成后返回状态,并按配置决定是否生成截图/证明产物
|
|
||||||
|
|
||||||
## 快速启动
|
## 快速启动
|
||||||
|
|
||||||
@@ -20,21 +15,18 @@ npm install
|
|||||||
复制并填写环境变量后启动:
|
复制并填写环境变量后启动:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
cp /Users/yml/codes/order-site-workspace/.env.mac-docker.example /Users/yml/codes/order-site-workspace/.env
|
cp ../../.env.mac-docker.example ../../.env
|
||||||
cd /Users/yml/codes/order-site-workspace/apps/backend
|
|
||||||
npm run dev
|
npm run dev
|
||||||
```
|
```
|
||||||
|
|
||||||
Linux 部署:
|
生产构建:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
npm install
|
|
||||||
npm run build
|
npm run build
|
||||||
npm run browser:install:linux
|
|
||||||
npm run start
|
npm run start
|
||||||
```
|
```
|
||||||
|
|
||||||
后端渐进 TypeScript 迁移已经进入源码和构建双轨模式:
|
常用验证:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
npm run typecheck
|
npm run typecheck
|
||||||
@@ -42,120 +34,61 @@ npm run build
|
|||||||
npm test
|
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`
|
- 通用默认值:`src/config/defaults.ts`
|
||||||
- 本地/部署覆盖:工作区根目录 `.env`
|
- 本地/部署覆盖:工作区根目录 `.env`
|
||||||
|
|
||||||
当前最常改的配置有:
|
常用配置:
|
||||||
|
|
||||||
- 服务端口
|
- 服务端口 `PORT`
|
||||||
- 日志级别 `LOG_LEVEL=debug|info|warn|error`
|
- 日志级别 `LOG_LEVEL=debug|info|warn|error`
|
||||||
- 浏览器是否无头、是否预热、是否常驻、slowMo
|
- 数据库连接 `DATABASE_URL`
|
||||||
- OCR 外部服务镜像 `OCR_IMAGE` 与访问地址 `OCR_BASE_URL`
|
- 后台登录密钥 `ADMIN_SESSION_SECRET`
|
||||||
- 会话调试开关
|
- 默认后台用户 `ADMIN_DEFAULT_USERS_JSON`
|
||||||
- 兑换证明模式 `full | basic | off`
|
- 91 卡券与快手相关平台配置
|
||||||
- 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` 兜底。
|
|
||||||
|
|
||||||
## 接口
|
## 接口
|
||||||
|
|
||||||
- `POST /api/v1/tencent/browser/session`
|
- `GET /health`:健康检查
|
||||||
创建浏览器会话,返回 `sessionId`、二维码和初始状态
|
- `GET /health/live`:存活检查
|
||||||
- `GET /api/v1/tencent/browser/session/:sessionId`
|
- `GET /health/ready`:就绪检查
|
||||||
返回完整会话状态
|
- `/api/v1/open/91/*`:91 卡券开放回调
|
||||||
- `GET /api/v1/tencent/browser/session/:sessionId/summary`
|
- `/api/v1/claim/*`:领取页接口
|
||||||
轮询用轻量摘要接口,默认不返回二维码 base64
|
- `/api/v1/admin/*`:后台管理接口
|
||||||
- `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`
|
|
||||||
关闭浏览器会话
|
|
||||||
|
|
||||||
## 目录
|
## 目录
|
||||||
|
|
||||||
- `src/index.ts`
|
- `src/index.ts`:服务入口
|
||||||
Express 入口
|
- `src/app.ts`:Express 应用组装
|
||||||
- `src/routes/tencent.ts`
|
- `src/routes`:API 路由
|
||||||
接口路由
|
- `src/services/order`:订单、库存和履约匹配
|
||||||
- `src/services/session/session.ts`
|
- `src/services/fulfillment`:快手 Cloud 履约编排
|
||||||
会话编排与浏览器生命周期
|
- `src/services/admin`:后台读写、权限、配置管理
|
||||||
- `src/services/session/*.ts`
|
- `src/repositories`:数据库访问
|
||||||
登录、兑换、OCR、凭证产物等模块
|
- `src/db/migrations`:数据库迁移
|
||||||
|
|
||||||
## Docker 开发
|
## Docker 开发
|
||||||
|
|
||||||
当前 `docker-compose.dev.yml` 已切到源码挂载模式:
|
开发版 Compose 会把源码挂载进容器:
|
||||||
|
|
||||||
- `postgres` 容器提供开发库,后端通过 `DATABASE_URL` 连接
|
- `postgres` 容器提供开发库,后端通过 `DATABASE_URL` 连接
|
||||||
- `ocr-worker` 使用外部镜像启动,后端通过 `OCR_BASE_URL=http://ocr-worker:8100` 调用
|
- 后端容器启动时执行依赖安装和 `npm run dev`
|
||||||
- 后端容器启动时执行 `npm install` 和 `npm run dev`
|
- 平时改 `src/` 或 `.env`,一般不需要重建镜像
|
||||||
- 平时改 `src/` 或 `.env`,一般都不需要重建镜像
|
|
||||||
- 生产镜像会在 build 阶段编译后端并只复制 `dist`、`config` 和运行依赖
|
|
||||||
|
|
||||||
只有下面几类改动通常还需要 `docker compose -f docker-compose.dev.yml up -d --build backend`:
|
只有下面几类改动通常还需要重新构建 backend:
|
||||||
|
|
||||||
- `deploy/docker/backend.Dockerfile`
|
- `deploy/docker/backend*.Dockerfile`
|
||||||
- 系统层依赖
|
- Node 版本或系统层依赖
|
||||||
- Playwright 浏览器基础环境
|
- `package.json` / `package-lock.json`
|
||||||
|
|
||||||
## 产物
|
## 运行产物
|
||||||
|
|
||||||
浏览器会话产物默认保存在:
|
|
||||||
|
|
||||||
- `data/browser-sessions/<sessionId>/qq-qr.png`
|
|
||||||
- `data/browser-sessions/<sessionId>/session.json`
|
|
||||||
- `data/browser-sessions/<sessionId>/captcha-attempt-*.png`
|
|
||||||
- `data/browser-sessions/<sessionId>/redeem-result.png`
|
|
||||||
- `data/browser-sessions/<sessionId>/page.html`
|
|
||||||
- `data/browser-sessions/<sessionId>/result.json`
|
|
||||||
|
|
||||||
这些都是运行时产物,默认不提交 Git。
|
|
||||||
|
|
||||||
运行日志默认保存在:
|
运行日志默认保存在:
|
||||||
|
|
||||||
- `data/logs/app-YYYY-MM-DD.log`
|
- `data/logs/app-YYYY-MM-DD.log`
|
||||||
- `data/logs/webhook-YYYY-MM-DD.log`
|
- `data/logs/webhook-YYYY-MM-DD.log`
|
||||||
|
|
||||||
日志按天切分,默认自动清理 7 天前的旧日志。
|
日志按天切分,默认自动清理 7 天前的旧日志。成功的 `/health`、`/health/live`、`/health/ready` 探活请求默认不写 access log。
|
||||||
成功的 `/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`:不生成证明文件
|
|
||||||
|
|||||||
Generated
+1
-46
@@ -9,8 +9,7 @@
|
|||||||
"version": "0.1.0",
|
"version": "0.1.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"express": "^5.1.0",
|
"express": "^5.1.0",
|
||||||
"pg": "^8.16.3",
|
"pg": "^8.16.3"
|
||||||
"playwright": "1.42.1"
|
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/express": "^5.0.6",
|
"@types/express": "^5.0.6",
|
||||||
@@ -911,20 +910,6 @@
|
|||||||
"node": ">= 0.8"
|
"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": {
|
"node_modules/function-bind": {
|
||||||
"version": "1.1.2",
|
"version": "1.1.2",
|
||||||
"resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz",
|
"resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz",
|
||||||
@@ -1275,36 +1260,6 @@
|
|||||||
"split2": "^4.1.0"
|
"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": {
|
"node_modules/postgres-array": {
|
||||||
"version": "2.0.0",
|
"version": "2.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/postgres-array/-/postgres-array-2.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/postgres-array/-/postgres-array-2.0.0.tgz",
|
||||||
|
|||||||
@@ -4,27 +4,18 @@
|
|||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"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",
|
"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",
|
"build": "tsc -p tsconfig.build.json && rm -rf dist/db/migrations && mkdir -p dist/db && cp -R src/db/migrations dist/db/migrations",
|
||||||
"build:kuaishou": "tsc -p tsconfig.kuaishou.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",
|
"db:migrate": "tsx src/db/migrate.ts",
|
||||||
"dev": "tsx watch --clear-screen=false src/index.ts",
|
"dev": "tsx watch --clear-screen=false src/index.ts",
|
||||||
"dev:kuaishou": "tsx watch --clear-screen=false src/index-kuaishou.ts",
|
|
||||||
"test": "node --import tsx --test $(find src \\( -name '*.test.ts' -o -name '*.test.js' \\) -print)",
|
"test": "node --import tsx --test $(find src \\( -name '*.test.ts' -o -name '*.test.js' \\) -print)",
|
||||||
"typecheck": "tsc -p tsconfig.json --noEmit",
|
"typecheck": "tsc -p tsconfig.json --noEmit",
|
||||||
"typecheck:kuaishou": "tsc -p tsconfig.kuaishou.json --noEmit",
|
|
||||||
"start": "node dist/index.js",
|
"start": "node dist/index.js",
|
||||||
"start:kuaishou": "node dist/index-kuaishou.js",
|
|
||||||
"start:src": "tsx src/index.ts"
|
"start:src": "tsx src/index.ts"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"express": "^5.1.0",
|
"express": "^5.1.0",
|
||||||
"pg": "^8.16.3",
|
"pg": "^8.16.3"
|
||||||
"playwright": "1.42.1"
|
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/express": "^5.0.6",
|
"@types/express": "^5.0.6",
|
||||||
|
|||||||
@@ -1,22 +0,0 @@
|
|||||||
{
|
|
||||||
"name": "order-site-backend-kuaishou",
|
|
||||||
"version": "0.1.0",
|
|
||||||
"private": true,
|
|
||||||
"type": "module",
|
|
||||||
"scripts": {
|
|
||||||
"build:kuaishou": "tsc -p tsconfig.kuaishou.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",
|
|
||||||
"start:kuaishou": "node dist/index-kuaishou.js"
|
|
||||||
},
|
|
||||||
"dependencies": {
|
|
||||||
"express": "^5.1.0",
|
|
||||||
"pg": "^8.16.3"
|
|
||||||
},
|
|
||||||
"devDependencies": {
|
|
||||||
"@types/express": "^5.0.6",
|
|
||||||
"@types/node": "^25.6.0",
|
|
||||||
"@types/pg": "^8.20.0",
|
|
||||||
"tsx": "^4.22.3",
|
|
||||||
"typescript": "^6.0.2"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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`')
|
|
||||||
}
|
|
||||||
@@ -1,97 +1,5 @@
|
|||||||
#!/bin/sh
|
#!/bin/sh
|
||||||
set -eu
|
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
|
npm install --no-fund --no-audit
|
||||||
|
|
||||||
if ! is_true "${TENCENT_BROWSER_HEADLESS:-true}"; then
|
|
||||||
start_browser_debug_services
|
|
||||||
fi
|
|
||||||
|
|
||||||
exec npm run dev
|
exec npm run dev
|
||||||
|
|||||||
@@ -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()
|
|
||||||
})
|
|
||||||
}
|
|
||||||
@@ -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 <<EOF
|
|
||||||
{"biz_order_id":"${ORDER_ID}","buyer_id":"buyer-local-001","buyer_name":"本地测试用户","receiver_contact":"13800000000","total_fee":"1","currency":"CNY","items":[{"goods_id":"${SKU_ID}","goods_name":"${SKU_NAME}","quantity":1}]}
|
|
||||||
EOF
|
|
||||||
}
|
|
||||||
|
|
||||||
paid_payload() {
|
|
||||||
cat <<EOF
|
|
||||||
{"biz_order_id":"${ORDER_ID}","buyer_id":"buyer-local-001","buyer_name":"本地测试用户","receiver_contact":"13800000000","total_fee":"1","currency":"CNY","order_status":"3","items":[{"goods_id":"${SKU_ID}","goods_name":"${SKU_NAME}","quantity":1}]}
|
|
||||||
EOF
|
|
||||||
}
|
|
||||||
|
|
||||||
confirm_payload() {
|
|
||||||
cat <<EOF
|
|
||||||
{"biz_order_id":"${ORDER_ID}","buyer_id":"buyer-local-001","buyer_name":"本地测试用户","receiver_contact":"13800000000","total_fee":"1","currency":"CNY","order_status":"4","items":[{"goods_id":"${SKU_ID}","goods_name":"${SKU_NAME}","quantity":1}]}
|
|
||||||
EOF
|
|
||||||
}
|
|
||||||
|
|
||||||
push_create() {
|
|
||||||
push_agiso_event "32" "$(create_payload)"
|
|
||||||
}
|
|
||||||
|
|
||||||
push_paid() {
|
|
||||||
push_agiso_event "1" "$(paid_payload)"
|
|
||||||
}
|
|
||||||
|
|
||||||
push_confirm() {
|
|
||||||
push_agiso_event "256" "$(confirm_payload)"
|
|
||||||
}
|
|
||||||
|
|
||||||
case "${2:-all}" in
|
|
||||||
create)
|
|
||||||
push_create
|
|
||||||
;;
|
|
||||||
paid)
|
|
||||||
push_paid
|
|
||||||
;;
|
|
||||||
confirm)
|
|
||||||
push_confirm
|
|
||||||
;;
|
|
||||||
all)
|
|
||||||
push_create
|
|
||||||
push_paid
|
|
||||||
;;
|
|
||||||
*)
|
|
||||||
echo "用法: $0 [ORDER_ID] [create|paid|confirm|all]"
|
|
||||||
exit 1
|
|
||||||
;;
|
|
||||||
esac
|
|
||||||
@@ -1,101 +0,0 @@
|
|||||||
import type { NextFunction, Request, Response } from "express";
|
|
||||||
import express from "express";
|
|
||||||
import process from "node:process";
|
|
||||||
|
|
||||||
import adminRouter from "./routes/admin-kuaishou.js";
|
|
||||||
import claimsRouter from "./routes/claims-kuaishou.js";
|
|
||||||
import open91Router from "./routes/open-91.js";
|
|
||||||
import { accessLogMiddleware } from "./middleware/access-log.js";
|
|
||||||
import { createCorsMiddleware } from "./middleware/cors.js";
|
|
||||||
import { buildHealthPayload, type StartupState } from "./startup/state.js";
|
|
||||||
import type { RuntimeConfig } from "./types/runtime-config.js";
|
|
||||||
import { buildSuccessPayload, sendRouteError } from "./utils/http.js";
|
|
||||||
|
|
||||||
type CreateKuaishouAppOptions = {
|
|
||||||
startupState: StartupState;
|
|
||||||
isShutdownStarted: () => boolean;
|
|
||||||
config: RuntimeConfig;
|
|
||||||
};
|
|
||||||
|
|
||||||
export function createKuaishouApp({
|
|
||||||
startupState,
|
|
||||||
isShutdownStarted,
|
|
||||||
config,
|
|
||||||
}: CreateKuaishouAppOptions) {
|
|
||||||
const app = express();
|
|
||||||
|
|
||||||
app.use(accessLogMiddleware);
|
|
||||||
app.use(createCorsMiddleware(config));
|
|
||||||
app.use(express.json({ limit: "2mb" }));
|
|
||||||
app.use(express.urlencoded({ extended: true }));
|
|
||||||
|
|
||||||
app.get("/health", (_req, res) => {
|
|
||||||
res.json(
|
|
||||||
buildSuccessPayload(
|
|
||||||
buildHealthPayload(startupState, isShutdownStarted()),
|
|
||||||
startupState.core.ready ? "ready" : "starting"
|
|
||||||
)
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
app.get("/health/live", (_req, res) => {
|
|
||||||
res.json(
|
|
||||||
buildSuccessPayload({
|
|
||||||
status: isShutdownStarted() ? "shutting_down" : "alive",
|
|
||||||
pid: process.pid,
|
|
||||||
mode: "kuaishou-lite",
|
|
||||||
})
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
app.get("/health/ready", (_req, res) => {
|
|
||||||
if (startupState.core.ready) {
|
|
||||||
res.json(
|
|
||||||
buildSuccessPayload(
|
|
||||||
buildHealthPayload(startupState, isShutdownStarted()),
|
|
||||||
"ready"
|
|
||||||
)
|
|
||||||
);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
res.status(503).json({
|
|
||||||
code: 1,
|
|
||||||
msg: startupState.core.lastError || "服务启动中,请稍后重试",
|
|
||||||
errorCode: "service_not_ready",
|
|
||||||
time: Math.floor(Date.now() / 1000),
|
|
||||||
data: buildHealthPayload(startupState, isShutdownStarted()),
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
app.use((req, res, next) => {
|
|
||||||
if (startupState.core.ready) {
|
|
||||||
next();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
res.status(503).json({
|
|
||||||
code: 1,
|
|
||||||
msg: startupState.core.lastError || "服务启动中,请稍后重试",
|
|
||||||
errorCode: "service_not_ready",
|
|
||||||
time: Math.floor(Date.now() / 1000),
|
|
||||||
data: {
|
|
||||||
startup: {
|
|
||||||
phase: startupState.phase,
|
|
||||||
attemptCount: startupState.core.attemptCount,
|
|
||||||
lastAttemptAt: startupState.core.lastAttemptAt,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
app.use("/api/v1/open/91", open91Router);
|
|
||||||
app.use("/api/v1/claim", claimsRouter);
|
|
||||||
app.use("/api/v1/admin", adminRouter);
|
|
||||||
|
|
||||||
app.use((err: unknown, _req: Request, res: Response, _next: NextFunction) => {
|
|
||||||
sendRouteError(res, err, "服务内部错误", "[global:kuaishou]");
|
|
||||||
});
|
|
||||||
|
|
||||||
return app;
|
|
||||||
}
|
|
||||||
@@ -1,17 +1,16 @@
|
|||||||
import type { Request, Response, NextFunction } from "express";
|
import type { NextFunction, Request, Response } from "express";
|
||||||
import express from "express";
|
import express from "express";
|
||||||
import process from "node:process";
|
import process from "node:process";
|
||||||
|
|
||||||
import adminRouter from "./routes/admin.js";
|
import adminRouter from "./routes/admin.js";
|
||||||
import claimsRouter from "./routes/claims.js";
|
import claimsRouter from "./routes/claims.js";
|
||||||
import open91Router from "./routes/open-91.js";
|
import open91Router from "./routes/open-91.js";
|
||||||
import tencentRouter from "./routes/tencent.js";
|
|
||||||
import webhooksRouter from "./routes/webhooks.js";
|
|
||||||
import { buildSuccessPayload, sendRouteError } from "./utils/http.js";
|
|
||||||
import { accessLogMiddleware } from "./middleware/access-log.js";
|
import { accessLogMiddleware } from "./middleware/access-log.js";
|
||||||
import { createCorsMiddleware } from "./middleware/cors.js";
|
import { createCorsMiddleware } from "./middleware/cors.js";
|
||||||
import type { RuntimeConfig } from "./types/runtime-config.js";
|
|
||||||
import { buildHealthPayload, type StartupState } from "./startup/state.js";
|
import { buildHealthPayload, type StartupState } from "./startup/state.js";
|
||||||
|
import type { RuntimeConfig } from "./types/runtime-config.js";
|
||||||
|
import { buildSuccessPayload, sendRouteError } from "./utils/http.js";
|
||||||
|
|
||||||
type CreateAppOptions = {
|
type CreateAppOptions = {
|
||||||
startupState: StartupState;
|
startupState: StartupState;
|
||||||
isShutdownStarted: () => boolean;
|
isShutdownStarted: () => boolean;
|
||||||
@@ -44,6 +43,7 @@ export function createApp({
|
|||||||
buildSuccessPayload({
|
buildSuccessPayload({
|
||||||
status: isShutdownStarted() ? "shutting_down" : "alive",
|
status: isShutdownStarted() ? "shutting_down" : "alive",
|
||||||
pid: process.pid,
|
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/open/91", open91Router);
|
||||||
app.use("/api/v1/webhooks", webhooksRouter);
|
|
||||||
app.use("/api/v1/claim", claimsRouter);
|
app.use("/api/v1/claim", claimsRouter);
|
||||||
app.use("/api/v1/admin", adminRouter);
|
app.use("/api/v1/admin", adminRouter);
|
||||||
|
|
||||||
|
|||||||
@@ -8,24 +8,6 @@ export function createDefaultRuntimeConfig(projectRoot: string): RuntimeConfig {
|
|||||||
port: 3000,
|
port: 3000,
|
||||||
},
|
},
|
||||||
|
|
||||||
browser: {
|
|
||||||
chromePath: "",
|
|
||||||
headless: null,
|
|
||||||
devtools: null,
|
|
||||||
keepAlive: null,
|
|
||||||
prewarm: null,
|
|
||||||
slowMoMs: null,
|
|
||||||
vncResolution: "1600x900x24",
|
|
||||||
},
|
|
||||||
|
|
||||||
session: {
|
|
||||||
debug: false,
|
|
||||||
},
|
|
||||||
|
|
||||||
ocr: {
|
|
||||||
baseUrl: "",
|
|
||||||
},
|
|
||||||
|
|
||||||
data: {
|
data: {
|
||||||
root: path.resolve(projectRoot, "data"),
|
root: path.resolve(projectRoot, "data"),
|
||||||
},
|
},
|
||||||
@@ -52,30 +34,6 @@ export function createDefaultRuntimeConfig(projectRoot: string): RuntimeConfig {
|
|||||||
},
|
},
|
||||||
|
|
||||||
platforms: {
|
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: {
|
ninetyone: {
|
||||||
userId: "",
|
userId: "",
|
||||||
secret: "",
|
secret: "",
|
||||||
@@ -137,9 +95,5 @@ PfQTPNA++KsXtwRX9M6Re3vkTDRsutIIWKtj8jqhUbbYS3vzS8GJnAWavUFVkR15
|
|||||||
cors: {
|
cors: {
|
||||||
allowedOrigins: ["*"],
|
allowedOrigins: ["*"],
|
||||||
},
|
},
|
||||||
|
|
||||||
redeem: {
|
|
||||||
proofMode: "full",
|
|
||||||
},
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -21,17 +21,13 @@ const DEPLOYMENT_ONLY_ENV_NAMES = [
|
|||||||
'CADDY_SITE_ADDR',
|
'CADDY_SITE_ADDR',
|
||||||
'CHOKIDAR_USEPOLLING',
|
'CHOKIDAR_USEPOLLING',
|
||||||
'NPM_CONFIG_REGISTRY',
|
'NPM_CONFIG_REGISTRY',
|
||||||
'OCR_IMAGE',
|
|
||||||
'OCR_PORT',
|
|
||||||
'POSTGRES_DB',
|
'POSTGRES_DB',
|
||||||
'POSTGRES_PASSWORD',
|
'POSTGRES_PASSWORD',
|
||||||
'POSTGRES_PORT',
|
'POSTGRES_PORT',
|
||||||
'POSTGRES_USER',
|
'POSTGRES_USER',
|
||||||
'TENCENT_BROWSER_NOVNC_ENABLED',
|
|
||||||
'TENCENT_BROWSER_NOVNC_PORT',
|
|
||||||
'TENCENT_BROWSER_VNC_PORT',
|
|
||||||
'TZ',
|
'TZ',
|
||||||
'VITE_API_TARGET',
|
'VITE_API_TARGET',
|
||||||
|
'VITE_ALLOWED_HOSTS',
|
||||||
]
|
]
|
||||||
|
|
||||||
const KNOWN_ENV_NAMES = new Set([
|
const KNOWN_ENV_NAMES = new Set([
|
||||||
|
|||||||
@@ -10,32 +10,21 @@ test('applyEnvOverrides maps typed environment values without mutating base conf
|
|||||||
|
|
||||||
const config = applyEnvOverrides(baseConfig, {
|
const config = applyEnvOverrides(baseConfig, {
|
||||||
PORT: '4000',
|
PORT: '4000',
|
||||||
TENCENT_BROWSER_HEADLESS: 'true',
|
|
||||||
TENCENT_BROWSER_SLOW_MO: '250',
|
|
||||||
TENCENT_BROWSER_VNC_RESOLUTION: '1920x1080x24',
|
|
||||||
DATA_ROOT: './tmp/backend-data',
|
DATA_ROOT: './tmp/backend-data',
|
||||||
DATABASE_SSL: 'yes',
|
DATABASE_SSL: 'yes',
|
||||||
ADMIN_DEFAULT_USERS_JSON: '[{"username":"admin","password":"secret","role":"admin"}]',
|
ADMIN_DEFAULT_USERS_JSON: '[{"username":"admin","password":"secret","role":"admin"}]',
|
||||||
AGISO_TRADE_DETAIL_TIMEOUT_MS: '7000',
|
|
||||||
CLOUDTENTACLES_DEVICE_TYPE: '3',
|
CLOUDTENTACLES_DEVICE_TYPE: '3',
|
||||||
TENCENT_REDEEM_PROOF_MODE: 'basic',
|
|
||||||
})
|
})
|
||||||
|
|
||||||
assert.equal(config.server.port, 4000)
|
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.data.root, path.resolve('./tmp/backend-data'))
|
||||||
assert.equal(config.database.ssl, true)
|
assert.equal(config.database.ssl, true)
|
||||||
assert.deepEqual(config.admin.defaultUsers, [
|
assert.deepEqual(config.admin.defaultUsers, [
|
||||||
{ username: 'admin', password: 'secret', role: 'admin' },
|
{ username: 'admin', password: 'secret', role: 'admin' },
|
||||||
])
|
])
|
||||||
assert.equal(config.platforms.agiso.tradeDetail.timeoutMs, 7000)
|
|
||||||
assert.equal(config.platforms.cloudtentacles.deviceType, 3)
|
assert.equal(config.platforms.cloudtentacles.deviceType, 3)
|
||||||
assert.equal(config.redeem.proofMode, 'basic')
|
|
||||||
|
|
||||||
assert.equal(baseConfig.server.port, 3000)
|
assert.equal(baseConfig.server.port, 3000)
|
||||||
assert.equal(baseConfig.browser.headless, null)
|
|
||||||
assert.deepEqual(baseConfig.admin.defaultUsers, [])
|
assert.deepEqual(baseConfig.admin.defaultUsers, [])
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -80,21 +69,6 @@ function createRuntimeConfig(): RuntimeConfig {
|
|||||||
server: {
|
server: {
|
||||||
port: 3000,
|
port: 3000,
|
||||||
},
|
},
|
||||||
browser: {
|
|
||||||
chromePath: '',
|
|
||||||
headless: null,
|
|
||||||
devtools: null,
|
|
||||||
keepAlive: null,
|
|
||||||
prewarm: null,
|
|
||||||
slowMoMs: null,
|
|
||||||
vncResolution: '1600x900x24',
|
|
||||||
},
|
|
||||||
session: {
|
|
||||||
debug: false,
|
|
||||||
},
|
|
||||||
ocr: {
|
|
||||||
baseUrl: '',
|
|
||||||
},
|
|
||||||
data: {
|
data: {
|
||||||
root: '/tmp/order-site',
|
root: '/tmp/order-site',
|
||||||
},
|
},
|
||||||
@@ -116,27 +90,6 @@ function createRuntimeConfig(): RuntimeConfig {
|
|||||||
defaultUsers: [],
|
defaultUsers: [],
|
||||||
},
|
},
|
||||||
platforms: {
|
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: {
|
ninetyone: {
|
||||||
userId: '',
|
userId: '',
|
||||||
secret: '',
|
secret: '',
|
||||||
@@ -184,8 +137,5 @@ function createRuntimeConfig(): RuntimeConfig {
|
|||||||
deviceType: 0,
|
deviceType: 0,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
redeem: {
|
|
||||||
proofMode: 'full',
|
|
||||||
},
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -30,15 +30,6 @@ type EnvOverride = {
|
|||||||
|
|
||||||
export const ENV_OVERRIDES: readonly EnvOverride[] = [
|
export const ENV_OVERRIDES: readonly EnvOverride[] = [
|
||||||
integerEnv("PORT", ["server", "port"]),
|
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("DATA_ROOT", ["data", "root"], path.resolve),
|
||||||
stringEnv("LOG_LEVEL", ["logging", "level"]),
|
stringEnv("LOG_LEVEL", ["logging", "level"]),
|
||||||
stringEnv("DATABASE_URL", ["database", "url"]),
|
stringEnv("DATABASE_URL", ["database", "url"]),
|
||||||
@@ -48,61 +39,6 @@ export const ENV_OVERRIDES: readonly EnvOverride[] = [
|
|||||||
stringEnv("ADMIN_SESSION_SECRET", ["admin", "sessionSecret"]),
|
stringEnv("ADMIN_SESSION_SECRET", ["admin", "sessionSecret"]),
|
||||||
integerEnv("ADMIN_SESSION_TTL_HOURS", ["admin", "sessionTtlHours"]),
|
integerEnv("ADMIN_SESSION_TTL_HOURS", ["admin", "sessionTtlHours"]),
|
||||||
adminUsersJsonEnv("ADMIN_DEFAULT_USERS_JSON", ["admin", "defaultUsers"]),
|
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_USER_ID", ["platforms", "ninetyone", "userId"]),
|
||||||
stringEnv("NINETYONE_SECRET", ["platforms", "ninetyone", "secret"]),
|
stringEnv("NINETYONE_SECRET", ["platforms", "ninetyone", "secret"]),
|
||||||
stringEnv("NINETYONE_VERSION", ["platforms", "ninetyone", "version"]),
|
stringEnv("NINETYONE_VERSION", ["platforms", "ninetyone", "version"]),
|
||||||
@@ -288,7 +224,6 @@ export const ENV_OVERRIDES: readonly EnvOverride[] = [
|
|||||||
"cloudtentacles",
|
"cloudtentacles",
|
||||||
"deviceType",
|
"deviceType",
|
||||||
]),
|
]),
|
||||||
stringEnv("TENCENT_REDEEM_PROOF_MODE", ["redeem", "proofMode"]),
|
|
||||||
corsOriginsEnv("CORS_ALLOWED_ORIGINS", ["cors", "allowedOrigins"]),
|
corsOriginsEnv("CORS_ALLOWED_ORIGINS", ["cors", "allowedOrigins"]),
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|||||||
@@ -112,21 +112,6 @@ function createRuntimeConfig(overrides: Record<string, unknown> = {}): RuntimeCo
|
|||||||
server: {
|
server: {
|
||||||
port: 3000,
|
port: 3000,
|
||||||
},
|
},
|
||||||
browser: {
|
|
||||||
chromePath: '',
|
|
||||||
headless: null,
|
|
||||||
devtools: null,
|
|
||||||
keepAlive: null,
|
|
||||||
prewarm: null,
|
|
||||||
slowMoMs: null,
|
|
||||||
vncResolution: '1600x900x24',
|
|
||||||
},
|
|
||||||
session: {
|
|
||||||
debug: false,
|
|
||||||
},
|
|
||||||
ocr: {
|
|
||||||
baseUrl: '',
|
|
||||||
},
|
|
||||||
data: {
|
data: {
|
||||||
root: '/tmp/order-site',
|
root: '/tmp/order-site',
|
||||||
},
|
},
|
||||||
@@ -154,27 +139,6 @@ function createRuntimeConfig(overrides: Record<string, unknown> = {}): RuntimeCo
|
|||||||
],
|
],
|
||||||
},
|
},
|
||||||
platforms: {
|
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: {
|
ninetyone: {
|
||||||
userId: '',
|
userId: '',
|
||||||
secret: '',
|
secret: '',
|
||||||
@@ -222,8 +186,5 @@ function createRuntimeConfig(overrides: Record<string, unknown> = {}): RuntimeCo
|
|||||||
deviceType: 0,
|
deviceType: 0,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
redeem: {
|
|
||||||
proofMode: 'full',
|
|
||||||
},
|
|
||||||
}, overrides) as RuntimeConfig
|
}, overrides) as RuntimeConfig
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -47,7 +47,6 @@ export function validateRuntimeConfig(
|
|||||||
requireInteger(issues, 'database.maxConnections', config.database?.maxConnections, { min: 1 })
|
requireInteger(issues, 'database.maxConnections', config.database?.maxConnections, { min: 1 })
|
||||||
requireInteger(issues, 'orders.tokenTtlHours', config.orders?.tokenTtlHours, { min: 1 })
|
requireInteger(issues, 'orders.tokenTtlHours', config.orders?.tokenTtlHours, { min: 1 })
|
||||||
requireInteger(issues, 'admin.sessionTtlHours', config.admin?.sessionTtlHours, { 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.timeoutMs', config.platforms?.cloudtentacles?.timeoutMs, { min: 1 })
|
||||||
requireInteger(issues, 'platforms.cloudtentacles.bindUrlTtlSeconds', config.platforms?.cloudtentacles?.bindUrlTtlSeconds, { min: 1 })
|
requireInteger(issues, 'platforms.cloudtentacles.bindUrlTtlSeconds', config.platforms?.cloudtentacles?.bindUrlTtlSeconds, { min: 1 })
|
||||||
requireInteger(
|
requireInteger(
|
||||||
|
|||||||
@@ -1,71 +0,0 @@
|
|||||||
import process from "node:process";
|
|
||||||
|
|
||||||
import { createKuaishouApp } from "./app-kuaishou.js";
|
|
||||||
import { runtimeConfig } from "./config/runtime.js";
|
|
||||||
import { assertRuntimeConfigValid } from "./config/runtime-validation.js";
|
|
||||||
import { bootstrapKuaishouCoreServices } from "./startup/bootstrap-kuaishou.js";
|
|
||||||
import { createKuaishouShutdownController } from "./startup/shutdown-kuaishou.js";
|
|
||||||
import { createStartupState, formatStartupError } from "./startup/state.js";
|
|
||||||
import { logError, logInfo } from "./utils/logger.js";
|
|
||||||
|
|
||||||
const port = Number(runtimeConfig.server.port || 3000);
|
|
||||||
const host = "0.0.0.0";
|
|
||||||
const startupState = createStartupState();
|
|
||||||
|
|
||||||
let shutdownController: ReturnType<
|
|
||||||
typeof createKuaishouShutdownController
|
|
||||||
> | null = null;
|
|
||||||
|
|
||||||
try {
|
|
||||||
assertRuntimeConfigValid(runtimeConfig);
|
|
||||||
} catch (error) {
|
|
||||||
logError("[startup:kuaishou]", "运行时配置校验失败,服务停止启动", error);
|
|
||||||
process.exit(1);
|
|
||||||
}
|
|
||||||
|
|
||||||
const app = createKuaishouApp({
|
|
||||||
startupState,
|
|
||||||
isShutdownStarted: () => shutdownController?.isShutdownStarted() || false,
|
|
||||||
config: runtimeConfig,
|
|
||||||
});
|
|
||||||
|
|
||||||
const server = app.listen(port, host, () => {
|
|
||||||
logInfo(
|
|
||||||
"[startup:kuaishou]",
|
|
||||||
`order-site-backend kuaishou-lite listening on http://${host}:${port}`
|
|
||||||
);
|
|
||||||
void bootstrapKuaishouCoreServices(
|
|
||||||
startupState,
|
|
||||||
() => shutdownController?.isShutdownStarted() || false
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
shutdownController = createKuaishouShutdownController(server, startupState);
|
|
||||||
|
|
||||||
server.on("error", (error) => {
|
|
||||||
logError("[startup:kuaishou]", "HTTP server failed", error);
|
|
||||||
});
|
|
||||||
|
|
||||||
process.on("SIGINT", () => {
|
|
||||||
void shutdownController?.shutdown("SIGINT");
|
|
||||||
});
|
|
||||||
|
|
||||||
process.on("SIGTERM", () => {
|
|
||||||
void shutdownController?.shutdown("SIGTERM");
|
|
||||||
});
|
|
||||||
|
|
||||||
process.on("unhandledRejection", (reason) => {
|
|
||||||
startupState.process.lastUnhandledRejection = {
|
|
||||||
time: new Date().toISOString(),
|
|
||||||
message: formatStartupError(reason),
|
|
||||||
};
|
|
||||||
logError("[process:kuaishou]", "unhandled promise rejection", reason);
|
|
||||||
});
|
|
||||||
|
|
||||||
process.on("uncaughtException", (error) => {
|
|
||||||
startupState.process.lastUncaughtException = {
|
|
||||||
time: new Date().toISOString(),
|
|
||||||
message: formatStartupError(error),
|
|
||||||
};
|
|
||||||
logError("[process:kuaishou]", "uncaught exception captured", error);
|
|
||||||
});
|
|
||||||
@@ -20,6 +20,7 @@ try {
|
|||||||
logError("[startup]", "运行时配置校验失败,服务停止启动", error);
|
logError("[startup]", "运行时配置校验失败,服务停止启动", error);
|
||||||
process.exit(1);
|
process.exit(1);
|
||||||
}
|
}
|
||||||
|
|
||||||
const app = createApp({
|
const app = createApp({
|
||||||
startupState,
|
startupState,
|
||||||
isShutdownStarted: () => shutdownController?.isShutdownStarted() || false,
|
isShutdownStarted: () => shutdownController?.isShutdownStarted() || false,
|
||||||
@@ -27,7 +28,10 @@ const app = createApp({
|
|||||||
});
|
});
|
||||||
|
|
||||||
const server = app.listen(port, host, () => {
|
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(
|
void bootstrapCoreServices(
|
||||||
startupState,
|
startupState,
|
||||||
() => shutdownController?.isShutdownStarted() || false
|
() => shutdownController?.isShutdownStarted() || false
|
||||||
|
|||||||
@@ -1,34 +0,0 @@
|
|||||||
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-kuaishou.js";
|
|
||||||
import messageDeliveriesRouter from "./admin/message-deliveries.js";
|
|
||||||
import ordersRouter from "./admin/orders.js";
|
|
||||||
import platformConfigRouter from "./admin/platform-config-kuaishou.js";
|
|
||||||
import { requireAdminSession } from "./admin/shared.js";
|
|
||||||
import tasksRouter from "./admin/tasks-kuaishou.js";
|
|
||||||
import usersRouter from "./admin/users.js";
|
|
||||||
import webhookEventsRouter from "./admin/webhook-events-kuaishou.js";
|
|
||||||
import { buildNotFoundPayload } from "../utils/http.js";
|
|
||||||
|
|
||||||
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(inventoryRouter);
|
|
||||||
router.use(messageDeliveriesRouter);
|
|
||||||
router.use(webhookEventsRouter);
|
|
||||||
|
|
||||||
router.use((req, res) => {
|
|
||||||
res.status(404).json(buildNotFoundPayload(req));
|
|
||||||
});
|
|
||||||
|
|
||||||
export default router;
|
|
||||||
@@ -1,36 +1,34 @@
|
|||||||
import { Router } from 'express'
|
import { Router } from "express";
|
||||||
|
|
||||||
import authRouter from './admin/auth.js'
|
import authRouter from "./admin/auth.js";
|
||||||
import auditLogsRouter from './admin/audit-logs.js'
|
import auditLogsRouter from "./admin/audit-logs.js";
|
||||||
import dashboardRouter from './admin/dashboard.js'
|
import dashboardRouter from "./admin/dashboard.js";
|
||||||
import inventoryRouter from './admin/inventory.js'
|
import inventoryRouter from "./admin/inventory.js";
|
||||||
import manualRedeemRouter from './admin/manual-redeem.js'
|
import messageDeliveriesRouter from "./admin/message-deliveries.js";
|
||||||
import messageDeliveriesRouter from './admin/message-deliveries.js'
|
import ordersRouter from "./admin/orders.js";
|
||||||
import ordersRouter from './admin/orders.js'
|
import platformConfigRouter from "./admin/platform-config.js";
|
||||||
import platformConfigRouter from './admin/platform-config.js'
|
import { requireAdminSession } from "./admin/shared.js";
|
||||||
import { requireAdminSession } from './admin/shared.js'
|
import tasksRouter from "./admin/tasks.js";
|
||||||
import tasksRouter from './admin/tasks.js'
|
import usersRouter from "./admin/users.js";
|
||||||
import usersRouter from './admin/users.js'
|
import webhookEventsRouter from "./admin/webhook-events.js";
|
||||||
import webhookEventsRouter from './admin/webhook-events.js'
|
import { buildNotFoundPayload } from "../utils/http.js";
|
||||||
import { buildNotFoundPayload } from '../utils/http.js'
|
|
||||||
|
|
||||||
const router = Router()
|
const router = Router();
|
||||||
|
|
||||||
router.use(authRouter)
|
router.use(authRouter);
|
||||||
router.use(requireAdminSession)
|
router.use(requireAdminSession);
|
||||||
router.use(dashboardRouter)
|
router.use(dashboardRouter);
|
||||||
router.use(usersRouter)
|
router.use(usersRouter);
|
||||||
router.use(auditLogsRouter)
|
router.use(auditLogsRouter);
|
||||||
router.use(platformConfigRouter)
|
router.use(platformConfigRouter);
|
||||||
router.use(ordersRouter)
|
router.use(ordersRouter);
|
||||||
router.use(tasksRouter)
|
router.use(tasksRouter);
|
||||||
router.use(manualRedeemRouter)
|
router.use(inventoryRouter);
|
||||||
router.use(inventoryRouter)
|
router.use(messageDeliveriesRouter);
|
||||||
router.use(messageDeliveriesRouter)
|
router.use(webhookEventsRouter);
|
||||||
router.use(webhookEventsRouter)
|
|
||||||
|
|
||||||
router.use((req, res) => {
|
router.use((req, res) => {
|
||||||
res.status(404).json(buildNotFoundPayload(req))
|
res.status(404).json(buildNotFoundPayload(req));
|
||||||
})
|
});
|
||||||
|
|
||||||
export default router
|
export default router;
|
||||||
|
|||||||
@@ -1,149 +0,0 @@
|
|||||||
import { Router } from "express";
|
|
||||||
|
|
||||||
import {
|
|
||||||
createAdminInventoryItem,
|
|
||||||
importAdminInventoryItems,
|
|
||||||
invalidateAdminInventoryItem,
|
|
||||||
releaseAdminInventoryItem,
|
|
||||||
} from "../../services/admin/write/inventory.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-kuaishou/inventory]",
|
|
||||||
}
|
|
||||||
)
|
|
||||||
);
|
|
||||||
|
|
||||||
router.get(
|
|
||||||
"/inventory/sku-suggestions",
|
|
||||||
createJsonHandler(
|
|
||||||
(req) =>
|
|
||||||
getAdminInventorySkuSuggestions(
|
|
||||||
req.query as AdminInventorySkuSuggestionRouteQuery,
|
|
||||||
req.adminSession || null
|
|
||||||
),
|
|
||||||
{
|
|
||||||
successMessage: "ok",
|
|
||||||
errorMessage: "读取内部 SKU 建议失败",
|
|
||||||
scope: "[admin-kuaishou/inventory/sku-suggestions]",
|
|
||||||
}
|
|
||||||
)
|
|
||||||
);
|
|
||||||
|
|
||||||
router.post(
|
|
||||||
"/inventory",
|
|
||||||
createJsonHandler(
|
|
||||||
(req) => createAdminInventoryItem(req.body as AdminInventoryCreateRouteBody),
|
|
||||||
{
|
|
||||||
successMessage: "库存项已新增",
|
|
||||||
errorMessage: "新增库存项失败",
|
|
||||||
scope: "[admin-kuaishou/inventory]",
|
|
||||||
}
|
|
||||||
)
|
|
||||||
);
|
|
||||||
|
|
||||||
router.post(
|
|
||||||
"/inventory/import",
|
|
||||||
createJsonHandler(
|
|
||||||
(req) => importAdminInventoryItems(req.body as AdminInventoryImportRouteBody),
|
|
||||||
{
|
|
||||||
successMessage: "导入完成",
|
|
||||||
errorMessage: "导入库存凭据失败",
|
|
||||||
scope: "[admin-kuaishou/inventory/import]",
|
|
||||||
}
|
|
||||||
)
|
|
||||||
);
|
|
||||||
|
|
||||||
router.post(
|
|
||||||
"/inventory/:inventoryItemId/release",
|
|
||||||
requireAdminRoles(["admin"]),
|
|
||||||
createJsonHandler(
|
|
||||||
(req) =>
|
|
||||||
releaseAdminInventoryItem(
|
|
||||||
(req.params as AdminInventoryRouteParams).inventoryItemId
|
|
||||||
),
|
|
||||||
{
|
|
||||||
successMessage: "库存项已释放",
|
|
||||||
errorMessage: "释放库存项失败",
|
|
||||||
scope: "[admin-kuaishou/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-kuaishou/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;
|
|
||||||
@@ -1,16 +1,16 @@
|
|||||||
import { Router } from 'express'
|
import { Router } from "express";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
createAdminInventoryItem,
|
createAdminInventoryItem,
|
||||||
importAdminInventoryItems,
|
importAdminInventoryItems,
|
||||||
invalidateAdminInventoryItem,
|
invalidateAdminInventoryItem,
|
||||||
releaseAdminInventoryItem,
|
releaseAdminInventoryItem,
|
||||||
} from '../../services/admin/admin-write-service.js'
|
} from "../../services/admin/write/inventory.js";
|
||||||
import {
|
import {
|
||||||
getAdminInventoryItems,
|
getAdminInventoryItems,
|
||||||
getAdminInventorySkuSuggestions,
|
getAdminInventorySkuSuggestions,
|
||||||
} from '../../services/admin/admin-read-service.js'
|
} from "../../services/admin/admin-read-service.js";
|
||||||
import { createJsonHandler, requireAdminRoles } from './shared.js'
|
import { createJsonHandler, requireAdminRoles } from "./shared.js";
|
||||||
import type {
|
import type {
|
||||||
AdminInventoryCreateRouteBody,
|
AdminInventoryCreateRouteBody,
|
||||||
AdminInventoryImportRouteBody,
|
AdminInventoryImportRouteBody,
|
||||||
@@ -18,100 +18,132 @@ import type {
|
|||||||
AdminInventoryRouteParams,
|
AdminInventoryRouteParams,
|
||||||
AdminInventoryRouteQuery,
|
AdminInventoryRouteQuery,
|
||||||
AdminInventorySkuSuggestionRouteQuery,
|
AdminInventorySkuSuggestionRouteQuery,
|
||||||
} from '../../types/admin-route-inputs.js'
|
} from "../../types/admin-route-inputs.js";
|
||||||
import type { AdminInventoryMutationResponse } from '../../types/admin-write-models.js'
|
import type { AdminInventoryMutationResponse } from "../../types/admin-write-models.js";
|
||||||
|
|
||||||
const router = Router()
|
const router = Router();
|
||||||
|
|
||||||
router.use('/inventory', requireAdminRoles(['admin', 'operator']))
|
router.use("/inventory", requireAdminRoles(["admin", "operator"]));
|
||||||
|
|
||||||
router.get('/inventory', createJsonHandler(
|
router.get(
|
||||||
(req) => getAdminInventoryItems(
|
"/inventory",
|
||||||
req.query as AdminInventoryRouteQuery,
|
createJsonHandler(
|
||||||
req.adminSession || null,
|
(req) =>
|
||||||
),
|
getAdminInventoryItems(
|
||||||
{
|
req.query as AdminInventoryRouteQuery,
|
||||||
successMessage: 'ok',
|
req.adminSession || null
|
||||||
errorMessage: '读取库存列表失败',
|
),
|
||||||
scope: '[admin/inventory]',
|
{
|
||||||
},
|
successMessage: "ok",
|
||||||
))
|
errorMessage: "读取库存列表失败",
|
||||||
|
scope: "[admin/inventory]",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
router.get('/inventory/sku-suggestions', createJsonHandler(
|
router.get(
|
||||||
(req) => getAdminInventorySkuSuggestions(
|
"/inventory/sku-suggestions",
|
||||||
req.query as AdminInventorySkuSuggestionRouteQuery,
|
createJsonHandler(
|
||||||
req.adminSession || null,
|
(req) =>
|
||||||
),
|
getAdminInventorySkuSuggestions(
|
||||||
{
|
req.query as AdminInventorySkuSuggestionRouteQuery,
|
||||||
successMessage: 'ok',
|
req.adminSession || null
|
||||||
errorMessage: '读取内部 SKU 建议失败',
|
),
|
||||||
scope: '[admin/inventory/sku-suggestions]',
|
{
|
||||||
},
|
successMessage: "ok",
|
||||||
))
|
errorMessage: "读取内部 SKU 建议失败",
|
||||||
|
scope: "[admin/inventory/sku-suggestions]",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
router.post('/inventory', createJsonHandler(
|
router.post(
|
||||||
(req) => createAdminInventoryItem(req.body as AdminInventoryCreateRouteBody),
|
"/inventory",
|
||||||
{
|
createJsonHandler(
|
||||||
successMessage: '库存项已新增',
|
(req) => createAdminInventoryItem(req.body as AdminInventoryCreateRouteBody),
|
||||||
errorMessage: '新增库存项失败',
|
{
|
||||||
scope: '[admin/inventory]',
|
successMessage: "库存项已新增",
|
||||||
},
|
errorMessage: "新增库存项失败",
|
||||||
))
|
scope: "[admin/inventory]",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
router.post('/inventory/import', createJsonHandler(
|
router.post(
|
||||||
(req) => importAdminInventoryItems(req.body as AdminInventoryImportRouteBody),
|
"/inventory/import",
|
||||||
{
|
createJsonHandler(
|
||||||
successMessage: '导入完成',
|
(req) => importAdminInventoryItems(req.body as AdminInventoryImportRouteBody),
|
||||||
errorMessage: '导入库存凭据失败',
|
{
|
||||||
scope: '[admin/inventory/import]',
|
successMessage: "导入完成",
|
||||||
},
|
errorMessage: "导入库存凭据失败",
|
||||||
))
|
scope: "[admin/inventory/import]",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
router.post('/inventory/:inventoryItemId/release', requireAdminRoles(['admin']), createJsonHandler(
|
router.post(
|
||||||
(req) => releaseAdminInventoryItem((req.params as AdminInventoryRouteParams).inventoryItemId),
|
"/inventory/:inventoryItemId/release",
|
||||||
{
|
requireAdminRoles(["admin"]),
|
||||||
successMessage: '库存项已释放',
|
createJsonHandler(
|
||||||
errorMessage: '释放库存项失败',
|
(req) =>
|
||||||
scope: '[admin/inventory/:inventoryItemId/release]',
|
releaseAdminInventoryItem(
|
||||||
audit: (req, data) => {
|
(req.params as AdminInventoryRouteParams).inventoryItemId
|
||||||
const result = data as AdminInventoryMutationResponse
|
),
|
||||||
return {
|
{
|
||||||
action: 'inventory_item_released',
|
successMessage: "库存项已释放",
|
||||||
targetType: 'inventory_item',
|
errorMessage: "释放库存项失败",
|
||||||
targetId: String((req.params as AdminInventoryRouteParams).inventoryItemId),
|
scope: "[admin/inventory/:inventoryItemId/release]",
|
||||||
data: {
|
audit: (req, data) => {
|
||||||
inventoryItemId: result.inventoryItem?.inventoryItemId,
|
const result = data as AdminInventoryMutationResponse;
|
||||||
skuCode: result.inventoryItem?.skuCode,
|
|
||||||
displayValue: result.inventoryItem?.displayValue,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
},
|
|
||||||
},
|
|
||||||
))
|
|
||||||
|
|
||||||
router.post('/inventory/:inventoryItemId/invalidate', requireAdminRoles(['admin']), createJsonHandler(
|
return {
|
||||||
(req) => invalidateAdminInventoryItem(
|
action: "inventory_item_released",
|
||||||
(req.params as AdminInventoryRouteParams).inventoryItemId,
|
targetType: "inventory_item",
|
||||||
req.body as AdminInventoryInvalidateRouteBody,
|
targetId: String(
|
||||||
),
|
(req.params as AdminInventoryRouteParams).inventoryItemId
|
||||||
{
|
),
|
||||||
successMessage: '库存项已作废',
|
data: {
|
||||||
errorMessage: '作废库存项失败',
|
inventoryItemId: result.inventoryItem?.inventoryItemId,
|
||||||
scope: '[admin/inventory/:inventoryItemId/invalidate]',
|
skuCode: result.inventoryItem?.skuCode,
|
||||||
audit: (req, data) => {
|
displayValue: result.inventoryItem?.displayValue,
|
||||||
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
|
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;
|
||||||
|
|||||||
@@ -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
|
|
||||||
@@ -1,19 +0,0 @@
|
|||||||
import { Router } from "express";
|
|
||||||
|
|
||||||
import { requireAdminRoles } from "./shared.js";
|
|
||||||
import cloudtentaclesRouter from "./platform-config/cloudtentacles.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";
|
|
||||||
|
|
||||||
const router = Router();
|
|
||||||
|
|
||||||
router.use("/platform-config", requireAdminRoles(["admin"]));
|
|
||||||
router.use("/platform-config", notificationsRouter);
|
|
||||||
router.use("/platform-config", kuaishouEticketRouter);
|
|
||||||
router.use("/platform-config", ninetyoneRouter);
|
|
||||||
router.use("/platform-config", cloudtentaclesRouter);
|
|
||||||
router.use("/platform-config", kuaishouCloudFulfillmentRouter);
|
|
||||||
|
|
||||||
export default router;
|
|
||||||
@@ -1,9 +1,7 @@
|
|||||||
import { Router } from "express";
|
import { Router } from "express";
|
||||||
|
|
||||||
import { requireAdminRoles } from "./shared.js";
|
import { requireAdminRoles } from "./shared.js";
|
||||||
import agisoRouter from "./platform-config/agiso.js";
|
|
||||||
import cloudtentaclesRouter from "./platform-config/cloudtentacles.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 kuaishouCloudFulfillmentRouter from "./platform-config/kuaishou-cloud-fulfillment.js";
|
||||||
import kuaishouEticketRouter from "./platform-config/kuaishou-eticket.js";
|
import kuaishouEticketRouter from "./platform-config/kuaishou-eticket.js";
|
||||||
import ninetyoneRouter from "./platform-config/ninetyone.js";
|
import ninetyoneRouter from "./platform-config/ninetyone.js";
|
||||||
@@ -12,12 +10,10 @@ import notificationsRouter from "./platform-config/notifications.js";
|
|||||||
const router = Router();
|
const router = Router();
|
||||||
|
|
||||||
router.use("/platform-config", requireAdminRoles(["admin"]));
|
router.use("/platform-config", requireAdminRoles(["admin"]));
|
||||||
router.use("/platform-config", agisoRouter);
|
|
||||||
router.use("/platform-config", notificationsRouter);
|
router.use("/platform-config", notificationsRouter);
|
||||||
router.use("/platform-config", kuaishouEticketRouter);
|
router.use("/platform-config", kuaishouEticketRouter);
|
||||||
router.use("/platform-config", ninetyoneRouter);
|
router.use("/platform-config", ninetyoneRouter);
|
||||||
router.use("/platform-config", cloudtentaclesRouter);
|
router.use("/platform-config", cloudtentaclesRouter);
|
||||||
router.use("/platform-config", fulfillmentBindingsRouter);
|
|
||||||
router.use("/platform-config", kuaishouCloudFulfillmentRouter);
|
router.use("/platform-config", kuaishouCloudFulfillmentRouter);
|
||||||
|
|
||||||
export default router;
|
export default router;
|
||||||
|
|||||||
@@ -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;
|
|
||||||
@@ -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;
|
|
||||||
@@ -1,162 +0,0 @@
|
|||||||
import type { Request } from "express";
|
|
||||||
import { Router } from "express";
|
|
||||||
|
|
||||||
import {
|
|
||||||
getAdminTaskDetail,
|
|
||||||
getAdminTasks,
|
|
||||||
} from "../../services/admin/admin-read-service.js";
|
|
||||||
import {
|
|
||||||
dispatchAdminTaskKuaishouCloudFulfillment,
|
|
||||||
prepareAdminTaskKuaishouCloudFulfillment,
|
|
||||||
refreshAdminTaskKuaishouCloudRoleInfo,
|
|
||||||
returnNumberAdminTaskKuaishouCloudFulfillment,
|
|
||||||
} from "../../services/admin/write/kuaishou-cloud-actions.js";
|
|
||||||
import {
|
|
||||||
createJsonHandler,
|
|
||||||
requireAdminRoles,
|
|
||||||
} from "./shared.js";
|
|
||||||
import type {
|
|
||||||
AdminTaskKuaishouCloudDispatchRouteBody,
|
|
||||||
AdminTaskRouteParams,
|
|
||||||
AdminTaskRouteQuery,
|
|
||||||
} from "../../types/admin-route-inputs.js";
|
|
||||||
import type { AdminTaskActionResponse } from "../../types/admin-write-models.js";
|
|
||||||
|
|
||||||
const router = Router();
|
|
||||||
|
|
||||||
function getTaskId(req: Request): string | undefined {
|
|
||||||
return (req.params as AdminTaskRouteParams).taskId;
|
|
||||||
}
|
|
||||||
|
|
||||||
router.get(
|
|
||||||
"/tasks",
|
|
||||||
createJsonHandler(
|
|
||||||
(req) =>
|
|
||||||
getAdminTasks(req.query as AdminTaskRouteQuery, req.adminSession || null),
|
|
||||||
{
|
|
||||||
successMessage: "ok",
|
|
||||||
errorMessage: "读取任务列表失败",
|
|
||||||
scope: "[admin-kuaishou/tasks]",
|
|
||||||
}
|
|
||||||
)
|
|
||||||
);
|
|
||||||
|
|
||||||
router.get(
|
|
||||||
"/tasks/:taskId",
|
|
||||||
createJsonHandler(
|
|
||||||
(req) => getAdminTaskDetail(getTaskId(req), req.adminSession || null),
|
|
||||||
{
|
|
||||||
successMessage: "ok",
|
|
||||||
errorMessage: "读取任务详情失败",
|
|
||||||
scope: "[admin-kuaishou/tasks/:taskId]",
|
|
||||||
}
|
|
||||||
)
|
|
||||||
);
|
|
||||||
|
|
||||||
router.post(
|
|
||||||
"/tasks/:taskId/kuaishou-cloud/prepare",
|
|
||||||
requireAdminRoles(["admin", "operator"]),
|
|
||||||
createJsonHandler(
|
|
||||||
(req) =>
|
|
||||||
prepareAdminTaskKuaishouCloudFulfillment(
|
|
||||||
getTaskId(req),
|
|
||||||
req.adminSession || null
|
|
||||||
),
|
|
||||||
{
|
|
||||||
successMessage: "绑定资源已准备完成",
|
|
||||||
errorMessage: "准备绑定资源失败",
|
|
||||||
scope: "[admin-kuaishou/tasks/:taskId/kuaishou-cloud/prepare]",
|
|
||||||
audit: (req, data) => buildTaskAudit("task_kuaishou_cloud_prepare", req, data),
|
|
||||||
}
|
|
||||||
)
|
|
||||||
);
|
|
||||||
|
|
||||||
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-kuaishou/tasks/:taskId/kuaishou-cloud/refresh-role-info]",
|
|
||||||
audit: (req, data) =>
|
|
||||||
buildTaskAudit("task_kuaishou_cloud_role_info_refreshed", req, data),
|
|
||||||
}
|
|
||||||
)
|
|
||||||
);
|
|
||||||
|
|
||||||
router.post(
|
|
||||||
"/tasks/:taskId/kuaishou-cloud/dispatch",
|
|
||||||
requireAdminRoles(["admin", "operator"]),
|
|
||||||
createJsonHandler(
|
|
||||||
(req) =>
|
|
||||||
dispatchAdminTaskKuaishouCloudFulfillment(
|
|
||||||
getTaskId(req),
|
|
||||||
req.body as AdminTaskKuaishouCloudDispatchRouteBody,
|
|
||||||
req.adminSession || null
|
|
||||||
),
|
|
||||||
{
|
|
||||||
successMessage: "已完成绑定确认并发货",
|
|
||||||
errorMessage: "执行发货失败",
|
|
||||||
scope: "[admin-kuaishou/tasks/:taskId/kuaishou-cloud/dispatch]",
|
|
||||||
audit: (req, data) => {
|
|
||||||
const result = data as AdminTaskActionResponse;
|
|
||||||
const body = req.body as AdminTaskKuaishouCloudDispatchRouteBody;
|
|
||||||
|
|
||||||
return {
|
|
||||||
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()),
|
|
||||||
},
|
|
||||||
};
|
|
||||||
},
|
|
||||||
}
|
|
||||||
)
|
|
||||||
);
|
|
||||||
|
|
||||||
router.post(
|
|
||||||
"/tasks/:taskId/kuaishou-cloud/return-number",
|
|
||||||
requireAdminRoles(["admin", "operator"]),
|
|
||||||
createJsonHandler(
|
|
||||||
(req) =>
|
|
||||||
returnNumberAdminTaskKuaishouCloudFulfillment(
|
|
||||||
getTaskId(req),
|
|
||||||
req.adminSession || null
|
|
||||||
),
|
|
||||||
{
|
|
||||||
successMessage: "号码已退还",
|
|
||||||
errorMessage: "退还号码失败",
|
|
||||||
scope: "[admin-kuaishou/tasks/:taskId/kuaishou-cloud/return-number]",
|
|
||||||
audit: (req, data) =>
|
|
||||||
buildTaskAudit("task_kuaishou_cloud_return_number", req, data),
|
|
||||||
}
|
|
||||||
)
|
|
||||||
);
|
|
||||||
|
|
||||||
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;
|
|
||||||
@@ -1,42 +1,26 @@
|
|||||||
|
import type { Request } from "express";
|
||||||
import { Router } from "express";
|
import { Router } from "express";
|
||||||
|
|
||||||
import {
|
|
||||||
closeAdminTask,
|
|
||||||
completeAdminTaskManualDispatch,
|
|
||||||
confirmAdminTaskAssistedRole,
|
|
||||||
dispatchAdminTaskKuaishouCloudFulfillment,
|
|
||||||
markAdminTaskManualReview,
|
|
||||||
prepareAdminTaskKuaishouCloudFulfillment,
|
|
||||||
refreshAdminTaskKuaishouCloudRoleInfo,
|
|
||||||
regenerateAdminTaskClaimLink,
|
|
||||||
redeemAdminTaskAssisted,
|
|
||||||
releaseAdminTaskInventoryBinding,
|
|
||||||
releaseAdminTaskInventory,
|
|
||||||
returnNumberAdminTaskKuaishouCloudFulfillment,
|
|
||||||
retryAdminTask,
|
|
||||||
} from "../../services/admin/admin-write-service.js";
|
|
||||||
import {
|
import {
|
||||||
getAdminTaskDetail,
|
getAdminTaskDetail,
|
||||||
getAdminTasks,
|
getAdminTasks,
|
||||||
} from "../../services/admin/admin-read-service.js";
|
} from "../../services/admin/admin-read-service.js";
|
||||||
import { getAdminTaskScreenshotPathWithTencentFallback } from "../../services/admin/admin-task-screenshot-service.js";
|
|
||||||
import {
|
import {
|
||||||
createFileHandler,
|
dispatchAdminTaskKuaishouCloudFulfillment,
|
||||||
|
prepareAdminTaskKuaishouCloudFulfillment,
|
||||||
|
refreshAdminTaskKuaishouCloudRoleInfo,
|
||||||
|
returnNumberAdminTaskKuaishouCloudFulfillment,
|
||||||
|
} from "../../services/admin/write/kuaishou-cloud-actions.js";
|
||||||
|
import {
|
||||||
createJsonHandler,
|
createJsonHandler,
|
||||||
requireAdminRoles,
|
requireAdminRoles,
|
||||||
} from "./shared.js";
|
} from "./shared.js";
|
||||||
import type { Request } from "express";
|
|
||||||
import type {
|
import type {
|
||||||
AdminTaskKuaishouCloudDispatchRouteBody,
|
AdminTaskKuaishouCloudDispatchRouteBody,
|
||||||
AdminTaskManualDispatchRouteBody,
|
|
||||||
AdminTaskRouteParams,
|
AdminTaskRouteParams,
|
||||||
AdminTaskRouteQuery,
|
AdminTaskRouteQuery,
|
||||||
} from "../../types/admin-route-inputs.js";
|
} from "../../types/admin-route-inputs.js";
|
||||||
import type {
|
import type { AdminTaskActionResponse } from "../../types/admin-write-models.js";
|
||||||
AdminTaskActionResponse,
|
|
||||||
AdminTaskBindingReleaseResponse,
|
|
||||||
AdminTaskManualDispatchResponse,
|
|
||||||
} from "../../types/admin-write-models.js";
|
|
||||||
|
|
||||||
const router = Router();
|
const router = Router();
|
||||||
|
|
||||||
@@ -44,10 +28,6 @@ function getTaskId(req: Request): string | undefined {
|
|||||||
return (req.params as AdminTaskRouteParams).taskId;
|
return (req.params as AdminTaskRouteParams).taskId;
|
||||||
}
|
}
|
||||||
|
|
||||||
function getBindingId(req: Request): string | undefined {
|
|
||||||
return (req.params as AdminTaskRouteParams).bindingId;
|
|
||||||
}
|
|
||||||
|
|
||||||
router.get(
|
router.get(
|
||||||
"/tasks",
|
"/tasks",
|
||||||
createJsonHandler(
|
createJsonHandler(
|
||||||
@@ -73,239 +53,6 @@ router.get(
|
|||||||
)
|
)
|
||||||
);
|
);
|
||||||
|
|
||||||
router.get(
|
|
||||||
"/tasks/:taskId/screenshot",
|
|
||||||
createFileHandler(
|
|
||||||
(req) =>
|
|
||||||
getAdminTaskScreenshotPathWithTencentFallback(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,
|
|
||||||
},
|
|
||||||
};
|
|
||||||
},
|
|
||||||
})
|
|
||||||
);
|
|
||||||
|
|
||||||
router.post(
|
|
||||||
"/tasks/:taskId/inventory-bindings/:bindingId/release",
|
|
||||||
requireAdminRoles(["admin"]),
|
|
||||||
createJsonHandler(
|
|
||||||
(req) =>
|
|
||||||
releaseAdminTaskInventoryBinding(getTaskId(req), getBindingId(req)),
|
|
||||||
{
|
|
||||||
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,
|
|
||||||
},
|
|
||||||
};
|
|
||||||
},
|
|
||||||
})
|
|
||||||
);
|
|
||||||
|
|
||||||
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,
|
|
||||||
},
|
|
||||||
};
|
|
||||||
},
|
|
||||||
})
|
|
||||||
);
|
|
||||||
|
|
||||||
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(
|
router.post(
|
||||||
"/tasks/:taskId/kuaishou-cloud/prepare",
|
"/tasks/:taskId/kuaishou-cloud/prepare",
|
||||||
requireAdminRoles(["admin", "operator"]),
|
requireAdminRoles(["admin", "operator"]),
|
||||||
@@ -319,19 +66,7 @@ router.post(
|
|||||||
successMessage: "绑定资源已准备完成",
|
successMessage: "绑定资源已准备完成",
|
||||||
errorMessage: "准备绑定资源失败",
|
errorMessage: "准备绑定资源失败",
|
||||||
scope: "[admin/tasks/:taskId/kuaishou-cloud/prepare]",
|
scope: "[admin/tasks/:taskId/kuaishou-cloud/prepare]",
|
||||||
audit: (req, data) => {
|
audit: (req, data) => buildTaskAudit("task_kuaishou_cloud_prepare", 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,
|
|
||||||
},
|
|
||||||
};
|
|
||||||
},
|
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
@@ -349,19 +84,8 @@ router.post(
|
|||||||
successMessage: "角色信息已刷新",
|
successMessage: "角色信息已刷新",
|
||||||
errorMessage: "刷新角色信息失败",
|
errorMessage: "刷新角色信息失败",
|
||||||
scope: "[admin/tasks/:taskId/kuaishou-cloud/refresh-role-info]",
|
scope: "[admin/tasks/:taskId/kuaishou-cloud/refresh-role-info]",
|
||||||
audit: (req, data) => {
|
audit: (req, data) =>
|
||||||
const result = data as AdminTaskActionResponse;
|
buildTaskAudit("task_kuaishou_cloud_role_info_refreshed", req, data),
|
||||||
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,
|
|
||||||
},
|
|
||||||
};
|
|
||||||
},
|
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
@@ -383,6 +107,7 @@ router.post(
|
|||||||
audit: (req, data) => {
|
audit: (req, data) => {
|
||||||
const result = data as AdminTaskActionResponse;
|
const result = data as AdminTaskActionResponse;
|
||||||
const body = req.body as AdminTaskKuaishouCloudDispatchRouteBody;
|
const body = req.body as AdminTaskKuaishouCloudDispatchRouteBody;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
action: "task_kuaishou_cloud_dispatch",
|
action: "task_kuaishou_cloud_dispatch",
|
||||||
targetType: "task",
|
targetType: "task",
|
||||||
@@ -413,21 +138,25 @@ router.post(
|
|||||||
successMessage: "号码已退还",
|
successMessage: "号码已退还",
|
||||||
errorMessage: "退还号码失败",
|
errorMessage: "退还号码失败",
|
||||||
scope: "[admin/tasks/:taskId/kuaishou-cloud/return-number]",
|
scope: "[admin/tasks/:taskId/kuaishou-cloud/return-number]",
|
||||||
audit: (req, data) => {
|
audit: (req, data) =>
|
||||||
const result = data as AdminTaskActionResponse;
|
buildTaskAudit("task_kuaishou_cloud_return_number", req, data),
|
||||||
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,
|
|
||||||
},
|
|
||||||
};
|
|
||||||
},
|
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
|
|
||||||
|
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;
|
export default router;
|
||||||
|
|||||||
@@ -1,76 +0,0 @@
|
|||||||
import { Router } from "express";
|
|
||||||
|
|
||||||
import {
|
|
||||||
getAdminWebhookEventDetail,
|
|
||||||
getAdminWebhookEvents,
|
|
||||||
} from "../../services/admin/admin-read-service.js";
|
|
||||||
import { createHttpError } from "../../utils/http.js";
|
|
||||||
import { createJsonHandler, requireAdminRoles } from "./shared.js";
|
|
||||||
import type {
|
|
||||||
AdminWebhookEventRouteParams,
|
|
||||||
AdminWebhookEventRouteQuery,
|
|
||||||
} from "../../types/admin-route-inputs.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-kuaishou/webhook-events]",
|
|
||||||
}
|
|
||||||
)
|
|
||||||
);
|
|
||||||
|
|
||||||
router.get(
|
|
||||||
"/webhook-events/:eventId",
|
|
||||||
createJsonHandler(
|
|
||||||
(req) =>
|
|
||||||
getAdminWebhookEventDetail(
|
|
||||||
(req.params as AdminWebhookEventRouteParams).eventId
|
|
||||||
),
|
|
||||||
{
|
|
||||||
successMessage: "ok",
|
|
||||||
errorMessage: "读取 webhook 详情失败",
|
|
||||||
scope: "[admin-kuaishou/webhook-events/:eventId]",
|
|
||||||
}
|
|
||||||
)
|
|
||||||
);
|
|
||||||
|
|
||||||
router.post(
|
|
||||||
"/webhook-events/:eventId/replay",
|
|
||||||
requireAdminRoles(["admin"]),
|
|
||||||
createJsonHandler(
|
|
||||||
() => {
|
|
||||||
throw createHttpError("快手轻量后端不支持重放旧 Webhook", {
|
|
||||||
statusCode: 409,
|
|
||||||
errorCode: "admin_webhook_replay_disabled",
|
|
||||||
});
|
|
||||||
},
|
|
||||||
{
|
|
||||||
successMessage: "Webhook 已重放",
|
|
||||||
errorMessage: "重放 webhook 失败",
|
|
||||||
scope: "[admin-kuaishou/webhook-events/:eventId/replay]",
|
|
||||||
audit: (req, data) => {
|
|
||||||
return {
|
|
||||||
action: "webhook_replayed",
|
|
||||||
targetType: "webhook_event",
|
|
||||||
targetId: String(
|
|
||||||
(req.params as AdminWebhookEventRouteParams).eventId
|
|
||||||
),
|
|
||||||
data: {
|
|
||||||
disabled: true,
|
|
||||||
result: data,
|
|
||||||
},
|
|
||||||
};
|
|
||||||
},
|
|
||||||
}
|
|
||||||
)
|
|
||||||
);
|
|
||||||
|
|
||||||
export default router;
|
|
||||||
@@ -1,60 +1,76 @@
|
|||||||
import { Router } from 'express'
|
import { Router } from "express";
|
||||||
|
|
||||||
import {
|
|
||||||
replayAdminWebhookEvent,
|
|
||||||
} from '../../services/admin/admin-write-service.js'
|
|
||||||
import {
|
import {
|
||||||
getAdminWebhookEventDetail,
|
getAdminWebhookEventDetail,
|
||||||
getAdminWebhookEvents,
|
getAdminWebhookEvents,
|
||||||
} from '../../services/admin/admin-read-service.js'
|
} from "../../services/admin/admin-read-service.js";
|
||||||
import { createJsonHandler, requireAdminRoles } from './shared.js'
|
import { createHttpError } from "../../utils/http.js";
|
||||||
|
import { createJsonHandler, requireAdminRoles } from "./shared.js";
|
||||||
import type {
|
import type {
|
||||||
AdminWebhookEventRouteParams,
|
AdminWebhookEventRouteParams,
|
||||||
AdminWebhookEventRouteQuery,
|
AdminWebhookEventRouteQuery,
|
||||||
} from '../../types/admin-route-inputs.js'
|
} from "../../types/admin-route-inputs.js";
|
||||||
import type { AdminWebhookReplayResponse } from '../../types/admin-write-models.js'
|
|
||||||
|
|
||||||
const router = Router()
|
const router = Router();
|
||||||
|
|
||||||
router.use('/webhook-events', requireAdminRoles(['admin', 'operator']))
|
router.use("/webhook-events", requireAdminRoles(["admin", "operator"]));
|
||||||
|
|
||||||
router.get('/webhook-events', createJsonHandler(
|
router.get(
|
||||||
(req) => getAdminWebhookEvents(req.query as AdminWebhookEventRouteQuery),
|
"/webhook-events",
|
||||||
{
|
createJsonHandler(
|
||||||
successMessage: 'ok',
|
(req) => getAdminWebhookEvents(req.query as AdminWebhookEventRouteQuery),
|
||||||
errorMessage: '读取 webhook 列表失败',
|
{
|
||||||
scope: '[admin/webhook-events]',
|
successMessage: "ok",
|
||||||
},
|
errorMessage: "读取 webhook 列表失败",
|
||||||
))
|
scope: "[admin/webhook-events]",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
router.get('/webhook-events/:eventId', createJsonHandler(
|
router.get(
|
||||||
(req) => getAdminWebhookEventDetail((req.params as AdminWebhookEventRouteParams).eventId),
|
"/webhook-events/:eventId",
|
||||||
{
|
createJsonHandler(
|
||||||
successMessage: 'ok',
|
(req) =>
|
||||||
errorMessage: '读取 webhook 详情失败',
|
getAdminWebhookEventDetail(
|
||||||
scope: '[admin/webhook-events/:eventId]',
|
(req.params as AdminWebhookEventRouteParams).eventId
|
||||||
},
|
),
|
||||||
))
|
{
|
||||||
|
successMessage: "ok",
|
||||||
|
errorMessage: "读取 webhook 详情失败",
|
||||||
|
scope: "[admin/webhook-events/:eventId]",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
router.post('/webhook-events/:eventId/replay', requireAdminRoles(['admin']), createJsonHandler(
|
router.post(
|
||||||
(req) => replayAdminWebhookEvent((req.params as AdminWebhookEventRouteParams).eventId),
|
"/webhook-events/:eventId/replay",
|
||||||
{
|
requireAdminRoles(["admin"]),
|
||||||
successMessage: 'Webhook 已重放',
|
createJsonHandler(
|
||||||
errorMessage: '重放 webhook 失败',
|
() => {
|
||||||
scope: '[admin/webhook-events/:eventId/replay]',
|
throw createHttpError("快手轻量后端不支持重放旧 Webhook", {
|
||||||
audit: (req, data) => {
|
statusCode: 409,
|
||||||
const result = data as AdminWebhookReplayResponse
|
errorCode: "admin_webhook_replay_disabled",
|
||||||
return {
|
});
|
||||||
action: 'webhook_replayed',
|
|
||||||
targetType: 'webhook_event',
|
|
||||||
targetId: String((req.params as AdminWebhookEventRouteParams).eventId),
|
|
||||||
data: {
|
|
||||||
eventId: result.eventId,
|
|
||||||
replayed: result.replayed,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
},
|
{
|
||||||
))
|
successMessage: "Webhook 已重放",
|
||||||
|
errorMessage: "重放 webhook 失败",
|
||||||
|
scope: "[admin/webhook-events/:eventId/replay]",
|
||||||
|
audit: (req, data) => {
|
||||||
|
return {
|
||||||
|
action: "webhook_replayed",
|
||||||
|
targetType: "webhook_event",
|
||||||
|
targetId: String(
|
||||||
|
(req.params as AdminWebhookEventRouteParams).eventId
|
||||||
|
),
|
||||||
|
data: {
|
||||||
|
disabled: true,
|
||||||
|
result: data,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
export default router
|
export default router;
|
||||||
|
|||||||
@@ -1,71 +0,0 @@
|
|||||||
import { Router } from "express";
|
|
||||||
|
|
||||||
import {
|
|
||||||
confirmKuaishouCloudClaimRole,
|
|
||||||
getKuaishouCloudClaimDetail,
|
|
||||||
getKuaishouCloudClaimGuideAssetPath,
|
|
||||||
redeemKuaishouCloudClaim,
|
|
||||||
verifyKuaishouCloudClaimTicket,
|
|
||||||
} from "../services/claim/kuaishou-cloud-claim-service.js";
|
|
||||||
import {
|
|
||||||
buildNotFoundPayload,
|
|
||||||
createRouteFileHandler,
|
|
||||||
createRouteHandler,
|
|
||||||
} from "../utils/http.js";
|
|
||||||
|
|
||||||
const router = Router();
|
|
||||||
|
|
||||||
router.get(
|
|
||||||
"/:token",
|
|
||||||
createRouteHandler((req) => getKuaishouCloudClaimDetail(req.params.token), {
|
|
||||||
errorMessage: "查询快手领取详情失败",
|
|
||||||
scope: "[claims-kuaishou/:token]",
|
|
||||||
})
|
|
||||||
);
|
|
||||||
|
|
||||||
router.post(
|
|
||||||
"/:token/kuaishou-cloud/verify-ticket",
|
|
||||||
createRouteHandler(
|
|
||||||
(req) => verifyKuaishouCloudClaimTicket(req.params.token, req.body),
|
|
||||||
{
|
|
||||||
successMessage: "核销码校验成功",
|
|
||||||
errorMessage: "校验核销码失败",
|
|
||||||
scope: "[claims-kuaishou/:token/kuaishou-cloud/verify-ticket]",
|
|
||||||
}
|
|
||||||
)
|
|
||||||
);
|
|
||||||
|
|
||||||
router.post(
|
|
||||||
"/:token/kuaishou-cloud/confirm-role",
|
|
||||||
createRouteHandler((req) => confirmKuaishouCloudClaimRole(req.params.token), {
|
|
||||||
successMessage: "角色已确认",
|
|
||||||
errorMessage: "确认角色失败",
|
|
||||||
scope: "[claims-kuaishou/:token/kuaishou-cloud/confirm-role]",
|
|
||||||
})
|
|
||||||
);
|
|
||||||
|
|
||||||
router.post(
|
|
||||||
"/:token/kuaishou-cloud/redeem",
|
|
||||||
createRouteHandler((req) => redeemKuaishouCloudClaim(req.params.token), {
|
|
||||||
successMessage: "兑换请求已提交",
|
|
||||||
errorMessage: "兑换失败",
|
|
||||||
scope: "[claims-kuaishou/:token/kuaishou-cloud/redeem]",
|
|
||||||
})
|
|
||||||
);
|
|
||||||
|
|
||||||
router.get(
|
|
||||||
"/assets/kuaishou-cloud/:filename",
|
|
||||||
createRouteFileHandler(
|
|
||||||
(req) => getKuaishouCloudClaimGuideAssetPath(req.params.filename),
|
|
||||||
{
|
|
||||||
errorMessage: "读取指引图片失败",
|
|
||||||
scope: "[claims-kuaishou/assets/kuaishou-cloud/:filename]",
|
|
||||||
}
|
|
||||||
)
|
|
||||||
);
|
|
||||||
|
|
||||||
router.use((req, res) => {
|
|
||||||
res.status(404).json(buildNotFoundPayload(req));
|
|
||||||
});
|
|
||||||
|
|
||||||
export default router;
|
|
||||||
@@ -1,97 +1,71 @@
|
|||||||
import { Router } from 'express'
|
import { Router } from "express";
|
||||||
|
|
||||||
import {
|
|
||||||
closeClaimSession,
|
|
||||||
confirmClaimRole,
|
|
||||||
createClaimSession,
|
|
||||||
getClaimDetail,
|
|
||||||
getClaimScreenshotPath,
|
|
||||||
getClaimSessionSummary,
|
|
||||||
reloadClaimSession,
|
|
||||||
redeemClaimTask,
|
|
||||||
} from '../services/claim/claim-session-service.js'
|
|
||||||
import {
|
import {
|
||||||
confirmKuaishouCloudClaimRole,
|
confirmKuaishouCloudClaimRole,
|
||||||
|
getKuaishouCloudClaimDetail,
|
||||||
getKuaishouCloudClaimGuideAssetPath,
|
getKuaishouCloudClaimGuideAssetPath,
|
||||||
redeemKuaishouCloudClaim,
|
redeemKuaishouCloudClaim,
|
||||||
verifyKuaishouCloudClaimTicket,
|
verifyKuaishouCloudClaimTicket,
|
||||||
} from '../services/claim/kuaishou-cloud-claim-service.js'
|
} from "../services/claim/kuaishou-cloud-claim-service.js";
|
||||||
import {
|
import {
|
||||||
buildNotFoundPayload,
|
buildNotFoundPayload,
|
||||||
buildSuccessPayload,
|
|
||||||
createRouteFileHandler,
|
createRouteFileHandler,
|
||||||
createRouteHandler,
|
createRouteHandler,
|
||||||
sendRouteError,
|
} from "../utils/http.js";
|
||||||
} from '../utils/http.js'
|
|
||||||
|
|
||||||
const router = Router()
|
const router = Router();
|
||||||
|
|
||||||
router.get('/:token', createRouteHandler(
|
router.get(
|
||||||
(req) => getClaimDetail(req.params.token),
|
"/:token",
|
||||||
{ errorMessage: '查询领取详情失败', scope: '[claims/:token]' },
|
createRouteHandler((req) => getKuaishouCloudClaimDetail(req.params.token), {
|
||||||
))
|
errorMessage: "查询快手领取详情失败",
|
||||||
|
scope: "[claims/:token]",
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
router.post('/:token/session', createRouteHandler(
|
router.post(
|
||||||
(req) => createClaimSession(req.params.token, req.body),
|
"/:token/kuaishou-cloud/verify-ticket",
|
||||||
{ successMessage: '浏览器会话已创建', errorMessage: '创建领取会话失败', scope: '[claims/:token/session]' },
|
createRouteHandler(
|
||||||
))
|
(req) => verifyKuaishouCloudClaimTicket(req.params.token, req.body),
|
||||||
|
{
|
||||||
|
successMessage: "核销码校验成功",
|
||||||
|
errorMessage: "校验核销码失败",
|
||||||
|
scope: "[claims/:token/kuaishou-cloud/verify-ticket]",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
router.get('/:token/session/summary', createRouteHandler(
|
router.post(
|
||||||
(req) => getClaimSessionSummary(req.params.token),
|
"/:token/kuaishou-cloud/confirm-role",
|
||||||
{ errorMessage: '查询领取会话摘要失败', scope: '[claims/:token/session/summary]' },
|
createRouteHandler((req) => confirmKuaishouCloudClaimRole(req.params.token), {
|
||||||
))
|
successMessage: "角色已确认",
|
||||||
|
errorMessage: "确认角色失败",
|
||||||
|
scope: "[claims/:token/kuaishou-cloud/confirm-role]",
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
router.post('/:token/session/refresh', async (req, res) => {
|
router.post(
|
||||||
try {
|
"/:token/kuaishou-cloud/redeem",
|
||||||
const data = await reloadClaimSession(req.params.token)
|
createRouteHandler((req) => redeemKuaishouCloudClaim(req.params.token), {
|
||||||
res.json(buildSuccessPayload(data, data.session ? (data.session.notice || '后端页面已刷新') : '领取会话已重置'))
|
successMessage: "兑换请求已提交",
|
||||||
} catch (error) {
|
errorMessage: "兑换失败",
|
||||||
sendRouteError(res, error, '刷新领取会话失败', '[claims/:token/session/refresh]')
|
scope: "[claims/:token/kuaishou-cloud/redeem]",
|
||||||
}
|
})
|
||||||
})
|
);
|
||||||
|
|
||||||
router.delete('/:token/session', createRouteHandler(
|
router.get(
|
||||||
(req) => closeClaimSession(req.params.token),
|
"/assets/kuaishou-cloud/:filename",
|
||||||
{ successMessage: '领取会话已关闭', errorMessage: '关闭领取会话失败', scope: '[claims/:token/session]' },
|
createRouteFileHandler(
|
||||||
))
|
(req) => getKuaishouCloudClaimGuideAssetPath(req.params.filename),
|
||||||
|
{
|
||||||
router.post('/:token/confirm-role', createRouteHandler(
|
errorMessage: "读取指引图片失败",
|
||||||
(req) => confirmClaimRole(req.params.token),
|
scope: "[claims/assets/kuaishou-cloud/:filename]",
|
||||||
{ successMessage: '角色已确认', errorMessage: '确认角色失败', scope: '[claims/:token/confirm-role]' },
|
}
|
||||||
))
|
)
|
||||||
|
);
|
||||||
router.post('/:token/redeem', createRouteHandler(
|
|
||||||
(req) => redeemClaimTask(req.params.token),
|
|
||||||
{ successMessage: '兑换完成', errorMessage: '执行领取兑换失败', scope: '[claims/:token/redeem]' },
|
|
||||||
))
|
|
||||||
|
|
||||||
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/confirm-role', createRouteHandler(
|
|
||||||
(req) => confirmKuaishouCloudClaimRole(req.params.token),
|
|
||||||
{ successMessage: '角色已确认', errorMessage: '确认角色失败', scope: '[claims/:token/kuaishou-cloud/confirm-role]' },
|
|
||||||
))
|
|
||||||
|
|
||||||
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.use((req, res) => {
|
router.use((req, res) => {
|
||||||
res.status(404).json(buildNotFoundPayload(req))
|
res.status(404).json(buildNotFoundPayload(req));
|
||||||
})
|
});
|
||||||
|
|
||||||
export default router
|
export default router;
|
||||||
|
|||||||
@@ -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
|
|
||||||
@@ -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
|
|
||||||
@@ -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<string, any>
|
|
||||||
|
|
||||||
export async function createAdminManualRedeemTask(
|
|
||||||
payload: AdminManualRedeemCreateInput = {},
|
|
||||||
session: AdminViewerSessionInput | null = null,
|
|
||||||
): Promise<JsonObject> {
|
|
||||||
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<JsonObject> {
|
|
||||||
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<JsonObject> {
|
|
||||||
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<JsonObject> {
|
|
||||||
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<JsonObject> {
|
|
||||||
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<JsonObject> {
|
|
||||||
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<JsonObject> {
|
|
||||||
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<JsonObject> {
|
|
||||||
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<JsonObject> {
|
|
||||||
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<TaskRow> {
|
|
||||||
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()
|
|
||||||
}
|
|
||||||
@@ -38,7 +38,6 @@ import { mapAdminInventoryListItem } from './admin-inventory-read-helpers.js'
|
|||||||
import { mapAdminOrderListItem, summarizeOrderItems } from './admin-order-read-helpers.js'
|
import { mapAdminOrderListItem, summarizeOrderItems } from './admin-order-read-helpers.js'
|
||||||
import { mapAdminWebhookEvent } from './admin-webhook-read-helpers.js'
|
import { mapAdminWebhookEvent } from './admin-webhook-read-helpers.js'
|
||||||
import {
|
import {
|
||||||
buildOrderAgisoAutoDeliverySummary,
|
|
||||||
buildOrderBindingSummary,
|
buildOrderBindingSummary,
|
||||||
createTaskBindingSummaryFromBindings,
|
createTaskBindingSummaryFromBindings,
|
||||||
getRequiredTask,
|
getRequiredTask,
|
||||||
@@ -130,7 +129,6 @@ export async function getAdminOrderDetail(orderId: AdminEntityIdInput): Promise<
|
|||||||
? order.raw_payload_json
|
? order.raw_payload_json
|
||||||
: JSON.parse(String(order.raw_payload_json || '{}')),
|
: JSON.parse(String(order.raw_payload_json || '{}')),
|
||||||
bindingSummary: buildOrderBindingSummary(tasks, taskBindingSummaryMap),
|
bindingSummary: buildOrderBindingSummary(tasks, taskBindingSummaryMap),
|
||||||
agisoAutoDelivery: buildOrderAgisoAutoDeliverySummary(order, tasks),
|
|
||||||
},
|
},
|
||||||
items: items.map((item) => ({
|
items: items.map((item) => ({
|
||||||
orderItemId: item.id,
|
orderItemId: item.id,
|
||||||
|
|||||||
@@ -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'
|
|
||||||
@@ -10,13 +10,11 @@ import {
|
|||||||
} from './admin-read-shared-helpers.js'
|
} from './admin-read-shared-helpers.js'
|
||||||
|
|
||||||
import type {
|
import type {
|
||||||
AdminAgisoAutoDeliveryStatus,
|
|
||||||
AdminTaskBindingSummary,
|
AdminTaskBindingSummary,
|
||||||
AdminTaskListItem,
|
AdminTaskListItem,
|
||||||
} from '../../types/admin-read-models.js'
|
} from '../../types/admin-read-models.js'
|
||||||
import type { AdminTaskActionPayload } from '../../types/admin-write-models.js'
|
import type { AdminTaskActionPayload } from '../../types/admin-write-models.js'
|
||||||
import type {
|
import type {
|
||||||
OrderRow,
|
|
||||||
TaskEventRow,
|
TaskEventRow,
|
||||||
TaskInventoryBindingRow,
|
TaskInventoryBindingRow,
|
||||||
TaskInventoryBindingSummaryRow,
|
TaskInventoryBindingSummaryRow,
|
||||||
@@ -47,24 +45,11 @@ type OrderBindingSummary = {
|
|||||||
userBindingStatus: string
|
userBindingStatus: string
|
||||||
}
|
}
|
||||||
|
|
||||||
type OrderAgisoAutoDeliverySummary = AdminAgisoAutoDeliveryStatus & {
|
|
||||||
sourceTaskId: number | null
|
|
||||||
sourceTaskNo: string
|
|
||||||
totalTaskCount: number
|
|
||||||
deliveredTaskCount: number
|
|
||||||
}
|
|
||||||
|
|
||||||
type OrderAgisoAutoDeliveryCandidate = AdminAgisoAutoDeliveryStatus & {
|
|
||||||
sourceTaskId: number | null
|
|
||||||
sourceTaskNo: string
|
|
||||||
}
|
|
||||||
|
|
||||||
export function mapAdminTaskSummary(
|
export function mapAdminTaskSummary(
|
||||||
task: TaskRow,
|
task: TaskRow,
|
||||||
bindingSummary: AdminTaskBindingSummary = createEmptyTaskBindingSummary(),
|
bindingSummary: AdminTaskBindingSummary = createEmptyTaskBindingSummary(),
|
||||||
): JsonRecord {
|
): JsonRecord {
|
||||||
const binding = buildTaskBindingState(task)
|
const binding = buildTaskBindingState(task)
|
||||||
const taskContext = parseTaskContext(task)
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
taskId: task.id,
|
taskId: task.id,
|
||||||
@@ -81,7 +66,6 @@ export function mapAdminTaskSummary(
|
|||||||
lastError: task.last_error,
|
lastError: task.last_error,
|
||||||
retryCount: getTaskRetryCount(task),
|
retryCount: getTaskRetryCount(task),
|
||||||
bindingSummary,
|
bindingSummary,
|
||||||
agisoAutoDelivery: mapAgisoAutoDeliveryContext(taskContext.agisoAutoDelivery),
|
|
||||||
createdAt: task.created_at,
|
createdAt: task.created_at,
|
||||||
updatedAt: task.updated_at,
|
updatedAt: task.updated_at,
|
||||||
}
|
}
|
||||||
@@ -124,7 +108,6 @@ export function mapAdminTaskListItem(
|
|||||||
viewerContext: AdminViewerContext = createAdminViewerContext(),
|
viewerContext: AdminViewerContext = createAdminViewerContext(),
|
||||||
): AdminTaskListItem {
|
): AdminTaskListItem {
|
||||||
const binding = buildTaskBindingState(task)
|
const binding = buildTaskBindingState(task)
|
||||||
const taskContext = parseTaskContext(task)
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
taskId: task.id,
|
taskId: task.id,
|
||||||
@@ -148,7 +131,6 @@ export function mapAdminTaskListItem(
|
|||||||
redeemedAt: task.redeemed_at,
|
redeemedAt: task.redeemed_at,
|
||||||
retryCount: getTaskRetryCount(task),
|
retryCount: getTaskRetryCount(task),
|
||||||
bindingSummary,
|
bindingSummary,
|
||||||
agisoAutoDelivery: mapAgisoAutoDeliveryContext(taskContext.agisoAutoDelivery),
|
|
||||||
lastError: task.last_error,
|
lastError: task.last_error,
|
||||||
createdAt: task.created_at,
|
createdAt: task.created_at,
|
||||||
updatedAt: task.updated_at,
|
updatedAt: task.updated_at,
|
||||||
@@ -315,63 +297,6 @@ export function buildOrderBindingSummary(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export function buildOrderAgisoAutoDeliverySummary(
|
|
||||||
order: Partial<OrderRow> | 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<OrderAgisoAutoDeliveryCandidate | null>((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 {
|
function canReleaseTaskInventoryBinding(task: TaskRow | null | undefined, binding: TaskInventoryBindingLike | null | undefined): boolean {
|
||||||
if (!task || !binding) {
|
if (!task || !binding) {
|
||||||
return false
|
return false
|
||||||
@@ -469,24 +394,6 @@ function isTaskSystemBound(task: TaskRow | null | undefined): boolean {
|
|||||||
return Boolean(task && (getTaskPrimaryInventoryItemId(task) || getTaskPrimaryClaimTokenId(task)))
|
return Boolean(task && (getTaskPrimaryInventoryItemId(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 normalizeRecord(value: unknown): JsonRecord {
|
function normalizeRecord(value: unknown): JsonRecord {
|
||||||
return value && typeof value === 'object' && !Array.isArray(value) ? value : {}
|
return value && typeof value === 'object' && !Array.isArray(value) ? value : {}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,28 +0,0 @@
|
|||||||
import { getTencentBrowserSessionReviewScreenshotPath } from '../session/session.js'
|
|
||||||
import { createHttpError } from '../../utils/http.js'
|
|
||||||
import { getRequiredTask } from './admin-task-read-helpers.js'
|
|
||||||
|
|
||||||
import type {
|
|
||||||
AdminEntityIdInput,
|
|
||||||
AdminViewerSessionInput,
|
|
||||||
} from '../../types/admin-read-inputs.js'
|
|
||||||
|
|
||||||
export async function getAdminTaskScreenshotPathWithTencentFallback(
|
|
||||||
taskId: AdminEntityIdInput,
|
|
||||||
_session: AdminViewerSessionInput | null = null,
|
|
||||||
): Promise<string> {
|
|
||||||
const task = await getRequiredTask(taskId)
|
|
||||||
|
|
||||||
if (task.screenshot_path) {
|
|
||||||
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',
|
|
||||||
})
|
|
||||||
}
|
|
||||||
@@ -1,6 +1,5 @@
|
|||||||
import { listTasksByOrderId } from '../../repositories/task-repo.js'
|
import { listTasksByOrderId } from '../../repositories/task-repo.js'
|
||||||
import { formatFenToAmount, parseAmountToFen } from '../../utils/money.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 { safeParseJson } from './admin-query-utils.js'
|
||||||
import { resolveDisplayShopName } from './admin-read-shared-helpers.js'
|
import { resolveDisplayShopName } from './admin-read-shared-helpers.js'
|
||||||
|
|
||||||
@@ -49,7 +48,7 @@ export async function mapAdminWebhookEvent(
|
|||||||
|
|
||||||
const mapped = {
|
const mapped = {
|
||||||
eventId: item.id,
|
eventId: item.id,
|
||||||
provider: item.provider || 'agiso',
|
provider: item.provider || '',
|
||||||
platform: item.platform,
|
platform: item.platform,
|
||||||
platformRaw: pickFirstNonEmpty([
|
platformRaw: pickFirstNonEmpty([
|
||||||
query.fromPlatform,
|
query.fromPlatform,
|
||||||
@@ -82,7 +81,7 @@ export async function mapAdminWebhookEvent(
|
|||||||
visibilityLevel: resolveWebhookVisibilityLevel(item.process_error),
|
visibilityLevel: resolveWebhookVisibilityLevel(item.process_error),
|
||||||
relatedOrderId: item.related_order_id,
|
relatedOrderId: item.related_order_id,
|
||||||
createdAt: item.created_at,
|
createdAt: item.created_at,
|
||||||
platformOrderId: resolveAgisoTradePlatformOrderId(payload),
|
platformOrderId: resolveWebhookPlatformOrderId(item, payload),
|
||||||
buyerId: pickFirstNonEmpty([
|
buyerId: pickFirstNonEmpty([
|
||||||
payload.buyer_id,
|
payload.buyer_id,
|
||||||
payload.buyerId,
|
payload.buyerId,
|
||||||
@@ -135,7 +134,44 @@ export async function mapAdminWebhookEvent(
|
|||||||
}
|
}
|
||||||
|
|
||||||
function extractWebhookPayload(body: JsonRecord): JsonRecord {
|
function extractWebhookPayload(body: JsonRecord): JsonRecord {
|
||||||
return extractAgisoTradePayload(body)
|
const candidates = [
|
||||||
|
body.data,
|
||||||
|
body.Data,
|
||||||
|
body.payload,
|
||||||
|
body.Payload,
|
||||||
|
body.message,
|
||||||
|
body.Message,
|
||||||
|
]
|
||||||
|
|
||||||
|
for (const candidate of candidates) {
|
||||||
|
if (candidate && typeof candidate === 'object' && !Array.isArray(candidate)) {
|
||||||
|
return normalizeRecord(candidate)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return body
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveWebhookPlatformOrderId(item: WebhookEventRow, payload: JsonRecord): string {
|
||||||
|
const fromPayload = pickFirstNonEmpty([
|
||||||
|
payload.biz_order_id,
|
||||||
|
payload.bizOrderId,
|
||||||
|
payload.BizOrderId,
|
||||||
|
payload.tid,
|
||||||
|
payload.Tid,
|
||||||
|
payload.order_id,
|
||||||
|
payload.orderId,
|
||||||
|
payload.OrderId,
|
||||||
|
payload.oid,
|
||||||
|
payload.Oid,
|
||||||
|
])
|
||||||
|
|
||||||
|
if (fromPayload) {
|
||||||
|
return fromPayload
|
||||||
|
}
|
||||||
|
|
||||||
|
const parts = String(item.event_key || '').split(':').map((part) => part.trim()).filter(Boolean)
|
||||||
|
return parts.find((part) => /^\d{6,}$/.test(part)) || ''
|
||||||
}
|
}
|
||||||
|
|
||||||
function extractWebhookItemSources(payload: unknown): JsonRecord[] {
|
function extractWebhookItemSources(payload: unknown): JsonRecord[] {
|
||||||
|
|||||||
@@ -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',
|
|
||||||
}),
|
|
||||||
}),
|
|
||||||
/已兑换任务不能关闭/,
|
|
||||||
)
|
|
||||||
})
|
|
||||||
@@ -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'
|
|
||||||
@@ -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<string, any>
|
|
||||||
|
|
||||||
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)),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,6 +0,0 @@
|
|||||||
export function normalizeAgisoMessageTemplate(value: unknown): string {
|
|
||||||
return String(value || "")
|
|
||||||
.replaceAll("\\r\\n", "\n")
|
|
||||||
.replaceAll("\\n", "\n")
|
|
||||||
.replaceAll("\r\n", "\n");
|
|
||||||
}
|
|
||||||
@@ -2,101 +2,15 @@ import test from 'node:test'
|
|||||||
import assert from 'node:assert/strict'
|
import assert from 'node:assert/strict'
|
||||||
|
|
||||||
import {
|
import {
|
||||||
applyOptionalStringField,
|
|
||||||
findMatchingObservedBinding,
|
findMatchingObservedBinding,
|
||||||
mapAdminAgisoMessagingDefaults,
|
|
||||||
mapAdminAgisoShopConfigItem,
|
|
||||||
mapAdminFulfillmentBindingConfigItem,
|
|
||||||
mapAdminObservedProductItem,
|
mapAdminObservedProductItem,
|
||||||
matchesObservedProduct,
|
matchesObservedProduct,
|
||||||
} from './domain.js'
|
} 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', () => {
|
test('matchesObservedProduct honors provider platform shop and external fields', () => {
|
||||||
const binding = {
|
const binding = {
|
||||||
provider: 'agiso',
|
provider: '91kaquan',
|
||||||
platform: 'xianyu',
|
platform: 'kuaishou',
|
||||||
shopId: 'shop-1',
|
shopId: 'shop-1',
|
||||||
match: {
|
match: {
|
||||||
externalSkuCode: 'sku-ext-1',
|
externalSkuCode: 'sku-ext-1',
|
||||||
@@ -107,8 +21,8 @@ test('matchesObservedProduct honors provider platform shop and external fields',
|
|||||||
|
|
||||||
assert.equal(
|
assert.equal(
|
||||||
matchesObservedProduct(binding, {
|
matchesObservedProduct(binding, {
|
||||||
provider: 'agiso',
|
provider: '91kaquan',
|
||||||
platform: 'xianyu',
|
platform: 'kuaishou',
|
||||||
shopId: 'shop-1',
|
shopId: 'shop-1',
|
||||||
externalSkuCode: 'sku-ext-1',
|
externalSkuCode: 'sku-ext-1',
|
||||||
}),
|
}),
|
||||||
@@ -117,8 +31,8 @@ test('matchesObservedProduct honors provider platform shop and external fields',
|
|||||||
|
|
||||||
assert.equal(
|
assert.equal(
|
||||||
matchesObservedProduct(binding, {
|
matchesObservedProduct(binding, {
|
||||||
provider: 'agiso',
|
provider: '91kaquan',
|
||||||
platform: 'xianyu',
|
platform: 'kuaishou',
|
||||||
shopId: 'shop-2',
|
shopId: 'shop-2',
|
||||||
externalSkuCode: 'sku-ext-1',
|
externalSkuCode: 'sku-ext-1',
|
||||||
}),
|
}),
|
||||||
@@ -129,8 +43,8 @@ test('matchesObservedProduct honors provider platform shop and external fields',
|
|||||||
test('mapAdminObservedProductItem attaches matched binding summary', () => {
|
test('mapAdminObservedProductItem attaches matched binding summary', () => {
|
||||||
const bindings = [
|
const bindings = [
|
||||||
{
|
{
|
||||||
provider: 'agiso',
|
provider: '91kaquan',
|
||||||
platform: 'xianyu',
|
platform: 'kuaishou',
|
||||||
shopId: 'shop-1',
|
shopId: 'shop-1',
|
||||||
skuCode: 'sku-1',
|
skuCode: 'sku-1',
|
||||||
skuName: 'SKU 1',
|
skuName: 'SKU 1',
|
||||||
@@ -141,8 +55,8 @@ test('mapAdminObservedProductItem attaches matched binding summary', () => {
|
|||||||
},
|
},
|
||||||
]
|
]
|
||||||
const observed = {
|
const observed = {
|
||||||
provider: 'agiso',
|
provider: '91kaquan',
|
||||||
platform: 'xianyu',
|
platform: 'kuaishou',
|
||||||
shopId: 'shop-1',
|
shopId: 'shop-1',
|
||||||
shopName: '店铺1',
|
shopName: '店铺1',
|
||||||
externalItemId: '',
|
externalItemId: '',
|
||||||
@@ -154,8 +68,8 @@ test('mapAdminObservedProductItem attaches matched binding summary', () => {
|
|||||||
|
|
||||||
assert.deepEqual(findMatchingObservedBinding(bindings, observed), bindings[0])
|
assert.deepEqual(findMatchingObservedBinding(bindings, observed), bindings[0])
|
||||||
assert.deepEqual(mapAdminObservedProductItem(observed, bindings), {
|
assert.deepEqual(mapAdminObservedProductItem(observed, bindings), {
|
||||||
provider: 'agiso',
|
provider: '91kaquan',
|
||||||
platform: 'xianyu',
|
platform: 'kuaishou',
|
||||||
shopId: 'shop-1',
|
shopId: 'shop-1',
|
||||||
shopName: '店铺1',
|
shopName: '店铺1',
|
||||||
externalItemId: '',
|
externalItemId: '',
|
||||||
|
|||||||
@@ -1,70 +1,5 @@
|
|||||||
import { normalizeAgisoMessageTemplate } from './agiso-template.js'
|
|
||||||
import { maskSecret } from './mappers.js'
|
|
||||||
|
|
||||||
type JsonObject = Record<string, any>
|
type JsonObject = Record<string, any>
|
||||||
|
|
||||||
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) {
|
export function matchesObservedProduct(binding: JsonObject, observed: JsonObject) {
|
||||||
const provider = String(binding?.provider || '').trim()
|
const provider = String(binding?.provider || '').trim()
|
||||||
const platform = String(binding?.platform || '').trim()
|
const platform = String(binding?.platform || '').trim()
|
||||||
|
|||||||
@@ -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<string, any>
|
|
||||||
|
|
||||||
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',
|
|
||||||
})
|
|
||||||
}
|
|
||||||
@@ -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',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
],
|
|
||||||
})
|
|
||||||
})
|
|
||||||
@@ -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<string, any>
|
|
||||||
|
|
||||||
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),
|
|
||||||
}
|
|
||||||
}),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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";
|
|
||||||
@@ -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 条规则与其它规则重复/,
|
|
||||||
)
|
|
||||||
})
|
|
||||||
@@ -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<string, any>
|
|
||||||
|
|
||||||
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<unknown>
|
|
||||||
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
|
|
||||||
}
|
|
||||||
@@ -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,
|
|
||||||
},
|
|
||||||
)
|
|
||||||
})
|
|
||||||
@@ -1,100 +0,0 @@
|
|||||||
import { applyOptionalStringField } from "./domain.js";
|
|
||||||
|
|
||||||
type JsonObject = Record<string, any>
|
|
||||||
|
|
||||||
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<string, JsonObject> = options.currentMap || {};
|
|
||||||
const nextDefaults: JsonObject = {
|
|
||||||
...currentDefaults,
|
|
||||||
};
|
|
||||||
const nextMap: Record<string, JsonObject> = {};
|
|
||||||
|
|
||||||
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,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
@@ -1,520 +0,0 @@
|
|||||||
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 {
|
|
||||||
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'
|
|
||||||
|
|
||||||
type CloseAdminTaskDeps = {
|
|
||||||
getRequiredTask?: (taskId: AdminEntityIdInput) => Promise<TaskRow>
|
|
||||||
listTaskInventoryBindingsByTaskId?: (taskId: AdminEntityIdInput) => Promise<any[]>
|
|
||||||
releaseReservedInventoryItem?: (inventoryItemId: AdminEntityIdInput, updatedAt: string) => Promise<unknown>
|
|
||||||
updateClaimToken?: (tokenId: AdminEntityIdInput, patch: Record<string, unknown>) => Promise<unknown>
|
|
||||||
updateTask?: (taskId: AdminEntityIdInput, patch: TaskUpdatePatch) => Promise<any>
|
|
||||||
createTaskEvent?: (taskId: AdminEntityIdInput, eventType: string, payload: Record<string, unknown>, createdAt: string) => Promise<unknown>
|
|
||||||
closeTencentBrowserSession?: (sessionId: string) => Promise<unknown>
|
|
||||||
nowIso?: () => string
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function releaseAdminTaskInventory(taskId: AdminEntityIdInput): Promise<AdminTaskActionResponse> {
|
|
||||||
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<AdminTaskBindingReleaseResponse> {
|
|
||||||
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<AdminTaskActionResponse> {
|
|
||||||
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(
|
|
||||||
taskId: AdminEntityIdInput,
|
|
||||||
session: AdminViewerSessionInput | null = null,
|
|
||||||
): Promise<AdminTaskActionResponse> {
|
|
||||||
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<AdminTaskActionResponse> {
|
|
||||||
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<AdminTaskActionResponse> {
|
|
||||||
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<AdminTaskActionResponse> {
|
|
||||||
const task = await getTask(taskId)
|
|
||||||
|
|
||||||
if (task.task_status === 'redeemed') {
|
|
||||||
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
|
|
||||||
|
|
||||||
if (primaryClaimTokenId) {
|
|
||||||
await updateToken(primaryClaimTokenId, {
|
|
||||||
status: 'revoked',
|
|
||||||
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',
|
|
||||||
delivery_status: 'closed',
|
|
||||||
user_action_status: 'closed',
|
|
||||||
claim_expires_at: primaryClaimTokenId ? now : task.claim_expires_at,
|
|
||||||
browser_session_id: '',
|
|
||||||
last_error: task.last_error || closeReasonParts.join(','),
|
|
||||||
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<AdminTaskActionResponse> {
|
|
||||||
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<AdminTaskActionResponse> {
|
|
||||||
const task = await getRequiredTask(taskId)
|
|
||||||
const now = nowIso()
|
|
||||||
|
|
||||||
if (isManualDispatchTask(task)) {
|
|
||||||
throw createHttpError('人工履约任务不能走自动重试,请在详情页直接回写人工履约结果', {
|
|
||||||
statusCode: 409,
|
|
||||||
errorCode: 'admin_task_manual_dispatch_retry_not_allowed',
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
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<AdminTaskManualDispatchResponse> {
|
|
||||||
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
|
|
||||||
? {
|
|
||||||
userId: Number(session.userId || 0),
|
|
||||||
username: String(session.username || ''),
|
|
||||||
role: String(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,
|
|
||||||
}, 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),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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<AdminWebhookReplayResponse> {
|
|
||||||
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,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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: '',
|
|
||||||
}),
|
|
||||||
[''],
|
|
||||||
)
|
|
||||||
})
|
|
||||||
@@ -8,7 +8,6 @@ import {
|
|||||||
import { upsertProductMatchRule } from '../../repositories/product-match-rule-repo.js'
|
import { upsertProductMatchRule } from '../../repositories/product-match-rule-repo.js'
|
||||||
import { normalizeProductName } from '../order/product-match-service.js'
|
import { normalizeProductName } from '../order/product-match-service.js'
|
||||||
import { query } from '../../db/client.js'
|
import { query } from '../../db/client.js'
|
||||||
import { getLegacyOrderFulfillmentBindingConfigs } from '../order/fulfillment-binding-config-service.js'
|
|
||||||
import {
|
import {
|
||||||
getKuaishouCloudFulfillmentConfig,
|
getKuaishouCloudFulfillmentConfig,
|
||||||
mapKuaishouCloudFulfillmentItemsToBindings,
|
mapKuaishouCloudFulfillmentItemsToBindings,
|
||||||
@@ -108,10 +107,7 @@ async function ensureCoreProfiles() {
|
|||||||
|
|
||||||
export async function syncConfiguredFulfillmentBindings(profileMap: JsonObject = {}) {
|
export async function syncConfiguredFulfillmentBindings(profileMap: JsonObject = {}) {
|
||||||
const timestamp = nowIso()
|
const timestamp = nowIso()
|
||||||
const bindingsToApply = [
|
const bindingsToApply = mapKuaishouCloudFulfillmentItemsToBindings(getKuaishouCloudFulfillmentConfig())
|
||||||
...getLegacyOrderFulfillmentBindingConfigs(),
|
|
||||||
...mapKuaishouCloudFulfillmentItemsToBindings(getKuaishouCloudFulfillmentConfig()),
|
|
||||||
]
|
|
||||||
|
|
||||||
await query('DELETE FROM product_match_rules')
|
await query('DELETE FROM product_match_rules')
|
||||||
await query('DELETE FROM sku_fulfillment_bindings')
|
await query('DELETE FROM sku_fulfillment_bindings')
|
||||||
@@ -124,7 +120,7 @@ export async function syncConfiguredFulfillmentBindings(profileMap: JsonObject =
|
|||||||
|
|
||||||
const bindingConfig = resolveBindingRuntimeConfig(binding)
|
const bindingConfig = resolveBindingRuntimeConfig(binding)
|
||||||
const matchShopIds = resolveBindingMatchShopIds(binding)
|
const matchShopIds = resolveBindingMatchShopIds(binding)
|
||||||
const match = binding.match || {}
|
const match: JsonObject = isPlainObject(binding.match) ? binding.match : {}
|
||||||
const externalSkuName = String(match.externalSkuName || '').trim()
|
const externalSkuName = String(match.externalSkuName || '').trim()
|
||||||
const externalItemId = String(match.externalItemId || '').trim()
|
const externalItemId = String(match.externalItemId || '').trim()
|
||||||
const externalSkuCode = String(match.externalSkuCode || '').trim()
|
const externalSkuCode = String(match.externalSkuCode || '').trim()
|
||||||
@@ -166,7 +162,7 @@ export async function syncConfiguredFulfillmentBindings(profileMap: JsonObject =
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export function resolveBindingMatchShopIds(binding: JsonObject = {}) {
|
function resolveBindingMatchShopIds(binding: JsonObject = {}) {
|
||||||
return [String(binding.shopId || '').trim()]
|
return [String(binding.shopId || '').trim()]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,273 +0,0 @@
|
|||||||
import test from 'node:test'
|
|
||||||
import assert from 'node:assert/strict'
|
|
||||||
|
|
||||||
import { redeemClaimTaskWithInventoryFallbackWithDeps } from './claim-session-service.js'
|
|
||||||
|
|
||||||
test('redeemClaimTaskWithInventoryFallbackWithDeps invalidates missing CDKEY and retries with replacement inventory', async () => {
|
|
||||||
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',
|
|
||||||
},
|
|
||||||
],
|
|
||||||
)
|
|
||||||
})
|
|
||||||
@@ -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'
|
|
||||||
+110
-70
@@ -1,32 +1,95 @@
|
|||||||
import { buildClaimUrl } from '../claim-service.js'
|
import { findClaimTokenByToken, updateClaimToken } from '../../repositories/claim-token-repo.js'
|
||||||
import { createHttpError } from '../../../utils/http.js'
|
import { releaseReservedInventoryItem } from '../../repositories/inventory-repo.js'
|
||||||
import { maskCode as maskCodeValue } from '../../../utils/masking.js'
|
import { getOrderItemById } from '../../repositories/order-item-repo.js'
|
||||||
import { formatFenToAmount, normalizeFen } from '../../../utils/money.js'
|
import { getOrderById } from '../../repositories/order-repo.js'
|
||||||
import {
|
import { findTaskByClaimTokenId, updateTask } from '../../repositories/task-repo.js'
|
||||||
parseTaskContext as parseTaskContextValue,
|
import { createHttpError } from '../../utils/http.js'
|
||||||
parseTaskState as parseTaskStateValue,
|
import { formatFenToAmount, normalizeFen } from '../../utils/money.js'
|
||||||
} from '../../../utils/task-json.js'
|
import { parseTaskContext as parseTaskContextValue } from '../../utils/task-json.js'
|
||||||
|
import { nowIso } from '../../utils/time.js'
|
||||||
|
import { buildClaimUrl } from './claim-service.js'
|
||||||
|
|
||||||
export const CLAIM_TERMINAL_STATUSES = new Set(['expired', 'closed'])
|
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 const KUAISHOU_CLOUD_CLAIM_GUIDE_BASE_PATH = '/kuaishou-cloud-guide'
|
||||||
|
|
||||||
export function buildClaimDetailPayload({ claimToken, task, order, orderItem, session }) {
|
export async function getClaimContext(token: unknown) {
|
||||||
const screenshotReady = Boolean(task.screenshot_path) || Boolean(session?.artifacts?.hasScreenshot)
|
const normalized = String(token || '').trim()
|
||||||
const screenshotUrl = screenshotReady ? `/api/v1/claim/${claimToken.token}/screenshot` : ''
|
|
||||||
const finalRedeem = session?.redeem?.final?.redeem || null
|
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 }) {
|
||||||
const kuaishouCloudFulfillment = mapClaimKuaishouCloudFulfillment(task, order)
|
const kuaishouCloudFulfillment = mapClaimKuaishouCloudFulfillment(task, order)
|
||||||
|
|
||||||
return {
|
return {
|
||||||
tokenStatus: claimToken.status,
|
tokenStatus: claimToken.status,
|
||||||
claimUrl: buildClaimUrl(claimToken.token),
|
claimUrl: buildClaimUrl(claimToken.token),
|
||||||
flowType: kuaishouCloudFulfillment ? 'kuaishou_cloud' : 'tencent_claim',
|
flowType: 'kuaishou_cloud',
|
||||||
task: {
|
task: {
|
||||||
taskId: task.id,
|
taskId: task.id,
|
||||||
taskNo: task.task_no,
|
taskNo: task.task_no,
|
||||||
status: task.task_status,
|
status: task.task_status,
|
||||||
executorKey: task.executor_key || '',
|
executorKey: task.executor_key || '',
|
||||||
requiresSupportReview: isAssistedClaimTask(task),
|
requiresSupportReview: false,
|
||||||
expiresAt: claimToken.expired_at,
|
expiresAt: claimToken.expired_at,
|
||||||
claimedAt: task.claimed_at,
|
claimedAt: task.claimed_at,
|
||||||
roleConfirmedAt: task.role_confirmed_at,
|
roleConfirmedAt: task.role_confirmed_at,
|
||||||
@@ -51,14 +114,14 @@ export function buildClaimDetailPayload({ claimToken, task, order, orderItem, se
|
|||||||
skuName: orderItem.sku_name,
|
skuName: orderItem.sku_name,
|
||||||
quantity: orderItem.quantity,
|
quantity: orderItem.quantity,
|
||||||
},
|
},
|
||||||
session,
|
session: null,
|
||||||
kuaishouCloudFulfillment,
|
kuaishouCloudFulfillment,
|
||||||
result: task.redeemed_at || session?.status === 'redeemed'
|
result: task.redeemed_at
|
||||||
? {
|
? {
|
||||||
resultCode: String(task.result_code || finalRedeem?.iRet || finalRedeem?.ret || ''),
|
resultCode: String(task.result_code || ''),
|
||||||
resultMessage: String(task.result_message || finalRedeem?.sMsg || finalRedeem?.msg || session?.notice || ''),
|
resultMessage: String(task.result_message || ''),
|
||||||
screenshotReady,
|
screenshotReady: false,
|
||||||
screenshotUrl,
|
screenshotUrl: '',
|
||||||
}
|
}
|
||||||
: null,
|
: null,
|
||||||
}
|
}
|
||||||
@@ -151,58 +214,35 @@ export function mapClaimKuaishouCloudFulfillment(task, order) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export function assertTaskCanProceed(task) {
|
function parseTaskContext(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)
|
return parseTaskContextValue(task)
|
||||||
}
|
}
|
||||||
|
|
||||||
export function normalizeClaimLoginType(loginType) {
|
async function expireClaimContext(claimToken, task) {
|
||||||
return String(loginType || '').trim() === 'wx' ? 'wx' : 'qq'
|
const now = nowIso()
|
||||||
}
|
const nextClaimToken = await updateClaimToken(claimToken.id, {
|
||||||
|
status: 'expired',
|
||||||
|
updated_at: now,
|
||||||
|
})
|
||||||
|
|
||||||
export function isRecoverableSessionError(error) {
|
let nextTask = task
|
||||||
const errorCode = String(error?.errorCode || error?.code || '').trim()
|
|
||||||
return errorCode === 'session_not_found' || errorCode === 'session_closed'
|
|
||||||
}
|
|
||||||
|
|
||||||
export function maskCode(value) {
|
if (!CLAIM_TERMINAL_STATUSES.has(String(task.task_status || '')) && task.task_status !== 'redeemed') {
|
||||||
return maskCodeValue(value, { shortMask: '****' })
|
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,
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -18,8 +18,7 @@ import {
|
|||||||
prepareKuaishouCloudFulfillmentTask,
|
prepareKuaishouCloudFulfillmentTask,
|
||||||
refreshKuaishouCloudTaskRoleInfo,
|
refreshKuaishouCloudTaskRoleInfo,
|
||||||
} from '../fulfillment/kuaishou-cloud-task-service.js'
|
} from '../fulfillment/kuaishou-cloud-task-service.js'
|
||||||
import { buildClaimDetailPayload } from './session/shared.js'
|
import { buildClaimDetailPayload, getClaimContext } from './kuaishou-cloud-claim-context.js'
|
||||||
import { getClaimContext } from './session/context.js'
|
|
||||||
import { syncKuaishouCloudRoleInfo } from './kuaishou-cloud-sync-service.js'
|
import { syncKuaishouCloudRoleInfo } from './kuaishou-cloud-sync-service.js'
|
||||||
|
|
||||||
const KUAISHOU_CLOUD_GUIDE_DIR = path.resolve(PROJECT_ROOT, '../../tems/imgs')
|
const KUAISHOU_CLOUD_GUIDE_DIR = path.resolve(PROJECT_ROOT, '../../tems/imgs')
|
||||||
@@ -170,7 +169,6 @@ export async function getKuaishouCloudClaimDetail(token: unknown) {
|
|||||||
task,
|
task,
|
||||||
order: context.order,
|
order: context.order,
|
||||||
orderItem: context.orderItem,
|
orderItem: context.orderItem,
|
||||||
session: 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,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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<string, any>
|
|
||||||
|
|
||||||
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<JsonObject>, 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<JsonObject>) {
|
|
||||||
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<JsonObject>) {
|
|
||||||
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,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
@@ -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<TaskRow, 'id' | 'browser_session_id'>
|
|
||||||
orderItem: Pick<OrderItemRow, 'sku_code'>
|
|
||||||
}
|
|
||||||
|
|
||||||
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<unknown>
|
|
||||||
redeemTencentBrowserSession?: (
|
|
||||||
sessionId: string,
|
|
||||||
payload: { code: string },
|
|
||||||
) => Promise<TencentRedeemSession>
|
|
||||||
classifyTencentRedeemResult?: (finalRedeem: TencentRedeemPayload | null) => TencentRedeemClassification
|
|
||||||
markInventoryItemConsumed?: (
|
|
||||||
inventoryItemId: number | string,
|
|
||||||
reason: string,
|
|
||||||
consumedAt: string,
|
|
||||||
) => Promise<InventoryItemRow | null>
|
|
||||||
createTaskEvent?: (
|
|
||||||
taskId: number | string,
|
|
||||||
eventType: string,
|
|
||||||
payload?: unknown,
|
|
||||||
createdAt?: string,
|
|
||||||
) => Promise<unknown>
|
|
||||||
invalidateReservedInventoryItem?: (
|
|
||||||
inventoryItemId: number | string,
|
|
||||||
reason: string,
|
|
||||||
updatedAt: string,
|
|
||||||
) => Promise<InventoryItemRow | null>
|
|
||||||
reserveInventoryForTask?: (payload: {
|
|
||||||
skuCode: string
|
|
||||||
taskId: number | string
|
|
||||||
credentialType?: string
|
|
||||||
roleKey?: string
|
|
||||||
inventoryGroupCodes?: string[] | null
|
|
||||||
}) => Promise<InventoryItemRow | null>
|
|
||||||
nowIso?: () => string
|
|
||||||
redeemReplacementLimit?: number
|
|
||||||
}
|
|
||||||
|
|
||||||
type RedeemClaimTaskResult = {
|
|
||||||
session: TencentRedeemSession
|
|
||||||
inventoryItem: InventoryItemRow
|
|
||||||
classification: TencentRedeemClassification
|
|
||||||
attempts: RedeemAttemptSummary[]
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function redeemClaimTaskWithInventoryFallback(
|
|
||||||
context: ClaimRedeemContext,
|
|
||||||
initialInventoryItem: InventoryItemRow,
|
|
||||||
): Promise<RedeemClaimTaskResult> {
|
|
||||||
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<RedeemClaimTaskResult> {
|
|
||||||
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<TencentRedeemClassification, 'retCode'> | null,
|
|
||||||
): string {
|
|
||||||
if (classification?.retCode != null) {
|
|
||||||
return String(classification.retCode)
|
|
||||||
}
|
|
||||||
|
|
||||||
const value = finalRedeem?.iRet ?? finalRedeem?.ret
|
|
||||||
return value == null ? '' : String(value)
|
|
||||||
}
|
|
||||||
@@ -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<string, any>
|
|
||||||
|
|
||||||
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)
|
|
||||||
}
|
|
||||||
@@ -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',
|
|
||||||
)
|
|
||||||
})
|
|
||||||
@@ -1,73 +0,0 @@
|
|||||||
import { parseJsonObject } from '../../utils/json.js'
|
|
||||||
|
|
||||||
type JsonObject = Record<string, unknown>
|
|
||||||
|
|
||||||
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 ''
|
|
||||||
}
|
|
||||||
@@ -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',
|
|
||||||
},
|
|
||||||
],
|
|
||||||
)
|
|
||||||
})
|
|
||||||
@@ -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<string, any>
|
|
||||||
|
|
||||||
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]'
|
|
||||||
}
|
|
||||||
@@ -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,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
])
|
|
||||||
})
|
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -1,9 +0,0 @@
|
|||||||
## Agiso Platform Services
|
|
||||||
|
|
||||||
Keep Agiso-wide shared helpers in this directory.
|
|
||||||
|
|
||||||
Per-platform integrations live in subdirectories:
|
|
||||||
|
|
||||||
- `xianyu/`
|
|
||||||
- `pdd/`
|
|
||||||
- `taobao/`
|
|
||||||
@@ -1,3 +0,0 @@
|
|||||||
## PDD
|
|
||||||
|
|
||||||
Put all Agiso PDD-specific service integrations here.
|
|
||||||
@@ -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<string, unknown>
|
|
||||||
|
|
||||||
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<string, AgisoShopConfig>
|
|
||||||
}
|
|
||||||
|
|
||||||
export function getAgisoShopsFilePath(): string {
|
|
||||||
return AGISO_SHOPS_FILE_PATH
|
|
||||||
}
|
|
||||||
|
|
||||||
export function getAgisoShopConfigMap(): Record<string, AgisoShopConfig> {
|
|
||||||
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<string, AgisoShopConfig> {
|
|
||||||
const output: Record<string, AgisoShopConfig> = {}
|
|
||||||
|
|
||||||
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]'
|
|
||||||
}
|
|
||||||
@@ -1,3 +0,0 @@
|
|||||||
## Taobao
|
|
||||||
|
|
||||||
Put all Agiso Taobao-specific service integrations here.
|
|
||||||
@@ -1,3 +0,0 @@
|
|||||||
## Xianyu
|
|
||||||
|
|
||||||
Put all Agiso Xianyu-specific service integrations here.
|
|
||||||
@@ -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
|
|
||||||
}
|
|
||||||
})
|
|
||||||
@@ -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<string, unknown>
|
|
||||||
|
|
||||||
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<TaskRow> & {
|
|
||||||
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<string>
|
|
||||||
}
|
|
||||||
|
|
||||||
type FetchLike = (
|
|
||||||
input: string | URL,
|
|
||||||
init?: RequestInit,
|
|
||||||
) => Promise<FetchResponseLike>
|
|
||||||
|
|
||||||
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<AutoDeliveryTask[]>
|
|
||||||
fetch?: FetchLike
|
|
||||||
confirmAgisoXianyuAutoDeliveryShipped?: (
|
|
||||||
input: AgisoAutoDeliveryConfirmInput,
|
|
||||||
) => Promise<AgisoAutoDeliveryConfirmResult>
|
|
||||||
updateTask?: (
|
|
||||||
taskId: number | string,
|
|
||||||
patch: { context_json: string; updated_at: string },
|
|
||||||
) => Promise<AutoDeliveryTask | null>
|
|
||||||
createTaskEvent?: (
|
|
||||||
taskId: number | string,
|
|
||||||
eventType: string,
|
|
||||||
payload?: unknown,
|
|
||||||
createdAt?: string,
|
|
||||||
) => Promise<unknown>
|
|
||||||
nowIso?: () => string
|
|
||||||
ensureAgisoXianyuAutoDeliveryMessageDeliveredForTask?: (
|
|
||||||
input: { order?: AutoDeliveryOrder | null; task?: AutoDeliveryTask | null },
|
|
||||||
) => Promise<AutoDeliveryMessageResult>
|
|
||||||
}
|
|
||||||
|
|
||||||
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<string, string> {
|
|
||||||
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<string, string> {
|
|
||||||
const payload: Record<string, string> = {
|
|
||||||
tid: String(platformOrderId || '').trim(),
|
|
||||||
timestamp: String(Math.floor(Date.now() / 1000)),
|
|
||||||
}
|
|
||||||
|
|
||||||
payload.sign = generateSign(payload, appSecret)
|
|
||||||
return payload
|
|
||||||
}
|
|
||||||
|
|
||||||
function generateSign(params: Record<string, string>, 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<AgisoAutoDeliveryConfirmResult> {
|
|
||||||
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<AutoDeliveryMessageResult> {
|
|
||||||
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' }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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结果:自动发货成功')
|
|
||||||
})
|
|
||||||
@@ -1,539 +0,0 @@
|
|||||||
import crypto from 'node:crypto'
|
|
||||||
|
|
||||||
import { runtimeConfig } from '../../../../config/runtime.js'
|
|
||||||
import { normalizeAgisoMessageTemplate } from '../../../admin/platform-config/agiso-template.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<string, unknown>
|
|
||||||
|
|
||||||
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<string>
|
|
||||||
}
|
|
||||||
|
|
||||||
type FetchLike = (
|
|
||||||
input: string | URL,
|
|
||||||
init?: RequestInit,
|
|
||||||
) => Promise<FetchResponseLike>
|
|
||||||
|
|
||||||
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<MessageDeliveryLike | null>
|
|
||||||
createMessageDelivery?: (
|
|
||||||
input: MessageDeliveryCreateInput,
|
|
||||||
) => Promise<MessageDeliveryLike | null>
|
|
||||||
updateMessageDelivery?: (
|
|
||||||
deliveryId: number | string,
|
|
||||||
patch: MessageDeliveryPatch,
|
|
||||||
) => Promise<MessageDeliveryLike | null>
|
|
||||||
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,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
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<string, string> {
|
|
||||||
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<string, string> {
|
|
||||||
const timestamp = String(Math.floor(Date.now() / 1000))
|
|
||||||
const payload: Record<string, string> = {
|
|
||||||
tid,
|
|
||||||
msg,
|
|
||||||
timestamp,
|
|
||||||
}
|
|
||||||
|
|
||||||
payload.sign = generateSign(payload, appSecret)
|
|
||||||
|
|
||||||
return payload
|
|
||||||
}
|
|
||||||
|
|
||||||
function generateSign(params: Record<string, string>, 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<string, string>): Record<string, string> {
|
|
||||||
const output = { ...headers }
|
|
||||||
|
|
||||||
if (output.Authorization) {
|
|
||||||
output.Authorization = '[masked]'
|
|
||||||
}
|
|
||||||
|
|
||||||
return output
|
|
||||||
}
|
|
||||||
|
|
||||||
function maskBodyForStorage(body: Record<string, string>): Record<string, string> {
|
|
||||||
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]'
|
|
||||||
}
|
|
||||||
@@ -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')
|
|
||||||
})
|
|
||||||
@@ -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<string, unknown>
|
|
||||||
|
|
||||||
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<string>
|
|
||||||
}
|
|
||||||
|
|
||||||
type FetchWithTimeout = (
|
|
||||||
url: string,
|
|
||||||
options: RequestInit,
|
|
||||||
timeoutMs: number,
|
|
||||||
) => Promise<FetchResponseLike>
|
|
||||||
|
|
||||||
type AgisoDetailDeps = {
|
|
||||||
fetchWithTimeout?: FetchWithTimeout
|
|
||||||
queryAgisoXianyuOrderDetail?: (
|
|
||||||
input: Required<AgisoDetailQueryInput>,
|
|
||||||
) => Promise<AgisoDetailResult>
|
|
||||||
}
|
|
||||||
|
|
||||||
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<AgisoDetailResult> {
|
|
||||||
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<string, string> {
|
|
||||||
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<string, string> {
|
|
||||||
const timestamp = String(Math.floor(Date.now() / 1000))
|
|
||||||
const payload: Record<string, string> = {
|
|
||||||
tid: String(platformOrderId || '').trim(),
|
|
||||||
timestamp,
|
|
||||||
}
|
|
||||||
|
|
||||||
payload.sign = generateSign(payload, appSecret)
|
|
||||||
return payload
|
|
||||||
}
|
|
||||||
|
|
||||||
function generateSign(params: Record<string, string>, 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]'
|
|
||||||
}
|
|
||||||
@@ -1,101 +0,0 @@
|
|||||||
import { runtimeConfig } from '../../config/runtime.js'
|
|
||||||
|
|
||||||
const DEFAULT_TIMEOUT_MS = 60_000
|
|
||||||
|
|
||||||
type OcrAction = 'recognize' | 'batch'
|
|
||||||
|
|
||||||
type OcrRequestPayload = Record<string, unknown>
|
|
||||||
|
|
||||||
type OcrHttpResult = Record<string, any> & {
|
|
||||||
code: number
|
|
||||||
msg?: string
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function recognizeTencentCaptcha(payload: OcrRequestPayload): Promise<OcrHttpResult> {
|
|
||||||
return callOcrViaHttp('recognize', payload)
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function recognizeImageCaptcha(payload: OcrRequestPayload): Promise<OcrHttpResult> {
|
|
||||||
return callOcrViaHttp('recognize', payload)
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function batchRecognizeTencentCaptcha(payload: OcrRequestPayload): Promise<OcrHttpResult> {
|
|
||||||
return callOcrViaHttp('batch', payload, { timeoutMs: 180_000 })
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function warmupOcrService(): Promise<void> {
|
|
||||||
await warmupOcrViaHttp()
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function closeOcrService(): Promise<void> {
|
|
||||||
// HTTP 模式无持久连接,无需关闭。
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── HTTP Call ───────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
async function callOcrViaHttp(
|
|
||||||
action: OcrAction,
|
|
||||||
payload: OcrRequestPayload,
|
|
||||||
{ timeoutMs = DEFAULT_TIMEOUT_MS }: { timeoutMs?: number } = {},
|
|
||||||
): Promise<OcrHttpResult> {
|
|
||||||
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<void> {
|
|
||||||
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}`)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,308 +0,0 @@
|
|||||||
/// <reference lib="dom" />
|
|
||||||
|
|
||||||
import { extractHostState, reloadActivityPageForPresentation } from './session-page.js'
|
|
||||||
import { shouldRefreshLoggedInPresentation } from './session-state.js'
|
|
||||||
|
|
||||||
type BrowserPageLike = {
|
|
||||||
evaluate: <T = any>(callback: any) => Promise<T>
|
|
||||||
waitForTimeout: (milliseconds: number) => Promise<void>
|
|
||||||
}
|
|
||||||
|
|
||||||
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<HostState>
|
|
||||||
reloadActivityPageForPresentation?: (page: BrowserPageLike, activityUrl: string) => Promise<unknown>
|
|
||||||
shouldRefreshLoggedInPresentation?: (hostState: HostState) => boolean
|
|
||||||
}
|
|
||||||
|
|
||||||
type EnsureActivityInfoReadyOptions = {
|
|
||||||
triggerRender?: boolean
|
|
||||||
timeoutMs?: number
|
|
||||||
extractActivityInfo?: (page: BrowserPageLike) => Promise<ActivityInfo>
|
|
||||||
triggerActivityRoleRender?: (page: BrowserPageLike) => Promise<unknown>
|
|
||||||
now?: () => number
|
|
||||||
}
|
|
||||||
|
|
||||||
declare global {
|
|
||||||
interface Window {
|
|
||||||
renderCdkey?: () => void
|
|
||||||
roleData?: Record<string, unknown>
|
|
||||||
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<ActivityInfo> {
|
|
||||||
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<void> {
|
|
||||||
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<ActivityInfo> {
|
|
||||||
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()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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'
|
|
||||||
}
|
|
||||||
@@ -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<BrowserCookie[]>
|
|
||||||
}
|
|
||||||
|
|
||||||
export type TencentCookieMap = Record<string, string>
|
|
||||||
|
|
||||||
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<TencentCookieMap> {
|
|
||||||
const cookies = await browserContext.cookies(TENCENT_COOKIE_URLS)
|
|
||||||
return normalizeTencentCookieMap(cookies)
|
|
||||||
}
|
|
||||||
@@ -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<void>
|
|
||||||
}
|
|
||||||
|
|
||||||
type QrDownloadResponse = {
|
|
||||||
ok: () => boolean
|
|
||||||
status: () => number
|
|
||||||
body: () => Promise<Buffer>
|
|
||||||
}
|
|
||||||
|
|
||||||
type PageWithRequestContext = {
|
|
||||||
context: () => {
|
|
||||||
request: {
|
|
||||||
get: (url: string, options?: {
|
|
||||||
failOnStatusCode?: boolean
|
|
||||||
headers?: Record<string, string>
|
|
||||||
}) => Promise<QrDownloadResponse>
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export function logBrowserSessionDebug(scope: string, detail?: unknown): void {
|
|
||||||
if (!SESSION_DEBUG_ENABLED) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
logDebug('[browser/session]', scope, detail)
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function waitForFrame<TFrame extends FrameLike>(
|
|
||||||
page: PageWithFrames,
|
|
||||||
predicate: (frame: TFrame) => boolean,
|
|
||||||
timeoutMs = 15_000,
|
|
||||||
): Promise<TFrame> {
|
|
||||||
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<Buffer | false | null> {
|
|
||||||
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')
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,244 +0,0 @@
|
|||||||
/// <reference lib="dom" />
|
|
||||||
|
|
||||||
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<number>
|
|
||||||
first: () => LocatorLike
|
|
||||||
click: (options?: { timeout?: number }) => Promise<void>
|
|
||||||
waitFor: (options?: { state?: string; timeout?: number }) => Promise<void>
|
|
||||||
screenshot: (options: { path: string }) => Promise<void>
|
|
||||||
}
|
|
||||||
|
|
||||||
type BrowserPageLike = {
|
|
||||||
frames: () => any[]
|
|
||||||
locator: (selector: string) => LocatorLike
|
|
||||||
waitForTimeout: (milliseconds: number) => Promise<void>
|
|
||||||
evaluate: <T = any>(callback: any) => Promise<T>
|
|
||||||
reload: (options?: { waitUntil?: string }) => Promise<unknown>
|
|
||||||
goto: (url: string, options?: { waitUntil?: string }) => Promise<unknown>
|
|
||||||
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<string>
|
|
||||||
now?: () => string
|
|
||||||
}
|
|
||||||
|
|
||||||
type CaptureQrImageWithRetryOptions = {
|
|
||||||
captureQqQrImage?: (page: BrowserPageLike, qrImagePath: string) => Promise<unknown>
|
|
||||||
captureWxQrImage?: (page: BrowserPageLike, qrImagePath: string) => Promise<unknown>
|
|
||||||
ensureLoginTab?: typeof ensureLoginTab
|
|
||||||
isRetryableQrCaptureError?: (error: unknown) => boolean
|
|
||||||
logBrowserSessionDebug?: (scope: string, detail?: unknown) => void
|
|
||||||
}
|
|
||||||
|
|
||||||
type EnsureLoginTabOptions = {
|
|
||||||
ensureQqLoginReady?: (page: BrowserPageLike) => Promise<unknown>
|
|
||||||
ensureWxLoginReady?: (page: BrowserPageLike) => Promise<unknown>
|
|
||||||
}
|
|
||||||
|
|
||||||
type ExtractQrStateOptions = {
|
|
||||||
extractQqQrState?: (page: BrowserPageLike) => Promise<unknown>
|
|
||||||
extractWxQrState?: (page: BrowserPageLike) => Promise<unknown>
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function captureSessionQr(
|
|
||||||
session: SessionForQrCapture,
|
|
||||||
{
|
|
||||||
ensureLoginReady = true,
|
|
||||||
ensureLoginTab: ensureLoginTabImpl = ensureLoginTab,
|
|
||||||
captureQrImageWithRetry: captureQrImageWithRetryImpl = captureQrImageWithRetry,
|
|
||||||
readFile = fs.readFile,
|
|
||||||
now = () => new Date().toISOString(),
|
|
||||||
}: CaptureSessionQrOptions = {},
|
|
||||||
): Promise<void> {
|
|
||||||
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<void> {
|
|
||||||
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<void> {
|
|
||||||
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<void> {
|
|
||||||
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<unknown> {
|
|
||||||
if (loginType === 'qq') {
|
|
||||||
return extractQqQrStateImpl(page)
|
|
||||||
}
|
|
||||||
|
|
||||||
return extractWxQrStateImpl(page)
|
|
||||||
}
|
|
||||||
@@ -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<string, any> = {
|
|
||||||
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 || '',
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,55 +0,0 @@
|
|||||||
/// <reference lib="dom" />
|
|
||||||
|
|
||||||
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<void>
|
|
||||||
goto: (url: string, options?: { waitUntil?: string }) => Promise<unknown>
|
|
||||||
waitForTimeout: (milliseconds: number) => Promise<void>
|
|
||||||
evaluate: (callback: () => unknown) => Promise<unknown>
|
|
||||||
screenshot: (options: {
|
|
||||||
path: string
|
|
||||||
clip: { x: number; y: number; width: number; height: number }
|
|
||||||
}) => Promise<unknown>
|
|
||||||
close: () => Promise<void>
|
|
||||||
}
|
|
||||||
|
|
||||||
type BrowserContextLike = {
|
|
||||||
newPage: () => Promise<BrowserPageLike>
|
|
||||||
}
|
|
||||||
|
|
||||||
export type BeijingTimeProof = {
|
|
||||||
screenshotPath: string
|
|
||||||
pageUrl: string
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function captureBeijingTimeProof(
|
|
||||||
browserContext: BrowserContextLike,
|
|
||||||
{ screenshotPath }: { screenshotPath: string },
|
|
||||||
): Promise<BeijingTimeProof> {
|
|
||||||
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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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'
|
|
||||||
@@ -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 `<!doctype html>
|
|
||||||
<html lang="zh-CN">
|
|
||||||
<head>
|
|
||||||
<meta charset="UTF-8" />
|
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
|
||||||
<title>兑换与北京时间凭证</title>
|
|
||||||
<style>
|
|
||||||
:root {
|
|
||||||
color-scheme: light;
|
|
||||||
}
|
|
||||||
* {
|
|
||||||
box-sizing: border-box;
|
|
||||||
}
|
|
||||||
body {
|
|
||||||
margin: 0;
|
|
||||||
font-family: "PingFang SC", "Microsoft YaHei", sans-serif;
|
|
||||||
background:
|
|
||||||
radial-gradient(circle at top left, rgba(33, 150, 243, 0.16), transparent 28%),
|
|
||||||
linear-gradient(180deg, #edf4ff 0%, #f6f8fc 52%, #eef1f7 100%);
|
|
||||||
color: #142033;
|
|
||||||
}
|
|
||||||
.sheet {
|
|
||||||
width: 1480px;
|
|
||||||
margin: 0 auto;
|
|
||||||
padding: 30px 24px 28px;
|
|
||||||
}
|
|
||||||
.hero {
|
|
||||||
display: flex;
|
|
||||||
justify-content: space-between;
|
|
||||||
gap: 20px;
|
|
||||||
align-items: flex-end;
|
|
||||||
padding: 0 6px 18px;
|
|
||||||
}
|
|
||||||
.eyebrow {
|
|
||||||
margin: 0 0 12px;
|
|
||||||
color: #1c78d0;
|
|
||||||
font-size: 13px;
|
|
||||||
letter-spacing: 0.2em;
|
|
||||||
text-transform: uppercase;
|
|
||||||
}
|
|
||||||
h1 {
|
|
||||||
margin: 0;
|
|
||||||
font-size: 44px;
|
|
||||||
line-height: 1.08;
|
|
||||||
}
|
|
||||||
.summary {
|
|
||||||
margin: 14px 0 0;
|
|
||||||
font-size: 18px;
|
|
||||||
line-height: 1.7;
|
|
||||||
color: #52627a;
|
|
||||||
}
|
|
||||||
.meta {
|
|
||||||
min-width: 320px;
|
|
||||||
padding: 18px 20px;
|
|
||||||
border-radius: 24px;
|
|
||||||
background: rgba(255, 255, 255, 0.76);
|
|
||||||
border: 1px solid rgba(20, 32, 51, 0.08);
|
|
||||||
box-shadow: 0 18px 48px rgba(29, 55, 90, 0.08);
|
|
||||||
}
|
|
||||||
.meta strong,
|
|
||||||
.meta span {
|
|
||||||
display: block;
|
|
||||||
}
|
|
||||||
.meta strong {
|
|
||||||
font-size: 13px;
|
|
||||||
letter-spacing: 0.1em;
|
|
||||||
text-transform: uppercase;
|
|
||||||
color: #60748d;
|
|
||||||
}
|
|
||||||
.meta span {
|
|
||||||
margin-top: 8px;
|
|
||||||
font-size: 20px;
|
|
||||||
color: #142033;
|
|
||||||
line-height: 1.5;
|
|
||||||
overflow-wrap: anywhere;
|
|
||||||
}
|
|
||||||
.card {
|
|
||||||
margin-top: 16px;
|
|
||||||
padding: 20px;
|
|
||||||
border-radius: 28px;
|
|
||||||
background: rgba(255, 255, 255, 0.82);
|
|
||||||
border: 1px solid rgba(20, 32, 51, 0.08);
|
|
||||||
box-shadow: 0 24px 80px rgba(24, 41, 72, 0.1);
|
|
||||||
}
|
|
||||||
.card h2 {
|
|
||||||
margin: 0;
|
|
||||||
font-size: 30px;
|
|
||||||
}
|
|
||||||
.card p {
|
|
||||||
margin: 8px 0 0;
|
|
||||||
color: #5a6b83;
|
|
||||||
line-height: 1.65;
|
|
||||||
font-size: 16px;
|
|
||||||
}
|
|
||||||
.preview {
|
|
||||||
margin-top: 14px;
|
|
||||||
overflow: hidden;
|
|
||||||
border-radius: 20px;
|
|
||||||
border: 1px solid rgba(20, 32, 51, 0.08);
|
|
||||||
background: #dfe7f3;
|
|
||||||
}
|
|
||||||
.preview img {
|
|
||||||
display: block;
|
|
||||||
width: 100%;
|
|
||||||
height: auto;
|
|
||||||
}
|
|
||||||
.preview--time {
|
|
||||||
max-height: 500px;
|
|
||||||
}
|
|
||||||
.preview--time img {
|
|
||||||
object-fit: cover;
|
|
||||||
object-position: top center;
|
|
||||||
}
|
|
||||||
.url {
|
|
||||||
margin-top: 10px;
|
|
||||||
font-family: ui-monospace, "SFMono-Regular", Menlo, monospace;
|
|
||||||
font-size: 14px;
|
|
||||||
color: #58708f;
|
|
||||||
overflow-wrap: anywhere;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
</head>
|
|
||||||
<body>
|
|
||||||
<main class="sheet">
|
|
||||||
<section class="hero">
|
|
||||||
<div>
|
|
||||||
<p class="eyebrow">Tencent Redeem Proof</p>
|
|
||||||
<h1>兑换截图与北京时间截图</h1>
|
|
||||||
<p class="summary">用于留存兑换结果与北京时间检索页的组合凭证。</p>
|
|
||||||
</div>
|
|
||||||
<div class="meta">
|
|
||||||
<strong>Captured At</strong>
|
|
||||||
<span>${escapeHtml(capturedAt)}</span>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
<section class="card">
|
|
||||||
<h2>兑换结果截图</h2>
|
|
||||||
<p>${escapeHtml(redeemMessage || '兑换完成')}</p>
|
|
||||||
<div class="preview">
|
|
||||||
<img src="data:image/png;base64,${redeemImageBase64}" alt="兑换结果截图" />
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
<section class="card">
|
|
||||||
<h2>百度检索“北京时间”截图</h2>
|
|
||||||
<p>单独标签页打开百度搜索结果并截图。</p>
|
|
||||||
<div class="url">${escapeHtml(timePageUrl)}</div>
|
|
||||||
<div class="preview preview--time">
|
|
||||||
<img src="data:image/png;base64,${timeImageBase64}" alt="北京时间截图" />
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
</main>
|
|
||||||
</body>
|
|
||||||
</html>`
|
|
||||||
}
|
|
||||||
|
|
||||||
export function escapeHtml(value: unknown): string {
|
|
||||||
return String(value || '')
|
|
||||||
.replaceAll('&', '&')
|
|
||||||
.replaceAll('<', '<')
|
|
||||||
.replaceAll('>', '>')
|
|
||||||
.replaceAll('"', '"')
|
|
||||||
}
|
|
||||||
@@ -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
|
|
||||||
}
|
|
||||||
@@ -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'),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user