删除咸鱼旧链路

This commit is contained in:
yml
2026-05-25 21:46:39 +08:00
parent 9aafddd624
commit e7aa194dde
142 changed files with 539 additions and 17373 deletions
-30
View File
@@ -24,41 +24,11 @@ POSTGRES_PORT=5432
DATA_ROOT=/app/data DATA_ROOT=/app/data
LOG_LEVEL=info LOG_LEVEL=info
# OCR
OCR_IMAGE=order_site-ocr-worker:latest
OCR_PORT=8100
OCR_BASE_URL=http://ocr-worker:8100
# Tencent Browser
TENCENT_REDEEM_PROOF_MODE=basic
TENCENT_BROWSER_HEADLESS=false
TENCENT_BROWSER_DEVTOOLS=false
TENCENT_BROWSER_PREWARM=true
TENCENT_BROWSER_KEEP_ALIVE=true
TENCENT_BROWSER_SLOW_MO=200
TENCENT_BROWSER_VNC_RESOLUTION=1600x900x24
TENCENT_SESSION_DEBUG=true
TENCENT_BROWSER_NOVNC_ENABLED=true
TENCENT_BROWSER_NOVNC_PORT=6080
TENCENT_BROWSER_VNC_PORT=5900
# Admin # Admin
ADMIN_SESSION_SECRET=dev-local-session-secret ADMIN_SESSION_SECRET=dev-local-session-secret
ADMIN_SESSION_TTL_HOURS=12 ADMIN_SESSION_TTL_HOURS=12
ADMIN_DEFAULT_USERS_JSON=[{"username":"admin","password":"dev-admin-123456","role":"admin"},{"username":"operator","password":"dev-operator-123456","role":"operator"}] ADMIN_DEFAULT_USERS_JSON=[{"username":"admin","password":"dev-admin-123456","role":"admin"},{"username":"operator","password":"dev-operator-123456","role":"operator"}]
# Agiso
AGISO_APP_SECRET=
AGISO_MESSAGING_ENABLED=true
AGISO_TRADE_DETAIL_ENDPOINT=https://gw-api.agiso.com/aldsIdle/Order/Detail
AGISO_TRADE_DETAIL_API_VERSION=1
AGISO_TRADE_DETAIL_TIMEOUT_MS=5000
AGISO_AUTO_DELIVERY_ENABLED=true
AGISO_AUTO_DELIVERY_ENDPOINT=https://gw-api.agiso.com/aldsIdle/Order/DummySend
AGISO_AUTO_DELIVERY_API_VERSION=1
AGISO_MESSAGE_API_VERSION=1
AGISO_SEND_MESSAGE_ENDPOINT=https://gw-api.agiso.com/aldsIdle/ImMsg/SendMsg
# 91Kaquan # 91Kaquan
NINETYONE_USER_ID= NINETYONE_USER_ID=
NINETYONE_SECRET= NINETYONE_SECRET=
-26
View File
@@ -23,37 +23,11 @@ DATABASE_MAX_CONNECTIONS=20
DATA_ROOT=/app/data DATA_ROOT=/app/data
LOG_LEVEL=info LOG_LEVEL=info
# OCR
OCR_IMAGE=order_site-ocr-worker:latest
OCR_BASE_URL=http://ocr-worker:8100
# Tencent Browser
TENCENT_REDEEM_PROOF_MODE=full
TENCENT_BROWSER_HEADLESS=true
TENCENT_BROWSER_DEVTOOLS=false
TENCENT_BROWSER_PREWARM=true
TENCENT_BROWSER_KEEP_ALIVE=true
TENCENT_BROWSER_SLOW_MO=0
TENCENT_BROWSER_VNC_RESOLUTION=1600x900x24
TENCENT_SESSION_DEBUG=false
# Admin # Admin
ADMIN_SESSION_SECRET=change-me-long-random-session-secret ADMIN_SESSION_SECRET=change-me-long-random-session-secret
ADMIN_SESSION_TTL_HOURS=12 ADMIN_SESSION_TTL_HOURS=12
ADMIN_DEFAULT_USERS_JSON=[{"username":"admin","password":"change-me-admin-password","role":"admin"},{"username":"operator","password":"change-me-operator-password","role":"operator"}] ADMIN_DEFAULT_USERS_JSON=[{"username":"admin","password":"change-me-admin-password","role":"admin"},{"username":"operator","password":"change-me-operator-password","role":"operator"}]
# Agiso
AGISO_APP_SECRET=change-me-agiso-app-secret
AGISO_MESSAGING_ENABLED=true
AGISO_TRADE_DETAIL_ENDPOINT=https://gw-api.agiso.com/aldsIdle/Order/Detail
AGISO_TRADE_DETAIL_API_VERSION=1
AGISO_TRADE_DETAIL_TIMEOUT_MS=5000
AGISO_AUTO_DELIVERY_ENABLED=true
AGISO_AUTO_DELIVERY_ENDPOINT=https://gw-api.agiso.com/aldsIdle/Order/DummySend
AGISO_AUTO_DELIVERY_API_VERSION=1
AGISO_MESSAGE_API_VERSION=1
AGISO_SEND_MESSAGE_ENDPOINT=https://gw-api.agiso.com/aldsIdle/ImMsg/SendMsg
# 91Kaquan # 91Kaquan
NINETYONE_USER_ID= NINETYONE_USER_ID=
NINETYONE_SECRET= NINETYONE_SECRET=
+7 -104
View File
@@ -1,14 +1,13 @@
# order_site # order_site
订单自动兑换与履约管理系统。 快手自动发货与履约管理系统。
项目采用前后端分离架构,主要用于接收订单、匹配履约规则、生成领取链接、管理库存、执行自动/半自动兑换,并提供后台页面处理人工复核、平台配置任务追踪。 项目采用前后端分离架构,用于接收订单、匹配快手履约规则、管理库存与云卡资源,并提供后台页面处理平台配置任务追踪和人工复核
## 技术栈 ## 技术栈
- 前端:Vue 3、Vite、Element Plus、TypeScript - 前端:Vue 3、Vite、Element Plus、TypeScript
- 后端:Node.js、Express、PostgreSQL、Playwright - 后端:Node.js、Express、PostgreSQL
- OCR:外部镜像服务,后端通过 `OCR_BASE_URL` 调用
- 网关:Caddy - 网关:Caddy
- 本地与生产运行:Docker Compose - 本地与生产运行:Docker Compose
@@ -30,28 +29,18 @@ docker-compose.yml 生产/服务器 Compose
## 本地开发 ## 本地开发
推荐直接使用 Docker,本地不需要额外维护 NodePostgreSQL、Playwright 浏览器和 OCR 运行环境 推荐直接使用 Docker,本地不需要额外维护 NodePostgreSQL。
```bash ```bash
cp .env.mac-docker.example .env cp .env.mac-docker.example .env
docker compose -f docker-compose.dev.yml up -d --build docker compose -f docker-compose.dev.yml up -d --build
``` ```
如果只运行快手 Cloud / 91 卡券 / 快手小店核销链路,可以使用轻量 Compose。这个模式不会启动 OCR,也不会安装或预热 Playwright 浏览器:
```bash
docker compose -f docker-compose.kuaishou.dev.yml up -d --build
```
启动后常用入口: 启动后常用入口:
- 前端、后台、领取页:`http://localhost` - 前端、后台、领取页:`http://localhost`
- 后端健康检查:`http://localhost/health` - 后端健康检查:`http://localhost/health`
- 后端 API 前缀:`http://localhost/api/v1/...` - 后端 API 前缀:`http://localhost/api/v1/...`
- OCR worker`http://127.0.0.1:8100/health`
- noVNC 浏览器画面:`http://127.0.0.1:6080/vnc.html`
快手轻量开发模式没有 noVNC 入口,前端首页默认进入后台。
查看容器状态: 查看容器状态:
@@ -64,7 +53,6 @@ docker compose -f docker-compose.dev.yml ps
```bash ```bash
docker compose -f docker-compose.dev.yml logs -f backend docker compose -f docker-compose.dev.yml logs -f backend
docker compose -f docker-compose.dev.yml logs -f frontend docker compose -f docker-compose.dev.yml logs -f frontend
docker compose -f docker-compose.dev.yml logs -f ocr-worker
``` ```
停止环境: 停止环境:
@@ -83,11 +71,6 @@ docker compose -f docker-compose.dev.yml exec -T backend npm run typecheck
docker compose -f docker-compose.dev.yml exec -T backend npm run build docker compose -f docker-compose.dev.yml exec -T backend npm run build
docker compose -f docker-compose.dev.yml exec -T frontend npm run typecheck docker compose -f docker-compose.dev.yml exec -T frontend npm run typecheck
docker compose -f docker-compose.dev.yml exec -T frontend npm run lint docker compose -f docker-compose.dev.yml exec -T frontend npm run lint
```
前端生产构建:
```bash
docker compose -f docker-compose.dev.yml exec -T frontend npm run build docker compose -f docker-compose.dev.yml exec -T frontend npm run build
``` ```
@@ -97,16 +80,6 @@ docker compose -f docker-compose.dev.yml exec -T frontend npm run build
docker compose -f docker-compose.dev.yml exec -T backend npm run db:migrate docker compose -f docker-compose.dev.yml exec -T backend npm run db:migrate
``` ```
## 热更新
开发版 Compose 会把源码挂载进容器:
- 修改 `apps/backend/src`:后端容器内 `tsx watch` 自动重启
- 修改 `apps/frontend/src`Vite 自动 HMR
- 修改数据库 migration:重启后端会自动执行迁移
如果只改业务代码,一般不需要重新 `--build`。改 Dockerfile、系统依赖或基础镜像时再重新构建。
## 环境变量 ## 环境变量
本地开发从根目录 `.env` 读取,模板为: 本地开发从根目录 `.env` 读取,模板为:
@@ -128,10 +101,7 @@ cp .env.server.example .env
- `ADMIN_SESSION_SECRET`:后台登录态签名密钥 - `ADMIN_SESSION_SECRET`:后台登录态签名密钥
- `ADMIN_DEFAULT_USERS_JSON`:默认后台用户 - `ADMIN_DEFAULT_USERS_JSON`:默认后台用户
- `DATABASE_URL`PostgreSQL 连接串 - `DATABASE_URL`PostgreSQL 连接串
- `TENCENT_BROWSER_HEADLESS`Playwright 是否无头运行 - `NINETYONE_*`91 卡券对接配置
- `TENCENT_BROWSER_PREWARM`:启动时是否预热浏览器
- `OCR_IMAGE`OCR 外部服务镜像,默认 `order_site-ocr-worker:latest`
- `OCR_BASE_URL`:后端访问 OCR 的 HTTP 地址,Docker 内默认 `http://ocr-worker:8100`
## 运行数据 ## 运行数据
@@ -140,8 +110,6 @@ cp .env.server.example .env
- 后端配置与运行产物:`apps/backend/data` - 后端配置与运行产物:`apps/backend/data`
- PostgreSQL 数据:Docker volume `postgres_dev_data` - PostgreSQL 数据:Docker volume `postgres_dev_data`
- 后端日志:`apps/backend/data/logs` - 后端日志:`apps/backend/data/logs`
- 浏览器会话:`apps/backend/data/browser-sessions`
- 兑换截图:`apps/backend/data/redeem-screenshots`
生产部署数据: 生产部署数据:
@@ -161,13 +129,6 @@ mkdir -p deploy/data/backend
docker compose up -d --build docker compose up -d --build
``` ```
只部署快手轻量服务时使用:
```bash
mkdir -p deploy/data/backend-kuaishou
docker compose -f docker-compose.kuaishou.yml up -d --build
```
生产入口: 生产入口:
- 前端与领取页:`https://你的域名/` - 前端与领取页:`https://你的域名/`
@@ -177,78 +138,21 @@ docker compose -f docker-compose.kuaishou.yml up -d --build
生产 Compose 会自动: 生产 Compose 会自动:
- 启动 PostgreSQL - 启动 PostgreSQL
- 启动 OCR worker
- 启动后端并执行 migration - 启动后端并执行 migration
- 初始化默认后台管理员 - 初始化默认后台管理员
- 同步履约目录 - 同步履约目录
- 通过 Caddy 暴露前端和 API - 通过 Caddy 暴露前端和 API
查看生产日志:
```bash
docker compose logs -f backend
docker compose logs -f web
```
## 开发数据脚本
后端提供开发数据脚本:
```bash
docker compose -f docker-compose.dev.yml exec -T backend npm run seed:dev-data
docker compose -f docker-compose.dev.yml exec -T backend npm run cleanup:dev-data
```
脚本默认有预览/确认语义,执行前注意终端输出说明。
## 主要业务模块 ## 主要业务模块
- `apps/backend/src/routes`API 路由 - `apps/backend/src/routes`API 路由
- `apps/backend/src/services/order`:订单、webhook、库存和履约匹配 - `apps/backend/src/services/order`:订单、库存和履约匹配
- `apps/backend/src/services/fulfillment`:快手 Cloud 履约编排 - `apps/backend/src/services/fulfillment`:快手 Cloud 履约编排
- `apps/backend/src/services/session`:腾讯登录、二维码、兑换和截图
- `apps/backend/src/services/admin`:后台读写、权限、配置管理 - `apps/backend/src/services/admin`:后台读写、权限、配置管理
- `apps/backend/src/services/platforms`:外部平台 API 封装
- `apps/backend/src/repositories`:数据库访问 - `apps/backend/src/repositories`:数据库访问
- `apps/frontend/src/views/admin`:后台页面 - `apps/frontend/src/views/admin`:后台页面
- `apps/frontend/src/views/claim`:用户领取页 - `apps/frontend/src/views/claim`:用户领取页
## 排障
如果页面打不开:
```bash
docker compose -f docker-compose.dev.yml ps
docker compose -f docker-compose.dev.yml logs -f web
```
如果后端未 ready
```bash
docker compose -f docker-compose.dev.yml logs -f backend
curl http://localhost/health/ready
```
如果浏览器兑换或扫码异常:
```bash
docker compose -f docker-compose.dev.yml logs -f backend
open http://127.0.0.1:6080/vnc.html
```
如果前端类型或 lint 失败:
```bash
docker compose -f docker-compose.dev.yml exec -T frontend npm run typecheck
docker compose -f docker-compose.dev.yml exec -T frontend npm run lint
```
如果后端单测失败:
```bash
docker compose -f docker-compose.dev.yml exec -T backend npm test
```
## 提交前检查清单 ## 提交前检查清单
```bash ```bash
@@ -264,4 +168,3 @@ docker compose -f docker-compose.dev.yml exec -T frontend npm run lint
- `.env` 未被提交 - `.env` 未被提交
- 数据库 migration 与代码一起提交 - 数据库 migration 与代码一起提交
- 领取链接相关改动已在 Docker 环境验证 - 领取链接相关改动已在 Docker 环境验证
- 涉及 Playwright/OCR 的改动已看过 noVNC 或相关日志
+35 -102
View File
@@ -1,13 +1,8 @@
# order-site-backend # order-site-backend
腾讯活动浏览器会话后端 快手轻量后端服务
当前主线只有一条: 后端负责订单入库、履约任务编排、快手 Cloud / 91 卡券 / 快手小店核销配置、后台管理接口和数据库迁移。默认入口就是轻量后端,不再包含浏览器会话、OCR 或旧咸鱼自动发货链路。
- 后端托管 Playwright 浏览器
- 前端展示二维码并轮询会话状态
- 登录完成后,后端直接在活动页填写 CDK、识别验证码、执行兑换
- 兑换完成后返回状态,并按配置决定是否生成截图/证明产物
## 快速启动 ## 快速启动
@@ -20,21 +15,18 @@ npm install
复制并填写环境变量后启动: 复制并填写环境变量后启动:
```bash ```bash
cp /Users/yml/codes/order-site-workspace/.env.mac-docker.example /Users/yml/codes/order-site-workspace/.env cp ../../.env.mac-docker.example ../../.env
cd /Users/yml/codes/order-site-workspace/apps/backend
npm run dev npm run dev
``` ```
Linux 部署 生产构建
```bash ```bash
npm install
npm run build npm run build
npm run browser:install:linux
npm run start npm run start
``` ```
后端渐进 TypeScript 迁移已经进入源码和构建双轨模式 常用验证
```bash ```bash
npm run typecheck npm run typecheck
@@ -42,120 +34,61 @@ npm run build
npm test npm test
``` ```
当前约定:
- 开发:`npm run dev` 使用 `tsx watch` 直接运行 `src`
- 数据库迁移:`npm run db:migrate` 使用 `tsx`
- 生产:`npm run build` 输出 `dist``npm run start` 运行 `dist/index.js`
- 新增或迁移后端模块优先使用 `.ts`
## 配置 ## 配置
当前后端采用 `.env` 单一路径做环境配置: 后端采用工作区根目录 `.env` 做环境配置:
- 通用默认值:`src/config/defaults.ts` - 通用默认值:`src/config/defaults.ts`
- 本地/部署覆盖:工作区根目录 `.env` - 本地/部署覆盖:工作区根目录 `.env`
当前最常改的配置 常用配置:
- 服务端口 - 服务端口 `PORT`
- 日志级别 `LOG_LEVEL=debug|info|warn|error` - 日志级别 `LOG_LEVEL=debug|info|warn|error`
- 浏览器是否无头、是否预热、是否常驻、slowMo - 数据库连接 `DATABASE_URL`
- OCR 外部服务镜像 `OCR_IMAGE` 与访问地址 `OCR_BASE_URL` - 后台登录密钥 `ADMIN_SESSION_SECRET`
- 会话调试开关 - 默认后台用户 `ADMIN_DEFAULT_USERS_JSON`
- 兑换证明模式 `full | basic | off` - 91 卡券与快手相关平台配置
- Agiso 全局配置与后台店铺文件配置
### Agiso 多店铺
当前支持同一个 Agiso 应用下配置多个店铺的消息 token。
- 全局兜底配置仍走 `.env`
- `AGISO_APP_SECRET`
- `AGISO_MESSAGING_ENABLED`
- 默认消息模板与店铺级覆盖配置改为文件:
- `apps/backend/data/agiso-shops.json`
- 后台维护入口:
- `#/admin/platform-shops`
消息发送时会按 webhook 识别出的 `shop_id` 读取 `agiso-shops.json` 中的店铺配置;模板未命中时,会先回退到文件里的 `defaults`,再回退到系统内置默认值。店铺的 `accessToken` 也需要在文件中维护,不再使用全局 `.env` 兜底。
## 接口 ## 接口
- `POST /api/v1/tencent/browser/session` - `GET /health`:健康检查
创建浏览器会话,返回 `sessionId`、二维码和初始状态 - `GET /health/live`:存活检查
- `GET /api/v1/tencent/browser/session/:sessionId` - `GET /health/ready`:就绪检查
返回完整会话状态 - `/api/v1/open/91/*`91 卡券开放回调
- `GET /api/v1/tencent/browser/session/:sessionId/summary` - `/api/v1/claim/*`:领取页接口
轮询用轻量摘要接口,默认不返回二维码 base64 - `/api/v1/admin/*`:后台管理接口
- `POST /api/v1/tencent/browser/session/:sessionId/refresh`
强制刷新后端活动页
- `POST /api/v1/tencent/browser/session/:sessionId/redeem`
执行兑换
- `GET /api/v1/tencent/browser/session/:sessionId/screenshot`
读取最近一次兑换截图
- `DELETE /api/v1/tencent/browser/session/:sessionId`
关闭浏览器会话
## 目录 ## 目录
- `src/index.ts` - `src/index.ts`:服务入口
Express 入口 - `src/app.ts`Express 应用组装
- `src/routes/tencent.ts` - `src/routes`API 路由
接口路由 - `src/services/order`:订单、库存和履约匹配
- `src/services/session/session.ts` - `src/services/fulfillment`:快手 Cloud 履约编排
会话编排与浏览器生命周期 - `src/services/admin`:后台读写、权限、配置管理
- `src/services/session/*.ts` - `src/repositories`:数据库访问
登录、兑换、OCR、凭证产物等模块 - `src/db/migrations`:数据库迁移
## Docker 开发 ## Docker 开发
当前 `docker-compose.dev.yml` 已切到源码挂载模式 开发版 Compose 会把源码挂载进容器
- `postgres` 容器提供开发库,后端通过 `DATABASE_URL` 连接 - `postgres` 容器提供开发库,后端通过 `DATABASE_URL` 连接
- `ocr-worker` 使用外部镜像启动,后端通过 `OCR_BASE_URL=http://ocr-worker:8100` 调用 - 后端容器启动时执行依赖安装和 `npm run dev`
- 后端容器启动时执行 `npm install` `npm run dev` - 平时改 `src/` `.env`,一般不需要重建镜像
- 平时改 `src/``.env`,一般都不需要重建镜像
- 生产镜像会在 build 阶段编译后端并只复制 `dist``config` 和运行依赖
只有下面几类改动通常还需要 `docker compose -f docker-compose.dev.yml up -d --build backend` 只有下面几类改动通常还需要重新构建 backend
- `deploy/docker/backend.Dockerfile` - `deploy/docker/backend*.Dockerfile`
- 系统层依赖 - Node 版本或系统层依赖
- Playwright 浏览器基础环境 - `package.json` / `package-lock.json`
## 产物 ## 运行产物
浏览器会话产物默认保存在:
- `data/browser-sessions/<sessionId>/qq-qr.png`
- `data/browser-sessions/<sessionId>/session.json`
- `data/browser-sessions/<sessionId>/captcha-attempt-*.png`
- `data/browser-sessions/<sessionId>/redeem-result.png`
- `data/browser-sessions/<sessionId>/page.html`
- `data/browser-sessions/<sessionId>/result.json`
这些都是运行时产物,默认不提交 Git。
运行日志默认保存在: 运行日志默认保存在:
- `data/logs/app-YYYY-MM-DD.log` - `data/logs/app-YYYY-MM-DD.log`
- `data/logs/webhook-YYYY-MM-DD.log` - `data/logs/webhook-YYYY-MM-DD.log`
日志按天切分,默认自动清理 7 天前的旧日志。 日志按天切分,默认自动清理 7 天前的旧日志。成功的 `/health``/health/live``/health/ready` 探活请求默认不写 access log。
成功的 `/health``/health/live``/health/ready` 探活请求默认不写 access log。
`LOG_LEVEL` 默认是 `info`
- `debug`:输出最详细的调试日志
- `info`:输出启动、请求、业务成功/失败等常规日志
- `warn`:只保留告警和错误
- `error`:只保留错误
OCR 识别通过 `OCR_BASE_URL` 调用 HTTP 服务。Docker Compose 会启动 `ocr-worker` 外部镜像并注入默认地址 `http://ocr-worker:8100`;如果单独运行后端且未配置该地址,启动健康检查会标记 OCR 为 degraded,兑换中需要 OCR 的流程会在调用时返回明确错误。
`TENCENT_REDEEM_PROOF_MODE` 对应的配置文件项会影响生成强度:
- `full`:完整证明
- `basic`:只保留最终截图和结果 JSON
- `off`:不生成证明文件
+1 -46
View File
@@ -9,8 +9,7 @@
"version": "0.1.0", "version": "0.1.0",
"dependencies": { "dependencies": {
"express": "^5.1.0", "express": "^5.1.0",
"pg": "^8.16.3", "pg": "^8.16.3"
"playwright": "1.42.1"
}, },
"devDependencies": { "devDependencies": {
"@types/express": "^5.0.6", "@types/express": "^5.0.6",
@@ -911,20 +910,6 @@
"node": ">= 0.8" "node": ">= 0.8"
} }
}, },
"node_modules/fsevents": {
"version": "2.3.2",
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz",
"integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==",
"hasInstallScript": true,
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
}
},
"node_modules/function-bind": { "node_modules/function-bind": {
"version": "1.1.2", "version": "1.1.2",
"resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz",
@@ -1275,36 +1260,6 @@
"split2": "^4.1.0" "split2": "^4.1.0"
} }
}, },
"node_modules/playwright": {
"version": "1.42.1",
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.42.1.tgz",
"integrity": "sha512-PgwB03s2DZBcNRoW+1w9E+VkLBxweib6KTXM0M3tkiT4jVxKSi6PmVJ591J+0u10LUrgxB7dLRbiJqO5s2QPMg==",
"license": "Apache-2.0",
"dependencies": {
"playwright-core": "1.42.1"
},
"bin": {
"playwright": "cli.js"
},
"engines": {
"node": ">=16"
},
"optionalDependencies": {
"fsevents": "2.3.2"
}
},
"node_modules/playwright-core": {
"version": "1.42.1",
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.42.1.tgz",
"integrity": "sha512-mxz6zclokgrke9p1vtdy/COWBH+eOZgYUVVU34C73M+4j4HLlQJHtfcqiqqxpP0o8HhMkflvfbquLX5dg6wlfA==",
"license": "Apache-2.0",
"bin": {
"playwright-core": "cli.js"
},
"engines": {
"node": ">=16"
}
},
"node_modules/postgres-array": { "node_modules/postgres-array": {
"version": "2.0.0", "version": "2.0.0",
"resolved": "https://registry.npmjs.org/postgres-array/-/postgres-array-2.0.0.tgz", "resolved": "https://registry.npmjs.org/postgres-array/-/postgres-array-2.0.0.tgz",
+1 -10
View File
@@ -4,27 +4,18 @@
"private": true, "private": true,
"type": "module", "type": "module",
"scripts": { "scripts": {
"browser:install": "playwright install chromium",
"browser:install:linux": "playwright install --with-deps chromium",
"agiso:auto-delivery:test": "tsx scripts/test-agiso-auto-delivery.ts",
"cleanup:dev-data": "tsx scripts/cleanup-dev-data.ts", "cleanup:dev-data": "tsx scripts/cleanup-dev-data.ts",
"seed:dev-data": "tsx scripts/seed-dev-data.ts",
"build": "tsc -p tsconfig.build.json && rm -rf dist/db/migrations && mkdir -p dist/db && cp -R src/db/migrations dist/db/migrations", "build": "tsc -p tsconfig.build.json && rm -rf dist/db/migrations && mkdir -p dist/db && cp -R src/db/migrations dist/db/migrations",
"build:kuaishou": "tsc -p tsconfig.kuaishou.build.json && rm -rf dist/db/migrations && mkdir -p dist/db && cp -R src/db/migrations dist/db/migrations",
"db:migrate": "tsx src/db/migrate.ts", "db:migrate": "tsx src/db/migrate.ts",
"dev": "tsx watch --clear-screen=false src/index.ts", "dev": "tsx watch --clear-screen=false src/index.ts",
"dev:kuaishou": "tsx watch --clear-screen=false src/index-kuaishou.ts",
"test": "node --import tsx --test $(find src \\( -name '*.test.ts' -o -name '*.test.js' \\) -print)", "test": "node --import tsx --test $(find src \\( -name '*.test.ts' -o -name '*.test.js' \\) -print)",
"typecheck": "tsc -p tsconfig.json --noEmit", "typecheck": "tsc -p tsconfig.json --noEmit",
"typecheck:kuaishou": "tsc -p tsconfig.kuaishou.json --noEmit",
"start": "node dist/index.js", "start": "node dist/index.js",
"start:kuaishou": "node dist/index-kuaishou.js",
"start:src": "tsx src/index.ts" "start:src": "tsx src/index.ts"
}, },
"dependencies": { "dependencies": {
"express": "^5.1.0", "express": "^5.1.0",
"pg": "^8.16.3", "pg": "^8.16.3"
"playwright": "1.42.1"
}, },
"devDependencies": { "devDependencies": {
"@types/express": "^5.0.6", "@types/express": "^5.0.6",
-22
View File
@@ -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"
}
}
-513
View File
@@ -1,513 +0,0 @@
#!/usr/bin/env node
import process from 'node:process'
import { runtimeConfig } from '../src/config/runtime.js'
import { closeDb, query } from '../src/db/client.js'
import { runDatabaseMigrations } from '../src/db/migrate.js'
import { updateClaimToken } from '../src/repositories/claim-token-repo.js'
import { getFulfillmentProfileByKey, upsertSkuFulfillmentBinding } from '../src/repositories/fulfillment-profile-repo.js'
import { createInventoryItems, releaseReservedInventoryItem } from '../src/repositories/inventory-repo.js'
import { getTaskById, updateTask } from '../src/repositories/task-repo.js'
import { ensureFulfillmentCatalogBootstrapped } from '../src/services/bootstrap/fulfillment-bootstrap-service.js'
import { completeAdminTaskManualDispatch } from '../src/services/admin/admin-service.js'
import { upsertOrderFromWebhook } from '../src/services/order/order-service.js'
import { addHours, nowIso } from '../src/utils/time.js'
const RESET_TABLES = [
'message_deliveries',
'tencent_browser_contexts',
'task_events',
'claim_tokens',
'task_inventory_bindings',
'fulfillment_tasks',
'order_items',
'orders',
'webhook_events',
'product_match_rules',
'inventory_items',
]
const SEED_PROVIDER = 'seed'
const SEED_PLATFORM = 'sandbox'
const SEED_SHOP_ID = 'seed-shop'
const SEED_SHOP_NAME = 'Seed Sandbox Shop'
const options = parseArgs(process.argv.slice(2))
if (options.help) {
printHelp()
process.exit(0)
}
try {
printPreview()
if (!options.apply) {
console.log('\n当前为预览模式。确认后追加 `--apply`,需要清库时再加 `--reset`。')
process.exit(0)
}
await runDatabaseMigrations()
await ensureFulfillmentCatalogBootstrapped()
if (options.reset) {
await query(`TRUNCATE TABLE ${RESET_TABLES.join(', ')} RESTART IDENTITY CASCADE`)
console.log('\n已先清空测试业务数据。')
}
await ensureSeedBindings()
await createSeedInventory()
const manualPending = await seedScenario({
scenarioKey: 'manual_pending',
orderLabel: 'SEED-MANUAL-PENDING',
skuCode: 'seed-manual-pending',
skuName: '开发种子-人工履约待处理',
})
const manualCompleted = await seedScenario({
scenarioKey: 'manual_completed',
orderLabel: 'SEED-MANUAL-COMPLETED',
skuCode: 'seed-manual-completed',
skuName: '开发种子-人工履约已完成',
afterCreate: async (result) => {
const task = result.tasks[0]
if (!task) {
return null
}
await completeAdminTaskManualDispatch(task.id, {
outcome: 'delivered',
resultMessage: '开发种子数据:人工履约已完成',
deliveryReference: 'SEED-MANUAL-REF-001',
deliveredCredential: '开发种子数据:已通过人工渠道发放',
})
return getTaskById(task.id)
},
})
const claimReady = await seedScenario({
scenarioKey: 'claim_ready',
orderLabel: 'SEED-CLAIM-READY',
skuCode: 'seed-claim-ready',
skuName: '开发种子-领取兑换可用',
})
const claimClaimed = await seedScenario({
scenarioKey: 'claim_claimed',
orderLabel: 'SEED-CLAIM-CLAIMED',
skuCode: 'seed-claim-claimed',
skuName: '开发种子-已打开领取链接',
afterCreate: async (result) => applyClaimedState(result.tasks[0]),
})
const claimRoleConfirmed = await seedScenario({
scenarioKey: 'claim_role_confirmed',
orderLabel: 'SEED-CLAIM-ROLE',
skuCode: 'seed-claim-role-confirmed',
skuName: '开发种子-已确认角色',
afterCreate: async (result) => applyRoleConfirmedState(result.tasks[0]),
})
const claimRetryPending = await seedScenario({
scenarioKey: 'claim_retry_pending',
orderLabel: 'SEED-CLAIM-RETRY',
skuCode: 'seed-claim-retry',
skuName: '开发种子-等待重试',
afterCreate: async (result) => applyRetryPendingState(result.tasks[0]),
})
const claimExpired = await seedScenario({
scenarioKey: 'claim_expired',
orderLabel: 'SEED-CLAIM-EXPIRED',
skuCode: 'seed-claim-expired',
skuName: '开发种子-领取链接已过期',
afterCreate: async (result) => applyExpiredState(result.tasks[0]),
})
const claimWaitingInventory = await seedScenario({
scenarioKey: 'claim_waiting_inventory',
orderLabel: 'SEED-CLAIM-WAIT',
skuCode: 'seed-claim-wait',
skuName: '开发种子-等待库存',
})
printResultSummary([
manualPending,
manualCompleted,
claimReady,
claimClaimed,
claimRoleConfirmed,
claimRetryPending,
claimExpired,
claimWaitingInventory,
])
} catch (error) {
console.error('\n写入开发种子数据失败:', error instanceof Error ? error.message : String(error || '未知错误'))
process.exitCode = 1
} finally {
await closeDb()
}
async function ensureSeedBindings() {
const timestamp = nowIso()
const manualProfile = await getRequiredProfile('manual_review')
const claimProfile = await getRequiredProfile('tencent_claim_redeem')
const bindings = [
{ skuCode: 'seed-manual-pending', profileId: manualProfile.id, priority: 10 },
{ skuCode: 'seed-manual-completed', profileId: manualProfile.id, priority: 20 },
{ skuCode: 'seed-claim-ready', profileId: claimProfile.id, priority: 30 },
{ skuCode: 'seed-claim-claimed', profileId: claimProfile.id, priority: 40 },
{ skuCode: 'seed-claim-role-confirmed', profileId: claimProfile.id, priority: 50 },
{ skuCode: 'seed-claim-retry', profileId: claimProfile.id, priority: 60 },
{ skuCode: 'seed-claim-expired', profileId: claimProfile.id, priority: 70 },
{ skuCode: 'seed-claim-wait', profileId: claimProfile.id, priority: 80 },
]
for (const binding of bindings) {
await upsertSkuFulfillmentBinding({
skuCode: binding.skuCode,
provider: SEED_PROVIDER,
platform: SEED_PLATFORM,
shopId: SEED_SHOP_ID,
profileId: binding.profileId,
enabled: true,
priority: binding.priority,
configJson: JSON.stringify({
seededBy: 'scripts/seed-dev-data.ts',
scenario: binding.skuCode,
}),
createdAt: timestamp,
updatedAt: timestamp,
})
}
}
async function createSeedInventory() {
const timestamp = nowIso()
await createInventoryItems([
{
skuCode: 'seed-claim-ready',
displayValue: 'SEED-CLAIM-CODE-READY-001',
batchNo: 'seed-20260409',
credentialType: 'tencent_code',
payload: {
code: 'SEED-CLAIM-CODE-READY-001',
scenario: 'claim_ready',
},
createdAt: timestamp,
updatedAt: timestamp,
},
{
skuCode: 'seed-claim-claimed',
displayValue: 'SEED-CLAIM-CODE-CLAIMED-001',
batchNo: 'seed-20260409',
credentialType: 'tencent_code',
payload: {
code: 'SEED-CLAIM-CODE-CLAIMED-001',
scenario: 'claim_claimed',
},
createdAt: timestamp,
updatedAt: timestamp,
},
{
skuCode: 'seed-claim-role-confirmed',
displayValue: 'SEED-CLAIM-CODE-ROLE-001',
batchNo: 'seed-20260409',
credentialType: 'tencent_code',
payload: {
code: 'SEED-CLAIM-CODE-ROLE-001',
scenario: 'claim_role_confirmed',
},
createdAt: timestamp,
updatedAt: timestamp,
},
{
skuCode: 'seed-claim-retry',
displayValue: 'SEED-CLAIM-CODE-RETRY-001',
batchNo: 'seed-20260409',
credentialType: 'tencent_code',
payload: {
code: 'SEED-CLAIM-CODE-RETRY-001',
scenario: 'claim_retry_pending',
},
createdAt: timestamp,
updatedAt: timestamp,
},
{
skuCode: 'seed-claim-expired',
displayValue: 'SEED-CLAIM-CODE-EXPIRED-001',
batchNo: 'seed-20260409',
credentialType: 'tencent_code',
payload: {
code: 'SEED-CLAIM-CODE-EXPIRED-001',
scenario: 'claim_expired',
},
createdAt: timestamp,
updatedAt: timestamp,
},
{
skuCode: 'seed-spare-available',
displayValue: 'SEED-SPARE-CODE-001',
batchNo: 'seed-20260409',
credentialType: 'tencent_code',
payload: {
code: 'SEED-SPARE-CODE-001',
scenario: 'spare_available',
},
createdAt: timestamp,
updatedAt: timestamp,
},
])
}
async function seedScenario({ scenarioKey, orderLabel, skuCode, skuName, afterCreate = null }) {
const result = await upsertOrderFromWebhook(buildSeedOrderEvent({
scenarioKey,
orderLabel,
skuCode,
skuName,
}))
const task = typeof afterCreate === 'function'
? await afterCreate(result)
: result.tasks[0] || null
return {
scenarioKey,
orderId: result.order.id,
platformOrderId: result.order.platform_order_id,
taskId: task?.id || null,
taskNo: task?.task_no || '',
taskStatus: task?.task_status || '',
deliveryStatus: task?.delivery_status || '',
inventoryStatus: task?.inventory_status || '',
inventoryItemId: Number(task?.primary_inventory_item_id || 0) || null,
claimTokenId: Number(task?.primary_claim_token_id || 0) || null,
executorKey: String(task?.executor_key || '').trim(),
}
}
async function applyClaimedState(task) {
if (!task) {
return null
}
const now = nowIso()
await updateTask(task.id, {
task_status: 'claimed',
user_action_status: 'claimed',
browser_session_id: `seed-browser-${task.id}`,
login_type: 'qq',
claimed_at: now,
updated_at: now,
last_error: '',
})
return getTaskById(task.id)
}
async function applyRoleConfirmedState(task) {
if (!task) {
return null
}
const claimedAt = addHours(nowIso(), -1)
const roleConfirmedAt = nowIso()
await updateTask(task.id, {
task_status: 'role_confirmed',
user_action_status: 'role_confirmed',
browser_session_id: `seed-browser-${task.id}`,
login_type: 'qq',
nickname: 'SeedHero',
role_id: 'seed-role-1001',
role_name: '开发角色-已确认',
area: 'seed-area',
partition_name: 'seed-partition',
claimed_at: claimedAt,
role_confirmed_at: roleConfirmedAt,
updated_at: roleConfirmedAt,
last_error: '',
})
return getTaskById(task.id)
}
async function applyRetryPendingState(task) {
if (!task) {
return null
}
const claimedAt = addHours(nowIso(), -2)
const roleConfirmedAt = addHours(nowIso(), -1)
const updatedAt = nowIso()
await updateTask(task.id, {
task_status: 'retry_pending',
delivery_status: 'pending',
inventory_status: 'reserved',
user_action_status: 'role_confirmed',
browser_session_id: `seed-browser-${task.id}`,
login_type: 'qq',
nickname: 'SeedRetryUser',
role_id: 'seed-role-retry',
role_name: '开发角色-待重试',
area: 'seed-area',
partition_name: 'seed-partition',
claimed_at: claimedAt,
role_confirmed_at: roleConfirmedAt,
attempt_count: 2,
last_error: '开发种子数据:模拟兑换失败,等待重试',
updated_at: updatedAt,
})
return getTaskById(task.id)
}
async function applyExpiredState(task) {
if (!task) {
return null
}
const now = nowIso()
if (task.primary_claim_token_id) {
await updateClaimToken(task.primary_claim_token_id, {
status: 'expired',
expired_at: addHours(now, -2),
updated_at: now,
})
}
if (task.primary_inventory_item_id) {
await releaseReservedInventoryItem(task.primary_inventory_item_id, now)
}
await updateTask(task.id, {
task_status: 'expired',
inventory_status: 'pending',
user_action_status: 'expired',
last_error: '领取链接已过期,预占库存项已释放',
updated_at: now,
})
return getTaskById(task.id)
}
function buildSeedOrderEvent({ scenarioKey, orderLabel, skuCode, skuName }) {
const timestamp = nowIso()
return {
provider: SEED_PROVIDER,
platform: SEED_PLATFORM,
shopId: SEED_SHOP_ID,
shopName: SEED_SHOP_NAME,
platformOrderId: orderLabel,
orderStatus: 'paid',
payStatus: 'paid',
buyerId: `${scenarioKey}-buyer`,
buyerName: `开发种子-${scenarioKey}`,
receiverContact: `seed+${scenarioKey}@example.com`,
totalAmount: 100,
currency: 'CNY',
paidAt: timestamp,
rawPayload: {
source: 'seed-dev-data',
scenarioKey,
platformOrderId: orderLabel,
},
items: [
{
skuCode,
skuName,
quantity: 1,
spec: {
title: skuName,
scenarioKey,
},
snapshot: {
title: skuName,
scenarioKey,
seeded: true,
},
},
],
}
}
async function getRequiredProfile(profileKey) {
const profile = await getFulfillmentProfileByKey(profileKey)
if (!profile) {
throw new Error(`缺少履约档案: ${profileKey}`)
}
return profile
}
function parseArgs(argv) {
return {
apply: argv.includes('--apply'),
reset: argv.includes('--reset'),
help: argv.includes('-h') || argv.includes('--help'),
}
}
function printHelp() {
console.log(`
用法:
npm run seed:dev-data -- [--apply] [--reset]
说明:
基于当前 PostgreSQL fulfillment schema 生成一组可重复验证的开发种子数据。
场景:
- manual_pending: 人工履约待处理
- manual_completed: 人工履约已完成
- claim_ready: 已预占库存并生成领取链接
- claim_claimed: 用户已打开领取链接
- claim_role_confirmed: 用户已确认角色
- claim_retry_pending: 自动履约失败等待重试
- claim_expired: 领取链接已过期
- claim_waiting_inventory: 等待库存
参数:
--apply 实际写入数据
--reset 写入前先清空测试业务数据
数据库连接:
${String(runtimeConfig.database?.url || '').trim() || '(未配置 DATABASE_URL)'}
`.trim())
}
function printPreview() {
console.log('准备生成开发种子数据:')
console.log('- manual_pending -> 人工履约待处理')
console.log('- manual_completed -> 人工履约已完成')
console.log('- claim_ready -> 领取兑换已就绪')
console.log('- claim_claimed -> 用户已打开领取链接')
console.log('- claim_role_confirmed -> 用户已确认角色')
console.log('- claim_retry_pending -> 自动履约失败等待重试')
console.log('- claim_expired -> 领取链接已过期')
console.log('- claim_waiting_inventory -> 等待库存')
console.log(`- database: ${String(runtimeConfig.database?.url || '').trim() || '(未配置 DATABASE_URL)'}`)
}
function printResultSummary(rows) {
console.log('\n开发种子数据已写入:')
for (const row of rows) {
console.log(`- ${row.scenarioKey}`)
console.log(` orderId=${row.orderId} platformOrderId=${row.platformOrderId}`)
console.log(` taskId=${row.taskId || '-'} taskNo=${row.taskNo || '-'} executor=${row.executorKey || '-'}`)
console.log(` taskStatus=${row.taskStatus || '-'} deliveryStatus=${row.deliveryStatus || '-'} inventoryStatus=${row.inventoryStatus || '-'}`)
console.log(` inventoryItemId=${row.inventoryItemId || '-'} claimTokenId=${row.claimTokenId || '-'}`)
}
console.log('\n建议验证入口:')
console.log('- `/api/v1/admin/orders?platformOrderId=SEED-`')
console.log('- `/api/v1/admin/tasks?taskNo=DT`')
console.log('- `/api/v1/admin/inventory`')
}
@@ -1,97 +1,5 @@
#!/bin/sh #!/bin/sh
set -eu set -eu
is_true() {
normalized=$(printf '%s' "${1:-}" | tr '[:upper:]' '[:lower:]')
case "$normalized" in
1|true|yes|on)
return 0
;;
*)
return 1
;;
esac
}
start_browser_debug_services() {
display="${TENCENT_BROWSER_DISPLAY:-:99}"
resolution="${TENCENT_BROWSER_VNC_RESOLUTION:-1600x900x24}"
display_number="${display#:}"
display_number="${display_number%%.*}"
display_socket="/tmp/.X11-unix/X${display_number}"
display_lock="/tmp/.X${display_number}-lock"
if [ -S "$display_socket" ] && [ ! -f "$display_lock" ]; then
rm -f "$display_socket"
fi
if [ -f "$display_lock" ]; then
display_pid=$(cat "$display_lock" 2>/dev/null || true)
if [ -z "$display_pid" ] || ! kill -0 "$display_pid" 2>/dev/null; then
rm -f "$display_lock" "$display_socket"
fi
fi
export DISPLAY="$display"
Xvfb "$display" -screen 0 "$resolution" -nolisten tcp >/tmp/xvfb.log 2>&1 &
xvfb_pid=$!
ready=0
attempt=0
while [ "$attempt" -lt 50 ]; do
if [ -S "$display_socket" ]; then
ready=1
break
fi
if ! kill -0 "$xvfb_pid" 2>/dev/null; then
break
fi
attempt=$((attempt + 1))
sleep 0.1
done
if [ "$ready" -ne 1 ]; then
echo "[backend-dev] Xvfb display ${DISPLAY} not ready for x11vnc; skip noVNC startup" >&2
return
fi
if ! is_true "${TENCENT_BROWSER_NOVNC_ENABLED:-true}"; then
echo "[backend-dev] headed browser enabled on ${DISPLAY}; noVNC disabled"
return
fi
vnc_port="${TENCENT_BROWSER_VNC_PORT:-5900}"
novnc_port="${TENCENT_BROWSER_NOVNC_PORT:-6080}"
x11vnc \
-display "$display" \
-rfbport "$vnc_port" \
-forever \
-shared \
-nopw \
-listen 0.0.0.0 \
-xkb >/tmp/x11vnc.log 2>&1 &
if command -v novnc_proxy >/dev/null 2>&1; then
novnc_proxy --listen "$novnc_port" --vnc "127.0.0.1:${vnc_port}" >/tmp/novnc.log 2>&1 &
elif [ -x /usr/share/novnc/utils/novnc_proxy ]; then
/usr/share/novnc/utils/novnc_proxy --listen "$novnc_port" --vnc "127.0.0.1:${vnc_port}" >/tmp/novnc.log 2>&1 &
elif command -v websockify >/dev/null 2>&1 && [ -d /usr/share/novnc ]; then
websockify --web=/usr/share/novnc "$novnc_port" "127.0.0.1:${vnc_port}" >/tmp/novnc.log 2>&1 &
else
echo "[backend-dev] noVNC requested but novnc/websockify not found"
return
fi
echo "[backend-dev] headed browser enabled on ${DISPLAY}; noVNC: http://127.0.0.1:${novnc_port}/vnc.html"
}
npm install --no-fund --no-audit npm install --no-fund --no-audit
if ! is_true "${TENCENT_BROWSER_HEADLESS:-true}"; then
start_browser_debug_services
fi
exec npm run dev exec npm run dev
@@ -1,288 +0,0 @@
#!/usr/bin/env node
import crypto from 'node:crypto'
import process from 'node:process'
import { fileURLToPath } from 'node:url'
import { setTimeout as sleep } from 'node:timers/promises'
import { query, closeDb } from '../src/db/client.js'
import { parseJsonObject } from '../src/utils/json.js'
import { getAgisoShopConfig } from '../src/services/platforms/agiso/shop-config-service.js'
import { queryAgisoXianyuOrderDetail } from '../src/services/platforms/agiso/xianyu/order-detail-service.js'
const DEFAULT_DUMMY_ENDPOINT = 'https://gw-api.agiso.com/aldsIdle/Order/DummySend'
const DEFAULT_POLL_SECONDS = [0, 3, 10]
async function main() {
const options = parseArgs(process.argv.slice(2))
if (options.help || !options.orderId) {
printHelp()
process.exit(options.help ? 0 : 1)
}
const orderRecord = await resolveOrderRecord(options)
const shopId = orderRecord?.shop_id || options.shopId
const shopConfig = getAgisoShopConfig(shopId) || {}
const accessToken = String(shopConfig.accessToken || '').trim()
const appSecret = String(shopConfig.appSecret || process.env.AGISO_APP_SECRET || '').trim()
if (!shopId) {
throw new Error('未提供 shopId,且数据库中也没有查到该订单对应的店铺')
}
if (!accessToken) {
throw new Error(`店铺 ${shopId} 缺少 accessToken 配置,请检查 apps/backend/data/agiso-shops.json`)
}
if (!appSecret) {
throw new Error('缺少 AGISO_APP_SECRET 或店铺级 appSecret 配置')
}
printJson('测试输入', {
orderId: options.orderId,
shopId,
pollSeconds: options.pollSeconds,
orderRecord: orderRecord
? {
id: Number(orderRecord.id || 0) || null,
platformOrderId: String(orderRecord.platform_order_id || '').trim(),
orderStatus: String(orderRecord.order_status || '').trim(),
payStatus: String(orderRecord.pay_status || '').trim(),
provider: String(orderRecord.provider || '').trim(),
platform: String(orderRecord.platform || '').trim(),
}
: null,
})
const beforeDetail = await queryAgisoXianyuOrderDetail({
shopId,
platformOrderId: options.orderId,
requestId: `diag-before-${Date.now()}`,
})
printJson('调用前订单详情', summarizeDetail(beforeDetail))
const result = await executeRequest({
platformOrderId: options.orderId,
accessToken,
appSecret,
})
printJson('接口响应 更新发货状态 DummySend', result)
for (const seconds of options.pollSeconds) {
if (seconds > 0) {
await sleep(seconds * 1000)
}
const detail = await queryAgisoXianyuOrderDetail({
shopId,
platformOrderId: options.orderId,
requestId: `diag-after-${seconds}s-${Date.now()}`,
})
printJson(`调用后订单详情 +${seconds}s`, summarizeDetail(detail))
}
}
function parseArgs(argv) {
const output = {
orderId: '',
shopId: '',
pollSeconds: [...DEFAULT_POLL_SECONDS],
help: false,
}
for (let index = 0; index < argv.length; index += 1) {
const current = String(argv[index] || '').trim()
if (!current) {
continue
}
if (!current.startsWith('--')) {
if (!output.orderId) {
output.orderId = current
continue
}
throw new Error(`无法识别的参数:${current}`)
}
if (current === '--help') {
output.help = true
continue
}
const [rawKey, inlineValue = ''] = current.split('=', 2)
const key = rawKey.slice(2)
const nextValue = inlineValue || argv[index + 1] || ''
const shouldConsumeNext = !inlineValue && argv[index + 1] && !String(argv[index + 1]).startsWith('--')
switch (key) {
case 'shop-id':
output.shopId = String(nextValue || '').trim()
break
case 'poll':
output.pollSeconds = parsePollSeconds(nextValue)
break
default:
throw new Error(`无法识别的参数:${current}`)
}
if (shouldConsumeNext) {
index += 1
}
}
return output
}
function parsePollSeconds(value) {
const normalized = String(value || '').trim()
if (!normalized) {
return [...DEFAULT_POLL_SECONDS]
}
const parsed = normalized
.split(',')
.map((item) => Number(String(item || '').trim()))
.filter((item) => Number.isFinite(item) && item >= 0)
.map((item) => Math.floor(item))
if (parsed.length === 0) {
throw new Error(`poll 参数格式无效:${value}`)
}
return parsed
}
async function resolveOrderRecord(options) {
const explicitShopId = String(options.shopId || '').trim()
if (explicitShopId) {
const result = await query(
`
SELECT *
FROM orders
WHERE provider = 'agiso'
AND platform = 'xianyu'
AND shop_id = $1
AND platform_order_id = $2
ORDER BY id DESC
LIMIT 1
`,
[explicitShopId, options.orderId],
)
return result.rows[0] || null
}
const result = await query(
`
SELECT *
FROM orders
WHERE provider = 'agiso'
AND platform = 'xianyu'
AND platform_order_id = $1
ORDER BY id DESC
LIMIT 2
`,
[options.orderId],
)
if (result.rows.length > 1) {
throw new Error(`数据库里命中多条同订单号记录,请显式传入 --shop-id。订单号:${options.orderId}`)
}
return result.rows[0] || null
}
async function executeRequest(context) {
const timestamp = String(Math.floor(Date.now() / 1000))
const requestBody = buildDummySendBody(context, timestamp)
const response = await fetch(DEFAULT_DUMMY_ENDPOINT, {
method: 'POST',
headers: {
Authorization: `Bearer ${context.accessToken}`,
ApiVersion: '1',
'content-type': 'application/x-www-form-urlencoded; charset=utf-8',
},
body: new URLSearchParams(requestBody).toString(),
})
const rawText = await response.text()
const payload = parseJsonObject(rawText, { preserveLargeIntegers: true })
return {
endpoint: DEFAULT_DUMMY_ENDPOINT,
status: response.status,
requestBody,
response: payload,
rawText,
}
}
function buildDummySendBody(context, timestamp) {
const payload = {
tid: context.platformOrderId,
timestamp,
}
payload.sign = generateSign(payload, context.appSecret)
return payload
}
function generateSign(params, appSecret) {
let raw = String(appSecret || '').trim()
for (const [key, value] of Object.entries(params).sort(([left], [right]) => left.localeCompare(right))) {
raw += `${key}${value}`
}
raw += String(appSecret || '').trim()
return crypto.createHash('md5').update(raw, 'utf8').digest('hex').toLowerCase()
}
function summarizeDetail(detailResult) {
return {
success: Boolean(detailResult?.success),
reason: String(detailResult?.reason || '').trim(),
errorMessage: String(detailResult?.errorMessage || '').trim(),
responseStatus: Number(detailResult?.responseStatus || 0),
shipped: Boolean(detailResult?.shipped),
shipTime: Number(detailResult?.shipTime || 0),
orderStatus: Number(detailResult?.orderStatus || 0),
bizOrderId: String(detailResult?.detailPayload?.biz_order_id || '').trim(),
sellerNick: String(detailResult?.detailPayload?.seller_nick || '').trim(),
buyerNick: String(detailResult?.detailPayload?.buyer_nick || '').trim(),
sku: String(detailResult?.detailPayload?.sku || '').trim(),
itemTitle: String(detailResult?.detailPayload?.item?.title || '').trim(),
}
}
function printJson(title, value) {
process.stdout.write(`\n=== ${title} ===\n`)
process.stdout.write(`${JSON.stringify(value, null, 2)}\n`)
}
function printHelp() {
process.stdout.write(`用法:
npm run agiso:auto-delivery:test -- <订单号> [--shop-id <店铺ID>] [--poll 0,3,10]
说明:
1. 该脚本只测试正确的发货接口 DummySend。
2. 会先查 Order/Detail,再调用 DummySend,再按 poll 秒数轮询发货状态。
示例:
npm run agiso:auto-delivery:test -- 4502285714045005830
npm run agiso:auto-delivery:test -- 4502285714045005830 --poll 0,5,15
npm run agiso:auto-delivery:test -- 4502285714045005830 --shop-id 2209880145223
`)
}
const isNodeTestRunner = Array.isArray(process.execArgv) && process.execArgv.includes('--test')
const isDirectRun = !isNodeTestRunner && process.argv[1] && fileURLToPath(import.meta.url) === process.argv[1]
if (isDirectRun) {
main()
.catch((error) => {
process.stderr.write(`${error instanceof Error ? error.stack || error.message : String(error)}\n`)
process.exitCode = 1
})
.finally(async () => {
await closeDb()
})
}
-98
View File
@@ -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}&timestamp=${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
-101
View File
@@ -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;
}
+5 -7
View File
@@ -1,17 +1,16 @@
import type { Request, Response, NextFunction } from "express"; import type { NextFunction, Request, Response } from "express";
import express from "express"; import express from "express";
import process from "node:process"; import process from "node:process";
import adminRouter from "./routes/admin.js"; import adminRouter from "./routes/admin.js";
import claimsRouter from "./routes/claims.js"; import claimsRouter from "./routes/claims.js";
import open91Router from "./routes/open-91.js"; import open91Router from "./routes/open-91.js";
import tencentRouter from "./routes/tencent.js";
import webhooksRouter from "./routes/webhooks.js";
import { buildSuccessPayload, sendRouteError } from "./utils/http.js";
import { accessLogMiddleware } from "./middleware/access-log.js"; import { accessLogMiddleware } from "./middleware/access-log.js";
import { createCorsMiddleware } from "./middleware/cors.js"; import { createCorsMiddleware } from "./middleware/cors.js";
import type { RuntimeConfig } from "./types/runtime-config.js";
import { buildHealthPayload, type StartupState } from "./startup/state.js"; import { buildHealthPayload, type StartupState } from "./startup/state.js";
import type { RuntimeConfig } from "./types/runtime-config.js";
import { buildSuccessPayload, sendRouteError } from "./utils/http.js";
type CreateAppOptions = { type CreateAppOptions = {
startupState: StartupState; startupState: StartupState;
isShutdownStarted: () => boolean; isShutdownStarted: () => boolean;
@@ -44,6 +43,7 @@ export function createApp({
buildSuccessPayload({ buildSuccessPayload({
status: isShutdownStarted() ? "shutting_down" : "alive", status: isShutdownStarted() ? "shutting_down" : "alive",
pid: process.pid, pid: process.pid,
mode: "kuaishou-lite",
}) })
); );
}); });
@@ -89,9 +89,7 @@ export function createApp({
}); });
}); });
app.use("/api/v1/tencent", tencentRouter);
app.use("/api/v1/open/91", open91Router); app.use("/api/v1/open/91", open91Router);
app.use("/api/v1/webhooks", webhooksRouter);
app.use("/api/v1/claim", claimsRouter); app.use("/api/v1/claim", claimsRouter);
app.use("/api/v1/admin", adminRouter); app.use("/api/v1/admin", adminRouter);
-46
View File
@@ -8,24 +8,6 @@ export function createDefaultRuntimeConfig(projectRoot: string): RuntimeConfig {
port: 3000, port: 3000,
}, },
browser: {
chromePath: "",
headless: null,
devtools: null,
keepAlive: null,
prewarm: null,
slowMoMs: null,
vncResolution: "1600x900x24",
},
session: {
debug: false,
},
ocr: {
baseUrl: "",
},
data: { data: {
root: path.resolve(projectRoot, "data"), root: path.resolve(projectRoot, "data"),
}, },
@@ -52,30 +34,6 @@ export function createDefaultRuntimeConfig(projectRoot: string): RuntimeConfig {
}, },
platforms: { platforms: {
agiso: {
appSecret: "",
tradeDetail: {
endpoint: "https://gw-api.agiso.com/aldsIdle/Order/Detail",
apiVersion: "1",
timeoutMs: 5000,
},
autoDelivery: {
enabled: true,
endpoint: "https://gw-api.agiso.com/aldsIdle/Order/DummySend",
apiVersion: "1",
},
messaging: {
enabled: false,
sendMessageEndpoint:
"https://gw-api.agiso.com/aldsIdle/ImMsg/SendMsg",
apiVersion: "1",
messageTemplate:
"您的订单 {platformOrderId} 已创建领取链接,请在 {expiredAt} 前完成领取:{claimUrl}",
autoDeliveryMessageTemplate:
"您的订单 {platformOrderId} 已完成自动发货,请注意查收。",
shops: {},
},
},
ninetyone: { ninetyone: {
userId: "", userId: "",
secret: "", secret: "",
@@ -137,9 +95,5 @@ PfQTPNA++KsXtwRX9M6Re3vkTDRsutIIWKtj8jqhUbbYS3vzS8GJnAWavUFVkR15
cors: { cors: {
allowedOrigins: ["*"], allowedOrigins: ["*"],
}, },
redeem: {
proofMode: "full",
},
}; };
} }
@@ -21,17 +21,13 @@ const DEPLOYMENT_ONLY_ENV_NAMES = [
'CADDY_SITE_ADDR', 'CADDY_SITE_ADDR',
'CHOKIDAR_USEPOLLING', 'CHOKIDAR_USEPOLLING',
'NPM_CONFIG_REGISTRY', 'NPM_CONFIG_REGISTRY',
'OCR_IMAGE',
'OCR_PORT',
'POSTGRES_DB', 'POSTGRES_DB',
'POSTGRES_PASSWORD', 'POSTGRES_PASSWORD',
'POSTGRES_PORT', 'POSTGRES_PORT',
'POSTGRES_USER', 'POSTGRES_USER',
'TENCENT_BROWSER_NOVNC_ENABLED',
'TENCENT_BROWSER_NOVNC_PORT',
'TENCENT_BROWSER_VNC_PORT',
'TZ', 'TZ',
'VITE_API_TARGET', 'VITE_API_TARGET',
'VITE_ALLOWED_HOSTS',
] ]
const KNOWN_ENV_NAMES = new Set([ const KNOWN_ENV_NAMES = new Set([
@@ -10,32 +10,21 @@ test('applyEnvOverrides maps typed environment values without mutating base conf
const config = applyEnvOverrides(baseConfig, { const config = applyEnvOverrides(baseConfig, {
PORT: '4000', PORT: '4000',
TENCENT_BROWSER_HEADLESS: 'true',
TENCENT_BROWSER_SLOW_MO: '250',
TENCENT_BROWSER_VNC_RESOLUTION: '1920x1080x24',
DATA_ROOT: './tmp/backend-data', DATA_ROOT: './tmp/backend-data',
DATABASE_SSL: 'yes', DATABASE_SSL: 'yes',
ADMIN_DEFAULT_USERS_JSON: '[{"username":"admin","password":"secret","role":"admin"}]', ADMIN_DEFAULT_USERS_JSON: '[{"username":"admin","password":"secret","role":"admin"}]',
AGISO_TRADE_DETAIL_TIMEOUT_MS: '7000',
CLOUDTENTACLES_DEVICE_TYPE: '3', CLOUDTENTACLES_DEVICE_TYPE: '3',
TENCENT_REDEEM_PROOF_MODE: 'basic',
}) })
assert.equal(config.server.port, 4000) assert.equal(config.server.port, 4000)
assert.equal(config.browser.headless, true)
assert.equal(config.browser.slowMoMs, 250)
assert.equal(config.browser.vncResolution, '1920x1080x24')
assert.equal(config.data.root, path.resolve('./tmp/backend-data')) assert.equal(config.data.root, path.resolve('./tmp/backend-data'))
assert.equal(config.database.ssl, true) assert.equal(config.database.ssl, true)
assert.deepEqual(config.admin.defaultUsers, [ assert.deepEqual(config.admin.defaultUsers, [
{ username: 'admin', password: 'secret', role: 'admin' }, { username: 'admin', password: 'secret', role: 'admin' },
]) ])
assert.equal(config.platforms.agiso.tradeDetail.timeoutMs, 7000)
assert.equal(config.platforms.cloudtentacles.deviceType, 3) assert.equal(config.platforms.cloudtentacles.deviceType, 3)
assert.equal(config.redeem.proofMode, 'basic')
assert.equal(baseConfig.server.port, 3000) assert.equal(baseConfig.server.port, 3000)
assert.equal(baseConfig.browser.headless, null)
assert.deepEqual(baseConfig.admin.defaultUsers, []) assert.deepEqual(baseConfig.admin.defaultUsers, [])
}) })
@@ -80,21 +69,6 @@ function createRuntimeConfig(): RuntimeConfig {
server: { server: {
port: 3000, port: 3000,
}, },
browser: {
chromePath: '',
headless: null,
devtools: null,
keepAlive: null,
prewarm: null,
slowMoMs: null,
vncResolution: '1600x900x24',
},
session: {
debug: false,
},
ocr: {
baseUrl: '',
},
data: { data: {
root: '/tmp/order-site', root: '/tmp/order-site',
}, },
@@ -116,27 +90,6 @@ function createRuntimeConfig(): RuntimeConfig {
defaultUsers: [], defaultUsers: [],
}, },
platforms: { platforms: {
agiso: {
appSecret: '',
tradeDetail: {
endpoint: 'https://gw-api.agiso.com/aldsIdle/Order/Detail',
apiVersion: '1',
timeoutMs: 5000,
},
autoDelivery: {
enabled: true,
endpoint: 'https://gw-api.agiso.com/aldsIdle/Order/DummySend',
apiVersion: '1',
},
messaging: {
enabled: false,
sendMessageEndpoint: 'https://gw-api.agiso.com/aldsIdle/ImMsg/SendMsg',
apiVersion: '1',
messageTemplate: '',
autoDeliveryMessageTemplate: '',
shops: {},
},
},
ninetyone: { ninetyone: {
userId: '', userId: '',
secret: '', secret: '',
@@ -184,8 +137,5 @@ function createRuntimeConfig(): RuntimeConfig {
deviceType: 0, deviceType: 0,
}, },
}, },
redeem: {
proofMode: 'full',
},
} }
} }
-65
View File
@@ -30,15 +30,6 @@ type EnvOverride = {
export const ENV_OVERRIDES: readonly EnvOverride[] = [ export const ENV_OVERRIDES: readonly EnvOverride[] = [
integerEnv("PORT", ["server", "port"]), integerEnv("PORT", ["server", "port"]),
stringEnv("CHROME_PATH", ["browser", "chromePath"]),
booleanEnv("TENCENT_BROWSER_HEADLESS", ["browser", "headless"]),
booleanEnv("TENCENT_BROWSER_DEVTOOLS", ["browser", "devtools"]),
booleanEnv("TENCENT_BROWSER_KEEP_ALIVE", ["browser", "keepAlive"]),
booleanEnv("TENCENT_BROWSER_PREWARM", ["browser", "prewarm"]),
integerEnv("TENCENT_BROWSER_SLOW_MO", ["browser", "slowMoMs"]),
stringEnv("TENCENT_BROWSER_VNC_RESOLUTION", ["browser", "vncResolution"]),
booleanEnv("TENCENT_SESSION_DEBUG", ["session", "debug"]),
stringEnv("OCR_BASE_URL", ["ocr", "baseUrl"]),
stringEnv("DATA_ROOT", ["data", "root"], path.resolve), stringEnv("DATA_ROOT", ["data", "root"], path.resolve),
stringEnv("LOG_LEVEL", ["logging", "level"]), stringEnv("LOG_LEVEL", ["logging", "level"]),
stringEnv("DATABASE_URL", ["database", "url"]), stringEnv("DATABASE_URL", ["database", "url"]),
@@ -48,61 +39,6 @@ export const ENV_OVERRIDES: readonly EnvOverride[] = [
stringEnv("ADMIN_SESSION_SECRET", ["admin", "sessionSecret"]), stringEnv("ADMIN_SESSION_SECRET", ["admin", "sessionSecret"]),
integerEnv("ADMIN_SESSION_TTL_HOURS", ["admin", "sessionTtlHours"]), integerEnv("ADMIN_SESSION_TTL_HOURS", ["admin", "sessionTtlHours"]),
adminUsersJsonEnv("ADMIN_DEFAULT_USERS_JSON", ["admin", "defaultUsers"]), adminUsersJsonEnv("ADMIN_DEFAULT_USERS_JSON", ["admin", "defaultUsers"]),
stringEnv("AGISO_APP_SECRET", ["platforms", "agiso", "appSecret"]),
stringEnv("AGISO_TRADE_DETAIL_ENDPOINT", [
"platforms",
"agiso",
"tradeDetail",
"endpoint",
]),
stringEnv("AGISO_TRADE_DETAIL_API_VERSION", [
"platforms",
"agiso",
"tradeDetail",
"apiVersion",
]),
integerEnv("AGISO_TRADE_DETAIL_TIMEOUT_MS", [
"platforms",
"agiso",
"tradeDetail",
"timeoutMs",
]),
booleanEnv("AGISO_AUTO_DELIVERY_ENABLED", [
"platforms",
"agiso",
"autoDelivery",
"enabled",
]),
stringEnv("AGISO_AUTO_DELIVERY_ENDPOINT", [
"platforms",
"agiso",
"autoDelivery",
"endpoint",
]),
stringEnv("AGISO_AUTO_DELIVERY_API_VERSION", [
"platforms",
"agiso",
"autoDelivery",
"apiVersion",
]),
stringEnv("AGISO_MESSAGE_API_VERSION", [
"platforms",
"agiso",
"messaging",
"apiVersion",
]),
stringEnv("AGISO_SEND_MESSAGE_ENDPOINT", [
"platforms",
"agiso",
"messaging",
"sendMessageEndpoint",
]),
booleanEnv("AGISO_MESSAGING_ENABLED", [
"platforms",
"agiso",
"messaging",
"enabled",
]),
stringEnv("NINETYONE_USER_ID", ["platforms", "ninetyone", "userId"]), stringEnv("NINETYONE_USER_ID", ["platforms", "ninetyone", "userId"]),
stringEnv("NINETYONE_SECRET", ["platforms", "ninetyone", "secret"]), stringEnv("NINETYONE_SECRET", ["platforms", "ninetyone", "secret"]),
stringEnv("NINETYONE_VERSION", ["platforms", "ninetyone", "version"]), stringEnv("NINETYONE_VERSION", ["platforms", "ninetyone", "version"]),
@@ -288,7 +224,6 @@ export const ENV_OVERRIDES: readonly EnvOverride[] = [
"cloudtentacles", "cloudtentacles",
"deviceType", "deviceType",
]), ]),
stringEnv("TENCENT_REDEEM_PROOF_MODE", ["redeem", "proofMode"]),
corsOriginsEnv("CORS_ALLOWED_ORIGINS", ["cors", "allowedOrigins"]), corsOriginsEnv("CORS_ALLOWED_ORIGINS", ["cors", "allowedOrigins"]),
]; ];
@@ -112,21 +112,6 @@ function createRuntimeConfig(overrides: Record<string, unknown> = {}): RuntimeCo
server: { server: {
port: 3000, port: 3000,
}, },
browser: {
chromePath: '',
headless: null,
devtools: null,
keepAlive: null,
prewarm: null,
slowMoMs: null,
vncResolution: '1600x900x24',
},
session: {
debug: false,
},
ocr: {
baseUrl: '',
},
data: { data: {
root: '/tmp/order-site', root: '/tmp/order-site',
}, },
@@ -154,27 +139,6 @@ function createRuntimeConfig(overrides: Record<string, unknown> = {}): RuntimeCo
], ],
}, },
platforms: { platforms: {
agiso: {
appSecret: '',
tradeDetail: {
endpoint: 'https://gw-api.agiso.com/aldsIdle/Order/Detail',
apiVersion: '1',
timeoutMs: 5000,
},
autoDelivery: {
enabled: true,
endpoint: 'https://gw-api.agiso.com/aldsIdle/Order/DummySend',
apiVersion: '1',
},
messaging: {
enabled: false,
sendMessageEndpoint: 'https://gw-api.agiso.com/aldsIdle/ImMsg/SendMsg',
apiVersion: '1',
messageTemplate: '',
autoDeliveryMessageTemplate: '',
shops: {},
},
},
ninetyone: { ninetyone: {
userId: '', userId: '',
secret: '', secret: '',
@@ -222,8 +186,5 @@ function createRuntimeConfig(overrides: Record<string, unknown> = {}): RuntimeCo
deviceType: 0, deviceType: 0,
}, },
}, },
redeem: {
proofMode: 'full',
},
}, overrides) as RuntimeConfig }, overrides) as RuntimeConfig
} }
@@ -47,7 +47,6 @@ export function validateRuntimeConfig(
requireInteger(issues, 'database.maxConnections', config.database?.maxConnections, { min: 1 }) requireInteger(issues, 'database.maxConnections', config.database?.maxConnections, { min: 1 })
requireInteger(issues, 'orders.tokenTtlHours', config.orders?.tokenTtlHours, { min: 1 }) requireInteger(issues, 'orders.tokenTtlHours', config.orders?.tokenTtlHours, { min: 1 })
requireInteger(issues, 'admin.sessionTtlHours', config.admin?.sessionTtlHours, { min: 1 }) requireInteger(issues, 'admin.sessionTtlHours', config.admin?.sessionTtlHours, { min: 1 })
requireInteger(issues, 'platforms.agiso.tradeDetail.timeoutMs', config.platforms?.agiso?.tradeDetail?.timeoutMs, { min: 1 })
requireInteger(issues, 'platforms.cloudtentacles.timeoutMs', config.platforms?.cloudtentacles?.timeoutMs, { min: 1 }) requireInteger(issues, 'platforms.cloudtentacles.timeoutMs', config.platforms?.cloudtentacles?.timeoutMs, { min: 1 })
requireInteger(issues, 'platforms.cloudtentacles.bindUrlTtlSeconds', config.platforms?.cloudtentacles?.bindUrlTtlSeconds, { min: 1 }) requireInteger(issues, 'platforms.cloudtentacles.bindUrlTtlSeconds', config.platforms?.cloudtentacles?.bindUrlTtlSeconds, { min: 1 })
requireInteger( requireInteger(
-71
View File
@@ -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);
});
+5 -1
View File
@@ -20,6 +20,7 @@ try {
logError("[startup]", "运行时配置校验失败,服务停止启动", error); logError("[startup]", "运行时配置校验失败,服务停止启动", error);
process.exit(1); process.exit(1);
} }
const app = createApp({ const app = createApp({
startupState, startupState,
isShutdownStarted: () => shutdownController?.isShutdownStarted() || false, isShutdownStarted: () => shutdownController?.isShutdownStarted() || false,
@@ -27,7 +28,10 @@ const app = createApp({
}); });
const server = app.listen(port, host, () => { const server = app.listen(port, host, () => {
logInfo("[startup]", `order-site-backend listening on http://${host}:${port}`); logInfo(
"[startup]",
`order-site-backend kuaishou-lite listening on http://${host}:${port}`
);
void bootstrapCoreServices( void bootstrapCoreServices(
startupState, startupState,
() => shutdownController?.isShutdownStarted() || false () => shutdownController?.isShutdownStarted() || false
-34
View File
@@ -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;
+28 -30
View File
@@ -1,36 +1,34 @@
import { Router } from 'express' import { Router } from "express";
import authRouter from './admin/auth.js' import authRouter from "./admin/auth.js";
import auditLogsRouter from './admin/audit-logs.js' import auditLogsRouter from "./admin/audit-logs.js";
import dashboardRouter from './admin/dashboard.js' import dashboardRouter from "./admin/dashboard.js";
import inventoryRouter from './admin/inventory.js' import inventoryRouter from "./admin/inventory.js";
import manualRedeemRouter from './admin/manual-redeem.js' import messageDeliveriesRouter from "./admin/message-deliveries.js";
import messageDeliveriesRouter from './admin/message-deliveries.js' import ordersRouter from "./admin/orders.js";
import ordersRouter from './admin/orders.js' import platformConfigRouter from "./admin/platform-config.js";
import platformConfigRouter from './admin/platform-config.js' import { requireAdminSession } from "./admin/shared.js";
import { requireAdminSession } from './admin/shared.js' import tasksRouter from "./admin/tasks.js";
import tasksRouter from './admin/tasks.js' import usersRouter from "./admin/users.js";
import usersRouter from './admin/users.js' import webhookEventsRouter from "./admin/webhook-events.js";
import webhookEventsRouter from './admin/webhook-events.js' import { buildNotFoundPayload } from "../utils/http.js";
import { buildNotFoundPayload } from '../utils/http.js'
const router = Router() const router = Router();
router.use(authRouter) router.use(authRouter);
router.use(requireAdminSession) router.use(requireAdminSession);
router.use(dashboardRouter) router.use(dashboardRouter);
router.use(usersRouter) router.use(usersRouter);
router.use(auditLogsRouter) router.use(auditLogsRouter);
router.use(platformConfigRouter) router.use(platformConfigRouter);
router.use(ordersRouter) router.use(ordersRouter);
router.use(tasksRouter) router.use(tasksRouter);
router.use(manualRedeemRouter) router.use(inventoryRouter);
router.use(inventoryRouter) router.use(messageDeliveriesRouter);
router.use(messageDeliveriesRouter) router.use(webhookEventsRouter);
router.use(webhookEventsRouter)
router.use((req, res) => { router.use((req, res) => {
res.status(404).json(buildNotFoundPayload(req)) res.status(404).json(buildNotFoundPayload(req));
}) });
export default router export default router;
@@ -1,149 +0,0 @@
import { Router } from "express";
import {
createAdminInventoryItem,
importAdminInventoryItems,
invalidateAdminInventoryItem,
releaseAdminInventoryItem,
} from "../../services/admin/write/inventory.js";
import {
getAdminInventoryItems,
getAdminInventorySkuSuggestions,
} from "../../services/admin/admin-read-service.js";
import { createJsonHandler, requireAdminRoles } from "./shared.js";
import type {
AdminInventoryCreateRouteBody,
AdminInventoryImportRouteBody,
AdminInventoryInvalidateRouteBody,
AdminInventoryRouteParams,
AdminInventoryRouteQuery,
AdminInventorySkuSuggestionRouteQuery,
} from "../../types/admin-route-inputs.js";
import type { AdminInventoryMutationResponse } from "../../types/admin-write-models.js";
const router = Router();
router.use("/inventory", requireAdminRoles(["admin", "operator"]));
router.get(
"/inventory",
createJsonHandler(
(req) =>
getAdminInventoryItems(
req.query as AdminInventoryRouteQuery,
req.adminSession || null
),
{
successMessage: "ok",
errorMessage: "读取库存列表失败",
scope: "[admin-kuaishou/inventory]",
}
)
);
router.get(
"/inventory/sku-suggestions",
createJsonHandler(
(req) =>
getAdminInventorySkuSuggestions(
req.query as AdminInventorySkuSuggestionRouteQuery,
req.adminSession || null
),
{
successMessage: "ok",
errorMessage: "读取内部 SKU 建议失败",
scope: "[admin-kuaishou/inventory/sku-suggestions]",
}
)
);
router.post(
"/inventory",
createJsonHandler(
(req) => createAdminInventoryItem(req.body as AdminInventoryCreateRouteBody),
{
successMessage: "库存项已新增",
errorMessage: "新增库存项失败",
scope: "[admin-kuaishou/inventory]",
}
)
);
router.post(
"/inventory/import",
createJsonHandler(
(req) => importAdminInventoryItems(req.body as AdminInventoryImportRouteBody),
{
successMessage: "导入完成",
errorMessage: "导入库存凭据失败",
scope: "[admin-kuaishou/inventory/import]",
}
)
);
router.post(
"/inventory/:inventoryItemId/release",
requireAdminRoles(["admin"]),
createJsonHandler(
(req) =>
releaseAdminInventoryItem(
(req.params as AdminInventoryRouteParams).inventoryItemId
),
{
successMessage: "库存项已释放",
errorMessage: "释放库存项失败",
scope: "[admin-kuaishou/inventory/:inventoryItemId/release]",
audit: (req, data) => {
const result = data as AdminInventoryMutationResponse;
return {
action: "inventory_item_released",
targetType: "inventory_item",
targetId: String(
(req.params as AdminInventoryRouteParams).inventoryItemId
),
data: {
inventoryItemId: result.inventoryItem?.inventoryItemId,
skuCode: result.inventoryItem?.skuCode,
displayValue: result.inventoryItem?.displayValue,
},
};
},
}
)
);
router.post(
"/inventory/:inventoryItemId/invalidate",
requireAdminRoles(["admin"]),
createJsonHandler(
(req) =>
invalidateAdminInventoryItem(
(req.params as AdminInventoryRouteParams).inventoryItemId,
req.body as AdminInventoryInvalidateRouteBody
),
{
successMessage: "库存项已作废",
errorMessage: "作废库存项失败",
scope: "[admin-kuaishou/inventory/:inventoryItemId/invalidate]",
audit: (req, data) => {
const result = data as AdminInventoryMutationResponse;
return {
action: "inventory_item_invalidated",
targetType: "inventory_item",
targetId: String(
(req.params as AdminInventoryRouteParams).inventoryItemId
),
data: {
inventoryItemId: result.inventoryItem?.inventoryItemId,
skuCode: result.inventoryItem?.skuCode,
invalidReason: result.inventoryItem?.invalidReason,
},
};
},
}
)
);
export default router;
+124 -92
View File
@@ -1,16 +1,16 @@
import { Router } from 'express' import { Router } from "express";
import { import {
createAdminInventoryItem, createAdminInventoryItem,
importAdminInventoryItems, importAdminInventoryItems,
invalidateAdminInventoryItem, invalidateAdminInventoryItem,
releaseAdminInventoryItem, releaseAdminInventoryItem,
} from '../../services/admin/admin-write-service.js' } from "../../services/admin/write/inventory.js";
import { import {
getAdminInventoryItems, getAdminInventoryItems,
getAdminInventorySkuSuggestions, getAdminInventorySkuSuggestions,
} from '../../services/admin/admin-read-service.js' } from "../../services/admin/admin-read-service.js";
import { createJsonHandler, requireAdminRoles } from './shared.js' import { createJsonHandler, requireAdminRoles } from "./shared.js";
import type { import type {
AdminInventoryCreateRouteBody, AdminInventoryCreateRouteBody,
AdminInventoryImportRouteBody, AdminInventoryImportRouteBody,
@@ -18,100 +18,132 @@ import type {
AdminInventoryRouteParams, AdminInventoryRouteParams,
AdminInventoryRouteQuery, AdminInventoryRouteQuery,
AdminInventorySkuSuggestionRouteQuery, AdminInventorySkuSuggestionRouteQuery,
} from '../../types/admin-route-inputs.js' } from "../../types/admin-route-inputs.js";
import type { AdminInventoryMutationResponse } from '../../types/admin-write-models.js' import type { AdminInventoryMutationResponse } from "../../types/admin-write-models.js";
const router = Router() const router = Router();
router.use('/inventory', requireAdminRoles(['admin', 'operator'])) router.use("/inventory", requireAdminRoles(["admin", "operator"]));
router.get('/inventory', createJsonHandler( router.get(
(req) => getAdminInventoryItems( "/inventory",
req.query as AdminInventoryRouteQuery, createJsonHandler(
req.adminSession || null, (req) =>
), getAdminInventoryItems(
{ req.query as AdminInventoryRouteQuery,
successMessage: 'ok', req.adminSession || null
errorMessage: '读取库存列表失败', ),
scope: '[admin/inventory]', {
}, successMessage: "ok",
)) errorMessage: "读取库存列表失败",
scope: "[admin/inventory]",
}
)
);
router.get('/inventory/sku-suggestions', createJsonHandler( router.get(
(req) => getAdminInventorySkuSuggestions( "/inventory/sku-suggestions",
req.query as AdminInventorySkuSuggestionRouteQuery, createJsonHandler(
req.adminSession || null, (req) =>
), getAdminInventorySkuSuggestions(
{ req.query as AdminInventorySkuSuggestionRouteQuery,
successMessage: 'ok', req.adminSession || null
errorMessage: '读取内部 SKU 建议失败', ),
scope: '[admin/inventory/sku-suggestions]', {
}, successMessage: "ok",
)) errorMessage: "读取内部 SKU 建议失败",
scope: "[admin/inventory/sku-suggestions]",
}
)
);
router.post('/inventory', createJsonHandler( router.post(
(req) => createAdminInventoryItem(req.body as AdminInventoryCreateRouteBody), "/inventory",
{ createJsonHandler(
successMessage: '库存项已新增', (req) => createAdminInventoryItem(req.body as AdminInventoryCreateRouteBody),
errorMessage: '新增库存项失败', {
scope: '[admin/inventory]', successMessage: "库存项已新增",
}, errorMessage: "新增库存项失败",
)) scope: "[admin/inventory]",
}
)
);
router.post('/inventory/import', createJsonHandler( router.post(
(req) => importAdminInventoryItems(req.body as AdminInventoryImportRouteBody), "/inventory/import",
{ createJsonHandler(
successMessage: '导入完成', (req) => importAdminInventoryItems(req.body as AdminInventoryImportRouteBody),
errorMessage: '导入库存凭据失败', {
scope: '[admin/inventory/import]', successMessage: "导入完成",
}, errorMessage: "导入库存凭据失败",
)) scope: "[admin/inventory/import]",
}
)
);
router.post('/inventory/:inventoryItemId/release', requireAdminRoles(['admin']), createJsonHandler( router.post(
(req) => releaseAdminInventoryItem((req.params as AdminInventoryRouteParams).inventoryItemId), "/inventory/:inventoryItemId/release",
{ requireAdminRoles(["admin"]),
successMessage: '库存项已释放', createJsonHandler(
errorMessage: '释放库存项失败', (req) =>
scope: '[admin/inventory/:inventoryItemId/release]', releaseAdminInventoryItem(
audit: (req, data) => { (req.params as AdminInventoryRouteParams).inventoryItemId
const result = data as AdminInventoryMutationResponse ),
return { {
action: 'inventory_item_released', successMessage: "库存项已释放",
targetType: 'inventory_item', errorMessage: "释放库存项失败",
targetId: String((req.params as AdminInventoryRouteParams).inventoryItemId), scope: "[admin/inventory/:inventoryItemId/release]",
data: { audit: (req, data) => {
inventoryItemId: result.inventoryItem?.inventoryItemId, const result = data as AdminInventoryMutationResponse;
skuCode: result.inventoryItem?.skuCode,
displayValue: result.inventoryItem?.displayValue,
},
}
},
},
))
router.post('/inventory/:inventoryItemId/invalidate', requireAdminRoles(['admin']), createJsonHandler( return {
(req) => invalidateAdminInventoryItem( action: "inventory_item_released",
(req.params as AdminInventoryRouteParams).inventoryItemId, targetType: "inventory_item",
req.body as AdminInventoryInvalidateRouteBody, targetId: String(
), (req.params as AdminInventoryRouteParams).inventoryItemId
{ ),
successMessage: '库存项已作废', data: {
errorMessage: '作废库存项失败', inventoryItemId: result.inventoryItem?.inventoryItemId,
scope: '[admin/inventory/:inventoryItemId/invalidate]', skuCode: result.inventoryItem?.skuCode,
audit: (req, data) => { displayValue: result.inventoryItem?.displayValue,
const result = data as AdminInventoryMutationResponse },
return { };
action: 'inventory_item_invalidated', },
targetType: 'inventory_item', }
targetId: String((req.params as AdminInventoryRouteParams).inventoryItemId), )
data: { );
inventoryItemId: result.inventoryItem?.inventoryItemId,
skuCode: result.inventoryItem?.skuCode,
invalidReason: result.inventoryItem?.invalidReason,
},
}
},
},
))
export default router router.post(
"/inventory/:inventoryItemId/invalidate",
requireAdminRoles(["admin"]),
createJsonHandler(
(req) =>
invalidateAdminInventoryItem(
(req.params as AdminInventoryRouteParams).inventoryItemId,
req.body as AdminInventoryInvalidateRouteBody
),
{
successMessage: "库存项已作废",
errorMessage: "作废库存项失败",
scope: "[admin/inventory/:inventoryItemId/invalidate]",
audit: (req, data) => {
const result = data as AdminInventoryMutationResponse;
return {
action: "inventory_item_invalidated",
targetType: "inventory_item",
targetId: String(
(req.params as AdminInventoryRouteParams).inventoryItemId
),
data: {
inventoryItemId: result.inventoryItem?.inventoryItemId,
skuCode: result.inventoryItem?.skuCode,
invalidReason: result.inventoryItem?.invalidReason,
},
};
},
}
)
);
export default router;
@@ -1,218 +0,0 @@
import { Router } from 'express'
import {
closeAdminManualRedeemSession,
closeAdminManualRedeemTask,
confirmAdminManualRedeemRole,
createAdminManualRedeemSession,
createAdminManualRedeemTask,
getAdminManualRedeemDetail,
getAdminManualRedeemSessionSummary,
reloadAdminManualRedeemSession,
redeemAdminManualRedeemTask,
} from '../../services/admin/admin-manual-redeem-service.js'
import { getAdminInventorySkuSuggestions } from '../../services/admin/admin-read-service.js'
import { createJsonHandler, requireAdminRoles } from './shared.js'
import type {
AdminInventorySkuSuggestionRouteQuery,
AdminManualRedeemCreateRouteBody,
AdminManualRedeemRouteParams,
} from '../../types/admin-route-inputs.js'
type AdminManualRedeemSessionRouteBody = {
loginType?: string
forceRecreate?: boolean
}
type AdminManualRedeemMutationResult = {
task?: {
taskId?: number
taskNo?: string
status?: string
deliveryStatus?: string
}
manualRequest?: { proofValue?: string }
order?: { platformOrderId?: string }
orderItem?: { skuCode?: string }
}
const router = Router()
router.use('/manual-redeem', requireAdminRoles(['admin', 'operator', 'support']))
router.get('/manual-redeem/sku-suggestions', createJsonHandler(
(req) => getAdminInventorySkuSuggestions(
req.query as AdminInventorySkuSuggestionRouteQuery,
req.adminSession || null,
),
{
successMessage: 'ok',
errorMessage: '读取人工兑换 SKU 建议失败',
scope: '[admin/manual-redeem/sku-suggestions]',
},
))
router.post('/manual-redeem', createJsonHandler(
(req) => createAdminManualRedeemTask(
req.body as AdminManualRedeemCreateRouteBody,
req.adminSession || null,
),
{
successMessage: '人工兑换任务已创建',
errorMessage: '创建人工兑换任务失败',
scope: '[admin/manual-redeem]',
audit: (req, data) => {
const result = data as AdminManualRedeemMutationResult
return {
action: 'manual_redeem_created',
targetType: 'task',
targetId: String(result.task?.taskId || ''),
data: {
taskId: result.task?.taskId,
taskNo: result.task?.taskNo,
proofValue: result.manualRequest?.proofValue || result.order?.platformOrderId || '',
skuCode: result.orderItem?.skuCode || '',
},
}
},
},
))
router.get('/manual-redeem/:taskId', createJsonHandler(
(req) => getAdminManualRedeemDetail(
(req.params as AdminManualRedeemRouteParams).taskId,
req.adminSession || null,
),
{
successMessage: 'ok',
errorMessage: '读取人工兑换详情失败',
scope: '[admin/manual-redeem/:taskId]',
},
))
router.post('/manual-redeem/:taskId/session', createJsonHandler(
(req) => createAdminManualRedeemSession(
(req.params as AdminManualRedeemRouteParams).taskId,
req.body as AdminManualRedeemSessionRouteBody,
req.adminSession || null,
),
{
successMessage: '人工兑换登录会话已创建',
errorMessage: '创建人工兑换登录会话失败',
scope: '[admin/manual-redeem/:taskId/session]',
},
))
router.get('/manual-redeem/:taskId/session/summary', createJsonHandler(
(req) => getAdminManualRedeemSessionSummary(
(req.params as AdminManualRedeemRouteParams).taskId,
req.adminSession || null,
),
{
successMessage: 'ok',
errorMessage: '读取人工兑换会话摘要失败',
scope: '[admin/manual-redeem/:taskId/session/summary]',
},
))
router.post('/manual-redeem/:taskId/session/refresh', createJsonHandler(
(req) => reloadAdminManualRedeemSession(
(req.params as AdminManualRedeemRouteParams).taskId,
req.adminSession || null,
),
{
successMessage: '人工兑换会话已刷新',
errorMessage: '刷新人工兑换会话失败',
scope: '[admin/manual-redeem/:taskId/session/refresh]',
},
))
router.delete('/manual-redeem/:taskId/session', createJsonHandler(
(req) => closeAdminManualRedeemSession(
(req.params as AdminManualRedeemRouteParams).taskId,
req.adminSession || null,
),
{
successMessage: '人工兑换会话已关闭',
errorMessage: '关闭人工兑换会话失败',
scope: '[admin/manual-redeem/:taskId/session]',
},
))
router.post('/manual-redeem/:taskId/confirm-role', createJsonHandler(
(req) => confirmAdminManualRedeemRole(
(req.params as AdminManualRedeemRouteParams).taskId,
req.adminSession || null,
),
{
successMessage: '角色已确认',
errorMessage: '确认人工兑换角色失败',
scope: '[admin/manual-redeem/:taskId/confirm-role]',
audit: (req, data) => {
const result = data as AdminManualRedeemMutationResult
return {
action: 'manual_redeem_role_confirmed',
targetType: 'task',
targetId: String(result.task?.taskId || ''),
data: {
taskId: result.task?.taskId,
taskNo: result.task?.taskNo,
status: result.task?.status,
},
}
},
},
))
router.post('/manual-redeem/:taskId/redeem', createJsonHandler(
(req) => redeemAdminManualRedeemTask(
(req.params as AdminManualRedeemRouteParams).taskId,
req.adminSession || null,
),
{
successMessage: '人工兑换任务已启动',
errorMessage: '执行人工兑换失败',
scope: '[admin/manual-redeem/:taskId/redeem]',
audit: (req, data) => {
const result = data as AdminManualRedeemMutationResult
return {
action: 'manual_redeem_started',
targetType: 'task',
targetId: String(result.task?.taskId || ''),
data: {
taskId: result.task?.taskId,
taskNo: result.task?.taskNo,
status: result.task?.status,
deliveryStatus: result.task?.deliveryStatus,
},
}
},
},
))
router.post('/manual-redeem/:taskId/close', createJsonHandler(
(req) => closeAdminManualRedeemTask(
(req.params as AdminManualRedeemRouteParams).taskId,
req.adminSession || null,
),
{
successMessage: '人工兑换任务已关闭',
errorMessage: '关闭人工兑换任务失败',
scope: '[admin/manual-redeem/:taskId/close]',
audit: (req, data) => {
const result = data as AdminManualRedeemMutationResult
return {
action: 'manual_redeem_closed',
targetType: 'task',
targetId: String(result.task?.taskId || ''),
data: {
taskId: result.task?.taskId,
taskNo: result.task?.taskNo,
status: result.task?.status,
},
}
},
},
))
export default router
@@ -1,19 +0,0 @@
import { Router } from "express";
import { requireAdminRoles } from "./shared.js";
import cloudtentaclesRouter from "./platform-config/cloudtentacles.js";
import kuaishouCloudFulfillmentRouter from "./platform-config/kuaishou-cloud-fulfillment.js";
import kuaishouEticketRouter from "./platform-config/kuaishou-eticket.js";
import ninetyoneRouter from "./platform-config/ninetyone.js";
import notificationsRouter from "./platform-config/notifications.js";
const router = Router();
router.use("/platform-config", requireAdminRoles(["admin"]));
router.use("/platform-config", notificationsRouter);
router.use("/platform-config", kuaishouEticketRouter);
router.use("/platform-config", ninetyoneRouter);
router.use("/platform-config", cloudtentaclesRouter);
router.use("/platform-config", kuaishouCloudFulfillmentRouter);
export default router;
@@ -1,9 +1,7 @@
import { Router } from "express"; import { Router } from "express";
import { requireAdminRoles } from "./shared.js"; import { requireAdminRoles } from "./shared.js";
import agisoRouter from "./platform-config/agiso.js";
import cloudtentaclesRouter from "./platform-config/cloudtentacles.js"; import cloudtentaclesRouter from "./platform-config/cloudtentacles.js";
import fulfillmentBindingsRouter from "./platform-config/fulfillment-bindings.js";
import kuaishouCloudFulfillmentRouter from "./platform-config/kuaishou-cloud-fulfillment.js"; import kuaishouCloudFulfillmentRouter from "./platform-config/kuaishou-cloud-fulfillment.js";
import kuaishouEticketRouter from "./platform-config/kuaishou-eticket.js"; import kuaishouEticketRouter from "./platform-config/kuaishou-eticket.js";
import ninetyoneRouter from "./platform-config/ninetyone.js"; import ninetyoneRouter from "./platform-config/ninetyone.js";
@@ -12,12 +10,10 @@ import notificationsRouter from "./platform-config/notifications.js";
const router = Router(); const router = Router();
router.use("/platform-config", requireAdminRoles(["admin"])); router.use("/platform-config", requireAdminRoles(["admin"]));
router.use("/platform-config", agisoRouter);
router.use("/platform-config", notificationsRouter); router.use("/platform-config", notificationsRouter);
router.use("/platform-config", kuaishouEticketRouter); router.use("/platform-config", kuaishouEticketRouter);
router.use("/platform-config", ninetyoneRouter); router.use("/platform-config", ninetyoneRouter);
router.use("/platform-config", cloudtentaclesRouter); router.use("/platform-config", cloudtentaclesRouter);
router.use("/platform-config", fulfillmentBindingsRouter);
router.use("/platform-config", kuaishouCloudFulfillmentRouter); router.use("/platform-config", kuaishouCloudFulfillmentRouter);
export default router; export default router;
@@ -1,47 +0,0 @@
import { Router } from "express";
import {
getAdminAgisoShopConfigs,
updateAdminAgisoShopConfigs,
} from "../../../services/admin/platform-config/service.js";
import type { AdminAgisoShopConfigRouteBody } from "../../../types/admin-route-inputs.js";
import type { AdminAgisoShopConfigSaveResponse } from "../../../types/admin-write-models.js";
import { createJsonHandler } from "../shared.js";
const router = Router();
router.get(
"/agiso-shops",
createJsonHandler(() => getAdminAgisoShopConfigs(), {
successMessage: "ok",
errorMessage: "读取 Agiso 店铺配置失败",
scope: "[admin/platform-config/agiso-shops]",
})
);
router.post(
"/agiso-shops",
createJsonHandler(
(req) =>
updateAdminAgisoShopConfigs(req.body as AdminAgisoShopConfigRouteBody),
{
successMessage: "Agiso 店铺配置已保存",
errorMessage: "保存 Agiso 店铺配置失败",
scope: "[admin/platform-config/agiso-shops]",
audit: (_req, data) => {
const result = data as AdminAgisoShopConfigSaveResponse;
return {
action: "platform_shop_config_updated",
targetType: "platform_config",
targetId: "agiso_shops",
data: {
shopCount: result.shops.length,
filePath: result.filePath,
},
};
},
}
)
);
export default router;
@@ -1,98 +0,0 @@
import { Router } from "express";
import {
getAdminFulfillmentBindingConfigs,
lookupAdminFulfillmentBindingOrder,
updateAdminFulfillmentBindingConfigs,
} from "../../../services/admin/platform-config/service.js";
import type {
AdminFulfillmentBindingConfigRouteBody,
AdminFulfillmentBindingLookupRouteBody,
} from "../../../types/admin-route-inputs.js";
import { createJsonHandler } from "../shared.js";
import type { JsonRecord } from "./shared.js";
const router = Router();
router.get(
"/fulfillment-bindings",
createJsonHandler(() => getAdminFulfillmentBindingConfigs(), {
successMessage: "ok",
errorMessage: "读取履约配置失败",
scope: "[admin/platform-config/fulfillment-bindings]",
})
);
router.post(
"/fulfillment-bindings/lookup-order",
createJsonHandler(
(req) =>
lookupAdminFulfillmentBindingOrder(
req.body as AdminFulfillmentBindingLookupRouteBody
),
{
successMessage: "订单商品查询成功",
errorMessage: "手动查询订单商品失败",
scope: "[admin/platform-config/fulfillment-bindings/lookup-order]",
audit: (req, data) => {
const result = data as JsonRecord;
return {
action: "platform_fulfillment_order_lookup",
targetType: "platform_config",
targetId: [
result.order?.provider || "unknown",
result.order?.platform || "unknown",
result.order?.shopId || "unknown",
result.order?.platformOrderId || "unknown",
].join(":"),
data: {
itemCount: Array.isArray(result.items) ? result.items.length : 0,
shopId:
result.order?.shopId ||
String(
(req.body as AdminFulfillmentBindingLookupRouteBody)?.shopId ||
""
).trim(),
platformOrderId:
result.order?.platformOrderId ||
String(
(req.body as AdminFulfillmentBindingLookupRouteBody)
?.platformOrderId || ""
).trim(),
},
};
},
}
)
);
router.post(
"/fulfillment-bindings",
createJsonHandler(
(req) =>
updateAdminFulfillmentBindingConfigs(
req.body as AdminFulfillmentBindingConfigRouteBody
),
{
successMessage: "履约配置已保存",
errorMessage: "保存履约配置失败",
scope: "[admin/platform-config/fulfillment-bindings]",
audit: (_req, data) => {
const result = data as JsonRecord;
return {
action: "platform_fulfillment_config_updated",
targetType: "platform_config",
targetId: "fulfillment_bindings",
data: {
bindingCount: Array.isArray(result.bindings)
? result.bindings.length
: 0,
filePath: result.filePath || "",
},
};
},
}
)
);
export default router;
@@ -1,162 +0,0 @@
import type { Request } from "express";
import { Router } from "express";
import {
getAdminTaskDetail,
getAdminTasks,
} from "../../services/admin/admin-read-service.js";
import {
dispatchAdminTaskKuaishouCloudFulfillment,
prepareAdminTaskKuaishouCloudFulfillment,
refreshAdminTaskKuaishouCloudRoleInfo,
returnNumberAdminTaskKuaishouCloudFulfillment,
} from "../../services/admin/write/kuaishou-cloud-actions.js";
import {
createJsonHandler,
requireAdminRoles,
} from "./shared.js";
import type {
AdminTaskKuaishouCloudDispatchRouteBody,
AdminTaskRouteParams,
AdminTaskRouteQuery,
} from "../../types/admin-route-inputs.js";
import type { AdminTaskActionResponse } from "../../types/admin-write-models.js";
const router = Router();
function getTaskId(req: Request): string | undefined {
return (req.params as AdminTaskRouteParams).taskId;
}
router.get(
"/tasks",
createJsonHandler(
(req) =>
getAdminTasks(req.query as AdminTaskRouteQuery, req.adminSession || null),
{
successMessage: "ok",
errorMessage: "读取任务列表失败",
scope: "[admin-kuaishou/tasks]",
}
)
);
router.get(
"/tasks/:taskId",
createJsonHandler(
(req) => getAdminTaskDetail(getTaskId(req), req.adminSession || null),
{
successMessage: "ok",
errorMessage: "读取任务详情失败",
scope: "[admin-kuaishou/tasks/:taskId]",
}
)
);
router.post(
"/tasks/:taskId/kuaishou-cloud/prepare",
requireAdminRoles(["admin", "operator"]),
createJsonHandler(
(req) =>
prepareAdminTaskKuaishouCloudFulfillment(
getTaskId(req),
req.adminSession || null
),
{
successMessage: "绑定资源已准备完成",
errorMessage: "准备绑定资源失败",
scope: "[admin-kuaishou/tasks/:taskId/kuaishou-cloud/prepare]",
audit: (req, data) => buildTaskAudit("task_kuaishou_cloud_prepare", req, data),
}
)
);
router.post(
"/tasks/:taskId/kuaishou-cloud/refresh-role-info",
requireAdminRoles(["admin", "operator", "support"]),
createJsonHandler(
(req) =>
refreshAdminTaskKuaishouCloudRoleInfo(
getTaskId(req),
req.adminSession || null
),
{
successMessage: "角色信息已刷新",
errorMessage: "刷新角色信息失败",
scope: "[admin-kuaishou/tasks/:taskId/kuaishou-cloud/refresh-role-info]",
audit: (req, data) =>
buildTaskAudit("task_kuaishou_cloud_role_info_refreshed", req, data),
}
)
);
router.post(
"/tasks/:taskId/kuaishou-cloud/dispatch",
requireAdminRoles(["admin", "operator"]),
createJsonHandler(
(req) =>
dispatchAdminTaskKuaishouCloudFulfillment(
getTaskId(req),
req.body as AdminTaskKuaishouCloudDispatchRouteBody,
req.adminSession || null
),
{
successMessage: "已完成绑定确认并发货",
errorMessage: "执行发货失败",
scope: "[admin-kuaishou/tasks/:taskId/kuaishou-cloud/dispatch]",
audit: (req, data) => {
const result = data as AdminTaskActionResponse;
const body = req.body as AdminTaskKuaishouCloudDispatchRouteBody;
return {
action: "task_kuaishou_cloud_dispatch",
targetType: "task",
targetId: String(getTaskId(req)),
data: {
taskId: result.task.taskId,
taskNo: result.task.taskNo,
status: result.task.status,
deliveryStatus: result.task.deliveryStatus,
ticketCodeProvided: Boolean(String(body.ticketCode || "").trim()),
},
};
},
}
)
);
router.post(
"/tasks/:taskId/kuaishou-cloud/return-number",
requireAdminRoles(["admin", "operator"]),
createJsonHandler(
(req) =>
returnNumberAdminTaskKuaishouCloudFulfillment(
getTaskId(req),
req.adminSession || null
),
{
successMessage: "号码已退还",
errorMessage: "退还号码失败",
scope: "[admin-kuaishou/tasks/:taskId/kuaishou-cloud/return-number]",
audit: (req, data) =>
buildTaskAudit("task_kuaishou_cloud_return_number", req, data),
}
)
);
function buildTaskAudit(action: string, req: Request, data: unknown) {
const result = data as AdminTaskActionResponse;
return {
action,
targetType: "task",
targetId: String(getTaskId(req)),
data: {
taskId: result.task.taskId,
taskNo: result.task.taskNo,
status: result.task.status,
},
};
}
export default router;
+29 -300
View File
@@ -1,42 +1,26 @@
import type { Request } from "express";
import { Router } from "express"; import { Router } from "express";
import {
closeAdminTask,
completeAdminTaskManualDispatch,
confirmAdminTaskAssistedRole,
dispatchAdminTaskKuaishouCloudFulfillment,
markAdminTaskManualReview,
prepareAdminTaskKuaishouCloudFulfillment,
refreshAdminTaskKuaishouCloudRoleInfo,
regenerateAdminTaskClaimLink,
redeemAdminTaskAssisted,
releaseAdminTaskInventoryBinding,
releaseAdminTaskInventory,
returnNumberAdminTaskKuaishouCloudFulfillment,
retryAdminTask,
} from "../../services/admin/admin-write-service.js";
import { import {
getAdminTaskDetail, getAdminTaskDetail,
getAdminTasks, getAdminTasks,
} from "../../services/admin/admin-read-service.js"; } from "../../services/admin/admin-read-service.js";
import { getAdminTaskScreenshotPathWithTencentFallback } from "../../services/admin/admin-task-screenshot-service.js";
import { import {
createFileHandler, dispatchAdminTaskKuaishouCloudFulfillment,
prepareAdminTaskKuaishouCloudFulfillment,
refreshAdminTaskKuaishouCloudRoleInfo,
returnNumberAdminTaskKuaishouCloudFulfillment,
} from "../../services/admin/write/kuaishou-cloud-actions.js";
import {
createJsonHandler, createJsonHandler,
requireAdminRoles, requireAdminRoles,
} from "./shared.js"; } from "./shared.js";
import type { Request } from "express";
import type { import type {
AdminTaskKuaishouCloudDispatchRouteBody, AdminTaskKuaishouCloudDispatchRouteBody,
AdminTaskManualDispatchRouteBody,
AdminTaskRouteParams, AdminTaskRouteParams,
AdminTaskRouteQuery, AdminTaskRouteQuery,
} from "../../types/admin-route-inputs.js"; } from "../../types/admin-route-inputs.js";
import type { import type { AdminTaskActionResponse } from "../../types/admin-write-models.js";
AdminTaskActionResponse,
AdminTaskBindingReleaseResponse,
AdminTaskManualDispatchResponse,
} from "../../types/admin-write-models.js";
const router = Router(); const router = Router();
@@ -44,10 +28,6 @@ function getTaskId(req: Request): string | undefined {
return (req.params as AdminTaskRouteParams).taskId; return (req.params as AdminTaskRouteParams).taskId;
} }
function getBindingId(req: Request): string | undefined {
return (req.params as AdminTaskRouteParams).bindingId;
}
router.get( router.get(
"/tasks", "/tasks",
createJsonHandler( createJsonHandler(
@@ -73,239 +53,6 @@ router.get(
) )
); );
router.get(
"/tasks/:taskId/screenshot",
createFileHandler(
(req) =>
getAdminTaskScreenshotPathWithTencentFallback(getTaskId(req), req.adminSession || null),
{
errorMessage: "读取任务截图失败",
scope: "[admin/tasks/:taskId/screenshot]",
}
)
);
router.post(
"/tasks/:taskId/retry",
requireAdminRoles(["admin", "operator"]),
createJsonHandler((req) => retryAdminTask(getTaskId(req)), {
successMessage: "任务已重试",
errorMessage: "重试任务失败",
scope: "[admin/tasks/:taskId/retry]",
})
);
router.post(
"/tasks/:taskId/release-inventory",
requireAdminRoles(["admin"]),
createJsonHandler((req) => releaseAdminTaskInventory(getTaskId(req)), {
successMessage: "库存凭据已释放",
errorMessage: "释放库存凭据失败",
scope: "[admin/tasks/:taskId/release-inventory]",
audit: (req, data) => {
const result = data as AdminTaskActionResponse;
return {
action: "task_release_inventory",
targetType: "task",
targetId: String(getTaskId(req)),
data: {
taskId: result.task.taskId,
taskNo: result.task.taskNo,
inventoryItemId: result.task.inventoryItemId,
},
};
},
})
);
router.post(
"/tasks/:taskId/inventory-bindings/:bindingId/release",
requireAdminRoles(["admin"]),
createJsonHandler(
(req) =>
releaseAdminTaskInventoryBinding(getTaskId(req), getBindingId(req)),
{
successMessage: "库存绑定已释放",
errorMessage: "释放库存绑定失败",
scope: "[admin/tasks/:taskId/inventory-bindings/:bindingId/release]",
audit: (req, data) => {
const result = data as AdminTaskBindingReleaseResponse;
return {
action: "task_release_inventory_binding",
targetType: "task_inventory_binding",
targetId: String(getBindingId(req)),
data: {
taskId: result.task.taskId,
taskNo: result.task.taskNo,
bindingId: result.bindingId,
inventoryItemId: result.inventoryItemId,
},
};
},
}
)
);
router.post(
"/tasks/:taskId/regenerate-claim-link",
requireAdminRoles(["admin", "operator", "support"]),
createJsonHandler(
(req) =>
regenerateAdminTaskClaimLink(getTaskId(req), req.adminSession || null),
{
successMessage: "领取链接已重新生成",
errorMessage: "重新生成领取链接失败",
scope: "[admin/tasks/:taskId/regenerate-claim-link]",
audit: (req, data) => {
const result = data as AdminTaskActionResponse;
return {
action: "task_regenerate_claim_link",
targetType: "task",
targetId: String(getTaskId(req)),
data: {
taskId: result.task.taskId,
taskNo: result.task.taskNo,
primaryClaimTokenId: result.task.primaryClaimTokenId,
},
};
},
}
)
);
router.post(
"/tasks/:taskId/support-confirm-role",
requireAdminRoles(["admin", "operator", "support"]),
createJsonHandler(
(req) =>
confirmAdminTaskAssistedRole(getTaskId(req), req.adminSession || null),
{
successMessage: "角色已确认",
errorMessage: "客服确认角色失败",
scope: "[admin/tasks/:taskId/support-confirm-role]",
audit: (req, data) => {
const result = data as AdminTaskActionResponse;
return {
action: "task_support_confirm_role",
targetType: "task",
targetId: String(getTaskId(req)),
data: {
taskId: result.task.taskId,
taskNo: result.task.taskNo,
status: result.task.status,
},
};
},
}
)
);
router.post(
"/tasks/:taskId/support-redeem",
requireAdminRoles(["admin", "operator", "support"]),
createJsonHandler(
(req) => redeemAdminTaskAssisted(getTaskId(req), req.adminSession || null),
{
successMessage: "兑换任务已启动",
errorMessage: "客服发起兑换失败",
scope: "[admin/tasks/:taskId/support-redeem]",
audit: (req, data) => {
const result = data as AdminTaskActionResponse;
return {
action: "task_support_redeem",
targetType: "task",
targetId: String(getTaskId(req)),
data: {
taskId: result.task.taskId,
taskNo: result.task.taskNo,
status: result.task.status,
deliveryStatus: result.task.deliveryStatus,
},
};
},
}
)
);
router.post(
"/tasks/:taskId/close",
requireAdminRoles(["admin", "operator", "support"]),
createJsonHandler((req) => closeAdminTask(getTaskId(req)), {
successMessage: "任务已关闭",
errorMessage: "关闭任务失败",
scope: "[admin/tasks/:taskId/close]",
audit: (req, data) => {
const result = data as AdminTaskActionResponse;
return {
action: "task_closed",
targetType: "task",
targetId: String(getTaskId(req)),
data: {
taskId: result.task.taskId,
taskNo: result.task.taskNo,
status: result.task.status,
},
};
},
})
);
router.post(
"/tasks/:taskId/mark-manual-review",
requireAdminRoles(["admin", "operator"]),
createJsonHandler((req) => markAdminTaskManualReview(getTaskId(req)), {
successMessage: "任务已转人工处理",
errorMessage: "标记人工处理失败",
scope: "[admin/tasks/:taskId/mark-manual-review]",
audit: (req, data) => {
const result = data as AdminTaskActionResponse;
return {
action: "task_mark_manual_review",
targetType: "task",
targetId: String(getTaskId(req)),
data: {
taskId: result.task.taskId,
taskNo: result.task.taskNo,
status: result.task.status,
},
};
},
})
);
router.post(
"/tasks/:taskId/complete-manual-dispatch",
requireAdminRoles(["admin", "operator"]),
createJsonHandler(
(req) =>
completeAdminTaskManualDispatch(
getTaskId(req),
req.body as AdminTaskManualDispatchRouteBody,
req.adminSession || null
),
{
successMessage: "人工履约结果已回写",
errorMessage: "回写人工履约结果失败",
scope: "[admin/tasks/:taskId/complete-manual-dispatch]",
audit: (req, data) => {
const result = data as AdminTaskManualDispatchResponse;
return {
action: "task_complete_manual_dispatch",
targetType: "task",
targetId: String(getTaskId(req)),
data: {
taskId: result.task.taskId,
taskNo: result.task.taskNo,
outcome: result.outcome,
deliveryStatus: result.task.deliveryStatus,
resultCode: result.task.resultCode,
},
};
},
}
)
);
router.post( router.post(
"/tasks/:taskId/kuaishou-cloud/prepare", "/tasks/:taskId/kuaishou-cloud/prepare",
requireAdminRoles(["admin", "operator"]), requireAdminRoles(["admin", "operator"]),
@@ -319,19 +66,7 @@ router.post(
successMessage: "绑定资源已准备完成", successMessage: "绑定资源已准备完成",
errorMessage: "准备绑定资源失败", errorMessage: "准备绑定资源失败",
scope: "[admin/tasks/:taskId/kuaishou-cloud/prepare]", scope: "[admin/tasks/:taskId/kuaishou-cloud/prepare]",
audit: (req, data) => { audit: (req, data) => buildTaskAudit("task_kuaishou_cloud_prepare", req, data),
const result = data as AdminTaskActionResponse;
return {
action: "task_kuaishou_cloud_prepare",
targetType: "task",
targetId: String(getTaskId(req)),
data: {
taskId: result.task.taskId,
taskNo: result.task.taskNo,
status: result.task.status,
},
};
},
} }
) )
); );
@@ -349,19 +84,8 @@ router.post(
successMessage: "角色信息已刷新", successMessage: "角色信息已刷新",
errorMessage: "刷新角色信息失败", errorMessage: "刷新角色信息失败",
scope: "[admin/tasks/:taskId/kuaishou-cloud/refresh-role-info]", scope: "[admin/tasks/:taskId/kuaishou-cloud/refresh-role-info]",
audit: (req, data) => { audit: (req, data) =>
const result = data as AdminTaskActionResponse; buildTaskAudit("task_kuaishou_cloud_role_info_refreshed", req, data),
return {
action: "task_kuaishou_cloud_role_info_refreshed",
targetType: "task",
targetId: String(getTaskId(req)),
data: {
taskId: result.task.taskId,
taskNo: result.task.taskNo,
status: result.task.status,
},
};
},
} }
) )
); );
@@ -383,6 +107,7 @@ router.post(
audit: (req, data) => { audit: (req, data) => {
const result = data as AdminTaskActionResponse; const result = data as AdminTaskActionResponse;
const body = req.body as AdminTaskKuaishouCloudDispatchRouteBody; const body = req.body as AdminTaskKuaishouCloudDispatchRouteBody;
return { return {
action: "task_kuaishou_cloud_dispatch", action: "task_kuaishou_cloud_dispatch",
targetType: "task", targetType: "task",
@@ -413,21 +138,25 @@ router.post(
successMessage: "号码已退还", successMessage: "号码已退还",
errorMessage: "退还号码失败", errorMessage: "退还号码失败",
scope: "[admin/tasks/:taskId/kuaishou-cloud/return-number]", scope: "[admin/tasks/:taskId/kuaishou-cloud/return-number]",
audit: (req, data) => { audit: (req, data) =>
const result = data as AdminTaskActionResponse; buildTaskAudit("task_kuaishou_cloud_return_number", req, data),
return {
action: "task_kuaishou_cloud_return_number",
targetType: "task",
targetId: String(getTaskId(req)),
data: {
taskId: result.task.taskId,
taskNo: result.task.taskNo,
status: result.task.status,
},
};
},
} }
) )
); );
function buildTaskAudit(action: string, req: Request, data: unknown) {
const result = data as AdminTaskActionResponse;
return {
action,
targetType: "task",
targetId: String(getTaskId(req)),
data: {
taskId: result.task.taskId,
taskNo: result.task.taskNo,
status: result.task.status,
},
};
}
export default router; export default router;
@@ -1,76 +0,0 @@
import { Router } from "express";
import {
getAdminWebhookEventDetail,
getAdminWebhookEvents,
} from "../../services/admin/admin-read-service.js";
import { createHttpError } from "../../utils/http.js";
import { createJsonHandler, requireAdminRoles } from "./shared.js";
import type {
AdminWebhookEventRouteParams,
AdminWebhookEventRouteQuery,
} from "../../types/admin-route-inputs.js";
const router = Router();
router.use("/webhook-events", requireAdminRoles(["admin", "operator"]));
router.get(
"/webhook-events",
createJsonHandler(
(req) => getAdminWebhookEvents(req.query as AdminWebhookEventRouteQuery),
{
successMessage: "ok",
errorMessage: "读取 webhook 列表失败",
scope: "[admin-kuaishou/webhook-events]",
}
)
);
router.get(
"/webhook-events/:eventId",
createJsonHandler(
(req) =>
getAdminWebhookEventDetail(
(req.params as AdminWebhookEventRouteParams).eventId
),
{
successMessage: "ok",
errorMessage: "读取 webhook 详情失败",
scope: "[admin-kuaishou/webhook-events/:eventId]",
}
)
);
router.post(
"/webhook-events/:eventId/replay",
requireAdminRoles(["admin"]),
createJsonHandler(
() => {
throw createHttpError("快手轻量后端不支持重放旧 Webhook", {
statusCode: 409,
errorCode: "admin_webhook_replay_disabled",
});
},
{
successMessage: "Webhook 已重放",
errorMessage: "重放 webhook 失败",
scope: "[admin-kuaishou/webhook-events/:eventId/replay]",
audit: (req, data) => {
return {
action: "webhook_replayed",
targetType: "webhook_event",
targetId: String(
(req.params as AdminWebhookEventRouteParams).eventId
),
data: {
disabled: true,
result: data,
},
};
},
}
)
);
export default router;
+62 -46
View File
@@ -1,60 +1,76 @@
import { Router } from 'express' import { Router } from "express";
import {
replayAdminWebhookEvent,
} from '../../services/admin/admin-write-service.js'
import { import {
getAdminWebhookEventDetail, getAdminWebhookEventDetail,
getAdminWebhookEvents, getAdminWebhookEvents,
} from '../../services/admin/admin-read-service.js' } from "../../services/admin/admin-read-service.js";
import { createJsonHandler, requireAdminRoles } from './shared.js' import { createHttpError } from "../../utils/http.js";
import { createJsonHandler, requireAdminRoles } from "./shared.js";
import type { import type {
AdminWebhookEventRouteParams, AdminWebhookEventRouteParams,
AdminWebhookEventRouteQuery, AdminWebhookEventRouteQuery,
} from '../../types/admin-route-inputs.js' } from "../../types/admin-route-inputs.js";
import type { AdminWebhookReplayResponse } from '../../types/admin-write-models.js'
const router = Router() const router = Router();
router.use('/webhook-events', requireAdminRoles(['admin', 'operator'])) router.use("/webhook-events", requireAdminRoles(["admin", "operator"]));
router.get('/webhook-events', createJsonHandler( router.get(
(req) => getAdminWebhookEvents(req.query as AdminWebhookEventRouteQuery), "/webhook-events",
{ createJsonHandler(
successMessage: 'ok', (req) => getAdminWebhookEvents(req.query as AdminWebhookEventRouteQuery),
errorMessage: '读取 webhook 列表失败', {
scope: '[admin/webhook-events]', successMessage: "ok",
}, errorMessage: "读取 webhook 列表失败",
)) scope: "[admin/webhook-events]",
}
)
);
router.get('/webhook-events/:eventId', createJsonHandler( router.get(
(req) => getAdminWebhookEventDetail((req.params as AdminWebhookEventRouteParams).eventId), "/webhook-events/:eventId",
{ createJsonHandler(
successMessage: 'ok', (req) =>
errorMessage: '读取 webhook 详情失败', getAdminWebhookEventDetail(
scope: '[admin/webhook-events/:eventId]', (req.params as AdminWebhookEventRouteParams).eventId
}, ),
)) {
successMessage: "ok",
errorMessage: "读取 webhook 详情失败",
scope: "[admin/webhook-events/:eventId]",
}
)
);
router.post('/webhook-events/:eventId/replay', requireAdminRoles(['admin']), createJsonHandler( router.post(
(req) => replayAdminWebhookEvent((req.params as AdminWebhookEventRouteParams).eventId), "/webhook-events/:eventId/replay",
{ requireAdminRoles(["admin"]),
successMessage: 'Webhook 已重放', createJsonHandler(
errorMessage: '重放 webhook 失败', () => {
scope: '[admin/webhook-events/:eventId/replay]', throw createHttpError("快手轻量后端不支持重放旧 Webhook", {
audit: (req, data) => { statusCode: 409,
const result = data as AdminWebhookReplayResponse errorCode: "admin_webhook_replay_disabled",
return { });
action: 'webhook_replayed',
targetType: 'webhook_event',
targetId: String((req.params as AdminWebhookEventRouteParams).eventId),
data: {
eventId: result.eventId,
replayed: result.replayed,
},
}
}, },
}, {
)) successMessage: "Webhook 已重放",
errorMessage: "重放 webhook 失败",
scope: "[admin/webhook-events/:eventId/replay]",
audit: (req, data) => {
return {
action: "webhook_replayed",
targetType: "webhook_event",
targetId: String(
(req.params as AdminWebhookEventRouteParams).eventId
),
data: {
disabled: true,
result: data,
},
};
},
}
)
);
export default router export default router;
@@ -1,71 +0,0 @@
import { Router } from "express";
import {
confirmKuaishouCloudClaimRole,
getKuaishouCloudClaimDetail,
getKuaishouCloudClaimGuideAssetPath,
redeemKuaishouCloudClaim,
verifyKuaishouCloudClaimTicket,
} from "../services/claim/kuaishou-cloud-claim-service.js";
import {
buildNotFoundPayload,
createRouteFileHandler,
createRouteHandler,
} from "../utils/http.js";
const router = Router();
router.get(
"/:token",
createRouteHandler((req) => getKuaishouCloudClaimDetail(req.params.token), {
errorMessage: "查询快手领取详情失败",
scope: "[claims-kuaishou/:token]",
})
);
router.post(
"/:token/kuaishou-cloud/verify-ticket",
createRouteHandler(
(req) => verifyKuaishouCloudClaimTicket(req.params.token, req.body),
{
successMessage: "核销码校验成功",
errorMessage: "校验核销码失败",
scope: "[claims-kuaishou/:token/kuaishou-cloud/verify-ticket]",
}
)
);
router.post(
"/:token/kuaishou-cloud/confirm-role",
createRouteHandler((req) => confirmKuaishouCloudClaimRole(req.params.token), {
successMessage: "角色已确认",
errorMessage: "确认角色失败",
scope: "[claims-kuaishou/:token/kuaishou-cloud/confirm-role]",
})
);
router.post(
"/:token/kuaishou-cloud/redeem",
createRouteHandler((req) => redeemKuaishouCloudClaim(req.params.token), {
successMessage: "兑换请求已提交",
errorMessage: "兑换失败",
scope: "[claims-kuaishou/:token/kuaishou-cloud/redeem]",
})
);
router.get(
"/assets/kuaishou-cloud/:filename",
createRouteFileHandler(
(req) => getKuaishouCloudClaimGuideAssetPath(req.params.filename),
{
errorMessage: "读取指引图片失败",
scope: "[claims-kuaishou/assets/kuaishou-cloud/:filename]",
}
)
);
router.use((req, res) => {
res.status(404).json(buildNotFoundPayload(req));
});
export default router;
+52 -78
View File
@@ -1,97 +1,71 @@
import { Router } from 'express' import { Router } from "express";
import {
closeClaimSession,
confirmClaimRole,
createClaimSession,
getClaimDetail,
getClaimScreenshotPath,
getClaimSessionSummary,
reloadClaimSession,
redeemClaimTask,
} from '../services/claim/claim-session-service.js'
import { import {
confirmKuaishouCloudClaimRole, confirmKuaishouCloudClaimRole,
getKuaishouCloudClaimDetail,
getKuaishouCloudClaimGuideAssetPath, getKuaishouCloudClaimGuideAssetPath,
redeemKuaishouCloudClaim, redeemKuaishouCloudClaim,
verifyKuaishouCloudClaimTicket, verifyKuaishouCloudClaimTicket,
} from '../services/claim/kuaishou-cloud-claim-service.js' } from "../services/claim/kuaishou-cloud-claim-service.js";
import { import {
buildNotFoundPayload, buildNotFoundPayload,
buildSuccessPayload,
createRouteFileHandler, createRouteFileHandler,
createRouteHandler, createRouteHandler,
sendRouteError, } from "../utils/http.js";
} from '../utils/http.js'
const router = Router() const router = Router();
router.get('/:token', createRouteHandler( router.get(
(req) => getClaimDetail(req.params.token), "/:token",
{ errorMessage: '查询领取详情失败', scope: '[claims/:token]' }, createRouteHandler((req) => getKuaishouCloudClaimDetail(req.params.token), {
)) errorMessage: "查询快手领取详情失败",
scope: "[claims/:token]",
})
);
router.post('/:token/session', createRouteHandler( router.post(
(req) => createClaimSession(req.params.token, req.body), "/:token/kuaishou-cloud/verify-ticket",
{ successMessage: '浏览器会话已创建', errorMessage: '创建领取会话失败', scope: '[claims/:token/session]' }, createRouteHandler(
)) (req) => verifyKuaishouCloudClaimTicket(req.params.token, req.body),
{
successMessage: "核销码校验成功",
errorMessage: "校验核销码失败",
scope: "[claims/:token/kuaishou-cloud/verify-ticket]",
}
)
);
router.get('/:token/session/summary', createRouteHandler( router.post(
(req) => getClaimSessionSummary(req.params.token), "/:token/kuaishou-cloud/confirm-role",
{ errorMessage: '查询领取会话摘要失败', scope: '[claims/:token/session/summary]' }, createRouteHandler((req) => confirmKuaishouCloudClaimRole(req.params.token), {
)) successMessage: "角色已确认",
errorMessage: "确认角色失败",
scope: "[claims/:token/kuaishou-cloud/confirm-role]",
})
);
router.post('/:token/session/refresh', async (req, res) => { router.post(
try { "/:token/kuaishou-cloud/redeem",
const data = await reloadClaimSession(req.params.token) createRouteHandler((req) => redeemKuaishouCloudClaim(req.params.token), {
res.json(buildSuccessPayload(data, data.session ? (data.session.notice || '后端页面已刷新') : '领取会话已重置')) successMessage: "兑换请求已提交",
} catch (error) { errorMessage: "兑换失败",
sendRouteError(res, error, '刷新领取会话失败', '[claims/:token/session/refresh]') scope: "[claims/:token/kuaishou-cloud/redeem]",
} })
}) );
router.delete('/:token/session', createRouteHandler( router.get(
(req) => closeClaimSession(req.params.token), "/assets/kuaishou-cloud/:filename",
{ successMessage: '领取会话已关闭', errorMessage: '关闭领取会话失败', scope: '[claims/:token/session]' }, createRouteFileHandler(
)) (req) => getKuaishouCloudClaimGuideAssetPath(req.params.filename),
{
router.post('/:token/confirm-role', createRouteHandler( errorMessage: "读取指引图片失败",
(req) => confirmClaimRole(req.params.token), scope: "[claims/assets/kuaishou-cloud/:filename]",
{ successMessage: '角色已确认', errorMessage: '确认角色失败', scope: '[claims/:token/confirm-role]' }, }
)) )
);
router.post('/:token/redeem', createRouteHandler(
(req) => redeemClaimTask(req.params.token),
{ successMessage: '兑换完成', errorMessage: '执行领取兑换失败', scope: '[claims/:token/redeem]' },
))
router.post('/:token/kuaishou-cloud/verify-ticket', createRouteHandler(
(req) => verifyKuaishouCloudClaimTicket(req.params.token, req.body),
{ successMessage: '核销码校验成功', errorMessage: '校验核销码失败', scope: '[claims/:token/kuaishou-cloud/verify-ticket]' },
))
router.post('/:token/kuaishou-cloud/confirm-role', createRouteHandler(
(req) => confirmKuaishouCloudClaimRole(req.params.token),
{ successMessage: '角色已确认', errorMessage: '确认角色失败', scope: '[claims/:token/kuaishou-cloud/confirm-role]' },
))
router.post('/:token/kuaishou-cloud/redeem', createRouteHandler(
(req) => redeemKuaishouCloudClaim(req.params.token),
{ successMessage: '兑换请求已提交', errorMessage: '兑换失败', scope: '[claims/:token/kuaishou-cloud/redeem]' },
))
router.get('/:token/screenshot', createRouteFileHandler(
(req) => getClaimScreenshotPath(req.params.token),
{ errorMessage: '读取领取截图失败', scope: '[claims/:token/screenshot]' },
))
router.get('/assets/kuaishou-cloud/:filename', createRouteFileHandler(
(req) => getKuaishouCloudClaimGuideAssetPath(req.params.filename),
{ errorMessage: '读取指引图片失败', scope: '[claims/assets/kuaishou-cloud/:filename]' },
))
router.use((req, res) => { router.use((req, res) => {
res.status(404).json(buildNotFoundPayload(req)) res.status(404).json(buildNotFoundPayload(req));
}) });
export default router export default router;
-77
View File
@@ -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
-57
View File
@@ -1,57 +0,0 @@
import { Router } from 'express'
import { processAgisoTradeWebhook } from '../services/order/webhook-service.js'
import { createRequestId, logWebhook } from '../utils/logger.js'
import { buildNotFoundPayload, buildSuccessPayload, sendRouteError } from '../utils/http.js'
const router = Router()
router.post('/agiso/trade', async (req, res) => {
const requestId = createRequestId('wh')
const startedAt = Date.now()
logWebhook('[webhooks/agiso/trade]', '收到 Agiso webhook 请求', {
requestId,
method: req.method,
originalUrl: req.originalUrl,
ip: req.ip,
headers: req.headers,
query: req.query,
body: req.body,
})
try {
const data = await processAgisoTradeWebhook({
requestId,
headers: req.headers,
query: req.query,
body: req.body,
})
logWebhook('[webhooks/agiso/trade]', 'Agiso webhook 处理完成', {
requestId,
durationMs: Date.now() - startedAt,
result: data,
})
res.json(buildSuccessPayload(data, 'success'))
} catch (error) {
logWebhook(
'[webhooks/agiso/trade]',
'Agiso webhook 处理失败',
{
requestId,
durationMs: Date.now() - startedAt,
error,
},
{ level: 'error' },
)
sendRouteError(res, error, '处理 Agiso 订单通知失败', '[webhooks/agiso/trade]')
}
})
router.use((req, res) => {
res.status(404).json(buildNotFoundPayload(req))
})
export default router
@@ -1,434 +0,0 @@
// @ts-check
import { createTaskClaimToken } from '../claim/claim-service.js'
import {
closeClaimSessionForAdminTask,
createClaimSessionForAdminTask,
getClaimDetailForAdminTask,
getClaimSessionSummaryForAdminTask,
reloadClaimSessionForAdminTask,
} from '../claim/claim-session-service.js'
import { reserveInventoryForTask } from '../order/inventory-service.js'
import { createHttpError } from '../../utils/http.js'
import { replaceOrderItems } from '../../repositories/order-item-repo.js'
import { createOrder, findOrderByPlatformOrderId } from '../../repositories/order-repo.js'
import { getFulfillmentProfileByKey, listFulfillmentProfileRequirements } from '../../repositories/fulfillment-profile-repo.js'
import { findFirstAvailableInventoryItemBySkuCode } from '../../repositories/inventory-repo.js'
import { createTask, getTaskById, updateTask } from '../../repositories/task-repo.js'
import { createTaskEvent } from '../../repositories/task-event-repo.js'
import { nowIso } from '../../utils/time.js'
import { randomId } from '../../utils/random.js'
import { closeAdminTask, confirmAdminTaskAssistedRole, redeemAdminTaskAssisted } from './admin-write-service.js'
import { createAdminViewerContext, isAssistedClaimTask, parseTaskContext } from './admin-read-shared-helpers.js'
import type {
AdminEntityIdInput,
AdminViewerSessionInput,
} from '../../types/admin-read-inputs.js'
import type { AdminManualRedeemCreateInput } from '../../types/admin-write-inputs.js'
import type { TaskRow } from '../../types/repository-rows.js'
const MANUAL_PROVIDER = 'manual'
const MANUAL_PLATFORM = 'manual_redeem'
const MANUAL_SOURCE_TYPE = 'admin_manual_redeem'
const ASSISTED_PROFILE_KEY = 'tencent_claim_assisted'
type JsonObject = Record<string, any>
export async function createAdminManualRedeemTask(
payload: AdminManualRedeemCreateInput = {},
session: AdminViewerSessionInput | null = null,
): Promise<JsonObject> {
const viewerContext = createAdminViewerContext(session)
const proofValue = normalizeManualProofValue(payload.proofValue)
const skuCode = String(payload.skuCode || '').trim()
const skuName = String(payload.skuName || '').trim() || skuCode
const remark = String(payload.remark || '').trim()
if (!proofValue) {
throw createHttpError('请先输入唯一凭据', {
statusCode: 400,
errorCode: 'admin_manual_redeem_missing_proof',
})
}
if (!skuCode) {
throw createHttpError('请先选择内部履约 SKU', {
statusCode: 400,
errorCode: 'admin_manual_redeem_missing_sku',
})
}
const existingOrder = await findOrderByPlatformOrderId({
provider: MANUAL_PROVIDER,
platform: MANUAL_PLATFORM,
shopId: '',
platformOrderId: proofValue,
})
if (existingOrder) {
throw createHttpError('该唯一凭据已经创建过人工兑换任务,请勿重复提交', {
statusCode: 409,
errorCode: 'admin_manual_redeem_duplicate_proof',
})
}
const profile = await getFulfillmentProfileByKey(ASSISTED_PROFILE_KEY)
if (!profile) {
throw createHttpError('人工兑换所需的履约档案不存在,请先检查系统初始化', {
statusCode: 500,
errorCode: 'admin_manual_redeem_profile_missing',
})
}
const requirements = await listFulfillmentProfileRequirements(profile.id)
const primaryRequirement = requirements.find((item) => item.is_required !== false) || requirements[0] || null
const credentialType = String(primaryRequirement?.credential_type || primaryRequirement?.credentialType || 'tencent_code').trim() || 'tencent_code'
const roleKey = String(primaryRequirement?.role_key || primaryRequirement?.roleKey || 'primary_code').trim() || 'primary_code'
const availableInventory = await findFirstAvailableInventoryItemBySkuCode(
skuCode,
credentialType,
viewerContext.allowedInventoryGroupCodes,
)
if (!availableInventory) {
if (viewerContext.restrictInventoryGroups && viewerContext.allowedInventoryGroupCodes?.length === 0) {
throw createHttpError('当前客服未绑定任何库存组,无法创建人工兑换任务', {
statusCode: 403,
errorCode: 'admin_manual_redeem_inventory_group_unbound',
})
}
throw createHttpError('当前 SKU 没有可用库存,无法创建人工兑换任务', {
statusCode: 409,
errorCode: 'admin_manual_redeem_inventory_unavailable',
})
}
const now = nowIso()
const manualContext = {
sourceType: MANUAL_SOURCE_TYPE,
proofValue,
remark,
createdAt: now,
createdBy: session
? {
userId: Number(session.userId || 0) || 0,
username: String(session.username || '').trim(),
role: String(session.role || '').trim(),
}
: null,
}
let order
try {
order = await createOrder({
provider: MANUAL_PROVIDER,
platform: MANUAL_PLATFORM,
shopId: '',
shopName: '人工兑换',
platformOrderId: proofValue,
orderStatus: 'manual_created',
payStatus: 'paid',
buyerId: '',
buyerName: '',
receiverContact: '',
totalAmount: 0,
currency: 'CNY',
rawPayloadJson: JSON.stringify({
manualRedeem: manualContext,
}),
paidAt: now,
createdAt: now,
updatedAt: now,
})
} catch (error) {
if (String(error?.code || '') === '23505') {
throw createHttpError('该唯一凭据已经创建过人工兑换任务,请勿重复提交', {
statusCode: 409,
errorCode: 'admin_manual_redeem_duplicate_proof',
})
}
throw error
}
if (!order) {
throw createHttpError('人工兑换订单创建失败', {
statusCode: 500,
errorCode: 'admin_manual_redeem_order_create_failed',
})
}
const orderItems = await replaceOrderItems(order.id, [
{
skuCode,
skuName,
quantity: 1,
specJson: JSON.stringify({
title: skuName,
sourceType: MANUAL_SOURCE_TYPE,
}),
itemSnapshotJson: JSON.stringify({
manualRedeem: manualContext,
skuCode,
skuName,
}),
createdAt: now,
updatedAt: now,
},
])
const orderItem = orderItems[0]
if (!orderItem) {
throw createHttpError('人工兑换订单商品创建失败', {
statusCode: 500,
errorCode: 'admin_manual_redeem_order_item_create_failed',
})
}
const createdTask = await createTask({
orderId: order.id,
orderItemId: orderItem.id,
unitIndex: 1,
provider: MANUAL_PROVIDER,
platform: MANUAL_PLATFORM,
shopId: '',
shopName: '人工兑换',
platformOrderId: proofValue,
taskNo: randomId('MR'),
profileId: Number(profile.id),
executorKey: String(profile.executor_key || ASSISTED_PROFILE_KEY),
taskStatus: 'paid',
inventoryStatus: 'pending',
deliveryStatus: 'pending',
resultCode: '',
resultMessage: '',
claimToken: '',
claimExpiresAt: null,
automationMode: 'manual',
requiresClaim: true,
userActionStatus: 'pending_claim',
attemptCount: 0,
lastError: '',
contextJson: JSON.stringify({
profileKey: String(profile.profile_key || ASSISTED_PROFILE_KEY),
profileName: String(profile.name || ''),
skuCode,
skuName,
inventorySkuCode: skuCode,
primaryRequirement: primaryRequirement
? {
roleKey,
credentialType,
}
: null,
inventoryGroupCode: String(availableInventory.inventory_group_code || '').trim(),
manualRedeem: manualContext,
}),
createdAt: now,
updatedAt: now,
})
if (!createdTask) {
throw createHttpError('人工兑换任务创建失败', {
statusCode: 500,
errorCode: 'admin_manual_redeem_task_create_failed',
})
}
const reservedInventory = await reserveInventoryForTask({
skuCode,
taskId: createdTask.id,
credentialType,
roleKey,
inventoryGroupCodes: viewerContext.allowedInventoryGroupCodes,
})
if (!reservedInventory) {
await updateTask(createdTask.id, {
task_status: 'waiting_inventory',
inventory_status: 'pending',
last_error: '库存不足,无法为人工兑换任务预占库存',
updated_at: nowIso(),
})
throw createHttpError('库存刚刚被其他任务占用,请重新选择或稍后再试', {
statusCode: 409,
errorCode: 'admin_manual_redeem_inventory_race_lost',
})
}
const claimToken = await createTaskClaimToken(createdTask.id)
await updateTask(createdTask.id, {
task_status: 'link_generated',
inventory_status: 'reserved',
claim_token: claimToken.token,
claim_expires_at: claimToken.expired_at,
user_action_status: 'pending_claim',
last_error: '',
context_json: JSON.stringify({
...parseTaskContext(createdTask),
inventoryGroupCode: String(reservedInventory.inventory_group_code || '').trim(),
}),
updated_at: nowIso(),
})
await createTaskEvent(createdTask.id, 'manual_redeem_created', {
sourceType: MANUAL_SOURCE_TYPE,
proofValue,
skuCode,
skuName,
inventoryItemId: Number(reservedInventory.id || 0) || null,
inventoryGroupCode: String(reservedInventory.inventory_group_code || '').trim(),
createdBy: manualContext.createdBy,
remark,
}, nowIso())
return getAdminManualRedeemDetail(createdTask.id, session)
}
export async function getAdminManualRedeemDetail(
taskId: AdminEntityIdInput,
session: AdminViewerSessionInput | null = null,
): Promise<JsonObject> {
const task = await getRequiredManualRedeemTask(taskId, session)
const detail = await getClaimDetailForAdminTask(task.id)
return decorateManualRedeemDetail(task, detail)
}
export async function createAdminManualRedeemSession(
taskId: AdminEntityIdInput,
payload: { loginType?: string, forceRecreate?: boolean } = {},
session: AdminViewerSessionInput | null = null,
): Promise<JsonObject> {
const task = await getRequiredManualRedeemTask(taskId, session)
const detail = await createClaimSessionForAdminTask(task.id, payload)
return decorateManualRedeemDetail(task, detail)
}
export async function getAdminManualRedeemSessionSummary(
taskId: AdminEntityIdInput,
session: AdminViewerSessionInput | null = null,
): Promise<JsonObject> {
const task = await getRequiredManualRedeemTask(taskId, session)
const detail = await getClaimSessionSummaryForAdminTask(task.id)
return decorateManualRedeemDetail(task, detail)
}
export async function reloadAdminManualRedeemSession(
taskId: AdminEntityIdInput,
session: AdminViewerSessionInput | null = null,
): Promise<JsonObject> {
const task = await getRequiredManualRedeemTask(taskId, session)
const detail = await reloadClaimSessionForAdminTask(task.id)
return decorateManualRedeemDetail(task, detail)
}
export async function closeAdminManualRedeemSession(
taskId: AdminEntityIdInput,
session: AdminViewerSessionInput | null = null,
): Promise<JsonObject> {
const task = await getRequiredManualRedeemTask(taskId, session)
const detail = await closeClaimSessionForAdminTask(task.id)
return decorateManualRedeemDetail(task, detail)
}
export async function closeAdminManualRedeemTask(
taskId: AdminEntityIdInput,
session: AdminViewerSessionInput | null = null,
): Promise<JsonObject> {
const task = await getRequiredManualRedeemTask(taskId, session)
await closeAdminTask(task.id)
return getAdminManualRedeemDetail(task.id, session)
}
export async function confirmAdminManualRedeemRole(
taskId: AdminEntityIdInput,
session: AdminViewerSessionInput | null = null,
): Promise<JsonObject> {
const task = await getRequiredManualRedeemTask(taskId, session)
await confirmAdminTaskAssistedRole(task.id, session)
return getAdminManualRedeemDetail(task.id, session)
}
export async function redeemAdminManualRedeemTask(
taskId: AdminEntityIdInput,
session: AdminViewerSessionInput | null = null,
): Promise<JsonObject> {
const task = await getRequiredManualRedeemTask(taskId, session)
await redeemAdminTaskAssisted(task.id, session)
return getAdminManualRedeemDetail(task.id, session)
}
async function getRequiredManualRedeemTask(
taskId: AdminEntityIdInput,
session: AdminViewerSessionInput | null = null,
): Promise<TaskRow> {
const task = await getTaskById(Number(taskId))
if (!task) {
throw createHttpError('人工兑换任务不存在', {
statusCode: 404,
errorCode: 'admin_manual_redeem_task_not_found',
})
}
const taskContext = parseTaskContext(task)
const sourceType = String(taskContext?.manualRedeem?.sourceType || '').trim()
if (!isAssistedClaimTask(task) || sourceType !== MANUAL_SOURCE_TYPE) {
throw createHttpError('当前任务不是人工兑换任务', {
statusCode: 409,
errorCode: 'admin_manual_redeem_task_invalid',
})
}
const viewerContext = createAdminViewerContext(session)
if (viewerContext.restrictInventoryGroups) {
if (!Array.isArray(viewerContext.allowedInventoryGroupCodes) || viewerContext.allowedInventoryGroupCodes.length === 0) {
throw createHttpError('当前客服未绑定任何库存组,无法操作人工兑换任务', {
statusCode: 403,
errorCode: 'admin_manual_redeem_inventory_group_unbound',
})
}
const inventoryGroupCode = String(taskContext?.inventoryGroupCode || '').trim()
if (inventoryGroupCode && !viewerContext.allowedInventoryGroupCodes.includes(inventoryGroupCode)) {
throw createHttpError('当前客服无权操作该库存组下的人工兑换任务', {
statusCode: 403,
errorCode: 'admin_manual_redeem_inventory_group_denied',
})
}
}
return task
}
function decorateManualRedeemDetail(task: TaskRow, detail: any): JsonObject {
const taskContext = parseTaskContext(task)
const manualRedeem = taskContext.manualRedeem && typeof taskContext.manualRedeem === 'object'
? taskContext.manualRedeem
: {}
return {
...detail,
claimUrl: '',
manualRequest: {
sourceType: String(manualRedeem.sourceType || MANUAL_SOURCE_TYPE).trim() || MANUAL_SOURCE_TYPE,
proofValue: String(manualRedeem.proofValue || detail?.order?.platformOrderId || '').trim(),
remark: String(manualRedeem.remark || '').trim(),
},
result: detail.result
? {
...detail.result,
screenshotUrl: detail.result.screenshotReady ? `/api/v1/admin/tasks/${detail.task.taskId}/screenshot` : '',
}
: null,
}
}
function normalizeManualProofValue(value: unknown): string {
return String(value || '').replace(/\s+/g, ' ').trim()
}
@@ -38,7 +38,6 @@ import { mapAdminInventoryListItem } from './admin-inventory-read-helpers.js'
import { mapAdminOrderListItem, summarizeOrderItems } from './admin-order-read-helpers.js' import { mapAdminOrderListItem, summarizeOrderItems } from './admin-order-read-helpers.js'
import { mapAdminWebhookEvent } from './admin-webhook-read-helpers.js' import { mapAdminWebhookEvent } from './admin-webhook-read-helpers.js'
import { import {
buildOrderAgisoAutoDeliverySummary,
buildOrderBindingSummary, buildOrderBindingSummary,
createTaskBindingSummaryFromBindings, createTaskBindingSummaryFromBindings,
getRequiredTask, getRequiredTask,
@@ -130,7 +129,6 @@ export async function getAdminOrderDetail(orderId: AdminEntityIdInput): Promise<
? order.raw_payload_json ? order.raw_payload_json
: JSON.parse(String(order.raw_payload_json || '{}')), : JSON.parse(String(order.raw_payload_json || '{}')),
bindingSummary: buildOrderBindingSummary(tasks, taskBindingSummaryMap), bindingSummary: buildOrderBindingSummary(tasks, taskBindingSummaryMap),
agisoAutoDelivery: buildOrderAgisoAutoDeliverySummary(order, tasks),
}, },
items: items.map((item) => ({ items: items.map((item) => ({
orderItemId: item.id, orderItemId: item.id,
@@ -1,86 +0,0 @@
export {
getRequiredInventoryItem,
mapAdminInventoryListItem,
} from './admin-inventory-read-helpers.js'
export {
mapAdminOrderListItem,
summarizeOrderItems,
} from './admin-order-read-helpers.js'
export { mapAdminWebhookEvent } from './admin-webhook-read-helpers.js'
export {
buildOrderAgisoAutoDeliverySummary,
buildOrderBindingSummary,
createEmptyTaskBindingSummary,
createTaskBindingSummaryFromBindings,
getRequiredTask,
getTaskBindingSummary,
getTaskBindingSummaryMap,
mapAdminTaskEvent,
mapAdminTaskInventoryBinding,
mapAdminTaskListItem,
mapAdminTaskSummary,
mapTaskActionPayload,
} from './admin-task-read-helpers.js'
export {
canRegenerateClaimLinkForViewer,
canViewerConfirmAssistedRole,
canViewerRedeemAssistedTask,
createAdminViewerContext,
getTaskPrimaryClaimTokenId,
getTaskPrimaryInventoryItemId,
isAssistedClaimTask,
isManualDispatchTask,
mapManualDispatchContext,
mapRedeemResolutionContext,
parseTaskContext,
parseTaskState,
resolveOrderItemDeliveryMode,
resolveOrderItemTitle,
resolveAdminTaskScreenshotUrl,
resolveDisplayShopName,
} from './admin-read-shared-helpers.js'
export {
getAdminInventoryItems,
getAdminInventorySkuSuggestions,
getAdminOrderDetail,
getAdminOrders,
getAdminTaskDetail,
getAdminTasks,
getAdminTaskScreenshotPath,
getAdminWebhookEventDetail,
getAdminWebhookEvents,
} from './admin-read-service.js'
export {
closeAdminTask,
completeAdminTaskManualDispatch,
confirmAdminTaskAssistedRole,
createAdminInventoryItem,
dispatchAdminTaskKuaishouCloudFulfillment,
importAdminInventoryItems,
invalidateAdminInventoryItem,
markAdminTaskManualReview,
prepareAdminTaskKuaishouCloudFulfillment,
redeemAdminTaskAssisted,
regenerateAdminTaskClaimLink,
releaseAdminInventoryItem,
releaseAdminTaskInventory,
releaseAdminTaskInventoryBinding,
replayAdminWebhookEvent,
returnNumberAdminTaskKuaishouCloudFulfillment,
retryAdminTask,
} from './admin-write-service.js'
export {
getAdminAgisoShopConfigs,
getAdminFulfillmentBindingConfigs,
lookupAdminFulfillmentBindingOrder,
updateAdminAgisoShopConfigs,
updateAdminFulfillmentBindingConfigs,
} from './platform-config/service.js'
export { getAdminDashboardSummary } from './admin-dashboard-service.js'
export { getAdminMessageDeliveries } from './admin-message-delivery-service.js'
@@ -10,13 +10,11 @@ import {
} from './admin-read-shared-helpers.js' } from './admin-read-shared-helpers.js'
import type { import type {
AdminAgisoAutoDeliveryStatus,
AdminTaskBindingSummary, AdminTaskBindingSummary,
AdminTaskListItem, AdminTaskListItem,
} from '../../types/admin-read-models.js' } from '../../types/admin-read-models.js'
import type { AdminTaskActionPayload } from '../../types/admin-write-models.js' import type { AdminTaskActionPayload } from '../../types/admin-write-models.js'
import type { import type {
OrderRow,
TaskEventRow, TaskEventRow,
TaskInventoryBindingRow, TaskInventoryBindingRow,
TaskInventoryBindingSummaryRow, TaskInventoryBindingSummaryRow,
@@ -47,24 +45,11 @@ type OrderBindingSummary = {
userBindingStatus: string userBindingStatus: string
} }
type OrderAgisoAutoDeliverySummary = AdminAgisoAutoDeliveryStatus & {
sourceTaskId: number | null
sourceTaskNo: string
totalTaskCount: number
deliveredTaskCount: number
}
type OrderAgisoAutoDeliveryCandidate = AdminAgisoAutoDeliveryStatus & {
sourceTaskId: number | null
sourceTaskNo: string
}
export function mapAdminTaskSummary( export function mapAdminTaskSummary(
task: TaskRow, task: TaskRow,
bindingSummary: AdminTaskBindingSummary = createEmptyTaskBindingSummary(), bindingSummary: AdminTaskBindingSummary = createEmptyTaskBindingSummary(),
): JsonRecord { ): JsonRecord {
const binding = buildTaskBindingState(task) const binding = buildTaskBindingState(task)
const taskContext = parseTaskContext(task)
return { return {
taskId: task.id, taskId: task.id,
@@ -81,7 +66,6 @@ export function mapAdminTaskSummary(
lastError: task.last_error, lastError: task.last_error,
retryCount: getTaskRetryCount(task), retryCount: getTaskRetryCount(task),
bindingSummary, bindingSummary,
agisoAutoDelivery: mapAgisoAutoDeliveryContext(taskContext.agisoAutoDelivery),
createdAt: task.created_at, createdAt: task.created_at,
updatedAt: task.updated_at, updatedAt: task.updated_at,
} }
@@ -124,7 +108,6 @@ export function mapAdminTaskListItem(
viewerContext: AdminViewerContext = createAdminViewerContext(), viewerContext: AdminViewerContext = createAdminViewerContext(),
): AdminTaskListItem { ): AdminTaskListItem {
const binding = buildTaskBindingState(task) const binding = buildTaskBindingState(task)
const taskContext = parseTaskContext(task)
return { return {
taskId: task.id, taskId: task.id,
@@ -148,7 +131,6 @@ export function mapAdminTaskListItem(
redeemedAt: task.redeemed_at, redeemedAt: task.redeemed_at,
retryCount: getTaskRetryCount(task), retryCount: getTaskRetryCount(task),
bindingSummary, bindingSummary,
agisoAutoDelivery: mapAgisoAutoDeliveryContext(taskContext.agisoAutoDelivery),
lastError: task.last_error, lastError: task.last_error,
createdAt: task.created_at, createdAt: task.created_at,
updatedAt: task.updated_at, updatedAt: task.updated_at,
@@ -315,63 +297,6 @@ export function buildOrderBindingSummary(
} }
} }
export function buildOrderAgisoAutoDeliverySummary(
order: Partial<OrderRow> | null | undefined,
tasks: TaskRow[] = [],
): OrderAgisoAutoDeliverySummary | null {
if (String(order?.provider || '').trim() !== 'agiso' || String(order?.platform || '').trim() !== 'xianyu') {
return null
}
const normalizedTasks = Array.isArray(tasks) ? tasks.filter(Boolean) : []
const totalTaskCount = normalizedTasks.length
const deliveredTaskCount = normalizedTasks.filter((task) => String(task?.delivery_status || '').trim() === 'delivered').length
const latest = normalizedTasks.reduce<OrderAgisoAutoDeliveryCandidate | null>((best, task) => {
const autoDelivery = mapAgisoAutoDeliveryContext(parseTaskContext(task).agisoAutoDelivery)
if (!autoDelivery) {
return best
}
const candidate = {
...autoDelivery,
sourceTaskId: Number(task.id || 0) || null,
sourceTaskNo: String(task.task_no || '').trim(),
}
const candidateTime = Date.parse(String(candidate.updatedAt || task.updated_at || ''))
const bestTime = Date.parse(String(best?.updatedAt || ''))
if (!best || (Number.isFinite(candidateTime) && (!Number.isFinite(bestTime) || candidateTime >= bestTime))) {
return candidate
}
return best
}, null)
if (latest) {
return {
...latest,
totalTaskCount,
deliveredTaskCount,
}
}
return {
status: totalTaskCount === 0 ? 'not_started' : deliveredTaskCount >= totalTaskCount ? 'pending' : 'waiting',
trigger: '',
reason: deliveredTaskCount >= totalTaskCount ? '' : 'waiting_other_tasks',
platformOrderId: String(order?.platform_order_id || '').trim(),
responseStatus: 0,
errorMessage: '',
requestId: '',
updatedAt: null,
sourceTaskId: null,
sourceTaskNo: '',
totalTaskCount,
deliveredTaskCount,
}
}
function canReleaseTaskInventoryBinding(task: TaskRow | null | undefined, binding: TaskInventoryBindingLike | null | undefined): boolean { function canReleaseTaskInventoryBinding(task: TaskRow | null | undefined, binding: TaskInventoryBindingLike | null | undefined): boolean {
if (!task || !binding) { if (!task || !binding) {
return false return false
@@ -469,24 +394,6 @@ function isTaskSystemBound(task: TaskRow | null | undefined): boolean {
return Boolean(task && (getTaskPrimaryInventoryItemId(task) || getTaskPrimaryClaimTokenId(task))) return Boolean(task && (getTaskPrimaryInventoryItemId(task) || getTaskPrimaryClaimTokenId(task)))
} }
function mapAgisoAutoDeliveryContext(value: unknown): AdminAgisoAutoDeliveryStatus | null {
if (!value || typeof value !== 'object') {
return null
}
const record = value as JsonRecord
return {
status: String(record.status || '').trim(),
trigger: String(record.trigger || '').trim(),
reason: String(record.reason || '').trim(),
platformOrderId: String(record.platformOrderId || '').trim(),
responseStatus: Number(record.responseStatus || 0),
errorMessage: String(record.errorMessage || '').trim(),
requestId: String(record.requestId || '').trim(),
updatedAt: record.updatedAt || null,
}
}
function normalizeRecord(value: unknown): JsonRecord { function normalizeRecord(value: unknown): JsonRecord {
return value && typeof value === 'object' && !Array.isArray(value) ? value : {} return value && typeof value === 'object' && !Array.isArray(value) ? value : {}
} }
@@ -1,28 +0,0 @@
import { getTencentBrowserSessionReviewScreenshotPath } from '../session/session.js'
import { createHttpError } from '../../utils/http.js'
import { getRequiredTask } from './admin-task-read-helpers.js'
import type {
AdminEntityIdInput,
AdminViewerSessionInput,
} from '../../types/admin-read-inputs.js'
export async function getAdminTaskScreenshotPathWithTencentFallback(
taskId: AdminEntityIdInput,
_session: AdminViewerSessionInput | null = null,
): Promise<string> {
const task = await getRequiredTask(taskId)
if (task.screenshot_path) {
return task.screenshot_path
}
if (task.browser_session_id) {
return getTencentBrowserSessionReviewScreenshotPath(task.browser_session_id)
}
throw createHttpError('当前任务还没有可查看截图', {
statusCode: 404,
errorCode: 'admin_task_screenshot_not_found',
})
}
@@ -1,6 +1,5 @@
import { listTasksByOrderId } from '../../repositories/task-repo.js' import { listTasksByOrderId } from '../../repositories/task-repo.js'
import { formatFenToAmount, parseAmountToFen } from '../../utils/money.js' import { formatFenToAmount, parseAmountToFen } from '../../utils/money.js'
import { extractAgisoTradePayload, resolveAgisoTradePlatformOrderId } from '../order/agiso-trade-parsing.js'
import { safeParseJson } from './admin-query-utils.js' import { safeParseJson } from './admin-query-utils.js'
import { resolveDisplayShopName } from './admin-read-shared-helpers.js' import { resolveDisplayShopName } from './admin-read-shared-helpers.js'
@@ -49,7 +48,7 @@ export async function mapAdminWebhookEvent(
const mapped = { const mapped = {
eventId: item.id, eventId: item.id,
provider: item.provider || 'agiso', provider: item.provider || '',
platform: item.platform, platform: item.platform,
platformRaw: pickFirstNonEmpty([ platformRaw: pickFirstNonEmpty([
query.fromPlatform, query.fromPlatform,
@@ -82,7 +81,7 @@ export async function mapAdminWebhookEvent(
visibilityLevel: resolveWebhookVisibilityLevel(item.process_error), visibilityLevel: resolveWebhookVisibilityLevel(item.process_error),
relatedOrderId: item.related_order_id, relatedOrderId: item.related_order_id,
createdAt: item.created_at, createdAt: item.created_at,
platformOrderId: resolveAgisoTradePlatformOrderId(payload), platformOrderId: resolveWebhookPlatformOrderId(item, payload),
buyerId: pickFirstNonEmpty([ buyerId: pickFirstNonEmpty([
payload.buyer_id, payload.buyer_id,
payload.buyerId, payload.buyerId,
@@ -135,7 +134,44 @@ export async function mapAdminWebhookEvent(
} }
function extractWebhookPayload(body: JsonRecord): JsonRecord { function extractWebhookPayload(body: JsonRecord): JsonRecord {
return extractAgisoTradePayload(body) const candidates = [
body.data,
body.Data,
body.payload,
body.Payload,
body.message,
body.Message,
]
for (const candidate of candidates) {
if (candidate && typeof candidate === 'object' && !Array.isArray(candidate)) {
return normalizeRecord(candidate)
}
}
return body
}
function resolveWebhookPlatformOrderId(item: WebhookEventRow, payload: JsonRecord): string {
const fromPayload = pickFirstNonEmpty([
payload.biz_order_id,
payload.bizOrderId,
payload.BizOrderId,
payload.tid,
payload.Tid,
payload.order_id,
payload.orderId,
payload.OrderId,
payload.oid,
payload.Oid,
])
if (fromPayload) {
return fromPayload
}
const parts = String(item.event_key || '').split(':').map((part) => part.trim()).filter(Boolean)
return parts.find((part) => /^\d{6,}$/.test(part)) || ''
} }
function extractWebhookItemSources(payload: unknown): JsonRecord[] { function extractWebhookItemSources(payload: unknown): JsonRecord[] {
@@ -1,105 +0,0 @@
import test from 'node:test'
import assert from 'node:assert/strict'
import { closeAdminTaskWithDeps } from './admin-write-service.js'
test('closeAdminTaskWithDeps revokes claim link, releases reserved inventory, and closes browser session', async () => {
const calls = {
updateToken: [],
releaseReserved: [],
closeSession: [],
updateTask: [],
createEvent: [],
}
const task = {
id: 30,
task_status: 'link_generated',
delivery_status: 'pending',
inventory_status: 'reserved',
user_action_status: 'pending_claim',
browser_session_id: 'browser-session-30',
primary_claim_token_id: 9,
claim_expires_at: '2026-04-15T10:00:00.000Z',
last_error: '',
updated_at: '2026-04-14T10:00:00.000Z',
}
const now = '2026-04-14T10:16:18.000Z'
const result = await closeAdminTaskWithDeps(task.id, {
getRequiredTask: async () => task,
listTaskInventoryBindingsByTaskId: async () => ([
{ inventory_item_id: 17, binding_status: 'reserved' },
{ inventory_item_id: 18, binding_status: 'released' },
{ inventory_item_id: 17, binding_status: 'reserved' },
]),
releaseReservedInventoryItem: async (inventoryItemId, updatedAt) => {
calls.releaseReserved.push({ inventoryItemId, updatedAt })
return { id: inventoryItemId }
},
updateClaimToken: async (tokenId, patch) => {
calls.updateToken.push({ tokenId, patch })
return { id: tokenId, ...patch }
},
closeTencentBrowserSession: async (sessionId) => {
calls.closeSession.push(sessionId)
return { sessionId, closed: true }
},
updateTask: async (taskId, patch) => {
calls.updateTask.push({ taskId, patch })
return { ...task, ...patch }
},
createTaskEvent: async (taskId, eventType, payload, createdAt) => {
calls.createEvent.push({ taskId, eventType, payload, createdAt })
return null
},
nowIso: () => now,
})
assert.equal(calls.updateToken.length, 1)
assert.deepEqual(calls.updateToken[0], {
tokenId: 9,
patch: {
status: 'revoked',
expired_at: now,
updated_at: now,
},
})
assert.deepEqual(calls.releaseReserved, [
{ inventoryItemId: 17, updatedAt: now },
])
assert.deepEqual(calls.closeSession, ['browser-session-30'])
assert.equal(calls.updateTask.length, 1)
assert.equal(calls.updateTask[0].patch.task_status, 'closed')
assert.equal(calls.updateTask[0].patch.delivery_status, 'closed')
assert.equal(calls.updateTask[0].patch.inventory_status, 'pending')
assert.equal(calls.updateTask[0].patch.user_action_status, 'closed')
assert.equal(calls.updateTask[0].patch.browser_session_id, '')
assert.equal(calls.updateTask[0].patch.claim_expires_at, now)
assert.match(calls.updateTask[0].patch.last_error, /领取链接已失效/)
assert.match(calls.updateTask[0].patch.last_error, /预占库存已释放/)
assert.equal(calls.createEvent.length, 1)
assert.deepEqual(calls.createEvent[0], {
taskId: 30,
eventType: 'task_closed',
payload: {
claimTokenRevoked: true,
releasedInventoryItemIds: [17],
releasedInventoryCount: 1,
browserSessionClosed: true,
},
createdAt: now,
})
assert.equal(result.task.status, 'closed')
})
test('closeAdminTaskWithDeps rejects redeemed tasks', async () => {
await assert.rejects(
() => closeAdminTaskWithDeps(99, {
getRequiredTask: async () => ({
id: 99,
task_status: 'redeemed',
}),
}),
/已兑换任务不能关闭/,
)
})
@@ -1,30 +0,0 @@
export {
createAdminInventoryItem,
importAdminInventoryItems,
releaseAdminInventoryItem,
invalidateAdminInventoryItem,
} from './write/inventory.js'
export {
replayAdminWebhookEvent,
} from './write/webhook-events.js'
export {
releaseAdminTaskInventory,
releaseAdminTaskInventoryBinding,
regenerateAdminTaskClaimLink,
confirmAdminTaskAssistedRole,
redeemAdminTaskAssisted,
closeAdminTask,
closeAdminTaskWithDeps,
markAdminTaskManualReview,
retryAdminTask,
completeAdminTaskManualDispatch,
} from './write/task-actions.js'
export {
prepareAdminTaskKuaishouCloudFulfillment,
dispatchAdminTaskKuaishouCloudFulfillment,
refreshAdminTaskKuaishouCloudRoleInfo,
returnNumberAdminTaskKuaishouCloudFulfillment,
} from './write/kuaishou-cloud-actions.js'
@@ -1,65 +0,0 @@
import { query } from '../../../db/client.js'
import {
getAgisoMessagingDefaults,
getAgisoShopConfigMap,
getAgisoShopsFilePath,
saveAgisoMessagingConfig,
} from '../../platforms/agiso/shop-config-service.js'
import { resolveDisplayShopName } from '../admin-read-shared-helpers.js'
import {
mapAdminAgisoMessagingDefaults,
mapAdminAgisoShopConfigItem,
} from './domain.js'
import { buildAdminAgisoMessagingSavePayload } from './writes.js'
type JsonObject = Record<string, any>
export async function getAdminAgisoShopConfigs() {
const configMap = getAgisoShopConfigMap()
const defaults = getAgisoMessagingDefaults()
const rowsResult = await query(
`
SELECT
shop_id,
MAX(CASE WHEN trim(shop_name) != '' THEN shop_name ELSE '' END) AS detected_shop_name,
MAX(created_at) AS latest_seen_at,
COUNT(*)::int AS webhook_event_count
FROM webhook_events
WHERE provider = 'agiso' AND trim(shop_id) != ''
GROUP BY shop_id
ORDER BY latest_seen_at DESC, shop_id DESC
`,
)
const rows = rowsResult.rows
return {
filePath: getAgisoShopsFilePath(),
defaults: mapAdminAgisoMessagingDefaults(defaults),
shops: Object.entries(configMap)
.sort(([left], [right]) => left.localeCompare(right))
.map(([shopId, config]) => mapAdminAgisoShopConfigItem(shopId, config)),
observedShops: rows.map((row) => ({
shopId: String(row.shop_id || '').trim(),
detectedShopName: String(row.detected_shop_name || '').trim(),
displayShopName: resolveDisplayShopName('agiso', row.shop_id, row.detected_shop_name),
latestSeenAt: row.latest_seen_at || null,
webhookEventCount: Number(row.webhook_event_count || 0),
configured: Boolean(configMap[String(row.shop_id || '').trim()]),
})),
}
}
export function updateAdminAgisoShopConfigs(payload: JsonObject = {}) {
const saved = saveAgisoMessagingConfig(buildAdminAgisoMessagingSavePayload(payload, {
currentDefaults: getAgisoMessagingDefaults(),
currentMap: getAgisoShopConfigMap(),
}))
return {
filePath: getAgisoShopsFilePath(),
defaults: mapAdminAgisoMessagingDefaults(saved.defaults),
shops: Object.entries(saved.shops)
.sort(([left], [right]) => left.localeCompare(right))
.map(([shopId, config]) => mapAdminAgisoShopConfigItem(shopId, config)),
}
}
@@ -1,6 +0,0 @@
export function normalizeAgisoMessageTemplate(value: unknown): string {
return String(value || "")
.replaceAll("\\r\\n", "\n")
.replaceAll("\\n", "\n")
.replaceAll("\r\n", "\n");
}
@@ -2,101 +2,15 @@ import test from 'node:test'
import assert from 'node:assert/strict' import assert from 'node:assert/strict'
import { import {
applyOptionalStringField,
findMatchingObservedBinding, findMatchingObservedBinding,
mapAdminAgisoMessagingDefaults,
mapAdminAgisoShopConfigItem,
mapAdminFulfillmentBindingConfigItem,
mapAdminObservedProductItem, mapAdminObservedProductItem,
matchesObservedProduct, matchesObservedProduct,
} from './domain.js' } from './domain.js'
test('mapAdminAgisoMessagingDefaults normalizes escaped newlines', () => {
assert.deepEqual(
mapAdminAgisoMessagingDefaults({
messageTemplate: ' hello\\nworld ',
autoDeliveryMessageTemplate: ' done\\nnow ',
}),
{
messageTemplate: 'hello\nworld',
autoDeliveryMessageTemplate: 'done\nnow',
},
)
})
test('mapAdminAgisoShopConfigItem masks secrets and reports configured flags', () => {
assert.deepEqual(
mapAdminAgisoShopConfigItem('1001', {
shopName: ' 店铺A ',
accessToken: 'abcdef1234567890',
enabled: true,
messageTemplate: ' hi\\nall ',
autoDeliveryMessageTemplate: ' ok ',
appSecret: 'secret-1',
apiVersion: ' v2 ',
sendMessageEndpoint: ' /send ',
}),
{
shopId: '1001',
shopName: '店铺A',
accessToken: 'abcdef1234567890',
accessTokenMasked: 'abcdef****567890',
enabled: true,
messageTemplate: 'hi\nall',
autoDeliveryMessageTemplate: 'ok',
appSecretConfigured: true,
apiVersion: 'v2',
sendMessageEndpoint: '/send',
},
)
})
test('applyOptionalStringField updates and clears normalized template fields', () => {
const target = { messageTemplate: 'old' }
applyOptionalStringField(target, 'messageTemplate', { messageTemplate: ' next\\nline ' })
assert.deepEqual(target, { messageTemplate: 'next\nline' })
applyOptionalStringField(target, 'messageTemplate', { messageTemplate: ' ' })
assert.deepEqual(target, {})
})
test('mapAdminFulfillmentBindingConfigItem normalizes binding shape', () => {
assert.deepEqual(
mapAdminFulfillmentBindingConfigItem({
provider: ' agiso ',
platform: ' xianyu ',
shopId: ' shop-1 ',
skuCode: ' sku-1 ',
priority: '80',
match: {
externalSkuCode: ' ext-1 ',
},
}),
{
provider: 'agiso',
platform: 'xianyu',
shopId: 'shop-1',
shopName: '',
skuCode: 'sku-1',
skuName: '',
profileKey: '',
enabled: true,
priority: 80,
config: {},
match: {
externalSkuCode: 'ext-1',
externalItemId: '',
externalSkuName: '',
config: {},
},
},
)
})
test('matchesObservedProduct honors provider platform shop and external fields', () => { test('matchesObservedProduct honors provider platform shop and external fields', () => {
const binding = { const binding = {
provider: 'agiso', provider: '91kaquan',
platform: 'xianyu', platform: 'kuaishou',
shopId: 'shop-1', shopId: 'shop-1',
match: { match: {
externalSkuCode: 'sku-ext-1', externalSkuCode: 'sku-ext-1',
@@ -107,8 +21,8 @@ test('matchesObservedProduct honors provider platform shop and external fields',
assert.equal( assert.equal(
matchesObservedProduct(binding, { matchesObservedProduct(binding, {
provider: 'agiso', provider: '91kaquan',
platform: 'xianyu', platform: 'kuaishou',
shopId: 'shop-1', shopId: 'shop-1',
externalSkuCode: 'sku-ext-1', externalSkuCode: 'sku-ext-1',
}), }),
@@ -117,8 +31,8 @@ test('matchesObservedProduct honors provider platform shop and external fields',
assert.equal( assert.equal(
matchesObservedProduct(binding, { matchesObservedProduct(binding, {
provider: 'agiso', provider: '91kaquan',
platform: 'xianyu', platform: 'kuaishou',
shopId: 'shop-2', shopId: 'shop-2',
externalSkuCode: 'sku-ext-1', externalSkuCode: 'sku-ext-1',
}), }),
@@ -129,8 +43,8 @@ test('matchesObservedProduct honors provider platform shop and external fields',
test('mapAdminObservedProductItem attaches matched binding summary', () => { test('mapAdminObservedProductItem attaches matched binding summary', () => {
const bindings = [ const bindings = [
{ {
provider: 'agiso', provider: '91kaquan',
platform: 'xianyu', platform: 'kuaishou',
shopId: 'shop-1', shopId: 'shop-1',
skuCode: 'sku-1', skuCode: 'sku-1',
skuName: 'SKU 1', skuName: 'SKU 1',
@@ -141,8 +55,8 @@ test('mapAdminObservedProductItem attaches matched binding summary', () => {
}, },
] ]
const observed = { const observed = {
provider: 'agiso', provider: '91kaquan',
platform: 'xianyu', platform: 'kuaishou',
shopId: 'shop-1', shopId: 'shop-1',
shopName: '店铺1', shopName: '店铺1',
externalItemId: '', externalItemId: '',
@@ -154,8 +68,8 @@ test('mapAdminObservedProductItem attaches matched binding summary', () => {
assert.deepEqual(findMatchingObservedBinding(bindings, observed), bindings[0]) assert.deepEqual(findMatchingObservedBinding(bindings, observed), bindings[0])
assert.deepEqual(mapAdminObservedProductItem(observed, bindings), { assert.deepEqual(mapAdminObservedProductItem(observed, bindings), {
provider: 'agiso', provider: '91kaquan',
platform: 'xianyu', platform: 'kuaishou',
shopId: 'shop-1', shopId: 'shop-1',
shopName: '店铺1', shopName: '店铺1',
externalItemId: '', externalItemId: '',
@@ -1,70 +1,5 @@
import { normalizeAgisoMessageTemplate } from './agiso-template.js'
import { maskSecret } from './mappers.js'
type JsonObject = Record<string, any> type JsonObject = Record<string, any>
export function mapAdminAgisoMessagingDefaults(defaults: JsonObject = {}) {
return {
messageTemplate: normalizeAgisoMessageTemplate(String(defaults.messageTemplate || '').trim()),
autoDeliveryMessageTemplate: normalizeAgisoMessageTemplate(
String(defaults.autoDeliveryMessageTemplate || '').trim(),
),
}
}
export function mapAdminAgisoShopConfigItem(shopId: unknown, config: JsonObject = {}) {
return {
shopId,
shopName: String(config.shopName || '').trim(),
accessToken: String(config.accessToken || '').trim(),
accessTokenMasked: maskSecret(config.accessToken),
enabled: typeof config.enabled === 'boolean' ? config.enabled : null,
messageTemplate: normalizeAgisoMessageTemplate(String(config.messageTemplate || '').trim()),
autoDeliveryMessageTemplate: normalizeAgisoMessageTemplate(
String(config.autoDeliveryMessageTemplate || '').trim(),
),
appSecretConfigured: Boolean(String(config.appSecret || '').trim()),
apiVersion: String(config.apiVersion || '').trim(),
sendMessageEndpoint: String(config.sendMessageEndpoint || '').trim(),
}
}
export function applyOptionalStringField(target: JsonObject, key: string, source: JsonObject) {
if (!source || typeof source[key] !== 'string') {
return
}
const value = String(source[key] || '').trim()
if (value) {
target[key] = normalizeAgisoMessageTemplate(value)
return
}
delete target[key]
}
export function mapAdminFulfillmentBindingConfigItem(item: JsonObject) {
const match = item?.match || {}
return {
provider: String(item?.provider || '').trim(),
platform: String(item?.platform || '').trim(),
shopId: String(item?.shopId || '').trim(),
shopName: String(item?.shopName || '').trim(),
skuCode: String(item?.skuCode || '').trim(),
skuName: String(item?.skuName || '').trim(),
profileKey: String(item?.profileKey || '').trim(),
enabled: item?.enabled !== false,
priority: Number(item?.priority || 100),
config: item?.config || {},
match: {
externalSkuCode: String(match.externalSkuCode || '').trim(),
externalItemId: String(match.externalItemId || '').trim(),
externalSkuName: String(match.externalSkuName || '').trim(),
config: match.config || {},
},
}
}
export function matchesObservedProduct(binding: JsonObject, observed: JsonObject) { export function matchesObservedProduct(binding: JsonObject, observed: JsonObject) {
const provider = String(binding?.provider || '').trim() const provider = String(binding?.provider || '').trim()
const platform = String(binding?.platform || '').trim() const platform = String(binding?.platform || '').trim()
@@ -1,103 +0,0 @@
import { getAgisoShopConfig } from '../../platforms/agiso/shop-config-service.js'
import {
filterLegacyOrderFulfillmentBindings,
getOrderFulfillmentBindingsFilePath,
getLegacyOrderFulfillmentBindingConfigs,
saveOrderFulfillmentBindingConfigs,
} from '../../order/fulfillment-binding-config-service.js'
import { enrichAgisoXianyuTradeOrder } from '../../platforms/agiso/xianyu/order-detail-service.js'
import { syncConfiguredFulfillmentBindings } from '../../bootstrap/fulfillment-bootstrap-service.js'
import { getFulfillmentProfileByKey } from '../../../repositories/fulfillment-profile-repo.js'
import { createHttpError } from '../../../utils/http.js'
import {
buildFulfillmentLookupResult,
normalizeFulfillmentLookupPayload,
resolveFulfillmentLookupDetail,
} from './fulfillment.js'
import { listAdminObservedProducts } from './observed-products.js'
import { validateAdminFulfillmentBindingConfigs } from './validation.js'
import { mapAdminFulfillmentBindingConfigItem } from './domain.js'
import type {
AdminFulfillmentBindingConfigSaveInput,
AdminFulfillmentBindingLookupInput,
} from '../../../types/admin-write-inputs.js'
type JsonObject = Record<string, any>
export async function getAdminFulfillmentBindingConfigs() {
const bindings = getLegacyOrderFulfillmentBindingConfigs()
return {
filePath: getOrderFulfillmentBindingsFilePath(),
bindings: bindings.map(mapAdminFulfillmentBindingConfigItem),
observedProducts: await listAdminObservedProducts(bindings),
}
}
export async function lookupAdminFulfillmentBindingOrder(
payload: AdminFulfillmentBindingLookupInput = {},
) {
const { provider, platform, shopId, platformOrderId } = normalizeFulfillmentLookupPayload(payload)
const detailResult = await enrichAgisoXianyuTradeOrder({
provider,
platform,
shopId,
shopName: '',
platformOrderId,
orderStatus: 'created',
payStatus: 'unpaid',
buyerId: '',
buyerName: '',
receiverContact: '',
totalAmount: 0,
currency: 'CNY',
paidAt: null,
rawPayload: {},
items: [],
}, {
requestId: `admin-fulfillment-lookup:${shopId}:${platformOrderId}`,
})
const { detail } = resolveFulfillmentLookupDetail(detailResult, platformOrderId)
const bindings = getLegacyOrderFulfillmentBindingConfigs()
return buildFulfillmentLookupResult({
provider,
platform,
shopId,
platformOrderId,
detail,
detailResult,
bindings,
fallbackShopName: getAgisoShopConfig(shopId)?.shopName,
})
}
export async function updateAdminFulfillmentBindingConfigs(
payload: AdminFulfillmentBindingConfigSaveInput = {},
) {
const bindingsInput = Array.isArray(payload.bindings) ? payload.bindings : []
const normalizedBindings = await validateAdminFulfillmentBindingConfigs(bindingsInput, {
getFulfillmentProfileByKey,
})
assertLegacyBindingConfigsOnly(normalizedBindings)
const saved = saveOrderFulfillmentBindingConfigs(normalizedBindings)
await syncConfiguredFulfillmentBindings()
return {
filePath: getOrderFulfillmentBindingsFilePath(),
bindings: saved.map(mapAdminFulfillmentBindingConfigItem),
}
}
function assertLegacyBindingConfigsOnly(bindings: JsonObject[] = []) {
if (filterLegacyOrderFulfillmentBindings(bindings).length === bindings.length) {
return
}
throw createHttpError('快手履约规则请改到“快手 Cloud 新履约”页维护,旧履约规则仅保留给 Agiso 等历史场景', {
statusCode: 400,
errorCode: 'admin_fulfillment_bindings_kuaishou_cloud_moved',
})
}
@@ -1,124 +0,0 @@
import test from 'node:test'
import assert from 'node:assert/strict'
import {
buildFulfillmentLookupResult,
normalizeFulfillmentLookupPayload,
resolveFulfillmentLookupDetail,
} from './fulfillment.js'
test('normalizeFulfillmentLookupPayload validates required fields and defaults provider/platform', () => {
assert.deepEqual(
normalizeFulfillmentLookupPayload({
shopId: ' shop-1 ',
platformOrderId: ' order-1 ',
}),
{
provider: 'agiso',
platform: 'xianyu',
shopId: 'shop-1',
platformOrderId: 'order-1',
},
)
assert.throws(
() => normalizeFulfillmentLookupPayload({ platformOrderId: 'order-1' }),
/请先填写店铺 ID/,
)
})
test('resolveFulfillmentLookupDetail maps missing config into readable error', () => {
assert.throws(
() =>
resolveFulfillmentLookupDetail(
{
reason: 'missing_config',
parsed: { items: [] },
},
'order-1',
),
/当前店铺缺少订单详情查询配置/,
)
})
test('buildFulfillmentLookupResult assembles order and item matching payload', () => {
const result = buildFulfillmentLookupResult({
provider: 'agiso',
platform: 'xianyu',
shopId: 'shop-1',
platformOrderId: 'order-1',
detail: {
shopName: '店铺A',
buyerName: '买家A',
totalAmount: 12345,
paidAt: '2026-05-04T10:00:00.000Z',
items: [
{
externalItemId: 'item-1',
externalSkuCode: 'sku-1',
externalSkuName: '礼包A',
skuName: '礼包A',
quantity: 2,
},
],
},
detailResult: {
enriched: true,
reason: 'ok',
errorMessage: '',
},
bindings: [
{
provider: 'agiso',
platform: 'xianyu',
shopId: 'shop-1',
skuCode: 'internal-1',
skuName: '内部商品',
profileKey: 'profile-1',
match: {
externalSkuCode: 'sku-1',
},
},
],
fallbackShopName: '备用店铺',
})
assert.deepEqual(result, {
order: {
provider: 'agiso',
platform: 'xianyu',
shopId: 'shop-1',
shopName: '店铺A',
platformOrderId: 'order-1',
buyerName: '买家A',
totalAmountFen: 12345,
totalAmount: '123.45',
paidAt: '2026-05-04T10:00:00.000Z',
enriched: true,
enrichReason: 'ok',
errorMessage: '',
},
items: [
{
lineId: 'order-1:1:sku-1:item-1',
itemTitle: '礼包A',
quantity: 2,
provider: 'agiso',
platform: 'xianyu',
shopId: 'shop-1',
shopName: '店铺A',
externalItemId: 'item-1',
externalSkuCode: 'sku-1',
externalSkuName: '礼包A',
latestSeenAt: null,
orderItemCount: 2,
configured: true,
matchedBinding: {
skuCode: 'internal-1',
skuName: '内部商品',
profileKey: 'profile-1',
},
},
],
})
})
@@ -1,137 +0,0 @@
import { createHttpError } from '../../../utils/http.js'
import { formatFenToAmount } from '../../../utils/money.js'
import { isPlainObject, pickFirstNonEmpty } from './context.js'
import { mapAdminObservedProductItem } from './domain.js'
type JsonObject = Record<string, any>
export function normalizeFulfillmentLookupPayload(payload: JsonObject = {}) {
const provider = String(payload.provider || 'agiso').trim() || 'agiso'
const platform = String(payload.platform || 'xianyu').trim() || 'xianyu'
const shopId = String(payload.shopId || '').trim()
const platformOrderId = String(payload.platformOrderId || '').trim()
if (!shopId) {
throw createHttpError('请先填写店铺 ID', {
statusCode: 400,
errorCode: 'admin_fulfillment_lookup_missing_shop_id',
})
}
if (!platformOrderId) {
throw createHttpError('请先填写平台订单号', {
statusCode: 400,
errorCode: 'admin_fulfillment_lookup_missing_platform_order_id',
})
}
if (provider !== 'agiso' || platform !== 'xianyu') {
throw createHttpError('目前仅支持 Agiso 咸鱼订单手动查询', {
statusCode: 400,
errorCode: 'admin_fulfillment_lookup_platform_not_supported',
})
}
return { provider, platform, shopId, platformOrderId }
}
export function resolveFulfillmentLookupDetail(detailResult: JsonObject, platformOrderId: unknown) {
const detail = isPlainObject(detailResult?.parsed) ? detailResult.parsed : {}
const items = Array.isArray(detail.items) ? detail.items : []
if (items.length > 0) {
return { detail, items }
}
const detailReason = String(detailResult?.reason || '').trim()
const detailMessage = String(detailResult?.errorMessage || '').trim()
let message = detailMessage
if (!message && detailReason === 'missing_config') {
message = '当前店铺缺少订单详情查询配置,请先检查 accessToken、appSecret 和详情接口地址'
}
if (!message) {
message = `未查询到订单 ${platformOrderId} 的商品明细`
}
throw createHttpError(message, {
statusCode: 404,
errorCode: 'admin_fulfillment_lookup_order_items_not_found',
})
}
export function buildFulfillmentLookupResult({
provider = '',
platform = '',
shopId = '',
platformOrderId = '',
detail = {},
detailResult = {},
bindings = [],
fallbackShopName = '',
}: {
provider?: string
platform?: string
shopId?: string
platformOrderId?: string
detail?: JsonObject
detailResult?: JsonObject
bindings?: JsonObject[]
fallbackShopName?: string
} = {}) {
const resolvedShopName = pickFirstNonEmpty([detail.shopName, fallbackShopName])
const items = Array.isArray(detail.items) ? detail.items : []
return {
order: {
provider,
platform,
shopId,
shopName: resolvedShopName,
platformOrderId,
buyerName: String(detail.buyerName || '').trim(),
totalAmountFen: Number(detail.totalAmount || 0),
totalAmount: formatFenToAmount(detail.totalAmount),
paidAt: detail.paidAt || null,
enriched: Boolean(detailResult?.enriched),
enrichReason: String(detailResult?.reason || '').trim(),
errorMessage: String(detailResult?.errorMessage || '').trim(),
},
items: items.map((item, index) => {
const observed = {
provider,
platform,
shopId,
shopName: resolvedShopName,
externalItemId: pickFirstNonEmpty([item?.externalItemId, item?.itemId]),
externalSkuCode: pickFirstNonEmpty([
item?.externalSkuCode,
item?.skuCode,
item?.externalItemId,
item?.itemId,
]),
externalSkuName: pickFirstNonEmpty([item?.externalSkuName, item?.skuName]),
latestSeenAt: null,
orderItemCount: Math.max(1, Number(item?.quantity || 0) || 1),
}
return {
lineId: [
platformOrderId,
index + 1,
observed.externalSkuCode || 'na',
observed.externalItemId || 'na',
].join(':'),
itemTitle: pickFirstNonEmpty([
item?.skuName,
item?.externalSkuName,
item?.externalSkuCode,
item?.externalItemId,
]),
quantity: observed.orderItemCount,
...mapAdminObservedProductItem(observed, bindings),
}
}),
}
}
@@ -1,61 +0,0 @@
export {
getAdminAgisoShopConfigs,
updateAdminAgisoShopConfigs,
} from "./agiso-service.js";
export {
getAdminNinetyoneOrders,
retryAdminNinetyoneOrder,
failAdminNinetyoneOrder,
} from "./ninetyone-service.js";
export {
getAdminKuaishouEticketSourceConfig,
updateAdminKuaishouEticketSourceConfig,
queryAdminKuaishouEticketDetail,
queryAdminKuaishouEticketShopInfo,
consumeAdminKuaishouEticket,
} from "./kuaishou-eticket-service.js";
export {
listAdminCloudtentaclesSources,
deleteAdminCloudtentaclesSource,
updateAdminCloudtentaclesSourceConfig,
sendAdminCloudtentaclesSmsCode,
testAdminCloudtentaclesLogin,
validateAdminCloudtentaclesSession,
getAdminCloudtentaclesAsset,
getAdminCloudtentaclesCategories,
getAdminCloudtentaclesSkuList,
buyAdminCloudtentaclesSku,
useAdminCloudtentaclesSku,
getAdminCloudtentaclesKnapsack,
listAdminCloudtentaclesVirtualNumbers,
appointAdminCloudtentaclesVirtualNumber,
generateAdminCloudtentaclesLoginCode,
fetchAdminCloudtentaclesVirtualNumberCode,
verifyAdminCloudtentaclesLoginCode,
getAdminCloudtentaclesBindUrl,
backAdminCloudtentaclesVirtualNumber,
runAdminCloudtentaclesFullFlow,
} from "./cloudtentacles-service.js";
export {
getAdminFulfillmentBindingConfigs,
lookupAdminFulfillmentBindingOrder,
updateAdminFulfillmentBindingConfigs,
} from "./fulfillment-bindings-service.js";
export {
getAdminKuaishouCloudFulfillmentConfig,
updateAdminKuaishouCloudFulfillmentConfig,
} from "./kuaishou-cloud-fulfillment-service.js";
export {
getAdminNotificationConfig,
updateAdminNotificationConfig,
testAdminNotification,
getAdminScheduledJobsConfig,
updateAdminScheduledJobsConfig,
runAdminScheduledJobNow,
} from "./notification-service.js";
@@ -1,175 +0,0 @@
import test from 'node:test'
import assert from 'node:assert/strict'
import {
assertAdminFulfillmentBindingsInput,
buildAdminFulfillmentBindingUniqueKey,
normalizeAdminFulfillmentBindingItem,
validateAdminFulfillmentBindingConfigs,
} from './validation.js'
test('assertAdminFulfillmentBindingsInput rejects non-array payloads', () => {
assert.doesNotThrow(() => assertAdminFulfillmentBindingsInput([]))
assert.throws(() => assertAdminFulfillmentBindingsInput({}), /履约配置格式不正确/)
})
test('normalizeAdminFulfillmentBindingItem normalizes generic binding fields', () => {
const result = normalizeAdminFulfillmentBindingItem(
{
provider: ' agiso ',
platform: ' xianyu ',
shopId: ' shop-1 ',
shopName: ' 店铺 ',
skuCode: 'sku-1',
match: {
externalSkuCode: 'ext-1',
},
config: {
kuaishouShop: {
note: 'keep',
},
},
},
{ index: 0 },
)
assert.deepEqual(result, {
provider: 'agiso',
platform: 'xianyu',
shopId: 'shop-1',
shopName: '店铺',
skuCode: 'sku-1',
skuName: '',
profileKey: 'manual_review',
enabled: true,
priority: undefined,
config: {
kuaishouShop: {
note: 'keep',
},
},
match: {
externalSkuCode: 'ext-1',
externalItemId: '',
externalSkuName: '',
config: {},
},
})
})
test('normalizeAdminFulfillmentBindingItem rejects missing sku and match conditions', () => {
assert.throws(
() =>
normalizeAdminFulfillmentBindingItem(
{
provider: 'agiso',
platform: 'xianyu',
match: {
externalSkuCode: 'ext-1',
},
},
{ index: 1 },
),
/第 2 条规则缺少内部履约 SKU/,
)
assert.throws(
() =>
normalizeAdminFulfillmentBindingItem(
{
provider: 'agiso',
platform: 'xianyu',
skuCode: 'sku-1',
},
{ index: 2 },
),
/第 3 条规则至少需要一种外部匹配条件/,
)
})
test('buildAdminFulfillmentBindingUniqueKey uses normalized external sku name and shop id rules', () => {
assert.equal(
buildAdminFulfillmentBindingUniqueKey({
provider: '91kaquan',
platform: 'kuaishou',
shopId: 'shop-a',
skuCode: 'sku-1',
match: {
externalItemId: '',
externalSkuCode: '',
externalSkuName: ' 礼包 A ',
},
}),
'91kaquan::kuaishou::shop-a::::::礼包 a::sku-1',
)
})
test('validateAdminFulfillmentBindingConfigs rejects missing fulfillment profile', async () => {
await assert.rejects(
() =>
validateAdminFulfillmentBindingConfigs(
[
{
provider: 'agiso',
platform: 'xianyu',
shopId: 'shop-1',
skuCode: 'sku-1',
profileKey: 'missing-profile',
match: {
externalSkuCode: 'ext-1',
},
},
],
{
kuaishouEticketSource: {},
resolveKuaishouEticketShopConfig() {
return null
},
async getFulfillmentProfileByKey() {
return null
},
},
),
/第 1 条规则使用了不存在的履约方式: missing-profile/,
)
})
test('validateAdminFulfillmentBindingConfigs rejects duplicate rules after normalization', async () => {
await assert.rejects(
() =>
validateAdminFulfillmentBindingConfigs(
[
{
provider: 'agiso',
platform: 'xianyu',
shopId: 'shop-1',
skuCode: 'sku-1',
profileKey: 'manual_review',
match: {
externalSkuCode: ' ext-1 ',
},
},
{
provider: 'agiso',
platform: 'xianyu',
shopId: ' shop-1 ',
skuCode: 'sku-1',
profileKey: 'manual_review',
match: {
externalSkuCode: 'ext-1',
},
},
],
{
kuaishouEticketSource: {},
resolveKuaishouEticketShopConfig() {
return null
},
async getFulfillmentProfileByKey() {
return { key: 'manual_review' }
},
},
),
/第 2 条规则与其它规则重复/,
)
})
@@ -1,125 +0,0 @@
import { createHttpError } from '../../../utils/http.js'
import { normalizeProductName } from '../../order/product-match-service.js'
import { isPlainObject } from './context.js'
type JsonObject = Record<string, any>
export function assertAdminFulfillmentBindingsInput(bindings: unknown) {
if (Array.isArray(bindings)) {
return
}
throw createHttpError('履约配置格式不正确', {
statusCode: 400,
errorCode: 'admin_fulfillment_bindings_invalid_payload',
})
}
export function normalizeAdminFulfillmentBindingItem(
rawBinding: unknown,
options: JsonObject = {},
) {
const index = Number(options.index || 0)
if (!isPlainObject(rawBinding)) {
throw createHttpError(`${index + 1} 条规则格式不正确`, {
statusCode: 400,
errorCode: 'admin_fulfillment_bindings_invalid_item',
})
}
const provider = String(rawBinding.provider || 'agiso').trim() || 'agiso'
const platform = String(rawBinding.platform || '').trim()
const shopId = String(rawBinding.shopId || '').trim()
const shopName = String(rawBinding.shopName || '').trim()
const skuCode = String(rawBinding.skuCode || '').trim()
const skuName = String(rawBinding.skuName || '').trim()
const profileKey = String(rawBinding.profileKey || '').trim() || 'manual_review'
const match = isPlainObject(rawBinding.match) ? rawBinding.match : {}
const externalItemId = String(match.externalItemId || '').trim()
const externalSkuCode = String(match.externalSkuCode || '').trim()
const externalSkuName = String(match.externalSkuName || '').trim()
const config = isPlainObject(rawBinding.config) ? { ...rawBinding.config } : {}
if (!skuCode) {
throw createHttpError(`${index + 1} 条规则缺少内部履约 SKU`, {
statusCode: 400,
errorCode: 'admin_fulfillment_bindings_missing_sku_code',
})
}
if (!externalItemId && !externalSkuCode && !externalSkuName) {
throw createHttpError(`${index + 1} 条规则至少需要一种外部匹配条件`, {
statusCode: 400,
errorCode: 'admin_fulfillment_bindings_missing_match_condition',
})
}
return {
provider,
platform,
shopId,
shopName,
skuCode,
skuName,
profileKey,
enabled: rawBinding.enabled !== false,
priority: rawBinding.priority,
config,
match: {
externalSkuCode,
externalItemId,
externalSkuName,
config: isPlainObject(match.config) ? match.config : {},
},
}
}
export function buildAdminFulfillmentBindingUniqueKey(binding) {
return [
String(binding?.provider || '').trim(),
String(binding?.platform || '').trim(),
String(binding?.shopId || '').trim(),
String(binding?.match?.externalItemId || '').trim(),
String(binding?.match?.externalSkuCode || '').trim(),
normalizeProductName(String(binding?.match?.externalSkuName || '').trim()),
String(binding?.skuCode || '').trim(),
].join('::')
}
export async function validateAdminFulfillmentBindingConfigs(
bindings: JsonObject[],
options: JsonObject = {},
) {
assertAdminFulfillmentBindingsInput(bindings)
const getFulfillmentProfileByKey = (
options.getFulfillmentProfileByKey || (async () => null)
) as (profileKey: string) => Promise<unknown>
const seenKeys = new Set()
const normalizedBindings: JsonObject[] = []
for (const [index, rawBinding] of bindings.entries()) {
const normalized = normalizeAdminFulfillmentBindingItem(rawBinding, { index })
const profile = await getFulfillmentProfileByKey(normalized.profileKey)
if (!profile) {
throw createHttpError(`${index + 1} 条规则使用了不存在的履约方式: ${normalized.profileKey}`, {
statusCode: 400,
errorCode: 'admin_fulfillment_bindings_invalid_profile_key',
})
}
const uniqueKey = buildAdminFulfillmentBindingUniqueKey(normalized)
if (seenKeys.has(uniqueKey)) {
throw createHttpError(`${index + 1} 条规则与其它规则重复,请调整匹配条件或内部履约 SKU`, {
statusCode: 409,
errorCode: 'admin_fulfillment_bindings_duplicate_rule',
})
}
seenKeys.add(uniqueKey)
normalizedBindings.push(normalized)
}
return normalizedBindings
}
@@ -1,127 +0,0 @@
import test from 'node:test'
import assert from 'node:assert/strict'
import {
buildAdminAgisoMessagingSavePayload,
buildAdminAgisoShopConfigUpdate,
normalizeAdminCloudtentaclesSourceConfigPayload,
} from './writes.js'
test('buildAdminAgisoShopConfigUpdate merges item fields and drops explicit empty templates', () => {
assert.deepEqual(
buildAdminAgisoShopConfigUpdate(
{
shopId: ' shop-1 ',
shopName: ' ',
accessToken: ' new-token ',
messageTemplate: ' ',
autoDeliveryMessageTemplate: ' done ',
enabled: false,
},
{
shopName: '旧店铺',
accessToken: 'old-token',
messageTemplate: 'old-template',
autoDeliveryMessageTemplate: 'old-auto',
appSecret: 'keep-secret',
},
),
{
shopId: 'shop-1',
config: {
accessToken: 'new-token',
autoDeliveryMessageTemplate: 'done',
appSecret: 'keep-secret',
enabled: false,
},
},
)
})
test('buildAdminAgisoMessagingSavePayload updates defaults and keeps only shops with resulting access token', () => {
assert.deepEqual(
buildAdminAgisoMessagingSavePayload(
{
defaults: {
messageTemplate: ' next\\nline ',
autoDeliveryMessageTemplate: ' ',
},
shops: [
{
shopId: ' shop-1 ',
shopName: '店铺一',
accessToken: '',
enabled: true,
},
{
shopId: 'shop-2',
shopName: ' 店铺二 ',
accessToken: ' token-2 ',
messageTemplate: ' hi ',
apiVersion: ' v2 ',
},
{
shopId: '',
accessToken: 'skip',
},
],
},
{
currentDefaults: {
messageTemplate: 'old',
autoDeliveryMessageTemplate: 'old-auto',
},
currentMap: {
'shop-1': {
accessToken: 'token-1',
autoDeliveryMessageTemplate: 'keep-auto',
},
},
},
),
{
defaults: {
messageTemplate: 'next\nline',
},
shops: {
'shop-1': {
accessToken: 'token-1',
autoDeliveryMessageTemplate: 'keep-auto',
enabled: true,
shopName: '店铺一',
},
'shop-2': {
accessToken: 'token-2',
apiVersion: 'v2',
messageTemplate: 'hi',
shopName: '店铺二',
},
},
},
)
})
test('normalizeAdminCloudtentaclesSourceConfigPayload applies defaults', () => {
assert.deepEqual(
normalizeAdminCloudtentaclesSourceConfigPayload({
enabled: false,
baseUrl: ' ',
username: ' user ',
password: ' pass ',
phone: ' 13800138000 ',
deviceId: ' ',
deviceType: '2',
}),
{
key: 'default',
label: '',
enabled: false,
baseUrl: 'https://123.207.217.176',
username: 'user',
password: 'pass',
phone: '13800138000',
deviceId: '-',
deviceType: 2,
},
)
})
@@ -1,100 +0,0 @@
import { applyOptionalStringField } from "./domain.js";
type JsonObject = Record<string, any>
export function buildAdminAgisoShopConfigUpdate(item: JsonObject, current: JsonObject = {}) {
const shopId = String(item?.shopId || "").trim();
if (!shopId) {
return null;
}
const next: JsonObject = { ...current };
const shopName = String(item?.shopName || "").trim();
const accessToken = String(item?.accessToken || "").trim();
const messageTemplate = String(item?.messageTemplate || "").trim();
const autoDeliveryMessageTemplate = String(
item?.autoDeliveryMessageTemplate || ""
).trim();
const appSecret = String(item?.appSecret || "").trim();
const apiVersion = String(item?.apiVersion || "").trim();
const sendMessageEndpoint = String(item?.sendMessageEndpoint || "").trim();
if (shopName) {
next.shopName = shopName;
} else {
delete next.shopName;
}
if (accessToken) {
next.accessToken = accessToken;
}
if (messageTemplate) {
next.messageTemplate = messageTemplate;
} else if (typeof item?.messageTemplate === "string") {
delete next.messageTemplate;
}
if (autoDeliveryMessageTemplate) {
next.autoDeliveryMessageTemplate = autoDeliveryMessageTemplate;
} else if (typeof item?.autoDeliveryMessageTemplate === "string") {
delete next.autoDeliveryMessageTemplate;
}
if (appSecret) {
next.appSecret = appSecret;
}
if (apiVersion) {
next.apiVersion = apiVersion;
}
if (sendMessageEndpoint) {
next.sendMessageEndpoint = sendMessageEndpoint;
}
if (typeof item?.enabled === "boolean") {
next.enabled = item.enabled;
}
if (!next.accessToken) {
return null;
}
return { shopId, config: next };
}
export function buildAdminAgisoMessagingSavePayload(
payload: JsonObject = {},
options: JsonObject = {}
) {
const rawItems = Array.isArray(payload.shops) ? payload.shops : [];
const currentDefaults: JsonObject = options.currentDefaults || {};
const currentMap: Record<string, JsonObject> = options.currentMap || {};
const nextDefaults: JsonObject = {
...currentDefaults,
};
const nextMap: Record<string, JsonObject> = {};
applyOptionalStringField(nextDefaults, "messageTemplate", payload.defaults);
applyOptionalStringField(
nextDefaults,
"autoDeliveryMessageTemplate",
payload.defaults
);
for (const item of rawItems) {
const shopId = String(item?.shopId || "").trim();
if (!shopId) {
continue;
}
const updated = buildAdminAgisoShopConfigUpdate(
item,
currentMap[shopId] || {}
);
if (!updated) {
continue;
}
nextMap[updated.shopId] = updated.config;
}
return {
defaults: nextDefaults,
shops: nextMap,
};
}
@@ -1,520 +0,0 @@
import {
markInventoryItemDelivered,
releaseReservedInventoryItem,
} from '../../../repositories/inventory-repo.js'
import { updateClaimToken } from '../../../repositories/claim-token-repo.js'
import { listOrderItemsByOrderId } from '../../../repositories/order-item-repo.js'
import { getOrderById } from '../../../repositories/order-repo.js'
import { updateTask } from '../../../repositories/task-repo.js'
import {
getTaskInventoryBindingById,
listTaskInventoryBindingsByTaskId,
} from '../../../repositories/task-inventory-binding-repo.js'
import { createTaskEvent } from '../../../repositories/task-event-repo.js'
import { createTaskClaimToken } from '../../claim/claim-service.js'
import { confirmClaimRoleForAdminTask, redeemClaimTaskForAdminTask } from '../../claim/claim-session-service.js'
import { reserveInventoryForTask } from '../../order/inventory-service.js'
import { ensureAgisoXianyuAutoDeliveryForDeliveredTask } from '../../platforms/agiso/xianyu/auto-delivery-service.js'
import { closeTencentBrowserSession } from '../../session/session.js'
import { createHttpError } from '../../../utils/http.js'
import { nowIso } from '../../../utils/time.js'
import {
createAdminViewerContext,
getTaskPrimaryClaimTokenId,
getTaskPrimaryInventoryItemId,
isAssistedClaimTask,
isManualDispatchTask,
parseTaskContext,
} from '../admin-read-shared-helpers.js'
import {
getRequiredTask,
mapTaskActionPayload,
} from '../admin-task-read-helpers.js'
import {
ensureViewerCanOperateAssistedTask,
getTaskClaimExpiresAt,
isRecoverableTaskSessionCloseError,
maskCode,
normalizeManualDispatchOutcome,
resolveTaskInventoryGroupCodes,
} from './shared.js'
import type {
AdminEntityIdInput,
AdminViewerSessionInput,
} from '../../../types/admin-read-inputs.js'
import type { AdminTaskManualDispatchInput } from '../../../types/admin-write-inputs.js'
import type {
AdminTaskActionResponse,
AdminTaskBindingReleaseResponse,
AdminTaskManualDispatchResponse,
} from '../../../types/admin-write-models.js'
import type { TaskRow } from '../../../types/repository-rows.js'
import type { TaskUpdatePatch } from '../../../types/repository-inputs.js'
type CloseAdminTaskDeps = {
getRequiredTask?: (taskId: AdminEntityIdInput) => Promise<TaskRow>
listTaskInventoryBindingsByTaskId?: (taskId: AdminEntityIdInput) => Promise<any[]>
releaseReservedInventoryItem?: (inventoryItemId: AdminEntityIdInput, updatedAt: string) => Promise<unknown>
updateClaimToken?: (tokenId: AdminEntityIdInput, patch: Record<string, unknown>) => Promise<unknown>
updateTask?: (taskId: AdminEntityIdInput, patch: TaskUpdatePatch) => Promise<any>
createTaskEvent?: (taskId: AdminEntityIdInput, eventType: string, payload: Record<string, unknown>, createdAt: string) => Promise<unknown>
closeTencentBrowserSession?: (sessionId: string) => Promise<unknown>
nowIso?: () => string
}
export async function releaseAdminTaskInventory(taskId: AdminEntityIdInput): Promise<AdminTaskActionResponse> {
const task = await getRequiredTask(taskId)
const primaryInventoryItemId = getTaskPrimaryInventoryItemId(task)
if (!primaryInventoryItemId) {
throw createHttpError('当前任务没有预占库存项', {
statusCode: 409,
errorCode: 'admin_task_no_reserved_inventory',
})
}
if (task.task_status === 'redeemed') {
throw createHttpError('已兑换任务不能释放库存项', {
statusCode: 409,
errorCode: 'admin_task_release_not_allowed',
})
}
const now = nowIso()
await releaseReservedInventoryItem(primaryInventoryItemId, now)
const updatedTask = await updateTask(task.id, {
task_status: 'waiting_inventory',
inventory_status: 'pending',
last_error: '已手动释放预占库存项',
updated_at: now,
})
return {
task: mapTaskActionPayload(updatedTask),
}
}
export async function releaseAdminTaskInventoryBinding(
taskId: AdminEntityIdInput,
bindingId: AdminEntityIdInput,
): Promise<AdminTaskBindingReleaseResponse> {
const task = await getRequiredTask(taskId)
const binding = await getTaskInventoryBindingById(Number(bindingId))
if (!binding || Number(binding.task_id) !== Number(task.id)) {
throw createHttpError('任务库存绑定不存在', {
statusCode: 404,
errorCode: 'admin_task_inventory_binding_not_found',
})
}
if (String(binding.binding_status || '').trim() !== 'reserved') {
throw createHttpError('当前库存绑定不是预占状态,不能释放', {
statusCode: 409,
errorCode: 'admin_task_inventory_binding_release_not_allowed',
})
}
if (task.task_status === 'redeemed') {
throw createHttpError('已兑换任务不能释放库存绑定', {
statusCode: 409,
errorCode: 'admin_task_release_not_allowed',
})
}
const now = nowIso()
await releaseReservedInventoryItem(binding.inventory_item_id, now)
const remainingBindings = await listTaskInventoryBindingsByTaskId(task.id)
const activeBindings = remainingBindings.filter((item) => ['reserved', 'consumed'].includes(String(item.binding_status || '').trim()))
const hasReservedBindings = activeBindings.some((item) => String(item.binding_status || '').trim() === 'reserved')
const hasConsumedBindings = activeBindings.some((item) => String(item.binding_status || '').trim() === 'consumed')
const nextInventoryStatus = hasReservedBindings ? 'reserved' : (hasConsumedBindings ? 'consumed' : 'pending')
const nextTaskStatus = !activeBindings.length && !['closed', 'expired'].includes(String(task.task_status || '').trim())
? 'waiting_inventory'
: task.task_status
if (!hasReservedBindings) {
const primaryClaimTokenId = getTaskPrimaryClaimTokenId(task)
if (primaryClaimTokenId) {
await updateClaimToken(primaryClaimTokenId, {
status: 'revoked',
updated_at: now,
})
}
}
const updatedTask = await updateTask(task.id, {
task_status: nextTaskStatus,
inventory_status: nextInventoryStatus,
last_error: !activeBindings.length ? '已手动释放预占库存绑定' : (task.last_error || ''),
updated_at: now,
})
await createTaskEvent(task.id, 'inventory_binding_released', {
bindingId: Number(binding.id),
inventoryItemId: Number(binding.inventory_item_id),
roleKey: String(binding.role_key || '').trim(),
}, now)
return {
task: mapTaskActionPayload(updatedTask),
bindingId: Number(binding.id),
inventoryItemId: Number(binding.inventory_item_id),
}
}
export async function regenerateAdminTaskClaimLink(
taskId: AdminEntityIdInput,
session: AdminViewerSessionInput | null = null,
): Promise<AdminTaskActionResponse> {
const task = await getRequiredTask(taskId)
const primaryClaimTokenId = getTaskPrimaryClaimTokenId(task)
const viewerContext = createAdminViewerContext(session)
if (isManualDispatchTask(task)) {
throw createHttpError('人工履约任务不需要领取链接,请直接回写人工履约结果', {
statusCode: 409,
errorCode: 'admin_task_manual_dispatch_claim_not_allowed',
})
}
if (['redeemed', 'closed'].includes(task.task_status)) {
throw createHttpError('当前任务状态不允许重新生成领取链接', {
statusCode: 409,
errorCode: 'admin_task_regenerate_not_allowed',
})
}
if (!viewerContext.canManageTaskLifecycle && !isAssistedClaimTask(task)) {
throw createHttpError('当前账号只能重发半自动客服任务的领取链接', {
statusCode: 403,
errorCode: 'admin_task_regenerate_permission_denied',
})
}
const now = nowIso()
if (primaryClaimTokenId) {
await updateClaimToken(primaryClaimTokenId, {
status: 'revoked',
updated_at: now,
})
}
const claimToken = await createTaskClaimToken(task.id)
const updatedTask = await updateTask(task.id, {
claim_token: claimToken.token,
claim_expires_at: claimToken.expired_at,
task_status: 'link_generated',
last_error: '',
updated_at: now,
})
return {
task: mapTaskActionPayload(updatedTask),
claimUrl: claimToken.claimUrl,
token: claimToken.token,
}
}
export async function confirmAdminTaskAssistedRole(
taskId: AdminEntityIdInput,
session: AdminViewerSessionInput | null = null,
): Promise<AdminTaskActionResponse> {
const task = await getRequiredTask(taskId)
const viewerContext = createAdminViewerContext(session)
ensureViewerCanOperateAssistedTask(task, viewerContext, 'confirm')
await confirmClaimRoleForAdminTask(task.id)
const updatedTask = await getRequiredTask(task.id)
return {
task: mapTaskActionPayload(updatedTask),
}
}
export async function redeemAdminTaskAssisted(
taskId: AdminEntityIdInput,
session: AdminViewerSessionInput | null = null,
): Promise<AdminTaskActionResponse> {
const task = await getRequiredTask(taskId)
const viewerContext = createAdminViewerContext(session)
ensureViewerCanOperateAssistedTask(task, viewerContext, 'redeem')
await redeemClaimTaskForAdminTask(task.id)
const updatedTask = await getRequiredTask(task.id)
return {
task: mapTaskActionPayload(updatedTask),
}
}
export async function closeAdminTask(taskId: AdminEntityIdInput): Promise<AdminTaskActionResponse> {
return closeAdminTaskWithDeps(taskId)
}
export async function closeAdminTaskWithDeps(
taskId: AdminEntityIdInput,
{
getRequiredTask: getTask = getRequiredTask,
listTaskInventoryBindingsByTaskId: listBindings = listTaskInventoryBindingsByTaskId,
releaseReservedInventoryItem: releaseReserved = releaseReservedInventoryItem,
updateClaimToken: updateToken = updateClaimToken,
updateTask: updateTaskRecord = updateTask,
createTaskEvent: createEvent = createTaskEvent,
closeTencentBrowserSession: closeSession = closeTencentBrowserSession,
nowIso: getNowIso = nowIso,
}: CloseAdminTaskDeps = {},
): Promise<AdminTaskActionResponse> {
const task = await getTask(taskId)
if (task.task_status === 'redeemed') {
throw createHttpError('已兑换任务不能关闭', {
statusCode: 409,
errorCode: 'admin_task_close_not_allowed',
})
}
const now = getNowIso()
const bindings = await listBindings(task.id)
const reservedBindings = bindings.filter((binding) => String(binding.binding_status || '').trim() === 'reserved')
const releasedInventoryItemIds = Array.from(new Set(reservedBindings.map((binding) => Number(binding.inventory_item_id)).filter((id) => id > 0)))
const hasConsumedBindings = bindings.some((binding) => String(binding.binding_status || '').trim() === 'consumed')
const primaryClaimTokenId = getTaskPrimaryClaimTokenId(task)
let browserSessionClosed = false
if (primaryClaimTokenId) {
await updateToken(primaryClaimTokenId, {
status: 'revoked',
expired_at: now,
updated_at: now,
})
}
if (task.browser_session_id) {
try {
await closeSession(task.browser_session_id)
browserSessionClosed = true
} catch (error) {
if (!isRecoverableTaskSessionCloseError(error)) {
throw error
}
}
}
for (const inventoryItemId of releasedInventoryItemIds) {
await releaseReserved(inventoryItemId, now)
}
const closeReasonParts = ['已手动关闭任务']
if (primaryClaimTokenId) {
closeReasonParts.push('领取链接已失效')
}
if (releasedInventoryItemIds.length > 0) {
closeReasonParts.push('预占库存已释放')
}
const updatedTask = await updateTaskRecord(task.id, {
task_status: 'closed',
inventory_status: hasConsumedBindings ? 'consumed' : 'pending',
delivery_status: 'closed',
user_action_status: 'closed',
claim_expires_at: primaryClaimTokenId ? now : task.claim_expires_at,
browser_session_id: '',
last_error: task.last_error || closeReasonParts.join(''),
updated_at: now,
})
await createEvent(task.id, 'task_closed', {
claimTokenRevoked: Boolean(primaryClaimTokenId),
releasedInventoryItemIds,
releasedInventoryCount: releasedInventoryItemIds.length,
browserSessionClosed,
}, now)
return {
task: mapTaskActionPayload(updatedTask),
}
}
export async function markAdminTaskManualReview(taskId: AdminEntityIdInput): Promise<AdminTaskActionResponse> {
const task = await getRequiredTask(taskId)
const updatedTask = await updateTask(task.id, {
task_status: 'manual_review',
delivery_status: task.delivery_status || 'pending',
last_error: task.last_error || '已转人工处理',
updated_at: nowIso(),
})
return {
task: mapTaskActionPayload(updatedTask),
}
}
export async function retryAdminTask(taskId: AdminEntityIdInput): Promise<AdminTaskActionResponse> {
const task = await getRequiredTask(taskId)
const now = nowIso()
if (isManualDispatchTask(task)) {
throw createHttpError('人工履约任务不能走自动重试,请在详情页直接回写人工履约结果', {
statusCode: 409,
errorCode: 'admin_task_manual_dispatch_retry_not_allowed',
})
}
if (!['retry_pending', 'manual_review', 'waiting_inventory'].includes(task.task_status)) {
throw createHttpError('当前任务状态不允许重试', {
statusCode: 409,
errorCode: 'admin_task_retry_not_allowed',
})
}
const taskContext = parseTaskContext(task)
const primaryRequirement = taskContext.primaryRequirement || null
const orderItems = await listOrderItemsByOrderId(task.order_id)
const orderItem = orderItems.find((item) => item.id === task.order_item_id) || null
let reservedInventoryItemId = getTaskPrimaryInventoryItemId(task)
let claimTokenId = getTaskPrimaryClaimTokenId(task)
let nextStatus = 'link_generated'
let lastError = ''
let claimExpiresAt = getTaskClaimExpiresAt(task)
let claimUrl = ''
let token = ''
if (!reservedInventoryItemId) {
const reserved = await reserveInventoryForTask({
skuCode: orderItem?.sku_code || '',
taskId: task.id,
credentialType: primaryRequirement?.credentialType || 'tencent_code',
roleKey: primaryRequirement?.roleKey || 'primary_code',
inventoryGroupCodes: resolveTaskInventoryGroupCodes(task),
})
if (!reserved) {
nextStatus = 'waiting_inventory'
lastError = '库存不足,等待可用库存凭据'
} else {
reservedInventoryItemId = reserved.id
}
}
if (nextStatus === 'link_generated' && !claimTokenId) {
const claimToken = await createTaskClaimToken(task.id)
claimTokenId = claimToken.id
claimExpiresAt = claimToken.expired_at
claimUrl = claimToken.claimUrl
token = claimToken.token
}
const updatedTask = await updateTask(task.id, {
task_status: nextStatus,
inventory_status: reservedInventoryItemId ? 'reserved' : 'pending',
claim_token: token || task.claim_token || '',
claim_expires_at: claimExpiresAt,
last_error: lastError,
updated_at: now,
})
const response: AdminTaskActionResponse = {
task: mapTaskActionPayload(updatedTask),
}
if (claimUrl) {
response.claimUrl = claimUrl
response.token = token
}
return response
}
export async function completeAdminTaskManualDispatch(
taskId: AdminEntityIdInput,
payload: AdminTaskManualDispatchInput = {},
session: AdminViewerSessionInput | null = null,
): Promise<AdminTaskManualDispatchResponse> {
const task = await getRequiredTask(taskId)
if (!isManualDispatchTask(task)) {
throw createHttpError('当前任务不是人工履约任务', {
statusCode: 409,
errorCode: 'admin_task_not_manual_dispatch',
})
}
if (['redeemed', 'closed'].includes(task.task_status)) {
throw createHttpError('当前任务已经完结,不能重复回写人工履约结果', {
statusCode: 409,
errorCode: 'admin_task_manual_dispatch_already_completed',
})
}
const now = nowIso()
const outcome = normalizeManualDispatchOutcome(payload.outcome)
const resultMessage = String(payload.resultMessage || '').trim()
const deliveryReference = String(payload.deliveryReference || '').trim()
const deliveredCredential = String(payload.deliveredCredential || '').trim()
const context = parseTaskContext(task)
const inventoryItemId = getTaskPrimaryInventoryItemId(task)
const resultCode = outcome === 'failed' ? 'manual_dispatch_failed' : 'manual_dispatch_delivered'
const fallbackMessage = outcome === 'failed' ? '人工履约失败' : '人工履约已完成'
const nextTaskStatus = outcome === 'failed' ? 'closed' : 'redeemed'
const nextDeliveryStatus = outcome === 'failed' ? 'failed' : 'delivered'
const nextInventoryStatus = outcome === 'delivered' && inventoryItemId
? 'consumed'
: task.inventory_status || 'not_required'
const manualDispatch = {
outcome,
deliveryReference,
deliveredCredential,
resultMessage: resultMessage || fallbackMessage,
completedAt: now,
completedBy: session
? {
userId: Number(session.userId || 0),
username: String(session.username || ''),
role: String(session.role || ''),
}
: null,
}
if (outcome === 'delivered' && inventoryItemId) {
await markInventoryItemDelivered(inventoryItemId, now)
}
const updatedTask = await updateTask(task.id, {
task_status: nextTaskStatus,
inventory_status: nextInventoryStatus,
delivery_status: nextDeliveryStatus,
result_code: resultCode,
result_message: resultMessage || fallbackMessage,
user_action_status: 'not_required',
last_error: outcome === 'failed' ? (resultMessage || fallbackMessage) : '',
redeemed_at: outcome === 'delivered' ? now : task.redeemed_at || null,
context_json: JSON.stringify({
...context,
manualDispatch,
}),
updated_at: now,
})
await createTaskEvent(task.id, 'manual_dispatch_completed', {
outcome,
resultCode,
resultMessage: resultMessage || fallbackMessage,
deliveryReference,
deliveredCredentialMasked: maskCode(deliveredCredential),
completedBy: manualDispatch.completedBy,
}, now)
const taskAfterAutoDelivery = outcome === 'delivered'
? (await ensureAgisoXianyuAutoDeliveryForDeliveredTask({
order: await getOrderById(task.order_id),
task: updatedTask,
trigger: 'manual_dispatch_completed',
})).task || updatedTask
: updatedTask
return {
outcome,
task: mapTaskActionPayload(taskAfterAutoDelivery),
}
}
@@ -1,32 +0,0 @@
import { getWebhookEventById } from '../../../repositories/webhook-event-repo.js'
import { replayAgisoTradeWebhookEvent } from '../../order/webhook-service.js'
import { createHttpError } from '../../../utils/http.js'
import type { AdminEntityIdInput } from '../../../types/admin-read-inputs.js'
import type { AdminWebhookReplayResponse } from '../../../types/admin-write-models.js'
export async function replayAdminWebhookEvent(eventId: AdminEntityIdInput): Promise<AdminWebhookReplayResponse> {
const event = await getWebhookEventById(Number(eventId))
if (!event) {
throw createHttpError('Webhook 事件不存在', {
statusCode: 404,
errorCode: 'admin_webhook_not_found',
})
}
if (String(event.provider || event.platform || '').trim() !== 'agiso') {
throw createHttpError('当前只支持重放 agiso webhook', {
statusCode: 409,
errorCode: 'admin_webhook_replay_not_supported',
})
}
const result = await replayAgisoTradeWebhookEvent(event)
return {
eventId: event.id,
replayed: true,
result,
}
}
@@ -1,26 +0,0 @@
import test from 'node:test'
import assert from 'node:assert/strict'
import { resolveBindingMatchShopIds } from './fulfillment-bootstrap-service.js'
test('resolveBindingMatchShopIds returns configured shop id for 91kaquan kuaishou bindings', () => {
assert.deepEqual(
resolveBindingMatchShopIds({
provider: '91kaquan',
platform: 'kuaishou',
shopId: '91kaquan',
}),
['91kaquan'],
)
})
test('resolveBindingMatchShopIds preserves wildcard rules for empty shop id', () => {
assert.deepEqual(
resolveBindingMatchShopIds({
provider: 'agiso',
platform: 'xianyu',
shopId: '',
}),
[''],
)
})
@@ -8,7 +8,6 @@ import {
import { upsertProductMatchRule } from '../../repositories/product-match-rule-repo.js' import { upsertProductMatchRule } from '../../repositories/product-match-rule-repo.js'
import { normalizeProductName } from '../order/product-match-service.js' import { normalizeProductName } from '../order/product-match-service.js'
import { query } from '../../db/client.js' import { query } from '../../db/client.js'
import { getLegacyOrderFulfillmentBindingConfigs } from '../order/fulfillment-binding-config-service.js'
import { import {
getKuaishouCloudFulfillmentConfig, getKuaishouCloudFulfillmentConfig,
mapKuaishouCloudFulfillmentItemsToBindings, mapKuaishouCloudFulfillmentItemsToBindings,
@@ -108,10 +107,7 @@ async function ensureCoreProfiles() {
export async function syncConfiguredFulfillmentBindings(profileMap: JsonObject = {}) { export async function syncConfiguredFulfillmentBindings(profileMap: JsonObject = {}) {
const timestamp = nowIso() const timestamp = nowIso()
const bindingsToApply = [ const bindingsToApply = mapKuaishouCloudFulfillmentItemsToBindings(getKuaishouCloudFulfillmentConfig())
...getLegacyOrderFulfillmentBindingConfigs(),
...mapKuaishouCloudFulfillmentItemsToBindings(getKuaishouCloudFulfillmentConfig()),
]
await query('DELETE FROM product_match_rules') await query('DELETE FROM product_match_rules')
await query('DELETE FROM sku_fulfillment_bindings') await query('DELETE FROM sku_fulfillment_bindings')
@@ -124,7 +120,7 @@ export async function syncConfiguredFulfillmentBindings(profileMap: JsonObject =
const bindingConfig = resolveBindingRuntimeConfig(binding) const bindingConfig = resolveBindingRuntimeConfig(binding)
const matchShopIds = resolveBindingMatchShopIds(binding) const matchShopIds = resolveBindingMatchShopIds(binding)
const match = binding.match || {} const match: JsonObject = isPlainObject(binding.match) ? binding.match : {}
const externalSkuName = String(match.externalSkuName || '').trim() const externalSkuName = String(match.externalSkuName || '').trim()
const externalItemId = String(match.externalItemId || '').trim() const externalItemId = String(match.externalItemId || '').trim()
const externalSkuCode = String(match.externalSkuCode || '').trim() const externalSkuCode = String(match.externalSkuCode || '').trim()
@@ -166,7 +162,7 @@ export async function syncConfiguredFulfillmentBindings(profileMap: JsonObject =
} }
} }
export function resolveBindingMatchShopIds(binding: JsonObject = {}) { function resolveBindingMatchShopIds(binding: JsonObject = {}) {
return [String(binding.shopId || '').trim()] return [String(binding.shopId || '').trim()]
} }
@@ -1,273 +0,0 @@
import test from 'node:test'
import assert from 'node:assert/strict'
import { redeemClaimTaskWithInventoryFallbackWithDeps } from './claim-session-service.js'
test('redeemClaimTaskWithInventoryFallbackWithDeps invalidates missing CDKEY and retries with replacement inventory', async () => {
const events = []
const invalidated = []
const reserveCalls = []
const reloadCalls = []
const context = {
task: {
id: 42,
browser_session_id: 'txbs-test',
},
orderItem: {
sku_code: 'df-cdk',
},
}
const initialInventoryItem = {
id: 1,
display_value: 'DJQFf7HgONCL4XD4EH',
credential_type: 'tencent_code',
inventory_group_code: 'A组',
}
const replacementInventoryItem = {
id: 2,
display_value: 'ABCD1234EFGH5678',
credential_type: 'tencent_code',
inventory_group_code: 'A组',
}
let redeemCount = 0
const result = await redeemClaimTaskWithInventoryFallbackWithDeps(
context,
initialInventoryItem,
{
reloadTencentBrowserSession: async (sessionId) => {
reloadCalls.push(sessionId)
return { sessionId, status: 'ready_to_redeem' }
},
redeemTencentBrowserSession: async (sessionId, payload) => {
redeemCount += 1
if (redeemCount === 1) {
assert.equal(sessionId, 'txbs-test')
assert.equal(payload.code, 'DJQFf7HgONCL4XD4EH')
return {
redeem: {
final: {
redeem: {
iRet: -183,
sMsg: '该CDKEY不存在,请您确认后输入!',
},
},
},
}
}
assert.equal(payload.code, 'ABCD1234EFGH5678')
return {
redeem: {
final: {
redeem: {
iRet: 0,
sMsg: '领取成功',
},
},
},
}
},
invalidateReservedInventoryItem: async (inventoryItemId, reason, updatedAt) => {
invalidated.push({ inventoryItemId, reason, updatedAt })
return { id: inventoryItemId, status: 'invalid', invalid_reason: reason, updated_at: updatedAt }
},
reserveInventoryForTask: async (payload) => {
reserveCalls.push(payload)
return replacementInventoryItem
},
createTaskEvent: async (taskId, eventType, payload, createdAt) => {
events.push({ taskId, eventType, payload, createdAt })
},
nowIso: (() => {
const values = [
'2026-04-14T12:03:34.000Z',
'2026-04-14T12:03:35.000Z',
]
let index = 0
return () => values[index++] || values[values.length - 1]
})(),
},
)
assert.equal(result.inventoryItem.id, 2)
assert.equal(result.classification.outcome, 'success')
assert.equal(result.attempts.length, 2)
assert.deepEqual(
result.attempts.map((attempt) => ({
attempt: attempt.attempt,
inventoryItemId: attempt.inventoryItemId,
outcome: attempt.outcome,
resultCode: attempt.resultCode,
})),
[
{
attempt: 1,
inventoryItemId: 1,
outcome: 'code_invalid',
resultCode: '-183',
},
{
attempt: 2,
inventoryItemId: 2,
outcome: 'success',
resultCode: '0',
},
],
)
assert.deepEqual(invalidated, [
{
inventoryItemId: 1,
reason: '该CDKEY不存在,请您确认后输入!',
updatedAt: '2026-04-14T12:03:34.000Z',
},
])
assert.deepEqual(reserveCalls, [
{
skuCode: 'df-cdk',
taskId: 42,
credentialType: 'tencent_code',
roleKey: 'primary_code',
inventoryGroupCodes: ['A组'],
},
])
assert.deepEqual(reloadCalls, ['txbs-test'])
assert.deepEqual(
events.map((event) => ({
eventType: event.eventType,
taskId: event.taskId,
createdAt: event.createdAt,
payload: event.payload,
})),
[
{
eventType: 'claim_redeem_code_invalid',
taskId: 42,
createdAt: '2026-04-14T12:03:34.000Z',
payload: {
inventoryItemId: 1,
codeMasked: 'DJQF****D4EH',
resultCode: '-183',
resultMessage: '该CDKEY不存在,请您确认后输入!',
},
},
{
eventType: 'claim_redeem_inventory_replaced',
taskId: 42,
createdAt: '2026-04-14T12:03:35.000Z',
payload: {
previousInventoryItemId: 1,
previousCodeMasked: 'DJQF****D4EH',
previousOutcome: 'code_invalid',
nextInventoryItemId: 2,
nextCodeMasked: 'ABCD****5678',
credentialType: 'tencent_code',
inventoryGroupCode: 'A组',
},
},
],
)
})
test('redeemClaimTaskWithInventoryFallbackWithDeps waits for inventory when used code has no replacement', async () => {
const events = []
const consumed = []
const reserveCalls = []
const context = {
task: {
id: 57,
browser_session_id: 'txbs-used',
},
orderItem: {
sku_code: 'df-cdk-used',
},
}
const initialInventoryItem = {
id: 7,
display_value: 'USED1234CODE5678',
credential_type: 'tencent_code',
inventory_group_code: 'B组',
}
let thrown = null
try {
await redeemClaimTaskWithInventoryFallbackWithDeps(
context,
initialInventoryItem,
{
redeemTencentBrowserSession: async () => ({
redeem: {
final: {
redeem: {
popup: {
text: '兑换结果',
detail: '兑换码已使用。',
},
},
},
},
}),
markInventoryItemConsumed: async (inventoryItemId, reason, updatedAt) => {
consumed.push({ inventoryItemId, reason, updatedAt })
return { id: inventoryItemId, status: 'consumed', invalid_reason: reason, updated_at: updatedAt }
},
reserveInventoryForTask: async (payload) => {
reserveCalls.push(payload)
return null
},
createTaskEvent: async (taskId, eventType, payload, createdAt) => {
events.push({ taskId, eventType, payload, createdAt })
},
nowIso: () => '2026-04-14T12:05:00.000Z',
},
)
} catch (error) {
thrown = error
}
assert.ok(thrown)
assert.equal(thrown.errorCode, 'claim_inventory_replacement_exhausted')
assert.equal(thrown.redeemTaskState.taskStatus, 'waiting_inventory')
assert.equal(thrown.redeemTaskState.inventoryStatus, 'pending')
assert.equal(thrown.redeemTaskState.deliveryStatus, 'pending')
assert.equal(thrown.redeemTaskState.attempts.length, 1)
assert.deepEqual(consumed, [
{
inventoryItemId: 7,
reason: '兑换码已使用。',
updatedAt: '2026-04-14T12:05:00.000Z',
},
])
assert.deepEqual(reserveCalls, [
{
skuCode: 'df-cdk-used',
taskId: 57,
credentialType: 'tencent_code',
roleKey: 'primary_code',
inventoryGroupCodes: ['B组'],
},
])
assert.deepEqual(
events.map((event) => ({
taskId: event.taskId,
eventType: event.eventType,
payload: event.payload,
createdAt: event.createdAt,
})),
[
{
taskId: 57,
eventType: 'claim_redeem_code_used',
payload: {
inventoryItemId: 7,
codeMasked: 'USED****5678',
resultCode: '',
resultMessage: '兑换码已使用。',
},
createdAt: '2026-04-14T12:05:00.000Z',
},
],
)
})
@@ -1,28 +0,0 @@
export {
getClaimDetail,
getClaimDetailForAdminTask,
getClaimSessionSummary,
getClaimSessionSummaryForAdminTask,
confirmClaimRole,
confirmClaimRoleForAdminTask,
redeemClaimTask,
redeemClaimTaskForAdminTask,
getClaimScreenshotPath,
} from './session/actions.js'
export {
createClaimSession,
createClaimSessionForAdminTask,
reloadClaimSession,
reloadClaimSessionForAdminTask,
closeClaimSession,
closeClaimSessionForAdminTask,
} from './session/lifecycle.js'
export {
getClaimContext,
} from './session/context.js'
export {
redeemClaimTaskWithInventoryFallbackWithDeps,
} from './session/redeem.js'
@@ -1,32 +1,95 @@
import { buildClaimUrl } from '../claim-service.js' import { findClaimTokenByToken, updateClaimToken } from '../../repositories/claim-token-repo.js'
import { createHttpError } from '../../../utils/http.js' import { releaseReservedInventoryItem } from '../../repositories/inventory-repo.js'
import { maskCode as maskCodeValue } from '../../../utils/masking.js' import { getOrderItemById } from '../../repositories/order-item-repo.js'
import { formatFenToAmount, normalizeFen } from '../../../utils/money.js' import { getOrderById } from '../../repositories/order-repo.js'
import { import { findTaskByClaimTokenId, updateTask } from '../../repositories/task-repo.js'
parseTaskContext as parseTaskContextValue, import { createHttpError } from '../../utils/http.js'
parseTaskState as parseTaskStateValue, import { formatFenToAmount, normalizeFen } from '../../utils/money.js'
} from '../../../utils/task-json.js' import { parseTaskContext as parseTaskContextValue } from '../../utils/task-json.js'
import { nowIso } from '../../utils/time.js'
import { buildClaimUrl } from './claim-service.js'
export const CLAIM_TERMINAL_STATUSES = new Set(['expired', 'closed']) export const CLAIM_TERMINAL_STATUSES = new Set(['expired', 'closed'])
export const REDEEM_REPLACEMENT_LIMIT = 10
export const KUAISHOU_CLOUD_CLAIM_GUIDE_BASE_PATH = '/kuaishou-cloud-guide' export const KUAISHOU_CLOUD_CLAIM_GUIDE_BASE_PATH = '/kuaishou-cloud-guide'
export function buildClaimDetailPayload({ claimToken, task, order, orderItem, session }) { export async function getClaimContext(token: unknown) {
const screenshotReady = Boolean(task.screenshot_path) || Boolean(session?.artifacts?.hasScreenshot) const normalized = String(token || '').trim()
const screenshotUrl = screenshotReady ? `/api/v1/claim/${claimToken.token}/screenshot` : ''
const finalRedeem = session?.redeem?.final?.redeem || null if (!normalized) {
throw createHttpError('缺少领取 token', {
statusCode: 400,
errorCode: 'missing_claim_token',
})
}
const claimToken = await findClaimTokenByToken(normalized)
if (!claimToken) {
throw createHttpError('领取链接无效或不存在', {
statusCode: 404,
errorCode: 'claim_token_not_found',
})
}
const task = await findTaskByClaimTokenId(claimToken.id)
if (!task) {
throw createHttpError('领取任务不存在', {
statusCode: 404,
errorCode: 'claim_task_not_found',
})
}
if (claimToken.status !== 'active') {
throw createHttpError('领取链接当前不可用', {
statusCode: 410,
errorCode: 'claim_token_inactive',
})
}
if (claimToken.expired_at && new Date(claimToken.expired_at).getTime() <= Date.now()) {
const expiredContext = await expireClaimContext(claimToken, task)
throw createHttpError('领取链接已过期', {
statusCode: 410,
errorCode: 'claim_token_expired',
context: expiredContext,
})
}
const [order, orderItem] = await Promise.all([
getOrderById(task.order_id),
getOrderItemById(task.order_item_id),
])
if (!order || !orderItem) {
throw createHttpError('领取任务关联订单不完整', {
statusCode: 500,
errorCode: 'claim_order_incomplete',
})
}
return {
claimToken,
task,
order,
orderItem,
}
}
export function buildClaimDetailPayload({ claimToken, task, order, orderItem }) {
const kuaishouCloudFulfillment = mapClaimKuaishouCloudFulfillment(task, order) const kuaishouCloudFulfillment = mapClaimKuaishouCloudFulfillment(task, order)
return { return {
tokenStatus: claimToken.status, tokenStatus: claimToken.status,
claimUrl: buildClaimUrl(claimToken.token), claimUrl: buildClaimUrl(claimToken.token),
flowType: kuaishouCloudFulfillment ? 'kuaishou_cloud' : 'tencent_claim', flowType: 'kuaishou_cloud',
task: { task: {
taskId: task.id, taskId: task.id,
taskNo: task.task_no, taskNo: task.task_no,
status: task.task_status, status: task.task_status,
executorKey: task.executor_key || '', executorKey: task.executor_key || '',
requiresSupportReview: isAssistedClaimTask(task), requiresSupportReview: false,
expiresAt: claimToken.expired_at, expiresAt: claimToken.expired_at,
claimedAt: task.claimed_at, claimedAt: task.claimed_at,
roleConfirmedAt: task.role_confirmed_at, roleConfirmedAt: task.role_confirmed_at,
@@ -51,14 +114,14 @@ export function buildClaimDetailPayload({ claimToken, task, order, orderItem, se
skuName: orderItem.sku_name, skuName: orderItem.sku_name,
quantity: orderItem.quantity, quantity: orderItem.quantity,
}, },
session, session: null,
kuaishouCloudFulfillment, kuaishouCloudFulfillment,
result: task.redeemed_at || session?.status === 'redeemed' result: task.redeemed_at
? { ? {
resultCode: String(task.result_code || finalRedeem?.iRet || finalRedeem?.ret || ''), resultCode: String(task.result_code || ''),
resultMessage: String(task.result_message || finalRedeem?.sMsg || finalRedeem?.msg || session?.notice || ''), resultMessage: String(task.result_message || ''),
screenshotReady, screenshotReady: false,
screenshotUrl, screenshotUrl: '',
} }
: null, : null,
} }
@@ -151,58 +214,35 @@ export function mapClaimKuaishouCloudFulfillment(task, order) {
} }
} }
export function assertTaskCanProceed(task) { function parseTaskContext(task) {
if (CLAIM_TERMINAL_STATUSES.has(String(task.task_status || ''))) {
throw createHttpError('当前任务已经结束,不能继续操作', {
statusCode: 410,
errorCode: 'claim_task_closed',
})
}
}
export function assertPublicClaimActionAllowed(task, action) {
if (!isAssistedClaimTask(task)) {
return
}
const message = action === 'confirm'
? '当前商品需要客服复核角色,请登录后联系人工继续'
: '当前商品需要客服确认后再执行兑换,请联系人工继续'
throw createHttpError(message, {
statusCode: 409,
errorCode: 'claim_support_review_required',
})
}
export function mergeTaskContext(task, patch = {}) {
return {
...parseTaskContext(task),
...patch,
}
}
export function parseTaskState(task) {
return parseTaskStateValue(task)
}
export function isAssistedClaimTask(task) {
return String(task?.executor_key || '').trim() === 'tencent_claim_assisted'
}
export function parseTaskContext(task) {
return parseTaskContextValue(task) return parseTaskContextValue(task)
} }
export function normalizeClaimLoginType(loginType) { async function expireClaimContext(claimToken, task) {
return String(loginType || '').trim() === 'wx' ? 'wx' : 'qq' const now = nowIso()
} const nextClaimToken = await updateClaimToken(claimToken.id, {
status: 'expired',
updated_at: now,
})
export function isRecoverableSessionError(error) { let nextTask = task
const errorCode = String(error?.errorCode || error?.code || '').trim()
return errorCode === 'session_not_found' || errorCode === 'session_closed'
}
export function maskCode(value) { if (!CLAIM_TERMINAL_STATUSES.has(String(task.task_status || '')) && task.task_status !== 'redeemed') {
return maskCodeValue(value, { shortMask: '****' }) if (task.primary_inventory_item_id) {
await releaseReservedInventoryItem(task.primary_inventory_item_id, now)
}
nextTask = await updateTask(task.id, {
task_status: 'expired',
inventory_status: 'pending',
user_action_status: 'expired',
last_error: '领取链接已过期,预占库存项已释放',
updated_at: now,
})
}
return {
claimToken: nextClaimToken,
task: nextTask,
}
} }
@@ -18,8 +18,7 @@ import {
prepareKuaishouCloudFulfillmentTask, prepareKuaishouCloudFulfillmentTask,
refreshKuaishouCloudTaskRoleInfo, refreshKuaishouCloudTaskRoleInfo,
} from '../fulfillment/kuaishou-cloud-task-service.js' } from '../fulfillment/kuaishou-cloud-task-service.js'
import { buildClaimDetailPayload } from './session/shared.js' import { buildClaimDetailPayload, getClaimContext } from './kuaishou-cloud-claim-context.js'
import { getClaimContext } from './session/context.js'
import { syncKuaishouCloudRoleInfo } from './kuaishou-cloud-sync-service.js' import { syncKuaishouCloudRoleInfo } from './kuaishou-cloud-sync-service.js'
const KUAISHOU_CLOUD_GUIDE_DIR = path.resolve(PROJECT_ROOT, '../../tems/imgs') const KUAISHOU_CLOUD_GUIDE_DIR = path.resolve(PROJECT_ROOT, '../../tems/imgs')
@@ -170,7 +169,6 @@ export async function getKuaishouCloudClaimDetail(token: unknown) {
task, task,
order: context.order, order: context.order,
orderItem: context.orderItem, orderItem: context.orderItem,
session: null,
}) })
} }
@@ -1,312 +0,0 @@
import {
getTencentBrowserSession,
getTencentBrowserSessionScreenshotPath,
} from '../../session/session.js'
import { createTaskEvent } from '../../../repositories/task-event-repo.js'
import { updateTask } from '../../../repositories/task-repo.js'
import {
getInventoryItemById,
markInventoryItemDelivered,
} from '../../../repositories/inventory-repo.js'
import { ensureAgisoXianyuAutoDeliveryForDeliveredTask } from '../../platforms/agiso/xianyu/auto-delivery-service.js'
import { syncKuaishouCloudRoleInfo } from '../kuaishou-cloud-sync-service.js'
import { notifyClaimRedeemNeedsAttention } from '../../notification/domain-notifications.js'
import { createHttpError } from '../../../utils/http.js'
import { nowIso } from '../../../utils/time.js'
import { getClaimContext, getClaimContextByTaskId } from './context.js'
import { loadTaskSession, syncTaskWithSession } from './runtime.js'
import { redeemClaimTaskWithInventoryFallback, resolveTencentRedeemResultCode } from './redeem.js'
import {
assertPublicClaimActionAllowed,
assertTaskCanProceed,
buildClaimDetailPayload,
maskCode,
mergeTaskContext,
} from './shared.js'
export async function getClaimDetail(token, { includeQrImage = true } = {}) {
const context = await getClaimContext(token)
let { task, session } = await loadTaskSession(context.task, { includeQrImage })
if (String(task.executor_key || '').trim() === 'kuaishou_ct_assisted') {
task = await syncKuaishouCloudRoleInfo(task)
}
const syncedTask = session ? await syncTaskWithSession(task, session) : task
return buildClaimDetailPayload({
claimToken: context.claimToken,
task: syncedTask,
order: context.order,
orderItem: context.orderItem,
session,
})
}
export async function getClaimDetailForAdminTask(taskId, { includeQrImage = true } = {}) {
const context = await getClaimContextByTaskId(taskId)
const { task, session } = await loadTaskSession(context.task, { includeQrImage })
const syncedTask = session ? await syncTaskWithSession(task, session) : task
return buildClaimDetailPayload({
claimToken: context.claimToken,
task: syncedTask,
order: context.order,
orderItem: context.orderItem,
session,
})
}
export async function getClaimSessionSummary(token) {
return getClaimSessionSummaryByContextLoader(() => getClaimContext(token))
}
export async function getClaimSessionSummaryForAdminTask(taskId) {
return getClaimSessionSummaryByContextLoader(() => getClaimContextByTaskId(taskId))
}
export async function confirmClaimRole(token) {
const context = await getClaimContext(token)
assertPublicClaimActionAllowed(context.task, 'confirm')
return finalizeClaimRoleConfirmation(context)
}
export async function confirmClaimRoleForAdminTask(taskId) {
const context = await getClaimContextByTaskId(taskId)
return finalizeClaimRoleConfirmation(context)
}
export async function redeemClaimTask(token) {
const context = await getClaimContext(token)
assertTaskCanProceed(context.task)
assertPublicClaimActionAllowed(context.task, 'redeem')
return finalizeClaimTaskRedeem(context)
}
export async function redeemClaimTaskForAdminTask(taskId) {
const context = await getClaimContextByTaskId(taskId)
assertTaskCanProceed(context.task)
return finalizeClaimTaskRedeem(context)
}
export async function getClaimScreenshotPath(token) {
const context = await getClaimContext(token)
if (context.task.screenshot_path) {
return context.task.screenshot_path
}
if (!context.task.browser_session_id) {
throw createHttpError('当前任务还没有兑换截图', {
statusCode: 404,
errorCode: 'claim_screenshot_not_ready',
})
}
return getTencentBrowserSessionScreenshotPath(context.task.browser_session_id)
}
async function getClaimSessionSummaryByContextLoader(loadContext) {
const context = await loadContext()
const { task, session } = await loadTaskSession(context.task, {
includeQrImage: false,
})
if (!session) {
return buildClaimDetailPayload({
claimToken: context.claimToken,
task,
order: context.order,
orderItem: context.orderItem,
session: null,
})
}
const syncedTask = await syncTaskWithSession(task, session)
return buildClaimDetailPayload({
claimToken: context.claimToken,
task: syncedTask,
order: context.order,
orderItem: context.orderItem,
session,
})
}
async function finalizeClaimRoleConfirmation(context) {
if (!context.task.browser_session_id) {
throw createHttpError('当前任务还没有创建浏览器会话', {
statusCode: 409,
errorCode: 'claim_session_not_created',
})
}
const session = await getTencentBrowserSession(context.task.browser_session_id)
const activityInfo = session.activityInfo || null
if (!activityInfo?.role?.ready) {
throw createHttpError('当前角色信息还没有准备好', {
statusCode: 409,
errorCode: 'claim_role_not_ready',
})
}
const updatedTask = await updateTask(context.task.id, {
task_status: 'role_confirmed',
nickname: String(activityInfo.nickname || ''),
role_id: String(activityInfo.role.roleId || ''),
role_name: String(activityInfo.role.roleName || ''),
area: String(activityInfo.role.area || ''),
partition_name: String(activityInfo.role.partition || ''),
user_action_status: 'role_confirmed',
role_confirmed_at: nowIso(),
updated_at: nowIso(),
last_error: '',
})
return buildClaimDetailPayload({
claimToken: context.claimToken,
task: updatedTask,
order: context.order,
orderItem: context.orderItem,
session,
})
}
async function finalizeClaimTaskRedeem(context) {
if (!context.task.browser_session_id) {
throw createHttpError('当前任务还没有创建浏览器会话', {
statusCode: 409,
errorCode: 'claim_session_not_created',
})
}
if (context.task.task_status !== 'role_confirmed' && context.task.task_status !== 'redeeming') {
throw createHttpError('当前任务还未确认角色,不能开始兑换', {
statusCode: 409,
errorCode: 'claim_role_not_confirmed',
})
}
const inventoryItem = context.task.primary_inventory_item_id
? await getInventoryItemById(context.task.primary_inventory_item_id)
: null
if (
!inventoryItem ||
String(inventoryItem.status || '').trim() !== 'reserved' ||
!String(inventoryItem.display_value || '').trim()
) {
throw createHttpError('当前任务没有可用的预占库存凭据', {
statusCode: 409,
errorCode: 'claim_inventory_not_reserved',
})
}
await updateTask(context.task.id, {
task_status: 'redeeming',
delivery_status: 'processing',
updated_at: nowIso(),
last_error: '',
})
try {
const redeemResult = await redeemClaimTaskWithInventoryFallback(context, inventoryItem)
const { session, inventoryItem: deliveredInventoryItem, classification, attempts } = redeemResult
const finalRedeem = session.redeem?.final?.redeem || null
const finishedAt = nowIso()
const updatedTask = await updateTask(context.task.id, {
task_status: 'redeemed',
inventory_status: 'consumed',
delivery_status: 'delivered',
result_code: resolveTencentRedeemResultCode(finalRedeem, classification),
result_message: classification.message,
screenshot_path: session.artifacts?.hasScreenshot ? await getTencentBrowserSessionScreenshotPath(session.sessionId) : '',
artifacts_json: JSON.stringify(session.artifacts || {}),
context_json: JSON.stringify(mergeTaskContext(context.task, {
redeemResolution: {
status: 'success',
attempts,
replacementCount: Math.max(0, attempts.length - 1),
finishedAt,
},
})),
redeemed_at: finishedAt,
updated_at: finishedAt,
last_error: '',
})
await markInventoryItemDelivered(deliveredInventoryItem.id, finishedAt)
await createTaskEvent(context.task.id, 'claim_redeem_completed', {
inventoryItemId: deliveredInventoryItem.id,
codeMasked: maskCode(deliveredInventoryItem.display_value),
replacementCount: Math.max(0, attempts.length - 1),
resultCode: resolveTencentRedeemResultCode(finalRedeem, classification),
resultMessage: classification.message,
}, finishedAt)
const autoDeliveryResult = await ensureAgisoXianyuAutoDeliveryForDeliveredTask({
order: context.order,
task: updatedTask,
trigger: 'claim_redeemed',
})
return buildClaimDetailPayload({
claimToken: context.claimToken,
task: autoDeliveryResult.task || updatedTask,
order: context.order,
orderItem: context.orderItem,
session,
})
} catch (error) {
const now = nowIso()
const nextRetryCount = Number(context.task.attempt_count || 0) + 1
const failureState = (
error && typeof error === 'object'
? /** @type {{ redeemTaskState?: { taskStatus: string, inventoryStatus: string, deliveryStatus: string, lastError: string, attempts: unknown[], classification: { retCode?: unknown } | null } }} */ (error).redeemTaskState
: null
) || {
taskStatus: 'retry_pending',
inventoryStatus: inventoryItem ? 'reserved' : 'pending',
deliveryStatus: 'pending',
lastError: error instanceof Error ? error.message : String(error || ''),
attempts: [],
classification: null,
}
const failedTask = await updateTask(context.task.id, {
task_status: failureState.taskStatus,
inventory_status: failureState.inventoryStatus,
delivery_status: failureState.deliveryStatus,
result_code: failureState.classification?.retCode != null ? String(failureState.classification.retCode) : '',
result_message: String(failureState.lastError || ''),
attempt_count: nextRetryCount,
last_error: String(failureState.lastError || ''),
context_json: JSON.stringify(mergeTaskContext(context.task, {
redeemResolution: {
status: 'failed',
taskStatus: failureState.taskStatus,
attempts: failureState.attempts || [],
replacementCount: Math.max(0, Number(failureState.attempts?.length || 1) - 1),
finishedAt: now,
},
})),
updated_at: now,
})
if (['retry_pending', 'waiting_inventory', 'manual_review'].includes(String(failureState.taskStatus || '').trim())) {
await notifyClaimRedeemNeedsAttention({
task: failedTask,
order: context.order,
status: failureState.taskStatus,
errorMessage: failureState.lastError,
})
}
throw Object.assign(error instanceof Error ? error : new Error(String(error || '兑换失败')), {
task: failedTask,
})
}
}
@@ -1,150 +0,0 @@
import { findClaimTokenByToken, getClaimTokenById, updateClaimToken } from '../../../repositories/claim-token-repo.js'
import { getOrderById } from '../../../repositories/order-repo.js'
import { getOrderItemById } from '../../../repositories/order-item-repo.js'
import { findTaskByClaimTokenId, getTaskById, updateTask } from '../../../repositories/task-repo.js'
import { releaseReservedInventoryItem } from '../../../repositories/inventory-repo.js'
import { createHttpError } from '../../../utils/http.js'
import { nowIso } from '../../../utils/time.js'
import { CLAIM_TERMINAL_STATUSES } from './shared.js'
export async function getClaimContext(token) {
const normalized = String(token || '').trim()
if (!normalized) {
throw createHttpError('缺少领取 token', {
statusCode: 400,
errorCode: 'missing_claim_token',
})
}
const claimToken = await findClaimTokenByToken(normalized)
if (!claimToken) {
throw createHttpError('领取链接无效或不存在', {
statusCode: 404,
errorCode: 'claim_token_not_found',
})
}
const task = await findTaskByClaimTokenId(claimToken.id)
if (!task) {
throw createHttpError('领取任务不存在', {
statusCode: 404,
errorCode: 'claim_task_not_found',
})
}
if (claimToken.status !== 'active') {
throw createHttpError('领取链接当前不可用', {
statusCode: 410,
errorCode: 'claim_token_inactive',
})
}
if (claimToken.expired_at && new Date(claimToken.expired_at).getTime() <= Date.now()) {
const expiredContext = await expireClaimContext(claimToken, task)
throw createHttpError('领取链接已过期', {
statusCode: 410,
errorCode: 'claim_token_expired',
context: expiredContext,
})
}
const [order, orderItem] = await Promise.all([
getOrderById(task.order_id),
getOrderItemById(task.order_item_id),
])
if (!order || !orderItem) {
throw createHttpError('领取任务关联订单不完整', {
statusCode: 500,
errorCode: 'claim_order_incomplete',
})
}
return {
claimToken,
task,
order,
orderItem,
}
}
export async function getClaimContextByTaskId(taskId) {
const task = await getTaskById(Number(taskId))
if (!task) {
throw createHttpError('领取任务不存在', {
statusCode: 404,
errorCode: 'claim_task_not_found',
})
}
const claimTokenId = Number(task.primary_claim_token_id || 0)
if (!claimTokenId) {
throw createHttpError('当前任务还没有领取链接', {
statusCode: 409,
errorCode: 'claim_token_missing',
})
}
const claimToken = await getClaimTokenById(claimTokenId)
if (!claimToken) {
throw createHttpError('领取链接不存在', {
statusCode: 404,
errorCode: 'claim_token_not_found',
})
}
const [order, orderItem] = await Promise.all([
getOrderById(task.order_id),
getOrderItemById(task.order_item_id),
])
if (!order || !orderItem) {
throw createHttpError('领取任务关联订单不完整', {
statusCode: 500,
errorCode: 'claim_order_incomplete',
})
}
return {
claimToken,
task,
order,
orderItem,
}
}
async function expireClaimContext(claimToken, task) {
const now = nowIso()
const nextClaimToken = await updateClaimToken(claimToken.id, {
status: 'expired',
updated_at: now,
})
let nextTask = task
if (!CLAIM_TERMINAL_STATUSES.has(String(task.task_status || '')) && task.task_status !== 'redeemed') {
if (task.primary_inventory_item_id) {
await releaseReservedInventoryItem(task.primary_inventory_item_id, now)
}
nextTask = await updateTask(task.id, {
task_status: 'expired',
inventory_status: 'pending',
user_action_status: 'expired',
last_error: '领取链接已过期,预占库存项已释放',
updated_at: now,
})
}
return {
claimToken: nextClaimToken,
task: nextTask,
}
}
@@ -1,175 +0,0 @@
import {
closeTencentBrowserSession,
createTencentBrowserSession,
reloadTencentBrowserSession,
} from '../../session/session.js'
import { updateTask } from '../../../repositories/task-repo.js'
import { nowIso } from '../../../utils/time.js'
import { getClaimContext, getClaimContextByTaskId } from './context.js'
import { clearTaskSession, loadTaskSession, syncTaskWithSession } from './runtime.js'
import {
assertTaskCanProceed,
buildClaimDetailPayload,
isRecoverableSessionError,
normalizeClaimLoginType,
} from './shared.js'
type JsonObject = Record<string, any>
export async function createClaimSession(token: unknown, payload: JsonObject = {}) {
return createClaimSessionWithContextLoader(
() => getClaimContext(token),
payload,
)
}
export async function createClaimSessionForAdminTask(taskId: unknown, payload: JsonObject = {}) {
return createClaimSessionWithContextLoader(
() => getClaimContextByTaskId(taskId),
payload,
)
}
export async function reloadClaimSession(token: unknown) {
return reloadClaimSessionWithContextLoader(() => getClaimContext(token))
}
export async function reloadClaimSessionForAdminTask(taskId: unknown) {
return reloadClaimSessionWithContextLoader(() => getClaimContextByTaskId(taskId))
}
export async function closeClaimSession(token: unknown) {
return closeClaimSessionWithContextLoader(() => getClaimContext(token))
}
export async function closeClaimSessionForAdminTask(taskId: unknown) {
return closeClaimSessionWithContextLoader(() => getClaimContextByTaskId(taskId))
}
async function createClaimSessionWithContextLoader(loadContext: () => Promise<JsonObject>, payload: JsonObject = {}) {
const context = await loadContext()
assertTaskCanProceed(context.task)
const requestedLoginType = normalizeClaimLoginType(payload.loginType)
const forceRecreate = Boolean(payload.forceRecreate)
let task = context.task
if (task.browser_session_id) {
const existing = await loadTaskSession(task, { includeQrImage: true })
task = existing.task
if (existing.session) {
const existingLoginType = normalizeClaimLoginType(existing.session.loginType || task.login_type)
if (!forceRecreate && existingLoginType === requestedLoginType) {
const syncedTask = await syncTaskWithSession(task, existing.session)
return buildClaimDetailPayload({
claimToken: context.claimToken,
task: syncedTask,
order: context.order,
orderItem: context.orderItem,
session: existing.session,
})
}
try {
await closeTencentBrowserSession(existing.session.sessionId)
} catch (error) {
if (!isRecoverableSessionError(error)) {
throw error
}
}
task = await clearTaskSession(task, {
lastError: '',
})
}
}
const session = await createTencentBrowserSession({
loginType: requestedLoginType,
})
const updatedTask = await updateTask(task.id, {
task_status: 'claimed',
browser_session_id: session.sessionId,
login_type: session.loginType,
user_action_status: 'claimed',
claimed_at: task.claimed_at || nowIso(),
updated_at: nowIso(),
last_error: '',
})
return buildClaimDetailPayload({
claimToken: context.claimToken,
task: updatedTask,
order: context.order,
orderItem: context.orderItem,
session,
})
}
async function reloadClaimSessionWithContextLoader(loadContext: () => Promise<JsonObject>) {
const context = await loadContext()
assertTaskCanProceed(context.task)
const active = await loadTaskSession(context.task, {
includeQrImage: true,
})
if (!active.session) {
return buildClaimDetailPayload({
claimToken: context.claimToken,
task: active.task,
order: context.order,
orderItem: context.orderItem,
session: null,
})
}
const session = await reloadTencentBrowserSession(active.session.sessionId)
const syncedTask = await syncTaskWithSession(active.task, session)
return buildClaimDetailPayload({
claimToken: context.claimToken,
task: syncedTask,
order: context.order,
orderItem: context.orderItem,
session,
})
}
async function closeClaimSessionWithContextLoader(loadContext: () => Promise<JsonObject>) {
const context = await loadContext()
assertTaskCanProceed(context.task)
let task = context.task
if (!task.browser_session_id) {
return buildClaimDetailPayload({
claimToken: context.claimToken,
task,
order: context.order,
orderItem: context.orderItem,
session: null,
})
}
try {
await closeTencentBrowserSession(task.browser_session_id)
} catch (error) {
if (!isRecoverableSessionError(error)) {
throw error
}
}
task = await clearTaskSession(task, {
lastError: '',
})
return buildClaimDetailPayload({
claimToken: context.claimToken,
task,
order: context.order,
orderItem: context.orderItem,
session: null,
})
}
@@ -1,301 +0,0 @@
import { reloadTencentBrowserSession, redeemTencentBrowserSession } from '../../session/session.js'
import { classifyTencentRedeemResult } from '../../session/session-redeem.js'
import { createTaskEvent } from '../../../repositories/task-event-repo.js'
import {
invalidateReservedInventoryItem,
markInventoryItemConsumed,
} from '../../../repositories/inventory-repo.js'
import { reserveInventoryForTask } from '../../order/inventory-service.js'
import { createHttpError } from '../../../utils/http.js'
import { nowIso } from '../../../utils/time.js'
import { maskCode, REDEEM_REPLACEMENT_LIMIT } from './shared.js'
import type { InventoryItemRow, OrderItemRow, TaskRow } from '../../../types/repository-rows.js'
type ClaimRedeemContext = {
task: Pick<TaskRow, 'id' | 'browser_session_id'>
orderItem: Pick<OrderItemRow, 'sku_code'>
}
type TencentRedeemPayload = {
[key: string]: unknown
iRet?: unknown
ret?: unknown
}
type TencentRedeemSession = {
[key: string]: unknown
sessionId?: string
artifacts?: {
[key: string]: unknown
hasScreenshot?: boolean
} | null
redeem?: {
final?: {
redeem?: TencentRedeemPayload | null
} | null
} | null
}
type TencentRedeemClassification = {
success: boolean
outcome: string
message: string
retCode?: unknown
[key: string]: unknown
}
type RedeemAttemptSummary = {
attempt: number
inventoryItemId: number
codeMasked: string
credentialType: string
outcome: string
resultCode: string
resultMessage: string
}
type RedeemTaskState = {
taskStatus: string
inventoryStatus: string
deliveryStatus: string
lastError: string
attempts: RedeemAttemptSummary[]
classification: TencentRedeemClassification | null
}
type RedeemTaskStateError = Error & {
statusCode?: number
errorCode?: string
redeemTaskState: RedeemTaskState
}
type RedeemTaskStateErrorPayload = {
statusCode?: number
errorCode?: string
taskStatus?: string
inventoryStatus?: string
deliveryStatus?: string
attempts?: RedeemAttemptSummary[]
classification?: TencentRedeemClassification | null
}
type RedeemClaimTaskDeps = {
reloadTencentBrowserSession?: (sessionId: string) => Promise<unknown>
redeemTencentBrowserSession?: (
sessionId: string,
payload: { code: string },
) => Promise<TencentRedeemSession>
classifyTencentRedeemResult?: (finalRedeem: TencentRedeemPayload | null) => TencentRedeemClassification
markInventoryItemConsumed?: (
inventoryItemId: number | string,
reason: string,
consumedAt: string,
) => Promise<InventoryItemRow | null>
createTaskEvent?: (
taskId: number | string,
eventType: string,
payload?: unknown,
createdAt?: string,
) => Promise<unknown>
invalidateReservedInventoryItem?: (
inventoryItemId: number | string,
reason: string,
updatedAt: string,
) => Promise<InventoryItemRow | null>
reserveInventoryForTask?: (payload: {
skuCode: string
taskId: number | string
credentialType?: string
roleKey?: string
inventoryGroupCodes?: string[] | null
}) => Promise<InventoryItemRow | null>
nowIso?: () => string
redeemReplacementLimit?: number
}
type RedeemClaimTaskResult = {
session: TencentRedeemSession
inventoryItem: InventoryItemRow
classification: TencentRedeemClassification
attempts: RedeemAttemptSummary[]
}
export async function redeemClaimTaskWithInventoryFallback(
context: ClaimRedeemContext,
initialInventoryItem: InventoryItemRow,
): Promise<RedeemClaimTaskResult> {
return redeemClaimTaskWithInventoryFallbackWithDeps(context, initialInventoryItem)
}
export async function redeemClaimTaskWithInventoryFallbackWithDeps(
context: ClaimRedeemContext,
initialInventoryItem: InventoryItemRow,
{
reloadTencentBrowserSession: reloadRedeemSession = reloadTencentBrowserSession,
redeemTencentBrowserSession: redeemSession = redeemTencentBrowserSession,
classifyTencentRedeemResult: classifyRedeemResult = classifyTencentRedeemResult,
markInventoryItemConsumed: markConsumedInventoryItem = markInventoryItemConsumed,
createTaskEvent: createRedeemTaskEvent = createTaskEvent,
invalidateReservedInventoryItem: invalidateReservedItem = invalidateReservedInventoryItem,
reserveInventoryForTask: reserveReplacementInventory = reserveInventoryForTask,
nowIso: getNowIso = nowIso,
redeemReplacementLimit = REDEEM_REPLACEMENT_LIMIT,
}: RedeemClaimTaskDeps = {},
): Promise<RedeemClaimTaskResult> {
const attempts: RedeemAttemptSummary[] = []
let currentInventoryItem = initialInventoryItem
let shouldReloadBeforeNextAttempt = false
for (let attemptIndex = 1; attemptIndex <= redeemReplacementLimit; attemptIndex += 1) {
if (shouldReloadBeforeNextAttempt) {
await reloadRedeemSession(context.task.browser_session_id)
shouldReloadBeforeNextAttempt = false
}
const session = await redeemSession(context.task.browser_session_id, {
code: currentInventoryItem.display_value,
})
const finalRedeem = session.redeem?.final?.redeem || null
const classification = classifyRedeemResult(finalRedeem)
const attemptSummary = {
attempt: attemptIndex,
inventoryItemId: currentInventoryItem.id,
codeMasked: maskCode(currentInventoryItem.display_value),
credentialType: String(currentInventoryItem.credential_type || ''),
outcome: classification.outcome,
resultCode: resolveTencentRedeemResultCode(finalRedeem, classification),
resultMessage: classification.message,
}
attempts.push(attemptSummary)
if (classification.success) {
return {
session,
inventoryItem: currentInventoryItem,
classification,
attempts,
}
}
if (classification.outcome === 'code_used') {
const updatedAt = getNowIso()
await markConsumedInventoryItem(currentInventoryItem.id, classification.message, updatedAt)
await createRedeemTaskEvent(context.task.id, 'claim_redeem_code_used', {
inventoryItemId: currentInventoryItem.id,
codeMasked: attemptSummary.codeMasked,
resultCode: attemptSummary.resultCode,
resultMessage: classification.message,
}, updatedAt)
} else if (classification.outcome === 'code_invalid') {
const updatedAt = getNowIso()
await invalidateReservedItem(currentInventoryItem.id, classification.message, updatedAt)
await createRedeemTaskEvent(context.task.id, 'claim_redeem_code_invalid', {
inventoryItemId: currentInventoryItem.id,
codeMasked: attemptSummary.codeMasked,
resultCode: attemptSummary.resultCode,
resultMessage: classification.message,
}, updatedAt)
} else {
throw createRedeemTaskStateError(classification.message, {
errorCode: 'claim_redeem_failed',
taskStatus: 'retry_pending',
inventoryStatus: 'reserved',
deliveryStatus: 'pending',
attempts,
classification,
})
}
if (attemptIndex >= redeemReplacementLimit) {
throw createRedeemTaskStateError('连续更换兑换码后仍未成功,请联系人工处理', {
errorCode: 'claim_redeem_replacement_limit_reached',
taskStatus: 'retry_pending',
inventoryStatus: 'pending',
deliveryStatus: 'pending',
attempts,
classification,
})
}
const replacement = await reserveReplacementInventory({
skuCode: context.orderItem.sku_code,
taskId: context.task.id,
credentialType: currentInventoryItem.credential_type || 'tencent_code',
roleKey: 'primary_code',
inventoryGroupCodes: currentInventoryItem.inventory_group_code
? [String(currentInventoryItem.inventory_group_code).trim()]
: null,
})
if (!replacement || !String(replacement.display_value || '').trim()) {
const exhaustedMessage = classification.outcome === 'code_used'
? '兑换码已使用,且没有更多同类型可用 CDK 可继续重试'
: '兑换码错误,请确认库存数据;当前没有更多同类型可用 CDK 可继续重试'
throw createRedeemTaskStateError(exhaustedMessage, {
errorCode: 'claim_inventory_replacement_exhausted',
taskStatus: 'waiting_inventory',
inventoryStatus: 'pending',
deliveryStatus: 'pending',
attempts,
classification,
})
}
const replacedAt = getNowIso()
await createRedeemTaskEvent(context.task.id, 'claim_redeem_inventory_replaced', {
previousInventoryItemId: currentInventoryItem.id,
previousCodeMasked: attemptSummary.codeMasked,
previousOutcome: classification.outcome,
nextInventoryItemId: replacement.id,
nextCodeMasked: maskCode(replacement.display_value),
credentialType: String(replacement.credential_type || currentInventoryItem.credential_type || ''),
inventoryGroupCode: String(replacement.inventory_group_code || currentInventoryItem.inventory_group_code || '').trim(),
}, replacedAt)
currentInventoryItem = replacement
shouldReloadBeforeNextAttempt = true
}
throw createRedeemTaskStateError('兑换失败,请稍后重试', {
errorCode: 'claim_redeem_failed',
taskStatus: 'retry_pending',
inventoryStatus: 'pending',
deliveryStatus: 'pending',
attempts,
classification: null,
})
}
function createRedeemTaskStateError(
message: string,
payload: RedeemTaskStateErrorPayload = {},
): RedeemTaskStateError {
const error = createHttpError(message, {
statusCode: payload.statusCode || 409,
errorCode: payload.errorCode || 'claim_redeem_failed',
}) as unknown as RedeemTaskStateError
error.redeemTaskState = {
taskStatus: payload.taskStatus || 'retry_pending',
inventoryStatus: payload.inventoryStatus || 'pending',
deliveryStatus: payload.deliveryStatus || 'pending',
lastError: String(message || ''),
attempts: payload.attempts || [],
classification: payload.classification || null,
}
return error
}
export function resolveTencentRedeemResultCode(
finalRedeem: TencentRedeemPayload | null,
classification: Pick<TencentRedeemClassification, 'retCode'> | null,
): string {
if (classification?.retCode != null) {
return String(classification.retCode)
}
const value = finalRedeem?.iRet ?? finalRedeem?.ret
return value == null ? '' : String(value)
}
@@ -1,107 +0,0 @@
import {
getTencentBrowserSession,
getTencentBrowserSessionSummary,
} from '../../session/session.js'
import { updateTask } from '../../../repositories/task-repo.js'
import { nowIso } from '../../../utils/time.js'
import { isRecoverableSessionError, parseTaskState } from './shared.js'
type JsonObject = Record<string, any>
export async function loadTaskSession(task: JsonObject, { includeQrImage = false }: { includeQrImage?: boolean } = {}) {
if (!task.browser_session_id) {
return {
task,
session: null,
}
}
try {
const session = includeQrImage
? await getTencentBrowserSession(task.browser_session_id)
: await getTencentBrowserSessionSummary(task.browser_session_id)
return {
task,
session,
}
} catch (error) {
if (!isRecoverableSessionError(error)) {
throw error
}
const nextTask = await clearTaskSession(task, {
lastError: '浏览器会话已失效,请重新初始化登录',
})
return {
task: nextTask,
session: null,
}
}
}
export async function syncTaskWithSession(task: JsonObject, session: JsonObject) {
const activityInfo = session.activityInfo || null
const patch: JsonObject = {
login_type: String(session.loginType || task.login_type || ''),
updated_at: nowIso(),
}
if (activityInfo?.nickname) {
patch.nickname = String(activityInfo.nickname)
}
if (activityInfo?.role?.ready) {
patch.role_id = String(activityInfo.role.roleId || '')
patch.role_name = String(activityInfo.role.roleName || '')
patch.area = String(activityInfo.role.area || '')
patch.partition_name = String(activityInfo.role.partition || '')
}
if (task.task_status === 'link_generated') {
patch.task_status = 'claimed'
patch.user_action_status = 'claimed'
patch.claimed_at = task.claimed_at || nowIso()
}
if (session.review?.capturedAt) {
patch.state_json = JSON.stringify({
...parseTaskState(task),
reviewScreenshotReady: true,
reviewCapturedAt: String(session.review.capturedAt || ''),
reviewRoleId: String(session.review.roleId || ''),
reviewRoleName: String(session.review.roleName || ''),
})
}
if (session.status === 'redeemed' && session.artifacts?.hasScreenshot) {
patch.screenshot_path = task.screenshot_path || ''
}
return updateTask(task.id, patch)
}
export async function clearTaskSession(task: JsonObject, { lastError = '' }: { lastError?: string } = {}) {
const shouldResetClaimProgress = ['link_generated', 'claimed', 'role_confirmed'].includes(String(task.task_status || ''))
const nextTaskStatus = shouldResetClaimProgress ? 'link_generated' : task.task_status
const nextUserActionStatus = shouldResetClaimProgress ? 'pending_claim' : task.user_action_status
const patch = {
task_status: nextTaskStatus,
user_action_status: nextUserActionStatus,
browser_session_id: '',
login_type: '',
nickname: '',
role_id: '',
role_name: '',
area: '',
partition_name: '',
artifacts_json: '{}',
state_json: '{}',
role_confirmed_at: nextTaskStatus === 'link_generated' ? null : task.role_confirmed_at,
last_error: String(lastError || ''),
updated_at: nowIso(),
}
return updateTask(task.id, patch)
}
@@ -1,31 +0,0 @@
import test from 'node:test'
import assert from 'node:assert/strict'
import { extractAgisoTradePayload, resolveAgisoTradePlatformOrderId } from './agiso-trade-parsing.js'
test('extractAgisoTradePayload preserves large integer order ids from raw json', () => {
const payload = extractAgisoTradePayload({
json: '{"biz_order_id":4502259793206016214,"item_id":978102431355,"order_status":4}',
})
assert.equal(payload.biz_order_id, '4502259793206016214')
assert.equal(typeof payload.biz_order_id, 'string')
})
test('resolveAgisoTradePlatformOrderId keeps exact webhook order id', () => {
const payload = extractAgisoTradePayload({
json: '{"biz_order_id":4502259793206016214,"item_id":978102431355,"order_status":4}',
})
assert.equal(resolveAgisoTradePlatformOrderId(payload), '4502259793206016214')
})
test('resolveAgisoTradePlatformOrderId falls back to tid when biz_order_id is absent', () => {
assert.equal(
resolveAgisoTradePlatformOrderId({
tid: '4502251008019011224',
orders: [{ oid: '4502251008019011888' }],
}),
'4502251008019011224',
)
})
@@ -1,73 +0,0 @@
import { parseJsonObject } from '../../utils/json.js'
type JsonObject = Record<string, unknown>
export function extractAgisoTradePayload(body: unknown): JsonObject {
const normalizedBody = normalizeRecord(body)
const rawJson = String(normalizedBody.json || normalizedBody.JSON || '').trim()
if (rawJson) {
return normalizeRecord(parseJsonObject(rawJson, { preserveLargeIntegers: true }))
}
return normalizedBody
}
export function resolveAgisoTradePlatformOrderId(payload: unknown): string {
const normalizedPayload = normalizeRecord(payload)
const firstItem = extractAgisoTradeOrderItemSources(normalizedPayload)[0] || {}
return pickFirstNonEmpty([
normalizedPayload.biz_order_id,
normalizedPayload.Tid,
normalizedPayload.tid,
normalizedPayload.Oid,
normalizedPayload.oid,
normalizedPayload.order_id,
normalizedPayload.orderId,
firstItem.Oid,
firstItem.oid,
])
}
export function extractAgisoTradeOrderItemSources(payload: unknown): JsonObject[] {
const normalizedPayload = normalizeRecord(payload)
const candidates = [
normalizedPayload.items,
normalizedPayload.Items,
normalizedPayload.orders,
normalizedPayload.Orders,
normalizedPayload.order_list,
normalizedPayload.OrderList,
]
for (const current of candidates) {
if (Array.isArray(current) && current.length > 0) {
return current.map((item) => normalizeRecord(item)).filter((item) => Object.keys(item).length > 0)
}
}
if (Object.keys(normalizedPayload).length > 0) {
return [normalizedPayload]
}
return []
}
function normalizeRecord(value: unknown): JsonObject {
return value && typeof value === 'object' && !Array.isArray(value) ? value as JsonObject : {}
}
function pickFirstNonEmpty(values: unknown[]): string {
for (const value of values) {
if (typeof value === 'string' && value.trim()) {
return value.trim()
}
if (typeof value === 'number' && Number.isFinite(value)) {
return String(value)
}
}
return ''
}
@@ -1,52 +0,0 @@
import test from 'node:test'
import assert from 'node:assert/strict'
import {
filterLegacyOrderFulfillmentBindings,
isKuaishouCloudFulfillmentBinding,
} from './fulfillment-binding-config-service.js'
test('isKuaishouCloudFulfillmentBinding only marks 91kaquan kuaishou rules as cloud rules', () => {
assert.equal(
isKuaishouCloudFulfillmentBinding({
provider: '91kaquan',
platform: 'kuaishou',
}),
true,
)
assert.equal(
isKuaishouCloudFulfillmentBinding({
provider: 'agiso',
platform: 'xianyu',
}),
false,
)
})
test('filterLegacyOrderFulfillmentBindings keeps agiso rules and removes 91kaquan kuaishou rules', () => {
assert.deepEqual(
filterLegacyOrderFulfillmentBindings([
{
provider: 'agiso',
platform: 'xianyu',
shopId: 'shop-1',
skuCode: 'sku-a',
},
{
provider: '91kaquan',
platform: 'kuaishou',
shopId: '91kaquan',
skuCode: 'sku-b',
},
]),
[
{
provider: 'agiso',
platform: 'xianyu',
shopId: 'shop-1',
skuCode: 'sku-a',
},
],
)
})
@@ -1,129 +0,0 @@
import path from 'node:path'
import { PROJECT_ROOT } from '../../config/runtime.js'
import { readJsonFile, writeJsonFile } from '../../utils/json-file-store.js'
const ORDER_FULFILLMENT_BINDINGS_FILE_PATH = path.join(PROJECT_ROOT, 'data', 'order-fulfillment-bindings.json')
type JsonObject = Record<string, any>
export function getOrderFulfillmentBindingsFilePath() {
return ORDER_FULFILLMENT_BINDINGS_FILE_PATH
}
export function getOrderFulfillmentBindingConfigs() {
return loadOrderFulfillmentBindingConfigsFromFile()
}
export function getLegacyOrderFulfillmentBindingConfigs() {
return filterLegacyOrderFulfillmentBindings(getOrderFulfillmentBindingConfigs())
}
export function saveOrderFulfillmentBindingConfigs(rawValue) {
return writeJsonFile(
ORDER_FULFILLMENT_BINDINGS_FILE_PATH,
rawValue,
normalizeOrderFulfillmentBindingConfigs,
)
}
function loadOrderFulfillmentBindingConfigsFromFile() {
return readJsonFile(
ORDER_FULFILLMENT_BINDINGS_FILE_PATH,
[],
normalizeOrderFulfillmentBindingConfigs,
)
}
function normalizeOrderFulfillmentBindingConfigs(rawValue) {
if (!Array.isArray(rawValue)) {
return []
}
return rawValue
.map((item) => normalizeOrderFulfillmentBinding(item))
.filter(Boolean)
}
export function filterLegacyOrderFulfillmentBindings(bindings: any[] = []) {
return (Array.isArray(bindings) ? bindings : []).filter((item) => !isKuaishouCloudFulfillmentBinding(item))
}
export function isKuaishouCloudFulfillmentBinding(binding: JsonObject = {}) {
return String(binding?.provider || '').trim() === '91kaquan'
&& String(binding?.platform || '').trim() === 'kuaishou'
}
function normalizeOrderFulfillmentBinding(rawValue) {
if (!isPlainObject(rawValue)) {
return null
}
const provider = String(rawValue.provider || 'agiso').trim() || 'agiso'
const platform = String(rawValue.platform || '').trim()
const shopId = String(rawValue.shopId || '').trim()
const shopName = String(rawValue.shopName || '').trim()
const skuCode = String(rawValue.skuCode || '').trim()
const skuName = String(rawValue.skuName || '').trim()
const profileKey = String(rawValue.profileKey || '').trim() || 'manual_review'
const priority = normalizePriority(rawValue.priority)
const enabled = rawValue.enabled !== false
const config = normalizeJsonObject(rawValue.config)
const match = normalizeOrderFulfillmentMatch(rawValue.match)
if (!skuCode || !match) {
return null
}
return {
provider,
platform,
shopId,
shopName,
skuCode,
skuName,
profileKey,
enabled,
priority,
config,
match,
}
}
function normalizeOrderFulfillmentMatch(rawValue) {
if (!isPlainObject(rawValue)) {
return null
}
const externalSkuCode = String(rawValue.externalSkuCode || '').trim()
const externalItemId = String(rawValue.externalItemId || '').trim()
const externalSkuName = String(rawValue.externalSkuName || '').trim()
if (!externalSkuCode && !externalItemId && !externalSkuName) {
return null
}
return {
externalSkuCode,
externalItemId,
externalSkuName,
config: normalizeJsonObject(rawValue.config),
}
}
function normalizePriority(value) {
const parsed = Number(value)
if (!Number.isFinite(parsed)) {
return 100
}
return Math.max(1, Math.round(parsed))
}
function normalizeJsonObject(value) {
return isPlainObject(value) ? value : {}
}
function isPlainObject(value) {
return Object.prototype.toString.call(value) === '[object Object]'
}
@@ -1,387 +0,0 @@
import test from 'node:test'
import assert from 'node:assert/strict'
import crypto from 'node:crypto'
import { runtimeConfig } from '../../config/runtime.js'
import {
buildWebhookEventInput,
executeAgisoTradeWebhookWithDeps,
parseAgisoTradeRequest,
resolveAgisoTradeIgnoreReason,
shouldEnrichAgisoXianyuTrade,
verifyAgisoSignatureWithSecret,
} from './webhook-service.js'
function createParsedTrade(patch = {}) {
return {
provider: 'agiso',
platform: 'xianyu',
platformRaw: 'xianyu',
eventType: 'payment_success',
eventKey: 'agiso:xianyu:shop-9:4502259793206016214:payment_success:1713097840',
signatureValid: true,
platformOrderId: '4502259793206016214',
shopId: 'shop-9',
shopName: '咸鱼店铺',
shopIdAliases: ['shop-9'],
orderStatus: 'paid',
payStatus: 'paid',
buyerId: 'buyer-1',
buyerName: '测试买家',
receiverContact: '',
totalAmount: 2550,
currency: 'CNY',
paidAt: '2026-04-14T12:30:40.000Z',
rawPayload: {},
items: [
{
skuCode: 'dnf-cdk-a',
skuName: 'DNF礼包',
quantity: 1,
spec: { title: 'DNF礼包' },
},
],
...patch,
}
}
test('parseAgisoTradeRequest maps Agiso payment_success webhook into stable order fields', () => {
const rawJson = '{"biz_order_id":4502259793206016214,"seller_id":"shop-9","seller_nick":"咸鱼店铺","buyer_id":"buyer-1","buyer_name":"测试买家","pay_time":"2026-04-14 20:30:40","total_fee":"25.50","currency":"CNY","items":[{"item_id":978102431355,"goods_name":"DNF礼包","quantity":2,"sku":"dnf-cdk-a|商品名称:DNF礼包"}]}'
const timestamp = '1713097840'
const appSecret = String(runtimeConfig.platforms?.agiso?.appSecret || '').trim()
const sign = appSecret
? crypto.createHash('md5').update(`${appSecret}json${rawJson}timestamp${timestamp}${appSecret}`, 'utf8').digest('hex')
: ''
const parsed = parseAgisoTradeRequest({
query: {
aopic: '1',
timestamp,
sign,
},
body: {
json: rawJson,
fromPlatform: 'xianyu',
},
})
assert.equal(parsed.provider, 'agiso')
assert.equal(parsed.platform, 'xianyu')
assert.equal(parsed.eventType, 'payment_success')
assert.equal(parsed.signatureValid, true)
assert.equal(parsed.platformOrderId, '4502259793206016214')
assert.equal(parsed.shopId, 'shop-9')
assert.equal(parsed.shopName, '咸鱼店铺')
assert.equal(parsed.payStatus, 'paid')
assert.equal(parsed.orderStatus, 'paid')
assert.equal(parsed.totalAmount, 2550)
assert.equal(parsed.items.length, 1)
assert.equal(parsed.items[0]?.skuCode, 'dnf-cdk-a')
assert.equal(parsed.items[0]?.quantity, 2)
})
test('shouldEnrichAgisoXianyuTrade only enriches when xianyu trade fields are incomplete', () => {
assert.equal(shouldEnrichAgisoXianyuTrade({
provider: 'agiso',
platform: 'xianyu',
totalAmount: 0,
buyerName: '',
shopName: '',
items: [],
}), true)
assert.equal(shouldEnrichAgisoXianyuTrade({
provider: 'agiso',
platform: 'xianyu',
totalAmount: 2550,
buyerName: '测试买家',
shopName: '咸鱼店铺',
items: [
{
skuCode: 'dnf-cdk-a',
spec: {
title: 'DNF礼包',
},
},
],
}), false)
assert.equal(shouldEnrichAgisoXianyuTrade({
provider: 'agiso',
platform: 'taobao',
totalAmount: 0,
items: [],
}), false)
})
test('parseAgisoTradeRequest maps buyer confirm goods webhook into ignored event type', () => {
const rawJson = '{"biz_order_id":2701836516013026052,"seller_id":"shop-9","seller_nick":"大锤商行","buyer_id":"buyer-1","buyer_name":"测试买家","order_status":4,"Status":"TRADE_FINISHED","total_fee":"95.88","items":[{"item_id":978102431355,"goods_name":"DNF礼包","quantity":1,"sku":"dnf-cdk-a|商品名称:DNF礼包"}]}'
const timestamp = '1776096226'
const appSecret = String(runtimeConfig.platforms?.agiso?.appSecret || '').trim()
const sign = appSecret
? crypto.createHash('md5').update(`${appSecret}json${rawJson}timestamp${timestamp}${appSecret}`, 'utf8').digest('hex')
: ''
const parsed = parseAgisoTradeRequest({
query: {
aopic: '16',
timestamp,
sign,
},
body: {
json: rawJson,
fromPlatform: 'AldsIdle',
},
})
assert.equal(parsed.platform, 'xianyu')
assert.equal(parsed.eventType, 'buyer_confirm_goods')
assert.equal(resolveAgisoTradeIgnoreReason(parsed), 'buyer_confirm_goods')
})
test('trade finished payload is still treated as buyer confirm goods when aopic drifts', () => {
const parsed = parseAgisoTradeRequest({
query: {
aopic: '1',
timestamp: '1776096226',
sign: '',
},
body: {
json: '{"biz_order_id":2701836516013026052,"order_status":4,"Status":"TRADE_FINISHED"}',
fromPlatform: 'xianyu',
},
})
assert.equal(parsed.eventType, 'buyer_confirm_goods')
assert.equal(resolveAgisoTradeIgnoreReason(parsed), 'buyer_confirm_goods')
})
test('verifyAgisoSignatureWithSecret supports documented and legacy signatures', () => {
const appSecret = 'unit-test-secret'
const rawJson = '{"biz_order_id":"4502259793206016214"}'
const timestamp = '1713097840'
const documentedSign = crypto
.createHash('md5')
.update(`${appSecret}json${rawJson}timestamp${timestamp}${appSecret}`, 'utf8')
.digest('hex')
const legacySign = crypto
.createHash('md5')
.update(`${appSecret}${rawJson}${timestamp}`, 'utf8')
.digest('hex')
assert.equal(
verifyAgisoSignatureWithSecret({ rawJson, timestamp, sign: documentedSign, appSecret }),
true,
)
assert.equal(
verifyAgisoSignatureWithSecret({ rawJson, timestamp, sign: legacySign, appSecret }),
true,
)
assert.equal(
verifyAgisoSignatureWithSecret({ rawJson, timestamp, sign: 'bad-sign', appSecret }),
false,
)
})
test('buildWebhookEventInput preserves raw request payload for later replay', () => {
const input = buildWebhookEventInput(
{
headers: { 'x-request-id': 'req-1' },
query: { timestamp: '1713097840', sign: 'bad-sign' },
body: { json: '{"biz_order_id":"4502259793206016214"}' },
},
{
provider: 'agiso',
platform: 'xianyu',
shopId: 'shop-9',
shopName: '咸鱼店铺',
eventType: 'payment_success',
eventKey: 'agiso:xianyu:4502259793206016214:payment_success',
signatureValid: false,
},
)
assert.equal(input.provider, 'agiso')
assert.equal(input.platform, 'xianyu')
assert.equal(input.eventType, 'payment_success')
assert.equal(input.eventKey, 'agiso:xianyu:4502259793206016214:payment_success')
assert.equal(input.signatureValid, false)
assert.equal(input.processed, false)
assert.equal(input.processError, '')
assert.equal(input.relatedOrderId, null)
assert.deepEqual(JSON.parse(input.headersJson), { 'x-request-id': 'req-1' })
assert.deepEqual(JSON.parse(input.queryJson), { timestamp: '1713097840', sign: 'bad-sign' })
assert.deepEqual(JSON.parse(input.bodyJson), { json: '{"biz_order_id":"4502259793206016214"}' })
assert.match(input.createdAt, /^\d{4}-\d{2}-\d{2}T/)
})
test('executeAgisoTradeWebhookWithDeps records invalid signature failure', async () => {
const updates = []
await assert.rejects(
() => executeAgisoTradeWebhookWithDeps(
createParsedTrade({ signatureValid: false }),
701,
{ requestId: 'req-invalid-signature' },
{
updateWebhookEvent: async (webhookEventId, patch) => {
updates.push({ webhookEventId, patch })
return { id: webhookEventId, ...patch }
},
hasConfiguredOrderItems: async () => {
throw new Error('should not check configured items when signature is invalid')
},
},
),
/验签失败/,
)
assert.deepEqual(updates, [
{
webhookEventId: 701,
patch: {
processed: false,
process_error: '验签失败',
related_order_id: null,
},
},
])
})
test('executeAgisoTradeWebhookWithDeps ignores unconfigured products before upsert', async () => {
const updates = []
let upsertCalled = false
const result = await executeAgisoTradeWebhookWithDeps(
createParsedTrade(),
702,
{ requestId: 'req-unconfigured' },
{
hasConfiguredOrderItems: async (input) => {
assert.equal(input.provider, 'agiso')
assert.equal(input.platform, 'xianyu')
assert.equal(input.shopId, 'shop-9')
assert.equal(input.items.length, 1)
return false
},
updateWebhookEvent: async (webhookEventId, patch) => {
updates.push({ webhookEventId, patch })
return { id: webhookEventId, ...patch }
},
upsertOrderFromWebhook: async () => {
upsertCalled = true
return {}
},
},
)
assert.equal(result.accepted, true)
assert.equal(result.ignored, true)
assert.equal(result.ignoreReason, 'unconfigured_product')
assert.equal(result.orderId, null)
assert.equal(upsertCalled, false)
assert.deepEqual(updates, [
{
webhookEventId: 702,
patch: {
processed: true,
process_error: 'ignored_unconfigured_product',
related_order_id: null,
},
},
])
})
test('executeAgisoTradeWebhookWithDeps enriches incomplete xianyu trade and upserts order', async () => {
const updates = []
const enrichCalls = []
const upsertCalls = []
const parsed = createParsedTrade({
totalAmount: 0,
buyerName: '',
shopName: '',
items: [
{
skuCode: 'dnf-cdk-a',
skuName: 'dnf-cdk-a',
quantity: 1,
spec: {},
},
],
})
const result = await executeAgisoTradeWebhookWithDeps(
parsed,
703,
{ requestId: 'req-success' },
{
hasConfiguredOrderItems: async () => true,
enrichAgisoXianyuTradeOrder: async (input, options) => {
enrichCalls.push({ input, options })
return {
parsed: {
...input,
totalAmount: 1990,
buyerName: '补查买家',
shopName: '补查店铺',
items: [
{
skuCode: 'dnf-cdk-a',
skuName: 'DNF礼包',
quantity: 1,
spec: { title: 'DNF礼包' },
},
],
},
enriched: true,
reason: '',
}
},
upsertOrderFromWebhook: async (input) => {
upsertCalls.push(input)
return {
ignored: false,
ignoreReason: '',
order: { id: 801 },
tasks: [
{ id: 901, task_no: 'DT901', task_status: 'paid' },
],
messageDeliveries: [{ id: 1001 }],
}
},
updateWebhookEvent: async (webhookEventId, patch) => {
updates.push({ webhookEventId, patch })
return { id: webhookEventId, ...patch }
},
},
)
assert.equal(enrichCalls.length, 1)
assert.equal(enrichCalls[0].options.requestId, 'req-success')
assert.equal(upsertCalls.length, 1)
assert.equal(upsertCalls[0].totalAmount, 1990)
assert.equal(upsertCalls[0].buyerName, '补查买家')
assert.equal(result.accepted, true)
assert.equal(result.ignored, false)
assert.equal(result.enriched, true)
assert.equal(result.orderId, 801)
assert.equal(result.totalAmountFen, 1990)
assert.equal(result.taskCount, 1)
assert.deepEqual(result.tasks, [
{
taskId: 901,
taskNo: 'DT901',
status: 'paid',
},
])
assert.deepEqual(updates, [
{
webhookEventId: 703,
patch: {
processed: true,
process_error: '',
related_order_id: 801,
},
},
])
})
File diff suppressed because it is too large Load Diff
@@ -1,9 +0,0 @@
## Agiso Platform Services
Keep Agiso-wide shared helpers in this directory.
Per-platform integrations live in subdirectories:
- `xianyu/`
- `pdd/`
- `taobao/`
@@ -1,3 +0,0 @@
## PDD
Put all Agiso PDD-specific service integrations here.
@@ -1,181 +0,0 @@
import fs from 'node:fs'
import path from 'node:path'
import { PROJECT_ROOT, runtimeConfig } from '../../../config/runtime.js'
const AGISO_SHOPS_FILE_PATH = path.join(PROJECT_ROOT, 'data', 'agiso-shops.json')
const AGISO_MESSAGING_DEFAULT_KEYS = ['messageTemplate', 'autoDeliveryMessageTemplate']
const AGISO_SHOP_CONFIG_KEYS = [
'shopName',
'accessToken',
'messageTemplate',
'autoDeliveryMessageTemplate',
'appSecret',
'apiVersion',
'sendMessageEndpoint',
'tradeDetailEndpoint',
'tradeDetailApiVersion',
'tradeDetailTimeoutMs',
]
type JsonObject = Record<string, unknown>
export type AgisoMessagingDefaults = {
messageTemplate?: string
autoDeliveryMessageTemplate?: string
}
export type AgisoShopConfig = {
enabled?: boolean
shopName?: string
accessToken?: string
messageTemplate?: string
autoDeliveryMessageTemplate?: string
appSecret?: string
apiVersion?: string
sendMessageEndpoint?: string
tradeDetailEndpoint?: string
tradeDetailApiVersion?: string
tradeDetailTimeoutMs?: string
}
export type AgisoMessagingConfigDocument = {
defaults: AgisoMessagingDefaults
shops: Record<string, AgisoShopConfig>
}
export function getAgisoShopsFilePath(): string {
return AGISO_SHOPS_FILE_PATH
}
export function getAgisoShopConfigMap(): Record<string, AgisoShopConfig> {
const envConfig = normalizeAgisoShopConfigMap(runtimeConfig.platforms?.agiso?.messaging?.shops || {})
const fileConfig = loadAgisoMessagingConfigDocumentFromFile()
return {
...envConfig,
...fileConfig.shops,
}
}
export function getAgisoShopConfig(shopId: unknown): AgisoShopConfig | null {
const normalizedShopId = String(shopId || '').trim()
if (!normalizedShopId) {
return null
}
return getAgisoShopConfigMap()[normalizedShopId] || null
}
export function getAgisoMessagingDefaults(): AgisoMessagingDefaults {
return loadAgisoMessagingConfigDocumentFromFile().defaults
}
export function saveAgisoMessagingConfig(rawValue: unknown): AgisoMessagingConfigDocument {
const normalized = normalizeAgisoMessagingConfigDocument(rawValue)
fs.mkdirSync(path.dirname(AGISO_SHOPS_FILE_PATH), { recursive: true })
fs.writeFileSync(AGISO_SHOPS_FILE_PATH, `${JSON.stringify(normalized, null, 2)}\n`, 'utf8')
return normalized
}
function loadAgisoMessagingConfigDocumentFromFile(): AgisoMessagingConfigDocument {
if (!fs.existsSync(AGISO_SHOPS_FILE_PATH)) {
return {
defaults: {},
shops: {},
}
}
try {
const rawText = fs.readFileSync(AGISO_SHOPS_FILE_PATH, 'utf8')
const parsed = JSON.parse(rawText)
return normalizeAgisoMessagingConfigDocument(parsed)
} catch {
return {
defaults: {},
shops: {},
}
}
}
function normalizeAgisoShopConfigMap(rawValue: unknown): Record<string, AgisoShopConfig> {
const output: Record<string, AgisoShopConfig> = {}
const entries = isPlainObject(rawValue) ? Object.entries(rawValue) : []
for (const [shopId, config] of entries) {
const normalizedShopId = String(shopId || '').trim()
if (!normalizedShopId || !isPlainObject(config)) {
continue
}
const next: AgisoShopConfig = {}
const enabled = normalizeBooleanLike(config.enabled)
if (enabled !== null) {
next.enabled = enabled
}
for (const key of AGISO_SHOP_CONFIG_KEYS) {
const value = String(config[key] || '').trim()
if (value) {
next[key] = value
}
}
output[normalizedShopId] = next
}
return output
}
function normalizeAgisoMessagingDefaults(rawValue: unknown): AgisoMessagingDefaults {
const output: AgisoMessagingDefaults = {}
if (!isPlainObject(rawValue)) {
return output
}
for (const key of AGISO_MESSAGING_DEFAULT_KEYS) {
const value = String(rawValue[key] || '').trim()
if (value) {
output[key] = value
}
}
return output
}
function normalizeAgisoMessagingConfigDocument(rawValue: unknown): AgisoMessagingConfigDocument {
const normalizedValue = isPlainObject(rawValue) ? rawValue : {}
const hasStructuredShape = Object.prototype.hasOwnProperty.call(normalizedValue, 'defaults')
|| Object.prototype.hasOwnProperty.call(normalizedValue, 'shops')
return {
defaults: normalizeAgisoMessagingDefaults(hasStructuredShape ? normalizedValue.defaults : {}),
shops: normalizeAgisoShopConfigMap(hasStructuredShape ? normalizedValue.shops : normalizedValue),
}
}
function normalizeBooleanLike(value: unknown): boolean | null {
if (typeof value === 'boolean') {
return value
}
const normalized = String(value || '').trim().toLowerCase()
if (!normalized) {
return null
}
if (['1', 'true', 'yes', 'on'].includes(normalized)) {
return true
}
if (['0', 'false', 'no', 'off'].includes(normalized)) {
return false
}
return null
}
function isPlainObject(value: unknown): value is JsonObject {
return Object.prototype.toString.call(value) === '[object Object]'
}
@@ -1,3 +0,0 @@
## Taobao
Put all Agiso Taobao-specific service integrations here.
@@ -1,3 +0,0 @@
## Xianyu
Put all Agiso Xianyu-specific service integrations here.
@@ -1,264 +0,0 @@
import test from 'node:test'
import assert from 'node:assert/strict'
import { runtimeConfig } from '../../../../config/runtime.js'
import {
hasAgisoAutoDeliverySucceeded,
ensureAgisoXianyuAutoDeliveryForDeliveredTaskWithDeps,
isOrderReadyForAgisoAutoDelivery,
isAgisoAutoDeliverySuccess,
resolveAgisoAutoDeliveryEndpoint,
resolveAgisoAutoDeliveryErrorMessage,
} from './auto-delivery-service.js'
const agisoOrder = {
id: 101,
provider: 'agiso',
platform: 'xianyu',
shop_id: 'shop-auto-delivery-test',
shop_name: '自动发货测试店',
platform_order_id: 'P-AUTO-10001',
}
function createDeliveredTask(patch = {}) {
return {
id: 201,
order_id: agisoOrder.id,
task_status: 'completed',
delivery_status: 'delivered',
context_json: '{}',
...patch,
}
}
test('isOrderReadyForAgisoAutoDelivery requires every task to be delivered', () => {
assert.equal(isOrderReadyForAgisoAutoDelivery([]), false)
assert.equal(isOrderReadyForAgisoAutoDelivery([
{ delivery_status: 'delivered' },
{ delivery_status: 'delivered' },
]), true)
assert.equal(isOrderReadyForAgisoAutoDelivery([
{ delivery_status: 'delivered' },
{ delivery_status: 'processing' },
]), false)
})
test('hasAgisoAutoDeliverySucceeded detects successful context on any task', () => {
assert.equal(hasAgisoAutoDeliverySucceeded([
{ context_json: {} },
{ context_json: { agisoAutoDelivery: { status: 'success' } } },
]), true)
assert.equal(hasAgisoAutoDeliverySucceeded([
{ context_json: { agisoAutoDelivery: { status: 'failed' } } },
]), false)
})
test('hasAgisoAutoDeliverySucceeded handles string and invalid task context payloads', () => {
assert.equal(hasAgisoAutoDeliverySucceeded([
{ context_json: '{bad-json' },
{ context_json: JSON.stringify({ agisoAutoDelivery: { status: 'success' } }) },
]), true)
assert.equal(hasAgisoAutoDeliverySucceeded([
{ context_json: '{bad-json' },
{ context_json: JSON.stringify({ agisoAutoDelivery: { status: 'skipped' } }) },
]), false)
})
test('isAgisoAutoDeliverySuccess accepts empty successful payloads and explicit success codes', () => {
assert.equal(isAgisoAutoDeliverySuccess(200, {}), true)
assert.equal(isAgisoAutoDeliverySuccess(200, { IsSuccess: true }), true)
assert.equal(isAgisoAutoDeliverySuccess(200, { Error_Code: 0 }), true)
assert.equal(isAgisoAutoDeliverySuccess(500, { IsSuccess: true }), false)
assert.equal(isAgisoAutoDeliverySuccess(200, { Error_Code: 500 }), false)
})
test('resolveAgisoAutoDeliveryErrorMessage prefers structured payload message before raw text', () => {
assert.equal(
resolveAgisoAutoDeliveryErrorMessage(
{ Error_Msg: '库存不足' },
'raw body',
400,
),
'库存不足',
)
assert.equal(
resolveAgisoAutoDeliveryErrorMessage(
{},
'fallback raw body',
400,
),
'fallback raw body',
)
})
test('resolveAgisoAutoDeliveryEndpoint falls back to DummySend', () => {
assert.equal(
resolveAgisoAutoDeliveryEndpoint(''),
'https://gw-api.agiso.com/aldsIdle/Order/DummySend',
)
assert.equal(
resolveAgisoAutoDeliveryEndpoint('https://gw-api.agiso.com/aldsIdle/Order/DummySend'),
'https://gw-api.agiso.com/aldsIdle/Order/DummySend',
)
})
test('ensureAgisoXianyuAutoDeliveryForDeliveredTaskWithDeps skips while other order tasks are not delivered', async () => {
const calls = {
update: 0,
fetch: 0,
}
const result = await ensureAgisoXianyuAutoDeliveryForDeliveredTaskWithDeps({
order: agisoOrder,
task: createDeliveredTask(),
}, {
listTasksByOrderId: async () => [
createDeliveredTask(),
createDeliveredTask({ id: 202, delivery_status: 'processing' }),
],
updateTask: async () => {
calls.update += 1
return null
},
fetch: async () => {
calls.fetch += 1
return { status: 200, text: async () => '{}' }
},
})
assert.equal(result.sent, false)
assert.equal(result.skipped, true)
assert.equal(result.reason, 'waiting_other_tasks')
assert.equal(calls.update, 0)
assert.equal(calls.fetch, 0)
})
test('ensureAgisoXianyuAutoDeliveryForDeliveredTaskWithDeps persists missing config as skipped', async () => {
const originalAppSecret = runtimeConfig.platforms.agiso.appSecret
const originalShops = runtimeConfig.platforms.agiso.messaging.shops
const updates = []
const events = []
runtimeConfig.platforms.agiso.appSecret = ''
runtimeConfig.platforms.agiso.messaging.shops = {}
try {
const task = createDeliveredTask({ context_json: JSON.stringify({ existing: true }) })
const result = await ensureAgisoXianyuAutoDeliveryForDeliveredTaskWithDeps({
order: {
...agisoOrder,
shop_id: 'missing-auto-delivery-config-shop',
},
task,
trigger: 'unit_test',
}, {
listTasksByOrderId: async () => [task],
updateTask: async (taskId, patch) => {
updates.push({ taskId, patch })
return { ...task, ...patch }
},
createTaskEvent: async (taskId, eventType, payload, createdAt) => {
events.push({ taskId, eventType, payload, createdAt })
return { id: 1 }
},
fetch: async () => {
throw new Error('fetch should not be called without config')
},
nowIso: () => '2026-05-21T12:00:00.000Z',
})
assert.equal(result.sent, false)
assert.equal(result.skipped, true)
assert.equal(result.reason, 'missing_config')
assert.equal(updates.length, 1)
assert.equal(events[0]?.eventType, 'agiso_auto_delivery_skipped')
const context = JSON.parse(updates[0].patch.context_json)
assert.equal(context.existing, true)
assert.equal(context.agisoAutoDelivery.status, 'skipped')
assert.equal(context.agisoAutoDelivery.reason, 'missing_config')
assert.deepEqual({
hasEndpoint: context.agisoAutoDelivery.hasEndpoint,
hasAccessToken: context.agisoAutoDelivery.hasAccessToken,
hasAppSecret: context.agisoAutoDelivery.hasAppSecret,
}, {
hasEndpoint: true,
hasAccessToken: false,
hasAppSecret: false,
})
} finally {
runtimeConfig.platforms.agiso.appSecret = originalAppSecret
runtimeConfig.platforms.agiso.messaging.shops = originalShops
}
})
test('ensureAgisoXianyuAutoDeliveryForDeliveredTaskWithDeps fails when accepted delivery is not confirmed as shipped', async () => {
const originalAppSecret = runtimeConfig.platforms.agiso.appSecret
const originalShops = runtimeConfig.platforms.agiso.messaging.shops
const updates = []
const events = []
const messages = []
runtimeConfig.platforms.agiso.appSecret = 'runtime-secret'
runtimeConfig.platforms.agiso.messaging.shops = {
[agisoOrder.shop_id]: {
accessToken: 'access-token',
appSecret: 'shop-secret',
apiVersion: '1',
},
}
try {
const task = createDeliveredTask()
const result = await ensureAgisoXianyuAutoDeliveryForDeliveredTaskWithDeps({
order: agisoOrder,
task,
trigger: 'unit_test',
}, {
listTasksByOrderId: async () => [task],
fetch: async () => ({
status: 200,
text: async () => JSON.stringify({ IsSuccess: true, RequestId: 'req-1' }),
}),
confirmAgisoXianyuAutoDeliveryShipped: async (input) => ({
shipped: false,
orderStatus: 2,
shipTime: 0,
reason: `not shipped: ${input.requestId}`,
}),
updateTask: async (taskId, patch) => {
updates.push({ taskId, patch })
return { ...task, ...patch }
},
createTaskEvent: async (taskId, eventType, payload, createdAt) => {
events.push({ taskId, eventType, payload, createdAt })
return { id: 2 }
},
ensureAgisoXianyuAutoDeliveryMessageDeliveredForTask: async (payload) => {
messages.push(payload)
return { sent: true }
},
nowIso: () => '2026-05-21T12:01:00.000Z',
})
assert.equal(result.sent, false)
assert.equal(result.skipped, false)
assert.equal(result.reason, 'delivery_not_confirmed')
assert.equal(messages.length, 0)
assert.equal(events[0]?.eventType, 'agiso_auto_delivery_failed')
const context = JSON.parse(updates[0].patch.context_json)
assert.equal(context.agisoAutoDelivery.status, 'failed')
assert.equal(context.agisoAutoDelivery.reason, 'delivery_not_confirmed')
assert.equal(context.agisoAutoDelivery.requestId, 'req-1')
assert.equal(context.agisoAutoDelivery.confirmOrderStatus, 2)
assert.match(context.agisoAutoDelivery.errorMessage, /订单仍未进入已发货/)
} finally {
runtimeConfig.platforms.agiso.appSecret = originalAppSecret
runtimeConfig.platforms.agiso.messaging.shops = originalShops
}
})
@@ -1,640 +0,0 @@
import crypto from 'node:crypto'
import { runtimeConfig } from '../../../../config/runtime.js'
import { listTasksByOrderId, updateTask } from '../../../../repositories/task-repo.js'
import { createTaskEvent } from '../../../../repositories/task-event-repo.js'
import { getAgisoShopConfig } from '../shop-config-service.js'
import { queryAgisoXianyuOrderDetail } from './order-detail-service.js'
import { ensureAgisoXianyuAutoDeliveryMessageDeliveredForTask } from './message-service.js'
import { parseJsonObject } from '../../../../utils/json.js'
import { logWebhook } from '../../../../utils/logger.js'
import { nowIso } from '../../../../utils/time.js'
import type { TaskRow } from '../../../../types/repository-rows.js'
const AGISO_DUMMY_SEND_ENDPOINT = 'https://gw-api.agiso.com/aldsIdle/Order/DummySend'
type JsonObject = Record<string, unknown>
type AutoDeliveryOrder = {
id?: number | string
provider?: string
platform?: string
shop_id?: string
shop_name?: string
platform_order_id?: string
[key: string]: unknown
}
type SupportedAgisoOrder = AutoDeliveryOrder & {
id: number | string
provider: 'agiso'
platform: 'xianyu'
platform_order_id: string
}
type AutoDeliveryTask = Partial<TaskRow> & {
id?: number | string
delivery_status?: string
task_status?: string
context_json?: string | JsonObject | null
[key: string]: unknown
}
type AutoDeliveryTaskWithId = AutoDeliveryTask & {
id: number | string
}
type EnsureAgisoXianyuAutoDeliveryInput = {
order?: AutoDeliveryOrder | null
task?: AutoDeliveryTask | null
trigger?: string
}
type AutoDeliveryStatus = 'success' | 'failed' | 'skipped'
type FetchResponseLike = {
status: number
text: () => Promise<string>
}
type FetchLike = (
input: string | URL,
init?: RequestInit,
) => Promise<FetchResponseLike>
type AgisoAutoDeliveryConfirmInput = {
shopId?: string
platformOrderId?: string
requestId?: string
}
type AgisoAutoDeliveryConfirmResult = {
shipped: boolean
orderStatus: number
shipTime: number
reason: string
}
type AutoDeliveryMessageResult = {
sent: boolean
skipped?: boolean
reason?: string
[key: string]: unknown
}
type EnsureAgisoXianyuAutoDeliveryDeps = {
listTasksByOrderId?: (orderId: number | string) => Promise<AutoDeliveryTask[]>
fetch?: FetchLike
confirmAgisoXianyuAutoDeliveryShipped?: (
input: AgisoAutoDeliveryConfirmInput,
) => Promise<AgisoAutoDeliveryConfirmResult>
updateTask?: (
taskId: number | string,
patch: { context_json: string; updated_at: string },
) => Promise<AutoDeliveryTask | null>
createTaskEvent?: (
taskId: number | string,
eventType: string,
payload?: unknown,
createdAt?: string,
) => Promise<unknown>
nowIso?: () => string
ensureAgisoXianyuAutoDeliveryMessageDeliveredForTask?: (
input: { order?: AutoDeliveryOrder | null; task?: AutoDeliveryTask | null },
) => Promise<AutoDeliveryMessageResult>
}
type PersistAgisoAutoDeliveryResultInput = {
status: AutoDeliveryStatus
trigger?: string
reason?: string
order?: AutoDeliveryOrder | null
responseStatus?: number
response?: unknown
errorMessage?: string
detail?: JsonObject
}
type AgisoAutoDeliveryConfig = {
enabled: boolean
endpoint: string
apiVersion: string
appSecret: string
accessToken: string
}
export async function ensureAgisoXianyuAutoDeliveryForDeliveredTask({
order,
task,
trigger = 'task_delivered',
}: EnsureAgisoXianyuAutoDeliveryInput = {}) {
return ensureAgisoXianyuAutoDeliveryForDeliveredTaskWithDeps({ order, task, trigger })
}
export async function ensureAgisoXianyuAutoDeliveryForDeliveredTaskWithDeps({
order,
task,
trigger = 'task_delivered',
}: EnsureAgisoXianyuAutoDeliveryInput = {}, deps: EnsureAgisoXianyuAutoDeliveryDeps = {}) {
const listOrderTasks = deps.listTasksByOrderId || listTasksByOrderId
const sendRequest = deps.fetch || (fetch as FetchLike)
const confirmShipped = deps.confirmAgisoXianyuAutoDeliveryShipped || confirmAgisoXianyuAutoDeliveryShipped
if (!isAgisoXianyuOrder(order) || !task?.id) {
return { sent: false, skipped: true, reason: 'not_supported', task }
}
const currentTask = task as AutoDeliveryTaskWithId
if (String(currentTask.delivery_status || '').trim() !== 'delivered') {
return { sent: false, skipped: true, reason: 'task_not_delivered', task: currentTask }
}
const orderTasks = await listOrderTasks(order.id)
if (hasAgisoAutoDeliverySucceeded(orderTasks)) {
return { sent: false, skipped: true, reason: 'already_sent', task: currentTask }
}
if (!isOrderReadyForAgisoAutoDelivery(orderTasks)) {
logWebhook('[agiso/xianyu/auto-delivery]', '跳过自动发货:订单下仍有任务未完成交付', {
trigger,
orderId: order.id,
taskId: currentTask.id,
platformOrderId: order.platform_order_id,
taskStatuses: orderTasks.map((current) => ({
taskId: current.id,
taskStatus: current.task_status,
deliveryStatus: current.delivery_status,
})),
})
return { sent: false, skipped: true, reason: 'waiting_other_tasks', task: currentTask }
}
const config = resolveAgisoXianyuAutoDeliveryConfig(order)
if (!config.enabled) {
return persistAgisoAutoDeliveryResult(currentTask, {
status: 'skipped',
trigger,
reason: 'auto_delivery_disabled',
order,
}, deps)
}
if (!config.endpoint || !config.accessToken || !config.appSecret) {
return persistAgisoAutoDeliveryResult(currentTask, {
status: 'skipped',
trigger,
reason: 'missing_config',
order,
detail: {
hasEndpoint: Boolean(config.endpoint),
hasAccessToken: Boolean(config.accessToken),
hasAppSecret: Boolean(config.appSecret),
},
}, deps)
}
const requestHeaders = buildRequestHeaders({
accessToken: config.accessToken,
apiVersion: config.apiVersion,
})
const requestBody = buildRequestBody({
platformOrderId: String(order.platform_order_id || '').trim(),
appSecret: config.appSecret,
})
logWebhook('[agiso/xianyu/auto-delivery]', '开始执行 Agiso 咸鱼自动发货', {
trigger,
orderId: order.id,
taskId: currentTask.id,
shopId: order.shop_id,
platformOrderId: order.platform_order_id,
endpoint: config.endpoint,
})
try {
const response = await sendRequest(config.endpoint, {
method: 'POST',
headers: requestHeaders,
body: new URLSearchParams(requestBody).toString(),
})
const rawText = await response.text()
const parsed = parseJsonObject(rawText) as JsonObject
const success = isAgisoAutoDeliverySuccess(response.status, parsed)
if (success) {
const requestId = String(parsed?.RequestId || '').trim()
// 发货接口返回成功后,再查一次 Order/Detail 确认订单真的进入已发货状态。
const confirmResult = await confirmShipped({
shopId: order.shop_id,
platformOrderId: order.platform_order_id,
requestId,
})
if (!confirmResult.shipped) {
logWebhook('[agiso/xianyu/auto-delivery]', 'Agiso 咸鱼自动发货接口返回成功,但订单状态未确认进入已发货', {
trigger,
orderId: order.id,
taskId: currentTask.id,
shopId: order.shop_id,
platformOrderId: order.platform_order_id,
responseStatus: response.status,
requestId,
confirmOrderStatus: confirmResult.orderStatus,
confirmShipTime: confirmResult.shipTime,
confirmReason: confirmResult.reason,
}, { level: 'warn' })
return persistAgisoAutoDeliveryResult(currentTask, {
status: 'failed',
trigger,
reason: 'delivery_not_confirmed',
order,
responseStatus: response.status,
response: parsed,
errorMessage: `Agiso 发货状态更新接口已受理,但订单仍未进入已发货,请检查该订单是否允许无物流发货 (orderStatus=${confirmResult.orderStatus}, shipTime=${confirmResult.shipTime})`,
detail: {
requestId,
confirmOrderStatus: confirmResult.orderStatus,
confirmShipTime: confirmResult.shipTime,
},
}, deps)
}
logWebhook('[agiso/xianyu/auto-delivery]', 'Agiso 咸鱼自动发货成功,订单已确认进入已发货状态', {
trigger,
orderId: order.id,
taskId: currentTask.id,
shopId: order.shop_id,
platformOrderId: order.platform_order_id,
responseStatus: response.status,
requestId,
confirmOrderStatus: confirmResult.orderStatus,
confirmShipTime: confirmResult.shipTime,
})
const result = await persistAgisoAutoDeliveryResult(currentTask, {
status: 'success',
trigger,
reason: '',
order,
responseStatus: response.status,
response: parsed,
detail: {
requestId,
confirmOrderStatus: confirmResult.orderStatus,
confirmShipTime: confirmResult.shipTime,
},
}, deps)
// 发货确认成功后,发送自定义消息通知。
await sendAutoDeliveryMessage({ order, task: currentTask }, deps)
return result
}
const errorMessage = resolveAgisoAutoDeliveryErrorMessage(parsed, rawText, response.status)
logWebhook('[agiso/xianyu/auto-delivery]', 'Agiso 咸鱼自动发货失败', {
trigger,
orderId: order.id,
taskId: currentTask.id,
shopId: order.shop_id,
platformOrderId: order.platform_order_id,
responseStatus: response.status,
errorMessage,
response: parsed,
}, { level: 'warn' })
return persistAgisoAutoDeliveryResult(currentTask, {
status: 'failed',
trigger,
reason: 'request_failed',
order,
responseStatus: response.status,
response: parsed,
errorMessage,
}, deps)
} catch (error) {
const errorMessage = error instanceof Error ? error.message : String(error || 'Agiso 咸鱼自动发货失败')
logWebhook('[agiso/xianyu/auto-delivery]', 'Agiso 咸鱼自动发货异常', {
trigger,
orderId: order.id,
taskId: currentTask.id,
shopId: order.shop_id,
platformOrderId: order.platform_order_id,
errorMessage,
}, { level: 'warn' })
return persistAgisoAutoDeliveryResult(currentTask, {
status: 'failed',
trigger,
reason: 'request_error',
order,
responseStatus: 0,
response: {},
errorMessage,
}, deps)
}
}
export function hasAgisoAutoDeliverySucceeded(tasks: AutoDeliveryTask[] = []): boolean {
return (Array.isArray(tasks) ? tasks : []).some((task) => {
const context = parseTaskContext(task)
const autoDelivery = isPlainObject(context.agisoAutoDelivery) ? context.agisoAutoDelivery : {}
return String(autoDelivery.status || '').trim() === 'success'
})
}
export function isOrderReadyForAgisoAutoDelivery(tasks: AutoDeliveryTask[] = []): boolean {
const normalizedTasks = Array.isArray(tasks) ? tasks.filter(Boolean) : []
return normalizedTasks.length > 0
&& normalizedTasks.every((task) => String(task.delivery_status || '').trim() === 'delivered')
}
function resolveAgisoXianyuAutoDeliveryConfig(order: AutoDeliveryOrder): AgisoAutoDeliveryConfig {
const baseConfig = runtimeConfig.platforms.agiso.autoDelivery
const shopConfig = getAgisoShopConfig(String(order?.shop_id || '').trim()) || {}
return {
enabled: normalizeBooleanLike(baseConfig.enabled, true),
endpoint: resolveAgisoAutoDeliveryEndpoint(baseConfig.endpoint),
apiVersion: String(baseConfig.apiVersion || shopConfig.apiVersion || '1').trim() || '1',
appSecret: String(shopConfig.appSecret || runtimeConfig.platforms?.agiso?.appSecret || '').trim(),
accessToken: String(shopConfig.accessToken || '').trim(),
}
}
function buildRequestHeaders({
accessToken,
apiVersion,
}: { accessToken: string; apiVersion: string }): Record<string, string> {
return {
Authorization: `Bearer ${accessToken}`,
ApiVersion: String(apiVersion || '1').trim() || '1',
'content-type': 'application/x-www-form-urlencoded; charset=utf-8',
}
}
function buildRequestBody({
platformOrderId,
appSecret,
}: { platformOrderId: string; appSecret: string }): Record<string, string> {
const payload: Record<string, string> = {
tid: String(platformOrderId || '').trim(),
timestamp: String(Math.floor(Date.now() / 1000)),
}
payload.sign = generateSign(payload, appSecret)
return payload
}
function generateSign(params: Record<string, string>, appSecret: string): string {
const sortedEntries = Object.entries(params).sort(([left], [right]) => left.localeCompare(right))
let raw = String(appSecret || '').trim()
for (const [key, value] of sortedEntries) {
raw += `${key}${value}`
}
raw += String(appSecret || '').trim()
return crypto.createHash('md5').update(raw, 'utf8').digest('hex').toLowerCase()
}
export function isAgisoAutoDeliverySuccess(statusCode: number, payload: unknown): boolean {
if (statusCode < 200 || statusCode >= 300) {
return false
}
if (!payload || typeof payload !== 'object' || Object.keys(payload).length === 0) {
return true
}
const normalizedPayload = payload as JsonObject
if (normalizedPayload.IsSuccess === true) {
return true
}
if (Number(normalizedPayload.Error_Code) === 0) {
return true
}
if (Number(normalizedPayload.code) === 0) {
return true
}
return false
}
export function resolveAgisoAutoDeliveryErrorMessage(
payload: unknown,
rawText: string,
statusCode: number,
): string {
if (payload && typeof payload === 'object') {
const normalizedPayload = payload as JsonObject
for (const value of [
normalizedPayload.Error_Msg,
normalizedPayload.msg,
normalizedPayload.message,
normalizedPayload.error,
]) {
const normalized = String(value || '').trim()
if (normalized) {
return normalized
}
}
}
const text = String(rawText || '').trim()
return text || `Agiso 咸鱼自动发货失败,HTTP ${statusCode}`
}
export function resolveAgisoAutoDeliveryEndpoint(value: unknown): string {
return AGISO_DUMMY_SEND_ENDPOINT
}
async function persistAgisoAutoDeliveryResult(task: AutoDeliveryTaskWithId, {
status,
trigger,
reason,
order,
responseStatus = 0,
response = {},
errorMessage = '',
detail = {},
}: PersistAgisoAutoDeliveryResultInput, deps: EnsureAgisoXianyuAutoDeliveryDeps = {}) {
const getNowIso = deps.nowIso || nowIso
const patchTask = deps.updateTask || updateTask
const insertTaskEvent = deps.createTaskEvent || createTaskEvent
const now = getNowIso()
const currentContext = parseTaskContext(task)
const nextContext = {
...currentContext,
agisoAutoDelivery: {
status,
trigger: String(trigger || '').trim(),
reason: String(reason || '').trim(),
platformOrderId: String(order?.platform_order_id || '').trim(),
responseStatus: Number(responseStatus || 0),
errorMessage: String(errorMessage || '').trim(),
response: isPlainObject(response) ? response : {},
updatedAt: now,
...detail,
},
}
const updatedTask = await patchTask(task.id, {
context_json: JSON.stringify(nextContext),
updated_at: now,
})
if (status === 'success' || status === 'failed' || status === 'skipped') {
await insertTaskEvent(task.id, `agiso_auto_delivery_${status}`, {
trigger: String(trigger || '').trim(),
reason: String(reason || '').trim(),
platformOrderId: String(order?.platform_order_id || '').trim(),
responseStatus: Number(responseStatus || 0),
errorMessage: String(errorMessage || '').trim(),
...detail,
}, now)
}
return {
sent: status === 'success',
skipped: status === 'skipped',
reason: String(reason || '').trim(),
errorMessage: String(errorMessage || '').trim(),
responseStatus: Number(responseStatus || 0),
response,
task: updatedTask || task,
}
}
function parseTaskContext(task: AutoDeliveryTask | null | undefined): JsonObject {
const value = task?.context_json
if (!value) {
return {}
}
if (isPlainObject(value)) {
return value
}
try {
return JSON.parse(String(value || '{}'))
} catch {
return {}
}
}
function isAgisoXianyuOrder(order: AutoDeliveryOrder | null | undefined): order is SupportedAgisoOrder {
return String(order?.provider || '').trim() === 'agiso'
&& String(order?.platform || '').trim() === 'xianyu'
&& Boolean(String(order?.platform_order_id || '').trim())
&& Number(order?.id || 0) > 0
}
function normalizeBooleanLike(value: unknown, fallbackValue: boolean): boolean {
if (typeof value === 'boolean') {
return value
}
if (typeof value === 'undefined' || value === null || value === '') {
return fallbackValue
}
const normalized = String(value || '').trim().toLowerCase()
if (['1', 'true', 'yes', 'on'].includes(normalized)) {
return true
}
if (['0', 'false', 'no', 'off'].includes(normalized)) {
return false
}
return fallbackValue
}
function isPlainObject(value: unknown): value is JsonObject {
return Object.prototype.toString.call(value) === '[object Object]'
}
/**
* 发货接口返回成功后,再查一次 Order/Detail 确认订单是否真的进入已发货状态。
* 只有 ship_time > 0 或 orderStatus >= 3 才算发货确认通过
*/
async function confirmAgisoXianyuAutoDeliveryShipped({
shopId = '',
platformOrderId = '',
requestId = '',
}: AgisoAutoDeliveryConfirmInput = {}): Promise<AgisoAutoDeliveryConfirmResult> {
try {
const detailResult = await queryAgisoXianyuOrderDetail({ shopId, platformOrderId, requestId })
if (detailResult.success && detailResult.shipped) {
return {
shipped: true,
orderStatus: detailResult.orderStatus,
shipTime: detailResult.shipTime,
reason: '',
}
}
return {
shipped: false,
orderStatus: detailResult.orderStatus || 0,
shipTime: detailResult.shipTime || 0,
reason: detailResult.success ? 'order_not_shipped' : detailResult.reason,
}
} catch (error) {
const message = error instanceof Error ? error.message : String(error || '订单发货状态确认失败')
logWebhook('[agiso/xianyu/auto-delivery]', '自动发货确认查询异常', {
requestId,
shopId,
platformOrderId,
errorMessage: message,
}, { level: 'warn' })
return {
shipped: false,
orderStatus: 0,
shipTime: 0,
reason: 'confirm_query_failed',
}
}
}
/**
* 自动发货成功后发送消息通知。
*/
async function sendAutoDeliveryMessage(
{ order, task }: { order?: AutoDeliveryOrder | null; task?: AutoDeliveryTask | null } = {},
deps: EnsureAgisoXianyuAutoDeliveryDeps = {},
): Promise<AutoDeliveryMessageResult> {
try {
const deliverMessage = deps.ensureAgisoXianyuAutoDeliveryMessageDeliveredForTask
|| ensureAgisoXianyuAutoDeliveryMessageDeliveredForTask
const result = await deliverMessage({ order, task })
logWebhook('[agiso/xianyu/auto-delivery]', '自动发货消息通知结果', {
orderId: order?.id,
taskId: task?.id,
platformOrderId: order?.platform_order_id,
sent: result.sent,
skipped: result.skipped,
reason: result.reason,
})
return result
} catch (error) {
const message = error instanceof Error ? error.message : String(error || '发送自动发货消息通知失败')
logWebhook('[agiso/xianyu/auto-delivery]', '自动发货消息通知异常', {
orderId: order?.id,
taskId: task?.id,
platformOrderId: order?.platform_order_id,
errorMessage: message,
}, { level: 'warn' })
return { sent: false, skipped: true, reason: 'message_send_error' }
}
}
@@ -1,103 +0,0 @@
import test from 'node:test'
import assert from 'node:assert/strict'
import { runtimeConfig } from '../../../../config/runtime.js'
import {
deliverAgisoXianyuMessageForTaskWithDeps,
normalizeAgisoMessageTemplate,
renderAgisoAutoDeliveryMessage,
} from './message-service.js'
test('deliverAgisoXianyuMessageForTaskWithDeps skips duplicate successful claim message by order scope', async () => {
const originalMessaging = runtimeConfig.platforms.agiso.messaging
const originalAppSecret = runtimeConfig.platforms.agiso.appSecret
runtimeConfig.platforms.agiso.messaging = {
...originalMessaging,
enabled: true,
sendMessageEndpoint: 'https://example.com/send',
apiVersion: '1',
accessToken: 'access-token',
}
runtimeConfig.platforms.agiso.appSecret = 'app-secret'
const calls = {
find: [],
create: 0,
fetch: 0,
}
try {
const result = await deliverAgisoXianyuMessageForTaskWithDeps({
order: {
id: 15,
platform: 'xianyu',
shop_id: '2209880145223',
platform_order_id: '4502280133178028841',
},
task: {
id: 36,
task_no: 'DT7af76e9b2438',
},
channel: 'agiso_im',
messageContent: '测试消息',
claimUrl: 'https://221329.cc.cd/#/claim/same-token',
}, {
findLatestSuccessfulMessageDelivery: async (input) => {
calls.find.push(input)
return { id: 16, task_id: null }
},
createMessageDelivery: async () => {
calls.create += 1
return { id: 999 }
},
fetch: async () => {
calls.fetch += 1
return { status: 200, text: async () => '{}' }
},
})
assert.deepEqual(calls.find, [
{
provider: 'agiso',
platform: 'xianyu',
shopId: '2209880145223',
platformOrderId: '4502280133178028841',
channel: 'agiso_im',
claimUrl: 'https://221329.cc.cd/#/claim/same-token',
},
])
assert.equal(calls.create, 0)
assert.equal(calls.fetch, 0)
assert.equal(result.sent, false)
assert.equal(result.skipped, true)
assert.equal(result.reason, 'already_sent')
assert.equal(result.deliveryId, 16)
} finally {
runtimeConfig.platforms.agiso.messaging = originalMessaging
runtimeConfig.platforms.agiso.appSecret = originalAppSecret
}
})
test('normalizeAgisoMessageTemplate converts escaped newline sequences into real line breaks', () => {
assert.equal(
normalizeAgisoMessageTemplate('第一行\\n第二行\\r\\n第三行'),
'第一行\n第二行\n第三行',
)
})
test('renderAgisoAutoDeliveryMessage supports escaped newlines in configured template', () => {
const message = renderAgisoAutoDeliveryMessage({
order: {
platform_order_id: '4502280133178028841',
shop_id: '2209880145223',
shop_name: '大锤号商',
},
task: {
task_no: 'DT7af76e9b2438',
},
template: '订单 {platformOrderId}\\n结果:{resultMessage}',
})
assert.equal(message, '订单 4502280133178028841\n结果:自动发货成功')
})
@@ -1,539 +0,0 @@
import crypto from 'node:crypto'
import { runtimeConfig } from '../../../../config/runtime.js'
import { normalizeAgisoMessageTemplate } from '../../../admin/platform-config/agiso-template.js'
import { getAgisoMessagingDefaults, getAgisoShopConfigMap } from '../shop-config-service.js'
import {
createMessageDelivery,
findLatestSuccessfulMessageDelivery,
updateMessageDelivery,
} from '../../../../repositories/message-delivery-repo.js'
import { nowIso } from '../../../../utils/time.js'
const AGISO_XIANYU_MESSAGE_CHANNEL = 'agiso_im'
const AGISO_XIANYU_AUTO_DELIVERY_MESSAGE_CHANNEL = 'agiso_im_auto_delivery'
type JsonObject = Record<string, unknown>
type MessageOrder = {
id?: number | string
platform?: string
shop_id?: string
shop_name?: string
platform_order_id?: string
[key: string]: unknown
}
type MessageTask = {
id?: number | string
task_no?: string
[key: string]: unknown
}
type AgisoMessagingConfig = {
enabled?: boolean
sendMessageEndpoint?: string
accessToken?: string
appSecret?: string
apiVersion?: string
messageTemplate?: string
autoDeliveryMessageTemplate?: string
shopName?: string
[key: string]: unknown
}
type EnsureClaimMessageInput = {
order?: MessageOrder | null
task?: MessageTask | null
claimUrl?: string
expiredAt?: string
}
type EnsureAutoDeliveryMessageInput = {
order?: MessageOrder | null
task?: MessageTask | null
}
type DeliverMessageInput = EnsureAutoDeliveryMessageInput & {
channel?: string
messageContent?: string
claimUrl?: string
}
type FetchResponseLike = {
status: number
text: () => Promise<string>
}
type FetchLike = (
input: string | URL,
init?: RequestInit,
) => Promise<FetchResponseLike>
type MessageDeliveryLike = {
id: number | string
[key: string]: unknown
}
type MessageDeliveryCreateInput = {
provider: string
platform: string
shopId: string
shopName: string
channel: string
orderId?: number | string | null
taskId?: number | string | null
platformOrderId?: string
recipientKey?: string
messageContent: string
claimUrl: string
status: string
requestUrl: string
requestHeadersJson: string
requestBodyJson: string
responseStatus: number
responseJson: string
errorMessage: string
sentAt: string | null
createdAt: string
updatedAt: string
}
type MessageDeliveryPatch = {
status?: string
response_status?: number
response_json?: string
error_message?: string
sent_at?: string | null
updated_at?: string
}
type FindSuccessfulDeliveryInput = {
provider: string
platform: string
shopId: string
platformOrderId: string
channel: string
claimUrl: string
}
type DeliverMessageDeps = {
nowIso?: () => string
findLatestSuccessfulMessageDelivery?: (
input: FindSuccessfulDeliveryInput,
) => Promise<MessageDeliveryLike | null>
createMessageDelivery?: (
input: MessageDeliveryCreateInput,
) => Promise<MessageDeliveryLike | null>
updateMessageDelivery?: (
deliveryId: number | string,
patch: MessageDeliveryPatch,
) => Promise<MessageDeliveryLike | null>
fetch?: FetchLike
}
type RenderClaimMessageInput = EnsureClaimMessageInput & {
template?: string
shopName?: string
}
type RenderAutoDeliveryMessageInput = EnsureAutoDeliveryMessageInput & {
template?: string
shopName?: string
}
type RenderMessageTemplateInput = RenderAutoDeliveryMessageInput & {
template?: string
claimUrl?: string
expiredAt?: string
resultMessage?: string
}
export async function ensureAgisoXianyuClaimMessageDeliveredForTask({
order,
task,
claimUrl,
expiredAt,
}: EnsureClaimMessageInput = {}) {
const config = resolveAgisoXianyuMessagingConfig(order)
const messageContent = renderAgisoClaimMessage({
order,
task,
claimUrl,
expiredAt,
template: config.messageTemplate,
shopName: config.shopName,
})
return deliverAgisoXianyuMessageForTask({
order,
task,
channel: AGISO_XIANYU_MESSAGE_CHANNEL,
messageContent,
claimUrl,
})
}
export async function ensureAgisoXianyuAutoDeliveryMessageDeliveredForTask({
order,
task,
}: EnsureAutoDeliveryMessageInput = {}) {
const config = resolveAgisoXianyuMessagingConfig(order)
const messageContent = renderAgisoAutoDeliveryMessage({
order,
task,
template: config.autoDeliveryMessageTemplate,
shopName: config.shopName,
})
return deliverAgisoXianyuMessageForTask({
order,
task,
channel: AGISO_XIANYU_AUTO_DELIVERY_MESSAGE_CHANNEL,
messageContent,
})
}
async function deliverAgisoXianyuMessageForTask({
order,
task,
channel,
messageContent,
claimUrl = '',
}: DeliverMessageInput = {}) {
return deliverAgisoXianyuMessageForTaskWithDeps({
order,
task,
channel,
messageContent,
claimUrl,
})
}
export async function deliverAgisoXianyuMessageForTaskWithDeps({
order,
task,
channel,
messageContent,
claimUrl = '',
}: DeliverMessageInput = {}, deps: DeliverMessageDeps = {}) {
const now = deps.nowIso || nowIso
const findSuccessfulDelivery = deps.findLatestSuccessfulMessageDelivery || findLatestSuccessfulMessageDelivery
const insertMessageDelivery = deps.createMessageDelivery || createMessageDelivery
const patchMessageDelivery = deps.updateMessageDelivery || updateMessageDelivery
const sendRequest = deps.fetch || (fetch as FetchLike)
if (!order || !task || !messageContent) {
return { sent: false, skipped: true, reason: 'missing_message_context' }
}
const config = resolveAgisoXianyuMessagingConfig(order)
if (!config.enabled) {
return { sent: false, skipped: true, reason: 'messaging_disabled' }
}
const endpoint = String(config.sendMessageEndpoint || '').trim()
const accessToken = String(config.accessToken || '').trim()
const appSecret = String(config.appSecret || runtimeConfig.platforms?.agiso?.appSecret || '').trim()
if (!endpoint || !accessToken || !appSecret) {
return { sent: false, skipped: true, reason: 'missing_endpoint_or_access_token_or_app_secret' }
}
const successful = await findSuccessfulDelivery({
provider: 'agiso',
platform: String(order.platform || '').trim() || 'unknown',
shopId: String(order.shop_id || '').trim(),
platformOrderId: String(order.platform_order_id || '').trim(),
channel,
claimUrl: String(claimUrl || ''),
})
if (successful) {
return { sent: false, skipped: true, reason: 'already_sent', deliveryId: successful.id }
}
const url = buildRequestUrl(endpoint)
const requestBody = buildRequestBody({
tid: String(order.platform_order_id || ''),
msg: messageContent,
appSecret,
})
const requestHeaders = buildRequestHeaders({
accessToken,
apiVersion: String(config.apiVersion || '1').trim() || '1',
})
const createdAt = now()
const delivery = await insertMessageDelivery({
provider: 'agiso',
platform: String(order.platform || '').trim() || 'unknown',
shopId: String(order.shop_id || '').trim(),
shopName: String(order.shop_name || '').trim(),
channel,
orderId: order.id,
taskId: task.id,
platformOrderId: order.platform_order_id,
recipientKey: order.platform_order_id,
messageContent,
claimUrl,
status: 'pending',
requestUrl: url,
requestHeadersJson: JSON.stringify(maskHeadersForStorage(requestHeaders)),
requestBodyJson: JSON.stringify(maskBodyForStorage(requestBody)),
responseStatus: 0,
responseJson: '{}',
errorMessage: '',
sentAt: null,
createdAt,
updatedAt: createdAt,
})
if (!delivery) {
return { sent: false, skipped: false, reason: 'delivery_create_failed' }
}
try {
const response = await sendRequest(url, {
method: 'POST',
headers: requestHeaders,
body: new URLSearchParams(requestBody).toString(),
})
const rawText = await response.text()
const parsed = safeParseJson(rawText)
const success = isAgisoSendSuccess(response.status, parsed)
const errorMessage = success ? '' : resolveAgisoErrorMessage(parsed, rawText, response.status)
const updated = await patchMessageDelivery(delivery.id, {
status: success ? 'success' : 'failed',
response_status: response.status,
response_json: JSON.stringify(parsed ?? { rawText }),
error_message: errorMessage,
sent_at: success ? now() : null,
updated_at: now(),
})
return {
sent: success,
skipped: false,
deliveryId: updated?.id || delivery.id,
responseStatus: response.status,
response: parsed ?? { rawText },
errorMessage,
}
} catch (error) {
const message = error instanceof Error ? error.message : String(error || '发送消息失败')
await patchMessageDelivery(delivery.id, {
status: 'failed',
response_status: 0,
response_json: '{}',
error_message: message,
sent_at: null,
updated_at: now(),
})
return {
sent: false,
skipped: false,
deliveryId: delivery.id,
errorMessage: message,
}
}
}
function resolveAgisoXianyuMessagingConfig(order: MessageOrder | null | undefined): AgisoMessagingConfig {
const baseConfig = runtimeConfig.platforms.agiso.messaging
const fileDefaults = getAgisoMessagingDefaults()
const shopId = String(order?.shop_id || '').trim()
const shopConfigs = getAgisoShopConfigMap()
const shopConfig = shopId && isPlainObject(shopConfigs[shopId]) ? shopConfigs[shopId] : {}
return {
...baseConfig,
...fileDefaults,
...shopConfig,
}
}
function buildRequestUrl(endpoint: string): string {
return new URL(endpoint).toString()
}
function buildRequestHeaders({
accessToken,
apiVersion,
}: { accessToken: string; apiVersion: string }): Record<string, string> {
return {
Authorization: `Bearer ${accessToken}`,
ApiVersion: apiVersion,
'content-type': 'application/x-www-form-urlencoded; charset=utf-8',
}
}
function buildRequestBody({
tid,
msg,
appSecret,
}: { tid: string; msg: string; appSecret: string }): Record<string, string> {
const timestamp = String(Math.floor(Date.now() / 1000))
const payload: Record<string, string> = {
tid,
msg,
timestamp,
}
payload.sign = generateSign(payload, appSecret)
return payload
}
function generateSign(params: Record<string, string>, appSecret: string): string {
const sortedEntries = Object.entries(params).sort(([left], [right]) => left.localeCompare(right))
let raw = appSecret
for (const [key, value] of sortedEntries) {
raw += `${key}${value}`
}
raw += appSecret
return crypto.createHash('md5').update(raw, 'utf8').digest('hex').toLowerCase()
}
function renderAgisoClaimMessage({
order,
task,
claimUrl,
expiredAt,
template,
shopName = '',
}: RenderClaimMessageInput): string {
const source = normalizeAgisoMessageTemplate(String(template || '').trim())
|| '您的订单 {platformOrderId} 已创建领取链接,请在 {expiredAt} 前完成领取:{claimUrl}'
return renderAgisoMessageTemplate({
order,
task,
template: source,
shopName,
claimUrl,
expiredAt: String(expiredAt || '尽快'),
})
}
export function renderAgisoAutoDeliveryMessage({
order,
task,
template,
shopName = '',
}: RenderAutoDeliveryMessageInput): string {
const source = normalizeAgisoMessageTemplate(String(template || '').trim())
|| '您的订单 {platformOrderId} 已完成自动发货,请注意查收。'
return renderAgisoMessageTemplate({
order,
task,
template: source,
shopName,
resultMessage: '自动发货成功',
})
}
function renderAgisoMessageTemplate({
order,
task,
template,
shopName = '',
claimUrl = '',
expiredAt = '',
resultMessage = '',
}: RenderMessageTemplateInput = {}): string {
const resolvedShopName = String(shopName || order?.shop_name || order?.shop_id || '').trim()
return normalizeAgisoMessageTemplate(template)
.replaceAll('{platformOrderId}', String(order?.platform_order_id || ''))
.replaceAll('{taskNo}', String(task?.task_no || ''))
.replaceAll('{shopName}', resolvedShopName)
.replaceAll('{shopId}', String(order?.shop_id || ''))
.replaceAll('{claimUrl}', String(claimUrl || ''))
.replaceAll('{expiredAt}', String(expiredAt || ''))
.replaceAll('{resultMessage}', String(resultMessage || ''))
}
function isAgisoSendSuccess(statusCode: number, payload: unknown): boolean {
if (statusCode < 200 || statusCode >= 300) {
return false
}
if (!payload || typeof payload !== 'object') {
return true
}
const normalizedPayload = payload as JsonObject
if (normalizedPayload.IsSuccess === true) {
return true
}
if (Number(normalizedPayload.Error_Code) === 0) {
return true
}
if (Number(normalizedPayload.code) === 0) {
return true
}
return false
}
function resolveAgisoErrorMessage(payload: unknown, rawText: string, statusCode: number): string {
if (payload && typeof payload === 'object') {
const normalizedPayload = payload as JsonObject
for (const value of [
normalizedPayload.Error_Msg,
normalizedPayload.msg,
normalizedPayload.message,
normalizedPayload.error,
]) {
const normalized = String(value || '').trim()
if (normalized) {
return normalized
}
}
}
const text = String(rawText || '').trim()
return text || `Agiso 咸鱼发消息失败,HTTP ${statusCode}`
}
function safeParseJson(rawText: string): JsonObject | null {
const normalized = String(rawText || '').trim()
if (!normalized) {
return null
}
try {
return JSON.parse(normalized)
} catch {
return null
}
}
function maskHeadersForStorage(headers: Record<string, string>): Record<string, string> {
const output = { ...headers }
if (output.Authorization) {
output.Authorization = '[masked]'
}
return output
}
function maskBodyForStorage(body: Record<string, string>): Record<string, string> {
const output = { ...body }
if (output.sign) {
output.sign = '[masked]'
}
return output
}
function isPlainObject(value: unknown): value is JsonObject {
return Object.prototype.toString.call(value) === '[object Object]'
}
@@ -1,219 +0,0 @@
import test from 'node:test'
import assert from 'node:assert/strict'
import { runtimeConfig } from '../../../../config/runtime.js'
import {
enrichAgisoXianyuTradeOrderWithDeps,
queryAgisoXianyuOrderDetailWithDeps,
resolveAgisoXianyuOrderDeliveryState,
} from './order-detail-service.js'
test('resolveAgisoXianyuOrderDeliveryState detects shipped order by ship time or status', () => {
assert.deepEqual(resolveAgisoXianyuOrderDeliveryState({ ship_time: 1713097840 }), {
shipped: true,
shipTime: 1713097840,
orderStatus: 0,
})
assert.deepEqual(resolveAgisoXianyuOrderDeliveryState({ order_status: 3 }), {
shipped: true,
shipTime: 0,
orderStatus: 3,
})
assert.deepEqual(resolveAgisoXianyuOrderDeliveryState({ order_status: 2 }), {
shipped: false,
shipTime: 0,
orderStatus: 2,
})
})
test('queryAgisoXianyuOrderDetailWithDeps parses successful detail payload and masks network dependency', async () => {
const originalShops = runtimeConfig.platforms.agiso.messaging.shops
const originalAppSecret = runtimeConfig.platforms.agiso.appSecret
const calls = []
runtimeConfig.platforms.agiso.appSecret = ''
runtimeConfig.platforms.agiso.messaging.shops = {
'shop-detail-test': {
accessToken: 'access-token',
appSecret: 'shop-secret',
tradeDetailEndpoint: 'https://example.com/detail',
tradeDetailApiVersion: '2',
tradeDetailTimeoutMs: '3000',
},
}
try {
const result = await queryAgisoXianyuOrderDetailWithDeps({
shopId: 'shop-detail-test',
platformOrderId: 'P-DETAIL-10001',
requestId: 'req-detail',
}, {
fetchWithTimeout: async (url, options, timeoutMs) => {
calls.push({ url, options, timeoutMs })
return {
status: 200,
text: async () => JSON.stringify({
IsSuccess: true,
Data: {
total_fee: '25.50',
pay_time: '2026-04-14 20:30:40',
ship_time: 1713097840,
order_status: 3,
item: {
item_id: 'item-1',
sku: 'dnf-cdk-a|商品名称:DNF礼包',
quantity: 2,
},
},
}),
}
},
})
assert.equal(result.success, true)
assert.equal(result.responseStatus, 200)
assert.equal(result.totalAmountFen, 2550)
assert.equal(result.shipped, true)
assert.equal(result.shipTime, 1713097840)
assert.equal(result.orderStatus, 3)
assert.equal(calls[0]?.url, 'https://example.com/detail')
assert.equal(calls[0]?.timeoutMs, 3000)
assert.equal(calls[0]?.options.headers.Authorization, 'Bearer access-token')
assert.equal(calls[0]?.options.headers.ApiVersion, '2')
assert.match(String(calls[0]?.options.body), /tid=P-DETAIL-10001/)
assert.match(String(calls[0]?.options.body), /sign=/)
} finally {
runtimeConfig.platforms.agiso.messaging.shops = originalShops
runtimeConfig.platforms.agiso.appSecret = originalAppSecret
}
})
test('queryAgisoXianyuOrderDetailWithDeps returns business_error with structured message', async () => {
const originalShops = runtimeConfig.platforms.agiso.messaging.shops
runtimeConfig.platforms.agiso.messaging.shops = {
'shop-detail-test': {
accessToken: 'access-token',
appSecret: 'shop-secret',
tradeDetailEndpoint: 'https://example.com/detail',
},
}
try {
const result = await queryAgisoXianyuOrderDetailWithDeps({
shopId: 'shop-detail-test',
platformOrderId: 'P-DETAIL-10002',
}, {
fetchWithTimeout: async () => ({
status: 200,
text: async () => JSON.stringify({
IsSuccess: false,
Error_Msg: '订单不存在',
}),
}),
})
assert.equal(result.success, false)
assert.equal(result.reason, 'business_error')
assert.equal(result.errorMessage, '订单不存在')
assert.equal(result.responseStatus, 200)
} finally {
runtimeConfig.platforms.agiso.messaging.shops = originalShops
}
})
test('queryAgisoXianyuOrderDetailWithDeps skips missing config without fetching', async () => {
const originalShops = runtimeConfig.platforms.agiso.messaging.shops
const originalAppSecret = runtimeConfig.platforms.agiso.appSecret
let fetchCalled = false
runtimeConfig.platforms.agiso.messaging.shops = {}
runtimeConfig.platforms.agiso.appSecret = ''
try {
const result = await queryAgisoXianyuOrderDetailWithDeps({
shopId: 'missing-shop',
platformOrderId: 'P-DETAIL-10003',
}, {
fetchWithTimeout: async () => {
fetchCalled = true
return { status: 200, text: async () => '{}' }
},
})
assert.equal(result.success, false)
assert.equal(result.reason, 'missing_config')
assert.equal(fetchCalled, false)
} finally {
runtimeConfig.platforms.agiso.messaging.shops = originalShops
runtimeConfig.platforms.agiso.appSecret = originalAppSecret
}
})
test('enrichAgisoXianyuTradeOrderWithDeps merges detail payload into incomplete trade', async () => {
const parsed = {
provider: 'agiso',
platform: 'xianyu',
shopId: 'shop-detail-test',
shopName: '',
platformOrderId: 'P-DETAIL-10004',
totalAmount: 0,
paidAt: null,
buyerId: '',
buyerName: '',
receiverContact: '',
rawPayload: {},
items: [],
}
const result = await enrichAgisoXianyuTradeOrderWithDeps(parsed, { requestId: 'req-enrich' }, {
queryAgisoXianyuOrderDetail: async (input) => {
assert.deepEqual(input, {
shopId: 'shop-detail-test',
platformOrderId: 'P-DETAIL-10004',
requestId: 'req-enrich',
})
return {
success: true,
reason: '',
errorMessage: '',
responseStatus: 200,
payload: {},
detailPayload: {
total_fee: '19.90',
pay_time: '2026-04-14 20:30:40',
buyer_id: 'buyer-1',
buyer_name: '测试买家',
receiver_mobile: '13800138000',
shop_name: '详情店铺',
items: [
{
item_id: 'item-1',
sku: 'dnf-cdk-a|商品名称:DNF礼包',
quantity: 2,
},
],
},
totalAmountFen: 1990,
shipped: false,
shipTime: 0,
orderStatus: 2,
}
},
})
assert.equal(result.enriched, true)
assert.equal(result.totalAmountFen, 1990)
assert.equal(result.parsed.totalAmount, 1990)
assert.equal(result.parsed.buyerId, 'buyer-1')
assert.equal(result.parsed.buyerName, '测试买家')
assert.equal(result.parsed.receiverContact, '13800138000')
assert.equal(result.parsed.shopName, '详情店铺')
assert.equal(result.parsed.items.length, 1)
assert.equal(result.parsed.items[0]?.skuCode, 'dnf-cdk-a')
assert.equal(result.parsed.items[0]?.quantity, 2)
assert.deepEqual(result.parsed.rawPayload._agisoTradeDetail.total_fee, '19.90')
})
@@ -1,803 +0,0 @@
import crypto from 'node:crypto'
import { runtimeConfig } from '../../../../config/runtime.js'
import { getAgisoShopConfig } from '../shop-config-service.js'
import { logWebhook } from '../../../../utils/logger.js'
import { nowIso } from '../../../../utils/time.js'
import { parseAmountToFen } from '../../../../utils/money.js'
import { parseJsonObject } from '../../../../utils/json.js'
const DEFAULT_DETAIL_TIMEOUT_MS = 5000
type JsonObject = Record<string, unknown>
type AgisoParsedTrade = {
provider?: string
platform?: string
shopId?: string
shopName?: string
platformOrderId?: string
totalAmount?: number
paidAt?: string | null
buyerId?: string
buyerName?: string
receiverContact?: string
rawPayload?: JsonObject
items?: AgisoOrderItem[]
[key: string]: unknown
}
type AgisoOrderItem = {
skuCode: string
skuName: string
externalItemId: string
externalSkuCode: string
externalSkuName: string
quantity: number
spec: JsonObject
snapshot: {
externalItemId: string
externalSkuCode: string
externalSkuName: string
}
}
type AgisoDetailQueryInput = {
shopId?: string
platformOrderId?: string
requestId?: string
}
type FetchResponseLike = {
status: number
text: () => Promise<string>
}
type FetchWithTimeout = (
url: string,
options: RequestInit,
timeoutMs: number,
) => Promise<FetchResponseLike>
type AgisoDetailDeps = {
fetchWithTimeout?: FetchWithTimeout
queryAgisoXianyuOrderDetail?: (
input: Required<AgisoDetailQueryInput>,
) => Promise<AgisoDetailResult>
}
type AgisoTradeDetailConfig = {
endpoint: string
apiVersion: string
accessToken: string
appSecret: string
timeoutMs: number
}
type AgisoShopConfig = {
accessToken?: string
appSecret?: string
tradeDetailEndpoint?: string
tradeDetailApiVersion?: string
tradeDetailTimeoutMs?: string | number
}
type DeliveryState = {
shipped: boolean
shipTime: number
orderStatus: number
}
type AgisoDetailResult = {
success: boolean
reason: string
errorMessage: string
responseStatus: number
payload: JsonObject
detailPayload: JsonObject
totalAmountFen: number
shipped: boolean
shipTime: number
orderStatus: number
}
type ExternalSkuDescriptor = {
raw: string
externalSkuCode: string
externalSkuName: string
}
export async function enrichAgisoXianyuTradeOrder(parsed: AgisoParsedTrade, { requestId = '' } = {}) {
return enrichAgisoXianyuTradeOrderWithDeps(parsed, { requestId })
}
export async function enrichAgisoXianyuTradeOrderWithDeps(
parsed: AgisoParsedTrade,
{ requestId = '' } = {},
deps: AgisoDetailDeps = {},
) {
const queryOrderDetail = deps.queryAgisoXianyuOrderDetail || queryAgisoXianyuOrderDetail
if (!shouldHydrateAgisoXianyuTradeOrder(parsed)) {
return { parsed, enriched: false, reason: 'not_needed' }
}
const detailResult = await queryOrderDetail({
shopId: parsed.shopId,
platformOrderId: parsed.platformOrderId,
requestId,
})
if (!detailResult.success) {
return {
parsed,
enriched: false,
reason: detailResult.reason,
errorMessage: detailResult.errorMessage,
}
}
const detailPayload = detailResult.detailPayload
if (detailResult.totalAmountFen <= 0) {
return {
parsed: mergeEnrichedTrade(parsed, detailPayload, { keepOriginalAmount: true }),
enriched: false,
reason: 'amount_still_missing',
}
}
const merged = mergeEnrichedTrade(parsed, detailPayload)
logWebhook('[agiso/xianyu/order-detail]', 'Agiso 咸鱼订单详情补查成功', {
requestId,
platform: parsed.platform,
shopId: parsed.shopId,
platformOrderId: parsed.platformOrderId,
totalAmountFen: merged.totalAmount,
paidAt: merged.paidAt,
})
return {
parsed: merged,
enriched: true,
totalAmountFen: merged.totalAmount,
}
}
export async function queryAgisoXianyuOrderDetail({ shopId = '', platformOrderId = '', requestId = '' } = {}) {
return queryAgisoXianyuOrderDetailWithDeps({ shopId, platformOrderId, requestId })
}
export async function queryAgisoXianyuOrderDetailWithDeps(
{ shopId = '', platformOrderId = '', requestId = '' } = {},
deps: AgisoDetailDeps = {},
): Promise<AgisoDetailResult> {
const requestWithTimeout = deps.fetchWithTimeout || fetchWithTimeout
const normalizedShopId = String(shopId || '').trim()
const normalizedPlatformOrderId = String(platformOrderId || '').trim()
const config = resolveAgisoXianyuTradeDetailConfig(normalizedShopId)
if (!config.endpoint || !config.accessToken || !config.appSecret) {
logWebhook('[agiso/xianyu/order-detail]', '跳过 Agiso 咸鱼订单详情补查:缺少必要配置', {
requestId,
platform: 'xianyu',
shopId: normalizedShopId,
platformOrderId: normalizedPlatformOrderId,
hasEndpoint: Boolean(config.endpoint),
hasAccessToken: Boolean(config.accessToken),
hasAppSecret: Boolean(config.appSecret),
}, { level: 'warn' })
return createFailedDetailResult('missing_config')
}
const requestBody = buildRequestBody({
platformOrderId: normalizedPlatformOrderId,
appSecret: config.appSecret,
})
const requestHeaders = buildRequestHeaders({
accessToken: config.accessToken,
apiVersion: config.apiVersion,
})
logWebhook('[agiso/xianyu/order-detail]', '开始补查 Agiso 咸鱼订单详情', {
requestId,
platform: 'xianyu',
shopId: normalizedShopId,
platformOrderId: normalizedPlatformOrderId,
endpoint: config.endpoint,
})
try {
const response = await requestWithTimeout(config.endpoint, {
method: 'POST',
headers: requestHeaders,
body: new URLSearchParams(requestBody).toString(),
}, config.timeoutMs)
const rawText = await response.text()
const payload = parseJsonObject(rawText, { preserveLargeIntegers: true }) as JsonObject
const detailPayload = extractAgisoDetailPayload(payload)
const delivery = resolveAgisoXianyuOrderDeliveryState(detailPayload)
const totalAmountFen = resolveTotalAmountFen(detailPayload)
if (response.status < 200 || response.status >= 300) {
const message = resolveAgisoDetailErrorMessage(payload, rawText, response.status)
logWebhook('[agiso/xianyu/order-detail]', 'Agiso 咸鱼订单详情补查失败', {
requestId,
platform: 'xianyu',
shopId: normalizedShopId,
platformOrderId: normalizedPlatformOrderId,
responseStatus: response.status,
errorMessage: message,
}, { level: 'warn' })
return createFailedDetailResult('http_error', {
responseStatus: response.status,
payload,
detailPayload,
errorMessage: message,
totalAmountFen,
delivery,
})
}
if (!isAgisoDetailSuccess(payload)) {
const message = resolveAgisoDetailErrorMessage(payload, rawText, response.status)
logWebhook('[agiso/xianyu/order-detail]', 'Agiso 咸鱼订单详情补查返回业务失败', {
requestId,
platform: 'xianyu',
shopId: normalizedShopId,
platformOrderId: normalizedPlatformOrderId,
responseStatus: response.status,
errorMessage: message,
response: payload,
}, { level: 'warn' })
return createFailedDetailResult('business_error', {
responseStatus: response.status,
payload,
detailPayload,
errorMessage: message,
totalAmountFen,
delivery,
})
}
if (!isPlainObject(detailPayload) || Object.keys(detailPayload).length === 0) {
logWebhook('[agiso/xianyu/order-detail]', 'Agiso 咸鱼订单详情补查未返回可用订单体', {
requestId,
platform: 'xianyu',
shopId: normalizedShopId,
platformOrderId: normalizedPlatformOrderId,
responseStatus: response.status,
}, { level: 'warn' })
return createFailedDetailResult('empty_detail_payload', {
responseStatus: response.status,
payload,
})
}
if (totalAmountFen <= 0) {
logWebhook('[agiso/xianyu/order-detail]', 'Agiso 咸鱼订单详情已返回,但仍未解析出金额', {
requestId,
platform: 'xianyu',
shopId: normalizedShopId,
platformOrderId: normalizedPlatformOrderId,
responseStatus: response.status,
detailKeys: Object.keys(detailPayload),
}, { level: 'warn' })
}
return {
success: true,
reason: '',
errorMessage: '',
responseStatus: response.status,
payload: isPlainObject(payload) ? payload : {},
detailPayload,
totalAmountFen,
shipped: delivery.shipped,
shipTime: delivery.shipTime,
orderStatus: delivery.orderStatus,
}
} catch (error) {
const message = error instanceof Error ? error.message : String(error || 'Agiso 咸鱼订单详情补查失败')
logWebhook('[agiso/xianyu/order-detail]', 'Agiso 咸鱼订单详情补查异常', {
requestId,
platform: 'xianyu',
shopId: normalizedShopId,
platformOrderId: normalizedPlatformOrderId,
errorMessage: message,
}, { level: 'warn' })
return createFailedDetailResult('request_failed', {
errorMessage: message,
})
}
}
function shouldHydrateAgisoXianyuTradeOrder(parsed: AgisoParsedTrade | null | undefined): boolean {
return parsed?.provider === 'agiso'
&& parsed?.platform === 'xianyu'
&& String(parsed?.platformOrderId || '').trim()
&& Number(parsed?.totalAmount || 0) <= 0
}
function resolveAgisoXianyuTradeDetailConfig(shopId: string): AgisoTradeDetailConfig {
const baseConfig = runtimeConfig.platforms.agiso.tradeDetail
const shopConfig = (getAgisoShopConfig(shopId) || {}) as AgisoShopConfig
return {
endpoint: String(shopConfig.tradeDetailEndpoint || baseConfig.endpoint || '').trim(),
apiVersion: String(shopConfig.tradeDetailApiVersion || baseConfig.apiVersion || '1').trim() || '1',
accessToken: String(shopConfig.accessToken || '').trim(),
appSecret: String(shopConfig.appSecret || runtimeConfig.platforms?.agiso?.appSecret || '').trim(),
timeoutMs: normalizePositiveInteger(shopConfig.tradeDetailTimeoutMs || baseConfig.timeoutMs, DEFAULT_DETAIL_TIMEOUT_MS),
}
}
function buildRequestHeaders({
accessToken,
apiVersion,
}: { accessToken: string; apiVersion: string }): Record<string, string> {
return {
Authorization: `Bearer ${accessToken}`,
ApiVersion: apiVersion,
'content-type': 'application/x-www-form-urlencoded; charset=utf-8',
}
}
function buildRequestBody({
platformOrderId,
appSecret,
}: { platformOrderId: string; appSecret: string }): Record<string, string> {
const timestamp = String(Math.floor(Date.now() / 1000))
const payload: Record<string, string> = {
tid: String(platformOrderId || '').trim(),
timestamp,
}
payload.sign = generateSign(payload, appSecret)
return payload
}
function generateSign(params: Record<string, string>, appSecret: string): string {
const sortedEntries = Object.entries(params).sort(([left], [right]) => left.localeCompare(right))
let raw = appSecret
for (const [key, value] of sortedEntries) {
raw += `${key}${value}`
}
raw += appSecret
return crypto.createHash('md5').update(raw, 'utf8').digest('hex').toLowerCase()
}
function extractAgisoDetailPayload(payload) {
if (!isPlainObject(payload)) {
return {}
}
for (const key of ['Data', 'data', 'Result', 'result', 'Trade', 'trade', 'Order', 'order']) {
if (isPlainObject(payload[key])) {
return payload[key]
}
}
return payload
}
export function resolveAgisoXianyuOrderDeliveryState(payload) {
const shipTime = normalizeTimestampValue(pickFirstNonEmpty([
payload?.ship_time,
payload?.shipTime,
payload?.ShipTime,
payload?.delivery_time,
payload?.deliveryTime,
]))
const orderStatus = normalizeInteger(
pickFirstNonEmpty([
payload?.order_status,
payload?.orderStatus,
payload?.status,
]),
)
return {
shipped: shipTime > 0 || orderStatus >= 3,
shipTime,
orderStatus,
}
}
function resolveTotalAmountFen(payload) {
const fenAmount = normalizeFenInteger(pickFirstNonEmpty([
payload.payment,
payload.Payment,
payload.post_fee,
payload.postFee,
payload.item?.price,
]))
if (fenAmount > 0) {
return fenAmount
}
return parseAmountToFen(pickFirstNonEmpty([
payload.total_fee,
payload.totalFee,
payload.TotalFee,
payload.pay_fee,
payload.payFee,
payload.PayFee,
payload.actual_fee,
payload.actualFee,
payload.ActualFee,
payload.total_amount,
payload.totalAmount,
payload.Amount,
payload.amount,
]))
}
function resolvePaidAt(payload, fallbackValue) {
const providerPaidAt = normalizeProviderDateTime(
pickFirstNonEmpty([
payload.paid_at,
payload.paidAt,
payload.pay_time,
payload.payTime,
payload.PayTime,
fallbackValue,
]),
)
if (providerPaidAt) {
return providerPaidAt
}
const rawStatus = normalizeInteger(
pickFirstNonEmpty([payload.order_status, payload.orderStatus, payload.status]),
)
if (rawStatus === 2 || rawStatus === 3 || rawStatus === 4) {
return nowIso()
}
return null
}
function mergeEnrichedTrade(parsed, detailPayload, { keepOriginalAmount = false } = {}) {
const mergedPayload = {
...parsed.rawPayload,
_agisoTradeDetail: detailPayload,
}
const totalAmountFen = resolveTotalAmountFen(detailPayload)
const mergedItems = normalizeOrderItems(detailPayload, normalizeOrderItems(parsed.rawPayload, parsed.items))
return {
...parsed,
buyerId: pickFirstNonEmpty([
parsed.buyerId,
detailPayload.encryption_buyer_id,
detailPayload.buyer_id,
detailPayload.buyerId,
detailPayload.BuyerId,
detailPayload.BuyerOpenUid,
detailPayload.buyer_open_uid,
]),
buyerName: pickFirstNonEmpty([
parsed.buyerName,
detailPayload.buyer_name,
detailPayload.buyerName,
detailPayload.BuyerName,
detailPayload.buyer_nick,
detailPayload.nick,
detailPayload.BuyerNick,
]),
receiverContact: pickFirstNonEmpty([
parsed.receiverContact,
detailPayload.receiver_contact,
detailPayload.receiverContact,
detailPayload.receiver_mobile,
detailPayload.receiverMobile,
detailPayload.mobile,
detailPayload.phone,
]),
shopName: pickFirstNonEmpty([
parsed.shopName,
detailPayload.shop_name,
detailPayload.shopName,
detailPayload.ShopName,
detailPayload.seller_name,
detailPayload.sellerName,
detailPayload.seller_nick,
detailPayload.sellerNick,
detailPayload.SellerNick,
]),
totalAmount: keepOriginalAmount ? parsed.totalAmount : (totalAmountFen || parsed.totalAmount),
paidAt: parsed.paidAt || resolvePaidAt(detailPayload, parsed.paidAt),
rawPayload: mergedPayload,
items: mergedItems,
}
}
function normalizeOrderItems(payload, fallbackItems = []) {
const candidates = [
payload.item ? [payload.item] : null,
payload.items,
payload.Items,
payload.orders,
payload.Orders,
payload.order_list,
payload.OrderList,
]
const items = candidates.find((item) => Array.isArray(item) && item.length > 0)
if (!Array.isArray(items) || items.length === 0) {
return Array.isArray(fallbackItems) ? fallbackItems : []
}
return items.map((item) => {
const source = isPlainObject(item) ? item : {}
const skuDescriptor = parseExternalSkuDescriptor(
pickFirstNonEmpty([
source.sku,
source.Sku,
payload.sku,
payload.Sku,
]),
)
const skuCode = pickFirstNonEmpty([
skuDescriptor.externalSkuCode,
source.OuterSkuId,
source.outerSkuId,
source.outer_sku_id,
source.OuterIid,
source.outerIid,
source.outer_iid,
source.sku_code,
source.skuCode,
source.goods_sku,
source.item_id,
source.itemId,
source.goods_id,
source.goodsId,
source.NumIid,
source.num_iid,
payload.item_id,
payload.itemId,
])
const externalItemId = pickFirstNonEmpty([
source.item_id,
source.itemId,
source.goods_id,
source.goodsId,
payload.item_id,
payload.itemId,
])
const externalSkuName = pickFirstNonEmpty([
skuDescriptor.externalSkuName,
source.Title,
source.title,
source.sku_name,
source.skuName,
source.goods_name,
source.goodsName,
skuCode,
])
return {
skuCode,
skuName: externalSkuName,
externalItemId,
externalSkuCode: pickFirstNonEmpty([skuDescriptor.externalSkuCode, skuCode, externalItemId]),
externalSkuName,
quantity: Math.max(
1,
normalizeInteger(
pickFirstNonEmpty([
source.quantity,
source.num,
source.Num,
source.buy_amount,
payload.quantity,
payload.num,
payload.Num,
]),
) || 1,
),
spec: source,
snapshot: {
externalItemId,
externalSkuCode: pickFirstNonEmpty([skuDescriptor.externalSkuCode, skuCode, externalItemId]),
externalSkuName,
},
}
})
}
function parseExternalSkuDescriptor(rawValue) {
const raw = String(rawValue || '').trim()
if (!raw) {
return {
raw,
externalSkuCode: '',
externalSkuName: '',
}
}
const parts = raw.split('|').map((part) => String(part || '').trim()).filter(Boolean)
let externalSkuCode = ''
let externalSkuName = ''
for (const part of parts) {
if (!externalSkuCode && !part.includes(':') && !part.includes('')) {
externalSkuCode = part
continue
}
const separatorIndex = Math.max(part.indexOf(':'), part.indexOf(''))
if (separatorIndex >= 0) {
const label = part.slice(0, separatorIndex).trim()
const value = part.slice(separatorIndex + 1).trim()
if (value && ['商品名称', '商品名', 'sku名称', '规格名称', '名称', '商品'].includes(label)) {
externalSkuName = value
}
}
}
if (!externalSkuCode && parts[0]) {
externalSkuCode = parts[0]
}
if (!externalSkuName) {
externalSkuName = parts
.map((part) => {
const separatorIndex = Math.max(part.indexOf(':'), part.indexOf(''))
return separatorIndex >= 0 ? part.slice(separatorIndex + 1).trim() : ''
})
.find(Boolean) || ''
}
return {
raw,
externalSkuCode,
externalSkuName,
}
}
function isAgisoDetailSuccess(payload) {
if (!payload || typeof payload !== 'object' || Object.keys(payload).length === 0) {
return false
}
if (payload.IsSuccess === true) {
return true
}
if (Number(payload.Error_Code) === 0) {
return true
}
if (Number(payload.code) === 0) {
return true
}
if (Number(payload.success) === 1 || payload.success === true) {
return true
}
return false
}
function resolveAgisoDetailErrorMessage(payload, rawText, statusCode) {
if (payload && typeof payload === 'object') {
for (const value of [payload.Error_Msg, payload.msg, payload.message, payload.error]) {
const normalized = String(value || '').trim()
if (normalized) {
return normalized
}
}
}
const text = String(rawText || '').trim()
return text || `Agiso 咸鱼订单详情接口失败,HTTP ${statusCode}`
}
async function fetchWithTimeout(url, options, timeoutMs) {
const controller = new AbortController()
const timer = setTimeout(() => controller.abort(new Error('Agiso 咸鱼订单详情请求超时')), timeoutMs)
try {
return await fetch(url, {
...options,
signal: controller.signal,
})
} finally {
clearTimeout(timer)
}
}
function normalizeProviderDateTime(value) {
const normalized = String(value || '').trim()
if (!normalized) {
return null
}
if (/^\d{10,13}$/.test(normalized)) {
const timestamp = normalized.length === 13 ? Number(normalized) : Number(normalized) * 1000
if (Number.isFinite(timestamp)) {
return new Date(timestamp).toISOString()
}
}
const isoLike = normalized.replace(' ', 'T')
const parsed = Date.parse(isoLike)
if (Number.isNaN(parsed)) {
return null
}
return new Date(parsed).toISOString()
}
function normalizeInteger(value) {
const parsed = Number(value)
return Number.isFinite(parsed) ? Math.round(parsed) : 0
}
function normalizePositiveInteger(value, fallbackValue) {
const parsed = Number(value)
return Number.isFinite(parsed) && parsed > 0 ? Math.round(parsed) : fallbackValue
}
function normalizeFenInteger(value) {
const parsed = Number(value)
return Number.isFinite(parsed) && parsed > 0 ? Math.round(parsed) : 0
}
function normalizeTimestampValue(value) {
const parsed = Number(value)
return Number.isFinite(parsed) && parsed > 0 ? Math.round(parsed) : 0
}
function createFailedDetailResult(reason, {
responseStatus = 0,
payload = {},
detailPayload = {},
errorMessage = '',
totalAmountFen = 0,
delivery = { shipped: false, shipTime: 0, orderStatus: 0 },
} = {}) {
return {
success: false,
reason: String(reason || '').trim(),
errorMessage: String(errorMessage || '').trim(),
responseStatus: Number(responseStatus || 0),
payload: isPlainObject(payload) ? payload : {},
detailPayload: isPlainObject(detailPayload) ? detailPayload : {},
totalAmountFen: Number(totalAmountFen || 0),
shipped: Boolean(delivery?.shipped),
shipTime: Number(delivery?.shipTime || 0),
orderStatus: Number(delivery?.orderStatus || 0),
}
}
function pickFirstNonEmpty(values) {
for (const value of values) {
const normalized = String(value || '').trim()
if (normalized) {
return normalized
}
}
return ''
}
function isPlainObject(value) {
return Object.prototype.toString.call(value) === '[object Object]'
}
-101
View File
@@ -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('&', '&amp;')
.replaceAll('<', '&lt;')
.replaceAll('>', '&gt;')
.replaceAll('"', '&quot;')
}
@@ -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