合并 feat/kuaishou-lite-split: 架构瘦身与模块化重组

This commit is contained in:
yml2213
2026-06-30 14:18:04 +08:00
453 changed files with 18151 additions and 41802 deletions
+3 -1
View File
@@ -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
View File
@@ -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
View File
@@ -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
+5
View File
@@ -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
+25 -94
View File
@@ -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,本地不需要额外维护 NodePostgreSQL、Playwright 浏览器和 OCR 运行环境
推荐直接使用 Docker,本地不需要额外维护 NodePostgreSQL。
```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 或相关日志
+3
View File
@@ -12,3 +12,6 @@ data/logs/
data/*.db
data/*.db-shm
data/*.db-wal
data/*.json
!data/*.example.json
data/cloudtentacles-session.json
+6
View File
@@ -0,0 +1,6 @@
{
"semi": false,
"singleQuote": true,
"printWidth": 100,
"trailingComma": "all"
}
+37 -102
View File
@@ -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` 后填写真实值。
-23
View File
@@ -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个"
}
}
}
]
@@ -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
}
]
+18 -46
View File
@@ -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",
+5 -6
View File
@@ -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"
}
+1 -5
View File
@@ -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 执行
`)
}
+182
View File
@@ -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 回调地址,默认空
`)
}
-513
View File
@@ -1,513 +0,0 @@
#!/usr/bin/env node
import process from 'node:process'
import { runtimeConfig } from '../src/config/runtime.js'
import { closeDb, query } from '../src/db/client.js'
import { runDatabaseMigrations } from '../src/db/migrate.js'
import { updateClaimToken } from '../src/repositories/claim-token-repo.js'
import { getFulfillmentProfileByKey, upsertSkuFulfillmentBinding } from '../src/repositories/fulfillment-profile-repo.js'
import { createInventoryItems, releaseReservedInventoryItem } from '../src/repositories/inventory-repo.js'
import { getTaskById, updateTask } from '../src/repositories/task-repo.js'
import { ensureFulfillmentCatalogBootstrapped } from '../src/services/bootstrap/fulfillment-bootstrap-service.js'
import { completeAdminTaskManualDispatch } from '../src/services/admin/admin-service.js'
import { upsertOrderFromWebhook } from '../src/services/order/order-service.js'
import { addHours, nowIso } from '../src/utils/time.js'
const RESET_TABLES = [
'message_deliveries',
'tencent_browser_contexts',
'task_events',
'claim_tokens',
'task_inventory_bindings',
'fulfillment_tasks',
'order_items',
'orders',
'webhook_events',
'product_match_rules',
'inventory_items',
]
const SEED_PROVIDER = 'seed'
const SEED_PLATFORM = 'sandbox'
const SEED_SHOP_ID = 'seed-shop'
const SEED_SHOP_NAME = 'Seed Sandbox Shop'
const options = parseArgs(process.argv.slice(2))
if (options.help) {
printHelp()
process.exit(0)
}
try {
printPreview()
if (!options.apply) {
console.log('\n当前为预览模式。确认后追加 `--apply`,需要清库时再加 `--reset`。')
process.exit(0)
}
await runDatabaseMigrations()
await ensureFulfillmentCatalogBootstrapped()
if (options.reset) {
await query(`TRUNCATE TABLE ${RESET_TABLES.join(', ')} RESTART IDENTITY CASCADE`)
console.log('\n已先清空测试业务数据。')
}
await ensureSeedBindings()
await createSeedInventory()
const manualPending = await seedScenario({
scenarioKey: 'manual_pending',
orderLabel: 'SEED-MANUAL-PENDING',
skuCode: 'seed-manual-pending',
skuName: '开发种子-人工履约待处理',
})
const manualCompleted = await seedScenario({
scenarioKey: 'manual_completed',
orderLabel: 'SEED-MANUAL-COMPLETED',
skuCode: 'seed-manual-completed',
skuName: '开发种子-人工履约已完成',
afterCreate: async (result) => {
const task = result.tasks[0]
if (!task) {
return null
}
await completeAdminTaskManualDispatch(task.id, {
outcome: 'delivered',
resultMessage: '开发种子数据:人工履约已完成',
deliveryReference: 'SEED-MANUAL-REF-001',
deliveredCredential: '开发种子数据:已通过人工渠道发放',
})
return getTaskById(task.id)
},
})
const claimReady = await seedScenario({
scenarioKey: 'claim_ready',
orderLabel: 'SEED-CLAIM-READY',
skuCode: 'seed-claim-ready',
skuName: '开发种子-领取兑换可用',
})
const claimClaimed = await seedScenario({
scenarioKey: 'claim_claimed',
orderLabel: 'SEED-CLAIM-CLAIMED',
skuCode: 'seed-claim-claimed',
skuName: '开发种子-已打开领取链接',
afterCreate: async (result) => applyClaimedState(result.tasks[0]),
})
const claimRoleConfirmed = await seedScenario({
scenarioKey: 'claim_role_confirmed',
orderLabel: 'SEED-CLAIM-ROLE',
skuCode: 'seed-claim-role-confirmed',
skuName: '开发种子-已确认角色',
afterCreate: async (result) => applyRoleConfirmedState(result.tasks[0]),
})
const claimRetryPending = await seedScenario({
scenarioKey: 'claim_retry_pending',
orderLabel: 'SEED-CLAIM-RETRY',
skuCode: 'seed-claim-retry',
skuName: '开发种子-等待重试',
afterCreate: async (result) => applyRetryPendingState(result.tasks[0]),
})
const claimExpired = await seedScenario({
scenarioKey: 'claim_expired',
orderLabel: 'SEED-CLAIM-EXPIRED',
skuCode: 'seed-claim-expired',
skuName: '开发种子-领取链接已过期',
afterCreate: async (result) => applyExpiredState(result.tasks[0]),
})
const claimWaitingInventory = await seedScenario({
scenarioKey: 'claim_waiting_inventory',
orderLabel: 'SEED-CLAIM-WAIT',
skuCode: 'seed-claim-wait',
skuName: '开发种子-等待库存',
})
printResultSummary([
manualPending,
manualCompleted,
claimReady,
claimClaimed,
claimRoleConfirmed,
claimRetryPending,
claimExpired,
claimWaitingInventory,
])
} catch (error) {
console.error('\n写入开发种子数据失败:', error instanceof Error ? error.message : String(error || '未知错误'))
process.exitCode = 1
} finally {
await closeDb()
}
async function ensureSeedBindings() {
const timestamp = nowIso()
const manualProfile = await getRequiredProfile('manual_review')
const claimProfile = await getRequiredProfile('tencent_claim_redeem')
const bindings = [
{ skuCode: 'seed-manual-pending', profileId: manualProfile.id, priority: 10 },
{ skuCode: 'seed-manual-completed', profileId: manualProfile.id, priority: 20 },
{ skuCode: 'seed-claim-ready', profileId: claimProfile.id, priority: 30 },
{ skuCode: 'seed-claim-claimed', profileId: claimProfile.id, priority: 40 },
{ skuCode: 'seed-claim-role-confirmed', profileId: claimProfile.id, priority: 50 },
{ skuCode: 'seed-claim-retry', profileId: claimProfile.id, priority: 60 },
{ skuCode: 'seed-claim-expired', profileId: claimProfile.id, priority: 70 },
{ skuCode: 'seed-claim-wait', profileId: claimProfile.id, priority: 80 },
]
for (const binding of bindings) {
await upsertSkuFulfillmentBinding({
skuCode: binding.skuCode,
provider: SEED_PROVIDER,
platform: SEED_PLATFORM,
shopId: SEED_SHOP_ID,
profileId: binding.profileId,
enabled: true,
priority: binding.priority,
configJson: JSON.stringify({
seededBy: 'scripts/seed-dev-data.ts',
scenario: binding.skuCode,
}),
createdAt: timestamp,
updatedAt: timestamp,
})
}
}
async function createSeedInventory() {
const timestamp = nowIso()
await createInventoryItems([
{
skuCode: 'seed-claim-ready',
displayValue: 'SEED-CLAIM-CODE-READY-001',
batchNo: 'seed-20260409',
credentialType: 'tencent_code',
payload: {
code: 'SEED-CLAIM-CODE-READY-001',
scenario: 'claim_ready',
},
createdAt: timestamp,
updatedAt: timestamp,
},
{
skuCode: 'seed-claim-claimed',
displayValue: 'SEED-CLAIM-CODE-CLAIMED-001',
batchNo: 'seed-20260409',
credentialType: 'tencent_code',
payload: {
code: 'SEED-CLAIM-CODE-CLAIMED-001',
scenario: 'claim_claimed',
},
createdAt: timestamp,
updatedAt: timestamp,
},
{
skuCode: 'seed-claim-role-confirmed',
displayValue: 'SEED-CLAIM-CODE-ROLE-001',
batchNo: 'seed-20260409',
credentialType: 'tencent_code',
payload: {
code: 'SEED-CLAIM-CODE-ROLE-001',
scenario: 'claim_role_confirmed',
},
createdAt: timestamp,
updatedAt: timestamp,
},
{
skuCode: 'seed-claim-retry',
displayValue: 'SEED-CLAIM-CODE-RETRY-001',
batchNo: 'seed-20260409',
credentialType: 'tencent_code',
payload: {
code: 'SEED-CLAIM-CODE-RETRY-001',
scenario: 'claim_retry_pending',
},
createdAt: timestamp,
updatedAt: timestamp,
},
{
skuCode: 'seed-claim-expired',
displayValue: 'SEED-CLAIM-CODE-EXPIRED-001',
batchNo: 'seed-20260409',
credentialType: 'tencent_code',
payload: {
code: 'SEED-CLAIM-CODE-EXPIRED-001',
scenario: 'claim_expired',
},
createdAt: timestamp,
updatedAt: timestamp,
},
{
skuCode: 'seed-spare-available',
displayValue: 'SEED-SPARE-CODE-001',
batchNo: 'seed-20260409',
credentialType: 'tencent_code',
payload: {
code: 'SEED-SPARE-CODE-001',
scenario: 'spare_available',
},
createdAt: timestamp,
updatedAt: timestamp,
},
])
}
async function seedScenario({ scenarioKey, orderLabel, skuCode, skuName, afterCreate = null }) {
const result = await upsertOrderFromWebhook(buildSeedOrderEvent({
scenarioKey,
orderLabel,
skuCode,
skuName,
}))
const task = typeof afterCreate === 'function'
? await afterCreate(result)
: result.tasks[0] || null
return {
scenarioKey,
orderId: result.order.id,
platformOrderId: result.order.platform_order_id,
taskId: task?.id || null,
taskNo: task?.task_no || '',
taskStatus: task?.task_status || '',
deliveryStatus: task?.delivery_status || '',
inventoryStatus: task?.inventory_status || '',
inventoryItemId: Number(task?.primary_inventory_item_id || 0) || null,
claimTokenId: Number(task?.primary_claim_token_id || 0) || null,
executorKey: String(task?.executor_key || '').trim(),
}
}
async function applyClaimedState(task) {
if (!task) {
return null
}
const now = nowIso()
await updateTask(task.id, {
task_status: 'claimed',
user_action_status: 'claimed',
browser_session_id: `seed-browser-${task.id}`,
login_type: 'qq',
claimed_at: now,
updated_at: now,
last_error: '',
})
return getTaskById(task.id)
}
async function applyRoleConfirmedState(task) {
if (!task) {
return null
}
const claimedAt = addHours(nowIso(), -1)
const roleConfirmedAt = nowIso()
await updateTask(task.id, {
task_status: 'role_confirmed',
user_action_status: 'role_confirmed',
browser_session_id: `seed-browser-${task.id}`,
login_type: 'qq',
nickname: 'SeedHero',
role_id: 'seed-role-1001',
role_name: '开发角色-已确认',
area: 'seed-area',
partition_name: 'seed-partition',
claimed_at: claimedAt,
role_confirmed_at: roleConfirmedAt,
updated_at: roleConfirmedAt,
last_error: '',
})
return getTaskById(task.id)
}
async function applyRetryPendingState(task) {
if (!task) {
return null
}
const claimedAt = addHours(nowIso(), -2)
const roleConfirmedAt = addHours(nowIso(), -1)
const updatedAt = nowIso()
await updateTask(task.id, {
task_status: 'retry_pending',
delivery_status: 'pending',
inventory_status: 'reserved',
user_action_status: 'role_confirmed',
browser_session_id: `seed-browser-${task.id}`,
login_type: 'qq',
nickname: 'SeedRetryUser',
role_id: 'seed-role-retry',
role_name: '开发角色-待重试',
area: 'seed-area',
partition_name: 'seed-partition',
claimed_at: claimedAt,
role_confirmed_at: roleConfirmedAt,
attempt_count: 2,
last_error: '开发种子数据:模拟兑换失败,等待重试',
updated_at: updatedAt,
})
return getTaskById(task.id)
}
async function applyExpiredState(task) {
if (!task) {
return null
}
const now = nowIso()
if (task.primary_claim_token_id) {
await updateClaimToken(task.primary_claim_token_id, {
status: 'expired',
expired_at: addHours(now, -2),
updated_at: now,
})
}
if (task.primary_inventory_item_id) {
await releaseReservedInventoryItem(task.primary_inventory_item_id, now)
}
await updateTask(task.id, {
task_status: 'expired',
inventory_status: 'pending',
user_action_status: 'expired',
last_error: '领取链接已过期,预占库存项已释放',
updated_at: now,
})
return getTaskById(task.id)
}
function buildSeedOrderEvent({ scenarioKey, orderLabel, skuCode, skuName }) {
const timestamp = nowIso()
return {
provider: SEED_PROVIDER,
platform: SEED_PLATFORM,
shopId: SEED_SHOP_ID,
shopName: SEED_SHOP_NAME,
platformOrderId: orderLabel,
orderStatus: 'paid',
payStatus: 'paid',
buyerId: `${scenarioKey}-buyer`,
buyerName: `开发种子-${scenarioKey}`,
receiverContact: `seed+${scenarioKey}@example.com`,
totalAmount: 100,
currency: 'CNY',
paidAt: timestamp,
rawPayload: {
source: 'seed-dev-data',
scenarioKey,
platformOrderId: orderLabel,
},
items: [
{
skuCode,
skuName,
quantity: 1,
spec: {
title: skuName,
scenarioKey,
},
snapshot: {
title: skuName,
scenarioKey,
seeded: true,
},
},
],
}
}
async function getRequiredProfile(profileKey) {
const profile = await getFulfillmentProfileByKey(profileKey)
if (!profile) {
throw new Error(`缺少履约档案: ${profileKey}`)
}
return profile
}
function parseArgs(argv) {
return {
apply: argv.includes('--apply'),
reset: argv.includes('--reset'),
help: argv.includes('-h') || argv.includes('--help'),
}
}
function printHelp() {
console.log(`
用法:
npm run seed:dev-data -- [--apply] [--reset]
说明:
基于当前 PostgreSQL fulfillment schema 生成一组可重复验证的开发种子数据。
场景:
- manual_pending: 人工履约待处理
- manual_completed: 人工履约已完成
- claim_ready: 已预占库存并生成领取链接
- claim_claimed: 用户已打开领取链接
- claim_role_confirmed: 用户已确认角色
- claim_retry_pending: 自动履约失败等待重试
- claim_expired: 领取链接已过期
- claim_waiting_inventory: 等待库存
参数:
--apply 实际写入数据
--reset 写入前先清空测试业务数据
数据库连接:
${String(runtimeConfig.database?.url || '').trim() || '(未配置 DATABASE_URL)'}
`.trim())
}
function printPreview() {
console.log('准备生成开发种子数据:')
console.log('- manual_pending -> 人工履约待处理')
console.log('- manual_completed -> 人工履约已完成')
console.log('- claim_ready -> 领取兑换已就绪')
console.log('- claim_claimed -> 用户已打开领取链接')
console.log('- claim_role_confirmed -> 用户已确认角色')
console.log('- claim_retry_pending -> 自动履约失败等待重试')
console.log('- claim_expired -> 领取链接已过期')
console.log('- claim_waiting_inventory -> 等待库存')
console.log(`- database: ${String(runtimeConfig.database?.url || '').trim() || '(未配置 DATABASE_URL)'}`)
}
function printResultSummary(rows) {
console.log('\n开发种子数据已写入:')
for (const row of rows) {
console.log(`- ${row.scenarioKey}`)
console.log(` orderId=${row.orderId} platformOrderId=${row.platformOrderId}`)
console.log(` taskId=${row.taskId || '-'} taskNo=${row.taskNo || '-'} executor=${row.executorKey || '-'}`)
console.log(` taskStatus=${row.taskStatus || '-'} deliveryStatus=${row.deliveryStatus || '-'} inventoryStatus=${row.inventoryStatus || '-'}`)
console.log(` inventoryItemId=${row.inventoryItemId || '-'} claimTokenId=${row.claimTokenId || '-'}`)
}
console.log('\n建议验证入口:')
console.log('- `/api/v1/admin/orders?platformOrderId=SEED-`')
console.log('- `/api/v1/admin/tasks?taskNo=DT`')
console.log('- `/api/v1/admin/inventory`')
}
@@ -1,97 +1,5 @@
#!/bin/sh
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()
})
}
-98
View File
@@ -1,98 +0,0 @@
#!/usr/bin/env bash
set -euo pipefail
BASE_URL="http://127.0.0.1:3000"
APP_SECRET=$(cd /Users/yml/codes/order-site-workspace/apps/backend && node --input-type=module -e "import { runtimeConfig } from './src/config/runtime.js'; process.stdout.write(String(runtimeConfig.platforms.agiso.appSecret || ''))")
ORDER_ID="${1:-SIM-ORDER-20260408-0001}"
SKU_ID="32768"
SKU_NAME="海底捞套餐"
sign_agiso() {
local raw_json="$1"
local ts="$2"
node -e '
const crypto = require("crypto")
const appSecret = process.argv[1]
const rawJson = process.argv[2]
const timestamp = process.argv[3]
const sign = crypto
.createHash("md5")
.update(`${appSecret}json${rawJson}timestamp${timestamp}${appSecret}`, "utf8")
.digest("hex")
.toLowerCase()
process.stdout.write(sign)
' "$APP_SECRET" "$raw_json" "$ts"
}
push_agiso_event() {
local aopic="$1"
local raw_json="$2"
local ts
ts=$(date +%s)
local sign
sign=$(sign_agiso "$raw_json" "$ts")
echo
echo "========== PUSH aopic=$aopic =========="
echo "ORDER_ID=$ORDER_ID"
echo
curl -sS -X POST "${BASE_URL}/api/v1/webhooks/agiso/trade?aopic=${aopic}&timestamp=${ts}&sign=${sign}" \
-H "Content-Type: application/x-www-form-urlencoded" \
--data-urlencode "json=${raw_json}"
echo
echo
}
create_payload() {
cat <<EOF
{"biz_order_id":"${ORDER_ID}","buyer_id":"buyer-local-001","buyer_name":"本地测试用户","receiver_contact":"13800000000","total_fee":"1","currency":"CNY","items":[{"goods_id":"${SKU_ID}","goods_name":"${SKU_NAME}","quantity":1}]}
EOF
}
paid_payload() {
cat <<EOF
{"biz_order_id":"${ORDER_ID}","buyer_id":"buyer-local-001","buyer_name":"本地测试用户","receiver_contact":"13800000000","total_fee":"1","currency":"CNY","order_status":"3","items":[{"goods_id":"${SKU_ID}","goods_name":"${SKU_NAME}","quantity":1}]}
EOF
}
confirm_payload() {
cat <<EOF
{"biz_order_id":"${ORDER_ID}","buyer_id":"buyer-local-001","buyer_name":"本地测试用户","receiver_contact":"13800000000","total_fee":"1","currency":"CNY","order_status":"4","items":[{"goods_id":"${SKU_ID}","goods_name":"${SKU_NAME}","quantity":1}]}
EOF
}
push_create() {
push_agiso_event "32" "$(create_payload)"
}
push_paid() {
push_agiso_event "1" "$(paid_payload)"
}
push_confirm() {
push_agiso_event "256" "$(confirm_payload)"
}
case "${2:-all}" in
create)
push_create
;;
paid)
push_paid
;;
confirm)
push_confirm
;;
all)
push_create
push_paid
;;
*)
echo "用法: $0 [ORDER_ID] [create|paid|confirm|all]"
exit 1
;;
esac
+5 -7
View File
@@ -1,17 +1,16 @@
import type { Request, Response, NextFunction } from "express";
import type { NextFunction, Request, Response } from "express";
import express from "express";
import 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);
+3 -48
View File
@@ -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([
+11 -52
View File
@@ -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',
},
}
}
+14 -85
View File
@@ -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;
}
-15
View File
@@ -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(
@@ -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)
})
+326
View File
@@ -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
}
+5 -1
View File
@@ -20,6 +20,7 @@ try {
logError("[startup]", "运行时配置校验失败,服务停止启动", error);
process.exit(1);
}
const app = createApp({
startupState,
isShutdownStarted: () => shutdownController?.isShutdownStarted() || false,
@@ -27,7 +28,10 @@ const app = createApp({
});
const server = app.listen(port, host, () => {
logInfo("[startup]", `order-site-backend listening on http://${host}:${port}`);
logInfo(
"[startup]",
`order-site-backend kuaishou-lite listening on http://${host}:${port}`
);
void bootstrapCoreServices(
startupState,
() => shutdownController?.isShutdownStarted() || false
+1 -1
View File
@@ -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;
};
}
+122
View File
@@ -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,
+8 -24
View File
@@ -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
}
+147 -80
View File
@@ -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
}
+24 -30
View File
@@ -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 -1
View File
@@ -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>
+8 -2
View File
@@ -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 -1
View File
@@ -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()
-117
View File
@@ -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
+3 -3
View File
@@ -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
}
+90 -357
View File
@@ -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 -27
View File
@@ -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
+66 -74
View File
@@ -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))
+29 -12
View File
@@ -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,
-77
View File
@@ -1,77 +0,0 @@
import { Router } from 'express'
import {
closeTencentBrowserSession,
createTencentBrowserSession,
getTencentBrowserSession,
getTencentBrowserSessionSummary,
getTencentBrowserSessionScreenshotPath,
reloadTencentBrowserSession,
redeemTencentBrowserSession,
} from '../services/session/session.js'
import {
buildNotFoundPayload,
buildSuccessPayload,
createRouteFileHandler,
createRouteHandler,
sendRouteError,
} from '../utils/http.js'
const router = Router()
router.post('/browser/session', createRouteHandler(
(req) => createTencentBrowserSession(req.body),
{ successMessage: '浏览器会话已创建', errorMessage: '创建浏览器会话失败', scope: '[browser/session]' },
))
router.get('/browser/session/:sessionId', async (req, res) => {
try {
const data = await getTencentBrowserSession(req.params.sessionId)
res.json(buildSuccessPayload(data, data.notice || 'ok'))
} catch (error) {
sendRouteError(res, error, '查询浏览器会话失败', '[browser/session/:sessionId]')
}
})
router.get('/browser/session/:sessionId/summary', async (req, res) => {
try {
const data = await getTencentBrowserSessionSummary(req.params.sessionId)
res.json(buildSuccessPayload(data, data.notice || 'ok'))
} catch (error) {
sendRouteError(res, error, '查询浏览器会话摘要失败', '[browser/session/:sessionId/summary]')
}
})
router.post('/browser/session/:sessionId/refresh', async (req, res) => {
try {
const data = await reloadTencentBrowserSession(req.params.sessionId)
res.json(buildSuccessPayload(data, data.notice || '后端页面已刷新'))
} catch (error) {
sendRouteError(res, error, '刷新后端页面失败', '[browser/session/:sessionId/refresh]')
}
})
router.post('/browser/session/:sessionId/redeem', async (req, res) => {
try {
const data = await redeemTencentBrowserSession(req.params.sessionId, req.body)
res.json(buildSuccessPayload(data, data.notice || '兑换完成'))
} catch (error) {
sendRouteError(res, error, '浏览器会话兑换失败', '[browser/session/:sessionId/redeem]')
}
})
router.get('/browser/session/:sessionId/screenshot', createRouteFileHandler(
(req) => getTencentBrowserSessionScreenshotPath(req.params.sessionId),
{ errorMessage: '读取兑换截图失败', scope: '[browser/session/:sessionId/screenshot]' },
))
router.delete('/browser/session/:sessionId', createRouteHandler(
(req) => closeTencentBrowserSession(req.params.sessionId),
{ successMessage: '浏览器会话已关闭', errorMessage: '关闭浏览器会话失败', scope: '[browser/session/:sessionId]' },
))
router.use((req, res) => {
res.status(404).json(buildNotFoundPayload(req))
})
export default router
-57
View File
@@ -1,57 +0,0 @@
import { Router } from 'express'
import { processAgisoTradeWebhook } from '../services/order/webhook-service.js'
import { createRequestId, logWebhook } from '../utils/logger.js'
import { buildNotFoundPayload, buildSuccessPayload, sendRouteError } from '../utils/http.js'
const router = Router()
router.post('/agiso/trade', async (req, res) => {
const requestId = createRequestId('wh')
const startedAt = Date.now()
logWebhook('[webhooks/agiso/trade]', '收到 Agiso webhook 请求', {
requestId,
method: req.method,
originalUrl: req.originalUrl,
ip: req.ip,
headers: req.headers,
query: req.query,
body: req.body,
})
try {
const data = await processAgisoTradeWebhook({
requestId,
headers: req.headers,
query: req.query,
body: req.body,
})
logWebhook('[webhooks/agiso/trade]', 'Agiso webhook 处理完成', {
requestId,
durationMs: Date.now() - startedAt,
result: data,
})
res.json(buildSuccessPayload(data, 'success'))
} catch (error) {
logWebhook(
'[webhooks/agiso/trade]',
'Agiso webhook 处理失败',
{
requestId,
durationMs: Date.now() - startedAt,
error,
},
{ level: 'error' },
)
sendRouteError(res, error, '处理 Agiso 订单通知失败', '[webhooks/agiso/trade]')
}
})
router.use((req, res) => {
res.status(404).json(buildNotFoundPayload(req))
})
export default router
@@ -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