合并 feat/kuaishou-lite-split: 架构瘦身与模块化重组
This commit is contained in:
+3
-1
@@ -16,12 +16,14 @@ apps/*/dist
|
||||
apps/*/.vite
|
||||
|
||||
# backend runtime data
|
||||
apps/backend/data/
|
||||
apps/backend/data/*.db
|
||||
apps/backend/data/*.db-shm
|
||||
apps/backend/data/*.db-wal
|
||||
apps/backend/data/browser-sessions
|
||||
apps/backend/data/redeem-screenshots
|
||||
apps/backend/data/cache
|
||||
apps/backend/.npm-cache
|
||||
|
||||
# deploy data (server only)
|
||||
# legacy deploy data (server only)
|
||||
deploy/data/
|
||||
|
||||
+12
-47
@@ -1,69 +1,34 @@
|
||||
# Mac Docker development template. Copy to .env for local full-stack testing.
|
||||
# For public claim links, update APP_BASE_URL and CLAIM_BASE_URL together.
|
||||
# App
|
||||
# Mac Docker 本机联调模板。复制为 .env 后按需修改。
|
||||
# 如果使用 ngrok / cloudflared 等公网地址,只需要同步修改 APP_DOMAIN / CADDY_SITE_ADDR / CLAIM_BASE_URL。
|
||||
|
||||
# App / Caddy
|
||||
APP_DOMAIN=localhost
|
||||
APP_BASE_URL=http://localhost
|
||||
CADDY_SITE_ADDR=http://localhost
|
||||
TZ=Asia/Shanghai
|
||||
BACKEND_PORT=3000
|
||||
|
||||
# Claim Links
|
||||
CLAIM_BASE_URL=http://localhost/#/claim
|
||||
CLAIM_TOKEN_TTL_HOURS=24
|
||||
|
||||
# Database
|
||||
POSTGRES_DB=order_site
|
||||
POSTGRES_USER=postgres
|
||||
POSTGRES_PASSWORD=postgres
|
||||
DATABASE_URL=postgres://postgres:postgres@postgres:5432/order_site
|
||||
DATABASE_SSL=false
|
||||
DATABASE_MAX_CONNECTIONS=10
|
||||
POSTGRES_PORT=5432
|
||||
|
||||
# Data And Logging
|
||||
DATA_ROOT=/app/data
|
||||
# Logging
|
||||
LOG_LEVEL=info
|
||||
|
||||
# OCR
|
||||
OCR_IMAGE=order_site-ocr-worker:latest
|
||||
OCR_PORT=8100
|
||||
OCR_BASE_URL=http://ocr-worker:8100
|
||||
|
||||
# Tencent Browser
|
||||
TENCENT_REDEEM_PROOF_MODE=basic
|
||||
TENCENT_BROWSER_HEADLESS=false
|
||||
TENCENT_BROWSER_DEVTOOLS=false
|
||||
TENCENT_BROWSER_PREWARM=true
|
||||
TENCENT_BROWSER_KEEP_ALIVE=true
|
||||
TENCENT_BROWSER_SLOW_MO=200
|
||||
TENCENT_BROWSER_VNC_RESOLUTION=1600x900x24
|
||||
TENCENT_SESSION_DEBUG=true
|
||||
TENCENT_BROWSER_NOVNC_ENABLED=true
|
||||
TENCENT_BROWSER_NOVNC_PORT=6080
|
||||
TENCENT_BROWSER_VNC_PORT=5900
|
||||
LOG_RETENTION_DAYS=30
|
||||
|
||||
# Admin
|
||||
ADMIN_SESSION_SECRET=dev-local-session-secret
|
||||
ADMIN_SESSION_TTL_HOURS=12
|
||||
ADMIN_DEFAULT_USERS_JSON=[{"username":"admin","password":"dev-admin-123456","role":"admin"},{"username":"operator","password":"dev-operator-123456","role":"operator"}]
|
||||
|
||||
# Agiso
|
||||
AGISO_APP_SECRET=
|
||||
AGISO_MESSAGING_ENABLED=true
|
||||
AGISO_TRADE_DETAIL_ENDPOINT=https://gw-api.agiso.com/aldsIdle/Order/Detail
|
||||
AGISO_TRADE_DETAIL_API_VERSION=1
|
||||
AGISO_TRADE_DETAIL_TIMEOUT_MS=5000
|
||||
AGISO_AUTO_DELIVERY_ENABLED=true
|
||||
AGISO_AUTO_DELIVERY_ENDPOINT=https://gw-api.agiso.com/aldsIdle/Order/DummySend
|
||||
AGISO_AUTO_DELIVERY_API_VERSION=1
|
||||
AGISO_MESSAGE_API_VERSION=1
|
||||
AGISO_SEND_MESSAGE_ENDPOINT=https://gw-api.agiso.com/aldsIdle/ImMsg/SendMsg
|
||||
|
||||
# 91Kaquan
|
||||
NINETYONE_USER_ID=
|
||||
NINETYONE_SECRET=
|
||||
NINETYONE_VERSION=1.0
|
||||
NINETYONE_SHOP_ID=91kaquan
|
||||
NINETYONE_SHOP_NAME=91卡券
|
||||
NINETYONE_TIMESTAMP_TOLERANCE_SECONDS=600
|
||||
NINETYONE_CARDS_ENCODING=aes-256-ecb-base64
|
||||
KAQUAN91_USER_ID=
|
||||
KAQUAN91_SECRET=
|
||||
|
||||
# Cloudtentacles
|
||||
CLOUDTENTACLES_DEVICE_ID=08bc9d8c-fd15-48ea-bc00-8d754076cafc
|
||||
CLOUDTENTACLES_DEVICE_TYPE=1
|
||||
|
||||
+12
-43
@@ -1,64 +1,33 @@
|
||||
# Production deployment template. Copy to .env and replace every change-me value.
|
||||
# Keep real secrets out of git.
|
||||
# App
|
||||
# 生产部署模板。复制为 .env 后替换所有 change-me 值。
|
||||
# 真实密钥不要提交到 git。
|
||||
|
||||
# App / Caddy
|
||||
APP_DOMAIN=order.khhao.com
|
||||
APP_BASE_URL=https://order.khhao.com
|
||||
CADDY_SITE_ADDR=https://order.khhao.com
|
||||
TZ=Asia/Shanghai
|
||||
BACKEND_PORT=3000
|
||||
|
||||
# Claim Links
|
||||
CLAIM_BASE_URL=https://order.khhao.com/#/claim
|
||||
CLAIM_TOKEN_TTL_HOURS=24
|
||||
|
||||
# Database
|
||||
POSTGRES_DB=order_site
|
||||
POSTGRES_USER=postgres
|
||||
POSTGRES_PASSWORD=change-me-postgres-password
|
||||
DATABASE_URL=postgres://postgres:change-me-postgres-password@postgres:5432/order_site
|
||||
DATABASE_SSL=false
|
||||
DATABASE_MAX_CONNECTIONS=20
|
||||
|
||||
# Data And Logging
|
||||
DATA_ROOT=/app/data
|
||||
# Logging
|
||||
LOG_LEVEL=info
|
||||
|
||||
# OCR
|
||||
OCR_IMAGE=order_site-ocr-worker:latest
|
||||
OCR_BASE_URL=http://ocr-worker:8100
|
||||
|
||||
# Tencent Browser
|
||||
TENCENT_REDEEM_PROOF_MODE=full
|
||||
TENCENT_BROWSER_HEADLESS=true
|
||||
TENCENT_BROWSER_DEVTOOLS=false
|
||||
TENCENT_BROWSER_PREWARM=true
|
||||
TENCENT_BROWSER_KEEP_ALIVE=true
|
||||
TENCENT_BROWSER_SLOW_MO=0
|
||||
TENCENT_BROWSER_VNC_RESOLUTION=1600x900x24
|
||||
TENCENT_SESSION_DEBUG=false
|
||||
LOG_RETENTION_DAYS=30
|
||||
|
||||
# Admin
|
||||
ADMIN_SESSION_SECRET=change-me-long-random-session-secret
|
||||
ADMIN_SESSION_TTL_HOURS=12
|
||||
ADMIN_DEFAULT_USERS_JSON=[{"username":"admin","password":"change-me-admin-password","role":"admin"},{"username":"operator","password":"change-me-operator-password","role":"operator"}]
|
||||
|
||||
# Agiso
|
||||
AGISO_APP_SECRET=change-me-agiso-app-secret
|
||||
AGISO_MESSAGING_ENABLED=true
|
||||
AGISO_TRADE_DETAIL_ENDPOINT=https://gw-api.agiso.com/aldsIdle/Order/Detail
|
||||
AGISO_TRADE_DETAIL_API_VERSION=1
|
||||
AGISO_TRADE_DETAIL_TIMEOUT_MS=5000
|
||||
AGISO_AUTO_DELIVERY_ENABLED=true
|
||||
AGISO_AUTO_DELIVERY_ENDPOINT=https://gw-api.agiso.com/aldsIdle/Order/DummySend
|
||||
AGISO_AUTO_DELIVERY_API_VERSION=1
|
||||
AGISO_MESSAGE_API_VERSION=1
|
||||
AGISO_SEND_MESSAGE_ENDPOINT=https://gw-api.agiso.com/aldsIdle/ImMsg/SendMsg
|
||||
|
||||
# 91Kaquan
|
||||
NINETYONE_USER_ID=
|
||||
NINETYONE_SECRET=
|
||||
NINETYONE_VERSION=1.0
|
||||
NINETYONE_SHOP_ID=91kaquan
|
||||
NINETYONE_SHOP_NAME=91卡券
|
||||
NINETYONE_TIMESTAMP_TOLERANCE_SECONDS=600
|
||||
NINETYONE_CARDS_ENCODING=aes-256-ecb-base64
|
||||
KAQUAN91_USER_ID=
|
||||
KAQUAN91_SECRET=
|
||||
|
||||
# Cloudtentacles
|
||||
CLOUDTENTACLES_DEVICE_ID=08bc9d8c-fd15-48ea-bc00-8d754076cafc
|
||||
CLOUDTENTACLES_DEVICE_TYPE=1
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
node_modules
|
||||
.env
|
||||
.env copy
|
||||
.env.local
|
||||
.DS_Store
|
||||
|
||||
@@ -11,8 +12,12 @@ apps/*/.env.local
|
||||
apps/backend/data/*.db
|
||||
apps/backend/data/*.db-shm
|
||||
apps/backend/data/*.db-wal
|
||||
apps/backend/data/*.json
|
||||
!apps/backend/data/*.example.json
|
||||
apps/backend/data/cloudtentacles-session.json
|
||||
apps/backend/data/browser-sessions
|
||||
apps/backend/data/redeem-screenshots
|
||||
apps/backend/data/cache
|
||||
apps/backend/data/logs
|
||||
|
||||
.ace-tool
|
||||
|
||||
@@ -1,14 +1,13 @@
|
||||
# order_site
|
||||
|
||||
订单自动兑换与履约管理系统。
|
||||
快手自动发货与履约管理系统。
|
||||
|
||||
项目采用前后端分离架构,主要用于接收订单、匹配履约规则、生成领取链接、管理库存、执行自动/半自动兑换,并提供后台页面处理人工复核、平台配置和任务追踪。
|
||||
项目采用前后端分离架构,用于接收订单、匹配快手履约规则、管理云卡资源,并提供后台页面处理平台配置、任务追踪和人工复核。
|
||||
|
||||
## 技术栈
|
||||
|
||||
- 前端:Vue 3、Vite、Element Plus、TypeScript
|
||||
- 后端:Node.js、Express、PostgreSQL、Playwright
|
||||
- OCR:外部镜像服务,后端通过 `OCR_BASE_URL` 调用
|
||||
- 后端:Node.js、Express、PostgreSQL
|
||||
- 网关:Caddy
|
||||
- 本地与生产运行:Docker Compose
|
||||
|
||||
@@ -30,7 +29,7 @@ docker-compose.yml 生产/服务器 Compose
|
||||
|
||||
## 本地开发
|
||||
|
||||
推荐直接使用 Docker,本地不需要额外维护 Node、PostgreSQL、Playwright 浏览器和 OCR 运行环境。
|
||||
推荐直接使用 Docker,本地不需要额外维护 Node 和 PostgreSQL。
|
||||
|
||||
```bash
|
||||
cp .env.mac-docker.example .env
|
||||
@@ -42,8 +41,6 @@ docker compose -f docker-compose.dev.yml up -d --build
|
||||
- 前端、后台、领取页:`http://localhost`
|
||||
- 后端健康检查:`http://localhost/health`
|
||||
- 后端 API 前缀:`http://localhost/api/v1/...`
|
||||
- OCR worker:`http://127.0.0.1:8100/health`
|
||||
- noVNC 浏览器画面:`http://127.0.0.1:6080/vnc.html`
|
||||
|
||||
查看容器状态:
|
||||
|
||||
@@ -56,7 +53,6 @@ docker compose -f docker-compose.dev.yml ps
|
||||
```bash
|
||||
docker compose -f docker-compose.dev.yml logs -f backend
|
||||
docker compose -f docker-compose.dev.yml logs -f frontend
|
||||
docker compose -f docker-compose.dev.yml logs -f ocr-worker
|
||||
```
|
||||
|
||||
停止环境:
|
||||
@@ -75,11 +71,6 @@ docker compose -f docker-compose.dev.yml exec -T backend npm run typecheck
|
||||
docker compose -f docker-compose.dev.yml exec -T backend npm run build
|
||||
docker compose -f docker-compose.dev.yml exec -T frontend npm run typecheck
|
||||
docker compose -f docker-compose.dev.yml exec -T frontend npm run lint
|
||||
```
|
||||
|
||||
前端生产构建:
|
||||
|
||||
```bash
|
||||
docker compose -f docker-compose.dev.yml exec -T frontend npm run build
|
||||
```
|
||||
|
||||
@@ -89,15 +80,7 @@ docker compose -f docker-compose.dev.yml exec -T frontend npm run build
|
||||
docker compose -f docker-compose.dev.yml exec -T backend npm run db:migrate
|
||||
```
|
||||
|
||||
## 热更新
|
||||
|
||||
开发版 Compose 会把源码挂载进容器:
|
||||
|
||||
- 修改 `apps/backend/src`:后端容器内 `tsx watch` 自动重启
|
||||
- 修改 `apps/frontend/src`:Vite 自动 HMR
|
||||
- 修改数据库 migration:重启后端会自动执行迁移
|
||||
|
||||
如果只改业务代码,一般不需要重新 `--build`。改 Dockerfile、系统依赖或基础镜像时再重新构建。
|
||||
当前数据库脚本只保留新库初始化结构,不维护历史测试库的升级兼容;切换到这版前请重建开发库或重置 Docker volume。
|
||||
|
||||
## 环境变量
|
||||
|
||||
@@ -115,15 +98,12 @@ cp .env.server.example .env
|
||||
|
||||
常用变量:
|
||||
|
||||
- `APP_BASE_URL`:应用公网地址,用于推导领取链接
|
||||
- `CLAIM_BASE_URL`:领取页地址,优先级高于 `APP_BASE_URL`
|
||||
- `APP_DOMAIN` / `CADDY_SITE_ADDR`:Caddy 对外域名与监听地址
|
||||
- `CLAIM_BASE_URL`:领取页完整地址
|
||||
- `DATABASE_URL`:PostgreSQL 连接串
|
||||
- `ADMIN_SESSION_SECRET`:后台登录态签名密钥
|
||||
- `ADMIN_DEFAULT_USERS_JSON`:默认后台用户
|
||||
- `DATABASE_URL`:PostgreSQL 连接串
|
||||
- `TENCENT_BROWSER_HEADLESS`:Playwright 是否无头运行
|
||||
- `TENCENT_BROWSER_PREWARM`:启动时是否预热浏览器
|
||||
- `OCR_IMAGE`:OCR 外部服务镜像,默认 `order_site-ocr-worker:latest`
|
||||
- `OCR_BASE_URL`:后端访问 OCR 的 HTTP 地址,Docker 内默认 `http://ocr-worker:8100`
|
||||
- `KAQUAN91_USER_ID` / `KAQUAN91_SECRET`:91 卡券开放接口凭据
|
||||
|
||||
## 运行数据
|
||||
|
||||
@@ -132,16 +112,16 @@ cp .env.server.example .env
|
||||
- 后端配置与运行产物:`apps/backend/data`
|
||||
- PostgreSQL 数据:Docker volume `postgres_dev_data`
|
||||
- 后端日志:`apps/backend/data/logs`
|
||||
- 浏览器会话:`apps/backend/data/browser-sessions`
|
||||
- 兑换截图:`apps/backend/data/redeem-screenshots`
|
||||
|
||||
生产部署数据:
|
||||
|
||||
- 后端数据挂载到 `deploy/data/backend`
|
||||
- 后端数据挂载到 `apps/backend/data`,与本地开发路径保持一致
|
||||
- PostgreSQL 数据保存在 Docker volume `postgres_data`
|
||||
- Caddy 数据保存在 Docker volume `caddy_data`
|
||||
|
||||
注意:`apps/backend/data/*.json` 中可能包含账号、Cookie、token、平台配置等敏感信息。提交代码前请确认没有把真实生产凭据提交到仓库。
|
||||
如果旧部署曾使用 `deploy/data/backend`,升级前请先把其中的 JSON 配置迁移到 `apps/backend/data`。
|
||||
|
||||
注意:`apps/backend/data/*.json` 中可能包含账号、Cookie、token、平台配置等敏感信息,默认不进入仓库;仓库只保留 `*.example.json` 模板。
|
||||
|
||||
## 生产部署
|
||||
|
||||
@@ -149,10 +129,19 @@ cp .env.server.example .env
|
||||
|
||||
```bash
|
||||
cp .env.server.example .env
|
||||
mkdir -p deploy/data/backend
|
||||
mkdir -p apps/backend/data
|
||||
sudo chown -R 1001:1001 apps/backend/data
|
||||
docker compose up -d --build
|
||||
```
|
||||
|
||||
Ubuntu 服务器也可以使用一键部署脚本:
|
||||
|
||||
```bash
|
||||
bash deploy/ubuntu-deploy.sh
|
||||
```
|
||||
|
||||
第一次执行如果没有 `.env`,脚本会从 `.env.server.example` 生成模板并停止;编辑真实配置后再次执行即可。脚本会自动检查 Docker、检查 `.env` 占位值、修正后端数据目录权限、构建启动服务并等待健康检查通过。
|
||||
|
||||
生产入口:
|
||||
|
||||
- 前端与领取页:`https://你的域名/`
|
||||
@@ -162,78 +151,21 @@ docker compose up -d --build
|
||||
生产 Compose 会自动:
|
||||
|
||||
- 启动 PostgreSQL
|
||||
- 启动 OCR worker
|
||||
- 启动后端并执行 migration
|
||||
- 初始化默认后台管理员
|
||||
- 同步履约目录
|
||||
- 通过 Caddy 暴露前端和 API
|
||||
|
||||
查看生产日志:
|
||||
|
||||
```bash
|
||||
docker compose logs -f backend
|
||||
docker compose logs -f web
|
||||
```
|
||||
|
||||
## 开发数据脚本
|
||||
|
||||
后端提供开发数据脚本:
|
||||
|
||||
```bash
|
||||
docker compose -f docker-compose.dev.yml exec -T backend npm run seed:dev-data
|
||||
docker compose -f docker-compose.dev.yml exec -T backend npm run cleanup:dev-data
|
||||
```
|
||||
|
||||
脚本默认有预览/确认语义,执行前注意终端输出说明。
|
||||
|
||||
## 主要业务模块
|
||||
|
||||
- `apps/backend/src/routes`:API 路由
|
||||
- `apps/backend/src/services/order`:订单、webhook、库存和履约匹配
|
||||
- `apps/backend/src/services/fulfillment`:快手 Cloud 等履约编排
|
||||
- `apps/backend/src/services/session`:腾讯登录、二维码、兑换和截图
|
||||
- `apps/backend/src/services/order`:订单、商品匹配和履约任务编排
|
||||
- `apps/backend/src/services/fulfillment`:快手 Cloud 履约编排
|
||||
- `apps/backend/src/services/admin`:后台读写、权限、配置管理
|
||||
- `apps/backend/src/services/platforms`:外部平台 API 封装
|
||||
- `apps/backend/src/repositories`:数据库访问
|
||||
- `apps/frontend/src/views/admin`:后台页面
|
||||
- `apps/frontend/src/views/claim`:用户领取页
|
||||
|
||||
## 排障
|
||||
|
||||
如果页面打不开:
|
||||
|
||||
```bash
|
||||
docker compose -f docker-compose.dev.yml ps
|
||||
docker compose -f docker-compose.dev.yml logs -f web
|
||||
```
|
||||
|
||||
如果后端未 ready:
|
||||
|
||||
```bash
|
||||
docker compose -f docker-compose.dev.yml logs -f backend
|
||||
curl http://localhost/health/ready
|
||||
```
|
||||
|
||||
如果浏览器兑换或扫码异常:
|
||||
|
||||
```bash
|
||||
docker compose -f docker-compose.dev.yml logs -f backend
|
||||
open http://127.0.0.1:6080/vnc.html
|
||||
```
|
||||
|
||||
如果前端类型或 lint 失败:
|
||||
|
||||
```bash
|
||||
docker compose -f docker-compose.dev.yml exec -T frontend npm run typecheck
|
||||
docker compose -f docker-compose.dev.yml exec -T frontend npm run lint
|
||||
```
|
||||
|
||||
如果后端单测失败:
|
||||
|
||||
```bash
|
||||
docker compose -f docker-compose.dev.yml exec -T backend npm test
|
||||
```
|
||||
|
||||
## 提交前检查清单
|
||||
|
||||
```bash
|
||||
@@ -249,4 +181,3 @@ docker compose -f docker-compose.dev.yml exec -T frontend npm run lint
|
||||
- `.env` 未被提交
|
||||
- 数据库 migration 与代码一起提交
|
||||
- 领取链接相关改动已在 Docker 环境验证
|
||||
- 涉及 Playwright/OCR 的改动已看过 noVNC 或相关日志
|
||||
|
||||
@@ -12,3 +12,6 @@ data/logs/
|
||||
data/*.db
|
||||
data/*.db-shm
|
||||
data/*.db-wal
|
||||
data/*.json
|
||||
!data/*.example.json
|
||||
data/cloudtentacles-session.json
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"semi": false,
|
||||
"singleQuote": true,
|
||||
"printWidth": 100,
|
||||
"trailingComma": "all"
|
||||
}
|
||||
+37
-102
@@ -1,13 +1,8 @@
|
||||
# order-site-backend
|
||||
|
||||
腾讯活动浏览器会话后端。
|
||||
快手轻量后端服务。
|
||||
|
||||
当前主线只有一条:
|
||||
|
||||
- 后端托管 Playwright 浏览器
|
||||
- 前端展示二维码并轮询会话状态
|
||||
- 登录完成后,后端直接在活动页填写 CDK、识别验证码、执行兑换
|
||||
- 兑换完成后返回状态,并按配置决定是否生成截图/证明产物
|
||||
后端负责订单入库、履约任务编排、快手 Cloud / 91 卡券 / 快手小店核销配置、后台管理接口和数据库迁移。默认入口就是快手轻量后端。
|
||||
|
||||
## 快速启动
|
||||
|
||||
@@ -20,21 +15,18 @@ npm install
|
||||
复制并填写环境变量后启动:
|
||||
|
||||
```bash
|
||||
cp /Users/yml/codes/order-site-workspace/.env.mac-docker.example /Users/yml/codes/order-site-workspace/.env
|
||||
cd /Users/yml/codes/order-site-workspace/apps/backend
|
||||
cp ../../.env.mac-docker.example ../../.env
|
||||
npm run dev
|
||||
```
|
||||
|
||||
Linux 部署:
|
||||
生产构建:
|
||||
|
||||
```bash
|
||||
npm install
|
||||
npm run build
|
||||
npm run browser:install:linux
|
||||
npm run start
|
||||
```
|
||||
|
||||
后端渐进 TypeScript 迁移已经进入源码和构建双轨模式:
|
||||
常用验证:
|
||||
|
||||
```bash
|
||||
npm run typecheck
|
||||
@@ -42,120 +34,63 @@ npm run build
|
||||
npm test
|
||||
```
|
||||
|
||||
当前约定:
|
||||
|
||||
- 开发:`npm run dev` 使用 `tsx watch` 直接运行 `src`
|
||||
- 数据库迁移:`npm run db:migrate` 使用 `tsx`
|
||||
- 生产:`npm run build` 输出 `dist`,`npm run start` 运行 `dist/index.js`
|
||||
- 新增或迁移后端模块优先使用 `.ts`
|
||||
|
||||
## 配置
|
||||
|
||||
当前后端采用 `.env` 单一路径做环境配置:
|
||||
后端采用工作区根目录 `.env` 做环境配置:
|
||||
|
||||
- 通用默认值:`src/config/defaults.ts`
|
||||
- 本地/部署覆盖:工作区根目录 `.env`
|
||||
|
||||
当前最常改的配置有:
|
||||
常用配置:
|
||||
|
||||
- 服务端口
|
||||
- 服务端口 `PORT`(Docker 部署中由根目录 `.env` 的 `BACKEND_PORT` 映射)
|
||||
- 日志级别 `LOG_LEVEL=debug|info|warn|error`
|
||||
- 浏览器是否无头、是否预热、是否常驻、slowMo
|
||||
- OCR 外部服务镜像 `OCR_IMAGE` 与访问地址 `OCR_BASE_URL`
|
||||
- 会话调试开关
|
||||
- 兑换证明模式 `full | basic | off`
|
||||
- Agiso 全局配置与后台店铺文件配置
|
||||
|
||||
### Agiso 多店铺
|
||||
|
||||
当前支持同一个 Agiso 应用下配置多个店铺的消息 token。
|
||||
|
||||
- 全局兜底配置仍走 `.env`:
|
||||
- `AGISO_APP_SECRET`
|
||||
- `AGISO_MESSAGING_ENABLED`
|
||||
- 默认消息模板与店铺级覆盖配置改为文件:
|
||||
- `apps/backend/data/agiso-shops.json`
|
||||
- 后台维护入口:
|
||||
- `#/admin/platform-shops`
|
||||
|
||||
消息发送时会按 webhook 识别出的 `shop_id` 读取 `agiso-shops.json` 中的店铺配置;模板未命中时,会先回退到文件里的 `defaults`,再回退到系统内置默认值。店铺的 `accessToken` 也需要在文件中维护,不再使用全局 `.env` 兜底。
|
||||
- 数据库连接 `DATABASE_URL`
|
||||
- 后台登录密钥 `ADMIN_SESSION_SECRET`
|
||||
- 默认后台用户 `ADMIN_DEFAULT_USERS_JSON`
|
||||
- 91 卡券开放接口凭据 `KAQUAN91_USER_ID` / `KAQUAN91_SECRET`
|
||||
|
||||
## 接口
|
||||
|
||||
- `POST /api/v1/tencent/browser/session`
|
||||
创建浏览器会话,返回 `sessionId`、二维码和初始状态
|
||||
- `GET /api/v1/tencent/browser/session/:sessionId`
|
||||
返回完整会话状态
|
||||
- `GET /api/v1/tencent/browser/session/:sessionId/summary`
|
||||
轮询用轻量摘要接口,默认不返回二维码 base64
|
||||
- `POST /api/v1/tencent/browser/session/:sessionId/refresh`
|
||||
强制刷新后端活动页
|
||||
- `POST /api/v1/tencent/browser/session/:sessionId/redeem`
|
||||
执行兑换
|
||||
- `GET /api/v1/tencent/browser/session/:sessionId/screenshot`
|
||||
读取最近一次兑换截图
|
||||
- `DELETE /api/v1/tencent/browser/session/:sessionId`
|
||||
关闭浏览器会话
|
||||
- `GET /health`:健康检查
|
||||
- `GET /health/live`:存活检查
|
||||
- `GET /health/ready`:就绪检查
|
||||
- `/api/v1/open/91/*`:91 卡券开放回调
|
||||
- `/api/v1/claim/*`:领取页接口
|
||||
- `/api/v1/admin/*`:后台管理接口
|
||||
|
||||
## 目录
|
||||
|
||||
- `src/index.ts`
|
||||
Express 入口
|
||||
- `src/routes/tencent.ts`
|
||||
接口路由
|
||||
- `src/services/session/session.ts`
|
||||
会话编排与浏览器生命周期
|
||||
- `src/services/session/*.ts`
|
||||
登录、兑换、OCR、凭证产物等模块
|
||||
- `src/index.ts`:服务入口
|
||||
- `src/app.ts`:Express 应用组装
|
||||
- `src/routes`:API 路由
|
||||
- `src/services/order`:订单、商品匹配和履约任务编排
|
||||
- `src/services/fulfillment`:快手 Cloud 履约编排
|
||||
- `src/services/admin`:后台读写、权限、配置管理
|
||||
- `src/repositories`:数据库访问
|
||||
- `src/db/migrations`:新库初始化脚本
|
||||
|
||||
## Docker 开发
|
||||
|
||||
当前 `docker-compose.dev.yml` 已切到源码挂载模式:
|
||||
开发版 Compose 会把源码挂载进容器:
|
||||
|
||||
- `postgres` 容器提供开发库,后端通过 `DATABASE_URL` 连接
|
||||
- `ocr-worker` 使用外部镜像启动,后端通过 `OCR_BASE_URL=http://ocr-worker:8100` 调用
|
||||
- 后端容器启动时执行 `npm install` 和 `npm run dev`
|
||||
- 平时改 `src/` 或 `.env`,一般都不需要重建镜像
|
||||
- 生产镜像会在 build 阶段编译后端并只复制 `dist`、`config` 和运行依赖
|
||||
- 后端容器启动时执行依赖安装和 `npm run dev`
|
||||
- 平时改 `src/` 或 `.env`,一般不需要重建镜像
|
||||
|
||||
只有下面几类改动通常还需要 `docker compose -f docker-compose.dev.yml up -d --build backend`:
|
||||
只有下面几类改动通常还需要重新构建 backend:
|
||||
|
||||
- `deploy/docker/backend.Dockerfile`
|
||||
- 系统层依赖
|
||||
- Playwright 浏览器基础环境
|
||||
- `deploy/docker/backend*.Dockerfile`
|
||||
- Node 版本或系统层依赖
|
||||
- `package.json` / `package-lock.json`
|
||||
|
||||
## 产物
|
||||
|
||||
浏览器会话产物默认保存在:
|
||||
|
||||
- `data/browser-sessions/<sessionId>/qq-qr.png`
|
||||
- `data/browser-sessions/<sessionId>/session.json`
|
||||
- `data/browser-sessions/<sessionId>/captcha-attempt-*.png`
|
||||
- `data/browser-sessions/<sessionId>/redeem-result.png`
|
||||
- `data/browser-sessions/<sessionId>/page.html`
|
||||
- `data/browser-sessions/<sessionId>/result.json`
|
||||
|
||||
这些都是运行时产物,默认不提交 Git。
|
||||
## 运行产物
|
||||
|
||||
运行日志默认保存在:
|
||||
|
||||
- `data/logs/app-YYYY-MM-DD.log`
|
||||
- `data/logs/webhook-YYYY-MM-DD.log`
|
||||
- `data/logs/integration-YYYY-MM-DD.log`
|
||||
|
||||
日志按天切分,默认自动清理 7 天前的旧日志。
|
||||
成功的 `/health`、`/health/live`、`/health/ready` 探活请求默认不写 access log。
|
||||
日志按天切分,默认自动清理 7 天前的旧日志。成功的 `/health`、`/health/live`、`/health/ready` 探活请求默认不写 access log。
|
||||
|
||||
`LOG_LEVEL` 默认是 `info`:
|
||||
|
||||
- `debug`:输出最详细的调试日志
|
||||
- `info`:输出启动、请求、业务成功/失败等常规日志
|
||||
- `warn`:只保留告警和错误
|
||||
- `error`:只保留错误
|
||||
|
||||
OCR 识别通过 `OCR_BASE_URL` 调用 HTTP 服务。Docker Compose 会启动 `ocr-worker` 外部镜像并注入默认地址 `http://ocr-worker:8100`;如果单独运行后端且未配置该地址,启动健康检查会标记 OCR 为 degraded,兑换中需要 OCR 的流程会在调用时返回明确错误。
|
||||
|
||||
`TENCENT_REDEEM_PROOF_MODE` 对应的配置文件项会影响生成强度:
|
||||
|
||||
- `full`:完整证明
|
||||
- `basic`:只保留最终截图和结果 JSON
|
||||
- `off`:不生成证明文件
|
||||
`data/*.json` 为本地运行配置,可能包含账号、Cookie、token 或平台配置,默认不提交;请复制 `data/*.example.json` 后填写真实值。
|
||||
|
||||
@@ -1,23 +0,0 @@
|
||||
{
|
||||
"defaults": {
|
||||
"messageTemplate": "您的订单 {platformOrderId} 已创建领取链接,请您复制链接到浏览器自动兑换,请尽快打开并完成领取:\n\n{claimUrl}",
|
||||
"autoDeliveryMessageTemplate": "亲亲,您购买的兑换码已自行兑换成功,请在游戏中邮件领取,感谢您的支持"
|
||||
},
|
||||
"shops": {
|
||||
"693760716": {
|
||||
"enabled": true,
|
||||
"shopName": "大锤商行",
|
||||
"accessToken": "AldsIdlefmykv7w6xdvgf9r2ymsxrmznux74tyaehc9pwygdnrgr4"
|
||||
},
|
||||
"2037495620": {
|
||||
"enabled": true,
|
||||
"shopName": "羊小胖。",
|
||||
"accessToken": "AldsIdle4925bh8c9n3ka79fxauc9egk7uea6f6frfbbb5btvxpvu"
|
||||
},
|
||||
"2209880145223": {
|
||||
"enabled": true,
|
||||
"shopName": "大锤号商",
|
||||
"accessToken": "AldsIdleu5rzb68g8gh32sk9e8rbunes6gwyg59aph2nn5em6fp4r"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,22 +0,0 @@
|
||||
{
|
||||
"sessions": {
|
||||
"default": {
|
||||
"token": "P1ifGvDcxYchECzx51cqL2TzBkntjQtRwxpetU7RDMMjsSCmC8kHU751bJ4rJMSSjQN73QveARqGLIWe078nKvnA",
|
||||
"baseUrl": "https://123.207.217.176",
|
||||
"username": "17665234375",
|
||||
"phone": "17665234375",
|
||||
"loggedInAt": "2026-05-25T06:48:25.301Z",
|
||||
"deviceId": "-",
|
||||
"deviceType": 0
|
||||
},
|
||||
"account2": {
|
||||
"token": "KU2draP6Rm7AQDIeN0SSIVeNBkdMdGODlN1ToNhAo0wYk6clo9EeHh8fE5cN2kIhxThC30gCZXZl6LQfs19K03S9",
|
||||
"baseUrl": "https://123.207.217.176",
|
||||
"username": "18216161068",
|
||||
"phone": "18216161068",
|
||||
"loggedInAt": "2026-05-25T06:38:36.698Z",
|
||||
"deviceId": "-",
|
||||
"deviceType": 0
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
{
|
||||
"enabled": true,
|
||||
"sources": [
|
||||
{
|
||||
"key": "default",
|
||||
"label": "默认账号",
|
||||
"enabled": true,
|
||||
"baseUrl": "https://example.com",
|
||||
"username": "",
|
||||
"password": "",
|
||||
"phone": "",
|
||||
"deviceId": "08bc9d8c-fd15-48ea-bc00-8d754076cafc",
|
||||
"deviceType": 1
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -1,27 +0,0 @@
|
||||
{
|
||||
"enabled": true,
|
||||
"sources": [
|
||||
{
|
||||
"key": "default",
|
||||
"label": "默认账号",
|
||||
"enabled": true,
|
||||
"baseUrl": "https://123.207.217.176",
|
||||
"username": "17665234375",
|
||||
"password": "yaochao11",
|
||||
"phone": "17665234375",
|
||||
"deviceId": "-",
|
||||
"deviceType": 0
|
||||
},
|
||||
{
|
||||
"key": "account2",
|
||||
"label": "备用账号 1",
|
||||
"enabled": true,
|
||||
"baseUrl": "https://123.207.217.176",
|
||||
"username": "18216161068",
|
||||
"password": "yaochao11",
|
||||
"phone": "18216161068",
|
||||
"deviceId": "-",
|
||||
"deviceType": 0
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -1,85 +0,0 @@
|
||||
{
|
||||
"enabled": true,
|
||||
"items": [
|
||||
{
|
||||
"id": "套装-浪漫天命",
|
||||
"enabled": true,
|
||||
"priority": 100,
|
||||
"provider": "91kaquan",
|
||||
"platform": "kuaishou",
|
||||
"shopId": "91kaquan",
|
||||
"internalSkuCode": "套装-浪漫天命",
|
||||
"internalSkuName": "183999074512936",
|
||||
"externalSkuCode": "183999074512936",
|
||||
"externalItemId": "183999074512936",
|
||||
"externalSkuName": "183999074512936",
|
||||
"resolvedSkuName": "183999074512936",
|
||||
"cloudSourceKey": "default",
|
||||
"cloudSourceKeyFallbacks": [
|
||||
"account2"
|
||||
],
|
||||
"cloudSkuId": 28,
|
||||
"cloudSkuName": "套装-浪漫天命",
|
||||
"vnKey": "1",
|
||||
"autoBuyEnabled": true,
|
||||
"minAssetReserve": 0,
|
||||
"autoReturnNumberAfterDispatch": true,
|
||||
"autoConsumeAfterDispatch": true,
|
||||
"kuaishouConsumeShopId": "3676797936",
|
||||
"kuaishouConsumeShopName": "小鱼儿游戏服务店",
|
||||
"notes": "从 91卡券订单 2614500561157561 导入"
|
||||
},
|
||||
{
|
||||
"id": "套装-浪漫天命",
|
||||
"enabled": true,
|
||||
"priority": 100,
|
||||
"provider": "91kaquan",
|
||||
"platform": "kuaishou",
|
||||
"shopId": "3676797936",
|
||||
"internalSkuCode": "套装-浪漫天命",
|
||||
"internalSkuName": "183999074511936",
|
||||
"externalSkuCode": "183999074511936",
|
||||
"externalItemId": "183999074511936",
|
||||
"externalSkuName": "183999074511936",
|
||||
"resolvedSkuName": "183999074511936",
|
||||
"cloudSourceKey": "default",
|
||||
"cloudSourceKeyFallbacks": [],
|
||||
"cloudSkuId": 28,
|
||||
"cloudSkuName": "套装-浪漫天命",
|
||||
"vnKey": "1",
|
||||
"autoBuyEnabled": true,
|
||||
"minAssetReserve": 0,
|
||||
"autoReturnNumberAfterDispatch": true,
|
||||
"autoConsumeAfterDispatch": true,
|
||||
"kuaishouConsumeShopId": "3676797936",
|
||||
"kuaishouConsumeShopName": "小鱼儿游戏服务店",
|
||||
"notes": "从 91卡券订单 2613900255300561 导入"
|
||||
},
|
||||
{
|
||||
"id": "91卡券订单取样导入",
|
||||
"enabled": true,
|
||||
"priority": 100,
|
||||
"provider": "91kaquan",
|
||||
"platform": "kuaishou",
|
||||
"shopId": "91kaquan",
|
||||
"internalSkuCode": "套装-浪漫天命",
|
||||
"internalSkuName": "套装-浪漫天命",
|
||||
"externalSkuCode": "183999074510936",
|
||||
"externalItemId": "183999074510936",
|
||||
"externalSkuName": "183999074510936",
|
||||
"resolvedSkuName": "183999074510936",
|
||||
"cloudSourceKey": "default",
|
||||
"cloudSourceKeyFallbacks": [],
|
||||
"cloudSkuId": 28,
|
||||
"cloudSkuName": "套装-浪漫天命",
|
||||
"vnKey": "1",
|
||||
"autoBuyEnabled": true,
|
||||
"minAssetReserve": 0,
|
||||
"autoReturnNumberAfterDispatch": true,
|
||||
"autoConsumeAfterDispatch": true,
|
||||
"kuaishouConsumeShopId": "3676797936",
|
||||
"kuaishouConsumeShopName": "小鱼儿游戏服务店",
|
||||
"notes": "从 91卡券订单 2613300010204561 导入"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"enabled": true,
|
||||
"baseUrl": "https://s.kwaixiaodian.com",
|
||||
"shops": [
|
||||
{
|
||||
"shopId": "",
|
||||
"kshopName": "",
|
||||
"cookie": "",
|
||||
"userAvatar": "",
|
||||
"enabled": false
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -1,20 +0,0 @@
|
||||
{
|
||||
"enabled": true,
|
||||
"baseUrl": "https://s.kwaixiaodian.com",
|
||||
"shops": [
|
||||
{
|
||||
"shopId": "3676797936",
|
||||
"kshopName": "小鱼儿游戏服务店",
|
||||
"cookie": "_did=web_977722931F45F3C4; did=web_mxsox80uwsj3o4bkdsi2xhbxnx5gdlpn; sid=kshop.api; kshop.api_st=Cgxrc2hvcC5hcGkuc3QSoAHnDz5KbyPrdR8uWNB0MspVtX2zUOBrDcysluKZ9P6NT0IaEtnV-24otPiLyxcbH1iStuNg84bSxClfdu63cgMKzi0AovJn5DFqJUjXPfJZ1XxL8xDXOIdvv73SVr0eQhPKrD_twqnWJu-1HLgxl294XMhDPhO8JbNcFhv_cOwBsxjZYUNMs_7kpRHIpDEU_adQH0zQOBVQYu4AzU0rhF2EGhI9zclILZeuwZLmGG1mEKpcYGMiIOkT8avvcn-qSsIRdLyBf0U1Fjoud_FP9qcCFMjohN1AKAUwAQ; kshop.api_ph=a5c01f4da50e7eda8facb70fd1b13af57eb3; bUserId=1000324969453; userId=3676797936; kuaishou.shop.b_st=ChJrdWFpc2hvdS5zaG9wLmIuc3QSoAHbRIrpTvTjW-nbSsGDVimfmFK01ohV83Wh1v9vCN89z4-bJqpa_r7jmXBgL53hZ0OD5Y8Jz5fUPxWT-6PYNCfLVB20XogIDibxfXXjJSW5d4nchty1_veIl6r4NsUhHu0N93XRsx8vUxfQvAwlczsvefFqvPeZ3iAQnACDEVqJ7m7oI5BK9MCobzGGGuznAgtY8l0Z10qy3e5WmPgiSaI4GhLSubyi-uEBAthqCoUoWxNxjR0iINJ8MTa8OcsVUjQ5AuIuH9BtGr4JuS89zMjEz3McjsjmKAUwAQ; kuaishou.shop.b_ph=0a29c066148190b0759c776da8e94775e7dd",
|
||||
"userAvatar": "https://p2-pro.a.yximgs.com/uhead/AB/2024/11/17/20/BMjAyNDExMTcyMDQ3NTdfMzY3Njc5NzkzNl8yX2hkODUyXzIwNg==_s.jpg",
|
||||
"enabled": true
|
||||
},
|
||||
{
|
||||
"shopId": "4269276762",
|
||||
"kshopName": "稚嫩游戏交易店",
|
||||
"cookie": "_did=web_1426300268274CD9; did=web_ozkrrsevhb6yuztyk6m63vqqi5h2pzfl; __risk_web_device_id=cd52e12c1773406609757e71; sid=kuaishou.shop.b; sellerId=4269276762; bUserId=1000492186791; userId=4269276762; merchantSellerId=4269276762; merchantSessionKey=1777621574120_fbd47024d23e73fc4ad1a157ffece433; kuaishou.shop.b_st=ChJrdWFpc2hvdS5zaG9wLmIuc3QSoAFzGnewF-wCpt4rHi-D-pp8zPUp7HByKY0MlHkw-WV676zG7z09Y5i7d7URwmGC8CL13jN0fOGV7d3GfaH2XLH8d4p60z0Ms-dIX0bipkI0yUWHfgnWqv9VPa8HqVRqMLs2VGD82X9tAR3eN9PXu38AYg0a5kpawchGPb2tjcYgLgH5b5DnJgjipHqzWb8Ma8AcLOmryqCSg4ZHwO5dbVJHGhIdwFBlxRpZgyh6i0Do-DSRsroiIIayd1xA628OsvCY2qV1HjCrTvMVt-3LmXcU6rLKSzytKAUwAQ; kuaishou.shop.b_ph=38925ccb882ec5456dd1c6bcee3f87b7e740",
|
||||
"userAvatar": "https://p2-pro.a.yximgs.com/uhead/AB/2024/07/23/17/BMjAyNDA3MjMxNzA1MThfNDI2OTI3Njc2Ml8yX2hkNDg1XzgxNQ==_s.jpg",
|
||||
"enabled": true
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"enabled": true,
|
||||
"channels": {
|
||||
"bark": {
|
||||
"enabled": true,
|
||||
"serverUrl": "https://api.day.app",
|
||||
"recipients": []
|
||||
},
|
||||
"wpush": {
|
||||
"enabled": true,
|
||||
"recipients": []
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,40 +0,0 @@
|
||||
{
|
||||
"enabled": true,
|
||||
"channels": {
|
||||
"bark": {
|
||||
"enabled": true,
|
||||
"serverUrl": "https://api.day.app",
|
||||
"recipients": [
|
||||
{
|
||||
"id": "cdab629b-0480-4741-a4b5-4c11fb8fbc6b",
|
||||
"name": "弋宸先生",
|
||||
"deviceKey": "NaiQzbZkJTcaW23VWU9AXf",
|
||||
"enabled": true
|
||||
},
|
||||
{
|
||||
"id": "82af4cb0-f6b2-4636-b1ca-29f482c49b30",
|
||||
"name": "yml-测试1",
|
||||
"deviceKey": "5EdwoMkrpuAfNLZN8aVrh5",
|
||||
"enabled": true
|
||||
}
|
||||
]
|
||||
},
|
||||
"wpush": {
|
||||
"enabled": true,
|
||||
"recipients": [
|
||||
{
|
||||
"id": "1c1089bb-4a54-4ec9-a10f-15b8099fdfb0",
|
||||
"name": "y",
|
||||
"apiKey": "WPUSHcPSmgodKUwMCB13IFSHJsyBqUI4",
|
||||
"enabled": true
|
||||
},
|
||||
{
|
||||
"id": "5affc7d0-3944-43ee-90cb-8242ab9b34e7",
|
||||
"name": "yml",
|
||||
"apiKey": "WPUSHsa44qjF35klEZRqVHxAouoDE2A6",
|
||||
"enabled": true
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,42 +0,0 @@
|
||||
[
|
||||
{
|
||||
"provider": "agiso",
|
||||
"platform": "xianyu",
|
||||
"shopId": "2209880145223",
|
||||
"shopName": "",
|
||||
"skuCode": "海底捞第五人格皮肤",
|
||||
"skuName": "海底捞第五人格皮肤",
|
||||
"profileKey": "tencent_claim_redeem",
|
||||
"enabled": false,
|
||||
"priority": 100,
|
||||
"config": {},
|
||||
"match": {
|
||||
"externalSkuCode": "6227949975509",
|
||||
"externalItemId": "1038123159888",
|
||||
"externalSkuName": "海底捞第五人格皮肤",
|
||||
"config": {
|
||||
"resolvedSkuName": "海底捞第五人格皮肤"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"provider": "agiso",
|
||||
"platform": "xianyu",
|
||||
"shopId": "2209880145223",
|
||||
"shopName": "",
|
||||
"skuCode": "三角洲海底捞动作",
|
||||
"skuName": "海底捞干员庆生动作1个",
|
||||
"profileKey": "tencent_claim_redeem",
|
||||
"enabled": true,
|
||||
"priority": 100,
|
||||
"config": {},
|
||||
"match": {
|
||||
"externalSkuCode": "6063968816746",
|
||||
"externalItemId": "1041598483721",
|
||||
"externalSkuName": "海底捞干员庆生动作1个",
|
||||
"config": {
|
||||
"resolvedSkuName": "海底捞干员庆生动作1个"
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
+2
-8
@@ -4,8 +4,8 @@
|
||||
{
|
||||
"id": "cloudtentacles-health",
|
||||
"type": "cloudtentacles_health",
|
||||
"enabled": true,
|
||||
"intervalSeconds": 18000,
|
||||
"enabled": false,
|
||||
"intervalSeconds": 300,
|
||||
"cooldownSeconds": 1800,
|
||||
"config": {
|
||||
"assetThreshold": 500,
|
||||
@@ -14,12 +14,6 @@
|
||||
"sourceKey": "default",
|
||||
"label": "默认账号",
|
||||
"enabled": true,
|
||||
"assetThreshold": 100
|
||||
},
|
||||
{
|
||||
"sourceKey": "account2",
|
||||
"label": "备用账号 1",
|
||||
"enabled": true,
|
||||
"assetThreshold": 500
|
||||
}
|
||||
]
|
||||
Generated
+18
-46
@@ -9,13 +9,13 @@
|
||||
"version": "0.1.0",
|
||||
"dependencies": {
|
||||
"express": "^5.1.0",
|
||||
"pg": "^8.16.3",
|
||||
"playwright": "1.42.1"
|
||||
"pg": "^8.16.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/express": "^5.0.6",
|
||||
"@types/node": "^25.6.0",
|
||||
"@types/pg": "^8.20.0",
|
||||
"prettier": "^3.8.3",
|
||||
"tsx": "^4.22.3",
|
||||
"typescript": "^6.0.2"
|
||||
}
|
||||
@@ -911,20 +911,6 @@
|
||||
"node": ">= 0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/fsevents": {
|
||||
"version": "2.3.2",
|
||||
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz",
|
||||
"integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==",
|
||||
"hasInstallScript": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
],
|
||||
"engines": {
|
||||
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/function-bind": {
|
||||
"version": "1.1.2",
|
||||
"resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz",
|
||||
@@ -1275,36 +1261,6 @@
|
||||
"split2": "^4.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/playwright": {
|
||||
"version": "1.42.1",
|
||||
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.42.1.tgz",
|
||||
"integrity": "sha512-PgwB03s2DZBcNRoW+1w9E+VkLBxweib6KTXM0M3tkiT4jVxKSi6PmVJ591J+0u10LUrgxB7dLRbiJqO5s2QPMg==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"playwright-core": "1.42.1"
|
||||
},
|
||||
"bin": {
|
||||
"playwright": "cli.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=16"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"fsevents": "2.3.2"
|
||||
}
|
||||
},
|
||||
"node_modules/playwright-core": {
|
||||
"version": "1.42.1",
|
||||
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.42.1.tgz",
|
||||
"integrity": "sha512-mxz6zclokgrke9p1vtdy/COWBH+eOZgYUVVU34C73M+4j4HLlQJHtfcqiqqxpP0o8HhMkflvfbquLX5dg6wlfA==",
|
||||
"license": "Apache-2.0",
|
||||
"bin": {
|
||||
"playwright-core": "cli.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=16"
|
||||
}
|
||||
},
|
||||
"node_modules/postgres-array": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/postgres-array/-/postgres-array-2.0.0.tgz",
|
||||
@@ -1344,6 +1300,22 @@
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/prettier": {
|
||||
"version": "3.8.3",
|
||||
"resolved": "https://registry.npmjs.org/prettier/-/prettier-3.8.3.tgz",
|
||||
"integrity": "sha512-7igPTM53cGHMW8xWuVTydi2KO233VFiTNyF5hLJqpilHfmn8C8gPf+PS7dUT64YcXFbiMGZxS9pCSxL/Dxm/Jw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"bin": {
|
||||
"prettier": "bin/prettier.cjs"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=14"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/prettier/prettier?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/proxy-addr": {
|
||||
"version": "2.0.7",
|
||||
"resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz",
|
||||
|
||||
@@ -4,14 +4,13 @@
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"browser:install": "playwright install chromium",
|
||||
"browser:install:linux": "playwright install --with-deps chromium",
|
||||
"agiso:auto-delivery:test": "tsx scripts/test-agiso-auto-delivery.ts",
|
||||
"cleanup:dev-data": "tsx scripts/cleanup-dev-data.ts",
|
||||
"seed:dev-data": "tsx scripts/seed-dev-data.ts",
|
||||
"build": "tsc -p tsconfig.build.json && rm -rf dist/db/migrations && mkdir -p dist/db && cp -R src/db/migrations dist/db/migrations",
|
||||
"db:migrate": "tsx src/db/migrate.ts",
|
||||
"dev": "tsx watch --clear-screen=false src/index.ts",
|
||||
"format": "prettier --write .",
|
||||
"mock:claim": "tsx scripts/mock-kuaishou-cloud-claim.ts",
|
||||
"mock:open91": "tsx scripts/mock-open91-order.ts",
|
||||
"test": "node --import tsx --test $(find src \\( -name '*.test.ts' -o -name '*.test.js' \\) -print)",
|
||||
"typecheck": "tsc -p tsconfig.json --noEmit",
|
||||
"start": "node dist/index.js",
|
||||
@@ -19,13 +18,13 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"express": "^5.1.0",
|
||||
"pg": "^8.16.3",
|
||||
"playwright": "1.42.1"
|
||||
"pg": "^8.16.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/express": "^5.0.6",
|
||||
"@types/node": "^25.6.0",
|
||||
"@types/pg": "^8.20.0",
|
||||
"prettier": "^3.8.3",
|
||||
"tsx": "^4.22.3",
|
||||
"typescript": "^6.0.2"
|
||||
}
|
||||
|
||||
@@ -6,16 +6,12 @@ import { closeDb, query } from '../src/db/client.js'
|
||||
import { runtimeConfig } from '../src/config/runtime.js'
|
||||
|
||||
const RESET_TABLES = [
|
||||
'message_deliveries',
|
||||
'tencent_browser_contexts',
|
||||
'task_runtime_contexts',
|
||||
'task_events',
|
||||
'claim_tokens',
|
||||
'task_inventory_bindings',
|
||||
'fulfillment_tasks',
|
||||
'order_items',
|
||||
'orders',
|
||||
'webhook_events',
|
||||
'inventory_items',
|
||||
]
|
||||
|
||||
const options = parseArgs(process.argv.slice(2))
|
||||
|
||||
@@ -0,0 +1,444 @@
|
||||
import process from 'node:process'
|
||||
|
||||
import { closeDb } from '../src/db/client.js'
|
||||
import { createOrder, findOrderByPlatformOrderId } from '../src/repositories/order-repo.js'
|
||||
import { replaceOrderItems } from '../src/repositories/order-item-repo.js'
|
||||
import { createTask, updateTask } from '../src/repositories/task-repo.js'
|
||||
import {
|
||||
getFulfillmentProfileByKey,
|
||||
upsertFulfillmentProfile,
|
||||
} from '../src/repositories/fulfillment-profile-repo.js'
|
||||
import { buildClaimUrl, createTaskClaimToken } from '../src/services/claim/claim-service.js'
|
||||
import { OPEN_91_PLATFORM, OPEN_91_PROVIDER } from '../src/services/open-91/config.js'
|
||||
import { parseOpen91ProductNo } from '../src/services/platforms/ninetyone/order-service.js'
|
||||
import { isProductionLike } from '../src/config/runtime-validation.js'
|
||||
import { addHours, nowIso } from '../src/utils/time.js'
|
||||
import { randomId } from '../src/utils/random.js'
|
||||
|
||||
type JsonObject = Record<string, unknown>
|
||||
type DeliveryItem = {
|
||||
cloudSkuId: number
|
||||
cloudSkuName: string
|
||||
quantity: number
|
||||
}
|
||||
|
||||
const args = parseArgs(process.argv.slice(2))
|
||||
|
||||
if (args.help) {
|
||||
printHelp()
|
||||
process.exit(0)
|
||||
}
|
||||
|
||||
if (isProductionLike() && args.force !== true) {
|
||||
console.error('当前是生产环境,已拒绝生成 mock 数据。如确实要执行,请追加 --force。')
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
const step = normalizeStep(args.step)
|
||||
const orderNo = String(args.orderNo || `MOCK91${Date.now()}`).trim()
|
||||
const productNo = String(args.productNo || '套餐_1----3676797936').trim()
|
||||
const productInfo = parseOpen91ProductNo(productNo)
|
||||
const productName = productInfo.productName || productInfo.rawProductNo || '套餐_1'
|
||||
const consumeShopId = productInfo.shopId || String(args.kuaishouShopId || '3676797936').trim()
|
||||
const deliveryItems = parseDeliveryItems(args.items)
|
||||
const now = nowIso()
|
||||
const existingOrder = await findOrderByPlatformOrderId({
|
||||
provider: OPEN_91_PROVIDER,
|
||||
platform: OPEN_91_PLATFORM,
|
||||
shopId: OPEN_91_PROVIDER,
|
||||
platformOrderId: orderNo,
|
||||
})
|
||||
|
||||
if (existingOrder) {
|
||||
console.error(`订单号已存在:${orderNo}`)
|
||||
console.error('请换一个 --orderNo,或不传 orderNo 让脚本自动生成。')
|
||||
await closeDb()
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
try {
|
||||
const profile = await ensureKuaishouCloudProfile(now)
|
||||
const order = await createOrder({
|
||||
provider: OPEN_91_PROVIDER,
|
||||
platform: OPEN_91_PLATFORM,
|
||||
shopId: OPEN_91_PROVIDER,
|
||||
shopName: '91卡券',
|
||||
platformOrderId: orderNo,
|
||||
orderStatus: 'paid',
|
||||
payStatus: 'paid',
|
||||
buyerId: 'mock-buyer',
|
||||
buyerName: 'mock 领取客户',
|
||||
receiverContact: '',
|
||||
totalAmount: 1,
|
||||
currency: 'CNY',
|
||||
rawPayloadJson: JSON.stringify({
|
||||
source: OPEN_91_PROVIDER,
|
||||
mock: true,
|
||||
receivedAt: now,
|
||||
body: {
|
||||
orderNo,
|
||||
productNo,
|
||||
productName,
|
||||
buyNum: 1,
|
||||
maxAmount: '0.01',
|
||||
},
|
||||
}),
|
||||
paidAt: now,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
})
|
||||
|
||||
if (!order) {
|
||||
throw new Error('订单创建失败')
|
||||
}
|
||||
|
||||
const [orderItem] = await replaceOrderItems(order.id, [{
|
||||
skuCode: productName,
|
||||
skuName: productName,
|
||||
quantity: 1,
|
||||
specJson: JSON.stringify({
|
||||
source: OPEN_91_PROVIDER,
|
||||
orderNo,
|
||||
productNo,
|
||||
productName,
|
||||
shopId: consumeShopId,
|
||||
}),
|
||||
itemSnapshotJson: JSON.stringify({
|
||||
source: OPEN_91_PROVIDER,
|
||||
orderNo,
|
||||
productNo,
|
||||
productName,
|
||||
shopId: consumeShopId,
|
||||
cloudtentacles: {
|
||||
matchMode: 'mock',
|
||||
normalizedProductName: productName,
|
||||
cloudSourceKeys: ['mock-cloudtentacles'],
|
||||
resolvedSourceKey: 'mock-cloudtentacles',
|
||||
deliveryItems,
|
||||
},
|
||||
}),
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
}])
|
||||
|
||||
if (!orderItem) {
|
||||
throw new Error('订单商品创建失败')
|
||||
}
|
||||
|
||||
const task = await createTask({
|
||||
orderId: order.id,
|
||||
orderItemId: orderItem.id,
|
||||
unitIndex: 1,
|
||||
provider: OPEN_91_PROVIDER,
|
||||
platform: OPEN_91_PLATFORM,
|
||||
shopId: OPEN_91_PROVIDER,
|
||||
shopName: '91卡券',
|
||||
platformOrderId: orderNo,
|
||||
taskNo: randomId('DT'),
|
||||
profileId: profile.id,
|
||||
executorKey: 'kuaishou_ct_assisted',
|
||||
taskStatus: resolveInitialTaskStatus(step),
|
||||
deliveryStatus: step === 'result' ? 'success' : 'pending',
|
||||
resultCode: step === 'result' ? 'mock_success' : '',
|
||||
resultMessage: step === 'result' ? '开发 mock 已模拟兑换成功' : '',
|
||||
claimToken: '',
|
||||
claimExpiresAt: null,
|
||||
automationMode: 'manual',
|
||||
requiresClaim: false,
|
||||
userActionStatus: step === 'result' ? 'not_required' : 'pending_claim',
|
||||
attemptCount: 0,
|
||||
lastError: '',
|
||||
contextJson: JSON.stringify(buildMockTaskContext({
|
||||
step,
|
||||
orderNo,
|
||||
productName,
|
||||
productNo,
|
||||
consumeShopId,
|
||||
deliveryItems,
|
||||
timestamp: now,
|
||||
})),
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
})
|
||||
|
||||
if (!task) {
|
||||
throw new Error('履约任务创建失败')
|
||||
}
|
||||
|
||||
const claimToken = await createTaskClaimToken(task.id)
|
||||
const claimUrl = buildClaimUrl(claimToken.token)
|
||||
await updateTask(task.id, {
|
||||
claim_token: claimToken.token,
|
||||
claim_expires_at: claimToken.expired_at,
|
||||
...(step !== 'ticket' ? { claimed_at: now } : {}),
|
||||
...(step === 'confirm' || step === 'result' ? { role_confirmed_at: now } : {}),
|
||||
...(step === 'result' ? { redeemed_at: now } : {}),
|
||||
updated_at: now,
|
||||
})
|
||||
|
||||
console.log('已生成快手 Cloud 领取 mock 数据:')
|
||||
console.log(` 91订单号:${orderNo}`)
|
||||
console.log(` 商品:${productName}`)
|
||||
console.log(` 当前步骤:${step}`)
|
||||
console.log(` 领取链接:${claimUrl}`)
|
||||
console.log(` 前端本地链接:${buildFrontendClaimUrl(claimToken.token, args.frontendBaseUrl)}`)
|
||||
console.log('')
|
||||
console.log('页面核销码可填:MOCK')
|
||||
console.log(`91 查询接口可用这个订单号测试:npm run mock:open91 -- --mode=query --orderNo=${orderNo}`)
|
||||
} finally {
|
||||
await closeDb()
|
||||
}
|
||||
|
||||
async function ensureKuaishouCloudProfile(timestamp: string) {
|
||||
const existing = await getFulfillmentProfileByKey('kuaishou_ct_assisted')
|
||||
if (existing) {
|
||||
return existing
|
||||
}
|
||||
|
||||
const profile = await upsertFulfillmentProfile({
|
||||
profileKey: 'kuaishou_ct_assisted',
|
||||
name: '快手 cloud 履约',
|
||||
executorKey: 'kuaishou_ct_assisted',
|
||||
requiresClaim: false,
|
||||
autoDispatch: false,
|
||||
inventoryStrategy: 'external_platform',
|
||||
configJson: '{}',
|
||||
createdAt: timestamp,
|
||||
updatedAt: timestamp,
|
||||
})
|
||||
|
||||
if (!profile) {
|
||||
throw new Error('履约配置创建失败')
|
||||
}
|
||||
|
||||
return profile
|
||||
}
|
||||
|
||||
function buildMockTaskContext({
|
||||
step,
|
||||
orderNo,
|
||||
productName,
|
||||
productNo,
|
||||
consumeShopId,
|
||||
deliveryItems,
|
||||
timestamp,
|
||||
}: {
|
||||
step: string
|
||||
orderNo: string
|
||||
productName: string
|
||||
productNo: string
|
||||
consumeShopId: string
|
||||
deliveryItems: DeliveryItem[]
|
||||
timestamp: string
|
||||
}) {
|
||||
const ticketVerified = ['binding', 'confirm', 'result'].includes(step)
|
||||
const roleReady = ['binding', 'confirm', 'result'].includes(step)
|
||||
const roleConfirmed = ['confirm', 'result'].includes(step)
|
||||
const completed = step === 'result'
|
||||
const primaryItem = deliveryItems[0] || {
|
||||
cloudSkuId: 910001,
|
||||
cloudSkuName: 'Mock 商品',
|
||||
quantity: 1,
|
||||
}
|
||||
|
||||
return {
|
||||
profileKey: 'kuaishou_ct_assisted',
|
||||
profileName: '快手 cloud 履约',
|
||||
skuCode: productName,
|
||||
skuName: productName,
|
||||
kuaishouCloudFulfillment: {
|
||||
flowType: 'kuaishou_cloud_fulfillment',
|
||||
configId: `mock:${productName}`,
|
||||
internalSkuCode: productName,
|
||||
internalSkuName: productName,
|
||||
deliveryItems,
|
||||
mock: {
|
||||
enabled: true,
|
||||
orderNo,
|
||||
productNo,
|
||||
createdAt: timestamp,
|
||||
},
|
||||
ticket: {
|
||||
code: ticketVerified ? `MOCK-${orderNo}` : '',
|
||||
status: ticketVerified ? 'verified' : 'pending',
|
||||
capturedAt: ticketVerified ? timestamp : null,
|
||||
capturedBy: ticketVerified ? { source: 'mock_script' } : null,
|
||||
verifiedAt: ticketVerified ? timestamp : null,
|
||||
oid: ticketVerified ? `MOCK-OID-${orderNo}` : '',
|
||||
formToken: ticketVerified ? `MOCK-FORM-${orderNo}` : '',
|
||||
leftCount: ticketVerified ? 1 : 0,
|
||||
goodsTitle: ticketVerified ? productName : '',
|
||||
},
|
||||
binding: {
|
||||
prepareStatus: roleReady ? 'ready' : 'pending',
|
||||
cloudSourceKeys: ['mock-cloudtentacles'],
|
||||
resolvedSourceKey: 'mock-cloudtentacles',
|
||||
skuId: primaryItem.cloudSkuId,
|
||||
skuName: primaryItem.cloudSkuName,
|
||||
vnKey: '1',
|
||||
vnId: roleReady ? 900001 : 0,
|
||||
vnPhone: roleReady ? '13800000000' : '',
|
||||
bindUrl: roleReady ? `https://example.com/mock-kuaishou-cloud-bind/${orderNo}` : '',
|
||||
bindPreparedAt: roleReady ? timestamp : null,
|
||||
bindExpiresAt: roleReady ? addHours(timestamp, 24) : null,
|
||||
bindProbeAt: roleReady ? timestamp : null,
|
||||
bindProbeStatus: roleReady ? 'success' : '',
|
||||
bindProbeMessage: '',
|
||||
roleName: roleReady ? '测试角色' : '',
|
||||
roleId: roleReady ? '10001' : '',
|
||||
},
|
||||
role: {
|
||||
status: roleReady ? 'ready' : 'pending',
|
||||
name: roleReady ? '测试角色' : '',
|
||||
rid: roleReady ? '10001' : '',
|
||||
refreshedAt: roleReady ? timestamp : null,
|
||||
errorMessage: '',
|
||||
rawInfo: roleReady ? { mock: true } : null,
|
||||
},
|
||||
purchase: {
|
||||
autoBuyEnabled: true,
|
||||
minAssetReserve: 0,
|
||||
usedKnapsack: false,
|
||||
purchaseTriggered: false,
|
||||
assetBefore: 0,
|
||||
assetAfter: 0,
|
||||
purchaseAt: null,
|
||||
items: [],
|
||||
},
|
||||
dispatch: {
|
||||
status: completed ? 'success' : 'pending',
|
||||
dispatchAt: completed ? timestamp : null,
|
||||
dispatchBy: completed ? { source: 'mock_script' } : null,
|
||||
sendType: 0,
|
||||
note: completed ? '开发 mock 已模拟发货成功' : '',
|
||||
items: completed ? deliveryItems : [],
|
||||
},
|
||||
returnNumber: {
|
||||
status: completed ? 'success' : 'pending',
|
||||
returnedAt: completed ? timestamp : null,
|
||||
returnedBy: completed ? { source: 'mock_script' } : null,
|
||||
autoReturnEnabled: true,
|
||||
},
|
||||
consume: {
|
||||
status: completed ? 'success' : 'pending',
|
||||
shopId: consumeShopId,
|
||||
shopName: 'Mock 快手小店',
|
||||
autoConsumeEnabled: true,
|
||||
consumedAt: completed ? timestamp : null,
|
||||
errorMessage: '',
|
||||
},
|
||||
notes: '开发 mock:不调用 cloudtentacles,只用于领取页流程验证',
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
function parseArgs(rawArgs: string[]) {
|
||||
const parsed: JsonObject = {}
|
||||
|
||||
for (const rawArg of rawArgs) {
|
||||
const arg = String(rawArg || '').trim()
|
||||
if (!arg) continue
|
||||
if (arg === '--help' || arg === '-h') {
|
||||
parsed.help = true
|
||||
continue
|
||||
}
|
||||
|
||||
const normalized = arg.startsWith('--') ? arg.slice(2) : arg
|
||||
const separatorIndex = normalized.indexOf('=')
|
||||
if (separatorIndex < 0) {
|
||||
parsed[normalized] = true
|
||||
continue
|
||||
}
|
||||
|
||||
const key = normalized.slice(0, separatorIndex).trim()
|
||||
const value = normalized.slice(separatorIndex + 1).trim()
|
||||
if (key) {
|
||||
parsed[key] = value
|
||||
}
|
||||
}
|
||||
|
||||
return parsed
|
||||
}
|
||||
|
||||
function normalizeStep(value: unknown) {
|
||||
const normalized = String(value || 'ticket').trim()
|
||||
if (['ticket', 'binding', 'confirm', 'result'].includes(normalized)) {
|
||||
return normalized
|
||||
}
|
||||
|
||||
throw new Error('--step 只能是 ticket、binding、confirm、result')
|
||||
}
|
||||
|
||||
function resolveInitialTaskStatus(step: string) {
|
||||
if (step === 'ticket') return 'pending_binding_prepare'
|
||||
if (step === 'binding') return 'waiting_binding'
|
||||
if (step === 'confirm') return 'role_confirmed'
|
||||
return 'completed'
|
||||
}
|
||||
|
||||
function parseDeliveryItems(value: unknown): DeliveryItem[] {
|
||||
const text = String(value || '').trim()
|
||||
const fallback = [
|
||||
{ cloudSkuId: 910001, cloudSkuName: '套餐商品 A', quantity: 1 },
|
||||
{ cloudSkuId: 910002, cloudSkuName: '套餐商品 B', quantity: 2 },
|
||||
]
|
||||
|
||||
if (!text) {
|
||||
return fallback
|
||||
}
|
||||
|
||||
const items = text
|
||||
.split(',')
|
||||
.map((segment) => {
|
||||
const [id, name, quantity] = segment.split(':')
|
||||
const cloudSkuId = Number(id)
|
||||
return {
|
||||
cloudSkuId,
|
||||
cloudSkuName: String(name || '').trim(),
|
||||
quantity: Math.max(1, Number(quantity || 1) || 1),
|
||||
}
|
||||
})
|
||||
.filter((item) => Number.isInteger(item.cloudSkuId) && item.cloudSkuId > 0 && item.cloudSkuName)
|
||||
|
||||
return items.length > 0 ? mergeDeliveryItems(items) : fallback
|
||||
}
|
||||
|
||||
function mergeDeliveryItems(items: DeliveryItem[]) {
|
||||
const merged = new Map<number, DeliveryItem>()
|
||||
|
||||
for (const item of items) {
|
||||
const existing = merged.get(item.cloudSkuId)
|
||||
if (existing) {
|
||||
existing.quantity += item.quantity
|
||||
existing.cloudSkuName = existing.cloudSkuName || item.cloudSkuName
|
||||
continue
|
||||
}
|
||||
merged.set(item.cloudSkuId, { ...item })
|
||||
}
|
||||
|
||||
return Array.from(merged.values())
|
||||
}
|
||||
|
||||
function buildFrontendClaimUrl(token: string, rawBaseUrl: unknown) {
|
||||
const baseUrl = String(rawBaseUrl || process.env.MOCK_CLAIM_FRONTEND_BASE_URL || 'http://127.0.0.1:5173').replace(/\/+$/, '')
|
||||
return `${baseUrl}/#/claim/${token}`
|
||||
}
|
||||
|
||||
function printHelp() {
|
||||
console.log(`
|
||||
生成快手 Cloud 领取 mock 数据:
|
||||
|
||||
npm run mock:claim
|
||||
npm run mock:claim -- --step=binding
|
||||
npm run mock:claim -- --step=result --productNo=套餐_1----3676797936
|
||||
npm run mock:claim -- --items=910001:商品A:1,910002:商品B:2
|
||||
|
||||
参数:
|
||||
--step ticket、binding、confirm、result,默认 ticket
|
||||
--orderNo 91 订单号,默认自动生成
|
||||
--productNo 91 商品名,可带 ----快手小店ID,默认 套餐_1----3676797936
|
||||
--items 套餐明细,格式 skuId:名称:数量,skuId:名称:数量
|
||||
--frontendBaseUrl 前端地址,默认 http://127.0.0.1:5173
|
||||
--force 允许在 NODE_ENV=production 执行
|
||||
`)
|
||||
}
|
||||
@@ -0,0 +1,182 @@
|
||||
import crypto from 'node:crypto'
|
||||
import path from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
|
||||
import { loadEnvFiles } from '../src/config/runtime-env.js'
|
||||
|
||||
type JsonObject = Record<string, unknown>
|
||||
|
||||
const CURRENT_DIR = path.dirname(fileURLToPath(import.meta.url))
|
||||
const PROJECT_ROOT = path.resolve(CURRENT_DIR, '..')
|
||||
const WORKSPACE_ROOT = path.resolve(PROJECT_ROOT, '../..')
|
||||
|
||||
loadEnvFiles([
|
||||
path.join(WORKSPACE_ROOT, '.env'),
|
||||
path.join(PROJECT_ROOT, '.env'),
|
||||
])
|
||||
|
||||
const args = parseArgs(process.argv.slice(2))
|
||||
|
||||
if (args.help) {
|
||||
printHelp()
|
||||
process.exit(0)
|
||||
}
|
||||
|
||||
const secret = readEnv('KAQUAN91_SECRET')
|
||||
const version = readEnv('KAQUAN91_VERSION') || '1.0'
|
||||
const baseUrl = String(args.baseUrl || process.env.OPEN91_MOCK_BASE_URL || 'http://127.0.0.1').replace(/\/$/, '')
|
||||
const mode = String(args.mode || 'create').trim()
|
||||
|
||||
if (!secret || secret.length !== 32) {
|
||||
console.error('缺少 32 位 91 密钥:请配置 KAQUAN91_SECRET。')
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
if (!['create', 'query'].includes(mode)) {
|
||||
console.error('mode 只能是 create 或 query。')
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
const orderNo = String(args.orderNo || `MOCK91${Date.now()}`).trim()
|
||||
const timestamp = Number(args.timestamp || Math.floor(Date.now() / 1000))
|
||||
|
||||
const payload = mode === 'query'
|
||||
? {
|
||||
orderNo,
|
||||
timestamp,
|
||||
version,
|
||||
}
|
||||
: {
|
||||
buyNum: normalizePositiveInteger(args.buyNum, 1),
|
||||
callbackUrl: String(args.callbackUrl || ''),
|
||||
maxAmount: String(args.maxAmount || '0.01'),
|
||||
orderNo,
|
||||
productNo: String(args.productNo || '套装-浪漫天命').trim(),
|
||||
timestamp,
|
||||
version,
|
||||
}
|
||||
|
||||
const body = {
|
||||
...payload,
|
||||
sign: signOpen91Payload(payload, secret),
|
||||
}
|
||||
|
||||
const endpoint = `${baseUrl}/api/v1/open/91/orders/${mode}`
|
||||
|
||||
console.log(`POST ${endpoint}`)
|
||||
console.log(JSON.stringify(maskPayload(body), null, 2))
|
||||
|
||||
const response = await fetch(endpoint, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'content-type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify(body),
|
||||
}).catch((error) => {
|
||||
console.error('请求 91 mock 接口失败,请确认后端入口可访问。')
|
||||
console.error(`当前地址:${endpoint}`)
|
||||
console.error('Docker 开发环境通常使用 http://127.0.0.1;本地直启后端通常使用 http://127.0.0.1:3000。')
|
||||
throw error
|
||||
})
|
||||
|
||||
const responseText = await response.text()
|
||||
console.log(`HTTP ${response.status}`)
|
||||
|
||||
try {
|
||||
console.log(JSON.stringify(JSON.parse(responseText), null, 2))
|
||||
} catch {
|
||||
console.log(responseText)
|
||||
}
|
||||
|
||||
function parseArgs(rawArgs: string[]) {
|
||||
const parsed: JsonObject = {}
|
||||
|
||||
for (const rawArg of rawArgs) {
|
||||
const arg = String(rawArg || '').trim()
|
||||
if (!arg) continue
|
||||
if (arg === '--help' || arg === '-h') {
|
||||
parsed.help = true
|
||||
continue
|
||||
}
|
||||
|
||||
const normalized = arg.startsWith('--') ? arg.slice(2) : arg
|
||||
const separatorIndex = normalized.indexOf('=')
|
||||
if (separatorIndex < 0) {
|
||||
parsed[normalized] = true
|
||||
continue
|
||||
}
|
||||
|
||||
const key = normalized.slice(0, separatorIndex).trim()
|
||||
const value = normalized.slice(separatorIndex + 1).trim()
|
||||
if (key) {
|
||||
parsed[key] = value
|
||||
}
|
||||
}
|
||||
|
||||
return parsed
|
||||
}
|
||||
|
||||
function readEnv(...keys: string[]) {
|
||||
for (const key of keys) {
|
||||
const value = String(process.env[key] || '').trim()
|
||||
if (value) return value
|
||||
}
|
||||
|
||||
return ''
|
||||
}
|
||||
|
||||
function signOpen91Payload(payload: JsonObject, currentSecret: string) {
|
||||
return crypto
|
||||
.createHash('md5')
|
||||
.update(buildOpen91SignSource(payload, currentSecret), 'utf8')
|
||||
.digest('hex')
|
||||
.toUpperCase()
|
||||
}
|
||||
|
||||
function buildOpen91SignSource(payload: JsonObject, currentSecret: string) {
|
||||
const queryString = Object.entries(payload)
|
||||
.filter(([key]) => key !== 'sign')
|
||||
.sort(([left], [right]) => left.localeCompare(right))
|
||||
.map(([key, value]) => `${key}=${stringifySignValue(value)}`)
|
||||
.join('&')
|
||||
|
||||
return `${currentSecret}${queryString}${currentSecret}`
|
||||
}
|
||||
|
||||
function stringifySignValue(value: unknown) {
|
||||
if (typeof value === 'number' && Number.isFinite(value)) return String(value)
|
||||
if (typeof value === 'boolean') return value ? 'true' : 'false'
|
||||
if (value == null) return ''
|
||||
return String(value)
|
||||
}
|
||||
|
||||
function normalizePositiveInteger(value: unknown, fallback: number) {
|
||||
const parsed = Number(value)
|
||||
return Number.isInteger(parsed) && parsed > 0 ? parsed : fallback
|
||||
}
|
||||
|
||||
function maskPayload(payload: JsonObject) {
|
||||
return {
|
||||
...payload,
|
||||
sign: String(payload.sign || '').replace(/^(.{6}).+(.{4})$/, '$1***$2'),
|
||||
}
|
||||
}
|
||||
|
||||
function printHelp() {
|
||||
console.log(`
|
||||
模拟 91 卡券请求:
|
||||
|
||||
npm run mock:open91 -- --productNo=套装-浪漫天命
|
||||
npm run mock:open91 -- --productNo=荣耀勋章礼包(30个) --maxAmount=300 --buyNum=1
|
||||
npm run mock:open91 -- --mode=query --orderNo=MOCK911779000000000
|
||||
|
||||
参数:
|
||||
--baseUrl 后端地址,默认 http://127.0.0.1
|
||||
--mode create 或 query,默认 create
|
||||
--orderNo 91 订单号,默认自动生成
|
||||
--productNo 91 商品名,默认 套装-浪漫天命
|
||||
--buyNum 购买数量,默认 1
|
||||
--maxAmount 最大金额/正常金额,默认 0.01
|
||||
--callbackUrl 回调地址,默认空
|
||||
`)
|
||||
}
|
||||
@@ -1,513 +0,0 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
import process from 'node:process'
|
||||
|
||||
import { runtimeConfig } from '../src/config/runtime.js'
|
||||
import { closeDb, query } from '../src/db/client.js'
|
||||
import { runDatabaseMigrations } from '../src/db/migrate.js'
|
||||
import { updateClaimToken } from '../src/repositories/claim-token-repo.js'
|
||||
import { getFulfillmentProfileByKey, upsertSkuFulfillmentBinding } from '../src/repositories/fulfillment-profile-repo.js'
|
||||
import { createInventoryItems, releaseReservedInventoryItem } from '../src/repositories/inventory-repo.js'
|
||||
import { getTaskById, updateTask } from '../src/repositories/task-repo.js'
|
||||
import { ensureFulfillmentCatalogBootstrapped } from '../src/services/bootstrap/fulfillment-bootstrap-service.js'
|
||||
import { completeAdminTaskManualDispatch } from '../src/services/admin/admin-service.js'
|
||||
import { upsertOrderFromWebhook } from '../src/services/order/order-service.js'
|
||||
import { addHours, nowIso } from '../src/utils/time.js'
|
||||
|
||||
const RESET_TABLES = [
|
||||
'message_deliveries',
|
||||
'tencent_browser_contexts',
|
||||
'task_events',
|
||||
'claim_tokens',
|
||||
'task_inventory_bindings',
|
||||
'fulfillment_tasks',
|
||||
'order_items',
|
||||
'orders',
|
||||
'webhook_events',
|
||||
'product_match_rules',
|
||||
'inventory_items',
|
||||
]
|
||||
|
||||
const SEED_PROVIDER = 'seed'
|
||||
const SEED_PLATFORM = 'sandbox'
|
||||
const SEED_SHOP_ID = 'seed-shop'
|
||||
const SEED_SHOP_NAME = 'Seed Sandbox Shop'
|
||||
|
||||
const options = parseArgs(process.argv.slice(2))
|
||||
|
||||
if (options.help) {
|
||||
printHelp()
|
||||
process.exit(0)
|
||||
}
|
||||
|
||||
try {
|
||||
printPreview()
|
||||
|
||||
if (!options.apply) {
|
||||
console.log('\n当前为预览模式。确认后追加 `--apply`,需要清库时再加 `--reset`。')
|
||||
process.exit(0)
|
||||
}
|
||||
|
||||
await runDatabaseMigrations()
|
||||
await ensureFulfillmentCatalogBootstrapped()
|
||||
|
||||
if (options.reset) {
|
||||
await query(`TRUNCATE TABLE ${RESET_TABLES.join(', ')} RESTART IDENTITY CASCADE`)
|
||||
console.log('\n已先清空测试业务数据。')
|
||||
}
|
||||
|
||||
await ensureSeedBindings()
|
||||
await createSeedInventory()
|
||||
|
||||
const manualPending = await seedScenario({
|
||||
scenarioKey: 'manual_pending',
|
||||
orderLabel: 'SEED-MANUAL-PENDING',
|
||||
skuCode: 'seed-manual-pending',
|
||||
skuName: '开发种子-人工履约待处理',
|
||||
})
|
||||
|
||||
const manualCompleted = await seedScenario({
|
||||
scenarioKey: 'manual_completed',
|
||||
orderLabel: 'SEED-MANUAL-COMPLETED',
|
||||
skuCode: 'seed-manual-completed',
|
||||
skuName: '开发种子-人工履约已完成',
|
||||
afterCreate: async (result) => {
|
||||
const task = result.tasks[0]
|
||||
if (!task) {
|
||||
return null
|
||||
}
|
||||
|
||||
await completeAdminTaskManualDispatch(task.id, {
|
||||
outcome: 'delivered',
|
||||
resultMessage: '开发种子数据:人工履约已完成',
|
||||
deliveryReference: 'SEED-MANUAL-REF-001',
|
||||
deliveredCredential: '开发种子数据:已通过人工渠道发放',
|
||||
})
|
||||
|
||||
return getTaskById(task.id)
|
||||
},
|
||||
})
|
||||
|
||||
const claimReady = await seedScenario({
|
||||
scenarioKey: 'claim_ready',
|
||||
orderLabel: 'SEED-CLAIM-READY',
|
||||
skuCode: 'seed-claim-ready',
|
||||
skuName: '开发种子-领取兑换可用',
|
||||
})
|
||||
|
||||
const claimClaimed = await seedScenario({
|
||||
scenarioKey: 'claim_claimed',
|
||||
orderLabel: 'SEED-CLAIM-CLAIMED',
|
||||
skuCode: 'seed-claim-claimed',
|
||||
skuName: '开发种子-已打开领取链接',
|
||||
afterCreate: async (result) => applyClaimedState(result.tasks[0]),
|
||||
})
|
||||
|
||||
const claimRoleConfirmed = await seedScenario({
|
||||
scenarioKey: 'claim_role_confirmed',
|
||||
orderLabel: 'SEED-CLAIM-ROLE',
|
||||
skuCode: 'seed-claim-role-confirmed',
|
||||
skuName: '开发种子-已确认角色',
|
||||
afterCreate: async (result) => applyRoleConfirmedState(result.tasks[0]),
|
||||
})
|
||||
|
||||
const claimRetryPending = await seedScenario({
|
||||
scenarioKey: 'claim_retry_pending',
|
||||
orderLabel: 'SEED-CLAIM-RETRY',
|
||||
skuCode: 'seed-claim-retry',
|
||||
skuName: '开发种子-等待重试',
|
||||
afterCreate: async (result) => applyRetryPendingState(result.tasks[0]),
|
||||
})
|
||||
|
||||
const claimExpired = await seedScenario({
|
||||
scenarioKey: 'claim_expired',
|
||||
orderLabel: 'SEED-CLAIM-EXPIRED',
|
||||
skuCode: 'seed-claim-expired',
|
||||
skuName: '开发种子-领取链接已过期',
|
||||
afterCreate: async (result) => applyExpiredState(result.tasks[0]),
|
||||
})
|
||||
|
||||
const claimWaitingInventory = await seedScenario({
|
||||
scenarioKey: 'claim_waiting_inventory',
|
||||
orderLabel: 'SEED-CLAIM-WAIT',
|
||||
skuCode: 'seed-claim-wait',
|
||||
skuName: '开发种子-等待库存',
|
||||
})
|
||||
|
||||
printResultSummary([
|
||||
manualPending,
|
||||
manualCompleted,
|
||||
claimReady,
|
||||
claimClaimed,
|
||||
claimRoleConfirmed,
|
||||
claimRetryPending,
|
||||
claimExpired,
|
||||
claimWaitingInventory,
|
||||
])
|
||||
} catch (error) {
|
||||
console.error('\n写入开发种子数据失败:', error instanceof Error ? error.message : String(error || '未知错误'))
|
||||
process.exitCode = 1
|
||||
} finally {
|
||||
await closeDb()
|
||||
}
|
||||
|
||||
async function ensureSeedBindings() {
|
||||
const timestamp = nowIso()
|
||||
const manualProfile = await getRequiredProfile('manual_review')
|
||||
const claimProfile = await getRequiredProfile('tencent_claim_redeem')
|
||||
|
||||
const bindings = [
|
||||
{ skuCode: 'seed-manual-pending', profileId: manualProfile.id, priority: 10 },
|
||||
{ skuCode: 'seed-manual-completed', profileId: manualProfile.id, priority: 20 },
|
||||
{ skuCode: 'seed-claim-ready', profileId: claimProfile.id, priority: 30 },
|
||||
{ skuCode: 'seed-claim-claimed', profileId: claimProfile.id, priority: 40 },
|
||||
{ skuCode: 'seed-claim-role-confirmed', profileId: claimProfile.id, priority: 50 },
|
||||
{ skuCode: 'seed-claim-retry', profileId: claimProfile.id, priority: 60 },
|
||||
{ skuCode: 'seed-claim-expired', profileId: claimProfile.id, priority: 70 },
|
||||
{ skuCode: 'seed-claim-wait', profileId: claimProfile.id, priority: 80 },
|
||||
]
|
||||
|
||||
for (const binding of bindings) {
|
||||
await upsertSkuFulfillmentBinding({
|
||||
skuCode: binding.skuCode,
|
||||
provider: SEED_PROVIDER,
|
||||
platform: SEED_PLATFORM,
|
||||
shopId: SEED_SHOP_ID,
|
||||
profileId: binding.profileId,
|
||||
enabled: true,
|
||||
priority: binding.priority,
|
||||
configJson: JSON.stringify({
|
||||
seededBy: 'scripts/seed-dev-data.ts',
|
||||
scenario: binding.skuCode,
|
||||
}),
|
||||
createdAt: timestamp,
|
||||
updatedAt: timestamp,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
async function createSeedInventory() {
|
||||
const timestamp = nowIso()
|
||||
|
||||
await createInventoryItems([
|
||||
{
|
||||
skuCode: 'seed-claim-ready',
|
||||
displayValue: 'SEED-CLAIM-CODE-READY-001',
|
||||
batchNo: 'seed-20260409',
|
||||
credentialType: 'tencent_code',
|
||||
payload: {
|
||||
code: 'SEED-CLAIM-CODE-READY-001',
|
||||
scenario: 'claim_ready',
|
||||
},
|
||||
createdAt: timestamp,
|
||||
updatedAt: timestamp,
|
||||
},
|
||||
{
|
||||
skuCode: 'seed-claim-claimed',
|
||||
displayValue: 'SEED-CLAIM-CODE-CLAIMED-001',
|
||||
batchNo: 'seed-20260409',
|
||||
credentialType: 'tencent_code',
|
||||
payload: {
|
||||
code: 'SEED-CLAIM-CODE-CLAIMED-001',
|
||||
scenario: 'claim_claimed',
|
||||
},
|
||||
createdAt: timestamp,
|
||||
updatedAt: timestamp,
|
||||
},
|
||||
{
|
||||
skuCode: 'seed-claim-role-confirmed',
|
||||
displayValue: 'SEED-CLAIM-CODE-ROLE-001',
|
||||
batchNo: 'seed-20260409',
|
||||
credentialType: 'tencent_code',
|
||||
payload: {
|
||||
code: 'SEED-CLAIM-CODE-ROLE-001',
|
||||
scenario: 'claim_role_confirmed',
|
||||
},
|
||||
createdAt: timestamp,
|
||||
updatedAt: timestamp,
|
||||
},
|
||||
{
|
||||
skuCode: 'seed-claim-retry',
|
||||
displayValue: 'SEED-CLAIM-CODE-RETRY-001',
|
||||
batchNo: 'seed-20260409',
|
||||
credentialType: 'tencent_code',
|
||||
payload: {
|
||||
code: 'SEED-CLAIM-CODE-RETRY-001',
|
||||
scenario: 'claim_retry_pending',
|
||||
},
|
||||
createdAt: timestamp,
|
||||
updatedAt: timestamp,
|
||||
},
|
||||
{
|
||||
skuCode: 'seed-claim-expired',
|
||||
displayValue: 'SEED-CLAIM-CODE-EXPIRED-001',
|
||||
batchNo: 'seed-20260409',
|
||||
credentialType: 'tencent_code',
|
||||
payload: {
|
||||
code: 'SEED-CLAIM-CODE-EXPIRED-001',
|
||||
scenario: 'claim_expired',
|
||||
},
|
||||
createdAt: timestamp,
|
||||
updatedAt: timestamp,
|
||||
},
|
||||
{
|
||||
skuCode: 'seed-spare-available',
|
||||
displayValue: 'SEED-SPARE-CODE-001',
|
||||
batchNo: 'seed-20260409',
|
||||
credentialType: 'tencent_code',
|
||||
payload: {
|
||||
code: 'SEED-SPARE-CODE-001',
|
||||
scenario: 'spare_available',
|
||||
},
|
||||
createdAt: timestamp,
|
||||
updatedAt: timestamp,
|
||||
},
|
||||
])
|
||||
}
|
||||
|
||||
async function seedScenario({ scenarioKey, orderLabel, skuCode, skuName, afterCreate = null }) {
|
||||
const result = await upsertOrderFromWebhook(buildSeedOrderEvent({
|
||||
scenarioKey,
|
||||
orderLabel,
|
||||
skuCode,
|
||||
skuName,
|
||||
}))
|
||||
|
||||
const task = typeof afterCreate === 'function'
|
||||
? await afterCreate(result)
|
||||
: result.tasks[0] || null
|
||||
|
||||
return {
|
||||
scenarioKey,
|
||||
orderId: result.order.id,
|
||||
platformOrderId: result.order.platform_order_id,
|
||||
taskId: task?.id || null,
|
||||
taskNo: task?.task_no || '',
|
||||
taskStatus: task?.task_status || '',
|
||||
deliveryStatus: task?.delivery_status || '',
|
||||
inventoryStatus: task?.inventory_status || '',
|
||||
inventoryItemId: Number(task?.primary_inventory_item_id || 0) || null,
|
||||
claimTokenId: Number(task?.primary_claim_token_id || 0) || null,
|
||||
executorKey: String(task?.executor_key || '').trim(),
|
||||
}
|
||||
}
|
||||
|
||||
async function applyClaimedState(task) {
|
||||
if (!task) {
|
||||
return null
|
||||
}
|
||||
|
||||
const now = nowIso()
|
||||
await updateTask(task.id, {
|
||||
task_status: 'claimed',
|
||||
user_action_status: 'claimed',
|
||||
browser_session_id: `seed-browser-${task.id}`,
|
||||
login_type: 'qq',
|
||||
claimed_at: now,
|
||||
updated_at: now,
|
||||
last_error: '',
|
||||
})
|
||||
|
||||
return getTaskById(task.id)
|
||||
}
|
||||
|
||||
async function applyRoleConfirmedState(task) {
|
||||
if (!task) {
|
||||
return null
|
||||
}
|
||||
|
||||
const claimedAt = addHours(nowIso(), -1)
|
||||
const roleConfirmedAt = nowIso()
|
||||
await updateTask(task.id, {
|
||||
task_status: 'role_confirmed',
|
||||
user_action_status: 'role_confirmed',
|
||||
browser_session_id: `seed-browser-${task.id}`,
|
||||
login_type: 'qq',
|
||||
nickname: 'SeedHero',
|
||||
role_id: 'seed-role-1001',
|
||||
role_name: '开发角色-已确认',
|
||||
area: 'seed-area',
|
||||
partition_name: 'seed-partition',
|
||||
claimed_at: claimedAt,
|
||||
role_confirmed_at: roleConfirmedAt,
|
||||
updated_at: roleConfirmedAt,
|
||||
last_error: '',
|
||||
})
|
||||
|
||||
return getTaskById(task.id)
|
||||
}
|
||||
|
||||
async function applyRetryPendingState(task) {
|
||||
if (!task) {
|
||||
return null
|
||||
}
|
||||
|
||||
const claimedAt = addHours(nowIso(), -2)
|
||||
const roleConfirmedAt = addHours(nowIso(), -1)
|
||||
const updatedAt = nowIso()
|
||||
await updateTask(task.id, {
|
||||
task_status: 'retry_pending',
|
||||
delivery_status: 'pending',
|
||||
inventory_status: 'reserved',
|
||||
user_action_status: 'role_confirmed',
|
||||
browser_session_id: `seed-browser-${task.id}`,
|
||||
login_type: 'qq',
|
||||
nickname: 'SeedRetryUser',
|
||||
role_id: 'seed-role-retry',
|
||||
role_name: '开发角色-待重试',
|
||||
area: 'seed-area',
|
||||
partition_name: 'seed-partition',
|
||||
claimed_at: claimedAt,
|
||||
role_confirmed_at: roleConfirmedAt,
|
||||
attempt_count: 2,
|
||||
last_error: '开发种子数据:模拟兑换失败,等待重试',
|
||||
updated_at: updatedAt,
|
||||
})
|
||||
|
||||
return getTaskById(task.id)
|
||||
}
|
||||
|
||||
async function applyExpiredState(task) {
|
||||
if (!task) {
|
||||
return null
|
||||
}
|
||||
|
||||
const now = nowIso()
|
||||
if (task.primary_claim_token_id) {
|
||||
await updateClaimToken(task.primary_claim_token_id, {
|
||||
status: 'expired',
|
||||
expired_at: addHours(now, -2),
|
||||
updated_at: now,
|
||||
})
|
||||
}
|
||||
|
||||
if (task.primary_inventory_item_id) {
|
||||
await releaseReservedInventoryItem(task.primary_inventory_item_id, now)
|
||||
}
|
||||
|
||||
await updateTask(task.id, {
|
||||
task_status: 'expired',
|
||||
inventory_status: 'pending',
|
||||
user_action_status: 'expired',
|
||||
last_error: '领取链接已过期,预占库存项已释放',
|
||||
updated_at: now,
|
||||
})
|
||||
|
||||
return getTaskById(task.id)
|
||||
}
|
||||
|
||||
function buildSeedOrderEvent({ scenarioKey, orderLabel, skuCode, skuName }) {
|
||||
const timestamp = nowIso()
|
||||
|
||||
return {
|
||||
provider: SEED_PROVIDER,
|
||||
platform: SEED_PLATFORM,
|
||||
shopId: SEED_SHOP_ID,
|
||||
shopName: SEED_SHOP_NAME,
|
||||
platformOrderId: orderLabel,
|
||||
orderStatus: 'paid',
|
||||
payStatus: 'paid',
|
||||
buyerId: `${scenarioKey}-buyer`,
|
||||
buyerName: `开发种子-${scenarioKey}`,
|
||||
receiverContact: `seed+${scenarioKey}@example.com`,
|
||||
totalAmount: 100,
|
||||
currency: 'CNY',
|
||||
paidAt: timestamp,
|
||||
rawPayload: {
|
||||
source: 'seed-dev-data',
|
||||
scenarioKey,
|
||||
platformOrderId: orderLabel,
|
||||
},
|
||||
items: [
|
||||
{
|
||||
skuCode,
|
||||
skuName,
|
||||
quantity: 1,
|
||||
spec: {
|
||||
title: skuName,
|
||||
scenarioKey,
|
||||
},
|
||||
snapshot: {
|
||||
title: skuName,
|
||||
scenarioKey,
|
||||
seeded: true,
|
||||
},
|
||||
},
|
||||
],
|
||||
}
|
||||
}
|
||||
|
||||
async function getRequiredProfile(profileKey) {
|
||||
const profile = await getFulfillmentProfileByKey(profileKey)
|
||||
|
||||
if (!profile) {
|
||||
throw new Error(`缺少履约档案: ${profileKey}`)
|
||||
}
|
||||
|
||||
return profile
|
||||
}
|
||||
|
||||
function parseArgs(argv) {
|
||||
return {
|
||||
apply: argv.includes('--apply'),
|
||||
reset: argv.includes('--reset'),
|
||||
help: argv.includes('-h') || argv.includes('--help'),
|
||||
}
|
||||
}
|
||||
|
||||
function printHelp() {
|
||||
console.log(`
|
||||
用法:
|
||||
npm run seed:dev-data -- [--apply] [--reset]
|
||||
|
||||
说明:
|
||||
基于当前 PostgreSQL fulfillment schema 生成一组可重复验证的开发种子数据。
|
||||
|
||||
场景:
|
||||
- manual_pending: 人工履约待处理
|
||||
- manual_completed: 人工履约已完成
|
||||
- claim_ready: 已预占库存并生成领取链接
|
||||
- claim_claimed: 用户已打开领取链接
|
||||
- claim_role_confirmed: 用户已确认角色
|
||||
- claim_retry_pending: 自动履约失败等待重试
|
||||
- claim_expired: 领取链接已过期
|
||||
- claim_waiting_inventory: 等待库存
|
||||
|
||||
参数:
|
||||
--apply 实际写入数据
|
||||
--reset 写入前先清空测试业务数据
|
||||
|
||||
数据库连接:
|
||||
${String(runtimeConfig.database?.url || '').trim() || '(未配置 DATABASE_URL)'}
|
||||
`.trim())
|
||||
}
|
||||
|
||||
function printPreview() {
|
||||
console.log('准备生成开发种子数据:')
|
||||
console.log('- manual_pending -> 人工履约待处理')
|
||||
console.log('- manual_completed -> 人工履约已完成')
|
||||
console.log('- claim_ready -> 领取兑换已就绪')
|
||||
console.log('- claim_claimed -> 用户已打开领取链接')
|
||||
console.log('- claim_role_confirmed -> 用户已确认角色')
|
||||
console.log('- claim_retry_pending -> 自动履约失败等待重试')
|
||||
console.log('- claim_expired -> 领取链接已过期')
|
||||
console.log('- claim_waiting_inventory -> 等待库存')
|
||||
console.log(`- database: ${String(runtimeConfig.database?.url || '').trim() || '(未配置 DATABASE_URL)'}`)
|
||||
}
|
||||
|
||||
function printResultSummary(rows) {
|
||||
console.log('\n开发种子数据已写入:')
|
||||
|
||||
for (const row of rows) {
|
||||
console.log(`- ${row.scenarioKey}`)
|
||||
console.log(` orderId=${row.orderId} platformOrderId=${row.platformOrderId}`)
|
||||
console.log(` taskId=${row.taskId || '-'} taskNo=${row.taskNo || '-'} executor=${row.executorKey || '-'}`)
|
||||
console.log(` taskStatus=${row.taskStatus || '-'} deliveryStatus=${row.deliveryStatus || '-'} inventoryStatus=${row.inventoryStatus || '-'}`)
|
||||
console.log(` inventoryItemId=${row.inventoryItemId || '-'} claimTokenId=${row.claimTokenId || '-'}`)
|
||||
}
|
||||
|
||||
console.log('\n建议验证入口:')
|
||||
console.log('- `/api/v1/admin/orders?platformOrderId=SEED-`')
|
||||
console.log('- `/api/v1/admin/tasks?taskNo=DT`')
|
||||
console.log('- `/api/v1/admin/inventory`')
|
||||
}
|
||||
@@ -1,97 +1,5 @@
|
||||
#!/bin/sh
|
||||
set -eu
|
||||
|
||||
is_true() {
|
||||
normalized=$(printf '%s' "${1:-}" | tr '[:upper:]' '[:lower:]')
|
||||
case "$normalized" in
|
||||
1|true|yes|on)
|
||||
return 0
|
||||
;;
|
||||
*)
|
||||
return 1
|
||||
;;
|
||||
esac
|
||||
}
|
||||
|
||||
start_browser_debug_services() {
|
||||
display="${TENCENT_BROWSER_DISPLAY:-:99}"
|
||||
resolution="${TENCENT_BROWSER_VNC_RESOLUTION:-1600x900x24}"
|
||||
display_number="${display#:}"
|
||||
display_number="${display_number%%.*}"
|
||||
display_socket="/tmp/.X11-unix/X${display_number}"
|
||||
display_lock="/tmp/.X${display_number}-lock"
|
||||
|
||||
if [ -S "$display_socket" ] && [ ! -f "$display_lock" ]; then
|
||||
rm -f "$display_socket"
|
||||
fi
|
||||
|
||||
if [ -f "$display_lock" ]; then
|
||||
display_pid=$(cat "$display_lock" 2>/dev/null || true)
|
||||
if [ -z "$display_pid" ] || ! kill -0 "$display_pid" 2>/dev/null; then
|
||||
rm -f "$display_lock" "$display_socket"
|
||||
fi
|
||||
fi
|
||||
|
||||
export DISPLAY="$display"
|
||||
Xvfb "$display" -screen 0 "$resolution" -nolisten tcp >/tmp/xvfb.log 2>&1 &
|
||||
xvfb_pid=$!
|
||||
|
||||
ready=0
|
||||
attempt=0
|
||||
while [ "$attempt" -lt 50 ]; do
|
||||
if [ -S "$display_socket" ]; then
|
||||
ready=1
|
||||
break
|
||||
fi
|
||||
|
||||
if ! kill -0 "$xvfb_pid" 2>/dev/null; then
|
||||
break
|
||||
fi
|
||||
|
||||
attempt=$((attempt + 1))
|
||||
sleep 0.1
|
||||
done
|
||||
|
||||
if [ "$ready" -ne 1 ]; then
|
||||
echo "[backend-dev] Xvfb display ${DISPLAY} not ready for x11vnc; skip noVNC startup" >&2
|
||||
return
|
||||
fi
|
||||
|
||||
if ! is_true "${TENCENT_BROWSER_NOVNC_ENABLED:-true}"; then
|
||||
echo "[backend-dev] headed browser enabled on ${DISPLAY}; noVNC disabled"
|
||||
return
|
||||
fi
|
||||
|
||||
vnc_port="${TENCENT_BROWSER_VNC_PORT:-5900}"
|
||||
novnc_port="${TENCENT_BROWSER_NOVNC_PORT:-6080}"
|
||||
|
||||
x11vnc \
|
||||
-display "$display" \
|
||||
-rfbport "$vnc_port" \
|
||||
-forever \
|
||||
-shared \
|
||||
-nopw \
|
||||
-listen 0.0.0.0 \
|
||||
-xkb >/tmp/x11vnc.log 2>&1 &
|
||||
|
||||
if command -v novnc_proxy >/dev/null 2>&1; then
|
||||
novnc_proxy --listen "$novnc_port" --vnc "127.0.0.1:${vnc_port}" >/tmp/novnc.log 2>&1 &
|
||||
elif [ -x /usr/share/novnc/utils/novnc_proxy ]; then
|
||||
/usr/share/novnc/utils/novnc_proxy --listen "$novnc_port" --vnc "127.0.0.1:${vnc_port}" >/tmp/novnc.log 2>&1 &
|
||||
elif command -v websockify >/dev/null 2>&1 && [ -d /usr/share/novnc ]; then
|
||||
websockify --web=/usr/share/novnc "$novnc_port" "127.0.0.1:${vnc_port}" >/tmp/novnc.log 2>&1 &
|
||||
else
|
||||
echo "[backend-dev] noVNC requested but novnc/websockify not found"
|
||||
return
|
||||
fi
|
||||
|
||||
echo "[backend-dev] headed browser enabled on ${DISPLAY}; noVNC: http://127.0.0.1:${novnc_port}/vnc.html"
|
||||
}
|
||||
|
||||
npm install --no-fund --no-audit
|
||||
|
||||
if ! is_true "${TENCENT_BROWSER_HEADLESS:-true}"; then
|
||||
start_browser_debug_services
|
||||
fi
|
||||
|
||||
exec npm run dev
|
||||
|
||||
@@ -1,288 +0,0 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
import crypto from 'node:crypto'
|
||||
import process from 'node:process'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import { setTimeout as sleep } from 'node:timers/promises'
|
||||
|
||||
import { query, closeDb } from '../src/db/client.js'
|
||||
import { parseJsonObject } from '../src/utils/json.js'
|
||||
import { getAgisoShopConfig } from '../src/services/platforms/agiso/shop-config-service.js'
|
||||
import { queryAgisoXianyuOrderDetail } from '../src/services/platforms/agiso/xianyu/order-detail-service.js'
|
||||
|
||||
const DEFAULT_DUMMY_ENDPOINT = 'https://gw-api.agiso.com/aldsIdle/Order/DummySend'
|
||||
const DEFAULT_POLL_SECONDS = [0, 3, 10]
|
||||
|
||||
async function main() {
|
||||
const options = parseArgs(process.argv.slice(2))
|
||||
if (options.help || !options.orderId) {
|
||||
printHelp()
|
||||
process.exit(options.help ? 0 : 1)
|
||||
}
|
||||
|
||||
const orderRecord = await resolveOrderRecord(options)
|
||||
const shopId = orderRecord?.shop_id || options.shopId
|
||||
const shopConfig = getAgisoShopConfig(shopId) || {}
|
||||
const accessToken = String(shopConfig.accessToken || '').trim()
|
||||
const appSecret = String(shopConfig.appSecret || process.env.AGISO_APP_SECRET || '').trim()
|
||||
|
||||
if (!shopId) {
|
||||
throw new Error('未提供 shopId,且数据库中也没有查到该订单对应的店铺')
|
||||
}
|
||||
|
||||
if (!accessToken) {
|
||||
throw new Error(`店铺 ${shopId} 缺少 accessToken 配置,请检查 apps/backend/data/agiso-shops.json`)
|
||||
}
|
||||
|
||||
if (!appSecret) {
|
||||
throw new Error('缺少 AGISO_APP_SECRET 或店铺级 appSecret 配置')
|
||||
}
|
||||
|
||||
printJson('测试输入', {
|
||||
orderId: options.orderId,
|
||||
shopId,
|
||||
pollSeconds: options.pollSeconds,
|
||||
orderRecord: orderRecord
|
||||
? {
|
||||
id: Number(orderRecord.id || 0) || null,
|
||||
platformOrderId: String(orderRecord.platform_order_id || '').trim(),
|
||||
orderStatus: String(orderRecord.order_status || '').trim(),
|
||||
payStatus: String(orderRecord.pay_status || '').trim(),
|
||||
provider: String(orderRecord.provider || '').trim(),
|
||||
platform: String(orderRecord.platform || '').trim(),
|
||||
}
|
||||
: null,
|
||||
})
|
||||
|
||||
const beforeDetail = await queryAgisoXianyuOrderDetail({
|
||||
shopId,
|
||||
platformOrderId: options.orderId,
|
||||
requestId: `diag-before-${Date.now()}`,
|
||||
})
|
||||
printJson('调用前订单详情', summarizeDetail(beforeDetail))
|
||||
|
||||
const result = await executeRequest({
|
||||
platformOrderId: options.orderId,
|
||||
accessToken,
|
||||
appSecret,
|
||||
})
|
||||
printJson('接口响应 更新发货状态 DummySend', result)
|
||||
|
||||
for (const seconds of options.pollSeconds) {
|
||||
if (seconds > 0) {
|
||||
await sleep(seconds * 1000)
|
||||
}
|
||||
|
||||
const detail = await queryAgisoXianyuOrderDetail({
|
||||
shopId,
|
||||
platformOrderId: options.orderId,
|
||||
requestId: `diag-after-${seconds}s-${Date.now()}`,
|
||||
})
|
||||
printJson(`调用后订单详情 +${seconds}s`, summarizeDetail(detail))
|
||||
}
|
||||
}
|
||||
|
||||
function parseArgs(argv) {
|
||||
const output = {
|
||||
orderId: '',
|
||||
shopId: '',
|
||||
pollSeconds: [...DEFAULT_POLL_SECONDS],
|
||||
help: false,
|
||||
}
|
||||
|
||||
for (let index = 0; index < argv.length; index += 1) {
|
||||
const current = String(argv[index] || '').trim()
|
||||
if (!current) {
|
||||
continue
|
||||
}
|
||||
|
||||
if (!current.startsWith('--')) {
|
||||
if (!output.orderId) {
|
||||
output.orderId = current
|
||||
continue
|
||||
}
|
||||
|
||||
throw new Error(`无法识别的参数:${current}`)
|
||||
}
|
||||
|
||||
if (current === '--help') {
|
||||
output.help = true
|
||||
continue
|
||||
}
|
||||
|
||||
const [rawKey, inlineValue = ''] = current.split('=', 2)
|
||||
const key = rawKey.slice(2)
|
||||
const nextValue = inlineValue || argv[index + 1] || ''
|
||||
const shouldConsumeNext = !inlineValue && argv[index + 1] && !String(argv[index + 1]).startsWith('--')
|
||||
|
||||
switch (key) {
|
||||
case 'shop-id':
|
||||
output.shopId = String(nextValue || '').trim()
|
||||
break
|
||||
case 'poll':
|
||||
output.pollSeconds = parsePollSeconds(nextValue)
|
||||
break
|
||||
default:
|
||||
throw new Error(`无法识别的参数:${current}`)
|
||||
}
|
||||
|
||||
if (shouldConsumeNext) {
|
||||
index += 1
|
||||
}
|
||||
}
|
||||
|
||||
return output
|
||||
}
|
||||
|
||||
function parsePollSeconds(value) {
|
||||
const normalized = String(value || '').trim()
|
||||
if (!normalized) {
|
||||
return [...DEFAULT_POLL_SECONDS]
|
||||
}
|
||||
|
||||
const parsed = normalized
|
||||
.split(',')
|
||||
.map((item) => Number(String(item || '').trim()))
|
||||
.filter((item) => Number.isFinite(item) && item >= 0)
|
||||
.map((item) => Math.floor(item))
|
||||
|
||||
if (parsed.length === 0) {
|
||||
throw new Error(`poll 参数格式无效:${value}`)
|
||||
}
|
||||
|
||||
return parsed
|
||||
}
|
||||
|
||||
async function resolveOrderRecord(options) {
|
||||
const explicitShopId = String(options.shopId || '').trim()
|
||||
if (explicitShopId) {
|
||||
const result = await query(
|
||||
`
|
||||
SELECT *
|
||||
FROM orders
|
||||
WHERE provider = 'agiso'
|
||||
AND platform = 'xianyu'
|
||||
AND shop_id = $1
|
||||
AND platform_order_id = $2
|
||||
ORDER BY id DESC
|
||||
LIMIT 1
|
||||
`,
|
||||
[explicitShopId, options.orderId],
|
||||
)
|
||||
|
||||
return result.rows[0] || null
|
||||
}
|
||||
|
||||
const result = await query(
|
||||
`
|
||||
SELECT *
|
||||
FROM orders
|
||||
WHERE provider = 'agiso'
|
||||
AND platform = 'xianyu'
|
||||
AND platform_order_id = $1
|
||||
ORDER BY id DESC
|
||||
LIMIT 2
|
||||
`,
|
||||
[options.orderId],
|
||||
)
|
||||
|
||||
if (result.rows.length > 1) {
|
||||
throw new Error(`数据库里命中多条同订单号记录,请显式传入 --shop-id。订单号:${options.orderId}`)
|
||||
}
|
||||
|
||||
return result.rows[0] || null
|
||||
}
|
||||
|
||||
async function executeRequest(context) {
|
||||
const timestamp = String(Math.floor(Date.now() / 1000))
|
||||
const requestBody = buildDummySendBody(context, timestamp)
|
||||
const response = await fetch(DEFAULT_DUMMY_ENDPOINT, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
Authorization: `Bearer ${context.accessToken}`,
|
||||
ApiVersion: '1',
|
||||
'content-type': 'application/x-www-form-urlencoded; charset=utf-8',
|
||||
},
|
||||
body: new URLSearchParams(requestBody).toString(),
|
||||
})
|
||||
const rawText = await response.text()
|
||||
const payload = parseJsonObject(rawText, { preserveLargeIntegers: true })
|
||||
|
||||
return {
|
||||
endpoint: DEFAULT_DUMMY_ENDPOINT,
|
||||
status: response.status,
|
||||
requestBody,
|
||||
response: payload,
|
||||
rawText,
|
||||
}
|
||||
}
|
||||
|
||||
function buildDummySendBody(context, timestamp) {
|
||||
const payload = {
|
||||
tid: context.platformOrderId,
|
||||
timestamp,
|
||||
}
|
||||
|
||||
payload.sign = generateSign(payload, context.appSecret)
|
||||
return payload
|
||||
}
|
||||
|
||||
function generateSign(params, appSecret) {
|
||||
let raw = String(appSecret || '').trim()
|
||||
for (const [key, value] of Object.entries(params).sort(([left], [right]) => left.localeCompare(right))) {
|
||||
raw += `${key}${value}`
|
||||
}
|
||||
raw += String(appSecret || '').trim()
|
||||
return crypto.createHash('md5').update(raw, 'utf8').digest('hex').toLowerCase()
|
||||
}
|
||||
|
||||
function summarizeDetail(detailResult) {
|
||||
return {
|
||||
success: Boolean(detailResult?.success),
|
||||
reason: String(detailResult?.reason || '').trim(),
|
||||
errorMessage: String(detailResult?.errorMessage || '').trim(),
|
||||
responseStatus: Number(detailResult?.responseStatus || 0),
|
||||
shipped: Boolean(detailResult?.shipped),
|
||||
shipTime: Number(detailResult?.shipTime || 0),
|
||||
orderStatus: Number(detailResult?.orderStatus || 0),
|
||||
bizOrderId: String(detailResult?.detailPayload?.biz_order_id || '').trim(),
|
||||
sellerNick: String(detailResult?.detailPayload?.seller_nick || '').trim(),
|
||||
buyerNick: String(detailResult?.detailPayload?.buyer_nick || '').trim(),
|
||||
sku: String(detailResult?.detailPayload?.sku || '').trim(),
|
||||
itemTitle: String(detailResult?.detailPayload?.item?.title || '').trim(),
|
||||
}
|
||||
}
|
||||
|
||||
function printJson(title, value) {
|
||||
process.stdout.write(`\n=== ${title} ===\n`)
|
||||
process.stdout.write(`${JSON.stringify(value, null, 2)}\n`)
|
||||
}
|
||||
|
||||
function printHelp() {
|
||||
process.stdout.write(`用法:
|
||||
npm run agiso:auto-delivery:test -- <订单号> [--shop-id <店铺ID>] [--poll 0,3,10]
|
||||
|
||||
说明:
|
||||
1. 该脚本只测试正确的发货接口 DummySend。
|
||||
2. 会先查 Order/Detail,再调用 DummySend,再按 poll 秒数轮询发货状态。
|
||||
|
||||
示例:
|
||||
npm run agiso:auto-delivery:test -- 4502285714045005830
|
||||
npm run agiso:auto-delivery:test -- 4502285714045005830 --poll 0,5,15
|
||||
npm run agiso:auto-delivery:test -- 4502285714045005830 --shop-id 2209880145223
|
||||
`)
|
||||
}
|
||||
|
||||
const isNodeTestRunner = Array.isArray(process.execArgv) && process.execArgv.includes('--test')
|
||||
const isDirectRun = !isNodeTestRunner && process.argv[1] && fileURLToPath(import.meta.url) === process.argv[1]
|
||||
|
||||
if (isDirectRun) {
|
||||
main()
|
||||
.catch((error) => {
|
||||
process.stderr.write(`${error instanceof Error ? error.stack || error.message : String(error)}\n`)
|
||||
process.exitCode = 1
|
||||
})
|
||||
.finally(async () => {
|
||||
await closeDb()
|
||||
})
|
||||
}
|
||||
@@ -1,98 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
BASE_URL="http://127.0.0.1:3000"
|
||||
APP_SECRET=$(cd /Users/yml/codes/order-site-workspace/apps/backend && node --input-type=module -e "import { runtimeConfig } from './src/config/runtime.js'; process.stdout.write(String(runtimeConfig.platforms.agiso.appSecret || ''))")
|
||||
|
||||
ORDER_ID="${1:-SIM-ORDER-20260408-0001}"
|
||||
SKU_ID="32768"
|
||||
SKU_NAME="海底捞套餐"
|
||||
|
||||
sign_agiso() {
|
||||
local raw_json="$1"
|
||||
local ts="$2"
|
||||
|
||||
node -e '
|
||||
const crypto = require("crypto")
|
||||
const appSecret = process.argv[1]
|
||||
const rawJson = process.argv[2]
|
||||
const timestamp = process.argv[3]
|
||||
const sign = crypto
|
||||
.createHash("md5")
|
||||
.update(`${appSecret}json${rawJson}timestamp${timestamp}${appSecret}`, "utf8")
|
||||
.digest("hex")
|
||||
.toLowerCase()
|
||||
process.stdout.write(sign)
|
||||
' "$APP_SECRET" "$raw_json" "$ts"
|
||||
}
|
||||
|
||||
push_agiso_event() {
|
||||
local aopic="$1"
|
||||
local raw_json="$2"
|
||||
local ts
|
||||
ts=$(date +%s)
|
||||
local sign
|
||||
sign=$(sign_agiso "$raw_json" "$ts")
|
||||
|
||||
echo
|
||||
echo "========== PUSH aopic=$aopic =========="
|
||||
echo "ORDER_ID=$ORDER_ID"
|
||||
echo
|
||||
|
||||
curl -sS -X POST "${BASE_URL}/api/v1/webhooks/agiso/trade?aopic=${aopic}×tamp=${ts}&sign=${sign}" \
|
||||
-H "Content-Type: application/x-www-form-urlencoded" \
|
||||
--data-urlencode "json=${raw_json}"
|
||||
|
||||
echo
|
||||
echo
|
||||
}
|
||||
|
||||
create_payload() {
|
||||
cat <<EOF
|
||||
{"biz_order_id":"${ORDER_ID}","buyer_id":"buyer-local-001","buyer_name":"本地测试用户","receiver_contact":"13800000000","total_fee":"1","currency":"CNY","items":[{"goods_id":"${SKU_ID}","goods_name":"${SKU_NAME}","quantity":1}]}
|
||||
EOF
|
||||
}
|
||||
|
||||
paid_payload() {
|
||||
cat <<EOF
|
||||
{"biz_order_id":"${ORDER_ID}","buyer_id":"buyer-local-001","buyer_name":"本地测试用户","receiver_contact":"13800000000","total_fee":"1","currency":"CNY","order_status":"3","items":[{"goods_id":"${SKU_ID}","goods_name":"${SKU_NAME}","quantity":1}]}
|
||||
EOF
|
||||
}
|
||||
|
||||
confirm_payload() {
|
||||
cat <<EOF
|
||||
{"biz_order_id":"${ORDER_ID}","buyer_id":"buyer-local-001","buyer_name":"本地测试用户","receiver_contact":"13800000000","total_fee":"1","currency":"CNY","order_status":"4","items":[{"goods_id":"${SKU_ID}","goods_name":"${SKU_NAME}","quantity":1}]}
|
||||
EOF
|
||||
}
|
||||
|
||||
push_create() {
|
||||
push_agiso_event "32" "$(create_payload)"
|
||||
}
|
||||
|
||||
push_paid() {
|
||||
push_agiso_event "1" "$(paid_payload)"
|
||||
}
|
||||
|
||||
push_confirm() {
|
||||
push_agiso_event "256" "$(confirm_payload)"
|
||||
}
|
||||
|
||||
case "${2:-all}" in
|
||||
create)
|
||||
push_create
|
||||
;;
|
||||
paid)
|
||||
push_paid
|
||||
;;
|
||||
confirm)
|
||||
push_confirm
|
||||
;;
|
||||
all)
|
||||
push_create
|
||||
push_paid
|
||||
;;
|
||||
*)
|
||||
echo "用法: $0 [ORDER_ID] [create|paid|confirm|all]"
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
@@ -1,17 +1,16 @@
|
||||
import type { Request, Response, NextFunction } from "express";
|
||||
import type { NextFunction, Request, Response } from "express";
|
||||
import express from "express";
|
||||
import process from "node:process";
|
||||
|
||||
import adminRouter from "./routes/admin.js";
|
||||
import claimsRouter from "./routes/claims.js";
|
||||
import open91Router from "./routes/open-91.js";
|
||||
import tencentRouter from "./routes/tencent.js";
|
||||
import webhooksRouter from "./routes/webhooks.js";
|
||||
import { buildSuccessPayload, sendRouteError } from "./utils/http.js";
|
||||
import { accessLogMiddleware } from "./middleware/access-log.js";
|
||||
import { createCorsMiddleware } from "./middleware/cors.js";
|
||||
import type { RuntimeConfig } from "./types/runtime-config.js";
|
||||
import { buildHealthPayload, type StartupState } from "./startup/state.js";
|
||||
import type { RuntimeConfig } from "./types/runtime-config.js";
|
||||
import { buildSuccessPayload, sendRouteError } from "./utils/http.js";
|
||||
|
||||
type CreateAppOptions = {
|
||||
startupState: StartupState;
|
||||
isShutdownStarted: () => boolean;
|
||||
@@ -44,6 +43,7 @@ export function createApp({
|
||||
buildSuccessPayload({
|
||||
status: isShutdownStarted() ? "shutting_down" : "alive",
|
||||
pid: process.pid,
|
||||
mode: "kuaishou-lite",
|
||||
})
|
||||
);
|
||||
});
|
||||
@@ -89,9 +89,7 @@ export function createApp({
|
||||
});
|
||||
});
|
||||
|
||||
app.use("/api/v1/tencent", tencentRouter);
|
||||
app.use("/api/v1/open/91", open91Router);
|
||||
app.use("/api/v1/webhooks", webhooksRouter);
|
||||
app.use("/api/v1/claim", claimsRouter);
|
||||
app.use("/api/v1/admin", adminRouter);
|
||||
|
||||
|
||||
@@ -8,30 +8,13 @@ export function createDefaultRuntimeConfig(projectRoot: string): RuntimeConfig {
|
||||
port: 3000,
|
||||
},
|
||||
|
||||
browser: {
|
||||
chromePath: "",
|
||||
headless: null,
|
||||
devtools: null,
|
||||
keepAlive: null,
|
||||
prewarm: null,
|
||||
slowMoMs: null,
|
||||
vncResolution: "1600x900x24",
|
||||
},
|
||||
|
||||
session: {
|
||||
debug: false,
|
||||
},
|
||||
|
||||
ocr: {
|
||||
baseUrl: "",
|
||||
},
|
||||
|
||||
data: {
|
||||
root: path.resolve(projectRoot, "data"),
|
||||
},
|
||||
|
||||
logging: {
|
||||
level: "info",
|
||||
retentionDays: 30,
|
||||
},
|
||||
|
||||
database: {
|
||||
@@ -52,30 +35,6 @@ export function createDefaultRuntimeConfig(projectRoot: string): RuntimeConfig {
|
||||
},
|
||||
|
||||
platforms: {
|
||||
agiso: {
|
||||
appSecret: "",
|
||||
tradeDetail: {
|
||||
endpoint: "https://gw-api.agiso.com/aldsIdle/Order/Detail",
|
||||
apiVersion: "1",
|
||||
timeoutMs: 5000,
|
||||
},
|
||||
autoDelivery: {
|
||||
enabled: true,
|
||||
endpoint: "https://gw-api.agiso.com/aldsIdle/Order/DummySend",
|
||||
apiVersion: "1",
|
||||
},
|
||||
messaging: {
|
||||
enabled: false,
|
||||
sendMessageEndpoint:
|
||||
"https://gw-api.agiso.com/aldsIdle/ImMsg/SendMsg",
|
||||
apiVersion: "1",
|
||||
messageTemplate:
|
||||
"您的订单 {platformOrderId} 已创建领取链接,请在 {expiredAt} 前完成领取:{claimUrl}",
|
||||
autoDeliveryMessageTemplate:
|
||||
"您的订单 {platformOrderId} 已完成自动发货,请注意查收。",
|
||||
shops: {},
|
||||
},
|
||||
},
|
||||
ninetyone: {
|
||||
userId: "",
|
||||
secret: "",
|
||||
@@ -120,8 +79,8 @@ export function createDefaultRuntimeConfig(projectRoot: string): RuntimeConfig {
|
||||
bindUrlProbeReferer: "https://gp.qq.com/",
|
||||
bindUrlProbeExtraCookie: "",
|
||||
clientSource: "ct-client",
|
||||
deviceId: "-",
|
||||
deviceType: 0,
|
||||
deviceId: "08bc9d8c-fd15-48ea-bc00-8d754076cafc",
|
||||
deviceType: 1,
|
||||
publicKeyPem: `-----BEGIN PUBLIC KEY-----
|
||||
MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAsSzjk+9st2f8IpYqYaM5
|
||||
F7K27ZR2IvzxTr2w4zgZAS2+BRdgMvXUOITdFQT+ytYF/r3pIgTkL7MF9UQuylmb
|
||||
@@ -137,9 +96,5 @@ PfQTPNA++KsXtwRX9M6Re3vkTDRsutIIWKtj8jqhUbbYS3vzS8GJnAWavUFVkR15
|
||||
cors: {
|
||||
allowedOrigins: ["*"],
|
||||
},
|
||||
|
||||
redeem: {
|
||||
proofMode: "full",
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -21,17 +21,13 @@ const DEPLOYMENT_ONLY_ENV_NAMES = [
|
||||
'CADDY_SITE_ADDR',
|
||||
'CHOKIDAR_USEPOLLING',
|
||||
'NPM_CONFIG_REGISTRY',
|
||||
'OCR_IMAGE',
|
||||
'OCR_PORT',
|
||||
'POSTGRES_DB',
|
||||
'POSTGRES_PASSWORD',
|
||||
'POSTGRES_PORT',
|
||||
'POSTGRES_USER',
|
||||
'TENCENT_BROWSER_NOVNC_ENABLED',
|
||||
'TENCENT_BROWSER_NOVNC_PORT',
|
||||
'TENCENT_BROWSER_VNC_PORT',
|
||||
'TZ',
|
||||
'VITE_API_TARGET',
|
||||
'VITE_ALLOWED_HOSTS',
|
||||
]
|
||||
|
||||
const KNOWN_ENV_NAMES = new Set([
|
||||
|
||||
@@ -10,32 +10,27 @@ test('applyEnvOverrides maps typed environment values without mutating base conf
|
||||
|
||||
const config = applyEnvOverrides(baseConfig, {
|
||||
PORT: '4000',
|
||||
TENCENT_BROWSER_HEADLESS: 'true',
|
||||
TENCENT_BROWSER_SLOW_MO: '250',
|
||||
TENCENT_BROWSER_VNC_RESOLUTION: '1920x1080x24',
|
||||
DATA_ROOT: './tmp/backend-data',
|
||||
DATABASE_SSL: 'yes',
|
||||
LOG_RETENTION_DAYS: '45',
|
||||
ADMIN_DEFAULT_USERS_JSON: '[{"username":"admin","password":"secret","role":"admin"}]',
|
||||
AGISO_TRADE_DETAIL_TIMEOUT_MS: '7000',
|
||||
KAQUAN91_USER_ID: 'kaquan-user',
|
||||
KAQUAN91_SECRET: 'kaquan-secret',
|
||||
CLOUDTENTACLES_DEVICE_TYPE: '3',
|
||||
TENCENT_REDEEM_PROOF_MODE: 'basic',
|
||||
})
|
||||
|
||||
assert.equal(config.server.port, 4000)
|
||||
assert.equal(config.browser.headless, true)
|
||||
assert.equal(config.browser.slowMoMs, 250)
|
||||
assert.equal(config.browser.vncResolution, '1920x1080x24')
|
||||
assert.equal(config.data.root, path.resolve('./tmp/backend-data'))
|
||||
assert.equal(config.database.ssl, true)
|
||||
assert.equal(config.logging.retentionDays, 45)
|
||||
assert.deepEqual(config.admin.defaultUsers, [
|
||||
{ username: 'admin', password: 'secret', role: 'admin' },
|
||||
])
|
||||
assert.equal(config.platforms.agiso.tradeDetail.timeoutMs, 7000)
|
||||
assert.equal(config.platforms.ninetyone.userId, 'kaquan-user')
|
||||
assert.equal(config.platforms.ninetyone.secret, 'kaquan-secret')
|
||||
assert.equal(config.platforms.cloudtentacles.deviceType, 3)
|
||||
assert.equal(config.redeem.proofMode, 'basic')
|
||||
|
||||
assert.equal(baseConfig.server.port, 3000)
|
||||
assert.equal(baseConfig.browser.headless, null)
|
||||
assert.deepEqual(baseConfig.admin.defaultUsers, [])
|
||||
})
|
||||
|
||||
@@ -62,12 +57,14 @@ test('applyEnvOverrides ignores empty or invalid values', () => {
|
||||
DATABASE_SSL: 'maybe',
|
||||
ADMIN_DEFAULT_USERS_JSON: '{"username":"admin"}',
|
||||
LOG_LEVEL: ' ',
|
||||
LOG_RETENTION_DAYS: 'not-a-number',
|
||||
})
|
||||
|
||||
assert.equal(config.server.port, 3000)
|
||||
assert.equal(config.database.ssl, false)
|
||||
assert.deepEqual(config.admin.defaultUsers, [])
|
||||
assert.equal(config.logging.level, 'info')
|
||||
assert.equal(config.logging.retentionDays, 30)
|
||||
})
|
||||
|
||||
test('ENV_OVERRIDES keeps environment variable names unique', () => {
|
||||
@@ -80,26 +77,12 @@ function createRuntimeConfig(): RuntimeConfig {
|
||||
server: {
|
||||
port: 3000,
|
||||
},
|
||||
browser: {
|
||||
chromePath: '',
|
||||
headless: null,
|
||||
devtools: null,
|
||||
keepAlive: null,
|
||||
prewarm: null,
|
||||
slowMoMs: null,
|
||||
vncResolution: '1600x900x24',
|
||||
},
|
||||
session: {
|
||||
debug: false,
|
||||
},
|
||||
ocr: {
|
||||
baseUrl: '',
|
||||
},
|
||||
data: {
|
||||
root: '/tmp/order-site',
|
||||
},
|
||||
logging: {
|
||||
level: 'info',
|
||||
retentionDays: 30,
|
||||
},
|
||||
database: {
|
||||
url: 'postgres://postgres:postgres@127.0.0.1:5432/order_site',
|
||||
@@ -116,27 +99,6 @@ function createRuntimeConfig(): RuntimeConfig {
|
||||
defaultUsers: [],
|
||||
},
|
||||
platforms: {
|
||||
agiso: {
|
||||
appSecret: '',
|
||||
tradeDetail: {
|
||||
endpoint: 'https://gw-api.agiso.com/aldsIdle/Order/Detail',
|
||||
apiVersion: '1',
|
||||
timeoutMs: 5000,
|
||||
},
|
||||
autoDelivery: {
|
||||
enabled: true,
|
||||
endpoint: 'https://gw-api.agiso.com/aldsIdle/Order/DummySend',
|
||||
apiVersion: '1',
|
||||
},
|
||||
messaging: {
|
||||
enabled: false,
|
||||
sendMessageEndpoint: 'https://gw-api.agiso.com/aldsIdle/ImMsg/SendMsg',
|
||||
apiVersion: '1',
|
||||
messageTemplate: '',
|
||||
autoDeliveryMessageTemplate: '',
|
||||
shops: {},
|
||||
},
|
||||
},
|
||||
ninetyone: {
|
||||
userId: '',
|
||||
secret: '',
|
||||
@@ -180,12 +142,9 @@ function createRuntimeConfig(): RuntimeConfig {
|
||||
bindUrlProbeExtraCookie: '',
|
||||
publicKeyPem: '',
|
||||
clientSource: 'ct-client',
|
||||
deviceId: '-',
|
||||
deviceType: 0,
|
||||
deviceId: '08bc9d8c-fd15-48ea-bc00-8d754076cafc',
|
||||
deviceType: 1,
|
||||
},
|
||||
},
|
||||
redeem: {
|
||||
proofMode: 'full',
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,7 +9,6 @@ import {
|
||||
parseBoolean,
|
||||
parseInteger,
|
||||
parseJsonArray,
|
||||
parseCommaSeparatedStrings,
|
||||
} from "./runtime-env.js";
|
||||
import { deepMerge } from "./runtime-merge.js";
|
||||
|
||||
@@ -30,17 +29,9 @@ type EnvOverride = {
|
||||
|
||||
export const ENV_OVERRIDES: readonly EnvOverride[] = [
|
||||
integerEnv("PORT", ["server", "port"]),
|
||||
stringEnv("CHROME_PATH", ["browser", "chromePath"]),
|
||||
booleanEnv("TENCENT_BROWSER_HEADLESS", ["browser", "headless"]),
|
||||
booleanEnv("TENCENT_BROWSER_DEVTOOLS", ["browser", "devtools"]),
|
||||
booleanEnv("TENCENT_BROWSER_KEEP_ALIVE", ["browser", "keepAlive"]),
|
||||
booleanEnv("TENCENT_BROWSER_PREWARM", ["browser", "prewarm"]),
|
||||
integerEnv("TENCENT_BROWSER_SLOW_MO", ["browser", "slowMoMs"]),
|
||||
stringEnv("TENCENT_BROWSER_VNC_RESOLUTION", ["browser", "vncResolution"]),
|
||||
booleanEnv("TENCENT_SESSION_DEBUG", ["session", "debug"]),
|
||||
stringEnv("OCR_BASE_URL", ["ocr", "baseUrl"]),
|
||||
stringEnv("DATA_ROOT", ["data", "root"], path.resolve),
|
||||
stringEnv("LOG_LEVEL", ["logging", "level"]),
|
||||
integerEnv("LOG_RETENTION_DAYS", ["logging", "retentionDays"]),
|
||||
stringEnv("DATABASE_URL", ["database", "url"]),
|
||||
booleanEnv("DATABASE_SSL", ["database", "ssl"]),
|
||||
integerEnv("DATABASE_MAX_CONNECTIONS", ["database", "maxConnections"]),
|
||||
@@ -48,72 +39,17 @@ export const ENV_OVERRIDES: readonly EnvOverride[] = [
|
||||
stringEnv("ADMIN_SESSION_SECRET", ["admin", "sessionSecret"]),
|
||||
integerEnv("ADMIN_SESSION_TTL_HOURS", ["admin", "sessionTtlHours"]),
|
||||
adminUsersJsonEnv("ADMIN_DEFAULT_USERS_JSON", ["admin", "defaultUsers"]),
|
||||
stringEnv("AGISO_APP_SECRET", ["platforms", "agiso", "appSecret"]),
|
||||
stringEnv("AGISO_TRADE_DETAIL_ENDPOINT", [
|
||||
"platforms",
|
||||
"agiso",
|
||||
"tradeDetail",
|
||||
"endpoint",
|
||||
]),
|
||||
stringEnv("AGISO_TRADE_DETAIL_API_VERSION", [
|
||||
"platforms",
|
||||
"agiso",
|
||||
"tradeDetail",
|
||||
"apiVersion",
|
||||
]),
|
||||
integerEnv("AGISO_TRADE_DETAIL_TIMEOUT_MS", [
|
||||
"platforms",
|
||||
"agiso",
|
||||
"tradeDetail",
|
||||
"timeoutMs",
|
||||
]),
|
||||
booleanEnv("AGISO_AUTO_DELIVERY_ENABLED", [
|
||||
"platforms",
|
||||
"agiso",
|
||||
"autoDelivery",
|
||||
"enabled",
|
||||
]),
|
||||
stringEnv("AGISO_AUTO_DELIVERY_ENDPOINT", [
|
||||
"platforms",
|
||||
"agiso",
|
||||
"autoDelivery",
|
||||
"endpoint",
|
||||
]),
|
||||
stringEnv("AGISO_AUTO_DELIVERY_API_VERSION", [
|
||||
"platforms",
|
||||
"agiso",
|
||||
"autoDelivery",
|
||||
"apiVersion",
|
||||
]),
|
||||
stringEnv("AGISO_MESSAGE_API_VERSION", [
|
||||
"platforms",
|
||||
"agiso",
|
||||
"messaging",
|
||||
"apiVersion",
|
||||
]),
|
||||
stringEnv("AGISO_SEND_MESSAGE_ENDPOINT", [
|
||||
"platforms",
|
||||
"agiso",
|
||||
"messaging",
|
||||
"sendMessageEndpoint",
|
||||
]),
|
||||
booleanEnv("AGISO_MESSAGING_ENABLED", [
|
||||
"platforms",
|
||||
"agiso",
|
||||
"messaging",
|
||||
"enabled",
|
||||
]),
|
||||
stringEnv("NINETYONE_USER_ID", ["platforms", "ninetyone", "userId"]),
|
||||
stringEnv("NINETYONE_SECRET", ["platforms", "ninetyone", "secret"]),
|
||||
stringEnv("NINETYONE_VERSION", ["platforms", "ninetyone", "version"]),
|
||||
stringEnv("NINETYONE_SHOP_ID", ["platforms", "ninetyone", "shopId"]),
|
||||
stringEnv("NINETYONE_SHOP_NAME", ["platforms", "ninetyone", "shopName"]),
|
||||
integerEnv("NINETYONE_TIMESTAMP_TOLERANCE_SECONDS", [
|
||||
stringEnv("KAQUAN91_USER_ID", ["platforms", "ninetyone", "userId"]),
|
||||
stringEnv("KAQUAN91_SECRET", ["platforms", "ninetyone", "secret"]),
|
||||
stringEnv("KAQUAN91_VERSION", ["platforms", "ninetyone", "version"]),
|
||||
stringEnv("KAQUAN91_SHOP_ID", ["platforms", "ninetyone", "shopId"]),
|
||||
stringEnv("KAQUAN91_SHOP_NAME", ["platforms", "ninetyone", "shopName"]),
|
||||
integerEnv("KAQUAN91_TIMESTAMP_TOLERANCE_SECONDS", [
|
||||
"platforms",
|
||||
"ninetyone",
|
||||
"timestampToleranceSeconds",
|
||||
]),
|
||||
stringEnv("NINETYONE_CARDS_ENCODING", [
|
||||
stringEnv("KAQUAN91_CARDS_ENCODING", [
|
||||
"platforms",
|
||||
"ninetyone",
|
||||
"cardsEncoding",
|
||||
@@ -288,7 +224,6 @@ export const ENV_OVERRIDES: readonly EnvOverride[] = [
|
||||
"cloudtentacles",
|
||||
"deviceType",
|
||||
]),
|
||||
stringEnv("TENCENT_REDEEM_PROOF_MODE", ["redeem", "proofMode"]),
|
||||
corsOriginsEnv("CORS_ALLOWED_ORIGINS", ["cors", "allowedOrigins"]),
|
||||
];
|
||||
|
||||
@@ -370,17 +305,6 @@ function adminUsersJsonEnv(
|
||||
};
|
||||
}
|
||||
|
||||
function stringArrayEnv(
|
||||
env: string,
|
||||
configPath: RuntimeConfigPath
|
||||
): EnvOverride {
|
||||
return {
|
||||
env,
|
||||
path: configPath,
|
||||
read: parseCommaSeparatedStrings,
|
||||
};
|
||||
}
|
||||
|
||||
function corsOriginsEnv(
|
||||
env: string,
|
||||
configPath: RuntimeConfigPath
|
||||
@@ -431,5 +355,10 @@ function setConfigValue(
|
||||
current = current[key] as Record<string, unknown>;
|
||||
}
|
||||
|
||||
current[configPath[configPath.length - 1]] = value;
|
||||
const lastKey = configPath[configPath.length - 1];
|
||||
if (!lastKey) {
|
||||
return;
|
||||
}
|
||||
|
||||
current[lastKey] = value;
|
||||
}
|
||||
|
||||
@@ -96,18 +96,3 @@ export function parseJsonArray<T = unknown>(rawValue: unknown): T[] | null {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function parseCommaSeparatedStrings(rawValue: unknown): string[] | null {
|
||||
const normalized = String(rawValue || "").trim();
|
||||
|
||||
if (!normalized) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const items = normalized
|
||||
.split(",")
|
||||
.map((item) => item.trim())
|
||||
.filter((item) => item.length > 0);
|
||||
|
||||
return items.length > 0 ? items : null;
|
||||
}
|
||||
|
||||
@@ -112,21 +112,6 @@ function createRuntimeConfig(overrides: Record<string, unknown> = {}): RuntimeCo
|
||||
server: {
|
||||
port: 3000,
|
||||
},
|
||||
browser: {
|
||||
chromePath: '',
|
||||
headless: null,
|
||||
devtools: null,
|
||||
keepAlive: null,
|
||||
prewarm: null,
|
||||
slowMoMs: null,
|
||||
vncResolution: '1600x900x24',
|
||||
},
|
||||
session: {
|
||||
debug: false,
|
||||
},
|
||||
ocr: {
|
||||
baseUrl: '',
|
||||
},
|
||||
data: {
|
||||
root: '/tmp/order-site',
|
||||
},
|
||||
@@ -154,27 +139,6 @@ function createRuntimeConfig(overrides: Record<string, unknown> = {}): RuntimeCo
|
||||
],
|
||||
},
|
||||
platforms: {
|
||||
agiso: {
|
||||
appSecret: '',
|
||||
tradeDetail: {
|
||||
endpoint: 'https://gw-api.agiso.com/aldsIdle/Order/Detail',
|
||||
apiVersion: '1',
|
||||
timeoutMs: 5000,
|
||||
},
|
||||
autoDelivery: {
|
||||
enabled: true,
|
||||
endpoint: 'https://gw-api.agiso.com/aldsIdle/Order/DummySend',
|
||||
apiVersion: '1',
|
||||
},
|
||||
messaging: {
|
||||
enabled: false,
|
||||
sendMessageEndpoint: 'https://gw-api.agiso.com/aldsIdle/ImMsg/SendMsg',
|
||||
apiVersion: '1',
|
||||
messageTemplate: '',
|
||||
autoDeliveryMessageTemplate: '',
|
||||
shops: {},
|
||||
},
|
||||
},
|
||||
ninetyone: {
|
||||
userId: '',
|
||||
secret: '',
|
||||
@@ -218,12 +182,9 @@ function createRuntimeConfig(overrides: Record<string, unknown> = {}): RuntimeCo
|
||||
bindUrlProbeExtraCookie: '',
|
||||
publicKeyPem: '',
|
||||
clientSource: 'ct-client',
|
||||
deviceId: '-',
|
||||
deviceType: 0,
|
||||
deviceId: '08bc9d8c-fd15-48ea-bc00-8d754076cafc',
|
||||
deviceType: 1,
|
||||
},
|
||||
},
|
||||
redeem: {
|
||||
proofMode: 'full',
|
||||
},
|
||||
}, overrides) as RuntimeConfig
|
||||
}
|
||||
|
||||
@@ -47,7 +47,6 @@ export function validateRuntimeConfig(
|
||||
requireInteger(issues, 'database.maxConnections', config.database?.maxConnections, { min: 1 })
|
||||
requireInteger(issues, 'orders.tokenTtlHours', config.orders?.tokenTtlHours, { min: 1 })
|
||||
requireInteger(issues, 'admin.sessionTtlHours', config.admin?.sessionTtlHours, { min: 1 })
|
||||
requireInteger(issues, 'platforms.agiso.tradeDetail.timeoutMs', config.platforms?.agiso?.tradeDetail?.timeoutMs, { min: 1 })
|
||||
requireInteger(issues, 'platforms.cloudtentacles.timeoutMs', config.platforms?.cloudtentacles?.timeoutMs, { min: 1 })
|
||||
requireInteger(issues, 'platforms.cloudtentacles.bindUrlTtlSeconds', config.platforms?.cloudtentacles?.bindUrlTtlSeconds, { min: 1 })
|
||||
requireInteger(
|
||||
|
||||
+33
-103
@@ -6,6 +6,7 @@ CREATE TABLE IF NOT EXISTS admin_users (
|
||||
password_hash TEXT NOT NULL,
|
||||
role TEXT NOT NULL,
|
||||
status TEXT NOT NULL,
|
||||
session_version INTEGER NOT NULL DEFAULT 1,
|
||||
created_at TIMESTAMPTZ NOT NULL,
|
||||
updated_at TIMESTAMPTZ NOT NULL
|
||||
);
|
||||
@@ -51,23 +52,6 @@ CREATE TABLE IF NOT EXISTS fulfillment_profile_requirements (
|
||||
UNIQUE(profile_id, role_key)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS sku_fulfillment_bindings (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
sku_code TEXT NOT NULL,
|
||||
provider TEXT NOT NULL DEFAULT '',
|
||||
platform TEXT NOT NULL DEFAULT '',
|
||||
shop_id TEXT NOT NULL DEFAULT '',
|
||||
profile_id BIGINT NOT NULL REFERENCES fulfillment_profiles(id) ON DELETE CASCADE,
|
||||
enabled BOOLEAN NOT NULL DEFAULT TRUE,
|
||||
priority INTEGER NOT NULL DEFAULT 100,
|
||||
config_json JSONB NOT NULL DEFAULT '{}'::jsonb,
|
||||
created_at TIMESTAMPTZ NOT NULL,
|
||||
updated_at TIMESTAMPTZ NOT NULL
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_sku_fulfillment_bindings_lookup
|
||||
ON sku_fulfillment_bindings(sku_code, provider, platform, shop_id, enabled, priority);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS orders (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
provider TEXT NOT NULL,
|
||||
@@ -89,6 +73,11 @@ CREATE TABLE IF NOT EXISTS orders (
|
||||
UNIQUE(provider, platform, shop_id, platform_order_id)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_orders_platform_order_lookup
|
||||
ON orders(provider, platform, platform_order_id, id DESC);
|
||||
CREATE INDEX IF NOT EXISTS idx_orders_created_at_desc
|
||||
ON orders(created_at DESC);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS order_items (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
order_id BIGINT NOT NULL REFERENCES orders(id) ON DELETE CASCADE,
|
||||
@@ -104,27 +93,6 @@ CREATE TABLE IF NOT EXISTS order_items (
|
||||
CREATE INDEX IF NOT EXISTS idx_order_items_order_id ON order_items(order_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_order_items_sku_code ON order_items(sku_code);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS inventory_items (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
batch_no TEXT NOT NULL DEFAULT '',
|
||||
sku_code TEXT NOT NULL,
|
||||
credential_type TEXT NOT NULL,
|
||||
display_value TEXT NOT NULL DEFAULT '',
|
||||
payload_json JSONB NOT NULL DEFAULT '{}'::jsonb,
|
||||
source_type TEXT NOT NULL DEFAULT 'static_import',
|
||||
status TEXT NOT NULL DEFAULT 'available',
|
||||
invalid_reason TEXT NOT NULL DEFAULT '',
|
||||
metadata_json JSONB NOT NULL DEFAULT '{}'::jsonb,
|
||||
consumed_at TIMESTAMPTZ,
|
||||
created_at TIMESTAMPTZ NOT NULL,
|
||||
updated_at TIMESTAMPTZ NOT NULL
|
||||
);
|
||||
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS idx_inventory_items_unique_payload
|
||||
ON inventory_items(sku_code, credential_type, md5(payload_json::text));
|
||||
CREATE INDEX IF NOT EXISTS idx_inventory_items_pool
|
||||
ON inventory_items(sku_code, credential_type, status, id);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS fulfillment_tasks (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
order_id BIGINT NOT NULL REFERENCES orders(id) ON DELETE CASCADE,
|
||||
@@ -139,7 +107,6 @@ CREATE TABLE IF NOT EXISTS fulfillment_tasks (
|
||||
profile_id BIGINT NOT NULL REFERENCES fulfillment_profiles(id) ON DELETE RESTRICT,
|
||||
executor_key TEXT NOT NULL,
|
||||
task_status TEXT NOT NULL DEFAULT 'pending_payment',
|
||||
inventory_status TEXT NOT NULL DEFAULT 'pending',
|
||||
delivery_status TEXT NOT NULL DEFAULT 'pending',
|
||||
result_code TEXT NOT NULL DEFAULT '',
|
||||
result_message TEXT NOT NULL DEFAULT '',
|
||||
@@ -162,6 +129,10 @@ CREATE TABLE IF NOT EXISTS fulfillment_tasks (
|
||||
CREATE INDEX IF NOT EXISTS idx_fulfillment_tasks_status ON fulfillment_tasks(task_status, delivery_status);
|
||||
CREATE INDEX IF NOT EXISTS idx_fulfillment_tasks_order ON fulfillment_tasks(order_id, order_item_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_fulfillment_tasks_claim_token ON fulfillment_tasks(claim_token);
|
||||
CREATE INDEX IF NOT EXISTS idx_fulfillment_tasks_platform_order_lookup
|
||||
ON fulfillment_tasks(provider, platform, platform_order_id, id DESC);
|
||||
CREATE INDEX IF NOT EXISTS idx_fulfillment_tasks_created_at_desc
|
||||
ON fulfillment_tasks(created_at DESC);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS claim_tokens (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
@@ -178,24 +149,6 @@ CREATE TABLE IF NOT EXISTS claim_tokens (
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_claim_tokens_status ON claim_tokens(status, expired_at);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS task_inventory_bindings (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
task_id BIGINT NOT NULL REFERENCES fulfillment_tasks(id) ON DELETE CASCADE,
|
||||
inventory_item_id BIGINT NOT NULL REFERENCES inventory_items(id) ON DELETE RESTRICT,
|
||||
role_key TEXT NOT NULL,
|
||||
quantity INTEGER NOT NULL DEFAULT 1,
|
||||
binding_status TEXT NOT NULL DEFAULT 'reserved',
|
||||
consumed_at TIMESTAMPTZ,
|
||||
released_at TIMESTAMPTZ,
|
||||
metadata_json JSONB NOT NULL DEFAULT '{}'::jsonb,
|
||||
created_at TIMESTAMPTZ NOT NULL,
|
||||
updated_at TIMESTAMPTZ NOT NULL,
|
||||
UNIQUE(task_id, role_key, inventory_item_id)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_task_inventory_bindings_task ON task_inventory_bindings(task_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_task_inventory_bindings_inventory ON task_inventory_bindings(inventory_item_id);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS task_events (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
task_id BIGINT NOT NULL REFERENCES fulfillment_tasks(id) ON DELETE CASCADE,
|
||||
@@ -206,10 +159,10 @@ CREATE TABLE IF NOT EXISTS task_events (
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_task_events_task_created_at ON task_events(task_id, created_at DESC);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS tencent_browser_contexts (
|
||||
CREATE TABLE IF NOT EXISTS task_runtime_contexts (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
task_id BIGINT NOT NULL UNIQUE REFERENCES fulfillment_tasks(id) ON DELETE CASCADE,
|
||||
browser_session_id TEXT NOT NULL DEFAULT '',
|
||||
runtime_session_id TEXT NOT NULL DEFAULT '',
|
||||
login_type TEXT NOT NULL DEFAULT '',
|
||||
nickname TEXT NOT NULL DEFAULT '',
|
||||
role_id TEXT NOT NULL DEFAULT '',
|
||||
@@ -223,50 +176,27 @@ CREATE TABLE IF NOT EXISTS tencent_browser_contexts (
|
||||
updated_at TIMESTAMPTZ NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS message_deliveries (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
provider TEXT NOT NULL DEFAULT '',
|
||||
platform TEXT NOT NULL DEFAULT '',
|
||||
shop_id TEXT NOT NULL DEFAULT '',
|
||||
shop_name TEXT NOT NULL DEFAULT '',
|
||||
channel TEXT NOT NULL,
|
||||
order_id BIGINT REFERENCES orders(id) ON DELETE SET NULL,
|
||||
task_id BIGINT REFERENCES fulfillment_tasks(id) ON DELETE SET NULL,
|
||||
platform_order_id TEXT NOT NULL DEFAULT '',
|
||||
recipient_key TEXT NOT NULL DEFAULT '',
|
||||
message_content TEXT NOT NULL DEFAULT '',
|
||||
claim_url TEXT NOT NULL DEFAULT '',
|
||||
status TEXT NOT NULL DEFAULT 'pending',
|
||||
request_url TEXT NOT NULL DEFAULT '',
|
||||
request_headers_json JSONB NOT NULL DEFAULT '{}'::jsonb,
|
||||
request_body_json JSONB NOT NULL DEFAULT '{}'::jsonb,
|
||||
response_status INTEGER NOT NULL DEFAULT 0,
|
||||
response_json JSONB NOT NULL DEFAULT '{}'::jsonb,
|
||||
error_message TEXT NOT NULL DEFAULT '',
|
||||
sent_at TIMESTAMPTZ,
|
||||
created_at TIMESTAMPTZ NOT NULL,
|
||||
CREATE TABLE IF NOT EXISTS kuaishou_cloud_task_states (
|
||||
task_id BIGINT PRIMARY KEY REFERENCES fulfillment_tasks(id) ON DELETE CASCADE,
|
||||
ticket_status TEXT NOT NULL DEFAULT 'pending',
|
||||
ticket_code_masked TEXT NOT NULL DEFAULT '',
|
||||
bind_url TEXT NOT NULL DEFAULT '',
|
||||
vn_phone TEXT NOT NULL DEFAULT '',
|
||||
role_name TEXT NOT NULL DEFAULT '',
|
||||
role_id TEXT NOT NULL DEFAULT '',
|
||||
dispatch_status TEXT NOT NULL DEFAULT 'pending',
|
||||
consume_status TEXT NOT NULL DEFAULT 'pending',
|
||||
source_key TEXT NOT NULL DEFAULT '',
|
||||
updated_at TIMESTAMPTZ NOT NULL
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_message_deliveries_task_channel ON message_deliveries(task_id, channel, created_at DESC);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS webhook_events (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
provider TEXT NOT NULL DEFAULT '',
|
||||
platform TEXT NOT NULL DEFAULT '',
|
||||
shop_id TEXT NOT NULL DEFAULT '',
|
||||
shop_name TEXT NOT NULL DEFAULT '',
|
||||
event_type TEXT NOT NULL,
|
||||
event_key TEXT NOT NULL,
|
||||
signature_valid BOOLEAN NOT NULL DEFAULT FALSE,
|
||||
headers_json JSONB NOT NULL DEFAULT '{}'::jsonb,
|
||||
query_json JSONB NOT NULL DEFAULT '{}'::jsonb,
|
||||
body_json JSONB NOT NULL DEFAULT '{}'::jsonb,
|
||||
processed BOOLEAN NOT NULL DEFAULT FALSE,
|
||||
process_error TEXT NOT NULL DEFAULT '',
|
||||
related_order_id BIGINT REFERENCES orders(id) ON DELETE SET NULL,
|
||||
created_at TIMESTAMPTZ NOT NULL
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_webhook_events_lookup ON webhook_events(provider, platform, shop_id, event_key);
|
||||
CREATE INDEX IF NOT EXISTS idx_webhook_events_processed ON webhook_events(processed, created_at DESC);
|
||||
CREATE INDEX IF NOT EXISTS idx_kuaishou_cloud_task_states_role_id
|
||||
ON kuaishou_cloud_task_states(role_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_kuaishou_cloud_task_states_source_key
|
||||
ON kuaishou_cloud_task_states(source_key);
|
||||
CREATE INDEX IF NOT EXISTS idx_kuaishou_cloud_task_states_dispatch_status
|
||||
ON kuaishou_cloud_task_states(dispatch_status);
|
||||
CREATE INDEX IF NOT EXISTS idx_kuaishou_cloud_task_states_consume_status
|
||||
ON kuaishou_cloud_task_states(consume_status);
|
||||
CREATE INDEX IF NOT EXISTS idx_kuaishou_cloud_task_states_updated_at_desc
|
||||
ON kuaishou_cloud_task_states(updated_at DESC);
|
||||
@@ -1,30 +0,0 @@
|
||||
CREATE TABLE IF NOT EXISTS product_match_rules (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
provider TEXT NOT NULL DEFAULT '',
|
||||
platform TEXT NOT NULL DEFAULT '',
|
||||
shop_id TEXT NOT NULL DEFAULT '',
|
||||
external_item_id TEXT NOT NULL DEFAULT '',
|
||||
external_sku_code TEXT NOT NULL DEFAULT '',
|
||||
external_sku_name TEXT NOT NULL DEFAULT '',
|
||||
external_sku_name_normalized TEXT NOT NULL DEFAULT '',
|
||||
resolved_sku_code TEXT NOT NULL,
|
||||
enabled BOOLEAN NOT NULL DEFAULT TRUE,
|
||||
priority INTEGER NOT NULL DEFAULT 100,
|
||||
config_json JSONB NOT NULL DEFAULT '{}'::jsonb,
|
||||
created_at TIMESTAMPTZ NOT NULL,
|
||||
updated_at TIMESTAMPTZ NOT NULL
|
||||
);
|
||||
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS idx_product_match_rules_unique
|
||||
ON product_match_rules(
|
||||
provider,
|
||||
platform,
|
||||
shop_id,
|
||||
external_item_id,
|
||||
external_sku_code,
|
||||
external_sku_name_normalized,
|
||||
resolved_sku_code
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_product_match_rules_lookup
|
||||
ON product_match_rules(provider, platform, shop_id, enabled, priority, id);
|
||||
@@ -1,3 +0,0 @@
|
||||
CREATE INDEX IF NOT EXISTS idx_message_deliveries_dedupe_success
|
||||
ON message_deliveries(provider, platform, shop_id, platform_order_id, channel, claim_url, created_at DESC)
|
||||
WHERE status = 'success';
|
||||
@@ -1,17 +0,0 @@
|
||||
ALTER TABLE inventory_items
|
||||
ADD COLUMN IF NOT EXISTS inventory_group_code TEXT NOT NULL DEFAULT '';
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_inventory_items_group_lookup
|
||||
ON inventory_items(sku_code, credential_type, inventory_group_code, status, id);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS admin_user_inventory_group_bindings (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
admin_user_id BIGINT NOT NULL REFERENCES admin_users(id) ON DELETE CASCADE,
|
||||
inventory_group_code TEXT NOT NULL,
|
||||
created_at TIMESTAMPTZ NOT NULL,
|
||||
updated_at TIMESTAMPTZ NOT NULL,
|
||||
UNIQUE(admin_user_id, inventory_group_code)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_admin_user_inventory_group_bindings_user_id
|
||||
ON admin_user_inventory_group_bindings(admin_user_id, inventory_group_code);
|
||||
@@ -0,0 +1,27 @@
|
||||
import test from 'node:test'
|
||||
import assert from 'node:assert/strict'
|
||||
|
||||
import {
|
||||
TASK_STATUS,
|
||||
canRedeemKuaishouCloudClaimStatus,
|
||||
canTaskTransition,
|
||||
isClaimInactiveTaskStatus,
|
||||
isOpen91FailedTaskStatus,
|
||||
shouldEnsureKuaishouCloudClaimLink,
|
||||
} from './task-status.js'
|
||||
|
||||
test('任务状态机声明快手 Cloud 主流程跳转', () => {
|
||||
assert.equal(canTaskTransition(TASK_STATUS.PENDING_BINDING_PREPARE, TASK_STATUS.WAITING_BINDING), true)
|
||||
assert.equal(canTaskTransition(TASK_STATUS.WAITING_BINDING, TASK_STATUS.ROLE_CONFIRMED), true)
|
||||
assert.equal(canTaskTransition(TASK_STATUS.ROLE_CONFIRMED, TASK_STATUS.REDEEMING), true)
|
||||
assert.equal(canTaskTransition(TASK_STATUS.REDEEMING, TASK_STATUS.DISPATCHED_PENDING_RETURN), true)
|
||||
assert.equal(canTaskTransition(TASK_STATUS.DISPATCHED_PENDING_RETURN, TASK_STATUS.COMPLETED), true)
|
||||
})
|
||||
|
||||
test('任务状态机收敛领取和 91 查询的业务判断', () => {
|
||||
assert.equal(shouldEnsureKuaishouCloudClaimLink(TASK_STATUS.PENDING_BINDING_PREPARE), true)
|
||||
assert.equal(canRedeemKuaishouCloudClaimStatus(TASK_STATUS.ROLE_CONFIRMED), true)
|
||||
assert.equal(canRedeemKuaishouCloudClaimStatus(TASK_STATUS.WAITING_BINDING), false)
|
||||
assert.equal(isClaimInactiveTaskStatus(TASK_STATUS.MANUAL_REVIEW), true)
|
||||
assert.equal(isOpen91FailedTaskStatus(TASK_STATUS.CLOSED), true)
|
||||
})
|
||||
@@ -0,0 +1,326 @@
|
||||
export const TASK_STATUS = {
|
||||
PENDING_PAYMENT: 'pending_payment',
|
||||
PAID: 'paid',
|
||||
LINK_GENERATED: 'link_generated',
|
||||
CLAIMED: 'claimed',
|
||||
PENDING_BINDING_PREPARE: 'pending_binding_prepare',
|
||||
WAITING_BINDING: 'waiting_binding',
|
||||
ROLE_CONFIRMED: 'role_confirmed',
|
||||
REDEEMING: 'redeeming',
|
||||
DISPATCHED_PENDING_RETURN: 'dispatched_pending_return',
|
||||
COMPLETED: 'completed',
|
||||
REDEEMED: 'redeemed',
|
||||
RETRY_PENDING: 'retry_pending',
|
||||
MANUAL_REVIEW: 'manual_review',
|
||||
FAILED: 'failed',
|
||||
EXPIRED: 'expired',
|
||||
CLOSED: 'closed',
|
||||
} as const
|
||||
|
||||
export type KnownTaskStatus = (typeof TASK_STATUS)[keyof typeof TASK_STATUS]
|
||||
export type TaskStatus = KnownTaskStatus | (string & {})
|
||||
|
||||
const TASK_TRANSITIONS: Record<KnownTaskStatus, readonly KnownTaskStatus[]> = {
|
||||
[TASK_STATUS.PENDING_PAYMENT]: [
|
||||
TASK_STATUS.PAID,
|
||||
TASK_STATUS.PENDING_BINDING_PREPARE,
|
||||
TASK_STATUS.MANUAL_REVIEW,
|
||||
TASK_STATUS.CLOSED,
|
||||
],
|
||||
[TASK_STATUS.PAID]: [
|
||||
TASK_STATUS.LINK_GENERATED,
|
||||
TASK_STATUS.PENDING_BINDING_PREPARE,
|
||||
TASK_STATUS.MANUAL_REVIEW,
|
||||
TASK_STATUS.CLOSED,
|
||||
],
|
||||
[TASK_STATUS.LINK_GENERATED]: [
|
||||
TASK_STATUS.CLAIMED,
|
||||
TASK_STATUS.WAITING_BINDING,
|
||||
TASK_STATUS.ROLE_CONFIRMED,
|
||||
TASK_STATUS.RETRY_PENDING,
|
||||
TASK_STATUS.MANUAL_REVIEW,
|
||||
TASK_STATUS.EXPIRED,
|
||||
TASK_STATUS.CLOSED,
|
||||
],
|
||||
[TASK_STATUS.CLAIMED]: [
|
||||
TASK_STATUS.WAITING_BINDING,
|
||||
TASK_STATUS.ROLE_CONFIRMED,
|
||||
TASK_STATUS.RETRY_PENDING,
|
||||
TASK_STATUS.MANUAL_REVIEW,
|
||||
TASK_STATUS.EXPIRED,
|
||||
TASK_STATUS.CLOSED,
|
||||
],
|
||||
[TASK_STATUS.PENDING_BINDING_PREPARE]: [
|
||||
TASK_STATUS.WAITING_BINDING,
|
||||
TASK_STATUS.RETRY_PENDING,
|
||||
TASK_STATUS.MANUAL_REVIEW,
|
||||
TASK_STATUS.EXPIRED,
|
||||
TASK_STATUS.CLOSED,
|
||||
],
|
||||
[TASK_STATUS.WAITING_BINDING]: [
|
||||
TASK_STATUS.ROLE_CONFIRMED,
|
||||
TASK_STATUS.REDEEMING,
|
||||
TASK_STATUS.RETRY_PENDING,
|
||||
TASK_STATUS.MANUAL_REVIEW,
|
||||
TASK_STATUS.EXPIRED,
|
||||
TASK_STATUS.CLOSED,
|
||||
],
|
||||
[TASK_STATUS.ROLE_CONFIRMED]: [
|
||||
TASK_STATUS.WAITING_BINDING,
|
||||
TASK_STATUS.REDEEMING,
|
||||
TASK_STATUS.RETRY_PENDING,
|
||||
TASK_STATUS.MANUAL_REVIEW,
|
||||
TASK_STATUS.EXPIRED,
|
||||
TASK_STATUS.CLOSED,
|
||||
],
|
||||
[TASK_STATUS.REDEEMING]: [
|
||||
TASK_STATUS.DISPATCHED_PENDING_RETURN,
|
||||
TASK_STATUS.COMPLETED,
|
||||
TASK_STATUS.REDEEMED,
|
||||
TASK_STATUS.RETRY_PENDING,
|
||||
TASK_STATUS.MANUAL_REVIEW,
|
||||
],
|
||||
[TASK_STATUS.DISPATCHED_PENDING_RETURN]: [
|
||||
TASK_STATUS.COMPLETED,
|
||||
TASK_STATUS.REDEEMED,
|
||||
TASK_STATUS.MANUAL_REVIEW,
|
||||
],
|
||||
[TASK_STATUS.RETRY_PENDING]: [
|
||||
TASK_STATUS.PENDING_BINDING_PREPARE,
|
||||
TASK_STATUS.WAITING_BINDING,
|
||||
TASK_STATUS.ROLE_CONFIRMED,
|
||||
TASK_STATUS.REDEEMING,
|
||||
TASK_STATUS.MANUAL_REVIEW,
|
||||
TASK_STATUS.CLOSED,
|
||||
],
|
||||
[TASK_STATUS.MANUAL_REVIEW]: [
|
||||
TASK_STATUS.PENDING_BINDING_PREPARE,
|
||||
TASK_STATUS.WAITING_BINDING,
|
||||
TASK_STATUS.ROLE_CONFIRMED,
|
||||
TASK_STATUS.REDEEMING,
|
||||
TASK_STATUS.DISPATCHED_PENDING_RETURN,
|
||||
TASK_STATUS.COMPLETED,
|
||||
TASK_STATUS.REDEEMED,
|
||||
TASK_STATUS.CLOSED,
|
||||
],
|
||||
[TASK_STATUS.FAILED]: [TASK_STATUS.RETRY_PENDING, TASK_STATUS.MANUAL_REVIEW, TASK_STATUS.CLOSED],
|
||||
[TASK_STATUS.COMPLETED]: [],
|
||||
[TASK_STATUS.REDEEMED]: [],
|
||||
[TASK_STATUS.EXPIRED]: [],
|
||||
[TASK_STATUS.CLOSED]: [],
|
||||
}
|
||||
|
||||
const FINAL_STATUSES = new Set<TaskStatus>([
|
||||
TASK_STATUS.COMPLETED,
|
||||
TASK_STATUS.REDEEMED,
|
||||
TASK_STATUS.EXPIRED,
|
||||
TASK_STATUS.CLOSED,
|
||||
])
|
||||
|
||||
const OPEN_91_FAILED_STATUSES = new Set<TaskStatus>([
|
||||
TASK_STATUS.FAILED,
|
||||
TASK_STATUS.MANUAL_REVIEW,
|
||||
TASK_STATUS.CLOSED,
|
||||
])
|
||||
|
||||
const CLAIM_INACTIVE_STATUSES = new Set<TaskStatus>([
|
||||
TASK_STATUS.COMPLETED,
|
||||
TASK_STATUS.REDEEMED,
|
||||
TASK_STATUS.MANUAL_REVIEW,
|
||||
TASK_STATUS.FAILED,
|
||||
TASK_STATUS.EXPIRED,
|
||||
TASK_STATUS.CLOSED,
|
||||
])
|
||||
|
||||
const PAID_PREPARATION_STABLE_STATUSES = new Set<TaskStatus>([
|
||||
TASK_STATUS.LINK_GENERATED,
|
||||
TASK_STATUS.CLAIMED,
|
||||
TASK_STATUS.ROLE_CONFIRMED,
|
||||
TASK_STATUS.REDEEMING,
|
||||
TASK_STATUS.DISPATCHED_PENDING_RETURN,
|
||||
TASK_STATUS.COMPLETED,
|
||||
TASK_STATUS.REDEEMED,
|
||||
TASK_STATUS.EXPIRED,
|
||||
TASK_STATUS.CLOSED,
|
||||
])
|
||||
|
||||
const KUAISHOU_CLOUD_CLAIM_LINK_STATUSES = new Set<TaskStatus>([
|
||||
TASK_STATUS.PENDING_BINDING_PREPARE,
|
||||
TASK_STATUS.WAITING_BINDING,
|
||||
TASK_STATUS.DISPATCHED_PENDING_RETURN,
|
||||
TASK_STATUS.COMPLETED,
|
||||
TASK_STATUS.MANUAL_REVIEW,
|
||||
TASK_STATUS.FAILED,
|
||||
])
|
||||
|
||||
const KUAISHOU_CLOUD_ROLE_CONFIRM_SETTLED_STATUSES = new Set<TaskStatus>([
|
||||
TASK_STATUS.ROLE_CONFIRMED,
|
||||
TASK_STATUS.REDEEMING,
|
||||
TASK_STATUS.DISPATCHED_PENDING_RETURN,
|
||||
TASK_STATUS.COMPLETED,
|
||||
TASK_STATUS.REDEEMED,
|
||||
TASK_STATUS.MANUAL_REVIEW,
|
||||
])
|
||||
|
||||
const KUAISHOU_CLOUD_REDEEM_SETTLED_STATUSES = new Set<TaskStatus>([
|
||||
TASK_STATUS.REDEEMING,
|
||||
TASK_STATUS.DISPATCHED_PENDING_RETURN,
|
||||
TASK_STATUS.COMPLETED,
|
||||
TASK_STATUS.REDEEMED,
|
||||
TASK_STATUS.MANUAL_REVIEW,
|
||||
])
|
||||
|
||||
const CLAIM_LINK_REGENERATABLE_STATUSES = new Set<TaskStatus>([
|
||||
TASK_STATUS.LINK_GENERATED,
|
||||
TASK_STATUS.CLAIMED,
|
||||
TASK_STATUS.ROLE_CONFIRMED,
|
||||
TASK_STATUS.RETRY_PENDING,
|
||||
TASK_STATUS.MANUAL_REVIEW,
|
||||
])
|
||||
|
||||
const KUAISHOU_CLOUD_RESULT_STATUSES = new Set<TaskStatus>([
|
||||
TASK_STATUS.DISPATCHED_PENDING_RETURN,
|
||||
TASK_STATUS.COMPLETED,
|
||||
TASK_STATUS.REDEEMED,
|
||||
TASK_STATUS.MANUAL_REVIEW,
|
||||
TASK_STATUS.FAILED,
|
||||
])
|
||||
|
||||
const FULFILLMENT_COMPLETED_STATUSES = new Set<TaskStatus>([
|
||||
TASK_STATUS.DISPATCHED_PENDING_RETURN,
|
||||
TASK_STATUS.COMPLETED,
|
||||
TASK_STATUS.REDEEMED,
|
||||
])
|
||||
|
||||
const RETRYABLE_TASK_STATUSES = new Set<TaskStatus>([
|
||||
TASK_STATUS.RETRY_PENDING,
|
||||
TASK_STATUS.MANUAL_REVIEW,
|
||||
])
|
||||
|
||||
const KUAISHOU_CLOUD_PREPARABLE_STATUSES = new Set<TaskStatus>([
|
||||
TASK_STATUS.PENDING_BINDING_PREPARE,
|
||||
TASK_STATUS.MANUAL_REVIEW,
|
||||
TASK_STATUS.FAILED,
|
||||
])
|
||||
|
||||
const KUAISHOU_CLOUD_REBINDABLE_STATUSES = new Set<TaskStatus>([
|
||||
TASK_STATUS.WAITING_BINDING,
|
||||
TASK_STATUS.ROLE_CONFIRMED,
|
||||
TASK_STATUS.MANUAL_REVIEW,
|
||||
TASK_STATUS.RETRY_PENDING,
|
||||
])
|
||||
|
||||
export function normalizeTaskStatus(value: unknown): TaskStatus {
|
||||
return String(value || '').trim() as TaskStatus
|
||||
}
|
||||
|
||||
export function canTaskTransition(from: unknown, to: unknown): boolean {
|
||||
const current = normalizeTaskStatus(from) as KnownTaskStatus
|
||||
const next = normalizeTaskStatus(to) as KnownTaskStatus
|
||||
if (!current || !next || current === next) {
|
||||
return Boolean(current && next)
|
||||
}
|
||||
|
||||
return Boolean(TASK_TRANSITIONS[current]?.includes(next))
|
||||
}
|
||||
|
||||
export function getTaskNextStatuses(status: unknown): readonly KnownTaskStatus[] {
|
||||
return TASK_TRANSITIONS[normalizeTaskStatus(status) as KnownTaskStatus] || []
|
||||
}
|
||||
|
||||
export function isTaskFinalStatus(status: unknown): boolean {
|
||||
return FINAL_STATUSES.has(normalizeTaskStatus(status))
|
||||
}
|
||||
|
||||
export function isOpen91FailedTaskStatus(status: unknown): boolean {
|
||||
return OPEN_91_FAILED_STATUSES.has(normalizeTaskStatus(status))
|
||||
}
|
||||
|
||||
export function isClaimInactiveTaskStatus(status: unknown): boolean {
|
||||
return CLAIM_INACTIVE_STATUSES.has(normalizeTaskStatus(status))
|
||||
}
|
||||
|
||||
export function isPaidPreparationStableStatus(status: unknown): boolean {
|
||||
return PAID_PREPARATION_STABLE_STATUSES.has(normalizeTaskStatus(status))
|
||||
}
|
||||
|
||||
export function shouldEnsureKuaishouCloudClaimLink(status: unknown): boolean {
|
||||
return KUAISHOU_CLOUD_CLAIM_LINK_STATUSES.has(normalizeTaskStatus(status))
|
||||
}
|
||||
|
||||
export function isKuaishouCloudRoleConfirmSettledStatus(status: unknown): boolean {
|
||||
return KUAISHOU_CLOUD_ROLE_CONFIRM_SETTLED_STATUSES.has(normalizeTaskStatus(status))
|
||||
}
|
||||
|
||||
export function isKuaishouCloudRedeemSettledStatus(status: unknown): boolean {
|
||||
return KUAISHOU_CLOUD_REDEEM_SETTLED_STATUSES.has(normalizeTaskStatus(status))
|
||||
}
|
||||
|
||||
export function canRedeemKuaishouCloudClaimStatus(status: unknown): boolean {
|
||||
return normalizeTaskStatus(status) === TASK_STATUS.ROLE_CONFIRMED
|
||||
}
|
||||
|
||||
export function canRegenerateClaimLinkStatus(status: unknown): boolean {
|
||||
return CLAIM_LINK_REGENERATABLE_STATUSES.has(normalizeTaskStatus(status))
|
||||
}
|
||||
|
||||
export function hasKuaishouCloudRedeemResultStatus(status: unknown): boolean {
|
||||
return KUAISHOU_CLOUD_RESULT_STATUSES.has(normalizeTaskStatus(status))
|
||||
}
|
||||
|
||||
export function isTaskFulfillmentCompletedStatus(status: unknown): boolean {
|
||||
return FULFILLMENT_COMPLETED_STATUSES.has(normalizeTaskStatus(status))
|
||||
}
|
||||
|
||||
export function canRetryTaskStatus(status: unknown): boolean {
|
||||
return RETRYABLE_TASK_STATUSES.has(normalizeTaskStatus(status))
|
||||
}
|
||||
|
||||
export function canMarkManualReviewTaskStatus(status: unknown): boolean {
|
||||
const normalized = normalizeTaskStatus(status)
|
||||
return normalized !== TASK_STATUS.MANUAL_REVIEW && !isTaskFinalStatus(normalized)
|
||||
}
|
||||
|
||||
export function canCompleteManualDispatchTaskStatus(status: unknown): boolean {
|
||||
return !isTaskFinalStatus(status)
|
||||
}
|
||||
|
||||
export function canPrepareKuaishouCloudFulfillmentStatus(status: unknown): boolean {
|
||||
return KUAISHOU_CLOUD_PREPARABLE_STATUSES.has(normalizeTaskStatus(status))
|
||||
}
|
||||
|
||||
export function canRebindKuaishouCloudRoleStatus(status: unknown): boolean {
|
||||
return KUAISHOU_CLOUD_REBINDABLE_STATUSES.has(normalizeTaskStatus(status))
|
||||
}
|
||||
|
||||
export function canDispatchKuaishouCloudFulfillmentStatus(status: unknown): boolean {
|
||||
return normalizeTaskStatus(status) === TASK_STATUS.WAITING_BINDING
|
||||
}
|
||||
|
||||
export function canReturnKuaishouCloudFulfillmentStatus(status: unknown): boolean {
|
||||
return normalizeTaskStatus(status) === TASK_STATUS.DISPATCHED_PENDING_RETURN
|
||||
}
|
||||
|
||||
export function resolveInitialPaidTaskStatus(
|
||||
profile:
|
||||
| {
|
||||
requires_claim?: boolean
|
||||
executor_key?: unknown
|
||||
}
|
||||
| null
|
||||
| undefined,
|
||||
): TaskStatus {
|
||||
if (Boolean(profile?.requires_claim)) {
|
||||
return TASK_STATUS.PAID
|
||||
}
|
||||
|
||||
if (String(profile?.executor_key || '').trim() === 'kuaishou_ct_assisted') {
|
||||
return TASK_STATUS.PENDING_BINDING_PREPARE
|
||||
}
|
||||
|
||||
if (String(profile?.executor_key || '').trim() === 'manual_dispatch') {
|
||||
return TASK_STATUS.MANUAL_REVIEW
|
||||
}
|
||||
|
||||
return TASK_STATUS.PAID
|
||||
}
|
||||
@@ -20,6 +20,7 @@ try {
|
||||
logError("[startup]", "运行时配置校验失败,服务停止启动", error);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const app = createApp({
|
||||
startupState,
|
||||
isShutdownStarted: () => shutdownController?.isShutdownStarted() || false,
|
||||
@@ -27,7 +28,10 @@ const app = createApp({
|
||||
});
|
||||
|
||||
const server = app.listen(port, host, () => {
|
||||
logInfo("[startup]", `order-site-backend listening on http://${host}:${port}`);
|
||||
logInfo(
|
||||
"[startup]",
|
||||
`order-site-backend kuaishou-lite listening on http://${host}:${port}`
|
||||
);
|
||||
void bootstrapCoreServices(
|
||||
startupState,
|
||||
() => shutdownController?.isShutdownStarted() || false
|
||||
|
||||
@@ -43,7 +43,7 @@ function resolveRequestId(req: Request): string {
|
||||
}
|
||||
|
||||
function shouldSkipAccessLog(originalUrl: string, statusCode: number): boolean {
|
||||
const pathname = String(originalUrl || "").split("?")[0];
|
||||
const pathname = String(originalUrl || "").split("?")[0] || "";
|
||||
return (
|
||||
["/health", "/health/live", "/health/ready"].includes(pathname) &&
|
||||
Number(statusCode) < 400
|
||||
|
||||
@@ -0,0 +1,116 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import type { NextFunction, Request, Response } from "express";
|
||||
|
||||
import {
|
||||
createRateLimitMiddleware,
|
||||
resetRateLimitBucketsForTest,
|
||||
} from "./rate-limit.js";
|
||||
|
||||
test("createRateLimitMiddleware allows requests within the window", () => {
|
||||
resetRateLimitBucketsForTest();
|
||||
|
||||
const limiter = createRateLimitMiddleware({
|
||||
scope: "test:allow",
|
||||
windowMs: 60_000,
|
||||
max: 2,
|
||||
});
|
||||
const req = createMockRequest();
|
||||
const res = createMockResponse();
|
||||
let nextCount = 0;
|
||||
const next: NextFunction = () => {
|
||||
nextCount += 1;
|
||||
};
|
||||
|
||||
limiter(req, res, next);
|
||||
limiter(req, res, next);
|
||||
|
||||
assert.equal(nextCount, 2);
|
||||
assert.equal(res.statusCode, 200);
|
||||
});
|
||||
|
||||
test("createRateLimitMiddleware blocks requests over the limit", () => {
|
||||
resetRateLimitBucketsForTest();
|
||||
|
||||
const limiter = createRateLimitMiddleware({
|
||||
scope: "test:block",
|
||||
windowMs: 60_000,
|
||||
max: 1,
|
||||
});
|
||||
const req = createMockRequest();
|
||||
const res = createMockResponse();
|
||||
let nextCount = 0;
|
||||
const next: NextFunction = () => {
|
||||
nextCount += 1;
|
||||
};
|
||||
|
||||
limiter(req, res, next);
|
||||
limiter(req, res, next);
|
||||
|
||||
assert.equal(nextCount, 1);
|
||||
assert.equal(res.statusCode, 429);
|
||||
assert.equal(res.headers["Retry-After"], "60");
|
||||
assert.equal(res.body.errorCode, "rate_limited");
|
||||
});
|
||||
|
||||
test("createRateLimitMiddleware supports custom limit responses", () => {
|
||||
resetRateLimitBucketsForTest();
|
||||
|
||||
const limiter = createRateLimitMiddleware({
|
||||
scope: "test:custom",
|
||||
windowMs: 60_000,
|
||||
max: 1,
|
||||
onLimit: (_req, res) => {
|
||||
res.status(200).json({ code: 429, message: "limited" });
|
||||
},
|
||||
});
|
||||
const req = createMockRequest();
|
||||
const res = createMockResponse();
|
||||
|
||||
limiter(req, res, () => {});
|
||||
limiter(req, res, () => {});
|
||||
|
||||
assert.equal(res.statusCode, 200);
|
||||
assert.deepEqual(res.body, { code: 429, message: "limited" });
|
||||
});
|
||||
|
||||
function createMockRequest(): Request {
|
||||
return {
|
||||
ip: "127.0.0.1",
|
||||
originalUrl: "/test",
|
||||
headers: {},
|
||||
socket: {
|
||||
remoteAddress: "127.0.0.1",
|
||||
},
|
||||
} as Request;
|
||||
}
|
||||
|
||||
function createMockResponse(): Response & {
|
||||
statusCode: number;
|
||||
headers: Record<string, string>;
|
||||
body: any;
|
||||
} {
|
||||
const res = {
|
||||
statusCode: 200,
|
||||
headers: {} as Record<string, string>,
|
||||
body: null as any,
|
||||
setHeader(name: string, value: string) {
|
||||
this.headers[name] = value;
|
||||
return this;
|
||||
},
|
||||
status(statusCode: number) {
|
||||
this.statusCode = statusCode;
|
||||
return this;
|
||||
},
|
||||
json(body: any) {
|
||||
this.body = body;
|
||||
return this;
|
||||
},
|
||||
};
|
||||
|
||||
return res as Response & {
|
||||
statusCode: number;
|
||||
headers: Record<string, string>;
|
||||
body: any;
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,122 @@
|
||||
import type { NextFunction, Request, Response } from "express";
|
||||
|
||||
import { buildErrorPayload, createHttpError } from "../utils/http.js";
|
||||
import { logWarn } from "../utils/logger.js";
|
||||
|
||||
type RateLimitKeyResolver = (req: Request) => string;
|
||||
|
||||
type RateLimitExceededContext = {
|
||||
scope: string;
|
||||
retryAfterSeconds: number;
|
||||
};
|
||||
|
||||
type RateLimitOptions = {
|
||||
scope: string;
|
||||
windowMs: number;
|
||||
max: number;
|
||||
key?: RateLimitKeyResolver;
|
||||
onLimit?: (req: Request, res: Response, context: RateLimitExceededContext) => void;
|
||||
};
|
||||
|
||||
type RateLimitBucket = {
|
||||
count: number;
|
||||
resetAt: number;
|
||||
};
|
||||
|
||||
const buckets = new Map<string, RateLimitBucket>();
|
||||
let lastCleanupAt = 0;
|
||||
|
||||
export function createRateLimitMiddleware({
|
||||
scope,
|
||||
windowMs,
|
||||
max,
|
||||
key = defaultRateLimitKey,
|
||||
onLimit,
|
||||
}: RateLimitOptions) {
|
||||
const normalizedScope = String(scope || "default").trim() || "default";
|
||||
const normalizedWindowMs = Math.max(1000, Number(windowMs || 0));
|
||||
const normalizedMax = Math.max(1, Number(max || 0));
|
||||
|
||||
return (req: Request, res: Response, next: NextFunction): void => {
|
||||
const now = Date.now();
|
||||
cleanupExpiredBuckets(now);
|
||||
|
||||
const bucketKey = `${normalizedScope}:${key(req)}`;
|
||||
const current = buckets.get(bucketKey);
|
||||
const bucket = current && current.resetAt > now
|
||||
? current
|
||||
: { count: 0, resetAt: now + normalizedWindowMs };
|
||||
|
||||
bucket.count += 1;
|
||||
buckets.set(bucketKey, bucket);
|
||||
|
||||
if (bucket.count <= normalizedMax) {
|
||||
next();
|
||||
return;
|
||||
}
|
||||
|
||||
const retryAfterSeconds = Math.max(1, Math.ceil((bucket.resetAt - now) / 1000));
|
||||
res.setHeader("Retry-After", String(retryAfterSeconds));
|
||||
|
||||
logWarn("[rate-limit]", "请求触发限流", {
|
||||
scope: normalizedScope,
|
||||
ip: req.ip,
|
||||
originalUrl: req.originalUrl,
|
||||
retryAfterSeconds,
|
||||
});
|
||||
|
||||
if (onLimit) {
|
||||
onLimit(req, res, {
|
||||
scope: normalizedScope,
|
||||
retryAfterSeconds,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const error = createHttpError("请求过于频繁,请稍后再试", {
|
||||
statusCode: 429,
|
||||
errorCode: "rate_limited",
|
||||
});
|
||||
res.status(429).json(buildErrorPayload(error, "请求过于频繁,请稍后再试"));
|
||||
};
|
||||
}
|
||||
|
||||
export function resetRateLimitBucketsForTest(): void {
|
||||
buckets.clear();
|
||||
lastCleanupAt = 0;
|
||||
}
|
||||
|
||||
export function getBodyFieldRateLimitKey(fieldName: string): RateLimitKeyResolver {
|
||||
return (req) => [defaultRateLimitKey(req), normalizeKeyPart(req.body?.[fieldName])].join(":");
|
||||
}
|
||||
|
||||
export function getParamRateLimitKey(paramName: string): RateLimitKeyResolver {
|
||||
return (req) => [defaultRateLimitKey(req), normalizeKeyPart(req.params?.[paramName])].join(":");
|
||||
}
|
||||
|
||||
function defaultRateLimitKey(req: Request): string {
|
||||
return normalizeKeyPart(
|
||||
req.ip ||
|
||||
String(req.headers["x-forwarded-for"] || "").split(",")[0] ||
|
||||
req.socket.remoteAddress ||
|
||||
"unknown",
|
||||
);
|
||||
}
|
||||
|
||||
function normalizeKeyPart(value: unknown): string {
|
||||
const normalized = String(value || "").trim().toLowerCase();
|
||||
return normalized || "unknown";
|
||||
}
|
||||
|
||||
function cleanupExpiredBuckets(now: number): void {
|
||||
if (now - lastCleanupAt < 60_000) {
|
||||
return;
|
||||
}
|
||||
|
||||
lastCleanupAt = now;
|
||||
for (const [key, bucket] of buckets.entries()) {
|
||||
if (bucket.resetAt <= now) {
|
||||
buckets.delete(key);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import { query, withTransaction } from '../db/client.js'
|
||||
import { query } from '../db/client.js'
|
||||
|
||||
type AdminUserRow = {
|
||||
id: number
|
||||
@@ -6,9 +6,9 @@ type AdminUserRow = {
|
||||
password_hash: string
|
||||
role: string
|
||||
status: string
|
||||
session_version: number
|
||||
created_at: string
|
||||
updated_at: string
|
||||
inventory_group_codes: string[]
|
||||
}
|
||||
|
||||
type AdminUserCreateInput = {
|
||||
@@ -22,7 +22,7 @@ type AdminUserCreateInput = {
|
||||
|
||||
type AdminUserPatch = Partial<Pick<
|
||||
AdminUserRow,
|
||||
'username' | 'password_hash' | 'role' | 'status' | 'updated_at'
|
||||
'username' | 'password_hash' | 'role' | 'status' | 'session_version' | 'updated_at'
|
||||
>>
|
||||
|
||||
type AdminUserListInput = {
|
||||
@@ -39,15 +39,8 @@ type AdminUserListResult = {
|
||||
}
|
||||
|
||||
const ADMIN_USER_SELECT = `
|
||||
SELECT
|
||||
au.*,
|
||||
COALESCE(bindings.inventory_group_codes, ARRAY[]::text[]) AS inventory_group_codes
|
||||
SELECT au.*
|
||||
FROM admin_users au
|
||||
LEFT JOIN LATERAL (
|
||||
SELECT ARRAY_AGG(auigb.inventory_group_code ORDER BY auigb.inventory_group_code) AS inventory_group_codes
|
||||
FROM admin_user_inventory_group_bindings auigb
|
||||
WHERE auigb.admin_user_id = au.id
|
||||
) bindings ON TRUE
|
||||
`
|
||||
|
||||
export async function getAdminUserById(userId: number | string): Promise<AdminUserRow | null> {
|
||||
@@ -74,9 +67,10 @@ export async function createAdminUser(input: AdminUserCreateInput): Promise<Admi
|
||||
password_hash,
|
||||
role,
|
||||
status,
|
||||
session_version,
|
||||
created_at,
|
||||
updated_at
|
||||
) VALUES ($1, $2, $3, $4, $5, $6)
|
||||
) VALUES ($1, $2, $3, $4, $5, $6, $7)
|
||||
RETURNING id
|
||||
`,
|
||||
[
|
||||
@@ -84,6 +78,7 @@ export async function createAdminUser(input: AdminUserCreateInput): Promise<Admi
|
||||
input.passwordHash,
|
||||
input.role,
|
||||
input.status,
|
||||
1,
|
||||
input.createdAt,
|
||||
input.updatedAt,
|
||||
],
|
||||
@@ -110,8 +105,9 @@ export async function updateAdminUser(
|
||||
password_hash = $2,
|
||||
role = $3,
|
||||
status = $4,
|
||||
updated_at = $5
|
||||
WHERE id = $6
|
||||
session_version = $5,
|
||||
updated_at = $6
|
||||
WHERE id = $7
|
||||
RETURNING id
|
||||
`,
|
||||
[
|
||||
@@ -119,6 +115,7 @@ export async function updateAdminUser(
|
||||
next.password_hash,
|
||||
next.role,
|
||||
next.status,
|
||||
Number(next.session_version || 1),
|
||||
next.updated_at,
|
||||
Number(userId),
|
||||
],
|
||||
@@ -183,39 +180,3 @@ export async function countActiveAdminUsers(): Promise<number> {
|
||||
)
|
||||
return Number(result.rows[0]?.total || 0)
|
||||
}
|
||||
|
||||
export async function replaceAdminUserInventoryGroupBindings(
|
||||
userId: number | string,
|
||||
inventoryGroupCodes: unknown[] = [],
|
||||
timestamp: string,
|
||||
): Promise<AdminUserRow | null> {
|
||||
const normalizedUserId = Number(userId)
|
||||
const normalizedCodes = Array.from(new Set((Array.isArray(inventoryGroupCodes) ? inventoryGroupCodes : [])
|
||||
.map((value) => String(value || '').trim())
|
||||
.filter(Boolean)))
|
||||
|
||||
await withTransaction(async (client) => {
|
||||
await client.query(
|
||||
'DELETE FROM admin_user_inventory_group_bindings WHERE admin_user_id = $1',
|
||||
[normalizedUserId],
|
||||
)
|
||||
|
||||
for (const inventoryGroupCode of normalizedCodes) {
|
||||
await client.query(
|
||||
`
|
||||
INSERT INTO admin_user_inventory_group_bindings (
|
||||
admin_user_id,
|
||||
inventory_group_code,
|
||||
created_at,
|
||||
updated_at
|
||||
) VALUES ($1, $2, $3, $4)
|
||||
ON CONFLICT (admin_user_id, inventory_group_code) DO UPDATE
|
||||
SET updated_at = EXCLUDED.updated_at
|
||||
`,
|
||||
[normalizedUserId, inventoryGroupCode, timestamp, timestamp],
|
||||
)
|
||||
}
|
||||
})
|
||||
|
||||
return getAdminUserById(normalizedUserId)
|
||||
}
|
||||
|
||||
@@ -27,28 +27,6 @@ type FulfillmentProfileRequirementRow = {
|
||||
updated_at: string
|
||||
}
|
||||
|
||||
type SkuFulfillmentBindingRow = {
|
||||
id: number
|
||||
sku_code: string
|
||||
provider: string
|
||||
platform: string
|
||||
shop_id: string
|
||||
profile_id: number
|
||||
enabled: boolean
|
||||
priority: number
|
||||
config_json: string | Record<string, unknown>
|
||||
created_at: string
|
||||
updated_at: string
|
||||
profile_key?: string
|
||||
name?: string
|
||||
profile_name?: string
|
||||
executor_key?: string
|
||||
requires_claim?: boolean
|
||||
auto_dispatch?: boolean
|
||||
inventory_strategy?: string
|
||||
profile_config_json?: string | Record<string, unknown>
|
||||
}
|
||||
|
||||
type FulfillmentProfileUpsertInput = {
|
||||
profileKey: string
|
||||
name: string
|
||||
@@ -61,7 +39,7 @@ type FulfillmentProfileUpsertInput = {
|
||||
updatedAt: string
|
||||
}
|
||||
|
||||
type FulfillmentProfileRequirementInput = {
|
||||
export type FulfillmentProfileRequirementInput = {
|
||||
roleKey: string
|
||||
credentialType: string
|
||||
quantityPerUnit?: number | string
|
||||
@@ -69,26 +47,6 @@ type FulfillmentProfileRequirementInput = {
|
||||
configJson?: string | Record<string, unknown>
|
||||
}
|
||||
|
||||
type SkuFulfillmentBindingUpsertInput = {
|
||||
skuCode: string
|
||||
provider?: string
|
||||
platform?: string
|
||||
shopId?: string
|
||||
profileId: number | string
|
||||
enabled?: boolean
|
||||
priority?: number | string
|
||||
configJson?: string | Record<string, unknown>
|
||||
createdAt: string
|
||||
updatedAt: string
|
||||
}
|
||||
|
||||
type FulfillmentBindingResolveInput = {
|
||||
skuCode: string
|
||||
provider?: string
|
||||
platform?: string
|
||||
shopId?: string
|
||||
}
|
||||
|
||||
export async function getFulfillmentProfileByKey(profileKey: string): Promise<FulfillmentProfileRow | null> {
|
||||
const result = await query<FulfillmentProfileRow>(
|
||||
'SELECT * FROM fulfillment_profiles WHERE profile_key = $1 LIMIT 1',
|
||||
@@ -195,113 +153,3 @@ export async function listFulfillmentProfileRequirements(
|
||||
|
||||
return result.rows
|
||||
}
|
||||
|
||||
export async function upsertSkuFulfillmentBinding(
|
||||
input: SkuFulfillmentBindingUpsertInput,
|
||||
): Promise<SkuFulfillmentBindingRow | null> {
|
||||
const existing = await query<SkuFulfillmentBindingRow>(
|
||||
`
|
||||
SELECT *
|
||||
FROM sku_fulfillment_bindings
|
||||
WHERE sku_code = $1 AND provider = $2 AND platform = $3 AND shop_id = $4
|
||||
LIMIT 1
|
||||
`,
|
||||
[input.skuCode, input.provider || '', input.platform || '', input.shopId || ''],
|
||||
)
|
||||
|
||||
if (!existing.rows[0]) {
|
||||
const inserted = await query<SkuFulfillmentBindingRow>(
|
||||
`
|
||||
INSERT INTO sku_fulfillment_bindings (
|
||||
sku_code,
|
||||
provider,
|
||||
platform,
|
||||
shop_id,
|
||||
profile_id,
|
||||
enabled,
|
||||
priority,
|
||||
config_json,
|
||||
created_at,
|
||||
updated_at
|
||||
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8::jsonb, $9, $10)
|
||||
RETURNING *
|
||||
`,
|
||||
[
|
||||
input.skuCode,
|
||||
input.provider || '',
|
||||
input.platform || '',
|
||||
input.shopId || '',
|
||||
Number(input.profileId),
|
||||
input.enabled !== false,
|
||||
Number(input.priority || 100),
|
||||
input.configJson || '{}',
|
||||
input.createdAt,
|
||||
input.updatedAt,
|
||||
],
|
||||
)
|
||||
|
||||
return inserted.rows[0] || null
|
||||
}
|
||||
|
||||
const updated = await query<SkuFulfillmentBindingRow>(
|
||||
`
|
||||
UPDATE sku_fulfillment_bindings
|
||||
SET
|
||||
profile_id = $1,
|
||||
enabled = $2,
|
||||
priority = $3,
|
||||
config_json = $4::jsonb,
|
||||
updated_at = $5
|
||||
WHERE id = $6
|
||||
RETURNING *
|
||||
`,
|
||||
[
|
||||
Number(input.profileId),
|
||||
input.enabled !== false,
|
||||
Number(input.priority || 100),
|
||||
input.configJson || '{}',
|
||||
input.updatedAt,
|
||||
Number(existing.rows[0].id),
|
||||
],
|
||||
)
|
||||
|
||||
return updated.rows[0] || null
|
||||
}
|
||||
|
||||
export async function resolveFulfillmentBinding({
|
||||
skuCode,
|
||||
provider = '',
|
||||
platform = '',
|
||||
shopId = '',
|
||||
}: FulfillmentBindingResolveInput): Promise<SkuFulfillmentBindingRow | null> {
|
||||
const result = await query<SkuFulfillmentBindingRow>(
|
||||
`
|
||||
SELECT
|
||||
sfb.*,
|
||||
fp.profile_key,
|
||||
fp.name AS profile_name,
|
||||
fp.executor_key,
|
||||
fp.requires_claim,
|
||||
fp.auto_dispatch,
|
||||
fp.inventory_strategy,
|
||||
fp.config_json AS profile_config_json
|
||||
FROM sku_fulfillment_bindings sfb
|
||||
JOIN fulfillment_profiles fp ON fp.id = sfb.profile_id
|
||||
WHERE sfb.enabled = TRUE
|
||||
AND sfb.sku_code = $1
|
||||
AND (sfb.provider = '' OR sfb.provider = $2)
|
||||
AND (sfb.platform = '' OR sfb.platform = $3)
|
||||
AND (sfb.shop_id = '' OR sfb.shop_id = $4)
|
||||
ORDER BY
|
||||
CASE WHEN sfb.shop_id = '' THEN 1 ELSE 0 END,
|
||||
CASE WHEN sfb.platform = '' THEN 1 ELSE 0 END,
|
||||
CASE WHEN sfb.provider = '' THEN 1 ELSE 0 END,
|
||||
sfb.priority ASC,
|
||||
sfb.id ASC
|
||||
LIMIT 1
|
||||
`,
|
||||
[skuCode, provider, platform, shopId],
|
||||
)
|
||||
|
||||
return result.rows[0] || null
|
||||
}
|
||||
|
||||
@@ -1,497 +0,0 @@
|
||||
import { query, withTransaction } from '../db/client.js'
|
||||
import type {
|
||||
InventoryCreateItemInput,
|
||||
InventoryListQueryInput,
|
||||
InventorySkuSuggestionQueryInput,
|
||||
} from '../types/repository-inputs.js'
|
||||
import type {
|
||||
InventoryItemRow,
|
||||
InventoryListQueryResult,
|
||||
InventorySkuSuggestionRow,
|
||||
} from '../types/repository-rows.js'
|
||||
|
||||
type InventoryGroupCodesInput = string | string[] | null | undefined
|
||||
|
||||
type InventoryGroupListInput = {
|
||||
keyword?: string
|
||||
limit?: number | string
|
||||
}
|
||||
|
||||
const INVENTORY_ITEM_SELECT = `
|
||||
SELECT
|
||||
ii.id,
|
||||
ii.sku_code,
|
||||
ii.batch_no,
|
||||
ii.credential_type,
|
||||
ii.inventory_group_code,
|
||||
ii.display_value,
|
||||
ii.status,
|
||||
ii.invalid_reason,
|
||||
ii.consumed_at AS delivered_at,
|
||||
ii.created_at,
|
||||
ii.updated_at,
|
||||
tib.task_id AS reserved_by_task_id,
|
||||
ft.task_no AS reserved_by_task_no,
|
||||
ft.platform_order_id
|
||||
FROM inventory_items ii
|
||||
LEFT JOIN LATERAL (
|
||||
SELECT *
|
||||
FROM task_inventory_bindings
|
||||
WHERE inventory_item_id = ii.id AND binding_status IN ('reserved', 'consumed')
|
||||
ORDER BY id DESC
|
||||
LIMIT 1
|
||||
) tib ON TRUE
|
||||
LEFT JOIN fulfillment_tasks ft ON ft.id = tib.task_id
|
||||
`
|
||||
|
||||
export async function findFirstAvailableInventoryItemBySkuCode(
|
||||
skuCode: string,
|
||||
credentialType = 'tencent_code',
|
||||
inventoryGroupCodes: InventoryGroupCodesInput = null,
|
||||
): Promise<InventoryItemRow | null> {
|
||||
const normalizedInventoryGroupCodes = normalizeInventoryGroupCodes(inventoryGroupCodes)
|
||||
if (normalizedInventoryGroupCodes && normalizedInventoryGroupCodes.length === 0) {
|
||||
return null
|
||||
}
|
||||
|
||||
const params: unknown[] = [skuCode, credentialType]
|
||||
const filters = [
|
||||
'ii.sku_code = $1',
|
||||
'ii.credential_type = $2',
|
||||
`ii.status = 'available'`,
|
||||
]
|
||||
|
||||
if (normalizedInventoryGroupCodes) {
|
||||
params.push(normalizedInventoryGroupCodes)
|
||||
filters.push(`ii.inventory_group_code = ANY($${params.length}::text[])`)
|
||||
}
|
||||
|
||||
const result = await query<InventoryItemRow>(
|
||||
`${INVENTORY_ITEM_SELECT}
|
||||
WHERE ${filters.join(' AND ')}
|
||||
ORDER BY ii.id ASC
|
||||
LIMIT 1`,
|
||||
params,
|
||||
)
|
||||
|
||||
return result.rows[0] || null
|
||||
}
|
||||
|
||||
export async function assignReservedInventoryItem(
|
||||
inventoryItemId: number | string,
|
||||
taskId: number | string,
|
||||
updatedAt: string,
|
||||
roleKey = 'primary_code',
|
||||
): Promise<InventoryItemRow | null> {
|
||||
return withTransaction(async (client) => {
|
||||
const inventoryResult = await client.query(
|
||||
`
|
||||
UPDATE inventory_items
|
||||
SET status = 'reserved', updated_at = $1
|
||||
WHERE id = $2 AND status = 'available'
|
||||
RETURNING id
|
||||
`,
|
||||
[updatedAt, Number(inventoryItemId)],
|
||||
)
|
||||
|
||||
if (!inventoryResult.rows[0]) {
|
||||
return getInventoryItemById(inventoryItemId)
|
||||
}
|
||||
|
||||
await client.query(
|
||||
`
|
||||
INSERT INTO task_inventory_bindings (
|
||||
task_id,
|
||||
inventory_item_id,
|
||||
role_key,
|
||||
quantity,
|
||||
binding_status,
|
||||
metadata_json,
|
||||
created_at,
|
||||
updated_at
|
||||
) VALUES ($1, $2, $3, 1, 'reserved', '{}'::jsonb, $4, $4)
|
||||
ON CONFLICT (task_id, role_key, inventory_item_id) DO UPDATE
|
||||
SET binding_status = 'reserved', updated_at = EXCLUDED.updated_at, released_at = NULL
|
||||
`,
|
||||
[Number(taskId), Number(inventoryItemId), roleKey, updatedAt],
|
||||
)
|
||||
|
||||
return getInventoryItemById(inventoryItemId)
|
||||
})
|
||||
}
|
||||
|
||||
export async function getInventoryItemById(inventoryItemId: number | string): Promise<InventoryItemRow | null> {
|
||||
const result = await query<InventoryItemRow>(
|
||||
`${INVENTORY_ITEM_SELECT}
|
||||
WHERE ii.id = $1
|
||||
LIMIT 1`,
|
||||
[Number(inventoryItemId)],
|
||||
)
|
||||
|
||||
return result.rows[0] || null
|
||||
}
|
||||
|
||||
export async function markInventoryItemDelivered(
|
||||
inventoryItemId: number | string,
|
||||
deliveredAt: string,
|
||||
): Promise<InventoryItemRow | null> {
|
||||
return withTransaction(async (client) => {
|
||||
await client.query(
|
||||
`
|
||||
UPDATE inventory_items
|
||||
SET status = 'consumed', consumed_at = $1, updated_at = $1
|
||||
WHERE id = $2
|
||||
`,
|
||||
[deliveredAt, Number(inventoryItemId)],
|
||||
)
|
||||
|
||||
await client.query(
|
||||
`
|
||||
UPDATE task_inventory_bindings
|
||||
SET binding_status = 'consumed', consumed_at = $1, updated_at = $1
|
||||
WHERE inventory_item_id = $2 AND binding_status = 'reserved'
|
||||
`,
|
||||
[deliveredAt, Number(inventoryItemId)],
|
||||
)
|
||||
|
||||
return getInventoryItemById(inventoryItemId)
|
||||
})
|
||||
}
|
||||
|
||||
export async function markInventoryItemConsumed(
|
||||
inventoryItemId: number | string,
|
||||
reason: string,
|
||||
consumedAt: string,
|
||||
): Promise<InventoryItemRow | null> {
|
||||
return withTransaction(async (client) => {
|
||||
await client.query(
|
||||
`
|
||||
UPDATE inventory_items
|
||||
SET
|
||||
status = 'consumed',
|
||||
invalid_reason = $1,
|
||||
consumed_at = $2,
|
||||
updated_at = $2
|
||||
WHERE id = $3
|
||||
`,
|
||||
[String(reason || '').trim(), consumedAt, Number(inventoryItemId)],
|
||||
)
|
||||
|
||||
await client.query(
|
||||
`
|
||||
UPDATE task_inventory_bindings
|
||||
SET binding_status = 'consumed', consumed_at = $1, updated_at = $1
|
||||
WHERE inventory_item_id = $2 AND binding_status = 'reserved'
|
||||
`,
|
||||
[consumedAt, Number(inventoryItemId)],
|
||||
)
|
||||
|
||||
return getInventoryItemById(inventoryItemId)
|
||||
})
|
||||
}
|
||||
|
||||
export async function listInventoryItems({
|
||||
page = 1,
|
||||
pageSize = 20,
|
||||
skuCode = '',
|
||||
credentialType = '',
|
||||
status = '',
|
||||
batchNo = '',
|
||||
inventoryGroupCode = '',
|
||||
allowedInventoryGroupCodes = null,
|
||||
}: InventoryListQueryInput = {}): Promise<InventoryListQueryResult> {
|
||||
const normalizedAllowedInventoryGroupCodes = normalizeInventoryGroupCodes(allowedInventoryGroupCodes)
|
||||
if (normalizedAllowedInventoryGroupCodes && normalizedAllowedInventoryGroupCodes.length === 0) {
|
||||
return {
|
||||
items: [],
|
||||
total: 0,
|
||||
}
|
||||
}
|
||||
|
||||
const offset = (page - 1) * pageSize
|
||||
const filters: string[] = []
|
||||
const params: unknown[] = []
|
||||
|
||||
if (skuCode) {
|
||||
params.push(skuCode)
|
||||
filters.push(`ii.sku_code = $${params.length}`)
|
||||
}
|
||||
|
||||
if (status) {
|
||||
params.push(status === 'delivered' ? 'consumed' : status)
|
||||
filters.push(`ii.status = $${params.length}`)
|
||||
}
|
||||
|
||||
if (credentialType) {
|
||||
params.push(credentialType)
|
||||
filters.push(`ii.credential_type = $${params.length}`)
|
||||
}
|
||||
|
||||
if (batchNo) {
|
||||
params.push(`%${batchNo}%`)
|
||||
filters.push(`ii.batch_no ILIKE $${params.length}`)
|
||||
}
|
||||
|
||||
if (inventoryGroupCode) {
|
||||
params.push(inventoryGroupCode)
|
||||
filters.push(`ii.inventory_group_code = $${params.length}`)
|
||||
}
|
||||
|
||||
if (normalizedAllowedInventoryGroupCodes) {
|
||||
params.push(normalizedAllowedInventoryGroupCodes)
|
||||
filters.push(`ii.inventory_group_code = ANY($${params.length}::text[])`)
|
||||
}
|
||||
|
||||
const whereClause = filters.length > 0 ? `WHERE ${filters.join(' AND ')}` : ''
|
||||
const totalResult = await query<{ [column: string]: unknown, total: number }>(
|
||||
`SELECT COUNT(*)::int AS total FROM inventory_items ii ${whereClause}`,
|
||||
params,
|
||||
)
|
||||
|
||||
params.push(pageSize)
|
||||
params.push(offset)
|
||||
const itemsResult = await query<InventoryItemRow>(
|
||||
`${INVENTORY_ITEM_SELECT}
|
||||
${whereClause}
|
||||
ORDER BY ii.id DESC
|
||||
LIMIT $${params.length - 1} OFFSET $${params.length}`,
|
||||
params,
|
||||
)
|
||||
|
||||
return {
|
||||
items: itemsResult.rows,
|
||||
total: Number(totalResult.rows[0]?.total || 0),
|
||||
}
|
||||
}
|
||||
|
||||
export async function listInventorySkuSuggestions({
|
||||
credentialType = '',
|
||||
keyword = '',
|
||||
limit = 50,
|
||||
inventoryGroupCode = '',
|
||||
allowedInventoryGroupCodes = null,
|
||||
}: InventorySkuSuggestionQueryInput = {}): Promise<InventorySkuSuggestionRow[]> {
|
||||
const normalizedAllowedInventoryGroupCodes = normalizeInventoryGroupCodes(allowedInventoryGroupCodes)
|
||||
if (normalizedAllowedInventoryGroupCodes && normalizedAllowedInventoryGroupCodes.length === 0) {
|
||||
return []
|
||||
}
|
||||
|
||||
const filters: string[] = []
|
||||
const params: unknown[] = []
|
||||
|
||||
if (credentialType) {
|
||||
params.push(credentialType)
|
||||
filters.push(`credential_type = $${params.length}`)
|
||||
}
|
||||
|
||||
if (keyword) {
|
||||
params.push(`%${keyword}%`)
|
||||
filters.push(`sku_code ILIKE $${params.length}`)
|
||||
}
|
||||
|
||||
if (inventoryGroupCode) {
|
||||
params.push(inventoryGroupCode)
|
||||
filters.push(`inventory_group_code = $${params.length}`)
|
||||
}
|
||||
|
||||
if (normalizedAllowedInventoryGroupCodes) {
|
||||
params.push(normalizedAllowedInventoryGroupCodes)
|
||||
filters.push(`inventory_group_code = ANY($${params.length}::text[])`)
|
||||
}
|
||||
|
||||
const normalizedLimit = Number.isFinite(Number(limit))
|
||||
? Math.max(1, Math.min(Number(limit), 100))
|
||||
: 50
|
||||
|
||||
params.push(normalizedLimit)
|
||||
const whereClause = filters.length > 0 ? `WHERE ${filters.join(' AND ')}` : ''
|
||||
const result = await query<InventorySkuSuggestionRow>(
|
||||
`
|
||||
SELECT
|
||||
sku_code,
|
||||
credential_type,
|
||||
inventory_group_code,
|
||||
COUNT(*)::int AS total_count,
|
||||
COUNT(*) FILTER (WHERE status = 'available')::int AS available_count,
|
||||
MAX(updated_at) AS latest_updated_at
|
||||
FROM inventory_items
|
||||
${whereClause}
|
||||
GROUP BY sku_code, credential_type, inventory_group_code
|
||||
ORDER BY
|
||||
COUNT(*) FILTER (WHERE status = 'available') DESC,
|
||||
COUNT(*) DESC,
|
||||
MAX(updated_at) DESC,
|
||||
sku_code ASC,
|
||||
inventory_group_code ASC
|
||||
LIMIT $${params.length}
|
||||
`,
|
||||
params,
|
||||
)
|
||||
|
||||
return result.rows
|
||||
}
|
||||
|
||||
export async function createInventoryItems(rows: InventoryCreateItemInput[]): Promise<number> {
|
||||
let created = 0
|
||||
|
||||
for (const row of rows) {
|
||||
const payloadJson = JSON.stringify(row.payload || { code: row.displayValue })
|
||||
const displayValue = String(row.displayValue || '').trim()
|
||||
const inventoryGroupCode = String(row.inventoryGroupCode || '').trim()
|
||||
const result = await query<{ [column: string]: unknown, id: number }>(
|
||||
`
|
||||
INSERT INTO inventory_items (
|
||||
batch_no,
|
||||
sku_code,
|
||||
credential_type,
|
||||
inventory_group_code,
|
||||
display_value,
|
||||
payload_json,
|
||||
source_type,
|
||||
status,
|
||||
invalid_reason,
|
||||
metadata_json,
|
||||
created_at,
|
||||
updated_at
|
||||
) VALUES ($1, $2, $3, $4, $5, $6::jsonb, 'static_import', 'available', '', '{}'::jsonb, $7, $8)
|
||||
ON CONFLICT DO NOTHING
|
||||
RETURNING id
|
||||
`,
|
||||
[
|
||||
row.batchNo || '',
|
||||
row.skuCode,
|
||||
row.credentialType || 'tencent_code',
|
||||
inventoryGroupCode,
|
||||
displayValue,
|
||||
payloadJson,
|
||||
row.createdAt,
|
||||
row.updatedAt,
|
||||
],
|
||||
)
|
||||
|
||||
if (result.rows[0]?.id) {
|
||||
created += 1
|
||||
}
|
||||
}
|
||||
|
||||
return created
|
||||
}
|
||||
|
||||
export async function listInventoryGroupCodes({
|
||||
keyword = '',
|
||||
limit = 100,
|
||||
}: InventoryGroupListInput = {}): Promise<string[]> {
|
||||
const params: unknown[] = []
|
||||
const filters = [`inventory_group_code <> ''`]
|
||||
|
||||
if (keyword) {
|
||||
params.push(`%${String(keyword || '').trim()}%`)
|
||||
filters.push(`inventory_group_code ILIKE $${params.length}`)
|
||||
}
|
||||
|
||||
const normalizedLimit = Number.isFinite(Number(limit))
|
||||
? Math.max(1, Math.min(Number(limit), 200))
|
||||
: 100
|
||||
|
||||
params.push(normalizedLimit)
|
||||
const result = await query<{ [column: string]: unknown, inventory_group_code: string }>(
|
||||
`
|
||||
SELECT inventory_group_code
|
||||
FROM inventory_items
|
||||
WHERE ${filters.join(' AND ')}
|
||||
GROUP BY inventory_group_code
|
||||
ORDER BY inventory_group_code ASC
|
||||
LIMIT $${params.length}
|
||||
`,
|
||||
params,
|
||||
)
|
||||
|
||||
return result.rows.map((row) => String(row.inventory_group_code || '').trim()).filter(Boolean)
|
||||
}
|
||||
|
||||
function normalizeInventoryGroupCodes(inventoryGroupCodes: InventoryGroupCodesInput): string[] | null {
|
||||
if (inventoryGroupCodes == null) {
|
||||
return null
|
||||
}
|
||||
|
||||
return Array.from(new Set((Array.isArray(inventoryGroupCodes) ? inventoryGroupCodes : [inventoryGroupCodes])
|
||||
.map((value) => String(value || '').trim())
|
||||
.filter(Boolean)))
|
||||
}
|
||||
|
||||
export async function releaseReservedInventoryItem(
|
||||
inventoryItemId: number | string,
|
||||
updatedAt: string,
|
||||
): Promise<InventoryItemRow | null> {
|
||||
return withTransaction(async (client) => {
|
||||
await client.query(
|
||||
`
|
||||
UPDATE task_inventory_bindings
|
||||
SET binding_status = 'released', released_at = $1, updated_at = $1
|
||||
WHERE inventory_item_id = $2 AND binding_status = 'reserved'
|
||||
`,
|
||||
[updatedAt, Number(inventoryItemId)],
|
||||
)
|
||||
|
||||
await client.query(
|
||||
`
|
||||
UPDATE inventory_items
|
||||
SET status = 'available', updated_at = $1
|
||||
WHERE id = $2 AND status = 'reserved'
|
||||
`,
|
||||
[updatedAt, Number(inventoryItemId)],
|
||||
)
|
||||
|
||||
return getInventoryItemById(inventoryItemId)
|
||||
})
|
||||
}
|
||||
|
||||
export async function invalidateInventoryItem(
|
||||
inventoryItemId: number | string,
|
||||
invalidReason: string,
|
||||
updatedAt: string,
|
||||
): Promise<InventoryItemRow | null> {
|
||||
const result = await query<{ [column: string]: unknown, id: number }>(
|
||||
`
|
||||
UPDATE inventory_items
|
||||
SET status = 'invalid', invalid_reason = $1, updated_at = $2
|
||||
WHERE id = $3 AND status = 'available'
|
||||
RETURNING id
|
||||
`,
|
||||
[invalidReason, updatedAt, Number(inventoryItemId)],
|
||||
)
|
||||
|
||||
if (!result.rows[0]) {
|
||||
return getInventoryItemById(inventoryItemId)
|
||||
}
|
||||
|
||||
return getInventoryItemById(inventoryItemId)
|
||||
}
|
||||
|
||||
export async function invalidateReservedInventoryItem(
|
||||
inventoryItemId: number | string,
|
||||
invalidReason: string,
|
||||
updatedAt: string,
|
||||
): Promise<InventoryItemRow | null> {
|
||||
return withTransaction(async (client) => {
|
||||
await client.query(
|
||||
`
|
||||
UPDATE task_inventory_bindings
|
||||
SET binding_status = 'released', released_at = $1, updated_at = $1
|
||||
WHERE inventory_item_id = $2 AND binding_status = 'reserved'
|
||||
`,
|
||||
[updatedAt, Number(inventoryItemId)],
|
||||
)
|
||||
|
||||
await client.query(
|
||||
`
|
||||
UPDATE inventory_items
|
||||
SET status = 'invalid', invalid_reason = $1, updated_at = $2
|
||||
WHERE id = $3
|
||||
`,
|
||||
[String(invalidReason || '').trim(), updatedAt, Number(inventoryItemId)],
|
||||
)
|
||||
|
||||
return getInventoryItemById(inventoryItemId)
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
import assert from 'node:assert/strict'
|
||||
import test from 'node:test'
|
||||
|
||||
import { syncKuaishouCloudTaskStateForTask } from './kuaishou-cloud-task-state-repo.js'
|
||||
|
||||
test('syncKuaishouCloudTaskStateForTask 将 Date 更新时间规范化为 ISO 字符串', async () => {
|
||||
const calls: Array<{ text: string; params?: unknown[] }> = []
|
||||
const executor = async (text: string, params?: unknown[]) => {
|
||||
calls.push({ text, params })
|
||||
return { rows: [] }
|
||||
}
|
||||
|
||||
await syncKuaishouCloudTaskStateForTask({
|
||||
taskId: 9,
|
||||
executorKey: 'kuaishou_ct_assisted',
|
||||
contextJson: {
|
||||
kuaishouCloudFulfillment: {
|
||||
ticket: {
|
||||
status: 'verified',
|
||||
code: '597172E91CF18417',
|
||||
},
|
||||
binding: {
|
||||
bindUrl: 'https://example.test/bind',
|
||||
vnPhone: '14062706492',
|
||||
roleName: '测试角色',
|
||||
roleId: '4212063544',
|
||||
resolvedSourceKey: 'account_test',
|
||||
},
|
||||
dispatch: {
|
||||
status: 'pending',
|
||||
},
|
||||
consume: {
|
||||
status: 'pending',
|
||||
},
|
||||
},
|
||||
},
|
||||
updatedAt: new Date('2026-05-29T09:05:46.658Z'),
|
||||
}, executor)
|
||||
|
||||
assert.equal(calls.length, 1)
|
||||
assert.match(calls[0].text, /INSERT INTO kuaishou_cloud_task_states/)
|
||||
assert.equal(calls[0].params?.[10], '2026-05-29T09:05:46.658Z')
|
||||
})
|
||||
@@ -0,0 +1,181 @@
|
||||
import { query } from '../db/client.js'
|
||||
import { TASK_STATUS } from '../domain/task-status.js'
|
||||
import { maskCode } from '../utils/masking.js'
|
||||
import { parseTaskContext } from '../utils/task-json.js'
|
||||
import { normalizeTimestampIso } from '../utils/time.js'
|
||||
|
||||
type QueryExecutor = (text: string, params?: unknown[]) => Promise<{ rows: unknown[] }>
|
||||
type JsonRecord = Record<string, any>
|
||||
|
||||
export type KuaishouCloudSourceLoadStat = {
|
||||
sourceKey: string
|
||||
activeCount: number
|
||||
redeemingCount: number
|
||||
}
|
||||
|
||||
export type KuaishouCloudTaskStateSyncInput = {
|
||||
taskId: number | string
|
||||
executorKey?: unknown
|
||||
contextJson?: unknown
|
||||
updatedAt?: unknown
|
||||
}
|
||||
|
||||
export async function syncKuaishouCloudTaskStateForTask(
|
||||
input: KuaishouCloudTaskStateSyncInput,
|
||||
executor: QueryExecutor = query,
|
||||
): Promise<void> {
|
||||
const taskId = Number(input.taskId || 0)
|
||||
if (!Number.isFinite(taskId) || taskId <= 0) {
|
||||
return
|
||||
}
|
||||
|
||||
if (String(input.executorKey || '').trim() !== 'kuaishou_ct_assisted') {
|
||||
await executor('DELETE FROM kuaishou_cloud_task_states WHERE task_id = $1', [taskId])
|
||||
return
|
||||
}
|
||||
|
||||
const flow = resolveKuaishouCloudFlow(input.contextJson)
|
||||
if (!flow) {
|
||||
await executor('DELETE FROM kuaishou_cloud_task_states WHERE task_id = $1', [taskId])
|
||||
return
|
||||
}
|
||||
|
||||
const ticket = asRecord(flow.ticket)
|
||||
const binding = asRecord(flow.binding)
|
||||
const role = asRecord(flow.role)
|
||||
const dispatch = asRecord(flow.dispatch)
|
||||
const consume = asRecord(flow.consume)
|
||||
const roleName = pickFirstNonEmpty([role.name, binding.roleName])
|
||||
const roleId = pickFirstNonEmpty([role.rid, binding.roleId])
|
||||
const updatedAt = normalizeTimestampIso(input.updatedAt)
|
||||
|
||||
await executor(
|
||||
`
|
||||
INSERT INTO kuaishou_cloud_task_states (
|
||||
task_id,
|
||||
ticket_status,
|
||||
ticket_code_masked,
|
||||
bind_url,
|
||||
vn_phone,
|
||||
role_name,
|
||||
role_id,
|
||||
dispatch_status,
|
||||
consume_status,
|
||||
source_key,
|
||||
updated_at
|
||||
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11)
|
||||
ON CONFLICT (task_id) DO UPDATE
|
||||
SET
|
||||
ticket_status = EXCLUDED.ticket_status,
|
||||
ticket_code_masked = EXCLUDED.ticket_code_masked,
|
||||
bind_url = EXCLUDED.bind_url,
|
||||
vn_phone = EXCLUDED.vn_phone,
|
||||
role_name = EXCLUDED.role_name,
|
||||
role_id = EXCLUDED.role_id,
|
||||
dispatch_status = EXCLUDED.dispatch_status,
|
||||
consume_status = EXCLUDED.consume_status,
|
||||
source_key = EXCLUDED.source_key,
|
||||
updated_at = EXCLUDED.updated_at
|
||||
`,
|
||||
[
|
||||
taskId,
|
||||
normalizeStatus(ticket.status),
|
||||
maskCode(ticket.code),
|
||||
String(binding.bindUrl || '').trim(),
|
||||
String(binding.vnPhone || '').trim(),
|
||||
roleName,
|
||||
roleId,
|
||||
normalizeStatus(dispatch.status),
|
||||
normalizeStatus(consume.status),
|
||||
String(binding.resolvedSourceKey || '').trim(),
|
||||
updatedAt,
|
||||
],
|
||||
)
|
||||
}
|
||||
|
||||
export async function listKuaishouCloudSourceLoadStats(
|
||||
sourceKeys: unknown[] = [],
|
||||
executor: QueryExecutor = query,
|
||||
): Promise<KuaishouCloudSourceLoadStat[]> {
|
||||
const candidates = normalizeStringArray(sourceKeys)
|
||||
if (candidates.length === 0) {
|
||||
return []
|
||||
}
|
||||
|
||||
const activeStatuses = [
|
||||
TASK_STATUS.WAITING_BINDING,
|
||||
TASK_STATUS.ROLE_CONFIRMED,
|
||||
TASK_STATUS.REDEEMING,
|
||||
TASK_STATUS.DISPATCHED_PENDING_RETURN,
|
||||
]
|
||||
const result = await executor(
|
||||
`
|
||||
SELECT
|
||||
kcts.source_key,
|
||||
COUNT(*) FILTER (WHERE ft.task_status = ANY($2::text[])) AS active_count,
|
||||
COUNT(*) FILTER (WHERE ft.task_status = $3) AS redeeming_count
|
||||
FROM fulfillment_tasks ft
|
||||
JOIN kuaishou_cloud_task_states kcts ON kcts.task_id = ft.id
|
||||
WHERE ft.executor_key = 'kuaishou_ct_assisted'
|
||||
AND kcts.source_key = ANY($1::text[])
|
||||
AND ft.task_status = ANY($2::text[])
|
||||
GROUP BY kcts.source_key
|
||||
`,
|
||||
[candidates, activeStatuses, TASK_STATUS.REDEEMING],
|
||||
)
|
||||
|
||||
return result.rows
|
||||
.map((row) => {
|
||||
const source = asRecord(row)
|
||||
return {
|
||||
sourceKey: String(source.source_key || '').trim(),
|
||||
activeCount: Number(source.active_count || 0) || 0,
|
||||
redeemingCount: Number(source.redeeming_count || 0) || 0,
|
||||
}
|
||||
})
|
||||
.filter((row) => row.sourceKey)
|
||||
}
|
||||
|
||||
function resolveKuaishouCloudFlow(contextJson: unknown): JsonRecord | null {
|
||||
const context = parseTaskContext({ context_json: contextJson })
|
||||
const flow = asRecord(context.kuaishouCloudFulfillment)
|
||||
return Object.keys(flow).length > 0 ? flow : null
|
||||
}
|
||||
|
||||
function asRecord(value: unknown): JsonRecord {
|
||||
return value && typeof value === 'object' && !Array.isArray(value) ? (value as JsonRecord) : {}
|
||||
}
|
||||
|
||||
function normalizeStatus(value: unknown): string {
|
||||
return String(value || 'pending').trim() || 'pending'
|
||||
}
|
||||
|
||||
function normalizeStringArray(value: unknown): string[] {
|
||||
if (Array.isArray(value)) {
|
||||
return Array.from(new Set(value.map((item) => String(item || '').trim()).filter(Boolean)))
|
||||
}
|
||||
|
||||
if (typeof value === 'string') {
|
||||
return Array.from(
|
||||
new Set(
|
||||
value
|
||||
.split(',')
|
||||
.map((item) => item.trim())
|
||||
.filter(Boolean),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
return []
|
||||
}
|
||||
|
||||
function pickFirstNonEmpty(values: unknown[]): string {
|
||||
for (const value of values) {
|
||||
const normalized = String(value || '').trim()
|
||||
if (normalized) {
|
||||
return normalized
|
||||
}
|
||||
}
|
||||
|
||||
return ''
|
||||
}
|
||||
@@ -1,314 +0,0 @@
|
||||
import { query } from '../db/client.js'
|
||||
import type {
|
||||
MessageDeliveryListQueryResult,
|
||||
MessageDeliveryRow,
|
||||
} from '../types/repository-rows.js'
|
||||
|
||||
type MessageDeliveryCreateInput = {
|
||||
provider: string
|
||||
platform: string
|
||||
shopId?: string
|
||||
shopName?: string
|
||||
channel: string
|
||||
orderId?: number | string | null
|
||||
taskId?: number | string | null
|
||||
platformOrderId?: string
|
||||
recipientKey?: string
|
||||
messageContent?: string
|
||||
claimUrl?: string
|
||||
status?: string
|
||||
requestUrl?: string
|
||||
requestHeadersJson?: string | Record<string, unknown>
|
||||
requestBodyJson?: string | Record<string, unknown>
|
||||
responseStatus?: number | string
|
||||
responseJson?: string | Record<string, unknown>
|
||||
errorMessage?: string
|
||||
sentAt?: string | null
|
||||
createdAt: string
|
||||
updatedAt: string
|
||||
}
|
||||
|
||||
type MessageDeliveryPatch = Partial<Pick<
|
||||
MessageDeliveryRow,
|
||||
| 'status'
|
||||
| 'request_url'
|
||||
| 'request_headers_json'
|
||||
| 'request_body_json'
|
||||
| 'response_status'
|
||||
| 'response_json'
|
||||
| 'error_message'
|
||||
| 'sent_at'
|
||||
| 'updated_at'
|
||||
>>
|
||||
|
||||
type LatestSuccessfulMessageDeliveryQuery = {
|
||||
provider?: string
|
||||
platform?: string
|
||||
shopId?: string
|
||||
platformOrderId?: string
|
||||
channel?: string
|
||||
claimUrl?: string
|
||||
}
|
||||
|
||||
type MessageDeliveryListQuery = {
|
||||
page?: number
|
||||
pageSize?: number
|
||||
provider?: string
|
||||
platform?: string
|
||||
status?: string
|
||||
shopId?: string
|
||||
platformOrderId?: string
|
||||
taskNo?: string
|
||||
dateFrom?: string
|
||||
dateTo?: string
|
||||
}
|
||||
|
||||
export async function createMessageDelivery(
|
||||
input: MessageDeliveryCreateInput,
|
||||
): Promise<MessageDeliveryRow | null> {
|
||||
const result = await query<MessageDeliveryRow>(
|
||||
`
|
||||
INSERT INTO message_deliveries (
|
||||
provider,
|
||||
platform,
|
||||
shop_id,
|
||||
shop_name,
|
||||
channel,
|
||||
order_id,
|
||||
task_id,
|
||||
platform_order_id,
|
||||
recipient_key,
|
||||
message_content,
|
||||
claim_url,
|
||||
status,
|
||||
request_url,
|
||||
request_headers_json,
|
||||
request_body_json,
|
||||
response_status,
|
||||
response_json,
|
||||
error_message,
|
||||
sent_at,
|
||||
created_at,
|
||||
updated_at
|
||||
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14::jsonb, $15::jsonb, $16, $17::jsonb, $18, $19, $20, $21)
|
||||
RETURNING *
|
||||
`,
|
||||
[
|
||||
input.provider,
|
||||
input.platform,
|
||||
input.shopId || '',
|
||||
input.shopName || '',
|
||||
input.channel,
|
||||
input.orderId || null,
|
||||
input.taskId || null,
|
||||
input.platformOrderId || '',
|
||||
input.recipientKey || '',
|
||||
input.messageContent || '',
|
||||
input.claimUrl || '',
|
||||
input.status || 'pending',
|
||||
input.requestUrl || '',
|
||||
input.requestHeadersJson || '{}',
|
||||
input.requestBodyJson || '{}',
|
||||
Number(input.responseStatus || 0),
|
||||
input.responseJson || '{}',
|
||||
input.errorMessage || '',
|
||||
input.sentAt || null,
|
||||
input.createdAt,
|
||||
input.updatedAt,
|
||||
],
|
||||
)
|
||||
|
||||
return result.rows[0] || null
|
||||
}
|
||||
|
||||
export async function getMessageDeliveryById(deliveryId: number | string): Promise<MessageDeliveryRow | null> {
|
||||
const result = await query<MessageDeliveryRow>('SELECT * FROM message_deliveries WHERE id = $1 LIMIT 1', [
|
||||
Number(deliveryId),
|
||||
])
|
||||
return result.rows[0] || null
|
||||
}
|
||||
|
||||
export async function updateMessageDelivery(
|
||||
deliveryId: number | string,
|
||||
patch: MessageDeliveryPatch = {},
|
||||
): Promise<MessageDeliveryRow | null> {
|
||||
const current = await getMessageDeliveryById(deliveryId)
|
||||
if (!current) {
|
||||
return null
|
||||
}
|
||||
|
||||
const next = { ...current, ...patch }
|
||||
const result = await query<MessageDeliveryRow>(
|
||||
`
|
||||
UPDATE message_deliveries
|
||||
SET
|
||||
status = $1,
|
||||
request_url = $2,
|
||||
request_headers_json = $3::jsonb,
|
||||
request_body_json = $4::jsonb,
|
||||
response_status = $5,
|
||||
response_json = $6::jsonb,
|
||||
error_message = $7,
|
||||
sent_at = $8,
|
||||
updated_at = $9
|
||||
WHERE id = $10
|
||||
RETURNING *
|
||||
`,
|
||||
[
|
||||
next.status,
|
||||
next.request_url || '',
|
||||
next.request_headers_json || '{}',
|
||||
next.request_body_json || '{}',
|
||||
Number(next.response_status || 0),
|
||||
next.response_json || '{}',
|
||||
next.error_message || '',
|
||||
next.sent_at || null,
|
||||
next.updated_at,
|
||||
Number(deliveryId),
|
||||
],
|
||||
)
|
||||
|
||||
return result.rows[0] || null
|
||||
}
|
||||
|
||||
export async function findLatestSuccessfulMessageDeliveryByTask(
|
||||
taskId: number | string,
|
||||
channel: string,
|
||||
): Promise<MessageDeliveryRow | null> {
|
||||
const result = await query<MessageDeliveryRow>(
|
||||
`
|
||||
SELECT *
|
||||
FROM message_deliveries
|
||||
WHERE task_id = $1 AND channel = $2 AND status = 'success'
|
||||
ORDER BY id DESC
|
||||
LIMIT 1
|
||||
`,
|
||||
[Number(taskId), channel],
|
||||
)
|
||||
|
||||
return result.rows[0] || null
|
||||
}
|
||||
|
||||
export async function findLatestSuccessfulMessageDelivery({
|
||||
provider = '',
|
||||
platform = '',
|
||||
shopId = '',
|
||||
platformOrderId = '',
|
||||
channel = '',
|
||||
claimUrl = '',
|
||||
}: LatestSuccessfulMessageDeliveryQuery = {}): Promise<MessageDeliveryRow | null> {
|
||||
const result = await query<MessageDeliveryRow>(
|
||||
`
|
||||
SELECT *
|
||||
FROM message_deliveries
|
||||
WHERE provider = $1
|
||||
AND platform = $2
|
||||
AND shop_id = $3
|
||||
AND platform_order_id = $4
|
||||
AND channel = $5
|
||||
AND claim_url = $6
|
||||
AND status = 'success'
|
||||
ORDER BY id DESC
|
||||
LIMIT 1
|
||||
`,
|
||||
[
|
||||
provider,
|
||||
platform,
|
||||
shopId,
|
||||
platformOrderId,
|
||||
channel,
|
||||
claimUrl,
|
||||
],
|
||||
)
|
||||
|
||||
return result.rows[0] || null
|
||||
}
|
||||
|
||||
export async function listMessageDeliveries({
|
||||
page = 1,
|
||||
pageSize = 20,
|
||||
provider = '',
|
||||
platform = '',
|
||||
status = '',
|
||||
shopId = '',
|
||||
platformOrderId = '',
|
||||
taskNo = '',
|
||||
dateFrom = '',
|
||||
dateTo = '',
|
||||
}: MessageDeliveryListQuery = {}): Promise<MessageDeliveryListQueryResult> {
|
||||
const offset = (page - 1) * pageSize
|
||||
const filters: string[] = []
|
||||
const params: unknown[] = []
|
||||
|
||||
if (provider) {
|
||||
params.push(provider)
|
||||
filters.push(`md.provider = $${params.length}`)
|
||||
}
|
||||
|
||||
if (platform) {
|
||||
params.push(platform)
|
||||
filters.push(`md.platform = $${params.length}`)
|
||||
}
|
||||
|
||||
if (status) {
|
||||
params.push(status)
|
||||
filters.push(`md.status = $${params.length}`)
|
||||
}
|
||||
|
||||
if (shopId) {
|
||||
params.push(shopId)
|
||||
filters.push(`md.shop_id = $${params.length}`)
|
||||
}
|
||||
|
||||
if (platformOrderId) {
|
||||
params.push(`%${platformOrderId}%`)
|
||||
filters.push(`md.platform_order_id ILIKE $${params.length}`)
|
||||
}
|
||||
|
||||
if (taskNo) {
|
||||
params.push(`%${taskNo}%`)
|
||||
filters.push(`ft.task_no ILIKE $${params.length}`)
|
||||
}
|
||||
|
||||
if (dateFrom) {
|
||||
params.push(dateFrom)
|
||||
filters.push(`md.created_at >= $${params.length}`)
|
||||
}
|
||||
|
||||
if (dateTo) {
|
||||
params.push(dateTo)
|
||||
filters.push(`md.created_at <= $${params.length}`)
|
||||
}
|
||||
|
||||
const whereClause = filters.length > 0 ? `WHERE ${filters.join(' AND ')}` : ''
|
||||
const fromClause = `
|
||||
FROM message_deliveries md
|
||||
LEFT JOIN fulfillment_tasks ft ON ft.id = md.task_id
|
||||
`
|
||||
|
||||
const totalResult = await query<{ [column: string]: unknown, total: number }>(
|
||||
`SELECT COUNT(*)::int AS total ${fromClause} ${whereClause}`,
|
||||
params,
|
||||
)
|
||||
|
||||
params.push(pageSize)
|
||||
params.push(offset)
|
||||
const itemsResult = await query<MessageDeliveryRow>(
|
||||
`
|
||||
SELECT
|
||||
md.*,
|
||||
ft.task_no,
|
||||
ft.task_status
|
||||
${fromClause}
|
||||
${whereClause}
|
||||
ORDER BY md.id DESC
|
||||
LIMIT $${params.length - 1} OFFSET $${params.length}
|
||||
`,
|
||||
params,
|
||||
)
|
||||
|
||||
return {
|
||||
items: itemsResult.rows,
|
||||
total: Number(totalResult.rows[0]?.total || 0),
|
||||
}
|
||||
}
|
||||
@@ -3,7 +3,7 @@ import assert from 'node:assert/strict'
|
||||
|
||||
import { resolveOrderItemSyncPlan } from './order-item-repo.js'
|
||||
|
||||
test('resolveOrderItemSyncPlan preserves matching order item ids across repeated webhook syncs', () => {
|
||||
test('resolveOrderItemSyncPlan preserves matching order item ids across repeated source syncs', () => {
|
||||
const existingItems = [
|
||||
{
|
||||
id: 15,
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import type { PoolClient, QueryResult } from 'pg'
|
||||
|
||||
import { query, withTransaction } from '../db/client.js'
|
||||
import type { OrderItemReplaceInput } from '../types/repository-inputs.js'
|
||||
import type { OrderItemRow } from '../types/repository-rows.js'
|
||||
import type { OrderItemReplaceInput } from '../types/repository/inputs.js'
|
||||
import type { OrderItemRow } from '../types/repository/rows.js'
|
||||
|
||||
type QueryExecutor = (text: string, params?: unknown[]) => Promise<QueryResult<any>>
|
||||
|
||||
@@ -109,6 +109,10 @@ export function resolveOrderItemSyncPlan(
|
||||
|
||||
if (matchedIndex >= 0) {
|
||||
const matched = unmatchedExisting.splice(matchedIndex, 1)[0]
|
||||
if (!matched) {
|
||||
creates.push(item)
|
||||
continue
|
||||
}
|
||||
updates.push({
|
||||
orderItemId: matched.id,
|
||||
item,
|
||||
|
||||
@@ -3,12 +3,12 @@ import type {
|
||||
OrderCreateInput,
|
||||
OrderListQueryInput,
|
||||
OrderUpdateInput,
|
||||
} from '../types/repository-inputs.js'
|
||||
} from '../types/repository/inputs.js'
|
||||
import type {
|
||||
OrderListQueryResult,
|
||||
OrderListRow,
|
||||
OrderRow,
|
||||
} from '../types/repository-rows.js'
|
||||
} from '../types/repository/rows.js'
|
||||
|
||||
type OrderPlatformLookupInput = {
|
||||
provider?: string
|
||||
@@ -17,15 +17,8 @@ type OrderPlatformLookupInput = {
|
||||
platformOrderId: string
|
||||
}
|
||||
|
||||
type OrderPlatformCandidateLookupInput = {
|
||||
provider?: string
|
||||
platform?: string
|
||||
shopIds?: unknown[]
|
||||
platformOrderId?: string
|
||||
}
|
||||
|
||||
export async function findOrderByPlatformOrderId({
|
||||
provider = 'agiso',
|
||||
provider = '91kaquan',
|
||||
platform,
|
||||
shopId = '',
|
||||
platformOrderId,
|
||||
@@ -43,29 +36,20 @@ export async function findOrderByPlatformOrderId({
|
||||
return result.rows[0] || null
|
||||
}
|
||||
|
||||
export async function findOrderByPlatformOrderIdCandidates({
|
||||
provider = 'agiso',
|
||||
export async function findLatestOrderByPlatformOrderId({
|
||||
provider = '91kaquan',
|
||||
platform,
|
||||
shopIds = [],
|
||||
platformOrderId,
|
||||
}: OrderPlatformCandidateLookupInput): Promise<OrderRow | null> {
|
||||
const normalizedShopIds = [...new Set((Array.isArray(shopIds) ? shopIds : [])
|
||||
.map((item) => String(item || '').trim())
|
||||
.filter(Boolean))]
|
||||
|
||||
if (!platform || !platformOrderId || normalizedShopIds.length === 0) {
|
||||
return null
|
||||
}
|
||||
|
||||
}: Omit<OrderPlatformLookupInput, 'shopId'>): Promise<OrderRow | null> {
|
||||
const result = await query<OrderRow>(
|
||||
`
|
||||
SELECT *
|
||||
FROM orders
|
||||
WHERE provider = $1 AND platform = $2 AND shop_id = ANY($3::text[]) AND platform_order_id = $4
|
||||
WHERE provider = $1 AND platform = $2 AND platform_order_id = $3
|
||||
ORDER BY id DESC
|
||||
LIMIT 1
|
||||
`,
|
||||
[provider, platform, normalizedShopIds, platformOrderId],
|
||||
[provider, platform, platformOrderId],
|
||||
)
|
||||
|
||||
return result.rows[0] || null
|
||||
|
||||
@@ -1,169 +0,0 @@
|
||||
import { query } from '../db/client.js'
|
||||
|
||||
type ProductMatchRuleRow = {
|
||||
id: number
|
||||
provider: string
|
||||
platform: string
|
||||
shop_id: string
|
||||
external_item_id: string
|
||||
external_sku_code: string
|
||||
external_sku_name: string
|
||||
external_sku_name_normalized: string
|
||||
resolved_sku_code: string
|
||||
enabled: boolean
|
||||
priority: number
|
||||
config_json: string | Record<string, unknown>
|
||||
created_at: string
|
||||
updated_at: string
|
||||
matched_by?: string
|
||||
match_score?: number
|
||||
}
|
||||
|
||||
type ProductMatchRuleUpsertInput = {
|
||||
provider?: string
|
||||
platform?: string
|
||||
shopId?: string
|
||||
externalItemId?: string
|
||||
externalSkuCode?: string
|
||||
externalSkuName?: string
|
||||
externalSkuNameNormalized?: string
|
||||
resolvedSkuCode: string
|
||||
enabled?: boolean
|
||||
priority?: number | string
|
||||
configJson?: string | Record<string, unknown>
|
||||
createdAt: string
|
||||
updatedAt: string
|
||||
}
|
||||
|
||||
type ProductMatchRuleResolveInput = {
|
||||
provider?: string
|
||||
platform?: string
|
||||
shopId?: string
|
||||
externalItemId?: string
|
||||
externalSkuCode?: string
|
||||
externalSkuNameNormalized?: string
|
||||
}
|
||||
|
||||
export async function upsertProductMatchRule(
|
||||
input: ProductMatchRuleUpsertInput,
|
||||
): Promise<ProductMatchRuleRow | null> {
|
||||
const result = await query<ProductMatchRuleRow>(
|
||||
`
|
||||
INSERT INTO product_match_rules (
|
||||
provider,
|
||||
platform,
|
||||
shop_id,
|
||||
external_item_id,
|
||||
external_sku_code,
|
||||
external_sku_name,
|
||||
external_sku_name_normalized,
|
||||
resolved_sku_code,
|
||||
enabled,
|
||||
priority,
|
||||
config_json,
|
||||
created_at,
|
||||
updated_at
|
||||
) VALUES (
|
||||
$1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11::jsonb, $12, $13
|
||||
)
|
||||
ON CONFLICT (
|
||||
provider,
|
||||
platform,
|
||||
shop_id,
|
||||
external_item_id,
|
||||
external_sku_code,
|
||||
external_sku_name_normalized,
|
||||
resolved_sku_code
|
||||
) DO UPDATE
|
||||
SET
|
||||
external_sku_name = EXCLUDED.external_sku_name,
|
||||
enabled = EXCLUDED.enabled,
|
||||
priority = EXCLUDED.priority,
|
||||
config_json = EXCLUDED.config_json,
|
||||
updated_at = EXCLUDED.updated_at
|
||||
RETURNING *
|
||||
`,
|
||||
[
|
||||
input.provider || '',
|
||||
input.platform || '',
|
||||
input.shopId || '',
|
||||
input.externalItemId || '',
|
||||
input.externalSkuCode || '',
|
||||
input.externalSkuName || '',
|
||||
input.externalSkuNameNormalized || '',
|
||||
input.resolvedSkuCode,
|
||||
input.enabled !== false,
|
||||
Number(input.priority || 100),
|
||||
input.configJson || '{}',
|
||||
input.createdAt,
|
||||
input.updatedAt,
|
||||
],
|
||||
)
|
||||
|
||||
return result.rows[0] || null
|
||||
}
|
||||
|
||||
export async function resolveProductMatchRule({
|
||||
provider = '',
|
||||
platform = '',
|
||||
shopId = '',
|
||||
externalItemId = '',
|
||||
externalSkuCode = '',
|
||||
externalSkuNameNormalized = '',
|
||||
}: ProductMatchRuleResolveInput): Promise<ProductMatchRuleRow | null> {
|
||||
const result = await query<ProductMatchRuleRow>(
|
||||
`
|
||||
SELECT
|
||||
pmr.*,
|
||||
CASE
|
||||
WHEN $4 != '' AND pmr.external_sku_code = $4 THEN 'external_sku_code'
|
||||
WHEN $5 != '' AND pmr.external_item_id = $5 THEN 'external_item_id'
|
||||
WHEN $6 != '' AND pmr.external_sku_name_normalized = $6 THEN 'external_sku_name_exact'
|
||||
WHEN $6 != '' AND pmr.external_sku_name_normalized != '' AND (
|
||||
position(pmr.external_sku_name_normalized in $6) > 0
|
||||
OR position($6 in pmr.external_sku_name_normalized) > 0
|
||||
) THEN 'external_sku_name_contains'
|
||||
ELSE ''
|
||||
END AS matched_by,
|
||||
CASE
|
||||
WHEN $4 != '' AND pmr.external_sku_code = $4 THEN 400
|
||||
WHEN $5 != '' AND pmr.external_item_id = $5 THEN 300
|
||||
WHEN $6 != '' AND pmr.external_sku_name_normalized = $6 THEN 200
|
||||
WHEN $6 != '' AND pmr.external_sku_name_normalized != '' AND (
|
||||
position(pmr.external_sku_name_normalized in $6) > 0
|
||||
OR position($6 in pmr.external_sku_name_normalized) > 0
|
||||
) THEN 100
|
||||
ELSE 0
|
||||
END AS match_score
|
||||
FROM product_match_rules pmr
|
||||
WHERE pmr.enabled = TRUE
|
||||
AND (pmr.provider = '' OR pmr.provider = $1)
|
||||
AND (pmr.platform = '' OR pmr.platform = $2)
|
||||
AND (pmr.shop_id = '' OR pmr.shop_id = $3)
|
||||
AND (
|
||||
($4 != '' AND pmr.external_sku_code = $4)
|
||||
OR ($5 != '' AND pmr.external_item_id = $5)
|
||||
OR (
|
||||
$6 != ''
|
||||
AND pmr.external_sku_name_normalized != ''
|
||||
AND (
|
||||
pmr.external_sku_name_normalized = $6
|
||||
OR position(pmr.external_sku_name_normalized in $6) > 0
|
||||
OR position($6 in pmr.external_sku_name_normalized) > 0
|
||||
)
|
||||
)
|
||||
)
|
||||
ORDER BY
|
||||
match_score DESC,
|
||||
CASE WHEN pmr.shop_id = '' THEN 1 ELSE 0 END,
|
||||
CASE WHEN pmr.platform = '' THEN 1 ELSE 0 END,
|
||||
CASE WHEN pmr.provider = '' THEN 1 ELSE 0 END,
|
||||
pmr.priority ASC,
|
||||
pmr.id ASC
|
||||
LIMIT 1
|
||||
`,
|
||||
[provider, platform, shopId, externalSkuCode, externalItemId, externalSkuNameNormalized],
|
||||
)
|
||||
|
||||
return result.rows[0] || null
|
||||
}
|
||||
@@ -1,105 +0,0 @@
|
||||
import { query } from '../db/client.js'
|
||||
import type {
|
||||
TaskInventoryBindingRow,
|
||||
TaskInventoryBindingSummaryRow,
|
||||
} from '../types/repository-rows.js'
|
||||
|
||||
export async function listTaskInventoryBindingsByTaskId(
|
||||
taskId: number | string,
|
||||
): Promise<TaskInventoryBindingRow[]> {
|
||||
const result = await query<TaskInventoryBindingRow>(
|
||||
`
|
||||
SELECT
|
||||
tib.id,
|
||||
tib.task_id,
|
||||
tib.inventory_item_id,
|
||||
tib.role_key,
|
||||
tib.quantity,
|
||||
tib.binding_status,
|
||||
tib.consumed_at,
|
||||
tib.released_at,
|
||||
tib.metadata_json,
|
||||
tib.created_at,
|
||||
tib.updated_at,
|
||||
ii.sku_code,
|
||||
ii.batch_no,
|
||||
ii.credential_type,
|
||||
ii.inventory_group_code,
|
||||
ii.display_value,
|
||||
ii.status AS inventory_item_status,
|
||||
ii.invalid_reason
|
||||
FROM task_inventory_bindings tib
|
||||
JOIN inventory_items ii ON ii.id = tib.inventory_item_id
|
||||
WHERE tib.task_id = $1
|
||||
ORDER BY tib.id ASC
|
||||
`,
|
||||
[Number(taskId)],
|
||||
)
|
||||
|
||||
return result.rows
|
||||
}
|
||||
|
||||
export async function getTaskInventoryBindingById(
|
||||
bindingId: number | string,
|
||||
): Promise<TaskInventoryBindingRow | null> {
|
||||
const result = await query<TaskInventoryBindingRow>(
|
||||
`
|
||||
SELECT
|
||||
tib.id,
|
||||
tib.task_id,
|
||||
tib.inventory_item_id,
|
||||
tib.role_key,
|
||||
tib.quantity,
|
||||
tib.binding_status,
|
||||
tib.consumed_at,
|
||||
tib.released_at,
|
||||
tib.metadata_json,
|
||||
tib.created_at,
|
||||
tib.updated_at,
|
||||
ii.sku_code,
|
||||
ii.batch_no,
|
||||
ii.credential_type,
|
||||
ii.inventory_group_code,
|
||||
ii.display_value,
|
||||
ii.status AS inventory_item_status,
|
||||
ii.invalid_reason
|
||||
FROM task_inventory_bindings tib
|
||||
JOIN inventory_items ii ON ii.id = tib.inventory_item_id
|
||||
WHERE tib.id = $1
|
||||
LIMIT 1
|
||||
`,
|
||||
[Number(bindingId)],
|
||||
)
|
||||
|
||||
return result.rows[0] || null
|
||||
}
|
||||
|
||||
export async function listTaskInventoryBindingSummariesByTaskIds(
|
||||
taskIds: unknown[] = [],
|
||||
): Promise<TaskInventoryBindingSummaryRow[]> {
|
||||
const normalizedTaskIds = Array.from(new Set((Array.isArray(taskIds) ? taskIds : [])
|
||||
.map((value) => Number(value))
|
||||
.filter((value) => Number.isFinite(value) && value > 0)))
|
||||
|
||||
if (normalizedTaskIds.length === 0) {
|
||||
return []
|
||||
}
|
||||
|
||||
const result = await query<TaskInventoryBindingSummaryRow>(
|
||||
`
|
||||
SELECT
|
||||
tib.task_id,
|
||||
COUNT(*)::int AS total_binding_count,
|
||||
COUNT(*) FILTER (WHERE tib.binding_status = 'reserved')::int AS reserved_binding_count,
|
||||
COUNT(*) FILTER (WHERE tib.binding_status = 'consumed')::int AS consumed_binding_count,
|
||||
COUNT(*) FILTER (WHERE tib.binding_status = 'released')::int AS released_binding_count,
|
||||
ARRAY_AGG(DISTINCT tib.role_key ORDER BY tib.role_key) AS role_keys
|
||||
FROM task_inventory_bindings tib
|
||||
WHERE tib.task_id = ANY($1::bigint[])
|
||||
GROUP BY tib.task_id
|
||||
`,
|
||||
[normalizedTaskIds],
|
||||
)
|
||||
|
||||
return result.rows
|
||||
}
|
||||
@@ -1,21 +1,23 @@
|
||||
import { query, withTransaction } from '../db/client.js'
|
||||
import type { PoolClient } from 'pg'
|
||||
|
||||
import { syncKuaishouCloudTaskStateForTask } from './kuaishou-cloud-task-state-repo.js'
|
||||
import { normalizeTimestampIso } from '../utils/time.js'
|
||||
import type {
|
||||
TaskCreateInput,
|
||||
TaskListQueryInput,
|
||||
TaskTencentContextPatch,
|
||||
TaskRuntimeContextPatch,
|
||||
TaskUpdatePatch,
|
||||
} from '../types/repository-inputs.js'
|
||||
} from '../types/repository/inputs.js'
|
||||
import type {
|
||||
TaskListQueryResult,
|
||||
TaskRow,
|
||||
} from '../types/repository-rows.js'
|
||||
} from '../types/repository/rows.js'
|
||||
|
||||
type TaskQueryExecutor = (text: string, params?: unknown[]) => Promise<{ rows: unknown[] }>
|
||||
|
||||
type TencentBrowserContextRow = {
|
||||
browser_session_id: string
|
||||
type TaskRuntimeContextRow = {
|
||||
runtime_session_id: string
|
||||
login_type: string
|
||||
nickname: string
|
||||
role_id: string
|
||||
@@ -33,7 +35,7 @@ const TASK_FIELDS = `
|
||||
ct.token AS primary_claim_token,
|
||||
ct.status AS primary_claim_token_status,
|
||||
ct.expired_at AS primary_claim_expires_at,
|
||||
ctx.browser_session_id,
|
||||
ctx.runtime_session_id,
|
||||
ctx.login_type,
|
||||
ctx.nickname,
|
||||
ctx.role_id,
|
||||
@@ -43,34 +45,23 @@ const TASK_FIELDS = `
|
||||
ctx.screenshot_path,
|
||||
ctx.artifacts_json,
|
||||
ctx.state_json,
|
||||
inv.inventory_item_id AS primary_inventory_item_id,
|
||||
inv.display_value AS primary_inventory_display_value,
|
||||
inv.credential_type AS primary_inventory_credential_type,
|
||||
inv.binding_status AS primary_inventory_binding_status
|
||||
kcts.ticket_status AS kuaishou_ticket_status,
|
||||
kcts.ticket_code_masked AS kuaishou_ticket_code_masked,
|
||||
kcts.bind_url AS kuaishou_bind_url,
|
||||
kcts.vn_phone AS kuaishou_vn_phone,
|
||||
kcts.role_name AS kuaishou_role_name,
|
||||
kcts.role_id AS kuaishou_role_id,
|
||||
kcts.dispatch_status AS kuaishou_dispatch_status,
|
||||
kcts.consume_status AS kuaishou_consume_status,
|
||||
kcts.source_key AS kuaishou_source_key,
|
||||
kcts.updated_at AS kuaishou_state_updated_at
|
||||
`
|
||||
|
||||
const TASK_JOINS = `
|
||||
FROM fulfillment_tasks ft
|
||||
LEFT JOIN claim_tokens ct ON ct.task_id = ft.id
|
||||
LEFT JOIN tencent_browser_contexts ctx ON ctx.task_id = ft.id
|
||||
LEFT JOIN LATERAL (
|
||||
SELECT
|
||||
tib.inventory_item_id,
|
||||
tib.binding_status,
|
||||
ii.display_value,
|
||||
ii.credential_type
|
||||
FROM task_inventory_bindings tib
|
||||
JOIN inventory_items ii ON ii.id = tib.inventory_item_id
|
||||
WHERE tib.task_id = ft.id AND tib.binding_status IN ('reserved', 'consumed')
|
||||
ORDER BY
|
||||
CASE tib.binding_status
|
||||
WHEN 'reserved' THEN 0
|
||||
WHEN 'consumed' THEN 1
|
||||
ELSE 2
|
||||
END ASC,
|
||||
tib.id DESC
|
||||
LIMIT 1
|
||||
) inv ON TRUE
|
||||
LEFT JOIN task_runtime_contexts ctx ON ctx.task_id = ft.id
|
||||
LEFT JOIN kuaishou_cloud_task_states kcts ON kcts.task_id = ft.id
|
||||
`
|
||||
|
||||
function buildTaskSelect(extraFields = ''): string {
|
||||
@@ -106,7 +97,6 @@ export async function createTask(input: TaskCreateInput): Promise<TaskRow | null
|
||||
profile_id,
|
||||
executor_key,
|
||||
task_status,
|
||||
inventory_status,
|
||||
delivery_status,
|
||||
result_code,
|
||||
result_message,
|
||||
@@ -123,7 +113,7 @@ export async function createTask(input: TaskCreateInput): Promise<TaskRow | null
|
||||
) VALUES (
|
||||
$1, $2, $3, $4, $5, $6, $7, $8, $9, $10,
|
||||
$11, $12, $13, $14, $15, $16, $17, $18, $19,
|
||||
$20, $21, $22, $23, $24::jsonb, $25, $26
|
||||
$20, $21, $22, $23::jsonb, $24, $25
|
||||
)
|
||||
RETURNING id
|
||||
`,
|
||||
@@ -140,7 +130,6 @@ export async function createTask(input: TaskCreateInput): Promise<TaskRow | null
|
||||
input.profileId,
|
||||
input.executorKey,
|
||||
input.taskStatus || 'pending_payment',
|
||||
input.inventoryStatus || 'pending',
|
||||
input.deliveryStatus || 'pending',
|
||||
input.resultCode || '',
|
||||
input.resultMessage || '',
|
||||
@@ -158,10 +147,17 @@ export async function createTask(input: TaskCreateInput): Promise<TaskRow | null
|
||||
)
|
||||
|
||||
const taskId = Number(taskResult.rows[0]?.id || 0)
|
||||
if (input.tencentContext) {
|
||||
await upsertTencentBrowserContextWithClient(client, taskId, input.tencentContext, input.createdAt)
|
||||
if (input.runtimeContext) {
|
||||
await upsertTaskRuntimeContextWithClient(client, taskId, input.runtimeContext, input.createdAt)
|
||||
}
|
||||
|
||||
await syncKuaishouCloudTaskStateForTask({
|
||||
taskId,
|
||||
executorKey: input.executorKey,
|
||||
contextJson: input.contextJson || '{}',
|
||||
updatedAt: input.updatedAt,
|
||||
}, client.query.bind(client) as TaskQueryExecutor)
|
||||
|
||||
return getTaskByIdWithExecutor(client.query.bind(client) as TaskQueryExecutor, taskId)
|
||||
})
|
||||
}
|
||||
@@ -180,27 +176,25 @@ export async function updateTask(taskId: number | string, patch: TaskUpdatePatch
|
||||
UPDATE fulfillment_tasks
|
||||
SET
|
||||
task_status = $1,
|
||||
inventory_status = $2,
|
||||
delivery_status = $3,
|
||||
result_code = $4,
|
||||
result_message = $5,
|
||||
claim_token = $6,
|
||||
claim_expires_at = $7,
|
||||
automation_mode = $8,
|
||||
requires_claim = $9,
|
||||
user_action_status = $10,
|
||||
attempt_count = $11,
|
||||
last_error = $12,
|
||||
context_json = $13::jsonb,
|
||||
claimed_at = $14,
|
||||
role_confirmed_at = $15,
|
||||
redeemed_at = $16,
|
||||
updated_at = $17
|
||||
WHERE id = $18
|
||||
delivery_status = $2,
|
||||
result_code = $3,
|
||||
result_message = $4,
|
||||
claim_token = $5,
|
||||
claim_expires_at = $6,
|
||||
automation_mode = $7,
|
||||
requires_claim = $8,
|
||||
user_action_status = $9,
|
||||
attempt_count = $10,
|
||||
last_error = $11,
|
||||
context_json = $12::jsonb,
|
||||
claimed_at = $13,
|
||||
role_confirmed_at = $14,
|
||||
redeemed_at = $15,
|
||||
updated_at = $16
|
||||
WHERE id = $17
|
||||
`,
|
||||
[
|
||||
next.task_status,
|
||||
next.inventory_status,
|
||||
next.delivery_status,
|
||||
next.result_code,
|
||||
next.result_message,
|
||||
@@ -220,21 +214,93 @@ export async function updateTask(taskId: number | string, patch: TaskUpdatePatch
|
||||
],
|
||||
)
|
||||
|
||||
if (containsTencentPatch(patch)) {
|
||||
await upsertTencentBrowserContextWithClient(client, Number(taskId), {
|
||||
browserSessionId: patch.browser_session_id,
|
||||
loginType: patch.login_type,
|
||||
nickname: patch.nickname,
|
||||
roleId: patch.role_id,
|
||||
roleName: patch.role_name,
|
||||
area: patch.area,
|
||||
partitionName: patch.partition_name,
|
||||
screenshotPath: patch.screenshot_path,
|
||||
artifactsJson: patch.artifacts_json,
|
||||
stateJson: patch.state_json,
|
||||
}, patch.updated_at || current.updated_at)
|
||||
if (containsRuntimeContextPatch(patch)) {
|
||||
const runtimeContextPatch: TaskRuntimeContextPatch = {}
|
||||
if (patch.runtime_session_id !== undefined) runtimeContextPatch.runtimeSessionId = patch.runtime_session_id
|
||||
if (patch.login_type !== undefined) runtimeContextPatch.loginType = patch.login_type
|
||||
if (patch.nickname !== undefined) runtimeContextPatch.nickname = patch.nickname
|
||||
if (patch.role_id !== undefined) runtimeContextPatch.roleId = patch.role_id
|
||||
if (patch.role_name !== undefined) runtimeContextPatch.roleName = patch.role_name
|
||||
if (patch.area !== undefined) runtimeContextPatch.area = patch.area
|
||||
if (patch.partition_name !== undefined) runtimeContextPatch.partitionName = patch.partition_name
|
||||
if (patch.screenshot_path !== undefined) runtimeContextPatch.screenshotPath = patch.screenshot_path
|
||||
if (patch.artifacts_json !== undefined) runtimeContextPatch.artifactsJson = patch.artifacts_json
|
||||
if (patch.state_json !== undefined) runtimeContextPatch.stateJson = patch.state_json
|
||||
|
||||
await upsertTaskRuntimeContextWithClient(client, Number(taskId), runtimeContextPatch, patch.updated_at || current.updated_at)
|
||||
}
|
||||
|
||||
await syncKuaishouCloudTaskStateForTask({
|
||||
taskId,
|
||||
executorKey: next.executor_key,
|
||||
contextJson: next.context_json,
|
||||
updatedAt: next.updated_at,
|
||||
}, client.query.bind(client) as TaskQueryExecutor)
|
||||
|
||||
return getTaskByIdWithExecutor(client.query.bind(client) as TaskQueryExecutor, taskId)
|
||||
})
|
||||
}
|
||||
|
||||
export async function updateTaskStatusIfCurrent(
|
||||
taskId: number | string,
|
||||
currentStatus: string,
|
||||
patch: TaskUpdatePatch,
|
||||
): Promise<TaskRow | null> {
|
||||
const now = normalizeTimestampIso(patch.updated_at, '')
|
||||
return withTransaction(async (client) => {
|
||||
const result = await client.query<{
|
||||
id: number
|
||||
executor_key: string
|
||||
context_json: string | Record<string, unknown>
|
||||
updated_at: string
|
||||
}>(
|
||||
`
|
||||
UPDATE fulfillment_tasks
|
||||
SET
|
||||
task_status = $1,
|
||||
delivery_status = COALESCE($2, delivery_status),
|
||||
result_code = COALESCE($3, result_code),
|
||||
result_message = COALESCE($4, result_message),
|
||||
user_action_status = COALESCE($5, user_action_status),
|
||||
last_error = COALESCE($6, last_error),
|
||||
context_json = COALESCE($7::jsonb, context_json),
|
||||
redeemed_at = COALESCE($8, redeemed_at),
|
||||
updated_at = COALESCE($9, updated_at)
|
||||
WHERE id = $10
|
||||
AND task_status = $11
|
||||
RETURNING id, executor_key, context_json, updated_at
|
||||
`,
|
||||
[
|
||||
patch.task_status,
|
||||
patch.delivery_status ?? null,
|
||||
patch.result_code ?? null,
|
||||
patch.result_message ?? null,
|
||||
patch.user_action_status ?? null,
|
||||
patch.last_error ?? null,
|
||||
typeof patch.context_json === 'undefined'
|
||||
? null
|
||||
: typeof patch.context_json === 'string'
|
||||
? patch.context_json
|
||||
: JSON.stringify(patch.context_json || {}),
|
||||
patch.redeemed_at ?? null,
|
||||
now || null,
|
||||
Number(taskId),
|
||||
currentStatus,
|
||||
],
|
||||
)
|
||||
|
||||
const updated = result.rows[0]
|
||||
if (!updated) {
|
||||
return null
|
||||
}
|
||||
|
||||
await syncKuaishouCloudTaskStateForTask({
|
||||
taskId: updated.id,
|
||||
executorKey: updated.executor_key,
|
||||
contextJson: updated.context_json,
|
||||
updatedAt: updated.updated_at,
|
||||
}, client.query.bind(client) as TaskQueryExecutor)
|
||||
|
||||
return getTaskByIdWithExecutor(client.query.bind(client) as TaskQueryExecutor, taskId)
|
||||
})
|
||||
}
|
||||
@@ -291,7 +357,7 @@ export async function listTasks({
|
||||
|
||||
if (roleId) {
|
||||
params.push(`%${roleId}%`)
|
||||
filters.push(`ctx.role_id ILIKE $${params.length}`)
|
||||
filters.push(`COALESCE(NULLIF(kcts.role_id, ''), ctx.role_id) ILIKE $${params.length}`)
|
||||
}
|
||||
|
||||
if (dateFrom) {
|
||||
@@ -310,7 +376,8 @@ export async function listTasks({
|
||||
SELECT COUNT(*)::int AS total
|
||||
FROM fulfillment_tasks ft
|
||||
LEFT JOIN order_items oi ON oi.id = ft.order_item_id
|
||||
LEFT JOIN tencent_browser_contexts ctx ON ctx.task_id = ft.id
|
||||
LEFT JOIN task_runtime_contexts ctx ON ctx.task_id = ft.id
|
||||
LEFT JOIN kuaishou_cloud_task_states kcts ON kcts.task_id = ft.id
|
||||
${whereClause}
|
||||
`,
|
||||
params,
|
||||
@@ -333,20 +400,20 @@ export async function listTasks({
|
||||
}
|
||||
}
|
||||
|
||||
async function upsertTencentBrowserContextWithClient(
|
||||
async function upsertTaskRuntimeContextWithClient(
|
||||
client: PoolClient,
|
||||
taskId: number | string,
|
||||
patch: TaskTencentContextPatch = {},
|
||||
patch: TaskRuntimeContextPatch = {},
|
||||
timestamp: string | undefined,
|
||||
): Promise<void> {
|
||||
const currentResult = await client.query<TencentBrowserContextRow>(
|
||||
'SELECT * FROM tencent_browser_contexts WHERE task_id = $1 LIMIT 1',
|
||||
const currentResult = await client.query<TaskRuntimeContextRow>(
|
||||
'SELECT * FROM task_runtime_contexts WHERE task_id = $1 LIMIT 1',
|
||||
[Number(taskId)],
|
||||
)
|
||||
const current = currentResult.rows[0] || null
|
||||
|
||||
const next = {
|
||||
browser_session_id: patch.browserSessionId ?? current?.browser_session_id ?? '',
|
||||
runtime_session_id: patch.runtimeSessionId ?? current?.runtime_session_id ?? '',
|
||||
login_type: patch.loginType ?? current?.login_type ?? '',
|
||||
nickname: patch.nickname ?? current?.nickname ?? '',
|
||||
role_id: patch.roleId ?? current?.role_id ?? '',
|
||||
@@ -362,9 +429,9 @@ async function upsertTencentBrowserContextWithClient(
|
||||
if (!current) {
|
||||
await client.query(
|
||||
`
|
||||
INSERT INTO tencent_browser_contexts (
|
||||
INSERT INTO task_runtime_contexts (
|
||||
task_id,
|
||||
browser_session_id,
|
||||
runtime_session_id,
|
||||
login_type,
|
||||
nickname,
|
||||
role_id,
|
||||
@@ -380,7 +447,7 @@ async function upsertTencentBrowserContextWithClient(
|
||||
`,
|
||||
[
|
||||
Number(taskId),
|
||||
next.browser_session_id,
|
||||
next.runtime_session_id,
|
||||
next.login_type,
|
||||
next.nickname,
|
||||
next.role_id,
|
||||
@@ -399,9 +466,9 @@ async function upsertTencentBrowserContextWithClient(
|
||||
|
||||
await client.query(
|
||||
`
|
||||
UPDATE tencent_browser_contexts
|
||||
UPDATE task_runtime_contexts
|
||||
SET
|
||||
browser_session_id = $1,
|
||||
runtime_session_id = $1,
|
||||
login_type = $2,
|
||||
nickname = $3,
|
||||
role_id = $4,
|
||||
@@ -415,7 +482,7 @@ async function upsertTencentBrowserContextWithClient(
|
||||
WHERE task_id = $12
|
||||
`,
|
||||
[
|
||||
next.browser_session_id,
|
||||
next.runtime_session_id,
|
||||
next.login_type,
|
||||
next.nickname,
|
||||
next.role_id,
|
||||
@@ -444,9 +511,9 @@ async function getTaskByIdWithExecutor(
|
||||
return (result.rows[0] as TaskRow | undefined) || null
|
||||
}
|
||||
|
||||
function containsTencentPatch(patch: TaskUpdatePatch = {}): boolean {
|
||||
function containsRuntimeContextPatch(patch: TaskUpdatePatch = {}): boolean {
|
||||
return [
|
||||
'browser_session_id',
|
||||
'runtime_session_id',
|
||||
'login_type',
|
||||
'nickname',
|
||||
'role_id',
|
||||
|
||||
@@ -1,181 +0,0 @@
|
||||
import { query } from '../db/client.js'
|
||||
import type {
|
||||
WebhookEventCreateInput,
|
||||
WebhookEventListQueryInput,
|
||||
WebhookEventUpdatePatch,
|
||||
} from '../types/repository-inputs.js'
|
||||
import type {
|
||||
WebhookEventListQueryResult,
|
||||
WebhookEventRow,
|
||||
} from '../types/repository-rows.js'
|
||||
|
||||
export async function createWebhookEvent(input: WebhookEventCreateInput): Promise<WebhookEventRow | null> {
|
||||
const result = await query<WebhookEventRow>(
|
||||
`
|
||||
INSERT INTO webhook_events (
|
||||
provider,
|
||||
platform,
|
||||
shop_id,
|
||||
shop_name,
|
||||
event_type,
|
||||
event_key,
|
||||
signature_valid,
|
||||
headers_json,
|
||||
query_json,
|
||||
body_json,
|
||||
processed,
|
||||
process_error,
|
||||
related_order_id,
|
||||
created_at
|
||||
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8::jsonb, $9::jsonb, $10::jsonb, $11, $12, $13, $14)
|
||||
RETURNING *
|
||||
`,
|
||||
[
|
||||
input.provider,
|
||||
input.platform,
|
||||
input.shopId || '',
|
||||
input.shopName || '',
|
||||
input.eventType,
|
||||
input.eventKey,
|
||||
Boolean(input.signatureValid),
|
||||
input.headersJson || '{}',
|
||||
input.queryJson || '{}',
|
||||
input.bodyJson || '{}',
|
||||
Boolean(input.processed),
|
||||
input.processError || '',
|
||||
input.relatedOrderId || null,
|
||||
input.createdAt,
|
||||
],
|
||||
)
|
||||
|
||||
return result.rows[0] || null
|
||||
}
|
||||
|
||||
export async function updateWebhookEvent(
|
||||
eventId: number | string,
|
||||
patch: WebhookEventUpdatePatch,
|
||||
): Promise<WebhookEventRow | null> {
|
||||
const current = await getWebhookEventById(eventId)
|
||||
if (!current) {
|
||||
return null
|
||||
}
|
||||
|
||||
const next = { ...current, ...patch }
|
||||
const result = await query<WebhookEventRow>(
|
||||
`
|
||||
UPDATE webhook_events
|
||||
SET
|
||||
processed = $1,
|
||||
process_error = $2,
|
||||
related_order_id = $3
|
||||
WHERE id = $4
|
||||
RETURNING *
|
||||
`,
|
||||
[Boolean(next.processed), next.process_error || '', next.related_order_id || null, Number(eventId)],
|
||||
)
|
||||
|
||||
return result.rows[0] || null
|
||||
}
|
||||
|
||||
export async function getWebhookEventById(eventId: number | string): Promise<WebhookEventRow | null> {
|
||||
const result = await query<WebhookEventRow>('SELECT * FROM webhook_events WHERE id = $1 LIMIT 1', [
|
||||
Number(eventId),
|
||||
])
|
||||
return result.rows[0] || null
|
||||
}
|
||||
|
||||
export async function listWebhookEvents({
|
||||
page = 1,
|
||||
pageSize = 20,
|
||||
provider = '',
|
||||
platform = '',
|
||||
platformOrderId = '',
|
||||
processed = '',
|
||||
visibility = 'important',
|
||||
relatedOrderId = '',
|
||||
dateFrom = '',
|
||||
dateTo = '',
|
||||
}: WebhookEventListQueryInput = {}): Promise<WebhookEventListQueryResult> {
|
||||
const offset = (page - 1) * pageSize
|
||||
const filters: string[] = []
|
||||
const params: unknown[] = []
|
||||
|
||||
if (provider) {
|
||||
params.push(provider)
|
||||
filters.push(`provider = $${params.length}`)
|
||||
}
|
||||
|
||||
if (platform) {
|
||||
params.push(platform)
|
||||
filters.push(`platform = $${params.length}`)
|
||||
}
|
||||
|
||||
if (platformOrderId) {
|
||||
params.push(`%${platformOrderId}%`)
|
||||
filters.push(`(event_key ILIKE $${params.length} OR body_json::text ILIKE $${params.length})`)
|
||||
}
|
||||
|
||||
if (processed === '0' || processed === '1') {
|
||||
params.push(processed === '1')
|
||||
filters.push(`processed = $${params.length}`)
|
||||
}
|
||||
|
||||
if (visibility === 'ignored') {
|
||||
filters.push(`process_error LIKE 'ignored_%'`)
|
||||
} else if (visibility === 'important') {
|
||||
filters.push(`(process_error = '' OR process_error NOT LIKE 'ignored_%')`)
|
||||
}
|
||||
|
||||
if (relatedOrderId) {
|
||||
params.push(Number(relatedOrderId))
|
||||
filters.push(`related_order_id = $${params.length}`)
|
||||
}
|
||||
|
||||
if (dateFrom) {
|
||||
params.push(dateFrom)
|
||||
filters.push(`created_at >= $${params.length}`)
|
||||
}
|
||||
|
||||
if (dateTo) {
|
||||
params.push(dateTo)
|
||||
filters.push(`created_at <= $${params.length}`)
|
||||
}
|
||||
|
||||
const whereClause = filters.length > 0 ? `WHERE ${filters.join(' AND ')}` : ''
|
||||
const totalResult = await query<{ [column: string]: unknown, total: number }>(
|
||||
`SELECT COUNT(*)::int AS total FROM webhook_events ${whereClause}`,
|
||||
params,
|
||||
)
|
||||
|
||||
params.push(pageSize)
|
||||
params.push(offset)
|
||||
const itemsResult = await query<WebhookEventRow>(
|
||||
`
|
||||
SELECT *
|
||||
FROM webhook_events
|
||||
${whereClause}
|
||||
ORDER BY id DESC
|
||||
LIMIT $${params.length - 1} OFFSET $${params.length}
|
||||
`,
|
||||
params,
|
||||
)
|
||||
|
||||
return {
|
||||
items: itemsResult.rows,
|
||||
total: Number(totalResult.rows[0]?.total || 0),
|
||||
}
|
||||
}
|
||||
|
||||
export async function listWebhookEventsByOrderId(orderId: number | string): Promise<WebhookEventRow[]> {
|
||||
const result = await query<WebhookEventRow>(
|
||||
`
|
||||
SELECT *
|
||||
FROM webhook_events
|
||||
WHERE related_order_id = $1
|
||||
ORDER BY id DESC
|
||||
`,
|
||||
[Number(orderId)],
|
||||
)
|
||||
|
||||
return result.rows
|
||||
}
|
||||
@@ -1,36 +1,30 @@
|
||||
import { Router } from 'express'
|
||||
import { Router } from "express";
|
||||
|
||||
import authRouter from './admin/auth.js'
|
||||
import auditLogsRouter from './admin/audit-logs.js'
|
||||
import dashboardRouter from './admin/dashboard.js'
|
||||
import inventoryRouter from './admin/inventory.js'
|
||||
import manualRedeemRouter from './admin/manual-redeem.js'
|
||||
import messageDeliveriesRouter from './admin/message-deliveries.js'
|
||||
import ordersRouter from './admin/orders.js'
|
||||
import platformConfigRouter from './admin/platform-config.js'
|
||||
import { requireAdminSession } from './admin/shared.js'
|
||||
import tasksRouter from './admin/tasks.js'
|
||||
import usersRouter from './admin/users.js'
|
||||
import webhookEventsRouter from './admin/webhook-events.js'
|
||||
import { buildNotFoundPayload } from '../utils/http.js'
|
||||
import authRouter from "./admin/auth.js";
|
||||
import auditLogsRouter from "./admin/audit-logs.js";
|
||||
import cloudtentaclesRecordsRouter from "./admin/cloudtentacles-records.js";
|
||||
import dashboardRouter from "./admin/dashboard.js";
|
||||
import ordersRouter from "./admin/orders.js";
|
||||
import platformConfigRouter from "./admin/platform-config.js";
|
||||
import { requireAdminSession } from "./admin/session.js";
|
||||
import tasksRouter from "./admin/tasks.js";
|
||||
import usersRouter from "./admin/users.js";
|
||||
import { buildNotFoundPayload } from "../utils/http.js";
|
||||
|
||||
const router = Router()
|
||||
const router = Router();
|
||||
|
||||
router.use(authRouter)
|
||||
router.use(requireAdminSession)
|
||||
router.use(dashboardRouter)
|
||||
router.use(usersRouter)
|
||||
router.use(auditLogsRouter)
|
||||
router.use(platformConfigRouter)
|
||||
router.use(ordersRouter)
|
||||
router.use(tasksRouter)
|
||||
router.use(manualRedeemRouter)
|
||||
router.use(inventoryRouter)
|
||||
router.use(messageDeliveriesRouter)
|
||||
router.use(webhookEventsRouter)
|
||||
router.use(authRouter);
|
||||
router.use(requireAdminSession);
|
||||
router.use(dashboardRouter);
|
||||
router.use(usersRouter);
|
||||
router.use(auditLogsRouter);
|
||||
router.use(platformConfigRouter);
|
||||
router.use(ordersRouter);
|
||||
router.use(tasksRouter);
|
||||
router.use(cloudtentaclesRecordsRouter);
|
||||
|
||||
router.use((req, res) => {
|
||||
res.status(404).json(buildNotFoundPayload(req))
|
||||
})
|
||||
res.status(404).json(buildNotFoundPayload(req));
|
||||
});
|
||||
|
||||
export default router
|
||||
export default router;
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { Router } from 'express'
|
||||
|
||||
import { getAdminAuditLogs } from '../../services/admin/admin-audit-service.js'
|
||||
import { createJsonHandler, requireAdminRoles } from './shared.js'
|
||||
import { createJsonHandler, requireAdminRoles } from './session.js'
|
||||
|
||||
type AdminAuditLogRouteQuery = Record<string, any>
|
||||
|
||||
|
||||
@@ -1,11 +1,17 @@
|
||||
import { Router } from 'express'
|
||||
|
||||
import { createRateLimitMiddleware, getBodyFieldRateLimitKey } from '../../middleware/rate-limit.js'
|
||||
import { getAdminSessionSummary, loginAdmin } from '../../services/admin/admin-auth-service.js'
|
||||
import { createJsonHandler, extractBearerToken } from './shared.js'
|
||||
import { createJsonHandler, extractBearerToken } from './session.js'
|
||||
|
||||
const router = Router()
|
||||
|
||||
router.post('/auth/login', createJsonHandler(
|
||||
router.post('/auth/login', createRateLimitMiddleware({
|
||||
scope: 'admin:login',
|
||||
windowMs: 60_000,
|
||||
max: 10,
|
||||
key: getBodyFieldRateLimitKey('username'),
|
||||
}), createJsonHandler(
|
||||
(req) => loginAdmin(req.body?.username, req.body?.password),
|
||||
{
|
||||
successMessage: '登录成功',
|
||||
|
||||
@@ -0,0 +1,176 @@
|
||||
import crypto from 'node:crypto'
|
||||
import fs from 'node:fs/promises'
|
||||
import path from 'node:path'
|
||||
|
||||
import { Router } from 'express'
|
||||
|
||||
import { PROJECT_ROOT } from '../../config/runtime.js'
|
||||
import {
|
||||
listAdminCloudtentaclesDeliveryRecords,
|
||||
listAdminCloudtentaclesRecordSources,
|
||||
} from '../../services/admin/platform-config/cloudtentacles/index.js'
|
||||
import { createJsonHandler, requireAdminRoles } from './session.js'
|
||||
import { createHttpError, sendRouteError } from '../../utils/http.js'
|
||||
import type { AdminCloudtentaclesDeliveryRecordQueryRouteBody } from '../../types/admin/route-inputs.js'
|
||||
|
||||
const router = Router()
|
||||
const RECORD_IMAGE_CACHE_DIR = path.join(PROJECT_ROOT, 'data', 'cache', 'cloudtentacles-record-images')
|
||||
|
||||
router.get(
|
||||
'/cloudtentacles-records/sources',
|
||||
requireAdminRoles(['admin', 'operator', 'support']),
|
||||
createJsonHandler(() => listAdminCloudtentaclesRecordSources(), {
|
||||
successMessage: 'ok',
|
||||
errorMessage: '读取 cloudtentacles 账号列表失败',
|
||||
scope: '[admin/cloudtentacles-records/sources]',
|
||||
}),
|
||||
)
|
||||
|
||||
router.post(
|
||||
'/cloudtentacles-records',
|
||||
requireAdminRoles(['admin', 'operator', 'support']),
|
||||
createJsonHandler(
|
||||
(req) =>
|
||||
listAdminCloudtentaclesDeliveryRecords(
|
||||
req.body as AdminCloudtentaclesDeliveryRecordQueryRouteBody,
|
||||
),
|
||||
{
|
||||
successMessage: 'cloudtentacles 发货记录查询成功',
|
||||
errorMessage: 'cloudtentacles 发货记录查询失败',
|
||||
scope: '[admin/cloudtentacles-records]',
|
||||
},
|
||||
),
|
||||
)
|
||||
|
||||
router.get(
|
||||
'/cloudtentacles-records/image',
|
||||
requireAdminRoles(['admin', 'operator', 'support']),
|
||||
async (req, res) => {
|
||||
try {
|
||||
const imageUrl = normalizeAllowedImageUrl(req.query.url)
|
||||
const cachedImage = await getCachedRecordImage(imageUrl)
|
||||
res.setHeader('Content-Type', cachedImage.contentType)
|
||||
res.setHeader('Cache-Control', 'public, max-age=86400')
|
||||
res.send(cachedImage.buffer)
|
||||
} catch (error) {
|
||||
sendRouteError(
|
||||
res,
|
||||
error,
|
||||
'读取 cloudtentacles 商品图片失败',
|
||||
'[admin/cloudtentacles-records/image]',
|
||||
)
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
export default router
|
||||
|
||||
function normalizeAllowedImageUrl(value: unknown) {
|
||||
const raw = String(value || '').trim()
|
||||
if (!raw) {
|
||||
throw createHttpError('缺少商品图片地址', {
|
||||
statusCode: 400,
|
||||
errorCode: 'cloudtentacles_record_image_url_required',
|
||||
})
|
||||
}
|
||||
|
||||
const url = new URL(raw)
|
||||
if (!['https:', 'http:'].includes(url.protocol) || url.hostname !== 'gp.playinjoy.com') {
|
||||
throw createHttpError('商品图片地址不在允许范围内', {
|
||||
statusCode: 400,
|
||||
errorCode: 'cloudtentacles_record_image_url_not_allowed',
|
||||
})
|
||||
}
|
||||
|
||||
return url.toString()
|
||||
}
|
||||
|
||||
async function getCachedRecordImage(imageUrl: string) {
|
||||
const cacheMeta = buildRecordImageCacheMeta(imageUrl)
|
||||
const cached = await readCachedRecordImage(cacheMeta)
|
||||
if (cached) {
|
||||
return cached
|
||||
}
|
||||
|
||||
const downloaded = await downloadRecordImage(imageUrl)
|
||||
const filePath = cacheMeta.filePathForContentType(downloaded.contentType)
|
||||
await fs.mkdir(RECORD_IMAGE_CACHE_DIR, { recursive: true })
|
||||
await fs.writeFile(filePath, downloaded.buffer)
|
||||
return downloaded
|
||||
}
|
||||
|
||||
async function readCachedRecordImage(cacheMeta: ReturnType<typeof buildRecordImageCacheMeta>) {
|
||||
for (const candidate of cacheMeta.candidateFilePaths) {
|
||||
try {
|
||||
const buffer = await fs.readFile(candidate.filePath)
|
||||
return {
|
||||
buffer,
|
||||
contentType: candidate.contentType,
|
||||
}
|
||||
} catch (error) {
|
||||
const code = String((error as NodeJS.ErrnoException).code || '')
|
||||
if (code !== 'ENOENT') {
|
||||
throw error
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
async function downloadRecordImage(imageUrl: string) {
|
||||
const response = await fetch(imageUrl, {
|
||||
headers: {
|
||||
accept: 'image/avif,image/webp,image/apng,image/svg+xml,image/*,*/*;q=0.8',
|
||||
referer: 'https://gp.playinjoy.com/',
|
||||
'user-agent':
|
||||
'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/122.0 Safari/537.36',
|
||||
},
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
throw createHttpError(`商品图片读取失败,HTTP ${response.status}`, {
|
||||
statusCode: 502,
|
||||
errorCode: 'cloudtentacles_record_image_fetch_failed',
|
||||
})
|
||||
}
|
||||
|
||||
const contentType = response.headers.get('content-type') || 'image/jpeg'
|
||||
if (!contentType.startsWith('image/')) {
|
||||
throw createHttpError('商品图片响应格式无效', {
|
||||
statusCode: 502,
|
||||
errorCode: 'cloudtentacles_record_image_invalid_content_type',
|
||||
})
|
||||
}
|
||||
|
||||
return {
|
||||
buffer: Buffer.from(await response.arrayBuffer()),
|
||||
contentType: normalizeImageContentType(contentType),
|
||||
}
|
||||
}
|
||||
|
||||
function buildRecordImageCacheMeta(imageUrl: string) {
|
||||
const digest = crypto.createHash('sha256').update(imageUrl).digest('hex')
|
||||
const candidateTypes = ['image/jpeg', 'image/png', 'image/webp', 'image/gif']
|
||||
return {
|
||||
candidateFilePaths: candidateTypes.map((contentType) => ({
|
||||
contentType,
|
||||
filePath: path.join(RECORD_IMAGE_CACHE_DIR, `${digest}.${extensionForContentType(contentType)}`),
|
||||
})),
|
||||
filePathForContentType(contentType: string) {
|
||||
return path.join(RECORD_IMAGE_CACHE_DIR, `${digest}.${extensionForContentType(contentType)}`)
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeImageContentType(contentType: string) {
|
||||
return String(contentType || 'image/jpeg').split(';')[0]?.trim().toLowerCase() || 'image/jpeg'
|
||||
}
|
||||
|
||||
function extensionForContentType(contentType: string) {
|
||||
const normalized = normalizeImageContentType(contentType)
|
||||
if (normalized === 'image/png') return 'png'
|
||||
if (normalized === 'image/webp') return 'webp'
|
||||
if (normalized === 'image/gif') return 'gif'
|
||||
return 'jpg'
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
import { Router } from 'express'
|
||||
|
||||
import { getAdminDashboardSummary } from '../../services/admin/admin-dashboard-service.js'
|
||||
import { createJsonHandler } from './shared.js'
|
||||
import { createJsonHandler } from './session.js'
|
||||
|
||||
const router = Router()
|
||||
|
||||
|
||||
@@ -1,117 +0,0 @@
|
||||
import { Router } from 'express'
|
||||
|
||||
import {
|
||||
createAdminInventoryItem,
|
||||
importAdminInventoryItems,
|
||||
invalidateAdminInventoryItem,
|
||||
releaseAdminInventoryItem,
|
||||
} from '../../services/admin/admin-write-service.js'
|
||||
import {
|
||||
getAdminInventoryItems,
|
||||
getAdminInventorySkuSuggestions,
|
||||
} from '../../services/admin/admin-read-service.js'
|
||||
import { createJsonHandler, requireAdminRoles } from './shared.js'
|
||||
import type {
|
||||
AdminInventoryCreateRouteBody,
|
||||
AdminInventoryImportRouteBody,
|
||||
AdminInventoryInvalidateRouteBody,
|
||||
AdminInventoryRouteParams,
|
||||
AdminInventoryRouteQuery,
|
||||
AdminInventorySkuSuggestionRouteQuery,
|
||||
} from '../../types/admin-route-inputs.js'
|
||||
import type { AdminInventoryMutationResponse } from '../../types/admin-write-models.js'
|
||||
|
||||
const router = Router()
|
||||
|
||||
router.use('/inventory', requireAdminRoles(['admin', 'operator']))
|
||||
|
||||
router.get('/inventory', createJsonHandler(
|
||||
(req) => getAdminInventoryItems(
|
||||
req.query as AdminInventoryRouteQuery,
|
||||
req.adminSession || null,
|
||||
),
|
||||
{
|
||||
successMessage: 'ok',
|
||||
errorMessage: '读取库存列表失败',
|
||||
scope: '[admin/inventory]',
|
||||
},
|
||||
))
|
||||
|
||||
router.get('/inventory/sku-suggestions', createJsonHandler(
|
||||
(req) => getAdminInventorySkuSuggestions(
|
||||
req.query as AdminInventorySkuSuggestionRouteQuery,
|
||||
req.adminSession || null,
|
||||
),
|
||||
{
|
||||
successMessage: 'ok',
|
||||
errorMessage: '读取内部 SKU 建议失败',
|
||||
scope: '[admin/inventory/sku-suggestions]',
|
||||
},
|
||||
))
|
||||
|
||||
router.post('/inventory', createJsonHandler(
|
||||
(req) => createAdminInventoryItem(req.body as AdminInventoryCreateRouteBody),
|
||||
{
|
||||
successMessage: '库存项已新增',
|
||||
errorMessage: '新增库存项失败',
|
||||
scope: '[admin/inventory]',
|
||||
},
|
||||
))
|
||||
|
||||
router.post('/inventory/import', createJsonHandler(
|
||||
(req) => importAdminInventoryItems(req.body as AdminInventoryImportRouteBody),
|
||||
{
|
||||
successMessage: '导入完成',
|
||||
errorMessage: '导入库存凭据失败',
|
||||
scope: '[admin/inventory/import]',
|
||||
},
|
||||
))
|
||||
|
||||
router.post('/inventory/:inventoryItemId/release', requireAdminRoles(['admin']), createJsonHandler(
|
||||
(req) => releaseAdminInventoryItem((req.params as AdminInventoryRouteParams).inventoryItemId),
|
||||
{
|
||||
successMessage: '库存项已释放',
|
||||
errorMessage: '释放库存项失败',
|
||||
scope: '[admin/inventory/:inventoryItemId/release]',
|
||||
audit: (req, data) => {
|
||||
const result = data as AdminInventoryMutationResponse
|
||||
return {
|
||||
action: 'inventory_item_released',
|
||||
targetType: 'inventory_item',
|
||||
targetId: String((req.params as AdminInventoryRouteParams).inventoryItemId),
|
||||
data: {
|
||||
inventoryItemId: result.inventoryItem?.inventoryItemId,
|
||||
skuCode: result.inventoryItem?.skuCode,
|
||||
displayValue: result.inventoryItem?.displayValue,
|
||||
},
|
||||
}
|
||||
},
|
||||
},
|
||||
))
|
||||
|
||||
router.post('/inventory/:inventoryItemId/invalidate', requireAdminRoles(['admin']), createJsonHandler(
|
||||
(req) => invalidateAdminInventoryItem(
|
||||
(req.params as AdminInventoryRouteParams).inventoryItemId,
|
||||
req.body as AdminInventoryInvalidateRouteBody,
|
||||
),
|
||||
{
|
||||
successMessage: '库存项已作废',
|
||||
errorMessage: '作废库存项失败',
|
||||
scope: '[admin/inventory/:inventoryItemId/invalidate]',
|
||||
audit: (req, data) => {
|
||||
const result = data as AdminInventoryMutationResponse
|
||||
return {
|
||||
action: 'inventory_item_invalidated',
|
||||
targetType: 'inventory_item',
|
||||
targetId: String((req.params as AdminInventoryRouteParams).inventoryItemId),
|
||||
data: {
|
||||
inventoryItemId: result.inventoryItem?.inventoryItemId,
|
||||
skuCode: result.inventoryItem?.skuCode,
|
||||
invalidReason: result.inventoryItem?.invalidReason,
|
||||
},
|
||||
}
|
||||
},
|
||||
},
|
||||
))
|
||||
|
||||
export default router
|
||||
@@ -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,18 +0,0 @@
|
||||
import { Router } from 'express'
|
||||
|
||||
import { getAdminMessageDeliveries } from '../../services/admin/admin-message-delivery-service.js'
|
||||
import { createJsonHandler } from './shared.js'
|
||||
import type { AdminMessageDeliveryRouteQuery } from '../../types/admin-route-inputs.js'
|
||||
|
||||
const router = Router()
|
||||
|
||||
router.get('/message-deliveries', createJsonHandler(
|
||||
(req) => getAdminMessageDeliveries(req.query as AdminMessageDeliveryRouteQuery),
|
||||
{
|
||||
successMessage: 'ok',
|
||||
errorMessage: '读取消息发送记录失败',
|
||||
scope: '[admin/message-deliveries]',
|
||||
},
|
||||
))
|
||||
|
||||
export default router
|
||||
@@ -1,11 +1,11 @@
|
||||
import { Router } from 'express'
|
||||
|
||||
import { getAdminOrderDetail, getAdminOrders } from '../../services/admin/admin-read-service.js'
|
||||
import { createJsonHandler } from './shared.js'
|
||||
import { createJsonHandler } from './session.js'
|
||||
import type {
|
||||
AdminOrderRouteParams,
|
||||
AdminOrderRouteQuery,
|
||||
} from '../../types/admin-route-inputs.js'
|
||||
} from '../../types/admin/route-inputs.js'
|
||||
|
||||
const router = Router()
|
||||
|
||||
@@ -19,7 +19,7 @@ router.get('/orders', createJsonHandler(
|
||||
))
|
||||
|
||||
router.get('/orders/:orderId', createJsonHandler(
|
||||
(req) => getAdminOrderDetail((req.params as AdminOrderRouteParams).orderId),
|
||||
(req) => getAdminOrderDetail(String((req.params as AdminOrderRouteParams).orderId || '')),
|
||||
{
|
||||
successMessage: 'ok',
|
||||
errorMessage: '读取订单详情失败',
|
||||
|
||||
@@ -1,10 +1,7 @@
|
||||
import { Router } from "express";
|
||||
|
||||
import { requireAdminRoles } from "./shared.js";
|
||||
import agisoRouter from "./platform-config/agiso.js";
|
||||
import { requireAdminRoles } from "./session.js";
|
||||
import cloudtentaclesRouter from "./platform-config/cloudtentacles.js";
|
||||
import fulfillmentBindingsRouter from "./platform-config/fulfillment-bindings.js";
|
||||
import kuaishouCloudFulfillmentRouter from "./platform-config/kuaishou-cloud-fulfillment.js";
|
||||
import kuaishouEticketRouter from "./platform-config/kuaishou-eticket.js";
|
||||
import ninetyoneRouter from "./platform-config/ninetyone.js";
|
||||
import notificationsRouter from "./platform-config/notifications.js";
|
||||
@@ -12,12 +9,9 @@ import notificationsRouter from "./platform-config/notifications.js";
|
||||
const router = Router();
|
||||
|
||||
router.use("/platform-config", requireAdminRoles(["admin"]));
|
||||
router.use("/platform-config", agisoRouter);
|
||||
router.use("/platform-config", notificationsRouter);
|
||||
router.use("/platform-config", kuaishouEticketRouter);
|
||||
router.use("/platform-config", ninetyoneRouter);
|
||||
router.use("/platform-config", cloudtentaclesRouter);
|
||||
router.use("/platform-config", fulfillmentBindingsRouter);
|
||||
router.use("/platform-config", kuaishouCloudFulfillmentRouter);
|
||||
|
||||
export default router;
|
||||
|
||||
@@ -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;
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
deleteAdminCloudtentaclesSource,
|
||||
fetchAdminCloudtentaclesVirtualNumberCode,
|
||||
generateAdminCloudtentaclesLoginCode,
|
||||
getAdminCloudtentaclesOverrideRules,
|
||||
getAdminCloudtentaclesAsset,
|
||||
getAdminCloudtentaclesBindUrl,
|
||||
getAdminCloudtentaclesCategories,
|
||||
@@ -17,14 +18,16 @@ import {
|
||||
runAdminCloudtentaclesFullFlow,
|
||||
sendAdminCloudtentaclesSmsCode,
|
||||
testAdminCloudtentaclesLogin,
|
||||
updateAdminCloudtentaclesOverrideRules,
|
||||
updateAdminCloudtentaclesSourceConfig,
|
||||
useAdminCloudtentaclesSku,
|
||||
validateAdminCloudtentaclesSession,
|
||||
verifyAdminCloudtentaclesLoginCode,
|
||||
} from "../../../services/admin/platform-config/service.js";
|
||||
} from "../../../services/admin/platform-config/cloudtentacles/index.js";
|
||||
import type {
|
||||
AdminCloudtentaclesCatalogQueryRouteBody,
|
||||
AdminCloudtentaclesFullFlowRouteBody,
|
||||
AdminCloudtentaclesOverrideRuleConfigRouteBody,
|
||||
AdminCloudtentaclesSendSmsCodeRouteBody,
|
||||
AdminCloudtentaclesSkuBuyRouteBody,
|
||||
AdminCloudtentaclesSkuUseRouteBody,
|
||||
@@ -32,9 +35,9 @@ import type {
|
||||
AdminCloudtentaclesTestLoginRouteBody,
|
||||
AdminCloudtentaclesValidateSessionRouteBody,
|
||||
AdminCloudtentaclesVirtualNumberRouteBody,
|
||||
} from "../../../types/admin-route-inputs.js";
|
||||
import { createJsonHandler } from "../shared.js";
|
||||
import type { JsonRecord } from "./shared.js";
|
||||
} from "../../../types/admin/route-inputs.js";
|
||||
import { createJsonHandler } from "../session.js";
|
||||
import type { JsonRecord } from "../../../types/json.js";
|
||||
|
||||
const router = Router();
|
||||
|
||||
@@ -96,6 +99,43 @@ router.post(
|
||||
)
|
||||
);
|
||||
|
||||
router.get(
|
||||
"/cloudtentacles/override-rules",
|
||||
createJsonHandler(() => getAdminCloudtentaclesOverrideRules(), {
|
||||
successMessage: "ok",
|
||||
errorMessage: "读取 cloudtentacles 覆盖规则失败",
|
||||
scope: "[admin/platform-config/cloudtentacles/override-rules]",
|
||||
})
|
||||
);
|
||||
|
||||
router.post(
|
||||
"/cloudtentacles/override-rules",
|
||||
createJsonHandler(
|
||||
(req) =>
|
||||
updateAdminCloudtentaclesOverrideRules(
|
||||
req.body as AdminCloudtentaclesOverrideRuleConfigRouteBody
|
||||
),
|
||||
{
|
||||
successMessage: "cloudtentacles 覆盖规则已保存",
|
||||
errorMessage: "保存 cloudtentacles 覆盖规则失败",
|
||||
scope: "[admin/platform-config/cloudtentacles/override-rules]",
|
||||
audit: (_req, data) => {
|
||||
const result = data as JsonRecord;
|
||||
return {
|
||||
action: "platform_cloudtentacles_override_rules_updated",
|
||||
targetType: "platform_config",
|
||||
targetId: "cloudtentacles_override_rules",
|
||||
data: {
|
||||
filePath: String(result.filePath || "").trim(),
|
||||
enabled: result.enabled !== false,
|
||||
ruleCount: Array.isArray(result.rules) ? result.rules.length : 0,
|
||||
},
|
||||
};
|
||||
},
|
||||
}
|
||||
)
|
||||
);
|
||||
|
||||
router.post(
|
||||
"/cloudtentacles/send-sms-code",
|
||||
createJsonHandler(
|
||||
|
||||
@@ -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,51 +0,0 @@
|
||||
import { Router } from "express";
|
||||
|
||||
import {
|
||||
getAdminKuaishouCloudFulfillmentConfig,
|
||||
updateAdminKuaishouCloudFulfillmentConfig,
|
||||
} from "../../../services/admin/platform-config/service.js";
|
||||
import type { AdminKuaishouCloudFulfillmentConfigRouteBody } from "../../../types/admin-route-inputs.js";
|
||||
import { createJsonHandler } from "../shared.js";
|
||||
import type { JsonRecord } from "./shared.js";
|
||||
|
||||
const router = Router();
|
||||
|
||||
router.get(
|
||||
"/kuaishou-cloud-fulfillment",
|
||||
createJsonHandler(() => getAdminKuaishouCloudFulfillmentConfig(), {
|
||||
successMessage: "ok",
|
||||
errorMessage: "读取新履约配置失败",
|
||||
scope: "[admin/platform-config/kuaishou-cloud-fulfillment]",
|
||||
})
|
||||
);
|
||||
|
||||
router.post(
|
||||
"/kuaishou-cloud-fulfillment",
|
||||
createJsonHandler(
|
||||
(req) =>
|
||||
updateAdminKuaishouCloudFulfillmentConfig(
|
||||
req.body as AdminKuaishouCloudFulfillmentConfigRouteBody
|
||||
),
|
||||
{
|
||||
successMessage: "新履约配置已保存",
|
||||
errorMessage: "保存新履约配置失败",
|
||||
scope: "[admin/platform-config/kuaishou-cloud-fulfillment]",
|
||||
audit: (_req, data) => {
|
||||
const result = data as JsonRecord;
|
||||
return {
|
||||
action: "platform_kuaishou_cloud_fulfillment_updated",
|
||||
targetType: "platform_config",
|
||||
targetId: "kuaishou_cloud_fulfillment",
|
||||
data: {
|
||||
itemCount: Array.isArray(result.source?.items)
|
||||
? result.source.items.length
|
||||
: 0,
|
||||
filePath: String(result.filePath || "").trim(),
|
||||
},
|
||||
};
|
||||
},
|
||||
}
|
||||
)
|
||||
);
|
||||
|
||||
export default router;
|
||||
@@ -6,15 +6,15 @@ import {
|
||||
queryAdminKuaishouEticketDetail,
|
||||
queryAdminKuaishouEticketShopInfo,
|
||||
updateAdminKuaishouEticketSourceConfig,
|
||||
} from "../../../services/admin/platform-config/service.js";
|
||||
} from "../../../services/admin/platform-config/kuaishou-eticket-service.js";
|
||||
import type {
|
||||
AdminKuaishouEticketConsumeRouteBody,
|
||||
AdminKuaishouEticketDetailQueryRouteBody,
|
||||
AdminKuaishouEticketShopInfoRouteBody,
|
||||
AdminKuaishouEticketSourceConfigRouteBody,
|
||||
} from "../../../types/admin-route-inputs.js";
|
||||
import { createJsonHandler } from "../shared.js";
|
||||
import type { JsonRecord } from "./shared.js";
|
||||
} from "../../../types/admin/route-inputs.js";
|
||||
import { createJsonHandler } from "../session.js";
|
||||
import type { JsonRecord } from "../../../types/json.js";
|
||||
|
||||
const router = Router();
|
||||
|
||||
|
||||
@@ -4,10 +4,10 @@ import {
|
||||
failAdminNinetyoneOrder,
|
||||
getAdminNinetyoneOrders,
|
||||
retryAdminNinetyoneOrder,
|
||||
} from "../../../services/admin/platform-config/service.js";
|
||||
import type { AdminEntityRouteParams } from "../../../types/admin-route-inputs.js";
|
||||
import { createJsonHandler } from "../shared.js";
|
||||
import type { JsonRecord } from "./shared.js";
|
||||
} from "../../../services/admin/platform-config/ninetyone-service.js";
|
||||
import type { AdminEntityRouteParams } from "../../../types/admin/route-inputs.js";
|
||||
import { createJsonHandler } from "../session.js";
|
||||
import type { JsonRecord } from "../../../types/json.js";
|
||||
|
||||
const router = Router();
|
||||
|
||||
@@ -23,7 +23,7 @@ router.get(
|
||||
router.post(
|
||||
"/ninetyone/orders/:id/retry",
|
||||
createJsonHandler(
|
||||
(req) => retryAdminNinetyoneOrder((req.params as AdminEntityRouteParams).id),
|
||||
(req) => retryAdminNinetyoneOrder(String((req.params as AdminEntityRouteParams).id || "")),
|
||||
{
|
||||
successMessage: "91卡券订单已重试",
|
||||
errorMessage: "重试 91卡券订单失败",
|
||||
@@ -49,7 +49,7 @@ router.post(
|
||||
createJsonHandler(
|
||||
(req) =>
|
||||
failAdminNinetyoneOrder(
|
||||
(req.params as AdminEntityRouteParams).id,
|
||||
String((req.params as AdminEntityRouteParams).id || ""),
|
||||
req.body as { reason?: string }
|
||||
),
|
||||
{
|
||||
|
||||
@@ -7,15 +7,15 @@ import {
|
||||
testAdminNotification,
|
||||
updateAdminNotificationConfig,
|
||||
updateAdminScheduledJobsConfig,
|
||||
} from "../../../services/admin/platform-config/service.js";
|
||||
} from "../../../services/admin/platform-config/notification-service.js";
|
||||
import type {
|
||||
AdminEntityRouteParams,
|
||||
AdminNotificationConfigRouteBody,
|
||||
AdminNotificationTestRouteBody,
|
||||
AdminScheduledJobsConfigRouteBody,
|
||||
} from "../../../types/admin-route-inputs.js";
|
||||
import { createJsonHandler } from "../shared.js";
|
||||
import type { JsonRecord } from "./shared.js";
|
||||
} from "../../../types/admin/route-inputs.js";
|
||||
import { createJsonHandler } from "../session.js";
|
||||
import type { JsonRecord } from "../../../types/json.js";
|
||||
|
||||
const router = Router();
|
||||
|
||||
|
||||
@@ -73,6 +73,17 @@ export function requireAdminRoles(allowedRoles: string[]) {
|
||||
}
|
||||
}
|
||||
|
||||
export function getRequiredAdminSession(req: Request): AdminSession {
|
||||
if (req.adminSession) {
|
||||
return req.adminSession
|
||||
}
|
||||
|
||||
throw createHttpError('未登录或登录已失效', {
|
||||
statusCode: 401,
|
||||
errorCode: 'admin_auth_required',
|
||||
})
|
||||
}
|
||||
|
||||
async function recordAdminAudit(
|
||||
session: AdminSession | null | undefined,
|
||||
req: Request,
|
||||
@@ -112,5 +123,13 @@ export function extractBearerToken(req: Request): string {
|
||||
})
|
||||
}
|
||||
|
||||
return matched[1]
|
||||
const token = matched[1]?.trim()
|
||||
if (!token) {
|
||||
throw createHttpError('未登录或登录已失效', {
|
||||
statusCode: 401,
|
||||
errorCode: 'admin_auth_required',
|
||||
})
|
||||
}
|
||||
|
||||
return token
|
||||
}
|
||||
@@ -1,433 +1,166 @@
|
||||
import { Router } from "express";
|
||||
import type { Request } from 'express'
|
||||
import { Router } from 'express'
|
||||
|
||||
import { getAdminTaskDetail, getAdminTasks } from '../../services/admin/admin-read-service.js'
|
||||
import { closeAdminTask } from '../../services/admin/write/task-actions.js'
|
||||
import {
|
||||
closeAdminTask,
|
||||
completeAdminTaskManualDispatch,
|
||||
confirmAdminTaskAssistedRole,
|
||||
dispatchAdminTaskKuaishouCloudFulfillment,
|
||||
markAdminTaskManualReview,
|
||||
prepareAdminTaskKuaishouCloudFulfillment,
|
||||
rebindAdminTaskKuaishouCloudRole,
|
||||
refreshAdminTaskKuaishouCloudRoleInfo,
|
||||
regenerateAdminTaskClaimLink,
|
||||
redeemAdminTaskAssisted,
|
||||
releaseAdminTaskInventoryBinding,
|
||||
releaseAdminTaskInventory,
|
||||
returnNumberAdminTaskKuaishouCloudFulfillment,
|
||||
retryAdminTask,
|
||||
} from "../../services/admin/admin-write-service.js";
|
||||
import {
|
||||
getAdminTaskDetail,
|
||||
getAdminTaskScreenshotPath,
|
||||
getAdminTasks,
|
||||
} from "../../services/admin/admin-read-service.js";
|
||||
import {
|
||||
createFileHandler,
|
||||
createJsonHandler,
|
||||
requireAdminRoles,
|
||||
} from "./shared.js";
|
||||
import type { Request } from "express";
|
||||
} from '../../services/admin/write/kuaishou-cloud-actions.js'
|
||||
import { createJsonHandler, requireAdminRoles } from './session.js'
|
||||
import type {
|
||||
AdminTaskKuaishouCloudDispatchRouteBody,
|
||||
AdminTaskManualDispatchRouteBody,
|
||||
AdminTaskRouteParams,
|
||||
AdminTaskRouteQuery,
|
||||
} from "../../types/admin-route-inputs.js";
|
||||
import type {
|
||||
AdminTaskActionResponse,
|
||||
AdminTaskBindingReleaseResponse,
|
||||
AdminTaskManualDispatchResponse,
|
||||
} from "../../types/admin-write-models.js";
|
||||
} from '../../types/admin/route-inputs.js'
|
||||
import type { AdminTaskActionResponse } from '../../types/admin/write-models.js'
|
||||
|
||||
const router = Router();
|
||||
const router = Router()
|
||||
|
||||
function getTaskId(req: Request): string | undefined {
|
||||
return (req.params as AdminTaskRouteParams).taskId;
|
||||
}
|
||||
|
||||
function getBindingId(req: Request): string | undefined {
|
||||
return (req.params as AdminTaskRouteParams).bindingId;
|
||||
function getTaskId(req: Request): string {
|
||||
return String((req.params as AdminTaskRouteParams).taskId || '')
|
||||
}
|
||||
|
||||
router.get(
|
||||
"/tasks",
|
||||
'/tasks',
|
||||
createJsonHandler(
|
||||
(req) =>
|
||||
getAdminTasks(req.query as AdminTaskRouteQuery, req.adminSession || null),
|
||||
(req) => getAdminTasks(req.query as AdminTaskRouteQuery, req.adminSession || null),
|
||||
{
|
||||
successMessage: "ok",
|
||||
errorMessage: "读取任务列表失败",
|
||||
scope: "[admin/tasks]",
|
||||
}
|
||||
successMessage: 'ok',
|
||||
errorMessage: '读取任务列表失败',
|
||||
scope: '[admin/tasks]',
|
||||
},
|
||||
),
|
||||
)
|
||||
);
|
||||
|
||||
router.get(
|
||||
"/tasks/:taskId",
|
||||
createJsonHandler(
|
||||
(req) => getAdminTaskDetail(getTaskId(req), req.adminSession || null),
|
||||
{
|
||||
successMessage: "ok",
|
||||
errorMessage: "读取任务详情失败",
|
||||
scope: "[admin/tasks/:taskId]",
|
||||
}
|
||||
'/tasks/:taskId',
|
||||
createJsonHandler((req) => getAdminTaskDetail(getTaskId(req), req.adminSession || null), {
|
||||
successMessage: 'ok',
|
||||
errorMessage: '读取任务详情失败',
|
||||
scope: '[admin/tasks/:taskId]',
|
||||
}),
|
||||
)
|
||||
);
|
||||
|
||||
router.get(
|
||||
"/tasks/:taskId/screenshot",
|
||||
createFileHandler(
|
||||
(req) =>
|
||||
getAdminTaskScreenshotPath(getTaskId(req), req.adminSession || null),
|
||||
{
|
||||
errorMessage: "读取任务截图失败",
|
||||
scope: "[admin/tasks/:taskId/screenshot]",
|
||||
}
|
||||
router.post(
|
||||
'/tasks/:taskId/close',
|
||||
requireAdminRoles(['admin', 'operator', 'support']),
|
||||
createJsonHandler((req) => closeAdminTask(getTaskId(req), req.adminSession || null), {
|
||||
successMessage: '任务已关闭',
|
||||
errorMessage: '关闭任务失败',
|
||||
scope: '[admin/tasks/:taskId/close]',
|
||||
audit: (req, data) => buildTaskAudit('task_closed', req, data),
|
||||
}),
|
||||
)
|
||||
);
|
||||
|
||||
router.post(
|
||||
"/tasks/:taskId/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"]),
|
||||
'/tasks/:taskId/kuaishou-cloud/prepare',
|
||||
requireAdminRoles(['admin', 'operator']),
|
||||
createJsonHandler(
|
||||
(req) =>
|
||||
releaseAdminTaskInventoryBinding(getTaskId(req), getBindingId(req)),
|
||||
(req) => prepareAdminTaskKuaishouCloudFulfillment(getTaskId(req), req.adminSession || null),
|
||||
{
|
||||
successMessage: "库存绑定已释放",
|
||||
errorMessage: "释放库存绑定失败",
|
||||
scope: "[admin/tasks/:taskId/inventory-bindings/:bindingId/release]",
|
||||
audit: (req, data) => {
|
||||
const result = data as AdminTaskBindingReleaseResponse;
|
||||
return {
|
||||
action: "task_release_inventory_binding",
|
||||
targetType: "task_inventory_binding",
|
||||
targetId: String(getBindingId(req)),
|
||||
data: {
|
||||
taskId: result.task.taskId,
|
||||
taskNo: result.task.taskNo,
|
||||
bindingId: result.bindingId,
|
||||
inventoryItemId: result.inventoryItemId,
|
||||
successMessage: '绑定资源已准备完成',
|
||||
errorMessage: '准备绑定资源失败',
|
||||
scope: '[admin/tasks/:taskId/kuaishou-cloud/prepare]',
|
||||
audit: (req, data) => buildTaskAudit('task_kuaishou_cloud_prepare', req, data),
|
||||
},
|
||||
};
|
||||
},
|
||||
}
|
||||
)
|
||||
);
|
||||
|
||||
router.post(
|
||||
"/tasks/:taskId/regenerate-claim-link",
|
||||
requireAdminRoles(["admin", "operator", "support"]),
|
||||
createJsonHandler(
|
||||
(req) =>
|
||||
regenerateAdminTaskClaimLink(getTaskId(req), req.adminSession || null),
|
||||
{
|
||||
successMessage: "领取链接已重新生成",
|
||||
errorMessage: "重新生成领取链接失败",
|
||||
scope: "[admin/tasks/:taskId/regenerate-claim-link]",
|
||||
audit: (req, data) => {
|
||||
const result = data as AdminTaskActionResponse;
|
||||
return {
|
||||
action: "task_regenerate_claim_link",
|
||||
targetType: "task",
|
||||
targetId: String(getTaskId(req)),
|
||||
data: {
|
||||
taskId: result.task.taskId,
|
||||
taskNo: result.task.taskNo,
|
||||
primaryClaimTokenId: result.task.primaryClaimTokenId,
|
||||
},
|
||||
};
|
||||
},
|
||||
}
|
||||
)
|
||||
);
|
||||
|
||||
router.post(
|
||||
"/tasks/:taskId/support-confirm-role",
|
||||
requireAdminRoles(["admin", "operator", "support"]),
|
||||
createJsonHandler(
|
||||
(req) =>
|
||||
confirmAdminTaskAssistedRole(getTaskId(req), req.adminSession || null),
|
||||
{
|
||||
successMessage: "角色已确认",
|
||||
errorMessage: "客服确认角色失败",
|
||||
scope: "[admin/tasks/:taskId/support-confirm-role]",
|
||||
audit: (req, data) => {
|
||||
const result = data as AdminTaskActionResponse;
|
||||
return {
|
||||
action: "task_support_confirm_role",
|
||||
targetType: "task",
|
||||
targetId: String(getTaskId(req)),
|
||||
data: {
|
||||
taskId: result.task.taskId,
|
||||
taskNo: result.task.taskNo,
|
||||
status: result.task.status,
|
||||
},
|
||||
};
|
||||
},
|
||||
}
|
||||
)
|
||||
);
|
||||
|
||||
router.post(
|
||||
"/tasks/:taskId/support-redeem",
|
||||
requireAdminRoles(["admin", "operator", "support"]),
|
||||
createJsonHandler(
|
||||
(req) => redeemAdminTaskAssisted(getTaskId(req), req.adminSession || null),
|
||||
{
|
||||
successMessage: "兑换任务已启动",
|
||||
errorMessage: "客服发起兑换失败",
|
||||
scope: "[admin/tasks/:taskId/support-redeem]",
|
||||
audit: (req, data) => {
|
||||
const result = data as AdminTaskActionResponse;
|
||||
return {
|
||||
action: "task_support_redeem",
|
||||
targetType: "task",
|
||||
targetId: String(getTaskId(req)),
|
||||
data: {
|
||||
taskId: result.task.taskId,
|
||||
taskNo: result.task.taskNo,
|
||||
status: result.task.status,
|
||||
deliveryStatus: result.task.deliveryStatus,
|
||||
},
|
||||
};
|
||||
},
|
||||
}
|
||||
)
|
||||
);
|
||||
|
||||
router.post(
|
||||
"/tasks/:taskId/close",
|
||||
requireAdminRoles(["admin", "operator", "support"]),
|
||||
createJsonHandler((req) => closeAdminTask(getTaskId(req)), {
|
||||
successMessage: "任务已关闭",
|
||||
errorMessage: "关闭任务失败",
|
||||
scope: "[admin/tasks/:taskId/close]",
|
||||
audit: (req, data) => {
|
||||
const result = data as AdminTaskActionResponse;
|
||||
return {
|
||||
action: "task_closed",
|
||||
targetType: "task",
|
||||
targetId: String(getTaskId(req)),
|
||||
data: {
|
||||
taskId: result.task.taskId,
|
||||
taskNo: result.task.taskNo,
|
||||
status: result.task.status,
|
||||
},
|
||||
};
|
||||
},
|
||||
})
|
||||
);
|
||||
|
||||
router.post(
|
||||
"/tasks/:taskId/mark-manual-review",
|
||||
requireAdminRoles(["admin", "operator"]),
|
||||
createJsonHandler((req) => markAdminTaskManualReview(getTaskId(req)), {
|
||||
successMessage: "任务已转人工处理",
|
||||
errorMessage: "标记人工处理失败",
|
||||
scope: "[admin/tasks/:taskId/mark-manual-review]",
|
||||
audit: (req, data) => {
|
||||
const result = data as AdminTaskActionResponse;
|
||||
return {
|
||||
action: "task_mark_manual_review",
|
||||
targetType: "task",
|
||||
targetId: String(getTaskId(req)),
|
||||
data: {
|
||||
taskId: result.task.taskId,
|
||||
taskNo: result.task.taskNo,
|
||||
status: result.task.status,
|
||||
},
|
||||
};
|
||||
},
|
||||
})
|
||||
);
|
||||
|
||||
router.post(
|
||||
"/tasks/:taskId/complete-manual-dispatch",
|
||||
requireAdminRoles(["admin", "operator"]),
|
||||
createJsonHandler(
|
||||
(req) =>
|
||||
completeAdminTaskManualDispatch(
|
||||
getTaskId(req),
|
||||
req.body as AdminTaskManualDispatchRouteBody,
|
||||
req.adminSession || null
|
||||
),
|
||||
{
|
||||
successMessage: "人工履约结果已回写",
|
||||
errorMessage: "回写人工履约结果失败",
|
||||
scope: "[admin/tasks/:taskId/complete-manual-dispatch]",
|
||||
audit: (req, data) => {
|
||||
const result = data as AdminTaskManualDispatchResponse;
|
||||
return {
|
||||
action: "task_complete_manual_dispatch",
|
||||
targetType: "task",
|
||||
targetId: String(getTaskId(req)),
|
||||
data: {
|
||||
taskId: result.task.taskId,
|
||||
taskNo: result.task.taskNo,
|
||||
outcome: result.outcome,
|
||||
deliveryStatus: result.task.deliveryStatus,
|
||||
resultCode: result.task.resultCode,
|
||||
},
|
||||
};
|
||||
},
|
||||
}
|
||||
)
|
||||
);
|
||||
|
||||
router.post(
|
||||
"/tasks/:taskId/kuaishou-cloud/prepare",
|
||||
requireAdminRoles(["admin", "operator"]),
|
||||
'/tasks/:taskId/kuaishou-cloud/refresh-role-info',
|
||||
requireAdminRoles(['admin', 'operator', 'support']),
|
||||
createJsonHandler(
|
||||
(req) =>
|
||||
prepareAdminTaskKuaishouCloudFulfillment(
|
||||
getTaskId(req),
|
||||
req.adminSession || null
|
||||
),
|
||||
(req) => refreshAdminTaskKuaishouCloudRoleInfo(getTaskId(req), req.adminSession || null),
|
||||
{
|
||||
successMessage: "绑定资源已准备完成",
|
||||
errorMessage: "准备绑定资源失败",
|
||||
scope: "[admin/tasks/:taskId/kuaishou-cloud/prepare]",
|
||||
audit: (req, data) => {
|
||||
const result = data as AdminTaskActionResponse;
|
||||
return {
|
||||
action: "task_kuaishou_cloud_prepare",
|
||||
targetType: "task",
|
||||
targetId: String(getTaskId(req)),
|
||||
data: {
|
||||
taskId: result.task.taskId,
|
||||
taskNo: result.task.taskNo,
|
||||
status: result.task.status,
|
||||
successMessage: '角色信息已刷新',
|
||||
errorMessage: '刷新角色信息失败',
|
||||
scope: '[admin/tasks/:taskId/kuaishou-cloud/refresh-role-info]',
|
||||
audit: (req, data) => buildTaskAudit('task_kuaishou_cloud_role_info_refreshed', req, data),
|
||||
},
|
||||
};
|
||||
},
|
||||
}
|
||||
),
|
||||
)
|
||||
);
|
||||
|
||||
router.post(
|
||||
"/tasks/:taskId/kuaishou-cloud/refresh-role-info",
|
||||
requireAdminRoles(["admin", "operator", "support"]),
|
||||
'/tasks/:taskId/kuaishou-cloud/rebind-role',
|
||||
requireAdminRoles(['admin', 'operator']),
|
||||
createJsonHandler(
|
||||
(req) =>
|
||||
refreshAdminTaskKuaishouCloudRoleInfo(
|
||||
getTaskId(req),
|
||||
req.adminSession || null
|
||||
),
|
||||
(req) => rebindAdminTaskKuaishouCloudRole(getTaskId(req), req.adminSession || null),
|
||||
{
|
||||
successMessage: "角色信息已刷新",
|
||||
errorMessage: "刷新角色信息失败",
|
||||
scope: "[admin/tasks/:taskId/kuaishou-cloud/refresh-role-info]",
|
||||
audit: (req, data) => {
|
||||
const result = data as AdminTaskActionResponse;
|
||||
return {
|
||||
action: "task_kuaishou_cloud_role_info_refreshed",
|
||||
targetType: "task",
|
||||
targetId: String(getTaskId(req)),
|
||||
data: {
|
||||
taskId: result.task.taskId,
|
||||
taskNo: result.task.taskNo,
|
||||
status: result.task.status,
|
||||
successMessage: '角色换绑资源已准备完成',
|
||||
errorMessage: '换绑角色失败',
|
||||
scope: '[admin/tasks/:taskId/kuaishou-cloud/rebind-role]',
|
||||
audit: (req, data) => buildTaskAudit('task_kuaishou_cloud_rebind_role', req, data),
|
||||
},
|
||||
};
|
||||
},
|
||||
}
|
||||
),
|
||||
)
|
||||
);
|
||||
|
||||
router.post(
|
||||
"/tasks/:taskId/kuaishou-cloud/dispatch",
|
||||
requireAdminRoles(["admin", "operator"]),
|
||||
'/tasks/:taskId/kuaishou-cloud/dispatch',
|
||||
requireAdminRoles(['admin', 'operator']),
|
||||
createJsonHandler(
|
||||
(req) =>
|
||||
dispatchAdminTaskKuaishouCloudFulfillment(
|
||||
getTaskId(req),
|
||||
req.body as AdminTaskKuaishouCloudDispatchRouteBody,
|
||||
req.adminSession || null
|
||||
req.adminSession || null,
|
||||
),
|
||||
{
|
||||
successMessage: "已完成绑定确认并发货",
|
||||
errorMessage: "执行发货失败",
|
||||
scope: "[admin/tasks/:taskId/kuaishou-cloud/dispatch]",
|
||||
successMessage: '已完成绑定确认并发货',
|
||||
errorMessage: '执行发货失败',
|
||||
scope: '[admin/tasks/:taskId/kuaishou-cloud/dispatch]',
|
||||
audit: (req, data) => {
|
||||
const result = data as AdminTaskActionResponse;
|
||||
const body = req.body as AdminTaskKuaishouCloudDispatchRouteBody;
|
||||
const result = data as AdminTaskActionResponse
|
||||
const body = req.body as AdminTaskKuaishouCloudDispatchRouteBody
|
||||
|
||||
return {
|
||||
action: "task_kuaishou_cloud_dispatch",
|
||||
targetType: "task",
|
||||
action: 'task_kuaishou_cloud_dispatch',
|
||||
targetType: 'task',
|
||||
targetId: String(getTaskId(req)),
|
||||
data: {
|
||||
taskId: result.task.taskId,
|
||||
taskNo: result.task.taskNo,
|
||||
status: result.task.status,
|
||||
deliveryStatus: result.task.deliveryStatus,
|
||||
ticketCodeProvided: Boolean(String(body.ticketCode || "").trim()),
|
||||
},
|
||||
};
|
||||
ticketCodeProvided: Boolean(String(body.ticketCode || '').trim()),
|
||||
},
|
||||
}
|
||||
},
|
||||
},
|
||||
),
|
||||
)
|
||||
);
|
||||
|
||||
router.post(
|
||||
"/tasks/:taskId/kuaishou-cloud/return-number",
|
||||
requireAdminRoles(["admin", "operator"]),
|
||||
'/tasks/:taskId/kuaishou-cloud/return-number',
|
||||
requireAdminRoles(['admin', 'operator']),
|
||||
createJsonHandler(
|
||||
(req) =>
|
||||
returnNumberAdminTaskKuaishouCloudFulfillment(
|
||||
getTaskId(req),
|
||||
req.adminSession || null
|
||||
),
|
||||
returnNumberAdminTaskKuaishouCloudFulfillment(getTaskId(req), req.adminSession || null),
|
||||
{
|
||||
successMessage: "号码已退还",
|
||||
errorMessage: "退还号码失败",
|
||||
scope: "[admin/tasks/:taskId/kuaishou-cloud/return-number]",
|
||||
audit: (req, data) => {
|
||||
const result = data as AdminTaskActionResponse;
|
||||
successMessage: '号码已退还',
|
||||
errorMessage: '退还号码失败',
|
||||
scope: '[admin/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: "task_kuaishou_cloud_return_number",
|
||||
targetType: "task",
|
||||
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
|
||||
|
||||
@@ -4,11 +4,10 @@ import {
|
||||
createManagedAdminUser,
|
||||
getAdminUserList,
|
||||
resetManagedAdminUserPassword,
|
||||
updateManagedAdminUserInventoryGroups,
|
||||
updateManagedAdminUserRole,
|
||||
updateManagedAdminUserStatus,
|
||||
} from '../../services/admin/admin-auth-service.js'
|
||||
import { createJsonHandler, requireAdminRoles } from './shared.js'
|
||||
import { createJsonHandler, getRequiredAdminSession, requireAdminRoles } from './session.js'
|
||||
|
||||
type AdminUserMutationResult = {
|
||||
user: {
|
||||
@@ -16,7 +15,6 @@ type AdminUserMutationResult = {
|
||||
username: string
|
||||
role: string
|
||||
status?: string
|
||||
inventoryGroupCodes?: string[]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -56,7 +54,7 @@ router.post('/users', createJsonHandler(
|
||||
))
|
||||
|
||||
router.post('/users/:userId/role', createJsonHandler(
|
||||
(req) => updateManagedAdminUserRole(String(req.params.userId), req.body, req.adminSession),
|
||||
(req) => updateManagedAdminUserRole(String(req.params.userId || ''), req.body, getRequiredAdminSession(req)),
|
||||
{
|
||||
successMessage: '用户角色已更新',
|
||||
errorMessage: '更新用户角色失败',
|
||||
@@ -77,7 +75,7 @@ router.post('/users/:userId/role', createJsonHandler(
|
||||
))
|
||||
|
||||
router.post('/users/:userId/status', createJsonHandler(
|
||||
(req) => updateManagedAdminUserStatus(String(req.params.userId), req.body, req.adminSession),
|
||||
(req) => updateManagedAdminUserStatus(String(req.params.userId || ''), req.body, getRequiredAdminSession(req)),
|
||||
{
|
||||
successMessage: '用户状态已更新',
|
||||
errorMessage: '更新用户状态失败',
|
||||
@@ -97,29 +95,8 @@ router.post('/users/:userId/status', createJsonHandler(
|
||||
},
|
||||
))
|
||||
|
||||
router.post('/users/:userId/inventory-groups', createJsonHandler(
|
||||
(req) => updateManagedAdminUserInventoryGroups(String(req.params.userId), req.body, req.adminSession),
|
||||
{
|
||||
successMessage: '用户库存组已更新',
|
||||
errorMessage: '更新用户库存组失败',
|
||||
scope: '[admin/users/:userId/inventory-groups]',
|
||||
audit: (_req, data) => {
|
||||
const result = data as AdminUserMutationResult
|
||||
return {
|
||||
action: 'admin_user_inventory_groups_updated',
|
||||
targetType: 'admin_user',
|
||||
targetId: String(result.user.userId),
|
||||
data: {
|
||||
username: result.user.username,
|
||||
inventoryGroupCodes: result.user.inventoryGroupCodes,
|
||||
},
|
||||
}
|
||||
},
|
||||
},
|
||||
))
|
||||
|
||||
router.post('/users/:userId/reset-password', createJsonHandler(
|
||||
(req) => resetManagedAdminUserPassword(String(req.params.userId), req.body),
|
||||
(req) => resetManagedAdminUserPassword(String(req.params.userId || ''), req.body),
|
||||
{
|
||||
successMessage: '用户密码已重置',
|
||||
errorMessage: '重置用户密码失败',
|
||||
|
||||
@@ -1,60 +0,0 @@
|
||||
import { Router } from 'express'
|
||||
|
||||
import {
|
||||
replayAdminWebhookEvent,
|
||||
} from '../../services/admin/admin-write-service.js'
|
||||
import {
|
||||
getAdminWebhookEventDetail,
|
||||
getAdminWebhookEvents,
|
||||
} from '../../services/admin/admin-read-service.js'
|
||||
import { createJsonHandler, requireAdminRoles } from './shared.js'
|
||||
import type {
|
||||
AdminWebhookEventRouteParams,
|
||||
AdminWebhookEventRouteQuery,
|
||||
} from '../../types/admin-route-inputs.js'
|
||||
import type { AdminWebhookReplayResponse } from '../../types/admin-write-models.js'
|
||||
|
||||
const router = Router()
|
||||
|
||||
router.use('/webhook-events', requireAdminRoles(['admin', 'operator']))
|
||||
|
||||
router.get('/webhook-events', createJsonHandler(
|
||||
(req) => getAdminWebhookEvents(req.query as AdminWebhookEventRouteQuery),
|
||||
{
|
||||
successMessage: 'ok',
|
||||
errorMessage: '读取 webhook 列表失败',
|
||||
scope: '[admin/webhook-events]',
|
||||
},
|
||||
))
|
||||
|
||||
router.get('/webhook-events/:eventId', createJsonHandler(
|
||||
(req) => getAdminWebhookEventDetail((req.params as AdminWebhookEventRouteParams).eventId),
|
||||
{
|
||||
successMessage: 'ok',
|
||||
errorMessage: '读取 webhook 详情失败',
|
||||
scope: '[admin/webhook-events/:eventId]',
|
||||
},
|
||||
))
|
||||
|
||||
router.post('/webhook-events/:eventId/replay', requireAdminRoles(['admin']), createJsonHandler(
|
||||
(req) => replayAdminWebhookEvent((req.params as AdminWebhookEventRouteParams).eventId),
|
||||
{
|
||||
successMessage: 'Webhook 已重放',
|
||||
errorMessage: '重放 webhook 失败',
|
||||
scope: '[admin/webhook-events/:eventId/replay]',
|
||||
audit: (req, data) => {
|
||||
const result = data as AdminWebhookReplayResponse
|
||||
return {
|
||||
action: 'webhook_replayed',
|
||||
targetType: 'webhook_event',
|
||||
targetId: String((req.params as AdminWebhookEventRouteParams).eventId),
|
||||
data: {
|
||||
eventId: result.eventId,
|
||||
replayed: result.replayed,
|
||||
},
|
||||
}
|
||||
},
|
||||
},
|
||||
))
|
||||
|
||||
export default router
|
||||
@@ -1,94 +1,86 @@
|
||||
import { Router } from 'express'
|
||||
|
||||
import {
|
||||
closeClaimSession,
|
||||
confirmClaimRole,
|
||||
createClaimSession,
|
||||
getClaimDetail,
|
||||
getClaimScreenshotPath,
|
||||
getClaimSessionSummary,
|
||||
reloadClaimSession,
|
||||
redeemClaimTask,
|
||||
} from '../services/claim/claim-session-service.js'
|
||||
import { createRateLimitMiddleware, getParamRateLimitKey } from '../middleware/rate-limit.js'
|
||||
import {
|
||||
confirmKuaishouCloudClaimRole,
|
||||
getKuaishouCloudClaimDetail,
|
||||
getKuaishouCloudClaimGuideAssetPath,
|
||||
rebindKuaishouCloudClaimRole,
|
||||
redeemKuaishouCloudClaim,
|
||||
verifyKuaishouCloudClaimTicket,
|
||||
} from '../services/claim/kuaishou-cloud-claim-service.js'
|
||||
import {
|
||||
buildNotFoundPayload,
|
||||
buildSuccessPayload,
|
||||
createRouteFileHandler,
|
||||
createRouteHandler,
|
||||
sendRouteError,
|
||||
} from '../utils/http.js'
|
||||
import { buildNotFoundPayload, createRouteFileHandler, createRouteHandler } from '../utils/http.js'
|
||||
|
||||
const router = Router()
|
||||
|
||||
router.get('/:token', createRouteHandler(
|
||||
(req) => getClaimDetail(req.params.token),
|
||||
{ errorMessage: '查询领取详情失败', scope: '[claims/:token]' },
|
||||
))
|
||||
|
||||
router.post('/:token/session', createRouteHandler(
|
||||
(req) => createClaimSession(req.params.token, req.body),
|
||||
{ successMessage: '浏览器会话已创建', errorMessage: '创建领取会话失败', scope: '[claims/:token/session]' },
|
||||
))
|
||||
|
||||
router.get('/:token/session/summary', createRouteHandler(
|
||||
(req) => getClaimSessionSummary(req.params.token),
|
||||
{ errorMessage: '查询领取会话摘要失败', scope: '[claims/:token/session/summary]' },
|
||||
))
|
||||
|
||||
router.post('/:token/session/refresh', async (req, res) => {
|
||||
try {
|
||||
const data = await reloadClaimSession(req.params.token)
|
||||
res.json(buildSuccessPayload(data, data.session ? (data.session.notice || '后端页面已刷新') : '领取会话已重置'))
|
||||
} catch (error) {
|
||||
sendRouteError(res, error, '刷新领取会话失败', '[claims/:token/session/refresh]')
|
||||
}
|
||||
const claimReadRateLimit = createRateLimitMiddleware({
|
||||
scope: 'claim:read',
|
||||
windowMs: 60_000,
|
||||
max: 120,
|
||||
key: getParamRateLimitKey('token'),
|
||||
})
|
||||
const claimWriteRateLimit = createRateLimitMiddleware({
|
||||
scope: 'claim:write',
|
||||
windowMs: 60_000,
|
||||
max: 30,
|
||||
key: getParamRateLimitKey('token'),
|
||||
})
|
||||
|
||||
router.delete('/:token/session', createRouteHandler(
|
||||
(req) => closeClaimSession(req.params.token),
|
||||
{ successMessage: '领取会话已关闭', errorMessage: '关闭领取会话失败', scope: '[claims/:token/session]' },
|
||||
))
|
||||
router.get(
|
||||
'/:token',
|
||||
claimReadRateLimit,
|
||||
createRouteHandler((req) => getKuaishouCloudClaimDetail(req.params.token), {
|
||||
errorMessage: '查询快手领取详情失败',
|
||||
scope: '[claims/:token]',
|
||||
}),
|
||||
)
|
||||
|
||||
router.post('/:token/confirm-role', createRouteHandler(
|
||||
(req) => confirmClaimRole(req.params.token),
|
||||
{ successMessage: '角色已确认', errorMessage: '确认角色失败', scope: '[claims/:token/confirm-role]' },
|
||||
))
|
||||
router.post(
|
||||
'/:token/kuaishou-cloud/verify-ticket',
|
||||
claimWriteRateLimit,
|
||||
createRouteHandler((req) => verifyKuaishouCloudClaimTicket(req.params.token, req.body), {
|
||||
successMessage: '核销码提交并核销成功',
|
||||
errorMessage: '提交核销码失败',
|
||||
scope: '[claims/:token/kuaishou-cloud/verify-ticket]',
|
||||
}),
|
||||
)
|
||||
|
||||
router.post('/:token/redeem', createRouteHandler(
|
||||
(req) => redeemClaimTask(req.params.token),
|
||||
{ successMessage: '兑换完成', errorMessage: '执行领取兑换失败', scope: '[claims/:token/redeem]' },
|
||||
))
|
||||
router.post(
|
||||
'/:token/kuaishou-cloud/confirm-role',
|
||||
claimWriteRateLimit,
|
||||
createRouteHandler((req) => confirmKuaishouCloudClaimRole(req.params.token), {
|
||||
successMessage: '角色已确认',
|
||||
errorMessage: '确认角色失败',
|
||||
scope: '[claims/:token/kuaishou-cloud/confirm-role]',
|
||||
}),
|
||||
)
|
||||
|
||||
router.post('/:token/kuaishou-cloud/verify-ticket', createRouteHandler(
|
||||
(req) => verifyKuaishouCloudClaimTicket(req.params.token, req.body),
|
||||
{ successMessage: '核销码校验成功', errorMessage: '校验核销码失败', scope: '[claims/:token/kuaishou-cloud/verify-ticket]' },
|
||||
))
|
||||
router.post(
|
||||
'/:token/kuaishou-cloud/rebind-role',
|
||||
claimWriteRateLimit,
|
||||
createRouteHandler((req) => rebindKuaishouCloudClaimRole(req.params.token), {
|
||||
successMessage: '角色换绑资源已准备完成',
|
||||
errorMessage: '换绑角色失败',
|
||||
scope: '[claims/:token/kuaishou-cloud/rebind-role]',
|
||||
}),
|
||||
)
|
||||
|
||||
router.post('/:token/kuaishou-cloud/confirm-role', createRouteHandler(
|
||||
(req) => confirmKuaishouCloudClaimRole(req.params.token),
|
||||
{ successMessage: '角色已确认', errorMessage: '确认角色失败', scope: '[claims/:token/kuaishou-cloud/confirm-role]' },
|
||||
))
|
||||
router.post(
|
||||
'/:token/kuaishou-cloud/redeem',
|
||||
claimWriteRateLimit,
|
||||
createRouteHandler((req) => redeemKuaishouCloudClaim(req.params.token), {
|
||||
successMessage: '兑换请求已提交',
|
||||
errorMessage: '兑换失败',
|
||||
scope: '[claims/:token/kuaishou-cloud/redeem]',
|
||||
}),
|
||||
)
|
||||
|
||||
router.post('/:token/kuaishou-cloud/redeem', createRouteHandler(
|
||||
(req) => redeemKuaishouCloudClaim(req.params.token),
|
||||
{ successMessage: '兑换请求已提交', errorMessage: '兑换失败', scope: '[claims/:token/kuaishou-cloud/redeem]' },
|
||||
))
|
||||
|
||||
router.get('/:token/screenshot', createRouteFileHandler(
|
||||
(req) => getClaimScreenshotPath(req.params.token),
|
||||
{ errorMessage: '读取领取截图失败', scope: '[claims/:token/screenshot]' },
|
||||
))
|
||||
|
||||
router.get('/assets/kuaishou-cloud/:filename', createRouteFileHandler(
|
||||
(req) => getKuaishouCloudClaimGuideAssetPath(req.params.filename),
|
||||
{ errorMessage: '读取指引图片失败', scope: '[claims/assets/kuaishou-cloud/:filename]' },
|
||||
))
|
||||
router.get(
|
||||
'/assets/kuaishou-cloud/:filename',
|
||||
createRouteFileHandler((req) => getKuaishouCloudClaimGuideAssetPath(req.params.filename), {
|
||||
errorMessage: '读取指引图片失败',
|
||||
scope: '[claims/assets/kuaishou-cloud/:filename]',
|
||||
}),
|
||||
)
|
||||
|
||||
router.use((req, res) => {
|
||||
res.status(404).json(buildNotFoundPayload(req))
|
||||
|
||||
@@ -1,17 +1,34 @@
|
||||
import { Router } from 'express'
|
||||
|
||||
import { createRateLimitMiddleware } from '../middleware/rate-limit.js'
|
||||
import { createOpen91Order } from '../services/open-91/order-create-service.js'
|
||||
import { queryOpen91Order } from '../services/open-91/order-query-service.js'
|
||||
import { buildOpen91ErrorResponse } from '../services/open-91/shared.js'
|
||||
import { createRequestId, logWebhook } from '../utils/logger.js'
|
||||
import { buildOpen91ErrorResponse } from '../services/open-91/response.js'
|
||||
import { createRequestId, logIntegration } from '../utils/logger.js'
|
||||
|
||||
const router = Router()
|
||||
const open91RateLimit = createRateLimitMiddleware({
|
||||
scope: 'open91',
|
||||
windowMs: 60_000,
|
||||
max: 120,
|
||||
onLimit: (_req, res) => {
|
||||
res.status(200).json(buildOpen91ErrorResponse('请求过于频繁,请稍后再试', 429))
|
||||
},
|
||||
})
|
||||
|
||||
router.post('/orders/create', async (req, res) => {
|
||||
function resolveErrorStatusCode(error: unknown): number {
|
||||
if (!error || typeof error !== 'object' || !('statusCode' in error)) {
|
||||
return 500
|
||||
}
|
||||
|
||||
return Number(error.statusCode || 500)
|
||||
}
|
||||
|
||||
router.post('/orders/create', open91RateLimit, async (req, res) => {
|
||||
const requestId = createRequestId('91')
|
||||
const startedAt = Date.now()
|
||||
|
||||
logWebhook('[open-91/create]', '收到 91卡券异步下单请求', {
|
||||
logIntegration('[open-91/create]', '收到 91卡券异步下单请求', {
|
||||
requestId,
|
||||
method: req.method,
|
||||
originalUrl: req.originalUrl,
|
||||
@@ -21,7 +38,7 @@ router.post('/orders/create', async (req, res) => {
|
||||
|
||||
try {
|
||||
const result = await createOpen91Order(req.body, { requestId })
|
||||
logWebhook('[open-91/create]', '91卡券异步下单处理完成', {
|
||||
logIntegration('[open-91/create]', '91卡券异步下单处理完成', {
|
||||
requestId,
|
||||
durationMs: Date.now() - startedAt,
|
||||
result,
|
||||
@@ -29,8 +46,8 @@ router.post('/orders/create', async (req, res) => {
|
||||
res.status(200).json(result)
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : '系统错误'
|
||||
const code = Number(error?.statusCode || 500) >= 500 ? 500 : 400
|
||||
logWebhook('[open-91/create]', '91卡券异步下单处理失败', {
|
||||
const code = resolveErrorStatusCode(error) >= 500 ? 500 : 400
|
||||
logIntegration('[open-91/create]', '91卡券异步下单处理失败', {
|
||||
requestId,
|
||||
durationMs: Date.now() - startedAt,
|
||||
error,
|
||||
@@ -39,11 +56,11 @@ router.post('/orders/create', async (req, res) => {
|
||||
}
|
||||
})
|
||||
|
||||
router.post('/orders/query', async (req, res) => {
|
||||
router.post('/orders/query', open91RateLimit, async (req, res) => {
|
||||
const requestId = createRequestId('91')
|
||||
const startedAt = Date.now()
|
||||
|
||||
logWebhook('[open-91/query]', '收到 91卡券订单查询请求', {
|
||||
logIntegration('[open-91/query]', '收到 91卡券订单查询请求', {
|
||||
requestId,
|
||||
method: req.method,
|
||||
originalUrl: req.originalUrl,
|
||||
@@ -53,7 +70,7 @@ router.post('/orders/query', async (req, res) => {
|
||||
|
||||
try {
|
||||
const result = await queryOpen91Order(req.body, { requestId })
|
||||
logWebhook('[open-91/query]', '91卡券订单查询处理完成', {
|
||||
logIntegration('[open-91/query]', '91卡券订单查询处理完成', {
|
||||
requestId,
|
||||
durationMs: Date.now() - startedAt,
|
||||
result,
|
||||
@@ -61,8 +78,8 @@ router.post('/orders/query', async (req, res) => {
|
||||
res.status(200).json(result)
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : '系统错误'
|
||||
const code = Number(error?.statusCode || 500) >= 500 ? 500 : 400
|
||||
logWebhook('[open-91/query]', '91卡券订单查询处理失败', {
|
||||
const code = resolveErrorStatusCode(error) >= 500 ? 500 : 400
|
||||
logIntegration('[open-91/query]', '91卡券订单查询处理失败', {
|
||||
requestId,
|
||||
durationMs: Date.now() - startedAt,
|
||||
error,
|
||||
|
||||
@@ -1,77 +0,0 @@
|
||||
import { Router } from 'express'
|
||||
|
||||
import {
|
||||
closeTencentBrowserSession,
|
||||
createTencentBrowserSession,
|
||||
getTencentBrowserSession,
|
||||
getTencentBrowserSessionSummary,
|
||||
getTencentBrowserSessionScreenshotPath,
|
||||
reloadTencentBrowserSession,
|
||||
redeemTencentBrowserSession,
|
||||
} from '../services/session/session.js'
|
||||
import {
|
||||
buildNotFoundPayload,
|
||||
buildSuccessPayload,
|
||||
createRouteFileHandler,
|
||||
createRouteHandler,
|
||||
sendRouteError,
|
||||
} from '../utils/http.js'
|
||||
|
||||
const router = Router()
|
||||
|
||||
router.post('/browser/session', createRouteHandler(
|
||||
(req) => createTencentBrowserSession(req.body),
|
||||
{ successMessage: '浏览器会话已创建', errorMessage: '创建浏览器会话失败', scope: '[browser/session]' },
|
||||
))
|
||||
|
||||
router.get('/browser/session/:sessionId', async (req, res) => {
|
||||
try {
|
||||
const data = await getTencentBrowserSession(req.params.sessionId)
|
||||
res.json(buildSuccessPayload(data, data.notice || 'ok'))
|
||||
} catch (error) {
|
||||
sendRouteError(res, error, '查询浏览器会话失败', '[browser/session/:sessionId]')
|
||||
}
|
||||
})
|
||||
|
||||
router.get('/browser/session/:sessionId/summary', async (req, res) => {
|
||||
try {
|
||||
const data = await getTencentBrowserSessionSummary(req.params.sessionId)
|
||||
res.json(buildSuccessPayload(data, data.notice || 'ok'))
|
||||
} catch (error) {
|
||||
sendRouteError(res, error, '查询浏览器会话摘要失败', '[browser/session/:sessionId/summary]')
|
||||
}
|
||||
})
|
||||
|
||||
router.post('/browser/session/:sessionId/refresh', async (req, res) => {
|
||||
try {
|
||||
const data = await reloadTencentBrowserSession(req.params.sessionId)
|
||||
res.json(buildSuccessPayload(data, data.notice || '后端页面已刷新'))
|
||||
} catch (error) {
|
||||
sendRouteError(res, error, '刷新后端页面失败', '[browser/session/:sessionId/refresh]')
|
||||
}
|
||||
})
|
||||
|
||||
router.post('/browser/session/:sessionId/redeem', async (req, res) => {
|
||||
try {
|
||||
const data = await redeemTencentBrowserSession(req.params.sessionId, req.body)
|
||||
res.json(buildSuccessPayload(data, data.notice || '兑换完成'))
|
||||
} catch (error) {
|
||||
sendRouteError(res, error, '浏览器会话兑换失败', '[browser/session/:sessionId/redeem]')
|
||||
}
|
||||
})
|
||||
|
||||
router.get('/browser/session/:sessionId/screenshot', createRouteFileHandler(
|
||||
(req) => getTencentBrowserSessionScreenshotPath(req.params.sessionId),
|
||||
{ errorMessage: '读取兑换截图失败', scope: '[browser/session/:sessionId/screenshot]' },
|
||||
))
|
||||
|
||||
router.delete('/browser/session/:sessionId', createRouteHandler(
|
||||
(req) => closeTencentBrowserSession(req.params.sessionId),
|
||||
{ successMessage: '浏览器会话已关闭', errorMessage: '关闭浏览器会话失败', scope: '[browser/session/:sessionId]' },
|
||||
))
|
||||
|
||||
router.use((req, res) => {
|
||||
res.status(404).json(buildNotFoundPayload(req))
|
||||
})
|
||||
|
||||
export default router
|
||||
@@ -1,57 +0,0 @@
|
||||
import { Router } from 'express'
|
||||
|
||||
import { processAgisoTradeWebhook } from '../services/order/webhook-service.js'
|
||||
import { createRequestId, logWebhook } from '../utils/logger.js'
|
||||
import { buildNotFoundPayload, buildSuccessPayload, sendRouteError } from '../utils/http.js'
|
||||
|
||||
const router = Router()
|
||||
|
||||
router.post('/agiso/trade', async (req, res) => {
|
||||
const requestId = createRequestId('wh')
|
||||
const startedAt = Date.now()
|
||||
|
||||
logWebhook('[webhooks/agiso/trade]', '收到 Agiso webhook 请求', {
|
||||
requestId,
|
||||
method: req.method,
|
||||
originalUrl: req.originalUrl,
|
||||
ip: req.ip,
|
||||
headers: req.headers,
|
||||
query: req.query,
|
||||
body: req.body,
|
||||
})
|
||||
|
||||
try {
|
||||
const data = await processAgisoTradeWebhook({
|
||||
requestId,
|
||||
headers: req.headers,
|
||||
query: req.query,
|
||||
body: req.body,
|
||||
})
|
||||
|
||||
logWebhook('[webhooks/agiso/trade]', 'Agiso webhook 处理完成', {
|
||||
requestId,
|
||||
durationMs: Date.now() - startedAt,
|
||||
result: data,
|
||||
})
|
||||
|
||||
res.json(buildSuccessPayload(data, 'success'))
|
||||
} catch (error) {
|
||||
logWebhook(
|
||||
'[webhooks/agiso/trade]',
|
||||
'Agiso webhook 处理失败',
|
||||
{
|
||||
requestId,
|
||||
durationMs: Date.now() - startedAt,
|
||||
error,
|
||||
},
|
||||
{ level: 'error' },
|
||||
)
|
||||
sendRouteError(res, error, '处理 Agiso 订单通知失败', '[webhooks/agiso/trade]')
|
||||
}
|
||||
})
|
||||
|
||||
router.use((req, res) => {
|
||||
res.status(404).json(buildNotFoundPayload(req))
|
||||
})
|
||||
|
||||
export default router
|
||||
@@ -7,7 +7,6 @@ import {
|
||||
getAdminUserById,
|
||||
getAdminUserByUsername,
|
||||
listAdminUsers,
|
||||
replaceAdminUserInventoryGroupBindings,
|
||||
updateAdminUser,
|
||||
} from '../../repositories/admin-user-repo.js'
|
||||
import { addHours, nowIso } from '../../utils/time.js'
|
||||
@@ -23,8 +22,8 @@ export type AdminSession = {
|
||||
userId: number
|
||||
username: string
|
||||
role: AdminRole
|
||||
allowedInventoryGroups: string[]
|
||||
expiresAt: string
|
||||
sessionVersion: number
|
||||
}
|
||||
|
||||
type AdminRole = 'admin' | 'operator' | 'support'
|
||||
@@ -139,13 +138,22 @@ export async function verifyAdminSessionToken(token: unknown): Promise<AdminSess
|
||||
})
|
||||
}
|
||||
|
||||
const tokenSessionVersion = Number(payload?.ver || 0)
|
||||
const currentSessionVersion = normalizeAdminSessionVersion(user.session_version)
|
||||
if (tokenSessionVersion !== currentSessionVersion) {
|
||||
throw createHttpError('后台登录态已失效,请重新登录', {
|
||||
statusCode: 401,
|
||||
errorCode: 'admin_auth_stale',
|
||||
})
|
||||
}
|
||||
|
||||
return {
|
||||
sessionId: String(payload?.sid || '').trim(),
|
||||
userId: Number(user.id),
|
||||
username: String(user.username || ''),
|
||||
role: normalizeAdminRole(user.role),
|
||||
allowedInventoryGroups: normalizeInventoryGroupCodes(user.inventory_group_codes),
|
||||
expiresAt,
|
||||
sessionVersion: currentSessionVersion,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -159,13 +167,12 @@ export async function getAdminSessionSummary(token: unknown): Promise<JsonObject
|
||||
userId: session.userId,
|
||||
username: session.username,
|
||||
role: session.role,
|
||||
inventoryGroupCodes: session.allowedInventoryGroups,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
export function requireAdminRole(session: { role?: string }, allowedRoles: string[]): void {
|
||||
if (allowedRoles.includes(session.role)) {
|
||||
export function requireAdminRole(session: { role?: string } | null | undefined, allowedRoles: string[]): void {
|
||||
if (session && allowedRoles.includes(session.role || '')) {
|
||||
return
|
||||
}
|
||||
|
||||
@@ -229,28 +236,18 @@ export async function createManagedAdminUser(payload: JsonObject = {}): Promise<
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
})
|
||||
if (!created) {
|
||||
throw createHttpError('后台用户创建失败', {
|
||||
statusCode: 500,
|
||||
errorCode: 'admin_user_create_failed',
|
||||
})
|
||||
}
|
||||
|
||||
return {
|
||||
user: mapAdminUser(created),
|
||||
}
|
||||
}
|
||||
|
||||
export async function updateManagedAdminUserInventoryGroups(
|
||||
userId: number | string,
|
||||
payload: JsonObject = {},
|
||||
session: AdminSession,
|
||||
): Promise<JsonObject> {
|
||||
const user = await getRequiredAdminUser(userId)
|
||||
const inventoryGroupCodes = normalizeInventoryGroupCodes(payload.inventoryGroupCodes)
|
||||
|
||||
await ensureAdminUserChangeAllowed(user, {}, session)
|
||||
const updated = await replaceAdminUserInventoryGroupBindings(user.id, inventoryGroupCodes, nowIso())
|
||||
|
||||
return {
|
||||
user: mapAdminUser(updated),
|
||||
}
|
||||
}
|
||||
|
||||
export async function updateManagedAdminUserRole(
|
||||
userId: number | string,
|
||||
payload: JsonObject = {},
|
||||
@@ -268,8 +265,15 @@ export async function updateManagedAdminUserRole(
|
||||
await ensureAdminUserChangeAllowed(user, { nextRole: role }, session)
|
||||
const updated = await updateAdminUser(user.id, {
|
||||
role,
|
||||
session_version: nextAdminSessionVersion(user),
|
||||
updated_at: nowIso(),
|
||||
})
|
||||
if (!updated) {
|
||||
throw createHttpError('后台用户更新失败', {
|
||||
statusCode: 500,
|
||||
errorCode: 'admin_user_update_failed',
|
||||
})
|
||||
}
|
||||
|
||||
return {
|
||||
user: mapAdminUser(updated),
|
||||
@@ -293,8 +297,15 @@ export async function updateManagedAdminUserStatus(
|
||||
await ensureAdminUserChangeAllowed(user, { nextStatus: status }, session)
|
||||
const updated = await updateAdminUser(user.id, {
|
||||
status,
|
||||
session_version: nextAdminSessionVersion(user),
|
||||
updated_at: nowIso(),
|
||||
})
|
||||
if (!updated) {
|
||||
throw createHttpError('后台用户更新失败', {
|
||||
statusCode: 500,
|
||||
errorCode: 'admin_user_update_failed',
|
||||
})
|
||||
}
|
||||
|
||||
return {
|
||||
user: mapAdminUser(updated),
|
||||
@@ -315,8 +326,15 @@ export async function resetManagedAdminUserPassword(userId: number | string, pay
|
||||
validatePassword(password)
|
||||
const updated = await updateAdminUser(user.id, {
|
||||
password_hash: hashAdminPassword(password),
|
||||
session_version: nextAdminSessionVersion(user),
|
||||
updated_at: nowIso(),
|
||||
})
|
||||
if (!updated) {
|
||||
throw createHttpError('后台用户更新失败', {
|
||||
statusCode: 500,
|
||||
errorCode: 'admin_user_update_failed',
|
||||
})
|
||||
}
|
||||
|
||||
return {
|
||||
user: mapAdminUser(updated),
|
||||
@@ -345,6 +363,7 @@ function createAdminSession(user: AdminUserRow): JsonObject {
|
||||
uid: Number(user.id),
|
||||
usr: String(user.username || ''),
|
||||
role: normalizeAdminRole(user.role),
|
||||
ver: normalizeAdminSessionVersion(user.session_version),
|
||||
iat: issuedAt,
|
||||
exp: expiresAt,
|
||||
}
|
||||
@@ -358,7 +377,6 @@ function createAdminSession(user: AdminUserRow): JsonObject {
|
||||
userId: Number(user.id),
|
||||
username: String(user.username || ''),
|
||||
role: normalizeAdminRole(user.role),
|
||||
inventoryGroupCodes: normalizeInventoryGroupCodes(user.inventory_group_codes),
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -451,6 +469,15 @@ function validatePassword(password: string): void {
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeAdminSessionVersion(value: unknown): number {
|
||||
const parsed = Number(value)
|
||||
return Number.isInteger(parsed) && parsed > 0 ? parsed : 1
|
||||
}
|
||||
|
||||
function nextAdminSessionVersion(user: AdminUserRow): number {
|
||||
return normalizeAdminSessionVersion(user.session_version) + 1
|
||||
}
|
||||
|
||||
async function getRequiredAdminUser(userId: number | string): Promise<AdminUserRow> {
|
||||
const user = await getAdminUserById(Number(userId))
|
||||
|
||||
@@ -487,20 +514,13 @@ async function ensureAdminUserChangeAllowed(
|
||||
}
|
||||
}
|
||||
|
||||
function mapAdminUser(user: AdminUserRow | null): JsonObject {
|
||||
function mapAdminUser(user: AdminUserRow): JsonObject {
|
||||
return {
|
||||
userId: Number(user.id),
|
||||
username: String(user.username || ''),
|
||||
role: normalizeAdminRole(user.role),
|
||||
status: normalizeAdminUserStatus(user.status),
|
||||
inventoryGroupCodes: normalizeInventoryGroupCodes(user.inventory_group_codes),
|
||||
createdAt: user.created_at,
|
||||
updatedAt: user.updated_at,
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeInventoryGroupCodes(values: unknown): string[] {
|
||||
return Array.from(new Set((Array.isArray(values) ? values : [])
|
||||
.map((value) => String(value || '').trim())
|
||||
.filter(Boolean)))
|
||||
}
|
||||
|
||||
@@ -1,10 +1,26 @@
|
||||
import { query } from '../../db/client.js'
|
||||
import { TASK_STATUS } from '../../domain/task-status.js'
|
||||
import { nowIso } from '../../utils/time.js'
|
||||
|
||||
type JsonObject = Record<string, any>
|
||||
|
||||
export async function getAdminDashboardSummary() {
|
||||
const todayPrefix = nowIso().slice(0, 10)
|
||||
const paidPendingClaimStatuses = [
|
||||
TASK_STATUS.PAID,
|
||||
TASK_STATUS.LINK_GENERATED,
|
||||
TASK_STATUS.PENDING_BINDING_PREPARE,
|
||||
TASK_STATUS.WAITING_BINDING,
|
||||
]
|
||||
const claimingStatuses = [
|
||||
TASK_STATUS.CLAIMED,
|
||||
TASK_STATUS.ROLE_CONFIRMED,
|
||||
TASK_STATUS.REDEEMING,
|
||||
]
|
||||
const abnormalStatuses = [
|
||||
TASK_STATUS.RETRY_PENDING,
|
||||
TASK_STATUS.MANUAL_REVIEW,
|
||||
]
|
||||
const result = await query(
|
||||
`
|
||||
SELECT
|
||||
@@ -12,32 +28,32 @@ export async function getAdminDashboardSummary() {
|
||||
(
|
||||
SELECT COUNT(*)::int
|
||||
FROM fulfillment_tasks
|
||||
WHERE task_status IN ('paid', 'link_generated')
|
||||
OR (inventory_status = 'reserved' AND task_status NOT IN ('claimed', 'role_confirmed', 'redeeming', 'redeemed', 'closed', 'expired'))
|
||||
WHERE task_status = ANY($2::text[])
|
||||
) AS paid_pending_claim,
|
||||
(
|
||||
SELECT COUNT(*)::int
|
||||
FROM fulfillment_tasks
|
||||
WHERE task_status IN ('claimed', 'role_confirmed', 'redeeming')
|
||||
WHERE task_status = ANY($3::text[])
|
||||
) AS claiming_tasks,
|
||||
(
|
||||
SELECT COUNT(*)::int
|
||||
FROM fulfillment_tasks
|
||||
WHERE task_status = 'redeemed'
|
||||
WHERE task_status = $4
|
||||
AND to_char(updated_at AT TIME ZONE 'Asia/Shanghai', 'YYYY-MM-DD') = $1
|
||||
) AS redeemed_today,
|
||||
(
|
||||
SELECT COUNT(*)::int
|
||||
FROM fulfillment_tasks
|
||||
WHERE task_status IN ('retry_pending', 'manual_review', 'waiting_inventory')
|
||||
) AS abnormal_tasks,
|
||||
(
|
||||
SELECT COUNT(DISTINCT sku_code)::int
|
||||
FROM inventory_items
|
||||
WHERE status = 'available'
|
||||
) AS sku_with_inventory
|
||||
WHERE task_status = ANY($5::text[])
|
||||
) AS abnormal_tasks
|
||||
`,
|
||||
[todayPrefix],
|
||||
[
|
||||
todayPrefix,
|
||||
paidPendingClaimStatuses,
|
||||
claimingStatuses,
|
||||
TASK_STATUS.REDEEMED,
|
||||
abnormalStatuses,
|
||||
],
|
||||
)
|
||||
const summary: JsonObject = result.rows[0] || {}
|
||||
|
||||
@@ -47,6 +63,5 @@ export async function getAdminDashboardSummary() {
|
||||
claimingTasks: Number(summary?.claiming_tasks || 0),
|
||||
redeemedToday: Number(summary?.redeemed_today || 0),
|
||||
abnormalTasks: Number(summary?.abnormal_tasks || 0),
|
||||
skuWithInventory: Number(summary?.sku_with_inventory || 0),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,46 +0,0 @@
|
||||
import { getInventoryItemById } from '../../repositories/inventory-repo.js'
|
||||
import { getOrderById } from '../../repositories/order-repo.js'
|
||||
import { getTaskById } from '../../repositories/task-repo.js'
|
||||
import { createHttpError } from '../../utils/http.js'
|
||||
import { mapAdminTaskSummary } from './admin-task-read-helpers.js'
|
||||
|
||||
import type { AdminInventoryItemListItem } from '../../types/admin-read-models.js'
|
||||
import type { InventoryItemRow } from '../../types/repository-rows.js'
|
||||
|
||||
export async function mapAdminInventoryListItem(item: InventoryItemRow): Promise<AdminInventoryItemListItem> {
|
||||
const task = item.reserved_by_task_id ? await getTaskById(item.reserved_by_task_id) : null
|
||||
const order = task?.order_id ? await getOrderById(task.order_id) : null
|
||||
const taskSummary = task ? mapAdminTaskSummary(task) : null
|
||||
|
||||
return {
|
||||
inventoryItemId: item.id,
|
||||
skuCode: item.sku_code,
|
||||
batchNo: item.batch_no,
|
||||
credentialType: item.credential_type || 'tencent_code',
|
||||
inventoryGroupCode: String(item.inventory_group_code || '').trim(),
|
||||
displayValue: item.display_value,
|
||||
status: item.status,
|
||||
reservedByTaskId: item.reserved_by_task_id,
|
||||
reservedByTaskNo: task?.task_no || '',
|
||||
platformOrderId: order?.platform_order_id || '',
|
||||
systemBindingStatus: item.status === 'consumed' ? 'system_bound' : (taskSummary?.systemBindingStatus || 'pending_binding'),
|
||||
userBindingStatus: item.status === 'consumed' ? 'binding_completed' : (taskSummary?.userBindingStatus || 'not_started'),
|
||||
invalidReason: item.invalid_reason || '',
|
||||
deliveredAt: item.delivered_at,
|
||||
createdAt: item.created_at,
|
||||
updatedAt: item.updated_at,
|
||||
}
|
||||
}
|
||||
|
||||
export async function getRequiredInventoryItem(inventoryItemId: number | string): Promise<InventoryItemRow> {
|
||||
const inventoryItem = await getInventoryItemById(Number(inventoryItemId))
|
||||
|
||||
if (!inventoryItem) {
|
||||
throw createHttpError('库存项不存在', {
|
||||
statusCode: 404,
|
||||
errorCode: 'admin_inventory_not_found',
|
||||
})
|
||||
}
|
||||
|
||||
return inventoryItem
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -1,55 +0,0 @@
|
||||
import { listMessageDeliveries } from '../../repositories/message-delivery-repo.js'
|
||||
import { normalizeDateQuery, normalizePage, normalizePageSize, safeParseJson } from './admin-query-utils.js'
|
||||
|
||||
import type { AdminMessageDeliveryListQueryInput } from '../../types/admin-read-inputs.js'
|
||||
|
||||
type JsonObject = Record<string, any>
|
||||
|
||||
export async function getAdminMessageDeliveries(query: AdminMessageDeliveryListQueryInput = {}) {
|
||||
const page = normalizePage(query.page)
|
||||
const pageSize = normalizePageSize(query.pageSize)
|
||||
const { items, total } = await listMessageDeliveries({
|
||||
page,
|
||||
pageSize,
|
||||
provider: String(query.provider || '').trim(),
|
||||
platform: String(query.platform || '').trim(),
|
||||
status: String(query.status || '').trim(),
|
||||
shopId: String(query.shopId || '').trim(),
|
||||
platformOrderId: String(query.platformOrderId || '').trim(),
|
||||
taskNo: String(query.taskNo || '').trim(),
|
||||
dateFrom: normalizeDateQuery(query.dateFrom),
|
||||
dateTo: normalizeDateQuery(query.dateTo, true),
|
||||
})
|
||||
|
||||
return {
|
||||
items: items.map(mapAdminMessageDeliveryListItem),
|
||||
pagination: { page, pageSize, total },
|
||||
}
|
||||
}
|
||||
|
||||
function mapAdminMessageDeliveryListItem(item: JsonObject) {
|
||||
return {
|
||||
deliveryId: Number(item.id),
|
||||
provider: String(item.provider || '').trim(),
|
||||
platform: String(item.platform || '').trim(),
|
||||
shopId: String(item.shop_id || '').trim(),
|
||||
shopName: String(item.shop_name || '').trim(),
|
||||
channel: String(item.channel || '').trim(),
|
||||
orderId: item.order_id ? Number(item.order_id) : null,
|
||||
taskId: item.task_id ? Number(item.task_id) : null,
|
||||
taskNo: String(item.task_no || '').trim(),
|
||||
taskStatus: String(item.task_status || '').trim(),
|
||||
platformOrderId: String(item.platform_order_id || '').trim(),
|
||||
recipientKey: String(item.recipient_key || '').trim(),
|
||||
messageContent: String(item.message_content || '').trim(),
|
||||
claimUrl: String(item.claim_url || '').trim(),
|
||||
status: String(item.status || '').trim(),
|
||||
requestUrl: String(item.request_url || '').trim(),
|
||||
responseStatus: Number(item.response_status || 0),
|
||||
response: safeParseJson(item.response_json),
|
||||
errorMessage: String(item.error_message || '').trim(),
|
||||
sentAt: item.sent_at || null,
|
||||
createdAt: item.created_at,
|
||||
updatedAt: item.updated_at,
|
||||
}
|
||||
}
|
||||
@@ -2,23 +2,22 @@ import { listOrderItemsByOrderId } from '../../repositories/order-item-repo.js'
|
||||
import { listTasksByOrderId } from '../../repositories/task-repo.js'
|
||||
import { formatFenToAmount, normalizeFen } from '../../utils/money.js'
|
||||
import { resolveDisplayShopName, resolveOrderItemTitle } from './admin-read-shared-helpers.js'
|
||||
import { buildOrderBindingSummary, getTaskBindingSummaryMap } from './admin-task-read-helpers.js'
|
||||
import { buildOrderFulfillmentProgress } from './admin-task-read-helpers.js'
|
||||
|
||||
import type { AdminOrderListItem } from '../../types/admin-read-models.js'
|
||||
import type { OrderItemRow, OrderListRow } from '../../types/repository-rows.js'
|
||||
import type { AdminOrderListItem } from '../../types/admin/read-models.js'
|
||||
import type { OrderItemRow, OrderListRow } from '../../types/repository/rows.js'
|
||||
|
||||
export async function mapAdminOrderListItem(item: OrderListRow): Promise<AdminOrderListItem> {
|
||||
const [tasks, orderItems] = await Promise.all([
|
||||
listTasksByOrderId(item.id),
|
||||
listOrderItemsByOrderId(item.id),
|
||||
])
|
||||
const taskBindingSummaryMap = await getTaskBindingSummaryMap(tasks.map((task) => task.id))
|
||||
const bindingSummary = buildOrderBindingSummary(tasks, taskBindingSummaryMap)
|
||||
const fulfillmentProgress = buildOrderFulfillmentProgress(tasks)
|
||||
const itemSummary = summarizeOrderItems(orderItems)
|
||||
|
||||
return {
|
||||
orderId: item.id,
|
||||
provider: item.provider || 'agiso',
|
||||
provider: item.provider || '',
|
||||
platform: item.platform,
|
||||
shopId: item.shop_id || '',
|
||||
shopName: resolveDisplayShopName(item.provider, item.shop_id, item.shop_name),
|
||||
@@ -38,14 +37,10 @@ export async function mapAdminOrderListItem(item: OrderListRow): Promise<AdminOr
|
||||
totalQuantity: orderItems.reduce((sum, orderItem) => sum + Math.max(1, Number(orderItem.quantity || 1)), 0),
|
||||
itemSummary,
|
||||
taskCount: Number(item.task_count || tasks.length || 0),
|
||||
systemBindingStatus: bindingSummary.systemBindingStatus,
|
||||
userBindingStatus: bindingSummary.userBindingStatus,
|
||||
systemBoundTaskCount: bindingSummary.systemBoundTaskCount,
|
||||
completedBindingTaskCount: bindingSummary.completedBindingTaskCount,
|
||||
totalBindingCount: bindingSummary.totalBindingCount,
|
||||
reservedBindingCount: bindingSummary.reservedBindingCount,
|
||||
consumedBindingCount: bindingSummary.consumedBindingCount,
|
||||
releasedBindingCount: bindingSummary.releasedBindingCount,
|
||||
resourceStatus: fulfillmentProgress.resourceStatus,
|
||||
customerStatus: fulfillmentProgress.customerStatus,
|
||||
preparedTaskCount: fulfillmentProgress.preparedTaskCount,
|
||||
completedTaskCount: fulfillmentProgress.completedTaskCount,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,31 +1,35 @@
|
||||
import { buildClaimUrl } from '../claim/claim-service.js'
|
||||
import { getClaimTokenById } from '../../repositories/claim-token-repo.js'
|
||||
import {
|
||||
getInventoryItemById,
|
||||
listInventoryItems,
|
||||
listInventorySkuSuggestions,
|
||||
} from '../../repositories/inventory-repo.js'
|
||||
import { listOrderItemsByOrderId } from '../../repositories/order-item-repo.js'
|
||||
import { getOrderById, listOrders } from '../../repositories/order-repo.js'
|
||||
import { getTaskById, listTasks, listTasksByOrderId } from '../../repositories/task-repo.js'
|
||||
import { listTaskInventoryBindingsByTaskId } from '../../repositories/task-inventory-binding-repo.js'
|
||||
import { listTaskEventsByTaskId } from '../../repositories/task-event-repo.js'
|
||||
import { getWebhookEventById, listWebhookEvents, listWebhookEventsByOrderId } from '../../repositories/webhook-event-repo.js'
|
||||
import { listCloudtentaclesSources } from '../platforms/cloudtentacles/source-config-service.js'
|
||||
import { createHttpError } from '../../utils/http.js'
|
||||
import { formatFenToAmount, normalizeFen } from '../../utils/money.js'
|
||||
import { getTencentBrowserSessionReviewScreenshotPath } from '../session/session.js'
|
||||
import { normalizeDateQuery, normalizePage, normalizePageSize, safeParseJson } from './admin-query-utils.js'
|
||||
import {
|
||||
normalizeDateQuery,
|
||||
normalizePage,
|
||||
normalizePageSize,
|
||||
safeParseJson,
|
||||
} from './admin-query-utils.js'
|
||||
import {
|
||||
canCompleteManualDispatchTaskStatus,
|
||||
canDispatchKuaishouCloudFulfillmentStatus,
|
||||
canMarkManualReviewTaskStatus,
|
||||
canPrepareKuaishouCloudFulfillmentStatus,
|
||||
canRebindKuaishouCloudRoleStatus,
|
||||
canReturnKuaishouCloudFulfillmentStatus,
|
||||
canRetryTaskStatus,
|
||||
} from '../../domain/task-status.js'
|
||||
import {
|
||||
canViewerCloseTask,
|
||||
canRegenerateClaimLinkForViewer,
|
||||
canViewerConfirmAssistedRole,
|
||||
canViewerRedeemAssistedTask,
|
||||
createAdminViewerContext,
|
||||
getTaskPrimaryClaimTokenId,
|
||||
getTaskPrimaryInventoryItemId,
|
||||
isAssistedClaimTask,
|
||||
isManualDispatchTask,
|
||||
mapKuaishouCloudFulfillmentContext,
|
||||
mapKuaishouCloudTaskStateProjection,
|
||||
mapManualDispatchContext,
|
||||
mapRedeemResolutionContext,
|
||||
parseTaskContext,
|
||||
@@ -35,42 +39,31 @@ import {
|
||||
resolveOrderItemDeliveryMode,
|
||||
resolveOrderItemTitle,
|
||||
} from './admin-read-shared-helpers.js'
|
||||
import { mapAdminInventoryListItem } from './admin-inventory-read-helpers.js'
|
||||
import { mapAdminOrderListItem, summarizeOrderItems } from './admin-order-read-helpers.js'
|
||||
import { mapAdminWebhookEvent } from './admin-webhook-read-helpers.js'
|
||||
import {
|
||||
buildOrderAgisoAutoDeliverySummary,
|
||||
buildOrderBindingSummary,
|
||||
createTaskBindingSummaryFromBindings,
|
||||
buildOrderFulfillmentProgress,
|
||||
getRequiredTask,
|
||||
getTaskBindingSummary,
|
||||
getTaskBindingSummaryMap,
|
||||
mapAdminTaskEvent,
|
||||
mapAdminTaskInventoryBinding,
|
||||
mapAdminTaskListItem,
|
||||
mapAdminTaskSummary,
|
||||
} from './admin-task-read-helpers.js'
|
||||
|
||||
import type {
|
||||
AdminInventoryListResponse,
|
||||
AdminInventorySkuSuggestionResponse,
|
||||
AdminOrderListResponse,
|
||||
AdminTaskListResponse,
|
||||
AdminWebhookEventListResponse,
|
||||
} from '../../types/admin-read-models.js'
|
||||
} from '../../types/admin/read-models.js'
|
||||
import type {
|
||||
AdminEntityIdInput,
|
||||
AdminInventoryListQueryInput,
|
||||
AdminInventorySkuSuggestionQueryInput,
|
||||
AdminOrderListQueryInput,
|
||||
AdminTaskListQueryInput,
|
||||
AdminViewerSessionInput,
|
||||
AdminWebhookEventListQueryInput,
|
||||
} from '../../types/admin-read-inputs.js'
|
||||
} from '../../types/admin/read-inputs.js'
|
||||
|
||||
type JsonRecord = Record<string, any>
|
||||
|
||||
export async function getAdminOrders(query: AdminOrderListQueryInput = {}): Promise<AdminOrderListResponse> {
|
||||
export async function getAdminOrders(
|
||||
query: AdminOrderListQueryInput = {},
|
||||
): Promise<AdminOrderListResponse> {
|
||||
const page = normalizePage(query.page)
|
||||
const pageSize = normalizePageSize(query.pageSize)
|
||||
const { items, total } = await listOrders({
|
||||
@@ -99,18 +92,16 @@ export async function getAdminOrderDetail(orderId: AdminEntityIdInput): Promise<
|
||||
})
|
||||
}
|
||||
|
||||
const [items, tasks, webhookEvents] = await Promise.all([
|
||||
const [items, tasks] = await Promise.all([
|
||||
listOrderItemsByOrderId(order.id),
|
||||
listTasksByOrderId(order.id),
|
||||
listWebhookEventsByOrderId(order.id),
|
||||
])
|
||||
const taskBindingSummaryMap = await getTaskBindingSummaryMap(tasks.map((task) => task.id))
|
||||
const itemSummary = summarizeOrderItems(items)
|
||||
|
||||
return {
|
||||
order: {
|
||||
orderId: order.id,
|
||||
provider: order.provider || 'agiso',
|
||||
provider: order.provider || '',
|
||||
platform: order.platform,
|
||||
shopId: order.shop_id || '',
|
||||
shopName: resolveDisplayShopName(order.provider, order.shop_id, order.shop_name),
|
||||
@@ -127,11 +118,11 @@ export async function getAdminOrderDetail(orderId: AdminEntityIdInput): Promise<
|
||||
createdAt: order.created_at,
|
||||
updatedAt: order.updated_at,
|
||||
itemSummary,
|
||||
rawPayload: order.raw_payload_json && typeof order.raw_payload_json === 'object'
|
||||
rawPayload:
|
||||
order.raw_payload_json && typeof order.raw_payload_json === 'object'
|
||||
? order.raw_payload_json
|
||||
: JSON.parse(String(order.raw_payload_json || '{}')),
|
||||
bindingSummary: buildOrderBindingSummary(tasks, taskBindingSummaryMap),
|
||||
agisoAutoDelivery: buildOrderAgisoAutoDeliverySummary(order, tasks),
|
||||
fulfillmentProgress: buildOrderFulfillmentProgress(tasks),
|
||||
},
|
||||
items: items.map((item) => ({
|
||||
orderItemId: item.id,
|
||||
@@ -140,24 +131,12 @@ export async function getAdminOrderDetail(orderId: AdminEntityIdInput): Promise<
|
||||
itemTitle: resolveOrderItemTitle(item),
|
||||
quantity: item.quantity,
|
||||
deliveryMode: resolveOrderItemDeliveryMode(tasks, item.id),
|
||||
spec: item.spec_json && typeof item.spec_json === 'object'
|
||||
spec:
|
||||
item.spec_json && typeof item.spec_json === 'object'
|
||||
? item.spec_json
|
||||
: JSON.parse(String(item.spec_json || '{}')),
|
||||
})),
|
||||
tasks: tasks.map((task) => mapAdminTaskSummary(task, getTaskBindingSummary(taskBindingSummaryMap, task.id))),
|
||||
webhookEvents: webhookEvents.map((event) => ({
|
||||
eventId: event.id,
|
||||
provider: event.provider || 'agiso',
|
||||
platform: event.platform,
|
||||
shopId: event.shop_id || '',
|
||||
shopName: resolveDisplayShopName(event.provider, event.shop_id, event.shop_name),
|
||||
eventType: event.event_type,
|
||||
eventKey: event.event_key,
|
||||
signatureValid: Boolean(event.signature_valid),
|
||||
processed: Boolean(event.processed),
|
||||
processError: event.process_error,
|
||||
createdAt: event.created_at,
|
||||
})),
|
||||
tasks: tasks.map((task) => mapAdminTaskSummary(task)),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -178,15 +157,10 @@ export async function getAdminTasks(
|
||||
dateFrom: normalizeDateQuery(query.dateFrom),
|
||||
dateTo: normalizeDateQuery(query.dateTo, true),
|
||||
})
|
||||
const taskBindingSummaryMap = await getTaskBindingSummaryMap(items.map((item) => item.id))
|
||||
const viewerContext = createAdminViewerContext(session)
|
||||
|
||||
return {
|
||||
items: items.map((item) => mapAdminTaskListItem(
|
||||
item,
|
||||
getTaskBindingSummary(taskBindingSummaryMap, item.id),
|
||||
viewerContext,
|
||||
)),
|
||||
items: items.map((item) => mapAdminTaskListItem(item, viewerContext)),
|
||||
pagination: { page, pageSize, total },
|
||||
}
|
||||
}
|
||||
@@ -204,36 +178,39 @@ export async function getAdminTaskDetail(
|
||||
})
|
||||
}
|
||||
|
||||
const [order, claimToken, inventoryItem, taskEvents, inventoryBindings] = await Promise.all([
|
||||
const primaryClaimTokenId = getTaskPrimaryClaimTokenId(task)
|
||||
const [order, claimToken, taskEvents] = await Promise.all([
|
||||
getOrderById(task.order_id),
|
||||
getTaskPrimaryClaimTokenId(task) ? getClaimTokenById(getTaskPrimaryClaimTokenId(task)) : Promise.resolve(null),
|
||||
getTaskPrimaryInventoryItemId(task) ? getInventoryItemById(getTaskPrimaryInventoryItemId(task)) : Promise.resolve(null),
|
||||
primaryClaimTokenId ? getClaimTokenById(primaryClaimTokenId) : Promise.resolve(null),
|
||||
listTaskEventsByTaskId(task.id),
|
||||
listTaskInventoryBindingsByTaskId(task.id),
|
||||
])
|
||||
const orderItems = order ? await listOrderItemsByOrderId(order.id) : []
|
||||
const orderItem = orderItems.find((item) => item.id === task.order_item_id) || null
|
||||
const taskContext = parseTaskContext(task)
|
||||
const taskState = parseTaskState(task)
|
||||
const taskBindingSummary = createTaskBindingSummaryFromBindings(inventoryBindings)
|
||||
const viewerContext = createAdminViewerContext(session)
|
||||
const mappedInventoryBindings = inventoryBindings.map((binding) => mapAdminTaskInventoryBinding(binding, task, viewerContext))
|
||||
const claimUrl = claimToken ? buildClaimUrl(claimToken.token) : ''
|
||||
const screenshotUrl = await resolveAdminTaskScreenshotUrl(task, viewerContext)
|
||||
const kuaishouCloudFulfillment = mapKuaishouCloudFulfillmentContext(taskContext.kuaishouCloudFulfillment)
|
||||
const cloudSourceLabelMap = buildCloudSourceLabelMap()
|
||||
const kuaishouCloudFulfillment =
|
||||
mapKuaishouCloudFulfillmentContext(taskContext.kuaishouCloudFulfillment, {
|
||||
cloudSourceLabelMap,
|
||||
}) || mapKuaishouCloudTaskStateProjection(task, { cloudSourceLabelMap })
|
||||
|
||||
return {
|
||||
task: mapAdminTaskListItem({
|
||||
task: mapAdminTaskListItem(
|
||||
{
|
||||
...task,
|
||||
sku_code: orderItem?.sku_code || '',
|
||||
sku_name: orderItem?.sku_name || '',
|
||||
inventory_display_value: inventoryItem?.display_value || '',
|
||||
claim_token: claimToken?.token || '',
|
||||
}, taskBindingSummary, viewerContext),
|
||||
},
|
||||
viewerContext,
|
||||
),
|
||||
order: order
|
||||
? {
|
||||
orderId: order.id,
|
||||
provider: order.provider || 'agiso',
|
||||
provider: order.provider || '',
|
||||
platform: order.platform,
|
||||
shopId: order.shop_id || '',
|
||||
shopName: order.shop_name || '',
|
||||
@@ -259,21 +236,10 @@ export async function getAdminTaskDetail(
|
||||
claimUrl,
|
||||
}
|
||||
: null,
|
||||
inventory: inventoryItem
|
||||
? {
|
||||
inventoryItemId: inventoryItem.id,
|
||||
skuCode: inventoryItem.sku_code,
|
||||
batchNo: inventoryItem.batch_no,
|
||||
credentialType: inventoryItem.credential_type || 'tencent_code',
|
||||
displayValue: viewerContext.canViewSensitiveTaskData ? inventoryItem.display_value : '',
|
||||
status: inventoryItem.status,
|
||||
}
|
||||
: null,
|
||||
inventoryBindings: mappedInventoryBindings,
|
||||
artifacts: viewerContext.canViewSensitiveTaskData ? safeParseJson(task.artifacts_json) : {},
|
||||
screenshotUrl,
|
||||
review: {
|
||||
required: isAssistedClaimTask(task),
|
||||
required: false,
|
||||
screenshotCapturedAt: String(taskState.reviewCapturedAt || '').trim() || null,
|
||||
roleId: String(taskState.reviewRoleId || '').trim() || '',
|
||||
roleName: String(taskState.reviewRoleName || '').trim() || '',
|
||||
@@ -283,35 +249,61 @@ export async function getAdminTaskDetail(
|
||||
manualDispatch: mapManualDispatchContext(taskContext.manualDispatch, viewerContext),
|
||||
events: taskEvents.map(mapAdminTaskEvent),
|
||||
operations: {
|
||||
canRetry: viewerContext.canManageTaskLifecycle
|
||||
&& !isManualDispatchTask(task)
|
||||
&& ['retry_pending', 'manual_review', 'waiting_inventory'].includes(task.task_status),
|
||||
canReleaseInventory: mappedInventoryBindings.some((binding) => binding.canRelease),
|
||||
canRetry:
|
||||
viewerContext.canManageTaskLifecycle &&
|
||||
!isManualDispatchTask(task) &&
|
||||
canRetryTaskStatus(task.task_status),
|
||||
canRegenerateClaimLink: canRegenerateClaimLinkForViewer(task, viewerContext),
|
||||
canClose: canViewerCloseTask(task, viewerContext),
|
||||
canMarkManualReview: viewerContext.canManageTaskLifecycle && !['redeemed', 'closed', 'manual_review'].includes(task.task_status),
|
||||
canCompleteManualDispatch: viewerContext.canManageTaskLifecycle && isManualDispatchTask(task) && !['redeemed', 'closed'].includes(task.task_status),
|
||||
canPrepareKuaishouCloudFulfillment: viewerContext.canManageTaskLifecycle
|
||||
&& String(task.executor_key || '').trim() === 'kuaishou_ct_assisted'
|
||||
&& String(kuaishouCloudFulfillment?.dispatch.status || 'pending').trim() === 'pending'
|
||||
&& String(kuaishouCloudFulfillment?.returnNumber.status || 'pending').trim() === 'pending'
|
||||
&& ['pending_binding_prepare', 'manual_review', 'failed'].includes(String(task.task_status || '').trim()),
|
||||
canRefreshKuaishouCloudRoleInfo: viewerContext.canOperateAssistedTask
|
||||
&& String(task.executor_key || '').trim() === 'kuaishou_ct_assisted'
|
||||
&& Number(kuaishouCloudFulfillment?.binding.vnId || 0) > 0,
|
||||
canDispatchKuaishouCloudFulfillment: viewerContext.canManageTaskLifecycle
|
||||
&& String(task.executor_key || '').trim() === 'kuaishou_ct_assisted'
|
||||
&& ['waiting_binding'].includes(String(task.task_status || '').trim()),
|
||||
canReturnKuaishouCloudFulfillment: viewerContext.canManageTaskLifecycle
|
||||
&& String(task.executor_key || '').trim() === 'kuaishou_ct_assisted'
|
||||
&& ['dispatched_pending_return'].includes(String(task.task_status || '').trim()),
|
||||
canSupportConfirmRole: canViewerConfirmAssistedRole(task, viewerContext),
|
||||
canSupportRedeem: canViewerRedeemAssistedTask(task, viewerContext),
|
||||
canMarkManualReview:
|
||||
viewerContext.canManageTaskLifecycle && canMarkManualReviewTaskStatus(task.task_status),
|
||||
canCompleteManualDispatch:
|
||||
viewerContext.canManageTaskLifecycle &&
|
||||
isManualDispatchTask(task) &&
|
||||
canCompleteManualDispatchTaskStatus(task.task_status),
|
||||
canPrepareKuaishouCloudFulfillment:
|
||||
viewerContext.canManageTaskLifecycle &&
|
||||
String(task.executor_key || '').trim() === 'kuaishou_ct_assisted' &&
|
||||
String(kuaishouCloudFulfillment?.dispatch.status || 'pending').trim() === 'pending' &&
|
||||
String(kuaishouCloudFulfillment?.returnNumber.status || 'pending').trim() === 'pending' &&
|
||||
canPrepareKuaishouCloudFulfillmentStatus(task.task_status),
|
||||
canRefreshKuaishouCloudRoleInfo:
|
||||
viewerContext.canOperateAssistedTask &&
|
||||
String(task.executor_key || '').trim() === 'kuaishou_ct_assisted' &&
|
||||
Number(kuaishouCloudFulfillment?.binding.vnId || 0) > 0,
|
||||
canRebindKuaishouCloudRole:
|
||||
viewerContext.canManageTaskLifecycle &&
|
||||
String(task.executor_key || '').trim() === 'kuaishou_ct_assisted' &&
|
||||
canRebindKuaishouCloudRoleStatus(task.task_status) &&
|
||||
String(kuaishouCloudFulfillment?.dispatch.status || 'pending').trim() !== 'success' &&
|
||||
Number(kuaishouCloudFulfillment?.binding.vnId || 0) > 0,
|
||||
canDispatchKuaishouCloudFulfillment:
|
||||
viewerContext.canManageTaskLifecycle &&
|
||||
String(task.executor_key || '').trim() === 'kuaishou_ct_assisted' &&
|
||||
canDispatchKuaishouCloudFulfillmentStatus(task.task_status),
|
||||
canReturnKuaishouCloudFulfillment:
|
||||
viewerContext.canManageTaskLifecycle &&
|
||||
String(task.executor_key || '').trim() === 'kuaishou_ct_assisted' &&
|
||||
canReturnKuaishouCloudFulfillmentStatus(task.task_status),
|
||||
canViewSensitiveTaskData: viewerContext.canViewSensitiveTaskData,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
function buildCloudSourceLabelMap() {
|
||||
const labelMap = new Map<string, string>()
|
||||
|
||||
for (const source of listCloudtentaclesSources().sources) {
|
||||
const key = String(source.key || '').trim()
|
||||
if (!key) continue
|
||||
|
||||
const label = String(source.label || source.username || key).trim() || key
|
||||
labelMap.set(key, label)
|
||||
}
|
||||
|
||||
return labelMap
|
||||
}
|
||||
|
||||
export async function getAdminTaskScreenshotPath(
|
||||
taskId: AdminEntityIdInput,
|
||||
session: AdminViewerSessionInput | null = null,
|
||||
@@ -322,105 +314,8 @@ export async function getAdminTaskScreenshotPath(
|
||||
return task.screenshot_path
|
||||
}
|
||||
|
||||
if (task.browser_session_id) {
|
||||
return getTencentBrowserSessionReviewScreenshotPath(task.browser_session_id)
|
||||
}
|
||||
|
||||
throw createHttpError('当前任务还没有可查看截图', {
|
||||
statusCode: 404,
|
||||
errorCode: 'admin_task_screenshot_not_found',
|
||||
})
|
||||
}
|
||||
|
||||
export async function getAdminInventoryItems(
|
||||
query: AdminInventoryListQueryInput = {},
|
||||
session: AdminViewerSessionInput | null = null,
|
||||
): Promise<AdminInventoryListResponse> {
|
||||
const page = normalizePage(query.page)
|
||||
const pageSize = normalizePageSize(query.pageSize)
|
||||
const viewerContext = createAdminViewerContext(session)
|
||||
const { items, total } = await listInventoryItems({
|
||||
page,
|
||||
pageSize,
|
||||
skuCode: String(query.skuCode || '').trim(),
|
||||
credentialType: String(query.credentialType || '').trim(),
|
||||
status: String(query.status || '').trim(),
|
||||
batchNo: String(query.batchNo || '').trim(),
|
||||
inventoryGroupCode: String(query.inventoryGroupCode || '').trim(),
|
||||
allowedInventoryGroupCodes: viewerContext.allowedInventoryGroupCodes,
|
||||
})
|
||||
|
||||
return {
|
||||
items: await Promise.all(items.map((item) => mapAdminInventoryListItem(item))),
|
||||
pagination: { page, pageSize, total },
|
||||
}
|
||||
}
|
||||
|
||||
export async function getAdminInventorySkuSuggestions(
|
||||
query: AdminInventorySkuSuggestionQueryInput = {},
|
||||
session: AdminViewerSessionInput | null = null,
|
||||
): Promise<AdminInventorySkuSuggestionResponse> {
|
||||
const viewerContext = createAdminViewerContext(session)
|
||||
const credentialType = String(query.credentialType || '').trim()
|
||||
const keyword = String(query.keyword || '').trim()
|
||||
const inventoryGroupCode = String(query.inventoryGroupCode || '').trim()
|
||||
const requestedLimit = Number(query.limit)
|
||||
const limit = Number.isFinite(requestedLimit) && requestedLimit > 0
|
||||
? Math.min(100, Math.floor(requestedLimit))
|
||||
: 50
|
||||
const items = await listInventorySkuSuggestions({
|
||||
credentialType,
|
||||
keyword,
|
||||
limit,
|
||||
inventoryGroupCode,
|
||||
allowedInventoryGroupCodes: viewerContext.allowedInventoryGroupCodes,
|
||||
})
|
||||
|
||||
return {
|
||||
items: items.map((item) => ({
|
||||
skuCode: String(item.sku_code || '').trim(),
|
||||
credentialType: String(item.credential_type || '').trim() || 'tencent_code',
|
||||
inventoryGroupCode: String(item.inventory_group_code || '').trim(),
|
||||
totalCount: Number(item.total_count || 0),
|
||||
availableCount: Number(item.available_count || 0),
|
||||
latestUpdatedAt: item.latest_updated_at || null,
|
||||
})),
|
||||
}
|
||||
}
|
||||
|
||||
export async function getAdminWebhookEvents(
|
||||
query: AdminWebhookEventListQueryInput = {},
|
||||
): Promise<AdminWebhookEventListResponse> {
|
||||
const page = normalizePage(query.page)
|
||||
const pageSize = normalizePageSize(query.pageSize)
|
||||
const { items, total } = await listWebhookEvents({
|
||||
page,
|
||||
pageSize,
|
||||
provider: String(query.provider || '').trim(),
|
||||
platform: String(query.platform || '').trim(),
|
||||
platformOrderId: String(query.platformOrderId || '').trim(),
|
||||
processed: String(query.processed || '').trim(),
|
||||
visibility: String(query.visibility || '').trim() || 'important',
|
||||
relatedOrderId: String(query.relatedOrderId || '').trim(),
|
||||
dateFrom: normalizeDateQuery(query.dateFrom),
|
||||
dateTo: normalizeDateQuery(query.dateTo, true),
|
||||
})
|
||||
|
||||
return {
|
||||
items: await Promise.all(items.map((item) => mapAdminWebhookEvent(item))),
|
||||
pagination: { page, pageSize, total },
|
||||
}
|
||||
}
|
||||
|
||||
export async function getAdminWebhookEventDetail(eventId: AdminEntityIdInput): Promise<JsonRecord> {
|
||||
const event = await getWebhookEventById(Number(eventId))
|
||||
|
||||
if (!event) {
|
||||
throw createHttpError('Webhook 事件不存在', {
|
||||
statusCode: 404,
|
||||
errorCode: 'admin_webhook_not_found',
|
||||
})
|
||||
}
|
||||
|
||||
return mapAdminWebhookEvent(event, { includeRaw: true })
|
||||
}
|
||||
|
||||
@@ -2,9 +2,9 @@ import test from 'node:test'
|
||||
import assert from 'node:assert/strict'
|
||||
|
||||
import {
|
||||
canViewerConfirmAssistedRole,
|
||||
canViewerCloseTask,
|
||||
createAdminViewerContext,
|
||||
mapKuaishouCloudFulfillmentContext,
|
||||
resolveAdminTaskScreenshotUrl,
|
||||
} from './admin-read-shared-helpers.js'
|
||||
|
||||
@@ -12,7 +12,7 @@ test('resolveAdminTaskScreenshotUrl lets support view final redeemed screenshot'
|
||||
const task = {
|
||||
id: 12,
|
||||
screenshot_path: '/tmp/redeemed-12.png',
|
||||
browser_session_id: '',
|
||||
runtime_session_id: '',
|
||||
}
|
||||
|
||||
const screenshotUrl = await resolveAdminTaskScreenshotUrl(task, createAdminViewerContext({ role: 'support' }))
|
||||
@@ -20,11 +20,11 @@ test('resolveAdminTaskScreenshotUrl lets support view final redeemed screenshot'
|
||||
assert.equal(screenshotUrl, '/api/v1/admin/tasks/12/screenshot')
|
||||
})
|
||||
|
||||
test('resolveAdminTaskScreenshotUrl falls back to review screenshot when only browser session exists', async () => {
|
||||
test('resolveAdminTaskScreenshotUrl falls back to review screenshot when runtime session exists', async () => {
|
||||
const task = {
|
||||
id: 13,
|
||||
screenshot_path: '',
|
||||
browser_session_id: 'browser-session-13',
|
||||
runtime_session_id: 'runtime-session-13',
|
||||
}
|
||||
|
||||
const screenshotUrl = await resolveAdminTaskScreenshotUrl(task, createAdminViewerContext({ role: 'support' }))
|
||||
@@ -39,17 +39,23 @@ test('canViewerCloseTask allows support to close active task but not redeemed ta
|
||||
assert.equal(canViewerCloseTask({ task_status: 'redeemed' }, viewerContext), false)
|
||||
})
|
||||
|
||||
test('canViewerConfirmAssistedRole blocks support when task inventory group is not bound', () => {
|
||||
const viewerContext = createAdminViewerContext({
|
||||
role: 'support',
|
||||
allowedInventoryGroups: ['A组'],
|
||||
})
|
||||
|
||||
assert.equal(canViewerConfirmAssistedRole({
|
||||
task_status: 'claimed',
|
||||
executor_key: 'tencent_claim_assisted',
|
||||
context_json: {
|
||||
inventoryGroupCode: 'B组',
|
||||
test('mapKuaishouCloudFulfillmentContext exposes cloud account display labels', () => {
|
||||
const flow = mapKuaishouCloudFulfillmentContext(
|
||||
{
|
||||
binding: {
|
||||
cloudSourceKeys: [' account_a ', 'account_b'],
|
||||
resolvedSourceKey: 'account_b',
|
||||
},
|
||||
}, viewerContext), false)
|
||||
},
|
||||
{
|
||||
cloudSourceLabelMap: new Map([
|
||||
['account_a', '白班账号'],
|
||||
['account_b', '夜班账号'],
|
||||
]),
|
||||
},
|
||||
)
|
||||
|
||||
assert.deepEqual(flow?.binding.cloudSourceKeys, ['account_a', 'account_b'])
|
||||
assert.deepEqual(flow?.binding.cloudSourceLabels, ['白班账号', '夜班账号'])
|
||||
assert.equal(flow?.binding.resolvedSourceLabel, '夜班账号')
|
||||
})
|
||||
|
||||
@@ -1,15 +1,20 @@
|
||||
import { getAgisoShopConfig } from '../platforms/agiso/shop-config-service.js'
|
||||
import { safeParseJson } from './admin-query-utils.js'
|
||||
import { normalizeAdminRole } from './admin-auth-service.js'
|
||||
import {
|
||||
parseTaskContext as parseTaskContextValue,
|
||||
parseTaskState as parseTaskStateValue,
|
||||
} from '../../utils/task-json.js'
|
||||
import { canRegenerateClaimLinkStatus, isTaskFinalStatus } from '../../domain/task-status.js'
|
||||
|
||||
import type { AdminViewerSessionInput } from '../../types/admin-read-inputs.js'
|
||||
import type { OrderItemRow, TaskRow } from '../../types/repository-rows.js'
|
||||
import type { AdminViewerSessionInput } from '../../types/admin/read-inputs.js'
|
||||
import type { OrderItemRow, TaskRow } from '../../types/repository/rows.js'
|
||||
|
||||
type JsonRecord = Record<string, any>
|
||||
type CloudSourceLabelMap = Map<string, string> | Record<string, string>
|
||||
|
||||
type KuaishouCloudFulfillmentMapOptions = {
|
||||
cloudSourceLabelMap?: CloudSourceLabelMap
|
||||
}
|
||||
|
||||
type TaskLike = Partial<TaskRow> & {
|
||||
state_json?: string | JsonRecord
|
||||
@@ -17,17 +22,13 @@ type TaskLike = Partial<TaskRow> & {
|
||||
|
||||
export type AdminViewerContext = {
|
||||
role: string
|
||||
allowedInventoryGroupCodes: string[] | null
|
||||
restrictInventoryGroups: boolean
|
||||
canViewSensitiveTaskData: boolean
|
||||
canManageSensitiveInventory: boolean
|
||||
canManageTaskLifecycle: boolean
|
||||
canOperateAssistedTask: boolean
|
||||
}
|
||||
|
||||
type RedeemResolutionAttempt = {
|
||||
attempt: number
|
||||
inventoryItemId: number | null
|
||||
codeMasked: string
|
||||
credentialType: string
|
||||
outcome: string
|
||||
@@ -47,10 +48,13 @@ export function mapManualDispatchContext(
|
||||
return {
|
||||
outcome: String(record.outcome || '').trim(),
|
||||
deliveryReference: String(record.deliveryReference || '').trim(),
|
||||
deliveredCredential: viewerContext.canViewSensitiveTaskData ? String(record.deliveredCredential || '').trim() : '',
|
||||
deliveredCredential: viewerContext.canViewSensitiveTaskData
|
||||
? String(record.deliveredCredential || '').trim()
|
||||
: '',
|
||||
resultMessage: String(record.resultMessage || '').trim(),
|
||||
completedAt: record.completedAt || null,
|
||||
completedBy: record.completedBy && typeof record.completedBy === 'object'
|
||||
completedBy:
|
||||
record.completedBy && typeof record.completedBy === 'object'
|
||||
? {
|
||||
userId: Number(record.completedBy.userId || 0) || 0,
|
||||
username: String(record.completedBy.username || '').trim(),
|
||||
@@ -60,7 +64,10 @@ export function mapManualDispatchContext(
|
||||
}
|
||||
}
|
||||
|
||||
export function mapKuaishouCloudFulfillmentContext(value: unknown): JsonRecord | null {
|
||||
export function mapKuaishouCloudFulfillmentContext(
|
||||
value: unknown,
|
||||
options: KuaishouCloudFulfillmentMapOptions = {},
|
||||
): JsonRecord | null {
|
||||
if (!value || typeof value !== 'object') {
|
||||
return null
|
||||
}
|
||||
@@ -71,8 +78,13 @@ export function mapKuaishouCloudFulfillmentContext(value: unknown): JsonRecord |
|
||||
const role = record.role && typeof record.role === 'object' ? record.role : {}
|
||||
const purchase = record.purchase && typeof record.purchase === 'object' ? record.purchase : {}
|
||||
const dispatch = record.dispatch && typeof record.dispatch === 'object' ? record.dispatch : {}
|
||||
const returnNumber = record.returnNumber && typeof record.returnNumber === 'object' ? record.returnNumber : {}
|
||||
const returnNumber =
|
||||
record.returnNumber && typeof record.returnNumber === 'object' ? record.returnNumber : {}
|
||||
const consume = record.consume && typeof record.consume === 'object' ? record.consume : {}
|
||||
const cloudSourceKeys = Array.isArray(binding.cloudSourceKeys)
|
||||
? binding.cloudSourceKeys.map((value: unknown) => String(value || '').trim()).filter(Boolean)
|
||||
: []
|
||||
const resolvedSourceKey = String(binding.resolvedSourceKey || '').trim()
|
||||
|
||||
return {
|
||||
flowType: String(record.flowType || '').trim(),
|
||||
@@ -89,11 +101,14 @@ export function mapKuaishouCloudFulfillmentContext(value: unknown): JsonRecord |
|
||||
},
|
||||
binding: {
|
||||
prepareStatus: String(binding.prepareStatus || 'pending').trim() || 'pending',
|
||||
cloudSourceKey: String(binding.cloudSourceKey || '').trim(),
|
||||
cloudSourceKeyFallbacks: Array.isArray(binding.cloudSourceKeyFallbacks)
|
||||
? binding.cloudSourceKeyFallbacks.map((value) => String(value || '').trim()).filter(Boolean)
|
||||
: [],
|
||||
resolvedSourceKey: String(binding.resolvedSourceKey || '').trim(),
|
||||
cloudSourceKeys,
|
||||
cloudSourceLabels: cloudSourceKeys.map((sourceKey: string) =>
|
||||
resolveCloudSourceLabel(sourceKey, options.cloudSourceLabelMap),
|
||||
),
|
||||
resolvedSourceKey,
|
||||
resolvedSourceLabel: resolvedSourceKey
|
||||
? resolveCloudSourceLabel(resolvedSourceKey, options.cloudSourceLabelMap)
|
||||
: '',
|
||||
skuId: Number(binding.skuId || 0) || 0,
|
||||
skuName: String(binding.skuName || '').trim(),
|
||||
vnKey: String(binding.vnKey || '').trim(),
|
||||
@@ -142,10 +157,183 @@ export function mapKuaishouCloudFulfillmentContext(value: unknown): JsonRecord |
|
||||
consumedAt: consume.consumedAt || null,
|
||||
errorMessage: String(consume.errorMessage || '').trim(),
|
||||
},
|
||||
rebind: mapKuaishouCloudRebindContext(record.rebind),
|
||||
notes: String(record.notes || '').trim(),
|
||||
}
|
||||
}
|
||||
|
||||
export function mapKuaishouCloudTaskStateProjection(
|
||||
task: TaskLike | null | undefined,
|
||||
options: KuaishouCloudFulfillmentMapOptions = {},
|
||||
): JsonRecord | null {
|
||||
if (!task || String(task.executor_key || '').trim() !== 'kuaishou_ct_assisted') {
|
||||
return null
|
||||
}
|
||||
|
||||
const hasProjection = [
|
||||
task.kuaishou_ticket_status,
|
||||
task.kuaishou_ticket_code_masked,
|
||||
task.kuaishou_bind_url,
|
||||
task.kuaishou_vn_phone,
|
||||
task.kuaishou_role_name,
|
||||
task.kuaishou_role_id,
|
||||
task.kuaishou_dispatch_status,
|
||||
task.kuaishou_consume_status,
|
||||
task.kuaishou_source_key,
|
||||
].some((value) => String(value || '').trim())
|
||||
|
||||
if (!hasProjection) {
|
||||
return null
|
||||
}
|
||||
|
||||
const sourceKey = String(task.kuaishou_source_key || '').trim()
|
||||
const roleName = String(task.kuaishou_role_name || '').trim()
|
||||
const roleId = String(task.kuaishou_role_id || '').trim()
|
||||
|
||||
return {
|
||||
flowType: 'kuaishou_cloud_fulfillment',
|
||||
configId: '',
|
||||
internalSkuCode: '',
|
||||
internalSkuName: '',
|
||||
ticket: {
|
||||
code: String(task.kuaishou_ticket_code_masked || '').trim(),
|
||||
capturedAt: null,
|
||||
status: String(task.kuaishou_ticket_status || 'pending').trim() || 'pending',
|
||||
verifiedAt: null,
|
||||
goodsTitle: '',
|
||||
leftCount: 0,
|
||||
},
|
||||
binding: {
|
||||
prepareStatus: task.kuaishou_bind_url ? 'ready' : 'pending',
|
||||
cloudSourceKeys: sourceKey ? [sourceKey] : [],
|
||||
cloudSourceLabels: sourceKey
|
||||
? [resolveCloudSourceLabel(sourceKey, options.cloudSourceLabelMap)]
|
||||
: [],
|
||||
resolvedSourceKey: sourceKey,
|
||||
resolvedSourceLabel: sourceKey
|
||||
? resolveCloudSourceLabel(sourceKey, options.cloudSourceLabelMap)
|
||||
: '',
|
||||
skuId: 0,
|
||||
skuName: '',
|
||||
vnKey: '',
|
||||
vnId: 0,
|
||||
vnPhone: String(task.kuaishou_vn_phone || '').trim(),
|
||||
bindUrl: String(task.kuaishou_bind_url || '').trim(),
|
||||
bindPreparedAt: null,
|
||||
bindExpiresAt: null,
|
||||
bindProbeAt: null,
|
||||
bindProbeStatus: '',
|
||||
bindProbeMessage: '',
|
||||
},
|
||||
role: {
|
||||
status: roleName || roleId ? 'ready' : 'pending',
|
||||
name: roleName,
|
||||
rid: roleId,
|
||||
refreshedAt: null,
|
||||
errorMessage: '',
|
||||
rawInfo: null,
|
||||
},
|
||||
purchase: {
|
||||
autoBuyEnabled: true,
|
||||
minAssetReserve: 0,
|
||||
usedKnapsack: false,
|
||||
purchaseTriggered: false,
|
||||
assetBefore: 0,
|
||||
assetAfter: 0,
|
||||
purchaseAt: null,
|
||||
},
|
||||
dispatch: {
|
||||
status: String(task.kuaishou_dispatch_status || 'pending').trim() || 'pending',
|
||||
dispatchAt: null,
|
||||
sendType: 0,
|
||||
note: '',
|
||||
},
|
||||
returnNumber: {
|
||||
status: 'pending',
|
||||
returnedAt: null,
|
||||
autoReturnEnabled: false,
|
||||
},
|
||||
consume: {
|
||||
status: String(task.kuaishou_consume_status || 'pending').trim() || 'pending',
|
||||
shopId: '',
|
||||
shopName: '',
|
||||
autoConsumeEnabled: false,
|
||||
consumedAt: null,
|
||||
errorMessage: '',
|
||||
},
|
||||
rebind: {
|
||||
currentAttempt: 0,
|
||||
history: [],
|
||||
},
|
||||
notes: '',
|
||||
}
|
||||
}
|
||||
|
||||
function resolveCloudSourceLabel(sourceKey: string, labelMap: CloudSourceLabelMap | undefined) {
|
||||
const key = String(sourceKey || '').trim()
|
||||
if (!key) return ''
|
||||
|
||||
const label = labelMap instanceof Map ? labelMap.get(key) : labelMap?.[key]
|
||||
|
||||
return String(label || key).trim() || key
|
||||
}
|
||||
|
||||
function mapKuaishouCloudRebindContext(value: unknown): JsonRecord {
|
||||
const record = value && typeof value === 'object' ? (value as JsonRecord) : {}
|
||||
const history = Array.isArray(record.history) ? record.history : []
|
||||
|
||||
return {
|
||||
currentAttempt: Math.max(0, Number(record.currentAttempt || 0) || 0),
|
||||
history: history.map((item) => mapKuaishouCloudRebindHistoryItem(item)).filter(Boolean),
|
||||
}
|
||||
}
|
||||
|
||||
function mapKuaishouCloudRebindHistoryItem(value: unknown): JsonRecord | null {
|
||||
if (!value || typeof value !== 'object') {
|
||||
return null
|
||||
}
|
||||
|
||||
const record = value as JsonRecord
|
||||
const oldBinding =
|
||||
record.oldBinding && typeof record.oldBinding === 'object'
|
||||
? (record.oldBinding as JsonRecord)
|
||||
: {}
|
||||
const newBinding =
|
||||
record.newBinding && typeof record.newBinding === 'object'
|
||||
? (record.newBinding as JsonRecord)
|
||||
: null
|
||||
|
||||
return {
|
||||
attempt: Math.max(1, Number(record.attempt || 1) || 1),
|
||||
source: String(record.source || '').trim(),
|
||||
requestedAt: record.requestedAt || null,
|
||||
requestedBy:
|
||||
record.requestedBy && typeof record.requestedBy === 'object' ? record.requestedBy : null,
|
||||
status: String(record.status || '').trim(),
|
||||
errorMessage: String(record.errorMessage || '').trim(),
|
||||
oldBinding: {
|
||||
vnKey: String(oldBinding.vnKey || '').trim(),
|
||||
vnId: Number(oldBinding.vnId || 0) || 0,
|
||||
vnPhone: String(oldBinding.vnPhone || '').trim(),
|
||||
bindUrl: String(oldBinding.bindUrl || '').trim(),
|
||||
bindPreparedAt: oldBinding.bindPreparedAt || null,
|
||||
bindExpiresAt: oldBinding.bindExpiresAt || null,
|
||||
roleName: String(oldBinding.roleName || '').trim(),
|
||||
roleId: String(oldBinding.roleId || '').trim(),
|
||||
},
|
||||
newBinding: newBinding
|
||||
? {
|
||||
vnKey: String(newBinding.vnKey || '').trim(),
|
||||
vnId: Number(newBinding.vnId || 0) || 0,
|
||||
vnPhone: String(newBinding.vnPhone || '').trim(),
|
||||
bindUrl: String(newBinding.bindUrl || '').trim(),
|
||||
bindPreparedAt: newBinding.bindPreparedAt || null,
|
||||
bindExpiresAt: newBinding.bindExpiresAt || null,
|
||||
}
|
||||
: null,
|
||||
}
|
||||
}
|
||||
|
||||
export function mapRedeemResolutionContext(value: unknown): JsonRecord | null {
|
||||
if (!value || typeof value !== 'object') {
|
||||
return null
|
||||
@@ -167,28 +355,19 @@ export function mapRedeemResolutionContext(value: unknown): JsonRecord | null {
|
||||
}
|
||||
}
|
||||
|
||||
export function getTaskPrimaryInventoryItemId(task: TaskLike | null | undefined): number | null {
|
||||
const value = Number(task?.primary_inventory_item_id || 0)
|
||||
return Number.isFinite(value) && value > 0 ? value : null
|
||||
}
|
||||
|
||||
export function getTaskPrimaryClaimTokenId(task: TaskLike | null | undefined): number | null {
|
||||
const value = Number(task?.primary_claim_token_id || 0)
|
||||
return Number.isFinite(value) && value > 0 ? value : null
|
||||
}
|
||||
|
||||
export function createAdminViewerContext(session: AdminViewerSessionInput | null = null): AdminViewerContext {
|
||||
export function createAdminViewerContext(
|
||||
session: AdminViewerSessionInput | null = null,
|
||||
): AdminViewerContext {
|
||||
const role = normalizeAdminRole(session?.role)
|
||||
const allowedInventoryGroupCodes = role === 'support'
|
||||
? normalizeInventoryGroupCodes(session?.allowedInventoryGroups)
|
||||
: null
|
||||
|
||||
return {
|
||||
role,
|
||||
allowedInventoryGroupCodes,
|
||||
restrictInventoryGroups: role === 'support',
|
||||
canViewSensitiveTaskData: role === 'admin' || role === 'operator',
|
||||
canManageSensitiveInventory: role === 'admin',
|
||||
canManageTaskLifecycle: role === 'admin' || role === 'operator',
|
||||
canOperateAssistedTask: role === 'admin' || role === 'operator' || role === 'support',
|
||||
}
|
||||
@@ -198,7 +377,7 @@ export async function resolveAdminTaskScreenshotUrl(
|
||||
task: TaskLike,
|
||||
viewerContext: AdminViewerContext,
|
||||
): Promise<string> {
|
||||
if (task.screenshot_path || task.browser_session_id) {
|
||||
if (task.screenshot_path || task.runtime_session_id) {
|
||||
return `/api/v1/admin/tasks/${task.id}/screenshot`
|
||||
}
|
||||
|
||||
@@ -210,10 +389,14 @@ export function canViewerCloseTask(task: TaskLike, viewerContext: AdminViewerCon
|
||||
return false
|
||||
}
|
||||
|
||||
return !['redeemed', 'completed', 'closed'].includes(String(task?.task_status || '').trim())
|
||||
return !isTaskFinalStatus(task?.task_status)
|
||||
}
|
||||
|
||||
export function resolveDisplayShopName(provider: unknown, shopId: unknown, shopName: unknown): string {
|
||||
export function resolveDisplayShopName(
|
||||
provider: unknown,
|
||||
shopId: unknown,
|
||||
shopName: unknown,
|
||||
): string {
|
||||
const normalizedShopName = String(shopName || '').trim()
|
||||
if (normalizedShopName) {
|
||||
return normalizedShopName
|
||||
@@ -224,13 +407,6 @@ export function resolveDisplayShopName(provider: unknown, shopId: unknown, shopN
|
||||
return ''
|
||||
}
|
||||
|
||||
if (String(provider || '').trim().toLowerCase() === 'agiso') {
|
||||
const configuredName = String(getAgisoShopConfig(normalizedShopId)?.shopName || '').trim()
|
||||
if (configuredName) {
|
||||
return configuredName
|
||||
}
|
||||
}
|
||||
|
||||
return normalizedShopId
|
||||
}
|
||||
|
||||
@@ -261,8 +437,13 @@ export function resolveOrderItemTitle(item: Partial<OrderItemRow> | null | undef
|
||||
])
|
||||
}
|
||||
|
||||
export function resolveOrderItemDeliveryMode(tasks: TaskLike[] | null | undefined, orderItemId: number | string): string {
|
||||
const task = (Array.isArray(tasks) ? tasks : []).find((item) => item.order_item_id === orderItemId)
|
||||
export function resolveOrderItemDeliveryMode(
|
||||
tasks: TaskLike[] | null | undefined,
|
||||
orderItemId: number | string,
|
||||
): string {
|
||||
const task = (Array.isArray(tasks) ? tasks : []).find(
|
||||
(item) => item.order_item_id === orderItemId,
|
||||
)
|
||||
|
||||
if (!task) {
|
||||
return ''
|
||||
@@ -276,7 +457,12 @@ export function resolveOrderItemDeliveryMode(tasks: TaskLike[] | null | undefine
|
||||
return 'kuaishou_cloud'
|
||||
}
|
||||
|
||||
if (task.requires_claim || getTaskPrimaryClaimTokenId(task) || task.primary_claim_token || task.claim_token) {
|
||||
if (
|
||||
task.requires_claim ||
|
||||
getTaskPrimaryClaimTokenId(task) ||
|
||||
task.primary_claim_token ||
|
||||
task.claim_token
|
||||
) {
|
||||
return 'claim_link'
|
||||
}
|
||||
|
||||
@@ -287,16 +473,15 @@ export function isManualDispatchTask(task: TaskLike | null | undefined): boolean
|
||||
return String(task?.executor_key || '').trim() === 'manual_dispatch'
|
||||
}
|
||||
|
||||
export function isAssistedClaimTask(task: TaskLike | null | undefined): boolean {
|
||||
return String(task?.executor_key || '').trim() === 'tencent_claim_assisted'
|
||||
}
|
||||
|
||||
export function canRegenerateClaimLinkForViewer(task: TaskLike, viewerContext: AdminViewerContext): boolean {
|
||||
export function canRegenerateClaimLinkForViewer(
|
||||
task: TaskLike,
|
||||
viewerContext: AdminViewerContext,
|
||||
): boolean {
|
||||
if (isManualDispatchTask(task)) {
|
||||
return false
|
||||
}
|
||||
|
||||
const baseAllowed = ['link_generated', 'claimed', 'role_confirmed', 'retry_pending', 'manual_review'].includes(task.task_status)
|
||||
const baseAllowed = canRegenerateClaimLinkStatus(task.task_status)
|
||||
|
||||
if (!baseAllowed) {
|
||||
return false
|
||||
@@ -306,59 +491,9 @@ export function canRegenerateClaimLinkForViewer(task: TaskLike, viewerContext: A
|
||||
return true
|
||||
}
|
||||
|
||||
return viewerContext.canOperateAssistedTask
|
||||
&& isAssistedClaimTask(task)
|
||||
&& canViewerAccessTaskInventoryGroup(task, viewerContext)
|
||||
}
|
||||
|
||||
export function canViewerConfirmAssistedRole(task: TaskLike, viewerContext: AdminViewerContext): boolean {
|
||||
if (!viewerContext.canOperateAssistedTask || !isAssistedClaimTask(task) || !canViewerAccessTaskInventoryGroup(task, viewerContext)) {
|
||||
return false
|
||||
}
|
||||
|
||||
return String(task?.task_status || '').trim() === 'claimed'
|
||||
}
|
||||
|
||||
export function canViewerRedeemAssistedTask(task: TaskLike, viewerContext: AdminViewerContext): boolean {
|
||||
if (!viewerContext.canOperateAssistedTask || !isAssistedClaimTask(task) || !canViewerAccessTaskInventoryGroup(task, viewerContext)) {
|
||||
return false
|
||||
}
|
||||
|
||||
return ['role_confirmed', 'redeeming'].includes(String(task?.task_status || '').trim())
|
||||
}
|
||||
|
||||
export function canViewerAccessTaskInventoryGroup(
|
||||
task: TaskLike,
|
||||
viewerContext: AdminViewerContext = createAdminViewerContext(),
|
||||
): boolean {
|
||||
if (!viewerContext.restrictInventoryGroups) {
|
||||
return true
|
||||
}
|
||||
|
||||
const allowedInventoryGroupCodes = Array.isArray(viewerContext.allowedInventoryGroupCodes)
|
||||
? viewerContext.allowedInventoryGroupCodes
|
||||
: []
|
||||
|
||||
if (allowedInventoryGroupCodes.length === 0) {
|
||||
return false
|
||||
}
|
||||
|
||||
const taskContext = parseTaskContext(task)
|
||||
const inventoryGroupCode = String(taskContext?.inventoryGroupCode || '').trim()
|
||||
|
||||
if (!inventoryGroupCode) {
|
||||
return true
|
||||
}
|
||||
|
||||
return allowedInventoryGroupCodes.includes(inventoryGroupCode)
|
||||
}
|
||||
|
||||
export function normalizeInventoryGroupCodes(values: unknown): string[] {
|
||||
return Array.from(new Set((Array.isArray(values) ? values : [])
|
||||
.map((value) => String(value || '').trim())
|
||||
.filter(Boolean)))
|
||||
}
|
||||
|
||||
function mapRedeemResolutionAttempt(value: unknown): RedeemResolutionAttempt | null {
|
||||
if (!value || typeof value !== 'object') {
|
||||
return null
|
||||
@@ -367,7 +502,6 @@ function mapRedeemResolutionAttempt(value: unknown): RedeemResolutionAttempt | n
|
||||
const record = value as JsonRecord
|
||||
return {
|
||||
attempt: Math.max(1, Number(record.attempt || 1)),
|
||||
inventoryItemId: Number(record.inventoryItemId || 0) || null,
|
||||
codeMasked: String(record.codeMasked || '').trim(),
|
||||
credentialType: String(record.credentialType || '').trim(),
|
||||
outcome: String(record.outcome || '').trim(),
|
||||
|
||||
@@ -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'
|
||||
@@ -1,94 +1,59 @@
|
||||
import { getTaskById } from '../../repositories/task-repo.js'
|
||||
import { listTaskInventoryBindingSummariesByTaskIds } from '../../repositories/task-inventory-binding-repo.js'
|
||||
import { createHttpError } from '../../utils/http.js'
|
||||
import { safeParseJson } from './admin-query-utils.js'
|
||||
import {
|
||||
TASK_STATUS,
|
||||
isTaskFulfillmentCompletedStatus,
|
||||
normalizeTaskStatus,
|
||||
} from '../../domain/task-status.js'
|
||||
import {
|
||||
createAdminViewerContext,
|
||||
getTaskPrimaryClaimTokenId,
|
||||
getTaskPrimaryInventoryItemId,
|
||||
parseTaskContext,
|
||||
} from './admin-read-shared-helpers.js'
|
||||
|
||||
import type {
|
||||
AdminAgisoAutoDeliveryStatus,
|
||||
AdminTaskBindingSummary,
|
||||
AdminOrderFulfillmentProgress,
|
||||
AdminTaskListItem,
|
||||
} from '../../types/admin-read-models.js'
|
||||
import type { AdminTaskActionPayload } from '../../types/admin-write-models.js'
|
||||
} from '../../types/admin/read-models.js'
|
||||
import type { AdminTaskActionPayload } from '../../types/admin/write-models.js'
|
||||
import type {
|
||||
OrderRow,
|
||||
TaskEventRow,
|
||||
TaskInventoryBindingRow,
|
||||
TaskInventoryBindingSummaryRow,
|
||||
TaskRow,
|
||||
} from '../../types/repository-rows.js'
|
||||
} from '../../types/repository/rows.js'
|
||||
import type { AdminViewerContext } from './admin-read-shared-helpers.js'
|
||||
|
||||
type JsonRecord = Record<string, any>
|
||||
|
||||
type TaskBindingState = {
|
||||
systemBindingStatus: string
|
||||
userBindingStatus: string
|
||||
}
|
||||
|
||||
type TaskInventoryBindingLike = TaskInventoryBindingRow & {
|
||||
inventory_item_status?: string
|
||||
}
|
||||
|
||||
type OrderBindingSummary = {
|
||||
totalTaskCount: number
|
||||
systemBoundTaskCount: number
|
||||
completedBindingTaskCount: number
|
||||
totalBindingCount: number
|
||||
reservedBindingCount: number
|
||||
consumedBindingCount: number
|
||||
releasedBindingCount: number
|
||||
systemBindingStatus: string
|
||||
userBindingStatus: string
|
||||
}
|
||||
|
||||
type OrderAgisoAutoDeliverySummary = AdminAgisoAutoDeliveryStatus & {
|
||||
sourceTaskId: number | null
|
||||
sourceTaskNo: string
|
||||
totalTaskCount: number
|
||||
deliveredTaskCount: number
|
||||
}
|
||||
|
||||
type OrderAgisoAutoDeliveryCandidate = AdminAgisoAutoDeliveryStatus & {
|
||||
sourceTaskId: number | null
|
||||
sourceTaskNo: string
|
||||
type TaskFulfillmentState = {
|
||||
resourceStatus: string
|
||||
customerStatus: string
|
||||
}
|
||||
|
||||
export function mapAdminTaskSummary(
|
||||
task: TaskRow,
|
||||
bindingSummary: AdminTaskBindingSummary = createEmptyTaskBindingSummary(),
|
||||
): JsonRecord {
|
||||
const binding = buildTaskBindingState(task)
|
||||
const taskContext = parseTaskContext(task)
|
||||
const fulfillment = buildTaskFulfillmentState(task)
|
||||
|
||||
return {
|
||||
taskId: task.id,
|
||||
taskNo: task.task_no,
|
||||
deliveryStatus: task.delivery_status || '',
|
||||
status: task.task_status,
|
||||
systemBindingStatus: binding.systemBindingStatus,
|
||||
userBindingStatus: binding.userBindingStatus,
|
||||
resourceStatus: fulfillment.resourceStatus,
|
||||
customerStatus: fulfillment.customerStatus,
|
||||
loginType: task.login_type,
|
||||
browserSessionId: task.browser_session_id,
|
||||
runtimeSessionId: task.runtime_session_id,
|
||||
claimedAt: task.claimed_at,
|
||||
roleConfirmedAt: task.role_confirmed_at,
|
||||
redeemedAt: task.redeemed_at,
|
||||
lastError: task.last_error,
|
||||
retryCount: getTaskRetryCount(task),
|
||||
bindingSummary,
|
||||
agisoAutoDelivery: mapAgisoAutoDeliveryContext(taskContext.agisoAutoDelivery),
|
||||
createdAt: task.created_at,
|
||||
updatedAt: task.updated_at,
|
||||
}
|
||||
}
|
||||
|
||||
export function mapTaskActionPayload(task: any): AdminTaskActionPayload {
|
||||
const inventoryItemId = getTaskPrimaryInventoryItemId(task)
|
||||
const primaryClaimTokenId = getTaskPrimaryClaimTokenId(task)
|
||||
|
||||
return {
|
||||
@@ -98,7 +63,6 @@ export function mapTaskActionPayload(task: any): AdminTaskActionPayload {
|
||||
deliveryStatus: task.delivery_status,
|
||||
resultCode: task.result_code,
|
||||
resultMessage: task.result_message,
|
||||
inventoryItemId,
|
||||
primaryClaimTokenId,
|
||||
lastError: task.last_error,
|
||||
updatedAt: task.updated_at,
|
||||
@@ -120,11 +84,9 @@ export async function getRequiredTask(taskId: number | string): Promise<TaskRow>
|
||||
|
||||
export function mapAdminTaskListItem(
|
||||
task: TaskRow,
|
||||
bindingSummary: AdminTaskBindingSummary = createEmptyTaskBindingSummary(),
|
||||
viewerContext: AdminViewerContext = createAdminViewerContext(),
|
||||
): AdminTaskListItem {
|
||||
const binding = buildTaskBindingState(task)
|
||||
const taskContext = parseTaskContext(task)
|
||||
const fulfillment = buildTaskFulfillmentState(task)
|
||||
|
||||
return {
|
||||
taskId: task.id,
|
||||
@@ -137,23 +99,19 @@ export function mapAdminTaskListItem(
|
||||
deliveryStatus: task.delivery_status || '',
|
||||
resultCode: task.result_code || '',
|
||||
resultMessage: task.result_message || '',
|
||||
systemBindingStatus: binding.systemBindingStatus,
|
||||
userBindingStatus: binding.userBindingStatus,
|
||||
resourceStatus: fulfillment.resourceStatus,
|
||||
customerStatus: fulfillment.customerStatus,
|
||||
loginType: task.login_type,
|
||||
roleName: task.role_name,
|
||||
roleId: task.role_id,
|
||||
browserSessionId: task.browser_session_id,
|
||||
roleName: resolveTaskDisplayRoleName(task),
|
||||
roleId: resolveTaskDisplayRoleId(task),
|
||||
runtimeSessionId: task.runtime_session_id,
|
||||
claimedAt: task.claimed_at,
|
||||
roleConfirmedAt: task.role_confirmed_at,
|
||||
redeemedAt: task.redeemed_at,
|
||||
retryCount: getTaskRetryCount(task),
|
||||
bindingSummary,
|
||||
agisoAutoDelivery: mapAgisoAutoDeliveryContext(taskContext.agisoAutoDelivery),
|
||||
lastError: task.last_error,
|
||||
createdAt: task.created_at,
|
||||
updatedAt: task.updated_at,
|
||||
inventoryDisplayMasked: viewerContext.canViewSensitiveTaskData ? maskCode(task.primary_inventory_display_value) : '',
|
||||
inventoryCredentialType: String(task.primary_inventory_credential_type || '').trim(),
|
||||
claimToken: viewerContext.canViewSensitiveTaskData ? (task.primary_claim_token || task.claim_token || '') : '',
|
||||
screenshotPath: viewerContext.role === 'support' ? '' : (task.screenshot_path || ''),
|
||||
}
|
||||
@@ -170,338 +128,152 @@ export function mapAdminTaskEvent(event: TaskEventRow): JsonRecord {
|
||||
}
|
||||
}
|
||||
|
||||
export function createEmptyTaskBindingSummary(): AdminTaskBindingSummary {
|
||||
return {
|
||||
totalBindingCount: 0,
|
||||
reservedBindingCount: 0,
|
||||
consumedBindingCount: 0,
|
||||
releasedBindingCount: 0,
|
||||
roleKeys: [],
|
||||
}
|
||||
}
|
||||
|
||||
export async function getTaskBindingSummaryMap(taskIds: Array<number | string> = []): Promise<Map<number, AdminTaskBindingSummary>> {
|
||||
const rows = await listTaskInventoryBindingSummariesByTaskIds(taskIds)
|
||||
const output = new Map<number, AdminTaskBindingSummary>()
|
||||
|
||||
for (const row of rows as TaskInventoryBindingSummaryRow[]) {
|
||||
output.set(Number(row.task_id), {
|
||||
totalBindingCount: Number(row.total_binding_count || 0),
|
||||
reservedBindingCount: Number(row.reserved_binding_count || 0),
|
||||
consumedBindingCount: Number(row.consumed_binding_count || 0),
|
||||
releasedBindingCount: Number(row.released_binding_count || 0),
|
||||
roleKeys: Array.isArray(row.role_keys)
|
||||
? row.role_keys.map((value) => String(value || '').trim()).filter(Boolean)
|
||||
: [],
|
||||
})
|
||||
}
|
||||
|
||||
return output
|
||||
}
|
||||
|
||||
export function getTaskBindingSummary(
|
||||
summaryMap: Map<number, AdminTaskBindingSummary>,
|
||||
taskId: number | string,
|
||||
): AdminTaskBindingSummary {
|
||||
return summaryMap.get(Number(taskId)) || createEmptyTaskBindingSummary()
|
||||
}
|
||||
|
||||
export function createTaskBindingSummaryFromBindings(
|
||||
bindings: Array<Partial<TaskInventoryBindingRow>> | null | undefined = [],
|
||||
): AdminTaskBindingSummary {
|
||||
const normalizedBindings = Array.isArray(bindings) ? bindings : []
|
||||
const roleKeys = Array.from(new Set(normalizedBindings
|
||||
.map((binding) => String(binding?.role_key || '').trim())
|
||||
.filter(Boolean)))
|
||||
|
||||
return {
|
||||
totalBindingCount: normalizedBindings.length,
|
||||
reservedBindingCount: normalizedBindings.filter((binding) => String(binding?.binding_status || '') === 'reserved').length,
|
||||
consumedBindingCount: normalizedBindings.filter((binding) => String(binding?.binding_status || '') === 'consumed').length,
|
||||
releasedBindingCount: normalizedBindings.filter((binding) => String(binding?.binding_status || '') === 'released').length,
|
||||
roleKeys,
|
||||
}
|
||||
}
|
||||
|
||||
export function mapAdminTaskInventoryBinding(
|
||||
binding: TaskInventoryBindingLike,
|
||||
task: TaskRow,
|
||||
viewerContext: AdminViewerContext = createAdminViewerContext(),
|
||||
): JsonRecord {
|
||||
const metadata = safeParseJson(binding.metadata_json)
|
||||
|
||||
return {
|
||||
bindingId: binding.id,
|
||||
inventoryItemId: binding.inventory_item_id,
|
||||
roleKey: String(binding.role_key || '').trim(),
|
||||
quantity: Math.max(1, Number(binding.quantity || 1)),
|
||||
bindingStatus: String(binding.binding_status || '').trim(),
|
||||
inventoryStatus: String(binding.inventory_item_status || '').trim(),
|
||||
skuCode: String(binding.sku_code || '').trim(),
|
||||
batchNo: String(binding.batch_no || '').trim(),
|
||||
credentialType: String(binding.credential_type || 'tencent_code').trim() || 'tencent_code',
|
||||
displayValue: viewerContext.canViewSensitiveTaskData ? String(binding.display_value || '').trim() : '',
|
||||
invalidReason: String(binding.invalid_reason || '').trim(),
|
||||
consumedAt: binding.consumed_at || null,
|
||||
releasedAt: binding.released_at || null,
|
||||
createdAt: binding.created_at,
|
||||
updatedAt: binding.updated_at,
|
||||
metadata: normalizeRecord(metadata),
|
||||
isPrimary: Number(binding.inventory_item_id || 0) === getTaskPrimaryInventoryItemId(task),
|
||||
canRelease: viewerContext.canManageSensitiveInventory && canReleaseTaskInventoryBinding(task, binding),
|
||||
}
|
||||
}
|
||||
|
||||
export function buildOrderBindingSummary(
|
||||
export function buildOrderFulfillmentProgress(
|
||||
tasks: TaskRow[] | null | undefined,
|
||||
taskBindingSummaryMap: Map<number, AdminTaskBindingSummary> = new Map(),
|
||||
): OrderBindingSummary {
|
||||
): AdminOrderFulfillmentProgress {
|
||||
const normalizedTasks = Array.isArray(tasks) ? tasks : []
|
||||
const totalTaskCount = normalizedTasks.length
|
||||
const taskBindings = normalizedTasks.map((task) => buildTaskBindingState(task))
|
||||
const systemBoundTaskCount = normalizedTasks.filter((task) => isTaskSystemBound(task)).length
|
||||
const completedBindingTaskCount = normalizedTasks.filter((task) => String(task?.task_status || '') === 'redeemed').length
|
||||
const bindingSummaries = normalizedTasks.map((task) => getTaskBindingSummary(taskBindingSummaryMap, task.id))
|
||||
const totalBindingCount = bindingSummaries.reduce((sum, item) => sum + item.totalBindingCount, 0)
|
||||
const reservedBindingCount = bindingSummaries.reduce((sum, item) => sum + item.reservedBindingCount, 0)
|
||||
const consumedBindingCount = bindingSummaries.reduce((sum, item) => sum + item.consumedBindingCount, 0)
|
||||
const releasedBindingCount = bindingSummaries.reduce((sum, item) => sum + item.releasedBindingCount, 0)
|
||||
const taskFulfillments = normalizedTasks.map((task) => buildTaskFulfillmentState(task))
|
||||
const preparedTaskCount = normalizedTasks.filter((task) => isTaskResourcePrepared(task)).length
|
||||
const completedTaskCount = normalizedTasks.filter((task) => isTaskFulfillmentCompleted(task)).length
|
||||
|
||||
let systemBindingStatus = 'pending_binding'
|
||||
let userBindingStatus = 'not_started'
|
||||
let resourceStatus = 'pending_prepare'
|
||||
let customerStatus = 'not_started'
|
||||
|
||||
if (totalTaskCount === 0) {
|
||||
return {
|
||||
totalTaskCount,
|
||||
systemBoundTaskCount,
|
||||
completedBindingTaskCount,
|
||||
totalBindingCount,
|
||||
reservedBindingCount,
|
||||
consumedBindingCount,
|
||||
releasedBindingCount,
|
||||
systemBindingStatus,
|
||||
userBindingStatus,
|
||||
preparedTaskCount,
|
||||
completedTaskCount,
|
||||
resourceStatus,
|
||||
customerStatus,
|
||||
}
|
||||
}
|
||||
|
||||
if (normalizedTasks.every((task) => String(task.task_status || '') === 'redeemed')) {
|
||||
systemBindingStatus = 'system_bound'
|
||||
userBindingStatus = 'binding_completed'
|
||||
} else if (taskBindings.some((item) => ['binding_in_progress', 'binding_confirmed', 'link_opened'].includes(item.userBindingStatus))) {
|
||||
systemBindingStatus = systemBoundTaskCount > 0 ? 'system_bound' : 'pending_binding'
|
||||
userBindingStatus = 'user_binding'
|
||||
} else if (taskBindings.some((item) => item.userBindingStatus === 'waiting_user_claim')) {
|
||||
systemBindingStatus = systemBoundTaskCount > 0 ? 'system_bound' : 'pending_binding'
|
||||
userBindingStatus = 'waiting_user_claim'
|
||||
} else if (taskBindings.some((item) => item.userBindingStatus === 'binding_exception')) {
|
||||
systemBindingStatus = systemBoundTaskCount > 0 ? 'system_bound' : 'pending_binding'
|
||||
userBindingStatus = 'binding_exception'
|
||||
} else if (systemBoundTaskCount > 0) {
|
||||
systemBindingStatus = 'system_bound'
|
||||
} else if (taskBindings.some((item) => ['manual_review', 'retry_pending', 'waiting_inventory'].includes(item.systemBindingStatus))) {
|
||||
systemBindingStatus = 'binding_exception'
|
||||
if (normalizedTasks.every((task) => isTaskFulfillmentCompleted(task))) {
|
||||
resourceStatus = 'resource_ready'
|
||||
customerStatus = 'customer_completed'
|
||||
} else if (taskFulfillments.some((item) => ['customer_processing', 'customer_confirmed', 'link_opened'].includes(item.customerStatus))) {
|
||||
resourceStatus = preparedTaskCount > 0 ? 'resource_ready' : 'pending_prepare'
|
||||
customerStatus = 'customer_processing'
|
||||
} else if (taskFulfillments.some((item) => item.customerStatus === 'waiting_customer')) {
|
||||
resourceStatus = preparedTaskCount > 0 ? 'resource_ready' : 'pending_prepare'
|
||||
customerStatus = 'waiting_customer'
|
||||
} else if (taskFulfillments.some((item) => item.customerStatus === 'customer_exception')) {
|
||||
resourceStatus = preparedTaskCount > 0 ? 'resource_ready' : 'pending_prepare'
|
||||
customerStatus = 'customer_exception'
|
||||
} else if (preparedTaskCount > 0) {
|
||||
resourceStatus = 'resource_ready'
|
||||
} else if (taskFulfillments.some((item) => ['manual_review', 'retry_pending'].includes(item.resourceStatus))) {
|
||||
resourceStatus = 'resource_exception'
|
||||
}
|
||||
|
||||
return {
|
||||
totalTaskCount,
|
||||
systemBoundTaskCount,
|
||||
completedBindingTaskCount,
|
||||
totalBindingCount,
|
||||
reservedBindingCount,
|
||||
consumedBindingCount,
|
||||
releasedBindingCount,
|
||||
systemBindingStatus,
|
||||
userBindingStatus,
|
||||
preparedTaskCount,
|
||||
completedTaskCount,
|
||||
resourceStatus,
|
||||
customerStatus,
|
||||
}
|
||||
}
|
||||
|
||||
export function buildOrderAgisoAutoDeliverySummary(
|
||||
order: Partial<OrderRow> | null | undefined,
|
||||
tasks: TaskRow[] = [],
|
||||
): OrderAgisoAutoDeliverySummary | null {
|
||||
if (String(order?.provider || '').trim() !== 'agiso' || String(order?.platform || '').trim() !== 'xianyu') {
|
||||
return null
|
||||
}
|
||||
|
||||
const normalizedTasks = Array.isArray(tasks) ? tasks.filter(Boolean) : []
|
||||
const totalTaskCount = normalizedTasks.length
|
||||
const deliveredTaskCount = normalizedTasks.filter((task) => String(task?.delivery_status || '').trim() === 'delivered').length
|
||||
const latest = normalizedTasks.reduce<OrderAgisoAutoDeliveryCandidate | null>((best, task) => {
|
||||
const autoDelivery = mapAgisoAutoDeliveryContext(parseTaskContext(task).agisoAutoDelivery)
|
||||
|
||||
if (!autoDelivery) {
|
||||
return best
|
||||
}
|
||||
|
||||
const candidate = {
|
||||
...autoDelivery,
|
||||
sourceTaskId: Number(task.id || 0) || null,
|
||||
sourceTaskNo: String(task.task_no || '').trim(),
|
||||
}
|
||||
const candidateTime = Date.parse(String(candidate.updatedAt || task.updated_at || ''))
|
||||
const bestTime = Date.parse(String(best?.updatedAt || ''))
|
||||
|
||||
if (!best || (Number.isFinite(candidateTime) && (!Number.isFinite(bestTime) || candidateTime >= bestTime))) {
|
||||
return candidate
|
||||
}
|
||||
|
||||
return best
|
||||
}, null)
|
||||
|
||||
if (latest) {
|
||||
return {
|
||||
...latest,
|
||||
totalTaskCount,
|
||||
deliveredTaskCount,
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
status: totalTaskCount === 0 ? 'not_started' : deliveredTaskCount >= totalTaskCount ? 'pending' : 'waiting',
|
||||
trigger: '',
|
||||
reason: deliveredTaskCount >= totalTaskCount ? '' : 'waiting_other_tasks',
|
||||
platformOrderId: String(order?.platform_order_id || '').trim(),
|
||||
responseStatus: 0,
|
||||
errorMessage: '',
|
||||
requestId: '',
|
||||
updatedAt: null,
|
||||
sourceTaskId: null,
|
||||
sourceTaskNo: '',
|
||||
totalTaskCount,
|
||||
deliveredTaskCount,
|
||||
}
|
||||
}
|
||||
|
||||
function canReleaseTaskInventoryBinding(task: TaskRow | null | undefined, binding: TaskInventoryBindingLike | null | undefined): boolean {
|
||||
if (!task || !binding) {
|
||||
return false
|
||||
}
|
||||
|
||||
if (String(binding.binding_status || '').trim() !== 'reserved') {
|
||||
return false
|
||||
}
|
||||
|
||||
return !['redeemed', 'expired'].includes(String(task.task_status || '').trim())
|
||||
}
|
||||
|
||||
function buildTaskBindingState(task: TaskRow | null | undefined): TaskBindingState {
|
||||
const normalizedStatus = String(task?.task_status || '').trim()
|
||||
function buildTaskFulfillmentState(task: TaskRow | null | undefined): TaskFulfillmentState {
|
||||
const normalizedStatus = normalizeTaskStatus(task?.task_status)
|
||||
|
||||
if (!normalizedStatus) {
|
||||
return {
|
||||
systemBindingStatus: 'pending_binding',
|
||||
userBindingStatus: 'not_started',
|
||||
resourceStatus: 'pending_prepare',
|
||||
customerStatus: 'not_started',
|
||||
}
|
||||
}
|
||||
|
||||
if (normalizedStatus === 'pending_payment') {
|
||||
return { systemBindingStatus: 'pending_payment', userBindingStatus: 'not_started' }
|
||||
if (normalizedStatus === TASK_STATUS.PENDING_PAYMENT) {
|
||||
return { resourceStatus: 'pending_payment', customerStatus: 'not_started' }
|
||||
}
|
||||
|
||||
if (normalizedStatus === 'paid') {
|
||||
return { systemBindingStatus: 'pending_binding', userBindingStatus: 'not_started' }
|
||||
if (normalizedStatus === TASK_STATUS.PAID) {
|
||||
return { resourceStatus: 'pending_prepare', customerStatus: 'not_started' }
|
||||
}
|
||||
|
||||
if (normalizedStatus === 'waiting_inventory') {
|
||||
return { systemBindingStatus: 'waiting_inventory', userBindingStatus: 'not_started' }
|
||||
if (normalizedStatus === TASK_STATUS.PENDING_BINDING_PREPARE) {
|
||||
return { resourceStatus: 'pending_prepare', customerStatus: 'not_started' }
|
||||
}
|
||||
|
||||
if (normalizedStatus === 'pending_binding_prepare') {
|
||||
return { systemBindingStatus: 'pending_binding', userBindingStatus: 'not_started' }
|
||||
if (normalizedStatus === TASK_STATUS.WAITING_BINDING) {
|
||||
return { resourceStatus: 'resource_ready', customerStatus: 'waiting_customer' }
|
||||
}
|
||||
|
||||
if (normalizedStatus === 'waiting_binding') {
|
||||
return { systemBindingStatus: 'system_bound', userBindingStatus: 'waiting_user_claim' }
|
||||
if (normalizedStatus === TASK_STATUS.DISPATCHED_PENDING_RETURN) {
|
||||
return { resourceStatus: 'resource_ready', customerStatus: 'customer_completed' }
|
||||
}
|
||||
|
||||
if (normalizedStatus === 'dispatched_pending_return') {
|
||||
return { systemBindingStatus: 'system_bound', userBindingStatus: 'binding_completed' }
|
||||
if (normalizedStatus === TASK_STATUS.COMPLETED) {
|
||||
return { resourceStatus: 'resource_ready', customerStatus: 'customer_completed' }
|
||||
}
|
||||
|
||||
if (normalizedStatus === 'completed') {
|
||||
return { systemBindingStatus: 'system_bound', userBindingStatus: 'binding_completed' }
|
||||
if (normalizedStatus === TASK_STATUS.MANUAL_REVIEW) {
|
||||
return { resourceStatus: 'manual_review', customerStatus: 'customer_exception' }
|
||||
}
|
||||
|
||||
if (normalizedStatus === 'manual_review') {
|
||||
return { systemBindingStatus: 'manual_review', userBindingStatus: 'binding_exception' }
|
||||
if (normalizedStatus === TASK_STATUS.RETRY_PENDING) {
|
||||
return { resourceStatus: 'retry_pending', customerStatus: 'customer_exception' }
|
||||
}
|
||||
|
||||
if (normalizedStatus === 'retry_pending') {
|
||||
return { systemBindingStatus: 'retry_pending', userBindingStatus: 'binding_exception' }
|
||||
if (normalizedStatus === TASK_STATUS.CLOSED) {
|
||||
return { resourceStatus: 'closed', customerStatus: 'closed' }
|
||||
}
|
||||
|
||||
if (normalizedStatus === 'closed') {
|
||||
return { systemBindingStatus: 'closed', userBindingStatus: 'closed' }
|
||||
if (normalizedStatus === TASK_STATUS.EXPIRED) {
|
||||
return { resourceStatus: 'expired', customerStatus: 'expired' }
|
||||
}
|
||||
|
||||
if (normalizedStatus === 'expired') {
|
||||
return { systemBindingStatus: 'expired', userBindingStatus: 'expired' }
|
||||
if (normalizedStatus === TASK_STATUS.LINK_GENERATED) {
|
||||
return { resourceStatus: 'resource_ready', customerStatus: 'waiting_customer' }
|
||||
}
|
||||
|
||||
if (normalizedStatus === 'link_generated') {
|
||||
return { systemBindingStatus: 'system_bound', userBindingStatus: 'waiting_user_claim' }
|
||||
if (normalizedStatus === TASK_STATUS.CLAIMED) {
|
||||
return { resourceStatus: 'resource_ready', customerStatus: 'link_opened' }
|
||||
}
|
||||
|
||||
if (normalizedStatus === 'claimed') {
|
||||
return { systemBindingStatus: 'system_bound', userBindingStatus: 'link_opened' }
|
||||
if (normalizedStatus === TASK_STATUS.ROLE_CONFIRMED) {
|
||||
return { resourceStatus: 'resource_ready', customerStatus: 'customer_confirmed' }
|
||||
}
|
||||
|
||||
if (normalizedStatus === 'role_confirmed') {
|
||||
return { systemBindingStatus: 'system_bound', userBindingStatus: 'binding_confirmed' }
|
||||
if (normalizedStatus === TASK_STATUS.REDEEMING) {
|
||||
return { resourceStatus: 'resource_ready', customerStatus: 'customer_processing' }
|
||||
}
|
||||
|
||||
if (normalizedStatus === 'redeeming') {
|
||||
return { systemBindingStatus: 'system_bound', userBindingStatus: 'binding_in_progress' }
|
||||
if (normalizedStatus === TASK_STATUS.REDEEMED) {
|
||||
return { resourceStatus: 'resource_ready', customerStatus: 'customer_completed' }
|
||||
}
|
||||
|
||||
if (normalizedStatus === 'redeemed') {
|
||||
return { systemBindingStatus: 'system_bound', userBindingStatus: 'binding_completed' }
|
||||
if (isTaskResourcePrepared(task)) {
|
||||
return { resourceStatus: 'resource_ready', customerStatus: 'waiting_customer' }
|
||||
}
|
||||
|
||||
if (isTaskSystemBound(task)) {
|
||||
return { systemBindingStatus: 'system_bound', userBindingStatus: 'waiting_user_claim' }
|
||||
return { resourceStatus: 'pending_prepare', customerStatus: 'not_started' }
|
||||
}
|
||||
|
||||
return { systemBindingStatus: 'pending_binding', userBindingStatus: 'not_started' }
|
||||
function isTaskResourcePrepared(task: TaskRow | null | undefined): boolean {
|
||||
return Boolean(task && getTaskPrimaryClaimTokenId(task))
|
||||
}
|
||||
|
||||
function isTaskSystemBound(task: TaskRow | null | undefined): boolean {
|
||||
return Boolean(task && (getTaskPrimaryInventoryItemId(task) || getTaskPrimaryClaimTokenId(task)))
|
||||
}
|
||||
|
||||
function mapAgisoAutoDeliveryContext(value: unknown): AdminAgisoAutoDeliveryStatus | null {
|
||||
if (!value || typeof value !== 'object') {
|
||||
return null
|
||||
}
|
||||
|
||||
const record = value as JsonRecord
|
||||
return {
|
||||
status: String(record.status || '').trim(),
|
||||
trigger: String(record.trigger || '').trim(),
|
||||
reason: String(record.reason || '').trim(),
|
||||
platformOrderId: String(record.platformOrderId || '').trim(),
|
||||
responseStatus: Number(record.responseStatus || 0),
|
||||
errorMessage: String(record.errorMessage || '').trim(),
|
||||
requestId: String(record.requestId || '').trim(),
|
||||
updatedAt: record.updatedAt || null,
|
||||
}
|
||||
function isTaskFulfillmentCompleted(task: TaskRow | null | undefined): boolean {
|
||||
return isTaskFulfillmentCompletedStatus(task?.task_status)
|
||||
}
|
||||
|
||||
function normalizeRecord(value: unknown): JsonRecord {
|
||||
return value && typeof value === 'object' && !Array.isArray(value) ? value : {}
|
||||
}
|
||||
|
||||
function maskCode(value: unknown): string {
|
||||
const text = String(value || '').trim()
|
||||
if (!text) {
|
||||
return ''
|
||||
}
|
||||
if (text.length <= 8) {
|
||||
return `${text.slice(0, 2)}****${text.slice(-2)}`
|
||||
}
|
||||
return `${text.slice(0, 4)}****${text.slice(-4)}`
|
||||
}
|
||||
|
||||
function getTaskRetryCount(task: TaskRow | null | undefined): number {
|
||||
return Number(task?.attempt_count || 0)
|
||||
}
|
||||
|
||||
function resolveTaskDisplayRoleName(task: TaskRow): string {
|
||||
return String(task.kuaishou_role_name || task.role_name || '').trim()
|
||||
}
|
||||
|
||||
function resolveTaskDisplayRoleId(task: TaskRow): string {
|
||||
return String(task.kuaishou_role_id || task.role_id || '').trim()
|
||||
}
|
||||
|
||||
@@ -1,14 +0,0 @@
|
||||
import test from 'node:test'
|
||||
import assert from 'node:assert/strict'
|
||||
|
||||
import { resolveWebhookVisibilityLevel } from './admin-webhook-read-helpers.js'
|
||||
|
||||
test('resolveWebhookVisibilityLevel marks ignored process errors as ignored', () => {
|
||||
assert.equal(resolveWebhookVisibilityLevel('ignored_unconfigured_product'), 'ignored')
|
||||
assert.equal(resolveWebhookVisibilityLevel('ignored_duplicate_event'), 'ignored')
|
||||
})
|
||||
|
||||
test('resolveWebhookVisibilityLevel keeps normal and failed events as important', () => {
|
||||
assert.equal(resolveWebhookVisibilityLevel(''), 'important')
|
||||
assert.equal(resolveWebhookVisibilityLevel('invalid_signature'), 'important')
|
||||
})
|
||||
@@ -1,199 +0,0 @@
|
||||
import { listTasksByOrderId } from '../../repositories/task-repo.js'
|
||||
import { formatFenToAmount, parseAmountToFen } from '../../utils/money.js'
|
||||
import { extractAgisoTradePayload, resolveAgisoTradePlatformOrderId } from '../order/agiso-trade-parsing.js'
|
||||
import { safeParseJson } from './admin-query-utils.js'
|
||||
import { resolveDisplayShopName } from './admin-read-shared-helpers.js'
|
||||
|
||||
import type { AdminWebhookEventListItem } from '../../types/admin-read-models.js'
|
||||
import type { WebhookEventRow } from '../../types/repository-rows.js'
|
||||
|
||||
type JsonRecord = Record<string, any>
|
||||
|
||||
type MapAdminWebhookEventOptions = {
|
||||
includeRaw?: boolean
|
||||
}
|
||||
|
||||
export async function mapAdminWebhookEvent(
|
||||
item: WebhookEventRow,
|
||||
{ includeRaw = false }: MapAdminWebhookEventOptions = {},
|
||||
): Promise<AdminWebhookEventListItem> {
|
||||
const headers = normalizeRecord(safeParseJson(item.headers_json))
|
||||
const query = normalizeRecord(safeParseJson(item.query_json))
|
||||
const body = normalizeRecord(safeParseJson(item.body_json))
|
||||
const payload = extractWebhookPayload(body)
|
||||
const itemSources = extractWebhookItemSources(payload)
|
||||
const rawAmount = pickFirstNonEmpty([
|
||||
payload.total_fee,
|
||||
payload.totalFee,
|
||||
payload.TotalFee,
|
||||
payload.pay_fee,
|
||||
payload.payFee,
|
||||
payload.Payment,
|
||||
payload.payment,
|
||||
payload.buy_amount,
|
||||
payload.buyAmount,
|
||||
])
|
||||
const totalAmountFen = parseAmountToFen(rawAmount)
|
||||
const taskCount = item.related_order_id ? (await listTasksByOrderId(item.related_order_id)).length : 0
|
||||
const shopId = item.shop_id || pickFirstNonEmpty([
|
||||
payload.PlatformUserId,
|
||||
payload.platformUserId,
|
||||
payload.platform_user_id,
|
||||
payload.seller_id,
|
||||
payload.sellerId,
|
||||
payload.SellerId,
|
||||
payload.seller_nick,
|
||||
payload.sellerNick,
|
||||
payload.SellerNick,
|
||||
])
|
||||
|
||||
const mapped = {
|
||||
eventId: item.id,
|
||||
provider: item.provider || 'agiso',
|
||||
platform: item.platform,
|
||||
platformRaw: pickFirstNonEmpty([
|
||||
query.fromPlatform,
|
||||
query.from_platform,
|
||||
body.fromPlatform,
|
||||
body.from_platform,
|
||||
payload.fromPlatform,
|
||||
payload.FromPlatform,
|
||||
payload.platform,
|
||||
payload.Platform,
|
||||
]),
|
||||
shopId,
|
||||
shopName: resolveDisplayShopName(
|
||||
item.provider,
|
||||
shopId,
|
||||
item.shop_name || pickFirstNonEmpty([
|
||||
payload.shopName,
|
||||
payload.shop_name,
|
||||
payload.ShopName,
|
||||
payload.seller_nick,
|
||||
payload.sellerNick,
|
||||
payload.SellerNick,
|
||||
]),
|
||||
),
|
||||
eventType: item.event_type,
|
||||
eventKey: item.event_key,
|
||||
signatureValid: Boolean(item.signature_valid),
|
||||
processed: Boolean(item.processed),
|
||||
processError: item.process_error,
|
||||
visibilityLevel: resolveWebhookVisibilityLevel(item.process_error),
|
||||
relatedOrderId: item.related_order_id,
|
||||
createdAt: item.created_at,
|
||||
platformOrderId: resolveAgisoTradePlatformOrderId(payload),
|
||||
buyerId: pickFirstNonEmpty([
|
||||
payload.buyer_id,
|
||||
payload.buyerId,
|
||||
payload.BuyerId,
|
||||
payload.openid,
|
||||
payload.BuyerOpenUid,
|
||||
payload.buyer_open_uid,
|
||||
payload.buyerOpenUid,
|
||||
]),
|
||||
buyerName: pickFirstNonEmpty([
|
||||
payload.buyer_name,
|
||||
payload.buyerName,
|
||||
payload.BuyerName,
|
||||
payload.nick,
|
||||
payload.BuyerNick,
|
||||
payload.buyer_nick,
|
||||
payload.buyerNick,
|
||||
]),
|
||||
totalAmount: rawAmount ? formatFenToAmount(totalAmountFen) : '',
|
||||
totalAmountFen,
|
||||
currency: pickFirstNonEmpty([payload.currency, payload.Currency, 'CNY']) || 'CNY',
|
||||
itemCount: itemSources.length,
|
||||
taskCount,
|
||||
messageText: pickFirstNonEmpty([
|
||||
payload.MsgTypeDes,
|
||||
payload.msgTypeDes,
|
||||
payload.msg_type_desc,
|
||||
payload.message,
|
||||
payload.msg,
|
||||
]),
|
||||
requestTimestamp: pickFirstNonEmpty([query.timestamp]),
|
||||
requestSign: pickFirstNonEmpty([query.sign]),
|
||||
aopic: pickFirstNonEmpty([query.aopic]),
|
||||
sourceHost: pickFirstNonEmpty([headers['x-forwarded-host'], headers.host]),
|
||||
sourceIp: pickFirstNonEmpty([headers['x-forwarded-for'], headers['x-real-ip']]),
|
||||
userAgent: pickFirstNonEmpty([headers['user-agent'], headers['User-Agent']]),
|
||||
}
|
||||
|
||||
if (!includeRaw) {
|
||||
return mapped
|
||||
}
|
||||
|
||||
return {
|
||||
...mapped,
|
||||
headers,
|
||||
query,
|
||||
body,
|
||||
payload,
|
||||
}
|
||||
}
|
||||
|
||||
function extractWebhookPayload(body: JsonRecord): JsonRecord {
|
||||
return extractAgisoTradePayload(body)
|
||||
}
|
||||
|
||||
function extractWebhookItemSources(payload: unknown): JsonRecord[] {
|
||||
const normalizedPayload = normalizeRecord(payload)
|
||||
const candidates = [
|
||||
normalizedPayload.orders,
|
||||
normalizedPayload.Orders,
|
||||
normalizedPayload.items,
|
||||
normalizedPayload.Items,
|
||||
normalizedPayload.order_items,
|
||||
]
|
||||
|
||||
for (const candidate of candidates) {
|
||||
if (Array.isArray(candidate) && candidate.length > 0) {
|
||||
return candidate.map((item) => normalizeRecord(item)).filter((item) => Object.keys(item).length > 0)
|
||||
}
|
||||
}
|
||||
|
||||
if (
|
||||
pickFirstNonEmpty([
|
||||
normalizedPayload.item_id,
|
||||
normalizedPayload.itemId,
|
||||
normalizedPayload.ItemId,
|
||||
normalizedPayload.num_iid,
|
||||
normalizedPayload.numIid,
|
||||
normalizedPayload.NumIid,
|
||||
])
|
||||
) {
|
||||
return [normalizedPayload]
|
||||
}
|
||||
|
||||
return []
|
||||
}
|
||||
|
||||
export function resolveWebhookVisibilityLevel(processError: unknown): string {
|
||||
const normalized = String(processError || '').trim()
|
||||
|
||||
if (normalized.startsWith('ignored_')) {
|
||||
return 'ignored'
|
||||
}
|
||||
|
||||
return 'important'
|
||||
}
|
||||
|
||||
function normalizeRecord(value: unknown): JsonRecord {
|
||||
return value && typeof value === 'object' && !Array.isArray(value) ? value : {}
|
||||
}
|
||||
|
||||
function pickFirstNonEmpty(values: unknown[]): string {
|
||||
for (const value of values) {
|
||||
if (typeof value === 'string' && value.trim()) {
|
||||
return value.trim()
|
||||
}
|
||||
|
||||
if (typeof value === 'number' && Number.isFinite(value)) {
|
||||
return String(value)
|
||||
}
|
||||
}
|
||||
|
||||
return ''
|
||||
}
|
||||
@@ -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)),
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user