合并 feat/kuaishou-lite-split: 架构瘦身与模块化重组
This commit is contained in:
+3
-1
@@ -16,12 +16,14 @@ apps/*/dist
|
|||||||
apps/*/.vite
|
apps/*/.vite
|
||||||
|
|
||||||
# backend runtime data
|
# backend runtime data
|
||||||
|
apps/backend/data/
|
||||||
apps/backend/data/*.db
|
apps/backend/data/*.db
|
||||||
apps/backend/data/*.db-shm
|
apps/backend/data/*.db-shm
|
||||||
apps/backend/data/*.db-wal
|
apps/backend/data/*.db-wal
|
||||||
apps/backend/data/browser-sessions
|
apps/backend/data/browser-sessions
|
||||||
apps/backend/data/redeem-screenshots
|
apps/backend/data/redeem-screenshots
|
||||||
|
apps/backend/data/cache
|
||||||
apps/backend/.npm-cache
|
apps/backend/.npm-cache
|
||||||
|
|
||||||
# deploy data (server only)
|
# legacy deploy data (server only)
|
||||||
deploy/data/
|
deploy/data/
|
||||||
|
|||||||
+12
-47
@@ -1,69 +1,34 @@
|
|||||||
# Mac Docker development template. Copy to .env for local full-stack testing.
|
# Mac Docker 本机联调模板。复制为 .env 后按需修改。
|
||||||
# For public claim links, update APP_BASE_URL and CLAIM_BASE_URL together.
|
# 如果使用 ngrok / cloudflared 等公网地址,只需要同步修改 APP_DOMAIN / CADDY_SITE_ADDR / CLAIM_BASE_URL。
|
||||||
# App
|
|
||||||
|
# App / Caddy
|
||||||
APP_DOMAIN=localhost
|
APP_DOMAIN=localhost
|
||||||
APP_BASE_URL=http://localhost
|
|
||||||
CADDY_SITE_ADDR=http://localhost
|
CADDY_SITE_ADDR=http://localhost
|
||||||
TZ=Asia/Shanghai
|
TZ=Asia/Shanghai
|
||||||
BACKEND_PORT=3000
|
BACKEND_PORT=3000
|
||||||
|
|
||||||
# Claim Links
|
# Claim Links
|
||||||
CLAIM_BASE_URL=http://localhost/#/claim
|
CLAIM_BASE_URL=http://localhost/#/claim
|
||||||
CLAIM_TOKEN_TTL_HOURS=24
|
|
||||||
|
|
||||||
# Database
|
# Database
|
||||||
POSTGRES_DB=order_site
|
POSTGRES_DB=order_site
|
||||||
POSTGRES_USER=postgres
|
POSTGRES_USER=postgres
|
||||||
POSTGRES_PASSWORD=postgres
|
POSTGRES_PASSWORD=postgres
|
||||||
DATABASE_URL=postgres://postgres:postgres@postgres:5432/order_site
|
DATABASE_URL=postgres://postgres:postgres@postgres:5432/order_site
|
||||||
DATABASE_SSL=false
|
|
||||||
DATABASE_MAX_CONNECTIONS=10
|
|
||||||
POSTGRES_PORT=5432
|
POSTGRES_PORT=5432
|
||||||
|
|
||||||
# Data And Logging
|
# Logging
|
||||||
DATA_ROOT=/app/data
|
|
||||||
LOG_LEVEL=info
|
LOG_LEVEL=info
|
||||||
|
LOG_RETENTION_DAYS=30
|
||||||
# OCR
|
|
||||||
OCR_IMAGE=order_site-ocr-worker:latest
|
|
||||||
OCR_PORT=8100
|
|
||||||
OCR_BASE_URL=http://ocr-worker:8100
|
|
||||||
|
|
||||||
# Tencent Browser
|
|
||||||
TENCENT_REDEEM_PROOF_MODE=basic
|
|
||||||
TENCENT_BROWSER_HEADLESS=false
|
|
||||||
TENCENT_BROWSER_DEVTOOLS=false
|
|
||||||
TENCENT_BROWSER_PREWARM=true
|
|
||||||
TENCENT_BROWSER_KEEP_ALIVE=true
|
|
||||||
TENCENT_BROWSER_SLOW_MO=200
|
|
||||||
TENCENT_BROWSER_VNC_RESOLUTION=1600x900x24
|
|
||||||
TENCENT_SESSION_DEBUG=true
|
|
||||||
TENCENT_BROWSER_NOVNC_ENABLED=true
|
|
||||||
TENCENT_BROWSER_NOVNC_PORT=6080
|
|
||||||
TENCENT_BROWSER_VNC_PORT=5900
|
|
||||||
|
|
||||||
# Admin
|
# Admin
|
||||||
ADMIN_SESSION_SECRET=dev-local-session-secret
|
ADMIN_SESSION_SECRET=dev-local-session-secret
|
||||||
ADMIN_SESSION_TTL_HOURS=12
|
|
||||||
ADMIN_DEFAULT_USERS_JSON=[{"username":"admin","password":"dev-admin-123456","role":"admin"},{"username":"operator","password":"dev-operator-123456","role":"operator"}]
|
ADMIN_DEFAULT_USERS_JSON=[{"username":"admin","password":"dev-admin-123456","role":"admin"},{"username":"operator","password":"dev-operator-123456","role":"operator"}]
|
||||||
|
|
||||||
# Agiso
|
|
||||||
AGISO_APP_SECRET=
|
|
||||||
AGISO_MESSAGING_ENABLED=true
|
|
||||||
AGISO_TRADE_DETAIL_ENDPOINT=https://gw-api.agiso.com/aldsIdle/Order/Detail
|
|
||||||
AGISO_TRADE_DETAIL_API_VERSION=1
|
|
||||||
AGISO_TRADE_DETAIL_TIMEOUT_MS=5000
|
|
||||||
AGISO_AUTO_DELIVERY_ENABLED=true
|
|
||||||
AGISO_AUTO_DELIVERY_ENDPOINT=https://gw-api.agiso.com/aldsIdle/Order/DummySend
|
|
||||||
AGISO_AUTO_DELIVERY_API_VERSION=1
|
|
||||||
AGISO_MESSAGE_API_VERSION=1
|
|
||||||
AGISO_SEND_MESSAGE_ENDPOINT=https://gw-api.agiso.com/aldsIdle/ImMsg/SendMsg
|
|
||||||
|
|
||||||
# 91Kaquan
|
# 91Kaquan
|
||||||
NINETYONE_USER_ID=
|
KAQUAN91_USER_ID=
|
||||||
NINETYONE_SECRET=
|
KAQUAN91_SECRET=
|
||||||
NINETYONE_VERSION=1.0
|
|
||||||
NINETYONE_SHOP_ID=91kaquan
|
# Cloudtentacles
|
||||||
NINETYONE_SHOP_NAME=91卡券
|
CLOUDTENTACLES_DEVICE_ID=08bc9d8c-fd15-48ea-bc00-8d754076cafc
|
||||||
NINETYONE_TIMESTAMP_TOLERANCE_SECONDS=600
|
CLOUDTENTACLES_DEVICE_TYPE=1
|
||||||
NINETYONE_CARDS_ENCODING=aes-256-ecb-base64
|
|
||||||
|
|||||||
+12
-43
@@ -1,64 +1,33 @@
|
|||||||
# Production deployment template. Copy to .env and replace every change-me value.
|
# 生产部署模板。复制为 .env 后替换所有 change-me 值。
|
||||||
# Keep real secrets out of git.
|
# 真实密钥不要提交到 git。
|
||||||
# App
|
|
||||||
|
# App / Caddy
|
||||||
APP_DOMAIN=order.khhao.com
|
APP_DOMAIN=order.khhao.com
|
||||||
APP_BASE_URL=https://order.khhao.com
|
|
||||||
CADDY_SITE_ADDR=https://order.khhao.com
|
CADDY_SITE_ADDR=https://order.khhao.com
|
||||||
TZ=Asia/Shanghai
|
TZ=Asia/Shanghai
|
||||||
BACKEND_PORT=3000
|
BACKEND_PORT=3000
|
||||||
|
|
||||||
# Claim Links
|
# Claim Links
|
||||||
CLAIM_BASE_URL=https://order.khhao.com/#/claim
|
CLAIM_BASE_URL=https://order.khhao.com/#/claim
|
||||||
CLAIM_TOKEN_TTL_HOURS=24
|
|
||||||
|
|
||||||
# Database
|
# Database
|
||||||
POSTGRES_DB=order_site
|
POSTGRES_DB=order_site
|
||||||
POSTGRES_USER=postgres
|
POSTGRES_USER=postgres
|
||||||
POSTGRES_PASSWORD=change-me-postgres-password
|
POSTGRES_PASSWORD=change-me-postgres-password
|
||||||
DATABASE_URL=postgres://postgres:change-me-postgres-password@postgres:5432/order_site
|
DATABASE_URL=postgres://postgres:change-me-postgres-password@postgres:5432/order_site
|
||||||
DATABASE_SSL=false
|
|
||||||
DATABASE_MAX_CONNECTIONS=20
|
|
||||||
|
|
||||||
# Data And Logging
|
# Logging
|
||||||
DATA_ROOT=/app/data
|
|
||||||
LOG_LEVEL=info
|
LOG_LEVEL=info
|
||||||
|
LOG_RETENTION_DAYS=30
|
||||||
# OCR
|
|
||||||
OCR_IMAGE=order_site-ocr-worker:latest
|
|
||||||
OCR_BASE_URL=http://ocr-worker:8100
|
|
||||||
|
|
||||||
# Tencent Browser
|
|
||||||
TENCENT_REDEEM_PROOF_MODE=full
|
|
||||||
TENCENT_BROWSER_HEADLESS=true
|
|
||||||
TENCENT_BROWSER_DEVTOOLS=false
|
|
||||||
TENCENT_BROWSER_PREWARM=true
|
|
||||||
TENCENT_BROWSER_KEEP_ALIVE=true
|
|
||||||
TENCENT_BROWSER_SLOW_MO=0
|
|
||||||
TENCENT_BROWSER_VNC_RESOLUTION=1600x900x24
|
|
||||||
TENCENT_SESSION_DEBUG=false
|
|
||||||
|
|
||||||
# Admin
|
# Admin
|
||||||
ADMIN_SESSION_SECRET=change-me-long-random-session-secret
|
ADMIN_SESSION_SECRET=change-me-long-random-session-secret
|
||||||
ADMIN_SESSION_TTL_HOURS=12
|
|
||||||
ADMIN_DEFAULT_USERS_JSON=[{"username":"admin","password":"change-me-admin-password","role":"admin"},{"username":"operator","password":"change-me-operator-password","role":"operator"}]
|
ADMIN_DEFAULT_USERS_JSON=[{"username":"admin","password":"change-me-admin-password","role":"admin"},{"username":"operator","password":"change-me-operator-password","role":"operator"}]
|
||||||
|
|
||||||
# Agiso
|
|
||||||
AGISO_APP_SECRET=change-me-agiso-app-secret
|
|
||||||
AGISO_MESSAGING_ENABLED=true
|
|
||||||
AGISO_TRADE_DETAIL_ENDPOINT=https://gw-api.agiso.com/aldsIdle/Order/Detail
|
|
||||||
AGISO_TRADE_DETAIL_API_VERSION=1
|
|
||||||
AGISO_TRADE_DETAIL_TIMEOUT_MS=5000
|
|
||||||
AGISO_AUTO_DELIVERY_ENABLED=true
|
|
||||||
AGISO_AUTO_DELIVERY_ENDPOINT=https://gw-api.agiso.com/aldsIdle/Order/DummySend
|
|
||||||
AGISO_AUTO_DELIVERY_API_VERSION=1
|
|
||||||
AGISO_MESSAGE_API_VERSION=1
|
|
||||||
AGISO_SEND_MESSAGE_ENDPOINT=https://gw-api.agiso.com/aldsIdle/ImMsg/SendMsg
|
|
||||||
|
|
||||||
# 91Kaquan
|
# 91Kaquan
|
||||||
NINETYONE_USER_ID=
|
KAQUAN91_USER_ID=
|
||||||
NINETYONE_SECRET=
|
KAQUAN91_SECRET=
|
||||||
NINETYONE_VERSION=1.0
|
|
||||||
NINETYONE_SHOP_ID=91kaquan
|
# Cloudtentacles
|
||||||
NINETYONE_SHOP_NAME=91卡券
|
CLOUDTENTACLES_DEVICE_ID=08bc9d8c-fd15-48ea-bc00-8d754076cafc
|
||||||
NINETYONE_TIMESTAMP_TOLERANCE_SECONDS=600
|
CLOUDTENTACLES_DEVICE_TYPE=1
|
||||||
NINETYONE_CARDS_ENCODING=aes-256-ecb-base64
|
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
node_modules
|
node_modules
|
||||||
.env
|
.env
|
||||||
|
.env copy
|
||||||
.env.local
|
.env.local
|
||||||
.DS_Store
|
.DS_Store
|
||||||
|
|
||||||
@@ -11,8 +12,12 @@ apps/*/.env.local
|
|||||||
apps/backend/data/*.db
|
apps/backend/data/*.db
|
||||||
apps/backend/data/*.db-shm
|
apps/backend/data/*.db-shm
|
||||||
apps/backend/data/*.db-wal
|
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/browser-sessions
|
||||||
apps/backend/data/redeem-screenshots
|
apps/backend/data/redeem-screenshots
|
||||||
|
apps/backend/data/cache
|
||||||
apps/backend/data/logs
|
apps/backend/data/logs
|
||||||
|
|
||||||
.ace-tool
|
.ace-tool
|
||||||
|
|||||||
@@ -1,14 +1,13 @@
|
|||||||
# order_site
|
# order_site
|
||||||
|
|
||||||
订单自动兑换与履约管理系统。
|
快手自动发货与履约管理系统。
|
||||||
|
|
||||||
项目采用前后端分离架构,主要用于接收订单、匹配履约规则、生成领取链接、管理库存、执行自动/半自动兑换,并提供后台页面处理人工复核、平台配置和任务追踪。
|
项目采用前后端分离架构,用于接收订单、匹配快手履约规则、管理云卡资源,并提供后台页面处理平台配置、任务追踪和人工复核。
|
||||||
|
|
||||||
## 技术栈
|
## 技术栈
|
||||||
|
|
||||||
- 前端:Vue 3、Vite、Element Plus、TypeScript
|
- 前端:Vue 3、Vite、Element Plus、TypeScript
|
||||||
- 后端:Node.js、Express、PostgreSQL、Playwright
|
- 后端:Node.js、Express、PostgreSQL
|
||||||
- OCR:外部镜像服务,后端通过 `OCR_BASE_URL` 调用
|
|
||||||
- 网关:Caddy
|
- 网关:Caddy
|
||||||
- 本地与生产运行:Docker Compose
|
- 本地与生产运行:Docker Compose
|
||||||
|
|
||||||
@@ -30,7 +29,7 @@ docker-compose.yml 生产/服务器 Compose
|
|||||||
|
|
||||||
## 本地开发
|
## 本地开发
|
||||||
|
|
||||||
推荐直接使用 Docker,本地不需要额外维护 Node、PostgreSQL、Playwright 浏览器和 OCR 运行环境。
|
推荐直接使用 Docker,本地不需要额外维护 Node 和 PostgreSQL。
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
cp .env.mac-docker.example .env
|
cp .env.mac-docker.example .env
|
||||||
@@ -42,8 +41,6 @@ docker compose -f docker-compose.dev.yml up -d --build
|
|||||||
- 前端、后台、领取页:`http://localhost`
|
- 前端、后台、领取页:`http://localhost`
|
||||||
- 后端健康检查:`http://localhost/health`
|
- 后端健康检查:`http://localhost/health`
|
||||||
- 后端 API 前缀:`http://localhost/api/v1/...`
|
- 后端 API 前缀:`http://localhost/api/v1/...`
|
||||||
- OCR worker:`http://127.0.0.1:8100/health`
|
|
||||||
- noVNC 浏览器画面:`http://127.0.0.1:6080/vnc.html`
|
|
||||||
|
|
||||||
查看容器状态:
|
查看容器状态:
|
||||||
|
|
||||||
@@ -56,7 +53,6 @@ docker compose -f docker-compose.dev.yml ps
|
|||||||
```bash
|
```bash
|
||||||
docker compose -f docker-compose.dev.yml logs -f backend
|
docker compose -f docker-compose.dev.yml logs -f backend
|
||||||
docker compose -f docker-compose.dev.yml logs -f frontend
|
docker compose -f docker-compose.dev.yml logs -f frontend
|
||||||
docker compose -f docker-compose.dev.yml logs -f ocr-worker
|
|
||||||
```
|
```
|
||||||
|
|
||||||
停止环境:
|
停止环境:
|
||||||
@@ -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 backend npm run build
|
||||||
docker compose -f docker-compose.dev.yml exec -T frontend npm run typecheck
|
docker compose -f docker-compose.dev.yml exec -T frontend npm run typecheck
|
||||||
docker compose -f docker-compose.dev.yml exec -T frontend npm run lint
|
docker compose -f docker-compose.dev.yml exec -T frontend npm run lint
|
||||||
```
|
|
||||||
|
|
||||||
前端生产构建:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
docker compose -f docker-compose.dev.yml exec -T frontend npm run build
|
docker compose -f docker-compose.dev.yml exec -T frontend npm run build
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -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
|
docker compose -f docker-compose.dev.yml exec -T backend npm run db:migrate
|
||||||
```
|
```
|
||||||
|
|
||||||
## 热更新
|
当前数据库脚本只保留新库初始化结构,不维护历史测试库的升级兼容;切换到这版前请重建开发库或重置 Docker volume。
|
||||||
|
|
||||||
开发版 Compose 会把源码挂载进容器:
|
|
||||||
|
|
||||||
- 修改 `apps/backend/src`:后端容器内 `tsx watch` 自动重启
|
|
||||||
- 修改 `apps/frontend/src`:Vite 自动 HMR
|
|
||||||
- 修改数据库 migration:重启后端会自动执行迁移
|
|
||||||
|
|
||||||
如果只改业务代码,一般不需要重新 `--build`。改 Dockerfile、系统依赖或基础镜像时再重新构建。
|
|
||||||
|
|
||||||
## 环境变量
|
## 环境变量
|
||||||
|
|
||||||
@@ -115,15 +98,12 @@ cp .env.server.example .env
|
|||||||
|
|
||||||
常用变量:
|
常用变量:
|
||||||
|
|
||||||
- `APP_BASE_URL`:应用公网地址,用于推导领取链接
|
- `APP_DOMAIN` / `CADDY_SITE_ADDR`:Caddy 对外域名与监听地址
|
||||||
- `CLAIM_BASE_URL`:领取页地址,优先级高于 `APP_BASE_URL`
|
- `CLAIM_BASE_URL`:领取页完整地址
|
||||||
|
- `DATABASE_URL`:PostgreSQL 连接串
|
||||||
- `ADMIN_SESSION_SECRET`:后台登录态签名密钥
|
- `ADMIN_SESSION_SECRET`:后台登录态签名密钥
|
||||||
- `ADMIN_DEFAULT_USERS_JSON`:默认后台用户
|
- `ADMIN_DEFAULT_USERS_JSON`:默认后台用户
|
||||||
- `DATABASE_URL`:PostgreSQL 连接串
|
- `KAQUAN91_USER_ID` / `KAQUAN91_SECRET`:91 卡券开放接口凭据
|
||||||
- `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`
|
|
||||||
|
|
||||||
## 运行数据
|
## 运行数据
|
||||||
|
|
||||||
@@ -132,16 +112,16 @@ cp .env.server.example .env
|
|||||||
- 后端配置与运行产物:`apps/backend/data`
|
- 后端配置与运行产物:`apps/backend/data`
|
||||||
- PostgreSQL 数据:Docker volume `postgres_dev_data`
|
- PostgreSQL 数据:Docker volume `postgres_dev_data`
|
||||||
- 后端日志:`apps/backend/data/logs`
|
- 后端日志:`apps/backend/data/logs`
|
||||||
- 浏览器会话:`apps/backend/data/browser-sessions`
|
|
||||||
- 兑换截图:`apps/backend/data/redeem-screenshots`
|
|
||||||
|
|
||||||
生产部署数据:
|
生产部署数据:
|
||||||
|
|
||||||
- 后端数据挂载到 `deploy/data/backend`
|
- 后端数据挂载到 `apps/backend/data`,与本地开发路径保持一致
|
||||||
- PostgreSQL 数据保存在 Docker volume `postgres_data`
|
- PostgreSQL 数据保存在 Docker volume `postgres_data`
|
||||||
- Caddy 数据保存在 Docker volume `caddy_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
|
```bash
|
||||||
cp .env.server.example .env
|
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
|
docker compose up -d --build
|
||||||
```
|
```
|
||||||
|
|
||||||
|
Ubuntu 服务器也可以使用一键部署脚本:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
bash deploy/ubuntu-deploy.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
第一次执行如果没有 `.env`,脚本会从 `.env.server.example` 生成模板并停止;编辑真实配置后再次执行即可。脚本会自动检查 Docker、检查 `.env` 占位值、修正后端数据目录权限、构建启动服务并等待健康检查通过。
|
||||||
|
|
||||||
生产入口:
|
生产入口:
|
||||||
|
|
||||||
- 前端与领取页:`https://你的域名/`
|
- 前端与领取页:`https://你的域名/`
|
||||||
@@ -162,78 +151,21 @@ docker compose up -d --build
|
|||||||
生产 Compose 会自动:
|
生产 Compose 会自动:
|
||||||
|
|
||||||
- 启动 PostgreSQL
|
- 启动 PostgreSQL
|
||||||
- 启动 OCR worker
|
|
||||||
- 启动后端并执行 migration
|
- 启动后端并执行 migration
|
||||||
- 初始化默认后台管理员
|
- 初始化默认后台管理员
|
||||||
- 同步履约目录
|
- 同步履约目录
|
||||||
- 通过 Caddy 暴露前端和 API
|
- 通过 Caddy 暴露前端和 API
|
||||||
|
|
||||||
查看生产日志:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
docker compose logs -f backend
|
|
||||||
docker compose logs -f web
|
|
||||||
```
|
|
||||||
|
|
||||||
## 开发数据脚本
|
|
||||||
|
|
||||||
后端提供开发数据脚本:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
docker compose -f docker-compose.dev.yml exec -T backend npm run seed:dev-data
|
|
||||||
docker compose -f docker-compose.dev.yml exec -T backend npm run cleanup:dev-data
|
|
||||||
```
|
|
||||||
|
|
||||||
脚本默认有预览/确认语义,执行前注意终端输出说明。
|
|
||||||
|
|
||||||
## 主要业务模块
|
## 主要业务模块
|
||||||
|
|
||||||
- `apps/backend/src/routes`:API 路由
|
- `apps/backend/src/routes`:API 路由
|
||||||
- `apps/backend/src/services/order`:订单、webhook、库存和履约匹配
|
- `apps/backend/src/services/order`:订单、商品匹配和履约任务编排
|
||||||
- `apps/backend/src/services/fulfillment`:快手 Cloud 等履约编排
|
- `apps/backend/src/services/fulfillment`:快手 Cloud 履约编排
|
||||||
- `apps/backend/src/services/session`:腾讯登录、二维码、兑换和截图
|
|
||||||
- `apps/backend/src/services/admin`:后台读写、权限、配置管理
|
- `apps/backend/src/services/admin`:后台读写、权限、配置管理
|
||||||
- `apps/backend/src/services/platforms`:外部平台 API 封装
|
|
||||||
- `apps/backend/src/repositories`:数据库访问
|
- `apps/backend/src/repositories`:数据库访问
|
||||||
- `apps/frontend/src/views/admin`:后台页面
|
- `apps/frontend/src/views/admin`:后台页面
|
||||||
- `apps/frontend/src/views/claim`:用户领取页
|
- `apps/frontend/src/views/claim`:用户领取页
|
||||||
|
|
||||||
## 排障
|
|
||||||
|
|
||||||
如果页面打不开:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
docker compose -f docker-compose.dev.yml ps
|
|
||||||
docker compose -f docker-compose.dev.yml logs -f web
|
|
||||||
```
|
|
||||||
|
|
||||||
如果后端未 ready:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
docker compose -f docker-compose.dev.yml logs -f backend
|
|
||||||
curl http://localhost/health/ready
|
|
||||||
```
|
|
||||||
|
|
||||||
如果浏览器兑换或扫码异常:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
docker compose -f docker-compose.dev.yml logs -f backend
|
|
||||||
open http://127.0.0.1:6080/vnc.html
|
|
||||||
```
|
|
||||||
|
|
||||||
如果前端类型或 lint 失败:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
docker compose -f docker-compose.dev.yml exec -T frontend npm run typecheck
|
|
||||||
docker compose -f docker-compose.dev.yml exec -T frontend npm run lint
|
|
||||||
```
|
|
||||||
|
|
||||||
如果后端单测失败:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
docker compose -f docker-compose.dev.yml exec -T backend npm test
|
|
||||||
```
|
|
||||||
|
|
||||||
## 提交前检查清单
|
## 提交前检查清单
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
@@ -249,4 +181,3 @@ docker compose -f docker-compose.dev.yml exec -T frontend npm run lint
|
|||||||
- `.env` 未被提交
|
- `.env` 未被提交
|
||||||
- 数据库 migration 与代码一起提交
|
- 数据库 migration 与代码一起提交
|
||||||
- 领取链接相关改动已在 Docker 环境验证
|
- 领取链接相关改动已在 Docker 环境验证
|
||||||
- 涉及 Playwright/OCR 的改动已看过 noVNC 或相关日志
|
|
||||||
|
|||||||
@@ -12,3 +12,6 @@ data/logs/
|
|||||||
data/*.db
|
data/*.db
|
||||||
data/*.db-shm
|
data/*.db-shm
|
||||||
data/*.db-wal
|
data/*.db-wal
|
||||||
|
data/*.json
|
||||||
|
!data/*.example.json
|
||||||
|
data/cloudtentacles-session.json
|
||||||
|
|||||||
@@ -0,0 +1,6 @@
|
|||||||
|
{
|
||||||
|
"semi": false,
|
||||||
|
"singleQuote": true,
|
||||||
|
"printWidth": 100,
|
||||||
|
"trailingComma": "all"
|
||||||
|
}
|
||||||
+37
-102
@@ -1,13 +1,8 @@
|
|||||||
# order-site-backend
|
# order-site-backend
|
||||||
|
|
||||||
腾讯活动浏览器会话后端。
|
快手轻量后端服务。
|
||||||
|
|
||||||
当前主线只有一条:
|
后端负责订单入库、履约任务编排、快手 Cloud / 91 卡券 / 快手小店核销配置、后台管理接口和数据库迁移。默认入口就是快手轻量后端。
|
||||||
|
|
||||||
- 后端托管 Playwright 浏览器
|
|
||||||
- 前端展示二维码并轮询会话状态
|
|
||||||
- 登录完成后,后端直接在活动页填写 CDK、识别验证码、执行兑换
|
|
||||||
- 兑换完成后返回状态,并按配置决定是否生成截图/证明产物
|
|
||||||
|
|
||||||
## 快速启动
|
## 快速启动
|
||||||
|
|
||||||
@@ -20,21 +15,18 @@ npm install
|
|||||||
复制并填写环境变量后启动:
|
复制并填写环境变量后启动:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
cp /Users/yml/codes/order-site-workspace/.env.mac-docker.example /Users/yml/codes/order-site-workspace/.env
|
cp ../../.env.mac-docker.example ../../.env
|
||||||
cd /Users/yml/codes/order-site-workspace/apps/backend
|
|
||||||
npm run dev
|
npm run dev
|
||||||
```
|
```
|
||||||
|
|
||||||
Linux 部署:
|
生产构建:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
npm install
|
|
||||||
npm run build
|
npm run build
|
||||||
npm run browser:install:linux
|
|
||||||
npm run start
|
npm run start
|
||||||
```
|
```
|
||||||
|
|
||||||
后端渐进 TypeScript 迁移已经进入源码和构建双轨模式:
|
常用验证:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
npm run typecheck
|
npm run typecheck
|
||||||
@@ -42,120 +34,63 @@ npm run build
|
|||||||
npm test
|
npm test
|
||||||
```
|
```
|
||||||
|
|
||||||
当前约定:
|
|
||||||
|
|
||||||
- 开发:`npm run dev` 使用 `tsx watch` 直接运行 `src`
|
|
||||||
- 数据库迁移:`npm run db:migrate` 使用 `tsx`
|
|
||||||
- 生产:`npm run build` 输出 `dist`,`npm run start` 运行 `dist/index.js`
|
|
||||||
- 新增或迁移后端模块优先使用 `.ts`
|
|
||||||
|
|
||||||
## 配置
|
## 配置
|
||||||
|
|
||||||
当前后端采用 `.env` 单一路径做环境配置:
|
后端采用工作区根目录 `.env` 做环境配置:
|
||||||
|
|
||||||
- 通用默认值:`src/config/defaults.ts`
|
- 通用默认值:`src/config/defaults.ts`
|
||||||
- 本地/部署覆盖:工作区根目录 `.env`
|
- 本地/部署覆盖:工作区根目录 `.env`
|
||||||
|
|
||||||
当前最常改的配置有:
|
常用配置:
|
||||||
|
|
||||||
- 服务端口
|
- 服务端口 `PORT`(Docker 部署中由根目录 `.env` 的 `BACKEND_PORT` 映射)
|
||||||
- 日志级别 `LOG_LEVEL=debug|info|warn|error`
|
- 日志级别 `LOG_LEVEL=debug|info|warn|error`
|
||||||
- 浏览器是否无头、是否预热、是否常驻、slowMo
|
- 数据库连接 `DATABASE_URL`
|
||||||
- OCR 外部服务镜像 `OCR_IMAGE` 与访问地址 `OCR_BASE_URL`
|
- 后台登录密钥 `ADMIN_SESSION_SECRET`
|
||||||
- 会话调试开关
|
- 默认后台用户 `ADMIN_DEFAULT_USERS_JSON`
|
||||||
- 兑换证明模式 `full | basic | off`
|
- 91 卡券开放接口凭据 `KAQUAN91_USER_ID` / `KAQUAN91_SECRET`
|
||||||
- Agiso 全局配置与后台店铺文件配置
|
|
||||||
|
|
||||||
### Agiso 多店铺
|
|
||||||
|
|
||||||
当前支持同一个 Agiso 应用下配置多个店铺的消息 token。
|
|
||||||
|
|
||||||
- 全局兜底配置仍走 `.env`:
|
|
||||||
- `AGISO_APP_SECRET`
|
|
||||||
- `AGISO_MESSAGING_ENABLED`
|
|
||||||
- 默认消息模板与店铺级覆盖配置改为文件:
|
|
||||||
- `apps/backend/data/agiso-shops.json`
|
|
||||||
- 后台维护入口:
|
|
||||||
- `#/admin/platform-shops`
|
|
||||||
|
|
||||||
消息发送时会按 webhook 识别出的 `shop_id` 读取 `agiso-shops.json` 中的店铺配置;模板未命中时,会先回退到文件里的 `defaults`,再回退到系统内置默认值。店铺的 `accessToken` 也需要在文件中维护,不再使用全局 `.env` 兜底。
|
|
||||||
|
|
||||||
## 接口
|
## 接口
|
||||||
|
|
||||||
- `POST /api/v1/tencent/browser/session`
|
- `GET /health`:健康检查
|
||||||
创建浏览器会话,返回 `sessionId`、二维码和初始状态
|
- `GET /health/live`:存活检查
|
||||||
- `GET /api/v1/tencent/browser/session/:sessionId`
|
- `GET /health/ready`:就绪检查
|
||||||
返回完整会话状态
|
- `/api/v1/open/91/*`:91 卡券开放回调
|
||||||
- `GET /api/v1/tencent/browser/session/:sessionId/summary`
|
- `/api/v1/claim/*`:领取页接口
|
||||||
轮询用轻量摘要接口,默认不返回二维码 base64
|
- `/api/v1/admin/*`:后台管理接口
|
||||||
- `POST /api/v1/tencent/browser/session/:sessionId/refresh`
|
|
||||||
强制刷新后端活动页
|
|
||||||
- `POST /api/v1/tencent/browser/session/:sessionId/redeem`
|
|
||||||
执行兑换
|
|
||||||
- `GET /api/v1/tencent/browser/session/:sessionId/screenshot`
|
|
||||||
读取最近一次兑换截图
|
|
||||||
- `DELETE /api/v1/tencent/browser/session/:sessionId`
|
|
||||||
关闭浏览器会话
|
|
||||||
|
|
||||||
## 目录
|
## 目录
|
||||||
|
|
||||||
- `src/index.ts`
|
- `src/index.ts`:服务入口
|
||||||
Express 入口
|
- `src/app.ts`:Express 应用组装
|
||||||
- `src/routes/tencent.ts`
|
- `src/routes`:API 路由
|
||||||
接口路由
|
- `src/services/order`:订单、商品匹配和履约任务编排
|
||||||
- `src/services/session/session.ts`
|
- `src/services/fulfillment`:快手 Cloud 履约编排
|
||||||
会话编排与浏览器生命周期
|
- `src/services/admin`:后台读写、权限、配置管理
|
||||||
- `src/services/session/*.ts`
|
- `src/repositories`:数据库访问
|
||||||
登录、兑换、OCR、凭证产物等模块
|
- `src/db/migrations`:新库初始化脚本
|
||||||
|
|
||||||
## Docker 开发
|
## Docker 开发
|
||||||
|
|
||||||
当前 `docker-compose.dev.yml` 已切到源码挂载模式:
|
开发版 Compose 会把源码挂载进容器:
|
||||||
|
|
||||||
- `postgres` 容器提供开发库,后端通过 `DATABASE_URL` 连接
|
- `postgres` 容器提供开发库,后端通过 `DATABASE_URL` 连接
|
||||||
- `ocr-worker` 使用外部镜像启动,后端通过 `OCR_BASE_URL=http://ocr-worker:8100` 调用
|
- 后端容器启动时执行依赖安装和 `npm run dev`
|
||||||
- 后端容器启动时执行 `npm install` 和 `npm run dev`
|
- 平时改 `src/` 或 `.env`,一般不需要重建镜像
|
||||||
- 平时改 `src/` 或 `.env`,一般都不需要重建镜像
|
|
||||||
- 生产镜像会在 build 阶段编译后端并只复制 `dist`、`config` 和运行依赖
|
|
||||||
|
|
||||||
只有下面几类改动通常还需要 `docker compose -f docker-compose.dev.yml up -d --build backend`:
|
只有下面几类改动通常还需要重新构建 backend:
|
||||||
|
|
||||||
- `deploy/docker/backend.Dockerfile`
|
- `deploy/docker/backend*.Dockerfile`
|
||||||
- 系统层依赖
|
- Node 版本或系统层依赖
|
||||||
- Playwright 浏览器基础环境
|
- `package.json` / `package-lock.json`
|
||||||
|
|
||||||
## 产物
|
## 运行产物
|
||||||
|
|
||||||
浏览器会话产物默认保存在:
|
|
||||||
|
|
||||||
- `data/browser-sessions/<sessionId>/qq-qr.png`
|
|
||||||
- `data/browser-sessions/<sessionId>/session.json`
|
|
||||||
- `data/browser-sessions/<sessionId>/captcha-attempt-*.png`
|
|
||||||
- `data/browser-sessions/<sessionId>/redeem-result.png`
|
|
||||||
- `data/browser-sessions/<sessionId>/page.html`
|
|
||||||
- `data/browser-sessions/<sessionId>/result.json`
|
|
||||||
|
|
||||||
这些都是运行时产物,默认不提交 Git。
|
|
||||||
|
|
||||||
运行日志默认保存在:
|
运行日志默认保存在:
|
||||||
|
|
||||||
- `data/logs/app-YYYY-MM-DD.log`
|
- `data/logs/app-YYYY-MM-DD.log`
|
||||||
- `data/logs/webhook-YYYY-MM-DD.log`
|
- `data/logs/integration-YYYY-MM-DD.log`
|
||||||
|
|
||||||
日志按天切分,默认自动清理 7 天前的旧日志。
|
日志按天切分,默认自动清理 7 天前的旧日志。成功的 `/health`、`/health/live`、`/health/ready` 探活请求默认不写 access log。
|
||||||
成功的 `/health`、`/health/live`、`/health/ready` 探活请求默认不写 access log。
|
|
||||||
|
|
||||||
`LOG_LEVEL` 默认是 `info`:
|
`data/*.json` 为本地运行配置,可能包含账号、Cookie、token 或平台配置,默认不提交;请复制 `data/*.example.json` 后填写真实值。
|
||||||
|
|
||||||
- `debug`:输出最详细的调试日志
|
|
||||||
- `info`:输出启动、请求、业务成功/失败等常规日志
|
|
||||||
- `warn`:只保留告警和错误
|
|
||||||
- `error`:只保留错误
|
|
||||||
|
|
||||||
OCR 识别通过 `OCR_BASE_URL` 调用 HTTP 服务。Docker Compose 会启动 `ocr-worker` 外部镜像并注入默认地址 `http://ocr-worker:8100`;如果单独运行后端且未配置该地址,启动健康检查会标记 OCR 为 degraded,兑换中需要 OCR 的流程会在调用时返回明确错误。
|
|
||||||
|
|
||||||
`TENCENT_REDEEM_PROOF_MODE` 对应的配置文件项会影响生成强度:
|
|
||||||
|
|
||||||
- `full`:完整证明
|
|
||||||
- `basic`:只保留最终截图和结果 JSON
|
|
||||||
- `off`:不生成证明文件
|
|
||||||
|
|||||||
@@ -1,23 +0,0 @@
|
|||||||
{
|
|
||||||
"defaults": {
|
|
||||||
"messageTemplate": "您的订单 {platformOrderId} 已创建领取链接,请您复制链接到浏览器自动兑换,请尽快打开并完成领取:\n\n{claimUrl}",
|
|
||||||
"autoDeliveryMessageTemplate": "亲亲,您购买的兑换码已自行兑换成功,请在游戏中邮件领取,感谢您的支持"
|
|
||||||
},
|
|
||||||
"shops": {
|
|
||||||
"693760716": {
|
|
||||||
"enabled": true,
|
|
||||||
"shopName": "大锤商行",
|
|
||||||
"accessToken": "AldsIdlefmykv7w6xdvgf9r2ymsxrmznux74tyaehc9pwygdnrgr4"
|
|
||||||
},
|
|
||||||
"2037495620": {
|
|
||||||
"enabled": true,
|
|
||||||
"shopName": "羊小胖。",
|
|
||||||
"accessToken": "AldsIdle4925bh8c9n3ka79fxauc9egk7uea6f6frfbbb5btvxpvu"
|
|
||||||
},
|
|
||||||
"2209880145223": {
|
|
||||||
"enabled": true,
|
|
||||||
"shopName": "大锤号商",
|
|
||||||
"accessToken": "AldsIdleu5rzb68g8gh32sk9e8rbunes6gwyg59aph2nn5em6fp4r"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,22 +0,0 @@
|
|||||||
{
|
|
||||||
"sessions": {
|
|
||||||
"default": {
|
|
||||||
"token": "P1ifGvDcxYchECzx51cqL2TzBkntjQtRwxpetU7RDMMjsSCmC8kHU751bJ4rJMSSjQN73QveARqGLIWe078nKvnA",
|
|
||||||
"baseUrl": "https://123.207.217.176",
|
|
||||||
"username": "17665234375",
|
|
||||||
"phone": "17665234375",
|
|
||||||
"loggedInAt": "2026-05-25T06:48:25.301Z",
|
|
||||||
"deviceId": "-",
|
|
||||||
"deviceType": 0
|
|
||||||
},
|
|
||||||
"account2": {
|
|
||||||
"token": "KU2draP6Rm7AQDIeN0SSIVeNBkdMdGODlN1ToNhAo0wYk6clo9EeHh8fE5cN2kIhxThC30gCZXZl6LQfs19K03S9",
|
|
||||||
"baseUrl": "https://123.207.217.176",
|
|
||||||
"username": "18216161068",
|
|
||||||
"phone": "18216161068",
|
|
||||||
"loggedInAt": "2026-05-25T06:38:36.698Z",
|
|
||||||
"deviceId": "-",
|
|
||||||
"deviceType": 0
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
{
|
||||||
|
"enabled": true,
|
||||||
|
"sources": [
|
||||||
|
{
|
||||||
|
"key": "default",
|
||||||
|
"label": "默认账号",
|
||||||
|
"enabled": true,
|
||||||
|
"baseUrl": "https://example.com",
|
||||||
|
"username": "",
|
||||||
|
"password": "",
|
||||||
|
"phone": "",
|
||||||
|
"deviceId": "08bc9d8c-fd15-48ea-bc00-8d754076cafc",
|
||||||
|
"deviceType": 1
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -1,27 +0,0 @@
|
|||||||
{
|
|
||||||
"enabled": true,
|
|
||||||
"sources": [
|
|
||||||
{
|
|
||||||
"key": "default",
|
|
||||||
"label": "默认账号",
|
|
||||||
"enabled": true,
|
|
||||||
"baseUrl": "https://123.207.217.176",
|
|
||||||
"username": "17665234375",
|
|
||||||
"password": "yaochao11",
|
|
||||||
"phone": "17665234375",
|
|
||||||
"deviceId": "-",
|
|
||||||
"deviceType": 0
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"key": "account2",
|
|
||||||
"label": "备用账号 1",
|
|
||||||
"enabled": true,
|
|
||||||
"baseUrl": "https://123.207.217.176",
|
|
||||||
"username": "18216161068",
|
|
||||||
"password": "yaochao11",
|
|
||||||
"phone": "18216161068",
|
|
||||||
"deviceId": "-",
|
|
||||||
"deviceType": 0
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
@@ -1,85 +0,0 @@
|
|||||||
{
|
|
||||||
"enabled": true,
|
|
||||||
"items": [
|
|
||||||
{
|
|
||||||
"id": "套装-浪漫天命",
|
|
||||||
"enabled": true,
|
|
||||||
"priority": 100,
|
|
||||||
"provider": "91kaquan",
|
|
||||||
"platform": "kuaishou",
|
|
||||||
"shopId": "91kaquan",
|
|
||||||
"internalSkuCode": "套装-浪漫天命",
|
|
||||||
"internalSkuName": "183999074512936",
|
|
||||||
"externalSkuCode": "183999074512936",
|
|
||||||
"externalItemId": "183999074512936",
|
|
||||||
"externalSkuName": "183999074512936",
|
|
||||||
"resolvedSkuName": "183999074512936",
|
|
||||||
"cloudSourceKey": "default",
|
|
||||||
"cloudSourceKeyFallbacks": [
|
|
||||||
"account2"
|
|
||||||
],
|
|
||||||
"cloudSkuId": 28,
|
|
||||||
"cloudSkuName": "套装-浪漫天命",
|
|
||||||
"vnKey": "1",
|
|
||||||
"autoBuyEnabled": true,
|
|
||||||
"minAssetReserve": 0,
|
|
||||||
"autoReturnNumberAfterDispatch": true,
|
|
||||||
"autoConsumeAfterDispatch": true,
|
|
||||||
"kuaishouConsumeShopId": "3676797936",
|
|
||||||
"kuaishouConsumeShopName": "小鱼儿游戏服务店",
|
|
||||||
"notes": "从 91卡券订单 2614500561157561 导入"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "套装-浪漫天命",
|
|
||||||
"enabled": true,
|
|
||||||
"priority": 100,
|
|
||||||
"provider": "91kaquan",
|
|
||||||
"platform": "kuaishou",
|
|
||||||
"shopId": "3676797936",
|
|
||||||
"internalSkuCode": "套装-浪漫天命",
|
|
||||||
"internalSkuName": "183999074511936",
|
|
||||||
"externalSkuCode": "183999074511936",
|
|
||||||
"externalItemId": "183999074511936",
|
|
||||||
"externalSkuName": "183999074511936",
|
|
||||||
"resolvedSkuName": "183999074511936",
|
|
||||||
"cloudSourceKey": "default",
|
|
||||||
"cloudSourceKeyFallbacks": [],
|
|
||||||
"cloudSkuId": 28,
|
|
||||||
"cloudSkuName": "套装-浪漫天命",
|
|
||||||
"vnKey": "1",
|
|
||||||
"autoBuyEnabled": true,
|
|
||||||
"minAssetReserve": 0,
|
|
||||||
"autoReturnNumberAfterDispatch": true,
|
|
||||||
"autoConsumeAfterDispatch": true,
|
|
||||||
"kuaishouConsumeShopId": "3676797936",
|
|
||||||
"kuaishouConsumeShopName": "小鱼儿游戏服务店",
|
|
||||||
"notes": "从 91卡券订单 2613900255300561 导入"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "91卡券订单取样导入",
|
|
||||||
"enabled": true,
|
|
||||||
"priority": 100,
|
|
||||||
"provider": "91kaquan",
|
|
||||||
"platform": "kuaishou",
|
|
||||||
"shopId": "91kaquan",
|
|
||||||
"internalSkuCode": "套装-浪漫天命",
|
|
||||||
"internalSkuName": "套装-浪漫天命",
|
|
||||||
"externalSkuCode": "183999074510936",
|
|
||||||
"externalItemId": "183999074510936",
|
|
||||||
"externalSkuName": "183999074510936",
|
|
||||||
"resolvedSkuName": "183999074510936",
|
|
||||||
"cloudSourceKey": "default",
|
|
||||||
"cloudSourceKeyFallbacks": [],
|
|
||||||
"cloudSkuId": 28,
|
|
||||||
"cloudSkuName": "套装-浪漫天命",
|
|
||||||
"vnKey": "1",
|
|
||||||
"autoBuyEnabled": true,
|
|
||||||
"minAssetReserve": 0,
|
|
||||||
"autoReturnNumberAfterDispatch": true,
|
|
||||||
"autoConsumeAfterDispatch": true,
|
|
||||||
"kuaishouConsumeShopId": "3676797936",
|
|
||||||
"kuaishouConsumeShopName": "小鱼儿游戏服务店",
|
|
||||||
"notes": "从 91卡券订单 2613300010204561 导入"
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
{
|
||||||
|
"enabled": true,
|
||||||
|
"baseUrl": "https://s.kwaixiaodian.com",
|
||||||
|
"shops": [
|
||||||
|
{
|
||||||
|
"shopId": "",
|
||||||
|
"kshopName": "",
|
||||||
|
"cookie": "",
|
||||||
|
"userAvatar": "",
|
||||||
|
"enabled": false
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -1,20 +0,0 @@
|
|||||||
{
|
|
||||||
"enabled": true,
|
|
||||||
"baseUrl": "https://s.kwaixiaodian.com",
|
|
||||||
"shops": [
|
|
||||||
{
|
|
||||||
"shopId": "3676797936",
|
|
||||||
"kshopName": "小鱼儿游戏服务店",
|
|
||||||
"cookie": "_did=web_977722931F45F3C4; did=web_mxsox80uwsj3o4bkdsi2xhbxnx5gdlpn; sid=kshop.api; kshop.api_st=Cgxrc2hvcC5hcGkuc3QSoAHnDz5KbyPrdR8uWNB0MspVtX2zUOBrDcysluKZ9P6NT0IaEtnV-24otPiLyxcbH1iStuNg84bSxClfdu63cgMKzi0AovJn5DFqJUjXPfJZ1XxL8xDXOIdvv73SVr0eQhPKrD_twqnWJu-1HLgxl294XMhDPhO8JbNcFhv_cOwBsxjZYUNMs_7kpRHIpDEU_adQH0zQOBVQYu4AzU0rhF2EGhI9zclILZeuwZLmGG1mEKpcYGMiIOkT8avvcn-qSsIRdLyBf0U1Fjoud_FP9qcCFMjohN1AKAUwAQ; kshop.api_ph=a5c01f4da50e7eda8facb70fd1b13af57eb3; bUserId=1000324969453; userId=3676797936; kuaishou.shop.b_st=ChJrdWFpc2hvdS5zaG9wLmIuc3QSoAHbRIrpTvTjW-nbSsGDVimfmFK01ohV83Wh1v9vCN89z4-bJqpa_r7jmXBgL53hZ0OD5Y8Jz5fUPxWT-6PYNCfLVB20XogIDibxfXXjJSW5d4nchty1_veIl6r4NsUhHu0N93XRsx8vUxfQvAwlczsvefFqvPeZ3iAQnACDEVqJ7m7oI5BK9MCobzGGGuznAgtY8l0Z10qy3e5WmPgiSaI4GhLSubyi-uEBAthqCoUoWxNxjR0iINJ8MTa8OcsVUjQ5AuIuH9BtGr4JuS89zMjEz3McjsjmKAUwAQ; kuaishou.shop.b_ph=0a29c066148190b0759c776da8e94775e7dd",
|
|
||||||
"userAvatar": "https://p2-pro.a.yximgs.com/uhead/AB/2024/11/17/20/BMjAyNDExMTcyMDQ3NTdfMzY3Njc5NzkzNl8yX2hkODUyXzIwNg==_s.jpg",
|
|
||||||
"enabled": true
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"shopId": "4269276762",
|
|
||||||
"kshopName": "稚嫩游戏交易店",
|
|
||||||
"cookie": "_did=web_1426300268274CD9; did=web_ozkrrsevhb6yuztyk6m63vqqi5h2pzfl; __risk_web_device_id=cd52e12c1773406609757e71; sid=kuaishou.shop.b; sellerId=4269276762; bUserId=1000492186791; userId=4269276762; merchantSellerId=4269276762; merchantSessionKey=1777621574120_fbd47024d23e73fc4ad1a157ffece433; kuaishou.shop.b_st=ChJrdWFpc2hvdS5zaG9wLmIuc3QSoAFzGnewF-wCpt4rHi-D-pp8zPUp7HByKY0MlHkw-WV676zG7z09Y5i7d7URwmGC8CL13jN0fOGV7d3GfaH2XLH8d4p60z0Ms-dIX0bipkI0yUWHfgnWqv9VPa8HqVRqMLs2VGD82X9tAR3eN9PXu38AYg0a5kpawchGPb2tjcYgLgH5b5DnJgjipHqzWb8Ma8AcLOmryqCSg4ZHwO5dbVJHGhIdwFBlxRpZgyh6i0Do-DSRsroiIIayd1xA628OsvCY2qV1HjCrTvMVt-3LmXcU6rLKSzytKAUwAQ; kuaishou.shop.b_ph=38925ccb882ec5456dd1c6bcee3f87b7e740",
|
|
||||||
"userAvatar": "https://p2-pro.a.yximgs.com/uhead/AB/2024/07/23/17/BMjAyNDA3MjMxNzA1MThfNDI2OTI3Njc2Ml8yX2hkNDg1XzgxNQ==_s.jpg",
|
|
||||||
"enabled": true
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
{
|
||||||
|
"enabled": true,
|
||||||
|
"channels": {
|
||||||
|
"bark": {
|
||||||
|
"enabled": true,
|
||||||
|
"serverUrl": "https://api.day.app",
|
||||||
|
"recipients": []
|
||||||
|
},
|
||||||
|
"wpush": {
|
||||||
|
"enabled": true,
|
||||||
|
"recipients": []
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,40 +0,0 @@
|
|||||||
{
|
|
||||||
"enabled": true,
|
|
||||||
"channels": {
|
|
||||||
"bark": {
|
|
||||||
"enabled": true,
|
|
||||||
"serverUrl": "https://api.day.app",
|
|
||||||
"recipients": [
|
|
||||||
{
|
|
||||||
"id": "cdab629b-0480-4741-a4b5-4c11fb8fbc6b",
|
|
||||||
"name": "弋宸先生",
|
|
||||||
"deviceKey": "NaiQzbZkJTcaW23VWU9AXf",
|
|
||||||
"enabled": true
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "82af4cb0-f6b2-4636-b1ca-29f482c49b30",
|
|
||||||
"name": "yml-测试1",
|
|
||||||
"deviceKey": "5EdwoMkrpuAfNLZN8aVrh5",
|
|
||||||
"enabled": true
|
|
||||||
}
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"wpush": {
|
|
||||||
"enabled": true,
|
|
||||||
"recipients": [
|
|
||||||
{
|
|
||||||
"id": "1c1089bb-4a54-4ec9-a10f-15b8099fdfb0",
|
|
||||||
"name": "y",
|
|
||||||
"apiKey": "WPUSHcPSmgodKUwMCB13IFSHJsyBqUI4",
|
|
||||||
"enabled": true
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "5affc7d0-3944-43ee-90cb-8242ab9b34e7",
|
|
||||||
"name": "yml",
|
|
||||||
"apiKey": "WPUSHsa44qjF35klEZRqVHxAouoDE2A6",
|
|
||||||
"enabled": true
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,42 +0,0 @@
|
|||||||
[
|
|
||||||
{
|
|
||||||
"provider": "agiso",
|
|
||||||
"platform": "xianyu",
|
|
||||||
"shopId": "2209880145223",
|
|
||||||
"shopName": "",
|
|
||||||
"skuCode": "海底捞第五人格皮肤",
|
|
||||||
"skuName": "海底捞第五人格皮肤",
|
|
||||||
"profileKey": "tencent_claim_redeem",
|
|
||||||
"enabled": false,
|
|
||||||
"priority": 100,
|
|
||||||
"config": {},
|
|
||||||
"match": {
|
|
||||||
"externalSkuCode": "6227949975509",
|
|
||||||
"externalItemId": "1038123159888",
|
|
||||||
"externalSkuName": "海底捞第五人格皮肤",
|
|
||||||
"config": {
|
|
||||||
"resolvedSkuName": "海底捞第五人格皮肤"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"provider": "agiso",
|
|
||||||
"platform": "xianyu",
|
|
||||||
"shopId": "2209880145223",
|
|
||||||
"shopName": "",
|
|
||||||
"skuCode": "三角洲海底捞动作",
|
|
||||||
"skuName": "海底捞干员庆生动作1个",
|
|
||||||
"profileKey": "tencent_claim_redeem",
|
|
||||||
"enabled": true,
|
|
||||||
"priority": 100,
|
|
||||||
"config": {},
|
|
||||||
"match": {
|
|
||||||
"externalSkuCode": "6063968816746",
|
|
||||||
"externalItemId": "1041598483721",
|
|
||||||
"externalSkuName": "海底捞干员庆生动作1个",
|
|
||||||
"config": {
|
|
||||||
"resolvedSkuName": "海底捞干员庆生动作1个"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
]
|
|
||||||
+2
-8
@@ -4,8 +4,8 @@
|
|||||||
{
|
{
|
||||||
"id": "cloudtentacles-health",
|
"id": "cloudtentacles-health",
|
||||||
"type": "cloudtentacles_health",
|
"type": "cloudtentacles_health",
|
||||||
"enabled": true,
|
"enabled": false,
|
||||||
"intervalSeconds": 18000,
|
"intervalSeconds": 300,
|
||||||
"cooldownSeconds": 1800,
|
"cooldownSeconds": 1800,
|
||||||
"config": {
|
"config": {
|
||||||
"assetThreshold": 500,
|
"assetThreshold": 500,
|
||||||
@@ -14,12 +14,6 @@
|
|||||||
"sourceKey": "default",
|
"sourceKey": "default",
|
||||||
"label": "默认账号",
|
"label": "默认账号",
|
||||||
"enabled": true,
|
"enabled": true,
|
||||||
"assetThreshold": 100
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"sourceKey": "account2",
|
|
||||||
"label": "备用账号 1",
|
|
||||||
"enabled": true,
|
|
||||||
"assetThreshold": 500
|
"assetThreshold": 500
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
Generated
+18
-46
@@ -9,13 +9,13 @@
|
|||||||
"version": "0.1.0",
|
"version": "0.1.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"express": "^5.1.0",
|
"express": "^5.1.0",
|
||||||
"pg": "^8.16.3",
|
"pg": "^8.16.3"
|
||||||
"playwright": "1.42.1"
|
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/express": "^5.0.6",
|
"@types/express": "^5.0.6",
|
||||||
"@types/node": "^25.6.0",
|
"@types/node": "^25.6.0",
|
||||||
"@types/pg": "^8.20.0",
|
"@types/pg": "^8.20.0",
|
||||||
|
"prettier": "^3.8.3",
|
||||||
"tsx": "^4.22.3",
|
"tsx": "^4.22.3",
|
||||||
"typescript": "^6.0.2"
|
"typescript": "^6.0.2"
|
||||||
}
|
}
|
||||||
@@ -911,20 +911,6 @@
|
|||||||
"node": ">= 0.8"
|
"node": ">= 0.8"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/fsevents": {
|
|
||||||
"version": "2.3.2",
|
|
||||||
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz",
|
|
||||||
"integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==",
|
|
||||||
"hasInstallScript": true,
|
|
||||||
"license": "MIT",
|
|
||||||
"optional": true,
|
|
||||||
"os": [
|
|
||||||
"darwin"
|
|
||||||
],
|
|
||||||
"engines": {
|
|
||||||
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/function-bind": {
|
"node_modules/function-bind": {
|
||||||
"version": "1.1.2",
|
"version": "1.1.2",
|
||||||
"resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz",
|
"resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz",
|
||||||
@@ -1275,36 +1261,6 @@
|
|||||||
"split2": "^4.1.0"
|
"split2": "^4.1.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/playwright": {
|
|
||||||
"version": "1.42.1",
|
|
||||||
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.42.1.tgz",
|
|
||||||
"integrity": "sha512-PgwB03s2DZBcNRoW+1w9E+VkLBxweib6KTXM0M3tkiT4jVxKSi6PmVJ591J+0u10LUrgxB7dLRbiJqO5s2QPMg==",
|
|
||||||
"license": "Apache-2.0",
|
|
||||||
"dependencies": {
|
|
||||||
"playwright-core": "1.42.1"
|
|
||||||
},
|
|
||||||
"bin": {
|
|
||||||
"playwright": "cli.js"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": ">=16"
|
|
||||||
},
|
|
||||||
"optionalDependencies": {
|
|
||||||
"fsevents": "2.3.2"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/playwright-core": {
|
|
||||||
"version": "1.42.1",
|
|
||||||
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.42.1.tgz",
|
|
||||||
"integrity": "sha512-mxz6zclokgrke9p1vtdy/COWBH+eOZgYUVVU34C73M+4j4HLlQJHtfcqiqqxpP0o8HhMkflvfbquLX5dg6wlfA==",
|
|
||||||
"license": "Apache-2.0",
|
|
||||||
"bin": {
|
|
||||||
"playwright-core": "cli.js"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": ">=16"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/postgres-array": {
|
"node_modules/postgres-array": {
|
||||||
"version": "2.0.0",
|
"version": "2.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/postgres-array/-/postgres-array-2.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/postgres-array/-/postgres-array-2.0.0.tgz",
|
||||||
@@ -1344,6 +1300,22 @@
|
|||||||
"node": ">=0.10.0"
|
"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": {
|
"node_modules/proxy-addr": {
|
||||||
"version": "2.0.7",
|
"version": "2.0.7",
|
||||||
"resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz",
|
"resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz",
|
||||||
|
|||||||
@@ -4,14 +4,13 @@
|
|||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"browser:install": "playwright install chromium",
|
|
||||||
"browser:install:linux": "playwright install --with-deps chromium",
|
|
||||||
"agiso:auto-delivery:test": "tsx scripts/test-agiso-auto-delivery.ts",
|
|
||||||
"cleanup:dev-data": "tsx scripts/cleanup-dev-data.ts",
|
"cleanup:dev-data": "tsx scripts/cleanup-dev-data.ts",
|
||||||
"seed:dev-data": "tsx scripts/seed-dev-data.ts",
|
|
||||||
"build": "tsc -p tsconfig.build.json && rm -rf dist/db/migrations && mkdir -p dist/db && cp -R src/db/migrations dist/db/migrations",
|
"build": "tsc -p tsconfig.build.json && rm -rf dist/db/migrations && mkdir -p dist/db && cp -R src/db/migrations dist/db/migrations",
|
||||||
"db:migrate": "tsx src/db/migrate.ts",
|
"db:migrate": "tsx src/db/migrate.ts",
|
||||||
"dev": "tsx watch --clear-screen=false src/index.ts",
|
"dev": "tsx watch --clear-screen=false src/index.ts",
|
||||||
|
"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)",
|
"test": "node --import tsx --test $(find src \\( -name '*.test.ts' -o -name '*.test.js' \\) -print)",
|
||||||
"typecheck": "tsc -p tsconfig.json --noEmit",
|
"typecheck": "tsc -p tsconfig.json --noEmit",
|
||||||
"start": "node dist/index.js",
|
"start": "node dist/index.js",
|
||||||
@@ -19,13 +18,13 @@
|
|||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"express": "^5.1.0",
|
"express": "^5.1.0",
|
||||||
"pg": "^8.16.3",
|
"pg": "^8.16.3"
|
||||||
"playwright": "1.42.1"
|
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/express": "^5.0.6",
|
"@types/express": "^5.0.6",
|
||||||
"@types/node": "^25.6.0",
|
"@types/node": "^25.6.0",
|
||||||
"@types/pg": "^8.20.0",
|
"@types/pg": "^8.20.0",
|
||||||
|
"prettier": "^3.8.3",
|
||||||
"tsx": "^4.22.3",
|
"tsx": "^4.22.3",
|
||||||
"typescript": "^6.0.2"
|
"typescript": "^6.0.2"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,16 +6,12 @@ import { closeDb, query } from '../src/db/client.js'
|
|||||||
import { runtimeConfig } from '../src/config/runtime.js'
|
import { runtimeConfig } from '../src/config/runtime.js'
|
||||||
|
|
||||||
const RESET_TABLES = [
|
const RESET_TABLES = [
|
||||||
'message_deliveries',
|
'task_runtime_contexts',
|
||||||
'tencent_browser_contexts',
|
|
||||||
'task_events',
|
'task_events',
|
||||||
'claim_tokens',
|
'claim_tokens',
|
||||||
'task_inventory_bindings',
|
|
||||||
'fulfillment_tasks',
|
'fulfillment_tasks',
|
||||||
'order_items',
|
'order_items',
|
||||||
'orders',
|
'orders',
|
||||||
'webhook_events',
|
|
||||||
'inventory_items',
|
|
||||||
]
|
]
|
||||||
|
|
||||||
const options = parseArgs(process.argv.slice(2))
|
const options = parseArgs(process.argv.slice(2))
|
||||||
|
|||||||
@@ -0,0 +1,444 @@
|
|||||||
|
import process from 'node:process'
|
||||||
|
|
||||||
|
import { closeDb } from '../src/db/client.js'
|
||||||
|
import { createOrder, findOrderByPlatformOrderId } from '../src/repositories/order-repo.js'
|
||||||
|
import { replaceOrderItems } from '../src/repositories/order-item-repo.js'
|
||||||
|
import { createTask, updateTask } from '../src/repositories/task-repo.js'
|
||||||
|
import {
|
||||||
|
getFulfillmentProfileByKey,
|
||||||
|
upsertFulfillmentProfile,
|
||||||
|
} from '../src/repositories/fulfillment-profile-repo.js'
|
||||||
|
import { buildClaimUrl, createTaskClaimToken } from '../src/services/claim/claim-service.js'
|
||||||
|
import { OPEN_91_PLATFORM, OPEN_91_PROVIDER } from '../src/services/open-91/config.js'
|
||||||
|
import { parseOpen91ProductNo } from '../src/services/platforms/ninetyone/order-service.js'
|
||||||
|
import { isProductionLike } from '../src/config/runtime-validation.js'
|
||||||
|
import { addHours, nowIso } from '../src/utils/time.js'
|
||||||
|
import { randomId } from '../src/utils/random.js'
|
||||||
|
|
||||||
|
type JsonObject = Record<string, unknown>
|
||||||
|
type DeliveryItem = {
|
||||||
|
cloudSkuId: number
|
||||||
|
cloudSkuName: string
|
||||||
|
quantity: number
|
||||||
|
}
|
||||||
|
|
||||||
|
const args = parseArgs(process.argv.slice(2))
|
||||||
|
|
||||||
|
if (args.help) {
|
||||||
|
printHelp()
|
||||||
|
process.exit(0)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isProductionLike() && args.force !== true) {
|
||||||
|
console.error('当前是生产环境,已拒绝生成 mock 数据。如确实要执行,请追加 --force。')
|
||||||
|
process.exit(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
const step = normalizeStep(args.step)
|
||||||
|
const orderNo = String(args.orderNo || `MOCK91${Date.now()}`).trim()
|
||||||
|
const productNo = String(args.productNo || '套餐_1----3676797936').trim()
|
||||||
|
const productInfo = parseOpen91ProductNo(productNo)
|
||||||
|
const productName = productInfo.productName || productInfo.rawProductNo || '套餐_1'
|
||||||
|
const consumeShopId = productInfo.shopId || String(args.kuaishouShopId || '3676797936').trim()
|
||||||
|
const deliveryItems = parseDeliveryItems(args.items)
|
||||||
|
const now = nowIso()
|
||||||
|
const existingOrder = await findOrderByPlatformOrderId({
|
||||||
|
provider: OPEN_91_PROVIDER,
|
||||||
|
platform: OPEN_91_PLATFORM,
|
||||||
|
shopId: OPEN_91_PROVIDER,
|
||||||
|
platformOrderId: orderNo,
|
||||||
|
})
|
||||||
|
|
||||||
|
if (existingOrder) {
|
||||||
|
console.error(`订单号已存在:${orderNo}`)
|
||||||
|
console.error('请换一个 --orderNo,或不传 orderNo 让脚本自动生成。')
|
||||||
|
await closeDb()
|
||||||
|
process.exit(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const profile = await ensureKuaishouCloudProfile(now)
|
||||||
|
const order = await createOrder({
|
||||||
|
provider: OPEN_91_PROVIDER,
|
||||||
|
platform: OPEN_91_PLATFORM,
|
||||||
|
shopId: OPEN_91_PROVIDER,
|
||||||
|
shopName: '91卡券',
|
||||||
|
platformOrderId: orderNo,
|
||||||
|
orderStatus: 'paid',
|
||||||
|
payStatus: 'paid',
|
||||||
|
buyerId: 'mock-buyer',
|
||||||
|
buyerName: 'mock 领取客户',
|
||||||
|
receiverContact: '',
|
||||||
|
totalAmount: 1,
|
||||||
|
currency: 'CNY',
|
||||||
|
rawPayloadJson: JSON.stringify({
|
||||||
|
source: OPEN_91_PROVIDER,
|
||||||
|
mock: true,
|
||||||
|
receivedAt: now,
|
||||||
|
body: {
|
||||||
|
orderNo,
|
||||||
|
productNo,
|
||||||
|
productName,
|
||||||
|
buyNum: 1,
|
||||||
|
maxAmount: '0.01',
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
paidAt: now,
|
||||||
|
createdAt: now,
|
||||||
|
updatedAt: now,
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!order) {
|
||||||
|
throw new Error('订单创建失败')
|
||||||
|
}
|
||||||
|
|
||||||
|
const [orderItem] = await replaceOrderItems(order.id, [{
|
||||||
|
skuCode: productName,
|
||||||
|
skuName: productName,
|
||||||
|
quantity: 1,
|
||||||
|
specJson: JSON.stringify({
|
||||||
|
source: OPEN_91_PROVIDER,
|
||||||
|
orderNo,
|
||||||
|
productNo,
|
||||||
|
productName,
|
||||||
|
shopId: consumeShopId,
|
||||||
|
}),
|
||||||
|
itemSnapshotJson: JSON.stringify({
|
||||||
|
source: OPEN_91_PROVIDER,
|
||||||
|
orderNo,
|
||||||
|
productNo,
|
||||||
|
productName,
|
||||||
|
shopId: consumeShopId,
|
||||||
|
cloudtentacles: {
|
||||||
|
matchMode: 'mock',
|
||||||
|
normalizedProductName: productName,
|
||||||
|
cloudSourceKeys: ['mock-cloudtentacles'],
|
||||||
|
resolvedSourceKey: 'mock-cloudtentacles',
|
||||||
|
deliveryItems,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
createdAt: now,
|
||||||
|
updatedAt: now,
|
||||||
|
}])
|
||||||
|
|
||||||
|
if (!orderItem) {
|
||||||
|
throw new Error('订单商品创建失败')
|
||||||
|
}
|
||||||
|
|
||||||
|
const task = await createTask({
|
||||||
|
orderId: order.id,
|
||||||
|
orderItemId: orderItem.id,
|
||||||
|
unitIndex: 1,
|
||||||
|
provider: OPEN_91_PROVIDER,
|
||||||
|
platform: OPEN_91_PLATFORM,
|
||||||
|
shopId: OPEN_91_PROVIDER,
|
||||||
|
shopName: '91卡券',
|
||||||
|
platformOrderId: orderNo,
|
||||||
|
taskNo: randomId('DT'),
|
||||||
|
profileId: profile.id,
|
||||||
|
executorKey: 'kuaishou_ct_assisted',
|
||||||
|
taskStatus: resolveInitialTaskStatus(step),
|
||||||
|
deliveryStatus: step === 'result' ? 'success' : 'pending',
|
||||||
|
resultCode: step === 'result' ? 'mock_success' : '',
|
||||||
|
resultMessage: step === 'result' ? '开发 mock 已模拟兑换成功' : '',
|
||||||
|
claimToken: '',
|
||||||
|
claimExpiresAt: null,
|
||||||
|
automationMode: 'manual',
|
||||||
|
requiresClaim: false,
|
||||||
|
userActionStatus: step === 'result' ? 'not_required' : 'pending_claim',
|
||||||
|
attemptCount: 0,
|
||||||
|
lastError: '',
|
||||||
|
contextJson: JSON.stringify(buildMockTaskContext({
|
||||||
|
step,
|
||||||
|
orderNo,
|
||||||
|
productName,
|
||||||
|
productNo,
|
||||||
|
consumeShopId,
|
||||||
|
deliveryItems,
|
||||||
|
timestamp: now,
|
||||||
|
})),
|
||||||
|
createdAt: now,
|
||||||
|
updatedAt: now,
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!task) {
|
||||||
|
throw new Error('履约任务创建失败')
|
||||||
|
}
|
||||||
|
|
||||||
|
const claimToken = await createTaskClaimToken(task.id)
|
||||||
|
const claimUrl = buildClaimUrl(claimToken.token)
|
||||||
|
await updateTask(task.id, {
|
||||||
|
claim_token: claimToken.token,
|
||||||
|
claim_expires_at: claimToken.expired_at,
|
||||||
|
...(step !== 'ticket' ? { claimed_at: now } : {}),
|
||||||
|
...(step === 'confirm' || step === 'result' ? { role_confirmed_at: now } : {}),
|
||||||
|
...(step === 'result' ? { redeemed_at: now } : {}),
|
||||||
|
updated_at: now,
|
||||||
|
})
|
||||||
|
|
||||||
|
console.log('已生成快手 Cloud 领取 mock 数据:')
|
||||||
|
console.log(` 91订单号:${orderNo}`)
|
||||||
|
console.log(` 商品:${productName}`)
|
||||||
|
console.log(` 当前步骤:${step}`)
|
||||||
|
console.log(` 领取链接:${claimUrl}`)
|
||||||
|
console.log(` 前端本地链接:${buildFrontendClaimUrl(claimToken.token, args.frontendBaseUrl)}`)
|
||||||
|
console.log('')
|
||||||
|
console.log('页面核销码可填:MOCK')
|
||||||
|
console.log(`91 查询接口可用这个订单号测试:npm run mock:open91 -- --mode=query --orderNo=${orderNo}`)
|
||||||
|
} finally {
|
||||||
|
await closeDb()
|
||||||
|
}
|
||||||
|
|
||||||
|
async function ensureKuaishouCloudProfile(timestamp: string) {
|
||||||
|
const existing = await getFulfillmentProfileByKey('kuaishou_ct_assisted')
|
||||||
|
if (existing) {
|
||||||
|
return existing
|
||||||
|
}
|
||||||
|
|
||||||
|
const profile = await upsertFulfillmentProfile({
|
||||||
|
profileKey: 'kuaishou_ct_assisted',
|
||||||
|
name: '快手 cloud 履约',
|
||||||
|
executorKey: 'kuaishou_ct_assisted',
|
||||||
|
requiresClaim: false,
|
||||||
|
autoDispatch: false,
|
||||||
|
inventoryStrategy: 'external_platform',
|
||||||
|
configJson: '{}',
|
||||||
|
createdAt: timestamp,
|
||||||
|
updatedAt: timestamp,
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!profile) {
|
||||||
|
throw new Error('履约配置创建失败')
|
||||||
|
}
|
||||||
|
|
||||||
|
return profile
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildMockTaskContext({
|
||||||
|
step,
|
||||||
|
orderNo,
|
||||||
|
productName,
|
||||||
|
productNo,
|
||||||
|
consumeShopId,
|
||||||
|
deliveryItems,
|
||||||
|
timestamp,
|
||||||
|
}: {
|
||||||
|
step: string
|
||||||
|
orderNo: string
|
||||||
|
productName: string
|
||||||
|
productNo: string
|
||||||
|
consumeShopId: string
|
||||||
|
deliveryItems: DeliveryItem[]
|
||||||
|
timestamp: string
|
||||||
|
}) {
|
||||||
|
const ticketVerified = ['binding', 'confirm', 'result'].includes(step)
|
||||||
|
const roleReady = ['binding', 'confirm', 'result'].includes(step)
|
||||||
|
const roleConfirmed = ['confirm', 'result'].includes(step)
|
||||||
|
const completed = step === 'result'
|
||||||
|
const primaryItem = deliveryItems[0] || {
|
||||||
|
cloudSkuId: 910001,
|
||||||
|
cloudSkuName: 'Mock 商品',
|
||||||
|
quantity: 1,
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
profileKey: 'kuaishou_ct_assisted',
|
||||||
|
profileName: '快手 cloud 履约',
|
||||||
|
skuCode: productName,
|
||||||
|
skuName: productName,
|
||||||
|
kuaishouCloudFulfillment: {
|
||||||
|
flowType: 'kuaishou_cloud_fulfillment',
|
||||||
|
configId: `mock:${productName}`,
|
||||||
|
internalSkuCode: productName,
|
||||||
|
internalSkuName: productName,
|
||||||
|
deliveryItems,
|
||||||
|
mock: {
|
||||||
|
enabled: true,
|
||||||
|
orderNo,
|
||||||
|
productNo,
|
||||||
|
createdAt: timestamp,
|
||||||
|
},
|
||||||
|
ticket: {
|
||||||
|
code: ticketVerified ? `MOCK-${orderNo}` : '',
|
||||||
|
status: ticketVerified ? 'verified' : 'pending',
|
||||||
|
capturedAt: ticketVerified ? timestamp : null,
|
||||||
|
capturedBy: ticketVerified ? { source: 'mock_script' } : null,
|
||||||
|
verifiedAt: ticketVerified ? timestamp : null,
|
||||||
|
oid: ticketVerified ? `MOCK-OID-${orderNo}` : '',
|
||||||
|
formToken: ticketVerified ? `MOCK-FORM-${orderNo}` : '',
|
||||||
|
leftCount: ticketVerified ? 1 : 0,
|
||||||
|
goodsTitle: ticketVerified ? productName : '',
|
||||||
|
},
|
||||||
|
binding: {
|
||||||
|
prepareStatus: roleReady ? 'ready' : 'pending',
|
||||||
|
cloudSourceKeys: ['mock-cloudtentacles'],
|
||||||
|
resolvedSourceKey: 'mock-cloudtentacles',
|
||||||
|
skuId: primaryItem.cloudSkuId,
|
||||||
|
skuName: primaryItem.cloudSkuName,
|
||||||
|
vnKey: '1',
|
||||||
|
vnId: roleReady ? 900001 : 0,
|
||||||
|
vnPhone: roleReady ? '13800000000' : '',
|
||||||
|
bindUrl: roleReady ? `https://example.com/mock-kuaishou-cloud-bind/${orderNo}` : '',
|
||||||
|
bindPreparedAt: roleReady ? timestamp : null,
|
||||||
|
bindExpiresAt: roleReady ? addHours(timestamp, 24) : null,
|
||||||
|
bindProbeAt: roleReady ? timestamp : null,
|
||||||
|
bindProbeStatus: roleReady ? 'success' : '',
|
||||||
|
bindProbeMessage: '',
|
||||||
|
roleName: roleReady ? '测试角色' : '',
|
||||||
|
roleId: roleReady ? '10001' : '',
|
||||||
|
},
|
||||||
|
role: {
|
||||||
|
status: roleReady ? 'ready' : 'pending',
|
||||||
|
name: roleReady ? '测试角色' : '',
|
||||||
|
rid: roleReady ? '10001' : '',
|
||||||
|
refreshedAt: roleReady ? timestamp : null,
|
||||||
|
errorMessage: '',
|
||||||
|
rawInfo: roleReady ? { mock: true } : null,
|
||||||
|
},
|
||||||
|
purchase: {
|
||||||
|
autoBuyEnabled: true,
|
||||||
|
minAssetReserve: 0,
|
||||||
|
usedKnapsack: false,
|
||||||
|
purchaseTriggered: false,
|
||||||
|
assetBefore: 0,
|
||||||
|
assetAfter: 0,
|
||||||
|
purchaseAt: null,
|
||||||
|
items: [],
|
||||||
|
},
|
||||||
|
dispatch: {
|
||||||
|
status: completed ? 'success' : 'pending',
|
||||||
|
dispatchAt: completed ? timestamp : null,
|
||||||
|
dispatchBy: completed ? { source: 'mock_script' } : null,
|
||||||
|
sendType: 0,
|
||||||
|
note: completed ? '开发 mock 已模拟发货成功' : '',
|
||||||
|
items: completed ? deliveryItems : [],
|
||||||
|
},
|
||||||
|
returnNumber: {
|
||||||
|
status: completed ? 'success' : 'pending',
|
||||||
|
returnedAt: completed ? timestamp : null,
|
||||||
|
returnedBy: completed ? { source: 'mock_script' } : null,
|
||||||
|
autoReturnEnabled: true,
|
||||||
|
},
|
||||||
|
consume: {
|
||||||
|
status: completed ? 'success' : 'pending',
|
||||||
|
shopId: consumeShopId,
|
||||||
|
shopName: 'Mock 快手小店',
|
||||||
|
autoConsumeEnabled: true,
|
||||||
|
consumedAt: completed ? timestamp : null,
|
||||||
|
errorMessage: '',
|
||||||
|
},
|
||||||
|
notes: '开发 mock:不调用 cloudtentacles,只用于领取页流程验证',
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseArgs(rawArgs: string[]) {
|
||||||
|
const parsed: JsonObject = {}
|
||||||
|
|
||||||
|
for (const rawArg of rawArgs) {
|
||||||
|
const arg = String(rawArg || '').trim()
|
||||||
|
if (!arg) continue
|
||||||
|
if (arg === '--help' || arg === '-h') {
|
||||||
|
parsed.help = true
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
const normalized = arg.startsWith('--') ? arg.slice(2) : arg
|
||||||
|
const separatorIndex = normalized.indexOf('=')
|
||||||
|
if (separatorIndex < 0) {
|
||||||
|
parsed[normalized] = true
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
const key = normalized.slice(0, separatorIndex).trim()
|
||||||
|
const value = normalized.slice(separatorIndex + 1).trim()
|
||||||
|
if (key) {
|
||||||
|
parsed[key] = value
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return parsed
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeStep(value: unknown) {
|
||||||
|
const normalized = String(value || 'ticket').trim()
|
||||||
|
if (['ticket', 'binding', 'confirm', 'result'].includes(normalized)) {
|
||||||
|
return normalized
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new Error('--step 只能是 ticket、binding、confirm、result')
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveInitialTaskStatus(step: string) {
|
||||||
|
if (step === 'ticket') return 'pending_binding_prepare'
|
||||||
|
if (step === 'binding') return 'waiting_binding'
|
||||||
|
if (step === 'confirm') return 'role_confirmed'
|
||||||
|
return 'completed'
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseDeliveryItems(value: unknown): DeliveryItem[] {
|
||||||
|
const text = String(value || '').trim()
|
||||||
|
const fallback = [
|
||||||
|
{ cloudSkuId: 910001, cloudSkuName: '套餐商品 A', quantity: 1 },
|
||||||
|
{ cloudSkuId: 910002, cloudSkuName: '套餐商品 B', quantity: 2 },
|
||||||
|
]
|
||||||
|
|
||||||
|
if (!text) {
|
||||||
|
return fallback
|
||||||
|
}
|
||||||
|
|
||||||
|
const items = text
|
||||||
|
.split(',')
|
||||||
|
.map((segment) => {
|
||||||
|
const [id, name, quantity] = segment.split(':')
|
||||||
|
const cloudSkuId = Number(id)
|
||||||
|
return {
|
||||||
|
cloudSkuId,
|
||||||
|
cloudSkuName: String(name || '').trim(),
|
||||||
|
quantity: Math.max(1, Number(quantity || 1) || 1),
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.filter((item) => Number.isInteger(item.cloudSkuId) && item.cloudSkuId > 0 && item.cloudSkuName)
|
||||||
|
|
||||||
|
return items.length > 0 ? mergeDeliveryItems(items) : fallback
|
||||||
|
}
|
||||||
|
|
||||||
|
function mergeDeliveryItems(items: DeliveryItem[]) {
|
||||||
|
const merged = new Map<number, DeliveryItem>()
|
||||||
|
|
||||||
|
for (const item of items) {
|
||||||
|
const existing = merged.get(item.cloudSkuId)
|
||||||
|
if (existing) {
|
||||||
|
existing.quantity += item.quantity
|
||||||
|
existing.cloudSkuName = existing.cloudSkuName || item.cloudSkuName
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
merged.set(item.cloudSkuId, { ...item })
|
||||||
|
}
|
||||||
|
|
||||||
|
return Array.from(merged.values())
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildFrontendClaimUrl(token: string, rawBaseUrl: unknown) {
|
||||||
|
const baseUrl = String(rawBaseUrl || process.env.MOCK_CLAIM_FRONTEND_BASE_URL || 'http://127.0.0.1:5173').replace(/\/+$/, '')
|
||||||
|
return `${baseUrl}/#/claim/${token}`
|
||||||
|
}
|
||||||
|
|
||||||
|
function printHelp() {
|
||||||
|
console.log(`
|
||||||
|
生成快手 Cloud 领取 mock 数据:
|
||||||
|
|
||||||
|
npm run mock:claim
|
||||||
|
npm run mock:claim -- --step=binding
|
||||||
|
npm run mock:claim -- --step=result --productNo=套餐_1----3676797936
|
||||||
|
npm run mock:claim -- --items=910001:商品A:1,910002:商品B:2
|
||||||
|
|
||||||
|
参数:
|
||||||
|
--step ticket、binding、confirm、result,默认 ticket
|
||||||
|
--orderNo 91 订单号,默认自动生成
|
||||||
|
--productNo 91 商品名,可带 ----快手小店ID,默认 套餐_1----3676797936
|
||||||
|
--items 套餐明细,格式 skuId:名称:数量,skuId:名称:数量
|
||||||
|
--frontendBaseUrl 前端地址,默认 http://127.0.0.1:5173
|
||||||
|
--force 允许在 NODE_ENV=production 执行
|
||||||
|
`)
|
||||||
|
}
|
||||||
@@ -0,0 +1,182 @@
|
|||||||
|
import crypto from 'node:crypto'
|
||||||
|
import path from 'node:path'
|
||||||
|
import { fileURLToPath } from 'node:url'
|
||||||
|
|
||||||
|
import { loadEnvFiles } from '../src/config/runtime-env.js'
|
||||||
|
|
||||||
|
type JsonObject = Record<string, unknown>
|
||||||
|
|
||||||
|
const CURRENT_DIR = path.dirname(fileURLToPath(import.meta.url))
|
||||||
|
const PROJECT_ROOT = path.resolve(CURRENT_DIR, '..')
|
||||||
|
const WORKSPACE_ROOT = path.resolve(PROJECT_ROOT, '../..')
|
||||||
|
|
||||||
|
loadEnvFiles([
|
||||||
|
path.join(WORKSPACE_ROOT, '.env'),
|
||||||
|
path.join(PROJECT_ROOT, '.env'),
|
||||||
|
])
|
||||||
|
|
||||||
|
const args = parseArgs(process.argv.slice(2))
|
||||||
|
|
||||||
|
if (args.help) {
|
||||||
|
printHelp()
|
||||||
|
process.exit(0)
|
||||||
|
}
|
||||||
|
|
||||||
|
const secret = readEnv('KAQUAN91_SECRET')
|
||||||
|
const version = readEnv('KAQUAN91_VERSION') || '1.0'
|
||||||
|
const baseUrl = String(args.baseUrl || process.env.OPEN91_MOCK_BASE_URL || 'http://127.0.0.1').replace(/\/$/, '')
|
||||||
|
const mode = String(args.mode || 'create').trim()
|
||||||
|
|
||||||
|
if (!secret || secret.length !== 32) {
|
||||||
|
console.error('缺少 32 位 91 密钥:请配置 KAQUAN91_SECRET。')
|
||||||
|
process.exit(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!['create', 'query'].includes(mode)) {
|
||||||
|
console.error('mode 只能是 create 或 query。')
|
||||||
|
process.exit(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
const orderNo = String(args.orderNo || `MOCK91${Date.now()}`).trim()
|
||||||
|
const timestamp = Number(args.timestamp || Math.floor(Date.now() / 1000))
|
||||||
|
|
||||||
|
const payload = mode === 'query'
|
||||||
|
? {
|
||||||
|
orderNo,
|
||||||
|
timestamp,
|
||||||
|
version,
|
||||||
|
}
|
||||||
|
: {
|
||||||
|
buyNum: normalizePositiveInteger(args.buyNum, 1),
|
||||||
|
callbackUrl: String(args.callbackUrl || ''),
|
||||||
|
maxAmount: String(args.maxAmount || '0.01'),
|
||||||
|
orderNo,
|
||||||
|
productNo: String(args.productNo || '套装-浪漫天命').trim(),
|
||||||
|
timestamp,
|
||||||
|
version,
|
||||||
|
}
|
||||||
|
|
||||||
|
const body = {
|
||||||
|
...payload,
|
||||||
|
sign: signOpen91Payload(payload, secret),
|
||||||
|
}
|
||||||
|
|
||||||
|
const endpoint = `${baseUrl}/api/v1/open/91/orders/${mode}`
|
||||||
|
|
||||||
|
console.log(`POST ${endpoint}`)
|
||||||
|
console.log(JSON.stringify(maskPayload(body), null, 2))
|
||||||
|
|
||||||
|
const response = await fetch(endpoint, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'content-type': 'application/json',
|
||||||
|
},
|
||||||
|
body: JSON.stringify(body),
|
||||||
|
}).catch((error) => {
|
||||||
|
console.error('请求 91 mock 接口失败,请确认后端入口可访问。')
|
||||||
|
console.error(`当前地址:${endpoint}`)
|
||||||
|
console.error('Docker 开发环境通常使用 http://127.0.0.1;本地直启后端通常使用 http://127.0.0.1:3000。')
|
||||||
|
throw error
|
||||||
|
})
|
||||||
|
|
||||||
|
const responseText = await response.text()
|
||||||
|
console.log(`HTTP ${response.status}`)
|
||||||
|
|
||||||
|
try {
|
||||||
|
console.log(JSON.stringify(JSON.parse(responseText), null, 2))
|
||||||
|
} catch {
|
||||||
|
console.log(responseText)
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseArgs(rawArgs: string[]) {
|
||||||
|
const parsed: JsonObject = {}
|
||||||
|
|
||||||
|
for (const rawArg of rawArgs) {
|
||||||
|
const arg = String(rawArg || '').trim()
|
||||||
|
if (!arg) continue
|
||||||
|
if (arg === '--help' || arg === '-h') {
|
||||||
|
parsed.help = true
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
const normalized = arg.startsWith('--') ? arg.slice(2) : arg
|
||||||
|
const separatorIndex = normalized.indexOf('=')
|
||||||
|
if (separatorIndex < 0) {
|
||||||
|
parsed[normalized] = true
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
const key = normalized.slice(0, separatorIndex).trim()
|
||||||
|
const value = normalized.slice(separatorIndex + 1).trim()
|
||||||
|
if (key) {
|
||||||
|
parsed[key] = value
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return parsed
|
||||||
|
}
|
||||||
|
|
||||||
|
function readEnv(...keys: string[]) {
|
||||||
|
for (const key of keys) {
|
||||||
|
const value = String(process.env[key] || '').trim()
|
||||||
|
if (value) return value
|
||||||
|
}
|
||||||
|
|
||||||
|
return ''
|
||||||
|
}
|
||||||
|
|
||||||
|
function signOpen91Payload(payload: JsonObject, currentSecret: string) {
|
||||||
|
return crypto
|
||||||
|
.createHash('md5')
|
||||||
|
.update(buildOpen91SignSource(payload, currentSecret), 'utf8')
|
||||||
|
.digest('hex')
|
||||||
|
.toUpperCase()
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildOpen91SignSource(payload: JsonObject, currentSecret: string) {
|
||||||
|
const queryString = Object.entries(payload)
|
||||||
|
.filter(([key]) => key !== 'sign')
|
||||||
|
.sort(([left], [right]) => left.localeCompare(right))
|
||||||
|
.map(([key, value]) => `${key}=${stringifySignValue(value)}`)
|
||||||
|
.join('&')
|
||||||
|
|
||||||
|
return `${currentSecret}${queryString}${currentSecret}`
|
||||||
|
}
|
||||||
|
|
||||||
|
function stringifySignValue(value: unknown) {
|
||||||
|
if (typeof value === 'number' && Number.isFinite(value)) return String(value)
|
||||||
|
if (typeof value === 'boolean') return value ? 'true' : 'false'
|
||||||
|
if (value == null) return ''
|
||||||
|
return String(value)
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizePositiveInteger(value: unknown, fallback: number) {
|
||||||
|
const parsed = Number(value)
|
||||||
|
return Number.isInteger(parsed) && parsed > 0 ? parsed : fallback
|
||||||
|
}
|
||||||
|
|
||||||
|
function maskPayload(payload: JsonObject) {
|
||||||
|
return {
|
||||||
|
...payload,
|
||||||
|
sign: String(payload.sign || '').replace(/^(.{6}).+(.{4})$/, '$1***$2'),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function printHelp() {
|
||||||
|
console.log(`
|
||||||
|
模拟 91 卡券请求:
|
||||||
|
|
||||||
|
npm run mock:open91 -- --productNo=套装-浪漫天命
|
||||||
|
npm run mock:open91 -- --productNo=荣耀勋章礼包(30个) --maxAmount=300 --buyNum=1
|
||||||
|
npm run mock:open91 -- --mode=query --orderNo=MOCK911779000000000
|
||||||
|
|
||||||
|
参数:
|
||||||
|
--baseUrl 后端地址,默认 http://127.0.0.1
|
||||||
|
--mode create 或 query,默认 create
|
||||||
|
--orderNo 91 订单号,默认自动生成
|
||||||
|
--productNo 91 商品名,默认 套装-浪漫天命
|
||||||
|
--buyNum 购买数量,默认 1
|
||||||
|
--maxAmount 最大金额/正常金额,默认 0.01
|
||||||
|
--callbackUrl 回调地址,默认空
|
||||||
|
`)
|
||||||
|
}
|
||||||
@@ -1,513 +0,0 @@
|
|||||||
#!/usr/bin/env node
|
|
||||||
|
|
||||||
import process from 'node:process'
|
|
||||||
|
|
||||||
import { runtimeConfig } from '../src/config/runtime.js'
|
|
||||||
import { closeDb, query } from '../src/db/client.js'
|
|
||||||
import { runDatabaseMigrations } from '../src/db/migrate.js'
|
|
||||||
import { updateClaimToken } from '../src/repositories/claim-token-repo.js'
|
|
||||||
import { getFulfillmentProfileByKey, upsertSkuFulfillmentBinding } from '../src/repositories/fulfillment-profile-repo.js'
|
|
||||||
import { createInventoryItems, releaseReservedInventoryItem } from '../src/repositories/inventory-repo.js'
|
|
||||||
import { getTaskById, updateTask } from '../src/repositories/task-repo.js'
|
|
||||||
import { ensureFulfillmentCatalogBootstrapped } from '../src/services/bootstrap/fulfillment-bootstrap-service.js'
|
|
||||||
import { completeAdminTaskManualDispatch } from '../src/services/admin/admin-service.js'
|
|
||||||
import { upsertOrderFromWebhook } from '../src/services/order/order-service.js'
|
|
||||||
import { addHours, nowIso } from '../src/utils/time.js'
|
|
||||||
|
|
||||||
const RESET_TABLES = [
|
|
||||||
'message_deliveries',
|
|
||||||
'tencent_browser_contexts',
|
|
||||||
'task_events',
|
|
||||||
'claim_tokens',
|
|
||||||
'task_inventory_bindings',
|
|
||||||
'fulfillment_tasks',
|
|
||||||
'order_items',
|
|
||||||
'orders',
|
|
||||||
'webhook_events',
|
|
||||||
'product_match_rules',
|
|
||||||
'inventory_items',
|
|
||||||
]
|
|
||||||
|
|
||||||
const SEED_PROVIDER = 'seed'
|
|
||||||
const SEED_PLATFORM = 'sandbox'
|
|
||||||
const SEED_SHOP_ID = 'seed-shop'
|
|
||||||
const SEED_SHOP_NAME = 'Seed Sandbox Shop'
|
|
||||||
|
|
||||||
const options = parseArgs(process.argv.slice(2))
|
|
||||||
|
|
||||||
if (options.help) {
|
|
||||||
printHelp()
|
|
||||||
process.exit(0)
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
printPreview()
|
|
||||||
|
|
||||||
if (!options.apply) {
|
|
||||||
console.log('\n当前为预览模式。确认后追加 `--apply`,需要清库时再加 `--reset`。')
|
|
||||||
process.exit(0)
|
|
||||||
}
|
|
||||||
|
|
||||||
await runDatabaseMigrations()
|
|
||||||
await ensureFulfillmentCatalogBootstrapped()
|
|
||||||
|
|
||||||
if (options.reset) {
|
|
||||||
await query(`TRUNCATE TABLE ${RESET_TABLES.join(', ')} RESTART IDENTITY CASCADE`)
|
|
||||||
console.log('\n已先清空测试业务数据。')
|
|
||||||
}
|
|
||||||
|
|
||||||
await ensureSeedBindings()
|
|
||||||
await createSeedInventory()
|
|
||||||
|
|
||||||
const manualPending = await seedScenario({
|
|
||||||
scenarioKey: 'manual_pending',
|
|
||||||
orderLabel: 'SEED-MANUAL-PENDING',
|
|
||||||
skuCode: 'seed-manual-pending',
|
|
||||||
skuName: '开发种子-人工履约待处理',
|
|
||||||
})
|
|
||||||
|
|
||||||
const manualCompleted = await seedScenario({
|
|
||||||
scenarioKey: 'manual_completed',
|
|
||||||
orderLabel: 'SEED-MANUAL-COMPLETED',
|
|
||||||
skuCode: 'seed-manual-completed',
|
|
||||||
skuName: '开发种子-人工履约已完成',
|
|
||||||
afterCreate: async (result) => {
|
|
||||||
const task = result.tasks[0]
|
|
||||||
if (!task) {
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
|
|
||||||
await completeAdminTaskManualDispatch(task.id, {
|
|
||||||
outcome: 'delivered',
|
|
||||||
resultMessage: '开发种子数据:人工履约已完成',
|
|
||||||
deliveryReference: 'SEED-MANUAL-REF-001',
|
|
||||||
deliveredCredential: '开发种子数据:已通过人工渠道发放',
|
|
||||||
})
|
|
||||||
|
|
||||||
return getTaskById(task.id)
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
const claimReady = await seedScenario({
|
|
||||||
scenarioKey: 'claim_ready',
|
|
||||||
orderLabel: 'SEED-CLAIM-READY',
|
|
||||||
skuCode: 'seed-claim-ready',
|
|
||||||
skuName: '开发种子-领取兑换可用',
|
|
||||||
})
|
|
||||||
|
|
||||||
const claimClaimed = await seedScenario({
|
|
||||||
scenarioKey: 'claim_claimed',
|
|
||||||
orderLabel: 'SEED-CLAIM-CLAIMED',
|
|
||||||
skuCode: 'seed-claim-claimed',
|
|
||||||
skuName: '开发种子-已打开领取链接',
|
|
||||||
afterCreate: async (result) => applyClaimedState(result.tasks[0]),
|
|
||||||
})
|
|
||||||
|
|
||||||
const claimRoleConfirmed = await seedScenario({
|
|
||||||
scenarioKey: 'claim_role_confirmed',
|
|
||||||
orderLabel: 'SEED-CLAIM-ROLE',
|
|
||||||
skuCode: 'seed-claim-role-confirmed',
|
|
||||||
skuName: '开发种子-已确认角色',
|
|
||||||
afterCreate: async (result) => applyRoleConfirmedState(result.tasks[0]),
|
|
||||||
})
|
|
||||||
|
|
||||||
const claimRetryPending = await seedScenario({
|
|
||||||
scenarioKey: 'claim_retry_pending',
|
|
||||||
orderLabel: 'SEED-CLAIM-RETRY',
|
|
||||||
skuCode: 'seed-claim-retry',
|
|
||||||
skuName: '开发种子-等待重试',
|
|
||||||
afterCreate: async (result) => applyRetryPendingState(result.tasks[0]),
|
|
||||||
})
|
|
||||||
|
|
||||||
const claimExpired = await seedScenario({
|
|
||||||
scenarioKey: 'claim_expired',
|
|
||||||
orderLabel: 'SEED-CLAIM-EXPIRED',
|
|
||||||
skuCode: 'seed-claim-expired',
|
|
||||||
skuName: '开发种子-领取链接已过期',
|
|
||||||
afterCreate: async (result) => applyExpiredState(result.tasks[0]),
|
|
||||||
})
|
|
||||||
|
|
||||||
const claimWaitingInventory = await seedScenario({
|
|
||||||
scenarioKey: 'claim_waiting_inventory',
|
|
||||||
orderLabel: 'SEED-CLAIM-WAIT',
|
|
||||||
skuCode: 'seed-claim-wait',
|
|
||||||
skuName: '开发种子-等待库存',
|
|
||||||
})
|
|
||||||
|
|
||||||
printResultSummary([
|
|
||||||
manualPending,
|
|
||||||
manualCompleted,
|
|
||||||
claimReady,
|
|
||||||
claimClaimed,
|
|
||||||
claimRoleConfirmed,
|
|
||||||
claimRetryPending,
|
|
||||||
claimExpired,
|
|
||||||
claimWaitingInventory,
|
|
||||||
])
|
|
||||||
} catch (error) {
|
|
||||||
console.error('\n写入开发种子数据失败:', error instanceof Error ? error.message : String(error || '未知错误'))
|
|
||||||
process.exitCode = 1
|
|
||||||
} finally {
|
|
||||||
await closeDb()
|
|
||||||
}
|
|
||||||
|
|
||||||
async function ensureSeedBindings() {
|
|
||||||
const timestamp = nowIso()
|
|
||||||
const manualProfile = await getRequiredProfile('manual_review')
|
|
||||||
const claimProfile = await getRequiredProfile('tencent_claim_redeem')
|
|
||||||
|
|
||||||
const bindings = [
|
|
||||||
{ skuCode: 'seed-manual-pending', profileId: manualProfile.id, priority: 10 },
|
|
||||||
{ skuCode: 'seed-manual-completed', profileId: manualProfile.id, priority: 20 },
|
|
||||||
{ skuCode: 'seed-claim-ready', profileId: claimProfile.id, priority: 30 },
|
|
||||||
{ skuCode: 'seed-claim-claimed', profileId: claimProfile.id, priority: 40 },
|
|
||||||
{ skuCode: 'seed-claim-role-confirmed', profileId: claimProfile.id, priority: 50 },
|
|
||||||
{ skuCode: 'seed-claim-retry', profileId: claimProfile.id, priority: 60 },
|
|
||||||
{ skuCode: 'seed-claim-expired', profileId: claimProfile.id, priority: 70 },
|
|
||||||
{ skuCode: 'seed-claim-wait', profileId: claimProfile.id, priority: 80 },
|
|
||||||
]
|
|
||||||
|
|
||||||
for (const binding of bindings) {
|
|
||||||
await upsertSkuFulfillmentBinding({
|
|
||||||
skuCode: binding.skuCode,
|
|
||||||
provider: SEED_PROVIDER,
|
|
||||||
platform: SEED_PLATFORM,
|
|
||||||
shopId: SEED_SHOP_ID,
|
|
||||||
profileId: binding.profileId,
|
|
||||||
enabled: true,
|
|
||||||
priority: binding.priority,
|
|
||||||
configJson: JSON.stringify({
|
|
||||||
seededBy: 'scripts/seed-dev-data.ts',
|
|
||||||
scenario: binding.skuCode,
|
|
||||||
}),
|
|
||||||
createdAt: timestamp,
|
|
||||||
updatedAt: timestamp,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function createSeedInventory() {
|
|
||||||
const timestamp = nowIso()
|
|
||||||
|
|
||||||
await createInventoryItems([
|
|
||||||
{
|
|
||||||
skuCode: 'seed-claim-ready',
|
|
||||||
displayValue: 'SEED-CLAIM-CODE-READY-001',
|
|
||||||
batchNo: 'seed-20260409',
|
|
||||||
credentialType: 'tencent_code',
|
|
||||||
payload: {
|
|
||||||
code: 'SEED-CLAIM-CODE-READY-001',
|
|
||||||
scenario: 'claim_ready',
|
|
||||||
},
|
|
||||||
createdAt: timestamp,
|
|
||||||
updatedAt: timestamp,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
skuCode: 'seed-claim-claimed',
|
|
||||||
displayValue: 'SEED-CLAIM-CODE-CLAIMED-001',
|
|
||||||
batchNo: 'seed-20260409',
|
|
||||||
credentialType: 'tencent_code',
|
|
||||||
payload: {
|
|
||||||
code: 'SEED-CLAIM-CODE-CLAIMED-001',
|
|
||||||
scenario: 'claim_claimed',
|
|
||||||
},
|
|
||||||
createdAt: timestamp,
|
|
||||||
updatedAt: timestamp,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
skuCode: 'seed-claim-role-confirmed',
|
|
||||||
displayValue: 'SEED-CLAIM-CODE-ROLE-001',
|
|
||||||
batchNo: 'seed-20260409',
|
|
||||||
credentialType: 'tencent_code',
|
|
||||||
payload: {
|
|
||||||
code: 'SEED-CLAIM-CODE-ROLE-001',
|
|
||||||
scenario: 'claim_role_confirmed',
|
|
||||||
},
|
|
||||||
createdAt: timestamp,
|
|
||||||
updatedAt: timestamp,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
skuCode: 'seed-claim-retry',
|
|
||||||
displayValue: 'SEED-CLAIM-CODE-RETRY-001',
|
|
||||||
batchNo: 'seed-20260409',
|
|
||||||
credentialType: 'tencent_code',
|
|
||||||
payload: {
|
|
||||||
code: 'SEED-CLAIM-CODE-RETRY-001',
|
|
||||||
scenario: 'claim_retry_pending',
|
|
||||||
},
|
|
||||||
createdAt: timestamp,
|
|
||||||
updatedAt: timestamp,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
skuCode: 'seed-claim-expired',
|
|
||||||
displayValue: 'SEED-CLAIM-CODE-EXPIRED-001',
|
|
||||||
batchNo: 'seed-20260409',
|
|
||||||
credentialType: 'tencent_code',
|
|
||||||
payload: {
|
|
||||||
code: 'SEED-CLAIM-CODE-EXPIRED-001',
|
|
||||||
scenario: 'claim_expired',
|
|
||||||
},
|
|
||||||
createdAt: timestamp,
|
|
||||||
updatedAt: timestamp,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
skuCode: 'seed-spare-available',
|
|
||||||
displayValue: 'SEED-SPARE-CODE-001',
|
|
||||||
batchNo: 'seed-20260409',
|
|
||||||
credentialType: 'tencent_code',
|
|
||||||
payload: {
|
|
||||||
code: 'SEED-SPARE-CODE-001',
|
|
||||||
scenario: 'spare_available',
|
|
||||||
},
|
|
||||||
createdAt: timestamp,
|
|
||||||
updatedAt: timestamp,
|
|
||||||
},
|
|
||||||
])
|
|
||||||
}
|
|
||||||
|
|
||||||
async function seedScenario({ scenarioKey, orderLabel, skuCode, skuName, afterCreate = null }) {
|
|
||||||
const result = await upsertOrderFromWebhook(buildSeedOrderEvent({
|
|
||||||
scenarioKey,
|
|
||||||
orderLabel,
|
|
||||||
skuCode,
|
|
||||||
skuName,
|
|
||||||
}))
|
|
||||||
|
|
||||||
const task = typeof afterCreate === 'function'
|
|
||||||
? await afterCreate(result)
|
|
||||||
: result.tasks[0] || null
|
|
||||||
|
|
||||||
return {
|
|
||||||
scenarioKey,
|
|
||||||
orderId: result.order.id,
|
|
||||||
platformOrderId: result.order.platform_order_id,
|
|
||||||
taskId: task?.id || null,
|
|
||||||
taskNo: task?.task_no || '',
|
|
||||||
taskStatus: task?.task_status || '',
|
|
||||||
deliveryStatus: task?.delivery_status || '',
|
|
||||||
inventoryStatus: task?.inventory_status || '',
|
|
||||||
inventoryItemId: Number(task?.primary_inventory_item_id || 0) || null,
|
|
||||||
claimTokenId: Number(task?.primary_claim_token_id || 0) || null,
|
|
||||||
executorKey: String(task?.executor_key || '').trim(),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function applyClaimedState(task) {
|
|
||||||
if (!task) {
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
|
|
||||||
const now = nowIso()
|
|
||||||
await updateTask(task.id, {
|
|
||||||
task_status: 'claimed',
|
|
||||||
user_action_status: 'claimed',
|
|
||||||
browser_session_id: `seed-browser-${task.id}`,
|
|
||||||
login_type: 'qq',
|
|
||||||
claimed_at: now,
|
|
||||||
updated_at: now,
|
|
||||||
last_error: '',
|
|
||||||
})
|
|
||||||
|
|
||||||
return getTaskById(task.id)
|
|
||||||
}
|
|
||||||
|
|
||||||
async function applyRoleConfirmedState(task) {
|
|
||||||
if (!task) {
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
|
|
||||||
const claimedAt = addHours(nowIso(), -1)
|
|
||||||
const roleConfirmedAt = nowIso()
|
|
||||||
await updateTask(task.id, {
|
|
||||||
task_status: 'role_confirmed',
|
|
||||||
user_action_status: 'role_confirmed',
|
|
||||||
browser_session_id: `seed-browser-${task.id}`,
|
|
||||||
login_type: 'qq',
|
|
||||||
nickname: 'SeedHero',
|
|
||||||
role_id: 'seed-role-1001',
|
|
||||||
role_name: '开发角色-已确认',
|
|
||||||
area: 'seed-area',
|
|
||||||
partition_name: 'seed-partition',
|
|
||||||
claimed_at: claimedAt,
|
|
||||||
role_confirmed_at: roleConfirmedAt,
|
|
||||||
updated_at: roleConfirmedAt,
|
|
||||||
last_error: '',
|
|
||||||
})
|
|
||||||
|
|
||||||
return getTaskById(task.id)
|
|
||||||
}
|
|
||||||
|
|
||||||
async function applyRetryPendingState(task) {
|
|
||||||
if (!task) {
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
|
|
||||||
const claimedAt = addHours(nowIso(), -2)
|
|
||||||
const roleConfirmedAt = addHours(nowIso(), -1)
|
|
||||||
const updatedAt = nowIso()
|
|
||||||
await updateTask(task.id, {
|
|
||||||
task_status: 'retry_pending',
|
|
||||||
delivery_status: 'pending',
|
|
||||||
inventory_status: 'reserved',
|
|
||||||
user_action_status: 'role_confirmed',
|
|
||||||
browser_session_id: `seed-browser-${task.id}`,
|
|
||||||
login_type: 'qq',
|
|
||||||
nickname: 'SeedRetryUser',
|
|
||||||
role_id: 'seed-role-retry',
|
|
||||||
role_name: '开发角色-待重试',
|
|
||||||
area: 'seed-area',
|
|
||||||
partition_name: 'seed-partition',
|
|
||||||
claimed_at: claimedAt,
|
|
||||||
role_confirmed_at: roleConfirmedAt,
|
|
||||||
attempt_count: 2,
|
|
||||||
last_error: '开发种子数据:模拟兑换失败,等待重试',
|
|
||||||
updated_at: updatedAt,
|
|
||||||
})
|
|
||||||
|
|
||||||
return getTaskById(task.id)
|
|
||||||
}
|
|
||||||
|
|
||||||
async function applyExpiredState(task) {
|
|
||||||
if (!task) {
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
|
|
||||||
const now = nowIso()
|
|
||||||
if (task.primary_claim_token_id) {
|
|
||||||
await updateClaimToken(task.primary_claim_token_id, {
|
|
||||||
status: 'expired',
|
|
||||||
expired_at: addHours(now, -2),
|
|
||||||
updated_at: now,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
if (task.primary_inventory_item_id) {
|
|
||||||
await releaseReservedInventoryItem(task.primary_inventory_item_id, now)
|
|
||||||
}
|
|
||||||
|
|
||||||
await updateTask(task.id, {
|
|
||||||
task_status: 'expired',
|
|
||||||
inventory_status: 'pending',
|
|
||||||
user_action_status: 'expired',
|
|
||||||
last_error: '领取链接已过期,预占库存项已释放',
|
|
||||||
updated_at: now,
|
|
||||||
})
|
|
||||||
|
|
||||||
return getTaskById(task.id)
|
|
||||||
}
|
|
||||||
|
|
||||||
function buildSeedOrderEvent({ scenarioKey, orderLabel, skuCode, skuName }) {
|
|
||||||
const timestamp = nowIso()
|
|
||||||
|
|
||||||
return {
|
|
||||||
provider: SEED_PROVIDER,
|
|
||||||
platform: SEED_PLATFORM,
|
|
||||||
shopId: SEED_SHOP_ID,
|
|
||||||
shopName: SEED_SHOP_NAME,
|
|
||||||
platformOrderId: orderLabel,
|
|
||||||
orderStatus: 'paid',
|
|
||||||
payStatus: 'paid',
|
|
||||||
buyerId: `${scenarioKey}-buyer`,
|
|
||||||
buyerName: `开发种子-${scenarioKey}`,
|
|
||||||
receiverContact: `seed+${scenarioKey}@example.com`,
|
|
||||||
totalAmount: 100,
|
|
||||||
currency: 'CNY',
|
|
||||||
paidAt: timestamp,
|
|
||||||
rawPayload: {
|
|
||||||
source: 'seed-dev-data',
|
|
||||||
scenarioKey,
|
|
||||||
platformOrderId: orderLabel,
|
|
||||||
},
|
|
||||||
items: [
|
|
||||||
{
|
|
||||||
skuCode,
|
|
||||||
skuName,
|
|
||||||
quantity: 1,
|
|
||||||
spec: {
|
|
||||||
title: skuName,
|
|
||||||
scenarioKey,
|
|
||||||
},
|
|
||||||
snapshot: {
|
|
||||||
title: skuName,
|
|
||||||
scenarioKey,
|
|
||||||
seeded: true,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
],
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function getRequiredProfile(profileKey) {
|
|
||||||
const profile = await getFulfillmentProfileByKey(profileKey)
|
|
||||||
|
|
||||||
if (!profile) {
|
|
||||||
throw new Error(`缺少履约档案: ${profileKey}`)
|
|
||||||
}
|
|
||||||
|
|
||||||
return profile
|
|
||||||
}
|
|
||||||
|
|
||||||
function parseArgs(argv) {
|
|
||||||
return {
|
|
||||||
apply: argv.includes('--apply'),
|
|
||||||
reset: argv.includes('--reset'),
|
|
||||||
help: argv.includes('-h') || argv.includes('--help'),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function printHelp() {
|
|
||||||
console.log(`
|
|
||||||
用法:
|
|
||||||
npm run seed:dev-data -- [--apply] [--reset]
|
|
||||||
|
|
||||||
说明:
|
|
||||||
基于当前 PostgreSQL fulfillment schema 生成一组可重复验证的开发种子数据。
|
|
||||||
|
|
||||||
场景:
|
|
||||||
- manual_pending: 人工履约待处理
|
|
||||||
- manual_completed: 人工履约已完成
|
|
||||||
- claim_ready: 已预占库存并生成领取链接
|
|
||||||
- claim_claimed: 用户已打开领取链接
|
|
||||||
- claim_role_confirmed: 用户已确认角色
|
|
||||||
- claim_retry_pending: 自动履约失败等待重试
|
|
||||||
- claim_expired: 领取链接已过期
|
|
||||||
- claim_waiting_inventory: 等待库存
|
|
||||||
|
|
||||||
参数:
|
|
||||||
--apply 实际写入数据
|
|
||||||
--reset 写入前先清空测试业务数据
|
|
||||||
|
|
||||||
数据库连接:
|
|
||||||
${String(runtimeConfig.database?.url || '').trim() || '(未配置 DATABASE_URL)'}
|
|
||||||
`.trim())
|
|
||||||
}
|
|
||||||
|
|
||||||
function printPreview() {
|
|
||||||
console.log('准备生成开发种子数据:')
|
|
||||||
console.log('- manual_pending -> 人工履约待处理')
|
|
||||||
console.log('- manual_completed -> 人工履约已完成')
|
|
||||||
console.log('- claim_ready -> 领取兑换已就绪')
|
|
||||||
console.log('- claim_claimed -> 用户已打开领取链接')
|
|
||||||
console.log('- claim_role_confirmed -> 用户已确认角色')
|
|
||||||
console.log('- claim_retry_pending -> 自动履约失败等待重试')
|
|
||||||
console.log('- claim_expired -> 领取链接已过期')
|
|
||||||
console.log('- claim_waiting_inventory -> 等待库存')
|
|
||||||
console.log(`- database: ${String(runtimeConfig.database?.url || '').trim() || '(未配置 DATABASE_URL)'}`)
|
|
||||||
}
|
|
||||||
|
|
||||||
function printResultSummary(rows) {
|
|
||||||
console.log('\n开发种子数据已写入:')
|
|
||||||
|
|
||||||
for (const row of rows) {
|
|
||||||
console.log(`- ${row.scenarioKey}`)
|
|
||||||
console.log(` orderId=${row.orderId} platformOrderId=${row.platformOrderId}`)
|
|
||||||
console.log(` taskId=${row.taskId || '-'} taskNo=${row.taskNo || '-'} executor=${row.executorKey || '-'}`)
|
|
||||||
console.log(` taskStatus=${row.taskStatus || '-'} deliveryStatus=${row.deliveryStatus || '-'} inventoryStatus=${row.inventoryStatus || '-'}`)
|
|
||||||
console.log(` inventoryItemId=${row.inventoryItemId || '-'} claimTokenId=${row.claimTokenId || '-'}`)
|
|
||||||
}
|
|
||||||
|
|
||||||
console.log('\n建议验证入口:')
|
|
||||||
console.log('- `/api/v1/admin/orders?platformOrderId=SEED-`')
|
|
||||||
console.log('- `/api/v1/admin/tasks?taskNo=DT`')
|
|
||||||
console.log('- `/api/v1/admin/inventory`')
|
|
||||||
}
|
|
||||||
@@ -1,97 +1,5 @@
|
|||||||
#!/bin/sh
|
#!/bin/sh
|
||||||
set -eu
|
set -eu
|
||||||
|
|
||||||
is_true() {
|
|
||||||
normalized=$(printf '%s' "${1:-}" | tr '[:upper:]' '[:lower:]')
|
|
||||||
case "$normalized" in
|
|
||||||
1|true|yes|on)
|
|
||||||
return 0
|
|
||||||
;;
|
|
||||||
*)
|
|
||||||
return 1
|
|
||||||
;;
|
|
||||||
esac
|
|
||||||
}
|
|
||||||
|
|
||||||
start_browser_debug_services() {
|
|
||||||
display="${TENCENT_BROWSER_DISPLAY:-:99}"
|
|
||||||
resolution="${TENCENT_BROWSER_VNC_RESOLUTION:-1600x900x24}"
|
|
||||||
display_number="${display#:}"
|
|
||||||
display_number="${display_number%%.*}"
|
|
||||||
display_socket="/tmp/.X11-unix/X${display_number}"
|
|
||||||
display_lock="/tmp/.X${display_number}-lock"
|
|
||||||
|
|
||||||
if [ -S "$display_socket" ] && [ ! -f "$display_lock" ]; then
|
|
||||||
rm -f "$display_socket"
|
|
||||||
fi
|
|
||||||
|
|
||||||
if [ -f "$display_lock" ]; then
|
|
||||||
display_pid=$(cat "$display_lock" 2>/dev/null || true)
|
|
||||||
if [ -z "$display_pid" ] || ! kill -0 "$display_pid" 2>/dev/null; then
|
|
||||||
rm -f "$display_lock" "$display_socket"
|
|
||||||
fi
|
|
||||||
fi
|
|
||||||
|
|
||||||
export DISPLAY="$display"
|
|
||||||
Xvfb "$display" -screen 0 "$resolution" -nolisten tcp >/tmp/xvfb.log 2>&1 &
|
|
||||||
xvfb_pid=$!
|
|
||||||
|
|
||||||
ready=0
|
|
||||||
attempt=0
|
|
||||||
while [ "$attempt" -lt 50 ]; do
|
|
||||||
if [ -S "$display_socket" ]; then
|
|
||||||
ready=1
|
|
||||||
break
|
|
||||||
fi
|
|
||||||
|
|
||||||
if ! kill -0 "$xvfb_pid" 2>/dev/null; then
|
|
||||||
break
|
|
||||||
fi
|
|
||||||
|
|
||||||
attempt=$((attempt + 1))
|
|
||||||
sleep 0.1
|
|
||||||
done
|
|
||||||
|
|
||||||
if [ "$ready" -ne 1 ]; then
|
|
||||||
echo "[backend-dev] Xvfb display ${DISPLAY} not ready for x11vnc; skip noVNC startup" >&2
|
|
||||||
return
|
|
||||||
fi
|
|
||||||
|
|
||||||
if ! is_true "${TENCENT_BROWSER_NOVNC_ENABLED:-true}"; then
|
|
||||||
echo "[backend-dev] headed browser enabled on ${DISPLAY}; noVNC disabled"
|
|
||||||
return
|
|
||||||
fi
|
|
||||||
|
|
||||||
vnc_port="${TENCENT_BROWSER_VNC_PORT:-5900}"
|
|
||||||
novnc_port="${TENCENT_BROWSER_NOVNC_PORT:-6080}"
|
|
||||||
|
|
||||||
x11vnc \
|
|
||||||
-display "$display" \
|
|
||||||
-rfbport "$vnc_port" \
|
|
||||||
-forever \
|
|
||||||
-shared \
|
|
||||||
-nopw \
|
|
||||||
-listen 0.0.0.0 \
|
|
||||||
-xkb >/tmp/x11vnc.log 2>&1 &
|
|
||||||
|
|
||||||
if command -v novnc_proxy >/dev/null 2>&1; then
|
|
||||||
novnc_proxy --listen "$novnc_port" --vnc "127.0.0.1:${vnc_port}" >/tmp/novnc.log 2>&1 &
|
|
||||||
elif [ -x /usr/share/novnc/utils/novnc_proxy ]; then
|
|
||||||
/usr/share/novnc/utils/novnc_proxy --listen "$novnc_port" --vnc "127.0.0.1:${vnc_port}" >/tmp/novnc.log 2>&1 &
|
|
||||||
elif command -v websockify >/dev/null 2>&1 && [ -d /usr/share/novnc ]; then
|
|
||||||
websockify --web=/usr/share/novnc "$novnc_port" "127.0.0.1:${vnc_port}" >/tmp/novnc.log 2>&1 &
|
|
||||||
else
|
|
||||||
echo "[backend-dev] noVNC requested but novnc/websockify not found"
|
|
||||||
return
|
|
||||||
fi
|
|
||||||
|
|
||||||
echo "[backend-dev] headed browser enabled on ${DISPLAY}; noVNC: http://127.0.0.1:${novnc_port}/vnc.html"
|
|
||||||
}
|
|
||||||
|
|
||||||
npm install --no-fund --no-audit
|
npm install --no-fund --no-audit
|
||||||
|
|
||||||
if ! is_true "${TENCENT_BROWSER_HEADLESS:-true}"; then
|
|
||||||
start_browser_debug_services
|
|
||||||
fi
|
|
||||||
|
|
||||||
exec npm run dev
|
exec npm run dev
|
||||||
|
|||||||
@@ -1,288 +0,0 @@
|
|||||||
#!/usr/bin/env node
|
|
||||||
|
|
||||||
import crypto from 'node:crypto'
|
|
||||||
import process from 'node:process'
|
|
||||||
import { fileURLToPath } from 'node:url'
|
|
||||||
import { setTimeout as sleep } from 'node:timers/promises'
|
|
||||||
|
|
||||||
import { query, closeDb } from '../src/db/client.js'
|
|
||||||
import { parseJsonObject } from '../src/utils/json.js'
|
|
||||||
import { getAgisoShopConfig } from '../src/services/platforms/agiso/shop-config-service.js'
|
|
||||||
import { queryAgisoXianyuOrderDetail } from '../src/services/platforms/agiso/xianyu/order-detail-service.js'
|
|
||||||
|
|
||||||
const DEFAULT_DUMMY_ENDPOINT = 'https://gw-api.agiso.com/aldsIdle/Order/DummySend'
|
|
||||||
const DEFAULT_POLL_SECONDS = [0, 3, 10]
|
|
||||||
|
|
||||||
async function main() {
|
|
||||||
const options = parseArgs(process.argv.slice(2))
|
|
||||||
if (options.help || !options.orderId) {
|
|
||||||
printHelp()
|
|
||||||
process.exit(options.help ? 0 : 1)
|
|
||||||
}
|
|
||||||
|
|
||||||
const orderRecord = await resolveOrderRecord(options)
|
|
||||||
const shopId = orderRecord?.shop_id || options.shopId
|
|
||||||
const shopConfig = getAgisoShopConfig(shopId) || {}
|
|
||||||
const accessToken = String(shopConfig.accessToken || '').trim()
|
|
||||||
const appSecret = String(shopConfig.appSecret || process.env.AGISO_APP_SECRET || '').trim()
|
|
||||||
|
|
||||||
if (!shopId) {
|
|
||||||
throw new Error('未提供 shopId,且数据库中也没有查到该订单对应的店铺')
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!accessToken) {
|
|
||||||
throw new Error(`店铺 ${shopId} 缺少 accessToken 配置,请检查 apps/backend/data/agiso-shops.json`)
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!appSecret) {
|
|
||||||
throw new Error('缺少 AGISO_APP_SECRET 或店铺级 appSecret 配置')
|
|
||||||
}
|
|
||||||
|
|
||||||
printJson('测试输入', {
|
|
||||||
orderId: options.orderId,
|
|
||||||
shopId,
|
|
||||||
pollSeconds: options.pollSeconds,
|
|
||||||
orderRecord: orderRecord
|
|
||||||
? {
|
|
||||||
id: Number(orderRecord.id || 0) || null,
|
|
||||||
platformOrderId: String(orderRecord.platform_order_id || '').trim(),
|
|
||||||
orderStatus: String(orderRecord.order_status || '').trim(),
|
|
||||||
payStatus: String(orderRecord.pay_status || '').trim(),
|
|
||||||
provider: String(orderRecord.provider || '').trim(),
|
|
||||||
platform: String(orderRecord.platform || '').trim(),
|
|
||||||
}
|
|
||||||
: null,
|
|
||||||
})
|
|
||||||
|
|
||||||
const beforeDetail = await queryAgisoXianyuOrderDetail({
|
|
||||||
shopId,
|
|
||||||
platformOrderId: options.orderId,
|
|
||||||
requestId: `diag-before-${Date.now()}`,
|
|
||||||
})
|
|
||||||
printJson('调用前订单详情', summarizeDetail(beforeDetail))
|
|
||||||
|
|
||||||
const result = await executeRequest({
|
|
||||||
platformOrderId: options.orderId,
|
|
||||||
accessToken,
|
|
||||||
appSecret,
|
|
||||||
})
|
|
||||||
printJson('接口响应 更新发货状态 DummySend', result)
|
|
||||||
|
|
||||||
for (const seconds of options.pollSeconds) {
|
|
||||||
if (seconds > 0) {
|
|
||||||
await sleep(seconds * 1000)
|
|
||||||
}
|
|
||||||
|
|
||||||
const detail = await queryAgisoXianyuOrderDetail({
|
|
||||||
shopId,
|
|
||||||
platformOrderId: options.orderId,
|
|
||||||
requestId: `diag-after-${seconds}s-${Date.now()}`,
|
|
||||||
})
|
|
||||||
printJson(`调用后订单详情 +${seconds}s`, summarizeDetail(detail))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function parseArgs(argv) {
|
|
||||||
const output = {
|
|
||||||
orderId: '',
|
|
||||||
shopId: '',
|
|
||||||
pollSeconds: [...DEFAULT_POLL_SECONDS],
|
|
||||||
help: false,
|
|
||||||
}
|
|
||||||
|
|
||||||
for (let index = 0; index < argv.length; index += 1) {
|
|
||||||
const current = String(argv[index] || '').trim()
|
|
||||||
if (!current) {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!current.startsWith('--')) {
|
|
||||||
if (!output.orderId) {
|
|
||||||
output.orderId = current
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
throw new Error(`无法识别的参数:${current}`)
|
|
||||||
}
|
|
||||||
|
|
||||||
if (current === '--help') {
|
|
||||||
output.help = true
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
const [rawKey, inlineValue = ''] = current.split('=', 2)
|
|
||||||
const key = rawKey.slice(2)
|
|
||||||
const nextValue = inlineValue || argv[index + 1] || ''
|
|
||||||
const shouldConsumeNext = !inlineValue && argv[index + 1] && !String(argv[index + 1]).startsWith('--')
|
|
||||||
|
|
||||||
switch (key) {
|
|
||||||
case 'shop-id':
|
|
||||||
output.shopId = String(nextValue || '').trim()
|
|
||||||
break
|
|
||||||
case 'poll':
|
|
||||||
output.pollSeconds = parsePollSeconds(nextValue)
|
|
||||||
break
|
|
||||||
default:
|
|
||||||
throw new Error(`无法识别的参数:${current}`)
|
|
||||||
}
|
|
||||||
|
|
||||||
if (shouldConsumeNext) {
|
|
||||||
index += 1
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return output
|
|
||||||
}
|
|
||||||
|
|
||||||
function parsePollSeconds(value) {
|
|
||||||
const normalized = String(value || '').trim()
|
|
||||||
if (!normalized) {
|
|
||||||
return [...DEFAULT_POLL_SECONDS]
|
|
||||||
}
|
|
||||||
|
|
||||||
const parsed = normalized
|
|
||||||
.split(',')
|
|
||||||
.map((item) => Number(String(item || '').trim()))
|
|
||||||
.filter((item) => Number.isFinite(item) && item >= 0)
|
|
||||||
.map((item) => Math.floor(item))
|
|
||||||
|
|
||||||
if (parsed.length === 0) {
|
|
||||||
throw new Error(`poll 参数格式无效:${value}`)
|
|
||||||
}
|
|
||||||
|
|
||||||
return parsed
|
|
||||||
}
|
|
||||||
|
|
||||||
async function resolveOrderRecord(options) {
|
|
||||||
const explicitShopId = String(options.shopId || '').trim()
|
|
||||||
if (explicitShopId) {
|
|
||||||
const result = await query(
|
|
||||||
`
|
|
||||||
SELECT *
|
|
||||||
FROM orders
|
|
||||||
WHERE provider = 'agiso'
|
|
||||||
AND platform = 'xianyu'
|
|
||||||
AND shop_id = $1
|
|
||||||
AND platform_order_id = $2
|
|
||||||
ORDER BY id DESC
|
|
||||||
LIMIT 1
|
|
||||||
`,
|
|
||||||
[explicitShopId, options.orderId],
|
|
||||||
)
|
|
||||||
|
|
||||||
return result.rows[0] || null
|
|
||||||
}
|
|
||||||
|
|
||||||
const result = await query(
|
|
||||||
`
|
|
||||||
SELECT *
|
|
||||||
FROM orders
|
|
||||||
WHERE provider = 'agiso'
|
|
||||||
AND platform = 'xianyu'
|
|
||||||
AND platform_order_id = $1
|
|
||||||
ORDER BY id DESC
|
|
||||||
LIMIT 2
|
|
||||||
`,
|
|
||||||
[options.orderId],
|
|
||||||
)
|
|
||||||
|
|
||||||
if (result.rows.length > 1) {
|
|
||||||
throw new Error(`数据库里命中多条同订单号记录,请显式传入 --shop-id。订单号:${options.orderId}`)
|
|
||||||
}
|
|
||||||
|
|
||||||
return result.rows[0] || null
|
|
||||||
}
|
|
||||||
|
|
||||||
async function executeRequest(context) {
|
|
||||||
const timestamp = String(Math.floor(Date.now() / 1000))
|
|
||||||
const requestBody = buildDummySendBody(context, timestamp)
|
|
||||||
const response = await fetch(DEFAULT_DUMMY_ENDPOINT, {
|
|
||||||
method: 'POST',
|
|
||||||
headers: {
|
|
||||||
Authorization: `Bearer ${context.accessToken}`,
|
|
||||||
ApiVersion: '1',
|
|
||||||
'content-type': 'application/x-www-form-urlencoded; charset=utf-8',
|
|
||||||
},
|
|
||||||
body: new URLSearchParams(requestBody).toString(),
|
|
||||||
})
|
|
||||||
const rawText = await response.text()
|
|
||||||
const payload = parseJsonObject(rawText, { preserveLargeIntegers: true })
|
|
||||||
|
|
||||||
return {
|
|
||||||
endpoint: DEFAULT_DUMMY_ENDPOINT,
|
|
||||||
status: response.status,
|
|
||||||
requestBody,
|
|
||||||
response: payload,
|
|
||||||
rawText,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function buildDummySendBody(context, timestamp) {
|
|
||||||
const payload = {
|
|
||||||
tid: context.platformOrderId,
|
|
||||||
timestamp,
|
|
||||||
}
|
|
||||||
|
|
||||||
payload.sign = generateSign(payload, context.appSecret)
|
|
||||||
return payload
|
|
||||||
}
|
|
||||||
|
|
||||||
function generateSign(params, appSecret) {
|
|
||||||
let raw = String(appSecret || '').trim()
|
|
||||||
for (const [key, value] of Object.entries(params).sort(([left], [right]) => left.localeCompare(right))) {
|
|
||||||
raw += `${key}${value}`
|
|
||||||
}
|
|
||||||
raw += String(appSecret || '').trim()
|
|
||||||
return crypto.createHash('md5').update(raw, 'utf8').digest('hex').toLowerCase()
|
|
||||||
}
|
|
||||||
|
|
||||||
function summarizeDetail(detailResult) {
|
|
||||||
return {
|
|
||||||
success: Boolean(detailResult?.success),
|
|
||||||
reason: String(detailResult?.reason || '').trim(),
|
|
||||||
errorMessage: String(detailResult?.errorMessage || '').trim(),
|
|
||||||
responseStatus: Number(detailResult?.responseStatus || 0),
|
|
||||||
shipped: Boolean(detailResult?.shipped),
|
|
||||||
shipTime: Number(detailResult?.shipTime || 0),
|
|
||||||
orderStatus: Number(detailResult?.orderStatus || 0),
|
|
||||||
bizOrderId: String(detailResult?.detailPayload?.biz_order_id || '').trim(),
|
|
||||||
sellerNick: String(detailResult?.detailPayload?.seller_nick || '').trim(),
|
|
||||||
buyerNick: String(detailResult?.detailPayload?.buyer_nick || '').trim(),
|
|
||||||
sku: String(detailResult?.detailPayload?.sku || '').trim(),
|
|
||||||
itemTitle: String(detailResult?.detailPayload?.item?.title || '').trim(),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function printJson(title, value) {
|
|
||||||
process.stdout.write(`\n=== ${title} ===\n`)
|
|
||||||
process.stdout.write(`${JSON.stringify(value, null, 2)}\n`)
|
|
||||||
}
|
|
||||||
|
|
||||||
function printHelp() {
|
|
||||||
process.stdout.write(`用法:
|
|
||||||
npm run agiso:auto-delivery:test -- <订单号> [--shop-id <店铺ID>] [--poll 0,3,10]
|
|
||||||
|
|
||||||
说明:
|
|
||||||
1. 该脚本只测试正确的发货接口 DummySend。
|
|
||||||
2. 会先查 Order/Detail,再调用 DummySend,再按 poll 秒数轮询发货状态。
|
|
||||||
|
|
||||||
示例:
|
|
||||||
npm run agiso:auto-delivery:test -- 4502285714045005830
|
|
||||||
npm run agiso:auto-delivery:test -- 4502285714045005830 --poll 0,5,15
|
|
||||||
npm run agiso:auto-delivery:test -- 4502285714045005830 --shop-id 2209880145223
|
|
||||||
`)
|
|
||||||
}
|
|
||||||
|
|
||||||
const isNodeTestRunner = Array.isArray(process.execArgv) && process.execArgv.includes('--test')
|
|
||||||
const isDirectRun = !isNodeTestRunner && process.argv[1] && fileURLToPath(import.meta.url) === process.argv[1]
|
|
||||||
|
|
||||||
if (isDirectRun) {
|
|
||||||
main()
|
|
||||||
.catch((error) => {
|
|
||||||
process.stderr.write(`${error instanceof Error ? error.stack || error.message : String(error)}\n`)
|
|
||||||
process.exitCode = 1
|
|
||||||
})
|
|
||||||
.finally(async () => {
|
|
||||||
await closeDb()
|
|
||||||
})
|
|
||||||
}
|
|
||||||
@@ -1,98 +0,0 @@
|
|||||||
#!/usr/bin/env bash
|
|
||||||
set -euo pipefail
|
|
||||||
|
|
||||||
BASE_URL="http://127.0.0.1:3000"
|
|
||||||
APP_SECRET=$(cd /Users/yml/codes/order-site-workspace/apps/backend && node --input-type=module -e "import { runtimeConfig } from './src/config/runtime.js'; process.stdout.write(String(runtimeConfig.platforms.agiso.appSecret || ''))")
|
|
||||||
|
|
||||||
ORDER_ID="${1:-SIM-ORDER-20260408-0001}"
|
|
||||||
SKU_ID="32768"
|
|
||||||
SKU_NAME="海底捞套餐"
|
|
||||||
|
|
||||||
sign_agiso() {
|
|
||||||
local raw_json="$1"
|
|
||||||
local ts="$2"
|
|
||||||
|
|
||||||
node -e '
|
|
||||||
const crypto = require("crypto")
|
|
||||||
const appSecret = process.argv[1]
|
|
||||||
const rawJson = process.argv[2]
|
|
||||||
const timestamp = process.argv[3]
|
|
||||||
const sign = crypto
|
|
||||||
.createHash("md5")
|
|
||||||
.update(`${appSecret}json${rawJson}timestamp${timestamp}${appSecret}`, "utf8")
|
|
||||||
.digest("hex")
|
|
||||||
.toLowerCase()
|
|
||||||
process.stdout.write(sign)
|
|
||||||
' "$APP_SECRET" "$raw_json" "$ts"
|
|
||||||
}
|
|
||||||
|
|
||||||
push_agiso_event() {
|
|
||||||
local aopic="$1"
|
|
||||||
local raw_json="$2"
|
|
||||||
local ts
|
|
||||||
ts=$(date +%s)
|
|
||||||
local sign
|
|
||||||
sign=$(sign_agiso "$raw_json" "$ts")
|
|
||||||
|
|
||||||
echo
|
|
||||||
echo "========== PUSH aopic=$aopic =========="
|
|
||||||
echo "ORDER_ID=$ORDER_ID"
|
|
||||||
echo
|
|
||||||
|
|
||||||
curl -sS -X POST "${BASE_URL}/api/v1/webhooks/agiso/trade?aopic=${aopic}×tamp=${ts}&sign=${sign}" \
|
|
||||||
-H "Content-Type: application/x-www-form-urlencoded" \
|
|
||||||
--data-urlencode "json=${raw_json}"
|
|
||||||
|
|
||||||
echo
|
|
||||||
echo
|
|
||||||
}
|
|
||||||
|
|
||||||
create_payload() {
|
|
||||||
cat <<EOF
|
|
||||||
{"biz_order_id":"${ORDER_ID}","buyer_id":"buyer-local-001","buyer_name":"本地测试用户","receiver_contact":"13800000000","total_fee":"1","currency":"CNY","items":[{"goods_id":"${SKU_ID}","goods_name":"${SKU_NAME}","quantity":1}]}
|
|
||||||
EOF
|
|
||||||
}
|
|
||||||
|
|
||||||
paid_payload() {
|
|
||||||
cat <<EOF
|
|
||||||
{"biz_order_id":"${ORDER_ID}","buyer_id":"buyer-local-001","buyer_name":"本地测试用户","receiver_contact":"13800000000","total_fee":"1","currency":"CNY","order_status":"3","items":[{"goods_id":"${SKU_ID}","goods_name":"${SKU_NAME}","quantity":1}]}
|
|
||||||
EOF
|
|
||||||
}
|
|
||||||
|
|
||||||
confirm_payload() {
|
|
||||||
cat <<EOF
|
|
||||||
{"biz_order_id":"${ORDER_ID}","buyer_id":"buyer-local-001","buyer_name":"本地测试用户","receiver_contact":"13800000000","total_fee":"1","currency":"CNY","order_status":"4","items":[{"goods_id":"${SKU_ID}","goods_name":"${SKU_NAME}","quantity":1}]}
|
|
||||||
EOF
|
|
||||||
}
|
|
||||||
|
|
||||||
push_create() {
|
|
||||||
push_agiso_event "32" "$(create_payload)"
|
|
||||||
}
|
|
||||||
|
|
||||||
push_paid() {
|
|
||||||
push_agiso_event "1" "$(paid_payload)"
|
|
||||||
}
|
|
||||||
|
|
||||||
push_confirm() {
|
|
||||||
push_agiso_event "256" "$(confirm_payload)"
|
|
||||||
}
|
|
||||||
|
|
||||||
case "${2:-all}" in
|
|
||||||
create)
|
|
||||||
push_create
|
|
||||||
;;
|
|
||||||
paid)
|
|
||||||
push_paid
|
|
||||||
;;
|
|
||||||
confirm)
|
|
||||||
push_confirm
|
|
||||||
;;
|
|
||||||
all)
|
|
||||||
push_create
|
|
||||||
push_paid
|
|
||||||
;;
|
|
||||||
*)
|
|
||||||
echo "用法: $0 [ORDER_ID] [create|paid|confirm|all]"
|
|
||||||
exit 1
|
|
||||||
;;
|
|
||||||
esac
|
|
||||||
@@ -1,17 +1,16 @@
|
|||||||
import type { Request, Response, NextFunction } from "express";
|
import type { NextFunction, Request, Response } from "express";
|
||||||
import express from "express";
|
import express from "express";
|
||||||
import process from "node:process";
|
import process from "node:process";
|
||||||
|
|
||||||
import adminRouter from "./routes/admin.js";
|
import adminRouter from "./routes/admin.js";
|
||||||
import claimsRouter from "./routes/claims.js";
|
import claimsRouter from "./routes/claims.js";
|
||||||
import open91Router from "./routes/open-91.js";
|
import open91Router from "./routes/open-91.js";
|
||||||
import tencentRouter from "./routes/tencent.js";
|
|
||||||
import webhooksRouter from "./routes/webhooks.js";
|
|
||||||
import { buildSuccessPayload, sendRouteError } from "./utils/http.js";
|
|
||||||
import { accessLogMiddleware } from "./middleware/access-log.js";
|
import { accessLogMiddleware } from "./middleware/access-log.js";
|
||||||
import { createCorsMiddleware } from "./middleware/cors.js";
|
import { createCorsMiddleware } from "./middleware/cors.js";
|
||||||
import type { RuntimeConfig } from "./types/runtime-config.js";
|
|
||||||
import { buildHealthPayload, type StartupState } from "./startup/state.js";
|
import { buildHealthPayload, type StartupState } from "./startup/state.js";
|
||||||
|
import type { RuntimeConfig } from "./types/runtime-config.js";
|
||||||
|
import { buildSuccessPayload, sendRouteError } from "./utils/http.js";
|
||||||
|
|
||||||
type CreateAppOptions = {
|
type CreateAppOptions = {
|
||||||
startupState: StartupState;
|
startupState: StartupState;
|
||||||
isShutdownStarted: () => boolean;
|
isShutdownStarted: () => boolean;
|
||||||
@@ -44,6 +43,7 @@ export function createApp({
|
|||||||
buildSuccessPayload({
|
buildSuccessPayload({
|
||||||
status: isShutdownStarted() ? "shutting_down" : "alive",
|
status: isShutdownStarted() ? "shutting_down" : "alive",
|
||||||
pid: process.pid,
|
pid: process.pid,
|
||||||
|
mode: "kuaishou-lite",
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
@@ -89,9 +89,7 @@ export function createApp({
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
app.use("/api/v1/tencent", tencentRouter);
|
|
||||||
app.use("/api/v1/open/91", open91Router);
|
app.use("/api/v1/open/91", open91Router);
|
||||||
app.use("/api/v1/webhooks", webhooksRouter);
|
|
||||||
app.use("/api/v1/claim", claimsRouter);
|
app.use("/api/v1/claim", claimsRouter);
|
||||||
app.use("/api/v1/admin", adminRouter);
|
app.use("/api/v1/admin", adminRouter);
|
||||||
|
|
||||||
|
|||||||
@@ -8,30 +8,13 @@ export function createDefaultRuntimeConfig(projectRoot: string): RuntimeConfig {
|
|||||||
port: 3000,
|
port: 3000,
|
||||||
},
|
},
|
||||||
|
|
||||||
browser: {
|
|
||||||
chromePath: "",
|
|
||||||
headless: null,
|
|
||||||
devtools: null,
|
|
||||||
keepAlive: null,
|
|
||||||
prewarm: null,
|
|
||||||
slowMoMs: null,
|
|
||||||
vncResolution: "1600x900x24",
|
|
||||||
},
|
|
||||||
|
|
||||||
session: {
|
|
||||||
debug: false,
|
|
||||||
},
|
|
||||||
|
|
||||||
ocr: {
|
|
||||||
baseUrl: "",
|
|
||||||
},
|
|
||||||
|
|
||||||
data: {
|
data: {
|
||||||
root: path.resolve(projectRoot, "data"),
|
root: path.resolve(projectRoot, "data"),
|
||||||
},
|
},
|
||||||
|
|
||||||
logging: {
|
logging: {
|
||||||
level: "info",
|
level: "info",
|
||||||
|
retentionDays: 30,
|
||||||
},
|
},
|
||||||
|
|
||||||
database: {
|
database: {
|
||||||
@@ -52,30 +35,6 @@ export function createDefaultRuntimeConfig(projectRoot: string): RuntimeConfig {
|
|||||||
},
|
},
|
||||||
|
|
||||||
platforms: {
|
platforms: {
|
||||||
agiso: {
|
|
||||||
appSecret: "",
|
|
||||||
tradeDetail: {
|
|
||||||
endpoint: "https://gw-api.agiso.com/aldsIdle/Order/Detail",
|
|
||||||
apiVersion: "1",
|
|
||||||
timeoutMs: 5000,
|
|
||||||
},
|
|
||||||
autoDelivery: {
|
|
||||||
enabled: true,
|
|
||||||
endpoint: "https://gw-api.agiso.com/aldsIdle/Order/DummySend",
|
|
||||||
apiVersion: "1",
|
|
||||||
},
|
|
||||||
messaging: {
|
|
||||||
enabled: false,
|
|
||||||
sendMessageEndpoint:
|
|
||||||
"https://gw-api.agiso.com/aldsIdle/ImMsg/SendMsg",
|
|
||||||
apiVersion: "1",
|
|
||||||
messageTemplate:
|
|
||||||
"您的订单 {platformOrderId} 已创建领取链接,请在 {expiredAt} 前完成领取:{claimUrl}",
|
|
||||||
autoDeliveryMessageTemplate:
|
|
||||||
"您的订单 {platformOrderId} 已完成自动发货,请注意查收。",
|
|
||||||
shops: {},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
ninetyone: {
|
ninetyone: {
|
||||||
userId: "",
|
userId: "",
|
||||||
secret: "",
|
secret: "",
|
||||||
@@ -120,8 +79,8 @@ export function createDefaultRuntimeConfig(projectRoot: string): RuntimeConfig {
|
|||||||
bindUrlProbeReferer: "https://gp.qq.com/",
|
bindUrlProbeReferer: "https://gp.qq.com/",
|
||||||
bindUrlProbeExtraCookie: "",
|
bindUrlProbeExtraCookie: "",
|
||||||
clientSource: "ct-client",
|
clientSource: "ct-client",
|
||||||
deviceId: "-",
|
deviceId: "08bc9d8c-fd15-48ea-bc00-8d754076cafc",
|
||||||
deviceType: 0,
|
deviceType: 1,
|
||||||
publicKeyPem: `-----BEGIN PUBLIC KEY-----
|
publicKeyPem: `-----BEGIN PUBLIC KEY-----
|
||||||
MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAsSzjk+9st2f8IpYqYaM5
|
MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAsSzjk+9st2f8IpYqYaM5
|
||||||
F7K27ZR2IvzxTr2w4zgZAS2+BRdgMvXUOITdFQT+ytYF/r3pIgTkL7MF9UQuylmb
|
F7K27ZR2IvzxTr2w4zgZAS2+BRdgMvXUOITdFQT+ytYF/r3pIgTkL7MF9UQuylmb
|
||||||
@@ -137,9 +96,5 @@ PfQTPNA++KsXtwRX9M6Re3vkTDRsutIIWKtj8jqhUbbYS3vzS8GJnAWavUFVkR15
|
|||||||
cors: {
|
cors: {
|
||||||
allowedOrigins: ["*"],
|
allowedOrigins: ["*"],
|
||||||
},
|
},
|
||||||
|
|
||||||
redeem: {
|
|
||||||
proofMode: "full",
|
|
||||||
},
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -21,17 +21,13 @@ const DEPLOYMENT_ONLY_ENV_NAMES = [
|
|||||||
'CADDY_SITE_ADDR',
|
'CADDY_SITE_ADDR',
|
||||||
'CHOKIDAR_USEPOLLING',
|
'CHOKIDAR_USEPOLLING',
|
||||||
'NPM_CONFIG_REGISTRY',
|
'NPM_CONFIG_REGISTRY',
|
||||||
'OCR_IMAGE',
|
|
||||||
'OCR_PORT',
|
|
||||||
'POSTGRES_DB',
|
'POSTGRES_DB',
|
||||||
'POSTGRES_PASSWORD',
|
'POSTGRES_PASSWORD',
|
||||||
'POSTGRES_PORT',
|
'POSTGRES_PORT',
|
||||||
'POSTGRES_USER',
|
'POSTGRES_USER',
|
||||||
'TENCENT_BROWSER_NOVNC_ENABLED',
|
|
||||||
'TENCENT_BROWSER_NOVNC_PORT',
|
|
||||||
'TENCENT_BROWSER_VNC_PORT',
|
|
||||||
'TZ',
|
'TZ',
|
||||||
'VITE_API_TARGET',
|
'VITE_API_TARGET',
|
||||||
|
'VITE_ALLOWED_HOSTS',
|
||||||
]
|
]
|
||||||
|
|
||||||
const KNOWN_ENV_NAMES = new Set([
|
const KNOWN_ENV_NAMES = new Set([
|
||||||
|
|||||||
@@ -10,32 +10,27 @@ test('applyEnvOverrides maps typed environment values without mutating base conf
|
|||||||
|
|
||||||
const config = applyEnvOverrides(baseConfig, {
|
const config = applyEnvOverrides(baseConfig, {
|
||||||
PORT: '4000',
|
PORT: '4000',
|
||||||
TENCENT_BROWSER_HEADLESS: 'true',
|
|
||||||
TENCENT_BROWSER_SLOW_MO: '250',
|
|
||||||
TENCENT_BROWSER_VNC_RESOLUTION: '1920x1080x24',
|
|
||||||
DATA_ROOT: './tmp/backend-data',
|
DATA_ROOT: './tmp/backend-data',
|
||||||
DATABASE_SSL: 'yes',
|
DATABASE_SSL: 'yes',
|
||||||
|
LOG_RETENTION_DAYS: '45',
|
||||||
ADMIN_DEFAULT_USERS_JSON: '[{"username":"admin","password":"secret","role":"admin"}]',
|
ADMIN_DEFAULT_USERS_JSON: '[{"username":"admin","password":"secret","role":"admin"}]',
|
||||||
AGISO_TRADE_DETAIL_TIMEOUT_MS: '7000',
|
KAQUAN91_USER_ID: 'kaquan-user',
|
||||||
|
KAQUAN91_SECRET: 'kaquan-secret',
|
||||||
CLOUDTENTACLES_DEVICE_TYPE: '3',
|
CLOUDTENTACLES_DEVICE_TYPE: '3',
|
||||||
TENCENT_REDEEM_PROOF_MODE: 'basic',
|
|
||||||
})
|
})
|
||||||
|
|
||||||
assert.equal(config.server.port, 4000)
|
assert.equal(config.server.port, 4000)
|
||||||
assert.equal(config.browser.headless, true)
|
|
||||||
assert.equal(config.browser.slowMoMs, 250)
|
|
||||||
assert.equal(config.browser.vncResolution, '1920x1080x24')
|
|
||||||
assert.equal(config.data.root, path.resolve('./tmp/backend-data'))
|
assert.equal(config.data.root, path.resolve('./tmp/backend-data'))
|
||||||
assert.equal(config.database.ssl, true)
|
assert.equal(config.database.ssl, true)
|
||||||
|
assert.equal(config.logging.retentionDays, 45)
|
||||||
assert.deepEqual(config.admin.defaultUsers, [
|
assert.deepEqual(config.admin.defaultUsers, [
|
||||||
{ username: 'admin', password: 'secret', role: 'admin' },
|
{ username: 'admin', password: 'secret', role: 'admin' },
|
||||||
])
|
])
|
||||||
assert.equal(config.platforms.agiso.tradeDetail.timeoutMs, 7000)
|
assert.equal(config.platforms.ninetyone.userId, 'kaquan-user')
|
||||||
|
assert.equal(config.platforms.ninetyone.secret, 'kaquan-secret')
|
||||||
assert.equal(config.platforms.cloudtentacles.deviceType, 3)
|
assert.equal(config.platforms.cloudtentacles.deviceType, 3)
|
||||||
assert.equal(config.redeem.proofMode, 'basic')
|
|
||||||
|
|
||||||
assert.equal(baseConfig.server.port, 3000)
|
assert.equal(baseConfig.server.port, 3000)
|
||||||
assert.equal(baseConfig.browser.headless, null)
|
|
||||||
assert.deepEqual(baseConfig.admin.defaultUsers, [])
|
assert.deepEqual(baseConfig.admin.defaultUsers, [])
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -62,12 +57,14 @@ test('applyEnvOverrides ignores empty or invalid values', () => {
|
|||||||
DATABASE_SSL: 'maybe',
|
DATABASE_SSL: 'maybe',
|
||||||
ADMIN_DEFAULT_USERS_JSON: '{"username":"admin"}',
|
ADMIN_DEFAULT_USERS_JSON: '{"username":"admin"}',
|
||||||
LOG_LEVEL: ' ',
|
LOG_LEVEL: ' ',
|
||||||
|
LOG_RETENTION_DAYS: 'not-a-number',
|
||||||
})
|
})
|
||||||
|
|
||||||
assert.equal(config.server.port, 3000)
|
assert.equal(config.server.port, 3000)
|
||||||
assert.equal(config.database.ssl, false)
|
assert.equal(config.database.ssl, false)
|
||||||
assert.deepEqual(config.admin.defaultUsers, [])
|
assert.deepEqual(config.admin.defaultUsers, [])
|
||||||
assert.equal(config.logging.level, 'info')
|
assert.equal(config.logging.level, 'info')
|
||||||
|
assert.equal(config.logging.retentionDays, 30)
|
||||||
})
|
})
|
||||||
|
|
||||||
test('ENV_OVERRIDES keeps environment variable names unique', () => {
|
test('ENV_OVERRIDES keeps environment variable names unique', () => {
|
||||||
@@ -80,26 +77,12 @@ function createRuntimeConfig(): RuntimeConfig {
|
|||||||
server: {
|
server: {
|
||||||
port: 3000,
|
port: 3000,
|
||||||
},
|
},
|
||||||
browser: {
|
|
||||||
chromePath: '',
|
|
||||||
headless: null,
|
|
||||||
devtools: null,
|
|
||||||
keepAlive: null,
|
|
||||||
prewarm: null,
|
|
||||||
slowMoMs: null,
|
|
||||||
vncResolution: '1600x900x24',
|
|
||||||
},
|
|
||||||
session: {
|
|
||||||
debug: false,
|
|
||||||
},
|
|
||||||
ocr: {
|
|
||||||
baseUrl: '',
|
|
||||||
},
|
|
||||||
data: {
|
data: {
|
||||||
root: '/tmp/order-site',
|
root: '/tmp/order-site',
|
||||||
},
|
},
|
||||||
logging: {
|
logging: {
|
||||||
level: 'info',
|
level: 'info',
|
||||||
|
retentionDays: 30,
|
||||||
},
|
},
|
||||||
database: {
|
database: {
|
||||||
url: 'postgres://postgres:postgres@127.0.0.1:5432/order_site',
|
url: 'postgres://postgres:postgres@127.0.0.1:5432/order_site',
|
||||||
@@ -116,27 +99,6 @@ function createRuntimeConfig(): RuntimeConfig {
|
|||||||
defaultUsers: [],
|
defaultUsers: [],
|
||||||
},
|
},
|
||||||
platforms: {
|
platforms: {
|
||||||
agiso: {
|
|
||||||
appSecret: '',
|
|
||||||
tradeDetail: {
|
|
||||||
endpoint: 'https://gw-api.agiso.com/aldsIdle/Order/Detail',
|
|
||||||
apiVersion: '1',
|
|
||||||
timeoutMs: 5000,
|
|
||||||
},
|
|
||||||
autoDelivery: {
|
|
||||||
enabled: true,
|
|
||||||
endpoint: 'https://gw-api.agiso.com/aldsIdle/Order/DummySend',
|
|
||||||
apiVersion: '1',
|
|
||||||
},
|
|
||||||
messaging: {
|
|
||||||
enabled: false,
|
|
||||||
sendMessageEndpoint: 'https://gw-api.agiso.com/aldsIdle/ImMsg/SendMsg',
|
|
||||||
apiVersion: '1',
|
|
||||||
messageTemplate: '',
|
|
||||||
autoDeliveryMessageTemplate: '',
|
|
||||||
shops: {},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
ninetyone: {
|
ninetyone: {
|
||||||
userId: '',
|
userId: '',
|
||||||
secret: '',
|
secret: '',
|
||||||
@@ -180,12 +142,9 @@ function createRuntimeConfig(): RuntimeConfig {
|
|||||||
bindUrlProbeExtraCookie: '',
|
bindUrlProbeExtraCookie: '',
|
||||||
publicKeyPem: '',
|
publicKeyPem: '',
|
||||||
clientSource: 'ct-client',
|
clientSource: 'ct-client',
|
||||||
deviceId: '-',
|
deviceId: '08bc9d8c-fd15-48ea-bc00-8d754076cafc',
|
||||||
deviceType: 0,
|
deviceType: 1,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
redeem: {
|
|
||||||
proofMode: 'full',
|
|
||||||
},
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,7 +9,6 @@ import {
|
|||||||
parseBoolean,
|
parseBoolean,
|
||||||
parseInteger,
|
parseInteger,
|
||||||
parseJsonArray,
|
parseJsonArray,
|
||||||
parseCommaSeparatedStrings,
|
|
||||||
} from "./runtime-env.js";
|
} from "./runtime-env.js";
|
||||||
import { deepMerge } from "./runtime-merge.js";
|
import { deepMerge } from "./runtime-merge.js";
|
||||||
|
|
||||||
@@ -30,17 +29,9 @@ type EnvOverride = {
|
|||||||
|
|
||||||
export const ENV_OVERRIDES: readonly EnvOverride[] = [
|
export const ENV_OVERRIDES: readonly EnvOverride[] = [
|
||||||
integerEnv("PORT", ["server", "port"]),
|
integerEnv("PORT", ["server", "port"]),
|
||||||
stringEnv("CHROME_PATH", ["browser", "chromePath"]),
|
|
||||||
booleanEnv("TENCENT_BROWSER_HEADLESS", ["browser", "headless"]),
|
|
||||||
booleanEnv("TENCENT_BROWSER_DEVTOOLS", ["browser", "devtools"]),
|
|
||||||
booleanEnv("TENCENT_BROWSER_KEEP_ALIVE", ["browser", "keepAlive"]),
|
|
||||||
booleanEnv("TENCENT_BROWSER_PREWARM", ["browser", "prewarm"]),
|
|
||||||
integerEnv("TENCENT_BROWSER_SLOW_MO", ["browser", "slowMoMs"]),
|
|
||||||
stringEnv("TENCENT_BROWSER_VNC_RESOLUTION", ["browser", "vncResolution"]),
|
|
||||||
booleanEnv("TENCENT_SESSION_DEBUG", ["session", "debug"]),
|
|
||||||
stringEnv("OCR_BASE_URL", ["ocr", "baseUrl"]),
|
|
||||||
stringEnv("DATA_ROOT", ["data", "root"], path.resolve),
|
stringEnv("DATA_ROOT", ["data", "root"], path.resolve),
|
||||||
stringEnv("LOG_LEVEL", ["logging", "level"]),
|
stringEnv("LOG_LEVEL", ["logging", "level"]),
|
||||||
|
integerEnv("LOG_RETENTION_DAYS", ["logging", "retentionDays"]),
|
||||||
stringEnv("DATABASE_URL", ["database", "url"]),
|
stringEnv("DATABASE_URL", ["database", "url"]),
|
||||||
booleanEnv("DATABASE_SSL", ["database", "ssl"]),
|
booleanEnv("DATABASE_SSL", ["database", "ssl"]),
|
||||||
integerEnv("DATABASE_MAX_CONNECTIONS", ["database", "maxConnections"]),
|
integerEnv("DATABASE_MAX_CONNECTIONS", ["database", "maxConnections"]),
|
||||||
@@ -48,72 +39,17 @@ export const ENV_OVERRIDES: readonly EnvOverride[] = [
|
|||||||
stringEnv("ADMIN_SESSION_SECRET", ["admin", "sessionSecret"]),
|
stringEnv("ADMIN_SESSION_SECRET", ["admin", "sessionSecret"]),
|
||||||
integerEnv("ADMIN_SESSION_TTL_HOURS", ["admin", "sessionTtlHours"]),
|
integerEnv("ADMIN_SESSION_TTL_HOURS", ["admin", "sessionTtlHours"]),
|
||||||
adminUsersJsonEnv("ADMIN_DEFAULT_USERS_JSON", ["admin", "defaultUsers"]),
|
adminUsersJsonEnv("ADMIN_DEFAULT_USERS_JSON", ["admin", "defaultUsers"]),
|
||||||
stringEnv("AGISO_APP_SECRET", ["platforms", "agiso", "appSecret"]),
|
stringEnv("KAQUAN91_USER_ID", ["platforms", "ninetyone", "userId"]),
|
||||||
stringEnv("AGISO_TRADE_DETAIL_ENDPOINT", [
|
stringEnv("KAQUAN91_SECRET", ["platforms", "ninetyone", "secret"]),
|
||||||
"platforms",
|
stringEnv("KAQUAN91_VERSION", ["platforms", "ninetyone", "version"]),
|
||||||
"agiso",
|
stringEnv("KAQUAN91_SHOP_ID", ["platforms", "ninetyone", "shopId"]),
|
||||||
"tradeDetail",
|
stringEnv("KAQUAN91_SHOP_NAME", ["platforms", "ninetyone", "shopName"]),
|
||||||
"endpoint",
|
integerEnv("KAQUAN91_TIMESTAMP_TOLERANCE_SECONDS", [
|
||||||
]),
|
|
||||||
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", [
|
|
||||||
"platforms",
|
"platforms",
|
||||||
"ninetyone",
|
"ninetyone",
|
||||||
"timestampToleranceSeconds",
|
"timestampToleranceSeconds",
|
||||||
]),
|
]),
|
||||||
stringEnv("NINETYONE_CARDS_ENCODING", [
|
stringEnv("KAQUAN91_CARDS_ENCODING", [
|
||||||
"platforms",
|
"platforms",
|
||||||
"ninetyone",
|
"ninetyone",
|
||||||
"cardsEncoding",
|
"cardsEncoding",
|
||||||
@@ -288,7 +224,6 @@ export const ENV_OVERRIDES: readonly EnvOverride[] = [
|
|||||||
"cloudtentacles",
|
"cloudtentacles",
|
||||||
"deviceType",
|
"deviceType",
|
||||||
]),
|
]),
|
||||||
stringEnv("TENCENT_REDEEM_PROOF_MODE", ["redeem", "proofMode"]),
|
|
||||||
corsOriginsEnv("CORS_ALLOWED_ORIGINS", ["cors", "allowedOrigins"]),
|
corsOriginsEnv("CORS_ALLOWED_ORIGINS", ["cors", "allowedOrigins"]),
|
||||||
];
|
];
|
||||||
|
|
||||||
@@ -370,17 +305,6 @@ function adminUsersJsonEnv(
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function stringArrayEnv(
|
|
||||||
env: string,
|
|
||||||
configPath: RuntimeConfigPath
|
|
||||||
): EnvOverride {
|
|
||||||
return {
|
|
||||||
env,
|
|
||||||
path: configPath,
|
|
||||||
read: parseCommaSeparatedStrings,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function corsOriginsEnv(
|
function corsOriginsEnv(
|
||||||
env: string,
|
env: string,
|
||||||
configPath: RuntimeConfigPath
|
configPath: RuntimeConfigPath
|
||||||
@@ -431,5 +355,10 @@ function setConfigValue(
|
|||||||
current = current[key] as Record<string, unknown>;
|
current = current[key] as Record<string, unknown>;
|
||||||
}
|
}
|
||||||
|
|
||||||
current[configPath[configPath.length - 1]] = value;
|
const lastKey = configPath[configPath.length - 1];
|
||||||
|
if (!lastKey) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
current[lastKey] = value;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -96,18 +96,3 @@ export function parseJsonArray<T = unknown>(rawValue: unknown): T[] | null {
|
|||||||
return null;
|
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: {
|
server: {
|
||||||
port: 3000,
|
port: 3000,
|
||||||
},
|
},
|
||||||
browser: {
|
|
||||||
chromePath: '',
|
|
||||||
headless: null,
|
|
||||||
devtools: null,
|
|
||||||
keepAlive: null,
|
|
||||||
prewarm: null,
|
|
||||||
slowMoMs: null,
|
|
||||||
vncResolution: '1600x900x24',
|
|
||||||
},
|
|
||||||
session: {
|
|
||||||
debug: false,
|
|
||||||
},
|
|
||||||
ocr: {
|
|
||||||
baseUrl: '',
|
|
||||||
},
|
|
||||||
data: {
|
data: {
|
||||||
root: '/tmp/order-site',
|
root: '/tmp/order-site',
|
||||||
},
|
},
|
||||||
@@ -154,27 +139,6 @@ function createRuntimeConfig(overrides: Record<string, unknown> = {}): RuntimeCo
|
|||||||
],
|
],
|
||||||
},
|
},
|
||||||
platforms: {
|
platforms: {
|
||||||
agiso: {
|
|
||||||
appSecret: '',
|
|
||||||
tradeDetail: {
|
|
||||||
endpoint: 'https://gw-api.agiso.com/aldsIdle/Order/Detail',
|
|
||||||
apiVersion: '1',
|
|
||||||
timeoutMs: 5000,
|
|
||||||
},
|
|
||||||
autoDelivery: {
|
|
||||||
enabled: true,
|
|
||||||
endpoint: 'https://gw-api.agiso.com/aldsIdle/Order/DummySend',
|
|
||||||
apiVersion: '1',
|
|
||||||
},
|
|
||||||
messaging: {
|
|
||||||
enabled: false,
|
|
||||||
sendMessageEndpoint: 'https://gw-api.agiso.com/aldsIdle/ImMsg/SendMsg',
|
|
||||||
apiVersion: '1',
|
|
||||||
messageTemplate: '',
|
|
||||||
autoDeliveryMessageTemplate: '',
|
|
||||||
shops: {},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
ninetyone: {
|
ninetyone: {
|
||||||
userId: '',
|
userId: '',
|
||||||
secret: '',
|
secret: '',
|
||||||
@@ -218,12 +182,9 @@ function createRuntimeConfig(overrides: Record<string, unknown> = {}): RuntimeCo
|
|||||||
bindUrlProbeExtraCookie: '',
|
bindUrlProbeExtraCookie: '',
|
||||||
publicKeyPem: '',
|
publicKeyPem: '',
|
||||||
clientSource: 'ct-client',
|
clientSource: 'ct-client',
|
||||||
deviceId: '-',
|
deviceId: '08bc9d8c-fd15-48ea-bc00-8d754076cafc',
|
||||||
deviceType: 0,
|
deviceType: 1,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
redeem: {
|
|
||||||
proofMode: 'full',
|
|
||||||
},
|
|
||||||
}, overrides) as RuntimeConfig
|
}, overrides) as RuntimeConfig
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -47,7 +47,6 @@ export function validateRuntimeConfig(
|
|||||||
requireInteger(issues, 'database.maxConnections', config.database?.maxConnections, { min: 1 })
|
requireInteger(issues, 'database.maxConnections', config.database?.maxConnections, { min: 1 })
|
||||||
requireInteger(issues, 'orders.tokenTtlHours', config.orders?.tokenTtlHours, { min: 1 })
|
requireInteger(issues, 'orders.tokenTtlHours', config.orders?.tokenTtlHours, { min: 1 })
|
||||||
requireInteger(issues, 'admin.sessionTtlHours', config.admin?.sessionTtlHours, { min: 1 })
|
requireInteger(issues, 'admin.sessionTtlHours', config.admin?.sessionTtlHours, { min: 1 })
|
||||||
requireInteger(issues, 'platforms.agiso.tradeDetail.timeoutMs', config.platforms?.agiso?.tradeDetail?.timeoutMs, { min: 1 })
|
|
||||||
requireInteger(issues, 'platforms.cloudtentacles.timeoutMs', config.platforms?.cloudtentacles?.timeoutMs, { min: 1 })
|
requireInteger(issues, 'platforms.cloudtentacles.timeoutMs', config.platforms?.cloudtentacles?.timeoutMs, { min: 1 })
|
||||||
requireInteger(issues, 'platforms.cloudtentacles.bindUrlTtlSeconds', config.platforms?.cloudtentacles?.bindUrlTtlSeconds, { min: 1 })
|
requireInteger(issues, 'platforms.cloudtentacles.bindUrlTtlSeconds', config.platforms?.cloudtentacles?.bindUrlTtlSeconds, { min: 1 })
|
||||||
requireInteger(
|
requireInteger(
|
||||||
|
|||||||
+33
-103
@@ -6,6 +6,7 @@ CREATE TABLE IF NOT EXISTS admin_users (
|
|||||||
password_hash TEXT NOT NULL,
|
password_hash TEXT NOT NULL,
|
||||||
role TEXT NOT NULL,
|
role TEXT NOT NULL,
|
||||||
status TEXT NOT NULL,
|
status TEXT NOT NULL,
|
||||||
|
session_version INTEGER NOT NULL DEFAULT 1,
|
||||||
created_at TIMESTAMPTZ NOT NULL,
|
created_at TIMESTAMPTZ NOT NULL,
|
||||||
updated_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)
|
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 (
|
CREATE TABLE IF NOT EXISTS orders (
|
||||||
id BIGSERIAL PRIMARY KEY,
|
id BIGSERIAL PRIMARY KEY,
|
||||||
provider TEXT NOT NULL,
|
provider TEXT NOT NULL,
|
||||||
@@ -89,6 +73,11 @@ CREATE TABLE IF NOT EXISTS orders (
|
|||||||
UNIQUE(provider, platform, shop_id, platform_order_id)
|
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 (
|
CREATE TABLE IF NOT EXISTS order_items (
|
||||||
id BIGSERIAL PRIMARY KEY,
|
id BIGSERIAL PRIMARY KEY,
|
||||||
order_id BIGINT NOT NULL REFERENCES orders(id) ON DELETE CASCADE,
|
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_order_id ON order_items(order_id);
|
||||||
CREATE INDEX IF NOT EXISTS idx_order_items_sku_code ON order_items(sku_code);
|
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 (
|
CREATE TABLE IF NOT EXISTS fulfillment_tasks (
|
||||||
id BIGSERIAL PRIMARY KEY,
|
id BIGSERIAL PRIMARY KEY,
|
||||||
order_id BIGINT NOT NULL REFERENCES orders(id) ON DELETE CASCADE,
|
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,
|
profile_id BIGINT NOT NULL REFERENCES fulfillment_profiles(id) ON DELETE RESTRICT,
|
||||||
executor_key TEXT NOT NULL,
|
executor_key TEXT NOT NULL,
|
||||||
task_status TEXT NOT NULL DEFAULT 'pending_payment',
|
task_status TEXT NOT NULL DEFAULT 'pending_payment',
|
||||||
inventory_status TEXT NOT NULL DEFAULT 'pending',
|
|
||||||
delivery_status TEXT NOT NULL DEFAULT 'pending',
|
delivery_status TEXT NOT NULL DEFAULT 'pending',
|
||||||
result_code TEXT NOT NULL DEFAULT '',
|
result_code TEXT NOT NULL DEFAULT '',
|
||||||
result_message 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_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_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_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 (
|
CREATE TABLE IF NOT EXISTS claim_tokens (
|
||||||
id BIGSERIAL PRIMARY KEY,
|
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 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 (
|
CREATE TABLE IF NOT EXISTS task_events (
|
||||||
id BIGSERIAL PRIMARY KEY,
|
id BIGSERIAL PRIMARY KEY,
|
||||||
task_id BIGINT NOT NULL REFERENCES fulfillment_tasks(id) ON DELETE CASCADE,
|
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 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,
|
id BIGSERIAL PRIMARY KEY,
|
||||||
task_id BIGINT NOT NULL UNIQUE REFERENCES fulfillment_tasks(id) ON DELETE CASCADE,
|
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 '',
|
login_type TEXT NOT NULL DEFAULT '',
|
||||||
nickname TEXT NOT NULL DEFAULT '',
|
nickname TEXT NOT NULL DEFAULT '',
|
||||||
role_id 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
|
updated_at TIMESTAMPTZ NOT NULL
|
||||||
);
|
);
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS message_deliveries (
|
CREATE TABLE IF NOT EXISTS kuaishou_cloud_task_states (
|
||||||
id BIGSERIAL PRIMARY KEY,
|
task_id BIGINT PRIMARY KEY REFERENCES fulfillment_tasks(id) ON DELETE CASCADE,
|
||||||
provider TEXT NOT NULL DEFAULT '',
|
ticket_status TEXT NOT NULL DEFAULT 'pending',
|
||||||
platform TEXT NOT NULL DEFAULT '',
|
ticket_code_masked TEXT NOT NULL DEFAULT '',
|
||||||
shop_id TEXT NOT NULL DEFAULT '',
|
bind_url TEXT NOT NULL DEFAULT '',
|
||||||
shop_name TEXT NOT NULL DEFAULT '',
|
vn_phone TEXT NOT NULL DEFAULT '',
|
||||||
channel TEXT NOT NULL,
|
role_name TEXT NOT NULL DEFAULT '',
|
||||||
order_id BIGINT REFERENCES orders(id) ON DELETE SET NULL,
|
role_id TEXT NOT NULL DEFAULT '',
|
||||||
task_id BIGINT REFERENCES fulfillment_tasks(id) ON DELETE SET NULL,
|
dispatch_status TEXT NOT NULL DEFAULT 'pending',
|
||||||
platform_order_id TEXT NOT NULL DEFAULT '',
|
consume_status TEXT NOT NULL DEFAULT 'pending',
|
||||||
recipient_key TEXT NOT NULL DEFAULT '',
|
source_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,
|
|
||||||
updated_at TIMESTAMPTZ NOT NULL
|
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 INDEX IF NOT EXISTS idx_kuaishou_cloud_task_states_role_id
|
||||||
|
ON kuaishou_cloud_task_states(role_id);
|
||||||
CREATE TABLE IF NOT EXISTS webhook_events (
|
CREATE INDEX IF NOT EXISTS idx_kuaishou_cloud_task_states_source_key
|
||||||
id BIGSERIAL PRIMARY KEY,
|
ON kuaishou_cloud_task_states(source_key);
|
||||||
provider TEXT NOT NULL DEFAULT '',
|
CREATE INDEX IF NOT EXISTS idx_kuaishou_cloud_task_states_dispatch_status
|
||||||
platform TEXT NOT NULL DEFAULT '',
|
ON kuaishou_cloud_task_states(dispatch_status);
|
||||||
shop_id TEXT NOT NULL DEFAULT '',
|
CREATE INDEX IF NOT EXISTS idx_kuaishou_cloud_task_states_consume_status
|
||||||
shop_name TEXT NOT NULL DEFAULT '',
|
ON kuaishou_cloud_task_states(consume_status);
|
||||||
event_type TEXT NOT NULL,
|
CREATE INDEX IF NOT EXISTS idx_kuaishou_cloud_task_states_updated_at_desc
|
||||||
event_key TEXT NOT NULL,
|
ON kuaishou_cloud_task_states(updated_at DESC);
|
||||||
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);
|
|
||||||
@@ -1,30 +0,0 @@
|
|||||||
CREATE TABLE IF NOT EXISTS product_match_rules (
|
|
||||||
id BIGSERIAL PRIMARY KEY,
|
|
||||||
provider TEXT NOT NULL DEFAULT '',
|
|
||||||
platform TEXT NOT NULL DEFAULT '',
|
|
||||||
shop_id TEXT NOT NULL DEFAULT '',
|
|
||||||
external_item_id TEXT NOT NULL DEFAULT '',
|
|
||||||
external_sku_code TEXT NOT NULL DEFAULT '',
|
|
||||||
external_sku_name TEXT NOT NULL DEFAULT '',
|
|
||||||
external_sku_name_normalized TEXT NOT NULL DEFAULT '',
|
|
||||||
resolved_sku_code TEXT NOT NULL,
|
|
||||||
enabled BOOLEAN NOT NULL DEFAULT TRUE,
|
|
||||||
priority INTEGER NOT NULL DEFAULT 100,
|
|
||||||
config_json JSONB NOT NULL DEFAULT '{}'::jsonb,
|
|
||||||
created_at TIMESTAMPTZ NOT NULL,
|
|
||||||
updated_at TIMESTAMPTZ NOT NULL
|
|
||||||
);
|
|
||||||
|
|
||||||
CREATE UNIQUE INDEX IF NOT EXISTS idx_product_match_rules_unique
|
|
||||||
ON product_match_rules(
|
|
||||||
provider,
|
|
||||||
platform,
|
|
||||||
shop_id,
|
|
||||||
external_item_id,
|
|
||||||
external_sku_code,
|
|
||||||
external_sku_name_normalized,
|
|
||||||
resolved_sku_code
|
|
||||||
);
|
|
||||||
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_product_match_rules_lookup
|
|
||||||
ON product_match_rules(provider, platform, shop_id, enabled, priority, id);
|
|
||||||
@@ -1,3 +0,0 @@
|
|||||||
CREATE INDEX IF NOT EXISTS idx_message_deliveries_dedupe_success
|
|
||||||
ON message_deliveries(provider, platform, shop_id, platform_order_id, channel, claim_url, created_at DESC)
|
|
||||||
WHERE status = 'success';
|
|
||||||
@@ -1,17 +0,0 @@
|
|||||||
ALTER TABLE inventory_items
|
|
||||||
ADD COLUMN IF NOT EXISTS inventory_group_code TEXT NOT NULL DEFAULT '';
|
|
||||||
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_inventory_items_group_lookup
|
|
||||||
ON inventory_items(sku_code, credential_type, inventory_group_code, status, id);
|
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS admin_user_inventory_group_bindings (
|
|
||||||
id BIGSERIAL PRIMARY KEY,
|
|
||||||
admin_user_id BIGINT NOT NULL REFERENCES admin_users(id) ON DELETE CASCADE,
|
|
||||||
inventory_group_code TEXT NOT NULL,
|
|
||||||
created_at TIMESTAMPTZ NOT NULL,
|
|
||||||
updated_at TIMESTAMPTZ NOT NULL,
|
|
||||||
UNIQUE(admin_user_id, inventory_group_code)
|
|
||||||
);
|
|
||||||
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_admin_user_inventory_group_bindings_user_id
|
|
||||||
ON admin_user_inventory_group_bindings(admin_user_id, inventory_group_code);
|
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
import test from 'node:test'
|
||||||
|
import assert from 'node:assert/strict'
|
||||||
|
|
||||||
|
import {
|
||||||
|
TASK_STATUS,
|
||||||
|
canRedeemKuaishouCloudClaimStatus,
|
||||||
|
canTaskTransition,
|
||||||
|
isClaimInactiveTaskStatus,
|
||||||
|
isOpen91FailedTaskStatus,
|
||||||
|
shouldEnsureKuaishouCloudClaimLink,
|
||||||
|
} from './task-status.js'
|
||||||
|
|
||||||
|
test('任务状态机声明快手 Cloud 主流程跳转', () => {
|
||||||
|
assert.equal(canTaskTransition(TASK_STATUS.PENDING_BINDING_PREPARE, TASK_STATUS.WAITING_BINDING), true)
|
||||||
|
assert.equal(canTaskTransition(TASK_STATUS.WAITING_BINDING, TASK_STATUS.ROLE_CONFIRMED), true)
|
||||||
|
assert.equal(canTaskTransition(TASK_STATUS.ROLE_CONFIRMED, TASK_STATUS.REDEEMING), true)
|
||||||
|
assert.equal(canTaskTransition(TASK_STATUS.REDEEMING, TASK_STATUS.DISPATCHED_PENDING_RETURN), true)
|
||||||
|
assert.equal(canTaskTransition(TASK_STATUS.DISPATCHED_PENDING_RETURN, TASK_STATUS.COMPLETED), true)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('任务状态机收敛领取和 91 查询的业务判断', () => {
|
||||||
|
assert.equal(shouldEnsureKuaishouCloudClaimLink(TASK_STATUS.PENDING_BINDING_PREPARE), true)
|
||||||
|
assert.equal(canRedeemKuaishouCloudClaimStatus(TASK_STATUS.ROLE_CONFIRMED), true)
|
||||||
|
assert.equal(canRedeemKuaishouCloudClaimStatus(TASK_STATUS.WAITING_BINDING), false)
|
||||||
|
assert.equal(isClaimInactiveTaskStatus(TASK_STATUS.MANUAL_REVIEW), true)
|
||||||
|
assert.equal(isOpen91FailedTaskStatus(TASK_STATUS.CLOSED), true)
|
||||||
|
})
|
||||||
@@ -0,0 +1,326 @@
|
|||||||
|
export const TASK_STATUS = {
|
||||||
|
PENDING_PAYMENT: 'pending_payment',
|
||||||
|
PAID: 'paid',
|
||||||
|
LINK_GENERATED: 'link_generated',
|
||||||
|
CLAIMED: 'claimed',
|
||||||
|
PENDING_BINDING_PREPARE: 'pending_binding_prepare',
|
||||||
|
WAITING_BINDING: 'waiting_binding',
|
||||||
|
ROLE_CONFIRMED: 'role_confirmed',
|
||||||
|
REDEEMING: 'redeeming',
|
||||||
|
DISPATCHED_PENDING_RETURN: 'dispatched_pending_return',
|
||||||
|
COMPLETED: 'completed',
|
||||||
|
REDEEMED: 'redeemed',
|
||||||
|
RETRY_PENDING: 'retry_pending',
|
||||||
|
MANUAL_REVIEW: 'manual_review',
|
||||||
|
FAILED: 'failed',
|
||||||
|
EXPIRED: 'expired',
|
||||||
|
CLOSED: 'closed',
|
||||||
|
} as const
|
||||||
|
|
||||||
|
export type KnownTaskStatus = (typeof TASK_STATUS)[keyof typeof TASK_STATUS]
|
||||||
|
export type TaskStatus = KnownTaskStatus | (string & {})
|
||||||
|
|
||||||
|
const TASK_TRANSITIONS: Record<KnownTaskStatus, readonly KnownTaskStatus[]> = {
|
||||||
|
[TASK_STATUS.PENDING_PAYMENT]: [
|
||||||
|
TASK_STATUS.PAID,
|
||||||
|
TASK_STATUS.PENDING_BINDING_PREPARE,
|
||||||
|
TASK_STATUS.MANUAL_REVIEW,
|
||||||
|
TASK_STATUS.CLOSED,
|
||||||
|
],
|
||||||
|
[TASK_STATUS.PAID]: [
|
||||||
|
TASK_STATUS.LINK_GENERATED,
|
||||||
|
TASK_STATUS.PENDING_BINDING_PREPARE,
|
||||||
|
TASK_STATUS.MANUAL_REVIEW,
|
||||||
|
TASK_STATUS.CLOSED,
|
||||||
|
],
|
||||||
|
[TASK_STATUS.LINK_GENERATED]: [
|
||||||
|
TASK_STATUS.CLAIMED,
|
||||||
|
TASK_STATUS.WAITING_BINDING,
|
||||||
|
TASK_STATUS.ROLE_CONFIRMED,
|
||||||
|
TASK_STATUS.RETRY_PENDING,
|
||||||
|
TASK_STATUS.MANUAL_REVIEW,
|
||||||
|
TASK_STATUS.EXPIRED,
|
||||||
|
TASK_STATUS.CLOSED,
|
||||||
|
],
|
||||||
|
[TASK_STATUS.CLAIMED]: [
|
||||||
|
TASK_STATUS.WAITING_BINDING,
|
||||||
|
TASK_STATUS.ROLE_CONFIRMED,
|
||||||
|
TASK_STATUS.RETRY_PENDING,
|
||||||
|
TASK_STATUS.MANUAL_REVIEW,
|
||||||
|
TASK_STATUS.EXPIRED,
|
||||||
|
TASK_STATUS.CLOSED,
|
||||||
|
],
|
||||||
|
[TASK_STATUS.PENDING_BINDING_PREPARE]: [
|
||||||
|
TASK_STATUS.WAITING_BINDING,
|
||||||
|
TASK_STATUS.RETRY_PENDING,
|
||||||
|
TASK_STATUS.MANUAL_REVIEW,
|
||||||
|
TASK_STATUS.EXPIRED,
|
||||||
|
TASK_STATUS.CLOSED,
|
||||||
|
],
|
||||||
|
[TASK_STATUS.WAITING_BINDING]: [
|
||||||
|
TASK_STATUS.ROLE_CONFIRMED,
|
||||||
|
TASK_STATUS.REDEEMING,
|
||||||
|
TASK_STATUS.RETRY_PENDING,
|
||||||
|
TASK_STATUS.MANUAL_REVIEW,
|
||||||
|
TASK_STATUS.EXPIRED,
|
||||||
|
TASK_STATUS.CLOSED,
|
||||||
|
],
|
||||||
|
[TASK_STATUS.ROLE_CONFIRMED]: [
|
||||||
|
TASK_STATUS.WAITING_BINDING,
|
||||||
|
TASK_STATUS.REDEEMING,
|
||||||
|
TASK_STATUS.RETRY_PENDING,
|
||||||
|
TASK_STATUS.MANUAL_REVIEW,
|
||||||
|
TASK_STATUS.EXPIRED,
|
||||||
|
TASK_STATUS.CLOSED,
|
||||||
|
],
|
||||||
|
[TASK_STATUS.REDEEMING]: [
|
||||||
|
TASK_STATUS.DISPATCHED_PENDING_RETURN,
|
||||||
|
TASK_STATUS.COMPLETED,
|
||||||
|
TASK_STATUS.REDEEMED,
|
||||||
|
TASK_STATUS.RETRY_PENDING,
|
||||||
|
TASK_STATUS.MANUAL_REVIEW,
|
||||||
|
],
|
||||||
|
[TASK_STATUS.DISPATCHED_PENDING_RETURN]: [
|
||||||
|
TASK_STATUS.COMPLETED,
|
||||||
|
TASK_STATUS.REDEEMED,
|
||||||
|
TASK_STATUS.MANUAL_REVIEW,
|
||||||
|
],
|
||||||
|
[TASK_STATUS.RETRY_PENDING]: [
|
||||||
|
TASK_STATUS.PENDING_BINDING_PREPARE,
|
||||||
|
TASK_STATUS.WAITING_BINDING,
|
||||||
|
TASK_STATUS.ROLE_CONFIRMED,
|
||||||
|
TASK_STATUS.REDEEMING,
|
||||||
|
TASK_STATUS.MANUAL_REVIEW,
|
||||||
|
TASK_STATUS.CLOSED,
|
||||||
|
],
|
||||||
|
[TASK_STATUS.MANUAL_REVIEW]: [
|
||||||
|
TASK_STATUS.PENDING_BINDING_PREPARE,
|
||||||
|
TASK_STATUS.WAITING_BINDING,
|
||||||
|
TASK_STATUS.ROLE_CONFIRMED,
|
||||||
|
TASK_STATUS.REDEEMING,
|
||||||
|
TASK_STATUS.DISPATCHED_PENDING_RETURN,
|
||||||
|
TASK_STATUS.COMPLETED,
|
||||||
|
TASK_STATUS.REDEEMED,
|
||||||
|
TASK_STATUS.CLOSED,
|
||||||
|
],
|
||||||
|
[TASK_STATUS.FAILED]: [TASK_STATUS.RETRY_PENDING, TASK_STATUS.MANUAL_REVIEW, TASK_STATUS.CLOSED],
|
||||||
|
[TASK_STATUS.COMPLETED]: [],
|
||||||
|
[TASK_STATUS.REDEEMED]: [],
|
||||||
|
[TASK_STATUS.EXPIRED]: [],
|
||||||
|
[TASK_STATUS.CLOSED]: [],
|
||||||
|
}
|
||||||
|
|
||||||
|
const FINAL_STATUSES = new Set<TaskStatus>([
|
||||||
|
TASK_STATUS.COMPLETED,
|
||||||
|
TASK_STATUS.REDEEMED,
|
||||||
|
TASK_STATUS.EXPIRED,
|
||||||
|
TASK_STATUS.CLOSED,
|
||||||
|
])
|
||||||
|
|
||||||
|
const OPEN_91_FAILED_STATUSES = new Set<TaskStatus>([
|
||||||
|
TASK_STATUS.FAILED,
|
||||||
|
TASK_STATUS.MANUAL_REVIEW,
|
||||||
|
TASK_STATUS.CLOSED,
|
||||||
|
])
|
||||||
|
|
||||||
|
const CLAIM_INACTIVE_STATUSES = new Set<TaskStatus>([
|
||||||
|
TASK_STATUS.COMPLETED,
|
||||||
|
TASK_STATUS.REDEEMED,
|
||||||
|
TASK_STATUS.MANUAL_REVIEW,
|
||||||
|
TASK_STATUS.FAILED,
|
||||||
|
TASK_STATUS.EXPIRED,
|
||||||
|
TASK_STATUS.CLOSED,
|
||||||
|
])
|
||||||
|
|
||||||
|
const PAID_PREPARATION_STABLE_STATUSES = new Set<TaskStatus>([
|
||||||
|
TASK_STATUS.LINK_GENERATED,
|
||||||
|
TASK_STATUS.CLAIMED,
|
||||||
|
TASK_STATUS.ROLE_CONFIRMED,
|
||||||
|
TASK_STATUS.REDEEMING,
|
||||||
|
TASK_STATUS.DISPATCHED_PENDING_RETURN,
|
||||||
|
TASK_STATUS.COMPLETED,
|
||||||
|
TASK_STATUS.REDEEMED,
|
||||||
|
TASK_STATUS.EXPIRED,
|
||||||
|
TASK_STATUS.CLOSED,
|
||||||
|
])
|
||||||
|
|
||||||
|
const KUAISHOU_CLOUD_CLAIM_LINK_STATUSES = new Set<TaskStatus>([
|
||||||
|
TASK_STATUS.PENDING_BINDING_PREPARE,
|
||||||
|
TASK_STATUS.WAITING_BINDING,
|
||||||
|
TASK_STATUS.DISPATCHED_PENDING_RETURN,
|
||||||
|
TASK_STATUS.COMPLETED,
|
||||||
|
TASK_STATUS.MANUAL_REVIEW,
|
||||||
|
TASK_STATUS.FAILED,
|
||||||
|
])
|
||||||
|
|
||||||
|
const KUAISHOU_CLOUD_ROLE_CONFIRM_SETTLED_STATUSES = new Set<TaskStatus>([
|
||||||
|
TASK_STATUS.ROLE_CONFIRMED,
|
||||||
|
TASK_STATUS.REDEEMING,
|
||||||
|
TASK_STATUS.DISPATCHED_PENDING_RETURN,
|
||||||
|
TASK_STATUS.COMPLETED,
|
||||||
|
TASK_STATUS.REDEEMED,
|
||||||
|
TASK_STATUS.MANUAL_REVIEW,
|
||||||
|
])
|
||||||
|
|
||||||
|
const KUAISHOU_CLOUD_REDEEM_SETTLED_STATUSES = new Set<TaskStatus>([
|
||||||
|
TASK_STATUS.REDEEMING,
|
||||||
|
TASK_STATUS.DISPATCHED_PENDING_RETURN,
|
||||||
|
TASK_STATUS.COMPLETED,
|
||||||
|
TASK_STATUS.REDEEMED,
|
||||||
|
TASK_STATUS.MANUAL_REVIEW,
|
||||||
|
])
|
||||||
|
|
||||||
|
const CLAIM_LINK_REGENERATABLE_STATUSES = new Set<TaskStatus>([
|
||||||
|
TASK_STATUS.LINK_GENERATED,
|
||||||
|
TASK_STATUS.CLAIMED,
|
||||||
|
TASK_STATUS.ROLE_CONFIRMED,
|
||||||
|
TASK_STATUS.RETRY_PENDING,
|
||||||
|
TASK_STATUS.MANUAL_REVIEW,
|
||||||
|
])
|
||||||
|
|
||||||
|
const KUAISHOU_CLOUD_RESULT_STATUSES = new Set<TaskStatus>([
|
||||||
|
TASK_STATUS.DISPATCHED_PENDING_RETURN,
|
||||||
|
TASK_STATUS.COMPLETED,
|
||||||
|
TASK_STATUS.REDEEMED,
|
||||||
|
TASK_STATUS.MANUAL_REVIEW,
|
||||||
|
TASK_STATUS.FAILED,
|
||||||
|
])
|
||||||
|
|
||||||
|
const FULFILLMENT_COMPLETED_STATUSES = new Set<TaskStatus>([
|
||||||
|
TASK_STATUS.DISPATCHED_PENDING_RETURN,
|
||||||
|
TASK_STATUS.COMPLETED,
|
||||||
|
TASK_STATUS.REDEEMED,
|
||||||
|
])
|
||||||
|
|
||||||
|
const RETRYABLE_TASK_STATUSES = new Set<TaskStatus>([
|
||||||
|
TASK_STATUS.RETRY_PENDING,
|
||||||
|
TASK_STATUS.MANUAL_REVIEW,
|
||||||
|
])
|
||||||
|
|
||||||
|
const KUAISHOU_CLOUD_PREPARABLE_STATUSES = new Set<TaskStatus>([
|
||||||
|
TASK_STATUS.PENDING_BINDING_PREPARE,
|
||||||
|
TASK_STATUS.MANUAL_REVIEW,
|
||||||
|
TASK_STATUS.FAILED,
|
||||||
|
])
|
||||||
|
|
||||||
|
const KUAISHOU_CLOUD_REBINDABLE_STATUSES = new Set<TaskStatus>([
|
||||||
|
TASK_STATUS.WAITING_BINDING,
|
||||||
|
TASK_STATUS.ROLE_CONFIRMED,
|
||||||
|
TASK_STATUS.MANUAL_REVIEW,
|
||||||
|
TASK_STATUS.RETRY_PENDING,
|
||||||
|
])
|
||||||
|
|
||||||
|
export function normalizeTaskStatus(value: unknown): TaskStatus {
|
||||||
|
return String(value || '').trim() as TaskStatus
|
||||||
|
}
|
||||||
|
|
||||||
|
export function canTaskTransition(from: unknown, to: unknown): boolean {
|
||||||
|
const current = normalizeTaskStatus(from) as KnownTaskStatus
|
||||||
|
const next = normalizeTaskStatus(to) as KnownTaskStatus
|
||||||
|
if (!current || !next || current === next) {
|
||||||
|
return Boolean(current && next)
|
||||||
|
}
|
||||||
|
|
||||||
|
return Boolean(TASK_TRANSITIONS[current]?.includes(next))
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getTaskNextStatuses(status: unknown): readonly KnownTaskStatus[] {
|
||||||
|
return TASK_TRANSITIONS[normalizeTaskStatus(status) as KnownTaskStatus] || []
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isTaskFinalStatus(status: unknown): boolean {
|
||||||
|
return FINAL_STATUSES.has(normalizeTaskStatus(status))
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isOpen91FailedTaskStatus(status: unknown): boolean {
|
||||||
|
return OPEN_91_FAILED_STATUSES.has(normalizeTaskStatus(status))
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isClaimInactiveTaskStatus(status: unknown): boolean {
|
||||||
|
return CLAIM_INACTIVE_STATUSES.has(normalizeTaskStatus(status))
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isPaidPreparationStableStatus(status: unknown): boolean {
|
||||||
|
return PAID_PREPARATION_STABLE_STATUSES.has(normalizeTaskStatus(status))
|
||||||
|
}
|
||||||
|
|
||||||
|
export function shouldEnsureKuaishouCloudClaimLink(status: unknown): boolean {
|
||||||
|
return KUAISHOU_CLOUD_CLAIM_LINK_STATUSES.has(normalizeTaskStatus(status))
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isKuaishouCloudRoleConfirmSettledStatus(status: unknown): boolean {
|
||||||
|
return KUAISHOU_CLOUD_ROLE_CONFIRM_SETTLED_STATUSES.has(normalizeTaskStatus(status))
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isKuaishouCloudRedeemSettledStatus(status: unknown): boolean {
|
||||||
|
return KUAISHOU_CLOUD_REDEEM_SETTLED_STATUSES.has(normalizeTaskStatus(status))
|
||||||
|
}
|
||||||
|
|
||||||
|
export function canRedeemKuaishouCloudClaimStatus(status: unknown): boolean {
|
||||||
|
return normalizeTaskStatus(status) === TASK_STATUS.ROLE_CONFIRMED
|
||||||
|
}
|
||||||
|
|
||||||
|
export function canRegenerateClaimLinkStatus(status: unknown): boolean {
|
||||||
|
return CLAIM_LINK_REGENERATABLE_STATUSES.has(normalizeTaskStatus(status))
|
||||||
|
}
|
||||||
|
|
||||||
|
export function hasKuaishouCloudRedeemResultStatus(status: unknown): boolean {
|
||||||
|
return KUAISHOU_CLOUD_RESULT_STATUSES.has(normalizeTaskStatus(status))
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isTaskFulfillmentCompletedStatus(status: unknown): boolean {
|
||||||
|
return FULFILLMENT_COMPLETED_STATUSES.has(normalizeTaskStatus(status))
|
||||||
|
}
|
||||||
|
|
||||||
|
export function canRetryTaskStatus(status: unknown): boolean {
|
||||||
|
return RETRYABLE_TASK_STATUSES.has(normalizeTaskStatus(status))
|
||||||
|
}
|
||||||
|
|
||||||
|
export function canMarkManualReviewTaskStatus(status: unknown): boolean {
|
||||||
|
const normalized = normalizeTaskStatus(status)
|
||||||
|
return normalized !== TASK_STATUS.MANUAL_REVIEW && !isTaskFinalStatus(normalized)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function canCompleteManualDispatchTaskStatus(status: unknown): boolean {
|
||||||
|
return !isTaskFinalStatus(status)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function canPrepareKuaishouCloudFulfillmentStatus(status: unknown): boolean {
|
||||||
|
return KUAISHOU_CLOUD_PREPARABLE_STATUSES.has(normalizeTaskStatus(status))
|
||||||
|
}
|
||||||
|
|
||||||
|
export function canRebindKuaishouCloudRoleStatus(status: unknown): boolean {
|
||||||
|
return KUAISHOU_CLOUD_REBINDABLE_STATUSES.has(normalizeTaskStatus(status))
|
||||||
|
}
|
||||||
|
|
||||||
|
export function canDispatchKuaishouCloudFulfillmentStatus(status: unknown): boolean {
|
||||||
|
return normalizeTaskStatus(status) === TASK_STATUS.WAITING_BINDING
|
||||||
|
}
|
||||||
|
|
||||||
|
export function canReturnKuaishouCloudFulfillmentStatus(status: unknown): boolean {
|
||||||
|
return normalizeTaskStatus(status) === TASK_STATUS.DISPATCHED_PENDING_RETURN
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resolveInitialPaidTaskStatus(
|
||||||
|
profile:
|
||||||
|
| {
|
||||||
|
requires_claim?: boolean
|
||||||
|
executor_key?: unknown
|
||||||
|
}
|
||||||
|
| null
|
||||||
|
| undefined,
|
||||||
|
): TaskStatus {
|
||||||
|
if (Boolean(profile?.requires_claim)) {
|
||||||
|
return TASK_STATUS.PAID
|
||||||
|
}
|
||||||
|
|
||||||
|
if (String(profile?.executor_key || '').trim() === 'kuaishou_ct_assisted') {
|
||||||
|
return TASK_STATUS.PENDING_BINDING_PREPARE
|
||||||
|
}
|
||||||
|
|
||||||
|
if (String(profile?.executor_key || '').trim() === 'manual_dispatch') {
|
||||||
|
return TASK_STATUS.MANUAL_REVIEW
|
||||||
|
}
|
||||||
|
|
||||||
|
return TASK_STATUS.PAID
|
||||||
|
}
|
||||||
@@ -20,6 +20,7 @@ try {
|
|||||||
logError("[startup]", "运行时配置校验失败,服务停止启动", error);
|
logError("[startup]", "运行时配置校验失败,服务停止启动", error);
|
||||||
process.exit(1);
|
process.exit(1);
|
||||||
}
|
}
|
||||||
|
|
||||||
const app = createApp({
|
const app = createApp({
|
||||||
startupState,
|
startupState,
|
||||||
isShutdownStarted: () => shutdownController?.isShutdownStarted() || false,
|
isShutdownStarted: () => shutdownController?.isShutdownStarted() || false,
|
||||||
@@ -27,7 +28,10 @@ const app = createApp({
|
|||||||
});
|
});
|
||||||
|
|
||||||
const server = app.listen(port, host, () => {
|
const server = app.listen(port, host, () => {
|
||||||
logInfo("[startup]", `order-site-backend listening on http://${host}:${port}`);
|
logInfo(
|
||||||
|
"[startup]",
|
||||||
|
`order-site-backend kuaishou-lite listening on http://${host}:${port}`
|
||||||
|
);
|
||||||
void bootstrapCoreServices(
|
void bootstrapCoreServices(
|
||||||
startupState,
|
startupState,
|
||||||
() => shutdownController?.isShutdownStarted() || false
|
() => shutdownController?.isShutdownStarted() || false
|
||||||
|
|||||||
@@ -43,7 +43,7 @@ function resolveRequestId(req: Request): string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function shouldSkipAccessLog(originalUrl: string, statusCode: number): boolean {
|
function shouldSkipAccessLog(originalUrl: string, statusCode: number): boolean {
|
||||||
const pathname = String(originalUrl || "").split("?")[0];
|
const pathname = String(originalUrl || "").split("?")[0] || "";
|
||||||
return (
|
return (
|
||||||
["/health", "/health/live", "/health/ready"].includes(pathname) &&
|
["/health", "/health/live", "/health/ready"].includes(pathname) &&
|
||||||
Number(statusCode) < 400
|
Number(statusCode) < 400
|
||||||
|
|||||||
@@ -0,0 +1,116 @@
|
|||||||
|
import test from "node:test";
|
||||||
|
import assert from "node:assert/strict";
|
||||||
|
import type { NextFunction, Request, Response } from "express";
|
||||||
|
|
||||||
|
import {
|
||||||
|
createRateLimitMiddleware,
|
||||||
|
resetRateLimitBucketsForTest,
|
||||||
|
} from "./rate-limit.js";
|
||||||
|
|
||||||
|
test("createRateLimitMiddleware allows requests within the window", () => {
|
||||||
|
resetRateLimitBucketsForTest();
|
||||||
|
|
||||||
|
const limiter = createRateLimitMiddleware({
|
||||||
|
scope: "test:allow",
|
||||||
|
windowMs: 60_000,
|
||||||
|
max: 2,
|
||||||
|
});
|
||||||
|
const req = createMockRequest();
|
||||||
|
const res = createMockResponse();
|
||||||
|
let nextCount = 0;
|
||||||
|
const next: NextFunction = () => {
|
||||||
|
nextCount += 1;
|
||||||
|
};
|
||||||
|
|
||||||
|
limiter(req, res, next);
|
||||||
|
limiter(req, res, next);
|
||||||
|
|
||||||
|
assert.equal(nextCount, 2);
|
||||||
|
assert.equal(res.statusCode, 200);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("createRateLimitMiddleware blocks requests over the limit", () => {
|
||||||
|
resetRateLimitBucketsForTest();
|
||||||
|
|
||||||
|
const limiter = createRateLimitMiddleware({
|
||||||
|
scope: "test:block",
|
||||||
|
windowMs: 60_000,
|
||||||
|
max: 1,
|
||||||
|
});
|
||||||
|
const req = createMockRequest();
|
||||||
|
const res = createMockResponse();
|
||||||
|
let nextCount = 0;
|
||||||
|
const next: NextFunction = () => {
|
||||||
|
nextCount += 1;
|
||||||
|
};
|
||||||
|
|
||||||
|
limiter(req, res, next);
|
||||||
|
limiter(req, res, next);
|
||||||
|
|
||||||
|
assert.equal(nextCount, 1);
|
||||||
|
assert.equal(res.statusCode, 429);
|
||||||
|
assert.equal(res.headers["Retry-After"], "60");
|
||||||
|
assert.equal(res.body.errorCode, "rate_limited");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("createRateLimitMiddleware supports custom limit responses", () => {
|
||||||
|
resetRateLimitBucketsForTest();
|
||||||
|
|
||||||
|
const limiter = createRateLimitMiddleware({
|
||||||
|
scope: "test:custom",
|
||||||
|
windowMs: 60_000,
|
||||||
|
max: 1,
|
||||||
|
onLimit: (_req, res) => {
|
||||||
|
res.status(200).json({ code: 429, message: "limited" });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const req = createMockRequest();
|
||||||
|
const res = createMockResponse();
|
||||||
|
|
||||||
|
limiter(req, res, () => {});
|
||||||
|
limiter(req, res, () => {});
|
||||||
|
|
||||||
|
assert.equal(res.statusCode, 200);
|
||||||
|
assert.deepEqual(res.body, { code: 429, message: "limited" });
|
||||||
|
});
|
||||||
|
|
||||||
|
function createMockRequest(): Request {
|
||||||
|
return {
|
||||||
|
ip: "127.0.0.1",
|
||||||
|
originalUrl: "/test",
|
||||||
|
headers: {},
|
||||||
|
socket: {
|
||||||
|
remoteAddress: "127.0.0.1",
|
||||||
|
},
|
||||||
|
} as Request;
|
||||||
|
}
|
||||||
|
|
||||||
|
function createMockResponse(): Response & {
|
||||||
|
statusCode: number;
|
||||||
|
headers: Record<string, string>;
|
||||||
|
body: any;
|
||||||
|
} {
|
||||||
|
const res = {
|
||||||
|
statusCode: 200,
|
||||||
|
headers: {} as Record<string, string>,
|
||||||
|
body: null as any,
|
||||||
|
setHeader(name: string, value: string) {
|
||||||
|
this.headers[name] = value;
|
||||||
|
return this;
|
||||||
|
},
|
||||||
|
status(statusCode: number) {
|
||||||
|
this.statusCode = statusCode;
|
||||||
|
return this;
|
||||||
|
},
|
||||||
|
json(body: any) {
|
||||||
|
this.body = body;
|
||||||
|
return this;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
return res as Response & {
|
||||||
|
statusCode: number;
|
||||||
|
headers: Record<string, string>;
|
||||||
|
body: any;
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,122 @@
|
|||||||
|
import type { NextFunction, Request, Response } from "express";
|
||||||
|
|
||||||
|
import { buildErrorPayload, createHttpError } from "../utils/http.js";
|
||||||
|
import { logWarn } from "../utils/logger.js";
|
||||||
|
|
||||||
|
type RateLimitKeyResolver = (req: Request) => string;
|
||||||
|
|
||||||
|
type RateLimitExceededContext = {
|
||||||
|
scope: string;
|
||||||
|
retryAfterSeconds: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
type RateLimitOptions = {
|
||||||
|
scope: string;
|
||||||
|
windowMs: number;
|
||||||
|
max: number;
|
||||||
|
key?: RateLimitKeyResolver;
|
||||||
|
onLimit?: (req: Request, res: Response, context: RateLimitExceededContext) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
type RateLimitBucket = {
|
||||||
|
count: number;
|
||||||
|
resetAt: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
const buckets = new Map<string, RateLimitBucket>();
|
||||||
|
let lastCleanupAt = 0;
|
||||||
|
|
||||||
|
export function createRateLimitMiddleware({
|
||||||
|
scope,
|
||||||
|
windowMs,
|
||||||
|
max,
|
||||||
|
key = defaultRateLimitKey,
|
||||||
|
onLimit,
|
||||||
|
}: RateLimitOptions) {
|
||||||
|
const normalizedScope = String(scope || "default").trim() || "default";
|
||||||
|
const normalizedWindowMs = Math.max(1000, Number(windowMs || 0));
|
||||||
|
const normalizedMax = Math.max(1, Number(max || 0));
|
||||||
|
|
||||||
|
return (req: Request, res: Response, next: NextFunction): void => {
|
||||||
|
const now = Date.now();
|
||||||
|
cleanupExpiredBuckets(now);
|
||||||
|
|
||||||
|
const bucketKey = `${normalizedScope}:${key(req)}`;
|
||||||
|
const current = buckets.get(bucketKey);
|
||||||
|
const bucket = current && current.resetAt > now
|
||||||
|
? current
|
||||||
|
: { count: 0, resetAt: now + normalizedWindowMs };
|
||||||
|
|
||||||
|
bucket.count += 1;
|
||||||
|
buckets.set(bucketKey, bucket);
|
||||||
|
|
||||||
|
if (bucket.count <= normalizedMax) {
|
||||||
|
next();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const retryAfterSeconds = Math.max(1, Math.ceil((bucket.resetAt - now) / 1000));
|
||||||
|
res.setHeader("Retry-After", String(retryAfterSeconds));
|
||||||
|
|
||||||
|
logWarn("[rate-limit]", "请求触发限流", {
|
||||||
|
scope: normalizedScope,
|
||||||
|
ip: req.ip,
|
||||||
|
originalUrl: req.originalUrl,
|
||||||
|
retryAfterSeconds,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (onLimit) {
|
||||||
|
onLimit(req, res, {
|
||||||
|
scope: normalizedScope,
|
||||||
|
retryAfterSeconds,
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const error = createHttpError("请求过于频繁,请稍后再试", {
|
||||||
|
statusCode: 429,
|
||||||
|
errorCode: "rate_limited",
|
||||||
|
});
|
||||||
|
res.status(429).json(buildErrorPayload(error, "请求过于频繁,请稍后再试"));
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resetRateLimitBucketsForTest(): void {
|
||||||
|
buckets.clear();
|
||||||
|
lastCleanupAt = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getBodyFieldRateLimitKey(fieldName: string): RateLimitKeyResolver {
|
||||||
|
return (req) => [defaultRateLimitKey(req), normalizeKeyPart(req.body?.[fieldName])].join(":");
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getParamRateLimitKey(paramName: string): RateLimitKeyResolver {
|
||||||
|
return (req) => [defaultRateLimitKey(req), normalizeKeyPart(req.params?.[paramName])].join(":");
|
||||||
|
}
|
||||||
|
|
||||||
|
function defaultRateLimitKey(req: Request): string {
|
||||||
|
return normalizeKeyPart(
|
||||||
|
req.ip ||
|
||||||
|
String(req.headers["x-forwarded-for"] || "").split(",")[0] ||
|
||||||
|
req.socket.remoteAddress ||
|
||||||
|
"unknown",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeKeyPart(value: unknown): string {
|
||||||
|
const normalized = String(value || "").trim().toLowerCase();
|
||||||
|
return normalized || "unknown";
|
||||||
|
}
|
||||||
|
|
||||||
|
function cleanupExpiredBuckets(now: number): void {
|
||||||
|
if (now - lastCleanupAt < 60_000) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
lastCleanupAt = now;
|
||||||
|
for (const [key, bucket] of buckets.entries()) {
|
||||||
|
if (bucket.resetAt <= now) {
|
||||||
|
buckets.delete(key);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
import { query, withTransaction } from '../db/client.js'
|
import { query } from '../db/client.js'
|
||||||
|
|
||||||
type AdminUserRow = {
|
type AdminUserRow = {
|
||||||
id: number
|
id: number
|
||||||
@@ -6,9 +6,9 @@ type AdminUserRow = {
|
|||||||
password_hash: string
|
password_hash: string
|
||||||
role: string
|
role: string
|
||||||
status: string
|
status: string
|
||||||
|
session_version: number
|
||||||
created_at: string
|
created_at: string
|
||||||
updated_at: string
|
updated_at: string
|
||||||
inventory_group_codes: string[]
|
|
||||||
}
|
}
|
||||||
|
|
||||||
type AdminUserCreateInput = {
|
type AdminUserCreateInput = {
|
||||||
@@ -22,7 +22,7 @@ type AdminUserCreateInput = {
|
|||||||
|
|
||||||
type AdminUserPatch = Partial<Pick<
|
type AdminUserPatch = Partial<Pick<
|
||||||
AdminUserRow,
|
AdminUserRow,
|
||||||
'username' | 'password_hash' | 'role' | 'status' | 'updated_at'
|
'username' | 'password_hash' | 'role' | 'status' | 'session_version' | 'updated_at'
|
||||||
>>
|
>>
|
||||||
|
|
||||||
type AdminUserListInput = {
|
type AdminUserListInput = {
|
||||||
@@ -39,15 +39,8 @@ type AdminUserListResult = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const ADMIN_USER_SELECT = `
|
const ADMIN_USER_SELECT = `
|
||||||
SELECT
|
SELECT au.*
|
||||||
au.*,
|
|
||||||
COALESCE(bindings.inventory_group_codes, ARRAY[]::text[]) AS inventory_group_codes
|
|
||||||
FROM admin_users 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> {
|
export async function getAdminUserById(userId: number | string): Promise<AdminUserRow | null> {
|
||||||
@@ -74,9 +67,10 @@ export async function createAdminUser(input: AdminUserCreateInput): Promise<Admi
|
|||||||
password_hash,
|
password_hash,
|
||||||
role,
|
role,
|
||||||
status,
|
status,
|
||||||
|
session_version,
|
||||||
created_at,
|
created_at,
|
||||||
updated_at
|
updated_at
|
||||||
) VALUES ($1, $2, $3, $4, $5, $6)
|
) VALUES ($1, $2, $3, $4, $5, $6, $7)
|
||||||
RETURNING id
|
RETURNING id
|
||||||
`,
|
`,
|
||||||
[
|
[
|
||||||
@@ -84,6 +78,7 @@ export async function createAdminUser(input: AdminUserCreateInput): Promise<Admi
|
|||||||
input.passwordHash,
|
input.passwordHash,
|
||||||
input.role,
|
input.role,
|
||||||
input.status,
|
input.status,
|
||||||
|
1,
|
||||||
input.createdAt,
|
input.createdAt,
|
||||||
input.updatedAt,
|
input.updatedAt,
|
||||||
],
|
],
|
||||||
@@ -110,8 +105,9 @@ export async function updateAdminUser(
|
|||||||
password_hash = $2,
|
password_hash = $2,
|
||||||
role = $3,
|
role = $3,
|
||||||
status = $4,
|
status = $4,
|
||||||
updated_at = $5
|
session_version = $5,
|
||||||
WHERE id = $6
|
updated_at = $6
|
||||||
|
WHERE id = $7
|
||||||
RETURNING id
|
RETURNING id
|
||||||
`,
|
`,
|
||||||
[
|
[
|
||||||
@@ -119,6 +115,7 @@ export async function updateAdminUser(
|
|||||||
next.password_hash,
|
next.password_hash,
|
||||||
next.role,
|
next.role,
|
||||||
next.status,
|
next.status,
|
||||||
|
Number(next.session_version || 1),
|
||||||
next.updated_at,
|
next.updated_at,
|
||||||
Number(userId),
|
Number(userId),
|
||||||
],
|
],
|
||||||
@@ -183,39 +180,3 @@ export async function countActiveAdminUsers(): Promise<number> {
|
|||||||
)
|
)
|
||||||
return Number(result.rows[0]?.total || 0)
|
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
|
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 = {
|
type FulfillmentProfileUpsertInput = {
|
||||||
profileKey: string
|
profileKey: string
|
||||||
name: string
|
name: string
|
||||||
@@ -61,7 +39,7 @@ type FulfillmentProfileUpsertInput = {
|
|||||||
updatedAt: string
|
updatedAt: string
|
||||||
}
|
}
|
||||||
|
|
||||||
type FulfillmentProfileRequirementInput = {
|
export type FulfillmentProfileRequirementInput = {
|
||||||
roleKey: string
|
roleKey: string
|
||||||
credentialType: string
|
credentialType: string
|
||||||
quantityPerUnit?: number | string
|
quantityPerUnit?: number | string
|
||||||
@@ -69,26 +47,6 @@ type FulfillmentProfileRequirementInput = {
|
|||||||
configJson?: string | Record<string, unknown>
|
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> {
|
export async function getFulfillmentProfileByKey(profileKey: string): Promise<FulfillmentProfileRow | null> {
|
||||||
const result = await query<FulfillmentProfileRow>(
|
const result = await query<FulfillmentProfileRow>(
|
||||||
'SELECT * FROM fulfillment_profiles WHERE profile_key = $1 LIMIT 1',
|
'SELECT * FROM fulfillment_profiles WHERE profile_key = $1 LIMIT 1',
|
||||||
@@ -195,113 +153,3 @@ export async function listFulfillmentProfileRequirements(
|
|||||||
|
|
||||||
return result.rows
|
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'
|
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 = [
|
const existingItems = [
|
||||||
{
|
{
|
||||||
id: 15,
|
id: 15,
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
import type { PoolClient, QueryResult } from 'pg'
|
import type { PoolClient, QueryResult } from 'pg'
|
||||||
|
|
||||||
import { query, withTransaction } from '../db/client.js'
|
import { query, withTransaction } from '../db/client.js'
|
||||||
import type { OrderItemReplaceInput } from '../types/repository-inputs.js'
|
import type { OrderItemReplaceInput } from '../types/repository/inputs.js'
|
||||||
import type { OrderItemRow } from '../types/repository-rows.js'
|
import type { OrderItemRow } from '../types/repository/rows.js'
|
||||||
|
|
||||||
type QueryExecutor = (text: string, params?: unknown[]) => Promise<QueryResult<any>>
|
type QueryExecutor = (text: string, params?: unknown[]) => Promise<QueryResult<any>>
|
||||||
|
|
||||||
@@ -109,6 +109,10 @@ export function resolveOrderItemSyncPlan(
|
|||||||
|
|
||||||
if (matchedIndex >= 0) {
|
if (matchedIndex >= 0) {
|
||||||
const matched = unmatchedExisting.splice(matchedIndex, 1)[0]
|
const matched = unmatchedExisting.splice(matchedIndex, 1)[0]
|
||||||
|
if (!matched) {
|
||||||
|
creates.push(item)
|
||||||
|
continue
|
||||||
|
}
|
||||||
updates.push({
|
updates.push({
|
||||||
orderItemId: matched.id,
|
orderItemId: matched.id,
|
||||||
item,
|
item,
|
||||||
|
|||||||
@@ -3,12 +3,12 @@ import type {
|
|||||||
OrderCreateInput,
|
OrderCreateInput,
|
||||||
OrderListQueryInput,
|
OrderListQueryInput,
|
||||||
OrderUpdateInput,
|
OrderUpdateInput,
|
||||||
} from '../types/repository-inputs.js'
|
} from '../types/repository/inputs.js'
|
||||||
import type {
|
import type {
|
||||||
OrderListQueryResult,
|
OrderListQueryResult,
|
||||||
OrderListRow,
|
OrderListRow,
|
||||||
OrderRow,
|
OrderRow,
|
||||||
} from '../types/repository-rows.js'
|
} from '../types/repository/rows.js'
|
||||||
|
|
||||||
type OrderPlatformLookupInput = {
|
type OrderPlatformLookupInput = {
|
||||||
provider?: string
|
provider?: string
|
||||||
@@ -17,15 +17,8 @@ type OrderPlatformLookupInput = {
|
|||||||
platformOrderId: string
|
platformOrderId: string
|
||||||
}
|
}
|
||||||
|
|
||||||
type OrderPlatformCandidateLookupInput = {
|
|
||||||
provider?: string
|
|
||||||
platform?: string
|
|
||||||
shopIds?: unknown[]
|
|
||||||
platformOrderId?: string
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function findOrderByPlatformOrderId({
|
export async function findOrderByPlatformOrderId({
|
||||||
provider = 'agiso',
|
provider = '91kaquan',
|
||||||
platform,
|
platform,
|
||||||
shopId = '',
|
shopId = '',
|
||||||
platformOrderId,
|
platformOrderId,
|
||||||
@@ -43,29 +36,20 @@ export async function findOrderByPlatformOrderId({
|
|||||||
return result.rows[0] || null
|
return result.rows[0] || null
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function findOrderByPlatformOrderIdCandidates({
|
export async function findLatestOrderByPlatformOrderId({
|
||||||
provider = 'agiso',
|
provider = '91kaquan',
|
||||||
platform,
|
platform,
|
||||||
shopIds = [],
|
|
||||||
platformOrderId,
|
platformOrderId,
|
||||||
}: OrderPlatformCandidateLookupInput): Promise<OrderRow | null> {
|
}: Omit<OrderPlatformLookupInput, 'shopId'>): 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
|
|
||||||
}
|
|
||||||
|
|
||||||
const result = await query<OrderRow>(
|
const result = await query<OrderRow>(
|
||||||
`
|
`
|
||||||
SELECT *
|
SELECT *
|
||||||
FROM orders
|
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
|
ORDER BY id DESC
|
||||||
LIMIT 1
|
LIMIT 1
|
||||||
`,
|
`,
|
||||||
[provider, platform, normalizedShopIds, platformOrderId],
|
[provider, platform, platformOrderId],
|
||||||
)
|
)
|
||||||
|
|
||||||
return result.rows[0] || null
|
return result.rows[0] || null
|
||||||
|
|||||||
@@ -1,169 +0,0 @@
|
|||||||
import { query } from '../db/client.js'
|
|
||||||
|
|
||||||
type ProductMatchRuleRow = {
|
|
||||||
id: number
|
|
||||||
provider: string
|
|
||||||
platform: string
|
|
||||||
shop_id: string
|
|
||||||
external_item_id: string
|
|
||||||
external_sku_code: string
|
|
||||||
external_sku_name: string
|
|
||||||
external_sku_name_normalized: string
|
|
||||||
resolved_sku_code: string
|
|
||||||
enabled: boolean
|
|
||||||
priority: number
|
|
||||||
config_json: string | Record<string, unknown>
|
|
||||||
created_at: string
|
|
||||||
updated_at: string
|
|
||||||
matched_by?: string
|
|
||||||
match_score?: number
|
|
||||||
}
|
|
||||||
|
|
||||||
type ProductMatchRuleUpsertInput = {
|
|
||||||
provider?: string
|
|
||||||
platform?: string
|
|
||||||
shopId?: string
|
|
||||||
externalItemId?: string
|
|
||||||
externalSkuCode?: string
|
|
||||||
externalSkuName?: string
|
|
||||||
externalSkuNameNormalized?: string
|
|
||||||
resolvedSkuCode: string
|
|
||||||
enabled?: boolean
|
|
||||||
priority?: number | string
|
|
||||||
configJson?: string | Record<string, unknown>
|
|
||||||
createdAt: string
|
|
||||||
updatedAt: string
|
|
||||||
}
|
|
||||||
|
|
||||||
type ProductMatchRuleResolveInput = {
|
|
||||||
provider?: string
|
|
||||||
platform?: string
|
|
||||||
shopId?: string
|
|
||||||
externalItemId?: string
|
|
||||||
externalSkuCode?: string
|
|
||||||
externalSkuNameNormalized?: string
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function upsertProductMatchRule(
|
|
||||||
input: ProductMatchRuleUpsertInput,
|
|
||||||
): Promise<ProductMatchRuleRow | null> {
|
|
||||||
const result = await query<ProductMatchRuleRow>(
|
|
||||||
`
|
|
||||||
INSERT INTO product_match_rules (
|
|
||||||
provider,
|
|
||||||
platform,
|
|
||||||
shop_id,
|
|
||||||
external_item_id,
|
|
||||||
external_sku_code,
|
|
||||||
external_sku_name,
|
|
||||||
external_sku_name_normalized,
|
|
||||||
resolved_sku_code,
|
|
||||||
enabled,
|
|
||||||
priority,
|
|
||||||
config_json,
|
|
||||||
created_at,
|
|
||||||
updated_at
|
|
||||||
) VALUES (
|
|
||||||
$1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11::jsonb, $12, $13
|
|
||||||
)
|
|
||||||
ON CONFLICT (
|
|
||||||
provider,
|
|
||||||
platform,
|
|
||||||
shop_id,
|
|
||||||
external_item_id,
|
|
||||||
external_sku_code,
|
|
||||||
external_sku_name_normalized,
|
|
||||||
resolved_sku_code
|
|
||||||
) DO UPDATE
|
|
||||||
SET
|
|
||||||
external_sku_name = EXCLUDED.external_sku_name,
|
|
||||||
enabled = EXCLUDED.enabled,
|
|
||||||
priority = EXCLUDED.priority,
|
|
||||||
config_json = EXCLUDED.config_json,
|
|
||||||
updated_at = EXCLUDED.updated_at
|
|
||||||
RETURNING *
|
|
||||||
`,
|
|
||||||
[
|
|
||||||
input.provider || '',
|
|
||||||
input.platform || '',
|
|
||||||
input.shopId || '',
|
|
||||||
input.externalItemId || '',
|
|
||||||
input.externalSkuCode || '',
|
|
||||||
input.externalSkuName || '',
|
|
||||||
input.externalSkuNameNormalized || '',
|
|
||||||
input.resolvedSkuCode,
|
|
||||||
input.enabled !== false,
|
|
||||||
Number(input.priority || 100),
|
|
||||||
input.configJson || '{}',
|
|
||||||
input.createdAt,
|
|
||||||
input.updatedAt,
|
|
||||||
],
|
|
||||||
)
|
|
||||||
|
|
||||||
return result.rows[0] || null
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function resolveProductMatchRule({
|
|
||||||
provider = '',
|
|
||||||
platform = '',
|
|
||||||
shopId = '',
|
|
||||||
externalItemId = '',
|
|
||||||
externalSkuCode = '',
|
|
||||||
externalSkuNameNormalized = '',
|
|
||||||
}: ProductMatchRuleResolveInput): Promise<ProductMatchRuleRow | null> {
|
|
||||||
const result = await query<ProductMatchRuleRow>(
|
|
||||||
`
|
|
||||||
SELECT
|
|
||||||
pmr.*,
|
|
||||||
CASE
|
|
||||||
WHEN $4 != '' AND pmr.external_sku_code = $4 THEN 'external_sku_code'
|
|
||||||
WHEN $5 != '' AND pmr.external_item_id = $5 THEN 'external_item_id'
|
|
||||||
WHEN $6 != '' AND pmr.external_sku_name_normalized = $6 THEN 'external_sku_name_exact'
|
|
||||||
WHEN $6 != '' AND pmr.external_sku_name_normalized != '' AND (
|
|
||||||
position(pmr.external_sku_name_normalized in $6) > 0
|
|
||||||
OR position($6 in pmr.external_sku_name_normalized) > 0
|
|
||||||
) THEN 'external_sku_name_contains'
|
|
||||||
ELSE ''
|
|
||||||
END AS matched_by,
|
|
||||||
CASE
|
|
||||||
WHEN $4 != '' AND pmr.external_sku_code = $4 THEN 400
|
|
||||||
WHEN $5 != '' AND pmr.external_item_id = $5 THEN 300
|
|
||||||
WHEN $6 != '' AND pmr.external_sku_name_normalized = $6 THEN 200
|
|
||||||
WHEN $6 != '' AND pmr.external_sku_name_normalized != '' AND (
|
|
||||||
position(pmr.external_sku_name_normalized in $6) > 0
|
|
||||||
OR position($6 in pmr.external_sku_name_normalized) > 0
|
|
||||||
) THEN 100
|
|
||||||
ELSE 0
|
|
||||||
END AS match_score
|
|
||||||
FROM product_match_rules pmr
|
|
||||||
WHERE pmr.enabled = TRUE
|
|
||||||
AND (pmr.provider = '' OR pmr.provider = $1)
|
|
||||||
AND (pmr.platform = '' OR pmr.platform = $2)
|
|
||||||
AND (pmr.shop_id = '' OR pmr.shop_id = $3)
|
|
||||||
AND (
|
|
||||||
($4 != '' AND pmr.external_sku_code = $4)
|
|
||||||
OR ($5 != '' AND pmr.external_item_id = $5)
|
|
||||||
OR (
|
|
||||||
$6 != ''
|
|
||||||
AND pmr.external_sku_name_normalized != ''
|
|
||||||
AND (
|
|
||||||
pmr.external_sku_name_normalized = $6
|
|
||||||
OR position(pmr.external_sku_name_normalized in $6) > 0
|
|
||||||
OR position($6 in pmr.external_sku_name_normalized) > 0
|
|
||||||
)
|
|
||||||
)
|
|
||||||
)
|
|
||||||
ORDER BY
|
|
||||||
match_score DESC,
|
|
||||||
CASE WHEN pmr.shop_id = '' THEN 1 ELSE 0 END,
|
|
||||||
CASE WHEN pmr.platform = '' THEN 1 ELSE 0 END,
|
|
||||||
CASE WHEN pmr.provider = '' THEN 1 ELSE 0 END,
|
|
||||||
pmr.priority ASC,
|
|
||||||
pmr.id ASC
|
|
||||||
LIMIT 1
|
|
||||||
`,
|
|
||||||
[provider, platform, shopId, externalSkuCode, externalItemId, externalSkuNameNormalized],
|
|
||||||
)
|
|
||||||
|
|
||||||
return result.rows[0] || null
|
|
||||||
}
|
|
||||||
@@ -1,105 +0,0 @@
|
|||||||
import { query } from '../db/client.js'
|
|
||||||
import type {
|
|
||||||
TaskInventoryBindingRow,
|
|
||||||
TaskInventoryBindingSummaryRow,
|
|
||||||
} from '../types/repository-rows.js'
|
|
||||||
|
|
||||||
export async function listTaskInventoryBindingsByTaskId(
|
|
||||||
taskId: number | string,
|
|
||||||
): Promise<TaskInventoryBindingRow[]> {
|
|
||||||
const result = await query<TaskInventoryBindingRow>(
|
|
||||||
`
|
|
||||||
SELECT
|
|
||||||
tib.id,
|
|
||||||
tib.task_id,
|
|
||||||
tib.inventory_item_id,
|
|
||||||
tib.role_key,
|
|
||||||
tib.quantity,
|
|
||||||
tib.binding_status,
|
|
||||||
tib.consumed_at,
|
|
||||||
tib.released_at,
|
|
||||||
tib.metadata_json,
|
|
||||||
tib.created_at,
|
|
||||||
tib.updated_at,
|
|
||||||
ii.sku_code,
|
|
||||||
ii.batch_no,
|
|
||||||
ii.credential_type,
|
|
||||||
ii.inventory_group_code,
|
|
||||||
ii.display_value,
|
|
||||||
ii.status AS inventory_item_status,
|
|
||||||
ii.invalid_reason
|
|
||||||
FROM task_inventory_bindings tib
|
|
||||||
JOIN inventory_items ii ON ii.id = tib.inventory_item_id
|
|
||||||
WHERE tib.task_id = $1
|
|
||||||
ORDER BY tib.id ASC
|
|
||||||
`,
|
|
||||||
[Number(taskId)],
|
|
||||||
)
|
|
||||||
|
|
||||||
return result.rows
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function getTaskInventoryBindingById(
|
|
||||||
bindingId: number | string,
|
|
||||||
): Promise<TaskInventoryBindingRow | null> {
|
|
||||||
const result = await query<TaskInventoryBindingRow>(
|
|
||||||
`
|
|
||||||
SELECT
|
|
||||||
tib.id,
|
|
||||||
tib.task_id,
|
|
||||||
tib.inventory_item_id,
|
|
||||||
tib.role_key,
|
|
||||||
tib.quantity,
|
|
||||||
tib.binding_status,
|
|
||||||
tib.consumed_at,
|
|
||||||
tib.released_at,
|
|
||||||
tib.metadata_json,
|
|
||||||
tib.created_at,
|
|
||||||
tib.updated_at,
|
|
||||||
ii.sku_code,
|
|
||||||
ii.batch_no,
|
|
||||||
ii.credential_type,
|
|
||||||
ii.inventory_group_code,
|
|
||||||
ii.display_value,
|
|
||||||
ii.status AS inventory_item_status,
|
|
||||||
ii.invalid_reason
|
|
||||||
FROM task_inventory_bindings tib
|
|
||||||
JOIN inventory_items ii ON ii.id = tib.inventory_item_id
|
|
||||||
WHERE tib.id = $1
|
|
||||||
LIMIT 1
|
|
||||||
`,
|
|
||||||
[Number(bindingId)],
|
|
||||||
)
|
|
||||||
|
|
||||||
return result.rows[0] || null
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function listTaskInventoryBindingSummariesByTaskIds(
|
|
||||||
taskIds: unknown[] = [],
|
|
||||||
): Promise<TaskInventoryBindingSummaryRow[]> {
|
|
||||||
const normalizedTaskIds = Array.from(new Set((Array.isArray(taskIds) ? taskIds : [])
|
|
||||||
.map((value) => Number(value))
|
|
||||||
.filter((value) => Number.isFinite(value) && value > 0)))
|
|
||||||
|
|
||||||
if (normalizedTaskIds.length === 0) {
|
|
||||||
return []
|
|
||||||
}
|
|
||||||
|
|
||||||
const result = await query<TaskInventoryBindingSummaryRow>(
|
|
||||||
`
|
|
||||||
SELECT
|
|
||||||
tib.task_id,
|
|
||||||
COUNT(*)::int AS total_binding_count,
|
|
||||||
COUNT(*) FILTER (WHERE tib.binding_status = 'reserved')::int AS reserved_binding_count,
|
|
||||||
COUNT(*) FILTER (WHERE tib.binding_status = 'consumed')::int AS consumed_binding_count,
|
|
||||||
COUNT(*) FILTER (WHERE tib.binding_status = 'released')::int AS released_binding_count,
|
|
||||||
ARRAY_AGG(DISTINCT tib.role_key ORDER BY tib.role_key) AS role_keys
|
|
||||||
FROM task_inventory_bindings tib
|
|
||||||
WHERE tib.task_id = ANY($1::bigint[])
|
|
||||||
GROUP BY tib.task_id
|
|
||||||
`,
|
|
||||||
[normalizedTaskIds],
|
|
||||||
)
|
|
||||||
|
|
||||||
return result.rows
|
|
||||||
}
|
|
||||||
@@ -1,21 +1,23 @@
|
|||||||
import { query, withTransaction } from '../db/client.js'
|
import { query, withTransaction } from '../db/client.js'
|
||||||
import type { PoolClient } from 'pg'
|
import type { PoolClient } from 'pg'
|
||||||
|
|
||||||
|
import { syncKuaishouCloudTaskStateForTask } from './kuaishou-cloud-task-state-repo.js'
|
||||||
|
import { normalizeTimestampIso } from '../utils/time.js'
|
||||||
import type {
|
import type {
|
||||||
TaskCreateInput,
|
TaskCreateInput,
|
||||||
TaskListQueryInput,
|
TaskListQueryInput,
|
||||||
TaskTencentContextPatch,
|
TaskRuntimeContextPatch,
|
||||||
TaskUpdatePatch,
|
TaskUpdatePatch,
|
||||||
} from '../types/repository-inputs.js'
|
} from '../types/repository/inputs.js'
|
||||||
import type {
|
import type {
|
||||||
TaskListQueryResult,
|
TaskListQueryResult,
|
||||||
TaskRow,
|
TaskRow,
|
||||||
} from '../types/repository-rows.js'
|
} from '../types/repository/rows.js'
|
||||||
|
|
||||||
type TaskQueryExecutor = (text: string, params?: unknown[]) => Promise<{ rows: unknown[] }>
|
type TaskQueryExecutor = (text: string, params?: unknown[]) => Promise<{ rows: unknown[] }>
|
||||||
|
|
||||||
type TencentBrowserContextRow = {
|
type TaskRuntimeContextRow = {
|
||||||
browser_session_id: string
|
runtime_session_id: string
|
||||||
login_type: string
|
login_type: string
|
||||||
nickname: string
|
nickname: string
|
||||||
role_id: string
|
role_id: string
|
||||||
@@ -33,7 +35,7 @@ const TASK_FIELDS = `
|
|||||||
ct.token AS primary_claim_token,
|
ct.token AS primary_claim_token,
|
||||||
ct.status AS primary_claim_token_status,
|
ct.status AS primary_claim_token_status,
|
||||||
ct.expired_at AS primary_claim_expires_at,
|
ct.expired_at AS primary_claim_expires_at,
|
||||||
ctx.browser_session_id,
|
ctx.runtime_session_id,
|
||||||
ctx.login_type,
|
ctx.login_type,
|
||||||
ctx.nickname,
|
ctx.nickname,
|
||||||
ctx.role_id,
|
ctx.role_id,
|
||||||
@@ -43,34 +45,23 @@ const TASK_FIELDS = `
|
|||||||
ctx.screenshot_path,
|
ctx.screenshot_path,
|
||||||
ctx.artifacts_json,
|
ctx.artifacts_json,
|
||||||
ctx.state_json,
|
ctx.state_json,
|
||||||
inv.inventory_item_id AS primary_inventory_item_id,
|
kcts.ticket_status AS kuaishou_ticket_status,
|
||||||
inv.display_value AS primary_inventory_display_value,
|
kcts.ticket_code_masked AS kuaishou_ticket_code_masked,
|
||||||
inv.credential_type AS primary_inventory_credential_type,
|
kcts.bind_url AS kuaishou_bind_url,
|
||||||
inv.binding_status AS primary_inventory_binding_status
|
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 = `
|
const TASK_JOINS = `
|
||||||
FROM fulfillment_tasks ft
|
FROM fulfillment_tasks ft
|
||||||
LEFT JOIN claim_tokens ct ON ct.task_id = ft.id
|
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 task_runtime_contexts ctx ON ctx.task_id = ft.id
|
||||||
LEFT JOIN LATERAL (
|
LEFT JOIN kuaishou_cloud_task_states kcts ON kcts.task_id = ft.id
|
||||||
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
|
|
||||||
`
|
`
|
||||||
|
|
||||||
function buildTaskSelect(extraFields = ''): string {
|
function buildTaskSelect(extraFields = ''): string {
|
||||||
@@ -106,7 +97,6 @@ export async function createTask(input: TaskCreateInput): Promise<TaskRow | null
|
|||||||
profile_id,
|
profile_id,
|
||||||
executor_key,
|
executor_key,
|
||||||
task_status,
|
task_status,
|
||||||
inventory_status,
|
|
||||||
delivery_status,
|
delivery_status,
|
||||||
result_code,
|
result_code,
|
||||||
result_message,
|
result_message,
|
||||||
@@ -123,7 +113,7 @@ export async function createTask(input: TaskCreateInput): Promise<TaskRow | null
|
|||||||
) VALUES (
|
) VALUES (
|
||||||
$1, $2, $3, $4, $5, $6, $7, $8, $9, $10,
|
$1, $2, $3, $4, $5, $6, $7, $8, $9, $10,
|
||||||
$11, $12, $13, $14, $15, $16, $17, $18, $19,
|
$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
|
RETURNING id
|
||||||
`,
|
`,
|
||||||
@@ -140,7 +130,6 @@ export async function createTask(input: TaskCreateInput): Promise<TaskRow | null
|
|||||||
input.profileId,
|
input.profileId,
|
||||||
input.executorKey,
|
input.executorKey,
|
||||||
input.taskStatus || 'pending_payment',
|
input.taskStatus || 'pending_payment',
|
||||||
input.inventoryStatus || 'pending',
|
|
||||||
input.deliveryStatus || 'pending',
|
input.deliveryStatus || 'pending',
|
||||||
input.resultCode || '',
|
input.resultCode || '',
|
||||||
input.resultMessage || '',
|
input.resultMessage || '',
|
||||||
@@ -158,10 +147,17 @@ export async function createTask(input: TaskCreateInput): Promise<TaskRow | null
|
|||||||
)
|
)
|
||||||
|
|
||||||
const taskId = Number(taskResult.rows[0]?.id || 0)
|
const taskId = Number(taskResult.rows[0]?.id || 0)
|
||||||
if (input.tencentContext) {
|
if (input.runtimeContext) {
|
||||||
await upsertTencentBrowserContextWithClient(client, taskId, input.tencentContext, input.createdAt)
|
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)
|
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
|
UPDATE fulfillment_tasks
|
||||||
SET
|
SET
|
||||||
task_status = $1,
|
task_status = $1,
|
||||||
inventory_status = $2,
|
delivery_status = $2,
|
||||||
delivery_status = $3,
|
result_code = $3,
|
||||||
result_code = $4,
|
result_message = $4,
|
||||||
result_message = $5,
|
claim_token = $5,
|
||||||
claim_token = $6,
|
claim_expires_at = $6,
|
||||||
claim_expires_at = $7,
|
automation_mode = $7,
|
||||||
automation_mode = $8,
|
requires_claim = $8,
|
||||||
requires_claim = $9,
|
user_action_status = $9,
|
||||||
user_action_status = $10,
|
attempt_count = $10,
|
||||||
attempt_count = $11,
|
last_error = $11,
|
||||||
last_error = $12,
|
context_json = $12::jsonb,
|
||||||
context_json = $13::jsonb,
|
claimed_at = $13,
|
||||||
claimed_at = $14,
|
role_confirmed_at = $14,
|
||||||
role_confirmed_at = $15,
|
redeemed_at = $15,
|
||||||
redeemed_at = $16,
|
updated_at = $16
|
||||||
updated_at = $17
|
WHERE id = $17
|
||||||
WHERE id = $18
|
|
||||||
`,
|
`,
|
||||||
[
|
[
|
||||||
next.task_status,
|
next.task_status,
|
||||||
next.inventory_status,
|
|
||||||
next.delivery_status,
|
next.delivery_status,
|
||||||
next.result_code,
|
next.result_code,
|
||||||
next.result_message,
|
next.result_message,
|
||||||
@@ -220,21 +214,93 @@ export async function updateTask(taskId: number | string, patch: TaskUpdatePatch
|
|||||||
],
|
],
|
||||||
)
|
)
|
||||||
|
|
||||||
if (containsTencentPatch(patch)) {
|
if (containsRuntimeContextPatch(patch)) {
|
||||||
await upsertTencentBrowserContextWithClient(client, Number(taskId), {
|
const runtimeContextPatch: TaskRuntimeContextPatch = {}
|
||||||
browserSessionId: patch.browser_session_id,
|
if (patch.runtime_session_id !== undefined) runtimeContextPatch.runtimeSessionId = patch.runtime_session_id
|
||||||
loginType: patch.login_type,
|
if (patch.login_type !== undefined) runtimeContextPatch.loginType = patch.login_type
|
||||||
nickname: patch.nickname,
|
if (patch.nickname !== undefined) runtimeContextPatch.nickname = patch.nickname
|
||||||
roleId: patch.role_id,
|
if (patch.role_id !== undefined) runtimeContextPatch.roleId = patch.role_id
|
||||||
roleName: patch.role_name,
|
if (patch.role_name !== undefined) runtimeContextPatch.roleName = patch.role_name
|
||||||
area: patch.area,
|
if (patch.area !== undefined) runtimeContextPatch.area = patch.area
|
||||||
partitionName: patch.partition_name,
|
if (patch.partition_name !== undefined) runtimeContextPatch.partitionName = patch.partition_name
|
||||||
screenshotPath: patch.screenshot_path,
|
if (patch.screenshot_path !== undefined) runtimeContextPatch.screenshotPath = patch.screenshot_path
|
||||||
artifactsJson: patch.artifacts_json,
|
if (patch.artifacts_json !== undefined) runtimeContextPatch.artifactsJson = patch.artifacts_json
|
||||||
stateJson: patch.state_json,
|
if (patch.state_json !== undefined) runtimeContextPatch.stateJson = patch.state_json
|
||||||
}, patch.updated_at || current.updated_at)
|
|
||||||
|
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)
|
return getTaskByIdWithExecutor(client.query.bind(client) as TaskQueryExecutor, taskId)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -291,7 +357,7 @@ export async function listTasks({
|
|||||||
|
|
||||||
if (roleId) {
|
if (roleId) {
|
||||||
params.push(`%${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) {
|
if (dateFrom) {
|
||||||
@@ -310,7 +376,8 @@ export async function listTasks({
|
|||||||
SELECT COUNT(*)::int AS total
|
SELECT COUNT(*)::int AS total
|
||||||
FROM fulfillment_tasks ft
|
FROM fulfillment_tasks ft
|
||||||
LEFT JOIN order_items oi ON oi.id = ft.order_item_id
|
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}
|
${whereClause}
|
||||||
`,
|
`,
|
||||||
params,
|
params,
|
||||||
@@ -333,20 +400,20 @@ export async function listTasks({
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function upsertTencentBrowserContextWithClient(
|
async function upsertTaskRuntimeContextWithClient(
|
||||||
client: PoolClient,
|
client: PoolClient,
|
||||||
taskId: number | string,
|
taskId: number | string,
|
||||||
patch: TaskTencentContextPatch = {},
|
patch: TaskRuntimeContextPatch = {},
|
||||||
timestamp: string | undefined,
|
timestamp: string | undefined,
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
const currentResult = await client.query<TencentBrowserContextRow>(
|
const currentResult = await client.query<TaskRuntimeContextRow>(
|
||||||
'SELECT * FROM tencent_browser_contexts WHERE task_id = $1 LIMIT 1',
|
'SELECT * FROM task_runtime_contexts WHERE task_id = $1 LIMIT 1',
|
||||||
[Number(taskId)],
|
[Number(taskId)],
|
||||||
)
|
)
|
||||||
const current = currentResult.rows[0] || null
|
const current = currentResult.rows[0] || null
|
||||||
|
|
||||||
const next = {
|
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 ?? '',
|
login_type: patch.loginType ?? current?.login_type ?? '',
|
||||||
nickname: patch.nickname ?? current?.nickname ?? '',
|
nickname: patch.nickname ?? current?.nickname ?? '',
|
||||||
role_id: patch.roleId ?? current?.role_id ?? '',
|
role_id: patch.roleId ?? current?.role_id ?? '',
|
||||||
@@ -362,9 +429,9 @@ async function upsertTencentBrowserContextWithClient(
|
|||||||
if (!current) {
|
if (!current) {
|
||||||
await client.query(
|
await client.query(
|
||||||
`
|
`
|
||||||
INSERT INTO tencent_browser_contexts (
|
INSERT INTO task_runtime_contexts (
|
||||||
task_id,
|
task_id,
|
||||||
browser_session_id,
|
runtime_session_id,
|
||||||
login_type,
|
login_type,
|
||||||
nickname,
|
nickname,
|
||||||
role_id,
|
role_id,
|
||||||
@@ -380,7 +447,7 @@ async function upsertTencentBrowserContextWithClient(
|
|||||||
`,
|
`,
|
||||||
[
|
[
|
||||||
Number(taskId),
|
Number(taskId),
|
||||||
next.browser_session_id,
|
next.runtime_session_id,
|
||||||
next.login_type,
|
next.login_type,
|
||||||
next.nickname,
|
next.nickname,
|
||||||
next.role_id,
|
next.role_id,
|
||||||
@@ -399,9 +466,9 @@ async function upsertTencentBrowserContextWithClient(
|
|||||||
|
|
||||||
await client.query(
|
await client.query(
|
||||||
`
|
`
|
||||||
UPDATE tencent_browser_contexts
|
UPDATE task_runtime_contexts
|
||||||
SET
|
SET
|
||||||
browser_session_id = $1,
|
runtime_session_id = $1,
|
||||||
login_type = $2,
|
login_type = $2,
|
||||||
nickname = $3,
|
nickname = $3,
|
||||||
role_id = $4,
|
role_id = $4,
|
||||||
@@ -415,7 +482,7 @@ async function upsertTencentBrowserContextWithClient(
|
|||||||
WHERE task_id = $12
|
WHERE task_id = $12
|
||||||
`,
|
`,
|
||||||
[
|
[
|
||||||
next.browser_session_id,
|
next.runtime_session_id,
|
||||||
next.login_type,
|
next.login_type,
|
||||||
next.nickname,
|
next.nickname,
|
||||||
next.role_id,
|
next.role_id,
|
||||||
@@ -444,9 +511,9 @@ async function getTaskByIdWithExecutor(
|
|||||||
return (result.rows[0] as TaskRow | undefined) || null
|
return (result.rows[0] as TaskRow | undefined) || null
|
||||||
}
|
}
|
||||||
|
|
||||||
function containsTencentPatch(patch: TaskUpdatePatch = {}): boolean {
|
function containsRuntimeContextPatch(patch: TaskUpdatePatch = {}): boolean {
|
||||||
return [
|
return [
|
||||||
'browser_session_id',
|
'runtime_session_id',
|
||||||
'login_type',
|
'login_type',
|
||||||
'nickname',
|
'nickname',
|
||||||
'role_id',
|
'role_id',
|
||||||
|
|||||||
@@ -1,181 +0,0 @@
|
|||||||
import { query } from '../db/client.js'
|
|
||||||
import type {
|
|
||||||
WebhookEventCreateInput,
|
|
||||||
WebhookEventListQueryInput,
|
|
||||||
WebhookEventUpdatePatch,
|
|
||||||
} from '../types/repository-inputs.js'
|
|
||||||
import type {
|
|
||||||
WebhookEventListQueryResult,
|
|
||||||
WebhookEventRow,
|
|
||||||
} from '../types/repository-rows.js'
|
|
||||||
|
|
||||||
export async function createWebhookEvent(input: WebhookEventCreateInput): Promise<WebhookEventRow | null> {
|
|
||||||
const result = await query<WebhookEventRow>(
|
|
||||||
`
|
|
||||||
INSERT INTO webhook_events (
|
|
||||||
provider,
|
|
||||||
platform,
|
|
||||||
shop_id,
|
|
||||||
shop_name,
|
|
||||||
event_type,
|
|
||||||
event_key,
|
|
||||||
signature_valid,
|
|
||||||
headers_json,
|
|
||||||
query_json,
|
|
||||||
body_json,
|
|
||||||
processed,
|
|
||||||
process_error,
|
|
||||||
related_order_id,
|
|
||||||
created_at
|
|
||||||
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8::jsonb, $9::jsonb, $10::jsonb, $11, $12, $13, $14)
|
|
||||||
RETURNING *
|
|
||||||
`,
|
|
||||||
[
|
|
||||||
input.provider,
|
|
||||||
input.platform,
|
|
||||||
input.shopId || '',
|
|
||||||
input.shopName || '',
|
|
||||||
input.eventType,
|
|
||||||
input.eventKey,
|
|
||||||
Boolean(input.signatureValid),
|
|
||||||
input.headersJson || '{}',
|
|
||||||
input.queryJson || '{}',
|
|
||||||
input.bodyJson || '{}',
|
|
||||||
Boolean(input.processed),
|
|
||||||
input.processError || '',
|
|
||||||
input.relatedOrderId || null,
|
|
||||||
input.createdAt,
|
|
||||||
],
|
|
||||||
)
|
|
||||||
|
|
||||||
return result.rows[0] || null
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function updateWebhookEvent(
|
|
||||||
eventId: number | string,
|
|
||||||
patch: WebhookEventUpdatePatch,
|
|
||||||
): Promise<WebhookEventRow | null> {
|
|
||||||
const current = await getWebhookEventById(eventId)
|
|
||||||
if (!current) {
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
|
|
||||||
const next = { ...current, ...patch }
|
|
||||||
const result = await query<WebhookEventRow>(
|
|
||||||
`
|
|
||||||
UPDATE webhook_events
|
|
||||||
SET
|
|
||||||
processed = $1,
|
|
||||||
process_error = $2,
|
|
||||||
related_order_id = $3
|
|
||||||
WHERE id = $4
|
|
||||||
RETURNING *
|
|
||||||
`,
|
|
||||||
[Boolean(next.processed), next.process_error || '', next.related_order_id || null, Number(eventId)],
|
|
||||||
)
|
|
||||||
|
|
||||||
return result.rows[0] || null
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function getWebhookEventById(eventId: number | string): Promise<WebhookEventRow | null> {
|
|
||||||
const result = await query<WebhookEventRow>('SELECT * FROM webhook_events WHERE id = $1 LIMIT 1', [
|
|
||||||
Number(eventId),
|
|
||||||
])
|
|
||||||
return result.rows[0] || null
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function listWebhookEvents({
|
|
||||||
page = 1,
|
|
||||||
pageSize = 20,
|
|
||||||
provider = '',
|
|
||||||
platform = '',
|
|
||||||
platformOrderId = '',
|
|
||||||
processed = '',
|
|
||||||
visibility = 'important',
|
|
||||||
relatedOrderId = '',
|
|
||||||
dateFrom = '',
|
|
||||||
dateTo = '',
|
|
||||||
}: WebhookEventListQueryInput = {}): Promise<WebhookEventListQueryResult> {
|
|
||||||
const offset = (page - 1) * pageSize
|
|
||||||
const filters: string[] = []
|
|
||||||
const params: unknown[] = []
|
|
||||||
|
|
||||||
if (provider) {
|
|
||||||
params.push(provider)
|
|
||||||
filters.push(`provider = $${params.length}`)
|
|
||||||
}
|
|
||||||
|
|
||||||
if (platform) {
|
|
||||||
params.push(platform)
|
|
||||||
filters.push(`platform = $${params.length}`)
|
|
||||||
}
|
|
||||||
|
|
||||||
if (platformOrderId) {
|
|
||||||
params.push(`%${platformOrderId}%`)
|
|
||||||
filters.push(`(event_key ILIKE $${params.length} OR body_json::text ILIKE $${params.length})`)
|
|
||||||
}
|
|
||||||
|
|
||||||
if (processed === '0' || processed === '1') {
|
|
||||||
params.push(processed === '1')
|
|
||||||
filters.push(`processed = $${params.length}`)
|
|
||||||
}
|
|
||||||
|
|
||||||
if (visibility === 'ignored') {
|
|
||||||
filters.push(`process_error LIKE 'ignored_%'`)
|
|
||||||
} else if (visibility === 'important') {
|
|
||||||
filters.push(`(process_error = '' OR process_error NOT LIKE 'ignored_%')`)
|
|
||||||
}
|
|
||||||
|
|
||||||
if (relatedOrderId) {
|
|
||||||
params.push(Number(relatedOrderId))
|
|
||||||
filters.push(`related_order_id = $${params.length}`)
|
|
||||||
}
|
|
||||||
|
|
||||||
if (dateFrom) {
|
|
||||||
params.push(dateFrom)
|
|
||||||
filters.push(`created_at >= $${params.length}`)
|
|
||||||
}
|
|
||||||
|
|
||||||
if (dateTo) {
|
|
||||||
params.push(dateTo)
|
|
||||||
filters.push(`created_at <= $${params.length}`)
|
|
||||||
}
|
|
||||||
|
|
||||||
const whereClause = filters.length > 0 ? `WHERE ${filters.join(' AND ')}` : ''
|
|
||||||
const totalResult = await query<{ [column: string]: unknown, total: number }>(
|
|
||||||
`SELECT COUNT(*)::int AS total FROM webhook_events ${whereClause}`,
|
|
||||||
params,
|
|
||||||
)
|
|
||||||
|
|
||||||
params.push(pageSize)
|
|
||||||
params.push(offset)
|
|
||||||
const itemsResult = await query<WebhookEventRow>(
|
|
||||||
`
|
|
||||||
SELECT *
|
|
||||||
FROM webhook_events
|
|
||||||
${whereClause}
|
|
||||||
ORDER BY id DESC
|
|
||||||
LIMIT $${params.length - 1} OFFSET $${params.length}
|
|
||||||
`,
|
|
||||||
params,
|
|
||||||
)
|
|
||||||
|
|
||||||
return {
|
|
||||||
items: itemsResult.rows,
|
|
||||||
total: Number(totalResult.rows[0]?.total || 0),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function listWebhookEventsByOrderId(orderId: number | string): Promise<WebhookEventRow[]> {
|
|
||||||
const result = await query<WebhookEventRow>(
|
|
||||||
`
|
|
||||||
SELECT *
|
|
||||||
FROM webhook_events
|
|
||||||
WHERE related_order_id = $1
|
|
||||||
ORDER BY id DESC
|
|
||||||
`,
|
|
||||||
[Number(orderId)],
|
|
||||||
)
|
|
||||||
|
|
||||||
return result.rows
|
|
||||||
}
|
|
||||||
@@ -1,36 +1,30 @@
|
|||||||
import { Router } from 'express'
|
import { Router } from "express";
|
||||||
|
|
||||||
import authRouter from './admin/auth.js'
|
import authRouter from "./admin/auth.js";
|
||||||
import auditLogsRouter from './admin/audit-logs.js'
|
import auditLogsRouter from "./admin/audit-logs.js";
|
||||||
import dashboardRouter from './admin/dashboard.js'
|
import cloudtentaclesRecordsRouter from "./admin/cloudtentacles-records.js";
|
||||||
import inventoryRouter from './admin/inventory.js'
|
import dashboardRouter from "./admin/dashboard.js";
|
||||||
import manualRedeemRouter from './admin/manual-redeem.js'
|
import ordersRouter from "./admin/orders.js";
|
||||||
import messageDeliveriesRouter from './admin/message-deliveries.js'
|
import platformConfigRouter from "./admin/platform-config.js";
|
||||||
import ordersRouter from './admin/orders.js'
|
import { requireAdminSession } from "./admin/session.js";
|
||||||
import platformConfigRouter from './admin/platform-config.js'
|
import tasksRouter from "./admin/tasks.js";
|
||||||
import { requireAdminSession } from './admin/shared.js'
|
import usersRouter from "./admin/users.js";
|
||||||
import tasksRouter from './admin/tasks.js'
|
import { buildNotFoundPayload } from "../utils/http.js";
|
||||||
import usersRouter from './admin/users.js'
|
|
||||||
import webhookEventsRouter from './admin/webhook-events.js'
|
|
||||||
import { buildNotFoundPayload } from '../utils/http.js'
|
|
||||||
|
|
||||||
const router = Router()
|
const router = Router();
|
||||||
|
|
||||||
router.use(authRouter)
|
router.use(authRouter);
|
||||||
router.use(requireAdminSession)
|
router.use(requireAdminSession);
|
||||||
router.use(dashboardRouter)
|
router.use(dashboardRouter);
|
||||||
router.use(usersRouter)
|
router.use(usersRouter);
|
||||||
router.use(auditLogsRouter)
|
router.use(auditLogsRouter);
|
||||||
router.use(platformConfigRouter)
|
router.use(platformConfigRouter);
|
||||||
router.use(ordersRouter)
|
router.use(ordersRouter);
|
||||||
router.use(tasksRouter)
|
router.use(tasksRouter);
|
||||||
router.use(manualRedeemRouter)
|
router.use(cloudtentaclesRecordsRouter);
|
||||||
router.use(inventoryRouter)
|
|
||||||
router.use(messageDeliveriesRouter)
|
|
||||||
router.use(webhookEventsRouter)
|
|
||||||
|
|
||||||
router.use((req, res) => {
|
router.use((req, res) => {
|
||||||
res.status(404).json(buildNotFoundPayload(req))
|
res.status(404).json(buildNotFoundPayload(req));
|
||||||
})
|
});
|
||||||
|
|
||||||
export default router
|
export default router;
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { Router } from 'express'
|
import { Router } from 'express'
|
||||||
|
|
||||||
import { getAdminAuditLogs } from '../../services/admin/admin-audit-service.js'
|
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>
|
type AdminAuditLogRouteQuery = Record<string, any>
|
||||||
|
|
||||||
|
|||||||
@@ -1,11 +1,17 @@
|
|||||||
import { Router } from 'express'
|
import { Router } from 'express'
|
||||||
|
|
||||||
|
import { createRateLimitMiddleware, getBodyFieldRateLimitKey } from '../../middleware/rate-limit.js'
|
||||||
import { getAdminSessionSummary, loginAdmin } from '../../services/admin/admin-auth-service.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()
|
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),
|
(req) => loginAdmin(req.body?.username, req.body?.password),
|
||||||
{
|
{
|
||||||
successMessage: '登录成功',
|
successMessage: '登录成功',
|
||||||
|
|||||||
@@ -0,0 +1,176 @@
|
|||||||
|
import crypto from 'node:crypto'
|
||||||
|
import fs from 'node:fs/promises'
|
||||||
|
import path from 'node:path'
|
||||||
|
|
||||||
|
import { Router } from 'express'
|
||||||
|
|
||||||
|
import { PROJECT_ROOT } from '../../config/runtime.js'
|
||||||
|
import {
|
||||||
|
listAdminCloudtentaclesDeliveryRecords,
|
||||||
|
listAdminCloudtentaclesRecordSources,
|
||||||
|
} from '../../services/admin/platform-config/cloudtentacles/index.js'
|
||||||
|
import { createJsonHandler, requireAdminRoles } from './session.js'
|
||||||
|
import { createHttpError, sendRouteError } from '../../utils/http.js'
|
||||||
|
import type { AdminCloudtentaclesDeliveryRecordQueryRouteBody } from '../../types/admin/route-inputs.js'
|
||||||
|
|
||||||
|
const router = Router()
|
||||||
|
const RECORD_IMAGE_CACHE_DIR = path.join(PROJECT_ROOT, 'data', 'cache', 'cloudtentacles-record-images')
|
||||||
|
|
||||||
|
router.get(
|
||||||
|
'/cloudtentacles-records/sources',
|
||||||
|
requireAdminRoles(['admin', 'operator', 'support']),
|
||||||
|
createJsonHandler(() => listAdminCloudtentaclesRecordSources(), {
|
||||||
|
successMessage: 'ok',
|
||||||
|
errorMessage: '读取 cloudtentacles 账号列表失败',
|
||||||
|
scope: '[admin/cloudtentacles-records/sources]',
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
router.post(
|
||||||
|
'/cloudtentacles-records',
|
||||||
|
requireAdminRoles(['admin', 'operator', 'support']),
|
||||||
|
createJsonHandler(
|
||||||
|
(req) =>
|
||||||
|
listAdminCloudtentaclesDeliveryRecords(
|
||||||
|
req.body as AdminCloudtentaclesDeliveryRecordQueryRouteBody,
|
||||||
|
),
|
||||||
|
{
|
||||||
|
successMessage: 'cloudtentacles 发货记录查询成功',
|
||||||
|
errorMessage: 'cloudtentacles 发货记录查询失败',
|
||||||
|
scope: '[admin/cloudtentacles-records]',
|
||||||
|
},
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
router.get(
|
||||||
|
'/cloudtentacles-records/image',
|
||||||
|
requireAdminRoles(['admin', 'operator', 'support']),
|
||||||
|
async (req, res) => {
|
||||||
|
try {
|
||||||
|
const imageUrl = normalizeAllowedImageUrl(req.query.url)
|
||||||
|
const cachedImage = await getCachedRecordImage(imageUrl)
|
||||||
|
res.setHeader('Content-Type', cachedImage.contentType)
|
||||||
|
res.setHeader('Cache-Control', 'public, max-age=86400')
|
||||||
|
res.send(cachedImage.buffer)
|
||||||
|
} catch (error) {
|
||||||
|
sendRouteError(
|
||||||
|
res,
|
||||||
|
error,
|
||||||
|
'读取 cloudtentacles 商品图片失败',
|
||||||
|
'[admin/cloudtentacles-records/image]',
|
||||||
|
)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
export default router
|
||||||
|
|
||||||
|
function normalizeAllowedImageUrl(value: unknown) {
|
||||||
|
const raw = String(value || '').trim()
|
||||||
|
if (!raw) {
|
||||||
|
throw createHttpError('缺少商品图片地址', {
|
||||||
|
statusCode: 400,
|
||||||
|
errorCode: 'cloudtentacles_record_image_url_required',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const url = new URL(raw)
|
||||||
|
if (!['https:', 'http:'].includes(url.protocol) || url.hostname !== 'gp.playinjoy.com') {
|
||||||
|
throw createHttpError('商品图片地址不在允许范围内', {
|
||||||
|
statusCode: 400,
|
||||||
|
errorCode: 'cloudtentacles_record_image_url_not_allowed',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return url.toString()
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getCachedRecordImage(imageUrl: string) {
|
||||||
|
const cacheMeta = buildRecordImageCacheMeta(imageUrl)
|
||||||
|
const cached = await readCachedRecordImage(cacheMeta)
|
||||||
|
if (cached) {
|
||||||
|
return cached
|
||||||
|
}
|
||||||
|
|
||||||
|
const downloaded = await downloadRecordImage(imageUrl)
|
||||||
|
const filePath = cacheMeta.filePathForContentType(downloaded.contentType)
|
||||||
|
await fs.mkdir(RECORD_IMAGE_CACHE_DIR, { recursive: true })
|
||||||
|
await fs.writeFile(filePath, downloaded.buffer)
|
||||||
|
return downloaded
|
||||||
|
}
|
||||||
|
|
||||||
|
async function readCachedRecordImage(cacheMeta: ReturnType<typeof buildRecordImageCacheMeta>) {
|
||||||
|
for (const candidate of cacheMeta.candidateFilePaths) {
|
||||||
|
try {
|
||||||
|
const buffer = await fs.readFile(candidate.filePath)
|
||||||
|
return {
|
||||||
|
buffer,
|
||||||
|
contentType: candidate.contentType,
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
const code = String((error as NodeJS.ErrnoException).code || '')
|
||||||
|
if (code !== 'ENOENT') {
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
async function downloadRecordImage(imageUrl: string) {
|
||||||
|
const response = await fetch(imageUrl, {
|
||||||
|
headers: {
|
||||||
|
accept: 'image/avif,image/webp,image/apng,image/svg+xml,image/*,*/*;q=0.8',
|
||||||
|
referer: 'https://gp.playinjoy.com/',
|
||||||
|
'user-agent':
|
||||||
|
'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/122.0 Safari/537.36',
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw createHttpError(`商品图片读取失败,HTTP ${response.status}`, {
|
||||||
|
statusCode: 502,
|
||||||
|
errorCode: 'cloudtentacles_record_image_fetch_failed',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const contentType = response.headers.get('content-type') || 'image/jpeg'
|
||||||
|
if (!contentType.startsWith('image/')) {
|
||||||
|
throw createHttpError('商品图片响应格式无效', {
|
||||||
|
statusCode: 502,
|
||||||
|
errorCode: 'cloudtentacles_record_image_invalid_content_type',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
buffer: Buffer.from(await response.arrayBuffer()),
|
||||||
|
contentType: normalizeImageContentType(contentType),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildRecordImageCacheMeta(imageUrl: string) {
|
||||||
|
const digest = crypto.createHash('sha256').update(imageUrl).digest('hex')
|
||||||
|
const candidateTypes = ['image/jpeg', 'image/png', 'image/webp', 'image/gif']
|
||||||
|
return {
|
||||||
|
candidateFilePaths: candidateTypes.map((contentType) => ({
|
||||||
|
contentType,
|
||||||
|
filePath: path.join(RECORD_IMAGE_CACHE_DIR, `${digest}.${extensionForContentType(contentType)}`),
|
||||||
|
})),
|
||||||
|
filePathForContentType(contentType: string) {
|
||||||
|
return path.join(RECORD_IMAGE_CACHE_DIR, `${digest}.${extensionForContentType(contentType)}`)
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeImageContentType(contentType: string) {
|
||||||
|
return String(contentType || 'image/jpeg').split(';')[0]?.trim().toLowerCase() || 'image/jpeg'
|
||||||
|
}
|
||||||
|
|
||||||
|
function extensionForContentType(contentType: string) {
|
||||||
|
const normalized = normalizeImageContentType(contentType)
|
||||||
|
if (normalized === 'image/png') return 'png'
|
||||||
|
if (normalized === 'image/webp') return 'webp'
|
||||||
|
if (normalized === 'image/gif') return 'gif'
|
||||||
|
return 'jpg'
|
||||||
|
}
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
import { Router } from 'express'
|
import { Router } from 'express'
|
||||||
|
|
||||||
import { getAdminDashboardSummary } from '../../services/admin/admin-dashboard-service.js'
|
import { getAdminDashboardSummary } from '../../services/admin/admin-dashboard-service.js'
|
||||||
import { createJsonHandler } from './shared.js'
|
import { createJsonHandler } from './session.js'
|
||||||
|
|
||||||
const router = Router()
|
const router = Router()
|
||||||
|
|
||||||
|
|||||||
@@ -1,117 +0,0 @@
|
|||||||
import { Router } from 'express'
|
|
||||||
|
|
||||||
import {
|
|
||||||
createAdminInventoryItem,
|
|
||||||
importAdminInventoryItems,
|
|
||||||
invalidateAdminInventoryItem,
|
|
||||||
releaseAdminInventoryItem,
|
|
||||||
} from '../../services/admin/admin-write-service.js'
|
|
||||||
import {
|
|
||||||
getAdminInventoryItems,
|
|
||||||
getAdminInventorySkuSuggestions,
|
|
||||||
} from '../../services/admin/admin-read-service.js'
|
|
||||||
import { createJsonHandler, requireAdminRoles } from './shared.js'
|
|
||||||
import type {
|
|
||||||
AdminInventoryCreateRouteBody,
|
|
||||||
AdminInventoryImportRouteBody,
|
|
||||||
AdminInventoryInvalidateRouteBody,
|
|
||||||
AdminInventoryRouteParams,
|
|
||||||
AdminInventoryRouteQuery,
|
|
||||||
AdminInventorySkuSuggestionRouteQuery,
|
|
||||||
} from '../../types/admin-route-inputs.js'
|
|
||||||
import type { AdminInventoryMutationResponse } from '../../types/admin-write-models.js'
|
|
||||||
|
|
||||||
const router = Router()
|
|
||||||
|
|
||||||
router.use('/inventory', requireAdminRoles(['admin', 'operator']))
|
|
||||||
|
|
||||||
router.get('/inventory', createJsonHandler(
|
|
||||||
(req) => getAdminInventoryItems(
|
|
||||||
req.query as AdminInventoryRouteQuery,
|
|
||||||
req.adminSession || null,
|
|
||||||
),
|
|
||||||
{
|
|
||||||
successMessage: 'ok',
|
|
||||||
errorMessage: '读取库存列表失败',
|
|
||||||
scope: '[admin/inventory]',
|
|
||||||
},
|
|
||||||
))
|
|
||||||
|
|
||||||
router.get('/inventory/sku-suggestions', createJsonHandler(
|
|
||||||
(req) => getAdminInventorySkuSuggestions(
|
|
||||||
req.query as AdminInventorySkuSuggestionRouteQuery,
|
|
||||||
req.adminSession || null,
|
|
||||||
),
|
|
||||||
{
|
|
||||||
successMessage: 'ok',
|
|
||||||
errorMessage: '读取内部 SKU 建议失败',
|
|
||||||
scope: '[admin/inventory/sku-suggestions]',
|
|
||||||
},
|
|
||||||
))
|
|
||||||
|
|
||||||
router.post('/inventory', createJsonHandler(
|
|
||||||
(req) => createAdminInventoryItem(req.body as AdminInventoryCreateRouteBody),
|
|
||||||
{
|
|
||||||
successMessage: '库存项已新增',
|
|
||||||
errorMessage: '新增库存项失败',
|
|
||||||
scope: '[admin/inventory]',
|
|
||||||
},
|
|
||||||
))
|
|
||||||
|
|
||||||
router.post('/inventory/import', createJsonHandler(
|
|
||||||
(req) => importAdminInventoryItems(req.body as AdminInventoryImportRouteBody),
|
|
||||||
{
|
|
||||||
successMessage: '导入完成',
|
|
||||||
errorMessage: '导入库存凭据失败',
|
|
||||||
scope: '[admin/inventory/import]',
|
|
||||||
},
|
|
||||||
))
|
|
||||||
|
|
||||||
router.post('/inventory/:inventoryItemId/release', requireAdminRoles(['admin']), createJsonHandler(
|
|
||||||
(req) => releaseAdminInventoryItem((req.params as AdminInventoryRouteParams).inventoryItemId),
|
|
||||||
{
|
|
||||||
successMessage: '库存项已释放',
|
|
||||||
errorMessage: '释放库存项失败',
|
|
||||||
scope: '[admin/inventory/:inventoryItemId/release]',
|
|
||||||
audit: (req, data) => {
|
|
||||||
const result = data as AdminInventoryMutationResponse
|
|
||||||
return {
|
|
||||||
action: 'inventory_item_released',
|
|
||||||
targetType: 'inventory_item',
|
|
||||||
targetId: String((req.params as AdminInventoryRouteParams).inventoryItemId),
|
|
||||||
data: {
|
|
||||||
inventoryItemId: result.inventoryItem?.inventoryItemId,
|
|
||||||
skuCode: result.inventoryItem?.skuCode,
|
|
||||||
displayValue: result.inventoryItem?.displayValue,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
},
|
|
||||||
},
|
|
||||||
))
|
|
||||||
|
|
||||||
router.post('/inventory/:inventoryItemId/invalidate', requireAdminRoles(['admin']), createJsonHandler(
|
|
||||||
(req) => invalidateAdminInventoryItem(
|
|
||||||
(req.params as AdminInventoryRouteParams).inventoryItemId,
|
|
||||||
req.body as AdminInventoryInvalidateRouteBody,
|
|
||||||
),
|
|
||||||
{
|
|
||||||
successMessage: '库存项已作废',
|
|
||||||
errorMessage: '作废库存项失败',
|
|
||||||
scope: '[admin/inventory/:inventoryItemId/invalidate]',
|
|
||||||
audit: (req, data) => {
|
|
||||||
const result = data as AdminInventoryMutationResponse
|
|
||||||
return {
|
|
||||||
action: 'inventory_item_invalidated',
|
|
||||||
targetType: 'inventory_item',
|
|
||||||
targetId: String((req.params as AdminInventoryRouteParams).inventoryItemId),
|
|
||||||
data: {
|
|
||||||
inventoryItemId: result.inventoryItem?.inventoryItemId,
|
|
||||||
skuCode: result.inventoryItem?.skuCode,
|
|
||||||
invalidReason: result.inventoryItem?.invalidReason,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
},
|
|
||||||
},
|
|
||||||
))
|
|
||||||
|
|
||||||
export default router
|
|
||||||
@@ -1,218 +0,0 @@
|
|||||||
import { Router } from 'express'
|
|
||||||
|
|
||||||
import {
|
|
||||||
closeAdminManualRedeemSession,
|
|
||||||
closeAdminManualRedeemTask,
|
|
||||||
confirmAdminManualRedeemRole,
|
|
||||||
createAdminManualRedeemSession,
|
|
||||||
createAdminManualRedeemTask,
|
|
||||||
getAdminManualRedeemDetail,
|
|
||||||
getAdminManualRedeemSessionSummary,
|
|
||||||
reloadAdminManualRedeemSession,
|
|
||||||
redeemAdminManualRedeemTask,
|
|
||||||
} from '../../services/admin/admin-manual-redeem-service.js'
|
|
||||||
import { getAdminInventorySkuSuggestions } from '../../services/admin/admin-read-service.js'
|
|
||||||
import { createJsonHandler, requireAdminRoles } from './shared.js'
|
|
||||||
import type {
|
|
||||||
AdminInventorySkuSuggestionRouteQuery,
|
|
||||||
AdminManualRedeemCreateRouteBody,
|
|
||||||
AdminManualRedeemRouteParams,
|
|
||||||
} from '../../types/admin-route-inputs.js'
|
|
||||||
|
|
||||||
type AdminManualRedeemSessionRouteBody = {
|
|
||||||
loginType?: string
|
|
||||||
forceRecreate?: boolean
|
|
||||||
}
|
|
||||||
|
|
||||||
type AdminManualRedeemMutationResult = {
|
|
||||||
task?: {
|
|
||||||
taskId?: number
|
|
||||||
taskNo?: string
|
|
||||||
status?: string
|
|
||||||
deliveryStatus?: string
|
|
||||||
}
|
|
||||||
manualRequest?: { proofValue?: string }
|
|
||||||
order?: { platformOrderId?: string }
|
|
||||||
orderItem?: { skuCode?: string }
|
|
||||||
}
|
|
||||||
|
|
||||||
const router = Router()
|
|
||||||
|
|
||||||
router.use('/manual-redeem', requireAdminRoles(['admin', 'operator', 'support']))
|
|
||||||
|
|
||||||
router.get('/manual-redeem/sku-suggestions', createJsonHandler(
|
|
||||||
(req) => getAdminInventorySkuSuggestions(
|
|
||||||
req.query as AdminInventorySkuSuggestionRouteQuery,
|
|
||||||
req.adminSession || null,
|
|
||||||
),
|
|
||||||
{
|
|
||||||
successMessage: 'ok',
|
|
||||||
errorMessage: '读取人工兑换 SKU 建议失败',
|
|
||||||
scope: '[admin/manual-redeem/sku-suggestions]',
|
|
||||||
},
|
|
||||||
))
|
|
||||||
|
|
||||||
router.post('/manual-redeem', createJsonHandler(
|
|
||||||
(req) => createAdminManualRedeemTask(
|
|
||||||
req.body as AdminManualRedeemCreateRouteBody,
|
|
||||||
req.adminSession || null,
|
|
||||||
),
|
|
||||||
{
|
|
||||||
successMessage: '人工兑换任务已创建',
|
|
||||||
errorMessage: '创建人工兑换任务失败',
|
|
||||||
scope: '[admin/manual-redeem]',
|
|
||||||
audit: (req, data) => {
|
|
||||||
const result = data as AdminManualRedeemMutationResult
|
|
||||||
return {
|
|
||||||
action: 'manual_redeem_created',
|
|
||||||
targetType: 'task',
|
|
||||||
targetId: String(result.task?.taskId || ''),
|
|
||||||
data: {
|
|
||||||
taskId: result.task?.taskId,
|
|
||||||
taskNo: result.task?.taskNo,
|
|
||||||
proofValue: result.manualRequest?.proofValue || result.order?.platformOrderId || '',
|
|
||||||
skuCode: result.orderItem?.skuCode || '',
|
|
||||||
},
|
|
||||||
}
|
|
||||||
},
|
|
||||||
},
|
|
||||||
))
|
|
||||||
|
|
||||||
router.get('/manual-redeem/:taskId', createJsonHandler(
|
|
||||||
(req) => getAdminManualRedeemDetail(
|
|
||||||
(req.params as AdminManualRedeemRouteParams).taskId,
|
|
||||||
req.adminSession || null,
|
|
||||||
),
|
|
||||||
{
|
|
||||||
successMessage: 'ok',
|
|
||||||
errorMessage: '读取人工兑换详情失败',
|
|
||||||
scope: '[admin/manual-redeem/:taskId]',
|
|
||||||
},
|
|
||||||
))
|
|
||||||
|
|
||||||
router.post('/manual-redeem/:taskId/session', createJsonHandler(
|
|
||||||
(req) => createAdminManualRedeemSession(
|
|
||||||
(req.params as AdminManualRedeemRouteParams).taskId,
|
|
||||||
req.body as AdminManualRedeemSessionRouteBody,
|
|
||||||
req.adminSession || null,
|
|
||||||
),
|
|
||||||
{
|
|
||||||
successMessage: '人工兑换登录会话已创建',
|
|
||||||
errorMessage: '创建人工兑换登录会话失败',
|
|
||||||
scope: '[admin/manual-redeem/:taskId/session]',
|
|
||||||
},
|
|
||||||
))
|
|
||||||
|
|
||||||
router.get('/manual-redeem/:taskId/session/summary', createJsonHandler(
|
|
||||||
(req) => getAdminManualRedeemSessionSummary(
|
|
||||||
(req.params as AdminManualRedeemRouteParams).taskId,
|
|
||||||
req.adminSession || null,
|
|
||||||
),
|
|
||||||
{
|
|
||||||
successMessage: 'ok',
|
|
||||||
errorMessage: '读取人工兑换会话摘要失败',
|
|
||||||
scope: '[admin/manual-redeem/:taskId/session/summary]',
|
|
||||||
},
|
|
||||||
))
|
|
||||||
|
|
||||||
router.post('/manual-redeem/:taskId/session/refresh', createJsonHandler(
|
|
||||||
(req) => reloadAdminManualRedeemSession(
|
|
||||||
(req.params as AdminManualRedeemRouteParams).taskId,
|
|
||||||
req.adminSession || null,
|
|
||||||
),
|
|
||||||
{
|
|
||||||
successMessage: '人工兑换会话已刷新',
|
|
||||||
errorMessage: '刷新人工兑换会话失败',
|
|
||||||
scope: '[admin/manual-redeem/:taskId/session/refresh]',
|
|
||||||
},
|
|
||||||
))
|
|
||||||
|
|
||||||
router.delete('/manual-redeem/:taskId/session', createJsonHandler(
|
|
||||||
(req) => closeAdminManualRedeemSession(
|
|
||||||
(req.params as AdminManualRedeemRouteParams).taskId,
|
|
||||||
req.adminSession || null,
|
|
||||||
),
|
|
||||||
{
|
|
||||||
successMessage: '人工兑换会话已关闭',
|
|
||||||
errorMessage: '关闭人工兑换会话失败',
|
|
||||||
scope: '[admin/manual-redeem/:taskId/session]',
|
|
||||||
},
|
|
||||||
))
|
|
||||||
|
|
||||||
router.post('/manual-redeem/:taskId/confirm-role', createJsonHandler(
|
|
||||||
(req) => confirmAdminManualRedeemRole(
|
|
||||||
(req.params as AdminManualRedeemRouteParams).taskId,
|
|
||||||
req.adminSession || null,
|
|
||||||
),
|
|
||||||
{
|
|
||||||
successMessage: '角色已确认',
|
|
||||||
errorMessage: '确认人工兑换角色失败',
|
|
||||||
scope: '[admin/manual-redeem/:taskId/confirm-role]',
|
|
||||||
audit: (req, data) => {
|
|
||||||
const result = data as AdminManualRedeemMutationResult
|
|
||||||
return {
|
|
||||||
action: 'manual_redeem_role_confirmed',
|
|
||||||
targetType: 'task',
|
|
||||||
targetId: String(result.task?.taskId || ''),
|
|
||||||
data: {
|
|
||||||
taskId: result.task?.taskId,
|
|
||||||
taskNo: result.task?.taskNo,
|
|
||||||
status: result.task?.status,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
},
|
|
||||||
},
|
|
||||||
))
|
|
||||||
|
|
||||||
router.post('/manual-redeem/:taskId/redeem', createJsonHandler(
|
|
||||||
(req) => redeemAdminManualRedeemTask(
|
|
||||||
(req.params as AdminManualRedeemRouteParams).taskId,
|
|
||||||
req.adminSession || null,
|
|
||||||
),
|
|
||||||
{
|
|
||||||
successMessage: '人工兑换任务已启动',
|
|
||||||
errorMessage: '执行人工兑换失败',
|
|
||||||
scope: '[admin/manual-redeem/:taskId/redeem]',
|
|
||||||
audit: (req, data) => {
|
|
||||||
const result = data as AdminManualRedeemMutationResult
|
|
||||||
return {
|
|
||||||
action: 'manual_redeem_started',
|
|
||||||
targetType: 'task',
|
|
||||||
targetId: String(result.task?.taskId || ''),
|
|
||||||
data: {
|
|
||||||
taskId: result.task?.taskId,
|
|
||||||
taskNo: result.task?.taskNo,
|
|
||||||
status: result.task?.status,
|
|
||||||
deliveryStatus: result.task?.deliveryStatus,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
},
|
|
||||||
},
|
|
||||||
))
|
|
||||||
|
|
||||||
router.post('/manual-redeem/:taskId/close', createJsonHandler(
|
|
||||||
(req) => closeAdminManualRedeemTask(
|
|
||||||
(req.params as AdminManualRedeemRouteParams).taskId,
|
|
||||||
req.adminSession || null,
|
|
||||||
),
|
|
||||||
{
|
|
||||||
successMessage: '人工兑换任务已关闭',
|
|
||||||
errorMessage: '关闭人工兑换任务失败',
|
|
||||||
scope: '[admin/manual-redeem/:taskId/close]',
|
|
||||||
audit: (req, data) => {
|
|
||||||
const result = data as AdminManualRedeemMutationResult
|
|
||||||
return {
|
|
||||||
action: 'manual_redeem_closed',
|
|
||||||
targetType: 'task',
|
|
||||||
targetId: String(result.task?.taskId || ''),
|
|
||||||
data: {
|
|
||||||
taskId: result.task?.taskId,
|
|
||||||
taskNo: result.task?.taskNo,
|
|
||||||
status: result.task?.status,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
},
|
|
||||||
},
|
|
||||||
))
|
|
||||||
|
|
||||||
export default router
|
|
||||||
@@ -1,18 +0,0 @@
|
|||||||
import { Router } from 'express'
|
|
||||||
|
|
||||||
import { getAdminMessageDeliveries } from '../../services/admin/admin-message-delivery-service.js'
|
|
||||||
import { createJsonHandler } from './shared.js'
|
|
||||||
import type { AdminMessageDeliveryRouteQuery } from '../../types/admin-route-inputs.js'
|
|
||||||
|
|
||||||
const router = Router()
|
|
||||||
|
|
||||||
router.get('/message-deliveries', createJsonHandler(
|
|
||||||
(req) => getAdminMessageDeliveries(req.query as AdminMessageDeliveryRouteQuery),
|
|
||||||
{
|
|
||||||
successMessage: 'ok',
|
|
||||||
errorMessage: '读取消息发送记录失败',
|
|
||||||
scope: '[admin/message-deliveries]',
|
|
||||||
},
|
|
||||||
))
|
|
||||||
|
|
||||||
export default router
|
|
||||||
@@ -1,11 +1,11 @@
|
|||||||
import { Router } from 'express'
|
import { Router } from 'express'
|
||||||
|
|
||||||
import { getAdminOrderDetail, getAdminOrders } from '../../services/admin/admin-read-service.js'
|
import { getAdminOrderDetail, getAdminOrders } from '../../services/admin/admin-read-service.js'
|
||||||
import { createJsonHandler } from './shared.js'
|
import { createJsonHandler } from './session.js'
|
||||||
import type {
|
import type {
|
||||||
AdminOrderRouteParams,
|
AdminOrderRouteParams,
|
||||||
AdminOrderRouteQuery,
|
AdminOrderRouteQuery,
|
||||||
} from '../../types/admin-route-inputs.js'
|
} from '../../types/admin/route-inputs.js'
|
||||||
|
|
||||||
const router = Router()
|
const router = Router()
|
||||||
|
|
||||||
@@ -19,7 +19,7 @@ router.get('/orders', createJsonHandler(
|
|||||||
))
|
))
|
||||||
|
|
||||||
router.get('/orders/:orderId', createJsonHandler(
|
router.get('/orders/:orderId', createJsonHandler(
|
||||||
(req) => getAdminOrderDetail((req.params as AdminOrderRouteParams).orderId),
|
(req) => getAdminOrderDetail(String((req.params as AdminOrderRouteParams).orderId || '')),
|
||||||
{
|
{
|
||||||
successMessage: 'ok',
|
successMessage: 'ok',
|
||||||
errorMessage: '读取订单详情失败',
|
errorMessage: '读取订单详情失败',
|
||||||
|
|||||||
@@ -1,10 +1,7 @@
|
|||||||
import { Router } from "express";
|
import { Router } from "express";
|
||||||
|
|
||||||
import { requireAdminRoles } from "./shared.js";
|
import { requireAdminRoles } from "./session.js";
|
||||||
import agisoRouter from "./platform-config/agiso.js";
|
|
||||||
import cloudtentaclesRouter from "./platform-config/cloudtentacles.js";
|
import cloudtentaclesRouter from "./platform-config/cloudtentacles.js";
|
||||||
import fulfillmentBindingsRouter from "./platform-config/fulfillment-bindings.js";
|
|
||||||
import kuaishouCloudFulfillmentRouter from "./platform-config/kuaishou-cloud-fulfillment.js";
|
|
||||||
import kuaishouEticketRouter from "./platform-config/kuaishou-eticket.js";
|
import kuaishouEticketRouter from "./platform-config/kuaishou-eticket.js";
|
||||||
import ninetyoneRouter from "./platform-config/ninetyone.js";
|
import ninetyoneRouter from "./platform-config/ninetyone.js";
|
||||||
import notificationsRouter from "./platform-config/notifications.js";
|
import notificationsRouter from "./platform-config/notifications.js";
|
||||||
@@ -12,12 +9,9 @@ import notificationsRouter from "./platform-config/notifications.js";
|
|||||||
const router = Router();
|
const router = Router();
|
||||||
|
|
||||||
router.use("/platform-config", requireAdminRoles(["admin"]));
|
router.use("/platform-config", requireAdminRoles(["admin"]));
|
||||||
router.use("/platform-config", agisoRouter);
|
|
||||||
router.use("/platform-config", notificationsRouter);
|
router.use("/platform-config", notificationsRouter);
|
||||||
router.use("/platform-config", kuaishouEticketRouter);
|
router.use("/platform-config", kuaishouEticketRouter);
|
||||||
router.use("/platform-config", ninetyoneRouter);
|
router.use("/platform-config", ninetyoneRouter);
|
||||||
router.use("/platform-config", cloudtentaclesRouter);
|
router.use("/platform-config", cloudtentaclesRouter);
|
||||||
router.use("/platform-config", fulfillmentBindingsRouter);
|
|
||||||
router.use("/platform-config", kuaishouCloudFulfillmentRouter);
|
|
||||||
|
|
||||||
export default router;
|
export default router;
|
||||||
|
|||||||
@@ -1,47 +0,0 @@
|
|||||||
import { Router } from "express";
|
|
||||||
|
|
||||||
import {
|
|
||||||
getAdminAgisoShopConfigs,
|
|
||||||
updateAdminAgisoShopConfigs,
|
|
||||||
} from "../../../services/admin/platform-config/service.js";
|
|
||||||
import type { AdminAgisoShopConfigRouteBody } from "../../../types/admin-route-inputs.js";
|
|
||||||
import type { AdminAgisoShopConfigSaveResponse } from "../../../types/admin-write-models.js";
|
|
||||||
import { createJsonHandler } from "../shared.js";
|
|
||||||
|
|
||||||
const router = Router();
|
|
||||||
|
|
||||||
router.get(
|
|
||||||
"/agiso-shops",
|
|
||||||
createJsonHandler(() => getAdminAgisoShopConfigs(), {
|
|
||||||
successMessage: "ok",
|
|
||||||
errorMessage: "读取 Agiso 店铺配置失败",
|
|
||||||
scope: "[admin/platform-config/agiso-shops]",
|
|
||||||
})
|
|
||||||
);
|
|
||||||
|
|
||||||
router.post(
|
|
||||||
"/agiso-shops",
|
|
||||||
createJsonHandler(
|
|
||||||
(req) =>
|
|
||||||
updateAdminAgisoShopConfigs(req.body as AdminAgisoShopConfigRouteBody),
|
|
||||||
{
|
|
||||||
successMessage: "Agiso 店铺配置已保存",
|
|
||||||
errorMessage: "保存 Agiso 店铺配置失败",
|
|
||||||
scope: "[admin/platform-config/agiso-shops]",
|
|
||||||
audit: (_req, data) => {
|
|
||||||
const result = data as AdminAgisoShopConfigSaveResponse;
|
|
||||||
return {
|
|
||||||
action: "platform_shop_config_updated",
|
|
||||||
targetType: "platform_config",
|
|
||||||
targetId: "agiso_shops",
|
|
||||||
data: {
|
|
||||||
shopCount: result.shops.length,
|
|
||||||
filePath: result.filePath,
|
|
||||||
},
|
|
||||||
};
|
|
||||||
},
|
|
||||||
}
|
|
||||||
)
|
|
||||||
);
|
|
||||||
|
|
||||||
export default router;
|
|
||||||
@@ -7,6 +7,7 @@ import {
|
|||||||
deleteAdminCloudtentaclesSource,
|
deleteAdminCloudtentaclesSource,
|
||||||
fetchAdminCloudtentaclesVirtualNumberCode,
|
fetchAdminCloudtentaclesVirtualNumberCode,
|
||||||
generateAdminCloudtentaclesLoginCode,
|
generateAdminCloudtentaclesLoginCode,
|
||||||
|
getAdminCloudtentaclesOverrideRules,
|
||||||
getAdminCloudtentaclesAsset,
|
getAdminCloudtentaclesAsset,
|
||||||
getAdminCloudtentaclesBindUrl,
|
getAdminCloudtentaclesBindUrl,
|
||||||
getAdminCloudtentaclesCategories,
|
getAdminCloudtentaclesCategories,
|
||||||
@@ -17,14 +18,16 @@ import {
|
|||||||
runAdminCloudtentaclesFullFlow,
|
runAdminCloudtentaclesFullFlow,
|
||||||
sendAdminCloudtentaclesSmsCode,
|
sendAdminCloudtentaclesSmsCode,
|
||||||
testAdminCloudtentaclesLogin,
|
testAdminCloudtentaclesLogin,
|
||||||
|
updateAdminCloudtentaclesOverrideRules,
|
||||||
updateAdminCloudtentaclesSourceConfig,
|
updateAdminCloudtentaclesSourceConfig,
|
||||||
useAdminCloudtentaclesSku,
|
useAdminCloudtentaclesSku,
|
||||||
validateAdminCloudtentaclesSession,
|
validateAdminCloudtentaclesSession,
|
||||||
verifyAdminCloudtentaclesLoginCode,
|
verifyAdminCloudtentaclesLoginCode,
|
||||||
} from "../../../services/admin/platform-config/service.js";
|
} from "../../../services/admin/platform-config/cloudtentacles/index.js";
|
||||||
import type {
|
import type {
|
||||||
AdminCloudtentaclesCatalogQueryRouteBody,
|
AdminCloudtentaclesCatalogQueryRouteBody,
|
||||||
AdminCloudtentaclesFullFlowRouteBody,
|
AdminCloudtentaclesFullFlowRouteBody,
|
||||||
|
AdminCloudtentaclesOverrideRuleConfigRouteBody,
|
||||||
AdminCloudtentaclesSendSmsCodeRouteBody,
|
AdminCloudtentaclesSendSmsCodeRouteBody,
|
||||||
AdminCloudtentaclesSkuBuyRouteBody,
|
AdminCloudtentaclesSkuBuyRouteBody,
|
||||||
AdminCloudtentaclesSkuUseRouteBody,
|
AdminCloudtentaclesSkuUseRouteBody,
|
||||||
@@ -32,9 +35,9 @@ import type {
|
|||||||
AdminCloudtentaclesTestLoginRouteBody,
|
AdminCloudtentaclesTestLoginRouteBody,
|
||||||
AdminCloudtentaclesValidateSessionRouteBody,
|
AdminCloudtentaclesValidateSessionRouteBody,
|
||||||
AdminCloudtentaclesVirtualNumberRouteBody,
|
AdminCloudtentaclesVirtualNumberRouteBody,
|
||||||
} from "../../../types/admin-route-inputs.js";
|
} from "../../../types/admin/route-inputs.js";
|
||||||
import { createJsonHandler } from "../shared.js";
|
import { createJsonHandler } from "../session.js";
|
||||||
import type { JsonRecord } from "./shared.js";
|
import type { JsonRecord } from "../../../types/json.js";
|
||||||
|
|
||||||
const router = Router();
|
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(
|
router.post(
|
||||||
"/cloudtentacles/send-sms-code",
|
"/cloudtentacles/send-sms-code",
|
||||||
createJsonHandler(
|
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,
|
queryAdminKuaishouEticketDetail,
|
||||||
queryAdminKuaishouEticketShopInfo,
|
queryAdminKuaishouEticketShopInfo,
|
||||||
updateAdminKuaishouEticketSourceConfig,
|
updateAdminKuaishouEticketSourceConfig,
|
||||||
} from "../../../services/admin/platform-config/service.js";
|
} from "../../../services/admin/platform-config/kuaishou-eticket-service.js";
|
||||||
import type {
|
import type {
|
||||||
AdminKuaishouEticketConsumeRouteBody,
|
AdminKuaishouEticketConsumeRouteBody,
|
||||||
AdminKuaishouEticketDetailQueryRouteBody,
|
AdminKuaishouEticketDetailQueryRouteBody,
|
||||||
AdminKuaishouEticketShopInfoRouteBody,
|
AdminKuaishouEticketShopInfoRouteBody,
|
||||||
AdminKuaishouEticketSourceConfigRouteBody,
|
AdminKuaishouEticketSourceConfigRouteBody,
|
||||||
} from "../../../types/admin-route-inputs.js";
|
} from "../../../types/admin/route-inputs.js";
|
||||||
import { createJsonHandler } from "../shared.js";
|
import { createJsonHandler } from "../session.js";
|
||||||
import type { JsonRecord } from "./shared.js";
|
import type { JsonRecord } from "../../../types/json.js";
|
||||||
|
|
||||||
const router = Router();
|
const router = Router();
|
||||||
|
|
||||||
|
|||||||
@@ -4,10 +4,10 @@ import {
|
|||||||
failAdminNinetyoneOrder,
|
failAdminNinetyoneOrder,
|
||||||
getAdminNinetyoneOrders,
|
getAdminNinetyoneOrders,
|
||||||
retryAdminNinetyoneOrder,
|
retryAdminNinetyoneOrder,
|
||||||
} from "../../../services/admin/platform-config/service.js";
|
} from "../../../services/admin/platform-config/ninetyone-service.js";
|
||||||
import type { AdminEntityRouteParams } from "../../../types/admin-route-inputs.js";
|
import type { AdminEntityRouteParams } from "../../../types/admin/route-inputs.js";
|
||||||
import { createJsonHandler } from "../shared.js";
|
import { createJsonHandler } from "../session.js";
|
||||||
import type { JsonRecord } from "./shared.js";
|
import type { JsonRecord } from "../../../types/json.js";
|
||||||
|
|
||||||
const router = Router();
|
const router = Router();
|
||||||
|
|
||||||
@@ -23,7 +23,7 @@ router.get(
|
|||||||
router.post(
|
router.post(
|
||||||
"/ninetyone/orders/:id/retry",
|
"/ninetyone/orders/:id/retry",
|
||||||
createJsonHandler(
|
createJsonHandler(
|
||||||
(req) => retryAdminNinetyoneOrder((req.params as AdminEntityRouteParams).id),
|
(req) => retryAdminNinetyoneOrder(String((req.params as AdminEntityRouteParams).id || "")),
|
||||||
{
|
{
|
||||||
successMessage: "91卡券订单已重试",
|
successMessage: "91卡券订单已重试",
|
||||||
errorMessage: "重试 91卡券订单失败",
|
errorMessage: "重试 91卡券订单失败",
|
||||||
@@ -49,7 +49,7 @@ router.post(
|
|||||||
createJsonHandler(
|
createJsonHandler(
|
||||||
(req) =>
|
(req) =>
|
||||||
failAdminNinetyoneOrder(
|
failAdminNinetyoneOrder(
|
||||||
(req.params as AdminEntityRouteParams).id,
|
String((req.params as AdminEntityRouteParams).id || ""),
|
||||||
req.body as { reason?: string }
|
req.body as { reason?: string }
|
||||||
),
|
),
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -7,15 +7,15 @@ import {
|
|||||||
testAdminNotification,
|
testAdminNotification,
|
||||||
updateAdminNotificationConfig,
|
updateAdminNotificationConfig,
|
||||||
updateAdminScheduledJobsConfig,
|
updateAdminScheduledJobsConfig,
|
||||||
} from "../../../services/admin/platform-config/service.js";
|
} from "../../../services/admin/platform-config/notification-service.js";
|
||||||
import type {
|
import type {
|
||||||
AdminEntityRouteParams,
|
AdminEntityRouteParams,
|
||||||
AdminNotificationConfigRouteBody,
|
AdminNotificationConfigRouteBody,
|
||||||
AdminNotificationTestRouteBody,
|
AdminNotificationTestRouteBody,
|
||||||
AdminScheduledJobsConfigRouteBody,
|
AdminScheduledJobsConfigRouteBody,
|
||||||
} from "../../../types/admin-route-inputs.js";
|
} from "../../../types/admin/route-inputs.js";
|
||||||
import { createJsonHandler } from "../shared.js";
|
import { createJsonHandler } from "../session.js";
|
||||||
import type { JsonRecord } from "./shared.js";
|
import type { JsonRecord } from "../../../types/json.js";
|
||||||
|
|
||||||
const router = Router();
|
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(
|
async function recordAdminAudit(
|
||||||
session: AdminSession | null | undefined,
|
session: AdminSession | null | undefined,
|
||||||
req: Request,
|
req: Request,
|
||||||
@@ -112,5 +123,13 @@ export function extractBearerToken(req: Request): string {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
return matched[1]
|
const token = matched[1]?.trim()
|
||||||
|
if (!token) {
|
||||||
|
throw createHttpError('未登录或登录已失效', {
|
||||||
|
statusCode: 401,
|
||||||
|
errorCode: 'admin_auth_required',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return token
|
||||||
}
|
}
|
||||||
@@ -1,433 +1,166 @@
|
|||||||
import { Router } from "express";
|
import type { Request } from 'express'
|
||||||
|
import { Router } from 'express'
|
||||||
|
|
||||||
|
import { getAdminTaskDetail, getAdminTasks } from '../../services/admin/admin-read-service.js'
|
||||||
|
import { closeAdminTask } from '../../services/admin/write/task-actions.js'
|
||||||
import {
|
import {
|
||||||
closeAdminTask,
|
|
||||||
completeAdminTaskManualDispatch,
|
|
||||||
confirmAdminTaskAssistedRole,
|
|
||||||
dispatchAdminTaskKuaishouCloudFulfillment,
|
dispatchAdminTaskKuaishouCloudFulfillment,
|
||||||
markAdminTaskManualReview,
|
|
||||||
prepareAdminTaskKuaishouCloudFulfillment,
|
prepareAdminTaskKuaishouCloudFulfillment,
|
||||||
|
rebindAdminTaskKuaishouCloudRole,
|
||||||
refreshAdminTaskKuaishouCloudRoleInfo,
|
refreshAdminTaskKuaishouCloudRoleInfo,
|
||||||
regenerateAdminTaskClaimLink,
|
|
||||||
redeemAdminTaskAssisted,
|
|
||||||
releaseAdminTaskInventoryBinding,
|
|
||||||
releaseAdminTaskInventory,
|
|
||||||
returnNumberAdminTaskKuaishouCloudFulfillment,
|
returnNumberAdminTaskKuaishouCloudFulfillment,
|
||||||
retryAdminTask,
|
} from '../../services/admin/write/kuaishou-cloud-actions.js'
|
||||||
} from "../../services/admin/admin-write-service.js";
|
import { createJsonHandler, requireAdminRoles } from './session.js'
|
||||||
import {
|
|
||||||
getAdminTaskDetail,
|
|
||||||
getAdminTaskScreenshotPath,
|
|
||||||
getAdminTasks,
|
|
||||||
} from "../../services/admin/admin-read-service.js";
|
|
||||||
import {
|
|
||||||
createFileHandler,
|
|
||||||
createJsonHandler,
|
|
||||||
requireAdminRoles,
|
|
||||||
} from "./shared.js";
|
|
||||||
import type { Request } from "express";
|
|
||||||
import type {
|
import type {
|
||||||
AdminTaskKuaishouCloudDispatchRouteBody,
|
AdminTaskKuaishouCloudDispatchRouteBody,
|
||||||
AdminTaskManualDispatchRouteBody,
|
|
||||||
AdminTaskRouteParams,
|
AdminTaskRouteParams,
|
||||||
AdminTaskRouteQuery,
|
AdminTaskRouteQuery,
|
||||||
} from "../../types/admin-route-inputs.js";
|
} from '../../types/admin/route-inputs.js'
|
||||||
import type {
|
import type { AdminTaskActionResponse } from '../../types/admin/write-models.js'
|
||||||
AdminTaskActionResponse,
|
|
||||||
AdminTaskBindingReleaseResponse,
|
|
||||||
AdminTaskManualDispatchResponse,
|
|
||||||
} from "../../types/admin-write-models.js";
|
|
||||||
|
|
||||||
const router = Router();
|
const router = Router()
|
||||||
|
|
||||||
function getTaskId(req: Request): string | undefined {
|
function getTaskId(req: Request): string {
|
||||||
return (req.params as AdminTaskRouteParams).taskId;
|
return String((req.params as AdminTaskRouteParams).taskId || '')
|
||||||
}
|
|
||||||
|
|
||||||
function getBindingId(req: Request): string | undefined {
|
|
||||||
return (req.params as AdminTaskRouteParams).bindingId;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
router.get(
|
router.get(
|
||||||
"/tasks",
|
'/tasks',
|
||||||
createJsonHandler(
|
createJsonHandler(
|
||||||
(req) =>
|
(req) => getAdminTasks(req.query as AdminTaskRouteQuery, req.adminSession || null),
|
||||||
getAdminTasks(req.query as AdminTaskRouteQuery, req.adminSession || null),
|
|
||||||
{
|
{
|
||||||
successMessage: "ok",
|
successMessage: 'ok',
|
||||||
errorMessage: "读取任务列表失败",
|
errorMessage: '读取任务列表失败',
|
||||||
scope: "[admin/tasks]",
|
scope: '[admin/tasks]',
|
||||||
}
|
},
|
||||||
)
|
),
|
||||||
);
|
)
|
||||||
|
|
||||||
router.get(
|
router.get(
|
||||||
"/tasks/:taskId",
|
'/tasks/:taskId',
|
||||||
|
createJsonHandler((req) => getAdminTaskDetail(getTaskId(req), req.adminSession || null), {
|
||||||
|
successMessage: 'ok',
|
||||||
|
errorMessage: '读取任务详情失败',
|
||||||
|
scope: '[admin/tasks/:taskId]',
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
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/kuaishou-cloud/prepare',
|
||||||
|
requireAdminRoles(['admin', 'operator']),
|
||||||
createJsonHandler(
|
createJsonHandler(
|
||||||
(req) => getAdminTaskDetail(getTaskId(req), req.adminSession || null),
|
(req) => prepareAdminTaskKuaishouCloudFulfillment(getTaskId(req), req.adminSession || null),
|
||||||
{
|
{
|
||||||
successMessage: "ok",
|
successMessage: '绑定资源已准备完成',
|
||||||
errorMessage: "读取任务详情失败",
|
errorMessage: '准备绑定资源失败',
|
||||||
scope: "[admin/tasks/:taskId]",
|
scope: '[admin/tasks/:taskId/kuaishou-cloud/prepare]',
|
||||||
}
|
audit: (req, data) => buildTaskAudit('task_kuaishou_cloud_prepare', req, data),
|
||||||
)
|
|
||||||
);
|
|
||||||
|
|
||||||
router.get(
|
|
||||||
"/tasks/:taskId/screenshot",
|
|
||||||
createFileHandler(
|
|
||||||
(req) =>
|
|
||||||
getAdminTaskScreenshotPath(getTaskId(req), req.adminSession || null),
|
|
||||||
{
|
|
||||||
errorMessage: "读取任务截图失败",
|
|
||||||
scope: "[admin/tasks/:taskId/screenshot]",
|
|
||||||
}
|
|
||||||
)
|
|
||||||
);
|
|
||||||
|
|
||||||
router.post(
|
|
||||||
"/tasks/:taskId/retry",
|
|
||||||
requireAdminRoles(["admin", "operator"]),
|
|
||||||
createJsonHandler((req) => retryAdminTask(getTaskId(req)), {
|
|
||||||
successMessage: "任务已重试",
|
|
||||||
errorMessage: "重试任务失败",
|
|
||||||
scope: "[admin/tasks/:taskId/retry]",
|
|
||||||
})
|
|
||||||
);
|
|
||||||
|
|
||||||
router.post(
|
|
||||||
"/tasks/:taskId/release-inventory",
|
|
||||||
requireAdminRoles(["admin"]),
|
|
||||||
createJsonHandler((req) => releaseAdminTaskInventory(getTaskId(req)), {
|
|
||||||
successMessage: "库存凭据已释放",
|
|
||||||
errorMessage: "释放库存凭据失败",
|
|
||||||
scope: "[admin/tasks/:taskId/release-inventory]",
|
|
||||||
audit: (req, data) => {
|
|
||||||
const result = data as AdminTaskActionResponse;
|
|
||||||
return {
|
|
||||||
action: "task_release_inventory",
|
|
||||||
targetType: "task",
|
|
||||||
targetId: String(getTaskId(req)),
|
|
||||||
data: {
|
|
||||||
taskId: result.task.taskId,
|
|
||||||
taskNo: result.task.taskNo,
|
|
||||||
inventoryItemId: result.task.inventoryItemId,
|
|
||||||
},
|
|
||||||
};
|
|
||||||
},
|
},
|
||||||
})
|
),
|
||||||
);
|
)
|
||||||
|
|
||||||
router.post(
|
router.post(
|
||||||
"/tasks/:taskId/inventory-bindings/:bindingId/release",
|
'/tasks/:taskId/kuaishou-cloud/refresh-role-info',
|
||||||
requireAdminRoles(["admin"]),
|
requireAdminRoles(['admin', 'operator', 'support']),
|
||||||
createJsonHandler(
|
createJsonHandler(
|
||||||
(req) =>
|
(req) => refreshAdminTaskKuaishouCloudRoleInfo(getTaskId(req), req.adminSession || null),
|
||||||
releaseAdminTaskInventoryBinding(getTaskId(req), getBindingId(req)),
|
|
||||||
{
|
{
|
||||||
successMessage: "库存绑定已释放",
|
successMessage: '角色信息已刷新',
|
||||||
errorMessage: "释放库存绑定失败",
|
errorMessage: '刷新角色信息失败',
|
||||||
scope: "[admin/tasks/:taskId/inventory-bindings/:bindingId/release]",
|
scope: '[admin/tasks/:taskId/kuaishou-cloud/refresh-role-info]',
|
||||||
audit: (req, data) => {
|
audit: (req, data) => buildTaskAudit('task_kuaishou_cloud_role_info_refreshed', req, data),
|
||||||
const result = data as AdminTaskBindingReleaseResponse;
|
|
||||||
return {
|
|
||||||
action: "task_release_inventory_binding",
|
|
||||||
targetType: "task_inventory_binding",
|
|
||||||
targetId: String(getBindingId(req)),
|
|
||||||
data: {
|
|
||||||
taskId: result.task.taskId,
|
|
||||||
taskNo: result.task.taskNo,
|
|
||||||
bindingId: result.bindingId,
|
|
||||||
inventoryItemId: result.inventoryItemId,
|
|
||||||
},
|
|
||||||
};
|
|
||||||
},
|
|
||||||
}
|
|
||||||
)
|
|
||||||
);
|
|
||||||
|
|
||||||
router.post(
|
|
||||||
"/tasks/:taskId/regenerate-claim-link",
|
|
||||||
requireAdminRoles(["admin", "operator", "support"]),
|
|
||||||
createJsonHandler(
|
|
||||||
(req) =>
|
|
||||||
regenerateAdminTaskClaimLink(getTaskId(req), req.adminSession || null),
|
|
||||||
{
|
|
||||||
successMessage: "领取链接已重新生成",
|
|
||||||
errorMessage: "重新生成领取链接失败",
|
|
||||||
scope: "[admin/tasks/:taskId/regenerate-claim-link]",
|
|
||||||
audit: (req, data) => {
|
|
||||||
const result = data as AdminTaskActionResponse;
|
|
||||||
return {
|
|
||||||
action: "task_regenerate_claim_link",
|
|
||||||
targetType: "task",
|
|
||||||
targetId: String(getTaskId(req)),
|
|
||||||
data: {
|
|
||||||
taskId: result.task.taskId,
|
|
||||||
taskNo: result.task.taskNo,
|
|
||||||
primaryClaimTokenId: result.task.primaryClaimTokenId,
|
|
||||||
},
|
|
||||||
};
|
|
||||||
},
|
|
||||||
}
|
|
||||||
)
|
|
||||||
);
|
|
||||||
|
|
||||||
router.post(
|
|
||||||
"/tasks/:taskId/support-confirm-role",
|
|
||||||
requireAdminRoles(["admin", "operator", "support"]),
|
|
||||||
createJsonHandler(
|
|
||||||
(req) =>
|
|
||||||
confirmAdminTaskAssistedRole(getTaskId(req), req.adminSession || null),
|
|
||||||
{
|
|
||||||
successMessage: "角色已确认",
|
|
||||||
errorMessage: "客服确认角色失败",
|
|
||||||
scope: "[admin/tasks/:taskId/support-confirm-role]",
|
|
||||||
audit: (req, data) => {
|
|
||||||
const result = data as AdminTaskActionResponse;
|
|
||||||
return {
|
|
||||||
action: "task_support_confirm_role",
|
|
||||||
targetType: "task",
|
|
||||||
targetId: String(getTaskId(req)),
|
|
||||||
data: {
|
|
||||||
taskId: result.task.taskId,
|
|
||||||
taskNo: result.task.taskNo,
|
|
||||||
status: result.task.status,
|
|
||||||
},
|
|
||||||
};
|
|
||||||
},
|
|
||||||
}
|
|
||||||
)
|
|
||||||
);
|
|
||||||
|
|
||||||
router.post(
|
|
||||||
"/tasks/:taskId/support-redeem",
|
|
||||||
requireAdminRoles(["admin", "operator", "support"]),
|
|
||||||
createJsonHandler(
|
|
||||||
(req) => redeemAdminTaskAssisted(getTaskId(req), req.adminSession || null),
|
|
||||||
{
|
|
||||||
successMessage: "兑换任务已启动",
|
|
||||||
errorMessage: "客服发起兑换失败",
|
|
||||||
scope: "[admin/tasks/:taskId/support-redeem]",
|
|
||||||
audit: (req, data) => {
|
|
||||||
const result = data as AdminTaskActionResponse;
|
|
||||||
return {
|
|
||||||
action: "task_support_redeem",
|
|
||||||
targetType: "task",
|
|
||||||
targetId: String(getTaskId(req)),
|
|
||||||
data: {
|
|
||||||
taskId: result.task.taskId,
|
|
||||||
taskNo: result.task.taskNo,
|
|
||||||
status: result.task.status,
|
|
||||||
deliveryStatus: result.task.deliveryStatus,
|
|
||||||
},
|
|
||||||
};
|
|
||||||
},
|
|
||||||
}
|
|
||||||
)
|
|
||||||
);
|
|
||||||
|
|
||||||
router.post(
|
|
||||||
"/tasks/:taskId/close",
|
|
||||||
requireAdminRoles(["admin", "operator", "support"]),
|
|
||||||
createJsonHandler((req) => closeAdminTask(getTaskId(req)), {
|
|
||||||
successMessage: "任务已关闭",
|
|
||||||
errorMessage: "关闭任务失败",
|
|
||||||
scope: "[admin/tasks/:taskId/close]",
|
|
||||||
audit: (req, data) => {
|
|
||||||
const result = data as AdminTaskActionResponse;
|
|
||||||
return {
|
|
||||||
action: "task_closed",
|
|
||||||
targetType: "task",
|
|
||||||
targetId: String(getTaskId(req)),
|
|
||||||
data: {
|
|
||||||
taskId: result.task.taskId,
|
|
||||||
taskNo: result.task.taskNo,
|
|
||||||
status: result.task.status,
|
|
||||||
},
|
|
||||||
};
|
|
||||||
},
|
},
|
||||||
})
|
),
|
||||||
);
|
)
|
||||||
|
|
||||||
router.post(
|
router.post(
|
||||||
"/tasks/:taskId/mark-manual-review",
|
'/tasks/:taskId/kuaishou-cloud/rebind-role',
|
||||||
requireAdminRoles(["admin", "operator"]),
|
requireAdminRoles(['admin', 'operator']),
|
||||||
createJsonHandler((req) => markAdminTaskManualReview(getTaskId(req)), {
|
createJsonHandler(
|
||||||
successMessage: "任务已转人工处理",
|
(req) => rebindAdminTaskKuaishouCloudRole(getTaskId(req), req.adminSession || null),
|
||||||
errorMessage: "标记人工处理失败",
|
{
|
||||||
scope: "[admin/tasks/:taskId/mark-manual-review]",
|
successMessage: '角色换绑资源已准备完成',
|
||||||
audit: (req, data) => {
|
errorMessage: '换绑角色失败',
|
||||||
const result = data as AdminTaskActionResponse;
|
scope: '[admin/tasks/:taskId/kuaishou-cloud/rebind-role]',
|
||||||
return {
|
audit: (req, data) => buildTaskAudit('task_kuaishou_cloud_rebind_role', req, data),
|
||||||
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(
|
router.post(
|
||||||
"/tasks/:taskId/complete-manual-dispatch",
|
'/tasks/:taskId/kuaishou-cloud/dispatch',
|
||||||
requireAdminRoles(["admin", "operator"]),
|
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"]),
|
|
||||||
createJsonHandler(
|
|
||||||
(req) =>
|
|
||||||
prepareAdminTaskKuaishouCloudFulfillment(
|
|
||||||
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,
|
|
||||||
},
|
|
||||||
};
|
|
||||||
},
|
|
||||||
}
|
|
||||||
)
|
|
||||||
);
|
|
||||||
|
|
||||||
router.post(
|
|
||||||
"/tasks/:taskId/kuaishou-cloud/refresh-role-info",
|
|
||||||
requireAdminRoles(["admin", "operator", "support"]),
|
|
||||||
createJsonHandler(
|
|
||||||
(req) =>
|
|
||||||
refreshAdminTaskKuaishouCloudRoleInfo(
|
|
||||||
getTaskId(req),
|
|
||||||
req.adminSession || null
|
|
||||||
),
|
|
||||||
{
|
|
||||||
successMessage: "角色信息已刷新",
|
|
||||||
errorMessage: "刷新角色信息失败",
|
|
||||||
scope: "[admin/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,
|
|
||||||
},
|
|
||||||
};
|
|
||||||
},
|
|
||||||
}
|
|
||||||
)
|
|
||||||
);
|
|
||||||
|
|
||||||
router.post(
|
|
||||||
"/tasks/:taskId/kuaishou-cloud/dispatch",
|
|
||||||
requireAdminRoles(["admin", "operator"]),
|
|
||||||
createJsonHandler(
|
createJsonHandler(
|
||||||
(req) =>
|
(req) =>
|
||||||
dispatchAdminTaskKuaishouCloudFulfillment(
|
dispatchAdminTaskKuaishouCloudFulfillment(
|
||||||
getTaskId(req),
|
getTaskId(req),
|
||||||
req.body as AdminTaskKuaishouCloudDispatchRouteBody,
|
req.body as AdminTaskKuaishouCloudDispatchRouteBody,
|
||||||
req.adminSession || null
|
req.adminSession || null,
|
||||||
),
|
),
|
||||||
{
|
{
|
||||||
successMessage: "已完成绑定确认并发货",
|
successMessage: '已完成绑定确认并发货',
|
||||||
errorMessage: "执行发货失败",
|
errorMessage: '执行发货失败',
|
||||||
scope: "[admin/tasks/:taskId/kuaishou-cloud/dispatch]",
|
scope: '[admin/tasks/:taskId/kuaishou-cloud/dispatch]',
|
||||||
audit: (req, data) => {
|
audit: (req, data) => {
|
||||||
const result = data as AdminTaskActionResponse;
|
const result = data as AdminTaskActionResponse
|
||||||
const body = req.body as AdminTaskKuaishouCloudDispatchRouteBody;
|
const body = req.body as AdminTaskKuaishouCloudDispatchRouteBody
|
||||||
|
|
||||||
return {
|
return {
|
||||||
action: "task_kuaishou_cloud_dispatch",
|
action: 'task_kuaishou_cloud_dispatch',
|
||||||
targetType: "task",
|
targetType: 'task',
|
||||||
targetId: String(getTaskId(req)),
|
targetId: String(getTaskId(req)),
|
||||||
data: {
|
data: {
|
||||||
taskId: result.task.taskId,
|
taskId: result.task.taskId,
|
||||||
taskNo: result.task.taskNo,
|
taskNo: result.task.taskNo,
|
||||||
status: result.task.status,
|
status: result.task.status,
|
||||||
deliveryStatus: result.task.deliveryStatus,
|
deliveryStatus: result.task.deliveryStatus,
|
||||||
ticketCodeProvided: Boolean(String(body.ticketCode || "").trim()),
|
ticketCodeProvided: Boolean(String(body.ticketCode || '').trim()),
|
||||||
},
|
},
|
||||||
};
|
}
|
||||||
},
|
},
|
||||||
}
|
},
|
||||||
)
|
),
|
||||||
);
|
)
|
||||||
|
|
||||||
router.post(
|
router.post(
|
||||||
"/tasks/:taskId/kuaishou-cloud/return-number",
|
'/tasks/:taskId/kuaishou-cloud/return-number',
|
||||||
requireAdminRoles(["admin", "operator"]),
|
requireAdminRoles(['admin', 'operator']),
|
||||||
createJsonHandler(
|
createJsonHandler(
|
||||||
(req) =>
|
(req) =>
|
||||||
returnNumberAdminTaskKuaishouCloudFulfillment(
|
returnNumberAdminTaskKuaishouCloudFulfillment(getTaskId(req), req.adminSession || null),
|
||||||
getTaskId(req),
|
|
||||||
req.adminSession || null
|
|
||||||
),
|
|
||||||
{
|
{
|
||||||
successMessage: "号码已退还",
|
successMessage: '号码已退还',
|
||||||
errorMessage: "退还号码失败",
|
errorMessage: '退还号码失败',
|
||||||
scope: "[admin/tasks/:taskId/kuaishou-cloud/return-number]",
|
scope: '[admin/tasks/:taskId/kuaishou-cloud/return-number]',
|
||||||
audit: (req, data) => {
|
audit: (req, data) => buildTaskAudit('task_kuaishou_cloud_return_number', req, data),
|
||||||
const result = data as AdminTaskActionResponse;
|
},
|
||||||
return {
|
),
|
||||||
action: "task_kuaishou_cloud_return_number",
|
)
|
||||||
targetType: "task",
|
|
||||||
targetId: String(getTaskId(req)),
|
|
||||||
data: {
|
|
||||||
taskId: result.task.taskId,
|
|
||||||
taskNo: result.task.taskNo,
|
|
||||||
status: result.task.status,
|
|
||||||
},
|
|
||||||
};
|
|
||||||
},
|
|
||||||
}
|
|
||||||
)
|
|
||||||
);
|
|
||||||
|
|
||||||
export default router;
|
function buildTaskAudit(action: string, req: Request, data: unknown) {
|
||||||
|
const result = data as AdminTaskActionResponse
|
||||||
|
|
||||||
|
return {
|
||||||
|
action,
|
||||||
|
targetType: 'task',
|
||||||
|
targetId: String(getTaskId(req)),
|
||||||
|
data: {
|
||||||
|
taskId: result.task.taskId,
|
||||||
|
taskNo: result.task.taskNo,
|
||||||
|
status: result.task.status,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default router
|
||||||
|
|||||||
@@ -4,11 +4,10 @@ import {
|
|||||||
createManagedAdminUser,
|
createManagedAdminUser,
|
||||||
getAdminUserList,
|
getAdminUserList,
|
||||||
resetManagedAdminUserPassword,
|
resetManagedAdminUserPassword,
|
||||||
updateManagedAdminUserInventoryGroups,
|
|
||||||
updateManagedAdminUserRole,
|
updateManagedAdminUserRole,
|
||||||
updateManagedAdminUserStatus,
|
updateManagedAdminUserStatus,
|
||||||
} from '../../services/admin/admin-auth-service.js'
|
} from '../../services/admin/admin-auth-service.js'
|
||||||
import { createJsonHandler, requireAdminRoles } from './shared.js'
|
import { createJsonHandler, getRequiredAdminSession, requireAdminRoles } from './session.js'
|
||||||
|
|
||||||
type AdminUserMutationResult = {
|
type AdminUserMutationResult = {
|
||||||
user: {
|
user: {
|
||||||
@@ -16,7 +15,6 @@ type AdminUserMutationResult = {
|
|||||||
username: string
|
username: string
|
||||||
role: string
|
role: string
|
||||||
status?: string
|
status?: string
|
||||||
inventoryGroupCodes?: string[]
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -56,7 +54,7 @@ router.post('/users', createJsonHandler(
|
|||||||
))
|
))
|
||||||
|
|
||||||
router.post('/users/:userId/role', 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: '用户角色已更新',
|
successMessage: '用户角色已更新',
|
||||||
errorMessage: '更新用户角色失败',
|
errorMessage: '更新用户角色失败',
|
||||||
@@ -77,7 +75,7 @@ router.post('/users/:userId/role', createJsonHandler(
|
|||||||
))
|
))
|
||||||
|
|
||||||
router.post('/users/:userId/status', 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: '用户状态已更新',
|
successMessage: '用户状态已更新',
|
||||||
errorMessage: '更新用户状态失败',
|
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(
|
router.post('/users/:userId/reset-password', createJsonHandler(
|
||||||
(req) => resetManagedAdminUserPassword(String(req.params.userId), req.body),
|
(req) => resetManagedAdminUserPassword(String(req.params.userId || ''), req.body),
|
||||||
{
|
{
|
||||||
successMessage: '用户密码已重置',
|
successMessage: '用户密码已重置',
|
||||||
errorMessage: '重置用户密码失败',
|
errorMessage: '重置用户密码失败',
|
||||||
|
|||||||
@@ -1,60 +0,0 @@
|
|||||||
import { Router } from 'express'
|
|
||||||
|
|
||||||
import {
|
|
||||||
replayAdminWebhookEvent,
|
|
||||||
} from '../../services/admin/admin-write-service.js'
|
|
||||||
import {
|
|
||||||
getAdminWebhookEventDetail,
|
|
||||||
getAdminWebhookEvents,
|
|
||||||
} from '../../services/admin/admin-read-service.js'
|
|
||||||
import { createJsonHandler, requireAdminRoles } from './shared.js'
|
|
||||||
import type {
|
|
||||||
AdminWebhookEventRouteParams,
|
|
||||||
AdminWebhookEventRouteQuery,
|
|
||||||
} from '../../types/admin-route-inputs.js'
|
|
||||||
import type { AdminWebhookReplayResponse } from '../../types/admin-write-models.js'
|
|
||||||
|
|
||||||
const router = Router()
|
|
||||||
|
|
||||||
router.use('/webhook-events', requireAdminRoles(['admin', 'operator']))
|
|
||||||
|
|
||||||
router.get('/webhook-events', createJsonHandler(
|
|
||||||
(req) => getAdminWebhookEvents(req.query as AdminWebhookEventRouteQuery),
|
|
||||||
{
|
|
||||||
successMessage: 'ok',
|
|
||||||
errorMessage: '读取 webhook 列表失败',
|
|
||||||
scope: '[admin/webhook-events]',
|
|
||||||
},
|
|
||||||
))
|
|
||||||
|
|
||||||
router.get('/webhook-events/:eventId', createJsonHandler(
|
|
||||||
(req) => getAdminWebhookEventDetail((req.params as AdminWebhookEventRouteParams).eventId),
|
|
||||||
{
|
|
||||||
successMessage: 'ok',
|
|
||||||
errorMessage: '读取 webhook 详情失败',
|
|
||||||
scope: '[admin/webhook-events/:eventId]',
|
|
||||||
},
|
|
||||||
))
|
|
||||||
|
|
||||||
router.post('/webhook-events/:eventId/replay', requireAdminRoles(['admin']), createJsonHandler(
|
|
||||||
(req) => replayAdminWebhookEvent((req.params as AdminWebhookEventRouteParams).eventId),
|
|
||||||
{
|
|
||||||
successMessage: 'Webhook 已重放',
|
|
||||||
errorMessage: '重放 webhook 失败',
|
|
||||||
scope: '[admin/webhook-events/:eventId/replay]',
|
|
||||||
audit: (req, data) => {
|
|
||||||
const result = data as AdminWebhookReplayResponse
|
|
||||||
return {
|
|
||||||
action: 'webhook_replayed',
|
|
||||||
targetType: 'webhook_event',
|
|
||||||
targetId: String((req.params as AdminWebhookEventRouteParams).eventId),
|
|
||||||
data: {
|
|
||||||
eventId: result.eventId,
|
|
||||||
replayed: result.replayed,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
},
|
|
||||||
},
|
|
||||||
))
|
|
||||||
|
|
||||||
export default router
|
|
||||||
@@ -1,94 +1,86 @@
|
|||||||
import { Router } from 'express'
|
import { Router } from 'express'
|
||||||
|
|
||||||
import {
|
import { createRateLimitMiddleware, getParamRateLimitKey } from '../middleware/rate-limit.js'
|
||||||
closeClaimSession,
|
|
||||||
confirmClaimRole,
|
|
||||||
createClaimSession,
|
|
||||||
getClaimDetail,
|
|
||||||
getClaimScreenshotPath,
|
|
||||||
getClaimSessionSummary,
|
|
||||||
reloadClaimSession,
|
|
||||||
redeemClaimTask,
|
|
||||||
} from '../services/claim/claim-session-service.js'
|
|
||||||
import {
|
import {
|
||||||
confirmKuaishouCloudClaimRole,
|
confirmKuaishouCloudClaimRole,
|
||||||
|
getKuaishouCloudClaimDetail,
|
||||||
getKuaishouCloudClaimGuideAssetPath,
|
getKuaishouCloudClaimGuideAssetPath,
|
||||||
|
rebindKuaishouCloudClaimRole,
|
||||||
redeemKuaishouCloudClaim,
|
redeemKuaishouCloudClaim,
|
||||||
verifyKuaishouCloudClaimTicket,
|
verifyKuaishouCloudClaimTicket,
|
||||||
} from '../services/claim/kuaishou-cloud-claim-service.js'
|
} from '../services/claim/kuaishou-cloud-claim-service.js'
|
||||||
import {
|
import { buildNotFoundPayload, createRouteFileHandler, createRouteHandler } from '../utils/http.js'
|
||||||
buildNotFoundPayload,
|
|
||||||
buildSuccessPayload,
|
|
||||||
createRouteFileHandler,
|
|
||||||
createRouteHandler,
|
|
||||||
sendRouteError,
|
|
||||||
} from '../utils/http.js'
|
|
||||||
|
|
||||||
const router = Router()
|
const router = Router()
|
||||||
|
const claimReadRateLimit = createRateLimitMiddleware({
|
||||||
router.get('/:token', createRouteHandler(
|
scope: 'claim:read',
|
||||||
(req) => getClaimDetail(req.params.token),
|
windowMs: 60_000,
|
||||||
{ errorMessage: '查询领取详情失败', scope: '[claims/:token]' },
|
max: 120,
|
||||||
))
|
key: getParamRateLimitKey('token'),
|
||||||
|
})
|
||||||
router.post('/:token/session', createRouteHandler(
|
const claimWriteRateLimit = createRateLimitMiddleware({
|
||||||
(req) => createClaimSession(req.params.token, req.body),
|
scope: 'claim:write',
|
||||||
{ successMessage: '浏览器会话已创建', errorMessage: '创建领取会话失败', scope: '[claims/:token/session]' },
|
windowMs: 60_000,
|
||||||
))
|
max: 30,
|
||||||
|
key: getParamRateLimitKey('token'),
|
||||||
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]')
|
|
||||||
}
|
|
||||||
})
|
})
|
||||||
|
|
||||||
router.delete('/:token/session', createRouteHandler(
|
router.get(
|
||||||
(req) => closeClaimSession(req.params.token),
|
'/:token',
|
||||||
{ successMessage: '领取会话已关闭', errorMessage: '关闭领取会话失败', scope: '[claims/:token/session]' },
|
claimReadRateLimit,
|
||||||
))
|
createRouteHandler((req) => getKuaishouCloudClaimDetail(req.params.token), {
|
||||||
|
errorMessage: '查询快手领取详情失败',
|
||||||
|
scope: '[claims/:token]',
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
router.post('/:token/confirm-role', createRouteHandler(
|
router.post(
|
||||||
(req) => confirmClaimRole(req.params.token),
|
'/:token/kuaishou-cloud/verify-ticket',
|
||||||
{ successMessage: '角色已确认', errorMessage: '确认角色失败', scope: '[claims/:token/confirm-role]' },
|
claimWriteRateLimit,
|
||||||
))
|
createRouteHandler((req) => verifyKuaishouCloudClaimTicket(req.params.token, req.body), {
|
||||||
|
successMessage: '核销码提交并核销成功',
|
||||||
|
errorMessage: '提交核销码失败',
|
||||||
|
scope: '[claims/:token/kuaishou-cloud/verify-ticket]',
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
router.post('/:token/redeem', createRouteHandler(
|
router.post(
|
||||||
(req) => redeemClaimTask(req.params.token),
|
'/:token/kuaishou-cloud/confirm-role',
|
||||||
{ successMessage: '兑换完成', errorMessage: '执行领取兑换失败', scope: '[claims/:token/redeem]' },
|
claimWriteRateLimit,
|
||||||
))
|
createRouteHandler((req) => confirmKuaishouCloudClaimRole(req.params.token), {
|
||||||
|
successMessage: '角色已确认',
|
||||||
|
errorMessage: '确认角色失败',
|
||||||
|
scope: '[claims/:token/kuaishou-cloud/confirm-role]',
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
router.post('/:token/kuaishou-cloud/verify-ticket', createRouteHandler(
|
router.post(
|
||||||
(req) => verifyKuaishouCloudClaimTicket(req.params.token, req.body),
|
'/:token/kuaishou-cloud/rebind-role',
|
||||||
{ successMessage: '核销码校验成功', errorMessage: '校验核销码失败', scope: '[claims/:token/kuaishou-cloud/verify-ticket]' },
|
claimWriteRateLimit,
|
||||||
))
|
createRouteHandler((req) => rebindKuaishouCloudClaimRole(req.params.token), {
|
||||||
|
successMessage: '角色换绑资源已准备完成',
|
||||||
|
errorMessage: '换绑角色失败',
|
||||||
|
scope: '[claims/:token/kuaishou-cloud/rebind-role]',
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
router.post('/:token/kuaishou-cloud/confirm-role', createRouteHandler(
|
router.post(
|
||||||
(req) => confirmKuaishouCloudClaimRole(req.params.token),
|
'/:token/kuaishou-cloud/redeem',
|
||||||
{ successMessage: '角色已确认', errorMessage: '确认角色失败', scope: '[claims/:token/kuaishou-cloud/confirm-role]' },
|
claimWriteRateLimit,
|
||||||
))
|
createRouteHandler((req) => redeemKuaishouCloudClaim(req.params.token), {
|
||||||
|
successMessage: '兑换请求已提交',
|
||||||
|
errorMessage: '兑换失败',
|
||||||
|
scope: '[claims/:token/kuaishou-cloud/redeem]',
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
router.post('/:token/kuaishou-cloud/redeem', createRouteHandler(
|
router.get(
|
||||||
(req) => redeemKuaishouCloudClaim(req.params.token),
|
'/assets/kuaishou-cloud/:filename',
|
||||||
{ successMessage: '兑换请求已提交', errorMessage: '兑换失败', scope: '[claims/:token/kuaishou-cloud/redeem]' },
|
createRouteFileHandler((req) => getKuaishouCloudClaimGuideAssetPath(req.params.filename), {
|
||||||
))
|
errorMessage: '读取指引图片失败',
|
||||||
|
scope: '[claims/assets/kuaishou-cloud/:filename]',
|
||||||
router.get('/:token/screenshot', createRouteFileHandler(
|
}),
|
||||||
(req) => getClaimScreenshotPath(req.params.token),
|
)
|
||||||
{ errorMessage: '读取领取截图失败', scope: '[claims/:token/screenshot]' },
|
|
||||||
))
|
|
||||||
|
|
||||||
router.get('/assets/kuaishou-cloud/:filename', createRouteFileHandler(
|
|
||||||
(req) => getKuaishouCloudClaimGuideAssetPath(req.params.filename),
|
|
||||||
{ errorMessage: '读取指引图片失败', scope: '[claims/assets/kuaishou-cloud/:filename]' },
|
|
||||||
))
|
|
||||||
|
|
||||||
router.use((req, res) => {
|
router.use((req, res) => {
|
||||||
res.status(404).json(buildNotFoundPayload(req))
|
res.status(404).json(buildNotFoundPayload(req))
|
||||||
|
|||||||
@@ -1,17 +1,34 @@
|
|||||||
import { Router } from 'express'
|
import { Router } from 'express'
|
||||||
|
|
||||||
|
import { createRateLimitMiddleware } from '../middleware/rate-limit.js'
|
||||||
import { createOpen91Order } from '../services/open-91/order-create-service.js'
|
import { createOpen91Order } from '../services/open-91/order-create-service.js'
|
||||||
import { queryOpen91Order } from '../services/open-91/order-query-service.js'
|
import { queryOpen91Order } from '../services/open-91/order-query-service.js'
|
||||||
import { buildOpen91ErrorResponse } from '../services/open-91/shared.js'
|
import { buildOpen91ErrorResponse } from '../services/open-91/response.js'
|
||||||
import { createRequestId, logWebhook } from '../utils/logger.js'
|
import { createRequestId, logIntegration } from '../utils/logger.js'
|
||||||
|
|
||||||
const router = Router()
|
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 requestId = createRequestId('91')
|
||||||
const startedAt = Date.now()
|
const startedAt = Date.now()
|
||||||
|
|
||||||
logWebhook('[open-91/create]', '收到 91卡券异步下单请求', {
|
logIntegration('[open-91/create]', '收到 91卡券异步下单请求', {
|
||||||
requestId,
|
requestId,
|
||||||
method: req.method,
|
method: req.method,
|
||||||
originalUrl: req.originalUrl,
|
originalUrl: req.originalUrl,
|
||||||
@@ -21,7 +38,7 @@ router.post('/orders/create', async (req, res) => {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
const result = await createOpen91Order(req.body, { requestId })
|
const result = await createOpen91Order(req.body, { requestId })
|
||||||
logWebhook('[open-91/create]', '91卡券异步下单处理完成', {
|
logIntegration('[open-91/create]', '91卡券异步下单处理完成', {
|
||||||
requestId,
|
requestId,
|
||||||
durationMs: Date.now() - startedAt,
|
durationMs: Date.now() - startedAt,
|
||||||
result,
|
result,
|
||||||
@@ -29,8 +46,8 @@ router.post('/orders/create', async (req, res) => {
|
|||||||
res.status(200).json(result)
|
res.status(200).json(result)
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
const message = error instanceof Error ? error.message : '系统错误'
|
const message = error instanceof Error ? error.message : '系统错误'
|
||||||
const code = Number(error?.statusCode || 500) >= 500 ? 500 : 400
|
const code = resolveErrorStatusCode(error) >= 500 ? 500 : 400
|
||||||
logWebhook('[open-91/create]', '91卡券异步下单处理失败', {
|
logIntegration('[open-91/create]', '91卡券异步下单处理失败', {
|
||||||
requestId,
|
requestId,
|
||||||
durationMs: Date.now() - startedAt,
|
durationMs: Date.now() - startedAt,
|
||||||
error,
|
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 requestId = createRequestId('91')
|
||||||
const startedAt = Date.now()
|
const startedAt = Date.now()
|
||||||
|
|
||||||
logWebhook('[open-91/query]', '收到 91卡券订单查询请求', {
|
logIntegration('[open-91/query]', '收到 91卡券订单查询请求', {
|
||||||
requestId,
|
requestId,
|
||||||
method: req.method,
|
method: req.method,
|
||||||
originalUrl: req.originalUrl,
|
originalUrl: req.originalUrl,
|
||||||
@@ -53,7 +70,7 @@ router.post('/orders/query', async (req, res) => {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
const result = await queryOpen91Order(req.body, { requestId })
|
const result = await queryOpen91Order(req.body, { requestId })
|
||||||
logWebhook('[open-91/query]', '91卡券订单查询处理完成', {
|
logIntegration('[open-91/query]', '91卡券订单查询处理完成', {
|
||||||
requestId,
|
requestId,
|
||||||
durationMs: Date.now() - startedAt,
|
durationMs: Date.now() - startedAt,
|
||||||
result,
|
result,
|
||||||
@@ -61,8 +78,8 @@ router.post('/orders/query', async (req, res) => {
|
|||||||
res.status(200).json(result)
|
res.status(200).json(result)
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
const message = error instanceof Error ? error.message : '系统错误'
|
const message = error instanceof Error ? error.message : '系统错误'
|
||||||
const code = Number(error?.statusCode || 500) >= 500 ? 500 : 400
|
const code = resolveErrorStatusCode(error) >= 500 ? 500 : 400
|
||||||
logWebhook('[open-91/query]', '91卡券订单查询处理失败', {
|
logIntegration('[open-91/query]', '91卡券订单查询处理失败', {
|
||||||
requestId,
|
requestId,
|
||||||
durationMs: Date.now() - startedAt,
|
durationMs: Date.now() - startedAt,
|
||||||
error,
|
error,
|
||||||
|
|||||||
@@ -1,77 +0,0 @@
|
|||||||
import { Router } from 'express'
|
|
||||||
|
|
||||||
import {
|
|
||||||
closeTencentBrowserSession,
|
|
||||||
createTencentBrowserSession,
|
|
||||||
getTencentBrowserSession,
|
|
||||||
getTencentBrowserSessionSummary,
|
|
||||||
getTencentBrowserSessionScreenshotPath,
|
|
||||||
reloadTencentBrowserSession,
|
|
||||||
redeemTencentBrowserSession,
|
|
||||||
} from '../services/session/session.js'
|
|
||||||
import {
|
|
||||||
buildNotFoundPayload,
|
|
||||||
buildSuccessPayload,
|
|
||||||
createRouteFileHandler,
|
|
||||||
createRouteHandler,
|
|
||||||
sendRouteError,
|
|
||||||
} from '../utils/http.js'
|
|
||||||
|
|
||||||
const router = Router()
|
|
||||||
|
|
||||||
router.post('/browser/session', createRouteHandler(
|
|
||||||
(req) => createTencentBrowserSession(req.body),
|
|
||||||
{ successMessage: '浏览器会话已创建', errorMessage: '创建浏览器会话失败', scope: '[browser/session]' },
|
|
||||||
))
|
|
||||||
|
|
||||||
router.get('/browser/session/:sessionId', async (req, res) => {
|
|
||||||
try {
|
|
||||||
const data = await getTencentBrowserSession(req.params.sessionId)
|
|
||||||
res.json(buildSuccessPayload(data, data.notice || 'ok'))
|
|
||||||
} catch (error) {
|
|
||||||
sendRouteError(res, error, '查询浏览器会话失败', '[browser/session/:sessionId]')
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
router.get('/browser/session/:sessionId/summary', async (req, res) => {
|
|
||||||
try {
|
|
||||||
const data = await getTencentBrowserSessionSummary(req.params.sessionId)
|
|
||||||
res.json(buildSuccessPayload(data, data.notice || 'ok'))
|
|
||||||
} catch (error) {
|
|
||||||
sendRouteError(res, error, '查询浏览器会话摘要失败', '[browser/session/:sessionId/summary]')
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
router.post('/browser/session/:sessionId/refresh', async (req, res) => {
|
|
||||||
try {
|
|
||||||
const data = await reloadTencentBrowserSession(req.params.sessionId)
|
|
||||||
res.json(buildSuccessPayload(data, data.notice || '后端页面已刷新'))
|
|
||||||
} catch (error) {
|
|
||||||
sendRouteError(res, error, '刷新后端页面失败', '[browser/session/:sessionId/refresh]')
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
router.post('/browser/session/:sessionId/redeem', async (req, res) => {
|
|
||||||
try {
|
|
||||||
const data = await redeemTencentBrowserSession(req.params.sessionId, req.body)
|
|
||||||
res.json(buildSuccessPayload(data, data.notice || '兑换完成'))
|
|
||||||
} catch (error) {
|
|
||||||
sendRouteError(res, error, '浏览器会话兑换失败', '[browser/session/:sessionId/redeem]')
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
router.get('/browser/session/:sessionId/screenshot', createRouteFileHandler(
|
|
||||||
(req) => getTencentBrowserSessionScreenshotPath(req.params.sessionId),
|
|
||||||
{ errorMessage: '读取兑换截图失败', scope: '[browser/session/:sessionId/screenshot]' },
|
|
||||||
))
|
|
||||||
|
|
||||||
router.delete('/browser/session/:sessionId', createRouteHandler(
|
|
||||||
(req) => closeTencentBrowserSession(req.params.sessionId),
|
|
||||||
{ successMessage: '浏览器会话已关闭', errorMessage: '关闭浏览器会话失败', scope: '[browser/session/:sessionId]' },
|
|
||||||
))
|
|
||||||
|
|
||||||
router.use((req, res) => {
|
|
||||||
res.status(404).json(buildNotFoundPayload(req))
|
|
||||||
})
|
|
||||||
|
|
||||||
export default router
|
|
||||||
@@ -1,57 +0,0 @@
|
|||||||
import { Router } from 'express'
|
|
||||||
|
|
||||||
import { processAgisoTradeWebhook } from '../services/order/webhook-service.js'
|
|
||||||
import { createRequestId, logWebhook } from '../utils/logger.js'
|
|
||||||
import { buildNotFoundPayload, buildSuccessPayload, sendRouteError } from '../utils/http.js'
|
|
||||||
|
|
||||||
const router = Router()
|
|
||||||
|
|
||||||
router.post('/agiso/trade', async (req, res) => {
|
|
||||||
const requestId = createRequestId('wh')
|
|
||||||
const startedAt = Date.now()
|
|
||||||
|
|
||||||
logWebhook('[webhooks/agiso/trade]', '收到 Agiso webhook 请求', {
|
|
||||||
requestId,
|
|
||||||
method: req.method,
|
|
||||||
originalUrl: req.originalUrl,
|
|
||||||
ip: req.ip,
|
|
||||||
headers: req.headers,
|
|
||||||
query: req.query,
|
|
||||||
body: req.body,
|
|
||||||
})
|
|
||||||
|
|
||||||
try {
|
|
||||||
const data = await processAgisoTradeWebhook({
|
|
||||||
requestId,
|
|
||||||
headers: req.headers,
|
|
||||||
query: req.query,
|
|
||||||
body: req.body,
|
|
||||||
})
|
|
||||||
|
|
||||||
logWebhook('[webhooks/agiso/trade]', 'Agiso webhook 处理完成', {
|
|
||||||
requestId,
|
|
||||||
durationMs: Date.now() - startedAt,
|
|
||||||
result: data,
|
|
||||||
})
|
|
||||||
|
|
||||||
res.json(buildSuccessPayload(data, 'success'))
|
|
||||||
} catch (error) {
|
|
||||||
logWebhook(
|
|
||||||
'[webhooks/agiso/trade]',
|
|
||||||
'Agiso webhook 处理失败',
|
|
||||||
{
|
|
||||||
requestId,
|
|
||||||
durationMs: Date.now() - startedAt,
|
|
||||||
error,
|
|
||||||
},
|
|
||||||
{ level: 'error' },
|
|
||||||
)
|
|
||||||
sendRouteError(res, error, '处理 Agiso 订单通知失败', '[webhooks/agiso/trade]')
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
router.use((req, res) => {
|
|
||||||
res.status(404).json(buildNotFoundPayload(req))
|
|
||||||
})
|
|
||||||
|
|
||||||
export default router
|
|
||||||
@@ -7,7 +7,6 @@ import {
|
|||||||
getAdminUserById,
|
getAdminUserById,
|
||||||
getAdminUserByUsername,
|
getAdminUserByUsername,
|
||||||
listAdminUsers,
|
listAdminUsers,
|
||||||
replaceAdminUserInventoryGroupBindings,
|
|
||||||
updateAdminUser,
|
updateAdminUser,
|
||||||
} from '../../repositories/admin-user-repo.js'
|
} from '../../repositories/admin-user-repo.js'
|
||||||
import { addHours, nowIso } from '../../utils/time.js'
|
import { addHours, nowIso } from '../../utils/time.js'
|
||||||
@@ -23,8 +22,8 @@ export type AdminSession = {
|
|||||||
userId: number
|
userId: number
|
||||||
username: string
|
username: string
|
||||||
role: AdminRole
|
role: AdminRole
|
||||||
allowedInventoryGroups: string[]
|
|
||||||
expiresAt: string
|
expiresAt: string
|
||||||
|
sessionVersion: number
|
||||||
}
|
}
|
||||||
|
|
||||||
type AdminRole = 'admin' | 'operator' | 'support'
|
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 {
|
return {
|
||||||
sessionId: String(payload?.sid || '').trim(),
|
sessionId: String(payload?.sid || '').trim(),
|
||||||
userId: Number(user.id),
|
userId: Number(user.id),
|
||||||
username: String(user.username || ''),
|
username: String(user.username || ''),
|
||||||
role: normalizeAdminRole(user.role),
|
role: normalizeAdminRole(user.role),
|
||||||
allowedInventoryGroups: normalizeInventoryGroupCodes(user.inventory_group_codes),
|
|
||||||
expiresAt,
|
expiresAt,
|
||||||
|
sessionVersion: currentSessionVersion,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -159,13 +167,12 @@ export async function getAdminSessionSummary(token: unknown): Promise<JsonObject
|
|||||||
userId: session.userId,
|
userId: session.userId,
|
||||||
username: session.username,
|
username: session.username,
|
||||||
role: session.role,
|
role: session.role,
|
||||||
inventoryGroupCodes: session.allowedInventoryGroups,
|
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export function requireAdminRole(session: { role?: string }, allowedRoles: string[]): void {
|
export function requireAdminRole(session: { role?: string } | null | undefined, allowedRoles: string[]): void {
|
||||||
if (allowedRoles.includes(session.role)) {
|
if (session && allowedRoles.includes(session.role || '')) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -229,28 +236,18 @@ export async function createManagedAdminUser(payload: JsonObject = {}): Promise<
|
|||||||
createdAt: now,
|
createdAt: now,
|
||||||
updatedAt: now,
|
updatedAt: now,
|
||||||
})
|
})
|
||||||
|
if (!created) {
|
||||||
|
throw createHttpError('后台用户创建失败', {
|
||||||
|
statusCode: 500,
|
||||||
|
errorCode: 'admin_user_create_failed',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
user: mapAdminUser(created),
|
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(
|
export async function updateManagedAdminUserRole(
|
||||||
userId: number | string,
|
userId: number | string,
|
||||||
payload: JsonObject = {},
|
payload: JsonObject = {},
|
||||||
@@ -268,8 +265,15 @@ export async function updateManagedAdminUserRole(
|
|||||||
await ensureAdminUserChangeAllowed(user, { nextRole: role }, session)
|
await ensureAdminUserChangeAllowed(user, { nextRole: role }, session)
|
||||||
const updated = await updateAdminUser(user.id, {
|
const updated = await updateAdminUser(user.id, {
|
||||||
role,
|
role,
|
||||||
|
session_version: nextAdminSessionVersion(user),
|
||||||
updated_at: nowIso(),
|
updated_at: nowIso(),
|
||||||
})
|
})
|
||||||
|
if (!updated) {
|
||||||
|
throw createHttpError('后台用户更新失败', {
|
||||||
|
statusCode: 500,
|
||||||
|
errorCode: 'admin_user_update_failed',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
user: mapAdminUser(updated),
|
user: mapAdminUser(updated),
|
||||||
@@ -293,8 +297,15 @@ export async function updateManagedAdminUserStatus(
|
|||||||
await ensureAdminUserChangeAllowed(user, { nextStatus: status }, session)
|
await ensureAdminUserChangeAllowed(user, { nextStatus: status }, session)
|
||||||
const updated = await updateAdminUser(user.id, {
|
const updated = await updateAdminUser(user.id, {
|
||||||
status,
|
status,
|
||||||
|
session_version: nextAdminSessionVersion(user),
|
||||||
updated_at: nowIso(),
|
updated_at: nowIso(),
|
||||||
})
|
})
|
||||||
|
if (!updated) {
|
||||||
|
throw createHttpError('后台用户更新失败', {
|
||||||
|
statusCode: 500,
|
||||||
|
errorCode: 'admin_user_update_failed',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
user: mapAdminUser(updated),
|
user: mapAdminUser(updated),
|
||||||
@@ -315,8 +326,15 @@ export async function resetManagedAdminUserPassword(userId: number | string, pay
|
|||||||
validatePassword(password)
|
validatePassword(password)
|
||||||
const updated = await updateAdminUser(user.id, {
|
const updated = await updateAdminUser(user.id, {
|
||||||
password_hash: hashAdminPassword(password),
|
password_hash: hashAdminPassword(password),
|
||||||
|
session_version: nextAdminSessionVersion(user),
|
||||||
updated_at: nowIso(),
|
updated_at: nowIso(),
|
||||||
})
|
})
|
||||||
|
if (!updated) {
|
||||||
|
throw createHttpError('后台用户更新失败', {
|
||||||
|
statusCode: 500,
|
||||||
|
errorCode: 'admin_user_update_failed',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
user: mapAdminUser(updated),
|
user: mapAdminUser(updated),
|
||||||
@@ -345,6 +363,7 @@ function createAdminSession(user: AdminUserRow): JsonObject {
|
|||||||
uid: Number(user.id),
|
uid: Number(user.id),
|
||||||
usr: String(user.username || ''),
|
usr: String(user.username || ''),
|
||||||
role: normalizeAdminRole(user.role),
|
role: normalizeAdminRole(user.role),
|
||||||
|
ver: normalizeAdminSessionVersion(user.session_version),
|
||||||
iat: issuedAt,
|
iat: issuedAt,
|
||||||
exp: expiresAt,
|
exp: expiresAt,
|
||||||
}
|
}
|
||||||
@@ -358,7 +377,6 @@ function createAdminSession(user: AdminUserRow): JsonObject {
|
|||||||
userId: Number(user.id),
|
userId: Number(user.id),
|
||||||
username: String(user.username || ''),
|
username: String(user.username || ''),
|
||||||
role: normalizeAdminRole(user.role),
|
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> {
|
async function getRequiredAdminUser(userId: number | string): Promise<AdminUserRow> {
|
||||||
const user = await getAdminUserById(Number(userId))
|
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 {
|
return {
|
||||||
userId: Number(user.id),
|
userId: Number(user.id),
|
||||||
username: String(user.username || ''),
|
username: String(user.username || ''),
|
||||||
role: normalizeAdminRole(user.role),
|
role: normalizeAdminRole(user.role),
|
||||||
status: normalizeAdminUserStatus(user.status),
|
status: normalizeAdminUserStatus(user.status),
|
||||||
inventoryGroupCodes: normalizeInventoryGroupCodes(user.inventory_group_codes),
|
|
||||||
createdAt: user.created_at,
|
createdAt: user.created_at,
|
||||||
updatedAt: user.updated_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 { query } from '../../db/client.js'
|
||||||
|
import { TASK_STATUS } from '../../domain/task-status.js'
|
||||||
import { nowIso } from '../../utils/time.js'
|
import { nowIso } from '../../utils/time.js'
|
||||||
|
|
||||||
type JsonObject = Record<string, any>
|
type JsonObject = Record<string, any>
|
||||||
|
|
||||||
export async function getAdminDashboardSummary() {
|
export async function getAdminDashboardSummary() {
|
||||||
const todayPrefix = nowIso().slice(0, 10)
|
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(
|
const result = await query(
|
||||||
`
|
`
|
||||||
SELECT
|
SELECT
|
||||||
@@ -12,32 +28,32 @@ export async function getAdminDashboardSummary() {
|
|||||||
(
|
(
|
||||||
SELECT COUNT(*)::int
|
SELECT COUNT(*)::int
|
||||||
FROM fulfillment_tasks
|
FROM fulfillment_tasks
|
||||||
WHERE task_status IN ('paid', 'link_generated')
|
WHERE task_status = ANY($2::text[])
|
||||||
OR (inventory_status = 'reserved' AND task_status NOT IN ('claimed', 'role_confirmed', 'redeeming', 'redeemed', 'closed', 'expired'))
|
|
||||||
) AS paid_pending_claim,
|
) AS paid_pending_claim,
|
||||||
(
|
(
|
||||||
SELECT COUNT(*)::int
|
SELECT COUNT(*)::int
|
||||||
FROM fulfillment_tasks
|
FROM fulfillment_tasks
|
||||||
WHERE task_status IN ('claimed', 'role_confirmed', 'redeeming')
|
WHERE task_status = ANY($3::text[])
|
||||||
) AS claiming_tasks,
|
) AS claiming_tasks,
|
||||||
(
|
(
|
||||||
SELECT COUNT(*)::int
|
SELECT COUNT(*)::int
|
||||||
FROM fulfillment_tasks
|
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
|
AND to_char(updated_at AT TIME ZONE 'Asia/Shanghai', 'YYYY-MM-DD') = $1
|
||||||
) AS redeemed_today,
|
) AS redeemed_today,
|
||||||
(
|
(
|
||||||
SELECT COUNT(*)::int
|
SELECT COUNT(*)::int
|
||||||
FROM fulfillment_tasks
|
FROM fulfillment_tasks
|
||||||
WHERE task_status IN ('retry_pending', 'manual_review', 'waiting_inventory')
|
WHERE task_status = ANY($5::text[])
|
||||||
) AS abnormal_tasks,
|
) AS abnormal_tasks
|
||||||
(
|
|
||||||
SELECT COUNT(DISTINCT sku_code)::int
|
|
||||||
FROM inventory_items
|
|
||||||
WHERE status = 'available'
|
|
||||||
) AS sku_with_inventory
|
|
||||||
`,
|
`,
|
||||||
[todayPrefix],
|
[
|
||||||
|
todayPrefix,
|
||||||
|
paidPendingClaimStatuses,
|
||||||
|
claimingStatuses,
|
||||||
|
TASK_STATUS.REDEEMED,
|
||||||
|
abnormalStatuses,
|
||||||
|
],
|
||||||
)
|
)
|
||||||
const summary: JsonObject = result.rows[0] || {}
|
const summary: JsonObject = result.rows[0] || {}
|
||||||
|
|
||||||
@@ -47,6 +63,5 @@ export async function getAdminDashboardSummary() {
|
|||||||
claimingTasks: Number(summary?.claiming_tasks || 0),
|
claimingTasks: Number(summary?.claiming_tasks || 0),
|
||||||
redeemedToday: Number(summary?.redeemed_today || 0),
|
redeemedToday: Number(summary?.redeemed_today || 0),
|
||||||
abnormalTasks: Number(summary?.abnormal_tasks || 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 { listTasksByOrderId } from '../../repositories/task-repo.js'
|
||||||
import { formatFenToAmount, normalizeFen } from '../../utils/money.js'
|
import { formatFenToAmount, normalizeFen } from '../../utils/money.js'
|
||||||
import { resolveDisplayShopName, resolveOrderItemTitle } from './admin-read-shared-helpers.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 { AdminOrderListItem } from '../../types/admin/read-models.js'
|
||||||
import type { OrderItemRow, OrderListRow } from '../../types/repository-rows.js'
|
import type { OrderItemRow, OrderListRow } from '../../types/repository/rows.js'
|
||||||
|
|
||||||
export async function mapAdminOrderListItem(item: OrderListRow): Promise<AdminOrderListItem> {
|
export async function mapAdminOrderListItem(item: OrderListRow): Promise<AdminOrderListItem> {
|
||||||
const [tasks, orderItems] = await Promise.all([
|
const [tasks, orderItems] = await Promise.all([
|
||||||
listTasksByOrderId(item.id),
|
listTasksByOrderId(item.id),
|
||||||
listOrderItemsByOrderId(item.id),
|
listOrderItemsByOrderId(item.id),
|
||||||
])
|
])
|
||||||
const taskBindingSummaryMap = await getTaskBindingSummaryMap(tasks.map((task) => task.id))
|
const fulfillmentProgress = buildOrderFulfillmentProgress(tasks)
|
||||||
const bindingSummary = buildOrderBindingSummary(tasks, taskBindingSummaryMap)
|
|
||||||
const itemSummary = summarizeOrderItems(orderItems)
|
const itemSummary = summarizeOrderItems(orderItems)
|
||||||
|
|
||||||
return {
|
return {
|
||||||
orderId: item.id,
|
orderId: item.id,
|
||||||
provider: item.provider || 'agiso',
|
provider: item.provider || '',
|
||||||
platform: item.platform,
|
platform: item.platform,
|
||||||
shopId: item.shop_id || '',
|
shopId: item.shop_id || '',
|
||||||
shopName: resolveDisplayShopName(item.provider, item.shop_id, item.shop_name),
|
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),
|
totalQuantity: orderItems.reduce((sum, orderItem) => sum + Math.max(1, Number(orderItem.quantity || 1)), 0),
|
||||||
itemSummary,
|
itemSummary,
|
||||||
taskCount: Number(item.task_count || tasks.length || 0),
|
taskCount: Number(item.task_count || tasks.length || 0),
|
||||||
systemBindingStatus: bindingSummary.systemBindingStatus,
|
resourceStatus: fulfillmentProgress.resourceStatus,
|
||||||
userBindingStatus: bindingSummary.userBindingStatus,
|
customerStatus: fulfillmentProgress.customerStatus,
|
||||||
systemBoundTaskCount: bindingSummary.systemBoundTaskCount,
|
preparedTaskCount: fulfillmentProgress.preparedTaskCount,
|
||||||
completedBindingTaskCount: bindingSummary.completedBindingTaskCount,
|
completedTaskCount: fulfillmentProgress.completedTaskCount,
|
||||||
totalBindingCount: bindingSummary.totalBindingCount,
|
|
||||||
reservedBindingCount: bindingSummary.reservedBindingCount,
|
|
||||||
consumedBindingCount: bindingSummary.consumedBindingCount,
|
|
||||||
releasedBindingCount: bindingSummary.releasedBindingCount,
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,31 +1,35 @@
|
|||||||
import { buildClaimUrl } from '../claim/claim-service.js'
|
import { buildClaimUrl } from '../claim/claim-service.js'
|
||||||
import { getClaimTokenById } from '../../repositories/claim-token-repo.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 { listOrderItemsByOrderId } from '../../repositories/order-item-repo.js'
|
||||||
import { getOrderById, listOrders } from '../../repositories/order-repo.js'
|
import { getOrderById, listOrders } from '../../repositories/order-repo.js'
|
||||||
import { getTaskById, listTasks, listTasksByOrderId } from '../../repositories/task-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 { 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 { createHttpError } from '../../utils/http.js'
|
||||||
import { formatFenToAmount, normalizeFen } from '../../utils/money.js'
|
import { formatFenToAmount, normalizeFen } from '../../utils/money.js'
|
||||||
import { getTencentBrowserSessionReviewScreenshotPath } from '../session/session.js'
|
import {
|
||||||
import { normalizeDateQuery, normalizePage, normalizePageSize, safeParseJson } from './admin-query-utils.js'
|
normalizeDateQuery,
|
||||||
|
normalizePage,
|
||||||
|
normalizePageSize,
|
||||||
|
safeParseJson,
|
||||||
|
} from './admin-query-utils.js'
|
||||||
|
import {
|
||||||
|
canCompleteManualDispatchTaskStatus,
|
||||||
|
canDispatchKuaishouCloudFulfillmentStatus,
|
||||||
|
canMarkManualReviewTaskStatus,
|
||||||
|
canPrepareKuaishouCloudFulfillmentStatus,
|
||||||
|
canRebindKuaishouCloudRoleStatus,
|
||||||
|
canReturnKuaishouCloudFulfillmentStatus,
|
||||||
|
canRetryTaskStatus,
|
||||||
|
} from '../../domain/task-status.js'
|
||||||
import {
|
import {
|
||||||
canViewerCloseTask,
|
canViewerCloseTask,
|
||||||
canRegenerateClaimLinkForViewer,
|
canRegenerateClaimLinkForViewer,
|
||||||
canViewerConfirmAssistedRole,
|
|
||||||
canViewerRedeemAssistedTask,
|
|
||||||
createAdminViewerContext,
|
createAdminViewerContext,
|
||||||
getTaskPrimaryClaimTokenId,
|
getTaskPrimaryClaimTokenId,
|
||||||
getTaskPrimaryInventoryItemId,
|
|
||||||
isAssistedClaimTask,
|
|
||||||
isManualDispatchTask,
|
isManualDispatchTask,
|
||||||
mapKuaishouCloudFulfillmentContext,
|
mapKuaishouCloudFulfillmentContext,
|
||||||
|
mapKuaishouCloudTaskStateProjection,
|
||||||
mapManualDispatchContext,
|
mapManualDispatchContext,
|
||||||
mapRedeemResolutionContext,
|
mapRedeemResolutionContext,
|
||||||
parseTaskContext,
|
parseTaskContext,
|
||||||
@@ -35,42 +39,31 @@ import {
|
|||||||
resolveOrderItemDeliveryMode,
|
resolveOrderItemDeliveryMode,
|
||||||
resolveOrderItemTitle,
|
resolveOrderItemTitle,
|
||||||
} from './admin-read-shared-helpers.js'
|
} from './admin-read-shared-helpers.js'
|
||||||
import { mapAdminInventoryListItem } from './admin-inventory-read-helpers.js'
|
|
||||||
import { mapAdminOrderListItem, summarizeOrderItems } from './admin-order-read-helpers.js'
|
import { mapAdminOrderListItem, summarizeOrderItems } from './admin-order-read-helpers.js'
|
||||||
import { mapAdminWebhookEvent } from './admin-webhook-read-helpers.js'
|
|
||||||
import {
|
import {
|
||||||
buildOrderAgisoAutoDeliverySummary,
|
buildOrderFulfillmentProgress,
|
||||||
buildOrderBindingSummary,
|
|
||||||
createTaskBindingSummaryFromBindings,
|
|
||||||
getRequiredTask,
|
getRequiredTask,
|
||||||
getTaskBindingSummary,
|
|
||||||
getTaskBindingSummaryMap,
|
|
||||||
mapAdminTaskEvent,
|
mapAdminTaskEvent,
|
||||||
mapAdminTaskInventoryBinding,
|
|
||||||
mapAdminTaskListItem,
|
mapAdminTaskListItem,
|
||||||
mapAdminTaskSummary,
|
mapAdminTaskSummary,
|
||||||
} from './admin-task-read-helpers.js'
|
} from './admin-task-read-helpers.js'
|
||||||
|
|
||||||
import type {
|
import type {
|
||||||
AdminInventoryListResponse,
|
|
||||||
AdminInventorySkuSuggestionResponse,
|
|
||||||
AdminOrderListResponse,
|
AdminOrderListResponse,
|
||||||
AdminTaskListResponse,
|
AdminTaskListResponse,
|
||||||
AdminWebhookEventListResponse,
|
} from '../../types/admin/read-models.js'
|
||||||
} from '../../types/admin-read-models.js'
|
|
||||||
import type {
|
import type {
|
||||||
AdminEntityIdInput,
|
AdminEntityIdInput,
|
||||||
AdminInventoryListQueryInput,
|
|
||||||
AdminInventorySkuSuggestionQueryInput,
|
|
||||||
AdminOrderListQueryInput,
|
AdminOrderListQueryInput,
|
||||||
AdminTaskListQueryInput,
|
AdminTaskListQueryInput,
|
||||||
AdminViewerSessionInput,
|
AdminViewerSessionInput,
|
||||||
AdminWebhookEventListQueryInput,
|
} from '../../types/admin/read-inputs.js'
|
||||||
} from '../../types/admin-read-inputs.js'
|
|
||||||
|
|
||||||
type JsonRecord = Record<string, any>
|
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 page = normalizePage(query.page)
|
||||||
const pageSize = normalizePageSize(query.pageSize)
|
const pageSize = normalizePageSize(query.pageSize)
|
||||||
const { items, total } = await listOrders({
|
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),
|
listOrderItemsByOrderId(order.id),
|
||||||
listTasksByOrderId(order.id),
|
listTasksByOrderId(order.id),
|
||||||
listWebhookEventsByOrderId(order.id),
|
|
||||||
])
|
])
|
||||||
const taskBindingSummaryMap = await getTaskBindingSummaryMap(tasks.map((task) => task.id))
|
|
||||||
const itemSummary = summarizeOrderItems(items)
|
const itemSummary = summarizeOrderItems(items)
|
||||||
|
|
||||||
return {
|
return {
|
||||||
order: {
|
order: {
|
||||||
orderId: order.id,
|
orderId: order.id,
|
||||||
provider: order.provider || 'agiso',
|
provider: order.provider || '',
|
||||||
platform: order.platform,
|
platform: order.platform,
|
||||||
shopId: order.shop_id || '',
|
shopId: order.shop_id || '',
|
||||||
shopName: resolveDisplayShopName(order.provider, order.shop_id, order.shop_name),
|
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,
|
createdAt: order.created_at,
|
||||||
updatedAt: order.updated_at,
|
updatedAt: order.updated_at,
|
||||||
itemSummary,
|
itemSummary,
|
||||||
rawPayload: order.raw_payload_json && typeof order.raw_payload_json === 'object'
|
rawPayload:
|
||||||
? order.raw_payload_json
|
order.raw_payload_json && typeof order.raw_payload_json === 'object'
|
||||||
: JSON.parse(String(order.raw_payload_json || '{}')),
|
? order.raw_payload_json
|
||||||
bindingSummary: buildOrderBindingSummary(tasks, taskBindingSummaryMap),
|
: JSON.parse(String(order.raw_payload_json || '{}')),
|
||||||
agisoAutoDelivery: buildOrderAgisoAutoDeliverySummary(order, tasks),
|
fulfillmentProgress: buildOrderFulfillmentProgress(tasks),
|
||||||
},
|
},
|
||||||
items: items.map((item) => ({
|
items: items.map((item) => ({
|
||||||
orderItemId: item.id,
|
orderItemId: item.id,
|
||||||
@@ -140,24 +131,12 @@ export async function getAdminOrderDetail(orderId: AdminEntityIdInput): Promise<
|
|||||||
itemTitle: resolveOrderItemTitle(item),
|
itemTitle: resolveOrderItemTitle(item),
|
||||||
quantity: item.quantity,
|
quantity: item.quantity,
|
||||||
deliveryMode: resolveOrderItemDeliveryMode(tasks, item.id),
|
deliveryMode: resolveOrderItemDeliveryMode(tasks, item.id),
|
||||||
spec: item.spec_json && typeof item.spec_json === 'object'
|
spec:
|
||||||
? item.spec_json
|
item.spec_json && typeof item.spec_json === 'object'
|
||||||
: JSON.parse(String(item.spec_json || '{}')),
|
? 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),
|
dateFrom: normalizeDateQuery(query.dateFrom),
|
||||||
dateTo: normalizeDateQuery(query.dateTo, true),
|
dateTo: normalizeDateQuery(query.dateTo, true),
|
||||||
})
|
})
|
||||||
const taskBindingSummaryMap = await getTaskBindingSummaryMap(items.map((item) => item.id))
|
|
||||||
const viewerContext = createAdminViewerContext(session)
|
const viewerContext = createAdminViewerContext(session)
|
||||||
|
|
||||||
return {
|
return {
|
||||||
items: items.map((item) => mapAdminTaskListItem(
|
items: items.map((item) => mapAdminTaskListItem(item, viewerContext)),
|
||||||
item,
|
|
||||||
getTaskBindingSummary(taskBindingSummaryMap, item.id),
|
|
||||||
viewerContext,
|
|
||||||
)),
|
|
||||||
pagination: { page, pageSize, total },
|
pagination: { page, pageSize, total },
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -204,76 +178,68 @@ 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),
|
getOrderById(task.order_id),
|
||||||
getTaskPrimaryClaimTokenId(task) ? getClaimTokenById(getTaskPrimaryClaimTokenId(task)) : Promise.resolve(null),
|
primaryClaimTokenId ? getClaimTokenById(primaryClaimTokenId) : Promise.resolve(null),
|
||||||
getTaskPrimaryInventoryItemId(task) ? getInventoryItemById(getTaskPrimaryInventoryItemId(task)) : Promise.resolve(null),
|
|
||||||
listTaskEventsByTaskId(task.id),
|
listTaskEventsByTaskId(task.id),
|
||||||
listTaskInventoryBindingsByTaskId(task.id),
|
|
||||||
])
|
])
|
||||||
const orderItems = order ? await listOrderItemsByOrderId(order.id) : []
|
const orderItems = order ? await listOrderItemsByOrderId(order.id) : []
|
||||||
const orderItem = orderItems.find((item) => item.id === task.order_item_id) || null
|
const orderItem = orderItems.find((item) => item.id === task.order_item_id) || null
|
||||||
const taskContext = parseTaskContext(task)
|
const taskContext = parseTaskContext(task)
|
||||||
const taskState = parseTaskState(task)
|
const taskState = parseTaskState(task)
|
||||||
const taskBindingSummary = createTaskBindingSummaryFromBindings(inventoryBindings)
|
|
||||||
const viewerContext = createAdminViewerContext(session)
|
const viewerContext = createAdminViewerContext(session)
|
||||||
const mappedInventoryBindings = inventoryBindings.map((binding) => mapAdminTaskInventoryBinding(binding, task, viewerContext))
|
|
||||||
const claimUrl = claimToken ? buildClaimUrl(claimToken.token) : ''
|
const claimUrl = claimToken ? buildClaimUrl(claimToken.token) : ''
|
||||||
const screenshotUrl = await resolveAdminTaskScreenshotUrl(task, viewerContext)
|
const screenshotUrl = await resolveAdminTaskScreenshotUrl(task, viewerContext)
|
||||||
const kuaishouCloudFulfillment = mapKuaishouCloudFulfillmentContext(taskContext.kuaishouCloudFulfillment)
|
const cloudSourceLabelMap = buildCloudSourceLabelMap()
|
||||||
|
const kuaishouCloudFulfillment =
|
||||||
|
mapKuaishouCloudFulfillmentContext(taskContext.kuaishouCloudFulfillment, {
|
||||||
|
cloudSourceLabelMap,
|
||||||
|
}) || mapKuaishouCloudTaskStateProjection(task, { cloudSourceLabelMap })
|
||||||
|
|
||||||
return {
|
return {
|
||||||
task: mapAdminTaskListItem({
|
task: mapAdminTaskListItem(
|
||||||
...task,
|
{
|
||||||
sku_code: orderItem?.sku_code || '',
|
...task,
|
||||||
sku_name: orderItem?.sku_name || '',
|
sku_code: orderItem?.sku_code || '',
|
||||||
inventory_display_value: inventoryItem?.display_value || '',
|
sku_name: orderItem?.sku_name || '',
|
||||||
claim_token: claimToken?.token || '',
|
claim_token: claimToken?.token || '',
|
||||||
}, taskBindingSummary, viewerContext),
|
},
|
||||||
|
viewerContext,
|
||||||
|
),
|
||||||
order: order
|
order: order
|
||||||
? {
|
? {
|
||||||
orderId: order.id,
|
orderId: order.id,
|
||||||
provider: order.provider || 'agiso',
|
provider: order.provider || '',
|
||||||
platform: order.platform,
|
platform: order.platform,
|
||||||
shopId: order.shop_id || '',
|
shopId: order.shop_id || '',
|
||||||
shopName: order.shop_name || '',
|
shopName: order.shop_name || '',
|
||||||
platformOrderId: order.platform_order_id,
|
platformOrderId: order.platform_order_id,
|
||||||
payStatus: order.pay_status,
|
payStatus: order.pay_status,
|
||||||
orderStatus: order.order_status,
|
orderStatus: order.order_status,
|
||||||
}
|
}
|
||||||
: null,
|
: null,
|
||||||
orderItem: orderItem
|
orderItem: orderItem
|
||||||
? {
|
? {
|
||||||
orderItemId: orderItem.id,
|
orderItemId: orderItem.id,
|
||||||
skuCode: orderItem.sku_code,
|
skuCode: orderItem.sku_code,
|
||||||
skuName: orderItem.sku_name,
|
skuName: orderItem.sku_name,
|
||||||
quantity: orderItem.quantity,
|
quantity: orderItem.quantity,
|
||||||
}
|
}
|
||||||
: null,
|
: null,
|
||||||
claimToken: claimToken
|
claimToken: claimToken
|
||||||
? {
|
? {
|
||||||
primaryClaimTokenId: claimToken.id,
|
primaryClaimTokenId: claimToken.id,
|
||||||
token: viewerContext.canViewSensitiveTaskData ? claimToken.token : '',
|
token: viewerContext.canViewSensitiveTaskData ? claimToken.token : '',
|
||||||
status: claimToken.status,
|
status: claimToken.status,
|
||||||
expiredAt: claimToken.expired_at,
|
expiredAt: claimToken.expired_at,
|
||||||
claimUrl,
|
claimUrl,
|
||||||
}
|
}
|
||||||
: null,
|
: 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) : {},
|
artifacts: viewerContext.canViewSensitiveTaskData ? safeParseJson(task.artifacts_json) : {},
|
||||||
screenshotUrl,
|
screenshotUrl,
|
||||||
review: {
|
review: {
|
||||||
required: isAssistedClaimTask(task),
|
required: false,
|
||||||
screenshotCapturedAt: String(taskState.reviewCapturedAt || '').trim() || null,
|
screenshotCapturedAt: String(taskState.reviewCapturedAt || '').trim() || null,
|
||||||
roleId: String(taskState.reviewRoleId || '').trim() || '',
|
roleId: String(taskState.reviewRoleId || '').trim() || '',
|
||||||
roleName: String(taskState.reviewRoleName || '').trim() || '',
|
roleName: String(taskState.reviewRoleName || '').trim() || '',
|
||||||
@@ -283,35 +249,61 @@ export async function getAdminTaskDetail(
|
|||||||
manualDispatch: mapManualDispatchContext(taskContext.manualDispatch, viewerContext),
|
manualDispatch: mapManualDispatchContext(taskContext.manualDispatch, viewerContext),
|
||||||
events: taskEvents.map(mapAdminTaskEvent),
|
events: taskEvents.map(mapAdminTaskEvent),
|
||||||
operations: {
|
operations: {
|
||||||
canRetry: viewerContext.canManageTaskLifecycle
|
canRetry:
|
||||||
&& !isManualDispatchTask(task)
|
viewerContext.canManageTaskLifecycle &&
|
||||||
&& ['retry_pending', 'manual_review', 'waiting_inventory'].includes(task.task_status),
|
!isManualDispatchTask(task) &&
|
||||||
canReleaseInventory: mappedInventoryBindings.some((binding) => binding.canRelease),
|
canRetryTaskStatus(task.task_status),
|
||||||
canRegenerateClaimLink: canRegenerateClaimLinkForViewer(task, viewerContext),
|
canRegenerateClaimLink: canRegenerateClaimLinkForViewer(task, viewerContext),
|
||||||
canClose: canViewerCloseTask(task, viewerContext),
|
canClose: canViewerCloseTask(task, viewerContext),
|
||||||
canMarkManualReview: viewerContext.canManageTaskLifecycle && !['redeemed', 'closed', 'manual_review'].includes(task.task_status),
|
canMarkManualReview:
|
||||||
canCompleteManualDispatch: viewerContext.canManageTaskLifecycle && isManualDispatchTask(task) && !['redeemed', 'closed'].includes(task.task_status),
|
viewerContext.canManageTaskLifecycle && canMarkManualReviewTaskStatus(task.task_status),
|
||||||
canPrepareKuaishouCloudFulfillment: viewerContext.canManageTaskLifecycle
|
canCompleteManualDispatch:
|
||||||
&& String(task.executor_key || '').trim() === 'kuaishou_ct_assisted'
|
viewerContext.canManageTaskLifecycle &&
|
||||||
&& String(kuaishouCloudFulfillment?.dispatch.status || 'pending').trim() === 'pending'
|
isManualDispatchTask(task) &&
|
||||||
&& String(kuaishouCloudFulfillment?.returnNumber.status || 'pending').trim() === 'pending'
|
canCompleteManualDispatchTaskStatus(task.task_status),
|
||||||
&& ['pending_binding_prepare', 'manual_review', 'failed'].includes(String(task.task_status || '').trim()),
|
canPrepareKuaishouCloudFulfillment:
|
||||||
canRefreshKuaishouCloudRoleInfo: viewerContext.canOperateAssistedTask
|
viewerContext.canManageTaskLifecycle &&
|
||||||
&& String(task.executor_key || '').trim() === 'kuaishou_ct_assisted'
|
String(task.executor_key || '').trim() === 'kuaishou_ct_assisted' &&
|
||||||
&& Number(kuaishouCloudFulfillment?.binding.vnId || 0) > 0,
|
String(kuaishouCloudFulfillment?.dispatch.status || 'pending').trim() === 'pending' &&
|
||||||
canDispatchKuaishouCloudFulfillment: viewerContext.canManageTaskLifecycle
|
String(kuaishouCloudFulfillment?.returnNumber.status || 'pending').trim() === 'pending' &&
|
||||||
&& String(task.executor_key || '').trim() === 'kuaishou_ct_assisted'
|
canPrepareKuaishouCloudFulfillmentStatus(task.task_status),
|
||||||
&& ['waiting_binding'].includes(String(task.task_status || '').trim()),
|
canRefreshKuaishouCloudRoleInfo:
|
||||||
canReturnKuaishouCloudFulfillment: viewerContext.canManageTaskLifecycle
|
viewerContext.canOperateAssistedTask &&
|
||||||
&& String(task.executor_key || '').trim() === 'kuaishou_ct_assisted'
|
String(task.executor_key || '').trim() === 'kuaishou_ct_assisted' &&
|
||||||
&& ['dispatched_pending_return'].includes(String(task.task_status || '').trim()),
|
Number(kuaishouCloudFulfillment?.binding.vnId || 0) > 0,
|
||||||
canSupportConfirmRole: canViewerConfirmAssistedRole(task, viewerContext),
|
canRebindKuaishouCloudRole:
|
||||||
canSupportRedeem: canViewerRedeemAssistedTask(task, viewerContext),
|
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,
|
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(
|
export async function getAdminTaskScreenshotPath(
|
||||||
taskId: AdminEntityIdInput,
|
taskId: AdminEntityIdInput,
|
||||||
session: AdminViewerSessionInput | null = null,
|
session: AdminViewerSessionInput | null = null,
|
||||||
@@ -322,105 +314,8 @@ export async function getAdminTaskScreenshotPath(
|
|||||||
return task.screenshot_path
|
return task.screenshot_path
|
||||||
}
|
}
|
||||||
|
|
||||||
if (task.browser_session_id) {
|
|
||||||
return getTencentBrowserSessionReviewScreenshotPath(task.browser_session_id)
|
|
||||||
}
|
|
||||||
|
|
||||||
throw createHttpError('当前任务还没有可查看截图', {
|
throw createHttpError('当前任务还没有可查看截图', {
|
||||||
statusCode: 404,
|
statusCode: 404,
|
||||||
errorCode: 'admin_task_screenshot_not_found',
|
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 assert from 'node:assert/strict'
|
||||||
|
|
||||||
import {
|
import {
|
||||||
canViewerConfirmAssistedRole,
|
|
||||||
canViewerCloseTask,
|
canViewerCloseTask,
|
||||||
createAdminViewerContext,
|
createAdminViewerContext,
|
||||||
|
mapKuaishouCloudFulfillmentContext,
|
||||||
resolveAdminTaskScreenshotUrl,
|
resolveAdminTaskScreenshotUrl,
|
||||||
} from './admin-read-shared-helpers.js'
|
} from './admin-read-shared-helpers.js'
|
||||||
|
|
||||||
@@ -12,7 +12,7 @@ test('resolveAdminTaskScreenshotUrl lets support view final redeemed screenshot'
|
|||||||
const task = {
|
const task = {
|
||||||
id: 12,
|
id: 12,
|
||||||
screenshot_path: '/tmp/redeemed-12.png',
|
screenshot_path: '/tmp/redeemed-12.png',
|
||||||
browser_session_id: '',
|
runtime_session_id: '',
|
||||||
}
|
}
|
||||||
|
|
||||||
const screenshotUrl = await resolveAdminTaskScreenshotUrl(task, createAdminViewerContext({ role: 'support' }))
|
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')
|
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 = {
|
const task = {
|
||||||
id: 13,
|
id: 13,
|
||||||
screenshot_path: '',
|
screenshot_path: '',
|
||||||
browser_session_id: 'browser-session-13',
|
runtime_session_id: 'runtime-session-13',
|
||||||
}
|
}
|
||||||
|
|
||||||
const screenshotUrl = await resolveAdminTaskScreenshotUrl(task, createAdminViewerContext({ role: 'support' }))
|
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)
|
assert.equal(canViewerCloseTask({ task_status: 'redeemed' }, viewerContext), false)
|
||||||
})
|
})
|
||||||
|
|
||||||
test('canViewerConfirmAssistedRole blocks support when task inventory group is not bound', () => {
|
test('mapKuaishouCloudFulfillmentContext exposes cloud account display labels', () => {
|
||||||
const viewerContext = createAdminViewerContext({
|
const flow = mapKuaishouCloudFulfillmentContext(
|
||||||
role: 'support',
|
{
|
||||||
allowedInventoryGroups: ['A组'],
|
binding: {
|
||||||
})
|
cloudSourceKeys: [' account_a ', 'account_b'],
|
||||||
|
resolvedSourceKey: 'account_b',
|
||||||
assert.equal(canViewerConfirmAssistedRole({
|
},
|
||||||
task_status: 'claimed',
|
|
||||||
executor_key: 'tencent_claim_assisted',
|
|
||||||
context_json: {
|
|
||||||
inventoryGroupCode: '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 { safeParseJson } from './admin-query-utils.js'
|
||||||
import { normalizeAdminRole } from './admin-auth-service.js'
|
import { normalizeAdminRole } from './admin-auth-service.js'
|
||||||
import {
|
import {
|
||||||
parseTaskContext as parseTaskContextValue,
|
parseTaskContext as parseTaskContextValue,
|
||||||
parseTaskState as parseTaskStateValue,
|
parseTaskState as parseTaskStateValue,
|
||||||
} from '../../utils/task-json.js'
|
} from '../../utils/task-json.js'
|
||||||
|
import { canRegenerateClaimLinkStatus, isTaskFinalStatus } from '../../domain/task-status.js'
|
||||||
|
|
||||||
import type { AdminViewerSessionInput } from '../../types/admin-read-inputs.js'
|
import type { AdminViewerSessionInput } from '../../types/admin/read-inputs.js'
|
||||||
import type { OrderItemRow, TaskRow } from '../../types/repository-rows.js'
|
import type { OrderItemRow, TaskRow } from '../../types/repository/rows.js'
|
||||||
|
|
||||||
type JsonRecord = Record<string, any>
|
type JsonRecord = Record<string, any>
|
||||||
|
type CloudSourceLabelMap = Map<string, string> | Record<string, string>
|
||||||
|
|
||||||
|
type KuaishouCloudFulfillmentMapOptions = {
|
||||||
|
cloudSourceLabelMap?: CloudSourceLabelMap
|
||||||
|
}
|
||||||
|
|
||||||
type TaskLike = Partial<TaskRow> & {
|
type TaskLike = Partial<TaskRow> & {
|
||||||
state_json?: string | JsonRecord
|
state_json?: string | JsonRecord
|
||||||
@@ -17,17 +22,13 @@ type TaskLike = Partial<TaskRow> & {
|
|||||||
|
|
||||||
export type AdminViewerContext = {
|
export type AdminViewerContext = {
|
||||||
role: string
|
role: string
|
||||||
allowedInventoryGroupCodes: string[] | null
|
|
||||||
restrictInventoryGroups: boolean
|
|
||||||
canViewSensitiveTaskData: boolean
|
canViewSensitiveTaskData: boolean
|
||||||
canManageSensitiveInventory: boolean
|
|
||||||
canManageTaskLifecycle: boolean
|
canManageTaskLifecycle: boolean
|
||||||
canOperateAssistedTask: boolean
|
canOperateAssistedTask: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
type RedeemResolutionAttempt = {
|
type RedeemResolutionAttempt = {
|
||||||
attempt: number
|
attempt: number
|
||||||
inventoryItemId: number | null
|
|
||||||
codeMasked: string
|
codeMasked: string
|
||||||
credentialType: string
|
credentialType: string
|
||||||
outcome: string
|
outcome: string
|
||||||
@@ -47,20 +48,26 @@ export function mapManualDispatchContext(
|
|||||||
return {
|
return {
|
||||||
outcome: String(record.outcome || '').trim(),
|
outcome: String(record.outcome || '').trim(),
|
||||||
deliveryReference: String(record.deliveryReference || '').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(),
|
resultMessage: String(record.resultMessage || '').trim(),
|
||||||
completedAt: record.completedAt || null,
|
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(),
|
userId: Number(record.completedBy.userId || 0) || 0,
|
||||||
role: String(record.completedBy.role || '').trim(),
|
username: String(record.completedBy.username || '').trim(),
|
||||||
}
|
role: String(record.completedBy.role || '').trim(),
|
||||||
: null,
|
}
|
||||||
|
: null,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export function mapKuaishouCloudFulfillmentContext(value: unknown): JsonRecord | null {
|
export function mapKuaishouCloudFulfillmentContext(
|
||||||
|
value: unknown,
|
||||||
|
options: KuaishouCloudFulfillmentMapOptions = {},
|
||||||
|
): JsonRecord | null {
|
||||||
if (!value || typeof value !== 'object') {
|
if (!value || typeof value !== 'object') {
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
@@ -71,8 +78,13 @@ export function mapKuaishouCloudFulfillmentContext(value: unknown): JsonRecord |
|
|||||||
const role = record.role && typeof record.role === 'object' ? record.role : {}
|
const role = record.role && typeof record.role === 'object' ? record.role : {}
|
||||||
const purchase = record.purchase && typeof record.purchase === 'object' ? record.purchase : {}
|
const purchase = record.purchase && typeof record.purchase === 'object' ? record.purchase : {}
|
||||||
const dispatch = record.dispatch && typeof record.dispatch === 'object' ? record.dispatch : {}
|
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 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 {
|
return {
|
||||||
flowType: String(record.flowType || '').trim(),
|
flowType: String(record.flowType || '').trim(),
|
||||||
@@ -89,11 +101,14 @@ export function mapKuaishouCloudFulfillmentContext(value: unknown): JsonRecord |
|
|||||||
},
|
},
|
||||||
binding: {
|
binding: {
|
||||||
prepareStatus: String(binding.prepareStatus || 'pending').trim() || 'pending',
|
prepareStatus: String(binding.prepareStatus || 'pending').trim() || 'pending',
|
||||||
cloudSourceKey: String(binding.cloudSourceKey || '').trim(),
|
cloudSourceKeys,
|
||||||
cloudSourceKeyFallbacks: Array.isArray(binding.cloudSourceKeyFallbacks)
|
cloudSourceLabels: cloudSourceKeys.map((sourceKey: string) =>
|
||||||
? binding.cloudSourceKeyFallbacks.map((value) => String(value || '').trim()).filter(Boolean)
|
resolveCloudSourceLabel(sourceKey, options.cloudSourceLabelMap),
|
||||||
: [],
|
),
|
||||||
resolvedSourceKey: String(binding.resolvedSourceKey || '').trim(),
|
resolvedSourceKey,
|
||||||
|
resolvedSourceLabel: resolvedSourceKey
|
||||||
|
? resolveCloudSourceLabel(resolvedSourceKey, options.cloudSourceLabelMap)
|
||||||
|
: '',
|
||||||
skuId: Number(binding.skuId || 0) || 0,
|
skuId: Number(binding.skuId || 0) || 0,
|
||||||
skuName: String(binding.skuName || '').trim(),
|
skuName: String(binding.skuName || '').trim(),
|
||||||
vnKey: String(binding.vnKey || '').trim(),
|
vnKey: String(binding.vnKey || '').trim(),
|
||||||
@@ -142,10 +157,183 @@ export function mapKuaishouCloudFulfillmentContext(value: unknown): JsonRecord |
|
|||||||
consumedAt: consume.consumedAt || null,
|
consumedAt: consume.consumedAt || null,
|
||||||
errorMessage: String(consume.errorMessage || '').trim(),
|
errorMessage: String(consume.errorMessage || '').trim(),
|
||||||
},
|
},
|
||||||
|
rebind: mapKuaishouCloudRebindContext(record.rebind),
|
||||||
notes: String(record.notes || '').trim(),
|
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 {
|
export function mapRedeemResolutionContext(value: unknown): JsonRecord | null {
|
||||||
if (!value || typeof value !== 'object') {
|
if (!value || typeof value !== 'object') {
|
||||||
return null
|
return null
|
||||||
@@ -154,8 +342,8 @@ export function mapRedeemResolutionContext(value: unknown): JsonRecord | null {
|
|||||||
const record = value as JsonRecord
|
const record = value as JsonRecord
|
||||||
const attempts = Array.isArray(record.attempts)
|
const attempts = Array.isArray(record.attempts)
|
||||||
? record.attempts
|
? record.attempts
|
||||||
.map((attempt) => mapRedeemResolutionAttempt(attempt))
|
.map((attempt) => mapRedeemResolutionAttempt(attempt))
|
||||||
.filter((attempt): attempt is RedeemResolutionAttempt => Boolean(attempt))
|
.filter((attempt): attempt is RedeemResolutionAttempt => Boolean(attempt))
|
||||||
: []
|
: []
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@@ -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 {
|
export function getTaskPrimaryClaimTokenId(task: TaskLike | null | undefined): number | null {
|
||||||
const value = Number(task?.primary_claim_token_id || 0)
|
const value = Number(task?.primary_claim_token_id || 0)
|
||||||
return Number.isFinite(value) && value > 0 ? value : null
|
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 role = normalizeAdminRole(session?.role)
|
||||||
const allowedInventoryGroupCodes = role === 'support'
|
|
||||||
? normalizeInventoryGroupCodes(session?.allowedInventoryGroups)
|
|
||||||
: null
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
role,
|
role,
|
||||||
allowedInventoryGroupCodes,
|
|
||||||
restrictInventoryGroups: role === 'support',
|
|
||||||
canViewSensitiveTaskData: role === 'admin' || role === 'operator',
|
canViewSensitiveTaskData: role === 'admin' || role === 'operator',
|
||||||
canManageSensitiveInventory: role === 'admin',
|
|
||||||
canManageTaskLifecycle: role === 'admin' || role === 'operator',
|
canManageTaskLifecycle: role === 'admin' || role === 'operator',
|
||||||
canOperateAssistedTask: role === 'admin' || role === 'operator' || role === 'support',
|
canOperateAssistedTask: role === 'admin' || role === 'operator' || role === 'support',
|
||||||
}
|
}
|
||||||
@@ -198,7 +377,7 @@ export async function resolveAdminTaskScreenshotUrl(
|
|||||||
task: TaskLike,
|
task: TaskLike,
|
||||||
viewerContext: AdminViewerContext,
|
viewerContext: AdminViewerContext,
|
||||||
): Promise<string> {
|
): 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`
|
return `/api/v1/admin/tasks/${task.id}/screenshot`
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -210,10 +389,14 @@ export function canViewerCloseTask(task: TaskLike, viewerContext: AdminViewerCon
|
|||||||
return false
|
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()
|
const normalizedShopName = String(shopName || '').trim()
|
||||||
if (normalizedShopName) {
|
if (normalizedShopName) {
|
||||||
return normalizedShopName
|
return normalizedShopName
|
||||||
@@ -224,13 +407,6 @@ export function resolveDisplayShopName(provider: unknown, shopId: unknown, shopN
|
|||||||
return ''
|
return ''
|
||||||
}
|
}
|
||||||
|
|
||||||
if (String(provider || '').trim().toLowerCase() === 'agiso') {
|
|
||||||
const configuredName = String(getAgisoShopConfig(normalizedShopId)?.shopName || '').trim()
|
|
||||||
if (configuredName) {
|
|
||||||
return configuredName
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return normalizedShopId
|
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 {
|
export function resolveOrderItemDeliveryMode(
|
||||||
const task = (Array.isArray(tasks) ? tasks : []).find((item) => item.order_item_id === orderItemId)
|
tasks: TaskLike[] | null | undefined,
|
||||||
|
orderItemId: number | string,
|
||||||
|
): string {
|
||||||
|
const task = (Array.isArray(tasks) ? tasks : []).find(
|
||||||
|
(item) => item.order_item_id === orderItemId,
|
||||||
|
)
|
||||||
|
|
||||||
if (!task) {
|
if (!task) {
|
||||||
return ''
|
return ''
|
||||||
@@ -276,7 +457,12 @@ export function resolveOrderItemDeliveryMode(tasks: TaskLike[] | null | undefine
|
|||||||
return 'kuaishou_cloud'
|
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'
|
return 'claim_link'
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -287,16 +473,15 @@ export function isManualDispatchTask(task: TaskLike | null | undefined): boolean
|
|||||||
return String(task?.executor_key || '').trim() === 'manual_dispatch'
|
return String(task?.executor_key || '').trim() === 'manual_dispatch'
|
||||||
}
|
}
|
||||||
|
|
||||||
export function isAssistedClaimTask(task: TaskLike | null | undefined): boolean {
|
export function canRegenerateClaimLinkForViewer(
|
||||||
return String(task?.executor_key || '').trim() === 'tencent_claim_assisted'
|
task: TaskLike,
|
||||||
}
|
viewerContext: AdminViewerContext,
|
||||||
|
): boolean {
|
||||||
export function canRegenerateClaimLinkForViewer(task: TaskLike, viewerContext: AdminViewerContext): boolean {
|
|
||||||
if (isManualDispatchTask(task)) {
|
if (isManualDispatchTask(task)) {
|
||||||
return false
|
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) {
|
if (!baseAllowed) {
|
||||||
return false
|
return false
|
||||||
@@ -306,57 +491,7 @@ export function canRegenerateClaimLinkForViewer(task: TaskLike, viewerContext: A
|
|||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
return viewerContext.canOperateAssistedTask
|
return false
|
||||||
&& 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 {
|
function mapRedeemResolutionAttempt(value: unknown): RedeemResolutionAttempt | null {
|
||||||
@@ -367,7 +502,6 @@ function mapRedeemResolutionAttempt(value: unknown): RedeemResolutionAttempt | n
|
|||||||
const record = value as JsonRecord
|
const record = value as JsonRecord
|
||||||
return {
|
return {
|
||||||
attempt: Math.max(1, Number(record.attempt || 1)),
|
attempt: Math.max(1, Number(record.attempt || 1)),
|
||||||
inventoryItemId: Number(record.inventoryItemId || 0) || null,
|
|
||||||
codeMasked: String(record.codeMasked || '').trim(),
|
codeMasked: String(record.codeMasked || '').trim(),
|
||||||
credentialType: String(record.credentialType || '').trim(),
|
credentialType: String(record.credentialType || '').trim(),
|
||||||
outcome: String(record.outcome || '').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 { getTaskById } from '../../repositories/task-repo.js'
|
||||||
import { listTaskInventoryBindingSummariesByTaskIds } from '../../repositories/task-inventory-binding-repo.js'
|
|
||||||
import { createHttpError } from '../../utils/http.js'
|
import { createHttpError } from '../../utils/http.js'
|
||||||
import { safeParseJson } from './admin-query-utils.js'
|
import { safeParseJson } from './admin-query-utils.js'
|
||||||
|
import {
|
||||||
|
TASK_STATUS,
|
||||||
|
isTaskFulfillmentCompletedStatus,
|
||||||
|
normalizeTaskStatus,
|
||||||
|
} from '../../domain/task-status.js'
|
||||||
import {
|
import {
|
||||||
createAdminViewerContext,
|
createAdminViewerContext,
|
||||||
getTaskPrimaryClaimTokenId,
|
getTaskPrimaryClaimTokenId,
|
||||||
getTaskPrimaryInventoryItemId,
|
|
||||||
parseTaskContext,
|
|
||||||
} from './admin-read-shared-helpers.js'
|
} from './admin-read-shared-helpers.js'
|
||||||
|
|
||||||
import type {
|
import type {
|
||||||
AdminAgisoAutoDeliveryStatus,
|
AdminOrderFulfillmentProgress,
|
||||||
AdminTaskBindingSummary,
|
|
||||||
AdminTaskListItem,
|
AdminTaskListItem,
|
||||||
} from '../../types/admin-read-models.js'
|
} from '../../types/admin/read-models.js'
|
||||||
import type { AdminTaskActionPayload } from '../../types/admin-write-models.js'
|
import type { AdminTaskActionPayload } from '../../types/admin/write-models.js'
|
||||||
import type {
|
import type {
|
||||||
OrderRow,
|
|
||||||
TaskEventRow,
|
TaskEventRow,
|
||||||
TaskInventoryBindingRow,
|
|
||||||
TaskInventoryBindingSummaryRow,
|
|
||||||
TaskRow,
|
TaskRow,
|
||||||
} from '../../types/repository-rows.js'
|
} from '../../types/repository/rows.js'
|
||||||
import type { AdminViewerContext } from './admin-read-shared-helpers.js'
|
import type { AdminViewerContext } from './admin-read-shared-helpers.js'
|
||||||
|
|
||||||
type JsonRecord = Record<string, any>
|
type JsonRecord = Record<string, any>
|
||||||
|
|
||||||
type TaskBindingState = {
|
type TaskFulfillmentState = {
|
||||||
systemBindingStatus: string
|
resourceStatus: string
|
||||||
userBindingStatus: string
|
customerStatus: 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
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function mapAdminTaskSummary(
|
export function mapAdminTaskSummary(
|
||||||
task: TaskRow,
|
task: TaskRow,
|
||||||
bindingSummary: AdminTaskBindingSummary = createEmptyTaskBindingSummary(),
|
|
||||||
): JsonRecord {
|
): JsonRecord {
|
||||||
const binding = buildTaskBindingState(task)
|
const fulfillment = buildTaskFulfillmentState(task)
|
||||||
const taskContext = parseTaskContext(task)
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
taskId: task.id,
|
taskId: task.id,
|
||||||
taskNo: task.task_no,
|
taskNo: task.task_no,
|
||||||
deliveryStatus: task.delivery_status || '',
|
deliveryStatus: task.delivery_status || '',
|
||||||
status: task.task_status,
|
status: task.task_status,
|
||||||
systemBindingStatus: binding.systemBindingStatus,
|
resourceStatus: fulfillment.resourceStatus,
|
||||||
userBindingStatus: binding.userBindingStatus,
|
customerStatus: fulfillment.customerStatus,
|
||||||
loginType: task.login_type,
|
loginType: task.login_type,
|
||||||
browserSessionId: task.browser_session_id,
|
runtimeSessionId: task.runtime_session_id,
|
||||||
claimedAt: task.claimed_at,
|
claimedAt: task.claimed_at,
|
||||||
roleConfirmedAt: task.role_confirmed_at,
|
roleConfirmedAt: task.role_confirmed_at,
|
||||||
redeemedAt: task.redeemed_at,
|
redeemedAt: task.redeemed_at,
|
||||||
lastError: task.last_error,
|
lastError: task.last_error,
|
||||||
retryCount: getTaskRetryCount(task),
|
retryCount: getTaskRetryCount(task),
|
||||||
bindingSummary,
|
|
||||||
agisoAutoDelivery: mapAgisoAutoDeliveryContext(taskContext.agisoAutoDelivery),
|
|
||||||
createdAt: task.created_at,
|
createdAt: task.created_at,
|
||||||
updatedAt: task.updated_at,
|
updatedAt: task.updated_at,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export function mapTaskActionPayload(task: any): AdminTaskActionPayload {
|
export function mapTaskActionPayload(task: any): AdminTaskActionPayload {
|
||||||
const inventoryItemId = getTaskPrimaryInventoryItemId(task)
|
|
||||||
const primaryClaimTokenId = getTaskPrimaryClaimTokenId(task)
|
const primaryClaimTokenId = getTaskPrimaryClaimTokenId(task)
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@@ -98,7 +63,6 @@ export function mapTaskActionPayload(task: any): AdminTaskActionPayload {
|
|||||||
deliveryStatus: task.delivery_status,
|
deliveryStatus: task.delivery_status,
|
||||||
resultCode: task.result_code,
|
resultCode: task.result_code,
|
||||||
resultMessage: task.result_message,
|
resultMessage: task.result_message,
|
||||||
inventoryItemId,
|
|
||||||
primaryClaimTokenId,
|
primaryClaimTokenId,
|
||||||
lastError: task.last_error,
|
lastError: task.last_error,
|
||||||
updatedAt: task.updated_at,
|
updatedAt: task.updated_at,
|
||||||
@@ -120,11 +84,9 @@ export async function getRequiredTask(taskId: number | string): Promise<TaskRow>
|
|||||||
|
|
||||||
export function mapAdminTaskListItem(
|
export function mapAdminTaskListItem(
|
||||||
task: TaskRow,
|
task: TaskRow,
|
||||||
bindingSummary: AdminTaskBindingSummary = createEmptyTaskBindingSummary(),
|
|
||||||
viewerContext: AdminViewerContext = createAdminViewerContext(),
|
viewerContext: AdminViewerContext = createAdminViewerContext(),
|
||||||
): AdminTaskListItem {
|
): AdminTaskListItem {
|
||||||
const binding = buildTaskBindingState(task)
|
const fulfillment = buildTaskFulfillmentState(task)
|
||||||
const taskContext = parseTaskContext(task)
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
taskId: task.id,
|
taskId: task.id,
|
||||||
@@ -137,23 +99,19 @@ export function mapAdminTaskListItem(
|
|||||||
deliveryStatus: task.delivery_status || '',
|
deliveryStatus: task.delivery_status || '',
|
||||||
resultCode: task.result_code || '',
|
resultCode: task.result_code || '',
|
||||||
resultMessage: task.result_message || '',
|
resultMessage: task.result_message || '',
|
||||||
systemBindingStatus: binding.systemBindingStatus,
|
resourceStatus: fulfillment.resourceStatus,
|
||||||
userBindingStatus: binding.userBindingStatus,
|
customerStatus: fulfillment.customerStatus,
|
||||||
loginType: task.login_type,
|
loginType: task.login_type,
|
||||||
roleName: task.role_name,
|
roleName: resolveTaskDisplayRoleName(task),
|
||||||
roleId: task.role_id,
|
roleId: resolveTaskDisplayRoleId(task),
|
||||||
browserSessionId: task.browser_session_id,
|
runtimeSessionId: task.runtime_session_id,
|
||||||
claimedAt: task.claimed_at,
|
claimedAt: task.claimed_at,
|
||||||
roleConfirmedAt: task.role_confirmed_at,
|
roleConfirmedAt: task.role_confirmed_at,
|
||||||
redeemedAt: task.redeemed_at,
|
redeemedAt: task.redeemed_at,
|
||||||
retryCount: getTaskRetryCount(task),
|
retryCount: getTaskRetryCount(task),
|
||||||
bindingSummary,
|
|
||||||
agisoAutoDelivery: mapAgisoAutoDeliveryContext(taskContext.agisoAutoDelivery),
|
|
||||||
lastError: task.last_error,
|
lastError: task.last_error,
|
||||||
createdAt: task.created_at,
|
createdAt: task.created_at,
|
||||||
updatedAt: task.updated_at,
|
updatedAt: task.updated_at,
|
||||||
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 || '') : '',
|
claimToken: viewerContext.canViewSensitiveTaskData ? (task.primary_claim_token || task.claim_token || '') : '',
|
||||||
screenshotPath: viewerContext.role === 'support' ? '' : (task.screenshot_path || ''),
|
screenshotPath: viewerContext.role === 'support' ? '' : (task.screenshot_path || ''),
|
||||||
}
|
}
|
||||||
@@ -170,338 +128,152 @@ export function mapAdminTaskEvent(event: TaskEventRow): JsonRecord {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export function createEmptyTaskBindingSummary(): AdminTaskBindingSummary {
|
export function buildOrderFulfillmentProgress(
|
||||||
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(
|
|
||||||
tasks: TaskRow[] | null | undefined,
|
tasks: TaskRow[] | null | undefined,
|
||||||
taskBindingSummaryMap: Map<number, AdminTaskBindingSummary> = new Map(),
|
): AdminOrderFulfillmentProgress {
|
||||||
): OrderBindingSummary {
|
|
||||||
const normalizedTasks = Array.isArray(tasks) ? tasks : []
|
const normalizedTasks = Array.isArray(tasks) ? tasks : []
|
||||||
const totalTaskCount = normalizedTasks.length
|
const totalTaskCount = normalizedTasks.length
|
||||||
const taskBindings = normalizedTasks.map((task) => buildTaskBindingState(task))
|
const taskFulfillments = normalizedTasks.map((task) => buildTaskFulfillmentState(task))
|
||||||
const systemBoundTaskCount = normalizedTasks.filter((task) => isTaskSystemBound(task)).length
|
const preparedTaskCount = normalizedTasks.filter((task) => isTaskResourcePrepared(task)).length
|
||||||
const completedBindingTaskCount = normalizedTasks.filter((task) => String(task?.task_status || '') === 'redeemed').length
|
const completedTaskCount = normalizedTasks.filter((task) => isTaskFulfillmentCompleted(task)).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)
|
|
||||||
|
|
||||||
let systemBindingStatus = 'pending_binding'
|
let resourceStatus = 'pending_prepare'
|
||||||
let userBindingStatus = 'not_started'
|
let customerStatus = 'not_started'
|
||||||
|
|
||||||
if (totalTaskCount === 0) {
|
if (totalTaskCount === 0) {
|
||||||
return {
|
return {
|
||||||
totalTaskCount,
|
totalTaskCount,
|
||||||
systemBoundTaskCount,
|
preparedTaskCount,
|
||||||
completedBindingTaskCount,
|
completedTaskCount,
|
||||||
totalBindingCount,
|
resourceStatus,
|
||||||
reservedBindingCount,
|
customerStatus,
|
||||||
consumedBindingCount,
|
|
||||||
releasedBindingCount,
|
|
||||||
systemBindingStatus,
|
|
||||||
userBindingStatus,
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (normalizedTasks.every((task) => String(task.task_status || '') === 'redeemed')) {
|
if (normalizedTasks.every((task) => isTaskFulfillmentCompleted(task))) {
|
||||||
systemBindingStatus = 'system_bound'
|
resourceStatus = 'resource_ready'
|
||||||
userBindingStatus = 'binding_completed'
|
customerStatus = 'customer_completed'
|
||||||
} else if (taskBindings.some((item) => ['binding_in_progress', 'binding_confirmed', 'link_opened'].includes(item.userBindingStatus))) {
|
} else if (taskFulfillments.some((item) => ['customer_processing', 'customer_confirmed', 'link_opened'].includes(item.customerStatus))) {
|
||||||
systemBindingStatus = systemBoundTaskCount > 0 ? 'system_bound' : 'pending_binding'
|
resourceStatus = preparedTaskCount > 0 ? 'resource_ready' : 'pending_prepare'
|
||||||
userBindingStatus = 'user_binding'
|
customerStatus = 'customer_processing'
|
||||||
} else if (taskBindings.some((item) => item.userBindingStatus === 'waiting_user_claim')) {
|
} else if (taskFulfillments.some((item) => item.customerStatus === 'waiting_customer')) {
|
||||||
systemBindingStatus = systemBoundTaskCount > 0 ? 'system_bound' : 'pending_binding'
|
resourceStatus = preparedTaskCount > 0 ? 'resource_ready' : 'pending_prepare'
|
||||||
userBindingStatus = 'waiting_user_claim'
|
customerStatus = 'waiting_customer'
|
||||||
} else if (taskBindings.some((item) => item.userBindingStatus === 'binding_exception')) {
|
} else if (taskFulfillments.some((item) => item.customerStatus === 'customer_exception')) {
|
||||||
systemBindingStatus = systemBoundTaskCount > 0 ? 'system_bound' : 'pending_binding'
|
resourceStatus = preparedTaskCount > 0 ? 'resource_ready' : 'pending_prepare'
|
||||||
userBindingStatus = 'binding_exception'
|
customerStatus = 'customer_exception'
|
||||||
} else if (systemBoundTaskCount > 0) {
|
} else if (preparedTaskCount > 0) {
|
||||||
systemBindingStatus = 'system_bound'
|
resourceStatus = 'resource_ready'
|
||||||
} else if (taskBindings.some((item) => ['manual_review', 'retry_pending', 'waiting_inventory'].includes(item.systemBindingStatus))) {
|
} else if (taskFulfillments.some((item) => ['manual_review', 'retry_pending'].includes(item.resourceStatus))) {
|
||||||
systemBindingStatus = 'binding_exception'
|
resourceStatus = 'resource_exception'
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
totalTaskCount,
|
totalTaskCount,
|
||||||
systemBoundTaskCount,
|
preparedTaskCount,
|
||||||
completedBindingTaskCount,
|
completedTaskCount,
|
||||||
totalBindingCount,
|
resourceStatus,
|
||||||
reservedBindingCount,
|
customerStatus,
|
||||||
consumedBindingCount,
|
|
||||||
releasedBindingCount,
|
|
||||||
systemBindingStatus,
|
|
||||||
userBindingStatus,
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export function buildOrderAgisoAutoDeliverySummary(
|
function buildTaskFulfillmentState(task: TaskRow | null | undefined): TaskFulfillmentState {
|
||||||
order: Partial<OrderRow> | null | undefined,
|
const normalizedStatus = normalizeTaskStatus(task?.task_status)
|
||||||
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()
|
|
||||||
|
|
||||||
if (!normalizedStatus) {
|
if (!normalizedStatus) {
|
||||||
return {
|
return {
|
||||||
systemBindingStatus: 'pending_binding',
|
resourceStatus: 'pending_prepare',
|
||||||
userBindingStatus: 'not_started',
|
customerStatus: 'not_started',
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (normalizedStatus === 'pending_payment') {
|
if (normalizedStatus === TASK_STATUS.PENDING_PAYMENT) {
|
||||||
return { systemBindingStatus: 'pending_payment', userBindingStatus: 'not_started' }
|
return { resourceStatus: 'pending_payment', customerStatus: 'not_started' }
|
||||||
}
|
}
|
||||||
|
|
||||||
if (normalizedStatus === 'paid') {
|
if (normalizedStatus === TASK_STATUS.PAID) {
|
||||||
return { systemBindingStatus: 'pending_binding', userBindingStatus: 'not_started' }
|
return { resourceStatus: 'pending_prepare', customerStatus: 'not_started' }
|
||||||
}
|
}
|
||||||
|
|
||||||
if (normalizedStatus === 'waiting_inventory') {
|
if (normalizedStatus === TASK_STATUS.PENDING_BINDING_PREPARE) {
|
||||||
return { systemBindingStatus: 'waiting_inventory', userBindingStatus: 'not_started' }
|
return { resourceStatus: 'pending_prepare', customerStatus: 'not_started' }
|
||||||
}
|
}
|
||||||
|
|
||||||
if (normalizedStatus === 'pending_binding_prepare') {
|
if (normalizedStatus === TASK_STATUS.WAITING_BINDING) {
|
||||||
return { systemBindingStatus: 'pending_binding', userBindingStatus: 'not_started' }
|
return { resourceStatus: 'resource_ready', customerStatus: 'waiting_customer' }
|
||||||
}
|
}
|
||||||
|
|
||||||
if (normalizedStatus === 'waiting_binding') {
|
if (normalizedStatus === TASK_STATUS.DISPATCHED_PENDING_RETURN) {
|
||||||
return { systemBindingStatus: 'system_bound', userBindingStatus: 'waiting_user_claim' }
|
return { resourceStatus: 'resource_ready', customerStatus: 'customer_completed' }
|
||||||
}
|
}
|
||||||
|
|
||||||
if (normalizedStatus === 'dispatched_pending_return') {
|
if (normalizedStatus === TASK_STATUS.COMPLETED) {
|
||||||
return { systemBindingStatus: 'system_bound', userBindingStatus: 'binding_completed' }
|
return { resourceStatus: 'resource_ready', customerStatus: 'customer_completed' }
|
||||||
}
|
}
|
||||||
|
|
||||||
if (normalizedStatus === 'completed') {
|
if (normalizedStatus === TASK_STATUS.MANUAL_REVIEW) {
|
||||||
return { systemBindingStatus: 'system_bound', userBindingStatus: 'binding_completed' }
|
return { resourceStatus: 'manual_review', customerStatus: 'customer_exception' }
|
||||||
}
|
}
|
||||||
|
|
||||||
if (normalizedStatus === 'manual_review') {
|
if (normalizedStatus === TASK_STATUS.RETRY_PENDING) {
|
||||||
return { systemBindingStatus: 'manual_review', userBindingStatus: 'binding_exception' }
|
return { resourceStatus: 'retry_pending', customerStatus: 'customer_exception' }
|
||||||
}
|
}
|
||||||
|
|
||||||
if (normalizedStatus === 'retry_pending') {
|
if (normalizedStatus === TASK_STATUS.CLOSED) {
|
||||||
return { systemBindingStatus: 'retry_pending', userBindingStatus: 'binding_exception' }
|
return { resourceStatus: 'closed', customerStatus: 'closed' }
|
||||||
}
|
}
|
||||||
|
|
||||||
if (normalizedStatus === 'closed') {
|
if (normalizedStatus === TASK_STATUS.EXPIRED) {
|
||||||
return { systemBindingStatus: 'closed', userBindingStatus: 'closed' }
|
return { resourceStatus: 'expired', customerStatus: 'expired' }
|
||||||
}
|
}
|
||||||
|
|
||||||
if (normalizedStatus === 'expired') {
|
if (normalizedStatus === TASK_STATUS.LINK_GENERATED) {
|
||||||
return { systemBindingStatus: 'expired', userBindingStatus: 'expired' }
|
return { resourceStatus: 'resource_ready', customerStatus: 'waiting_customer' }
|
||||||
}
|
}
|
||||||
|
|
||||||
if (normalizedStatus === 'link_generated') {
|
if (normalizedStatus === TASK_STATUS.CLAIMED) {
|
||||||
return { systemBindingStatus: 'system_bound', userBindingStatus: 'waiting_user_claim' }
|
return { resourceStatus: 'resource_ready', customerStatus: 'link_opened' }
|
||||||
}
|
}
|
||||||
|
|
||||||
if (normalizedStatus === 'claimed') {
|
if (normalizedStatus === TASK_STATUS.ROLE_CONFIRMED) {
|
||||||
return { systemBindingStatus: 'system_bound', userBindingStatus: 'link_opened' }
|
return { resourceStatus: 'resource_ready', customerStatus: 'customer_confirmed' }
|
||||||
}
|
}
|
||||||
|
|
||||||
if (normalizedStatus === 'role_confirmed') {
|
if (normalizedStatus === TASK_STATUS.REDEEMING) {
|
||||||
return { systemBindingStatus: 'system_bound', userBindingStatus: 'binding_confirmed' }
|
return { resourceStatus: 'resource_ready', customerStatus: 'customer_processing' }
|
||||||
}
|
}
|
||||||
|
|
||||||
if (normalizedStatus === 'redeeming') {
|
if (normalizedStatus === TASK_STATUS.REDEEMED) {
|
||||||
return { systemBindingStatus: 'system_bound', userBindingStatus: 'binding_in_progress' }
|
return { resourceStatus: 'resource_ready', customerStatus: 'customer_completed' }
|
||||||
}
|
}
|
||||||
|
|
||||||
if (normalizedStatus === 'redeemed') {
|
if (isTaskResourcePrepared(task)) {
|
||||||
return { systemBindingStatus: 'system_bound', userBindingStatus: 'binding_completed' }
|
return { resourceStatus: 'resource_ready', customerStatus: 'waiting_customer' }
|
||||||
}
|
}
|
||||||
|
|
||||||
if (isTaskSystemBound(task)) {
|
return { resourceStatus: 'pending_prepare', customerStatus: 'not_started' }
|
||||||
return { systemBindingStatus: 'system_bound', userBindingStatus: 'waiting_user_claim' }
|
|
||||||
}
|
|
||||||
|
|
||||||
return { systemBindingStatus: 'pending_binding', userBindingStatus: 'not_started' }
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function isTaskSystemBound(task: TaskRow | null | undefined): boolean {
|
function isTaskResourcePrepared(task: TaskRow | null | undefined): boolean {
|
||||||
return Boolean(task && (getTaskPrimaryInventoryItemId(task) || getTaskPrimaryClaimTokenId(task)))
|
return Boolean(task && getTaskPrimaryClaimTokenId(task))
|
||||||
}
|
}
|
||||||
|
|
||||||
function mapAgisoAutoDeliveryContext(value: unknown): AdminAgisoAutoDeliveryStatus | null {
|
function isTaskFulfillmentCompleted(task: TaskRow | null | undefined): boolean {
|
||||||
if (!value || typeof value !== 'object') {
|
return isTaskFulfillmentCompletedStatus(task?.task_status)
|
||||||
return null
|
|
||||||
}
|
|
||||||
|
|
||||||
const record = value as JsonRecord
|
|
||||||
return {
|
|
||||||
status: String(record.status || '').trim(),
|
|
||||||
trigger: String(record.trigger || '').trim(),
|
|
||||||
reason: String(record.reason || '').trim(),
|
|
||||||
platformOrderId: String(record.platformOrderId || '').trim(),
|
|
||||||
responseStatus: Number(record.responseStatus || 0),
|
|
||||||
errorMessage: String(record.errorMessage || '').trim(),
|
|
||||||
requestId: String(record.requestId || '').trim(),
|
|
||||||
updatedAt: record.updatedAt || null,
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function normalizeRecord(value: unknown): JsonRecord {
|
function normalizeRecord(value: unknown): JsonRecord {
|
||||||
return value && typeof value === 'object' && !Array.isArray(value) ? value : {}
|
return value && typeof value === 'object' && !Array.isArray(value) ? value : {}
|
||||||
}
|
}
|
||||||
|
|
||||||
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 {
|
function getTaskRetryCount(task: TaskRow | null | undefined): number {
|
||||||
return Number(task?.attempt_count || 0)
|
return Number(task?.attempt_count || 0)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function resolveTaskDisplayRoleName(task: TaskRow): string {
|
||||||
|
return String(task.kuaishou_role_name || task.role_name || '').trim()
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveTaskDisplayRoleId(task: TaskRow): string {
|
||||||
|
return String(task.kuaishou_role_id || task.role_id || '').trim()
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,14 +0,0 @@
|
|||||||
import test from 'node:test'
|
|
||||||
import assert from 'node:assert/strict'
|
|
||||||
|
|
||||||
import { resolveWebhookVisibilityLevel } from './admin-webhook-read-helpers.js'
|
|
||||||
|
|
||||||
test('resolveWebhookVisibilityLevel marks ignored process errors as ignored', () => {
|
|
||||||
assert.equal(resolveWebhookVisibilityLevel('ignored_unconfigured_product'), 'ignored')
|
|
||||||
assert.equal(resolveWebhookVisibilityLevel('ignored_duplicate_event'), 'ignored')
|
|
||||||
})
|
|
||||||
|
|
||||||
test('resolveWebhookVisibilityLevel keeps normal and failed events as important', () => {
|
|
||||||
assert.equal(resolveWebhookVisibilityLevel(''), 'important')
|
|
||||||
assert.equal(resolveWebhookVisibilityLevel('invalid_signature'), 'important')
|
|
||||||
})
|
|
||||||
@@ -1,199 +0,0 @@
|
|||||||
import { listTasksByOrderId } from '../../repositories/task-repo.js'
|
|
||||||
import { formatFenToAmount, parseAmountToFen } from '../../utils/money.js'
|
|
||||||
import { extractAgisoTradePayload, resolveAgisoTradePlatformOrderId } from '../order/agiso-trade-parsing.js'
|
|
||||||
import { safeParseJson } from './admin-query-utils.js'
|
|
||||||
import { resolveDisplayShopName } from './admin-read-shared-helpers.js'
|
|
||||||
|
|
||||||
import type { AdminWebhookEventListItem } from '../../types/admin-read-models.js'
|
|
||||||
import type { WebhookEventRow } from '../../types/repository-rows.js'
|
|
||||||
|
|
||||||
type JsonRecord = Record<string, any>
|
|
||||||
|
|
||||||
type MapAdminWebhookEventOptions = {
|
|
||||||
includeRaw?: boolean
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function mapAdminWebhookEvent(
|
|
||||||
item: WebhookEventRow,
|
|
||||||
{ includeRaw = false }: MapAdminWebhookEventOptions = {},
|
|
||||||
): Promise<AdminWebhookEventListItem> {
|
|
||||||
const headers = normalizeRecord(safeParseJson(item.headers_json))
|
|
||||||
const query = normalizeRecord(safeParseJson(item.query_json))
|
|
||||||
const body = normalizeRecord(safeParseJson(item.body_json))
|
|
||||||
const payload = extractWebhookPayload(body)
|
|
||||||
const itemSources = extractWebhookItemSources(payload)
|
|
||||||
const rawAmount = pickFirstNonEmpty([
|
|
||||||
payload.total_fee,
|
|
||||||
payload.totalFee,
|
|
||||||
payload.TotalFee,
|
|
||||||
payload.pay_fee,
|
|
||||||
payload.payFee,
|
|
||||||
payload.Payment,
|
|
||||||
payload.payment,
|
|
||||||
payload.buy_amount,
|
|
||||||
payload.buyAmount,
|
|
||||||
])
|
|
||||||
const totalAmountFen = parseAmountToFen(rawAmount)
|
|
||||||
const taskCount = item.related_order_id ? (await listTasksByOrderId(item.related_order_id)).length : 0
|
|
||||||
const shopId = item.shop_id || pickFirstNonEmpty([
|
|
||||||
payload.PlatformUserId,
|
|
||||||
payload.platformUserId,
|
|
||||||
payload.platform_user_id,
|
|
||||||
payload.seller_id,
|
|
||||||
payload.sellerId,
|
|
||||||
payload.SellerId,
|
|
||||||
payload.seller_nick,
|
|
||||||
payload.sellerNick,
|
|
||||||
payload.SellerNick,
|
|
||||||
])
|
|
||||||
|
|
||||||
const mapped = {
|
|
||||||
eventId: item.id,
|
|
||||||
provider: item.provider || 'agiso',
|
|
||||||
platform: item.platform,
|
|
||||||
platformRaw: pickFirstNonEmpty([
|
|
||||||
query.fromPlatform,
|
|
||||||
query.from_platform,
|
|
||||||
body.fromPlatform,
|
|
||||||
body.from_platform,
|
|
||||||
payload.fromPlatform,
|
|
||||||
payload.FromPlatform,
|
|
||||||
payload.platform,
|
|
||||||
payload.Platform,
|
|
||||||
]),
|
|
||||||
shopId,
|
|
||||||
shopName: resolveDisplayShopName(
|
|
||||||
item.provider,
|
|
||||||
shopId,
|
|
||||||
item.shop_name || pickFirstNonEmpty([
|
|
||||||
payload.shopName,
|
|
||||||
payload.shop_name,
|
|
||||||
payload.ShopName,
|
|
||||||
payload.seller_nick,
|
|
||||||
payload.sellerNick,
|
|
||||||
payload.SellerNick,
|
|
||||||
]),
|
|
||||||
),
|
|
||||||
eventType: item.event_type,
|
|
||||||
eventKey: item.event_key,
|
|
||||||
signatureValid: Boolean(item.signature_valid),
|
|
||||||
processed: Boolean(item.processed),
|
|
||||||
processError: item.process_error,
|
|
||||||
visibilityLevel: resolveWebhookVisibilityLevel(item.process_error),
|
|
||||||
relatedOrderId: item.related_order_id,
|
|
||||||
createdAt: item.created_at,
|
|
||||||
platformOrderId: resolveAgisoTradePlatformOrderId(payload),
|
|
||||||
buyerId: pickFirstNonEmpty([
|
|
||||||
payload.buyer_id,
|
|
||||||
payload.buyerId,
|
|
||||||
payload.BuyerId,
|
|
||||||
payload.openid,
|
|
||||||
payload.BuyerOpenUid,
|
|
||||||
payload.buyer_open_uid,
|
|
||||||
payload.buyerOpenUid,
|
|
||||||
]),
|
|
||||||
buyerName: pickFirstNonEmpty([
|
|
||||||
payload.buyer_name,
|
|
||||||
payload.buyerName,
|
|
||||||
payload.BuyerName,
|
|
||||||
payload.nick,
|
|
||||||
payload.BuyerNick,
|
|
||||||
payload.buyer_nick,
|
|
||||||
payload.buyerNick,
|
|
||||||
]),
|
|
||||||
totalAmount: rawAmount ? formatFenToAmount(totalAmountFen) : '',
|
|
||||||
totalAmountFen,
|
|
||||||
currency: pickFirstNonEmpty([payload.currency, payload.Currency, 'CNY']) || 'CNY',
|
|
||||||
itemCount: itemSources.length,
|
|
||||||
taskCount,
|
|
||||||
messageText: pickFirstNonEmpty([
|
|
||||||
payload.MsgTypeDes,
|
|
||||||
payload.msgTypeDes,
|
|
||||||
payload.msg_type_desc,
|
|
||||||
payload.message,
|
|
||||||
payload.msg,
|
|
||||||
]),
|
|
||||||
requestTimestamp: pickFirstNonEmpty([query.timestamp]),
|
|
||||||
requestSign: pickFirstNonEmpty([query.sign]),
|
|
||||||
aopic: pickFirstNonEmpty([query.aopic]),
|
|
||||||
sourceHost: pickFirstNonEmpty([headers['x-forwarded-host'], headers.host]),
|
|
||||||
sourceIp: pickFirstNonEmpty([headers['x-forwarded-for'], headers['x-real-ip']]),
|
|
||||||
userAgent: pickFirstNonEmpty([headers['user-agent'], headers['User-Agent']]),
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!includeRaw) {
|
|
||||||
return mapped
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
...mapped,
|
|
||||||
headers,
|
|
||||||
query,
|
|
||||||
body,
|
|
||||||
payload,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function extractWebhookPayload(body: JsonRecord): JsonRecord {
|
|
||||||
return extractAgisoTradePayload(body)
|
|
||||||
}
|
|
||||||
|
|
||||||
function extractWebhookItemSources(payload: unknown): JsonRecord[] {
|
|
||||||
const normalizedPayload = normalizeRecord(payload)
|
|
||||||
const candidates = [
|
|
||||||
normalizedPayload.orders,
|
|
||||||
normalizedPayload.Orders,
|
|
||||||
normalizedPayload.items,
|
|
||||||
normalizedPayload.Items,
|
|
||||||
normalizedPayload.order_items,
|
|
||||||
]
|
|
||||||
|
|
||||||
for (const candidate of candidates) {
|
|
||||||
if (Array.isArray(candidate) && candidate.length > 0) {
|
|
||||||
return candidate.map((item) => normalizeRecord(item)).filter((item) => Object.keys(item).length > 0)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (
|
|
||||||
pickFirstNonEmpty([
|
|
||||||
normalizedPayload.item_id,
|
|
||||||
normalizedPayload.itemId,
|
|
||||||
normalizedPayload.ItemId,
|
|
||||||
normalizedPayload.num_iid,
|
|
||||||
normalizedPayload.numIid,
|
|
||||||
normalizedPayload.NumIid,
|
|
||||||
])
|
|
||||||
) {
|
|
||||||
return [normalizedPayload]
|
|
||||||
}
|
|
||||||
|
|
||||||
return []
|
|
||||||
}
|
|
||||||
|
|
||||||
export function resolveWebhookVisibilityLevel(processError: unknown): string {
|
|
||||||
const normalized = String(processError || '').trim()
|
|
||||||
|
|
||||||
if (normalized.startsWith('ignored_')) {
|
|
||||||
return 'ignored'
|
|
||||||
}
|
|
||||||
|
|
||||||
return 'important'
|
|
||||||
}
|
|
||||||
|
|
||||||
function normalizeRecord(value: unknown): JsonRecord {
|
|
||||||
return value && typeof value === 'object' && !Array.isArray(value) ? value : {}
|
|
||||||
}
|
|
||||||
|
|
||||||
function pickFirstNonEmpty(values: unknown[]): string {
|
|
||||||
for (const value of values) {
|
|
||||||
if (typeof value === 'string' && value.trim()) {
|
|
||||||
return value.trim()
|
|
||||||
}
|
|
||||||
|
|
||||||
if (typeof value === 'number' && Number.isFinite(value)) {
|
|
||||||
return String(value)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return ''
|
|
||||||
}
|
|
||||||
@@ -1,105 +0,0 @@
|
|||||||
import test from 'node:test'
|
|
||||||
import assert from 'node:assert/strict'
|
|
||||||
|
|
||||||
import { closeAdminTaskWithDeps } from './admin-write-service.js'
|
|
||||||
|
|
||||||
test('closeAdminTaskWithDeps revokes claim link, releases reserved inventory, and closes browser session', async () => {
|
|
||||||
const calls = {
|
|
||||||
updateToken: [],
|
|
||||||
releaseReserved: [],
|
|
||||||
closeSession: [],
|
|
||||||
updateTask: [],
|
|
||||||
createEvent: [],
|
|
||||||
}
|
|
||||||
const task = {
|
|
||||||
id: 30,
|
|
||||||
task_status: 'link_generated',
|
|
||||||
delivery_status: 'pending',
|
|
||||||
inventory_status: 'reserved',
|
|
||||||
user_action_status: 'pending_claim',
|
|
||||||
browser_session_id: 'browser-session-30',
|
|
||||||
primary_claim_token_id: 9,
|
|
||||||
claim_expires_at: '2026-04-15T10:00:00.000Z',
|
|
||||||
last_error: '',
|
|
||||||
updated_at: '2026-04-14T10:00:00.000Z',
|
|
||||||
}
|
|
||||||
const now = '2026-04-14T10:16:18.000Z'
|
|
||||||
|
|
||||||
const result = await closeAdminTaskWithDeps(task.id, {
|
|
||||||
getRequiredTask: async () => task,
|
|
||||||
listTaskInventoryBindingsByTaskId: async () => ([
|
|
||||||
{ inventory_item_id: 17, binding_status: 'reserved' },
|
|
||||||
{ inventory_item_id: 18, binding_status: 'released' },
|
|
||||||
{ inventory_item_id: 17, binding_status: 'reserved' },
|
|
||||||
]),
|
|
||||||
releaseReservedInventoryItem: async (inventoryItemId, updatedAt) => {
|
|
||||||
calls.releaseReserved.push({ inventoryItemId, updatedAt })
|
|
||||||
return { id: inventoryItemId }
|
|
||||||
},
|
|
||||||
updateClaimToken: async (tokenId, patch) => {
|
|
||||||
calls.updateToken.push({ tokenId, patch })
|
|
||||||
return { id: tokenId, ...patch }
|
|
||||||
},
|
|
||||||
closeTencentBrowserSession: async (sessionId) => {
|
|
||||||
calls.closeSession.push(sessionId)
|
|
||||||
return { sessionId, closed: true }
|
|
||||||
},
|
|
||||||
updateTask: async (taskId, patch) => {
|
|
||||||
calls.updateTask.push({ taskId, patch })
|
|
||||||
return { ...task, ...patch }
|
|
||||||
},
|
|
||||||
createTaskEvent: async (taskId, eventType, payload, createdAt) => {
|
|
||||||
calls.createEvent.push({ taskId, eventType, payload, createdAt })
|
|
||||||
return null
|
|
||||||
},
|
|
||||||
nowIso: () => now,
|
|
||||||
})
|
|
||||||
|
|
||||||
assert.equal(calls.updateToken.length, 1)
|
|
||||||
assert.deepEqual(calls.updateToken[0], {
|
|
||||||
tokenId: 9,
|
|
||||||
patch: {
|
|
||||||
status: 'revoked',
|
|
||||||
expired_at: now,
|
|
||||||
updated_at: now,
|
|
||||||
},
|
|
||||||
})
|
|
||||||
assert.deepEqual(calls.releaseReserved, [
|
|
||||||
{ inventoryItemId: 17, updatedAt: now },
|
|
||||||
])
|
|
||||||
assert.deepEqual(calls.closeSession, ['browser-session-30'])
|
|
||||||
assert.equal(calls.updateTask.length, 1)
|
|
||||||
assert.equal(calls.updateTask[0].patch.task_status, 'closed')
|
|
||||||
assert.equal(calls.updateTask[0].patch.delivery_status, 'closed')
|
|
||||||
assert.equal(calls.updateTask[0].patch.inventory_status, 'pending')
|
|
||||||
assert.equal(calls.updateTask[0].patch.user_action_status, 'closed')
|
|
||||||
assert.equal(calls.updateTask[0].patch.browser_session_id, '')
|
|
||||||
assert.equal(calls.updateTask[0].patch.claim_expires_at, now)
|
|
||||||
assert.match(calls.updateTask[0].patch.last_error, /领取链接已失效/)
|
|
||||||
assert.match(calls.updateTask[0].patch.last_error, /预占库存已释放/)
|
|
||||||
assert.equal(calls.createEvent.length, 1)
|
|
||||||
assert.deepEqual(calls.createEvent[0], {
|
|
||||||
taskId: 30,
|
|
||||||
eventType: 'task_closed',
|
|
||||||
payload: {
|
|
||||||
claimTokenRevoked: true,
|
|
||||||
releasedInventoryItemIds: [17],
|
|
||||||
releasedInventoryCount: 1,
|
|
||||||
browserSessionClosed: true,
|
|
||||||
},
|
|
||||||
createdAt: now,
|
|
||||||
})
|
|
||||||
assert.equal(result.task.status, 'closed')
|
|
||||||
})
|
|
||||||
|
|
||||||
test('closeAdminTaskWithDeps rejects redeemed tasks', async () => {
|
|
||||||
await assert.rejects(
|
|
||||||
() => closeAdminTaskWithDeps(99, {
|
|
||||||
getRequiredTask: async () => ({
|
|
||||||
id: 99,
|
|
||||||
task_status: 'redeemed',
|
|
||||||
}),
|
|
||||||
}),
|
|
||||||
/已兑换任务不能关闭/,
|
|
||||||
)
|
|
||||||
})
|
|
||||||
@@ -1,30 +0,0 @@
|
|||||||
export {
|
|
||||||
createAdminInventoryItem,
|
|
||||||
importAdminInventoryItems,
|
|
||||||
releaseAdminInventoryItem,
|
|
||||||
invalidateAdminInventoryItem,
|
|
||||||
} from './write/inventory.js'
|
|
||||||
|
|
||||||
export {
|
|
||||||
replayAdminWebhookEvent,
|
|
||||||
} from './write/webhook-events.js'
|
|
||||||
|
|
||||||
export {
|
|
||||||
releaseAdminTaskInventory,
|
|
||||||
releaseAdminTaskInventoryBinding,
|
|
||||||
regenerateAdminTaskClaimLink,
|
|
||||||
confirmAdminTaskAssistedRole,
|
|
||||||
redeemAdminTaskAssisted,
|
|
||||||
closeAdminTask,
|
|
||||||
closeAdminTaskWithDeps,
|
|
||||||
markAdminTaskManualReview,
|
|
||||||
retryAdminTask,
|
|
||||||
completeAdminTaskManualDispatch,
|
|
||||||
} from './write/task-actions.js'
|
|
||||||
|
|
||||||
export {
|
|
||||||
prepareAdminTaskKuaishouCloudFulfillment,
|
|
||||||
dispatchAdminTaskKuaishouCloudFulfillment,
|
|
||||||
refreshAdminTaskKuaishouCloudRoleInfo,
|
|
||||||
returnNumberAdminTaskKuaishouCloudFulfillment,
|
|
||||||
} from './write/kuaishou-cloud-actions.js'
|
|
||||||
@@ -1,65 +0,0 @@
|
|||||||
import { query } from '../../../db/client.js'
|
|
||||||
import {
|
|
||||||
getAgisoMessagingDefaults,
|
|
||||||
getAgisoShopConfigMap,
|
|
||||||
getAgisoShopsFilePath,
|
|
||||||
saveAgisoMessagingConfig,
|
|
||||||
} from '../../platforms/agiso/shop-config-service.js'
|
|
||||||
import { resolveDisplayShopName } from '../admin-read-shared-helpers.js'
|
|
||||||
import {
|
|
||||||
mapAdminAgisoMessagingDefaults,
|
|
||||||
mapAdminAgisoShopConfigItem,
|
|
||||||
} from './domain.js'
|
|
||||||
import { buildAdminAgisoMessagingSavePayload } from './writes.js'
|
|
||||||
|
|
||||||
type JsonObject = Record<string, any>
|
|
||||||
|
|
||||||
export async function getAdminAgisoShopConfigs() {
|
|
||||||
const configMap = getAgisoShopConfigMap()
|
|
||||||
const defaults = getAgisoMessagingDefaults()
|
|
||||||
const rowsResult = await query(
|
|
||||||
`
|
|
||||||
SELECT
|
|
||||||
shop_id,
|
|
||||||
MAX(CASE WHEN trim(shop_name) != '' THEN shop_name ELSE '' END) AS detected_shop_name,
|
|
||||||
MAX(created_at) AS latest_seen_at,
|
|
||||||
COUNT(*)::int AS webhook_event_count
|
|
||||||
FROM webhook_events
|
|
||||||
WHERE provider = 'agiso' AND trim(shop_id) != ''
|
|
||||||
GROUP BY shop_id
|
|
||||||
ORDER BY latest_seen_at DESC, shop_id DESC
|
|
||||||
`,
|
|
||||||
)
|
|
||||||
const rows = rowsResult.rows
|
|
||||||
|
|
||||||
return {
|
|
||||||
filePath: getAgisoShopsFilePath(),
|
|
||||||
defaults: mapAdminAgisoMessagingDefaults(defaults),
|
|
||||||
shops: Object.entries(configMap)
|
|
||||||
.sort(([left], [right]) => left.localeCompare(right))
|
|
||||||
.map(([shopId, config]) => mapAdminAgisoShopConfigItem(shopId, config)),
|
|
||||||
observedShops: rows.map((row) => ({
|
|
||||||
shopId: String(row.shop_id || '').trim(),
|
|
||||||
detectedShopName: String(row.detected_shop_name || '').trim(),
|
|
||||||
displayShopName: resolveDisplayShopName('agiso', row.shop_id, row.detected_shop_name),
|
|
||||||
latestSeenAt: row.latest_seen_at || null,
|
|
||||||
webhookEventCount: Number(row.webhook_event_count || 0),
|
|
||||||
configured: Boolean(configMap[String(row.shop_id || '').trim()]),
|
|
||||||
})),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export function updateAdminAgisoShopConfigs(payload: JsonObject = {}) {
|
|
||||||
const saved = saveAgisoMessagingConfig(buildAdminAgisoMessagingSavePayload(payload, {
|
|
||||||
currentDefaults: getAgisoMessagingDefaults(),
|
|
||||||
currentMap: getAgisoShopConfigMap(),
|
|
||||||
}))
|
|
||||||
|
|
||||||
return {
|
|
||||||
filePath: getAgisoShopsFilePath(),
|
|
||||||
defaults: mapAdminAgisoMessagingDefaults(saved.defaults),
|
|
||||||
shops: Object.entries(saved.shops)
|
|
||||||
.sort(([left], [right]) => left.localeCompare(right))
|
|
||||||
.map(([shopId, config]) => mapAdminAgisoShopConfigItem(shopId, config)),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user