删除咸鱼旧链路
This commit is contained in:
@@ -24,41 +24,11 @@ POSTGRES_PORT=5432
|
||||
DATA_ROOT=/app/data
|
||||
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_SESSION_SECRET=dev-local-session-secret
|
||||
ADMIN_SESSION_TTL_HOURS=12
|
||||
ADMIN_DEFAULT_USERS_JSON=[{"username":"admin","password":"dev-admin-123456","role":"admin"},{"username":"operator","password":"dev-operator-123456","role":"operator"}]
|
||||
|
||||
# Agiso
|
||||
AGISO_APP_SECRET=
|
||||
AGISO_MESSAGING_ENABLED=true
|
||||
AGISO_TRADE_DETAIL_ENDPOINT=https://gw-api.agiso.com/aldsIdle/Order/Detail
|
||||
AGISO_TRADE_DETAIL_API_VERSION=1
|
||||
AGISO_TRADE_DETAIL_TIMEOUT_MS=5000
|
||||
AGISO_AUTO_DELIVERY_ENABLED=true
|
||||
AGISO_AUTO_DELIVERY_ENDPOINT=https://gw-api.agiso.com/aldsIdle/Order/DummySend
|
||||
AGISO_AUTO_DELIVERY_API_VERSION=1
|
||||
AGISO_MESSAGE_API_VERSION=1
|
||||
AGISO_SEND_MESSAGE_ENDPOINT=https://gw-api.agiso.com/aldsIdle/ImMsg/SendMsg
|
||||
|
||||
# 91Kaquan
|
||||
NINETYONE_USER_ID=
|
||||
NINETYONE_SECRET=
|
||||
|
||||
@@ -23,37 +23,11 @@ DATABASE_MAX_CONNECTIONS=20
|
||||
DATA_ROOT=/app/data
|
||||
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_SESSION_SECRET=change-me-long-random-session-secret
|
||||
ADMIN_SESSION_TTL_HOURS=12
|
||||
ADMIN_DEFAULT_USERS_JSON=[{"username":"admin","password":"change-me-admin-password","role":"admin"},{"username":"operator","password":"change-me-operator-password","role":"operator"}]
|
||||
|
||||
# Agiso
|
||||
AGISO_APP_SECRET=change-me-agiso-app-secret
|
||||
AGISO_MESSAGING_ENABLED=true
|
||||
AGISO_TRADE_DETAIL_ENDPOINT=https://gw-api.agiso.com/aldsIdle/Order/Detail
|
||||
AGISO_TRADE_DETAIL_API_VERSION=1
|
||||
AGISO_TRADE_DETAIL_TIMEOUT_MS=5000
|
||||
AGISO_AUTO_DELIVERY_ENABLED=true
|
||||
AGISO_AUTO_DELIVERY_ENDPOINT=https://gw-api.agiso.com/aldsIdle/Order/DummySend
|
||||
AGISO_AUTO_DELIVERY_API_VERSION=1
|
||||
AGISO_MESSAGE_API_VERSION=1
|
||||
AGISO_SEND_MESSAGE_ENDPOINT=https://gw-api.agiso.com/aldsIdle/ImMsg/SendMsg
|
||||
|
||||
# 91Kaquan
|
||||
NINETYONE_USER_ID=
|
||||
NINETYONE_SECRET=
|
||||
|
||||
@@ -1,14 +1,13 @@
|
||||
# order_site
|
||||
|
||||
订单自动兑换与履约管理系统。
|
||||
快手自动发货与履约管理系统。
|
||||
|
||||
项目采用前后端分离架构,主要用于接收订单、匹配履约规则、生成领取链接、管理库存、执行自动/半自动兑换,并提供后台页面处理人工复核、平台配置和任务追踪。
|
||||
项目采用前后端分离架构,用于接收订单、匹配快手履约规则、管理库存与云卡资源,并提供后台页面处理平台配置、任务追踪和人工复核。
|
||||
|
||||
## 技术栈
|
||||
|
||||
- 前端:Vue 3、Vite、Element Plus、TypeScript
|
||||
- 后端:Node.js、Express、PostgreSQL、Playwright
|
||||
- OCR:外部镜像服务,后端通过 `OCR_BASE_URL` 调用
|
||||
- 后端:Node.js、Express、PostgreSQL
|
||||
- 网关:Caddy
|
||||
- 本地与生产运行:Docker Compose
|
||||
|
||||
@@ -30,28 +29,18 @@ docker-compose.yml 生产/服务器 Compose
|
||||
|
||||
## 本地开发
|
||||
|
||||
推荐直接使用 Docker,本地不需要额外维护 Node、PostgreSQL、Playwright 浏览器和 OCR 运行环境。
|
||||
推荐直接使用 Docker,本地不需要额外维护 Node 和 PostgreSQL。
|
||||
|
||||
```bash
|
||||
cp .env.mac-docker.example .env
|
||||
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/health`
|
||||
- 后端 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
|
||||
docker compose -f docker-compose.dev.yml logs -f backend
|
||||
docker compose -f docker-compose.dev.yml logs -f frontend
|
||||
docker compose -f docker-compose.dev.yml logs -f ocr-worker
|
||||
```
|
||||
|
||||
停止环境:
|
||||
@@ -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 frontend npm run typecheck
|
||||
docker compose -f docker-compose.dev.yml exec -T frontend npm run lint
|
||||
```
|
||||
|
||||
前端生产构建:
|
||||
|
||||
```bash
|
||||
docker compose -f docker-compose.dev.yml exec -T frontend npm run build
|
||||
```
|
||||
|
||||
@@ -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
|
||||
```
|
||||
|
||||
## 热更新
|
||||
|
||||
开发版 Compose 会把源码挂载进容器:
|
||||
|
||||
- 修改 `apps/backend/src`:后端容器内 `tsx watch` 自动重启
|
||||
- 修改 `apps/frontend/src`:Vite 自动 HMR
|
||||
- 修改数据库 migration:重启后端会自动执行迁移
|
||||
|
||||
如果只改业务代码,一般不需要重新 `--build`。改 Dockerfile、系统依赖或基础镜像时再重新构建。
|
||||
|
||||
## 环境变量
|
||||
|
||||
本地开发从根目录 `.env` 读取,模板为:
|
||||
@@ -128,10 +101,7 @@ cp .env.server.example .env
|
||||
- `ADMIN_SESSION_SECRET`:后台登录态签名密钥
|
||||
- `ADMIN_DEFAULT_USERS_JSON`:默认后台用户
|
||||
- `DATABASE_URL`:PostgreSQL 连接串
|
||||
- `TENCENT_BROWSER_HEADLESS`:Playwright 是否无头运行
|
||||
- `TENCENT_BROWSER_PREWARM`:启动时是否预热浏览器
|
||||
- `OCR_IMAGE`:OCR 外部服务镜像,默认 `order_site-ocr-worker:latest`
|
||||
- `OCR_BASE_URL`:后端访问 OCR 的 HTTP 地址,Docker 内默认 `http://ocr-worker:8100`
|
||||
- `NINETYONE_*`:91 卡券对接配置
|
||||
|
||||
## 运行数据
|
||||
|
||||
@@ -140,8 +110,6 @@ cp .env.server.example .env
|
||||
- 后端配置与运行产物:`apps/backend/data`
|
||||
- PostgreSQL 数据:Docker volume `postgres_dev_data`
|
||||
- 后端日志:`apps/backend/data/logs`
|
||||
- 浏览器会话:`apps/backend/data/browser-sessions`
|
||||
- 兑换截图:`apps/backend/data/redeem-screenshots`
|
||||
|
||||
生产部署数据:
|
||||
|
||||
@@ -161,13 +129,6 @@ mkdir -p deploy/data/backend
|
||||
docker compose up -d --build
|
||||
```
|
||||
|
||||
只部署快手轻量服务时使用:
|
||||
|
||||
```bash
|
||||
mkdir -p deploy/data/backend-kuaishou
|
||||
docker compose -f docker-compose.kuaishou.yml up -d --build
|
||||
```
|
||||
|
||||
生产入口:
|
||||
|
||||
- 前端与领取页:`https://你的域名/`
|
||||
@@ -177,78 +138,21 @@ docker compose -f docker-compose.kuaishou.yml up -d --build
|
||||
生产 Compose 会自动:
|
||||
|
||||
- 启动 PostgreSQL
|
||||
- 启动 OCR worker
|
||||
- 启动后端并执行 migration
|
||||
- 初始化默认后台管理员
|
||||
- 同步履约目录
|
||||
- 通过 Caddy 暴露前端和 API
|
||||
|
||||
查看生产日志:
|
||||
|
||||
```bash
|
||||
docker compose logs -f backend
|
||||
docker compose logs -f web
|
||||
```
|
||||
|
||||
## 开发数据脚本
|
||||
|
||||
后端提供开发数据脚本:
|
||||
|
||||
```bash
|
||||
docker compose -f docker-compose.dev.yml exec -T backend npm run seed:dev-data
|
||||
docker compose -f docker-compose.dev.yml exec -T backend npm run cleanup:dev-data
|
||||
```
|
||||
|
||||
脚本默认有预览/确认语义,执行前注意终端输出说明。
|
||||
|
||||
## 主要业务模块
|
||||
|
||||
- `apps/backend/src/routes`:API 路由
|
||||
- `apps/backend/src/services/order`:订单、webhook、库存和履约匹配
|
||||
- `apps/backend/src/services/fulfillment`:快手 Cloud 等履约编排
|
||||
- `apps/backend/src/services/session`:腾讯登录、二维码、兑换和截图
|
||||
- `apps/backend/src/services/order`:订单、库存和履约匹配
|
||||
- `apps/backend/src/services/fulfillment`:快手 Cloud 履约编排
|
||||
- `apps/backend/src/services/admin`:后台读写、权限、配置管理
|
||||
- `apps/backend/src/services/platforms`:外部平台 API 封装
|
||||
- `apps/backend/src/repositories`:数据库访问
|
||||
- `apps/frontend/src/views/admin`:后台页面
|
||||
- `apps/frontend/src/views/claim`:用户领取页
|
||||
|
||||
## 排障
|
||||
|
||||
如果页面打不开:
|
||||
|
||||
```bash
|
||||
docker compose -f docker-compose.dev.yml ps
|
||||
docker compose -f docker-compose.dev.yml logs -f web
|
||||
```
|
||||
|
||||
如果后端未 ready:
|
||||
|
||||
```bash
|
||||
docker compose -f docker-compose.dev.yml logs -f backend
|
||||
curl http://localhost/health/ready
|
||||
```
|
||||
|
||||
如果浏览器兑换或扫码异常:
|
||||
|
||||
```bash
|
||||
docker compose -f docker-compose.dev.yml logs -f backend
|
||||
open http://127.0.0.1:6080/vnc.html
|
||||
```
|
||||
|
||||
如果前端类型或 lint 失败:
|
||||
|
||||
```bash
|
||||
docker compose -f docker-compose.dev.yml exec -T frontend npm run typecheck
|
||||
docker compose -f docker-compose.dev.yml exec -T frontend npm run lint
|
||||
```
|
||||
|
||||
如果后端单测失败:
|
||||
|
||||
```bash
|
||||
docker compose -f docker-compose.dev.yml exec -T backend npm test
|
||||
```
|
||||
|
||||
## 提交前检查清单
|
||||
|
||||
```bash
|
||||
@@ -264,4 +168,3 @@ docker compose -f docker-compose.dev.yml exec -T frontend npm run lint
|
||||
- `.env` 未被提交
|
||||
- 数据库 migration 与代码一起提交
|
||||
- 领取链接相关改动已在 Docker 环境验证
|
||||
- 涉及 Playwright/OCR 的改动已看过 noVNC 或相关日志
|
||||
|
||||
+35
-102
@@ -1,13 +1,8 @@
|
||||
# order-site-backend
|
||||
|
||||
腾讯活动浏览器会话后端。
|
||||
快手轻量后端服务。
|
||||
|
||||
当前主线只有一条:
|
||||
|
||||
- 后端托管 Playwright 浏览器
|
||||
- 前端展示二维码并轮询会话状态
|
||||
- 登录完成后,后端直接在活动页填写 CDK、识别验证码、执行兑换
|
||||
- 兑换完成后返回状态,并按配置决定是否生成截图/证明产物
|
||||
后端负责订单入库、履约任务编排、快手 Cloud / 91 卡券 / 快手小店核销配置、后台管理接口和数据库迁移。默认入口就是轻量后端,不再包含浏览器会话、OCR 或旧咸鱼自动发货链路。
|
||||
|
||||
## 快速启动
|
||||
|
||||
@@ -20,21 +15,18 @@ npm install
|
||||
复制并填写环境变量后启动:
|
||||
|
||||
```bash
|
||||
cp /Users/yml/codes/order-site-workspace/.env.mac-docker.example /Users/yml/codes/order-site-workspace/.env
|
||||
cd /Users/yml/codes/order-site-workspace/apps/backend
|
||||
cp ../../.env.mac-docker.example ../../.env
|
||||
npm run dev
|
||||
```
|
||||
|
||||
Linux 部署:
|
||||
生产构建:
|
||||
|
||||
```bash
|
||||
npm install
|
||||
npm run build
|
||||
npm run browser:install:linux
|
||||
npm run start
|
||||
```
|
||||
|
||||
后端渐进 TypeScript 迁移已经进入源码和构建双轨模式:
|
||||
常用验证:
|
||||
|
||||
```bash
|
||||
npm run typecheck
|
||||
@@ -42,120 +34,61 @@ npm run build
|
||||
npm test
|
||||
```
|
||||
|
||||
当前约定:
|
||||
|
||||
- 开发:`npm run dev` 使用 `tsx watch` 直接运行 `src`
|
||||
- 数据库迁移:`npm run db:migrate` 使用 `tsx`
|
||||
- 生产:`npm run build` 输出 `dist`,`npm run start` 运行 `dist/index.js`
|
||||
- 新增或迁移后端模块优先使用 `.ts`
|
||||
|
||||
## 配置
|
||||
|
||||
当前后端采用 `.env` 单一路径做环境配置:
|
||||
后端采用工作区根目录 `.env` 做环境配置:
|
||||
|
||||
- 通用默认值:`src/config/defaults.ts`
|
||||
- 本地/部署覆盖:工作区根目录 `.env`
|
||||
|
||||
当前最常改的配置有:
|
||||
常用配置:
|
||||
|
||||
- 服务端口
|
||||
- 服务端口 `PORT`
|
||||
- 日志级别 `LOG_LEVEL=debug|info|warn|error`
|
||||
- 浏览器是否无头、是否预热、是否常驻、slowMo
|
||||
- OCR 外部服务镜像 `OCR_IMAGE` 与访问地址 `OCR_BASE_URL`
|
||||
- 会话调试开关
|
||||
- 兑换证明模式 `full | basic | off`
|
||||
- Agiso 全局配置与后台店铺文件配置
|
||||
|
||||
### Agiso 多店铺
|
||||
|
||||
当前支持同一个 Agiso 应用下配置多个店铺的消息 token。
|
||||
|
||||
- 全局兜底配置仍走 `.env`:
|
||||
- `AGISO_APP_SECRET`
|
||||
- `AGISO_MESSAGING_ENABLED`
|
||||
- 默认消息模板与店铺级覆盖配置改为文件:
|
||||
- `apps/backend/data/agiso-shops.json`
|
||||
- 后台维护入口:
|
||||
- `#/admin/platform-shops`
|
||||
|
||||
消息发送时会按 webhook 识别出的 `shop_id` 读取 `agiso-shops.json` 中的店铺配置;模板未命中时,会先回退到文件里的 `defaults`,再回退到系统内置默认值。店铺的 `accessToken` 也需要在文件中维护,不再使用全局 `.env` 兜底。
|
||||
- 数据库连接 `DATABASE_URL`
|
||||
- 后台登录密钥 `ADMIN_SESSION_SECRET`
|
||||
- 默认后台用户 `ADMIN_DEFAULT_USERS_JSON`
|
||||
- 91 卡券与快手相关平台配置
|
||||
|
||||
## 接口
|
||||
|
||||
- `POST /api/v1/tencent/browser/session`
|
||||
创建浏览器会话,返回 `sessionId`、二维码和初始状态
|
||||
- `GET /api/v1/tencent/browser/session/:sessionId`
|
||||
返回完整会话状态
|
||||
- `GET /api/v1/tencent/browser/session/:sessionId/summary`
|
||||
轮询用轻量摘要接口,默认不返回二维码 base64
|
||||
- `POST /api/v1/tencent/browser/session/:sessionId/refresh`
|
||||
强制刷新后端活动页
|
||||
- `POST /api/v1/tencent/browser/session/:sessionId/redeem`
|
||||
执行兑换
|
||||
- `GET /api/v1/tencent/browser/session/:sessionId/screenshot`
|
||||
读取最近一次兑换截图
|
||||
- `DELETE /api/v1/tencent/browser/session/:sessionId`
|
||||
关闭浏览器会话
|
||||
- `GET /health`:健康检查
|
||||
- `GET /health/live`:存活检查
|
||||
- `GET /health/ready`:就绪检查
|
||||
- `/api/v1/open/91/*`:91 卡券开放回调
|
||||
- `/api/v1/claim/*`:领取页接口
|
||||
- `/api/v1/admin/*`:后台管理接口
|
||||
|
||||
## 目录
|
||||
|
||||
- `src/index.ts`
|
||||
Express 入口
|
||||
- `src/routes/tencent.ts`
|
||||
接口路由
|
||||
- `src/services/session/session.ts`
|
||||
会话编排与浏览器生命周期
|
||||
- `src/services/session/*.ts`
|
||||
登录、兑换、OCR、凭证产物等模块
|
||||
- `src/index.ts`:服务入口
|
||||
- `src/app.ts`:Express 应用组装
|
||||
- `src/routes`:API 路由
|
||||
- `src/services/order`:订单、库存和履约匹配
|
||||
- `src/services/fulfillment`:快手 Cloud 履约编排
|
||||
- `src/services/admin`:后台读写、权限、配置管理
|
||||
- `src/repositories`:数据库访问
|
||||
- `src/db/migrations`:数据库迁移
|
||||
|
||||
## Docker 开发
|
||||
|
||||
当前 `docker-compose.dev.yml` 已切到源码挂载模式:
|
||||
开发版 Compose 会把源码挂载进容器:
|
||||
|
||||
- `postgres` 容器提供开发库,后端通过 `DATABASE_URL` 连接
|
||||
- `ocr-worker` 使用外部镜像启动,后端通过 `OCR_BASE_URL=http://ocr-worker:8100` 调用
|
||||
- 后端容器启动时执行 `npm install` 和 `npm run dev`
|
||||
- 平时改 `src/` 或 `.env`,一般都不需要重建镜像
|
||||
- 生产镜像会在 build 阶段编译后端并只复制 `dist`、`config` 和运行依赖
|
||||
- 后端容器启动时执行依赖安装和 `npm run dev`
|
||||
- 平时改 `src/` 或 `.env`,一般不需要重建镜像
|
||||
|
||||
只有下面几类改动通常还需要 `docker compose -f docker-compose.dev.yml up -d --build backend`:
|
||||
只有下面几类改动通常还需要重新构建 backend:
|
||||
|
||||
- `deploy/docker/backend.Dockerfile`
|
||||
- 系统层依赖
|
||||
- Playwright 浏览器基础环境
|
||||
- `deploy/docker/backend*.Dockerfile`
|
||||
- Node 版本或系统层依赖
|
||||
- `package.json` / `package-lock.json`
|
||||
|
||||
## 产物
|
||||
|
||||
浏览器会话产物默认保存在:
|
||||
|
||||
- `data/browser-sessions/<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/webhook-YYYY-MM-DD.log`
|
||||
|
||||
日志按天切分,默认自动清理 7 天前的旧日志。
|
||||
成功的 `/health`、`/health/live`、`/health/ready` 探活请求默认不写 access log。
|
||||
|
||||
`LOG_LEVEL` 默认是 `info`:
|
||||
|
||||
- `debug`:输出最详细的调试日志
|
||||
- `info`:输出启动、请求、业务成功/失败等常规日志
|
||||
- `warn`:只保留告警和错误
|
||||
- `error`:只保留错误
|
||||
|
||||
OCR 识别通过 `OCR_BASE_URL` 调用 HTTP 服务。Docker Compose 会启动 `ocr-worker` 外部镜像并注入默认地址 `http://ocr-worker:8100`;如果单独运行后端且未配置该地址,启动健康检查会标记 OCR 为 degraded,兑换中需要 OCR 的流程会在调用时返回明确错误。
|
||||
|
||||
`TENCENT_REDEEM_PROOF_MODE` 对应的配置文件项会影响生成强度:
|
||||
|
||||
- `full`:完整证明
|
||||
- `basic`:只保留最终截图和结果 JSON
|
||||
- `off`:不生成证明文件
|
||||
日志按天切分,默认自动清理 7 天前的旧日志。成功的 `/health`、`/health/live`、`/health/ready` 探活请求默认不写 access log。
|
||||
|
||||
Generated
+1
-46
@@ -9,8 +9,7 @@
|
||||
"version": "0.1.0",
|
||||
"dependencies": {
|
||||
"express": "^5.1.0",
|
||||
"pg": "^8.16.3",
|
||||
"playwright": "1.42.1"
|
||||
"pg": "^8.16.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/express": "^5.0.6",
|
||||
@@ -911,20 +910,6 @@
|
||||
"node": ">= 0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/fsevents": {
|
||||
"version": "2.3.2",
|
||||
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz",
|
||||
"integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==",
|
||||
"hasInstallScript": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
],
|
||||
"engines": {
|
||||
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/function-bind": {
|
||||
"version": "1.1.2",
|
||||
"resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz",
|
||||
@@ -1275,36 +1260,6 @@
|
||||
"split2": "^4.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/playwright": {
|
||||
"version": "1.42.1",
|
||||
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.42.1.tgz",
|
||||
"integrity": "sha512-PgwB03s2DZBcNRoW+1w9E+VkLBxweib6KTXM0M3tkiT4jVxKSi6PmVJ591J+0u10LUrgxB7dLRbiJqO5s2QPMg==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"playwright-core": "1.42.1"
|
||||
},
|
||||
"bin": {
|
||||
"playwright": "cli.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=16"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"fsevents": "2.3.2"
|
||||
}
|
||||
},
|
||||
"node_modules/playwright-core": {
|
||||
"version": "1.42.1",
|
||||
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.42.1.tgz",
|
||||
"integrity": "sha512-mxz6zclokgrke9p1vtdy/COWBH+eOZgYUVVU34C73M+4j4HLlQJHtfcqiqqxpP0o8HhMkflvfbquLX5dg6wlfA==",
|
||||
"license": "Apache-2.0",
|
||||
"bin": {
|
||||
"playwright-core": "cli.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=16"
|
||||
}
|
||||
},
|
||||
"node_modules/postgres-array": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/postgres-array/-/postgres-array-2.0.0.tgz",
|
||||
|
||||
@@ -4,27 +4,18 @@
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"browser:install": "playwright install chromium",
|
||||
"browser:install:linux": "playwright install --with-deps chromium",
|
||||
"agiso:auto-delivery:test": "tsx scripts/test-agiso-auto-delivery.ts",
|
||||
"cleanup:dev-data": "tsx scripts/cleanup-dev-data.ts",
|
||||
"seed:dev-data": "tsx scripts/seed-dev-data.ts",
|
||||
"build": "tsc -p tsconfig.build.json && rm -rf dist/db/migrations && mkdir -p dist/db && cp -R src/db/migrations dist/db/migrations",
|
||||
"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",
|
||||
"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)",
|
||||
"typecheck": "tsc -p tsconfig.json --noEmit",
|
||||
"typecheck:kuaishou": "tsc -p tsconfig.kuaishou.json --noEmit",
|
||||
"start": "node dist/index.js",
|
||||
"start:kuaishou": "node dist/index-kuaishou.js",
|
||||
"start:src": "tsx src/index.ts"
|
||||
},
|
||||
"dependencies": {
|
||||
"express": "^5.1.0",
|
||||
"pg": "^8.16.3",
|
||||
"playwright": "1.42.1"
|
||||
"pg": "^8.16.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@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
|
||||
set -eu
|
||||
|
||||
is_true() {
|
||||
normalized=$(printf '%s' "${1:-}" | tr '[:upper:]' '[:lower:]')
|
||||
case "$normalized" in
|
||||
1|true|yes|on)
|
||||
return 0
|
||||
;;
|
||||
*)
|
||||
return 1
|
||||
;;
|
||||
esac
|
||||
}
|
||||
|
||||
start_browser_debug_services() {
|
||||
display="${TENCENT_BROWSER_DISPLAY:-:99}"
|
||||
resolution="${TENCENT_BROWSER_VNC_RESOLUTION:-1600x900x24}"
|
||||
display_number="${display#:}"
|
||||
display_number="${display_number%%.*}"
|
||||
display_socket="/tmp/.X11-unix/X${display_number}"
|
||||
display_lock="/tmp/.X${display_number}-lock"
|
||||
|
||||
if [ -S "$display_socket" ] && [ ! -f "$display_lock" ]; then
|
||||
rm -f "$display_socket"
|
||||
fi
|
||||
|
||||
if [ -f "$display_lock" ]; then
|
||||
display_pid=$(cat "$display_lock" 2>/dev/null || true)
|
||||
if [ -z "$display_pid" ] || ! kill -0 "$display_pid" 2>/dev/null; then
|
||||
rm -f "$display_lock" "$display_socket"
|
||||
fi
|
||||
fi
|
||||
|
||||
export DISPLAY="$display"
|
||||
Xvfb "$display" -screen 0 "$resolution" -nolisten tcp >/tmp/xvfb.log 2>&1 &
|
||||
xvfb_pid=$!
|
||||
|
||||
ready=0
|
||||
attempt=0
|
||||
while [ "$attempt" -lt 50 ]; do
|
||||
if [ -S "$display_socket" ]; then
|
||||
ready=1
|
||||
break
|
||||
fi
|
||||
|
||||
if ! kill -0 "$xvfb_pid" 2>/dev/null; then
|
||||
break
|
||||
fi
|
||||
|
||||
attempt=$((attempt + 1))
|
||||
sleep 0.1
|
||||
done
|
||||
|
||||
if [ "$ready" -ne 1 ]; then
|
||||
echo "[backend-dev] Xvfb display ${DISPLAY} not ready for x11vnc; skip noVNC startup" >&2
|
||||
return
|
||||
fi
|
||||
|
||||
if ! is_true "${TENCENT_BROWSER_NOVNC_ENABLED:-true}"; then
|
||||
echo "[backend-dev] headed browser enabled on ${DISPLAY}; noVNC disabled"
|
||||
return
|
||||
fi
|
||||
|
||||
vnc_port="${TENCENT_BROWSER_VNC_PORT:-5900}"
|
||||
novnc_port="${TENCENT_BROWSER_NOVNC_PORT:-6080}"
|
||||
|
||||
x11vnc \
|
||||
-display "$display" \
|
||||
-rfbport "$vnc_port" \
|
||||
-forever \
|
||||
-shared \
|
||||
-nopw \
|
||||
-listen 0.0.0.0 \
|
||||
-xkb >/tmp/x11vnc.log 2>&1 &
|
||||
|
||||
if command -v novnc_proxy >/dev/null 2>&1; then
|
||||
novnc_proxy --listen "$novnc_port" --vnc "127.0.0.1:${vnc_port}" >/tmp/novnc.log 2>&1 &
|
||||
elif [ -x /usr/share/novnc/utils/novnc_proxy ]; then
|
||||
/usr/share/novnc/utils/novnc_proxy --listen "$novnc_port" --vnc "127.0.0.1:${vnc_port}" >/tmp/novnc.log 2>&1 &
|
||||
elif command -v websockify >/dev/null 2>&1 && [ -d /usr/share/novnc ]; then
|
||||
websockify --web=/usr/share/novnc "$novnc_port" "127.0.0.1:${vnc_port}" >/tmp/novnc.log 2>&1 &
|
||||
else
|
||||
echo "[backend-dev] noVNC requested but novnc/websockify not found"
|
||||
return
|
||||
fi
|
||||
|
||||
echo "[backend-dev] headed browser enabled on ${DISPLAY}; noVNC: http://127.0.0.1:${novnc_port}/vnc.html"
|
||||
}
|
||||
|
||||
npm install --no-fund --no-audit
|
||||
|
||||
if ! is_true "${TENCENT_BROWSER_HEADLESS:-true}"; then
|
||||
start_browser_debug_services
|
||||
fi
|
||||
|
||||
exec npm run dev
|
||||
|
||||
@@ -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 process from "node:process";
|
||||
|
||||
import adminRouter from "./routes/admin.js";
|
||||
import claimsRouter from "./routes/claims.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 { createCorsMiddleware } from "./middleware/cors.js";
|
||||
import type { RuntimeConfig } from "./types/runtime-config.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 = {
|
||||
startupState: StartupState;
|
||||
isShutdownStarted: () => boolean;
|
||||
@@ -44,6 +43,7 @@ export function createApp({
|
||||
buildSuccessPayload({
|
||||
status: isShutdownStarted() ? "shutting_down" : "alive",
|
||||
pid: process.pid,
|
||||
mode: "kuaishou-lite",
|
||||
})
|
||||
);
|
||||
});
|
||||
@@ -89,9 +89,7 @@ export function createApp({
|
||||
});
|
||||
});
|
||||
|
||||
app.use("/api/v1/tencent", tencentRouter);
|
||||
app.use("/api/v1/open/91", open91Router);
|
||||
app.use("/api/v1/webhooks", webhooksRouter);
|
||||
app.use("/api/v1/claim", claimsRouter);
|
||||
app.use("/api/v1/admin", adminRouter);
|
||||
|
||||
|
||||
@@ -8,24 +8,6 @@ export function createDefaultRuntimeConfig(projectRoot: string): RuntimeConfig {
|
||||
port: 3000,
|
||||
},
|
||||
|
||||
browser: {
|
||||
chromePath: "",
|
||||
headless: null,
|
||||
devtools: null,
|
||||
keepAlive: null,
|
||||
prewarm: null,
|
||||
slowMoMs: null,
|
||||
vncResolution: "1600x900x24",
|
||||
},
|
||||
|
||||
session: {
|
||||
debug: false,
|
||||
},
|
||||
|
||||
ocr: {
|
||||
baseUrl: "",
|
||||
},
|
||||
|
||||
data: {
|
||||
root: path.resolve(projectRoot, "data"),
|
||||
},
|
||||
@@ -52,30 +34,6 @@ export function createDefaultRuntimeConfig(projectRoot: string): RuntimeConfig {
|
||||
},
|
||||
|
||||
platforms: {
|
||||
agiso: {
|
||||
appSecret: "",
|
||||
tradeDetail: {
|
||||
endpoint: "https://gw-api.agiso.com/aldsIdle/Order/Detail",
|
||||
apiVersion: "1",
|
||||
timeoutMs: 5000,
|
||||
},
|
||||
autoDelivery: {
|
||||
enabled: true,
|
||||
endpoint: "https://gw-api.agiso.com/aldsIdle/Order/DummySend",
|
||||
apiVersion: "1",
|
||||
},
|
||||
messaging: {
|
||||
enabled: false,
|
||||
sendMessageEndpoint:
|
||||
"https://gw-api.agiso.com/aldsIdle/ImMsg/SendMsg",
|
||||
apiVersion: "1",
|
||||
messageTemplate:
|
||||
"您的订单 {platformOrderId} 已创建领取链接,请在 {expiredAt} 前完成领取:{claimUrl}",
|
||||
autoDeliveryMessageTemplate:
|
||||
"您的订单 {platformOrderId} 已完成自动发货,请注意查收。",
|
||||
shops: {},
|
||||
},
|
||||
},
|
||||
ninetyone: {
|
||||
userId: "",
|
||||
secret: "",
|
||||
@@ -137,9 +95,5 @@ PfQTPNA++KsXtwRX9M6Re3vkTDRsutIIWKtj8jqhUbbYS3vzS8GJnAWavUFVkR15
|
||||
cors: {
|
||||
allowedOrigins: ["*"],
|
||||
},
|
||||
|
||||
redeem: {
|
||||
proofMode: "full",
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -21,17 +21,13 @@ const DEPLOYMENT_ONLY_ENV_NAMES = [
|
||||
'CADDY_SITE_ADDR',
|
||||
'CHOKIDAR_USEPOLLING',
|
||||
'NPM_CONFIG_REGISTRY',
|
||||
'OCR_IMAGE',
|
||||
'OCR_PORT',
|
||||
'POSTGRES_DB',
|
||||
'POSTGRES_PASSWORD',
|
||||
'POSTGRES_PORT',
|
||||
'POSTGRES_USER',
|
||||
'TENCENT_BROWSER_NOVNC_ENABLED',
|
||||
'TENCENT_BROWSER_NOVNC_PORT',
|
||||
'TENCENT_BROWSER_VNC_PORT',
|
||||
'TZ',
|
||||
'VITE_API_TARGET',
|
||||
'VITE_ALLOWED_HOSTS',
|
||||
]
|
||||
|
||||
const KNOWN_ENV_NAMES = new Set([
|
||||
|
||||
@@ -10,32 +10,21 @@ test('applyEnvOverrides maps typed environment values without mutating base conf
|
||||
|
||||
const config = applyEnvOverrides(baseConfig, {
|
||||
PORT: '4000',
|
||||
TENCENT_BROWSER_HEADLESS: 'true',
|
||||
TENCENT_BROWSER_SLOW_MO: '250',
|
||||
TENCENT_BROWSER_VNC_RESOLUTION: '1920x1080x24',
|
||||
DATA_ROOT: './tmp/backend-data',
|
||||
DATABASE_SSL: 'yes',
|
||||
ADMIN_DEFAULT_USERS_JSON: '[{"username":"admin","password":"secret","role":"admin"}]',
|
||||
AGISO_TRADE_DETAIL_TIMEOUT_MS: '7000',
|
||||
CLOUDTENTACLES_DEVICE_TYPE: '3',
|
||||
TENCENT_REDEEM_PROOF_MODE: 'basic',
|
||||
})
|
||||
|
||||
assert.equal(config.server.port, 4000)
|
||||
assert.equal(config.browser.headless, true)
|
||||
assert.equal(config.browser.slowMoMs, 250)
|
||||
assert.equal(config.browser.vncResolution, '1920x1080x24')
|
||||
assert.equal(config.data.root, path.resolve('./tmp/backend-data'))
|
||||
assert.equal(config.database.ssl, true)
|
||||
assert.deepEqual(config.admin.defaultUsers, [
|
||||
{ username: 'admin', password: 'secret', role: 'admin' },
|
||||
])
|
||||
assert.equal(config.platforms.agiso.tradeDetail.timeoutMs, 7000)
|
||||
assert.equal(config.platforms.cloudtentacles.deviceType, 3)
|
||||
assert.equal(config.redeem.proofMode, 'basic')
|
||||
|
||||
assert.equal(baseConfig.server.port, 3000)
|
||||
assert.equal(baseConfig.browser.headless, null)
|
||||
assert.deepEqual(baseConfig.admin.defaultUsers, [])
|
||||
})
|
||||
|
||||
@@ -80,21 +69,6 @@ function createRuntimeConfig(): RuntimeConfig {
|
||||
server: {
|
||||
port: 3000,
|
||||
},
|
||||
browser: {
|
||||
chromePath: '',
|
||||
headless: null,
|
||||
devtools: null,
|
||||
keepAlive: null,
|
||||
prewarm: null,
|
||||
slowMoMs: null,
|
||||
vncResolution: '1600x900x24',
|
||||
},
|
||||
session: {
|
||||
debug: false,
|
||||
},
|
||||
ocr: {
|
||||
baseUrl: '',
|
||||
},
|
||||
data: {
|
||||
root: '/tmp/order-site',
|
||||
},
|
||||
@@ -116,27 +90,6 @@ function createRuntimeConfig(): RuntimeConfig {
|
||||
defaultUsers: [],
|
||||
},
|
||||
platforms: {
|
||||
agiso: {
|
||||
appSecret: '',
|
||||
tradeDetail: {
|
||||
endpoint: 'https://gw-api.agiso.com/aldsIdle/Order/Detail',
|
||||
apiVersion: '1',
|
||||
timeoutMs: 5000,
|
||||
},
|
||||
autoDelivery: {
|
||||
enabled: true,
|
||||
endpoint: 'https://gw-api.agiso.com/aldsIdle/Order/DummySend',
|
||||
apiVersion: '1',
|
||||
},
|
||||
messaging: {
|
||||
enabled: false,
|
||||
sendMessageEndpoint: 'https://gw-api.agiso.com/aldsIdle/ImMsg/SendMsg',
|
||||
apiVersion: '1',
|
||||
messageTemplate: '',
|
||||
autoDeliveryMessageTemplate: '',
|
||||
shops: {},
|
||||
},
|
||||
},
|
||||
ninetyone: {
|
||||
userId: '',
|
||||
secret: '',
|
||||
@@ -184,8 +137,5 @@ function createRuntimeConfig(): RuntimeConfig {
|
||||
deviceType: 0,
|
||||
},
|
||||
},
|
||||
redeem: {
|
||||
proofMode: 'full',
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -30,15 +30,6 @@ type EnvOverride = {
|
||||
|
||||
export const ENV_OVERRIDES: readonly EnvOverride[] = [
|
||||
integerEnv("PORT", ["server", "port"]),
|
||||
stringEnv("CHROME_PATH", ["browser", "chromePath"]),
|
||||
booleanEnv("TENCENT_BROWSER_HEADLESS", ["browser", "headless"]),
|
||||
booleanEnv("TENCENT_BROWSER_DEVTOOLS", ["browser", "devtools"]),
|
||||
booleanEnv("TENCENT_BROWSER_KEEP_ALIVE", ["browser", "keepAlive"]),
|
||||
booleanEnv("TENCENT_BROWSER_PREWARM", ["browser", "prewarm"]),
|
||||
integerEnv("TENCENT_BROWSER_SLOW_MO", ["browser", "slowMoMs"]),
|
||||
stringEnv("TENCENT_BROWSER_VNC_RESOLUTION", ["browser", "vncResolution"]),
|
||||
booleanEnv("TENCENT_SESSION_DEBUG", ["session", "debug"]),
|
||||
stringEnv("OCR_BASE_URL", ["ocr", "baseUrl"]),
|
||||
stringEnv("DATA_ROOT", ["data", "root"], path.resolve),
|
||||
stringEnv("LOG_LEVEL", ["logging", "level"]),
|
||||
stringEnv("DATABASE_URL", ["database", "url"]),
|
||||
@@ -48,61 +39,6 @@ export const ENV_OVERRIDES: readonly EnvOverride[] = [
|
||||
stringEnv("ADMIN_SESSION_SECRET", ["admin", "sessionSecret"]),
|
||||
integerEnv("ADMIN_SESSION_TTL_HOURS", ["admin", "sessionTtlHours"]),
|
||||
adminUsersJsonEnv("ADMIN_DEFAULT_USERS_JSON", ["admin", "defaultUsers"]),
|
||||
stringEnv("AGISO_APP_SECRET", ["platforms", "agiso", "appSecret"]),
|
||||
stringEnv("AGISO_TRADE_DETAIL_ENDPOINT", [
|
||||
"platforms",
|
||||
"agiso",
|
||||
"tradeDetail",
|
||||
"endpoint",
|
||||
]),
|
||||
stringEnv("AGISO_TRADE_DETAIL_API_VERSION", [
|
||||
"platforms",
|
||||
"agiso",
|
||||
"tradeDetail",
|
||||
"apiVersion",
|
||||
]),
|
||||
integerEnv("AGISO_TRADE_DETAIL_TIMEOUT_MS", [
|
||||
"platforms",
|
||||
"agiso",
|
||||
"tradeDetail",
|
||||
"timeoutMs",
|
||||
]),
|
||||
booleanEnv("AGISO_AUTO_DELIVERY_ENABLED", [
|
||||
"platforms",
|
||||
"agiso",
|
||||
"autoDelivery",
|
||||
"enabled",
|
||||
]),
|
||||
stringEnv("AGISO_AUTO_DELIVERY_ENDPOINT", [
|
||||
"platforms",
|
||||
"agiso",
|
||||
"autoDelivery",
|
||||
"endpoint",
|
||||
]),
|
||||
stringEnv("AGISO_AUTO_DELIVERY_API_VERSION", [
|
||||
"platforms",
|
||||
"agiso",
|
||||
"autoDelivery",
|
||||
"apiVersion",
|
||||
]),
|
||||
stringEnv("AGISO_MESSAGE_API_VERSION", [
|
||||
"platforms",
|
||||
"agiso",
|
||||
"messaging",
|
||||
"apiVersion",
|
||||
]),
|
||||
stringEnv("AGISO_SEND_MESSAGE_ENDPOINT", [
|
||||
"platforms",
|
||||
"agiso",
|
||||
"messaging",
|
||||
"sendMessageEndpoint",
|
||||
]),
|
||||
booleanEnv("AGISO_MESSAGING_ENABLED", [
|
||||
"platforms",
|
||||
"agiso",
|
||||
"messaging",
|
||||
"enabled",
|
||||
]),
|
||||
stringEnv("NINETYONE_USER_ID", ["platforms", "ninetyone", "userId"]),
|
||||
stringEnv("NINETYONE_SECRET", ["platforms", "ninetyone", "secret"]),
|
||||
stringEnv("NINETYONE_VERSION", ["platforms", "ninetyone", "version"]),
|
||||
@@ -288,7 +224,6 @@ export const ENV_OVERRIDES: readonly EnvOverride[] = [
|
||||
"cloudtentacles",
|
||||
"deviceType",
|
||||
]),
|
||||
stringEnv("TENCENT_REDEEM_PROOF_MODE", ["redeem", "proofMode"]),
|
||||
corsOriginsEnv("CORS_ALLOWED_ORIGINS", ["cors", "allowedOrigins"]),
|
||||
];
|
||||
|
||||
|
||||
@@ -112,21 +112,6 @@ function createRuntimeConfig(overrides: Record<string, unknown> = {}): RuntimeCo
|
||||
server: {
|
||||
port: 3000,
|
||||
},
|
||||
browser: {
|
||||
chromePath: '',
|
||||
headless: null,
|
||||
devtools: null,
|
||||
keepAlive: null,
|
||||
prewarm: null,
|
||||
slowMoMs: null,
|
||||
vncResolution: '1600x900x24',
|
||||
},
|
||||
session: {
|
||||
debug: false,
|
||||
},
|
||||
ocr: {
|
||||
baseUrl: '',
|
||||
},
|
||||
data: {
|
||||
root: '/tmp/order-site',
|
||||
},
|
||||
@@ -154,27 +139,6 @@ function createRuntimeConfig(overrides: Record<string, unknown> = {}): RuntimeCo
|
||||
],
|
||||
},
|
||||
platforms: {
|
||||
agiso: {
|
||||
appSecret: '',
|
||||
tradeDetail: {
|
||||
endpoint: 'https://gw-api.agiso.com/aldsIdle/Order/Detail',
|
||||
apiVersion: '1',
|
||||
timeoutMs: 5000,
|
||||
},
|
||||
autoDelivery: {
|
||||
enabled: true,
|
||||
endpoint: 'https://gw-api.agiso.com/aldsIdle/Order/DummySend',
|
||||
apiVersion: '1',
|
||||
},
|
||||
messaging: {
|
||||
enabled: false,
|
||||
sendMessageEndpoint: 'https://gw-api.agiso.com/aldsIdle/ImMsg/SendMsg',
|
||||
apiVersion: '1',
|
||||
messageTemplate: '',
|
||||
autoDeliveryMessageTemplate: '',
|
||||
shops: {},
|
||||
},
|
||||
},
|
||||
ninetyone: {
|
||||
userId: '',
|
||||
secret: '',
|
||||
@@ -222,8 +186,5 @@ function createRuntimeConfig(overrides: Record<string, unknown> = {}): RuntimeCo
|
||||
deviceType: 0,
|
||||
},
|
||||
},
|
||||
redeem: {
|
||||
proofMode: 'full',
|
||||
},
|
||||
}, overrides) as RuntimeConfig
|
||||
}
|
||||
|
||||
@@ -47,7 +47,6 @@ export function validateRuntimeConfig(
|
||||
requireInteger(issues, 'database.maxConnections', config.database?.maxConnections, { min: 1 })
|
||||
requireInteger(issues, 'orders.tokenTtlHours', config.orders?.tokenTtlHours, { min: 1 })
|
||||
requireInteger(issues, 'admin.sessionTtlHours', config.admin?.sessionTtlHours, { min: 1 })
|
||||
requireInteger(issues, 'platforms.agiso.tradeDetail.timeoutMs', config.platforms?.agiso?.tradeDetail?.timeoutMs, { min: 1 })
|
||||
requireInteger(issues, 'platforms.cloudtentacles.timeoutMs', config.platforms?.cloudtentacles?.timeoutMs, { min: 1 })
|
||||
requireInteger(issues, 'platforms.cloudtentacles.bindUrlTtlSeconds', config.platforms?.cloudtentacles?.bindUrlTtlSeconds, { min: 1 })
|
||||
requireInteger(
|
||||
|
||||
@@ -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);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const app = createApp({
|
||||
startupState,
|
||||
isShutdownStarted: () => shutdownController?.isShutdownStarted() || false,
|
||||
@@ -27,7 +28,10 @@ const app = createApp({
|
||||
});
|
||||
|
||||
const server = app.listen(port, host, () => {
|
||||
logInfo("[startup]", `order-site-backend listening on http://${host}:${port}`);
|
||||
logInfo(
|
||||
"[startup]",
|
||||
`order-site-backend kuaishou-lite listening on http://${host}:${port}`
|
||||
);
|
||||
void bootstrapCoreServices(
|
||||
startupState,
|
||||
() => shutdownController?.isShutdownStarted() || false
|
||||
|
||||
@@ -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 auditLogsRouter from './admin/audit-logs.js'
|
||||
import dashboardRouter from './admin/dashboard.js'
|
||||
import inventoryRouter from './admin/inventory.js'
|
||||
import manualRedeemRouter from './admin/manual-redeem.js'
|
||||
import messageDeliveriesRouter from './admin/message-deliveries.js'
|
||||
import ordersRouter from './admin/orders.js'
|
||||
import platformConfigRouter from './admin/platform-config.js'
|
||||
import { requireAdminSession } from './admin/shared.js'
|
||||
import tasksRouter from './admin/tasks.js'
|
||||
import usersRouter from './admin/users.js'
|
||||
import webhookEventsRouter from './admin/webhook-events.js'
|
||||
import { buildNotFoundPayload } from '../utils/http.js'
|
||||
import authRouter from "./admin/auth.js";
|
||||
import auditLogsRouter from "./admin/audit-logs.js";
|
||||
import dashboardRouter from "./admin/dashboard.js";
|
||||
import inventoryRouter from "./admin/inventory.js";
|
||||
import messageDeliveriesRouter from "./admin/message-deliveries.js";
|
||||
import ordersRouter from "./admin/orders.js";
|
||||
import platformConfigRouter from "./admin/platform-config.js";
|
||||
import { requireAdminSession } from "./admin/shared.js";
|
||||
import tasksRouter from "./admin/tasks.js";
|
||||
import usersRouter from "./admin/users.js";
|
||||
import webhookEventsRouter from "./admin/webhook-events.js";
|
||||
import { buildNotFoundPayload } from "../utils/http.js";
|
||||
|
||||
const router = Router()
|
||||
const router = Router();
|
||||
|
||||
router.use(authRouter)
|
||||
router.use(requireAdminSession)
|
||||
router.use(dashboardRouter)
|
||||
router.use(usersRouter)
|
||||
router.use(auditLogsRouter)
|
||||
router.use(platformConfigRouter)
|
||||
router.use(ordersRouter)
|
||||
router.use(tasksRouter)
|
||||
router.use(manualRedeemRouter)
|
||||
router.use(inventoryRouter)
|
||||
router.use(messageDeliveriesRouter)
|
||||
router.use(webhookEventsRouter)
|
||||
router.use(authRouter);
|
||||
router.use(requireAdminSession);
|
||||
router.use(dashboardRouter);
|
||||
router.use(usersRouter);
|
||||
router.use(auditLogsRouter);
|
||||
router.use(platformConfigRouter);
|
||||
router.use(ordersRouter);
|
||||
router.use(tasksRouter);
|
||||
router.use(inventoryRouter);
|
||||
router.use(messageDeliveriesRouter);
|
||||
router.use(webhookEventsRouter);
|
||||
|
||||
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 {
|
||||
createAdminInventoryItem,
|
||||
importAdminInventoryItems,
|
||||
invalidateAdminInventoryItem,
|
||||
releaseAdminInventoryItem,
|
||||
} from '../../services/admin/admin-write-service.js'
|
||||
} from "../../services/admin/write/inventory.js";
|
||||
import {
|
||||
getAdminInventoryItems,
|
||||
getAdminInventorySkuSuggestions,
|
||||
} from '../../services/admin/admin-read-service.js'
|
||||
import { createJsonHandler, requireAdminRoles } from './shared.js'
|
||||
} from "../../services/admin/admin-read-service.js";
|
||||
import { createJsonHandler, requireAdminRoles } from "./shared.js";
|
||||
import type {
|
||||
AdminInventoryCreateRouteBody,
|
||||
AdminInventoryImportRouteBody,
|
||||
@@ -18,100 +18,132 @@ import type {
|
||||
AdminInventoryRouteParams,
|
||||
AdminInventoryRouteQuery,
|
||||
AdminInventorySkuSuggestionRouteQuery,
|
||||
} from '../../types/admin-route-inputs.js'
|
||||
import type { AdminInventoryMutationResponse } from '../../types/admin-write-models.js'
|
||||
} from "../../types/admin-route-inputs.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(
|
||||
(req) => getAdminInventoryItems(
|
||||
req.query as AdminInventoryRouteQuery,
|
||||
req.adminSession || null,
|
||||
),
|
||||
{
|
||||
successMessage: 'ok',
|
||||
errorMessage: '读取库存列表失败',
|
||||
scope: '[admin/inventory]',
|
||||
},
|
||||
))
|
||||
router.get(
|
||||
"/inventory",
|
||||
createJsonHandler(
|
||||
(req) =>
|
||||
getAdminInventoryItems(
|
||||
req.query as AdminInventoryRouteQuery,
|
||||
req.adminSession || null
|
||||
),
|
||||
{
|
||||
successMessage: "ok",
|
||||
errorMessage: "读取库存列表失败",
|
||||
scope: "[admin/inventory]",
|
||||
}
|
||||
)
|
||||
);
|
||||
|
||||
router.get('/inventory/sku-suggestions', createJsonHandler(
|
||||
(req) => getAdminInventorySkuSuggestions(
|
||||
req.query as AdminInventorySkuSuggestionRouteQuery,
|
||||
req.adminSession || null,
|
||||
),
|
||||
{
|
||||
successMessage: 'ok',
|
||||
errorMessage: '读取内部 SKU 建议失败',
|
||||
scope: '[admin/inventory/sku-suggestions]',
|
||||
},
|
||||
))
|
||||
router.get(
|
||||
"/inventory/sku-suggestions",
|
||||
createJsonHandler(
|
||||
(req) =>
|
||||
getAdminInventorySkuSuggestions(
|
||||
req.query as AdminInventorySkuSuggestionRouteQuery,
|
||||
req.adminSession || null
|
||||
),
|
||||
{
|
||||
successMessage: "ok",
|
||||
errorMessage: "读取内部 SKU 建议失败",
|
||||
scope: "[admin/inventory/sku-suggestions]",
|
||||
}
|
||||
)
|
||||
);
|
||||
|
||||
router.post('/inventory', createJsonHandler(
|
||||
(req) => createAdminInventoryItem(req.body as AdminInventoryCreateRouteBody),
|
||||
{
|
||||
successMessage: '库存项已新增',
|
||||
errorMessage: '新增库存项失败',
|
||||
scope: '[admin/inventory]',
|
||||
},
|
||||
))
|
||||
router.post(
|
||||
"/inventory",
|
||||
createJsonHandler(
|
||||
(req) => createAdminInventoryItem(req.body as AdminInventoryCreateRouteBody),
|
||||
{
|
||||
successMessage: "库存项已新增",
|
||||
errorMessage: "新增库存项失败",
|
||||
scope: "[admin/inventory]",
|
||||
}
|
||||
)
|
||||
);
|
||||
|
||||
router.post('/inventory/import', createJsonHandler(
|
||||
(req) => importAdminInventoryItems(req.body as AdminInventoryImportRouteBody),
|
||||
{
|
||||
successMessage: '导入完成',
|
||||
errorMessage: '导入库存凭据失败',
|
||||
scope: '[admin/inventory/import]',
|
||||
},
|
||||
))
|
||||
router.post(
|
||||
"/inventory/import",
|
||||
createJsonHandler(
|
||||
(req) => importAdminInventoryItems(req.body as AdminInventoryImportRouteBody),
|
||||
{
|
||||
successMessage: "导入完成",
|
||||
errorMessage: "导入库存凭据失败",
|
||||
scope: "[admin/inventory/import]",
|
||||
}
|
||||
)
|
||||
);
|
||||
|
||||
router.post('/inventory/:inventoryItemId/release', requireAdminRoles(['admin']), createJsonHandler(
|
||||
(req) => releaseAdminInventoryItem((req.params as AdminInventoryRouteParams).inventoryItemId),
|
||||
{
|
||||
successMessage: '库存项已释放',
|
||||
errorMessage: '释放库存项失败',
|
||||
scope: '[admin/inventory/:inventoryItemId/release]',
|
||||
audit: (req, data) => {
|
||||
const result = data as AdminInventoryMutationResponse
|
||||
return {
|
||||
action: 'inventory_item_released',
|
||||
targetType: 'inventory_item',
|
||||
targetId: String((req.params as AdminInventoryRouteParams).inventoryItemId),
|
||||
data: {
|
||||
inventoryItemId: result.inventoryItem?.inventoryItemId,
|
||||
skuCode: result.inventoryItem?.skuCode,
|
||||
displayValue: result.inventoryItem?.displayValue,
|
||||
},
|
||||
}
|
||||
},
|
||||
},
|
||||
))
|
||||
router.post(
|
||||
"/inventory/:inventoryItemId/release",
|
||||
requireAdminRoles(["admin"]),
|
||||
createJsonHandler(
|
||||
(req) =>
|
||||
releaseAdminInventoryItem(
|
||||
(req.params as AdminInventoryRouteParams).inventoryItemId
|
||||
),
|
||||
{
|
||||
successMessage: "库存项已释放",
|
||||
errorMessage: "释放库存项失败",
|
||||
scope: "[admin/inventory/:inventoryItemId/release]",
|
||||
audit: (req, data) => {
|
||||
const result = data as AdminInventoryMutationResponse;
|
||||
|
||||
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,
|
||||
},
|
||||
}
|
||||
},
|
||||
},
|
||||
))
|
||||
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,
|
||||
},
|
||||
};
|
||||
},
|
||||
}
|
||||
)
|
||||
);
|
||||
|
||||
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 { requireAdminRoles } from "./shared.js";
|
||||
import agisoRouter from "./platform-config/agiso.js";
|
||||
import cloudtentaclesRouter from "./platform-config/cloudtentacles.js";
|
||||
import fulfillmentBindingsRouter from "./platform-config/fulfillment-bindings.js";
|
||||
import kuaishouCloudFulfillmentRouter from "./platform-config/kuaishou-cloud-fulfillment.js";
|
||||
import kuaishouEticketRouter from "./platform-config/kuaishou-eticket.js";
|
||||
import ninetyoneRouter from "./platform-config/ninetyone.js";
|
||||
@@ -12,12 +10,10 @@ import notificationsRouter from "./platform-config/notifications.js";
|
||||
const router = Router();
|
||||
|
||||
router.use("/platform-config", requireAdminRoles(["admin"]));
|
||||
router.use("/platform-config", agisoRouter);
|
||||
router.use("/platform-config", notificationsRouter);
|
||||
router.use("/platform-config", kuaishouEticketRouter);
|
||||
router.use("/platform-config", ninetyoneRouter);
|
||||
router.use("/platform-config", cloudtentaclesRouter);
|
||||
router.use("/platform-config", fulfillmentBindingsRouter);
|
||||
router.use("/platform-config", kuaishouCloudFulfillmentRouter);
|
||||
|
||||
export default router;
|
||||
|
||||
@@ -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 {
|
||||
closeAdminTask,
|
||||
completeAdminTaskManualDispatch,
|
||||
confirmAdminTaskAssistedRole,
|
||||
dispatchAdminTaskKuaishouCloudFulfillment,
|
||||
markAdminTaskManualReview,
|
||||
prepareAdminTaskKuaishouCloudFulfillment,
|
||||
refreshAdminTaskKuaishouCloudRoleInfo,
|
||||
regenerateAdminTaskClaimLink,
|
||||
redeemAdminTaskAssisted,
|
||||
releaseAdminTaskInventoryBinding,
|
||||
releaseAdminTaskInventory,
|
||||
returnNumberAdminTaskKuaishouCloudFulfillment,
|
||||
retryAdminTask,
|
||||
} from "../../services/admin/admin-write-service.js";
|
||||
import {
|
||||
getAdminTaskDetail,
|
||||
getAdminTasks,
|
||||
} from "../../services/admin/admin-read-service.js";
|
||||
import { getAdminTaskScreenshotPathWithTencentFallback } from "../../services/admin/admin-task-screenshot-service.js";
|
||||
import {
|
||||
createFileHandler,
|
||||
dispatchAdminTaskKuaishouCloudFulfillment,
|
||||
prepareAdminTaskKuaishouCloudFulfillment,
|
||||
refreshAdminTaskKuaishouCloudRoleInfo,
|
||||
returnNumberAdminTaskKuaishouCloudFulfillment,
|
||||
} from "../../services/admin/write/kuaishou-cloud-actions.js";
|
||||
import {
|
||||
createJsonHandler,
|
||||
requireAdminRoles,
|
||||
} from "./shared.js";
|
||||
import type { Request } from "express";
|
||||
import type {
|
||||
AdminTaskKuaishouCloudDispatchRouteBody,
|
||||
AdminTaskManualDispatchRouteBody,
|
||||
AdminTaskRouteParams,
|
||||
AdminTaskRouteQuery,
|
||||
} from "../../types/admin-route-inputs.js";
|
||||
import type {
|
||||
AdminTaskActionResponse,
|
||||
AdminTaskBindingReleaseResponse,
|
||||
AdminTaskManualDispatchResponse,
|
||||
} from "../../types/admin-write-models.js";
|
||||
import type { AdminTaskActionResponse } from "../../types/admin-write-models.js";
|
||||
|
||||
const router = Router();
|
||||
|
||||
@@ -44,10 +28,6 @@ function getTaskId(req: Request): string | undefined {
|
||||
return (req.params as AdminTaskRouteParams).taskId;
|
||||
}
|
||||
|
||||
function getBindingId(req: Request): string | undefined {
|
||||
return (req.params as AdminTaskRouteParams).bindingId;
|
||||
}
|
||||
|
||||
router.get(
|
||||
"/tasks",
|
||||
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(
|
||||
"/tasks/:taskId/kuaishou-cloud/prepare",
|
||||
requireAdminRoles(["admin", "operator"]),
|
||||
@@ -319,19 +66,7 @@ router.post(
|
||||
successMessage: "绑定资源已准备完成",
|
||||
errorMessage: "准备绑定资源失败",
|
||||
scope: "[admin/tasks/:taskId/kuaishou-cloud/prepare]",
|
||||
audit: (req, data) => {
|
||||
const result = data as AdminTaskActionResponse;
|
||||
return {
|
||||
action: "task_kuaishou_cloud_prepare",
|
||||
targetType: "task",
|
||||
targetId: String(getTaskId(req)),
|
||||
data: {
|
||||
taskId: result.task.taskId,
|
||||
taskNo: result.task.taskNo,
|
||||
status: result.task.status,
|
||||
},
|
||||
};
|
||||
},
|
||||
audit: (req, data) => buildTaskAudit("task_kuaishou_cloud_prepare", req, data),
|
||||
}
|
||||
)
|
||||
);
|
||||
@@ -349,19 +84,8 @@ router.post(
|
||||
successMessage: "角色信息已刷新",
|
||||
errorMessage: "刷新角色信息失败",
|
||||
scope: "[admin/tasks/:taskId/kuaishou-cloud/refresh-role-info]",
|
||||
audit: (req, data) => {
|
||||
const result = data as AdminTaskActionResponse;
|
||||
return {
|
||||
action: "task_kuaishou_cloud_role_info_refreshed",
|
||||
targetType: "task",
|
||||
targetId: String(getTaskId(req)),
|
||||
data: {
|
||||
taskId: result.task.taskId,
|
||||
taskNo: result.task.taskNo,
|
||||
status: result.task.status,
|
||||
},
|
||||
};
|
||||
},
|
||||
audit: (req, data) =>
|
||||
buildTaskAudit("task_kuaishou_cloud_role_info_refreshed", req, data),
|
||||
}
|
||||
)
|
||||
);
|
||||
@@ -383,6 +107,7 @@ router.post(
|
||||
audit: (req, data) => {
|
||||
const result = data as AdminTaskActionResponse;
|
||||
const body = req.body as AdminTaskKuaishouCloudDispatchRouteBody;
|
||||
|
||||
return {
|
||||
action: "task_kuaishou_cloud_dispatch",
|
||||
targetType: "task",
|
||||
@@ -413,21 +138,25 @@ router.post(
|
||||
successMessage: "号码已退还",
|
||||
errorMessage: "退还号码失败",
|
||||
scope: "[admin/tasks/:taskId/kuaishou-cloud/return-number]",
|
||||
audit: (req, data) => {
|
||||
const result = data as AdminTaskActionResponse;
|
||||
return {
|
||||
action: "task_kuaishou_cloud_return_number",
|
||||
targetType: "task",
|
||||
targetId: String(getTaskId(req)),
|
||||
data: {
|
||||
taskId: result.task.taskId,
|
||||
taskNo: result.task.taskNo,
|
||||
status: result.task.status,
|
||||
},
|
||||
};
|
||||
},
|
||||
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,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 {
|
||||
getAdminWebhookEventDetail,
|
||||
getAdminWebhookEvents,
|
||||
} from '../../services/admin/admin-read-service.js'
|
||||
import { createJsonHandler, requireAdminRoles } from './shared.js'
|
||||
} 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'
|
||||
import type { AdminWebhookReplayResponse } from '../../types/admin-write-models.js'
|
||||
} from "../../types/admin-route-inputs.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(
|
||||
(req) => getAdminWebhookEvents(req.query as AdminWebhookEventRouteQuery),
|
||||
{
|
||||
successMessage: 'ok',
|
||||
errorMessage: '读取 webhook 列表失败',
|
||||
scope: '[admin/webhook-events]',
|
||||
},
|
||||
))
|
||||
router.get(
|
||||
"/webhook-events",
|
||||
createJsonHandler(
|
||||
(req) => getAdminWebhookEvents(req.query as AdminWebhookEventRouteQuery),
|
||||
{
|
||||
successMessage: "ok",
|
||||
errorMessage: "读取 webhook 列表失败",
|
||||
scope: "[admin/webhook-events]",
|
||||
}
|
||||
)
|
||||
);
|
||||
|
||||
router.get('/webhook-events/:eventId', createJsonHandler(
|
||||
(req) => getAdminWebhookEventDetail((req.params as AdminWebhookEventRouteParams).eventId),
|
||||
{
|
||||
successMessage: 'ok',
|
||||
errorMessage: '读取 webhook 详情失败',
|
||||
scope: '[admin/webhook-events/:eventId]',
|
||||
},
|
||||
))
|
||||
router.get(
|
||||
"/webhook-events/:eventId",
|
||||
createJsonHandler(
|
||||
(req) =>
|
||||
getAdminWebhookEventDetail(
|
||||
(req.params as AdminWebhookEventRouteParams).eventId
|
||||
),
|
||||
{
|
||||
successMessage: "ok",
|
||||
errorMessage: "读取 webhook 详情失败",
|
||||
scope: "[admin/webhook-events/:eventId]",
|
||||
}
|
||||
)
|
||||
);
|
||||
|
||||
router.post('/webhook-events/:eventId/replay', requireAdminRoles(['admin']), createJsonHandler(
|
||||
(req) => replayAdminWebhookEvent((req.params as AdminWebhookEventRouteParams).eventId),
|
||||
{
|
||||
successMessage: 'Webhook 已重放',
|
||||
errorMessage: '重放 webhook 失败',
|
||||
scope: '[admin/webhook-events/:eventId/replay]',
|
||||
audit: (req, data) => {
|
||||
const result = data as AdminWebhookReplayResponse
|
||||
return {
|
||||
action: 'webhook_replayed',
|
||||
targetType: 'webhook_event',
|
||||
targetId: String((req.params as AdminWebhookEventRouteParams).eventId),
|
||||
data: {
|
||||
eventId: result.eventId,
|
||||
replayed: result.replayed,
|
||||
},
|
||||
}
|
||||
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/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 {
|
||||
confirmKuaishouCloudClaimRole,
|
||||
getKuaishouCloudClaimDetail,
|
||||
getKuaishouCloudClaimGuideAssetPath,
|
||||
redeemKuaishouCloudClaim,
|
||||
verifyKuaishouCloudClaimTicket,
|
||||
} from '../services/claim/kuaishou-cloud-claim-service.js'
|
||||
} from "../services/claim/kuaishou-cloud-claim-service.js";
|
||||
import {
|
||||
buildNotFoundPayload,
|
||||
buildSuccessPayload,
|
||||
createRouteFileHandler,
|
||||
createRouteHandler,
|
||||
sendRouteError,
|
||||
} from '../utils/http.js'
|
||||
} from "../utils/http.js";
|
||||
|
||||
const router = Router()
|
||||
const router = Router();
|
||||
|
||||
router.get('/:token', createRouteHandler(
|
||||
(req) => getClaimDetail(req.params.token),
|
||||
{ errorMessage: '查询领取详情失败', scope: '[claims/:token]' },
|
||||
))
|
||||
router.get(
|
||||
"/:token",
|
||||
createRouteHandler((req) => getKuaishouCloudClaimDetail(req.params.token), {
|
||||
errorMessage: "查询快手领取详情失败",
|
||||
scope: "[claims/:token]",
|
||||
})
|
||||
);
|
||||
|
||||
router.post('/:token/session', createRouteHandler(
|
||||
(req) => createClaimSession(req.params.token, req.body),
|
||||
{ successMessage: '浏览器会话已创建', errorMessage: '创建领取会话失败', scope: '[claims/:token/session]' },
|
||||
))
|
||||
router.post(
|
||||
"/:token/kuaishou-cloud/verify-ticket",
|
||||
createRouteHandler(
|
||||
(req) => verifyKuaishouCloudClaimTicket(req.params.token, req.body),
|
||||
{
|
||||
successMessage: "核销码校验成功",
|
||||
errorMessage: "校验核销码失败",
|
||||
scope: "[claims/:token/kuaishou-cloud/verify-ticket]",
|
||||
}
|
||||
)
|
||||
);
|
||||
|
||||
router.get('/:token/session/summary', createRouteHandler(
|
||||
(req) => getClaimSessionSummary(req.params.token),
|
||||
{ errorMessage: '查询领取会话摘要失败', scope: '[claims/:token/session/summary]' },
|
||||
))
|
||||
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/session/refresh', async (req, res) => {
|
||||
try {
|
||||
const data = await reloadClaimSession(req.params.token)
|
||||
res.json(buildSuccessPayload(data, data.session ? (data.session.notice || '后端页面已刷新') : '领取会话已重置'))
|
||||
} catch (error) {
|
||||
sendRouteError(res, error, '刷新领取会话失败', '[claims/:token/session/refresh]')
|
||||
}
|
||||
})
|
||||
router.post(
|
||||
"/:token/kuaishou-cloud/redeem",
|
||||
createRouteHandler((req) => redeemKuaishouCloudClaim(req.params.token), {
|
||||
successMessage: "兑换请求已提交",
|
||||
errorMessage: "兑换失败",
|
||||
scope: "[claims/:token/kuaishou-cloud/redeem]",
|
||||
})
|
||||
);
|
||||
|
||||
router.delete('/:token/session', createRouteHandler(
|
||||
(req) => closeClaimSession(req.params.token),
|
||||
{ successMessage: '领取会话已关闭', errorMessage: '关闭领取会话失败', scope: '[claims/:token/session]' },
|
||||
))
|
||||
|
||||
router.post('/:token/confirm-role', createRouteHandler(
|
||||
(req) => confirmClaimRole(req.params.token),
|
||||
{ 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.get(
|
||||
"/assets/kuaishou-cloud/:filename",
|
||||
createRouteFileHandler(
|
||||
(req) => getKuaishouCloudClaimGuideAssetPath(req.params.filename),
|
||||
{
|
||||
errorMessage: "读取指引图片失败",
|
||||
scope: "[claims/assets/kuaishou-cloud/:filename]",
|
||||
}
|
||||
)
|
||||
);
|
||||
|
||||
router.use((req, res) => {
|
||||
res.status(404).json(buildNotFoundPayload(req))
|
||||
})
|
||||
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 { mapAdminWebhookEvent } from './admin-webhook-read-helpers.js'
|
||||
import {
|
||||
buildOrderAgisoAutoDeliverySummary,
|
||||
buildOrderBindingSummary,
|
||||
createTaskBindingSummaryFromBindings,
|
||||
getRequiredTask,
|
||||
@@ -130,7 +129,6 @@ export async function getAdminOrderDetail(orderId: AdminEntityIdInput): Promise<
|
||||
? order.raw_payload_json
|
||||
: JSON.parse(String(order.raw_payload_json || '{}')),
|
||||
bindingSummary: buildOrderBindingSummary(tasks, taskBindingSummaryMap),
|
||||
agisoAutoDelivery: buildOrderAgisoAutoDeliverySummary(order, tasks),
|
||||
},
|
||||
items: items.map((item) => ({
|
||||
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'
|
||||
|
||||
import type {
|
||||
AdminAgisoAutoDeliveryStatus,
|
||||
AdminTaskBindingSummary,
|
||||
AdminTaskListItem,
|
||||
} from '../../types/admin-read-models.js'
|
||||
import type { AdminTaskActionPayload } from '../../types/admin-write-models.js'
|
||||
import type {
|
||||
OrderRow,
|
||||
TaskEventRow,
|
||||
TaskInventoryBindingRow,
|
||||
TaskInventoryBindingSummaryRow,
|
||||
@@ -47,24 +45,11 @@ type OrderBindingSummary = {
|
||||
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(
|
||||
task: TaskRow,
|
||||
bindingSummary: AdminTaskBindingSummary = createEmptyTaskBindingSummary(),
|
||||
): JsonRecord {
|
||||
const binding = buildTaskBindingState(task)
|
||||
const taskContext = parseTaskContext(task)
|
||||
|
||||
return {
|
||||
taskId: task.id,
|
||||
@@ -81,7 +66,6 @@ export function mapAdminTaskSummary(
|
||||
lastError: task.last_error,
|
||||
retryCount: getTaskRetryCount(task),
|
||||
bindingSummary,
|
||||
agisoAutoDelivery: mapAgisoAutoDeliveryContext(taskContext.agisoAutoDelivery),
|
||||
createdAt: task.created_at,
|
||||
updatedAt: task.updated_at,
|
||||
}
|
||||
@@ -124,7 +108,6 @@ export function mapAdminTaskListItem(
|
||||
viewerContext: AdminViewerContext = createAdminViewerContext(),
|
||||
): AdminTaskListItem {
|
||||
const binding = buildTaskBindingState(task)
|
||||
const taskContext = parseTaskContext(task)
|
||||
|
||||
return {
|
||||
taskId: task.id,
|
||||
@@ -148,7 +131,6 @@ export function mapAdminTaskListItem(
|
||||
redeemedAt: task.redeemed_at,
|
||||
retryCount: getTaskRetryCount(task),
|
||||
bindingSummary,
|
||||
agisoAutoDelivery: mapAgisoAutoDeliveryContext(taskContext.agisoAutoDelivery),
|
||||
lastError: task.last_error,
|
||||
createdAt: task.created_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 {
|
||||
if (!task || !binding) {
|
||||
return false
|
||||
@@ -469,24 +394,6 @@ function isTaskSystemBound(task: TaskRow | null | undefined): boolean {
|
||||
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 {
|
||||
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 { formatFenToAmount, parseAmountToFen } from '../../utils/money.js'
|
||||
import { extractAgisoTradePayload, resolveAgisoTradePlatformOrderId } from '../order/agiso-trade-parsing.js'
|
||||
import { safeParseJson } from './admin-query-utils.js'
|
||||
import { resolveDisplayShopName } from './admin-read-shared-helpers.js'
|
||||
|
||||
@@ -49,7 +48,7 @@ export async function mapAdminWebhookEvent(
|
||||
|
||||
const mapped = {
|
||||
eventId: item.id,
|
||||
provider: item.provider || 'agiso',
|
||||
provider: item.provider || '',
|
||||
platform: item.platform,
|
||||
platformRaw: pickFirstNonEmpty([
|
||||
query.fromPlatform,
|
||||
@@ -82,7 +81,7 @@ export async function mapAdminWebhookEvent(
|
||||
visibilityLevel: resolveWebhookVisibilityLevel(item.process_error),
|
||||
relatedOrderId: item.related_order_id,
|
||||
createdAt: item.created_at,
|
||||
platformOrderId: resolveAgisoTradePlatformOrderId(payload),
|
||||
platformOrderId: resolveWebhookPlatformOrderId(item, payload),
|
||||
buyerId: pickFirstNonEmpty([
|
||||
payload.buyer_id,
|
||||
payload.buyerId,
|
||||
@@ -135,7 +134,44 @@ export async function mapAdminWebhookEvent(
|
||||
}
|
||||
|
||||
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[] {
|
||||
|
||||
@@ -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 {
|
||||
applyOptionalStringField,
|
||||
findMatchingObservedBinding,
|
||||
mapAdminAgisoMessagingDefaults,
|
||||
mapAdminAgisoShopConfigItem,
|
||||
mapAdminFulfillmentBindingConfigItem,
|
||||
mapAdminObservedProductItem,
|
||||
matchesObservedProduct,
|
||||
} from './domain.js'
|
||||
|
||||
test('mapAdminAgisoMessagingDefaults normalizes escaped newlines', () => {
|
||||
assert.deepEqual(
|
||||
mapAdminAgisoMessagingDefaults({
|
||||
messageTemplate: ' hello\\nworld ',
|
||||
autoDeliveryMessageTemplate: ' done\\nnow ',
|
||||
}),
|
||||
{
|
||||
messageTemplate: 'hello\nworld',
|
||||
autoDeliveryMessageTemplate: 'done\nnow',
|
||||
},
|
||||
)
|
||||
})
|
||||
|
||||
test('mapAdminAgisoShopConfigItem masks secrets and reports configured flags', () => {
|
||||
assert.deepEqual(
|
||||
mapAdminAgisoShopConfigItem('1001', {
|
||||
shopName: ' 店铺A ',
|
||||
accessToken: 'abcdef1234567890',
|
||||
enabled: true,
|
||||
messageTemplate: ' hi\\nall ',
|
||||
autoDeliveryMessageTemplate: ' ok ',
|
||||
appSecret: 'secret-1',
|
||||
apiVersion: ' v2 ',
|
||||
sendMessageEndpoint: ' /send ',
|
||||
}),
|
||||
{
|
||||
shopId: '1001',
|
||||
shopName: '店铺A',
|
||||
accessToken: 'abcdef1234567890',
|
||||
accessTokenMasked: 'abcdef****567890',
|
||||
enabled: true,
|
||||
messageTemplate: 'hi\nall',
|
||||
autoDeliveryMessageTemplate: 'ok',
|
||||
appSecretConfigured: true,
|
||||
apiVersion: 'v2',
|
||||
sendMessageEndpoint: '/send',
|
||||
},
|
||||
)
|
||||
})
|
||||
|
||||
test('applyOptionalStringField updates and clears normalized template fields', () => {
|
||||
const target = { messageTemplate: 'old' }
|
||||
applyOptionalStringField(target, 'messageTemplate', { messageTemplate: ' next\\nline ' })
|
||||
assert.deepEqual(target, { messageTemplate: 'next\nline' })
|
||||
|
||||
applyOptionalStringField(target, 'messageTemplate', { messageTemplate: ' ' })
|
||||
assert.deepEqual(target, {})
|
||||
})
|
||||
|
||||
test('mapAdminFulfillmentBindingConfigItem normalizes binding shape', () => {
|
||||
assert.deepEqual(
|
||||
mapAdminFulfillmentBindingConfigItem({
|
||||
provider: ' agiso ',
|
||||
platform: ' xianyu ',
|
||||
shopId: ' shop-1 ',
|
||||
skuCode: ' sku-1 ',
|
||||
priority: '80',
|
||||
match: {
|
||||
externalSkuCode: ' ext-1 ',
|
||||
},
|
||||
}),
|
||||
{
|
||||
provider: 'agiso',
|
||||
platform: 'xianyu',
|
||||
shopId: 'shop-1',
|
||||
shopName: '',
|
||||
skuCode: 'sku-1',
|
||||
skuName: '',
|
||||
profileKey: '',
|
||||
enabled: true,
|
||||
priority: 80,
|
||||
config: {},
|
||||
match: {
|
||||
externalSkuCode: 'ext-1',
|
||||
externalItemId: '',
|
||||
externalSkuName: '',
|
||||
config: {},
|
||||
},
|
||||
},
|
||||
)
|
||||
})
|
||||
|
||||
test('matchesObservedProduct honors provider platform shop and external fields', () => {
|
||||
const binding = {
|
||||
provider: 'agiso',
|
||||
platform: 'xianyu',
|
||||
provider: '91kaquan',
|
||||
platform: 'kuaishou',
|
||||
shopId: 'shop-1',
|
||||
match: {
|
||||
externalSkuCode: 'sku-ext-1',
|
||||
@@ -107,8 +21,8 @@ test('matchesObservedProduct honors provider platform shop and external fields',
|
||||
|
||||
assert.equal(
|
||||
matchesObservedProduct(binding, {
|
||||
provider: 'agiso',
|
||||
platform: 'xianyu',
|
||||
provider: '91kaquan',
|
||||
platform: 'kuaishou',
|
||||
shopId: 'shop-1',
|
||||
externalSkuCode: 'sku-ext-1',
|
||||
}),
|
||||
@@ -117,8 +31,8 @@ test('matchesObservedProduct honors provider platform shop and external fields',
|
||||
|
||||
assert.equal(
|
||||
matchesObservedProduct(binding, {
|
||||
provider: 'agiso',
|
||||
platform: 'xianyu',
|
||||
provider: '91kaquan',
|
||||
platform: 'kuaishou',
|
||||
shopId: 'shop-2',
|
||||
externalSkuCode: 'sku-ext-1',
|
||||
}),
|
||||
@@ -129,8 +43,8 @@ test('matchesObservedProduct honors provider platform shop and external fields',
|
||||
test('mapAdminObservedProductItem attaches matched binding summary', () => {
|
||||
const bindings = [
|
||||
{
|
||||
provider: 'agiso',
|
||||
platform: 'xianyu',
|
||||
provider: '91kaquan',
|
||||
platform: 'kuaishou',
|
||||
shopId: 'shop-1',
|
||||
skuCode: 'sku-1',
|
||||
skuName: 'SKU 1',
|
||||
@@ -141,8 +55,8 @@ test('mapAdminObservedProductItem attaches matched binding summary', () => {
|
||||
},
|
||||
]
|
||||
const observed = {
|
||||
provider: 'agiso',
|
||||
platform: 'xianyu',
|
||||
provider: '91kaquan',
|
||||
platform: 'kuaishou',
|
||||
shopId: 'shop-1',
|
||||
shopName: '店铺1',
|
||||
externalItemId: '',
|
||||
@@ -154,8 +68,8 @@ test('mapAdminObservedProductItem attaches matched binding summary', () => {
|
||||
|
||||
assert.deepEqual(findMatchingObservedBinding(bindings, observed), bindings[0])
|
||||
assert.deepEqual(mapAdminObservedProductItem(observed, bindings), {
|
||||
provider: 'agiso',
|
||||
platform: 'xianyu',
|
||||
provider: '91kaquan',
|
||||
platform: 'kuaishou',
|
||||
shopId: 'shop-1',
|
||||
shopName: '店铺1',
|
||||
externalItemId: '',
|
||||
|
||||
@@ -1,70 +1,5 @@
|
||||
import { normalizeAgisoMessageTemplate } from './agiso-template.js'
|
||||
import { maskSecret } from './mappers.js'
|
||||
|
||||
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) {
|
||||
const provider = String(binding?.provider || '').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 { normalizeProductName } from '../order/product-match-service.js'
|
||||
import { query } from '../../db/client.js'
|
||||
import { getLegacyOrderFulfillmentBindingConfigs } from '../order/fulfillment-binding-config-service.js'
|
||||
import {
|
||||
getKuaishouCloudFulfillmentConfig,
|
||||
mapKuaishouCloudFulfillmentItemsToBindings,
|
||||
@@ -108,10 +107,7 @@ async function ensureCoreProfiles() {
|
||||
|
||||
export async function syncConfiguredFulfillmentBindings(profileMap: JsonObject = {}) {
|
||||
const timestamp = nowIso()
|
||||
const bindingsToApply = [
|
||||
...getLegacyOrderFulfillmentBindingConfigs(),
|
||||
...mapKuaishouCloudFulfillmentItemsToBindings(getKuaishouCloudFulfillmentConfig()),
|
||||
]
|
||||
const bindingsToApply = mapKuaishouCloudFulfillmentItemsToBindings(getKuaishouCloudFulfillmentConfig())
|
||||
|
||||
await query('DELETE FROM product_match_rules')
|
||||
await query('DELETE FROM sku_fulfillment_bindings')
|
||||
@@ -124,7 +120,7 @@ export async function syncConfiguredFulfillmentBindings(profileMap: JsonObject =
|
||||
|
||||
const bindingConfig = resolveBindingRuntimeConfig(binding)
|
||||
const matchShopIds = resolveBindingMatchShopIds(binding)
|
||||
const match = binding.match || {}
|
||||
const match: JsonObject = isPlainObject(binding.match) ? binding.match : {}
|
||||
const externalSkuName = String(match.externalSkuName || '').trim()
|
||||
const externalItemId = String(match.externalItemId || '').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()]
|
||||
}
|
||||
|
||||
|
||||
@@ -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 { createHttpError } from '../../../utils/http.js'
|
||||
import { maskCode as maskCodeValue } from '../../../utils/masking.js'
|
||||
import { formatFenToAmount, normalizeFen } from '../../../utils/money.js'
|
||||
import {
|
||||
parseTaskContext as parseTaskContextValue,
|
||||
parseTaskState as parseTaskStateValue,
|
||||
} from '../../../utils/task-json.js'
|
||||
import { findClaimTokenByToken, updateClaimToken } from '../../repositories/claim-token-repo.js'
|
||||
import { releaseReservedInventoryItem } from '../../repositories/inventory-repo.js'
|
||||
import { getOrderItemById } from '../../repositories/order-item-repo.js'
|
||||
import { getOrderById } from '../../repositories/order-repo.js'
|
||||
import { findTaskByClaimTokenId, updateTask } from '../../repositories/task-repo.js'
|
||||
import { createHttpError } from '../../utils/http.js'
|
||||
import { formatFenToAmount, normalizeFen } from '../../utils/money.js'
|
||||
import { parseTaskContext as parseTaskContextValue } from '../../utils/task-json.js'
|
||||
import { nowIso } from '../../utils/time.js'
|
||||
import { buildClaimUrl } from './claim-service.js'
|
||||
|
||||
export const CLAIM_TERMINAL_STATUSES = new Set(['expired', 'closed'])
|
||||
export const REDEEM_REPLACEMENT_LIMIT = 10
|
||||
export const KUAISHOU_CLOUD_CLAIM_GUIDE_BASE_PATH = '/kuaishou-cloud-guide'
|
||||
|
||||
export function buildClaimDetailPayload({ claimToken, task, order, orderItem, session }) {
|
||||
const screenshotReady = Boolean(task.screenshot_path) || Boolean(session?.artifacts?.hasScreenshot)
|
||||
const screenshotUrl = screenshotReady ? `/api/v1/claim/${claimToken.token}/screenshot` : ''
|
||||
const finalRedeem = session?.redeem?.final?.redeem || null
|
||||
export async function getClaimContext(token: unknown) {
|
||||
const normalized = String(token || '').trim()
|
||||
|
||||
if (!normalized) {
|
||||
throw createHttpError('缺少领取 token', {
|
||||
statusCode: 400,
|
||||
errorCode: 'missing_claim_token',
|
||||
})
|
||||
}
|
||||
|
||||
const claimToken = await findClaimTokenByToken(normalized)
|
||||
|
||||
if (!claimToken) {
|
||||
throw createHttpError('领取链接无效或不存在', {
|
||||
statusCode: 404,
|
||||
errorCode: 'claim_token_not_found',
|
||||
})
|
||||
}
|
||||
|
||||
const task = await findTaskByClaimTokenId(claimToken.id)
|
||||
|
||||
if (!task) {
|
||||
throw createHttpError('领取任务不存在', {
|
||||
statusCode: 404,
|
||||
errorCode: 'claim_task_not_found',
|
||||
})
|
||||
}
|
||||
|
||||
if (claimToken.status !== 'active') {
|
||||
throw createHttpError('领取链接当前不可用', {
|
||||
statusCode: 410,
|
||||
errorCode: 'claim_token_inactive',
|
||||
})
|
||||
}
|
||||
|
||||
if (claimToken.expired_at && new Date(claimToken.expired_at).getTime() <= Date.now()) {
|
||||
const expiredContext = await expireClaimContext(claimToken, task)
|
||||
|
||||
throw createHttpError('领取链接已过期', {
|
||||
statusCode: 410,
|
||||
errorCode: 'claim_token_expired',
|
||||
context: expiredContext,
|
||||
})
|
||||
}
|
||||
|
||||
const [order, orderItem] = await Promise.all([
|
||||
getOrderById(task.order_id),
|
||||
getOrderItemById(task.order_item_id),
|
||||
])
|
||||
|
||||
if (!order || !orderItem) {
|
||||
throw createHttpError('领取任务关联订单不完整', {
|
||||
statusCode: 500,
|
||||
errorCode: 'claim_order_incomplete',
|
||||
})
|
||||
}
|
||||
|
||||
return {
|
||||
claimToken,
|
||||
task,
|
||||
order,
|
||||
orderItem,
|
||||
}
|
||||
}
|
||||
|
||||
export function buildClaimDetailPayload({ claimToken, task, order, orderItem }) {
|
||||
const kuaishouCloudFulfillment = mapClaimKuaishouCloudFulfillment(task, order)
|
||||
|
||||
return {
|
||||
tokenStatus: claimToken.status,
|
||||
claimUrl: buildClaimUrl(claimToken.token),
|
||||
flowType: kuaishouCloudFulfillment ? 'kuaishou_cloud' : 'tencent_claim',
|
||||
flowType: 'kuaishou_cloud',
|
||||
task: {
|
||||
taskId: task.id,
|
||||
taskNo: task.task_no,
|
||||
status: task.task_status,
|
||||
executorKey: task.executor_key || '',
|
||||
requiresSupportReview: isAssistedClaimTask(task),
|
||||
requiresSupportReview: false,
|
||||
expiresAt: claimToken.expired_at,
|
||||
claimedAt: task.claimed_at,
|
||||
roleConfirmedAt: task.role_confirmed_at,
|
||||
@@ -51,14 +114,14 @@ export function buildClaimDetailPayload({ claimToken, task, order, orderItem, se
|
||||
skuName: orderItem.sku_name,
|
||||
quantity: orderItem.quantity,
|
||||
},
|
||||
session,
|
||||
session: null,
|
||||
kuaishouCloudFulfillment,
|
||||
result: task.redeemed_at || session?.status === 'redeemed'
|
||||
result: task.redeemed_at
|
||||
? {
|
||||
resultCode: String(task.result_code || finalRedeem?.iRet || finalRedeem?.ret || ''),
|
||||
resultMessage: String(task.result_message || finalRedeem?.sMsg || finalRedeem?.msg || session?.notice || ''),
|
||||
screenshotReady,
|
||||
screenshotUrl,
|
||||
resultCode: String(task.result_code || ''),
|
||||
resultMessage: String(task.result_message || ''),
|
||||
screenshotReady: false,
|
||||
screenshotUrl: '',
|
||||
}
|
||||
: null,
|
||||
}
|
||||
@@ -151,58 +214,35 @@ export function mapClaimKuaishouCloudFulfillment(task, order) {
|
||||
}
|
||||
}
|
||||
|
||||
export function assertTaskCanProceed(task) {
|
||||
if (CLAIM_TERMINAL_STATUSES.has(String(task.task_status || ''))) {
|
||||
throw createHttpError('当前任务已经结束,不能继续操作', {
|
||||
statusCode: 410,
|
||||
errorCode: 'claim_task_closed',
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
export function assertPublicClaimActionAllowed(task, action) {
|
||||
if (!isAssistedClaimTask(task)) {
|
||||
return
|
||||
}
|
||||
|
||||
const message = action === 'confirm'
|
||||
? '当前商品需要客服复核角色,请登录后联系人工继续'
|
||||
: '当前商品需要客服确认后再执行兑换,请联系人工继续'
|
||||
|
||||
throw createHttpError(message, {
|
||||
statusCode: 409,
|
||||
errorCode: 'claim_support_review_required',
|
||||
})
|
||||
}
|
||||
|
||||
export function mergeTaskContext(task, patch = {}) {
|
||||
return {
|
||||
...parseTaskContext(task),
|
||||
...patch,
|
||||
}
|
||||
}
|
||||
|
||||
export function parseTaskState(task) {
|
||||
return parseTaskStateValue(task)
|
||||
}
|
||||
|
||||
export function isAssistedClaimTask(task) {
|
||||
return String(task?.executor_key || '').trim() === 'tencent_claim_assisted'
|
||||
}
|
||||
|
||||
export function parseTaskContext(task) {
|
||||
function parseTaskContext(task) {
|
||||
return parseTaskContextValue(task)
|
||||
}
|
||||
|
||||
export function normalizeClaimLoginType(loginType) {
|
||||
return String(loginType || '').trim() === 'wx' ? 'wx' : 'qq'
|
||||
}
|
||||
async function expireClaimContext(claimToken, task) {
|
||||
const now = nowIso()
|
||||
const nextClaimToken = await updateClaimToken(claimToken.id, {
|
||||
status: 'expired',
|
||||
updated_at: now,
|
||||
})
|
||||
|
||||
export function isRecoverableSessionError(error) {
|
||||
const errorCode = String(error?.errorCode || error?.code || '').trim()
|
||||
return errorCode === 'session_not_found' || errorCode === 'session_closed'
|
||||
}
|
||||
let nextTask = task
|
||||
|
||||
export function maskCode(value) {
|
||||
return maskCodeValue(value, { shortMask: '****' })
|
||||
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,
|
||||
}
|
||||
}
|
||||
@@ -18,8 +18,7 @@ import {
|
||||
prepareKuaishouCloudFulfillmentTask,
|
||||
refreshKuaishouCloudTaskRoleInfo,
|
||||
} from '../fulfillment/kuaishou-cloud-task-service.js'
|
||||
import { buildClaimDetailPayload } from './session/shared.js'
|
||||
import { getClaimContext } from './session/context.js'
|
||||
import { buildClaimDetailPayload, getClaimContext } from './kuaishou-cloud-claim-context.js'
|
||||
import { syncKuaishouCloudRoleInfo } from './kuaishou-cloud-sync-service.js'
|
||||
|
||||
const KUAISHOU_CLOUD_GUIDE_DIR = path.resolve(PROJECT_ROOT, '../../tems/imgs')
|
||||
@@ -170,7 +169,6 @@ export async function getKuaishouCloudClaimDetail(token: unknown) {
|
||||
task,
|
||||
order: context.order,
|
||||
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