From e7aa194ddeda1252e238c8586896d1a38533df04 Mon Sep 17 00:00:00 2001 From: yml Date: Mon, 25 May 2026 21:46:39 +0800 Subject: [PATCH] =?UTF-8?q?=E5=88=A0=E9=99=A4=E5=92=B8=E9=B1=BC=E6=97=A7?= =?UTF-8?q?=E9=93=BE=E8=B7=AF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .env.mac-docker.example | 30 - .env.server.example | 26 - README.md | 111 +- apps/backend/README.md | 137 +-- apps/backend/package-lock.json | 47 +- apps/backend/package.json | 11 +- apps/backend/package.kuaishou.json | 22 - apps/backend/scripts/seed-dev-data.ts | 513 -------- apps/backend/scripts/start-dev-container.sh | 92 -- .../scripts/test-agiso-auto-delivery.ts | 288 ----- apps/backend/scripts/test-order-flow.sh | 98 -- apps/backend/src/app-kuaishou.ts | 101 -- apps/backend/src/app.ts | 12 +- apps/backend/src/config/defaults.ts | 46 - .../src/config/env-documentation.test.ts | 6 +- apps/backend/src/config/env-overrides.test.ts | 50 - apps/backend/src/config/env-overrides.ts | 65 -- .../src/config/runtime-validation.test.ts | 39 - apps/backend/src/config/runtime-validation.ts | 1 - apps/backend/src/index-kuaishou.ts | 71 -- apps/backend/src/index.ts | 6 +- apps/backend/src/routes/admin-kuaishou.ts | 34 - apps/backend/src/routes/admin.ts | 58 +- .../src/routes/admin/inventory-kuaishou.ts | 149 --- apps/backend/src/routes/admin/inventory.ts | 216 ++-- .../backend/src/routes/admin/manual-redeem.ts | 218 ---- .../routes/admin/platform-config-kuaishou.ts | 19 - .../src/routes/admin/platform-config.ts | 4 - .../src/routes/admin/platform-config/agiso.ts | 47 - .../platform-config/fulfillment-bindings.ts | 98 -- .../src/routes/admin/tasks-kuaishou.ts | 162 --- apps/backend/src/routes/admin/tasks.ts | 329 +----- .../routes/admin/webhook-events-kuaishou.ts | 76 -- .../src/routes/admin/webhook-events.ts | 108 +- apps/backend/src/routes/claims-kuaishou.ts | 71 -- apps/backend/src/routes/claims.ts | 130 +-- apps/backend/src/routes/tencent.ts | 77 -- apps/backend/src/routes/webhooks.ts | 57 - .../admin/admin-manual-redeem-service.ts | 434 ------- .../src/services/admin/admin-read-service.ts | 2 - .../src/services/admin/admin-service.ts | 86 -- .../services/admin/admin-task-read-helpers.ts | 93 -- .../admin/admin-task-screenshot-service.ts | 28 - .../admin/admin-webhook-read-helpers.ts | 44 +- .../admin/admin-write-service.test.ts | 105 -- .../src/services/admin/admin-write-service.ts | 30 - .../admin/platform-config/agiso-service.ts | 65 -- .../admin/platform-config/agiso-template.ts | 6 - .../admin/platform-config/domain.test.ts | 110 +- .../services/admin/platform-config/domain.ts | 65 -- .../fulfillment-bindings-service.ts | 103 -- .../admin/platform-config/fulfillment.test.ts | 124 -- .../admin/platform-config/fulfillment.ts | 137 --- .../services/admin/platform-config/service.ts | 61 - .../admin/platform-config/validation.test.ts | 175 --- .../admin/platform-config/validation.ts | 125 -- .../admin/platform-config/writes.test.ts | 127 -- .../services/admin/platform-config/writes.ts | 100 -- .../src/services/admin/write/task-actions.ts | 520 --------- .../services/admin/write/webhook-events.ts | 32 - .../fulfillment-bootstrap-service.test.ts | 26 - .../fulfillment-bootstrap-service.ts | 10 +- .../claim/claim-session-service.test.ts | 273 ----- .../services/claim/claim-session-service.ts | 28 - ...red.ts => kuaishou-cloud-claim-context.ts} | 180 +-- .../claim/kuaishou-cloud-claim-service.ts | 4 +- .../src/services/claim/session/actions.ts | 312 ----- .../src/services/claim/session/context.ts | 150 --- .../src/services/claim/session/lifecycle.ts | 175 --- .../src/services/claim/session/redeem.ts | 301 ----- .../src/services/claim/session/runtime.ts | 107 -- .../order/agiso-trade-parsing.test.ts | 31 - .../src/services/order/agiso-trade-parsing.ts | 73 -- ...fulfillment-binding-config-service.test.ts | 52 - .../fulfillment-binding-config-service.ts | 129 -- .../services/order/webhook-service.test.ts | 387 ------ .../src/services/order/webhook-service.ts | 1038 ----------------- .../src/services/platforms/agiso/README.md | 9 - .../services/platforms/agiso/pdd/README.md | 3 - .../platforms/agiso/shop-config-service.ts | 181 --- .../services/platforms/agiso/taobao/README.md | 3 - .../services/platforms/agiso/xianyu/README.md | 3 - .../xianyu/auto-delivery-service.test.ts | 264 ----- .../agiso/xianyu/auto-delivery-service.ts | 640 ---------- .../agiso/xianyu/message-service.test.ts | 103 -- .../platforms/agiso/xianyu/message-service.ts | 539 --------- .../agiso/xianyu/order-detail-service.test.ts | 219 ---- .../agiso/xianyu/order-detail-service.ts | 803 ------------- apps/backend/src/services/session/ocr.ts | 101 -- .../src/services/session/session-activity.ts | 308 ----- .../session/session-browser-config.ts | 135 --- .../services/session/session-cookie-map.ts | 49 - .../services/session/session-login-shared.ts | 113 -- .../src/services/session/session-page.ts | 244 ---- .../src/services/session/session-payload.ts | 190 --- .../session/session-proof-beijing-time.ts | 55 - .../session/session-proof-constants.ts | 11 - .../services/session/session-proof-html.ts | 180 --- .../services/session/session-proof-mode.ts | 38 - .../services/session/session-proof-paths.ts | 19 - .../session/session-proof-renderer.ts | 131 --- .../session/session-proof-result-writer.ts | 65 -- .../services/session/session-proof.test.ts | 81 -- .../src/services/session/session-proof.ts | 205 ---- .../src/services/session/session-qq.ts | 240 ---- .../services/session/session-redeem.test.ts | 89 -- .../src/services/session/session-redeem.ts | 718 ------------ .../src/services/session/session-review.ts | 68 -- .../session/session-service-config.ts | 11 - .../session/session-service-runtime.ts | 102 -- .../services/session/session-service-store.ts | 205 ---- .../src/services/session/session-state.ts | 137 --- .../src/services/session/session-wx.ts | 377 ------ .../src/services/session/session.test.ts | 376 ------ apps/backend/src/services/session/session.ts | 410 ------- .../backend/src/startup/bootstrap-kuaishou.ts | 71 -- apps/backend/src/startup/bootstrap.ts | 103 +- apps/backend/src/startup/shutdown-kuaishou.ts | 44 - apps/backend/src/startup/shutdown.ts | 21 +- apps/backend/src/types/admin-read-models.ts | 12 - apps/backend/src/types/admin-route-inputs.ts | 9 - apps/backend/src/types/admin-write-inputs.ts | 59 - apps/backend/src/types/admin-write-models.ts | 24 - apps/backend/src/types/runtime-config.ts | 55 - apps/backend/tsconfig.kuaishou.build.json | 10 - apps/backend/tsconfig.kuaishou.json | 14 - apps/frontend/src/types/admin/index.ts | 2 - apps/frontend/src/types/admin/orders.ts | 9 - apps/frontend/src/types/admin/tasks.ts | 12 - apps/frontend/src/utils/admin-display.ts | 70 +- apps/frontend/src/utils/admin-options.ts | 2 +- .../admin/orders/AdminOrderDetailView.vue | 33 - .../views/admin/tasks/AdminTaskDetailView.vue | 10 - .../AdminTaskAutoDeliverySection.vue | 66 -- .../tasks/composables/useAdminTaskDetail.ts | 26 - deploy/docker/backend-dev.Dockerfile | 13 +- deploy/docker/backend-kuaishou.Dockerfile | 62 - deploy/docker/backend.Dockerfile | 56 +- docker-compose.dev.yml | 32 - docker-compose.kuaishou.dev.yml | 99 -- docker-compose.kuaishou.yml | 78 -- docker-compose.yml | 22 - 142 files changed, 539 insertions(+), 17373 deletions(-) delete mode 100644 apps/backend/package.kuaishou.json delete mode 100644 apps/backend/scripts/seed-dev-data.ts delete mode 100644 apps/backend/scripts/test-agiso-auto-delivery.ts delete mode 100755 apps/backend/scripts/test-order-flow.sh delete mode 100644 apps/backend/src/app-kuaishou.ts delete mode 100644 apps/backend/src/index-kuaishou.ts delete mode 100644 apps/backend/src/routes/admin-kuaishou.ts delete mode 100644 apps/backend/src/routes/admin/inventory-kuaishou.ts delete mode 100644 apps/backend/src/routes/admin/manual-redeem.ts delete mode 100644 apps/backend/src/routes/admin/platform-config-kuaishou.ts delete mode 100644 apps/backend/src/routes/admin/platform-config/agiso.ts delete mode 100644 apps/backend/src/routes/admin/platform-config/fulfillment-bindings.ts delete mode 100644 apps/backend/src/routes/admin/tasks-kuaishou.ts delete mode 100644 apps/backend/src/routes/admin/webhook-events-kuaishou.ts delete mode 100644 apps/backend/src/routes/claims-kuaishou.ts delete mode 100644 apps/backend/src/routes/tencent.ts delete mode 100644 apps/backend/src/routes/webhooks.ts delete mode 100644 apps/backend/src/services/admin/admin-manual-redeem-service.ts delete mode 100644 apps/backend/src/services/admin/admin-service.ts delete mode 100644 apps/backend/src/services/admin/admin-task-screenshot-service.ts delete mode 100644 apps/backend/src/services/admin/admin-write-service.test.ts delete mode 100644 apps/backend/src/services/admin/admin-write-service.ts delete mode 100644 apps/backend/src/services/admin/platform-config/agiso-service.ts delete mode 100644 apps/backend/src/services/admin/platform-config/agiso-template.ts delete mode 100644 apps/backend/src/services/admin/platform-config/fulfillment-bindings-service.ts delete mode 100644 apps/backend/src/services/admin/platform-config/fulfillment.test.ts delete mode 100644 apps/backend/src/services/admin/platform-config/fulfillment.ts delete mode 100644 apps/backend/src/services/admin/platform-config/service.ts delete mode 100644 apps/backend/src/services/admin/platform-config/validation.test.ts delete mode 100644 apps/backend/src/services/admin/platform-config/validation.ts delete mode 100644 apps/backend/src/services/admin/platform-config/writes.test.ts delete mode 100644 apps/backend/src/services/admin/platform-config/writes.ts delete mode 100644 apps/backend/src/services/admin/write/task-actions.ts delete mode 100644 apps/backend/src/services/admin/write/webhook-events.ts delete mode 100644 apps/backend/src/services/bootstrap/fulfillment-bootstrap-service.test.ts delete mode 100644 apps/backend/src/services/claim/claim-session-service.test.ts delete mode 100644 apps/backend/src/services/claim/claim-session-service.ts rename apps/backend/src/services/claim/{session/shared.ts => kuaishou-cloud-claim-context.ts} (62%) delete mode 100644 apps/backend/src/services/claim/session/actions.ts delete mode 100644 apps/backend/src/services/claim/session/context.ts delete mode 100644 apps/backend/src/services/claim/session/lifecycle.ts delete mode 100644 apps/backend/src/services/claim/session/redeem.ts delete mode 100644 apps/backend/src/services/claim/session/runtime.ts delete mode 100644 apps/backend/src/services/order/agiso-trade-parsing.test.ts delete mode 100644 apps/backend/src/services/order/agiso-trade-parsing.ts delete mode 100644 apps/backend/src/services/order/fulfillment-binding-config-service.test.ts delete mode 100644 apps/backend/src/services/order/fulfillment-binding-config-service.ts delete mode 100644 apps/backend/src/services/order/webhook-service.test.ts delete mode 100644 apps/backend/src/services/order/webhook-service.ts delete mode 100644 apps/backend/src/services/platforms/agiso/README.md delete mode 100644 apps/backend/src/services/platforms/agiso/pdd/README.md delete mode 100644 apps/backend/src/services/platforms/agiso/shop-config-service.ts delete mode 100644 apps/backend/src/services/platforms/agiso/taobao/README.md delete mode 100644 apps/backend/src/services/platforms/agiso/xianyu/README.md delete mode 100644 apps/backend/src/services/platforms/agiso/xianyu/auto-delivery-service.test.ts delete mode 100644 apps/backend/src/services/platforms/agiso/xianyu/auto-delivery-service.ts delete mode 100644 apps/backend/src/services/platforms/agiso/xianyu/message-service.test.ts delete mode 100644 apps/backend/src/services/platforms/agiso/xianyu/message-service.ts delete mode 100644 apps/backend/src/services/platforms/agiso/xianyu/order-detail-service.test.ts delete mode 100644 apps/backend/src/services/platforms/agiso/xianyu/order-detail-service.ts delete mode 100644 apps/backend/src/services/session/ocr.ts delete mode 100644 apps/backend/src/services/session/session-activity.ts delete mode 100644 apps/backend/src/services/session/session-browser-config.ts delete mode 100644 apps/backend/src/services/session/session-cookie-map.ts delete mode 100644 apps/backend/src/services/session/session-login-shared.ts delete mode 100644 apps/backend/src/services/session/session-page.ts delete mode 100644 apps/backend/src/services/session/session-payload.ts delete mode 100644 apps/backend/src/services/session/session-proof-beijing-time.ts delete mode 100644 apps/backend/src/services/session/session-proof-constants.ts delete mode 100644 apps/backend/src/services/session/session-proof-html.ts delete mode 100644 apps/backend/src/services/session/session-proof-mode.ts delete mode 100644 apps/backend/src/services/session/session-proof-paths.ts delete mode 100644 apps/backend/src/services/session/session-proof-renderer.ts delete mode 100644 apps/backend/src/services/session/session-proof-result-writer.ts delete mode 100644 apps/backend/src/services/session/session-proof.test.ts delete mode 100644 apps/backend/src/services/session/session-proof.ts delete mode 100644 apps/backend/src/services/session/session-qq.ts delete mode 100644 apps/backend/src/services/session/session-redeem.test.ts delete mode 100644 apps/backend/src/services/session/session-redeem.ts delete mode 100644 apps/backend/src/services/session/session-review.ts delete mode 100644 apps/backend/src/services/session/session-service-config.ts delete mode 100644 apps/backend/src/services/session/session-service-runtime.ts delete mode 100644 apps/backend/src/services/session/session-service-store.ts delete mode 100644 apps/backend/src/services/session/session-state.ts delete mode 100644 apps/backend/src/services/session/session-wx.ts delete mode 100644 apps/backend/src/services/session/session.test.ts delete mode 100644 apps/backend/src/services/session/session.ts delete mode 100644 apps/backend/src/startup/bootstrap-kuaishou.ts delete mode 100644 apps/backend/src/startup/shutdown-kuaishou.ts delete mode 100644 apps/backend/tsconfig.kuaishou.build.json delete mode 100644 apps/backend/tsconfig.kuaishou.json delete mode 100644 apps/frontend/src/views/admin/tasks/components/AdminTaskAutoDeliverySection.vue delete mode 100644 deploy/docker/backend-kuaishou.Dockerfile delete mode 100644 docker-compose.kuaishou.dev.yml delete mode 100644 docker-compose.kuaishou.yml diff --git a/.env.mac-docker.example b/.env.mac-docker.example index 0a934257..0aa3a517 100644 --- a/.env.mac-docker.example +++ b/.env.mac-docker.example @@ -24,41 +24,11 @@ POSTGRES_PORT=5432 DATA_ROOT=/app/data LOG_LEVEL=info -# OCR -OCR_IMAGE=order_site-ocr-worker:latest -OCR_PORT=8100 -OCR_BASE_URL=http://ocr-worker:8100 - -# Tencent Browser -TENCENT_REDEEM_PROOF_MODE=basic -TENCENT_BROWSER_HEADLESS=false -TENCENT_BROWSER_DEVTOOLS=false -TENCENT_BROWSER_PREWARM=true -TENCENT_BROWSER_KEEP_ALIVE=true -TENCENT_BROWSER_SLOW_MO=200 -TENCENT_BROWSER_VNC_RESOLUTION=1600x900x24 -TENCENT_SESSION_DEBUG=true -TENCENT_BROWSER_NOVNC_ENABLED=true -TENCENT_BROWSER_NOVNC_PORT=6080 -TENCENT_BROWSER_VNC_PORT=5900 - # Admin ADMIN_SESSION_SECRET=dev-local-session-secret ADMIN_SESSION_TTL_HOURS=12 ADMIN_DEFAULT_USERS_JSON=[{"username":"admin","password":"dev-admin-123456","role":"admin"},{"username":"operator","password":"dev-operator-123456","role":"operator"}] -# Agiso -AGISO_APP_SECRET= -AGISO_MESSAGING_ENABLED=true -AGISO_TRADE_DETAIL_ENDPOINT=https://gw-api.agiso.com/aldsIdle/Order/Detail -AGISO_TRADE_DETAIL_API_VERSION=1 -AGISO_TRADE_DETAIL_TIMEOUT_MS=5000 -AGISO_AUTO_DELIVERY_ENABLED=true -AGISO_AUTO_DELIVERY_ENDPOINT=https://gw-api.agiso.com/aldsIdle/Order/DummySend -AGISO_AUTO_DELIVERY_API_VERSION=1 -AGISO_MESSAGE_API_VERSION=1 -AGISO_SEND_MESSAGE_ENDPOINT=https://gw-api.agiso.com/aldsIdle/ImMsg/SendMsg - # 91Kaquan NINETYONE_USER_ID= NINETYONE_SECRET= diff --git a/.env.server.example b/.env.server.example index 20443117..1cbcf74f 100644 --- a/.env.server.example +++ b/.env.server.example @@ -23,37 +23,11 @@ DATABASE_MAX_CONNECTIONS=20 DATA_ROOT=/app/data LOG_LEVEL=info -# OCR -OCR_IMAGE=order_site-ocr-worker:latest -OCR_BASE_URL=http://ocr-worker:8100 - -# Tencent Browser -TENCENT_REDEEM_PROOF_MODE=full -TENCENT_BROWSER_HEADLESS=true -TENCENT_BROWSER_DEVTOOLS=false -TENCENT_BROWSER_PREWARM=true -TENCENT_BROWSER_KEEP_ALIVE=true -TENCENT_BROWSER_SLOW_MO=0 -TENCENT_BROWSER_VNC_RESOLUTION=1600x900x24 -TENCENT_SESSION_DEBUG=false - # Admin ADMIN_SESSION_SECRET=change-me-long-random-session-secret ADMIN_SESSION_TTL_HOURS=12 ADMIN_DEFAULT_USERS_JSON=[{"username":"admin","password":"change-me-admin-password","role":"admin"},{"username":"operator","password":"change-me-operator-password","role":"operator"}] -# Agiso -AGISO_APP_SECRET=change-me-agiso-app-secret -AGISO_MESSAGING_ENABLED=true -AGISO_TRADE_DETAIL_ENDPOINT=https://gw-api.agiso.com/aldsIdle/Order/Detail -AGISO_TRADE_DETAIL_API_VERSION=1 -AGISO_TRADE_DETAIL_TIMEOUT_MS=5000 -AGISO_AUTO_DELIVERY_ENABLED=true -AGISO_AUTO_DELIVERY_ENDPOINT=https://gw-api.agiso.com/aldsIdle/Order/DummySend -AGISO_AUTO_DELIVERY_API_VERSION=1 -AGISO_MESSAGE_API_VERSION=1 -AGISO_SEND_MESSAGE_ENDPOINT=https://gw-api.agiso.com/aldsIdle/ImMsg/SendMsg - # 91Kaquan NINETYONE_USER_ID= NINETYONE_SECRET= diff --git a/README.md b/README.md index a2dfdb6f..03bf600a 100644 --- a/README.md +++ b/README.md @@ -1,14 +1,13 @@ # order_site -订单自动兑换与履约管理系统。 +快手自动发货与履约管理系统。 -项目采用前后端分离架构,主要用于接收订单、匹配履约规则、生成领取链接、管理库存、执行自动/半自动兑换,并提供后台页面处理人工复核、平台配置和任务追踪。 +项目采用前后端分离架构,用于接收订单、匹配快手履约规则、管理库存与云卡资源,并提供后台页面处理平台配置、任务追踪和人工复核。 ## 技术栈 - 前端:Vue 3、Vite、Element Plus、TypeScript -- 后端:Node.js、Express、PostgreSQL、Playwright -- OCR:外部镜像服务,后端通过 `OCR_BASE_URL` 调用 +- 后端:Node.js、Express、PostgreSQL - 网关:Caddy - 本地与生产运行:Docker Compose @@ -30,28 +29,18 @@ docker-compose.yml 生产/服务器 Compose ## 本地开发 -推荐直接使用 Docker,本地不需要额外维护 Node、PostgreSQL、Playwright 浏览器和 OCR 运行环境。 +推荐直接使用 Docker,本地不需要额外维护 Node 和 PostgreSQL。 ```bash cp .env.mac-docker.example .env docker compose -f docker-compose.dev.yml up -d --build ``` -如果只运行快手 Cloud / 91 卡券 / 快手小店核销链路,可以使用轻量 Compose。这个模式不会启动 OCR,也不会安装或预热 Playwright 浏览器: - -```bash -docker compose -f docker-compose.kuaishou.dev.yml up -d --build -``` - 启动后常用入口: - 前端、后台、领取页:`http://localhost` - 后端健康检查:`http://localhost/health` - 后端 API 前缀:`http://localhost/api/v1/...` -- OCR worker:`http://127.0.0.1:8100/health` -- noVNC 浏览器画面:`http://127.0.0.1:6080/vnc.html` - -快手轻量开发模式没有 noVNC 入口,前端首页默认进入后台。 查看容器状态: @@ -64,7 +53,6 @@ docker compose -f docker-compose.dev.yml ps ```bash docker compose -f docker-compose.dev.yml logs -f backend docker compose -f docker-compose.dev.yml logs -f frontend -docker compose -f docker-compose.dev.yml logs -f ocr-worker ``` 停止环境: @@ -83,11 +71,6 @@ docker compose -f docker-compose.dev.yml exec -T backend npm run typecheck docker compose -f docker-compose.dev.yml exec -T backend npm run build docker compose -f docker-compose.dev.yml exec -T frontend npm run typecheck docker compose -f docker-compose.dev.yml exec -T frontend npm run lint -``` - -前端生产构建: - -```bash docker compose -f docker-compose.dev.yml exec -T frontend npm run build ``` @@ -97,16 +80,6 @@ docker compose -f docker-compose.dev.yml exec -T frontend npm run build docker compose -f docker-compose.dev.yml exec -T backend npm run db:migrate ``` -## 热更新 - -开发版 Compose 会把源码挂载进容器: - -- 修改 `apps/backend/src`:后端容器内 `tsx watch` 自动重启 -- 修改 `apps/frontend/src`:Vite 自动 HMR -- 修改数据库 migration:重启后端会自动执行迁移 - -如果只改业务代码,一般不需要重新 `--build`。改 Dockerfile、系统依赖或基础镜像时再重新构建。 - ## 环境变量 本地开发从根目录 `.env` 读取,模板为: @@ -128,10 +101,7 @@ cp .env.server.example .env - `ADMIN_SESSION_SECRET`:后台登录态签名密钥 - `ADMIN_DEFAULT_USERS_JSON`:默认后台用户 - `DATABASE_URL`:PostgreSQL 连接串 -- `TENCENT_BROWSER_HEADLESS`:Playwright 是否无头运行 -- `TENCENT_BROWSER_PREWARM`:启动时是否预热浏览器 -- `OCR_IMAGE`:OCR 外部服务镜像,默认 `order_site-ocr-worker:latest` -- `OCR_BASE_URL`:后端访问 OCR 的 HTTP 地址,Docker 内默认 `http://ocr-worker:8100` +- `NINETYONE_*`:91 卡券对接配置 ## 运行数据 @@ -140,8 +110,6 @@ cp .env.server.example .env - 后端配置与运行产物:`apps/backend/data` - PostgreSQL 数据:Docker volume `postgres_dev_data` - 后端日志:`apps/backend/data/logs` -- 浏览器会话:`apps/backend/data/browser-sessions` -- 兑换截图:`apps/backend/data/redeem-screenshots` 生产部署数据: @@ -161,13 +129,6 @@ mkdir -p deploy/data/backend docker compose up -d --build ``` -只部署快手轻量服务时使用: - -```bash -mkdir -p deploy/data/backend-kuaishou -docker compose -f docker-compose.kuaishou.yml up -d --build -``` - 生产入口: - 前端与领取页:`https://你的域名/` @@ -177,78 +138,21 @@ docker compose -f docker-compose.kuaishou.yml up -d --build 生产 Compose 会自动: - 启动 PostgreSQL -- 启动 OCR worker - 启动后端并执行 migration - 初始化默认后台管理员 - 同步履约目录 - 通过 Caddy 暴露前端和 API -查看生产日志: - -```bash -docker compose logs -f backend -docker compose logs -f web -``` - -## 开发数据脚本 - -后端提供开发数据脚本: - -```bash -docker compose -f docker-compose.dev.yml exec -T backend npm run seed:dev-data -docker compose -f docker-compose.dev.yml exec -T backend npm run cleanup:dev-data -``` - -脚本默认有预览/确认语义,执行前注意终端输出说明。 - ## 主要业务模块 - `apps/backend/src/routes`:API 路由 -- `apps/backend/src/services/order`:订单、webhook、库存和履约匹配 -- `apps/backend/src/services/fulfillment`:快手 Cloud 等履约编排 -- `apps/backend/src/services/session`:腾讯登录、二维码、兑换和截图 +- `apps/backend/src/services/order`:订单、库存和履约匹配 +- `apps/backend/src/services/fulfillment`:快手 Cloud 履约编排 - `apps/backend/src/services/admin`:后台读写、权限、配置管理 -- `apps/backend/src/services/platforms`:外部平台 API 封装 - `apps/backend/src/repositories`:数据库访问 - `apps/frontend/src/views/admin`:后台页面 - `apps/frontend/src/views/claim`:用户领取页 -## 排障 - -如果页面打不开: - -```bash -docker compose -f docker-compose.dev.yml ps -docker compose -f docker-compose.dev.yml logs -f web -``` - -如果后端未 ready: - -```bash -docker compose -f docker-compose.dev.yml logs -f backend -curl http://localhost/health/ready -``` - -如果浏览器兑换或扫码异常: - -```bash -docker compose -f docker-compose.dev.yml logs -f backend -open http://127.0.0.1:6080/vnc.html -``` - -如果前端类型或 lint 失败: - -```bash -docker compose -f docker-compose.dev.yml exec -T frontend npm run typecheck -docker compose -f docker-compose.dev.yml exec -T frontend npm run lint -``` - -如果后端单测失败: - -```bash -docker compose -f docker-compose.dev.yml exec -T backend npm test -``` - ## 提交前检查清单 ```bash @@ -264,4 +168,3 @@ docker compose -f docker-compose.dev.yml exec -T frontend npm run lint - `.env` 未被提交 - 数据库 migration 与代码一起提交 - 领取链接相关改动已在 Docker 环境验证 -- 涉及 Playwright/OCR 的改动已看过 noVNC 或相关日志 diff --git a/apps/backend/README.md b/apps/backend/README.md index ce404ff2..f0d88734 100644 --- a/apps/backend/README.md +++ b/apps/backend/README.md @@ -1,13 +1,8 @@ # order-site-backend -腾讯活动浏览器会话后端。 +快手轻量后端服务。 -当前主线只有一条: - -- 后端托管 Playwright 浏览器 -- 前端展示二维码并轮询会话状态 -- 登录完成后,后端直接在活动页填写 CDK、识别验证码、执行兑换 -- 兑换完成后返回状态,并按配置决定是否生成截图/证明产物 +后端负责订单入库、履约任务编排、快手 Cloud / 91 卡券 / 快手小店核销配置、后台管理接口和数据库迁移。默认入口就是轻量后端,不再包含浏览器会话、OCR 或旧咸鱼自动发货链路。 ## 快速启动 @@ -20,21 +15,18 @@ npm install 复制并填写环境变量后启动: ```bash -cp /Users/yml/codes/order-site-workspace/.env.mac-docker.example /Users/yml/codes/order-site-workspace/.env -cd /Users/yml/codes/order-site-workspace/apps/backend +cp ../../.env.mac-docker.example ../../.env npm run dev ``` -Linux 部署: +生产构建: ```bash -npm install npm run build -npm run browser:install:linux npm run start ``` -后端渐进 TypeScript 迁移已经进入源码和构建双轨模式: +常用验证: ```bash npm run typecheck @@ -42,120 +34,61 @@ npm run build npm test ``` -当前约定: - -- 开发:`npm run dev` 使用 `tsx watch` 直接运行 `src` -- 数据库迁移:`npm run db:migrate` 使用 `tsx` -- 生产:`npm run build` 输出 `dist`,`npm run start` 运行 `dist/index.js` -- 新增或迁移后端模块优先使用 `.ts` - ## 配置 -当前后端采用 `.env` 单一路径做环境配置: +后端采用工作区根目录 `.env` 做环境配置: - 通用默认值:`src/config/defaults.ts` - 本地/部署覆盖:工作区根目录 `.env` -当前最常改的配置有: +常用配置: -- 服务端口 +- 服务端口 `PORT` - 日志级别 `LOG_LEVEL=debug|info|warn|error` -- 浏览器是否无头、是否预热、是否常驻、slowMo -- OCR 外部服务镜像 `OCR_IMAGE` 与访问地址 `OCR_BASE_URL` -- 会话调试开关 -- 兑换证明模式 `full | basic | off` -- Agiso 全局配置与后台店铺文件配置 - -### Agiso 多店铺 - -当前支持同一个 Agiso 应用下配置多个店铺的消息 token。 - -- 全局兜底配置仍走 `.env`: - - `AGISO_APP_SECRET` - - `AGISO_MESSAGING_ENABLED` -- 默认消息模板与店铺级覆盖配置改为文件: - - `apps/backend/data/agiso-shops.json` -- 后台维护入口: - - `#/admin/platform-shops` - -消息发送时会按 webhook 识别出的 `shop_id` 读取 `agiso-shops.json` 中的店铺配置;模板未命中时,会先回退到文件里的 `defaults`,再回退到系统内置默认值。店铺的 `accessToken` 也需要在文件中维护,不再使用全局 `.env` 兜底。 +- 数据库连接 `DATABASE_URL` +- 后台登录密钥 `ADMIN_SESSION_SECRET` +- 默认后台用户 `ADMIN_DEFAULT_USERS_JSON` +- 91 卡券与快手相关平台配置 ## 接口 -- `POST /api/v1/tencent/browser/session` - 创建浏览器会话,返回 `sessionId`、二维码和初始状态 -- `GET /api/v1/tencent/browser/session/:sessionId` - 返回完整会话状态 -- `GET /api/v1/tencent/browser/session/:sessionId/summary` - 轮询用轻量摘要接口,默认不返回二维码 base64 -- `POST /api/v1/tencent/browser/session/:sessionId/refresh` - 强制刷新后端活动页 -- `POST /api/v1/tencent/browser/session/:sessionId/redeem` - 执行兑换 -- `GET /api/v1/tencent/browser/session/:sessionId/screenshot` - 读取最近一次兑换截图 -- `DELETE /api/v1/tencent/browser/session/:sessionId` - 关闭浏览器会话 +- `GET /health`:健康检查 +- `GET /health/live`:存活检查 +- `GET /health/ready`:就绪检查 +- `/api/v1/open/91/*`:91 卡券开放回调 +- `/api/v1/claim/*`:领取页接口 +- `/api/v1/admin/*`:后台管理接口 ## 目录 -- `src/index.ts` - Express 入口 -- `src/routes/tencent.ts` - 接口路由 -- `src/services/session/session.ts` - 会话编排与浏览器生命周期 -- `src/services/session/*.ts` - 登录、兑换、OCR、凭证产物等模块 +- `src/index.ts`:服务入口 +- `src/app.ts`:Express 应用组装 +- `src/routes`:API 路由 +- `src/services/order`:订单、库存和履约匹配 +- `src/services/fulfillment`:快手 Cloud 履约编排 +- `src/services/admin`:后台读写、权限、配置管理 +- `src/repositories`:数据库访问 +- `src/db/migrations`:数据库迁移 ## Docker 开发 -当前 `docker-compose.dev.yml` 已切到源码挂载模式: +开发版 Compose 会把源码挂载进容器: - `postgres` 容器提供开发库,后端通过 `DATABASE_URL` 连接 -- `ocr-worker` 使用外部镜像启动,后端通过 `OCR_BASE_URL=http://ocr-worker:8100` 调用 -- 后端容器启动时执行 `npm install` 和 `npm run dev` -- 平时改 `src/` 或 `.env`,一般都不需要重建镜像 -- 生产镜像会在 build 阶段编译后端并只复制 `dist`、`config` 和运行依赖 +- 后端容器启动时执行依赖安装和 `npm run dev` +- 平时改 `src/` 或 `.env`,一般不需要重建镜像 -只有下面几类改动通常还需要 `docker compose -f docker-compose.dev.yml up -d --build backend`: +只有下面几类改动通常还需要重新构建 backend: -- `deploy/docker/backend.Dockerfile` -- 系统层依赖 -- Playwright 浏览器基础环境 +- `deploy/docker/backend*.Dockerfile` +- Node 版本或系统层依赖 +- `package.json` / `package-lock.json` -## 产物 - -浏览器会话产物默认保存在: - -- `data/browser-sessions//qq-qr.png` -- `data/browser-sessions//session.json` -- `data/browser-sessions//captcha-attempt-*.png` -- `data/browser-sessions//redeem-result.png` -- `data/browser-sessions//page.html` -- `data/browser-sessions//result.json` - -这些都是运行时产物,默认不提交 Git。 +## 运行产物 运行日志默认保存在: - `data/logs/app-YYYY-MM-DD.log` - `data/logs/webhook-YYYY-MM-DD.log` -日志按天切分,默认自动清理 7 天前的旧日志。 -成功的 `/health`、`/health/live`、`/health/ready` 探活请求默认不写 access log。 - -`LOG_LEVEL` 默认是 `info`: - -- `debug`:输出最详细的调试日志 -- `info`:输出启动、请求、业务成功/失败等常规日志 -- `warn`:只保留告警和错误 -- `error`:只保留错误 - -OCR 识别通过 `OCR_BASE_URL` 调用 HTTP 服务。Docker Compose 会启动 `ocr-worker` 外部镜像并注入默认地址 `http://ocr-worker:8100`;如果单独运行后端且未配置该地址,启动健康检查会标记 OCR 为 degraded,兑换中需要 OCR 的流程会在调用时返回明确错误。 - -`TENCENT_REDEEM_PROOF_MODE` 对应的配置文件项会影响生成强度: - -- `full`:完整证明 -- `basic`:只保留最终截图和结果 JSON -- `off`:不生成证明文件 +日志按天切分,默认自动清理 7 天前的旧日志。成功的 `/health`、`/health/live`、`/health/ready` 探活请求默认不写 access log。 diff --git a/apps/backend/package-lock.json b/apps/backend/package-lock.json index 9e587fd5..30a329c8 100644 --- a/apps/backend/package-lock.json +++ b/apps/backend/package-lock.json @@ -9,8 +9,7 @@ "version": "0.1.0", "dependencies": { "express": "^5.1.0", - "pg": "^8.16.3", - "playwright": "1.42.1" + "pg": "^8.16.3" }, "devDependencies": { "@types/express": "^5.0.6", @@ -911,20 +910,6 @@ "node": ">= 0.8" } }, - "node_modules/fsevents": { - "version": "2.3.2", - "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz", - "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==", - "hasInstallScript": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": "^8.16.0 || ^10.6.0 || >=11.0.0" - } - }, "node_modules/function-bind": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", @@ -1275,36 +1260,6 @@ "split2": "^4.1.0" } }, - "node_modules/playwright": { - "version": "1.42.1", - "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.42.1.tgz", - "integrity": "sha512-PgwB03s2DZBcNRoW+1w9E+VkLBxweib6KTXM0M3tkiT4jVxKSi6PmVJ591J+0u10LUrgxB7dLRbiJqO5s2QPMg==", - "license": "Apache-2.0", - "dependencies": { - "playwright-core": "1.42.1" - }, - "bin": { - "playwright": "cli.js" - }, - "engines": { - "node": ">=16" - }, - "optionalDependencies": { - "fsevents": "2.3.2" - } - }, - "node_modules/playwright-core": { - "version": "1.42.1", - "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.42.1.tgz", - "integrity": "sha512-mxz6zclokgrke9p1vtdy/COWBH+eOZgYUVVU34C73M+4j4HLlQJHtfcqiqqxpP0o8HhMkflvfbquLX5dg6wlfA==", - "license": "Apache-2.0", - "bin": { - "playwright-core": "cli.js" - }, - "engines": { - "node": ">=16" - } - }, "node_modules/postgres-array": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/postgres-array/-/postgres-array-2.0.0.tgz", diff --git a/apps/backend/package.json b/apps/backend/package.json index 22bff426..23e1d140 100644 --- a/apps/backend/package.json +++ b/apps/backend/package.json @@ -4,27 +4,18 @@ "private": true, "type": "module", "scripts": { - "browser:install": "playwright install chromium", - "browser:install:linux": "playwright install --with-deps chromium", - "agiso:auto-delivery:test": "tsx scripts/test-agiso-auto-delivery.ts", "cleanup:dev-data": "tsx scripts/cleanup-dev-data.ts", - "seed:dev-data": "tsx scripts/seed-dev-data.ts", "build": "tsc -p tsconfig.build.json && rm -rf dist/db/migrations && mkdir -p dist/db && cp -R src/db/migrations dist/db/migrations", - "build:kuaishou": "tsc -p tsconfig.kuaishou.build.json && rm -rf dist/db/migrations && mkdir -p dist/db && cp -R src/db/migrations dist/db/migrations", "db:migrate": "tsx src/db/migrate.ts", "dev": "tsx watch --clear-screen=false src/index.ts", - "dev:kuaishou": "tsx watch --clear-screen=false src/index-kuaishou.ts", "test": "node --import tsx --test $(find src \\( -name '*.test.ts' -o -name '*.test.js' \\) -print)", "typecheck": "tsc -p tsconfig.json --noEmit", - "typecheck:kuaishou": "tsc -p tsconfig.kuaishou.json --noEmit", "start": "node dist/index.js", - "start:kuaishou": "node dist/index-kuaishou.js", "start:src": "tsx src/index.ts" }, "dependencies": { "express": "^5.1.0", - "pg": "^8.16.3", - "playwright": "1.42.1" + "pg": "^8.16.3" }, "devDependencies": { "@types/express": "^5.0.6", diff --git a/apps/backend/package.kuaishou.json b/apps/backend/package.kuaishou.json deleted file mode 100644 index 10d71dfb..00000000 --- a/apps/backend/package.kuaishou.json +++ /dev/null @@ -1,22 +0,0 @@ -{ - "name": "order-site-backend-kuaishou", - "version": "0.1.0", - "private": true, - "type": "module", - "scripts": { - "build:kuaishou": "tsc -p tsconfig.kuaishou.build.json && rm -rf dist/db/migrations && mkdir -p dist/db && cp -R src/db/migrations dist/db/migrations", - "db:migrate": "tsx src/db/migrate.ts", - "start:kuaishou": "node dist/index-kuaishou.js" - }, - "dependencies": { - "express": "^5.1.0", - "pg": "^8.16.3" - }, - "devDependencies": { - "@types/express": "^5.0.6", - "@types/node": "^25.6.0", - "@types/pg": "^8.20.0", - "tsx": "^4.22.3", - "typescript": "^6.0.2" - } -} diff --git a/apps/backend/scripts/seed-dev-data.ts b/apps/backend/scripts/seed-dev-data.ts deleted file mode 100644 index 45444b50..00000000 --- a/apps/backend/scripts/seed-dev-data.ts +++ /dev/null @@ -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`') -} diff --git a/apps/backend/scripts/start-dev-container.sh b/apps/backend/scripts/start-dev-container.sh index ec4373ea..69990b1c 100644 --- a/apps/backend/scripts/start-dev-container.sh +++ b/apps/backend/scripts/start-dev-container.sh @@ -1,97 +1,5 @@ #!/bin/sh set -eu -is_true() { - normalized=$(printf '%s' "${1:-}" | tr '[:upper:]' '[:lower:]') - case "$normalized" in - 1|true|yes|on) - return 0 - ;; - *) - return 1 - ;; - esac -} - -start_browser_debug_services() { - display="${TENCENT_BROWSER_DISPLAY:-:99}" - resolution="${TENCENT_BROWSER_VNC_RESOLUTION:-1600x900x24}" - display_number="${display#:}" - display_number="${display_number%%.*}" - display_socket="/tmp/.X11-unix/X${display_number}" - display_lock="/tmp/.X${display_number}-lock" - - if [ -S "$display_socket" ] && [ ! -f "$display_lock" ]; then - rm -f "$display_socket" - fi - - if [ -f "$display_lock" ]; then - display_pid=$(cat "$display_lock" 2>/dev/null || true) - if [ -z "$display_pid" ] || ! kill -0 "$display_pid" 2>/dev/null; then - rm -f "$display_lock" "$display_socket" - fi - fi - - export DISPLAY="$display" - Xvfb "$display" -screen 0 "$resolution" -nolisten tcp >/tmp/xvfb.log 2>&1 & - xvfb_pid=$! - - ready=0 - attempt=0 - while [ "$attempt" -lt 50 ]; do - if [ -S "$display_socket" ]; then - ready=1 - break - fi - - if ! kill -0 "$xvfb_pid" 2>/dev/null; then - break - fi - - attempt=$((attempt + 1)) - sleep 0.1 - done - - if [ "$ready" -ne 1 ]; then - echo "[backend-dev] Xvfb display ${DISPLAY} not ready for x11vnc; skip noVNC startup" >&2 - return - fi - - if ! is_true "${TENCENT_BROWSER_NOVNC_ENABLED:-true}"; then - echo "[backend-dev] headed browser enabled on ${DISPLAY}; noVNC disabled" - return - fi - - vnc_port="${TENCENT_BROWSER_VNC_PORT:-5900}" - novnc_port="${TENCENT_BROWSER_NOVNC_PORT:-6080}" - - x11vnc \ - -display "$display" \ - -rfbport "$vnc_port" \ - -forever \ - -shared \ - -nopw \ - -listen 0.0.0.0 \ - -xkb >/tmp/x11vnc.log 2>&1 & - - if command -v novnc_proxy >/dev/null 2>&1; then - novnc_proxy --listen "$novnc_port" --vnc "127.0.0.1:${vnc_port}" >/tmp/novnc.log 2>&1 & - elif [ -x /usr/share/novnc/utils/novnc_proxy ]; then - /usr/share/novnc/utils/novnc_proxy --listen "$novnc_port" --vnc "127.0.0.1:${vnc_port}" >/tmp/novnc.log 2>&1 & - elif command -v websockify >/dev/null 2>&1 && [ -d /usr/share/novnc ]; then - websockify --web=/usr/share/novnc "$novnc_port" "127.0.0.1:${vnc_port}" >/tmp/novnc.log 2>&1 & - else - echo "[backend-dev] noVNC requested but novnc/websockify not found" - return - fi - - echo "[backend-dev] headed browser enabled on ${DISPLAY}; noVNC: http://127.0.0.1:${novnc_port}/vnc.html" -} - npm install --no-fund --no-audit - -if ! is_true "${TENCENT_BROWSER_HEADLESS:-true}"; then - start_browser_debug_services -fi - exec npm run dev diff --git a/apps/backend/scripts/test-agiso-auto-delivery.ts b/apps/backend/scripts/test-agiso-auto-delivery.ts deleted file mode 100644 index 5dac381b..00000000 --- a/apps/backend/scripts/test-agiso-auto-delivery.ts +++ /dev/null @@ -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() - }) -} diff --git a/apps/backend/scripts/test-order-flow.sh b/apps/backend/scripts/test-order-flow.sh deleted file mode 100755 index 0924019a..00000000 --- a/apps/backend/scripts/test-order-flow.sh +++ /dev/null @@ -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 < boolean; - config: RuntimeConfig; -}; - -export function createKuaishouApp({ - startupState, - isShutdownStarted, - config, -}: CreateKuaishouAppOptions) { - const app = express(); - - app.use(accessLogMiddleware); - app.use(createCorsMiddleware(config)); - app.use(express.json({ limit: "2mb" })); - app.use(express.urlencoded({ extended: true })); - - app.get("/health", (_req, res) => { - res.json( - buildSuccessPayload( - buildHealthPayload(startupState, isShutdownStarted()), - startupState.core.ready ? "ready" : "starting" - ) - ); - }); - - app.get("/health/live", (_req, res) => { - res.json( - buildSuccessPayload({ - status: isShutdownStarted() ? "shutting_down" : "alive", - pid: process.pid, - mode: "kuaishou-lite", - }) - ); - }); - - app.get("/health/ready", (_req, res) => { - if (startupState.core.ready) { - res.json( - buildSuccessPayload( - buildHealthPayload(startupState, isShutdownStarted()), - "ready" - ) - ); - return; - } - - res.status(503).json({ - code: 1, - msg: startupState.core.lastError || "服务启动中,请稍后重试", - errorCode: "service_not_ready", - time: Math.floor(Date.now() / 1000), - data: buildHealthPayload(startupState, isShutdownStarted()), - }); - }); - - app.use((req, res, next) => { - if (startupState.core.ready) { - next(); - return; - } - - res.status(503).json({ - code: 1, - msg: startupState.core.lastError || "服务启动中,请稍后重试", - errorCode: "service_not_ready", - time: Math.floor(Date.now() / 1000), - data: { - startup: { - phase: startupState.phase, - attemptCount: startupState.core.attemptCount, - lastAttemptAt: startupState.core.lastAttemptAt, - }, - }, - }); - }); - - app.use("/api/v1/open/91", open91Router); - app.use("/api/v1/claim", claimsRouter); - app.use("/api/v1/admin", adminRouter); - - app.use((err: unknown, _req: Request, res: Response, _next: NextFunction) => { - sendRouteError(res, err, "服务内部错误", "[global:kuaishou]"); - }); - - return app; -} diff --git a/apps/backend/src/app.ts b/apps/backend/src/app.ts index e11f35e4..195f5699 100644 --- a/apps/backend/src/app.ts +++ b/apps/backend/src/app.ts @@ -1,17 +1,16 @@ -import type { Request, Response, NextFunction } from "express"; +import type { NextFunction, Request, Response } from "express"; import express from "express"; import process from "node:process"; import adminRouter from "./routes/admin.js"; import claimsRouter from "./routes/claims.js"; import open91Router from "./routes/open-91.js"; -import tencentRouter from "./routes/tencent.js"; -import webhooksRouter from "./routes/webhooks.js"; -import { buildSuccessPayload, sendRouteError } from "./utils/http.js"; import { accessLogMiddleware } from "./middleware/access-log.js"; import { createCorsMiddleware } from "./middleware/cors.js"; -import type { RuntimeConfig } from "./types/runtime-config.js"; import { buildHealthPayload, type StartupState } from "./startup/state.js"; +import type { RuntimeConfig } from "./types/runtime-config.js"; +import { buildSuccessPayload, sendRouteError } from "./utils/http.js"; + type CreateAppOptions = { startupState: StartupState; isShutdownStarted: () => boolean; @@ -44,6 +43,7 @@ export function createApp({ buildSuccessPayload({ status: isShutdownStarted() ? "shutting_down" : "alive", pid: process.pid, + mode: "kuaishou-lite", }) ); }); @@ -89,9 +89,7 @@ export function createApp({ }); }); - app.use("/api/v1/tencent", tencentRouter); app.use("/api/v1/open/91", open91Router); - app.use("/api/v1/webhooks", webhooksRouter); app.use("/api/v1/claim", claimsRouter); app.use("/api/v1/admin", adminRouter); diff --git a/apps/backend/src/config/defaults.ts b/apps/backend/src/config/defaults.ts index aabc6eff..467de9e2 100644 --- a/apps/backend/src/config/defaults.ts +++ b/apps/backend/src/config/defaults.ts @@ -8,24 +8,6 @@ export function createDefaultRuntimeConfig(projectRoot: string): RuntimeConfig { port: 3000, }, - browser: { - chromePath: "", - headless: null, - devtools: null, - keepAlive: null, - prewarm: null, - slowMoMs: null, - vncResolution: "1600x900x24", - }, - - session: { - debug: false, - }, - - ocr: { - baseUrl: "", - }, - data: { root: path.resolve(projectRoot, "data"), }, @@ -52,30 +34,6 @@ export function createDefaultRuntimeConfig(projectRoot: string): RuntimeConfig { }, platforms: { - agiso: { - appSecret: "", - tradeDetail: { - endpoint: "https://gw-api.agiso.com/aldsIdle/Order/Detail", - apiVersion: "1", - timeoutMs: 5000, - }, - autoDelivery: { - enabled: true, - endpoint: "https://gw-api.agiso.com/aldsIdle/Order/DummySend", - apiVersion: "1", - }, - messaging: { - enabled: false, - sendMessageEndpoint: - "https://gw-api.agiso.com/aldsIdle/ImMsg/SendMsg", - apiVersion: "1", - messageTemplate: - "您的订单 {platformOrderId} 已创建领取链接,请在 {expiredAt} 前完成领取:{claimUrl}", - autoDeliveryMessageTemplate: - "您的订单 {platformOrderId} 已完成自动发货,请注意查收。", - shops: {}, - }, - }, ninetyone: { userId: "", secret: "", @@ -137,9 +95,5 @@ PfQTPNA++KsXtwRX9M6Re3vkTDRsutIIWKtj8jqhUbbYS3vzS8GJnAWavUFVkR15 cors: { allowedOrigins: ["*"], }, - - redeem: { - proofMode: "full", - }, }; } diff --git a/apps/backend/src/config/env-documentation.test.ts b/apps/backend/src/config/env-documentation.test.ts index dce702ae..609c84e6 100644 --- a/apps/backend/src/config/env-documentation.test.ts +++ b/apps/backend/src/config/env-documentation.test.ts @@ -21,17 +21,13 @@ const DEPLOYMENT_ONLY_ENV_NAMES = [ 'CADDY_SITE_ADDR', 'CHOKIDAR_USEPOLLING', 'NPM_CONFIG_REGISTRY', - 'OCR_IMAGE', - 'OCR_PORT', 'POSTGRES_DB', 'POSTGRES_PASSWORD', 'POSTGRES_PORT', 'POSTGRES_USER', - 'TENCENT_BROWSER_NOVNC_ENABLED', - 'TENCENT_BROWSER_NOVNC_PORT', - 'TENCENT_BROWSER_VNC_PORT', 'TZ', 'VITE_API_TARGET', + 'VITE_ALLOWED_HOSTS', ] const KNOWN_ENV_NAMES = new Set([ diff --git a/apps/backend/src/config/env-overrides.test.ts b/apps/backend/src/config/env-overrides.test.ts index f63256a5..2f3cdb0b 100644 --- a/apps/backend/src/config/env-overrides.test.ts +++ b/apps/backend/src/config/env-overrides.test.ts @@ -10,32 +10,21 @@ test('applyEnvOverrides maps typed environment values without mutating base conf const config = applyEnvOverrides(baseConfig, { PORT: '4000', - TENCENT_BROWSER_HEADLESS: 'true', - TENCENT_BROWSER_SLOW_MO: '250', - TENCENT_BROWSER_VNC_RESOLUTION: '1920x1080x24', DATA_ROOT: './tmp/backend-data', DATABASE_SSL: 'yes', ADMIN_DEFAULT_USERS_JSON: '[{"username":"admin","password":"secret","role":"admin"}]', - AGISO_TRADE_DETAIL_TIMEOUT_MS: '7000', CLOUDTENTACLES_DEVICE_TYPE: '3', - TENCENT_REDEEM_PROOF_MODE: 'basic', }) assert.equal(config.server.port, 4000) - assert.equal(config.browser.headless, true) - assert.equal(config.browser.slowMoMs, 250) - assert.equal(config.browser.vncResolution, '1920x1080x24') assert.equal(config.data.root, path.resolve('./tmp/backend-data')) assert.equal(config.database.ssl, true) assert.deepEqual(config.admin.defaultUsers, [ { username: 'admin', password: 'secret', role: 'admin' }, ]) - assert.equal(config.platforms.agiso.tradeDetail.timeoutMs, 7000) assert.equal(config.platforms.cloudtentacles.deviceType, 3) - assert.equal(config.redeem.proofMode, 'basic') assert.equal(baseConfig.server.port, 3000) - assert.equal(baseConfig.browser.headless, null) assert.deepEqual(baseConfig.admin.defaultUsers, []) }) @@ -80,21 +69,6 @@ function createRuntimeConfig(): RuntimeConfig { server: { port: 3000, }, - browser: { - chromePath: '', - headless: null, - devtools: null, - keepAlive: null, - prewarm: null, - slowMoMs: null, - vncResolution: '1600x900x24', - }, - session: { - debug: false, - }, - ocr: { - baseUrl: '', - }, data: { root: '/tmp/order-site', }, @@ -116,27 +90,6 @@ function createRuntimeConfig(): RuntimeConfig { defaultUsers: [], }, platforms: { - agiso: { - appSecret: '', - tradeDetail: { - endpoint: 'https://gw-api.agiso.com/aldsIdle/Order/Detail', - apiVersion: '1', - timeoutMs: 5000, - }, - autoDelivery: { - enabled: true, - endpoint: 'https://gw-api.agiso.com/aldsIdle/Order/DummySend', - apiVersion: '1', - }, - messaging: { - enabled: false, - sendMessageEndpoint: 'https://gw-api.agiso.com/aldsIdle/ImMsg/SendMsg', - apiVersion: '1', - messageTemplate: '', - autoDeliveryMessageTemplate: '', - shops: {}, - }, - }, ninetyone: { userId: '', secret: '', @@ -184,8 +137,5 @@ function createRuntimeConfig(): RuntimeConfig { deviceType: 0, }, }, - redeem: { - proofMode: 'full', - }, } } diff --git a/apps/backend/src/config/env-overrides.ts b/apps/backend/src/config/env-overrides.ts index e47e5222..0b018d35 100644 --- a/apps/backend/src/config/env-overrides.ts +++ b/apps/backend/src/config/env-overrides.ts @@ -30,15 +30,6 @@ type EnvOverride = { export const ENV_OVERRIDES: readonly EnvOverride[] = [ integerEnv("PORT", ["server", "port"]), - stringEnv("CHROME_PATH", ["browser", "chromePath"]), - booleanEnv("TENCENT_BROWSER_HEADLESS", ["browser", "headless"]), - booleanEnv("TENCENT_BROWSER_DEVTOOLS", ["browser", "devtools"]), - booleanEnv("TENCENT_BROWSER_KEEP_ALIVE", ["browser", "keepAlive"]), - booleanEnv("TENCENT_BROWSER_PREWARM", ["browser", "prewarm"]), - integerEnv("TENCENT_BROWSER_SLOW_MO", ["browser", "slowMoMs"]), - stringEnv("TENCENT_BROWSER_VNC_RESOLUTION", ["browser", "vncResolution"]), - booleanEnv("TENCENT_SESSION_DEBUG", ["session", "debug"]), - stringEnv("OCR_BASE_URL", ["ocr", "baseUrl"]), stringEnv("DATA_ROOT", ["data", "root"], path.resolve), stringEnv("LOG_LEVEL", ["logging", "level"]), stringEnv("DATABASE_URL", ["database", "url"]), @@ -48,61 +39,6 @@ export const ENV_OVERRIDES: readonly EnvOverride[] = [ stringEnv("ADMIN_SESSION_SECRET", ["admin", "sessionSecret"]), integerEnv("ADMIN_SESSION_TTL_HOURS", ["admin", "sessionTtlHours"]), adminUsersJsonEnv("ADMIN_DEFAULT_USERS_JSON", ["admin", "defaultUsers"]), - stringEnv("AGISO_APP_SECRET", ["platforms", "agiso", "appSecret"]), - stringEnv("AGISO_TRADE_DETAIL_ENDPOINT", [ - "platforms", - "agiso", - "tradeDetail", - "endpoint", - ]), - stringEnv("AGISO_TRADE_DETAIL_API_VERSION", [ - "platforms", - "agiso", - "tradeDetail", - "apiVersion", - ]), - integerEnv("AGISO_TRADE_DETAIL_TIMEOUT_MS", [ - "platforms", - "agiso", - "tradeDetail", - "timeoutMs", - ]), - booleanEnv("AGISO_AUTO_DELIVERY_ENABLED", [ - "platforms", - "agiso", - "autoDelivery", - "enabled", - ]), - stringEnv("AGISO_AUTO_DELIVERY_ENDPOINT", [ - "platforms", - "agiso", - "autoDelivery", - "endpoint", - ]), - stringEnv("AGISO_AUTO_DELIVERY_API_VERSION", [ - "platforms", - "agiso", - "autoDelivery", - "apiVersion", - ]), - stringEnv("AGISO_MESSAGE_API_VERSION", [ - "platforms", - "agiso", - "messaging", - "apiVersion", - ]), - stringEnv("AGISO_SEND_MESSAGE_ENDPOINT", [ - "platforms", - "agiso", - "messaging", - "sendMessageEndpoint", - ]), - booleanEnv("AGISO_MESSAGING_ENABLED", [ - "platforms", - "agiso", - "messaging", - "enabled", - ]), stringEnv("NINETYONE_USER_ID", ["platforms", "ninetyone", "userId"]), stringEnv("NINETYONE_SECRET", ["platforms", "ninetyone", "secret"]), stringEnv("NINETYONE_VERSION", ["platforms", "ninetyone", "version"]), @@ -288,7 +224,6 @@ export const ENV_OVERRIDES: readonly EnvOverride[] = [ "cloudtentacles", "deviceType", ]), - stringEnv("TENCENT_REDEEM_PROOF_MODE", ["redeem", "proofMode"]), corsOriginsEnv("CORS_ALLOWED_ORIGINS", ["cors", "allowedOrigins"]), ]; diff --git a/apps/backend/src/config/runtime-validation.test.ts b/apps/backend/src/config/runtime-validation.test.ts index 6def1efc..ba403a20 100644 --- a/apps/backend/src/config/runtime-validation.test.ts +++ b/apps/backend/src/config/runtime-validation.test.ts @@ -112,21 +112,6 @@ function createRuntimeConfig(overrides: Record = {}): RuntimeCo server: { port: 3000, }, - browser: { - chromePath: '', - headless: null, - devtools: null, - keepAlive: null, - prewarm: null, - slowMoMs: null, - vncResolution: '1600x900x24', - }, - session: { - debug: false, - }, - ocr: { - baseUrl: '', - }, data: { root: '/tmp/order-site', }, @@ -154,27 +139,6 @@ function createRuntimeConfig(overrides: Record = {}): RuntimeCo ], }, platforms: { - agiso: { - appSecret: '', - tradeDetail: { - endpoint: 'https://gw-api.agiso.com/aldsIdle/Order/Detail', - apiVersion: '1', - timeoutMs: 5000, - }, - autoDelivery: { - enabled: true, - endpoint: 'https://gw-api.agiso.com/aldsIdle/Order/DummySend', - apiVersion: '1', - }, - messaging: { - enabled: false, - sendMessageEndpoint: 'https://gw-api.agiso.com/aldsIdle/ImMsg/SendMsg', - apiVersion: '1', - messageTemplate: '', - autoDeliveryMessageTemplate: '', - shops: {}, - }, - }, ninetyone: { userId: '', secret: '', @@ -222,8 +186,5 @@ function createRuntimeConfig(overrides: Record = {}): RuntimeCo deviceType: 0, }, }, - redeem: { - proofMode: 'full', - }, }, overrides) as RuntimeConfig } diff --git a/apps/backend/src/config/runtime-validation.ts b/apps/backend/src/config/runtime-validation.ts index 0e3fcebb..6a68eb80 100644 --- a/apps/backend/src/config/runtime-validation.ts +++ b/apps/backend/src/config/runtime-validation.ts @@ -47,7 +47,6 @@ export function validateRuntimeConfig( requireInteger(issues, 'database.maxConnections', config.database?.maxConnections, { min: 1 }) requireInteger(issues, 'orders.tokenTtlHours', config.orders?.tokenTtlHours, { min: 1 }) requireInteger(issues, 'admin.sessionTtlHours', config.admin?.sessionTtlHours, { min: 1 }) - requireInteger(issues, 'platforms.agiso.tradeDetail.timeoutMs', config.platforms?.agiso?.tradeDetail?.timeoutMs, { min: 1 }) requireInteger(issues, 'platforms.cloudtentacles.timeoutMs', config.platforms?.cloudtentacles?.timeoutMs, { min: 1 }) requireInteger(issues, 'platforms.cloudtentacles.bindUrlTtlSeconds', config.platforms?.cloudtentacles?.bindUrlTtlSeconds, { min: 1 }) requireInteger( diff --git a/apps/backend/src/index-kuaishou.ts b/apps/backend/src/index-kuaishou.ts deleted file mode 100644 index ec1ce6ea..00000000 --- a/apps/backend/src/index-kuaishou.ts +++ /dev/null @@ -1,71 +0,0 @@ -import process from "node:process"; - -import { createKuaishouApp } from "./app-kuaishou.js"; -import { runtimeConfig } from "./config/runtime.js"; -import { assertRuntimeConfigValid } from "./config/runtime-validation.js"; -import { bootstrapKuaishouCoreServices } from "./startup/bootstrap-kuaishou.js"; -import { createKuaishouShutdownController } from "./startup/shutdown-kuaishou.js"; -import { createStartupState, formatStartupError } from "./startup/state.js"; -import { logError, logInfo } from "./utils/logger.js"; - -const port = Number(runtimeConfig.server.port || 3000); -const host = "0.0.0.0"; -const startupState = createStartupState(); - -let shutdownController: ReturnType< - typeof createKuaishouShutdownController -> | null = null; - -try { - assertRuntimeConfigValid(runtimeConfig); -} catch (error) { - logError("[startup:kuaishou]", "运行时配置校验失败,服务停止启动", error); - process.exit(1); -} - -const app = createKuaishouApp({ - startupState, - isShutdownStarted: () => shutdownController?.isShutdownStarted() || false, - config: runtimeConfig, -}); - -const server = app.listen(port, host, () => { - logInfo( - "[startup:kuaishou]", - `order-site-backend kuaishou-lite listening on http://${host}:${port}` - ); - void bootstrapKuaishouCoreServices( - startupState, - () => shutdownController?.isShutdownStarted() || false - ); -}); - -shutdownController = createKuaishouShutdownController(server, startupState); - -server.on("error", (error) => { - logError("[startup:kuaishou]", "HTTP server failed", error); -}); - -process.on("SIGINT", () => { - void shutdownController?.shutdown("SIGINT"); -}); - -process.on("SIGTERM", () => { - void shutdownController?.shutdown("SIGTERM"); -}); - -process.on("unhandledRejection", (reason) => { - startupState.process.lastUnhandledRejection = { - time: new Date().toISOString(), - message: formatStartupError(reason), - }; - logError("[process:kuaishou]", "unhandled promise rejection", reason); -}); - -process.on("uncaughtException", (error) => { - startupState.process.lastUncaughtException = { - time: new Date().toISOString(), - message: formatStartupError(error), - }; - logError("[process:kuaishou]", "uncaught exception captured", error); -}); diff --git a/apps/backend/src/index.ts b/apps/backend/src/index.ts index f0bbbae0..ffb144c5 100644 --- a/apps/backend/src/index.ts +++ b/apps/backend/src/index.ts @@ -20,6 +20,7 @@ try { logError("[startup]", "运行时配置校验失败,服务停止启动", error); process.exit(1); } + const app = createApp({ startupState, isShutdownStarted: () => shutdownController?.isShutdownStarted() || false, @@ -27,7 +28,10 @@ const app = createApp({ }); const server = app.listen(port, host, () => { - logInfo("[startup]", `order-site-backend listening on http://${host}:${port}`); + logInfo( + "[startup]", + `order-site-backend kuaishou-lite listening on http://${host}:${port}` + ); void bootstrapCoreServices( startupState, () => shutdownController?.isShutdownStarted() || false diff --git a/apps/backend/src/routes/admin-kuaishou.ts b/apps/backend/src/routes/admin-kuaishou.ts deleted file mode 100644 index d329014c..00000000 --- a/apps/backend/src/routes/admin-kuaishou.ts +++ /dev/null @@ -1,34 +0,0 @@ -import { Router } from "express"; - -import authRouter from "./admin/auth.js"; -import auditLogsRouter from "./admin/audit-logs.js"; -import dashboardRouter from "./admin/dashboard.js"; -import inventoryRouter from "./admin/inventory-kuaishou.js"; -import messageDeliveriesRouter from "./admin/message-deliveries.js"; -import ordersRouter from "./admin/orders.js"; -import platformConfigRouter from "./admin/platform-config-kuaishou.js"; -import { requireAdminSession } from "./admin/shared.js"; -import tasksRouter from "./admin/tasks-kuaishou.js"; -import usersRouter from "./admin/users.js"; -import webhookEventsRouter from "./admin/webhook-events-kuaishou.js"; -import { buildNotFoundPayload } from "../utils/http.js"; - -const router = Router(); - -router.use(authRouter); -router.use(requireAdminSession); -router.use(dashboardRouter); -router.use(usersRouter); -router.use(auditLogsRouter); -router.use(platformConfigRouter); -router.use(ordersRouter); -router.use(tasksRouter); -router.use(inventoryRouter); -router.use(messageDeliveriesRouter); -router.use(webhookEventsRouter); - -router.use((req, res) => { - res.status(404).json(buildNotFoundPayload(req)); -}); - -export default router; diff --git a/apps/backend/src/routes/admin.ts b/apps/backend/src/routes/admin.ts index a0e80c04..6a8e3e2c 100644 --- a/apps/backend/src/routes/admin.ts +++ b/apps/backend/src/routes/admin.ts @@ -1,36 +1,34 @@ -import { Router } from 'express' +import { Router } from "express"; -import authRouter from './admin/auth.js' -import auditLogsRouter from './admin/audit-logs.js' -import dashboardRouter from './admin/dashboard.js' -import inventoryRouter from './admin/inventory.js' -import manualRedeemRouter from './admin/manual-redeem.js' -import messageDeliveriesRouter from './admin/message-deliveries.js' -import ordersRouter from './admin/orders.js' -import platformConfigRouter from './admin/platform-config.js' -import { requireAdminSession } from './admin/shared.js' -import tasksRouter from './admin/tasks.js' -import usersRouter from './admin/users.js' -import webhookEventsRouter from './admin/webhook-events.js' -import { buildNotFoundPayload } from '../utils/http.js' +import authRouter from "./admin/auth.js"; +import auditLogsRouter from "./admin/audit-logs.js"; +import dashboardRouter from "./admin/dashboard.js"; +import inventoryRouter from "./admin/inventory.js"; +import messageDeliveriesRouter from "./admin/message-deliveries.js"; +import ordersRouter from "./admin/orders.js"; +import platformConfigRouter from "./admin/platform-config.js"; +import { requireAdminSession } from "./admin/shared.js"; +import tasksRouter from "./admin/tasks.js"; +import usersRouter from "./admin/users.js"; +import webhookEventsRouter from "./admin/webhook-events.js"; +import { buildNotFoundPayload } from "../utils/http.js"; -const router = Router() +const router = Router(); -router.use(authRouter) -router.use(requireAdminSession) -router.use(dashboardRouter) -router.use(usersRouter) -router.use(auditLogsRouter) -router.use(platformConfigRouter) -router.use(ordersRouter) -router.use(tasksRouter) -router.use(manualRedeemRouter) -router.use(inventoryRouter) -router.use(messageDeliveriesRouter) -router.use(webhookEventsRouter) +router.use(authRouter); +router.use(requireAdminSession); +router.use(dashboardRouter); +router.use(usersRouter); +router.use(auditLogsRouter); +router.use(platformConfigRouter); +router.use(ordersRouter); +router.use(tasksRouter); +router.use(inventoryRouter); +router.use(messageDeliveriesRouter); +router.use(webhookEventsRouter); router.use((req, res) => { - res.status(404).json(buildNotFoundPayload(req)) -}) + res.status(404).json(buildNotFoundPayload(req)); +}); -export default router +export default router; diff --git a/apps/backend/src/routes/admin/inventory-kuaishou.ts b/apps/backend/src/routes/admin/inventory-kuaishou.ts deleted file mode 100644 index 652c7899..00000000 --- a/apps/backend/src/routes/admin/inventory-kuaishou.ts +++ /dev/null @@ -1,149 +0,0 @@ -import { Router } from "express"; - -import { - createAdminInventoryItem, - importAdminInventoryItems, - invalidateAdminInventoryItem, - releaseAdminInventoryItem, -} from "../../services/admin/write/inventory.js"; -import { - getAdminInventoryItems, - getAdminInventorySkuSuggestions, -} from "../../services/admin/admin-read-service.js"; -import { createJsonHandler, requireAdminRoles } from "./shared.js"; -import type { - AdminInventoryCreateRouteBody, - AdminInventoryImportRouteBody, - AdminInventoryInvalidateRouteBody, - AdminInventoryRouteParams, - AdminInventoryRouteQuery, - AdminInventorySkuSuggestionRouteQuery, -} from "../../types/admin-route-inputs.js"; -import type { AdminInventoryMutationResponse } from "../../types/admin-write-models.js"; - -const router = Router(); - -router.use("/inventory", requireAdminRoles(["admin", "operator"])); - -router.get( - "/inventory", - createJsonHandler( - (req) => - getAdminInventoryItems( - req.query as AdminInventoryRouteQuery, - req.adminSession || null - ), - { - successMessage: "ok", - errorMessage: "读取库存列表失败", - scope: "[admin-kuaishou/inventory]", - } - ) -); - -router.get( - "/inventory/sku-suggestions", - createJsonHandler( - (req) => - getAdminInventorySkuSuggestions( - req.query as AdminInventorySkuSuggestionRouteQuery, - req.adminSession || null - ), - { - successMessage: "ok", - errorMessage: "读取内部 SKU 建议失败", - scope: "[admin-kuaishou/inventory/sku-suggestions]", - } - ) -); - -router.post( - "/inventory", - createJsonHandler( - (req) => createAdminInventoryItem(req.body as AdminInventoryCreateRouteBody), - { - successMessage: "库存项已新增", - errorMessage: "新增库存项失败", - scope: "[admin-kuaishou/inventory]", - } - ) -); - -router.post( - "/inventory/import", - createJsonHandler( - (req) => importAdminInventoryItems(req.body as AdminInventoryImportRouteBody), - { - successMessage: "导入完成", - errorMessage: "导入库存凭据失败", - scope: "[admin-kuaishou/inventory/import]", - } - ) -); - -router.post( - "/inventory/:inventoryItemId/release", - requireAdminRoles(["admin"]), - createJsonHandler( - (req) => - releaseAdminInventoryItem( - (req.params as AdminInventoryRouteParams).inventoryItemId - ), - { - successMessage: "库存项已释放", - errorMessage: "释放库存项失败", - scope: "[admin-kuaishou/inventory/:inventoryItemId/release]", - audit: (req, data) => { - const result = data as AdminInventoryMutationResponse; - - return { - action: "inventory_item_released", - targetType: "inventory_item", - targetId: String( - (req.params as AdminInventoryRouteParams).inventoryItemId - ), - data: { - inventoryItemId: result.inventoryItem?.inventoryItemId, - skuCode: result.inventoryItem?.skuCode, - displayValue: result.inventoryItem?.displayValue, - }, - }; - }, - } - ) -); - -router.post( - "/inventory/:inventoryItemId/invalidate", - requireAdminRoles(["admin"]), - createJsonHandler( - (req) => - invalidateAdminInventoryItem( - (req.params as AdminInventoryRouteParams).inventoryItemId, - req.body as AdminInventoryInvalidateRouteBody - ), - { - successMessage: "库存项已作废", - errorMessage: "作废库存项失败", - scope: "[admin-kuaishou/inventory/:inventoryItemId/invalidate]", - audit: (req, data) => { - const result = data as AdminInventoryMutationResponse; - - return { - action: "inventory_item_invalidated", - targetType: "inventory_item", - targetId: String( - (req.params as AdminInventoryRouteParams).inventoryItemId - ), - data: { - inventoryItemId: result.inventoryItem?.inventoryItemId, - skuCode: result.inventoryItem?.skuCode, - invalidReason: result.inventoryItem?.invalidReason, - }, - }; - }, - } - ) -); - -export default router; diff --git a/apps/backend/src/routes/admin/inventory.ts b/apps/backend/src/routes/admin/inventory.ts index 8f135c02..b446d580 100644 --- a/apps/backend/src/routes/admin/inventory.ts +++ b/apps/backend/src/routes/admin/inventory.ts @@ -1,16 +1,16 @@ -import { Router } from 'express' +import { Router } from "express"; import { createAdminInventoryItem, importAdminInventoryItems, invalidateAdminInventoryItem, releaseAdminInventoryItem, -} from '../../services/admin/admin-write-service.js' +} from "../../services/admin/write/inventory.js"; import { getAdminInventoryItems, getAdminInventorySkuSuggestions, -} from '../../services/admin/admin-read-service.js' -import { createJsonHandler, requireAdminRoles } from './shared.js' +} from "../../services/admin/admin-read-service.js"; +import { createJsonHandler, requireAdminRoles } from "./shared.js"; import type { AdminInventoryCreateRouteBody, AdminInventoryImportRouteBody, @@ -18,100 +18,132 @@ import type { AdminInventoryRouteParams, AdminInventoryRouteQuery, AdminInventorySkuSuggestionRouteQuery, -} from '../../types/admin-route-inputs.js' -import type { AdminInventoryMutationResponse } from '../../types/admin-write-models.js' +} from "../../types/admin-route-inputs.js"; +import type { AdminInventoryMutationResponse } from "../../types/admin-write-models.js"; -const router = Router() +const router = Router(); -router.use('/inventory', requireAdminRoles(['admin', 'operator'])) +router.use("/inventory", requireAdminRoles(["admin", "operator"])); -router.get('/inventory', createJsonHandler( - (req) => getAdminInventoryItems( - req.query as AdminInventoryRouteQuery, - req.adminSession || null, - ), - { - successMessage: 'ok', - errorMessage: '读取库存列表失败', - scope: '[admin/inventory]', - }, -)) +router.get( + "/inventory", + createJsonHandler( + (req) => + getAdminInventoryItems( + req.query as AdminInventoryRouteQuery, + req.adminSession || null + ), + { + successMessage: "ok", + errorMessage: "读取库存列表失败", + scope: "[admin/inventory]", + } + ) +); -router.get('/inventory/sku-suggestions', createJsonHandler( - (req) => getAdminInventorySkuSuggestions( - req.query as AdminInventorySkuSuggestionRouteQuery, - req.adminSession || null, - ), - { - successMessage: 'ok', - errorMessage: '读取内部 SKU 建议失败', - scope: '[admin/inventory/sku-suggestions]', - }, -)) +router.get( + "/inventory/sku-suggestions", + createJsonHandler( + (req) => + getAdminInventorySkuSuggestions( + req.query as AdminInventorySkuSuggestionRouteQuery, + req.adminSession || null + ), + { + successMessage: "ok", + errorMessage: "读取内部 SKU 建议失败", + scope: "[admin/inventory/sku-suggestions]", + } + ) +); -router.post('/inventory', createJsonHandler( - (req) => createAdminInventoryItem(req.body as AdminInventoryCreateRouteBody), - { - successMessage: '库存项已新增', - errorMessage: '新增库存项失败', - scope: '[admin/inventory]', - }, -)) +router.post( + "/inventory", + createJsonHandler( + (req) => createAdminInventoryItem(req.body as AdminInventoryCreateRouteBody), + { + successMessage: "库存项已新增", + errorMessage: "新增库存项失败", + scope: "[admin/inventory]", + } + ) +); -router.post('/inventory/import', createJsonHandler( - (req) => importAdminInventoryItems(req.body as AdminInventoryImportRouteBody), - { - successMessage: '导入完成', - errorMessage: '导入库存凭据失败', - scope: '[admin/inventory/import]', - }, -)) +router.post( + "/inventory/import", + createJsonHandler( + (req) => importAdminInventoryItems(req.body as AdminInventoryImportRouteBody), + { + successMessage: "导入完成", + errorMessage: "导入库存凭据失败", + scope: "[admin/inventory/import]", + } + ) +); -router.post('/inventory/:inventoryItemId/release', requireAdminRoles(['admin']), createJsonHandler( - (req) => releaseAdminInventoryItem((req.params as AdminInventoryRouteParams).inventoryItemId), - { - successMessage: '库存项已释放', - errorMessage: '释放库存项失败', - scope: '[admin/inventory/:inventoryItemId/release]', - audit: (req, data) => { - const result = data as AdminInventoryMutationResponse - return { - action: 'inventory_item_released', - targetType: 'inventory_item', - targetId: String((req.params as AdminInventoryRouteParams).inventoryItemId), - data: { - inventoryItemId: result.inventoryItem?.inventoryItemId, - skuCode: result.inventoryItem?.skuCode, - displayValue: result.inventoryItem?.displayValue, - }, - } - }, - }, -)) +router.post( + "/inventory/:inventoryItemId/release", + requireAdminRoles(["admin"]), + createJsonHandler( + (req) => + releaseAdminInventoryItem( + (req.params as AdminInventoryRouteParams).inventoryItemId + ), + { + successMessage: "库存项已释放", + errorMessage: "释放库存项失败", + scope: "[admin/inventory/:inventoryItemId/release]", + audit: (req, data) => { + const result = data as AdminInventoryMutationResponse; -router.post('/inventory/:inventoryItemId/invalidate', requireAdminRoles(['admin']), createJsonHandler( - (req) => invalidateAdminInventoryItem( - (req.params as AdminInventoryRouteParams).inventoryItemId, - req.body as AdminInventoryInvalidateRouteBody, - ), - { - successMessage: '库存项已作废', - errorMessage: '作废库存项失败', - scope: '[admin/inventory/:inventoryItemId/invalidate]', - audit: (req, data) => { - const result = data as AdminInventoryMutationResponse - return { - action: 'inventory_item_invalidated', - targetType: 'inventory_item', - targetId: String((req.params as AdminInventoryRouteParams).inventoryItemId), - data: { - inventoryItemId: result.inventoryItem?.inventoryItemId, - skuCode: result.inventoryItem?.skuCode, - invalidReason: result.inventoryItem?.invalidReason, - }, - } - }, - }, -)) + return { + action: "inventory_item_released", + targetType: "inventory_item", + targetId: String( + (req.params as AdminInventoryRouteParams).inventoryItemId + ), + data: { + inventoryItemId: result.inventoryItem?.inventoryItemId, + skuCode: result.inventoryItem?.skuCode, + displayValue: result.inventoryItem?.displayValue, + }, + }; + }, + } + ) +); -export default router +router.post( + "/inventory/:inventoryItemId/invalidate", + requireAdminRoles(["admin"]), + createJsonHandler( + (req) => + invalidateAdminInventoryItem( + (req.params as AdminInventoryRouteParams).inventoryItemId, + req.body as AdminInventoryInvalidateRouteBody + ), + { + successMessage: "库存项已作废", + errorMessage: "作废库存项失败", + scope: "[admin/inventory/:inventoryItemId/invalidate]", + audit: (req, data) => { + const result = data as AdminInventoryMutationResponse; + + return { + action: "inventory_item_invalidated", + targetType: "inventory_item", + targetId: String( + (req.params as AdminInventoryRouteParams).inventoryItemId + ), + data: { + inventoryItemId: result.inventoryItem?.inventoryItemId, + skuCode: result.inventoryItem?.skuCode, + invalidReason: result.inventoryItem?.invalidReason, + }, + }; + }, + } + ) +); + +export default router; diff --git a/apps/backend/src/routes/admin/manual-redeem.ts b/apps/backend/src/routes/admin/manual-redeem.ts deleted file mode 100644 index dd4af358..00000000 --- a/apps/backend/src/routes/admin/manual-redeem.ts +++ /dev/null @@ -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 diff --git a/apps/backend/src/routes/admin/platform-config-kuaishou.ts b/apps/backend/src/routes/admin/platform-config-kuaishou.ts deleted file mode 100644 index f5cb0345..00000000 --- a/apps/backend/src/routes/admin/platform-config-kuaishou.ts +++ /dev/null @@ -1,19 +0,0 @@ -import { Router } from "express"; - -import { requireAdminRoles } from "./shared.js"; -import cloudtentaclesRouter from "./platform-config/cloudtentacles.js"; -import kuaishouCloudFulfillmentRouter from "./platform-config/kuaishou-cloud-fulfillment.js"; -import kuaishouEticketRouter from "./platform-config/kuaishou-eticket.js"; -import ninetyoneRouter from "./platform-config/ninetyone.js"; -import notificationsRouter from "./platform-config/notifications.js"; - -const router = Router(); - -router.use("/platform-config", requireAdminRoles(["admin"])); -router.use("/platform-config", notificationsRouter); -router.use("/platform-config", kuaishouEticketRouter); -router.use("/platform-config", ninetyoneRouter); -router.use("/platform-config", cloudtentaclesRouter); -router.use("/platform-config", kuaishouCloudFulfillmentRouter); - -export default router; diff --git a/apps/backend/src/routes/admin/platform-config.ts b/apps/backend/src/routes/admin/platform-config.ts index 3be79e58..f5cb0345 100644 --- a/apps/backend/src/routes/admin/platform-config.ts +++ b/apps/backend/src/routes/admin/platform-config.ts @@ -1,9 +1,7 @@ import { Router } from "express"; import { requireAdminRoles } from "./shared.js"; -import agisoRouter from "./platform-config/agiso.js"; import cloudtentaclesRouter from "./platform-config/cloudtentacles.js"; -import fulfillmentBindingsRouter from "./platform-config/fulfillment-bindings.js"; import kuaishouCloudFulfillmentRouter from "./platform-config/kuaishou-cloud-fulfillment.js"; import kuaishouEticketRouter from "./platform-config/kuaishou-eticket.js"; import ninetyoneRouter from "./platform-config/ninetyone.js"; @@ -12,12 +10,10 @@ import notificationsRouter from "./platform-config/notifications.js"; const router = Router(); router.use("/platform-config", requireAdminRoles(["admin"])); -router.use("/platform-config", agisoRouter); router.use("/platform-config", notificationsRouter); router.use("/platform-config", kuaishouEticketRouter); router.use("/platform-config", ninetyoneRouter); router.use("/platform-config", cloudtentaclesRouter); -router.use("/platform-config", fulfillmentBindingsRouter); router.use("/platform-config", kuaishouCloudFulfillmentRouter); export default router; diff --git a/apps/backend/src/routes/admin/platform-config/agiso.ts b/apps/backend/src/routes/admin/platform-config/agiso.ts deleted file mode 100644 index d92a7738..00000000 --- a/apps/backend/src/routes/admin/platform-config/agiso.ts +++ /dev/null @@ -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; diff --git a/apps/backend/src/routes/admin/platform-config/fulfillment-bindings.ts b/apps/backend/src/routes/admin/platform-config/fulfillment-bindings.ts deleted file mode 100644 index 054b92dc..00000000 --- a/apps/backend/src/routes/admin/platform-config/fulfillment-bindings.ts +++ /dev/null @@ -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; diff --git a/apps/backend/src/routes/admin/tasks-kuaishou.ts b/apps/backend/src/routes/admin/tasks-kuaishou.ts deleted file mode 100644 index 564b274f..00000000 --- a/apps/backend/src/routes/admin/tasks-kuaishou.ts +++ /dev/null @@ -1,162 +0,0 @@ -import type { Request } from "express"; -import { Router } from "express"; - -import { - getAdminTaskDetail, - getAdminTasks, -} from "../../services/admin/admin-read-service.js"; -import { - dispatchAdminTaskKuaishouCloudFulfillment, - prepareAdminTaskKuaishouCloudFulfillment, - refreshAdminTaskKuaishouCloudRoleInfo, - returnNumberAdminTaskKuaishouCloudFulfillment, -} from "../../services/admin/write/kuaishou-cloud-actions.js"; -import { - createJsonHandler, - requireAdminRoles, -} from "./shared.js"; -import type { - AdminTaskKuaishouCloudDispatchRouteBody, - AdminTaskRouteParams, - AdminTaskRouteQuery, -} from "../../types/admin-route-inputs.js"; -import type { AdminTaskActionResponse } from "../../types/admin-write-models.js"; - -const router = Router(); - -function getTaskId(req: Request): string | undefined { - return (req.params as AdminTaskRouteParams).taskId; -} - -router.get( - "/tasks", - createJsonHandler( - (req) => - getAdminTasks(req.query as AdminTaskRouteQuery, req.adminSession || null), - { - successMessage: "ok", - errorMessage: "读取任务列表失败", - scope: "[admin-kuaishou/tasks]", - } - ) -); - -router.get( - "/tasks/:taskId", - createJsonHandler( - (req) => getAdminTaskDetail(getTaskId(req), req.adminSession || null), - { - successMessage: "ok", - errorMessage: "读取任务详情失败", - scope: "[admin-kuaishou/tasks/:taskId]", - } - ) -); - -router.post( - "/tasks/:taskId/kuaishou-cloud/prepare", - requireAdminRoles(["admin", "operator"]), - createJsonHandler( - (req) => - prepareAdminTaskKuaishouCloudFulfillment( - getTaskId(req), - req.adminSession || null - ), - { - successMessage: "绑定资源已准备完成", - errorMessage: "准备绑定资源失败", - scope: "[admin-kuaishou/tasks/:taskId/kuaishou-cloud/prepare]", - audit: (req, data) => buildTaskAudit("task_kuaishou_cloud_prepare", req, data), - } - ) -); - -router.post( - "/tasks/:taskId/kuaishou-cloud/refresh-role-info", - requireAdminRoles(["admin", "operator", "support"]), - createJsonHandler( - (req) => - refreshAdminTaskKuaishouCloudRoleInfo( - getTaskId(req), - req.adminSession || null - ), - { - successMessage: "角色信息已刷新", - errorMessage: "刷新角色信息失败", - scope: "[admin-kuaishou/tasks/:taskId/kuaishou-cloud/refresh-role-info]", - audit: (req, data) => - buildTaskAudit("task_kuaishou_cloud_role_info_refreshed", req, data), - } - ) -); - -router.post( - "/tasks/:taskId/kuaishou-cloud/dispatch", - requireAdminRoles(["admin", "operator"]), - createJsonHandler( - (req) => - dispatchAdminTaskKuaishouCloudFulfillment( - getTaskId(req), - req.body as AdminTaskKuaishouCloudDispatchRouteBody, - req.adminSession || null - ), - { - successMessage: "已完成绑定确认并发货", - errorMessage: "执行发货失败", - scope: "[admin-kuaishou/tasks/:taskId/kuaishou-cloud/dispatch]", - audit: (req, data) => { - const result = data as AdminTaskActionResponse; - const body = req.body as AdminTaskKuaishouCloudDispatchRouteBody; - - return { - action: "task_kuaishou_cloud_dispatch", - targetType: "task", - targetId: String(getTaskId(req)), - data: { - taskId: result.task.taskId, - taskNo: result.task.taskNo, - status: result.task.status, - deliveryStatus: result.task.deliveryStatus, - ticketCodeProvided: Boolean(String(body.ticketCode || "").trim()), - }, - }; - }, - } - ) -); - -router.post( - "/tasks/:taskId/kuaishou-cloud/return-number", - requireAdminRoles(["admin", "operator"]), - createJsonHandler( - (req) => - returnNumberAdminTaskKuaishouCloudFulfillment( - getTaskId(req), - req.adminSession || null - ), - { - successMessage: "号码已退还", - errorMessage: "退还号码失败", - scope: "[admin-kuaishou/tasks/:taskId/kuaishou-cloud/return-number]", - audit: (req, data) => - buildTaskAudit("task_kuaishou_cloud_return_number", req, data), - } - ) -); - -function buildTaskAudit(action: string, req: Request, data: unknown) { - const result = data as AdminTaskActionResponse; - - return { - action, - targetType: "task", - targetId: String(getTaskId(req)), - data: { - taskId: result.task.taskId, - taskNo: result.task.taskNo, - status: result.task.status, - }, - }; -} - -export default router; diff --git a/apps/backend/src/routes/admin/tasks.ts b/apps/backend/src/routes/admin/tasks.ts index 8a167c1c..038d89af 100644 --- a/apps/backend/src/routes/admin/tasks.ts +++ b/apps/backend/src/routes/admin/tasks.ts @@ -1,42 +1,26 @@ +import type { Request } from "express"; import { Router } from "express"; -import { - closeAdminTask, - completeAdminTaskManualDispatch, - confirmAdminTaskAssistedRole, - dispatchAdminTaskKuaishouCloudFulfillment, - markAdminTaskManualReview, - prepareAdminTaskKuaishouCloudFulfillment, - refreshAdminTaskKuaishouCloudRoleInfo, - regenerateAdminTaskClaimLink, - redeemAdminTaskAssisted, - releaseAdminTaskInventoryBinding, - releaseAdminTaskInventory, - returnNumberAdminTaskKuaishouCloudFulfillment, - retryAdminTask, -} from "../../services/admin/admin-write-service.js"; import { getAdminTaskDetail, getAdminTasks, } from "../../services/admin/admin-read-service.js"; -import { getAdminTaskScreenshotPathWithTencentFallback } from "../../services/admin/admin-task-screenshot-service.js"; import { - createFileHandler, + dispatchAdminTaskKuaishouCloudFulfillment, + prepareAdminTaskKuaishouCloudFulfillment, + refreshAdminTaskKuaishouCloudRoleInfo, + returnNumberAdminTaskKuaishouCloudFulfillment, +} from "../../services/admin/write/kuaishou-cloud-actions.js"; +import { createJsonHandler, requireAdminRoles, } from "./shared.js"; -import type { Request } from "express"; import type { AdminTaskKuaishouCloudDispatchRouteBody, - AdminTaskManualDispatchRouteBody, AdminTaskRouteParams, AdminTaskRouteQuery, } from "../../types/admin-route-inputs.js"; -import type { - AdminTaskActionResponse, - AdminTaskBindingReleaseResponse, - AdminTaskManualDispatchResponse, -} from "../../types/admin-write-models.js"; +import type { AdminTaskActionResponse } from "../../types/admin-write-models.js"; const router = Router(); @@ -44,10 +28,6 @@ function getTaskId(req: Request): string | undefined { return (req.params as AdminTaskRouteParams).taskId; } -function getBindingId(req: Request): string | undefined { - return (req.params as AdminTaskRouteParams).bindingId; -} - router.get( "/tasks", createJsonHandler( @@ -73,239 +53,6 @@ router.get( ) ); -router.get( - "/tasks/:taskId/screenshot", - createFileHandler( - (req) => - getAdminTaskScreenshotPathWithTencentFallback(getTaskId(req), req.adminSession || null), - { - errorMessage: "读取任务截图失败", - scope: "[admin/tasks/:taskId/screenshot]", - } - ) -); - -router.post( - "/tasks/:taskId/retry", - requireAdminRoles(["admin", "operator"]), - createJsonHandler((req) => retryAdminTask(getTaskId(req)), { - successMessage: "任务已重试", - errorMessage: "重试任务失败", - scope: "[admin/tasks/:taskId/retry]", - }) -); - -router.post( - "/tasks/:taskId/release-inventory", - requireAdminRoles(["admin"]), - createJsonHandler((req) => releaseAdminTaskInventory(getTaskId(req)), { - successMessage: "库存凭据已释放", - errorMessage: "释放库存凭据失败", - scope: "[admin/tasks/:taskId/release-inventory]", - audit: (req, data) => { - const result = data as AdminTaskActionResponse; - return { - action: "task_release_inventory", - targetType: "task", - targetId: String(getTaskId(req)), - data: { - taskId: result.task.taskId, - taskNo: result.task.taskNo, - inventoryItemId: result.task.inventoryItemId, - }, - }; - }, - }) -); - -router.post( - "/tasks/:taskId/inventory-bindings/:bindingId/release", - requireAdminRoles(["admin"]), - createJsonHandler( - (req) => - releaseAdminTaskInventoryBinding(getTaskId(req), getBindingId(req)), - { - successMessage: "库存绑定已释放", - errorMessage: "释放库存绑定失败", - scope: "[admin/tasks/:taskId/inventory-bindings/:bindingId/release]", - audit: (req, data) => { - const result = data as AdminTaskBindingReleaseResponse; - return { - action: "task_release_inventory_binding", - targetType: "task_inventory_binding", - targetId: String(getBindingId(req)), - data: { - taskId: result.task.taskId, - taskNo: result.task.taskNo, - bindingId: result.bindingId, - inventoryItemId: result.inventoryItemId, - }, - }; - }, - } - ) -); - -router.post( - "/tasks/:taskId/regenerate-claim-link", - requireAdminRoles(["admin", "operator", "support"]), - createJsonHandler( - (req) => - regenerateAdminTaskClaimLink(getTaskId(req), req.adminSession || null), - { - successMessage: "领取链接已重新生成", - errorMessage: "重新生成领取链接失败", - scope: "[admin/tasks/:taskId/regenerate-claim-link]", - audit: (req, data) => { - const result = data as AdminTaskActionResponse; - return { - action: "task_regenerate_claim_link", - targetType: "task", - targetId: String(getTaskId(req)), - data: { - taskId: result.task.taskId, - taskNo: result.task.taskNo, - primaryClaimTokenId: result.task.primaryClaimTokenId, - }, - }; - }, - } - ) -); - -router.post( - "/tasks/:taskId/support-confirm-role", - requireAdminRoles(["admin", "operator", "support"]), - createJsonHandler( - (req) => - confirmAdminTaskAssistedRole(getTaskId(req), req.adminSession || null), - { - successMessage: "角色已确认", - errorMessage: "客服确认角色失败", - scope: "[admin/tasks/:taskId/support-confirm-role]", - audit: (req, data) => { - const result = data as AdminTaskActionResponse; - return { - action: "task_support_confirm_role", - targetType: "task", - targetId: String(getTaskId(req)), - data: { - taskId: result.task.taskId, - taskNo: result.task.taskNo, - status: result.task.status, - }, - }; - }, - } - ) -); - -router.post( - "/tasks/:taskId/support-redeem", - requireAdminRoles(["admin", "operator", "support"]), - createJsonHandler( - (req) => redeemAdminTaskAssisted(getTaskId(req), req.adminSession || null), - { - successMessage: "兑换任务已启动", - errorMessage: "客服发起兑换失败", - scope: "[admin/tasks/:taskId/support-redeem]", - audit: (req, data) => { - const result = data as AdminTaskActionResponse; - return { - action: "task_support_redeem", - targetType: "task", - targetId: String(getTaskId(req)), - data: { - taskId: result.task.taskId, - taskNo: result.task.taskNo, - status: result.task.status, - deliveryStatus: result.task.deliveryStatus, - }, - }; - }, - } - ) -); - -router.post( - "/tasks/:taskId/close", - requireAdminRoles(["admin", "operator", "support"]), - createJsonHandler((req) => closeAdminTask(getTaskId(req)), { - successMessage: "任务已关闭", - errorMessage: "关闭任务失败", - scope: "[admin/tasks/:taskId/close]", - audit: (req, data) => { - const result = data as AdminTaskActionResponse; - return { - action: "task_closed", - targetType: "task", - targetId: String(getTaskId(req)), - data: { - taskId: result.task.taskId, - taskNo: result.task.taskNo, - status: result.task.status, - }, - }; - }, - }) -); - -router.post( - "/tasks/:taskId/mark-manual-review", - requireAdminRoles(["admin", "operator"]), - createJsonHandler((req) => markAdminTaskManualReview(getTaskId(req)), { - successMessage: "任务已转人工处理", - errorMessage: "标记人工处理失败", - scope: "[admin/tasks/:taskId/mark-manual-review]", - audit: (req, data) => { - const result = data as AdminTaskActionResponse; - return { - action: "task_mark_manual_review", - targetType: "task", - targetId: String(getTaskId(req)), - data: { - taskId: result.task.taskId, - taskNo: result.task.taskNo, - status: result.task.status, - }, - }; - }, - }) -); - -router.post( - "/tasks/:taskId/complete-manual-dispatch", - requireAdminRoles(["admin", "operator"]), - createJsonHandler( - (req) => - completeAdminTaskManualDispatch( - getTaskId(req), - req.body as AdminTaskManualDispatchRouteBody, - req.adminSession || null - ), - { - successMessage: "人工履约结果已回写", - errorMessage: "回写人工履约结果失败", - scope: "[admin/tasks/:taskId/complete-manual-dispatch]", - audit: (req, data) => { - const result = data as AdminTaskManualDispatchResponse; - return { - action: "task_complete_manual_dispatch", - targetType: "task", - targetId: String(getTaskId(req)), - data: { - taskId: result.task.taskId, - taskNo: result.task.taskNo, - outcome: result.outcome, - deliveryStatus: result.task.deliveryStatus, - resultCode: result.task.resultCode, - }, - }; - }, - } - ) -); - router.post( "/tasks/:taskId/kuaishou-cloud/prepare", requireAdminRoles(["admin", "operator"]), @@ -319,19 +66,7 @@ router.post( successMessage: "绑定资源已准备完成", errorMessage: "准备绑定资源失败", scope: "[admin/tasks/:taskId/kuaishou-cloud/prepare]", - audit: (req, data) => { - const result = data as AdminTaskActionResponse; - return { - action: "task_kuaishou_cloud_prepare", - targetType: "task", - targetId: String(getTaskId(req)), - data: { - taskId: result.task.taskId, - taskNo: result.task.taskNo, - status: result.task.status, - }, - }; - }, + audit: (req, data) => buildTaskAudit("task_kuaishou_cloud_prepare", req, data), } ) ); @@ -349,19 +84,8 @@ router.post( successMessage: "角色信息已刷新", errorMessage: "刷新角色信息失败", scope: "[admin/tasks/:taskId/kuaishou-cloud/refresh-role-info]", - audit: (req, data) => { - const result = data as AdminTaskActionResponse; - return { - action: "task_kuaishou_cloud_role_info_refreshed", - targetType: "task", - targetId: String(getTaskId(req)), - data: { - taskId: result.task.taskId, - taskNo: result.task.taskNo, - status: result.task.status, - }, - }; - }, + audit: (req, data) => + buildTaskAudit("task_kuaishou_cloud_role_info_refreshed", req, data), } ) ); @@ -383,6 +107,7 @@ router.post( audit: (req, data) => { const result = data as AdminTaskActionResponse; const body = req.body as AdminTaskKuaishouCloudDispatchRouteBody; + return { action: "task_kuaishou_cloud_dispatch", targetType: "task", @@ -413,21 +138,25 @@ router.post( successMessage: "号码已退还", errorMessage: "退还号码失败", scope: "[admin/tasks/:taskId/kuaishou-cloud/return-number]", - audit: (req, data) => { - const result = data as AdminTaskActionResponse; - return { - action: "task_kuaishou_cloud_return_number", - targetType: "task", - targetId: String(getTaskId(req)), - data: { - taskId: result.task.taskId, - taskNo: result.task.taskNo, - status: result.task.status, - }, - }; - }, + audit: (req, data) => + buildTaskAudit("task_kuaishou_cloud_return_number", req, data), } ) ); +function buildTaskAudit(action: string, req: Request, data: unknown) { + const result = data as AdminTaskActionResponse; + + return { + action, + targetType: "task", + targetId: String(getTaskId(req)), + data: { + taskId: result.task.taskId, + taskNo: result.task.taskNo, + status: result.task.status, + }, + }; +} + export default router; diff --git a/apps/backend/src/routes/admin/webhook-events-kuaishou.ts b/apps/backend/src/routes/admin/webhook-events-kuaishou.ts deleted file mode 100644 index b60970a9..00000000 --- a/apps/backend/src/routes/admin/webhook-events-kuaishou.ts +++ /dev/null @@ -1,76 +0,0 @@ -import { Router } from "express"; - -import { - getAdminWebhookEventDetail, - getAdminWebhookEvents, -} from "../../services/admin/admin-read-service.js"; -import { createHttpError } from "../../utils/http.js"; -import { createJsonHandler, requireAdminRoles } from "./shared.js"; -import type { - AdminWebhookEventRouteParams, - AdminWebhookEventRouteQuery, -} from "../../types/admin-route-inputs.js"; - -const router = Router(); - -router.use("/webhook-events", requireAdminRoles(["admin", "operator"])); - -router.get( - "/webhook-events", - createJsonHandler( - (req) => getAdminWebhookEvents(req.query as AdminWebhookEventRouteQuery), - { - successMessage: "ok", - errorMessage: "读取 webhook 列表失败", - scope: "[admin-kuaishou/webhook-events]", - } - ) -); - -router.get( - "/webhook-events/:eventId", - createJsonHandler( - (req) => - getAdminWebhookEventDetail( - (req.params as AdminWebhookEventRouteParams).eventId - ), - { - successMessage: "ok", - errorMessage: "读取 webhook 详情失败", - scope: "[admin-kuaishou/webhook-events/:eventId]", - } - ) -); - -router.post( - "/webhook-events/:eventId/replay", - requireAdminRoles(["admin"]), - createJsonHandler( - () => { - throw createHttpError("快手轻量后端不支持重放旧 Webhook", { - statusCode: 409, - errorCode: "admin_webhook_replay_disabled", - }); - }, - { - successMessage: "Webhook 已重放", - errorMessage: "重放 webhook 失败", - scope: "[admin-kuaishou/webhook-events/:eventId/replay]", - audit: (req, data) => { - return { - action: "webhook_replayed", - targetType: "webhook_event", - targetId: String( - (req.params as AdminWebhookEventRouteParams).eventId - ), - data: { - disabled: true, - result: data, - }, - }; - }, - } - ) -); - -export default router; diff --git a/apps/backend/src/routes/admin/webhook-events.ts b/apps/backend/src/routes/admin/webhook-events.ts index 212b6aee..aff97045 100644 --- a/apps/backend/src/routes/admin/webhook-events.ts +++ b/apps/backend/src/routes/admin/webhook-events.ts @@ -1,60 +1,76 @@ -import { Router } from 'express' +import { Router } from "express"; -import { - replayAdminWebhookEvent, -} from '../../services/admin/admin-write-service.js' import { getAdminWebhookEventDetail, getAdminWebhookEvents, -} from '../../services/admin/admin-read-service.js' -import { createJsonHandler, requireAdminRoles } from './shared.js' +} from "../../services/admin/admin-read-service.js"; +import { createHttpError } from "../../utils/http.js"; +import { createJsonHandler, requireAdminRoles } from "./shared.js"; import type { AdminWebhookEventRouteParams, AdminWebhookEventRouteQuery, -} from '../../types/admin-route-inputs.js' -import type { AdminWebhookReplayResponse } from '../../types/admin-write-models.js' +} from "../../types/admin-route-inputs.js"; -const router = Router() +const router = Router(); -router.use('/webhook-events', requireAdminRoles(['admin', 'operator'])) +router.use("/webhook-events", requireAdminRoles(["admin", "operator"])); -router.get('/webhook-events', createJsonHandler( - (req) => getAdminWebhookEvents(req.query as AdminWebhookEventRouteQuery), - { - successMessage: 'ok', - errorMessage: '读取 webhook 列表失败', - scope: '[admin/webhook-events]', - }, -)) +router.get( + "/webhook-events", + createJsonHandler( + (req) => getAdminWebhookEvents(req.query as AdminWebhookEventRouteQuery), + { + successMessage: "ok", + errorMessage: "读取 webhook 列表失败", + scope: "[admin/webhook-events]", + } + ) +); -router.get('/webhook-events/:eventId', createJsonHandler( - (req) => getAdminWebhookEventDetail((req.params as AdminWebhookEventRouteParams).eventId), - { - successMessage: 'ok', - errorMessage: '读取 webhook 详情失败', - scope: '[admin/webhook-events/:eventId]', - }, -)) +router.get( + "/webhook-events/:eventId", + createJsonHandler( + (req) => + getAdminWebhookEventDetail( + (req.params as AdminWebhookEventRouteParams).eventId + ), + { + successMessage: "ok", + errorMessage: "读取 webhook 详情失败", + scope: "[admin/webhook-events/:eventId]", + } + ) +); -router.post('/webhook-events/:eventId/replay', requireAdminRoles(['admin']), createJsonHandler( - (req) => replayAdminWebhookEvent((req.params as AdminWebhookEventRouteParams).eventId), - { - successMessage: 'Webhook 已重放', - errorMessage: '重放 webhook 失败', - scope: '[admin/webhook-events/:eventId/replay]', - audit: (req, data) => { - const result = data as AdminWebhookReplayResponse - return { - action: 'webhook_replayed', - targetType: 'webhook_event', - targetId: String((req.params as AdminWebhookEventRouteParams).eventId), - data: { - eventId: result.eventId, - replayed: result.replayed, - }, - } +router.post( + "/webhook-events/:eventId/replay", + requireAdminRoles(["admin"]), + createJsonHandler( + () => { + throw createHttpError("快手轻量后端不支持重放旧 Webhook", { + statusCode: 409, + errorCode: "admin_webhook_replay_disabled", + }); }, - }, -)) + { + successMessage: "Webhook 已重放", + errorMessage: "重放 webhook 失败", + scope: "[admin/webhook-events/:eventId/replay]", + audit: (req, data) => { + return { + action: "webhook_replayed", + targetType: "webhook_event", + targetId: String( + (req.params as AdminWebhookEventRouteParams).eventId + ), + data: { + disabled: true, + result: data, + }, + }; + }, + } + ) +); -export default router +export default router; diff --git a/apps/backend/src/routes/claims-kuaishou.ts b/apps/backend/src/routes/claims-kuaishou.ts deleted file mode 100644 index 7c2632b7..00000000 --- a/apps/backend/src/routes/claims-kuaishou.ts +++ /dev/null @@ -1,71 +0,0 @@ -import { Router } from "express"; - -import { - confirmKuaishouCloudClaimRole, - getKuaishouCloudClaimDetail, - getKuaishouCloudClaimGuideAssetPath, - redeemKuaishouCloudClaim, - verifyKuaishouCloudClaimTicket, -} from "../services/claim/kuaishou-cloud-claim-service.js"; -import { - buildNotFoundPayload, - createRouteFileHandler, - createRouteHandler, -} from "../utils/http.js"; - -const router = Router(); - -router.get( - "/:token", - createRouteHandler((req) => getKuaishouCloudClaimDetail(req.params.token), { - errorMessage: "查询快手领取详情失败", - scope: "[claims-kuaishou/:token]", - }) -); - -router.post( - "/:token/kuaishou-cloud/verify-ticket", - createRouteHandler( - (req) => verifyKuaishouCloudClaimTicket(req.params.token, req.body), - { - successMessage: "核销码校验成功", - errorMessage: "校验核销码失败", - scope: "[claims-kuaishou/:token/kuaishou-cloud/verify-ticket]", - } - ) -); - -router.post( - "/:token/kuaishou-cloud/confirm-role", - createRouteHandler((req) => confirmKuaishouCloudClaimRole(req.params.token), { - successMessage: "角色已确认", - errorMessage: "确认角色失败", - scope: "[claims-kuaishou/:token/kuaishou-cloud/confirm-role]", - }) -); - -router.post( - "/:token/kuaishou-cloud/redeem", - createRouteHandler((req) => redeemKuaishouCloudClaim(req.params.token), { - successMessage: "兑换请求已提交", - errorMessage: "兑换失败", - scope: "[claims-kuaishou/:token/kuaishou-cloud/redeem]", - }) -); - -router.get( - "/assets/kuaishou-cloud/:filename", - createRouteFileHandler( - (req) => getKuaishouCloudClaimGuideAssetPath(req.params.filename), - { - errorMessage: "读取指引图片失败", - scope: "[claims-kuaishou/assets/kuaishou-cloud/:filename]", - } - ) -); - -router.use((req, res) => { - res.status(404).json(buildNotFoundPayload(req)); -}); - -export default router; diff --git a/apps/backend/src/routes/claims.ts b/apps/backend/src/routes/claims.ts index f1654d19..659b9573 100644 --- a/apps/backend/src/routes/claims.ts +++ b/apps/backend/src/routes/claims.ts @@ -1,97 +1,71 @@ -import { Router } from 'express' +import { Router } from "express"; -import { - closeClaimSession, - confirmClaimRole, - createClaimSession, - getClaimDetail, - getClaimScreenshotPath, - getClaimSessionSummary, - reloadClaimSession, - redeemClaimTask, -} from '../services/claim/claim-session-service.js' import { confirmKuaishouCloudClaimRole, + getKuaishouCloudClaimDetail, getKuaishouCloudClaimGuideAssetPath, redeemKuaishouCloudClaim, verifyKuaishouCloudClaimTicket, -} from '../services/claim/kuaishou-cloud-claim-service.js' +} from "../services/claim/kuaishou-cloud-claim-service.js"; import { buildNotFoundPayload, - buildSuccessPayload, createRouteFileHandler, createRouteHandler, - sendRouteError, -} from '../utils/http.js' +} from "../utils/http.js"; -const router = Router() +const router = Router(); -router.get('/:token', createRouteHandler( - (req) => getClaimDetail(req.params.token), - { errorMessage: '查询领取详情失败', scope: '[claims/:token]' }, -)) +router.get( + "/:token", + createRouteHandler((req) => getKuaishouCloudClaimDetail(req.params.token), { + errorMessage: "查询快手领取详情失败", + scope: "[claims/:token]", + }) +); -router.post('/:token/session', createRouteHandler( - (req) => createClaimSession(req.params.token, req.body), - { successMessage: '浏览器会话已创建', errorMessage: '创建领取会话失败', scope: '[claims/:token/session]' }, -)) +router.post( + "/:token/kuaishou-cloud/verify-ticket", + createRouteHandler( + (req) => verifyKuaishouCloudClaimTicket(req.params.token, req.body), + { + successMessage: "核销码校验成功", + errorMessage: "校验核销码失败", + scope: "[claims/:token/kuaishou-cloud/verify-ticket]", + } + ) +); -router.get('/:token/session/summary', createRouteHandler( - (req) => getClaimSessionSummary(req.params.token), - { errorMessage: '查询领取会话摘要失败', scope: '[claims/:token/session/summary]' }, -)) +router.post( + "/:token/kuaishou-cloud/confirm-role", + createRouteHandler((req) => confirmKuaishouCloudClaimRole(req.params.token), { + successMessage: "角色已确认", + errorMessage: "确认角色失败", + scope: "[claims/:token/kuaishou-cloud/confirm-role]", + }) +); -router.post('/:token/session/refresh', async (req, res) => { - try { - const data = await reloadClaimSession(req.params.token) - res.json(buildSuccessPayload(data, data.session ? (data.session.notice || '后端页面已刷新') : '领取会话已重置')) - } catch (error) { - sendRouteError(res, error, '刷新领取会话失败', '[claims/:token/session/refresh]') - } -}) +router.post( + "/:token/kuaishou-cloud/redeem", + createRouteHandler((req) => redeemKuaishouCloudClaim(req.params.token), { + successMessage: "兑换请求已提交", + errorMessage: "兑换失败", + scope: "[claims/:token/kuaishou-cloud/redeem]", + }) +); -router.delete('/:token/session', createRouteHandler( - (req) => closeClaimSession(req.params.token), - { successMessage: '领取会话已关闭', errorMessage: '关闭领取会话失败', scope: '[claims/:token/session]' }, -)) - -router.post('/:token/confirm-role', createRouteHandler( - (req) => confirmClaimRole(req.params.token), - { successMessage: '角色已确认', errorMessage: '确认角色失败', scope: '[claims/:token/confirm-role]' }, -)) - -router.post('/:token/redeem', createRouteHandler( - (req) => redeemClaimTask(req.params.token), - { successMessage: '兑换完成', errorMessage: '执行领取兑换失败', scope: '[claims/:token/redeem]' }, -)) - -router.post('/:token/kuaishou-cloud/verify-ticket', createRouteHandler( - (req) => verifyKuaishouCloudClaimTicket(req.params.token, req.body), - { successMessage: '核销码校验成功', errorMessage: '校验核销码失败', scope: '[claims/:token/kuaishou-cloud/verify-ticket]' }, -)) - -router.post('/:token/kuaishou-cloud/confirm-role', createRouteHandler( - (req) => confirmKuaishouCloudClaimRole(req.params.token), - { successMessage: '角色已确认', errorMessage: '确认角色失败', scope: '[claims/:token/kuaishou-cloud/confirm-role]' }, -)) - -router.post('/:token/kuaishou-cloud/redeem', createRouteHandler( - (req) => redeemKuaishouCloudClaim(req.params.token), - { successMessage: '兑换请求已提交', errorMessage: '兑换失败', scope: '[claims/:token/kuaishou-cloud/redeem]' }, -)) - -router.get('/:token/screenshot', createRouteFileHandler( - (req) => getClaimScreenshotPath(req.params.token), - { errorMessage: '读取领取截图失败', scope: '[claims/:token/screenshot]' }, -)) - -router.get('/assets/kuaishou-cloud/:filename', createRouteFileHandler( - (req) => getKuaishouCloudClaimGuideAssetPath(req.params.filename), - { errorMessage: '读取指引图片失败', scope: '[claims/assets/kuaishou-cloud/:filename]' }, -)) +router.get( + "/assets/kuaishou-cloud/:filename", + createRouteFileHandler( + (req) => getKuaishouCloudClaimGuideAssetPath(req.params.filename), + { + errorMessage: "读取指引图片失败", + scope: "[claims/assets/kuaishou-cloud/:filename]", + } + ) +); router.use((req, res) => { - res.status(404).json(buildNotFoundPayload(req)) -}) + res.status(404).json(buildNotFoundPayload(req)); +}); -export default router +export default router; diff --git a/apps/backend/src/routes/tencent.ts b/apps/backend/src/routes/tencent.ts deleted file mode 100644 index a4125e5d..00000000 --- a/apps/backend/src/routes/tencent.ts +++ /dev/null @@ -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 diff --git a/apps/backend/src/routes/webhooks.ts b/apps/backend/src/routes/webhooks.ts deleted file mode 100644 index 9c2c3aee..00000000 --- a/apps/backend/src/routes/webhooks.ts +++ /dev/null @@ -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 diff --git a/apps/backend/src/services/admin/admin-manual-redeem-service.ts b/apps/backend/src/services/admin/admin-manual-redeem-service.ts deleted file mode 100644 index bd58651a..00000000 --- a/apps/backend/src/services/admin/admin-manual-redeem-service.ts +++ /dev/null @@ -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 - -export async function createAdminManualRedeemTask( - payload: AdminManualRedeemCreateInput = {}, - session: AdminViewerSessionInput | null = null, -): Promise { - 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 { - 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 { - 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 { - 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 { - 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 { - 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 { - 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 { - 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 { - 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 { - 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() -} diff --git a/apps/backend/src/services/admin/admin-read-service.ts b/apps/backend/src/services/admin/admin-read-service.ts index bdda4330..f9e42c1f 100644 --- a/apps/backend/src/services/admin/admin-read-service.ts +++ b/apps/backend/src/services/admin/admin-read-service.ts @@ -38,7 +38,6 @@ import { mapAdminInventoryListItem } from './admin-inventory-read-helpers.js' import { mapAdminOrderListItem, summarizeOrderItems } from './admin-order-read-helpers.js' import { mapAdminWebhookEvent } from './admin-webhook-read-helpers.js' import { - buildOrderAgisoAutoDeliverySummary, buildOrderBindingSummary, createTaskBindingSummaryFromBindings, getRequiredTask, @@ -130,7 +129,6 @@ export async function getAdminOrderDetail(orderId: AdminEntityIdInput): Promise< ? order.raw_payload_json : JSON.parse(String(order.raw_payload_json || '{}')), bindingSummary: buildOrderBindingSummary(tasks, taskBindingSummaryMap), - agisoAutoDelivery: buildOrderAgisoAutoDeliverySummary(order, tasks), }, items: items.map((item) => ({ orderItemId: item.id, diff --git a/apps/backend/src/services/admin/admin-service.ts b/apps/backend/src/services/admin/admin-service.ts deleted file mode 100644 index 6a5767ab..00000000 --- a/apps/backend/src/services/admin/admin-service.ts +++ /dev/null @@ -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' diff --git a/apps/backend/src/services/admin/admin-task-read-helpers.ts b/apps/backend/src/services/admin/admin-task-read-helpers.ts index ae9092db..b13b9e01 100644 --- a/apps/backend/src/services/admin/admin-task-read-helpers.ts +++ b/apps/backend/src/services/admin/admin-task-read-helpers.ts @@ -10,13 +10,11 @@ import { } from './admin-read-shared-helpers.js' import type { - AdminAgisoAutoDeliveryStatus, AdminTaskBindingSummary, AdminTaskListItem, } from '../../types/admin-read-models.js' import type { AdminTaskActionPayload } from '../../types/admin-write-models.js' import type { - OrderRow, TaskEventRow, TaskInventoryBindingRow, TaskInventoryBindingSummaryRow, @@ -47,24 +45,11 @@ type OrderBindingSummary = { userBindingStatus: string } -type OrderAgisoAutoDeliverySummary = AdminAgisoAutoDeliveryStatus & { - sourceTaskId: number | null - sourceTaskNo: string - totalTaskCount: number - deliveredTaskCount: number -} - -type OrderAgisoAutoDeliveryCandidate = AdminAgisoAutoDeliveryStatus & { - sourceTaskId: number | null - sourceTaskNo: string -} - export function mapAdminTaskSummary( task: TaskRow, bindingSummary: AdminTaskBindingSummary = createEmptyTaskBindingSummary(), ): JsonRecord { const binding = buildTaskBindingState(task) - const taskContext = parseTaskContext(task) return { taskId: task.id, @@ -81,7 +66,6 @@ export function mapAdminTaskSummary( lastError: task.last_error, retryCount: getTaskRetryCount(task), bindingSummary, - agisoAutoDelivery: mapAgisoAutoDeliveryContext(taskContext.agisoAutoDelivery), createdAt: task.created_at, updatedAt: task.updated_at, } @@ -124,7 +108,6 @@ export function mapAdminTaskListItem( viewerContext: AdminViewerContext = createAdminViewerContext(), ): AdminTaskListItem { const binding = buildTaskBindingState(task) - const taskContext = parseTaskContext(task) return { taskId: task.id, @@ -148,7 +131,6 @@ export function mapAdminTaskListItem( redeemedAt: task.redeemed_at, retryCount: getTaskRetryCount(task), bindingSummary, - agisoAutoDelivery: mapAgisoAutoDeliveryContext(taskContext.agisoAutoDelivery), lastError: task.last_error, createdAt: task.created_at, updatedAt: task.updated_at, @@ -315,63 +297,6 @@ export function buildOrderBindingSummary( } } -export function buildOrderAgisoAutoDeliverySummary( - order: Partial | null | undefined, - tasks: TaskRow[] = [], -): OrderAgisoAutoDeliverySummary | null { - if (String(order?.provider || '').trim() !== 'agiso' || String(order?.platform || '').trim() !== 'xianyu') { - return null - } - - const normalizedTasks = Array.isArray(tasks) ? tasks.filter(Boolean) : [] - const totalTaskCount = normalizedTasks.length - const deliveredTaskCount = normalizedTasks.filter((task) => String(task?.delivery_status || '').trim() === 'delivered').length - const latest = normalizedTasks.reduce((best, task) => { - const autoDelivery = mapAgisoAutoDeliveryContext(parseTaskContext(task).agisoAutoDelivery) - - if (!autoDelivery) { - return best - } - - const candidate = { - ...autoDelivery, - sourceTaskId: Number(task.id || 0) || null, - sourceTaskNo: String(task.task_no || '').trim(), - } - const candidateTime = Date.parse(String(candidate.updatedAt || task.updated_at || '')) - const bestTime = Date.parse(String(best?.updatedAt || '')) - - if (!best || (Number.isFinite(candidateTime) && (!Number.isFinite(bestTime) || candidateTime >= bestTime))) { - return candidate - } - - return best - }, null) - - if (latest) { - return { - ...latest, - totalTaskCount, - deliveredTaskCount, - } - } - - return { - status: totalTaskCount === 0 ? 'not_started' : deliveredTaskCount >= totalTaskCount ? 'pending' : 'waiting', - trigger: '', - reason: deliveredTaskCount >= totalTaskCount ? '' : 'waiting_other_tasks', - platformOrderId: String(order?.platform_order_id || '').trim(), - responseStatus: 0, - errorMessage: '', - requestId: '', - updatedAt: null, - sourceTaskId: null, - sourceTaskNo: '', - totalTaskCount, - deliveredTaskCount, - } -} - function canReleaseTaskInventoryBinding(task: TaskRow | null | undefined, binding: TaskInventoryBindingLike | null | undefined): boolean { if (!task || !binding) { return false @@ -469,24 +394,6 @@ function isTaskSystemBound(task: TaskRow | null | undefined): boolean { return Boolean(task && (getTaskPrimaryInventoryItemId(task) || getTaskPrimaryClaimTokenId(task))) } -function mapAgisoAutoDeliveryContext(value: unknown): AdminAgisoAutoDeliveryStatus | null { - if (!value || typeof value !== 'object') { - return null - } - - const record = value as JsonRecord - return { - status: String(record.status || '').trim(), - trigger: String(record.trigger || '').trim(), - reason: String(record.reason || '').trim(), - platformOrderId: String(record.platformOrderId || '').trim(), - responseStatus: Number(record.responseStatus || 0), - errorMessage: String(record.errorMessage || '').trim(), - requestId: String(record.requestId || '').trim(), - updatedAt: record.updatedAt || null, - } -} - function normalizeRecord(value: unknown): JsonRecord { return value && typeof value === 'object' && !Array.isArray(value) ? value : {} } diff --git a/apps/backend/src/services/admin/admin-task-screenshot-service.ts b/apps/backend/src/services/admin/admin-task-screenshot-service.ts deleted file mode 100644 index ffb2d698..00000000 --- a/apps/backend/src/services/admin/admin-task-screenshot-service.ts +++ /dev/null @@ -1,28 +0,0 @@ -import { getTencentBrowserSessionReviewScreenshotPath } from '../session/session.js' -import { createHttpError } from '../../utils/http.js' -import { getRequiredTask } from './admin-task-read-helpers.js' - -import type { - AdminEntityIdInput, - AdminViewerSessionInput, -} from '../../types/admin-read-inputs.js' - -export async function getAdminTaskScreenshotPathWithTencentFallback( - taskId: AdminEntityIdInput, - _session: AdminViewerSessionInput | null = null, -): Promise { - const task = await getRequiredTask(taskId) - - if (task.screenshot_path) { - return task.screenshot_path - } - - if (task.browser_session_id) { - return getTencentBrowserSessionReviewScreenshotPath(task.browser_session_id) - } - - throw createHttpError('当前任务还没有可查看截图', { - statusCode: 404, - errorCode: 'admin_task_screenshot_not_found', - }) -} diff --git a/apps/backend/src/services/admin/admin-webhook-read-helpers.ts b/apps/backend/src/services/admin/admin-webhook-read-helpers.ts index 76827730..d7fddcc0 100644 --- a/apps/backend/src/services/admin/admin-webhook-read-helpers.ts +++ b/apps/backend/src/services/admin/admin-webhook-read-helpers.ts @@ -1,6 +1,5 @@ import { listTasksByOrderId } from '../../repositories/task-repo.js' import { formatFenToAmount, parseAmountToFen } from '../../utils/money.js' -import { extractAgisoTradePayload, resolveAgisoTradePlatformOrderId } from '../order/agiso-trade-parsing.js' import { safeParseJson } from './admin-query-utils.js' import { resolveDisplayShopName } from './admin-read-shared-helpers.js' @@ -49,7 +48,7 @@ export async function mapAdminWebhookEvent( const mapped = { eventId: item.id, - provider: item.provider || 'agiso', + provider: item.provider || '', platform: item.platform, platformRaw: pickFirstNonEmpty([ query.fromPlatform, @@ -82,7 +81,7 @@ export async function mapAdminWebhookEvent( visibilityLevel: resolveWebhookVisibilityLevel(item.process_error), relatedOrderId: item.related_order_id, createdAt: item.created_at, - platformOrderId: resolveAgisoTradePlatformOrderId(payload), + platformOrderId: resolveWebhookPlatformOrderId(item, payload), buyerId: pickFirstNonEmpty([ payload.buyer_id, payload.buyerId, @@ -135,7 +134,44 @@ export async function mapAdminWebhookEvent( } function extractWebhookPayload(body: JsonRecord): JsonRecord { - return extractAgisoTradePayload(body) + const candidates = [ + body.data, + body.Data, + body.payload, + body.Payload, + body.message, + body.Message, + ] + + for (const candidate of candidates) { + if (candidate && typeof candidate === 'object' && !Array.isArray(candidate)) { + return normalizeRecord(candidate) + } + } + + return body +} + +function resolveWebhookPlatformOrderId(item: WebhookEventRow, payload: JsonRecord): string { + const fromPayload = pickFirstNonEmpty([ + payload.biz_order_id, + payload.bizOrderId, + payload.BizOrderId, + payload.tid, + payload.Tid, + payload.order_id, + payload.orderId, + payload.OrderId, + payload.oid, + payload.Oid, + ]) + + if (fromPayload) { + return fromPayload + } + + const parts = String(item.event_key || '').split(':').map((part) => part.trim()).filter(Boolean) + return parts.find((part) => /^\d{6,}$/.test(part)) || '' } function extractWebhookItemSources(payload: unknown): JsonRecord[] { diff --git a/apps/backend/src/services/admin/admin-write-service.test.ts b/apps/backend/src/services/admin/admin-write-service.test.ts deleted file mode 100644 index 0ada829a..00000000 --- a/apps/backend/src/services/admin/admin-write-service.test.ts +++ /dev/null @@ -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', - }), - }), - /已兑换任务不能关闭/, - ) -}) diff --git a/apps/backend/src/services/admin/admin-write-service.ts b/apps/backend/src/services/admin/admin-write-service.ts deleted file mode 100644 index 9deebe15..00000000 --- a/apps/backend/src/services/admin/admin-write-service.ts +++ /dev/null @@ -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' diff --git a/apps/backend/src/services/admin/platform-config/agiso-service.ts b/apps/backend/src/services/admin/platform-config/agiso-service.ts deleted file mode 100644 index 2c7cdcd6..00000000 --- a/apps/backend/src/services/admin/platform-config/agiso-service.ts +++ /dev/null @@ -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 - -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)), - } -} diff --git a/apps/backend/src/services/admin/platform-config/agiso-template.ts b/apps/backend/src/services/admin/platform-config/agiso-template.ts deleted file mode 100644 index 0aec7536..00000000 --- a/apps/backend/src/services/admin/platform-config/agiso-template.ts +++ /dev/null @@ -1,6 +0,0 @@ -export function normalizeAgisoMessageTemplate(value: unknown): string { - return String(value || "") - .replaceAll("\\r\\n", "\n") - .replaceAll("\\n", "\n") - .replaceAll("\r\n", "\n"); -} diff --git a/apps/backend/src/services/admin/platform-config/domain.test.ts b/apps/backend/src/services/admin/platform-config/domain.test.ts index 3f69a6a6..4670b7d4 100644 --- a/apps/backend/src/services/admin/platform-config/domain.test.ts +++ b/apps/backend/src/services/admin/platform-config/domain.test.ts @@ -2,101 +2,15 @@ import test from 'node:test' import assert from 'node:assert/strict' import { - applyOptionalStringField, findMatchingObservedBinding, - mapAdminAgisoMessagingDefaults, - mapAdminAgisoShopConfigItem, - mapAdminFulfillmentBindingConfigItem, mapAdminObservedProductItem, matchesObservedProduct, } from './domain.js' -test('mapAdminAgisoMessagingDefaults normalizes escaped newlines', () => { - assert.deepEqual( - mapAdminAgisoMessagingDefaults({ - messageTemplate: ' hello\\nworld ', - autoDeliveryMessageTemplate: ' done\\nnow ', - }), - { - messageTemplate: 'hello\nworld', - autoDeliveryMessageTemplate: 'done\nnow', - }, - ) -}) - -test('mapAdminAgisoShopConfigItem masks secrets and reports configured flags', () => { - assert.deepEqual( - mapAdminAgisoShopConfigItem('1001', { - shopName: ' 店铺A ', - accessToken: 'abcdef1234567890', - enabled: true, - messageTemplate: ' hi\\nall ', - autoDeliveryMessageTemplate: ' ok ', - appSecret: 'secret-1', - apiVersion: ' v2 ', - sendMessageEndpoint: ' /send ', - }), - { - shopId: '1001', - shopName: '店铺A', - accessToken: 'abcdef1234567890', - accessTokenMasked: 'abcdef****567890', - enabled: true, - messageTemplate: 'hi\nall', - autoDeliveryMessageTemplate: 'ok', - appSecretConfigured: true, - apiVersion: 'v2', - sendMessageEndpoint: '/send', - }, - ) -}) - -test('applyOptionalStringField updates and clears normalized template fields', () => { - const target = { messageTemplate: 'old' } - applyOptionalStringField(target, 'messageTemplate', { messageTemplate: ' next\\nline ' }) - assert.deepEqual(target, { messageTemplate: 'next\nline' }) - - applyOptionalStringField(target, 'messageTemplate', { messageTemplate: ' ' }) - assert.deepEqual(target, {}) -}) - -test('mapAdminFulfillmentBindingConfigItem normalizes binding shape', () => { - assert.deepEqual( - mapAdminFulfillmentBindingConfigItem({ - provider: ' agiso ', - platform: ' xianyu ', - shopId: ' shop-1 ', - skuCode: ' sku-1 ', - priority: '80', - match: { - externalSkuCode: ' ext-1 ', - }, - }), - { - provider: 'agiso', - platform: 'xianyu', - shopId: 'shop-1', - shopName: '', - skuCode: 'sku-1', - skuName: '', - profileKey: '', - enabled: true, - priority: 80, - config: {}, - match: { - externalSkuCode: 'ext-1', - externalItemId: '', - externalSkuName: '', - config: {}, - }, - }, - ) -}) - test('matchesObservedProduct honors provider platform shop and external fields', () => { const binding = { - provider: 'agiso', - platform: 'xianyu', + provider: '91kaquan', + platform: 'kuaishou', shopId: 'shop-1', match: { externalSkuCode: 'sku-ext-1', @@ -107,8 +21,8 @@ test('matchesObservedProduct honors provider platform shop and external fields', assert.equal( matchesObservedProduct(binding, { - provider: 'agiso', - platform: 'xianyu', + provider: '91kaquan', + platform: 'kuaishou', shopId: 'shop-1', externalSkuCode: 'sku-ext-1', }), @@ -117,8 +31,8 @@ test('matchesObservedProduct honors provider platform shop and external fields', assert.equal( matchesObservedProduct(binding, { - provider: 'agiso', - platform: 'xianyu', + provider: '91kaquan', + platform: 'kuaishou', shopId: 'shop-2', externalSkuCode: 'sku-ext-1', }), @@ -129,8 +43,8 @@ test('matchesObservedProduct honors provider platform shop and external fields', test('mapAdminObservedProductItem attaches matched binding summary', () => { const bindings = [ { - provider: 'agiso', - platform: 'xianyu', + provider: '91kaquan', + platform: 'kuaishou', shopId: 'shop-1', skuCode: 'sku-1', skuName: 'SKU 1', @@ -141,8 +55,8 @@ test('mapAdminObservedProductItem attaches matched binding summary', () => { }, ] const observed = { - provider: 'agiso', - platform: 'xianyu', + provider: '91kaquan', + platform: 'kuaishou', shopId: 'shop-1', shopName: '店铺1', externalItemId: '', @@ -154,8 +68,8 @@ test('mapAdminObservedProductItem attaches matched binding summary', () => { assert.deepEqual(findMatchingObservedBinding(bindings, observed), bindings[0]) assert.deepEqual(mapAdminObservedProductItem(observed, bindings), { - provider: 'agiso', - platform: 'xianyu', + provider: '91kaquan', + platform: 'kuaishou', shopId: 'shop-1', shopName: '店铺1', externalItemId: '', diff --git a/apps/backend/src/services/admin/platform-config/domain.ts b/apps/backend/src/services/admin/platform-config/domain.ts index a84c41d0..690ac297 100644 --- a/apps/backend/src/services/admin/platform-config/domain.ts +++ b/apps/backend/src/services/admin/platform-config/domain.ts @@ -1,70 +1,5 @@ -import { normalizeAgisoMessageTemplate } from './agiso-template.js' -import { maskSecret } from './mappers.js' - type JsonObject = Record -export function mapAdminAgisoMessagingDefaults(defaults: JsonObject = {}) { - return { - messageTemplate: normalizeAgisoMessageTemplate(String(defaults.messageTemplate || '').trim()), - autoDeliveryMessageTemplate: normalizeAgisoMessageTemplate( - String(defaults.autoDeliveryMessageTemplate || '').trim(), - ), - } -} - -export function mapAdminAgisoShopConfigItem(shopId: unknown, config: JsonObject = {}) { - return { - shopId, - shopName: String(config.shopName || '').trim(), - accessToken: String(config.accessToken || '').trim(), - accessTokenMasked: maskSecret(config.accessToken), - enabled: typeof config.enabled === 'boolean' ? config.enabled : null, - messageTemplate: normalizeAgisoMessageTemplate(String(config.messageTemplate || '').trim()), - autoDeliveryMessageTemplate: normalizeAgisoMessageTemplate( - String(config.autoDeliveryMessageTemplate || '').trim(), - ), - appSecretConfigured: Boolean(String(config.appSecret || '').trim()), - apiVersion: String(config.apiVersion || '').trim(), - sendMessageEndpoint: String(config.sendMessageEndpoint || '').trim(), - } -} - -export function applyOptionalStringField(target: JsonObject, key: string, source: JsonObject) { - if (!source || typeof source[key] !== 'string') { - return - } - - const value = String(source[key] || '').trim() - if (value) { - target[key] = normalizeAgisoMessageTemplate(value) - return - } - - delete target[key] -} - -export function mapAdminFulfillmentBindingConfigItem(item: JsonObject) { - const match = item?.match || {} - return { - provider: String(item?.provider || '').trim(), - platform: String(item?.platform || '').trim(), - shopId: String(item?.shopId || '').trim(), - shopName: String(item?.shopName || '').trim(), - skuCode: String(item?.skuCode || '').trim(), - skuName: String(item?.skuName || '').trim(), - profileKey: String(item?.profileKey || '').trim(), - enabled: item?.enabled !== false, - priority: Number(item?.priority || 100), - config: item?.config || {}, - match: { - externalSkuCode: String(match.externalSkuCode || '').trim(), - externalItemId: String(match.externalItemId || '').trim(), - externalSkuName: String(match.externalSkuName || '').trim(), - config: match.config || {}, - }, - } -} - export function matchesObservedProduct(binding: JsonObject, observed: JsonObject) { const provider = String(binding?.provider || '').trim() const platform = String(binding?.platform || '').trim() diff --git a/apps/backend/src/services/admin/platform-config/fulfillment-bindings-service.ts b/apps/backend/src/services/admin/platform-config/fulfillment-bindings-service.ts deleted file mode 100644 index 8606a908..00000000 --- a/apps/backend/src/services/admin/platform-config/fulfillment-bindings-service.ts +++ /dev/null @@ -1,103 +0,0 @@ -import { getAgisoShopConfig } from '../../platforms/agiso/shop-config-service.js' -import { - filterLegacyOrderFulfillmentBindings, - getOrderFulfillmentBindingsFilePath, - getLegacyOrderFulfillmentBindingConfigs, - saveOrderFulfillmentBindingConfigs, -} from '../../order/fulfillment-binding-config-service.js' -import { enrichAgisoXianyuTradeOrder } from '../../platforms/agiso/xianyu/order-detail-service.js' -import { syncConfiguredFulfillmentBindings } from '../../bootstrap/fulfillment-bootstrap-service.js' -import { getFulfillmentProfileByKey } from '../../../repositories/fulfillment-profile-repo.js' -import { createHttpError } from '../../../utils/http.js' -import { - buildFulfillmentLookupResult, - normalizeFulfillmentLookupPayload, - resolveFulfillmentLookupDetail, -} from './fulfillment.js' -import { listAdminObservedProducts } from './observed-products.js' -import { validateAdminFulfillmentBindingConfigs } from './validation.js' -import { mapAdminFulfillmentBindingConfigItem } from './domain.js' - -import type { - AdminFulfillmentBindingConfigSaveInput, - AdminFulfillmentBindingLookupInput, -} from '../../../types/admin-write-inputs.js' - -type JsonObject = Record - -export async function getAdminFulfillmentBindingConfigs() { - const bindings = getLegacyOrderFulfillmentBindingConfigs() - - return { - filePath: getOrderFulfillmentBindingsFilePath(), - bindings: bindings.map(mapAdminFulfillmentBindingConfigItem), - observedProducts: await listAdminObservedProducts(bindings), - } -} - -export async function lookupAdminFulfillmentBindingOrder( - payload: AdminFulfillmentBindingLookupInput = {}, -) { - const { provider, platform, shopId, platformOrderId } = normalizeFulfillmentLookupPayload(payload) - - const detailResult = await enrichAgisoXianyuTradeOrder({ - provider, - platform, - shopId, - shopName: '', - platformOrderId, - orderStatus: 'created', - payStatus: 'unpaid', - buyerId: '', - buyerName: '', - receiverContact: '', - totalAmount: 0, - currency: 'CNY', - paidAt: null, - rawPayload: {}, - items: [], - }, { - requestId: `admin-fulfillment-lookup:${shopId}:${platformOrderId}`, - }) - - const { detail } = resolveFulfillmentLookupDetail(detailResult, platformOrderId) - const bindings = getLegacyOrderFulfillmentBindingConfigs() - return buildFulfillmentLookupResult({ - provider, - platform, - shopId, - platformOrderId, - detail, - detailResult, - bindings, - fallbackShopName: getAgisoShopConfig(shopId)?.shopName, - }) -} - -export async function updateAdminFulfillmentBindingConfigs( - payload: AdminFulfillmentBindingConfigSaveInput = {}, -) { - const bindingsInput = Array.isArray(payload.bindings) ? payload.bindings : [] - const normalizedBindings = await validateAdminFulfillmentBindingConfigs(bindingsInput, { - getFulfillmentProfileByKey, - }) - assertLegacyBindingConfigsOnly(normalizedBindings) - const saved = saveOrderFulfillmentBindingConfigs(normalizedBindings) - await syncConfiguredFulfillmentBindings() - - return { - filePath: getOrderFulfillmentBindingsFilePath(), - bindings: saved.map(mapAdminFulfillmentBindingConfigItem), - } -} - -function assertLegacyBindingConfigsOnly(bindings: JsonObject[] = []) { - if (filterLegacyOrderFulfillmentBindings(bindings).length === bindings.length) { - return - } - - throw createHttpError('快手履约规则请改到“快手 Cloud 新履约”页维护,旧履约规则仅保留给 Agiso 等历史场景', { - statusCode: 400, - errorCode: 'admin_fulfillment_bindings_kuaishou_cloud_moved', - }) -} diff --git a/apps/backend/src/services/admin/platform-config/fulfillment.test.ts b/apps/backend/src/services/admin/platform-config/fulfillment.test.ts deleted file mode 100644 index 1f43e4df..00000000 --- a/apps/backend/src/services/admin/platform-config/fulfillment.test.ts +++ /dev/null @@ -1,124 +0,0 @@ -import test from 'node:test' -import assert from 'node:assert/strict' - -import { - buildFulfillmentLookupResult, - normalizeFulfillmentLookupPayload, - resolveFulfillmentLookupDetail, -} from './fulfillment.js' - -test('normalizeFulfillmentLookupPayload validates required fields and defaults provider/platform', () => { - assert.deepEqual( - normalizeFulfillmentLookupPayload({ - shopId: ' shop-1 ', - platformOrderId: ' order-1 ', - }), - { - provider: 'agiso', - platform: 'xianyu', - shopId: 'shop-1', - platformOrderId: 'order-1', - }, - ) - - assert.throws( - () => normalizeFulfillmentLookupPayload({ platformOrderId: 'order-1' }), - /请先填写店铺 ID/, - ) -}) - -test('resolveFulfillmentLookupDetail maps missing config into readable error', () => { - assert.throws( - () => - resolveFulfillmentLookupDetail( - { - reason: 'missing_config', - parsed: { items: [] }, - }, - 'order-1', - ), - /当前店铺缺少订单详情查询配置/, - ) -}) - -test('buildFulfillmentLookupResult assembles order and item matching payload', () => { - const result = buildFulfillmentLookupResult({ - provider: 'agiso', - platform: 'xianyu', - shopId: 'shop-1', - platformOrderId: 'order-1', - detail: { - shopName: '店铺A', - buyerName: '买家A', - totalAmount: 12345, - paidAt: '2026-05-04T10:00:00.000Z', - items: [ - { - externalItemId: 'item-1', - externalSkuCode: 'sku-1', - externalSkuName: '礼包A', - skuName: '礼包A', - quantity: 2, - }, - ], - }, - detailResult: { - enriched: true, - reason: 'ok', - errorMessage: '', - }, - bindings: [ - { - provider: 'agiso', - platform: 'xianyu', - shopId: 'shop-1', - skuCode: 'internal-1', - skuName: '内部商品', - profileKey: 'profile-1', - match: { - externalSkuCode: 'sku-1', - }, - }, - ], - fallbackShopName: '备用店铺', - }) - - assert.deepEqual(result, { - order: { - provider: 'agiso', - platform: 'xianyu', - shopId: 'shop-1', - shopName: '店铺A', - platformOrderId: 'order-1', - buyerName: '买家A', - totalAmountFen: 12345, - totalAmount: '123.45', - paidAt: '2026-05-04T10:00:00.000Z', - enriched: true, - enrichReason: 'ok', - errorMessage: '', - }, - items: [ - { - lineId: 'order-1:1:sku-1:item-1', - itemTitle: '礼包A', - quantity: 2, - provider: 'agiso', - platform: 'xianyu', - shopId: 'shop-1', - shopName: '店铺A', - externalItemId: 'item-1', - externalSkuCode: 'sku-1', - externalSkuName: '礼包A', - latestSeenAt: null, - orderItemCount: 2, - configured: true, - matchedBinding: { - skuCode: 'internal-1', - skuName: '内部商品', - profileKey: 'profile-1', - }, - }, - ], - }) -}) diff --git a/apps/backend/src/services/admin/platform-config/fulfillment.ts b/apps/backend/src/services/admin/platform-config/fulfillment.ts deleted file mode 100644 index 76806ae7..00000000 --- a/apps/backend/src/services/admin/platform-config/fulfillment.ts +++ /dev/null @@ -1,137 +0,0 @@ -import { createHttpError } from '../../../utils/http.js' -import { formatFenToAmount } from '../../../utils/money.js' -import { isPlainObject, pickFirstNonEmpty } from './context.js' -import { mapAdminObservedProductItem } from './domain.js' - -type JsonObject = Record - -export function normalizeFulfillmentLookupPayload(payload: JsonObject = {}) { - const provider = String(payload.provider || 'agiso').trim() || 'agiso' - const platform = String(payload.platform || 'xianyu').trim() || 'xianyu' - const shopId = String(payload.shopId || '').trim() - const platformOrderId = String(payload.platformOrderId || '').trim() - - if (!shopId) { - throw createHttpError('请先填写店铺 ID', { - statusCode: 400, - errorCode: 'admin_fulfillment_lookup_missing_shop_id', - }) - } - - if (!platformOrderId) { - throw createHttpError('请先填写平台订单号', { - statusCode: 400, - errorCode: 'admin_fulfillment_lookup_missing_platform_order_id', - }) - } - - if (provider !== 'agiso' || platform !== 'xianyu') { - throw createHttpError('目前仅支持 Agiso 咸鱼订单手动查询', { - statusCode: 400, - errorCode: 'admin_fulfillment_lookup_platform_not_supported', - }) - } - - return { provider, platform, shopId, platformOrderId } -} - -export function resolveFulfillmentLookupDetail(detailResult: JsonObject, platformOrderId: unknown) { - const detail = isPlainObject(detailResult?.parsed) ? detailResult.parsed : {} - const items = Array.isArray(detail.items) ? detail.items : [] - - if (items.length > 0) { - return { detail, items } - } - - const detailReason = String(detailResult?.reason || '').trim() - const detailMessage = String(detailResult?.errorMessage || '').trim() - let message = detailMessage - - if (!message && detailReason === 'missing_config') { - message = '当前店铺缺少订单详情查询配置,请先检查 accessToken、appSecret 和详情接口地址' - } - - if (!message) { - message = `未查询到订单 ${platformOrderId} 的商品明细` - } - - throw createHttpError(message, { - statusCode: 404, - errorCode: 'admin_fulfillment_lookup_order_items_not_found', - }) -} - -export function buildFulfillmentLookupResult({ - provider = '', - platform = '', - shopId = '', - platformOrderId = '', - detail = {}, - detailResult = {}, - bindings = [], - fallbackShopName = '', -}: { - provider?: string - platform?: string - shopId?: string - platformOrderId?: string - detail?: JsonObject - detailResult?: JsonObject - bindings?: JsonObject[] - fallbackShopName?: string -} = {}) { - const resolvedShopName = pickFirstNonEmpty([detail.shopName, fallbackShopName]) - const items = Array.isArray(detail.items) ? detail.items : [] - - return { - order: { - provider, - platform, - shopId, - shopName: resolvedShopName, - platformOrderId, - buyerName: String(detail.buyerName || '').trim(), - totalAmountFen: Number(detail.totalAmount || 0), - totalAmount: formatFenToAmount(detail.totalAmount), - paidAt: detail.paidAt || null, - enriched: Boolean(detailResult?.enriched), - enrichReason: String(detailResult?.reason || '').trim(), - errorMessage: String(detailResult?.errorMessage || '').trim(), - }, - items: items.map((item, index) => { - const observed = { - provider, - platform, - shopId, - shopName: resolvedShopName, - externalItemId: pickFirstNonEmpty([item?.externalItemId, item?.itemId]), - externalSkuCode: pickFirstNonEmpty([ - item?.externalSkuCode, - item?.skuCode, - item?.externalItemId, - item?.itemId, - ]), - externalSkuName: pickFirstNonEmpty([item?.externalSkuName, item?.skuName]), - latestSeenAt: null, - orderItemCount: Math.max(1, Number(item?.quantity || 0) || 1), - } - - return { - lineId: [ - platformOrderId, - index + 1, - observed.externalSkuCode || 'na', - observed.externalItemId || 'na', - ].join(':'), - itemTitle: pickFirstNonEmpty([ - item?.skuName, - item?.externalSkuName, - item?.externalSkuCode, - item?.externalItemId, - ]), - quantity: observed.orderItemCount, - ...mapAdminObservedProductItem(observed, bindings), - } - }), - } -} diff --git a/apps/backend/src/services/admin/platform-config/service.ts b/apps/backend/src/services/admin/platform-config/service.ts deleted file mode 100644 index 273c4f35..00000000 --- a/apps/backend/src/services/admin/platform-config/service.ts +++ /dev/null @@ -1,61 +0,0 @@ -export { - getAdminAgisoShopConfigs, - updateAdminAgisoShopConfigs, -} from "./agiso-service.js"; - -export { - getAdminNinetyoneOrders, - retryAdminNinetyoneOrder, - failAdminNinetyoneOrder, -} from "./ninetyone-service.js"; - -export { - getAdminKuaishouEticketSourceConfig, - updateAdminKuaishouEticketSourceConfig, - queryAdminKuaishouEticketDetail, - queryAdminKuaishouEticketShopInfo, - consumeAdminKuaishouEticket, -} from "./kuaishou-eticket-service.js"; - -export { - listAdminCloudtentaclesSources, - deleteAdminCloudtentaclesSource, - updateAdminCloudtentaclesSourceConfig, - sendAdminCloudtentaclesSmsCode, - testAdminCloudtentaclesLogin, - validateAdminCloudtentaclesSession, - getAdminCloudtentaclesAsset, - getAdminCloudtentaclesCategories, - getAdminCloudtentaclesSkuList, - buyAdminCloudtentaclesSku, - useAdminCloudtentaclesSku, - getAdminCloudtentaclesKnapsack, - listAdminCloudtentaclesVirtualNumbers, - appointAdminCloudtentaclesVirtualNumber, - generateAdminCloudtentaclesLoginCode, - fetchAdminCloudtentaclesVirtualNumberCode, - verifyAdminCloudtentaclesLoginCode, - getAdminCloudtentaclesBindUrl, - backAdminCloudtentaclesVirtualNumber, - runAdminCloudtentaclesFullFlow, -} from "./cloudtentacles-service.js"; - -export { - getAdminFulfillmentBindingConfigs, - lookupAdminFulfillmentBindingOrder, - updateAdminFulfillmentBindingConfigs, -} from "./fulfillment-bindings-service.js"; - -export { - getAdminKuaishouCloudFulfillmentConfig, - updateAdminKuaishouCloudFulfillmentConfig, -} from "./kuaishou-cloud-fulfillment-service.js"; - -export { - getAdminNotificationConfig, - updateAdminNotificationConfig, - testAdminNotification, - getAdminScheduledJobsConfig, - updateAdminScheduledJobsConfig, - runAdminScheduledJobNow, -} from "./notification-service.js"; diff --git a/apps/backend/src/services/admin/platform-config/validation.test.ts b/apps/backend/src/services/admin/platform-config/validation.test.ts deleted file mode 100644 index 9be59f24..00000000 --- a/apps/backend/src/services/admin/platform-config/validation.test.ts +++ /dev/null @@ -1,175 +0,0 @@ -import test from 'node:test' -import assert from 'node:assert/strict' - -import { - assertAdminFulfillmentBindingsInput, - buildAdminFulfillmentBindingUniqueKey, - normalizeAdminFulfillmentBindingItem, - validateAdminFulfillmentBindingConfigs, -} from './validation.js' - -test('assertAdminFulfillmentBindingsInput rejects non-array payloads', () => { - assert.doesNotThrow(() => assertAdminFulfillmentBindingsInput([])) - assert.throws(() => assertAdminFulfillmentBindingsInput({}), /履约配置格式不正确/) -}) - -test('normalizeAdminFulfillmentBindingItem normalizes generic binding fields', () => { - const result = normalizeAdminFulfillmentBindingItem( - { - provider: ' agiso ', - platform: ' xianyu ', - shopId: ' shop-1 ', - shopName: ' 店铺 ', - skuCode: 'sku-1', - match: { - externalSkuCode: 'ext-1', - }, - config: { - kuaishouShop: { - note: 'keep', - }, - }, - }, - { index: 0 }, - ) - - assert.deepEqual(result, { - provider: 'agiso', - platform: 'xianyu', - shopId: 'shop-1', - shopName: '店铺', - skuCode: 'sku-1', - skuName: '', - profileKey: 'manual_review', - enabled: true, - priority: undefined, - config: { - kuaishouShop: { - note: 'keep', - }, - }, - match: { - externalSkuCode: 'ext-1', - externalItemId: '', - externalSkuName: '', - config: {}, - }, - }) -}) - -test('normalizeAdminFulfillmentBindingItem rejects missing sku and match conditions', () => { - assert.throws( - () => - normalizeAdminFulfillmentBindingItem( - { - provider: 'agiso', - platform: 'xianyu', - match: { - externalSkuCode: 'ext-1', - }, - }, - { index: 1 }, - ), - /第 2 条规则缺少内部履约 SKU/, - ) - - assert.throws( - () => - normalizeAdminFulfillmentBindingItem( - { - provider: 'agiso', - platform: 'xianyu', - skuCode: 'sku-1', - }, - { index: 2 }, - ), - /第 3 条规则至少需要一种外部匹配条件/, - ) -}) - -test('buildAdminFulfillmentBindingUniqueKey uses normalized external sku name and shop id rules', () => { - assert.equal( - buildAdminFulfillmentBindingUniqueKey({ - provider: '91kaquan', - platform: 'kuaishou', - shopId: 'shop-a', - skuCode: 'sku-1', - match: { - externalItemId: '', - externalSkuCode: '', - externalSkuName: ' 礼包 A ', - }, - }), - '91kaquan::kuaishou::shop-a::::::礼包 a::sku-1', - ) -}) - -test('validateAdminFulfillmentBindingConfigs rejects missing fulfillment profile', async () => { - await assert.rejects( - () => - validateAdminFulfillmentBindingConfigs( - [ - { - provider: 'agiso', - platform: 'xianyu', - shopId: 'shop-1', - skuCode: 'sku-1', - profileKey: 'missing-profile', - match: { - externalSkuCode: 'ext-1', - }, - }, - ], - { - kuaishouEticketSource: {}, - resolveKuaishouEticketShopConfig() { - return null - }, - async getFulfillmentProfileByKey() { - return null - }, - }, - ), - /第 1 条规则使用了不存在的履约方式: missing-profile/, - ) -}) - -test('validateAdminFulfillmentBindingConfigs rejects duplicate rules after normalization', async () => { - await assert.rejects( - () => - validateAdminFulfillmentBindingConfigs( - [ - { - provider: 'agiso', - platform: 'xianyu', - shopId: 'shop-1', - skuCode: 'sku-1', - profileKey: 'manual_review', - match: { - externalSkuCode: ' ext-1 ', - }, - }, - { - provider: 'agiso', - platform: 'xianyu', - shopId: ' shop-1 ', - skuCode: 'sku-1', - profileKey: 'manual_review', - match: { - externalSkuCode: 'ext-1', - }, - }, - ], - { - kuaishouEticketSource: {}, - resolveKuaishouEticketShopConfig() { - return null - }, - async getFulfillmentProfileByKey() { - return { key: 'manual_review' } - }, - }, - ), - /第 2 条规则与其它规则重复/, - ) -}) diff --git a/apps/backend/src/services/admin/platform-config/validation.ts b/apps/backend/src/services/admin/platform-config/validation.ts deleted file mode 100644 index 49a51cb2..00000000 --- a/apps/backend/src/services/admin/platform-config/validation.ts +++ /dev/null @@ -1,125 +0,0 @@ -import { createHttpError } from '../../../utils/http.js' -import { normalizeProductName } from '../../order/product-match-service.js' -import { isPlainObject } from './context.js' - -type JsonObject = Record - -export function assertAdminFulfillmentBindingsInput(bindings: unknown) { - if (Array.isArray(bindings)) { - return - } - - throw createHttpError('履约配置格式不正确', { - statusCode: 400, - errorCode: 'admin_fulfillment_bindings_invalid_payload', - }) -} - -export function normalizeAdminFulfillmentBindingItem( - rawBinding: unknown, - options: JsonObject = {}, -) { - const index = Number(options.index || 0) - if (!isPlainObject(rawBinding)) { - throw createHttpError(`第 ${index + 1} 条规则格式不正确`, { - statusCode: 400, - errorCode: 'admin_fulfillment_bindings_invalid_item', - }) - } - - const provider = String(rawBinding.provider || 'agiso').trim() || 'agiso' - const platform = String(rawBinding.platform || '').trim() - const shopId = String(rawBinding.shopId || '').trim() - const shopName = String(rawBinding.shopName || '').trim() - const skuCode = String(rawBinding.skuCode || '').trim() - const skuName = String(rawBinding.skuName || '').trim() - const profileKey = String(rawBinding.profileKey || '').trim() || 'manual_review' - const match = isPlainObject(rawBinding.match) ? rawBinding.match : {} - const externalItemId = String(match.externalItemId || '').trim() - const externalSkuCode = String(match.externalSkuCode || '').trim() - const externalSkuName = String(match.externalSkuName || '').trim() - const config = isPlainObject(rawBinding.config) ? { ...rawBinding.config } : {} - - if (!skuCode) { - throw createHttpError(`第 ${index + 1} 条规则缺少内部履约 SKU`, { - statusCode: 400, - errorCode: 'admin_fulfillment_bindings_missing_sku_code', - }) - } - - if (!externalItemId && !externalSkuCode && !externalSkuName) { - throw createHttpError(`第 ${index + 1} 条规则至少需要一种外部匹配条件`, { - statusCode: 400, - errorCode: 'admin_fulfillment_bindings_missing_match_condition', - }) - } - - return { - provider, - platform, - shopId, - shopName, - skuCode, - skuName, - profileKey, - enabled: rawBinding.enabled !== false, - priority: rawBinding.priority, - config, - match: { - externalSkuCode, - externalItemId, - externalSkuName, - config: isPlainObject(match.config) ? match.config : {}, - }, - } -} - -export function buildAdminFulfillmentBindingUniqueKey(binding) { - return [ - String(binding?.provider || '').trim(), - String(binding?.platform || '').trim(), - String(binding?.shopId || '').trim(), - String(binding?.match?.externalItemId || '').trim(), - String(binding?.match?.externalSkuCode || '').trim(), - normalizeProductName(String(binding?.match?.externalSkuName || '').trim()), - String(binding?.skuCode || '').trim(), - ].join('::') -} - -export async function validateAdminFulfillmentBindingConfigs( - bindings: JsonObject[], - options: JsonObject = {}, -) { - assertAdminFulfillmentBindingsInput(bindings) - - const getFulfillmentProfileByKey = ( - options.getFulfillmentProfileByKey || (async () => null) - ) as (profileKey: string) => Promise - const seenKeys = new Set() - const normalizedBindings: JsonObject[] = [] - - for (const [index, rawBinding] of bindings.entries()) { - const normalized = normalizeAdminFulfillmentBindingItem(rawBinding, { index }) - - const profile = await getFulfillmentProfileByKey(normalized.profileKey) - if (!profile) { - throw createHttpError(`第 ${index + 1} 条规则使用了不存在的履约方式: ${normalized.profileKey}`, { - statusCode: 400, - errorCode: 'admin_fulfillment_bindings_invalid_profile_key', - }) - } - - const uniqueKey = buildAdminFulfillmentBindingUniqueKey(normalized) - if (seenKeys.has(uniqueKey)) { - throw createHttpError(`第 ${index + 1} 条规则与其它规则重复,请调整匹配条件或内部履约 SKU`, { - statusCode: 409, - errorCode: 'admin_fulfillment_bindings_duplicate_rule', - }) - } - - seenKeys.add(uniqueKey) - normalizedBindings.push(normalized) - } - - return normalizedBindings -} diff --git a/apps/backend/src/services/admin/platform-config/writes.test.ts b/apps/backend/src/services/admin/platform-config/writes.test.ts deleted file mode 100644 index cb63cd67..00000000 --- a/apps/backend/src/services/admin/platform-config/writes.test.ts +++ /dev/null @@ -1,127 +0,0 @@ -import test from 'node:test' -import assert from 'node:assert/strict' - -import { - buildAdminAgisoMessagingSavePayload, - buildAdminAgisoShopConfigUpdate, - normalizeAdminCloudtentaclesSourceConfigPayload, -} from './writes.js' - -test('buildAdminAgisoShopConfigUpdate merges item fields and drops explicit empty templates', () => { - assert.deepEqual( - buildAdminAgisoShopConfigUpdate( - { - shopId: ' shop-1 ', - shopName: ' ', - accessToken: ' new-token ', - messageTemplate: ' ', - autoDeliveryMessageTemplate: ' done ', - enabled: false, - }, - { - shopName: '旧店铺', - accessToken: 'old-token', - messageTemplate: 'old-template', - autoDeliveryMessageTemplate: 'old-auto', - appSecret: 'keep-secret', - }, - ), - { - shopId: 'shop-1', - config: { - accessToken: 'new-token', - autoDeliveryMessageTemplate: 'done', - appSecret: 'keep-secret', - enabled: false, - }, - }, - ) -}) - -test('buildAdminAgisoMessagingSavePayload updates defaults and keeps only shops with resulting access token', () => { - assert.deepEqual( - buildAdminAgisoMessagingSavePayload( - { - defaults: { - messageTemplate: ' next\\nline ', - autoDeliveryMessageTemplate: ' ', - }, - shops: [ - { - shopId: ' shop-1 ', - shopName: '店铺一', - accessToken: '', - enabled: true, - }, - { - shopId: 'shop-2', - shopName: ' 店铺二 ', - accessToken: ' token-2 ', - messageTemplate: ' hi ', - apiVersion: ' v2 ', - }, - { - shopId: '', - accessToken: 'skip', - }, - ], - }, - { - currentDefaults: { - messageTemplate: 'old', - autoDeliveryMessageTemplate: 'old-auto', - }, - currentMap: { - 'shop-1': { - accessToken: 'token-1', - autoDeliveryMessageTemplate: 'keep-auto', - }, - }, - }, - ), - { - defaults: { - messageTemplate: 'next\nline', - }, - shops: { - 'shop-1': { - accessToken: 'token-1', - autoDeliveryMessageTemplate: 'keep-auto', - enabled: true, - shopName: '店铺一', - }, - 'shop-2': { - accessToken: 'token-2', - apiVersion: 'v2', - messageTemplate: 'hi', - shopName: '店铺二', - }, - }, - }, - ) -}) - -test('normalizeAdminCloudtentaclesSourceConfigPayload applies defaults', () => { - assert.deepEqual( - normalizeAdminCloudtentaclesSourceConfigPayload({ - enabled: false, - baseUrl: ' ', - username: ' user ', - password: ' pass ', - phone: ' 13800138000 ', - deviceId: ' ', - deviceType: '2', - }), - { - key: 'default', - label: '', - enabled: false, - baseUrl: 'https://123.207.217.176', - username: 'user', - password: 'pass', - phone: '13800138000', - deviceId: '-', - deviceType: 2, - }, - ) -}) diff --git a/apps/backend/src/services/admin/platform-config/writes.ts b/apps/backend/src/services/admin/platform-config/writes.ts deleted file mode 100644 index 68c7a9bb..00000000 --- a/apps/backend/src/services/admin/platform-config/writes.ts +++ /dev/null @@ -1,100 +0,0 @@ -import { applyOptionalStringField } from "./domain.js"; - -type JsonObject = Record - -export function buildAdminAgisoShopConfigUpdate(item: JsonObject, current: JsonObject = {}) { - const shopId = String(item?.shopId || "").trim(); - if (!shopId) { - return null; - } - - const next: JsonObject = { ...current }; - const shopName = String(item?.shopName || "").trim(); - const accessToken = String(item?.accessToken || "").trim(); - const messageTemplate = String(item?.messageTemplate || "").trim(); - const autoDeliveryMessageTemplate = String( - item?.autoDeliveryMessageTemplate || "" - ).trim(); - const appSecret = String(item?.appSecret || "").trim(); - const apiVersion = String(item?.apiVersion || "").trim(); - const sendMessageEndpoint = String(item?.sendMessageEndpoint || "").trim(); - - if (shopName) { - next.shopName = shopName; - } else { - delete next.shopName; - } - if (accessToken) { - next.accessToken = accessToken; - } - if (messageTemplate) { - next.messageTemplate = messageTemplate; - } else if (typeof item?.messageTemplate === "string") { - delete next.messageTemplate; - } - if (autoDeliveryMessageTemplate) { - next.autoDeliveryMessageTemplate = autoDeliveryMessageTemplate; - } else if (typeof item?.autoDeliveryMessageTemplate === "string") { - delete next.autoDeliveryMessageTemplate; - } - if (appSecret) { - next.appSecret = appSecret; - } - if (apiVersion) { - next.apiVersion = apiVersion; - } - if (sendMessageEndpoint) { - next.sendMessageEndpoint = sendMessageEndpoint; - } - if (typeof item?.enabled === "boolean") { - next.enabled = item.enabled; - } - - if (!next.accessToken) { - return null; - } - - return { shopId, config: next }; -} - -export function buildAdminAgisoMessagingSavePayload( - payload: JsonObject = {}, - options: JsonObject = {} -) { - const rawItems = Array.isArray(payload.shops) ? payload.shops : []; - const currentDefaults: JsonObject = options.currentDefaults || {}; - const currentMap: Record = options.currentMap || {}; - const nextDefaults: JsonObject = { - ...currentDefaults, - }; - const nextMap: Record = {}; - - applyOptionalStringField(nextDefaults, "messageTemplate", payload.defaults); - applyOptionalStringField( - nextDefaults, - "autoDeliveryMessageTemplate", - payload.defaults - ); - - for (const item of rawItems) { - const shopId = String(item?.shopId || "").trim(); - if (!shopId) { - continue; - } - - const updated = buildAdminAgisoShopConfigUpdate( - item, - currentMap[shopId] || {} - ); - if (!updated) { - continue; - } - - nextMap[updated.shopId] = updated.config; - } - - return { - defaults: nextDefaults, - shops: nextMap, - }; -} diff --git a/apps/backend/src/services/admin/write/task-actions.ts b/apps/backend/src/services/admin/write/task-actions.ts deleted file mode 100644 index b65f6ad3..00000000 --- a/apps/backend/src/services/admin/write/task-actions.ts +++ /dev/null @@ -1,520 +0,0 @@ -import { - markInventoryItemDelivered, - releaseReservedInventoryItem, -} from '../../../repositories/inventory-repo.js' -import { updateClaimToken } from '../../../repositories/claim-token-repo.js' -import { listOrderItemsByOrderId } from '../../../repositories/order-item-repo.js' -import { getOrderById } from '../../../repositories/order-repo.js' -import { updateTask } from '../../../repositories/task-repo.js' -import { - getTaskInventoryBindingById, - listTaskInventoryBindingsByTaskId, -} from '../../../repositories/task-inventory-binding-repo.js' -import { createTaskEvent } from '../../../repositories/task-event-repo.js' -import { createTaskClaimToken } from '../../claim/claim-service.js' -import { confirmClaimRoleForAdminTask, redeemClaimTaskForAdminTask } from '../../claim/claim-session-service.js' -import { reserveInventoryForTask } from '../../order/inventory-service.js' -import { ensureAgisoXianyuAutoDeliveryForDeliveredTask } from '../../platforms/agiso/xianyu/auto-delivery-service.js' -import { closeTencentBrowserSession } from '../../session/session.js' -import { createHttpError } from '../../../utils/http.js' -import { nowIso } from '../../../utils/time.js' -import { - createAdminViewerContext, - getTaskPrimaryClaimTokenId, - getTaskPrimaryInventoryItemId, - isAssistedClaimTask, - isManualDispatchTask, - parseTaskContext, -} from '../admin-read-shared-helpers.js' -import { - getRequiredTask, - mapTaskActionPayload, -} from '../admin-task-read-helpers.js' -import { - ensureViewerCanOperateAssistedTask, - getTaskClaimExpiresAt, - isRecoverableTaskSessionCloseError, - maskCode, - normalizeManualDispatchOutcome, - resolveTaskInventoryGroupCodes, -} from './shared.js' - -import type { - AdminEntityIdInput, - AdminViewerSessionInput, -} from '../../../types/admin-read-inputs.js' -import type { AdminTaskManualDispatchInput } from '../../../types/admin-write-inputs.js' -import type { - AdminTaskActionResponse, - AdminTaskBindingReleaseResponse, - AdminTaskManualDispatchResponse, -} from '../../../types/admin-write-models.js' -import type { TaskRow } from '../../../types/repository-rows.js' -import type { TaskUpdatePatch } from '../../../types/repository-inputs.js' - -type CloseAdminTaskDeps = { - getRequiredTask?: (taskId: AdminEntityIdInput) => Promise - listTaskInventoryBindingsByTaskId?: (taskId: AdminEntityIdInput) => Promise - releaseReservedInventoryItem?: (inventoryItemId: AdminEntityIdInput, updatedAt: string) => Promise - updateClaimToken?: (tokenId: AdminEntityIdInput, patch: Record) => Promise - updateTask?: (taskId: AdminEntityIdInput, patch: TaskUpdatePatch) => Promise - createTaskEvent?: (taskId: AdminEntityIdInput, eventType: string, payload: Record, createdAt: string) => Promise - closeTencentBrowserSession?: (sessionId: string) => Promise - nowIso?: () => string -} - -export async function releaseAdminTaskInventory(taskId: AdminEntityIdInput): Promise { - const task = await getRequiredTask(taskId) - const primaryInventoryItemId = getTaskPrimaryInventoryItemId(task) - - if (!primaryInventoryItemId) { - throw createHttpError('当前任务没有预占库存项', { - statusCode: 409, - errorCode: 'admin_task_no_reserved_inventory', - }) - } - - if (task.task_status === 'redeemed') { - throw createHttpError('已兑换任务不能释放库存项', { - statusCode: 409, - errorCode: 'admin_task_release_not_allowed', - }) - } - - const now = nowIso() - await releaseReservedInventoryItem(primaryInventoryItemId, now) - const updatedTask = await updateTask(task.id, { - task_status: 'waiting_inventory', - inventory_status: 'pending', - last_error: '已手动释放预占库存项', - updated_at: now, - }) - - return { - task: mapTaskActionPayload(updatedTask), - } -} - -export async function releaseAdminTaskInventoryBinding( - taskId: AdminEntityIdInput, - bindingId: AdminEntityIdInput, -): Promise { - const task = await getRequiredTask(taskId) - const binding = await getTaskInventoryBindingById(Number(bindingId)) - - if (!binding || Number(binding.task_id) !== Number(task.id)) { - throw createHttpError('任务库存绑定不存在', { - statusCode: 404, - errorCode: 'admin_task_inventory_binding_not_found', - }) - } - - if (String(binding.binding_status || '').trim() !== 'reserved') { - throw createHttpError('当前库存绑定不是预占状态,不能释放', { - statusCode: 409, - errorCode: 'admin_task_inventory_binding_release_not_allowed', - }) - } - - if (task.task_status === 'redeemed') { - throw createHttpError('已兑换任务不能释放库存绑定', { - statusCode: 409, - errorCode: 'admin_task_release_not_allowed', - }) - } - - const now = nowIso() - await releaseReservedInventoryItem(binding.inventory_item_id, now) - - const remainingBindings = await listTaskInventoryBindingsByTaskId(task.id) - const activeBindings = remainingBindings.filter((item) => ['reserved', 'consumed'].includes(String(item.binding_status || '').trim())) - const hasReservedBindings = activeBindings.some((item) => String(item.binding_status || '').trim() === 'reserved') - const hasConsumedBindings = activeBindings.some((item) => String(item.binding_status || '').trim() === 'consumed') - const nextInventoryStatus = hasReservedBindings ? 'reserved' : (hasConsumedBindings ? 'consumed' : 'pending') - const nextTaskStatus = !activeBindings.length && !['closed', 'expired'].includes(String(task.task_status || '').trim()) - ? 'waiting_inventory' - : task.task_status - - if (!hasReservedBindings) { - const primaryClaimTokenId = getTaskPrimaryClaimTokenId(task) - if (primaryClaimTokenId) { - await updateClaimToken(primaryClaimTokenId, { - status: 'revoked', - updated_at: now, - }) - } - } - - const updatedTask = await updateTask(task.id, { - task_status: nextTaskStatus, - inventory_status: nextInventoryStatus, - last_error: !activeBindings.length ? '已手动释放预占库存绑定' : (task.last_error || ''), - updated_at: now, - }) - - await createTaskEvent(task.id, 'inventory_binding_released', { - bindingId: Number(binding.id), - inventoryItemId: Number(binding.inventory_item_id), - roleKey: String(binding.role_key || '').trim(), - }, now) - - return { - task: mapTaskActionPayload(updatedTask), - bindingId: Number(binding.id), - inventoryItemId: Number(binding.inventory_item_id), - } -} - -export async function regenerateAdminTaskClaimLink( - taskId: AdminEntityIdInput, - session: AdminViewerSessionInput | null = null, -): Promise { - const task = await getRequiredTask(taskId) - const primaryClaimTokenId = getTaskPrimaryClaimTokenId(task) - const viewerContext = createAdminViewerContext(session) - - if (isManualDispatchTask(task)) { - throw createHttpError('人工履约任务不需要领取链接,请直接回写人工履约结果', { - statusCode: 409, - errorCode: 'admin_task_manual_dispatch_claim_not_allowed', - }) - } - - if (['redeemed', 'closed'].includes(task.task_status)) { - throw createHttpError('当前任务状态不允许重新生成领取链接', { - statusCode: 409, - errorCode: 'admin_task_regenerate_not_allowed', - }) - } - - if (!viewerContext.canManageTaskLifecycle && !isAssistedClaimTask(task)) { - throw createHttpError('当前账号只能重发半自动客服任务的领取链接', { - statusCode: 403, - errorCode: 'admin_task_regenerate_permission_denied', - }) - } - - const now = nowIso() - if (primaryClaimTokenId) { - await updateClaimToken(primaryClaimTokenId, { - status: 'revoked', - updated_at: now, - }) - } - - const claimToken = await createTaskClaimToken(task.id) - const updatedTask = await updateTask(task.id, { - claim_token: claimToken.token, - claim_expires_at: claimToken.expired_at, - task_status: 'link_generated', - last_error: '', - updated_at: now, - }) - - return { - task: mapTaskActionPayload(updatedTask), - claimUrl: claimToken.claimUrl, - token: claimToken.token, - } -} - -export async function confirmAdminTaskAssistedRole( - taskId: AdminEntityIdInput, - session: AdminViewerSessionInput | null = null, -): Promise { - const task = await getRequiredTask(taskId) - const viewerContext = createAdminViewerContext(session) - ensureViewerCanOperateAssistedTask(task, viewerContext, 'confirm') - await confirmClaimRoleForAdminTask(task.id) - const updatedTask = await getRequiredTask(task.id) - - return { - task: mapTaskActionPayload(updatedTask), - } -} - -export async function redeemAdminTaskAssisted( - taskId: AdminEntityIdInput, - session: AdminViewerSessionInput | null = null, -): Promise { - const task = await getRequiredTask(taskId) - const viewerContext = createAdminViewerContext(session) - ensureViewerCanOperateAssistedTask(task, viewerContext, 'redeem') - await redeemClaimTaskForAdminTask(task.id) - const updatedTask = await getRequiredTask(task.id) - - return { - task: mapTaskActionPayload(updatedTask), - } -} - -export async function closeAdminTask(taskId: AdminEntityIdInput): Promise { - return closeAdminTaskWithDeps(taskId) -} - -export async function closeAdminTaskWithDeps( - taskId: AdminEntityIdInput, - { - getRequiredTask: getTask = getRequiredTask, - listTaskInventoryBindingsByTaskId: listBindings = listTaskInventoryBindingsByTaskId, - releaseReservedInventoryItem: releaseReserved = releaseReservedInventoryItem, - updateClaimToken: updateToken = updateClaimToken, - updateTask: updateTaskRecord = updateTask, - createTaskEvent: createEvent = createTaskEvent, - closeTencentBrowserSession: closeSession = closeTencentBrowserSession, - nowIso: getNowIso = nowIso, - }: CloseAdminTaskDeps = {}, -): Promise { - const task = await getTask(taskId) - - if (task.task_status === 'redeemed') { - throw createHttpError('已兑换任务不能关闭', { - statusCode: 409, - errorCode: 'admin_task_close_not_allowed', - }) - } - - const now = getNowIso() - const bindings = await listBindings(task.id) - const reservedBindings = bindings.filter((binding) => String(binding.binding_status || '').trim() === 'reserved') - const releasedInventoryItemIds = Array.from(new Set(reservedBindings.map((binding) => Number(binding.inventory_item_id)).filter((id) => id > 0))) - const hasConsumedBindings = bindings.some((binding) => String(binding.binding_status || '').trim() === 'consumed') - const primaryClaimTokenId = getTaskPrimaryClaimTokenId(task) - let browserSessionClosed = false - - if (primaryClaimTokenId) { - await updateToken(primaryClaimTokenId, { - status: 'revoked', - expired_at: now, - updated_at: now, - }) - } - - if (task.browser_session_id) { - try { - await closeSession(task.browser_session_id) - browserSessionClosed = true - } catch (error) { - if (!isRecoverableTaskSessionCloseError(error)) { - throw error - } - } - } - - for (const inventoryItemId of releasedInventoryItemIds) { - await releaseReserved(inventoryItemId, now) - } - - const closeReasonParts = ['已手动关闭任务'] - if (primaryClaimTokenId) { - closeReasonParts.push('领取链接已失效') - } - if (releasedInventoryItemIds.length > 0) { - closeReasonParts.push('预占库存已释放') - } - - const updatedTask = await updateTaskRecord(task.id, { - task_status: 'closed', - inventory_status: hasConsumedBindings ? 'consumed' : 'pending', - delivery_status: 'closed', - user_action_status: 'closed', - claim_expires_at: primaryClaimTokenId ? now : task.claim_expires_at, - browser_session_id: '', - last_error: task.last_error || closeReasonParts.join(','), - updated_at: now, - }) - - await createEvent(task.id, 'task_closed', { - claimTokenRevoked: Boolean(primaryClaimTokenId), - releasedInventoryItemIds, - releasedInventoryCount: releasedInventoryItemIds.length, - browserSessionClosed, - }, now) - - return { - task: mapTaskActionPayload(updatedTask), - } -} - -export async function markAdminTaskManualReview(taskId: AdminEntityIdInput): Promise { - const task = await getRequiredTask(taskId) - const updatedTask = await updateTask(task.id, { - task_status: 'manual_review', - delivery_status: task.delivery_status || 'pending', - last_error: task.last_error || '已转人工处理', - updated_at: nowIso(), - }) - - return { - task: mapTaskActionPayload(updatedTask), - } -} - -export async function retryAdminTask(taskId: AdminEntityIdInput): Promise { - const task = await getRequiredTask(taskId) - const now = nowIso() - - if (isManualDispatchTask(task)) { - throw createHttpError('人工履约任务不能走自动重试,请在详情页直接回写人工履约结果', { - statusCode: 409, - errorCode: 'admin_task_manual_dispatch_retry_not_allowed', - }) - } - - if (!['retry_pending', 'manual_review', 'waiting_inventory'].includes(task.task_status)) { - throw createHttpError('当前任务状态不允许重试', { - statusCode: 409, - errorCode: 'admin_task_retry_not_allowed', - }) - } - - const taskContext = parseTaskContext(task) - const primaryRequirement = taskContext.primaryRequirement || null - const orderItems = await listOrderItemsByOrderId(task.order_id) - const orderItem = orderItems.find((item) => item.id === task.order_item_id) || null - let reservedInventoryItemId = getTaskPrimaryInventoryItemId(task) - let claimTokenId = getTaskPrimaryClaimTokenId(task) - let nextStatus = 'link_generated' - let lastError = '' - let claimExpiresAt = getTaskClaimExpiresAt(task) - let claimUrl = '' - let token = '' - - if (!reservedInventoryItemId) { - const reserved = await reserveInventoryForTask({ - skuCode: orderItem?.sku_code || '', - taskId: task.id, - credentialType: primaryRequirement?.credentialType || 'tencent_code', - roleKey: primaryRequirement?.roleKey || 'primary_code', - inventoryGroupCodes: resolveTaskInventoryGroupCodes(task), - }) - - if (!reserved) { - nextStatus = 'waiting_inventory' - lastError = '库存不足,等待可用库存凭据' - } else { - reservedInventoryItemId = reserved.id - } - } - - if (nextStatus === 'link_generated' && !claimTokenId) { - const claimToken = await createTaskClaimToken(task.id) - claimTokenId = claimToken.id - claimExpiresAt = claimToken.expired_at - claimUrl = claimToken.claimUrl - token = claimToken.token - } - - const updatedTask = await updateTask(task.id, { - task_status: nextStatus, - inventory_status: reservedInventoryItemId ? 'reserved' : 'pending', - claim_token: token || task.claim_token || '', - claim_expires_at: claimExpiresAt, - last_error: lastError, - updated_at: now, - }) - - const response: AdminTaskActionResponse = { - task: mapTaskActionPayload(updatedTask), - } - - if (claimUrl) { - response.claimUrl = claimUrl - response.token = token - } - - return response -} - -export async function completeAdminTaskManualDispatch( - taskId: AdminEntityIdInput, - payload: AdminTaskManualDispatchInput = {}, - session: AdminViewerSessionInput | null = null, -): Promise { - const task = await getRequiredTask(taskId) - - if (!isManualDispatchTask(task)) { - throw createHttpError('当前任务不是人工履约任务', { - statusCode: 409, - errorCode: 'admin_task_not_manual_dispatch', - }) - } - - if (['redeemed', 'closed'].includes(task.task_status)) { - throw createHttpError('当前任务已经完结,不能重复回写人工履约结果', { - statusCode: 409, - errorCode: 'admin_task_manual_dispatch_already_completed', - }) - } - - const now = nowIso() - const outcome = normalizeManualDispatchOutcome(payload.outcome) - const resultMessage = String(payload.resultMessage || '').trim() - const deliveryReference = String(payload.deliveryReference || '').trim() - const deliveredCredential = String(payload.deliveredCredential || '').trim() - const context = parseTaskContext(task) - const inventoryItemId = getTaskPrimaryInventoryItemId(task) - const resultCode = outcome === 'failed' ? 'manual_dispatch_failed' : 'manual_dispatch_delivered' - const fallbackMessage = outcome === 'failed' ? '人工履约失败' : '人工履约已完成' - const nextTaskStatus = outcome === 'failed' ? 'closed' : 'redeemed' - const nextDeliveryStatus = outcome === 'failed' ? 'failed' : 'delivered' - const nextInventoryStatus = outcome === 'delivered' && inventoryItemId - ? 'consumed' - : task.inventory_status || 'not_required' - const manualDispatch = { - outcome, - deliveryReference, - deliveredCredential, - resultMessage: resultMessage || fallbackMessage, - completedAt: now, - completedBy: session - ? { - userId: Number(session.userId || 0), - username: String(session.username || ''), - role: String(session.role || ''), - } - : null, - } - - if (outcome === 'delivered' && inventoryItemId) { - await markInventoryItemDelivered(inventoryItemId, now) - } - - const updatedTask = await updateTask(task.id, { - task_status: nextTaskStatus, - inventory_status: nextInventoryStatus, - delivery_status: nextDeliveryStatus, - result_code: resultCode, - result_message: resultMessage || fallbackMessage, - user_action_status: 'not_required', - last_error: outcome === 'failed' ? (resultMessage || fallbackMessage) : '', - redeemed_at: outcome === 'delivered' ? now : task.redeemed_at || null, - context_json: JSON.stringify({ - ...context, - manualDispatch, - }), - updated_at: now, - }) - - await createTaskEvent(task.id, 'manual_dispatch_completed', { - outcome, - resultCode, - resultMessage: resultMessage || fallbackMessage, - deliveryReference, - deliveredCredentialMasked: maskCode(deliveredCredential), - completedBy: manualDispatch.completedBy, - }, now) - - const taskAfterAutoDelivery = outcome === 'delivered' - ? (await ensureAgisoXianyuAutoDeliveryForDeliveredTask({ - order: await getOrderById(task.order_id), - task: updatedTask, - trigger: 'manual_dispatch_completed', - })).task || updatedTask - : updatedTask - - return { - outcome, - task: mapTaskActionPayload(taskAfterAutoDelivery), - } -} diff --git a/apps/backend/src/services/admin/write/webhook-events.ts b/apps/backend/src/services/admin/write/webhook-events.ts deleted file mode 100644 index b0f7bdef..00000000 --- a/apps/backend/src/services/admin/write/webhook-events.ts +++ /dev/null @@ -1,32 +0,0 @@ -import { getWebhookEventById } from '../../../repositories/webhook-event-repo.js' -import { replayAgisoTradeWebhookEvent } from '../../order/webhook-service.js' -import { createHttpError } from '../../../utils/http.js' - -import type { AdminEntityIdInput } from '../../../types/admin-read-inputs.js' -import type { AdminWebhookReplayResponse } from '../../../types/admin-write-models.js' - -export async function replayAdminWebhookEvent(eventId: AdminEntityIdInput): Promise { - const event = await getWebhookEventById(Number(eventId)) - - if (!event) { - throw createHttpError('Webhook 事件不存在', { - statusCode: 404, - errorCode: 'admin_webhook_not_found', - }) - } - - if (String(event.provider || event.platform || '').trim() !== 'agiso') { - throw createHttpError('当前只支持重放 agiso webhook', { - statusCode: 409, - errorCode: 'admin_webhook_replay_not_supported', - }) - } - - const result = await replayAgisoTradeWebhookEvent(event) - - return { - eventId: event.id, - replayed: true, - result, - } -} diff --git a/apps/backend/src/services/bootstrap/fulfillment-bootstrap-service.test.ts b/apps/backend/src/services/bootstrap/fulfillment-bootstrap-service.test.ts deleted file mode 100644 index 0fb21e92..00000000 --- a/apps/backend/src/services/bootstrap/fulfillment-bootstrap-service.test.ts +++ /dev/null @@ -1,26 +0,0 @@ -import test from 'node:test' -import assert from 'node:assert/strict' - -import { resolveBindingMatchShopIds } from './fulfillment-bootstrap-service.js' - -test('resolveBindingMatchShopIds returns configured shop id for 91kaquan kuaishou bindings', () => { - assert.deepEqual( - resolveBindingMatchShopIds({ - provider: '91kaquan', - platform: 'kuaishou', - shopId: '91kaquan', - }), - ['91kaquan'], - ) -}) - -test('resolveBindingMatchShopIds preserves wildcard rules for empty shop id', () => { - assert.deepEqual( - resolveBindingMatchShopIds({ - provider: 'agiso', - platform: 'xianyu', - shopId: '', - }), - [''], - ) -}) diff --git a/apps/backend/src/services/bootstrap/fulfillment-bootstrap-service.ts b/apps/backend/src/services/bootstrap/fulfillment-bootstrap-service.ts index 8779d538..92f1c83f 100644 --- a/apps/backend/src/services/bootstrap/fulfillment-bootstrap-service.ts +++ b/apps/backend/src/services/bootstrap/fulfillment-bootstrap-service.ts @@ -8,7 +8,6 @@ import { import { upsertProductMatchRule } from '../../repositories/product-match-rule-repo.js' import { normalizeProductName } from '../order/product-match-service.js' import { query } from '../../db/client.js' -import { getLegacyOrderFulfillmentBindingConfigs } from '../order/fulfillment-binding-config-service.js' import { getKuaishouCloudFulfillmentConfig, mapKuaishouCloudFulfillmentItemsToBindings, @@ -108,10 +107,7 @@ async function ensureCoreProfiles() { export async function syncConfiguredFulfillmentBindings(profileMap: JsonObject = {}) { const timestamp = nowIso() - const bindingsToApply = [ - ...getLegacyOrderFulfillmentBindingConfigs(), - ...mapKuaishouCloudFulfillmentItemsToBindings(getKuaishouCloudFulfillmentConfig()), - ] + const bindingsToApply = mapKuaishouCloudFulfillmentItemsToBindings(getKuaishouCloudFulfillmentConfig()) await query('DELETE FROM product_match_rules') await query('DELETE FROM sku_fulfillment_bindings') @@ -124,7 +120,7 @@ export async function syncConfiguredFulfillmentBindings(profileMap: JsonObject = const bindingConfig = resolveBindingRuntimeConfig(binding) const matchShopIds = resolveBindingMatchShopIds(binding) - const match = binding.match || {} + const match: JsonObject = isPlainObject(binding.match) ? binding.match : {} const externalSkuName = String(match.externalSkuName || '').trim() const externalItemId = String(match.externalItemId || '').trim() const externalSkuCode = String(match.externalSkuCode || '').trim() @@ -166,7 +162,7 @@ export async function syncConfiguredFulfillmentBindings(profileMap: JsonObject = } } -export function resolveBindingMatchShopIds(binding: JsonObject = {}) { +function resolveBindingMatchShopIds(binding: JsonObject = {}) { return [String(binding.shopId || '').trim()] } diff --git a/apps/backend/src/services/claim/claim-session-service.test.ts b/apps/backend/src/services/claim/claim-session-service.test.ts deleted file mode 100644 index 79307cc2..00000000 --- a/apps/backend/src/services/claim/claim-session-service.test.ts +++ /dev/null @@ -1,273 +0,0 @@ -import test from 'node:test' -import assert from 'node:assert/strict' - -import { redeemClaimTaskWithInventoryFallbackWithDeps } from './claim-session-service.js' - -test('redeemClaimTaskWithInventoryFallbackWithDeps invalidates missing CDKEY and retries with replacement inventory', async () => { - const events = [] - const invalidated = [] - const reserveCalls = [] - const reloadCalls = [] - - const context = { - task: { - id: 42, - browser_session_id: 'txbs-test', - }, - orderItem: { - sku_code: 'df-cdk', - }, - } - const initialInventoryItem = { - id: 1, - display_value: 'DJQFf7HgONCL4XD4EH', - credential_type: 'tencent_code', - inventory_group_code: 'A组', - } - const replacementInventoryItem = { - id: 2, - display_value: 'ABCD1234EFGH5678', - credential_type: 'tencent_code', - inventory_group_code: 'A组', - } - - let redeemCount = 0 - const result = await redeemClaimTaskWithInventoryFallbackWithDeps( - context, - initialInventoryItem, - { - reloadTencentBrowserSession: async (sessionId) => { - reloadCalls.push(sessionId) - return { sessionId, status: 'ready_to_redeem' } - }, - redeemTencentBrowserSession: async (sessionId, payload) => { - redeemCount += 1 - if (redeemCount === 1) { - assert.equal(sessionId, 'txbs-test') - assert.equal(payload.code, 'DJQFf7HgONCL4XD4EH') - return { - redeem: { - final: { - redeem: { - iRet: -183, - sMsg: '该CDKEY不存在,请您确认后输入!', - }, - }, - }, - } - } - - assert.equal(payload.code, 'ABCD1234EFGH5678') - return { - redeem: { - final: { - redeem: { - iRet: 0, - sMsg: '领取成功', - }, - }, - }, - } - }, - invalidateReservedInventoryItem: async (inventoryItemId, reason, updatedAt) => { - invalidated.push({ inventoryItemId, reason, updatedAt }) - return { id: inventoryItemId, status: 'invalid', invalid_reason: reason, updated_at: updatedAt } - }, - reserveInventoryForTask: async (payload) => { - reserveCalls.push(payload) - return replacementInventoryItem - }, - createTaskEvent: async (taskId, eventType, payload, createdAt) => { - events.push({ taskId, eventType, payload, createdAt }) - }, - nowIso: (() => { - const values = [ - '2026-04-14T12:03:34.000Z', - '2026-04-14T12:03:35.000Z', - ] - let index = 0 - return () => values[index++] || values[values.length - 1] - })(), - }, - ) - - assert.equal(result.inventoryItem.id, 2) - assert.equal(result.classification.outcome, 'success') - assert.equal(result.attempts.length, 2) - assert.deepEqual( - result.attempts.map((attempt) => ({ - attempt: attempt.attempt, - inventoryItemId: attempt.inventoryItemId, - outcome: attempt.outcome, - resultCode: attempt.resultCode, - })), - [ - { - attempt: 1, - inventoryItemId: 1, - outcome: 'code_invalid', - resultCode: '-183', - }, - { - attempt: 2, - inventoryItemId: 2, - outcome: 'success', - resultCode: '0', - }, - ], - ) - assert.deepEqual(invalidated, [ - { - inventoryItemId: 1, - reason: '该CDKEY不存在,请您确认后输入!', - updatedAt: '2026-04-14T12:03:34.000Z', - }, - ]) - assert.deepEqual(reserveCalls, [ - { - skuCode: 'df-cdk', - taskId: 42, - credentialType: 'tencent_code', - roleKey: 'primary_code', - inventoryGroupCodes: ['A组'], - }, - ]) - assert.deepEqual(reloadCalls, ['txbs-test']) - assert.deepEqual( - events.map((event) => ({ - eventType: event.eventType, - taskId: event.taskId, - createdAt: event.createdAt, - payload: event.payload, - })), - [ - { - eventType: 'claim_redeem_code_invalid', - taskId: 42, - createdAt: '2026-04-14T12:03:34.000Z', - payload: { - inventoryItemId: 1, - codeMasked: 'DJQF****D4EH', - resultCode: '-183', - resultMessage: '该CDKEY不存在,请您确认后输入!', - }, - }, - { - eventType: 'claim_redeem_inventory_replaced', - taskId: 42, - createdAt: '2026-04-14T12:03:35.000Z', - payload: { - previousInventoryItemId: 1, - previousCodeMasked: 'DJQF****D4EH', - previousOutcome: 'code_invalid', - nextInventoryItemId: 2, - nextCodeMasked: 'ABCD****5678', - credentialType: 'tencent_code', - inventoryGroupCode: 'A组', - }, - }, - ], - ) -}) - -test('redeemClaimTaskWithInventoryFallbackWithDeps waits for inventory when used code has no replacement', async () => { - const events = [] - const consumed = [] - const reserveCalls = [] - - const context = { - task: { - id: 57, - browser_session_id: 'txbs-used', - }, - orderItem: { - sku_code: 'df-cdk-used', - }, - } - const initialInventoryItem = { - id: 7, - display_value: 'USED1234CODE5678', - credential_type: 'tencent_code', - inventory_group_code: 'B组', - } - - let thrown = null - try { - await redeemClaimTaskWithInventoryFallbackWithDeps( - context, - initialInventoryItem, - { - redeemTencentBrowserSession: async () => ({ - redeem: { - final: { - redeem: { - popup: { - text: '兑换结果', - detail: '兑换码已使用。', - }, - }, - }, - }, - }), - markInventoryItemConsumed: async (inventoryItemId, reason, updatedAt) => { - consumed.push({ inventoryItemId, reason, updatedAt }) - return { id: inventoryItemId, status: 'consumed', invalid_reason: reason, updated_at: updatedAt } - }, - reserveInventoryForTask: async (payload) => { - reserveCalls.push(payload) - return null - }, - createTaskEvent: async (taskId, eventType, payload, createdAt) => { - events.push({ taskId, eventType, payload, createdAt }) - }, - nowIso: () => '2026-04-14T12:05:00.000Z', - }, - ) - } catch (error) { - thrown = error - } - - assert.ok(thrown) - assert.equal(thrown.errorCode, 'claim_inventory_replacement_exhausted') - assert.equal(thrown.redeemTaskState.taskStatus, 'waiting_inventory') - assert.equal(thrown.redeemTaskState.inventoryStatus, 'pending') - assert.equal(thrown.redeemTaskState.deliveryStatus, 'pending') - assert.equal(thrown.redeemTaskState.attempts.length, 1) - assert.deepEqual(consumed, [ - { - inventoryItemId: 7, - reason: '兑换码已使用。', - updatedAt: '2026-04-14T12:05:00.000Z', - }, - ]) - assert.deepEqual(reserveCalls, [ - { - skuCode: 'df-cdk-used', - taskId: 57, - credentialType: 'tencent_code', - roleKey: 'primary_code', - inventoryGroupCodes: ['B组'], - }, - ]) - assert.deepEqual( - events.map((event) => ({ - taskId: event.taskId, - eventType: event.eventType, - payload: event.payload, - createdAt: event.createdAt, - })), - [ - { - taskId: 57, - eventType: 'claim_redeem_code_used', - payload: { - inventoryItemId: 7, - codeMasked: 'USED****5678', - resultCode: '', - resultMessage: '兑换码已使用。', - }, - createdAt: '2026-04-14T12:05:00.000Z', - }, - ], - ) -}) diff --git a/apps/backend/src/services/claim/claim-session-service.ts b/apps/backend/src/services/claim/claim-session-service.ts deleted file mode 100644 index aa0764e7..00000000 --- a/apps/backend/src/services/claim/claim-session-service.ts +++ /dev/null @@ -1,28 +0,0 @@ -export { - getClaimDetail, - getClaimDetailForAdminTask, - getClaimSessionSummary, - getClaimSessionSummaryForAdminTask, - confirmClaimRole, - confirmClaimRoleForAdminTask, - redeemClaimTask, - redeemClaimTaskForAdminTask, - getClaimScreenshotPath, -} from './session/actions.js' - -export { - createClaimSession, - createClaimSessionForAdminTask, - reloadClaimSession, - reloadClaimSessionForAdminTask, - closeClaimSession, - closeClaimSessionForAdminTask, -} from './session/lifecycle.js' - -export { - getClaimContext, -} from './session/context.js' - -export { - redeemClaimTaskWithInventoryFallbackWithDeps, -} from './session/redeem.js' diff --git a/apps/backend/src/services/claim/session/shared.ts b/apps/backend/src/services/claim/kuaishou-cloud-claim-context.ts similarity index 62% rename from apps/backend/src/services/claim/session/shared.ts rename to apps/backend/src/services/claim/kuaishou-cloud-claim-context.ts index b71d661a..7aaf2c80 100644 --- a/apps/backend/src/services/claim/session/shared.ts +++ b/apps/backend/src/services/claim/kuaishou-cloud-claim-context.ts @@ -1,32 +1,95 @@ -import { buildClaimUrl } from '../claim-service.js' -import { createHttpError } from '../../../utils/http.js' -import { maskCode as maskCodeValue } from '../../../utils/masking.js' -import { formatFenToAmount, normalizeFen } from '../../../utils/money.js' -import { - parseTaskContext as parseTaskContextValue, - parseTaskState as parseTaskStateValue, -} from '../../../utils/task-json.js' +import { findClaimTokenByToken, updateClaimToken } from '../../repositories/claim-token-repo.js' +import { releaseReservedInventoryItem } from '../../repositories/inventory-repo.js' +import { getOrderItemById } from '../../repositories/order-item-repo.js' +import { getOrderById } from '../../repositories/order-repo.js' +import { findTaskByClaimTokenId, updateTask } from '../../repositories/task-repo.js' +import { createHttpError } from '../../utils/http.js' +import { formatFenToAmount, normalizeFen } from '../../utils/money.js' +import { parseTaskContext as parseTaskContextValue } from '../../utils/task-json.js' +import { nowIso } from '../../utils/time.js' +import { buildClaimUrl } from './claim-service.js' export const CLAIM_TERMINAL_STATUSES = new Set(['expired', 'closed']) -export const REDEEM_REPLACEMENT_LIMIT = 10 export const KUAISHOU_CLOUD_CLAIM_GUIDE_BASE_PATH = '/kuaishou-cloud-guide' -export function buildClaimDetailPayload({ claimToken, task, order, orderItem, session }) { - const screenshotReady = Boolean(task.screenshot_path) || Boolean(session?.artifacts?.hasScreenshot) - const screenshotUrl = screenshotReady ? `/api/v1/claim/${claimToken.token}/screenshot` : '' - const finalRedeem = session?.redeem?.final?.redeem || null +export async function getClaimContext(token: unknown) { + const normalized = String(token || '').trim() + + if (!normalized) { + throw createHttpError('缺少领取 token', { + statusCode: 400, + errorCode: 'missing_claim_token', + }) + } + + const claimToken = await findClaimTokenByToken(normalized) + + if (!claimToken) { + throw createHttpError('领取链接无效或不存在', { + statusCode: 404, + errorCode: 'claim_token_not_found', + }) + } + + const task = await findTaskByClaimTokenId(claimToken.id) + + if (!task) { + throw createHttpError('领取任务不存在', { + statusCode: 404, + errorCode: 'claim_task_not_found', + }) + } + + if (claimToken.status !== 'active') { + throw createHttpError('领取链接当前不可用', { + statusCode: 410, + errorCode: 'claim_token_inactive', + }) + } + + if (claimToken.expired_at && new Date(claimToken.expired_at).getTime() <= Date.now()) { + const expiredContext = await expireClaimContext(claimToken, task) + + throw createHttpError('领取链接已过期', { + statusCode: 410, + errorCode: 'claim_token_expired', + context: expiredContext, + }) + } + + const [order, orderItem] = await Promise.all([ + getOrderById(task.order_id), + getOrderItemById(task.order_item_id), + ]) + + if (!order || !orderItem) { + throw createHttpError('领取任务关联订单不完整', { + statusCode: 500, + errorCode: 'claim_order_incomplete', + }) + } + + return { + claimToken, + task, + order, + orderItem, + } +} + +export function buildClaimDetailPayload({ claimToken, task, order, orderItem }) { const kuaishouCloudFulfillment = mapClaimKuaishouCloudFulfillment(task, order) return { tokenStatus: claimToken.status, claimUrl: buildClaimUrl(claimToken.token), - flowType: kuaishouCloudFulfillment ? 'kuaishou_cloud' : 'tencent_claim', + flowType: 'kuaishou_cloud', task: { taskId: task.id, taskNo: task.task_no, status: task.task_status, executorKey: task.executor_key || '', - requiresSupportReview: isAssistedClaimTask(task), + requiresSupportReview: false, expiresAt: claimToken.expired_at, claimedAt: task.claimed_at, roleConfirmedAt: task.role_confirmed_at, @@ -51,14 +114,14 @@ export function buildClaimDetailPayload({ claimToken, task, order, orderItem, se skuName: orderItem.sku_name, quantity: orderItem.quantity, }, - session, + session: null, kuaishouCloudFulfillment, - result: task.redeemed_at || session?.status === 'redeemed' + result: task.redeemed_at ? { - resultCode: String(task.result_code || finalRedeem?.iRet || finalRedeem?.ret || ''), - resultMessage: String(task.result_message || finalRedeem?.sMsg || finalRedeem?.msg || session?.notice || ''), - screenshotReady, - screenshotUrl, + resultCode: String(task.result_code || ''), + resultMessage: String(task.result_message || ''), + screenshotReady: false, + screenshotUrl: '', } : null, } @@ -151,58 +214,35 @@ export function mapClaimKuaishouCloudFulfillment(task, order) { } } -export function assertTaskCanProceed(task) { - if (CLAIM_TERMINAL_STATUSES.has(String(task.task_status || ''))) { - throw createHttpError('当前任务已经结束,不能继续操作', { - statusCode: 410, - errorCode: 'claim_task_closed', - }) - } -} - -export function assertPublicClaimActionAllowed(task, action) { - if (!isAssistedClaimTask(task)) { - return - } - - const message = action === 'confirm' - ? '当前商品需要客服复核角色,请登录后联系人工继续' - : '当前商品需要客服确认后再执行兑换,请联系人工继续' - - throw createHttpError(message, { - statusCode: 409, - errorCode: 'claim_support_review_required', - }) -} - -export function mergeTaskContext(task, patch = {}) { - return { - ...parseTaskContext(task), - ...patch, - } -} - -export function parseTaskState(task) { - return parseTaskStateValue(task) -} - -export function isAssistedClaimTask(task) { - return String(task?.executor_key || '').trim() === 'tencent_claim_assisted' -} - -export function parseTaskContext(task) { +function parseTaskContext(task) { return parseTaskContextValue(task) } -export function normalizeClaimLoginType(loginType) { - return String(loginType || '').trim() === 'wx' ? 'wx' : 'qq' -} +async function expireClaimContext(claimToken, task) { + const now = nowIso() + const nextClaimToken = await updateClaimToken(claimToken.id, { + status: 'expired', + updated_at: now, + }) -export function isRecoverableSessionError(error) { - const errorCode = String(error?.errorCode || error?.code || '').trim() - return errorCode === 'session_not_found' || errorCode === 'session_closed' -} + let nextTask = task -export function maskCode(value) { - return maskCodeValue(value, { shortMask: '****' }) + if (!CLAIM_TERMINAL_STATUSES.has(String(task.task_status || '')) && task.task_status !== 'redeemed') { + if (task.primary_inventory_item_id) { + await releaseReservedInventoryItem(task.primary_inventory_item_id, now) + } + + nextTask = await updateTask(task.id, { + task_status: 'expired', + inventory_status: 'pending', + user_action_status: 'expired', + last_error: '领取链接已过期,预占库存项已释放', + updated_at: now, + }) + } + + return { + claimToken: nextClaimToken, + task: nextTask, + } } diff --git a/apps/backend/src/services/claim/kuaishou-cloud-claim-service.ts b/apps/backend/src/services/claim/kuaishou-cloud-claim-service.ts index ad535898..8209cacc 100644 --- a/apps/backend/src/services/claim/kuaishou-cloud-claim-service.ts +++ b/apps/backend/src/services/claim/kuaishou-cloud-claim-service.ts @@ -18,8 +18,7 @@ import { prepareKuaishouCloudFulfillmentTask, refreshKuaishouCloudTaskRoleInfo, } from '../fulfillment/kuaishou-cloud-task-service.js' -import { buildClaimDetailPayload } from './session/shared.js' -import { getClaimContext } from './session/context.js' +import { buildClaimDetailPayload, getClaimContext } from './kuaishou-cloud-claim-context.js' import { syncKuaishouCloudRoleInfo } from './kuaishou-cloud-sync-service.js' const KUAISHOU_CLOUD_GUIDE_DIR = path.resolve(PROJECT_ROOT, '../../tems/imgs') @@ -170,7 +169,6 @@ export async function getKuaishouCloudClaimDetail(token: unknown) { task, order: context.order, orderItem: context.orderItem, - session: null, }) } diff --git a/apps/backend/src/services/claim/session/actions.ts b/apps/backend/src/services/claim/session/actions.ts deleted file mode 100644 index 7c7bcf0e..00000000 --- a/apps/backend/src/services/claim/session/actions.ts +++ /dev/null @@ -1,312 +0,0 @@ -import { - getTencentBrowserSession, - getTencentBrowserSessionScreenshotPath, -} from '../../session/session.js' -import { createTaskEvent } from '../../../repositories/task-event-repo.js' -import { updateTask } from '../../../repositories/task-repo.js' -import { - getInventoryItemById, - markInventoryItemDelivered, -} from '../../../repositories/inventory-repo.js' -import { ensureAgisoXianyuAutoDeliveryForDeliveredTask } from '../../platforms/agiso/xianyu/auto-delivery-service.js' -import { syncKuaishouCloudRoleInfo } from '../kuaishou-cloud-sync-service.js' -import { notifyClaimRedeemNeedsAttention } from '../../notification/domain-notifications.js' -import { createHttpError } from '../../../utils/http.js' -import { nowIso } from '../../../utils/time.js' -import { getClaimContext, getClaimContextByTaskId } from './context.js' -import { loadTaskSession, syncTaskWithSession } from './runtime.js' -import { redeemClaimTaskWithInventoryFallback, resolveTencentRedeemResultCode } from './redeem.js' -import { - assertPublicClaimActionAllowed, - assertTaskCanProceed, - buildClaimDetailPayload, - maskCode, - mergeTaskContext, -} from './shared.js' - -export async function getClaimDetail(token, { includeQrImage = true } = {}) { - const context = await getClaimContext(token) - let { task, session } = await loadTaskSession(context.task, { includeQrImage }) - - if (String(task.executor_key || '').trim() === 'kuaishou_ct_assisted') { - task = await syncKuaishouCloudRoleInfo(task) - } - - const syncedTask = session ? await syncTaskWithSession(task, session) : task - - return buildClaimDetailPayload({ - claimToken: context.claimToken, - task: syncedTask, - order: context.order, - orderItem: context.orderItem, - session, - }) -} - -export async function getClaimDetailForAdminTask(taskId, { includeQrImage = true } = {}) { - const context = await getClaimContextByTaskId(taskId) - const { task, session } = await loadTaskSession(context.task, { includeQrImage }) - const syncedTask = session ? await syncTaskWithSession(task, session) : task - - return buildClaimDetailPayload({ - claimToken: context.claimToken, - task: syncedTask, - order: context.order, - orderItem: context.orderItem, - session, - }) -} - -export async function getClaimSessionSummary(token) { - return getClaimSessionSummaryByContextLoader(() => getClaimContext(token)) -} - -export async function getClaimSessionSummaryForAdminTask(taskId) { - return getClaimSessionSummaryByContextLoader(() => getClaimContextByTaskId(taskId)) -} - -export async function confirmClaimRole(token) { - const context = await getClaimContext(token) - assertPublicClaimActionAllowed(context.task, 'confirm') - - return finalizeClaimRoleConfirmation(context) -} - -export async function confirmClaimRoleForAdminTask(taskId) { - const context = await getClaimContextByTaskId(taskId) - - return finalizeClaimRoleConfirmation(context) -} - -export async function redeemClaimTask(token) { - const context = await getClaimContext(token) - assertTaskCanProceed(context.task) - assertPublicClaimActionAllowed(context.task, 'redeem') - - return finalizeClaimTaskRedeem(context) -} - -export async function redeemClaimTaskForAdminTask(taskId) { - const context = await getClaimContextByTaskId(taskId) - assertTaskCanProceed(context.task) - - return finalizeClaimTaskRedeem(context) -} - -export async function getClaimScreenshotPath(token) { - const context = await getClaimContext(token) - - if (context.task.screenshot_path) { - return context.task.screenshot_path - } - - if (!context.task.browser_session_id) { - throw createHttpError('当前任务还没有兑换截图', { - statusCode: 404, - errorCode: 'claim_screenshot_not_ready', - }) - } - - return getTencentBrowserSessionScreenshotPath(context.task.browser_session_id) -} - -async function getClaimSessionSummaryByContextLoader(loadContext) { - const context = await loadContext() - const { task, session } = await loadTaskSession(context.task, { - includeQrImage: false, - }) - - if (!session) { - return buildClaimDetailPayload({ - claimToken: context.claimToken, - task, - order: context.order, - orderItem: context.orderItem, - session: null, - }) - } - - const syncedTask = await syncTaskWithSession(task, session) - - return buildClaimDetailPayload({ - claimToken: context.claimToken, - task: syncedTask, - order: context.order, - orderItem: context.orderItem, - session, - }) -} - -async function finalizeClaimRoleConfirmation(context) { - if (!context.task.browser_session_id) { - throw createHttpError('当前任务还没有创建浏览器会话', { - statusCode: 409, - errorCode: 'claim_session_not_created', - }) - } - - const session = await getTencentBrowserSession(context.task.browser_session_id) - const activityInfo = session.activityInfo || null - - if (!activityInfo?.role?.ready) { - throw createHttpError('当前角色信息还没有准备好', { - statusCode: 409, - errorCode: 'claim_role_not_ready', - }) - } - - const updatedTask = await updateTask(context.task.id, { - task_status: 'role_confirmed', - nickname: String(activityInfo.nickname || ''), - role_id: String(activityInfo.role.roleId || ''), - role_name: String(activityInfo.role.roleName || ''), - area: String(activityInfo.role.area || ''), - partition_name: String(activityInfo.role.partition || ''), - user_action_status: 'role_confirmed', - role_confirmed_at: nowIso(), - updated_at: nowIso(), - last_error: '', - }) - - return buildClaimDetailPayload({ - claimToken: context.claimToken, - task: updatedTask, - order: context.order, - orderItem: context.orderItem, - session, - }) -} - -async function finalizeClaimTaskRedeem(context) { - if (!context.task.browser_session_id) { - throw createHttpError('当前任务还没有创建浏览器会话', { - statusCode: 409, - errorCode: 'claim_session_not_created', - }) - } - - if (context.task.task_status !== 'role_confirmed' && context.task.task_status !== 'redeeming') { - throw createHttpError('当前任务还未确认角色,不能开始兑换', { - statusCode: 409, - errorCode: 'claim_role_not_confirmed', - }) - } - - const inventoryItem = context.task.primary_inventory_item_id - ? await getInventoryItemById(context.task.primary_inventory_item_id) - : null - - if ( - !inventoryItem || - String(inventoryItem.status || '').trim() !== 'reserved' || - !String(inventoryItem.display_value || '').trim() - ) { - throw createHttpError('当前任务没有可用的预占库存凭据', { - statusCode: 409, - errorCode: 'claim_inventory_not_reserved', - }) - } - - await updateTask(context.task.id, { - task_status: 'redeeming', - delivery_status: 'processing', - updated_at: nowIso(), - last_error: '', - }) - - try { - const redeemResult = await redeemClaimTaskWithInventoryFallback(context, inventoryItem) - const { session, inventoryItem: deliveredInventoryItem, classification, attempts } = redeemResult - const finalRedeem = session.redeem?.final?.redeem || null - const finishedAt = nowIso() - const updatedTask = await updateTask(context.task.id, { - task_status: 'redeemed', - inventory_status: 'consumed', - delivery_status: 'delivered', - result_code: resolveTencentRedeemResultCode(finalRedeem, classification), - result_message: classification.message, - screenshot_path: session.artifacts?.hasScreenshot ? await getTencentBrowserSessionScreenshotPath(session.sessionId) : '', - artifacts_json: JSON.stringify(session.artifacts || {}), - context_json: JSON.stringify(mergeTaskContext(context.task, { - redeemResolution: { - status: 'success', - attempts, - replacementCount: Math.max(0, attempts.length - 1), - finishedAt, - }, - })), - redeemed_at: finishedAt, - updated_at: finishedAt, - last_error: '', - }) - - await markInventoryItemDelivered(deliveredInventoryItem.id, finishedAt) - await createTaskEvent(context.task.id, 'claim_redeem_completed', { - inventoryItemId: deliveredInventoryItem.id, - codeMasked: maskCode(deliveredInventoryItem.display_value), - replacementCount: Math.max(0, attempts.length - 1), - resultCode: resolveTencentRedeemResultCode(finalRedeem, classification), - resultMessage: classification.message, - }, finishedAt) - const autoDeliveryResult = await ensureAgisoXianyuAutoDeliveryForDeliveredTask({ - order: context.order, - task: updatedTask, - trigger: 'claim_redeemed', - }) - - return buildClaimDetailPayload({ - claimToken: context.claimToken, - task: autoDeliveryResult.task || updatedTask, - order: context.order, - orderItem: context.orderItem, - session, - }) - } catch (error) { - const now = nowIso() - const nextRetryCount = Number(context.task.attempt_count || 0) + 1 - const failureState = ( - error && typeof error === 'object' - ? /** @type {{ redeemTaskState?: { taskStatus: string, inventoryStatus: string, deliveryStatus: string, lastError: string, attempts: unknown[], classification: { retCode?: unknown } | null } }} */ (error).redeemTaskState - : null - ) || { - taskStatus: 'retry_pending', - inventoryStatus: inventoryItem ? 'reserved' : 'pending', - deliveryStatus: 'pending', - lastError: error instanceof Error ? error.message : String(error || ''), - attempts: [], - classification: null, - } - const failedTask = await updateTask(context.task.id, { - task_status: failureState.taskStatus, - inventory_status: failureState.inventoryStatus, - delivery_status: failureState.deliveryStatus, - result_code: failureState.classification?.retCode != null ? String(failureState.classification.retCode) : '', - result_message: String(failureState.lastError || ''), - attempt_count: nextRetryCount, - last_error: String(failureState.lastError || ''), - context_json: JSON.stringify(mergeTaskContext(context.task, { - redeemResolution: { - status: 'failed', - taskStatus: failureState.taskStatus, - attempts: failureState.attempts || [], - replacementCount: Math.max(0, Number(failureState.attempts?.length || 1) - 1), - finishedAt: now, - }, - })), - updated_at: now, - }) - - if (['retry_pending', 'waiting_inventory', 'manual_review'].includes(String(failureState.taskStatus || '').trim())) { - await notifyClaimRedeemNeedsAttention({ - task: failedTask, - order: context.order, - status: failureState.taskStatus, - errorMessage: failureState.lastError, - }) - } - - throw Object.assign(error instanceof Error ? error : new Error(String(error || '兑换失败')), { - task: failedTask, - }) - } -} diff --git a/apps/backend/src/services/claim/session/context.ts b/apps/backend/src/services/claim/session/context.ts deleted file mode 100644 index de77bca6..00000000 --- a/apps/backend/src/services/claim/session/context.ts +++ /dev/null @@ -1,150 +0,0 @@ -import { findClaimTokenByToken, getClaimTokenById, updateClaimToken } from '../../../repositories/claim-token-repo.js' -import { getOrderById } from '../../../repositories/order-repo.js' -import { getOrderItemById } from '../../../repositories/order-item-repo.js' -import { findTaskByClaimTokenId, getTaskById, updateTask } from '../../../repositories/task-repo.js' -import { releaseReservedInventoryItem } from '../../../repositories/inventory-repo.js' -import { createHttpError } from '../../../utils/http.js' -import { nowIso } from '../../../utils/time.js' -import { CLAIM_TERMINAL_STATUSES } from './shared.js' - -export async function getClaimContext(token) { - const normalized = String(token || '').trim() - - if (!normalized) { - throw createHttpError('缺少领取 token', { - statusCode: 400, - errorCode: 'missing_claim_token', - }) - } - - const claimToken = await findClaimTokenByToken(normalized) - - if (!claimToken) { - throw createHttpError('领取链接无效或不存在', { - statusCode: 404, - errorCode: 'claim_token_not_found', - }) - } - - const task = await findTaskByClaimTokenId(claimToken.id) - - if (!task) { - throw createHttpError('领取任务不存在', { - statusCode: 404, - errorCode: 'claim_task_not_found', - }) - } - - if (claimToken.status !== 'active') { - throw createHttpError('领取链接当前不可用', { - statusCode: 410, - errorCode: 'claim_token_inactive', - }) - } - - if (claimToken.expired_at && new Date(claimToken.expired_at).getTime() <= Date.now()) { - const expiredContext = await expireClaimContext(claimToken, task) - - throw createHttpError('领取链接已过期', { - statusCode: 410, - errorCode: 'claim_token_expired', - context: expiredContext, - }) - } - - const [order, orderItem] = await Promise.all([ - getOrderById(task.order_id), - getOrderItemById(task.order_item_id), - ]) - - if (!order || !orderItem) { - throw createHttpError('领取任务关联订单不完整', { - statusCode: 500, - errorCode: 'claim_order_incomplete', - }) - } - - return { - claimToken, - task, - order, - orderItem, - } -} - -export async function getClaimContextByTaskId(taskId) { - const task = await getTaskById(Number(taskId)) - - if (!task) { - throw createHttpError('领取任务不存在', { - statusCode: 404, - errorCode: 'claim_task_not_found', - }) - } - - const claimTokenId = Number(task.primary_claim_token_id || 0) - - if (!claimTokenId) { - throw createHttpError('当前任务还没有领取链接', { - statusCode: 409, - errorCode: 'claim_token_missing', - }) - } - - const claimToken = await getClaimTokenById(claimTokenId) - - if (!claimToken) { - throw createHttpError('领取链接不存在', { - statusCode: 404, - errorCode: 'claim_token_not_found', - }) - } - - const [order, orderItem] = await Promise.all([ - getOrderById(task.order_id), - getOrderItemById(task.order_item_id), - ]) - - if (!order || !orderItem) { - throw createHttpError('领取任务关联订单不完整', { - statusCode: 500, - errorCode: 'claim_order_incomplete', - }) - } - - return { - claimToken, - task, - order, - orderItem, - } -} - -async function expireClaimContext(claimToken, task) { - const now = nowIso() - const nextClaimToken = await updateClaimToken(claimToken.id, { - status: 'expired', - updated_at: now, - }) - - let nextTask = task - - if (!CLAIM_TERMINAL_STATUSES.has(String(task.task_status || '')) && task.task_status !== 'redeemed') { - if (task.primary_inventory_item_id) { - await releaseReservedInventoryItem(task.primary_inventory_item_id, now) - } - - nextTask = await updateTask(task.id, { - task_status: 'expired', - inventory_status: 'pending', - user_action_status: 'expired', - last_error: '领取链接已过期,预占库存项已释放', - updated_at: now, - }) - } - - return { - claimToken: nextClaimToken, - task: nextTask, - } -} diff --git a/apps/backend/src/services/claim/session/lifecycle.ts b/apps/backend/src/services/claim/session/lifecycle.ts deleted file mode 100644 index 3b39f57b..00000000 --- a/apps/backend/src/services/claim/session/lifecycle.ts +++ /dev/null @@ -1,175 +0,0 @@ -import { - closeTencentBrowserSession, - createTencentBrowserSession, - reloadTencentBrowserSession, -} from '../../session/session.js' -import { updateTask } from '../../../repositories/task-repo.js' -import { nowIso } from '../../../utils/time.js' -import { getClaimContext, getClaimContextByTaskId } from './context.js' -import { clearTaskSession, loadTaskSession, syncTaskWithSession } from './runtime.js' -import { - assertTaskCanProceed, - buildClaimDetailPayload, - isRecoverableSessionError, - normalizeClaimLoginType, -} from './shared.js' - -type JsonObject = Record - -export async function createClaimSession(token: unknown, payload: JsonObject = {}) { - return createClaimSessionWithContextLoader( - () => getClaimContext(token), - payload, - ) -} - -export async function createClaimSessionForAdminTask(taskId: unknown, payload: JsonObject = {}) { - return createClaimSessionWithContextLoader( - () => getClaimContextByTaskId(taskId), - payload, - ) -} - -export async function reloadClaimSession(token: unknown) { - return reloadClaimSessionWithContextLoader(() => getClaimContext(token)) -} - -export async function reloadClaimSessionForAdminTask(taskId: unknown) { - return reloadClaimSessionWithContextLoader(() => getClaimContextByTaskId(taskId)) -} - -export async function closeClaimSession(token: unknown) { - return closeClaimSessionWithContextLoader(() => getClaimContext(token)) -} - -export async function closeClaimSessionForAdminTask(taskId: unknown) { - return closeClaimSessionWithContextLoader(() => getClaimContextByTaskId(taskId)) -} - -async function createClaimSessionWithContextLoader(loadContext: () => Promise, payload: JsonObject = {}) { - const context = await loadContext() - assertTaskCanProceed(context.task) - const requestedLoginType = normalizeClaimLoginType(payload.loginType) - const forceRecreate = Boolean(payload.forceRecreate) - let task = context.task - - if (task.browser_session_id) { - const existing = await loadTaskSession(task, { includeQrImage: true }) - task = existing.task - - if (existing.session) { - const existingLoginType = normalizeClaimLoginType(existing.session.loginType || task.login_type) - - if (!forceRecreate && existingLoginType === requestedLoginType) { - const syncedTask = await syncTaskWithSession(task, existing.session) - - return buildClaimDetailPayload({ - claimToken: context.claimToken, - task: syncedTask, - order: context.order, - orderItem: context.orderItem, - session: existing.session, - }) - } - - try { - await closeTencentBrowserSession(existing.session.sessionId) - } catch (error) { - if (!isRecoverableSessionError(error)) { - throw error - } - } - - task = await clearTaskSession(task, { - lastError: '', - }) - } - } - - const session = await createTencentBrowserSession({ - loginType: requestedLoginType, - }) - const updatedTask = await updateTask(task.id, { - task_status: 'claimed', - browser_session_id: session.sessionId, - login_type: session.loginType, - user_action_status: 'claimed', - claimed_at: task.claimed_at || nowIso(), - updated_at: nowIso(), - last_error: '', - }) - - return buildClaimDetailPayload({ - claimToken: context.claimToken, - task: updatedTask, - order: context.order, - orderItem: context.orderItem, - session, - }) -} - -async function reloadClaimSessionWithContextLoader(loadContext: () => Promise) { - const context = await loadContext() - assertTaskCanProceed(context.task) - - const active = await loadTaskSession(context.task, { - includeQrImage: true, - }) - - if (!active.session) { - return buildClaimDetailPayload({ - claimToken: context.claimToken, - task: active.task, - order: context.order, - orderItem: context.orderItem, - session: null, - }) - } - - const session = await reloadTencentBrowserSession(active.session.sessionId) - const syncedTask = await syncTaskWithSession(active.task, session) - - return buildClaimDetailPayload({ - claimToken: context.claimToken, - task: syncedTask, - order: context.order, - orderItem: context.orderItem, - session, - }) -} - -async function closeClaimSessionWithContextLoader(loadContext: () => Promise) { - const context = await loadContext() - assertTaskCanProceed(context.task) - let task = context.task - - if (!task.browser_session_id) { - return buildClaimDetailPayload({ - claimToken: context.claimToken, - task, - order: context.order, - orderItem: context.orderItem, - session: null, - }) - } - - try { - await closeTencentBrowserSession(task.browser_session_id) - } catch (error) { - if (!isRecoverableSessionError(error)) { - throw error - } - } - - task = await clearTaskSession(task, { - lastError: '', - }) - - return buildClaimDetailPayload({ - claimToken: context.claimToken, - task, - order: context.order, - orderItem: context.orderItem, - session: null, - }) -} diff --git a/apps/backend/src/services/claim/session/redeem.ts b/apps/backend/src/services/claim/session/redeem.ts deleted file mode 100644 index cb50ecc2..00000000 --- a/apps/backend/src/services/claim/session/redeem.ts +++ /dev/null @@ -1,301 +0,0 @@ -import { reloadTencentBrowserSession, redeemTencentBrowserSession } from '../../session/session.js' -import { classifyTencentRedeemResult } from '../../session/session-redeem.js' -import { createTaskEvent } from '../../../repositories/task-event-repo.js' -import { - invalidateReservedInventoryItem, - markInventoryItemConsumed, -} from '../../../repositories/inventory-repo.js' -import { reserveInventoryForTask } from '../../order/inventory-service.js' -import { createHttpError } from '../../../utils/http.js' -import { nowIso } from '../../../utils/time.js' -import { maskCode, REDEEM_REPLACEMENT_LIMIT } from './shared.js' -import type { InventoryItemRow, OrderItemRow, TaskRow } from '../../../types/repository-rows.js' - -type ClaimRedeemContext = { - task: Pick - orderItem: Pick -} - -type TencentRedeemPayload = { - [key: string]: unknown - iRet?: unknown - ret?: unknown -} - -type TencentRedeemSession = { - [key: string]: unknown - sessionId?: string - artifacts?: { - [key: string]: unknown - hasScreenshot?: boolean - } | null - redeem?: { - final?: { - redeem?: TencentRedeemPayload | null - } | null - } | null -} - -type TencentRedeemClassification = { - success: boolean - outcome: string - message: string - retCode?: unknown - [key: string]: unknown -} - -type RedeemAttemptSummary = { - attempt: number - inventoryItemId: number - codeMasked: string - credentialType: string - outcome: string - resultCode: string - resultMessage: string -} - -type RedeemTaskState = { - taskStatus: string - inventoryStatus: string - deliveryStatus: string - lastError: string - attempts: RedeemAttemptSummary[] - classification: TencentRedeemClassification | null -} - -type RedeemTaskStateError = Error & { - statusCode?: number - errorCode?: string - redeemTaskState: RedeemTaskState -} - -type RedeemTaskStateErrorPayload = { - statusCode?: number - errorCode?: string - taskStatus?: string - inventoryStatus?: string - deliveryStatus?: string - attempts?: RedeemAttemptSummary[] - classification?: TencentRedeemClassification | null -} - -type RedeemClaimTaskDeps = { - reloadTencentBrowserSession?: (sessionId: string) => Promise - redeemTencentBrowserSession?: ( - sessionId: string, - payload: { code: string }, - ) => Promise - classifyTencentRedeemResult?: (finalRedeem: TencentRedeemPayload | null) => TencentRedeemClassification - markInventoryItemConsumed?: ( - inventoryItemId: number | string, - reason: string, - consumedAt: string, - ) => Promise - createTaskEvent?: ( - taskId: number | string, - eventType: string, - payload?: unknown, - createdAt?: string, - ) => Promise - invalidateReservedInventoryItem?: ( - inventoryItemId: number | string, - reason: string, - updatedAt: string, - ) => Promise - reserveInventoryForTask?: (payload: { - skuCode: string - taskId: number | string - credentialType?: string - roleKey?: string - inventoryGroupCodes?: string[] | null - }) => Promise - nowIso?: () => string - redeemReplacementLimit?: number -} - -type RedeemClaimTaskResult = { - session: TencentRedeemSession - inventoryItem: InventoryItemRow - classification: TencentRedeemClassification - attempts: RedeemAttemptSummary[] -} - -export async function redeemClaimTaskWithInventoryFallback( - context: ClaimRedeemContext, - initialInventoryItem: InventoryItemRow, -): Promise { - return redeemClaimTaskWithInventoryFallbackWithDeps(context, initialInventoryItem) -} - -export async function redeemClaimTaskWithInventoryFallbackWithDeps( - context: ClaimRedeemContext, - initialInventoryItem: InventoryItemRow, - { - reloadTencentBrowserSession: reloadRedeemSession = reloadTencentBrowserSession, - redeemTencentBrowserSession: redeemSession = redeemTencentBrowserSession, - classifyTencentRedeemResult: classifyRedeemResult = classifyTencentRedeemResult, - markInventoryItemConsumed: markConsumedInventoryItem = markInventoryItemConsumed, - createTaskEvent: createRedeemTaskEvent = createTaskEvent, - invalidateReservedInventoryItem: invalidateReservedItem = invalidateReservedInventoryItem, - reserveInventoryForTask: reserveReplacementInventory = reserveInventoryForTask, - nowIso: getNowIso = nowIso, - redeemReplacementLimit = REDEEM_REPLACEMENT_LIMIT, - }: RedeemClaimTaskDeps = {}, -): Promise { - const attempts: RedeemAttemptSummary[] = [] - let currentInventoryItem = initialInventoryItem - let shouldReloadBeforeNextAttempt = false - - for (let attemptIndex = 1; attemptIndex <= redeemReplacementLimit; attemptIndex += 1) { - if (shouldReloadBeforeNextAttempt) { - await reloadRedeemSession(context.task.browser_session_id) - shouldReloadBeforeNextAttempt = false - } - - const session = await redeemSession(context.task.browser_session_id, { - code: currentInventoryItem.display_value, - }) - const finalRedeem = session.redeem?.final?.redeem || null - const classification = classifyRedeemResult(finalRedeem) - const attemptSummary = { - attempt: attemptIndex, - inventoryItemId: currentInventoryItem.id, - codeMasked: maskCode(currentInventoryItem.display_value), - credentialType: String(currentInventoryItem.credential_type || ''), - outcome: classification.outcome, - resultCode: resolveTencentRedeemResultCode(finalRedeem, classification), - resultMessage: classification.message, - } - - attempts.push(attemptSummary) - - if (classification.success) { - return { - session, - inventoryItem: currentInventoryItem, - classification, - attempts, - } - } - - if (classification.outcome === 'code_used') { - const updatedAt = getNowIso() - await markConsumedInventoryItem(currentInventoryItem.id, classification.message, updatedAt) - await createRedeemTaskEvent(context.task.id, 'claim_redeem_code_used', { - inventoryItemId: currentInventoryItem.id, - codeMasked: attemptSummary.codeMasked, - resultCode: attemptSummary.resultCode, - resultMessage: classification.message, - }, updatedAt) - } else if (classification.outcome === 'code_invalid') { - const updatedAt = getNowIso() - await invalidateReservedItem(currentInventoryItem.id, classification.message, updatedAt) - await createRedeemTaskEvent(context.task.id, 'claim_redeem_code_invalid', { - inventoryItemId: currentInventoryItem.id, - codeMasked: attemptSummary.codeMasked, - resultCode: attemptSummary.resultCode, - resultMessage: classification.message, - }, updatedAt) - } else { - throw createRedeemTaskStateError(classification.message, { - errorCode: 'claim_redeem_failed', - taskStatus: 'retry_pending', - inventoryStatus: 'reserved', - deliveryStatus: 'pending', - attempts, - classification, - }) - } - - if (attemptIndex >= redeemReplacementLimit) { - throw createRedeemTaskStateError('连续更换兑换码后仍未成功,请联系人工处理', { - errorCode: 'claim_redeem_replacement_limit_reached', - taskStatus: 'retry_pending', - inventoryStatus: 'pending', - deliveryStatus: 'pending', - attempts, - classification, - }) - } - - const replacement = await reserveReplacementInventory({ - skuCode: context.orderItem.sku_code, - taskId: context.task.id, - credentialType: currentInventoryItem.credential_type || 'tencent_code', - roleKey: 'primary_code', - inventoryGroupCodes: currentInventoryItem.inventory_group_code - ? [String(currentInventoryItem.inventory_group_code).trim()] - : null, - }) - - if (!replacement || !String(replacement.display_value || '').trim()) { - const exhaustedMessage = classification.outcome === 'code_used' - ? '兑换码已使用,且没有更多同类型可用 CDK 可继续重试' - : '兑换码错误,请确认库存数据;当前没有更多同类型可用 CDK 可继续重试' - - throw createRedeemTaskStateError(exhaustedMessage, { - errorCode: 'claim_inventory_replacement_exhausted', - taskStatus: 'waiting_inventory', - inventoryStatus: 'pending', - deliveryStatus: 'pending', - attempts, - classification, - }) - } - - const replacedAt = getNowIso() - await createRedeemTaskEvent(context.task.id, 'claim_redeem_inventory_replaced', { - previousInventoryItemId: currentInventoryItem.id, - previousCodeMasked: attemptSummary.codeMasked, - previousOutcome: classification.outcome, - nextInventoryItemId: replacement.id, - nextCodeMasked: maskCode(replacement.display_value), - credentialType: String(replacement.credential_type || currentInventoryItem.credential_type || ''), - inventoryGroupCode: String(replacement.inventory_group_code || currentInventoryItem.inventory_group_code || '').trim(), - }, replacedAt) - currentInventoryItem = replacement - shouldReloadBeforeNextAttempt = true - } - - throw createRedeemTaskStateError('兑换失败,请稍后重试', { - errorCode: 'claim_redeem_failed', - taskStatus: 'retry_pending', - inventoryStatus: 'pending', - deliveryStatus: 'pending', - attempts, - classification: null, - }) -} - -function createRedeemTaskStateError( - message: string, - payload: RedeemTaskStateErrorPayload = {}, -): RedeemTaskStateError { - const error = createHttpError(message, { - statusCode: payload.statusCode || 409, - errorCode: payload.errorCode || 'claim_redeem_failed', - }) as unknown as RedeemTaskStateError - - error.redeemTaskState = { - taskStatus: payload.taskStatus || 'retry_pending', - inventoryStatus: payload.inventoryStatus || 'pending', - deliveryStatus: payload.deliveryStatus || 'pending', - lastError: String(message || ''), - attempts: payload.attempts || [], - classification: payload.classification || null, - } - - return error -} - -export function resolveTencentRedeemResultCode( - finalRedeem: TencentRedeemPayload | null, - classification: Pick | null, -): string { - if (classification?.retCode != null) { - return String(classification.retCode) - } - - const value = finalRedeem?.iRet ?? finalRedeem?.ret - return value == null ? '' : String(value) -} diff --git a/apps/backend/src/services/claim/session/runtime.ts b/apps/backend/src/services/claim/session/runtime.ts deleted file mode 100644 index 7b3c0cfc..00000000 --- a/apps/backend/src/services/claim/session/runtime.ts +++ /dev/null @@ -1,107 +0,0 @@ -import { - getTencentBrowserSession, - getTencentBrowserSessionSummary, -} from '../../session/session.js' -import { updateTask } from '../../../repositories/task-repo.js' -import { nowIso } from '../../../utils/time.js' -import { isRecoverableSessionError, parseTaskState } from './shared.js' - -type JsonObject = Record - -export async function loadTaskSession(task: JsonObject, { includeQrImage = false }: { includeQrImage?: boolean } = {}) { - if (!task.browser_session_id) { - return { - task, - session: null, - } - } - - try { - const session = includeQrImage - ? await getTencentBrowserSession(task.browser_session_id) - : await getTencentBrowserSessionSummary(task.browser_session_id) - - return { - task, - session, - } - } catch (error) { - if (!isRecoverableSessionError(error)) { - throw error - } - - const nextTask = await clearTaskSession(task, { - lastError: '浏览器会话已失效,请重新初始化登录', - }) - - return { - task: nextTask, - session: null, - } - } -} - -export async function syncTaskWithSession(task: JsonObject, session: JsonObject) { - const activityInfo = session.activityInfo || null - const patch: JsonObject = { - login_type: String(session.loginType || task.login_type || ''), - updated_at: nowIso(), - } - - if (activityInfo?.nickname) { - patch.nickname = String(activityInfo.nickname) - } - - if (activityInfo?.role?.ready) { - patch.role_id = String(activityInfo.role.roleId || '') - patch.role_name = String(activityInfo.role.roleName || '') - patch.area = String(activityInfo.role.area || '') - patch.partition_name = String(activityInfo.role.partition || '') - } - - if (task.task_status === 'link_generated') { - patch.task_status = 'claimed' - patch.user_action_status = 'claimed' - patch.claimed_at = task.claimed_at || nowIso() - } - - if (session.review?.capturedAt) { - patch.state_json = JSON.stringify({ - ...parseTaskState(task), - reviewScreenshotReady: true, - reviewCapturedAt: String(session.review.capturedAt || ''), - reviewRoleId: String(session.review.roleId || ''), - reviewRoleName: String(session.review.roleName || ''), - }) - } - - if (session.status === 'redeemed' && session.artifacts?.hasScreenshot) { - patch.screenshot_path = task.screenshot_path || '' - } - - return updateTask(task.id, patch) -} - -export async function clearTaskSession(task: JsonObject, { lastError = '' }: { lastError?: string } = {}) { - const shouldResetClaimProgress = ['link_generated', 'claimed', 'role_confirmed'].includes(String(task.task_status || '')) - const nextTaskStatus = shouldResetClaimProgress ? 'link_generated' : task.task_status - const nextUserActionStatus = shouldResetClaimProgress ? 'pending_claim' : task.user_action_status - const patch = { - task_status: nextTaskStatus, - user_action_status: nextUserActionStatus, - browser_session_id: '', - login_type: '', - nickname: '', - role_id: '', - role_name: '', - area: '', - partition_name: '', - artifacts_json: '{}', - state_json: '{}', - role_confirmed_at: nextTaskStatus === 'link_generated' ? null : task.role_confirmed_at, - last_error: String(lastError || ''), - updated_at: nowIso(), - } - - return updateTask(task.id, patch) -} diff --git a/apps/backend/src/services/order/agiso-trade-parsing.test.ts b/apps/backend/src/services/order/agiso-trade-parsing.test.ts deleted file mode 100644 index 4f85b9d5..00000000 --- a/apps/backend/src/services/order/agiso-trade-parsing.test.ts +++ /dev/null @@ -1,31 +0,0 @@ -import test from 'node:test' -import assert from 'node:assert/strict' - -import { extractAgisoTradePayload, resolveAgisoTradePlatformOrderId } from './agiso-trade-parsing.js' - -test('extractAgisoTradePayload preserves large integer order ids from raw json', () => { - const payload = extractAgisoTradePayload({ - json: '{"biz_order_id":4502259793206016214,"item_id":978102431355,"order_status":4}', - }) - - assert.equal(payload.biz_order_id, '4502259793206016214') - assert.equal(typeof payload.biz_order_id, 'string') -}) - -test('resolveAgisoTradePlatformOrderId keeps exact webhook order id', () => { - const payload = extractAgisoTradePayload({ - json: '{"biz_order_id":4502259793206016214,"item_id":978102431355,"order_status":4}', - }) - - assert.equal(resolveAgisoTradePlatformOrderId(payload), '4502259793206016214') -}) - -test('resolveAgisoTradePlatformOrderId falls back to tid when biz_order_id is absent', () => { - assert.equal( - resolveAgisoTradePlatformOrderId({ - tid: '4502251008019011224', - orders: [{ oid: '4502251008019011888' }], - }), - '4502251008019011224', - ) -}) diff --git a/apps/backend/src/services/order/agiso-trade-parsing.ts b/apps/backend/src/services/order/agiso-trade-parsing.ts deleted file mode 100644 index c21167f1..00000000 --- a/apps/backend/src/services/order/agiso-trade-parsing.ts +++ /dev/null @@ -1,73 +0,0 @@ -import { parseJsonObject } from '../../utils/json.js' - -type JsonObject = Record - -export function extractAgisoTradePayload(body: unknown): JsonObject { - const normalizedBody = normalizeRecord(body) - const rawJson = String(normalizedBody.json || normalizedBody.JSON || '').trim() - - if (rawJson) { - return normalizeRecord(parseJsonObject(rawJson, { preserveLargeIntegers: true })) - } - - return normalizedBody -} - -export function resolveAgisoTradePlatformOrderId(payload: unknown): string { - const normalizedPayload = normalizeRecord(payload) - const firstItem = extractAgisoTradeOrderItemSources(normalizedPayload)[0] || {} - - return pickFirstNonEmpty([ - normalizedPayload.biz_order_id, - normalizedPayload.Tid, - normalizedPayload.tid, - normalizedPayload.Oid, - normalizedPayload.oid, - normalizedPayload.order_id, - normalizedPayload.orderId, - firstItem.Oid, - firstItem.oid, - ]) -} - -export function extractAgisoTradeOrderItemSources(payload: unknown): JsonObject[] { - const normalizedPayload = normalizeRecord(payload) - const candidates = [ - normalizedPayload.items, - normalizedPayload.Items, - normalizedPayload.orders, - normalizedPayload.Orders, - normalizedPayload.order_list, - normalizedPayload.OrderList, - ] - - for (const current of candidates) { - if (Array.isArray(current) && current.length > 0) { - return current.map((item) => normalizeRecord(item)).filter((item) => Object.keys(item).length > 0) - } - } - - if (Object.keys(normalizedPayload).length > 0) { - return [normalizedPayload] - } - - return [] -} - -function normalizeRecord(value: unknown): JsonObject { - return value && typeof value === 'object' && !Array.isArray(value) ? value as JsonObject : {} -} - -function pickFirstNonEmpty(values: unknown[]): string { - for (const value of values) { - if (typeof value === 'string' && value.trim()) { - return value.trim() - } - - if (typeof value === 'number' && Number.isFinite(value)) { - return String(value) - } - } - - return '' -} diff --git a/apps/backend/src/services/order/fulfillment-binding-config-service.test.ts b/apps/backend/src/services/order/fulfillment-binding-config-service.test.ts deleted file mode 100644 index a886995c..00000000 --- a/apps/backend/src/services/order/fulfillment-binding-config-service.test.ts +++ /dev/null @@ -1,52 +0,0 @@ -import test from 'node:test' -import assert from 'node:assert/strict' - -import { - filterLegacyOrderFulfillmentBindings, - isKuaishouCloudFulfillmentBinding, -} from './fulfillment-binding-config-service.js' - -test('isKuaishouCloudFulfillmentBinding only marks 91kaquan kuaishou rules as cloud rules', () => { - assert.equal( - isKuaishouCloudFulfillmentBinding({ - provider: '91kaquan', - platform: 'kuaishou', - }), - true, - ) - - assert.equal( - isKuaishouCloudFulfillmentBinding({ - provider: 'agiso', - platform: 'xianyu', - }), - false, - ) -}) - -test('filterLegacyOrderFulfillmentBindings keeps agiso rules and removes 91kaquan kuaishou rules', () => { - assert.deepEqual( - filterLegacyOrderFulfillmentBindings([ - { - provider: 'agiso', - platform: 'xianyu', - shopId: 'shop-1', - skuCode: 'sku-a', - }, - { - provider: '91kaquan', - platform: 'kuaishou', - shopId: '91kaquan', - skuCode: 'sku-b', - }, - ]), - [ - { - provider: 'agiso', - platform: 'xianyu', - shopId: 'shop-1', - skuCode: 'sku-a', - }, - ], - ) -}) diff --git a/apps/backend/src/services/order/fulfillment-binding-config-service.ts b/apps/backend/src/services/order/fulfillment-binding-config-service.ts deleted file mode 100644 index ed8511fa..00000000 --- a/apps/backend/src/services/order/fulfillment-binding-config-service.ts +++ /dev/null @@ -1,129 +0,0 @@ -import path from 'node:path' - -import { PROJECT_ROOT } from '../../config/runtime.js' -import { readJsonFile, writeJsonFile } from '../../utils/json-file-store.js' - -const ORDER_FULFILLMENT_BINDINGS_FILE_PATH = path.join(PROJECT_ROOT, 'data', 'order-fulfillment-bindings.json') - -type JsonObject = Record - -export function getOrderFulfillmentBindingsFilePath() { - return ORDER_FULFILLMENT_BINDINGS_FILE_PATH -} - -export function getOrderFulfillmentBindingConfigs() { - return loadOrderFulfillmentBindingConfigsFromFile() -} - -export function getLegacyOrderFulfillmentBindingConfigs() { - return filterLegacyOrderFulfillmentBindings(getOrderFulfillmentBindingConfigs()) -} - -export function saveOrderFulfillmentBindingConfigs(rawValue) { - return writeJsonFile( - ORDER_FULFILLMENT_BINDINGS_FILE_PATH, - rawValue, - normalizeOrderFulfillmentBindingConfigs, - ) -} - -function loadOrderFulfillmentBindingConfigsFromFile() { - return readJsonFile( - ORDER_FULFILLMENT_BINDINGS_FILE_PATH, - [], - normalizeOrderFulfillmentBindingConfigs, - ) -} - -function normalizeOrderFulfillmentBindingConfigs(rawValue) { - if (!Array.isArray(rawValue)) { - return [] - } - - return rawValue - .map((item) => normalizeOrderFulfillmentBinding(item)) - .filter(Boolean) -} - -export function filterLegacyOrderFulfillmentBindings(bindings: any[] = []) { - return (Array.isArray(bindings) ? bindings : []).filter((item) => !isKuaishouCloudFulfillmentBinding(item)) -} - -export function isKuaishouCloudFulfillmentBinding(binding: JsonObject = {}) { - return String(binding?.provider || '').trim() === '91kaquan' - && String(binding?.platform || '').trim() === 'kuaishou' -} - -function normalizeOrderFulfillmentBinding(rawValue) { - if (!isPlainObject(rawValue)) { - return null - } - - const provider = String(rawValue.provider || 'agiso').trim() || 'agiso' - const platform = String(rawValue.platform || '').trim() - const shopId = String(rawValue.shopId || '').trim() - const shopName = String(rawValue.shopName || '').trim() - const skuCode = String(rawValue.skuCode || '').trim() - const skuName = String(rawValue.skuName || '').trim() - const profileKey = String(rawValue.profileKey || '').trim() || 'manual_review' - const priority = normalizePriority(rawValue.priority) - const enabled = rawValue.enabled !== false - const config = normalizeJsonObject(rawValue.config) - const match = normalizeOrderFulfillmentMatch(rawValue.match) - - if (!skuCode || !match) { - return null - } - - return { - provider, - platform, - shopId, - shopName, - skuCode, - skuName, - profileKey, - enabled, - priority, - config, - match, - } -} - -function normalizeOrderFulfillmentMatch(rawValue) { - if (!isPlainObject(rawValue)) { - return null - } - - const externalSkuCode = String(rawValue.externalSkuCode || '').trim() - const externalItemId = String(rawValue.externalItemId || '').trim() - const externalSkuName = String(rawValue.externalSkuName || '').trim() - - if (!externalSkuCode && !externalItemId && !externalSkuName) { - return null - } - - return { - externalSkuCode, - externalItemId, - externalSkuName, - config: normalizeJsonObject(rawValue.config), - } -} - -function normalizePriority(value) { - const parsed = Number(value) - if (!Number.isFinite(parsed)) { - return 100 - } - - return Math.max(1, Math.round(parsed)) -} - -function normalizeJsonObject(value) { - return isPlainObject(value) ? value : {} -} - -function isPlainObject(value) { - return Object.prototype.toString.call(value) === '[object Object]' -} diff --git a/apps/backend/src/services/order/webhook-service.test.ts b/apps/backend/src/services/order/webhook-service.test.ts deleted file mode 100644 index 8bbf2b9a..00000000 --- a/apps/backend/src/services/order/webhook-service.test.ts +++ /dev/null @@ -1,387 +0,0 @@ -import test from 'node:test' -import assert from 'node:assert/strict' -import crypto from 'node:crypto' - -import { runtimeConfig } from '../../config/runtime.js' -import { - buildWebhookEventInput, - executeAgisoTradeWebhookWithDeps, - parseAgisoTradeRequest, - resolveAgisoTradeIgnoreReason, - shouldEnrichAgisoXianyuTrade, - verifyAgisoSignatureWithSecret, -} from './webhook-service.js' - -function createParsedTrade(patch = {}) { - return { - provider: 'agiso', - platform: 'xianyu', - platformRaw: 'xianyu', - eventType: 'payment_success', - eventKey: 'agiso:xianyu:shop-9:4502259793206016214:payment_success:1713097840', - signatureValid: true, - platformOrderId: '4502259793206016214', - shopId: 'shop-9', - shopName: '咸鱼店铺', - shopIdAliases: ['shop-9'], - orderStatus: 'paid', - payStatus: 'paid', - buyerId: 'buyer-1', - buyerName: '测试买家', - receiverContact: '', - totalAmount: 2550, - currency: 'CNY', - paidAt: '2026-04-14T12:30:40.000Z', - rawPayload: {}, - items: [ - { - skuCode: 'dnf-cdk-a', - skuName: 'DNF礼包', - quantity: 1, - spec: { title: 'DNF礼包' }, - }, - ], - ...patch, - } -} - -test('parseAgisoTradeRequest maps Agiso payment_success webhook into stable order fields', () => { - const rawJson = '{"biz_order_id":4502259793206016214,"seller_id":"shop-9","seller_nick":"咸鱼店铺","buyer_id":"buyer-1","buyer_name":"测试买家","pay_time":"2026-04-14 20:30:40","total_fee":"25.50","currency":"CNY","items":[{"item_id":978102431355,"goods_name":"DNF礼包","quantity":2,"sku":"dnf-cdk-a|商品名称:DNF礼包"}]}' - const timestamp = '1713097840' - const appSecret = String(runtimeConfig.platforms?.agiso?.appSecret || '').trim() - const sign = appSecret - ? crypto.createHash('md5').update(`${appSecret}json${rawJson}timestamp${timestamp}${appSecret}`, 'utf8').digest('hex') - : '' - - const parsed = parseAgisoTradeRequest({ - query: { - aopic: '1', - timestamp, - sign, - }, - body: { - json: rawJson, - fromPlatform: 'xianyu', - }, - }) - - assert.equal(parsed.provider, 'agiso') - assert.equal(parsed.platform, 'xianyu') - assert.equal(parsed.eventType, 'payment_success') - assert.equal(parsed.signatureValid, true) - assert.equal(parsed.platformOrderId, '4502259793206016214') - assert.equal(parsed.shopId, 'shop-9') - assert.equal(parsed.shopName, '咸鱼店铺') - assert.equal(parsed.payStatus, 'paid') - assert.equal(parsed.orderStatus, 'paid') - assert.equal(parsed.totalAmount, 2550) - assert.equal(parsed.items.length, 1) - assert.equal(parsed.items[0]?.skuCode, 'dnf-cdk-a') - assert.equal(parsed.items[0]?.quantity, 2) -}) - -test('shouldEnrichAgisoXianyuTrade only enriches when xianyu trade fields are incomplete', () => { - assert.equal(shouldEnrichAgisoXianyuTrade({ - provider: 'agiso', - platform: 'xianyu', - totalAmount: 0, - buyerName: '', - shopName: '', - items: [], - }), true) - - assert.equal(shouldEnrichAgisoXianyuTrade({ - provider: 'agiso', - platform: 'xianyu', - totalAmount: 2550, - buyerName: '测试买家', - shopName: '咸鱼店铺', - items: [ - { - skuCode: 'dnf-cdk-a', - spec: { - title: 'DNF礼包', - }, - }, - ], - }), false) - - assert.equal(shouldEnrichAgisoXianyuTrade({ - provider: 'agiso', - platform: 'taobao', - totalAmount: 0, - items: [], - }), false) -}) - -test('parseAgisoTradeRequest maps buyer confirm goods webhook into ignored event type', () => { - const rawJson = '{"biz_order_id":2701836516013026052,"seller_id":"shop-9","seller_nick":"大锤商行","buyer_id":"buyer-1","buyer_name":"测试买家","order_status":4,"Status":"TRADE_FINISHED","total_fee":"95.88","items":[{"item_id":978102431355,"goods_name":"DNF礼包","quantity":1,"sku":"dnf-cdk-a|商品名称:DNF礼包"}]}' - const timestamp = '1776096226' - const appSecret = String(runtimeConfig.platforms?.agiso?.appSecret || '').trim() - const sign = appSecret - ? crypto.createHash('md5').update(`${appSecret}json${rawJson}timestamp${timestamp}${appSecret}`, 'utf8').digest('hex') - : '' - - const parsed = parseAgisoTradeRequest({ - query: { - aopic: '16', - timestamp, - sign, - }, - body: { - json: rawJson, - fromPlatform: 'AldsIdle', - }, - }) - - assert.equal(parsed.platform, 'xianyu') - assert.equal(parsed.eventType, 'buyer_confirm_goods') - assert.equal(resolveAgisoTradeIgnoreReason(parsed), 'buyer_confirm_goods') -}) - -test('trade finished payload is still treated as buyer confirm goods when aopic drifts', () => { - const parsed = parseAgisoTradeRequest({ - query: { - aopic: '1', - timestamp: '1776096226', - sign: '', - }, - body: { - json: '{"biz_order_id":2701836516013026052,"order_status":4,"Status":"TRADE_FINISHED"}', - fromPlatform: 'xianyu', - }, - }) - - assert.equal(parsed.eventType, 'buyer_confirm_goods') - assert.equal(resolveAgisoTradeIgnoreReason(parsed), 'buyer_confirm_goods') -}) - -test('verifyAgisoSignatureWithSecret supports documented and legacy signatures', () => { - const appSecret = 'unit-test-secret' - const rawJson = '{"biz_order_id":"4502259793206016214"}' - const timestamp = '1713097840' - const documentedSign = crypto - .createHash('md5') - .update(`${appSecret}json${rawJson}timestamp${timestamp}${appSecret}`, 'utf8') - .digest('hex') - const legacySign = crypto - .createHash('md5') - .update(`${appSecret}${rawJson}${timestamp}`, 'utf8') - .digest('hex') - - assert.equal( - verifyAgisoSignatureWithSecret({ rawJson, timestamp, sign: documentedSign, appSecret }), - true, - ) - assert.equal( - verifyAgisoSignatureWithSecret({ rawJson, timestamp, sign: legacySign, appSecret }), - true, - ) - assert.equal( - verifyAgisoSignatureWithSecret({ rawJson, timestamp, sign: 'bad-sign', appSecret }), - false, - ) -}) - -test('buildWebhookEventInput preserves raw request payload for later replay', () => { - const input = buildWebhookEventInput( - { - headers: { 'x-request-id': 'req-1' }, - query: { timestamp: '1713097840', sign: 'bad-sign' }, - body: { json: '{"biz_order_id":"4502259793206016214"}' }, - }, - { - provider: 'agiso', - platform: 'xianyu', - shopId: 'shop-9', - shopName: '咸鱼店铺', - eventType: 'payment_success', - eventKey: 'agiso:xianyu:4502259793206016214:payment_success', - signatureValid: false, - }, - ) - - assert.equal(input.provider, 'agiso') - assert.equal(input.platform, 'xianyu') - assert.equal(input.eventType, 'payment_success') - assert.equal(input.eventKey, 'agiso:xianyu:4502259793206016214:payment_success') - assert.equal(input.signatureValid, false) - assert.equal(input.processed, false) - assert.equal(input.processError, '') - assert.equal(input.relatedOrderId, null) - assert.deepEqual(JSON.parse(input.headersJson), { 'x-request-id': 'req-1' }) - assert.deepEqual(JSON.parse(input.queryJson), { timestamp: '1713097840', sign: 'bad-sign' }) - assert.deepEqual(JSON.parse(input.bodyJson), { json: '{"biz_order_id":"4502259793206016214"}' }) - assert.match(input.createdAt, /^\d{4}-\d{2}-\d{2}T/) -}) - -test('executeAgisoTradeWebhookWithDeps records invalid signature failure', async () => { - const updates = [] - - await assert.rejects( - () => executeAgisoTradeWebhookWithDeps( - createParsedTrade({ signatureValid: false }), - 701, - { requestId: 'req-invalid-signature' }, - { - updateWebhookEvent: async (webhookEventId, patch) => { - updates.push({ webhookEventId, patch }) - return { id: webhookEventId, ...patch } - }, - hasConfiguredOrderItems: async () => { - throw new Error('should not check configured items when signature is invalid') - }, - }, - ), - /验签失败/, - ) - - assert.deepEqual(updates, [ - { - webhookEventId: 701, - patch: { - processed: false, - process_error: '验签失败', - related_order_id: null, - }, - }, - ]) -}) - -test('executeAgisoTradeWebhookWithDeps ignores unconfigured products before upsert', async () => { - const updates = [] - let upsertCalled = false - - const result = await executeAgisoTradeWebhookWithDeps( - createParsedTrade(), - 702, - { requestId: 'req-unconfigured' }, - { - hasConfiguredOrderItems: async (input) => { - assert.equal(input.provider, 'agiso') - assert.equal(input.platform, 'xianyu') - assert.equal(input.shopId, 'shop-9') - assert.equal(input.items.length, 1) - return false - }, - updateWebhookEvent: async (webhookEventId, patch) => { - updates.push({ webhookEventId, patch }) - return { id: webhookEventId, ...patch } - }, - upsertOrderFromWebhook: async () => { - upsertCalled = true - return {} - }, - }, - ) - - assert.equal(result.accepted, true) - assert.equal(result.ignored, true) - assert.equal(result.ignoreReason, 'unconfigured_product') - assert.equal(result.orderId, null) - assert.equal(upsertCalled, false) - assert.deepEqual(updates, [ - { - webhookEventId: 702, - patch: { - processed: true, - process_error: 'ignored_unconfigured_product', - related_order_id: null, - }, - }, - ]) -}) - -test('executeAgisoTradeWebhookWithDeps enriches incomplete xianyu trade and upserts order', async () => { - const updates = [] - const enrichCalls = [] - const upsertCalls = [] - const parsed = createParsedTrade({ - totalAmount: 0, - buyerName: '', - shopName: '', - items: [ - { - skuCode: 'dnf-cdk-a', - skuName: 'dnf-cdk-a', - quantity: 1, - spec: {}, - }, - ], - }) - - const result = await executeAgisoTradeWebhookWithDeps( - parsed, - 703, - { requestId: 'req-success' }, - { - hasConfiguredOrderItems: async () => true, - enrichAgisoXianyuTradeOrder: async (input, options) => { - enrichCalls.push({ input, options }) - return { - parsed: { - ...input, - totalAmount: 1990, - buyerName: '补查买家', - shopName: '补查店铺', - items: [ - { - skuCode: 'dnf-cdk-a', - skuName: 'DNF礼包', - quantity: 1, - spec: { title: 'DNF礼包' }, - }, - ], - }, - enriched: true, - reason: '', - } - }, - upsertOrderFromWebhook: async (input) => { - upsertCalls.push(input) - return { - ignored: false, - ignoreReason: '', - order: { id: 801 }, - tasks: [ - { id: 901, task_no: 'DT901', task_status: 'paid' }, - ], - messageDeliveries: [{ id: 1001 }], - } - }, - updateWebhookEvent: async (webhookEventId, patch) => { - updates.push({ webhookEventId, patch }) - return { id: webhookEventId, ...patch } - }, - }, - ) - - assert.equal(enrichCalls.length, 1) - assert.equal(enrichCalls[0].options.requestId, 'req-success') - assert.equal(upsertCalls.length, 1) - assert.equal(upsertCalls[0].totalAmount, 1990) - assert.equal(upsertCalls[0].buyerName, '补查买家') - assert.equal(result.accepted, true) - assert.equal(result.ignored, false) - assert.equal(result.enriched, true) - assert.equal(result.orderId, 801) - assert.equal(result.totalAmountFen, 1990) - assert.equal(result.taskCount, 1) - assert.deepEqual(result.tasks, [ - { - taskId: 901, - taskNo: 'DT901', - status: 'paid', - }, - ]) - assert.deepEqual(updates, [ - { - webhookEventId: 703, - patch: { - processed: true, - process_error: '', - related_order_id: 801, - }, - }, - ]) -}) diff --git a/apps/backend/src/services/order/webhook-service.ts b/apps/backend/src/services/order/webhook-service.ts deleted file mode 100644 index 4f050c34..00000000 --- a/apps/backend/src/services/order/webhook-service.ts +++ /dev/null @@ -1,1038 +0,0 @@ -import crypto from 'node:crypto' - -import { runtimeConfig } from '../../config/runtime.js' -import { getClaimTokenById } from '../../repositories/claim-token-repo.js' -import { createWebhookEvent, updateWebhookEvent } from '../../repositories/webhook-event-repo.js' -import { buildClaimUrl } from '../claim/claim-service.js' -import { ensureAgisoXianyuClaimMessageDeliveredForTask } from '../platforms/agiso/xianyu/message-service.js' -import { enrichAgisoXianyuTradeOrder } from '../platforms/agiso/xianyu/order-detail-service.js' -import { - extractAgisoTradeOrderItemSources, - extractAgisoTradePayload, - resolveAgisoTradePlatformOrderId, -} from './agiso-trade-parsing.js' -import { upsertOrderFromSource, upsertOrderFromWebhook } from './order-service.js' -import { hasConfiguredOrderItems } from './product-match-service.js' -import { createHttpError } from '../../utils/http.js' -import { parseJsonObject } from '../../utils/json.js' -import { logWebhook } from '../../utils/logger.js' -import { parseAmountToFen } from '../../utils/money.js' -import { nowIso } from '../../utils/time.js' - -type AgisoTradeWebhookDeps = { - updateWebhookEvent?: typeof updateWebhookEvent - hasConfiguredOrderItems?: typeof hasConfiguredOrderItems - upsertOrderFromWebhook?: typeof upsertOrderFromWebhook - enrichAgisoXianyuTradeOrder?: typeof enrichAgisoXianyuTradeOrder -} - -export async function processAgisoTradeWebhook(requestLike) { - const parsed = parseAgisoTradeRequest(requestLike) - const webhookEvent = await createWebhookEvent(buildWebhookEventInput(requestLike, parsed)) - - logWebhook('[webhook-service/agiso]', 'Webhook 已解析并写入 webhook_events', { - requestId: requestLike.requestId || '', - webhookEventId: webhookEvent.id, - provider: parsed.provider, - platform: parsed.platform, - shopId: parsed.shopId, - shopName: parsed.shopName, - eventKey: parsed.eventKey, - eventType: parsed.eventType, - platformOrderId: parsed.platformOrderId, - signatureValid: parsed.signatureValid, - }) - - return executeAgisoTradeWebhook(parsed, webhookEvent.id, { requestId: requestLike.requestId || '' }) -} - -export async function replayAgisoTradeWebhookEvent(webhookEvent) { - const requestLike = { - requestId: `replay-${webhookEvent.id}`, - headers: safeParseJson(webhookEvent.headers_json), - query: safeParseJson(webhookEvent.query_json), - body: safeParseJson(webhookEvent.body_json), - } - const parsed = parseAgisoTradeRequest(requestLike) - - logWebhook('[webhook-service/agiso]', '开始重放 webhook 事件', { - requestId: requestLike.requestId, - webhookEventId: webhookEvent.id, - provider: parsed.provider, - platform: parsed.platform, - shopId: parsed.shopId, - shopName: parsed.shopName, - eventKey: parsed.eventKey, - eventType: parsed.eventType, - platformOrderId: parsed.platformOrderId, - }) - - return executeAgisoTradeWebhook(parsed, webhookEvent.id, { requestId: requestLike.requestId }) -} - -export function buildWebhookEventInput(requestLike, parsed) { - return { - provider: parsed.provider, - platform: parsed.platform, - shopId: parsed.shopId, - shopName: parsed.shopName, - eventType: parsed.eventType, - eventKey: parsed.eventKey, - signatureValid: parsed.signatureValid, - headersJson: JSON.stringify(requestLike.headers || {}), - queryJson: JSON.stringify(requestLike.query || {}), - bodyJson: JSON.stringify(requestLike.body || {}), - processed: false, - processError: '', - relatedOrderId: null, - createdAt: nowIso(), - } -} - -async function executeAgisoTradeWebhook(parsed, webhookEventId, { requestId = '' } = {}) { - return executeAgisoTradeWebhookWithDeps(parsed, webhookEventId, { requestId }) -} - -export async function executeAgisoTradeWebhookWithDeps( - parsed, - webhookEventId, - { requestId = '' } = {}, - deps: AgisoTradeWebhookDeps = {}, -) { - const updateEvent = deps.updateWebhookEvent || updateWebhookEvent - const hasConfiguredItems = deps.hasConfiguredOrderItems || hasConfiguredOrderItems - const upsertOrder = deps.upsertOrderFromWebhook || upsertAgisoOrderFromWebhook - - try { - if (!parsed.signatureValid) { - throw createHttpError('验签失败', { - statusCode: 400, - errorCode: 'invalid_signature', - }) - } - - if (!parsed.platformOrderId) { - throw createHttpError('缺少平台订单号', { - statusCode: 400, - errorCode: 'missing_platform_order_id', - }) - } - - const ignoreReason = resolveAgisoTradeIgnoreReason(parsed) - if (ignoreReason) { - await updateEvent(webhookEventId, { - processed: true, - process_error: `ignored_${ignoreReason}`, - related_order_id: null, - }) - - logWebhook('[webhook-service/agiso]', 'Webhook 已忽略:当前通知不应再次触发履约发送', { - requestId, - webhookEventId, - provider: parsed.provider, - platform: parsed.platform, - shopId: parsed.shopId, - shopName: parsed.shopName, - eventKey: parsed.eventKey, - eventType: parsed.eventType, - platformOrderId: parsed.platformOrderId, - ignoreReason, - }) - - return { - accepted: true, - ignored: true, - ignoreReason, - provider: parsed.provider, - platform: parsed.platform, - shopId: parsed.shopId, - shopName: parsed.shopName, - platformRaw: parsed.platformRaw, - eventType: parsed.eventType, - platformOrderId: parsed.platformOrderId, - orderId: null, - totalAmountFen: Number(parsed.totalAmount || 0), - enriched: false, - enrichReason: 'skipped_ignored_event', - taskCount: 0, - messageDeliveries: [], - tasks: [], - } - } - - const shouldProcess = await hasConfiguredItems({ - provider: parsed.provider, - platform: parsed.platform, - shopId: parsed.shopId, - shopIdAliases: parsed.shopIdAliases, - items: parsed.items, - }) - - if (!shouldProcess) { - await updateEvent(webhookEventId, { - processed: true, - process_error: 'ignored_unconfigured_product', - related_order_id: null, - }) - - logWebhook('[webhook-service/agiso]', 'Webhook 已忽略:未命中任何已配置履约商品', { - requestId, - webhookEventId, - provider: parsed.provider, - platform: parsed.platform, - shopId: parsed.shopId, - shopName: parsed.shopName, - platformOrderId: parsed.platformOrderId, - rawItemCount: Array.isArray(parsed.items) ? parsed.items.length : 0, - }) - - return { - accepted: true, - ignored: true, - ignoreReason: 'unconfigured_product', - provider: parsed.provider, - platform: parsed.platform, - shopId: parsed.shopId, - shopName: parsed.shopName, - platformRaw: parsed.platformRaw, - eventType: parsed.eventType, - platformOrderId: parsed.platformOrderId, - orderId: null, - totalAmountFen: Number(parsed.totalAmount || 0), - enriched: false, - enrichReason: 'skipped_unconfigured_product', - taskCount: 0, - messageDeliveries: [], - tasks: [], - } - } - - const enriched = await enrichTradeBeforeUpsert(parsed, { - requestId, - webhookEventId, - }, deps) - const result = await upsertOrder(enriched.parsed) - await updateEvent(webhookEventId, { - processed: true, - process_error: result.ignored ? 'ignored_unconfigured_product' : '', - related_order_id: result.order?.id || null, - }) - - logWebhook('[webhook-service/agiso]', 'Webhook 业务处理成功', { - requestId, - webhookEventId, - provider: parsed.provider, - platform: parsed.platform, - shopId: parsed.shopId, - shopName: parsed.shopName, - eventKey: parsed.eventKey, - eventType: parsed.eventType, - platformOrderId: enriched.parsed.platformOrderId, - orderId: result.order?.id || null, - ignored: Boolean(result.ignored), - ignoreReason: String(result.ignoreReason || ''), - enriched: enriched.enriched, - enrichReason: enriched.reason || '', - totalAmountFen: Number(enriched.parsed.totalAmount || 0), - taskCount: result.tasks.length, - messageDeliveryCount: Array.isArray(result.messageDeliveries) ? result.messageDeliveries.length : 0, - }) - - return { - accepted: true, - provider: enriched.parsed.provider, - platform: enriched.parsed.platform, - shopId: enriched.parsed.shopId, - shopName: enriched.parsed.shopName, - platformRaw: enriched.parsed.platformRaw, - eventType: enriched.parsed.eventType, - platformOrderId: enriched.parsed.platformOrderId, - orderId: result.order?.id || null, - totalAmountFen: Number(enriched.parsed.totalAmount || 0), - ignored: Boolean(result.ignored), - ignoreReason: String(result.ignoreReason || ''), - enriched: enriched.enriched, - enrichReason: enriched.reason || '', - taskCount: result.tasks.length, - messageDeliveries: result.messageDeliveries || [], - tasks: result.tasks.map((task) => ({ - taskId: task.id, - taskNo: task.task_no, - status: task.task_status, - })), - } - } catch (error) { - await updateEvent(webhookEventId, { - processed: false, - process_error: error instanceof Error ? error.message : String(error || ''), - related_order_id: null, - }) - - logWebhook( - '[webhook-service/agiso]', - 'Webhook 业务处理失败', - { - requestId, - webhookEventId, - provider: parsed.provider, - platform: parsed.platform, - shopId: parsed.shopId, - shopName: parsed.shopName, - eventKey: parsed.eventKey, - eventType: parsed.eventType, - platformOrderId: parsed.platformOrderId, - signatureValid: parsed.signatureValid, - error, - }, - { level: 'error' }, - ) - throw error - } -} - -async function upsertAgisoOrderFromWebhook(event) { - return upsertOrderFromSource(event, { - sourceLabel: 'webhook', - handleClaimLinkTask: async ({ order, task }) => { - const claimToken = await getClaimTokenById(task.primary_claim_token_id) - if (!claimToken) { - return null - } - - return ensureAgisoXianyuClaimMessageDeliveredForTask({ - order, - task, - claimUrl: buildClaimUrl(claimToken.token), - expiredAt: claimToken.expired_at, - }) - }, - }) -} - -async function enrichTradeBeforeUpsert( - parsed, - { requestId = '', webhookEventId = null } = {}, - deps: AgisoTradeWebhookDeps = {}, -) { - const enrichOrder = deps.enrichAgisoXianyuTradeOrder || enrichAgisoXianyuTradeOrder - - if (parsed.provider !== 'agiso' || parsed.platform !== 'xianyu') { - return { parsed, enriched: false, reason: 'not_supported' } - } - - if (!shouldEnrichAgisoXianyuTrade(parsed)) { - return { parsed, enriched: false, reason: 'enough_fields_present' } - } - - const detailResult = await enrichOrder(parsed, { requestId }) - const nextParsed = detailResult?.parsed || parsed - - logWebhook('[webhook-service/agiso]', 'Agiso webhook 订单补查结束', { - requestId, - webhookEventId, - provider: nextParsed.provider, - platform: nextParsed.platform, - shopId: nextParsed.shopId, - platformOrderId: nextParsed.platformOrderId, - enriched: Boolean(detailResult?.enriched), - reason: String(detailResult?.reason || ''), - totalAmountFen: Number(nextParsed.totalAmount || 0), - paidAt: nextParsed.paidAt || null, - errorMessage: String(detailResult?.errorMessage || ''), - }) - - return { - parsed: nextParsed, - enriched: Boolean(detailResult?.enriched), - reason: String(detailResult?.reason || ''), - errorMessage: String(detailResult?.errorMessage || ''), - } -} - -export function shouldEnrichAgisoXianyuTrade(parsed) { - if (!parsed || parsed.provider !== 'agiso' || parsed.platform !== 'xianyu') { - return false - } - - if (Number(parsed.totalAmount || 0) <= 0) { - return true - } - - if (!String(parsed.buyerName || '').trim()) { - return true - } - - if (!String(parsed.shopName || '').trim()) { - return true - } - - if (!hasDetailedOrderItems(parsed.items)) { - return true - } - - return false -} - -function hasDetailedOrderItems(items) { - const normalizedItems = Array.isArray(items) ? items : [] - - if (normalizedItems.length === 0) { - return false - } - - return normalizedItems.some((item) => { - const spec = isPlainObject(item?.spec) ? item.spec : {} - const title = pickFirstNonEmpty([ - spec.title, - spec.Title, - spec.itemTitle, - spec.item_title, - spec.goods_name, - spec.goodsName, - item?.skuName, - ]) - const skuCode = String(item?.skuCode || '').trim() - - return Boolean(title && title !== skuCode) - }) -} - -export function parseAgisoTradeRequest(requestLike) { - const query = normalizeRecord(requestLike.query) - const body = normalizeRecord(requestLike.body) - const rawJson = String(body.json || '').trim() - const payload = rawJson ? parsePayloadJson(rawJson) : extractAgisoTradePayload(body) - const timestamp = String(query.timestamp || '').trim() - const sign = String(query.sign || '').trim().toLowerCase() - const provider = 'agiso' - const platformRaw = pickFirstNonEmpty([ - query.fromPlatform, - query.from_platform, - body.fromPlatform, - body.from_platform, - payload.fromPlatform, - payload.FromPlatform, - payload.platform, - payload.Platform, - ]) - const platform = resolveBusinessPlatform(platformRaw) - const shop = resolveShop(payload) - const eventType = resolveEventType(query.aopic, payload) - const signatureValid = verifyAgisoSignature({ rawJson, timestamp, sign }) - const platformOrderId = resolveAgisoTradePlatformOrderId(payload) - - return { - provider, - platform, - platformRaw, - eventType, - eventKey: [provider, platform || 'unknown', shop.shopId || 'unknown', platformOrderId || 'unknown', eventType, timestamp || 'na'].join(':'), - signatureValid, - platformOrderId, - shopId: shop.shopId, - shopName: shop.shopName, - orderStatus: resolveOrderStatus(eventType, payload), - payStatus: resolvePayStatus(eventType, 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, - ]), - receiverContact: pickFirstNonEmpty([ - payload.receiver_contact, - payload.receiverContact, - payload.mobile, - payload.phone, - payload.receiver_mobile, - payload.receiverMobile, - ]), - totalAmount: parseAmountToFen( - pickFirstNonEmpty([ - payload.total_fee, - payload.totalFee, - payload.TotalFee, - payload.pay_fee, - payload.payFee, - payload.Payment, - payload.payment, - ]), - ), - currency: pickFirstNonEmpty([payload.currency, 'CNY']) || 'CNY', - paidAt: resolvePaidAt(eventType, payload), - rawPayload: payload, - items: normalizeOrderItems(payload), - } -} - -export function verifyAgisoSignatureWithSecret({ rawJson, timestamp, sign, appSecret }) { - const normalizedSecret = String(appSecret || '').trim() - - if (!normalizedSecret) { - return true - } - - if (!rawJson || !timestamp || !sign) { - return false - } - - const documentedDigest = crypto - .createHash('md5') - .update(`${normalizedSecret}json${rawJson}timestamp${timestamp}${normalizedSecret}`, 'utf8') - .digest('hex') - .toLowerCase() - - if (documentedDigest === sign) { - return true - } - - const legacyDigest = crypto - .createHash('md5') - .update(`${normalizedSecret}${rawJson}${timestamp}`, 'utf8') - .digest('hex') - .toLowerCase() - - return legacyDigest === sign -} - -function verifyAgisoSignature({ rawJson, timestamp, sign }) { - const appSecret = String(runtimeConfig.platforms.agiso.appSecret || '').trim() - return verifyAgisoSignatureWithSecret({ rawJson, timestamp, sign, appSecret }) -} - -function resolveEventType(aopic, payload) { - const normalized = String(aopic || '').trim() - - if (isTradeFinishedPayload(payload)) { - return 'buyer_confirm_goods' - } - - if (normalized === '1') { - return 'payment_success' - } - - if (normalized === '21') { - return 'trade_create' - } - - if (normalized === '32') { - return 'trade_create' - } - - if (normalized === '16' || normalized === '256') { - return 'buyer_confirm_goods' - } - - if (normalized === '128') { - return 'trade_closed' - } - - const status = resolveProviderStatus(payload) - if (status === 'closed') { - return 'trade_closed' - } - - return 'trade_event' -} - -export function resolveAgisoTradeIgnoreReason(parsed) { - if (!parsed || parsed.provider !== 'agiso') { - return '' - } - - if (parsed.eventType === 'buyer_confirm_goods') { - return 'buyer_confirm_goods' - } - - if (parsed.platform === 'xianyu' && isTradeFinishedPayload(parsed.rawPayload)) { - return 'buyer_confirm_goods' - } - - return '' -} - -function resolveOrderStatus(eventType, payload) { - const providerStatus = resolveProviderStatus(payload) - - if (providerStatus === 'paid') { - return 'paid' - } - - if (providerStatus === 'closed') { - return 'closed' - } - - if (providerStatus === 'refunded') { - return 'refunded' - } - - if (eventType === 'payment_success') { - return 'paid' - } - - if (eventType === 'trade_closed') { - return 'closed' - } - - const rawStatus = normalizeInteger( - pickFirstNonEmpty([payload.order_status, payload.orderStatus, payload.status]), - ) - - if (rawStatus === 3 || rawStatus === 4) { - return 'paid' - } - - if (rawStatus === 6) { - return 'closed' - } - - if (rawStatus === 5) { - return 'refunded' - } - - return 'created' -} - -function resolvePayStatus(eventType, payload) { - const providerStatus = resolveProviderStatus(payload) - - if (providerStatus === 'paid') { - return 'paid' - } - - if (providerStatus === 'closed') { - return 'unpaid' - } - - if (providerStatus === 'refunded') { - return 'refunded' - } - - if (eventType === 'payment_success') { - return 'paid' - } - - if (eventType === 'trade_closed') { - return 'unpaid' - } - - const rawStatus = normalizeInteger( - pickFirstNonEmpty([payload.order_status, payload.orderStatus, payload.status]), - ) - - if (rawStatus === 3 || rawStatus === 4) { - return 'paid' - } - - if (rawStatus === 5) { - return 'refunded' - } - - return 'unpaid' -} - -function resolvePaidAt(eventType, payload) { - const providerPaidAt = normalizeProviderDateTime( - pickFirstNonEmpty([ - payload.paid_at, - payload.paidAt, - payload.pay_time, - payload.payTime, - payload.PayTime, - ]), - ) - - if (providerPaidAt) { - return providerPaidAt - } - - if (resolveProviderStatus(payload) === 'paid') { - return nowIso() - } - - if (eventType === 'payment_success') { - return nowIso() - } - - const rawStatus = normalizeInteger( - pickFirstNonEmpty([payload.order_status, payload.orderStatus, payload.status]), - ) - - if (rawStatus === 3 || rawStatus === 4) { - return nowIso() - } - - return null -} - -function parsePayloadJson(rawJson) { - if (!rawJson) { - return {} - } - - return parseJsonObject(rawJson, { - preserveLargeIntegers: true, - throwOnError: true, - }) -} - -function normalizeOrderItems(payload) { - const items = extractAgisoTradeOrderItemSources(payload) - - return items.map((item) => { - const source = isPlainObject(item) ? item : {} - const skuDescriptor = parseExternalSkuDescriptor( - pickFirstNonEmpty([ - source.sku, - source.Sku, - payload.sku, - payload.Sku, - payload._agisoTradeDetail?.sku, - ]), - ) - const rawSkuCandidates = [ - skuDescriptor.externalSkuCode, - source.OuterSkuId, - source.outerSkuId, - source.outer_sku_id, - source.OuterIid, - source.outerIid, - source.outer_iid, - source.sku_code, - source.skuCode, - source.goods_sku, - source.item_id, - source.itemId, - source.goods_id, - source.goodsId, - source.NumIid, - source.num_iid, - payload.item_id, - payload.itemId, - ] - const skuCode = resolveSkuCode(rawSkuCandidates) - const externalItemId = pickFirstNonEmpty([ - source.item_id, - source.itemId, - source.goods_id, - source.goodsId, - payload.item_id, - payload.itemId, - ]) - const externalSkuName = pickFirstNonEmpty([ - skuDescriptor.externalSkuName, - source.Title, - source.title, - source.sku_name, - source.skuName, - source.goods_name, - source.goodsName, - skuCode, - ]) - - return { - skuCode, - skuName: externalSkuName, - externalItemId, - externalSkuCode: pickFirstNonEmpty([skuDescriptor.externalSkuCode, skuCode, externalItemId]), - externalSkuName, - quantity: Math.max( - 1, - normalizeInteger( - pickFirstNonEmpty([ - source.quantity, - source.num, - source.Num, - source.buy_amount, - payload.quantity, - payload.num, - payload.Num, - ]), - ) || 1, - ), - spec: source, - snapshot: { - externalItemId, - externalSkuCode: pickFirstNonEmpty([skuDescriptor.externalSkuCode, skuCode, externalItemId]), - externalSkuName, - }, - } - }) -} - -function resolveBusinessPlatform(rawPlatform) { - const normalized = normalizePlatformKey(rawPlatform) - - if (!normalized) { - return 'unknown' - } - - const aliases = { - xianyu: 'xianyu', - idlefish: 'xianyu', - idle: 'xianyu', - aldsidle: 'xianyu', - taobaoidle: 'xianyu', - taobaoxianyu: 'xianyu', - taobao: 'taobao', - tb: 'taobao', - pdd: 'pdd', - pinduoduo: 'pdd', - douyin: 'douyin', - weidian: 'weidian', - jd: 'jd', - jingdong: 'jd', - } - - return aliases[normalized] || normalized -} - -function normalizePlatformKey(value) { - return String(value || '') - .trim() - .toLowerCase() - .replace(/[\s_-]+/g, '') -} - -function resolveShop(payload) { - const shopName = pickFirstNonEmpty([ - payload.shop_name, - payload.shopName, - payload.ShopName, - payload.seller_name, - payload.sellerName, - payload.seller_nick, - payload.sellerNick, - payload.SellerNick, - ]) - - return { - shopId: pickFirstNonEmpty([ - payload.shop_id, - payload.shopId, - payload.ShopId, - payload.seller_id, - payload.sellerId, - payload.SellerId, - payload.PlatformUserId, - payload.platformUserId, - shopName, - ]), - shopName, - } -} - -function resolveProviderStatus(payload) { - const rawStatus = normalizeInteger( - pickFirstNonEmpty([payload.order_status, payload.orderStatus, payload.status]), - ) - - if (rawStatus === 3 || rawStatus === 4) { - return 'paid' - } - - if (rawStatus === 6) { - return 'closed' - } - - if (rawStatus === 5) { - return 'refunded' - } - - const statusText = pickFirstNonEmpty([ - payload.Status, - payload.status, - payload.order_status_text, - payload.orderStatusText, - ]).toUpperCase() - - if (!statusText) { - return pickFirstNonEmpty([payload.PayTime, payload.payTime, payload.pay_time]) ? 'paid' : 'unknown' - } - - if (['WAIT_SELLER_SEND_GOODS', 'WAIT_BUYER_CONFIRM_GOODS', 'TRADE_FINISHED', 'SUCCESS', 'PAID'].includes(statusText)) { - return 'paid' - } - - if (['TRADE_CLOSED', 'TRADE_CLOSED_BY_TAOBAO', 'CLOSED'].includes(statusText)) { - return 'closed' - } - - if (['REFUND_SUCCESS', 'TRADE_REFUND', 'REFUNDED'].includes(statusText)) { - return 'refunded' - } - - if (['WAIT_BUYER_PAY', 'CREATED', 'NEW'].includes(statusText)) { - return 'unpaid' - } - - return pickFirstNonEmpty([payload.PayTime, payload.payTime, payload.pay_time]) ? 'paid' : 'unknown' -} - -function isTradeFinishedPayload(payload) { - const rawStatus = normalizeInteger( - pickFirstNonEmpty([payload?.order_status, payload?.orderStatus, payload?.status]), - ) - - if (rawStatus === 4) { - return true - } - - const statusText = pickFirstNonEmpty([ - payload?.Status, - payload?.status, - payload?.order_status_text, - payload?.orderStatusText, - ]).toUpperCase() - - return statusText === 'TRADE_FINISHED' -} - -function normalizeProviderDateTime(value) { - const normalized = String(value || '').trim() - - if (!normalized) { - return null - } - - const isoText = normalized.replace(' ', 'T') - const utcMatch = isoText.match(/^(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2}):(\d{2})$/) - - if (utcMatch) { - const [, year, month, day, hours, minutes, seconds] = utcMatch - return new Date( - Date.UTC( - Number(year), - Number(month) - 1, - Number(day), - Number(hours) - 8, - Number(minutes), - Number(seconds), - ), - ).toISOString() - } - - const date = new Date(normalized) - return Number.isNaN(date.getTime()) ? null : date.toISOString() -} - -function resolveSkuCode(rawKey) { - const candidates = Array.isArray(rawKey) ? rawKey : [rawKey] - - for (const candidate of candidates) { - if (typeof candidate === 'string' && candidate.trim()) { - return candidate.trim() - } - - if (typeof candidate === 'number' && Number.isFinite(candidate)) { - return String(candidate) - } - } - - return '' -} - -function parseExternalSkuDescriptor(rawValue) { - const raw = String(rawValue || '').trim() - if (!raw) { - return { - raw, - externalSkuCode: '', - externalSkuName: '', - } - } - - const parts = raw.split('|').map((part) => String(part || '').trim()).filter(Boolean) - let externalSkuCode = '' - let externalSkuName = '' - - for (const part of parts) { - if (!externalSkuCode && !part.includes(':') && !part.includes(':')) { - externalSkuCode = part - continue - } - - const separatorIndex = Math.max(part.indexOf(':'), part.indexOf(':')) - if (separatorIndex >= 0) { - const label = part.slice(0, separatorIndex).trim() - const value = part.slice(separatorIndex + 1).trim() - if (value && ['商品名称', '商品名', 'sku名称', '规格名称', '名称', '商品'].includes(label)) { - externalSkuName = value - } - } - } - - if (!externalSkuCode && parts[0]) { - externalSkuCode = parts[0] - } - - if (!externalSkuName) { - externalSkuName = parts - .map((part) => { - const separatorIndex = Math.max(part.indexOf(':'), part.indexOf(':')) - return separatorIndex >= 0 ? part.slice(separatorIndex + 1).trim() : '' - }) - .find(Boolean) || '' - } - - return { - raw, - externalSkuCode, - externalSkuName, - } -} - -function normalizeRecord(value) { - return isPlainObject(value) ? value : {} -} - -function normalizeInteger(value) { - const parsed = Number(value) - return Number.isFinite(parsed) ? Math.round(parsed) : 0 -} - -function pickFirstNonEmpty(values) { - for (const value of values) { - const normalized = String(value || '').trim() - if (normalized) { - return normalized - } - } - - return '' -} - -function isPlainObject(value) { - return Object.prototype.toString.call(value) === '[object Object]' -} - -function safeParseJson(rawValue) { - if (rawValue && typeof rawValue === 'object' && !Array.isArray(rawValue)) { - return rawValue - } - - const normalized = String(rawValue || '').trim() - - if (!normalized) { - return {} - } - - try { - const parsed = JSON.parse(normalized) - return isPlainObject(parsed) ? parsed : {} - } catch { - return {} - } -} diff --git a/apps/backend/src/services/platforms/agiso/README.md b/apps/backend/src/services/platforms/agiso/README.md deleted file mode 100644 index 8ad79c0a..00000000 --- a/apps/backend/src/services/platforms/agiso/README.md +++ /dev/null @@ -1,9 +0,0 @@ -## Agiso Platform Services - -Keep Agiso-wide shared helpers in this directory. - -Per-platform integrations live in subdirectories: - -- `xianyu/` -- `pdd/` -- `taobao/` diff --git a/apps/backend/src/services/platforms/agiso/pdd/README.md b/apps/backend/src/services/platforms/agiso/pdd/README.md deleted file mode 100644 index fc40152b..00000000 --- a/apps/backend/src/services/platforms/agiso/pdd/README.md +++ /dev/null @@ -1,3 +0,0 @@ -## PDD - -Put all Agiso PDD-specific service integrations here. diff --git a/apps/backend/src/services/platforms/agiso/shop-config-service.ts b/apps/backend/src/services/platforms/agiso/shop-config-service.ts deleted file mode 100644 index decce936..00000000 --- a/apps/backend/src/services/platforms/agiso/shop-config-service.ts +++ /dev/null @@ -1,181 +0,0 @@ -import fs from 'node:fs' -import path from 'node:path' - -import { PROJECT_ROOT, runtimeConfig } from '../../../config/runtime.js' - -const AGISO_SHOPS_FILE_PATH = path.join(PROJECT_ROOT, 'data', 'agiso-shops.json') -const AGISO_MESSAGING_DEFAULT_KEYS = ['messageTemplate', 'autoDeliveryMessageTemplate'] -const AGISO_SHOP_CONFIG_KEYS = [ - 'shopName', - 'accessToken', - 'messageTemplate', - 'autoDeliveryMessageTemplate', - 'appSecret', - 'apiVersion', - 'sendMessageEndpoint', - 'tradeDetailEndpoint', - 'tradeDetailApiVersion', - 'tradeDetailTimeoutMs', -] - -type JsonObject = Record - -export type AgisoMessagingDefaults = { - messageTemplate?: string - autoDeliveryMessageTemplate?: string -} - -export type AgisoShopConfig = { - enabled?: boolean - shopName?: string - accessToken?: string - messageTemplate?: string - autoDeliveryMessageTemplate?: string - appSecret?: string - apiVersion?: string - sendMessageEndpoint?: string - tradeDetailEndpoint?: string - tradeDetailApiVersion?: string - tradeDetailTimeoutMs?: string -} - -export type AgisoMessagingConfigDocument = { - defaults: AgisoMessagingDefaults - shops: Record -} - -export function getAgisoShopsFilePath(): string { - return AGISO_SHOPS_FILE_PATH -} - -export function getAgisoShopConfigMap(): Record { - const envConfig = normalizeAgisoShopConfigMap(runtimeConfig.platforms?.agiso?.messaging?.shops || {}) - const fileConfig = loadAgisoMessagingConfigDocumentFromFile() - - return { - ...envConfig, - ...fileConfig.shops, - } -} - -export function getAgisoShopConfig(shopId: unknown): AgisoShopConfig | null { - const normalizedShopId = String(shopId || '').trim() - if (!normalizedShopId) { - return null - } - - return getAgisoShopConfigMap()[normalizedShopId] || null -} - -export function getAgisoMessagingDefaults(): AgisoMessagingDefaults { - return loadAgisoMessagingConfigDocumentFromFile().defaults -} - -export function saveAgisoMessagingConfig(rawValue: unknown): AgisoMessagingConfigDocument { - const normalized = normalizeAgisoMessagingConfigDocument(rawValue) - fs.mkdirSync(path.dirname(AGISO_SHOPS_FILE_PATH), { recursive: true }) - fs.writeFileSync(AGISO_SHOPS_FILE_PATH, `${JSON.stringify(normalized, null, 2)}\n`, 'utf8') - return normalized -} - -function loadAgisoMessagingConfigDocumentFromFile(): AgisoMessagingConfigDocument { - if (!fs.existsSync(AGISO_SHOPS_FILE_PATH)) { - return { - defaults: {}, - shops: {}, - } - } - - try { - const rawText = fs.readFileSync(AGISO_SHOPS_FILE_PATH, 'utf8') - const parsed = JSON.parse(rawText) - return normalizeAgisoMessagingConfigDocument(parsed) - } catch { - return { - defaults: {}, - shops: {}, - } - } -} - -function normalizeAgisoShopConfigMap(rawValue: unknown): Record { - const output: Record = {} - - const entries = isPlainObject(rawValue) ? Object.entries(rawValue) : [] - for (const [shopId, config] of entries) { - const normalizedShopId = String(shopId || '').trim() - if (!normalizedShopId || !isPlainObject(config)) { - continue - } - - const next: AgisoShopConfig = {} - const enabled = normalizeBooleanLike(config.enabled) - if (enabled !== null) { - next.enabled = enabled - } - - for (const key of AGISO_SHOP_CONFIG_KEYS) { - const value = String(config[key] || '').trim() - if (value) { - next[key] = value - } - } - - output[normalizedShopId] = next - } - - return output -} - -function normalizeAgisoMessagingDefaults(rawValue: unknown): AgisoMessagingDefaults { - const output: AgisoMessagingDefaults = {} - - if (!isPlainObject(rawValue)) { - return output - } - - for (const key of AGISO_MESSAGING_DEFAULT_KEYS) { - const value = String(rawValue[key] || '').trim() - if (value) { - output[key] = value - } - } - - return output -} - -function normalizeAgisoMessagingConfigDocument(rawValue: unknown): AgisoMessagingConfigDocument { - const normalizedValue = isPlainObject(rawValue) ? rawValue : {} - const hasStructuredShape = Object.prototype.hasOwnProperty.call(normalizedValue, 'defaults') - || Object.prototype.hasOwnProperty.call(normalizedValue, 'shops') - - return { - defaults: normalizeAgisoMessagingDefaults(hasStructuredShape ? normalizedValue.defaults : {}), - shops: normalizeAgisoShopConfigMap(hasStructuredShape ? normalizedValue.shops : normalizedValue), - } -} - -function normalizeBooleanLike(value: unknown): boolean | null { - if (typeof value === 'boolean') { - return value - } - - const normalized = String(value || '').trim().toLowerCase() - if (!normalized) { - return null - } - - if (['1', 'true', 'yes', 'on'].includes(normalized)) { - return true - } - - if (['0', 'false', 'no', 'off'].includes(normalized)) { - return false - } - - return null -} - -function isPlainObject(value: unknown): value is JsonObject { - return Object.prototype.toString.call(value) === '[object Object]' -} diff --git a/apps/backend/src/services/platforms/agiso/taobao/README.md b/apps/backend/src/services/platforms/agiso/taobao/README.md deleted file mode 100644 index c685793c..00000000 --- a/apps/backend/src/services/platforms/agiso/taobao/README.md +++ /dev/null @@ -1,3 +0,0 @@ -## Taobao - -Put all Agiso Taobao-specific service integrations here. diff --git a/apps/backend/src/services/platforms/agiso/xianyu/README.md b/apps/backend/src/services/platforms/agiso/xianyu/README.md deleted file mode 100644 index b0a28579..00000000 --- a/apps/backend/src/services/platforms/agiso/xianyu/README.md +++ /dev/null @@ -1,3 +0,0 @@ -## Xianyu - -Put all Agiso Xianyu-specific service integrations here. diff --git a/apps/backend/src/services/platforms/agiso/xianyu/auto-delivery-service.test.ts b/apps/backend/src/services/platforms/agiso/xianyu/auto-delivery-service.test.ts deleted file mode 100644 index 5284b13f..00000000 --- a/apps/backend/src/services/platforms/agiso/xianyu/auto-delivery-service.test.ts +++ /dev/null @@ -1,264 +0,0 @@ -import test from 'node:test' -import assert from 'node:assert/strict' - -import { runtimeConfig } from '../../../../config/runtime.js' -import { - hasAgisoAutoDeliverySucceeded, - ensureAgisoXianyuAutoDeliveryForDeliveredTaskWithDeps, - isOrderReadyForAgisoAutoDelivery, - isAgisoAutoDeliverySuccess, - resolveAgisoAutoDeliveryEndpoint, - resolveAgisoAutoDeliveryErrorMessage, -} from './auto-delivery-service.js' - -const agisoOrder = { - id: 101, - provider: 'agiso', - platform: 'xianyu', - shop_id: 'shop-auto-delivery-test', - shop_name: '自动发货测试店', - platform_order_id: 'P-AUTO-10001', -} - -function createDeliveredTask(patch = {}) { - return { - id: 201, - order_id: agisoOrder.id, - task_status: 'completed', - delivery_status: 'delivered', - context_json: '{}', - ...patch, - } -} - -test('isOrderReadyForAgisoAutoDelivery requires every task to be delivered', () => { - assert.equal(isOrderReadyForAgisoAutoDelivery([]), false) - assert.equal(isOrderReadyForAgisoAutoDelivery([ - { delivery_status: 'delivered' }, - { delivery_status: 'delivered' }, - ]), true) - assert.equal(isOrderReadyForAgisoAutoDelivery([ - { delivery_status: 'delivered' }, - { delivery_status: 'processing' }, - ]), false) -}) - -test('hasAgisoAutoDeliverySucceeded detects successful context on any task', () => { - assert.equal(hasAgisoAutoDeliverySucceeded([ - { context_json: {} }, - { context_json: { agisoAutoDelivery: { status: 'success' } } }, - ]), true) - - assert.equal(hasAgisoAutoDeliverySucceeded([ - { context_json: { agisoAutoDelivery: { status: 'failed' } } }, - ]), false) -}) - -test('hasAgisoAutoDeliverySucceeded handles string and invalid task context payloads', () => { - assert.equal(hasAgisoAutoDeliverySucceeded([ - { context_json: '{bad-json' }, - { context_json: JSON.stringify({ agisoAutoDelivery: { status: 'success' } }) }, - ]), true) - - assert.equal(hasAgisoAutoDeliverySucceeded([ - { context_json: '{bad-json' }, - { context_json: JSON.stringify({ agisoAutoDelivery: { status: 'skipped' } }) }, - ]), false) -}) - -test('isAgisoAutoDeliverySuccess accepts empty successful payloads and explicit success codes', () => { - assert.equal(isAgisoAutoDeliverySuccess(200, {}), true) - assert.equal(isAgisoAutoDeliverySuccess(200, { IsSuccess: true }), true) - assert.equal(isAgisoAutoDeliverySuccess(200, { Error_Code: 0 }), true) - assert.equal(isAgisoAutoDeliverySuccess(500, { IsSuccess: true }), false) - assert.equal(isAgisoAutoDeliverySuccess(200, { Error_Code: 500 }), false) -}) - -test('resolveAgisoAutoDeliveryErrorMessage prefers structured payload message before raw text', () => { - assert.equal( - resolveAgisoAutoDeliveryErrorMessage( - { Error_Msg: '库存不足' }, - 'raw body', - 400, - ), - '库存不足', - ) - - assert.equal( - resolveAgisoAutoDeliveryErrorMessage( - {}, - 'fallback raw body', - 400, - ), - 'fallback raw body', - ) -}) - -test('resolveAgisoAutoDeliveryEndpoint falls back to DummySend', () => { - assert.equal( - resolveAgisoAutoDeliveryEndpoint(''), - 'https://gw-api.agiso.com/aldsIdle/Order/DummySend', - ) - - assert.equal( - resolveAgisoAutoDeliveryEndpoint('https://gw-api.agiso.com/aldsIdle/Order/DummySend'), - 'https://gw-api.agiso.com/aldsIdle/Order/DummySend', - ) -}) - -test('ensureAgisoXianyuAutoDeliveryForDeliveredTaskWithDeps skips while other order tasks are not delivered', async () => { - const calls = { - update: 0, - fetch: 0, - } - - const result = await ensureAgisoXianyuAutoDeliveryForDeliveredTaskWithDeps({ - order: agisoOrder, - task: createDeliveredTask(), - }, { - listTasksByOrderId: async () => [ - createDeliveredTask(), - createDeliveredTask({ id: 202, delivery_status: 'processing' }), - ], - updateTask: async () => { - calls.update += 1 - return null - }, - fetch: async () => { - calls.fetch += 1 - return { status: 200, text: async () => '{}' } - }, - }) - - assert.equal(result.sent, false) - assert.equal(result.skipped, true) - assert.equal(result.reason, 'waiting_other_tasks') - assert.equal(calls.update, 0) - assert.equal(calls.fetch, 0) -}) - -test('ensureAgisoXianyuAutoDeliveryForDeliveredTaskWithDeps persists missing config as skipped', async () => { - const originalAppSecret = runtimeConfig.platforms.agiso.appSecret - const originalShops = runtimeConfig.platforms.agiso.messaging.shops - const updates = [] - const events = [] - - runtimeConfig.platforms.agiso.appSecret = '' - runtimeConfig.platforms.agiso.messaging.shops = {} - - try { - const task = createDeliveredTask({ context_json: JSON.stringify({ existing: true }) }) - const result = await ensureAgisoXianyuAutoDeliveryForDeliveredTaskWithDeps({ - order: { - ...agisoOrder, - shop_id: 'missing-auto-delivery-config-shop', - }, - task, - trigger: 'unit_test', - }, { - listTasksByOrderId: async () => [task], - updateTask: async (taskId, patch) => { - updates.push({ taskId, patch }) - return { ...task, ...patch } - }, - createTaskEvent: async (taskId, eventType, payload, createdAt) => { - events.push({ taskId, eventType, payload, createdAt }) - return { id: 1 } - }, - fetch: async () => { - throw new Error('fetch should not be called without config') - }, - nowIso: () => '2026-05-21T12:00:00.000Z', - }) - - assert.equal(result.sent, false) - assert.equal(result.skipped, true) - assert.equal(result.reason, 'missing_config') - assert.equal(updates.length, 1) - assert.equal(events[0]?.eventType, 'agiso_auto_delivery_skipped') - - const context = JSON.parse(updates[0].patch.context_json) - assert.equal(context.existing, true) - assert.equal(context.agisoAutoDelivery.status, 'skipped') - assert.equal(context.agisoAutoDelivery.reason, 'missing_config') - assert.deepEqual({ - hasEndpoint: context.agisoAutoDelivery.hasEndpoint, - hasAccessToken: context.agisoAutoDelivery.hasAccessToken, - hasAppSecret: context.agisoAutoDelivery.hasAppSecret, - }, { - hasEndpoint: true, - hasAccessToken: false, - hasAppSecret: false, - }) - } finally { - runtimeConfig.platforms.agiso.appSecret = originalAppSecret - runtimeConfig.platforms.agiso.messaging.shops = originalShops - } -}) - -test('ensureAgisoXianyuAutoDeliveryForDeliveredTaskWithDeps fails when accepted delivery is not confirmed as shipped', async () => { - const originalAppSecret = runtimeConfig.platforms.agiso.appSecret - const originalShops = runtimeConfig.platforms.agiso.messaging.shops - const updates = [] - const events = [] - const messages = [] - - runtimeConfig.platforms.agiso.appSecret = 'runtime-secret' - runtimeConfig.platforms.agiso.messaging.shops = { - [agisoOrder.shop_id]: { - accessToken: 'access-token', - appSecret: 'shop-secret', - apiVersion: '1', - }, - } - - try { - const task = createDeliveredTask() - const result = await ensureAgisoXianyuAutoDeliveryForDeliveredTaskWithDeps({ - order: agisoOrder, - task, - trigger: 'unit_test', - }, { - listTasksByOrderId: async () => [task], - fetch: async () => ({ - status: 200, - text: async () => JSON.stringify({ IsSuccess: true, RequestId: 'req-1' }), - }), - confirmAgisoXianyuAutoDeliveryShipped: async (input) => ({ - shipped: false, - orderStatus: 2, - shipTime: 0, - reason: `not shipped: ${input.requestId}`, - }), - updateTask: async (taskId, patch) => { - updates.push({ taskId, patch }) - return { ...task, ...patch } - }, - createTaskEvent: async (taskId, eventType, payload, createdAt) => { - events.push({ taskId, eventType, payload, createdAt }) - return { id: 2 } - }, - ensureAgisoXianyuAutoDeliveryMessageDeliveredForTask: async (payload) => { - messages.push(payload) - return { sent: true } - }, - nowIso: () => '2026-05-21T12:01:00.000Z', - }) - - assert.equal(result.sent, false) - assert.equal(result.skipped, false) - assert.equal(result.reason, 'delivery_not_confirmed') - assert.equal(messages.length, 0) - assert.equal(events[0]?.eventType, 'agiso_auto_delivery_failed') - - const context = JSON.parse(updates[0].patch.context_json) - assert.equal(context.agisoAutoDelivery.status, 'failed') - assert.equal(context.agisoAutoDelivery.reason, 'delivery_not_confirmed') - assert.equal(context.agisoAutoDelivery.requestId, 'req-1') - assert.equal(context.agisoAutoDelivery.confirmOrderStatus, 2) - assert.match(context.agisoAutoDelivery.errorMessage, /订单仍未进入已发货/) - } finally { - runtimeConfig.platforms.agiso.appSecret = originalAppSecret - runtimeConfig.platforms.agiso.messaging.shops = originalShops - } -}) diff --git a/apps/backend/src/services/platforms/agiso/xianyu/auto-delivery-service.ts b/apps/backend/src/services/platforms/agiso/xianyu/auto-delivery-service.ts deleted file mode 100644 index a8aa9537..00000000 --- a/apps/backend/src/services/platforms/agiso/xianyu/auto-delivery-service.ts +++ /dev/null @@ -1,640 +0,0 @@ -import crypto from 'node:crypto' - -import { runtimeConfig } from '../../../../config/runtime.js' -import { listTasksByOrderId, updateTask } from '../../../../repositories/task-repo.js' -import { createTaskEvent } from '../../../../repositories/task-event-repo.js' -import { getAgisoShopConfig } from '../shop-config-service.js' -import { queryAgisoXianyuOrderDetail } from './order-detail-service.js' -import { ensureAgisoXianyuAutoDeliveryMessageDeliveredForTask } from './message-service.js' -import { parseJsonObject } from '../../../../utils/json.js' -import { logWebhook } from '../../../../utils/logger.js' -import { nowIso } from '../../../../utils/time.js' -import type { TaskRow } from '../../../../types/repository-rows.js' - -const AGISO_DUMMY_SEND_ENDPOINT = 'https://gw-api.agiso.com/aldsIdle/Order/DummySend' - -type JsonObject = Record - -type AutoDeliveryOrder = { - id?: number | string - provider?: string - platform?: string - shop_id?: string - shop_name?: string - platform_order_id?: string - [key: string]: unknown -} - -type SupportedAgisoOrder = AutoDeliveryOrder & { - id: number | string - provider: 'agiso' - platform: 'xianyu' - platform_order_id: string -} - -type AutoDeliveryTask = Partial & { - id?: number | string - delivery_status?: string - task_status?: string - context_json?: string | JsonObject | null - [key: string]: unknown -} - -type AutoDeliveryTaskWithId = AutoDeliveryTask & { - id: number | string -} - -type EnsureAgisoXianyuAutoDeliveryInput = { - order?: AutoDeliveryOrder | null - task?: AutoDeliveryTask | null - trigger?: string -} - -type AutoDeliveryStatus = 'success' | 'failed' | 'skipped' - -type FetchResponseLike = { - status: number - text: () => Promise -} - -type FetchLike = ( - input: string | URL, - init?: RequestInit, -) => Promise - -type AgisoAutoDeliveryConfirmInput = { - shopId?: string - platformOrderId?: string - requestId?: string -} - -type AgisoAutoDeliveryConfirmResult = { - shipped: boolean - orderStatus: number - shipTime: number - reason: string -} - -type AutoDeliveryMessageResult = { - sent: boolean - skipped?: boolean - reason?: string - [key: string]: unknown -} - -type EnsureAgisoXianyuAutoDeliveryDeps = { - listTasksByOrderId?: (orderId: number | string) => Promise - fetch?: FetchLike - confirmAgisoXianyuAutoDeliveryShipped?: ( - input: AgisoAutoDeliveryConfirmInput, - ) => Promise - updateTask?: ( - taskId: number | string, - patch: { context_json: string; updated_at: string }, - ) => Promise - createTaskEvent?: ( - taskId: number | string, - eventType: string, - payload?: unknown, - createdAt?: string, - ) => Promise - nowIso?: () => string - ensureAgisoXianyuAutoDeliveryMessageDeliveredForTask?: ( - input: { order?: AutoDeliveryOrder | null; task?: AutoDeliveryTask | null }, - ) => Promise -} - -type PersistAgisoAutoDeliveryResultInput = { - status: AutoDeliveryStatus - trigger?: string - reason?: string - order?: AutoDeliveryOrder | null - responseStatus?: number - response?: unknown - errorMessage?: string - detail?: JsonObject -} - -type AgisoAutoDeliveryConfig = { - enabled: boolean - endpoint: string - apiVersion: string - appSecret: string - accessToken: string -} - -export async function ensureAgisoXianyuAutoDeliveryForDeliveredTask({ - order, - task, - trigger = 'task_delivered', -}: EnsureAgisoXianyuAutoDeliveryInput = {}) { - return ensureAgisoXianyuAutoDeliveryForDeliveredTaskWithDeps({ order, task, trigger }) -} - -export async function ensureAgisoXianyuAutoDeliveryForDeliveredTaskWithDeps({ - order, - task, - trigger = 'task_delivered', -}: EnsureAgisoXianyuAutoDeliveryInput = {}, deps: EnsureAgisoXianyuAutoDeliveryDeps = {}) { - const listOrderTasks = deps.listTasksByOrderId || listTasksByOrderId - const sendRequest = deps.fetch || (fetch as FetchLike) - const confirmShipped = deps.confirmAgisoXianyuAutoDeliveryShipped || confirmAgisoXianyuAutoDeliveryShipped - - if (!isAgisoXianyuOrder(order) || !task?.id) { - return { sent: false, skipped: true, reason: 'not_supported', task } - } - - const currentTask = task as AutoDeliveryTaskWithId - if (String(currentTask.delivery_status || '').trim() !== 'delivered') { - return { sent: false, skipped: true, reason: 'task_not_delivered', task: currentTask } - } - - const orderTasks = await listOrderTasks(order.id) - if (hasAgisoAutoDeliverySucceeded(orderTasks)) { - return { sent: false, skipped: true, reason: 'already_sent', task: currentTask } - } - - if (!isOrderReadyForAgisoAutoDelivery(orderTasks)) { - logWebhook('[agiso/xianyu/auto-delivery]', '跳过自动发货:订单下仍有任务未完成交付', { - trigger, - orderId: order.id, - taskId: currentTask.id, - platformOrderId: order.platform_order_id, - taskStatuses: orderTasks.map((current) => ({ - taskId: current.id, - taskStatus: current.task_status, - deliveryStatus: current.delivery_status, - })), - }) - - return { sent: false, skipped: true, reason: 'waiting_other_tasks', task: currentTask } - } - - const config = resolveAgisoXianyuAutoDeliveryConfig(order) - if (!config.enabled) { - return persistAgisoAutoDeliveryResult(currentTask, { - status: 'skipped', - trigger, - reason: 'auto_delivery_disabled', - order, - }, deps) - } - - if (!config.endpoint || !config.accessToken || !config.appSecret) { - return persistAgisoAutoDeliveryResult(currentTask, { - status: 'skipped', - trigger, - reason: 'missing_config', - order, - detail: { - hasEndpoint: Boolean(config.endpoint), - hasAccessToken: Boolean(config.accessToken), - hasAppSecret: Boolean(config.appSecret), - }, - }, deps) - } - - const requestHeaders = buildRequestHeaders({ - accessToken: config.accessToken, - apiVersion: config.apiVersion, - }) - const requestBody = buildRequestBody({ - platformOrderId: String(order.platform_order_id || '').trim(), - appSecret: config.appSecret, - }) - - logWebhook('[agiso/xianyu/auto-delivery]', '开始执行 Agiso 咸鱼自动发货', { - trigger, - orderId: order.id, - taskId: currentTask.id, - shopId: order.shop_id, - platformOrderId: order.platform_order_id, - endpoint: config.endpoint, - }) - - try { - const response = await sendRequest(config.endpoint, { - method: 'POST', - headers: requestHeaders, - body: new URLSearchParams(requestBody).toString(), - }) - const rawText = await response.text() - const parsed = parseJsonObject(rawText) as JsonObject - const success = isAgisoAutoDeliverySuccess(response.status, parsed) - - if (success) { - const requestId = String(parsed?.RequestId || '').trim() - - // 发货接口返回成功后,再查一次 Order/Detail 确认订单真的进入已发货状态。 - const confirmResult = await confirmShipped({ - shopId: order.shop_id, - platformOrderId: order.platform_order_id, - requestId, - }) - - if (!confirmResult.shipped) { - logWebhook('[agiso/xianyu/auto-delivery]', 'Agiso 咸鱼自动发货接口返回成功,但订单状态未确认进入已发货', { - trigger, - orderId: order.id, - taskId: currentTask.id, - shopId: order.shop_id, - platformOrderId: order.platform_order_id, - responseStatus: response.status, - requestId, - confirmOrderStatus: confirmResult.orderStatus, - confirmShipTime: confirmResult.shipTime, - confirmReason: confirmResult.reason, - }, { level: 'warn' }) - - return persistAgisoAutoDeliveryResult(currentTask, { - status: 'failed', - trigger, - reason: 'delivery_not_confirmed', - order, - responseStatus: response.status, - response: parsed, - errorMessage: `Agiso 发货状态更新接口已受理,但订单仍未进入已发货,请检查该订单是否允许无物流发货 (orderStatus=${confirmResult.orderStatus}, shipTime=${confirmResult.shipTime})`, - detail: { - requestId, - confirmOrderStatus: confirmResult.orderStatus, - confirmShipTime: confirmResult.shipTime, - }, - }, deps) - } - - logWebhook('[agiso/xianyu/auto-delivery]', 'Agiso 咸鱼自动发货成功,订单已确认进入已发货状态', { - trigger, - orderId: order.id, - taskId: currentTask.id, - shopId: order.shop_id, - platformOrderId: order.platform_order_id, - responseStatus: response.status, - requestId, - confirmOrderStatus: confirmResult.orderStatus, - confirmShipTime: confirmResult.shipTime, - }) - - const result = await persistAgisoAutoDeliveryResult(currentTask, { - status: 'success', - trigger, - reason: '', - order, - responseStatus: response.status, - response: parsed, - detail: { - requestId, - confirmOrderStatus: confirmResult.orderStatus, - confirmShipTime: confirmResult.shipTime, - }, - }, deps) - - // 发货确认成功后,发送自定义消息通知。 - await sendAutoDeliveryMessage({ order, task: currentTask }, deps) - - return result - } - - const errorMessage = resolveAgisoAutoDeliveryErrorMessage(parsed, rawText, response.status) - logWebhook('[agiso/xianyu/auto-delivery]', 'Agiso 咸鱼自动发货失败', { - trigger, - orderId: order.id, - taskId: currentTask.id, - shopId: order.shop_id, - platformOrderId: order.platform_order_id, - responseStatus: response.status, - errorMessage, - response: parsed, - }, { level: 'warn' }) - - return persistAgisoAutoDeliveryResult(currentTask, { - status: 'failed', - trigger, - reason: 'request_failed', - order, - responseStatus: response.status, - response: parsed, - errorMessage, - }, deps) - } catch (error) { - const errorMessage = error instanceof Error ? error.message : String(error || 'Agiso 咸鱼自动发货失败') - logWebhook('[agiso/xianyu/auto-delivery]', 'Agiso 咸鱼自动发货异常', { - trigger, - orderId: order.id, - taskId: currentTask.id, - shopId: order.shop_id, - platformOrderId: order.platform_order_id, - errorMessage, - }, { level: 'warn' }) - - return persistAgisoAutoDeliveryResult(currentTask, { - status: 'failed', - trigger, - reason: 'request_error', - order, - responseStatus: 0, - response: {}, - errorMessage, - }, deps) - } -} - -export function hasAgisoAutoDeliverySucceeded(tasks: AutoDeliveryTask[] = []): boolean { - return (Array.isArray(tasks) ? tasks : []).some((task) => { - const context = parseTaskContext(task) - const autoDelivery = isPlainObject(context.agisoAutoDelivery) ? context.agisoAutoDelivery : {} - return String(autoDelivery.status || '').trim() === 'success' - }) -} - -export function isOrderReadyForAgisoAutoDelivery(tasks: AutoDeliveryTask[] = []): boolean { - const normalizedTasks = Array.isArray(tasks) ? tasks.filter(Boolean) : [] - return normalizedTasks.length > 0 - && normalizedTasks.every((task) => String(task.delivery_status || '').trim() === 'delivered') -} - -function resolveAgisoXianyuAutoDeliveryConfig(order: AutoDeliveryOrder): AgisoAutoDeliveryConfig { - const baseConfig = runtimeConfig.platforms.agiso.autoDelivery - const shopConfig = getAgisoShopConfig(String(order?.shop_id || '').trim()) || {} - - return { - enabled: normalizeBooleanLike(baseConfig.enabled, true), - endpoint: resolveAgisoAutoDeliveryEndpoint(baseConfig.endpoint), - apiVersion: String(baseConfig.apiVersion || shopConfig.apiVersion || '1').trim() || '1', - appSecret: String(shopConfig.appSecret || runtimeConfig.platforms?.agiso?.appSecret || '').trim(), - accessToken: String(shopConfig.accessToken || '').trim(), - } -} - -function buildRequestHeaders({ - accessToken, - apiVersion, -}: { accessToken: string; apiVersion: string }): Record { - return { - Authorization: `Bearer ${accessToken}`, - ApiVersion: String(apiVersion || '1').trim() || '1', - 'content-type': 'application/x-www-form-urlencoded; charset=utf-8', - } -} - -function buildRequestBody({ - platformOrderId, - appSecret, -}: { platformOrderId: string; appSecret: string }): Record { - const payload: Record = { - tid: String(platformOrderId || '').trim(), - timestamp: String(Math.floor(Date.now() / 1000)), - } - - payload.sign = generateSign(payload, appSecret) - return payload -} - -function generateSign(params: Record, appSecret: string): string { - const sortedEntries = Object.entries(params).sort(([left], [right]) => left.localeCompare(right)) - let raw = String(appSecret || '').trim() - - for (const [key, value] of sortedEntries) { - raw += `${key}${value}` - } - - raw += String(appSecret || '').trim() - - return crypto.createHash('md5').update(raw, 'utf8').digest('hex').toLowerCase() -} - -export function isAgisoAutoDeliverySuccess(statusCode: number, payload: unknown): boolean { - if (statusCode < 200 || statusCode >= 300) { - return false - } - - if (!payload || typeof payload !== 'object' || Object.keys(payload).length === 0) { - return true - } - - const normalizedPayload = payload as JsonObject - if (normalizedPayload.IsSuccess === true) { - return true - } - - if (Number(normalizedPayload.Error_Code) === 0) { - return true - } - - if (Number(normalizedPayload.code) === 0) { - return true - } - - return false -} - -export function resolveAgisoAutoDeliveryErrorMessage( - payload: unknown, - rawText: string, - statusCode: number, -): string { - if (payload && typeof payload === 'object') { - const normalizedPayload = payload as JsonObject - for (const value of [ - normalizedPayload.Error_Msg, - normalizedPayload.msg, - normalizedPayload.message, - normalizedPayload.error, - ]) { - const normalized = String(value || '').trim() - if (normalized) { - return normalized - } - } - } - - const text = String(rawText || '').trim() - return text || `Agiso 咸鱼自动发货失败,HTTP ${statusCode}` -} - -export function resolveAgisoAutoDeliveryEndpoint(value: unknown): string { - return AGISO_DUMMY_SEND_ENDPOINT -} - -async function persistAgisoAutoDeliveryResult(task: AutoDeliveryTaskWithId, { - status, - trigger, - reason, - order, - responseStatus = 0, - response = {}, - errorMessage = '', - detail = {}, -}: PersistAgisoAutoDeliveryResultInput, deps: EnsureAgisoXianyuAutoDeliveryDeps = {}) { - const getNowIso = deps.nowIso || nowIso - const patchTask = deps.updateTask || updateTask - const insertTaskEvent = deps.createTaskEvent || createTaskEvent - const now = getNowIso() - const currentContext = parseTaskContext(task) - const nextContext = { - ...currentContext, - agisoAutoDelivery: { - status, - trigger: String(trigger || '').trim(), - reason: String(reason || '').trim(), - platformOrderId: String(order?.platform_order_id || '').trim(), - responseStatus: Number(responseStatus || 0), - errorMessage: String(errorMessage || '').trim(), - response: isPlainObject(response) ? response : {}, - updatedAt: now, - ...detail, - }, - } - const updatedTask = await patchTask(task.id, { - context_json: JSON.stringify(nextContext), - updated_at: now, - }) - - if (status === 'success' || status === 'failed' || status === 'skipped') { - await insertTaskEvent(task.id, `agiso_auto_delivery_${status}`, { - trigger: String(trigger || '').trim(), - reason: String(reason || '').trim(), - platformOrderId: String(order?.platform_order_id || '').trim(), - responseStatus: Number(responseStatus || 0), - errorMessage: String(errorMessage || '').trim(), - ...detail, - }, now) - } - - return { - sent: status === 'success', - skipped: status === 'skipped', - reason: String(reason || '').trim(), - errorMessage: String(errorMessage || '').trim(), - responseStatus: Number(responseStatus || 0), - response, - task: updatedTask || task, - } -} - -function parseTaskContext(task: AutoDeliveryTask | null | undefined): JsonObject { - const value = task?.context_json - - if (!value) { - return {} - } - - if (isPlainObject(value)) { - return value - } - - try { - return JSON.parse(String(value || '{}')) - } catch { - return {} - } -} - -function isAgisoXianyuOrder(order: AutoDeliveryOrder | null | undefined): order is SupportedAgisoOrder { - return String(order?.provider || '').trim() === 'agiso' - && String(order?.platform || '').trim() === 'xianyu' - && Boolean(String(order?.platform_order_id || '').trim()) - && Number(order?.id || 0) > 0 -} - -function normalizeBooleanLike(value: unknown, fallbackValue: boolean): boolean { - if (typeof value === 'boolean') { - return value - } - - if (typeof value === 'undefined' || value === null || value === '') { - return fallbackValue - } - - const normalized = String(value || '').trim().toLowerCase() - if (['1', 'true', 'yes', 'on'].includes(normalized)) { - return true - } - - if (['0', 'false', 'no', 'off'].includes(normalized)) { - return false - } - - return fallbackValue -} - -function isPlainObject(value: unknown): value is JsonObject { - return Object.prototype.toString.call(value) === '[object Object]' -} - -/** - * 发货接口返回成功后,再查一次 Order/Detail 确认订单是否真的进入已发货状态。 - * 只有 ship_time > 0 或 orderStatus >= 3 才算发货确认通过 - */ -async function confirmAgisoXianyuAutoDeliveryShipped({ - shopId = '', - platformOrderId = '', - requestId = '', -}: AgisoAutoDeliveryConfirmInput = {}): Promise { - try { - const detailResult = await queryAgisoXianyuOrderDetail({ shopId, platformOrderId, requestId }) - - if (detailResult.success && detailResult.shipped) { - return { - shipped: true, - orderStatus: detailResult.orderStatus, - shipTime: detailResult.shipTime, - reason: '', - } - } - - return { - shipped: false, - orderStatus: detailResult.orderStatus || 0, - shipTime: detailResult.shipTime || 0, - reason: detailResult.success ? 'order_not_shipped' : detailResult.reason, - } - } catch (error) { - const message = error instanceof Error ? error.message : String(error || '订单发货状态确认失败') - logWebhook('[agiso/xianyu/auto-delivery]', '自动发货确认查询异常', { - requestId, - shopId, - platformOrderId, - errorMessage: message, - }, { level: 'warn' }) - - return { - shipped: false, - orderStatus: 0, - shipTime: 0, - reason: 'confirm_query_failed', - } - } -} - -/** - * 自动发货成功后发送消息通知。 - */ -async function sendAutoDeliveryMessage( - { order, task }: { order?: AutoDeliveryOrder | null; task?: AutoDeliveryTask | null } = {}, - deps: EnsureAgisoXianyuAutoDeliveryDeps = {}, -): Promise { - try { - const deliverMessage = deps.ensureAgisoXianyuAutoDeliveryMessageDeliveredForTask - || ensureAgisoXianyuAutoDeliveryMessageDeliveredForTask - const result = await deliverMessage({ order, task }) - logWebhook('[agiso/xianyu/auto-delivery]', '自动发货消息通知结果', { - orderId: order?.id, - taskId: task?.id, - platformOrderId: order?.platform_order_id, - sent: result.sent, - skipped: result.skipped, - reason: result.reason, - }) - return result - } catch (error) { - const message = error instanceof Error ? error.message : String(error || '发送自动发货消息通知失败') - logWebhook('[agiso/xianyu/auto-delivery]', '自动发货消息通知异常', { - orderId: order?.id, - taskId: task?.id, - platformOrderId: order?.platform_order_id, - errorMessage: message, - }, { level: 'warn' }) - - return { sent: false, skipped: true, reason: 'message_send_error' } - } -} diff --git a/apps/backend/src/services/platforms/agiso/xianyu/message-service.test.ts b/apps/backend/src/services/platforms/agiso/xianyu/message-service.test.ts deleted file mode 100644 index 7d6395e1..00000000 --- a/apps/backend/src/services/platforms/agiso/xianyu/message-service.test.ts +++ /dev/null @@ -1,103 +0,0 @@ -import test from 'node:test' -import assert from 'node:assert/strict' - -import { runtimeConfig } from '../../../../config/runtime.js' -import { - deliverAgisoXianyuMessageForTaskWithDeps, - normalizeAgisoMessageTemplate, - renderAgisoAutoDeliveryMessage, -} from './message-service.js' - -test('deliverAgisoXianyuMessageForTaskWithDeps skips duplicate successful claim message by order scope', async () => { - const originalMessaging = runtimeConfig.platforms.agiso.messaging - const originalAppSecret = runtimeConfig.platforms.agiso.appSecret - - runtimeConfig.platforms.agiso.messaging = { - ...originalMessaging, - enabled: true, - sendMessageEndpoint: 'https://example.com/send', - apiVersion: '1', - accessToken: 'access-token', - } - runtimeConfig.platforms.agiso.appSecret = 'app-secret' - - const calls = { - find: [], - create: 0, - fetch: 0, - } - - try { - const result = await deliverAgisoXianyuMessageForTaskWithDeps({ - order: { - id: 15, - platform: 'xianyu', - shop_id: '2209880145223', - platform_order_id: '4502280133178028841', - }, - task: { - id: 36, - task_no: 'DT7af76e9b2438', - }, - channel: 'agiso_im', - messageContent: '测试消息', - claimUrl: 'https://221329.cc.cd/#/claim/same-token', - }, { - findLatestSuccessfulMessageDelivery: async (input) => { - calls.find.push(input) - return { id: 16, task_id: null } - }, - createMessageDelivery: async () => { - calls.create += 1 - return { id: 999 } - }, - fetch: async () => { - calls.fetch += 1 - return { status: 200, text: async () => '{}' } - }, - }) - - assert.deepEqual(calls.find, [ - { - provider: 'agiso', - platform: 'xianyu', - shopId: '2209880145223', - platformOrderId: '4502280133178028841', - channel: 'agiso_im', - claimUrl: 'https://221329.cc.cd/#/claim/same-token', - }, - ]) - assert.equal(calls.create, 0) - assert.equal(calls.fetch, 0) - assert.equal(result.sent, false) - assert.equal(result.skipped, true) - assert.equal(result.reason, 'already_sent') - assert.equal(result.deliveryId, 16) - } finally { - runtimeConfig.platforms.agiso.messaging = originalMessaging - runtimeConfig.platforms.agiso.appSecret = originalAppSecret - } -}) - -test('normalizeAgisoMessageTemplate converts escaped newline sequences into real line breaks', () => { - assert.equal( - normalizeAgisoMessageTemplate('第一行\\n第二行\\r\\n第三行'), - '第一行\n第二行\n第三行', - ) -}) - -test('renderAgisoAutoDeliveryMessage supports escaped newlines in configured template', () => { - const message = renderAgisoAutoDeliveryMessage({ - order: { - platform_order_id: '4502280133178028841', - shop_id: '2209880145223', - shop_name: '大锤号商', - }, - task: { - task_no: 'DT7af76e9b2438', - }, - template: '订单 {platformOrderId}\\n结果:{resultMessage}', - }) - - assert.equal(message, '订单 4502280133178028841\n结果:自动发货成功') -}) diff --git a/apps/backend/src/services/platforms/agiso/xianyu/message-service.ts b/apps/backend/src/services/platforms/agiso/xianyu/message-service.ts deleted file mode 100644 index 1a7aebe0..00000000 --- a/apps/backend/src/services/platforms/agiso/xianyu/message-service.ts +++ /dev/null @@ -1,539 +0,0 @@ -import crypto from 'node:crypto' - -import { runtimeConfig } from '../../../../config/runtime.js' -import { normalizeAgisoMessageTemplate } from '../../../admin/platform-config/agiso-template.js' -import { getAgisoMessagingDefaults, getAgisoShopConfigMap } from '../shop-config-service.js' -import { - createMessageDelivery, - findLatestSuccessfulMessageDelivery, - updateMessageDelivery, -} from '../../../../repositories/message-delivery-repo.js' -import { nowIso } from '../../../../utils/time.js' - -const AGISO_XIANYU_MESSAGE_CHANNEL = 'agiso_im' -const AGISO_XIANYU_AUTO_DELIVERY_MESSAGE_CHANNEL = 'agiso_im_auto_delivery' - -type JsonObject = Record - -type MessageOrder = { - id?: number | string - platform?: string - shop_id?: string - shop_name?: string - platform_order_id?: string - [key: string]: unknown -} - -type MessageTask = { - id?: number | string - task_no?: string - [key: string]: unknown -} - -type AgisoMessagingConfig = { - enabled?: boolean - sendMessageEndpoint?: string - accessToken?: string - appSecret?: string - apiVersion?: string - messageTemplate?: string - autoDeliveryMessageTemplate?: string - shopName?: string - [key: string]: unknown -} - -type EnsureClaimMessageInput = { - order?: MessageOrder | null - task?: MessageTask | null - claimUrl?: string - expiredAt?: string -} - -type EnsureAutoDeliveryMessageInput = { - order?: MessageOrder | null - task?: MessageTask | null -} - -type DeliverMessageInput = EnsureAutoDeliveryMessageInput & { - channel?: string - messageContent?: string - claimUrl?: string -} - -type FetchResponseLike = { - status: number - text: () => Promise -} - -type FetchLike = ( - input: string | URL, - init?: RequestInit, -) => Promise - -type MessageDeliveryLike = { - id: number | string - [key: string]: unknown -} - -type MessageDeliveryCreateInput = { - provider: string - platform: string - shopId: string - shopName: string - channel: string - orderId?: number | string | null - taskId?: number | string | null - platformOrderId?: string - recipientKey?: string - messageContent: string - claimUrl: string - status: string - requestUrl: string - requestHeadersJson: string - requestBodyJson: string - responseStatus: number - responseJson: string - errorMessage: string - sentAt: string | null - createdAt: string - updatedAt: string -} - -type MessageDeliveryPatch = { - status?: string - response_status?: number - response_json?: string - error_message?: string - sent_at?: string | null - updated_at?: string -} - -type FindSuccessfulDeliveryInput = { - provider: string - platform: string - shopId: string - platformOrderId: string - channel: string - claimUrl: string -} - -type DeliverMessageDeps = { - nowIso?: () => string - findLatestSuccessfulMessageDelivery?: ( - input: FindSuccessfulDeliveryInput, - ) => Promise - createMessageDelivery?: ( - input: MessageDeliveryCreateInput, - ) => Promise - updateMessageDelivery?: ( - deliveryId: number | string, - patch: MessageDeliveryPatch, - ) => Promise - fetch?: FetchLike -} - -type RenderClaimMessageInput = EnsureClaimMessageInput & { - template?: string - shopName?: string -} - -type RenderAutoDeliveryMessageInput = EnsureAutoDeliveryMessageInput & { - template?: string - shopName?: string -} - -type RenderMessageTemplateInput = RenderAutoDeliveryMessageInput & { - template?: string - claimUrl?: string - expiredAt?: string - resultMessage?: string -} - -export async function ensureAgisoXianyuClaimMessageDeliveredForTask({ - order, - task, - claimUrl, - expiredAt, -}: EnsureClaimMessageInput = {}) { - const config = resolveAgisoXianyuMessagingConfig(order) - const messageContent = renderAgisoClaimMessage({ - order, - task, - claimUrl, - expiredAt, - template: config.messageTemplate, - shopName: config.shopName, - }) - - return deliverAgisoXianyuMessageForTask({ - order, - task, - channel: AGISO_XIANYU_MESSAGE_CHANNEL, - messageContent, - claimUrl, - }) -} - -export async function ensureAgisoXianyuAutoDeliveryMessageDeliveredForTask({ - order, - task, -}: EnsureAutoDeliveryMessageInput = {}) { - const config = resolveAgisoXianyuMessagingConfig(order) - const messageContent = renderAgisoAutoDeliveryMessage({ - order, - task, - template: config.autoDeliveryMessageTemplate, - shopName: config.shopName, - }) - - return deliverAgisoXianyuMessageForTask({ - order, - task, - channel: AGISO_XIANYU_AUTO_DELIVERY_MESSAGE_CHANNEL, - messageContent, - }) -} - -async function deliverAgisoXianyuMessageForTask({ - order, - task, - channel, - messageContent, - claimUrl = '', -}: DeliverMessageInput = {}) { - return deliverAgisoXianyuMessageForTaskWithDeps({ - order, - task, - channel, - messageContent, - claimUrl, - }) -} - -export async function deliverAgisoXianyuMessageForTaskWithDeps({ - order, - task, - channel, - messageContent, - claimUrl = '', -}: DeliverMessageInput = {}, deps: DeliverMessageDeps = {}) { - const now = deps.nowIso || nowIso - const findSuccessfulDelivery = deps.findLatestSuccessfulMessageDelivery || findLatestSuccessfulMessageDelivery - const insertMessageDelivery = deps.createMessageDelivery || createMessageDelivery - const patchMessageDelivery = deps.updateMessageDelivery || updateMessageDelivery - const sendRequest = deps.fetch || (fetch as FetchLike) - - if (!order || !task || !messageContent) { - return { sent: false, skipped: true, reason: 'missing_message_context' } - } - - const config = resolveAgisoXianyuMessagingConfig(order) - if (!config.enabled) { - return { sent: false, skipped: true, reason: 'messaging_disabled' } - } - - const endpoint = String(config.sendMessageEndpoint || '').trim() - const accessToken = String(config.accessToken || '').trim() - const appSecret = String(config.appSecret || runtimeConfig.platforms?.agiso?.appSecret || '').trim() - if (!endpoint || !accessToken || !appSecret) { - return { sent: false, skipped: true, reason: 'missing_endpoint_or_access_token_or_app_secret' } - } - - const successful = await findSuccessfulDelivery({ - provider: 'agiso', - platform: String(order.platform || '').trim() || 'unknown', - shopId: String(order.shop_id || '').trim(), - platformOrderId: String(order.platform_order_id || '').trim(), - channel, - claimUrl: String(claimUrl || ''), - }) - if (successful) { - return { sent: false, skipped: true, reason: 'already_sent', deliveryId: successful.id } - } - - const url = buildRequestUrl(endpoint) - const requestBody = buildRequestBody({ - tid: String(order.platform_order_id || ''), - msg: messageContent, - appSecret, - }) - const requestHeaders = buildRequestHeaders({ - accessToken, - apiVersion: String(config.apiVersion || '1').trim() || '1', - }) - const createdAt = now() - const delivery = await insertMessageDelivery({ - provider: 'agiso', - platform: String(order.platform || '').trim() || 'unknown', - shopId: String(order.shop_id || '').trim(), - shopName: String(order.shop_name || '').trim(), - channel, - orderId: order.id, - taskId: task.id, - platformOrderId: order.platform_order_id, - recipientKey: order.platform_order_id, - messageContent, - claimUrl, - status: 'pending', - requestUrl: url, - requestHeadersJson: JSON.stringify(maskHeadersForStorage(requestHeaders)), - requestBodyJson: JSON.stringify(maskBodyForStorage(requestBody)), - responseStatus: 0, - responseJson: '{}', - errorMessage: '', - sentAt: null, - createdAt, - updatedAt: createdAt, - }) - if (!delivery) { - return { sent: false, skipped: false, reason: 'delivery_create_failed' } - } - - try { - const response = await sendRequest(url, { - method: 'POST', - headers: requestHeaders, - body: new URLSearchParams(requestBody).toString(), - }) - const rawText = await response.text() - const parsed = safeParseJson(rawText) - const success = isAgisoSendSuccess(response.status, parsed) - const errorMessage = success ? '' : resolveAgisoErrorMessage(parsed, rawText, response.status) - const updated = await patchMessageDelivery(delivery.id, { - status: success ? 'success' : 'failed', - response_status: response.status, - response_json: JSON.stringify(parsed ?? { rawText }), - error_message: errorMessage, - sent_at: success ? now() : null, - updated_at: now(), - }) - - return { - sent: success, - skipped: false, - deliveryId: updated?.id || delivery.id, - responseStatus: response.status, - response: parsed ?? { rawText }, - errorMessage, - } - } catch (error) { - const message = error instanceof Error ? error.message : String(error || '发送消息失败') - await patchMessageDelivery(delivery.id, { - status: 'failed', - response_status: 0, - response_json: '{}', - error_message: message, - sent_at: null, - updated_at: now(), - }) - - return { - sent: false, - skipped: false, - deliveryId: delivery.id, - errorMessage: message, - } - } -} - -function resolveAgisoXianyuMessagingConfig(order: MessageOrder | null | undefined): AgisoMessagingConfig { - const baseConfig = runtimeConfig.platforms.agiso.messaging - const fileDefaults = getAgisoMessagingDefaults() - const shopId = String(order?.shop_id || '').trim() - const shopConfigs = getAgisoShopConfigMap() - const shopConfig = shopId && isPlainObject(shopConfigs[shopId]) ? shopConfigs[shopId] : {} - - return { - ...baseConfig, - ...fileDefaults, - ...shopConfig, - } -} - -function buildRequestUrl(endpoint: string): string { - return new URL(endpoint).toString() -} - -function buildRequestHeaders({ - accessToken, - apiVersion, -}: { accessToken: string; apiVersion: string }): Record { - return { - Authorization: `Bearer ${accessToken}`, - ApiVersion: apiVersion, - 'content-type': 'application/x-www-form-urlencoded; charset=utf-8', - } -} - -function buildRequestBody({ - tid, - msg, - appSecret, -}: { tid: string; msg: string; appSecret: string }): Record { - const timestamp = String(Math.floor(Date.now() / 1000)) - const payload: Record = { - tid, - msg, - timestamp, - } - - payload.sign = generateSign(payload, appSecret) - - return payload -} - -function generateSign(params: Record, appSecret: string): string { - const sortedEntries = Object.entries(params).sort(([left], [right]) => left.localeCompare(right)) - let raw = appSecret - - for (const [key, value] of sortedEntries) { - raw += `${key}${value}` - } - - raw += appSecret - - return crypto.createHash('md5').update(raw, 'utf8').digest('hex').toLowerCase() -} - -function renderAgisoClaimMessage({ - order, - task, - claimUrl, - expiredAt, - template, - shopName = '', -}: RenderClaimMessageInput): string { - const source = normalizeAgisoMessageTemplate(String(template || '').trim()) - || '您的订单 {platformOrderId} 已创建领取链接,请在 {expiredAt} 前完成领取:{claimUrl}' - - return renderAgisoMessageTemplate({ - order, - task, - template: source, - shopName, - claimUrl, - expiredAt: String(expiredAt || '尽快'), - }) -} - -export function renderAgisoAutoDeliveryMessage({ - order, - task, - template, - shopName = '', -}: RenderAutoDeliveryMessageInput): string { - const source = normalizeAgisoMessageTemplate(String(template || '').trim()) - || '您的订单 {platformOrderId} 已完成自动发货,请注意查收。' - - return renderAgisoMessageTemplate({ - order, - task, - template: source, - shopName, - resultMessage: '自动发货成功', - }) -} - -function renderAgisoMessageTemplate({ - order, - task, - template, - shopName = '', - claimUrl = '', - expiredAt = '', - resultMessage = '', -}: RenderMessageTemplateInput = {}): string { - const resolvedShopName = String(shopName || order?.shop_name || order?.shop_id || '').trim() - - return normalizeAgisoMessageTemplate(template) - .replaceAll('{platformOrderId}', String(order?.platform_order_id || '')) - .replaceAll('{taskNo}', String(task?.task_no || '')) - .replaceAll('{shopName}', resolvedShopName) - .replaceAll('{shopId}', String(order?.shop_id || '')) - .replaceAll('{claimUrl}', String(claimUrl || '')) - .replaceAll('{expiredAt}', String(expiredAt || '')) - .replaceAll('{resultMessage}', String(resultMessage || '')) -} - -function isAgisoSendSuccess(statusCode: number, payload: unknown): boolean { - if (statusCode < 200 || statusCode >= 300) { - return false - } - - if (!payload || typeof payload !== 'object') { - return true - } - - const normalizedPayload = payload as JsonObject - if (normalizedPayload.IsSuccess === true) { - return true - } - - if (Number(normalizedPayload.Error_Code) === 0) { - return true - } - - if (Number(normalizedPayload.code) === 0) { - return true - } - - return false -} - -function resolveAgisoErrorMessage(payload: unknown, rawText: string, statusCode: number): string { - if (payload && typeof payload === 'object') { - const normalizedPayload = payload as JsonObject - for (const value of [ - normalizedPayload.Error_Msg, - normalizedPayload.msg, - normalizedPayload.message, - normalizedPayload.error, - ]) { - const normalized = String(value || '').trim() - if (normalized) { - return normalized - } - } - } - - const text = String(rawText || '').trim() - return text || `Agiso 咸鱼发消息失败,HTTP ${statusCode}` -} - -function safeParseJson(rawText: string): JsonObject | null { - const normalized = String(rawText || '').trim() - - if (!normalized) { - return null - } - - try { - return JSON.parse(normalized) - } catch { - return null - } -} - -function maskHeadersForStorage(headers: Record): Record { - const output = { ...headers } - - if (output.Authorization) { - output.Authorization = '[masked]' - } - - return output -} - -function maskBodyForStorage(body: Record): Record { - const output = { ...body } - - if (output.sign) { - output.sign = '[masked]' - } - - return output -} - -function isPlainObject(value: unknown): value is JsonObject { - return Object.prototype.toString.call(value) === '[object Object]' -} diff --git a/apps/backend/src/services/platforms/agiso/xianyu/order-detail-service.test.ts b/apps/backend/src/services/platforms/agiso/xianyu/order-detail-service.test.ts deleted file mode 100644 index 6277808f..00000000 --- a/apps/backend/src/services/platforms/agiso/xianyu/order-detail-service.test.ts +++ /dev/null @@ -1,219 +0,0 @@ -import test from 'node:test' -import assert from 'node:assert/strict' - -import { runtimeConfig } from '../../../../config/runtime.js' -import { - enrichAgisoXianyuTradeOrderWithDeps, - queryAgisoXianyuOrderDetailWithDeps, - resolveAgisoXianyuOrderDeliveryState, -} from './order-detail-service.js' - -test('resolveAgisoXianyuOrderDeliveryState detects shipped order by ship time or status', () => { - assert.deepEqual(resolveAgisoXianyuOrderDeliveryState({ ship_time: 1713097840 }), { - shipped: true, - shipTime: 1713097840, - orderStatus: 0, - }) - - assert.deepEqual(resolveAgisoXianyuOrderDeliveryState({ order_status: 3 }), { - shipped: true, - shipTime: 0, - orderStatus: 3, - }) - - assert.deepEqual(resolveAgisoXianyuOrderDeliveryState({ order_status: 2 }), { - shipped: false, - shipTime: 0, - orderStatus: 2, - }) -}) - -test('queryAgisoXianyuOrderDetailWithDeps parses successful detail payload and masks network dependency', async () => { - const originalShops = runtimeConfig.platforms.agiso.messaging.shops - const originalAppSecret = runtimeConfig.platforms.agiso.appSecret - const calls = [] - - runtimeConfig.platforms.agiso.appSecret = '' - runtimeConfig.platforms.agiso.messaging.shops = { - 'shop-detail-test': { - accessToken: 'access-token', - appSecret: 'shop-secret', - tradeDetailEndpoint: 'https://example.com/detail', - tradeDetailApiVersion: '2', - tradeDetailTimeoutMs: '3000', - }, - } - - try { - const result = await queryAgisoXianyuOrderDetailWithDeps({ - shopId: 'shop-detail-test', - platformOrderId: 'P-DETAIL-10001', - requestId: 'req-detail', - }, { - fetchWithTimeout: async (url, options, timeoutMs) => { - calls.push({ url, options, timeoutMs }) - return { - status: 200, - text: async () => JSON.stringify({ - IsSuccess: true, - Data: { - total_fee: '25.50', - pay_time: '2026-04-14 20:30:40', - ship_time: 1713097840, - order_status: 3, - item: { - item_id: 'item-1', - sku: 'dnf-cdk-a|商品名称:DNF礼包', - quantity: 2, - }, - }, - }), - } - }, - }) - - assert.equal(result.success, true) - assert.equal(result.responseStatus, 200) - assert.equal(result.totalAmountFen, 2550) - assert.equal(result.shipped, true) - assert.equal(result.shipTime, 1713097840) - assert.equal(result.orderStatus, 3) - assert.equal(calls[0]?.url, 'https://example.com/detail') - assert.equal(calls[0]?.timeoutMs, 3000) - assert.equal(calls[0]?.options.headers.Authorization, 'Bearer access-token') - assert.equal(calls[0]?.options.headers.ApiVersion, '2') - assert.match(String(calls[0]?.options.body), /tid=P-DETAIL-10001/) - assert.match(String(calls[0]?.options.body), /sign=/) - } finally { - runtimeConfig.platforms.agiso.messaging.shops = originalShops - runtimeConfig.platforms.agiso.appSecret = originalAppSecret - } -}) - -test('queryAgisoXianyuOrderDetailWithDeps returns business_error with structured message', async () => { - const originalShops = runtimeConfig.platforms.agiso.messaging.shops - - runtimeConfig.platforms.agiso.messaging.shops = { - 'shop-detail-test': { - accessToken: 'access-token', - appSecret: 'shop-secret', - tradeDetailEndpoint: 'https://example.com/detail', - }, - } - - try { - const result = await queryAgisoXianyuOrderDetailWithDeps({ - shopId: 'shop-detail-test', - platformOrderId: 'P-DETAIL-10002', - }, { - fetchWithTimeout: async () => ({ - status: 200, - text: async () => JSON.stringify({ - IsSuccess: false, - Error_Msg: '订单不存在', - }), - }), - }) - - assert.equal(result.success, false) - assert.equal(result.reason, 'business_error') - assert.equal(result.errorMessage, '订单不存在') - assert.equal(result.responseStatus, 200) - } finally { - runtimeConfig.platforms.agiso.messaging.shops = originalShops - } -}) - -test('queryAgisoXianyuOrderDetailWithDeps skips missing config without fetching', async () => { - const originalShops = runtimeConfig.platforms.agiso.messaging.shops - const originalAppSecret = runtimeConfig.platforms.agiso.appSecret - let fetchCalled = false - - runtimeConfig.platforms.agiso.messaging.shops = {} - runtimeConfig.platforms.agiso.appSecret = '' - - try { - const result = await queryAgisoXianyuOrderDetailWithDeps({ - shopId: 'missing-shop', - platformOrderId: 'P-DETAIL-10003', - }, { - fetchWithTimeout: async () => { - fetchCalled = true - return { status: 200, text: async () => '{}' } - }, - }) - - assert.equal(result.success, false) - assert.equal(result.reason, 'missing_config') - assert.equal(fetchCalled, false) - } finally { - runtimeConfig.platforms.agiso.messaging.shops = originalShops - runtimeConfig.platforms.agiso.appSecret = originalAppSecret - } -}) - -test('enrichAgisoXianyuTradeOrderWithDeps merges detail payload into incomplete trade', async () => { - const parsed = { - provider: 'agiso', - platform: 'xianyu', - shopId: 'shop-detail-test', - shopName: '', - platformOrderId: 'P-DETAIL-10004', - totalAmount: 0, - paidAt: null, - buyerId: '', - buyerName: '', - receiverContact: '', - rawPayload: {}, - items: [], - } - - const result = await enrichAgisoXianyuTradeOrderWithDeps(parsed, { requestId: 'req-enrich' }, { - queryAgisoXianyuOrderDetail: async (input) => { - assert.deepEqual(input, { - shopId: 'shop-detail-test', - platformOrderId: 'P-DETAIL-10004', - requestId: 'req-enrich', - }) - - return { - success: true, - reason: '', - errorMessage: '', - responseStatus: 200, - payload: {}, - detailPayload: { - total_fee: '19.90', - pay_time: '2026-04-14 20:30:40', - buyer_id: 'buyer-1', - buyer_name: '测试买家', - receiver_mobile: '13800138000', - shop_name: '详情店铺', - items: [ - { - item_id: 'item-1', - sku: 'dnf-cdk-a|商品名称:DNF礼包', - quantity: 2, - }, - ], - }, - totalAmountFen: 1990, - shipped: false, - shipTime: 0, - orderStatus: 2, - } - }, - }) - - assert.equal(result.enriched, true) - assert.equal(result.totalAmountFen, 1990) - assert.equal(result.parsed.totalAmount, 1990) - assert.equal(result.parsed.buyerId, 'buyer-1') - assert.equal(result.parsed.buyerName, '测试买家') - assert.equal(result.parsed.receiverContact, '13800138000') - assert.equal(result.parsed.shopName, '详情店铺') - assert.equal(result.parsed.items.length, 1) - assert.equal(result.parsed.items[0]?.skuCode, 'dnf-cdk-a') - assert.equal(result.parsed.items[0]?.quantity, 2) - assert.deepEqual(result.parsed.rawPayload._agisoTradeDetail.total_fee, '19.90') -}) diff --git a/apps/backend/src/services/platforms/agiso/xianyu/order-detail-service.ts b/apps/backend/src/services/platforms/agiso/xianyu/order-detail-service.ts deleted file mode 100644 index 3905e842..00000000 --- a/apps/backend/src/services/platforms/agiso/xianyu/order-detail-service.ts +++ /dev/null @@ -1,803 +0,0 @@ -import crypto from 'node:crypto' - -import { runtimeConfig } from '../../../../config/runtime.js' -import { getAgisoShopConfig } from '../shop-config-service.js' -import { logWebhook } from '../../../../utils/logger.js' -import { nowIso } from '../../../../utils/time.js' -import { parseAmountToFen } from '../../../../utils/money.js' -import { parseJsonObject } from '../../../../utils/json.js' - -const DEFAULT_DETAIL_TIMEOUT_MS = 5000 - -type JsonObject = Record - -type AgisoParsedTrade = { - provider?: string - platform?: string - shopId?: string - shopName?: string - platformOrderId?: string - totalAmount?: number - paidAt?: string | null - buyerId?: string - buyerName?: string - receiverContact?: string - rawPayload?: JsonObject - items?: AgisoOrderItem[] - [key: string]: unknown -} - -type AgisoOrderItem = { - skuCode: string - skuName: string - externalItemId: string - externalSkuCode: string - externalSkuName: string - quantity: number - spec: JsonObject - snapshot: { - externalItemId: string - externalSkuCode: string - externalSkuName: string - } -} - -type AgisoDetailQueryInput = { - shopId?: string - platformOrderId?: string - requestId?: string -} - -type FetchResponseLike = { - status: number - text: () => Promise -} - -type FetchWithTimeout = ( - url: string, - options: RequestInit, - timeoutMs: number, -) => Promise - -type AgisoDetailDeps = { - fetchWithTimeout?: FetchWithTimeout - queryAgisoXianyuOrderDetail?: ( - input: Required, - ) => Promise -} - -type AgisoTradeDetailConfig = { - endpoint: string - apiVersion: string - accessToken: string - appSecret: string - timeoutMs: number -} - -type AgisoShopConfig = { - accessToken?: string - appSecret?: string - tradeDetailEndpoint?: string - tradeDetailApiVersion?: string - tradeDetailTimeoutMs?: string | number -} - -type DeliveryState = { - shipped: boolean - shipTime: number - orderStatus: number -} - -type AgisoDetailResult = { - success: boolean - reason: string - errorMessage: string - responseStatus: number - payload: JsonObject - detailPayload: JsonObject - totalAmountFen: number - shipped: boolean - shipTime: number - orderStatus: number -} - -type ExternalSkuDescriptor = { - raw: string - externalSkuCode: string - externalSkuName: string -} - -export async function enrichAgisoXianyuTradeOrder(parsed: AgisoParsedTrade, { requestId = '' } = {}) { - return enrichAgisoXianyuTradeOrderWithDeps(parsed, { requestId }) -} - -export async function enrichAgisoXianyuTradeOrderWithDeps( - parsed: AgisoParsedTrade, - { requestId = '' } = {}, - deps: AgisoDetailDeps = {}, -) { - const queryOrderDetail = deps.queryAgisoXianyuOrderDetail || queryAgisoXianyuOrderDetail - - if (!shouldHydrateAgisoXianyuTradeOrder(parsed)) { - return { parsed, enriched: false, reason: 'not_needed' } - } - - const detailResult = await queryOrderDetail({ - shopId: parsed.shopId, - platformOrderId: parsed.platformOrderId, - requestId, - }) - - if (!detailResult.success) { - return { - parsed, - enriched: false, - reason: detailResult.reason, - errorMessage: detailResult.errorMessage, - } - } - - const detailPayload = detailResult.detailPayload - if (detailResult.totalAmountFen <= 0) { - return { - parsed: mergeEnrichedTrade(parsed, detailPayload, { keepOriginalAmount: true }), - enriched: false, - reason: 'amount_still_missing', - } - } - - const merged = mergeEnrichedTrade(parsed, detailPayload) - logWebhook('[agiso/xianyu/order-detail]', 'Agiso 咸鱼订单详情补查成功', { - requestId, - platform: parsed.platform, - shopId: parsed.shopId, - platformOrderId: parsed.platformOrderId, - totalAmountFen: merged.totalAmount, - paidAt: merged.paidAt, - }) - - return { - parsed: merged, - enriched: true, - totalAmountFen: merged.totalAmount, - } -} - -export async function queryAgisoXianyuOrderDetail({ shopId = '', platformOrderId = '', requestId = '' } = {}) { - return queryAgisoXianyuOrderDetailWithDeps({ shopId, platformOrderId, requestId }) -} - -export async function queryAgisoXianyuOrderDetailWithDeps( - { shopId = '', platformOrderId = '', requestId = '' } = {}, - deps: AgisoDetailDeps = {}, -): Promise { - const requestWithTimeout = deps.fetchWithTimeout || fetchWithTimeout - const normalizedShopId = String(shopId || '').trim() - const normalizedPlatformOrderId = String(platformOrderId || '').trim() - const config = resolveAgisoXianyuTradeDetailConfig(normalizedShopId) - - if (!config.endpoint || !config.accessToken || !config.appSecret) { - logWebhook('[agiso/xianyu/order-detail]', '跳过 Agiso 咸鱼订单详情补查:缺少必要配置', { - requestId, - platform: 'xianyu', - shopId: normalizedShopId, - platformOrderId: normalizedPlatformOrderId, - hasEndpoint: Boolean(config.endpoint), - hasAccessToken: Boolean(config.accessToken), - hasAppSecret: Boolean(config.appSecret), - }, { level: 'warn' }) - - return createFailedDetailResult('missing_config') - } - - const requestBody = buildRequestBody({ - platformOrderId: normalizedPlatformOrderId, - appSecret: config.appSecret, - }) - const requestHeaders = buildRequestHeaders({ - accessToken: config.accessToken, - apiVersion: config.apiVersion, - }) - - logWebhook('[agiso/xianyu/order-detail]', '开始补查 Agiso 咸鱼订单详情', { - requestId, - platform: 'xianyu', - shopId: normalizedShopId, - platformOrderId: normalizedPlatformOrderId, - endpoint: config.endpoint, - }) - - try { - const response = await requestWithTimeout(config.endpoint, { - method: 'POST', - headers: requestHeaders, - body: new URLSearchParams(requestBody).toString(), - }, config.timeoutMs) - const rawText = await response.text() - const payload = parseJsonObject(rawText, { preserveLargeIntegers: true }) as JsonObject - const detailPayload = extractAgisoDetailPayload(payload) - const delivery = resolveAgisoXianyuOrderDeliveryState(detailPayload) - const totalAmountFen = resolveTotalAmountFen(detailPayload) - - if (response.status < 200 || response.status >= 300) { - const message = resolveAgisoDetailErrorMessage(payload, rawText, response.status) - logWebhook('[agiso/xianyu/order-detail]', 'Agiso 咸鱼订单详情补查失败', { - requestId, - platform: 'xianyu', - shopId: normalizedShopId, - platformOrderId: normalizedPlatformOrderId, - responseStatus: response.status, - errorMessage: message, - }, { level: 'warn' }) - - return createFailedDetailResult('http_error', { - responseStatus: response.status, - payload, - detailPayload, - errorMessage: message, - totalAmountFen, - delivery, - }) - } - - if (!isAgisoDetailSuccess(payload)) { - const message = resolveAgisoDetailErrorMessage(payload, rawText, response.status) - logWebhook('[agiso/xianyu/order-detail]', 'Agiso 咸鱼订单详情补查返回业务失败', { - requestId, - platform: 'xianyu', - shopId: normalizedShopId, - platformOrderId: normalizedPlatformOrderId, - responseStatus: response.status, - errorMessage: message, - response: payload, - }, { level: 'warn' }) - - return createFailedDetailResult('business_error', { - responseStatus: response.status, - payload, - detailPayload, - errorMessage: message, - totalAmountFen, - delivery, - }) - } - - if (!isPlainObject(detailPayload) || Object.keys(detailPayload).length === 0) { - logWebhook('[agiso/xianyu/order-detail]', 'Agiso 咸鱼订单详情补查未返回可用订单体', { - requestId, - platform: 'xianyu', - shopId: normalizedShopId, - platformOrderId: normalizedPlatformOrderId, - responseStatus: response.status, - }, { level: 'warn' }) - - return createFailedDetailResult('empty_detail_payload', { - responseStatus: response.status, - payload, - }) - } - - if (totalAmountFen <= 0) { - logWebhook('[agiso/xianyu/order-detail]', 'Agiso 咸鱼订单详情已返回,但仍未解析出金额', { - requestId, - platform: 'xianyu', - shopId: normalizedShopId, - platformOrderId: normalizedPlatformOrderId, - responseStatus: response.status, - detailKeys: Object.keys(detailPayload), - }, { level: 'warn' }) - } - - return { - success: true, - reason: '', - errorMessage: '', - responseStatus: response.status, - payload: isPlainObject(payload) ? payload : {}, - detailPayload, - totalAmountFen, - shipped: delivery.shipped, - shipTime: delivery.shipTime, - orderStatus: delivery.orderStatus, - } - } catch (error) { - const message = error instanceof Error ? error.message : String(error || 'Agiso 咸鱼订单详情补查失败') - logWebhook('[agiso/xianyu/order-detail]', 'Agiso 咸鱼订单详情补查异常', { - requestId, - platform: 'xianyu', - shopId: normalizedShopId, - platformOrderId: normalizedPlatformOrderId, - errorMessage: message, - }, { level: 'warn' }) - - return createFailedDetailResult('request_failed', { - errorMessage: message, - }) - } -} - -function shouldHydrateAgisoXianyuTradeOrder(parsed: AgisoParsedTrade | null | undefined): boolean { - return parsed?.provider === 'agiso' - && parsed?.platform === 'xianyu' - && String(parsed?.platformOrderId || '').trim() - && Number(parsed?.totalAmount || 0) <= 0 -} - -function resolveAgisoXianyuTradeDetailConfig(shopId: string): AgisoTradeDetailConfig { - const baseConfig = runtimeConfig.platforms.agiso.tradeDetail - const shopConfig = (getAgisoShopConfig(shopId) || {}) as AgisoShopConfig - - return { - endpoint: String(shopConfig.tradeDetailEndpoint || baseConfig.endpoint || '').trim(), - apiVersion: String(shopConfig.tradeDetailApiVersion || baseConfig.apiVersion || '1').trim() || '1', - accessToken: String(shopConfig.accessToken || '').trim(), - appSecret: String(shopConfig.appSecret || runtimeConfig.platforms?.agiso?.appSecret || '').trim(), - timeoutMs: normalizePositiveInteger(shopConfig.tradeDetailTimeoutMs || baseConfig.timeoutMs, DEFAULT_DETAIL_TIMEOUT_MS), - } -} - -function buildRequestHeaders({ - accessToken, - apiVersion, -}: { accessToken: string; apiVersion: string }): Record { - return { - Authorization: `Bearer ${accessToken}`, - ApiVersion: apiVersion, - 'content-type': 'application/x-www-form-urlencoded; charset=utf-8', - } -} - -function buildRequestBody({ - platformOrderId, - appSecret, -}: { platformOrderId: string; appSecret: string }): Record { - const timestamp = String(Math.floor(Date.now() / 1000)) - const payload: Record = { - tid: String(platformOrderId || '').trim(), - timestamp, - } - - payload.sign = generateSign(payload, appSecret) - return payload -} - -function generateSign(params: Record, appSecret: string): string { - const sortedEntries = Object.entries(params).sort(([left], [right]) => left.localeCompare(right)) - let raw = appSecret - - for (const [key, value] of sortedEntries) { - raw += `${key}${value}` - } - - raw += appSecret - - return crypto.createHash('md5').update(raw, 'utf8').digest('hex').toLowerCase() -} - -function extractAgisoDetailPayload(payload) { - if (!isPlainObject(payload)) { - return {} - } - - for (const key of ['Data', 'data', 'Result', 'result', 'Trade', 'trade', 'Order', 'order']) { - if (isPlainObject(payload[key])) { - return payload[key] - } - } - - return payload -} - -export function resolveAgisoXianyuOrderDeliveryState(payload) { - const shipTime = normalizeTimestampValue(pickFirstNonEmpty([ - payload?.ship_time, - payload?.shipTime, - payload?.ShipTime, - payload?.delivery_time, - payload?.deliveryTime, - ])) - const orderStatus = normalizeInteger( - pickFirstNonEmpty([ - payload?.order_status, - payload?.orderStatus, - payload?.status, - ]), - ) - - return { - shipped: shipTime > 0 || orderStatus >= 3, - shipTime, - orderStatus, - } -} - -function resolveTotalAmountFen(payload) { - const fenAmount = normalizeFenInteger(pickFirstNonEmpty([ - payload.payment, - payload.Payment, - payload.post_fee, - payload.postFee, - payload.item?.price, - ])) - - if (fenAmount > 0) { - return fenAmount - } - - return parseAmountToFen(pickFirstNonEmpty([ - payload.total_fee, - payload.totalFee, - payload.TotalFee, - payload.pay_fee, - payload.payFee, - payload.PayFee, - payload.actual_fee, - payload.actualFee, - payload.ActualFee, - payload.total_amount, - payload.totalAmount, - payload.Amount, - payload.amount, - ])) -} - -function resolvePaidAt(payload, fallbackValue) { - const providerPaidAt = normalizeProviderDateTime( - pickFirstNonEmpty([ - payload.paid_at, - payload.paidAt, - payload.pay_time, - payload.payTime, - payload.PayTime, - fallbackValue, - ]), - ) - - if (providerPaidAt) { - return providerPaidAt - } - - const rawStatus = normalizeInteger( - pickFirstNonEmpty([payload.order_status, payload.orderStatus, payload.status]), - ) - - if (rawStatus === 2 || rawStatus === 3 || rawStatus === 4) { - return nowIso() - } - - return null -} - -function mergeEnrichedTrade(parsed, detailPayload, { keepOriginalAmount = false } = {}) { - const mergedPayload = { - ...parsed.rawPayload, - _agisoTradeDetail: detailPayload, - } - const totalAmountFen = resolveTotalAmountFen(detailPayload) - const mergedItems = normalizeOrderItems(detailPayload, normalizeOrderItems(parsed.rawPayload, parsed.items)) - - return { - ...parsed, - buyerId: pickFirstNonEmpty([ - parsed.buyerId, - detailPayload.encryption_buyer_id, - detailPayload.buyer_id, - detailPayload.buyerId, - detailPayload.BuyerId, - detailPayload.BuyerOpenUid, - detailPayload.buyer_open_uid, - ]), - buyerName: pickFirstNonEmpty([ - parsed.buyerName, - detailPayload.buyer_name, - detailPayload.buyerName, - detailPayload.BuyerName, - detailPayload.buyer_nick, - detailPayload.nick, - detailPayload.BuyerNick, - ]), - receiverContact: pickFirstNonEmpty([ - parsed.receiverContact, - detailPayload.receiver_contact, - detailPayload.receiverContact, - detailPayload.receiver_mobile, - detailPayload.receiverMobile, - detailPayload.mobile, - detailPayload.phone, - ]), - shopName: pickFirstNonEmpty([ - parsed.shopName, - detailPayload.shop_name, - detailPayload.shopName, - detailPayload.ShopName, - detailPayload.seller_name, - detailPayload.sellerName, - detailPayload.seller_nick, - detailPayload.sellerNick, - detailPayload.SellerNick, - ]), - totalAmount: keepOriginalAmount ? parsed.totalAmount : (totalAmountFen || parsed.totalAmount), - paidAt: parsed.paidAt || resolvePaidAt(detailPayload, parsed.paidAt), - rawPayload: mergedPayload, - items: mergedItems, - } -} - -function normalizeOrderItems(payload, fallbackItems = []) { - const candidates = [ - payload.item ? [payload.item] : null, - payload.items, - payload.Items, - payload.orders, - payload.Orders, - payload.order_list, - payload.OrderList, - ] - const items = candidates.find((item) => Array.isArray(item) && item.length > 0) - - if (!Array.isArray(items) || items.length === 0) { - return Array.isArray(fallbackItems) ? fallbackItems : [] - } - - return items.map((item) => { - const source = isPlainObject(item) ? item : {} - const skuDescriptor = parseExternalSkuDescriptor( - pickFirstNonEmpty([ - source.sku, - source.Sku, - payload.sku, - payload.Sku, - ]), - ) - const skuCode = pickFirstNonEmpty([ - skuDescriptor.externalSkuCode, - source.OuterSkuId, - source.outerSkuId, - source.outer_sku_id, - source.OuterIid, - source.outerIid, - source.outer_iid, - source.sku_code, - source.skuCode, - source.goods_sku, - source.item_id, - source.itemId, - source.goods_id, - source.goodsId, - source.NumIid, - source.num_iid, - payload.item_id, - payload.itemId, - ]) - const externalItemId = pickFirstNonEmpty([ - source.item_id, - source.itemId, - source.goods_id, - source.goodsId, - payload.item_id, - payload.itemId, - ]) - const externalSkuName = pickFirstNonEmpty([ - skuDescriptor.externalSkuName, - source.Title, - source.title, - source.sku_name, - source.skuName, - source.goods_name, - source.goodsName, - skuCode, - ]) - - return { - skuCode, - skuName: externalSkuName, - externalItemId, - externalSkuCode: pickFirstNonEmpty([skuDescriptor.externalSkuCode, skuCode, externalItemId]), - externalSkuName, - quantity: Math.max( - 1, - normalizeInteger( - pickFirstNonEmpty([ - source.quantity, - source.num, - source.Num, - source.buy_amount, - payload.quantity, - payload.num, - payload.Num, - ]), - ) || 1, - ), - spec: source, - snapshot: { - externalItemId, - externalSkuCode: pickFirstNonEmpty([skuDescriptor.externalSkuCode, skuCode, externalItemId]), - externalSkuName, - }, - } - }) -} - -function parseExternalSkuDescriptor(rawValue) { - const raw = String(rawValue || '').trim() - if (!raw) { - return { - raw, - externalSkuCode: '', - externalSkuName: '', - } - } - - const parts = raw.split('|').map((part) => String(part || '').trim()).filter(Boolean) - let externalSkuCode = '' - let externalSkuName = '' - - for (const part of parts) { - if (!externalSkuCode && !part.includes(':') && !part.includes(':')) { - externalSkuCode = part - continue - } - - const separatorIndex = Math.max(part.indexOf(':'), part.indexOf(':')) - if (separatorIndex >= 0) { - const label = part.slice(0, separatorIndex).trim() - const value = part.slice(separatorIndex + 1).trim() - if (value && ['商品名称', '商品名', 'sku名称', '规格名称', '名称', '商品'].includes(label)) { - externalSkuName = value - } - } - } - - if (!externalSkuCode && parts[0]) { - externalSkuCode = parts[0] - } - - if (!externalSkuName) { - externalSkuName = parts - .map((part) => { - const separatorIndex = Math.max(part.indexOf(':'), part.indexOf(':')) - return separatorIndex >= 0 ? part.slice(separatorIndex + 1).trim() : '' - }) - .find(Boolean) || '' - } - - return { - raw, - externalSkuCode, - externalSkuName, - } -} - -function isAgisoDetailSuccess(payload) { - if (!payload || typeof payload !== 'object' || Object.keys(payload).length === 0) { - return false - } - - if (payload.IsSuccess === true) { - return true - } - - if (Number(payload.Error_Code) === 0) { - return true - } - - if (Number(payload.code) === 0) { - return true - } - - if (Number(payload.success) === 1 || payload.success === true) { - return true - } - - return false -} - -function resolveAgisoDetailErrorMessage(payload, rawText, statusCode) { - if (payload && typeof payload === 'object') { - for (const value of [payload.Error_Msg, payload.msg, payload.message, payload.error]) { - const normalized = String(value || '').trim() - if (normalized) { - return normalized - } - } - } - - const text = String(rawText || '').trim() - return text || `Agiso 咸鱼订单详情接口失败,HTTP ${statusCode}` -} - -async function fetchWithTimeout(url, options, timeoutMs) { - const controller = new AbortController() - const timer = setTimeout(() => controller.abort(new Error('Agiso 咸鱼订单详情请求超时')), timeoutMs) - - try { - return await fetch(url, { - ...options, - signal: controller.signal, - }) - } finally { - clearTimeout(timer) - } -} - -function normalizeProviderDateTime(value) { - const normalized = String(value || '').trim() - - if (!normalized) { - return null - } - - if (/^\d{10,13}$/.test(normalized)) { - const timestamp = normalized.length === 13 ? Number(normalized) : Number(normalized) * 1000 - - if (Number.isFinite(timestamp)) { - return new Date(timestamp).toISOString() - } - } - - const isoLike = normalized.replace(' ', 'T') - const parsed = Date.parse(isoLike) - - if (Number.isNaN(parsed)) { - return null - } - - return new Date(parsed).toISOString() -} - -function normalizeInteger(value) { - const parsed = Number(value) - return Number.isFinite(parsed) ? Math.round(parsed) : 0 -} - -function normalizePositiveInteger(value, fallbackValue) { - const parsed = Number(value) - return Number.isFinite(parsed) && parsed > 0 ? Math.round(parsed) : fallbackValue -} - -function normalizeFenInteger(value) { - const parsed = Number(value) - return Number.isFinite(parsed) && parsed > 0 ? Math.round(parsed) : 0 -} - -function normalizeTimestampValue(value) { - const parsed = Number(value) - return Number.isFinite(parsed) && parsed > 0 ? Math.round(parsed) : 0 -} - -function createFailedDetailResult(reason, { - responseStatus = 0, - payload = {}, - detailPayload = {}, - errorMessage = '', - totalAmountFen = 0, - delivery = { shipped: false, shipTime: 0, orderStatus: 0 }, -} = {}) { - return { - success: false, - reason: String(reason || '').trim(), - errorMessage: String(errorMessage || '').trim(), - responseStatus: Number(responseStatus || 0), - payload: isPlainObject(payload) ? payload : {}, - detailPayload: isPlainObject(detailPayload) ? detailPayload : {}, - totalAmountFen: Number(totalAmountFen || 0), - shipped: Boolean(delivery?.shipped), - shipTime: Number(delivery?.shipTime || 0), - orderStatus: Number(delivery?.orderStatus || 0), - } -} - -function pickFirstNonEmpty(values) { - for (const value of values) { - const normalized = String(value || '').trim() - if (normalized) { - return normalized - } - } - - return '' -} - -function isPlainObject(value) { - return Object.prototype.toString.call(value) === '[object Object]' -} diff --git a/apps/backend/src/services/session/ocr.ts b/apps/backend/src/services/session/ocr.ts deleted file mode 100644 index 9c46b480..00000000 --- a/apps/backend/src/services/session/ocr.ts +++ /dev/null @@ -1,101 +0,0 @@ -import { runtimeConfig } from '../../config/runtime.js' - -const DEFAULT_TIMEOUT_MS = 60_000 - -type OcrAction = 'recognize' | 'batch' - -type OcrRequestPayload = Record - -type OcrHttpResult = Record & { - code: number - msg?: string -} - -export async function recognizeTencentCaptcha(payload: OcrRequestPayload): Promise { - return callOcrViaHttp('recognize', payload) -} - -export async function recognizeImageCaptcha(payload: OcrRequestPayload): Promise { - return callOcrViaHttp('recognize', payload) -} - -export async function batchRecognizeTencentCaptcha(payload: OcrRequestPayload): Promise { - return callOcrViaHttp('batch', payload, { timeoutMs: 180_000 }) -} - -export async function warmupOcrService(): Promise { - await warmupOcrViaHttp() -} - -export async function closeOcrService(): Promise { - // HTTP 模式无持久连接,无需关闭。 -} - -// ── HTTP Call ─────────────────────────────────────────────────────────────── - -async function callOcrViaHttp( - action: OcrAction, - payload: OcrRequestPayload, - { timeoutMs = DEFAULT_TIMEOUT_MS }: { timeoutMs?: number } = {}, -): Promise { - const baseUrl = String(runtimeConfig.ocr.baseUrl).trim().replace(/\/+$/, '') - - if (!baseUrl) { - throw new Error('OCR_BASE_URL 未配置,无法调用 OCR 服务') - } - - const url = `${baseUrl}/ocr/${action}` - - let response - try { - response = await fetch(url, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(payload), - signal: AbortSignal.timeout(timeoutMs), - }) - } catch (err) { - if (err instanceof Error && err.name === 'TimeoutError') { - throw new Error(`OCR 请求超时(${timeoutMs}ms),请检查 OCR 服务状态`) - } - throw new Error(`OCR 服务不可用(${baseUrl}),请检查 OCR_BASE_URL 或外部 OCR 服务状态`) - } - - if (!response.ok) { - throw new Error(`OCR 服务返回错误:HTTP ${response.status}`) - } - - const result = await response.json() - if (!result || typeof result !== 'object' || Array.isArray(result)) { - throw new Error('OCR 服务返回了无效响应') - } - const ocrResult = result as OcrHttpResult - if (ocrResult.code !== 0) { - throw new Error(`OCR 服务返回错误:${ocrResult.msg || '未知错误'}`) - } - - return ocrResult -} - -async function warmupOcrViaHttp(): Promise { - const baseUrl = String(runtimeConfig.ocr.baseUrl).trim().replace(/\/+$/, '') - - if (!baseUrl) { - throw new Error('OCR_BASE_URL 未配置,无法启动 OCR 健康检查') - } - - let response - try { - response = await fetch(`${baseUrl}/health`, { - signal: AbortSignal.timeout(30_000), - }) - } catch (err) { - if (err instanceof Error && err.name === 'TimeoutError') { - throw new Error(`OCR 服务健康检查超时,请检查 ${baseUrl} 是否可访问`) - } - throw new Error(`OCR 服务不可用(${baseUrl}),请检查 OCR_BASE_URL 或外部 OCR 服务状态`) - } - if (!response.ok) { - throw new Error(`OCR 服务健康检查失败:HTTP ${response.status}`) - } -} diff --git a/apps/backend/src/services/session/session-activity.ts b/apps/backend/src/services/session/session-activity.ts deleted file mode 100644 index 5010cf5a..00000000 --- a/apps/backend/src/services/session/session-activity.ts +++ /dev/null @@ -1,308 +0,0 @@ -/// - -import { extractHostState, reloadActivityPageForPresentation } from './session-page.js' -import { shouldRefreshLoggedInPresentation } from './session-state.js' - -type BrowserPageLike = { - evaluate: (callback: any) => Promise - waitForTimeout: (milliseconds: number) => Promise -} - -type HostState = { - loginText?: string - unloginVisible?: boolean - loginedVisible?: boolean -} - -export type ActivityInfo = { - nickname: string - role: { - ready: boolean - roleName: string - roleId: string - area: string - partition: string - platId: string - md5str: string - checkparam: string - } - form: { - cdkeyInputId: string - cdkeyValue: string - verifyInputId: string - verifyValue: string - verifyImgId: string - submitId: string - } - verify: { - visible: boolean - src: string - naturalWidth: number - naturalHeight: number - } - popup: { - visible: boolean - text: string - detail: string - } -} - -type SessionForPresentation = { - page: BrowserPageLike - presentationSyncAttempts: number -} - -type EnsureLoggedInPresentationOptions = { - hostState?: HostState | null - credentialReady?: boolean - activityUrl?: string - extractHostState?: (page: BrowserPageLike) => Promise - reloadActivityPageForPresentation?: (page: BrowserPageLike, activityUrl: string) => Promise - shouldRefreshLoggedInPresentation?: (hostState: HostState) => boolean -} - -type EnsureActivityInfoReadyOptions = { - triggerRender?: boolean - timeoutMs?: number - extractActivityInfo?: (page: BrowserPageLike) => Promise - triggerActivityRoleRender?: (page: BrowserPageLike) => Promise - now?: () => number -} - -declare global { - interface Window { - renderCdkey?: () => void - roleData?: Record - isRole?: unknown - } -} - -export function createEmptyActivityInfo(): ActivityInfo { - return { - nickname: '', - role: { - ready: false, - roleName: '', - roleId: '', - area: '', - partition: '', - platId: '', - md5str: '', - checkparam: '', - }, - form: { - cdkeyInputId: '', - cdkeyValue: '', - verifyInputId: '', - verifyValue: '', - verifyImgId: '', - submitId: '', - }, - verify: { - visible: false, - src: '', - naturalWidth: 0, - naturalHeight: 0, - }, - popup: { - visible: false, - text: '', - detail: '', - }, - } -} - -export async function ensureLoggedInPresentation( - session: SessionForPresentation, - { - hostState = null, - credentialReady = false, - activityUrl = '', - extractHostState: extractHostStateImpl = extractHostState, - reloadActivityPageForPresentation: reloadActivityPageForPresentationImpl = reloadActivityPageForPresentation, - shouldRefreshLoggedInPresentation: shouldRefreshLoggedInPresentationImpl = shouldRefreshLoggedInPresentation, - }: EnsureLoggedInPresentationOptions = {}, -): Promise<{ reloaded: boolean; hostState: HostState }> { - let nextHostState = hostState || (await extractHostStateImpl(session.page)) - - if (!credentialReady || !shouldRefreshLoggedInPresentationImpl(nextHostState)) { - return { - reloaded: false, - hostState: nextHostState, - } - } - - if (session.presentationSyncAttempts >= 2) { - return { - reloaded: false, - hostState: nextHostState, - } - } - - session.presentationSyncAttempts += 1 - await reloadActivityPageForPresentationImpl(session.page, activityUrl) - nextHostState = await extractHostStateImpl(session.page) - - return { - reloaded: true, - hostState: nextHostState, - } -} - -export async function ensureActivityInfoReady( - page: BrowserPageLike, - { - triggerRender = false, - timeoutMs = 8_000, - extractActivityInfo: extractActivityInfoImpl = extractActivityInfo, - triggerActivityRoleRender: triggerActivityRoleRenderImpl = triggerActivityRoleRender, - now = () => Date.now(), - }: EnsureActivityInfoReadyOptions = {}, -): Promise { - if (triggerRender) { - await triggerActivityRoleRenderImpl(page) - } - - const startedAt = now() - let lastInfo = await extractActivityInfoImpl(page) - - while (now() - startedAt < timeoutMs) { - if (lastInfo?.role?.ready) { - return lastInfo - } - - await page.waitForTimeout(400) - lastInfo = await extractActivityInfoImpl(page) - } - - return lastInfo -} - -export async function triggerActivityRoleRender(page: BrowserPageLike): Promise { - try { - await page.evaluate(() => { - const maybeRender = window.renderCdkey - - if (typeof maybeRender === 'function') { - maybeRender() - } - }) - } catch { - // ignore render trigger failures; polling will still inspect the page - } -} - -export async function extractActivityInfo(page: BrowserPageLike): Promise { - try { - return await page.evaluate(() => { - const asString = (value: unknown) => (typeof value === 'string' ? value.trim() : '') - const isVisible = (element: Element | null) => { - if (!element) { - return false - } - - const style = window.getComputedStyle(element) - return style.display !== 'none' && style.visibility !== 'hidden' && style.opacity !== '0' - } - - const pick = (selector: string) => document.querySelector(selector) - const pickBest = ( - selector: string, - predicate: ((element: Element) => boolean) | null = null, - ): Element | null => { - const matches = [...document.querySelectorAll(selector)] - const preferred = matches.find((element) => { - if (predicate && !predicate(element)) { - return false - } - - return isVisible(element) - }) - - if (preferred) { - return preferred - } - - if (predicate) { - return matches.find((element) => predicate(element)) || null - } - - return matches[0] || null - } - const textOf = (selector) => asString(pick(selector)?.textContent || '') - const inputOf = (selector) => { - const element = pickBest( - selector, - (node) => node instanceof HTMLInputElement || node instanceof HTMLTextAreaElement, - ) - - if (!(element instanceof HTMLInputElement) && !(element instanceof HTMLTextAreaElement)) { - return '' - } - - return asString(element.value || '') - } - - const roleData = window.roleData && typeof window.roleData === 'object' ? window.roleData : {} - const roleSelector = pick('[id^="milo_role_selector_"]') - const roleNameText = - textOf('#role_name') || - asString(roleSelector instanceof HTMLSelectElement ? roleSelector.selectedOptions?.[0]?.textContent || '' : '') - const verifyImg = pickBest('[id^="milo_verifyImg_"]', (node) => node instanceof HTMLImageElement) - const popup = pick('#pop2') - const cdkeyInput = pickBest( - '[id^="milo_cdkeyInfo_"]', - (node) => - (node instanceof HTMLInputElement || node instanceof HTMLTextAreaElement) && - !node.disabled && - node.type !== 'hidden', - ) - const verifyInput = pickBest( - '[id^="milo_verifyInput_"]', - (node) => - (node instanceof HTMLInputElement || node instanceof HTMLTextAreaElement) && - !node.disabled && - node.type !== 'hidden', - ) - const submitButton = pickBest( - '#milo_cdkey_submit, [id^="milo_cdkey_submit"]', - (node) => node instanceof HTMLElement && !node.hasAttribute('disabled'), - ) - - return { - nickname: textOf('#login_nickname_span'), - role: { - ready: Boolean(window.isRole || roleNameText), - roleName: roleNameText, - roleId: asString(roleData.sRoleId), - area: asString(roleData.sArea || '36'), - partition: asString(roleData.sPartition), - platId: asString(roleData.sPlatId), - md5str: asString(roleData.sMd5str || roleData.md5str || roleData.sMdStr), - checkparam: asString(roleData.sCheckparam), - }, - form: { - cdkeyInputId: cdkeyInput?.id || '', - cdkeyValue: inputOf('[id^="milo_cdkeyInfo_"]'), - verifyInputId: verifyInput?.id || '', - verifyValue: inputOf('[id^="milo_verifyInput_"]'), - verifyImgId: verifyImg?.id || '', - submitId: submitButton?.id || '', - }, - verify: { - visible: isVisible(verifyImg), - src: asString(verifyImg?.getAttribute('src')), - naturalWidth: verifyImg instanceof HTMLImageElement ? Number(verifyImg.naturalWidth || 0) : 0, - naturalHeight: verifyImg instanceof HTMLImageElement ? Number(verifyImg.naturalHeight || 0) : 0, - }, - popup: { - visible: isVisible(popup), - text: textOf('#PopText'), - detail: textOf('#PopText2'), - }, - } - }) - } catch { - return createEmptyActivityInfo() - } -} diff --git a/apps/backend/src/services/session/session-browser-config.ts b/apps/backend/src/services/session/session-browser-config.ts deleted file mode 100644 index 2504431c..00000000 --- a/apps/backend/src/services/session/session-browser-config.ts +++ /dev/null @@ -1,135 +0,0 @@ -import process from 'node:process' - -export type LoginType = 'qq' | 'wx' - -export const DEFAULT_LOGIN_TYPE: LoginType = 'qq' - -type BrowserConfigInput = { - headless?: boolean - devtools?: boolean - keepAlive?: boolean - prewarm?: boolean - slowMoMs?: number -} - -type ResolveBrowserLaunchOptionsInput = { - browserConfig?: BrowserConfigInput - chromePath?: string - nodeEnv?: string - ci?: string -} - -export type BrowserLaunchOptions = { - headless: boolean - devtools: boolean - usesBundledChromium: boolean - keepAlive: boolean - prewarm: boolean - slowMoMs: number -} - -export type ViewportSize = { - width: number - height: number -} - -export function resolveBrowserLaunchOptions(options: ResolveBrowserLaunchOptionsInput = {}): BrowserLaunchOptions { - const browserConfig = options.browserConfig || {} - const chromePath = options.chromePath || '' - const nodeEnv = options.nodeEnv ?? process.env.NODE_ENV - const ci = options.ci ?? process.env.CI - const explicitHeadless = browserConfig.headless - const explicitDevtools = browserConfig.devtools - const explicitKeepAlive = browserConfig.keepAlive - const explicitPrewarm = browserConfig.prewarm - const explicitSlowMo = browserConfig.slowMoMs - const isProductionLike = nodeEnv === 'production' || ci === '1' - - const headless = typeof explicitHeadless === 'boolean' ? explicitHeadless : isProductionLike - - const devtools = typeof explicitDevtools === 'boolean' ? explicitDevtools : !headless - - const slowMoMs = Number.isFinite(explicitSlowMo) - ? Math.max(0, Math.min(explicitSlowMo, 2_000)) - : headless - ? 0 - : 150 - - const keepAlive = typeof explicitKeepAlive === 'boolean' ? explicitKeepAlive : !isProductionLike - const prewarm = typeof explicitPrewarm === 'boolean' ? explicitPrewarm : keepAlive - - return { - headless, - devtools, - usesBundledChromium: !chromePath, - keepAlive, - prewarm, - slowMoMs, - } -} - -export function buildChromiumLaunchOptions( - launchOptions: BrowserLaunchOptions, - { chromePath = '' }: { chromePath?: string } = {}, -) { - const options = { - headless: launchOptions.headless, - devtools: launchOptions.devtools, - slowMo: launchOptions.slowMoMs, - } - - if (chromePath) { - return { - ...options, - executablePath: chromePath, - } - } - - return options -} - -export function resolveDefaultViewport(rawValue?: unknown): ViewportSize { - const fromVncResolution = parseViewportSpec(rawValue) - if (fromVncResolution) { - return fromVncResolution - } - - return { width: 1600, height: 900 } -} - -export function parseViewportSpec(rawValue: unknown): ViewportSize | null { - const text = String(rawValue || '').trim() - - if (!text) { - return null - } - - const match = text.match(/^(\d+)x(\d+)(?:x\d+)?$/i) - if (!match) { - return null - } - - const width = Number(match[1]) - const height = Number(match[2]) - - if (!Number.isFinite(width) || !Number.isFinite(height) || width < 800 || height < 600) { - return null - } - - return { width, height } -} - -export function normalizeLoginType(value: unknown, fallback: LoginType = DEFAULT_LOGIN_TYPE): LoginType { - const normalized = String(value || fallback).trim().toLowerCase() - if (['qq', 'qc'].includes(normalized)) { - return 'qq' - } - if (['wx', 'vx', 'wechat', 'weixin'].includes(normalized)) { - return 'wx' - } - return fallback -} - -export function getLoginTypeLabel(loginType: LoginType | string): string { - return loginType === 'wx' ? '微信' : 'QQ' -} diff --git a/apps/backend/src/services/session/session-cookie-map.ts b/apps/backend/src/services/session/session-cookie-map.ts deleted file mode 100644 index f39da4c8..00000000 --- a/apps/backend/src/services/session/session-cookie-map.ts +++ /dev/null @@ -1,49 +0,0 @@ -const TENCENT_COOKIE_URLS = [ - 'https://df.qq.com', - 'https://ssl.captcha.qq.com', - 'https://graph.qq.com', - 'https://milo.qq.com', -] - -type BrowserCookie = { - name?: string - value?: string -} - -type BrowserContextLike = { - cookies: (urls?: string[]) => Promise -} - -export type TencentCookieMap = Record - -export function normalizeTencentCookieMap(cookies: BrowserCookie[] = []): TencentCookieMap { - const cookieMap: TencentCookieMap = {} - - for (const cookie of cookies) { - if (!cookie?.name) { - continue - } - - cookieMap[String(cookie.name)] = String(cookie.value || '') - } - - if (!cookieMap.acctype) { - cookieMap.acctype = 'qc' - } - - if (!cookieMap.appid) { - cookieMap.appid = '101491592' - } - - if (cookieMap.openid && cookieMap.access_token && !cookieMap.iegams_milo_proxylogin_qc) { - cookieMap.iegams_milo_proxylogin_qc = - `${cookieMap.appid}_$$_${cookieMap.openid}_$$_${cookieMap.access_token}` - } - - return cookieMap -} - -export async function loadCookieMapFromContext(browserContext: BrowserContextLike): Promise { - const cookies = await browserContext.cookies(TENCENT_COOKIE_URLS) - return normalizeTencentCookieMap(cookies) -} diff --git a/apps/backend/src/services/session/session-login-shared.ts b/apps/backend/src/services/session/session-login-shared.ts deleted file mode 100644 index d59ca960..00000000 --- a/apps/backend/src/services/session/session-login-shared.ts +++ /dev/null @@ -1,113 +0,0 @@ -import { runtimeConfig } from '../../config/runtime.js' -import { logDebug } from '../../utils/logger.js' - -const SESSION_DEBUG_ENABLED = Boolean(runtimeConfig.session.debug) - -type FrameLike = unknown - -type PageWithFrames = { - frames: () => FrameLike[] - waitForTimeout: (milliseconds: number) => Promise -} - -type QrDownloadResponse = { - ok: () => boolean - status: () => number - body: () => Promise -} - -type PageWithRequestContext = { - context: () => { - request: { - get: (url: string, options?: { - failOnStatusCode?: boolean - headers?: Record - }) => Promise - } - } -} - -export function logBrowserSessionDebug(scope: string, detail?: unknown): void { - if (!SESSION_DEBUG_ENABLED) { - return - } - - logDebug('[browser/session]', scope, detail) -} - -export async function waitForFrame( - page: PageWithFrames, - predicate: (frame: TFrame) => boolean, - timeoutMs = 15_000, -): Promise { - const startedAt = Date.now() - - while (Date.now() - startedAt < timeoutMs) { - const matched = page.frames().find((frame): frame is TFrame => { - try { - return predicate(frame as TFrame) - } catch { - return false - } - }) - - if (matched) { - return matched - } - - await page.waitForTimeout(300) - } - - throw new Error('未找到登录二维码 frame') -} - -export async function downloadRemoteQrImage( - page: PageWithRequestContext, - qrUrl: string, - qrImagePath: string, - referer = '', - loginType = 'unknown', -): Promise { - try { - const response = await page.context().request.get(qrUrl, { - failOnStatusCode: false, - headers: { - referer, - }, - }) - - if (!response.ok()) { - logBrowserSessionDebug(`${loginType}.capture.downloadNotOk`, { - qrUrl, - status: response.status(), - }) - return false - } - - const body = await response.body() - - if (!body?.length) { - return false - } - - return body - } catch (error) { - logBrowserSessionDebug(`${loginType}.capture.downloadError`, { - qrUrl, - message: error instanceof Error ? error.message : String(error), - }) - return null - } -} - -export function isRetryableQrCaptureError(error: unknown): boolean { - const message = error instanceof Error ? error.message : String(error || '') - - return ( - message.includes('Frame was detached') || - message.includes('Execution context was destroyed') || - message.includes('Target page, context or browser has been closed') || - message.includes('waiting for') || - message.includes('Timeout') - ) -} diff --git a/apps/backend/src/services/session/session-page.ts b/apps/backend/src/services/session/session-page.ts deleted file mode 100644 index bc423f5b..00000000 --- a/apps/backend/src/services/session/session-page.ts +++ /dev/null @@ -1,244 +0,0 @@ -/// - -import fs from 'node:fs/promises' -import path from 'node:path' - -import { isRetryableQrCaptureError, logBrowserSessionDebug } from './session-login-shared.js' -import { captureQqQrImage, ensureQqLoginReady, extractQqQrState } from './session-qq.js' -import { captureWxQrImage, ensureWxLoginReady, extractWxQrState } from './session-wx.js' - -type LoginType = 'qq' | 'wx' | string - -type LocatorLike = { - count: () => Promise - first: () => LocatorLike - click: (options?: { timeout?: number }) => Promise - waitFor: (options?: { state?: string; timeout?: number }) => Promise - screenshot: (options: { path: string }) => Promise -} - -type BrowserPageLike = { - frames: () => any[] - locator: (selector: string) => LocatorLike - waitForTimeout: (milliseconds: number) => Promise - evaluate: (callback: any) => Promise - reload: (options?: { waitUntil?: string }) => Promise - goto: (url: string, options?: { waitUntil?: string }) => Promise - context: () => any -} - -type SessionForQrCapture = { - page: BrowserPageLike - loginType: LoginType - sessionDir: string - qrImagePath?: string - qrImageBase64?: string - qrUpdatedAt?: string - updatedAt?: string -} - -type CaptureSessionQrOptions = { - ensureLoginReady?: boolean - ensureLoginTab?: typeof ensureLoginTab - captureQrImageWithRetry?: typeof captureQrImageWithRetry - readFile?: (path: string, encoding: BufferEncoding) => Promise - now?: () => string -} - -type CaptureQrImageWithRetryOptions = { - captureQqQrImage?: (page: BrowserPageLike, qrImagePath: string) => Promise - captureWxQrImage?: (page: BrowserPageLike, qrImagePath: string) => Promise - ensureLoginTab?: typeof ensureLoginTab - isRetryableQrCaptureError?: (error: unknown) => boolean - logBrowserSessionDebug?: (scope: string, detail?: unknown) => void -} - -type EnsureLoginTabOptions = { - ensureQqLoginReady?: (page: BrowserPageLike) => Promise - ensureWxLoginReady?: (page: BrowserPageLike) => Promise -} - -type ExtractQrStateOptions = { - extractQqQrState?: (page: BrowserPageLike) => Promise - extractWxQrState?: (page: BrowserPageLike) => Promise -} - -export async function captureSessionQr( - session: SessionForQrCapture, - { - ensureLoginReady = true, - ensureLoginTab: ensureLoginTabImpl = ensureLoginTab, - captureQrImageWithRetry: captureQrImageWithRetryImpl = captureQrImageWithRetry, - readFile = fs.readFile, - now = () => new Date().toISOString(), - }: CaptureSessionQrOptions = {}, -): Promise { - if (ensureLoginReady) { - await ensureLoginTabImpl(session.page, session.loginType) - } - - const qrImagePath = path.join(session.sessionDir, `${session.loginType}-qr.png`) - await captureQrImageWithRetryImpl(session.page, session.loginType, qrImagePath, { - ensureLoginTab: ensureLoginTabImpl, - }) - - session.qrImagePath = qrImagePath - session.qrImageBase64 = await readFile(qrImagePath, 'base64') - session.qrUpdatedAt = now() - session.updatedAt = session.qrUpdatedAt -} - -export async function captureQrImageWithRetry( - page: BrowserPageLike, - loginType: LoginType, - qrImagePath: string, - { - captureQqQrImage: captureQqQrImageImpl = captureQqQrImage, - captureWxQrImage: captureWxQrImageImpl = captureWxQrImage, - ensureLoginTab: ensureLoginTabImpl = ensureLoginTab, - isRetryableQrCaptureError: isRetryableQrCaptureErrorImpl = isRetryableQrCaptureError, - logBrowserSessionDebug: logBrowserSessionDebugImpl = logBrowserSessionDebug, - }: CaptureQrImageWithRetryOptions = {}, -): Promise { - const maxAttempts = loginType === 'qq' ? 4 : 3 - let lastError: unknown = null - - for (let attempt = 1; attempt <= maxAttempts; attempt += 1) { - try { - logBrowserSessionDebugImpl('captureQrImageWithRetry.start', { - loginType, - attempt, - maxAttempts, - qrImagePath, - }) - - if (loginType === 'qq') { - await captureQqQrImageImpl(page, qrImagePath) - } else { - await captureWxQrImageImpl(page, qrImagePath) - } - - logBrowserSessionDebugImpl('captureQrImageWithRetry.success', { - loginType, - attempt, - qrImagePath, - }) - - return - } catch (error) { - lastError = error - logBrowserSessionDebugImpl('captureQrImageWithRetry.error', { - loginType, - attempt, - message: error instanceof Error ? error.message : String(error), - }) - - if (!isRetryableQrCaptureErrorImpl(error) || attempt === maxAttempts) { - throw error - } - - await page.waitForTimeout(500) - await ensureLoginTabImpl(page, loginType) - } - } - - if (lastError) { - throw lastError - } - - throw new Error('二维码截图失败') -} - -export async function ensureLoginTab( - page: BrowserPageLike, - loginType: LoginType, - { - ensureQqLoginReady: ensureQqLoginReadyImpl = ensureQqLoginReady, - ensureWxLoginReady: ensureWxLoginReadyImpl = ensureWxLoginReady, - }: EnsureLoginTabOptions = {}, -): Promise { - const tabSelector = loginType === 'wx' ? '.wx-tab' : '.qc-tab' - const loginButton = page.locator('#unlogin') - let interacted = false - - if (await loginButton.count()) { - try { - await loginButton.first().click({ timeout: 2_000 }) - interacted = true - } catch { - // ignore when the login layer is already open - } - } - - const tab = page.locator(tabSelector).first() - - if (await tab.count()) { - try { - await tab.click({ timeout: 2_000 }) - interacted = true - } catch { - // ignore tab click failures - } - } - - if (interacted) { - await page.waitForTimeout(250) - } - - if (loginType === 'qq') { - await ensureQqLoginReadyImpl(page) - return - } - - if (loginType === 'wx') { - await ensureWxLoginReadyImpl(page) - } -} - -export async function extractHostState(page: BrowserPageLike) { - return page.evaluate(() => { - const isVisible = (element: Element | null) => { - if (!element) { - return false - } - - const style = window.getComputedStyle(element) - - return style.display !== 'none' && style.visibility !== 'hidden' && style.opacity !== '0' - } - - const unlogin = document.querySelector('#unlogin') - const logined = document.querySelector('#logined') - - return { - unloginVisible: isVisible(unlogin), - loginedVisible: isVisible(logined), - loginText: String(logined?.textContent || '').trim(), - } - }) -} - -export async function reloadActivityPageForPresentation(page: BrowserPageLike, activityUrl: string): Promise { - try { - await page.reload({ waitUntil: 'domcontentloaded' }) - } catch { - await page.goto(activityUrl, { waitUntil: 'domcontentloaded' }) - } - - await page.waitForTimeout(2_500) -} - -export async function extractQrState( - page: BrowserPageLike, - loginType: LoginType, - { - extractQqQrState: extractQqQrStateImpl = extractQqQrState, - extractWxQrState: extractWxQrStateImpl = extractWxQrState, - }: ExtractQrStateOptions = {}, -): Promise { - if (loginType === 'qq') { - return extractQqQrStateImpl(page) - } - - return extractWxQrStateImpl(page) -} diff --git a/apps/backend/src/services/session/session-payload.ts b/apps/backend/src/services/session/session-payload.ts deleted file mode 100644 index 595e09ad..00000000 --- a/apps/backend/src/services/session/session-payload.ts +++ /dev/null @@ -1,190 +0,0 @@ -export function buildSessionPayload( - session: TencentBrowserSessionPayloadSource, - { - activityUrl = '', - includeQrImage = true, - sessionDebugEnabled = false, - browserDebug = null, - }: BuildSessionPayloadOptions = {}, -): any { - const activityInfo = session.lastState?.activityInfo || null - const payload: Record = { - sessionId: session.sessionId, - loginType: session.loginType, - activityUrl, - status: session.status, - notice: session.notice, - createdAt: session.createdAt, - updatedAt: session.updatedAt, - expiresAt: new Date(session.expiresAt).toISOString(), - qrUpdatedAt: session.qrUpdatedAt, - credentialReady: Boolean(session.lastState?.credentialReady), - activityInfo, - review: buildReviewPayload(session.lastReview), - redeem: buildRedeemPayload(session.lastRedeem), - artifacts: buildArtifactsPayload(session), - } - - if (includeQrImage) { - payload.qrImageBase64 = session.qrImageBase64 - } - - if (!sessionDebugEnabled) { - return payload - } - - return { - ...payload, - cookieKeys: Array.isArray(session.lastState?.cookieKeys) ? session.lastState.cookieKeys : [], - state: session.lastState, - browserDebug, - qrImagePath: session.qrImagePath, - review: buildReviewPayload(session.lastReview, { includeInternalPaths: true }), - redeem: buildRedeemPayload(session.lastRedeem, { includeInternalPaths: true }), - artifacts: buildArtifactsPayload(session, { includeInternalPaths: true }), - } -} - -type BuildSessionPayloadOptions = { - activityUrl?: string - includeQrImage?: boolean - sessionDebugEnabled?: boolean - browserDebug?: unknown -} - -type InternalPathOptions = { - includeInternalPaths?: boolean -} - -type TencentBrowserSessionPayloadSource = { - sessionId: string - loginType?: string - status?: string - notice?: string - createdAt?: string - updatedAt?: string - expiresAt: string | number | Date - qrUpdatedAt?: string - qrImageBase64?: string - qrImagePath?: string - sessionDir?: string - lastState?: { - credentialReady?: boolean - cookieKeys?: string[] - activityInfo?: any - [key: string]: unknown - } | null - lastReview?: ReviewPayloadSource | null - lastRedeem?: RedeemPayloadSource | null -} - -type ReviewPayloadSource = { - capturedAt?: string - roleId?: string - roleName?: string - screenshotPath?: string - signature?: string -} - -type RedeemAttemptPayloadSource = { - attempt?: number - redeem?: unknown -} - -type RedeemPayloadSource = { - code?: string - area?: string - proofMode?: string - attempts?: RedeemAttemptPayloadSource[] - final?: RedeemAttemptPayloadSource | null - finishedAt?: string - screenshotPath?: string - htmlPath?: string - resultPath?: string -} - -export function buildReviewPayload(review: ReviewPayloadSource | null | undefined, { includeInternalPaths = false }: InternalPathOptions = {}) { - if (!review) { - return null - } - - const payload = { - capturedAt: review.capturedAt, - roleId: review.roleId || '', - roleName: review.roleName || '', - } - - if (!includeInternalPaths) { - return payload - } - - return { - ...payload, - screenshotPath: review.screenshotPath || '', - signature: review.signature || '', - } -} - -export function buildRedeemPayload(redeem: RedeemPayloadSource | null | undefined, { includeInternalPaths = false }: InternalPathOptions = {}) { - if (!redeem) { - return null - } - - const payload = { - code: redeem.code, - area: redeem.area, - proofMode: redeem.proofMode || 'full', - attempts: Array.isArray(redeem.attempts) - ? redeem.attempts.map((attempt) => ({ - attempt: attempt.attempt, - redeem: attempt.redeem || null, - })) - : [], - final: redeem.final - ? { - attempt: redeem.final.attempt, - redeem: redeem.final.redeem || null, - } - : null, - finishedAt: redeem.finishedAt, - } - - if (!includeInternalPaths) { - return payload - } - - return { - ...payload, - screenshotPath: redeem.screenshotPath || '', - htmlPath: redeem.htmlPath || '', - resultPath: redeem.resultPath || '', - } -} - -export function buildArtifactsPayload( - session: Pick< - TencentBrowserSessionPayloadSource, - 'sessionDir' | 'qrImagePath' | 'qrImageBase64' | 'lastReview' | 'lastRedeem' - >, - { includeInternalPaths = false }: InternalPathOptions = {}, -) { - const payload = { - hasQrImage: Boolean(session.qrImageBase64), - hasReviewScreenshot: Boolean(session.lastReview?.screenshotPath), - hasScreenshot: Boolean(session.lastRedeem?.screenshotPath), - } - - if (!includeInternalPaths) { - return payload - } - - return { - ...payload, - sessionDir: session.sessionDir, - qrImagePath: session.qrImagePath, - screenshotPath: session.lastRedeem?.screenshotPath || '', - htmlPath: session.lastRedeem?.htmlPath || '', - resultPath: session.lastRedeem?.resultPath || '', - reviewScreenshotPath: session.lastReview?.screenshotPath || '', - } -} diff --git a/apps/backend/src/services/session/session-proof-beijing-time.ts b/apps/backend/src/services/session/session-proof-beijing-time.ts deleted file mode 100644 index 4805a51b..00000000 --- a/apps/backend/src/services/session/session-proof-beijing-time.ts +++ /dev/null @@ -1,55 +0,0 @@ -/// - -import { - BAIDU_BEIJING_TIME_URL, - BEIJING_TIME_PROOF_CLIP, - BEIJING_TIME_PROOF_VIEWPORT, -} from './session-proof-constants.js' - -type BrowserPageLike = { - setViewportSize: (viewport: { width: number; height: number }) => Promise - goto: (url: string, options?: { waitUntil?: string }) => Promise - waitForTimeout: (milliseconds: number) => Promise - evaluate: (callback: () => unknown) => Promise - screenshot: (options: { - path: string - clip: { x: number; y: number; width: number; height: number } - }) => Promise - close: () => Promise -} - -type BrowserContextLike = { - newPage: () => Promise -} - -export type BeijingTimeProof = { - screenshotPath: string - pageUrl: string -} - -export async function captureBeijingTimeProof( - browserContext: BrowserContextLike, - { screenshotPath }: { screenshotPath: string }, -): Promise { - const proofPage = await browserContext.newPage() - - try { - await proofPage.setViewportSize(BEIJING_TIME_PROOF_VIEWPORT) - await proofPage.goto(BAIDU_BEIJING_TIME_URL, { waitUntil: 'domcontentloaded' }) - await proofPage.waitForTimeout(3_000) - await proofPage.evaluate(() => { - window.scrollTo(0, 0) - }) - await proofPage.screenshot({ - path: screenshotPath, - clip: BEIJING_TIME_PROOF_CLIP, - }) - - return { - screenshotPath, - pageUrl: BAIDU_BEIJING_TIME_URL, - } - } finally { - await proofPage.close().catch(() => null) - } -} diff --git a/apps/backend/src/services/session/session-proof-constants.ts b/apps/backend/src/services/session/session-proof-constants.ts deleted file mode 100644 index 0b80f9f0..00000000 --- a/apps/backend/src/services/session/session-proof-constants.ts +++ /dev/null @@ -1,11 +0,0 @@ -export const BAIDU_BEIJING_TIME_URL = 'https://www.baidu.com/s?ie=utf-8&f=3&rsv_bp=1&rsv_idx=1&tn=baidu&wd=%E5%8C%97%E4%BA%AC%E6%97%B6%E9%97%B4&fenlei=256&rsv_pq=0xa7d529b30000a6eb&rsv_t=2d404COdLspTa8jnKQl%2FyCU%2BSCmRyb%2BvSqxxjk%2B7E1scokLYTuDbehaQ0Dvq&rqlang=en&rsv_enter=1&rsv_dl=ih_0&rsv_sug3=1&rsv_sug1=1&rsv_sug7=001&rsv_sug2=1&rsv_btype=i&rsp=0&rsv_sug9=es_2_1&rsv_sug=1' -export type RedeemProofMode = 'full' | 'basic' | 'off' - -export const BEIJING_TIME_PROOF_VIEWPORT = { width: 1440, height: 860 } -export const BEIJING_TIME_PROOF_CLIP = { - x: 0, - y: 0, - width: BEIJING_TIME_PROOF_VIEWPORT.width, - height: 620, -} -export const DEFAULT_REDEEM_PROOF_MODE: RedeemProofMode = 'full' diff --git a/apps/backend/src/services/session/session-proof-html.ts b/apps/backend/src/services/session/session-proof-html.ts deleted file mode 100644 index 31f5d32b..00000000 --- a/apps/backend/src/services/session/session-proof-html.ts +++ /dev/null @@ -1,180 +0,0 @@ -export type ProofHtmlInput = { - redeemImageBase64: string - timeImageBase64: string - redeemMessage?: string - timePageUrl: string -} - -export function buildProofHtml({ - redeemImageBase64, - timeImageBase64, - redeemMessage, - timePageUrl, -}: ProofHtmlInput): string { - const capturedAt = new Date().toLocaleString('zh-CN', { - hour12: false, - timeZone: 'Asia/Shanghai', - }) - - return ` - - - - - 兑换与北京时间凭证 - - - -
-
-
-

Tencent Redeem Proof

-

兑换截图与北京时间截图

-

用于留存兑换结果与北京时间检索页的组合凭证。

-
-
- Captured At - ${escapeHtml(capturedAt)} -
-
-
-

兑换结果截图

-

${escapeHtml(redeemMessage || '兑换完成')}

-
- 兑换结果截图 -
-
-
-

百度检索“北京时间”截图

-

单独标签页打开百度搜索结果并截图。

-
${escapeHtml(timePageUrl)}
-
- 北京时间截图 -
-
-
- -` -} - -export function escapeHtml(value: unknown): string { - return String(value || '') - .replaceAll('&', '&') - .replaceAll('<', '<') - .replaceAll('>', '>') - .replaceAll('"', '"') -} diff --git a/apps/backend/src/services/session/session-proof-mode.ts b/apps/backend/src/services/session/session-proof-mode.ts deleted file mode 100644 index 790ef3a6..00000000 --- a/apps/backend/src/services/session/session-proof-mode.ts +++ /dev/null @@ -1,38 +0,0 @@ -import { runtimeConfig } from '../../config/runtime.js' - -import { DEFAULT_REDEEM_PROOF_MODE, type RedeemProofMode } from './session-proof-constants.js' - -type RedeemResultForProof = { - classification?: { - success?: boolean - } - redeem?: { - iRet?: number | string - ret?: number | string - } -} | null | undefined - -export function resolveRedeemProofMode(rawMode: unknown = runtimeConfig.redeem.proofMode): RedeemProofMode { - const normalized = String(rawMode || '') - .trim() - .toLowerCase() - - if (normalized === 'basic' || normalized === 'off') { - return normalized - } - - return DEFAULT_REDEEM_PROOF_MODE -} - -export function shouldCaptureBeijingTimeProof(finalResult: RedeemResultForProof): boolean { - if (finalResult?.classification?.success === true) { - return true - } - - if (finalResult?.classification?.success === false) { - return false - } - - const retCode = Number(finalResult?.redeem?.iRet ?? finalResult?.redeem?.ret) - return Number.isFinite(retCode) && retCode === 0 -} diff --git a/apps/backend/src/services/session/session-proof-paths.ts b/apps/backend/src/services/session/session-proof-paths.ts deleted file mode 100644 index e8202379..00000000 --- a/apps/backend/src/services/session/session-proof-paths.ts +++ /dev/null @@ -1,19 +0,0 @@ -import path from 'node:path' - -export type RedeemArtifactPaths = { - redeemPageScreenshotPath: string - beijingTimeScreenshotPath: string - screenshotPath: string - htmlPath: string - resultPath: string -} - -export function buildArtifactPaths(outputDir: string): RedeemArtifactPaths { - return { - redeemPageScreenshotPath: path.join(outputDir, 'redeem-page.png'), - beijingTimeScreenshotPath: path.join(outputDir, 'beijing-time.png'), - screenshotPath: path.join(outputDir, 'redeem-result.png'), - htmlPath: path.join(outputDir, 'page.html'), - resultPath: path.join(outputDir, 'result.json'), - } -} diff --git a/apps/backend/src/services/session/session-proof-renderer.ts b/apps/backend/src/services/session/session-proof-renderer.ts deleted file mode 100644 index 891d5735..00000000 --- a/apps/backend/src/services/session/session-proof-renderer.ts +++ /dev/null @@ -1,131 +0,0 @@ -/// - -import fs from 'node:fs/promises' - -import { buildProofHtml } from './session-proof-html.js' - -type BrowserPageLike = { - evaluate: (callback: any, arg?: unknown) => Promise - waitForTimeout: (milliseconds: number) => Promise - setViewportSize: (viewport: { width: number; height: number }) => Promise - setContent: (html: string, options?: { waitUntil?: string }) => Promise - screenshot: (options: { path: string; fullPage?: boolean }) => Promise - close: () => Promise -} - -type BrowserContextLike = { - newPage: () => Promise -} - -type ComposeProofScreenshotOptions = { - outputPath: string - redeemImagePath: string - timeImagePath: string - redeemMessage?: string - timePageUrl: string -} - -export async function showResultDialog(page: BrowserPageLike, message: string): Promise { - await page.evaluate((text) => { - document.querySelectorAll('iframe').forEach((element) => element.remove()) - document.querySelectorAll('.pop').forEach((element) => { - if (element instanceof HTMLElement) { - element.style.display = 'none' - } - }) - - let card = document.getElementById('codex-redeem-result') - - if (!card) { - card = document.createElement('div') - card.id = 'codex-redeem-result' - card.innerHTML = ` -
Tencent Browser Session
-
兑换结果
-
- ` - document.body.appendChild(card) - } - - const messageNode = card.querySelector('.codex-redeem-result__message') - - if (messageNode) { - messageNode.textContent = text - } - - Object.assign(card.style, { - position: 'fixed', - left: '50%', - top: '50%', - transform: 'translate(-50%, -50%)', - zIndex: '99999', - width: 'min(560px, calc(100vw - 80px))', - padding: '32px 36px', - borderRadius: '24px', - background: 'rgba(10, 18, 28, 0.92)', - boxShadow: '0 24px 80px rgba(0, 0, 0, 0.35)', - border: '1px solid rgba(109, 241, 202, 0.25)', - color: '#f4fbff', - fontFamily: '"PingFang SC", "Microsoft YaHei", sans-serif', - }) - - const styleId = 'codex-redeem-result-style' - - if (!document.getElementById(styleId)) { - const style = document.createElement('style') - style.id = styleId - style.textContent = ` - #codex-redeem-result .codex-redeem-result__eyebrow { - font-size: 12px; - letter-spacing: 0.18em; - text-transform: uppercase; - color: #6df1ca; - margin-bottom: 12px; - } - #codex-redeem-result .codex-redeem-result__title { - font-size: 34px; - font-weight: 700; - margin-bottom: 16px; - } - #codex-redeem-result .codex-redeem-result__message { - font-size: 24px; - line-height: 1.5; - color: #ffffff; - } - ` - document.head.appendChild(style) - } - - window.scrollTo(0, 0) - }, message) - - await page.waitForTimeout(600) -} - -export async function composeProofScreenshot( - browserContext: BrowserContextLike, - { outputPath, redeemImagePath, timeImagePath, redeemMessage, timePageUrl }: ComposeProofScreenshotOptions, -): Promise { - const [redeemImageBase64, timeImageBase64] = await Promise.all([ - fs.readFile(redeemImagePath, 'base64'), - fs.readFile(timeImagePath, 'base64'), - ]) - - const composePage = await browserContext.newPage() - - try { - await composePage.setViewportSize({ width: 1520, height: 1900 }) - await composePage.setContent( - buildProofHtml({ - redeemImageBase64, - timeImageBase64, - redeemMessage, - timePageUrl, - }), - { waitUntil: 'domcontentloaded' }, - ) - await composePage.screenshot({ path: outputPath, fullPage: true }) - } finally { - await composePage.close().catch(() => null) - } -} diff --git a/apps/backend/src/services/session/session-proof-result-writer.ts b/apps/backend/src/services/session/session-proof-result-writer.ts deleted file mode 100644 index 2910933e..00000000 --- a/apps/backend/src/services/session/session-proof-result-writer.ts +++ /dev/null @@ -1,65 +0,0 @@ -import fs from 'node:fs/promises' - -import { BAIDU_BEIJING_TIME_URL, type RedeemProofMode } from './session-proof-constants.js' - -type RedeemResultFilePayload = { - proofMode: RedeemProofMode - sessionId?: string - activityUrl: string - code: string - finalResult?: { - role?: { - area?: string - } - [key: string]: unknown - } | null - attempts?: unknown[] - screenshotPath: string - htmlPath?: string - redeemPageScreenshotPath?: string - beijingTimeScreenshotPath?: string - beijingTimePageUrl?: string - beijingTimeError?: string -} - -export async function writeRedeemResultFile( - resultPath: string, - { - proofMode, - sessionId, - activityUrl, - code, - finalResult, - attempts, - screenshotPath, - htmlPath = '', - redeemPageScreenshotPath = '', - beijingTimeScreenshotPath = '', - beijingTimePageUrl = BAIDU_BEIJING_TIME_URL, - beijingTimeError = '', - }: RedeemResultFilePayload, -): Promise { - await fs.writeFile( - resultPath, - JSON.stringify( - { - proofMode, - sessionId, - activityUrl, - code, - area: finalResult?.role?.area || '', - final: finalResult, - attempts, - redeemPageScreenshotPath, - beijingTimeScreenshotPath, - beijingTimePageUrl, - beijingTimeError, - screenshotPath, - htmlPath, - }, - null, - 2, - ), - 'utf8', - ) -} diff --git a/apps/backend/src/services/session/session-proof.test.ts b/apps/backend/src/services/session/session-proof.test.ts deleted file mode 100644 index ff4ef220..00000000 --- a/apps/backend/src/services/session/session-proof.test.ts +++ /dev/null @@ -1,81 +0,0 @@ -import test from 'node:test' -import assert from 'node:assert/strict' -import path from 'node:path' - -import { resolveRedeemProofMode, shouldCaptureBeijingTimeProof } from './session-proof.js' -import { buildProofHtml } from './session-proof-html.js' -import { buildArtifactPaths } from './session-proof-paths.js' - -test('resolveRedeemProofMode normalizes configured modes and falls back to full', () => { - assert.equal(resolveRedeemProofMode('basic'), 'basic') - assert.equal(resolveRedeemProofMode(' OFF '), 'off') - assert.equal(resolveRedeemProofMode('full'), 'full') - assert.equal(resolveRedeemProofMode('unexpected'), 'full') - assert.equal(resolveRedeemProofMode(''), 'full') -}) - -test('shouldCaptureBeijingTimeProof only returns true for successful redeem results', () => { - assert.equal( - shouldCaptureBeijingTimeProof({ - classification: { - success: true, - }, - }), - true, - ) - - assert.equal( - shouldCaptureBeijingTimeProof({ - classification: { - success: false, - }, - }), - false, - ) - - assert.equal( - shouldCaptureBeijingTimeProof({ - redeem: { - iRet: 0, - }, - }), - true, - ) - - assert.equal( - shouldCaptureBeijingTimeProof({ - redeem: { - iRet: -183, - }, - }), - false, - ) - - assert.equal(shouldCaptureBeijingTimeProof(null), false) -}) - -test('buildArtifactPaths keeps redeem proof artifact filenames stable', () => { - const artifactPaths = buildArtifactPaths('/tmp/redeem-proof') - - assert.equal(artifactPaths.redeemPageScreenshotPath, path.join('/tmp/redeem-proof', 'redeem-page.png')) - assert.equal(artifactPaths.beijingTimeScreenshotPath, path.join('/tmp/redeem-proof', 'beijing-time.png')) - assert.equal(artifactPaths.screenshotPath, path.join('/tmp/redeem-proof', 'redeem-result.png')) - assert.equal(artifactPaths.htmlPath, path.join('/tmp/redeem-proof', 'page.html')) - assert.equal(artifactPaths.resultPath, path.join('/tmp/redeem-proof', 'result.json')) -}) - -test('buildProofHtml escapes injected text while keeping proof sections stable', () => { - const html = buildProofHtml({ - redeemImageBase64: 'redeem-base64', - timeImageBase64: 'time-base64', - redeemMessage: '兑换成功 & 已到账', - timePageUrl: 'https://example.com/?q=<北京时间>', - }) - - assert.match(html, /Tencent Redeem Proof/) - assert.match(html, /兑换截图与北京时间截图/) - assert.match(html, /<b>兑换成功 & 已到账<\/b>/) - assert.match(html, /https:\/\/example\.com\/\?q=<北京时间>/) - assert.match(html, /data:image\/png;base64,redeem-base64/) - assert.match(html, /data:image\/png;base64,time-base64/) -}) diff --git a/apps/backend/src/services/session/session-proof.ts b/apps/backend/src/services/session/session-proof.ts deleted file mode 100644 index c132ce44..00000000 --- a/apps/backend/src/services/session/session-proof.ts +++ /dev/null @@ -1,205 +0,0 @@ -import fs from 'node:fs/promises' - -import { - BAIDU_BEIJING_TIME_URL, - DEFAULT_REDEEM_PROOF_MODE, - type RedeemProofMode, -} from './session-proof-constants.js' -import { captureBeijingTimeProof } from './session-proof-beijing-time.js' -import { showResultDialog, composeProofScreenshot } from './session-proof-renderer.js' -import { resolveRedeemProofMode, shouldCaptureBeijingTimeProof } from './session-proof-mode.js' -import { buildArtifactPaths } from './session-proof-paths.js' -import { writeRedeemResultFile } from './session-proof-result-writer.js' - -type BrowserPageLike = { - evaluate: (callback: any, arg?: unknown) => Promise - waitForTimeout: (milliseconds: number) => Promise - setViewportSize: (viewport: { width: number; height: number }) => Promise - setContent: (html: string, options?: { waitUntil?: string }) => Promise - goto: (url: string, options?: { waitUntil?: string }) => Promise - screenshot: (options: { - path: string - fullPage?: boolean - clip?: { x: number; y: number; width: number; height: number } - }) => Promise - content: () => Promise - close: () => Promise -} - -type BrowserContextLike = { - newPage: () => Promise -} - -type RedeemFinalResult = { - role?: { - area?: string - } - classification?: { - success?: boolean - } - redeem?: { - iRet?: number | string - ret?: number | string - sMsg?: string - } - [key: string]: unknown -} - -type RedeemArtifactsInput = { - browserContext: BrowserContextLike - page: BrowserPageLike - sessionDir: string - sessionId?: string - activityUrl: string - code: string - finalResult: RedeemFinalResult - attempts?: unknown[] -} - -type WriteRedeemArtifactFilesInput = Omit & { - outputDir: string - proofMode?: RedeemProofMode | string -} - -type RedeemArtifactsResult = { - proofMode: RedeemProofMode - screenshotPath: string - htmlPath: string - resultPath: string -} - -export async function saveRedeemArtifacts({ - browserContext, - page, - sessionDir, - sessionId, - activityUrl, - code, - finalResult, - attempts, -}: RedeemArtifactsInput): Promise { - return writeRedeemArtifactFiles({ - browserContext, - page, - outputDir: sessionDir, - sessionId, - activityUrl, - code, - finalResult, - attempts, - proofMode: resolveRedeemProofMode(), - }) -} - -export async function writeRedeemArtifactFiles({ - browserContext, - page, - outputDir, - sessionId = '', - activityUrl, - code, - finalResult, - attempts, - proofMode = DEFAULT_REDEEM_PROOF_MODE, -}: WriteRedeemArtifactFilesInput): Promise { - const effectiveProofMode = resolveRedeemProofMode(proofMode) - const { - redeemPageScreenshotPath, - beijingTimeScreenshotPath, - screenshotPath, - htmlPath, - resultPath, - } = buildArtifactPaths(outputDir) - - if (effectiveProofMode === 'off') { - return { - proofMode: effectiveProofMode, - screenshotPath: '', - htmlPath: '', - resultPath: '', - } - } - - await showResultDialog(page, finalResult.redeem?.sMsg || '兑换完成') - - if (effectiveProofMode === 'basic') { - await page.screenshot({ path: screenshotPath, fullPage: true }) - await writeRedeemResultFile(resultPath, { - proofMode: effectiveProofMode, - sessionId, - activityUrl, - code, - finalResult, - attempts, - screenshotPath, - }) - - return { - proofMode: effectiveProofMode, - screenshotPath, - htmlPath: '', - resultPath, - } - } - - await page.screenshot({ path: redeemPageScreenshotPath, fullPage: true }) - - let beijingTimeProof: { - screenshotPath: string - pageUrl: string - error?: string - } = { - screenshotPath: '', - pageUrl: BAIDU_BEIJING_TIME_URL, - error: '', - } - - if (shouldCaptureBeijingTimeProof(finalResult)) { - try { - beijingTimeProof = await captureBeijingTimeProof(browserContext, { - screenshotPath: beijingTimeScreenshotPath, - }) - await composeProofScreenshot(browserContext, { - outputPath: screenshotPath, - redeemImagePath: redeemPageScreenshotPath, - timeImagePath: beijingTimeProof.screenshotPath, - redeemMessage: String(finalResult.redeem?.sMsg || '兑换完成'), - timePageUrl: beijingTimeProof.pageUrl, - }) - } catch (error) { - beijingTimeProof = { - screenshotPath: '', - pageUrl: BAIDU_BEIJING_TIME_URL, - error: error instanceof Error ? error.message : '北京时间截图生成失败', - } - await fs.copyFile(redeemPageScreenshotPath, screenshotPath) - } - } else { - await fs.copyFile(redeemPageScreenshotPath, screenshotPath) - } - - await fs.writeFile(htmlPath, await page.content(), 'utf8') - await writeRedeemResultFile(resultPath, { - proofMode: effectiveProofMode, - sessionId, - activityUrl, - code, - finalResult, - attempts, - screenshotPath, - htmlPath, - redeemPageScreenshotPath, - beijingTimeScreenshotPath: beijingTimeProof.screenshotPath || '', - beijingTimePageUrl: beijingTimeProof.pageUrl, - beijingTimeError: beijingTimeProof.error || '', - }) - - return { - proofMode: effectiveProofMode, - screenshotPath, - htmlPath, - resultPath, - } -} - -export { resolveRedeemProofMode, shouldCaptureBeijingTimeProof } diff --git a/apps/backend/src/services/session/session-qq.ts b/apps/backend/src/services/session/session-qq.ts deleted file mode 100644 index bf96d042..00000000 --- a/apps/backend/src/services/session/session-qq.ts +++ /dev/null @@ -1,240 +0,0 @@ -/// - -import fs from 'node:fs/promises' - -import { downloadRemoteQrImage, logBrowserSessionDebug, waitForFrame } from './session-login-shared.js' - -const QQ_QR_TARGET_SIZE = 144 - -type LocatorLike = { - count: () => Promise - click: (options?: { timeout?: number }) => Promise - waitFor: (options?: { state?: string; timeout?: number }) => Promise - screenshot: (options: { path: string }) => Promise - first: () => LocatorLike -} - -type FrameLike = { - url: () => string - locator: (selector: string) => LocatorLike - waitForTimeout: (milliseconds: number) => Promise - evaluate: (callback: any) => Promise -} - -type PageLike = { - frames: () => FrameLike[] - waitForTimeout: (milliseconds: number) => Promise - locator: (selector: string) => LocatorLike - context: () => { - request: { - get: (url: string, options?: any) => Promise<{ - ok: () => boolean - status: () => number - body: () => Promise - }> - } - } -} - -type QqQrState = { - visible: boolean - qrVisible?: boolean - scanned: boolean - expired: boolean - message: string - frameUrl?: string -} - -export async function ensureQqLoginReady(page: PageLike): Promise { - try { - const frame = await waitForFrame( - page, - (item) => item.url().includes('xui.ptlogin2.qq.com/cgi-bin/xlogin'), - 10_000, - ) - const switcher = frame.locator('#switcher_qlogin') - - if (!(await switcher.count())) { - return - } - - await switcher.click({ timeout: 3_000 }).catch(() => null) - await frame.waitForTimeout(250) - } catch { - // ignore qq inner tab switch failures; the screenshot retry path will try again - } -} - -export async function captureQqQrImage(page: PageLike, qrImagePath: string): Promise { - const frame = await waitForFrame( - page, - (item) => item.url().includes('xui.ptlogin2.qq.com/cgi-bin/xlogin'), - 10_000, - ) - const qrUrl = await resolveQqQrImageUrl(frame) - const effectiveQrUrl = upgradeQqQrImageUrl(qrUrl) - - logBrowserSessionDebug('qq.capture.qrUrl', { - qrUrl, - effectiveQrUrl, - frameUrl: frame.url(), - }) - - if (effectiveQrUrl) { - const body = await downloadRemoteQrImage(page, effectiveQrUrl, qrImagePath, frame.url(), 'qq') - logBrowserSessionDebug('qq.capture.downloadResult', { - qrUrl: effectiveQrUrl, - downloaded: Buffer.isBuffer(body) && body.length > 0, - qrImagePath, - }) - - if (Buffer.isBuffer(body) && body.length > 0) { - await fs.writeFile(qrImagePath, body) - return - } - } - - logBrowserSessionDebug('qq.capture.fallbackScreenshot', { - qrImagePath, - }) - - const qrLocator = await findQqQrLocator(page, frame) - - try { - await qrLocator.screenshot({ path: qrImagePath }) - return - } catch { - const iframeLocator = page.locator('#milo-qcwx-frame-qc').first() - await iframeLocator.waitFor({ state: 'visible', timeout: 15_000 }) - await iframeLocator.screenshot({ path: qrImagePath }) - } -} - -export async function findQqQrLocator(page: PageLike, frame: FrameLike | null = null): Promise { - const effectiveFrame = - frame || - await waitForFrame(page, (item) => item.url().includes('xui.ptlogin2.qq.com/cgi-bin/xlogin')) - const locator = effectiveFrame.locator('#qrlogin_img') - await locator.waitFor({ state: 'visible', timeout: 15_000 }) - logBrowserSessionDebug('qq.findQrLocator.visibleReady', { - frameUrl: effectiveFrame.url(), - }) - return locator -} - -export async function extractQqQrState(page: Pick): Promise { - const frame = page.frames().find((item) => item.url().includes('xui.ptlogin2.qq.com/cgi-bin/xlogin')) - - if (!frame) { - return { - visible: false, - scanned: false, - expired: false, - message: '', - } - } - - try { - const { qrVisible, bodyText } = await frame - .evaluate(() => { - const isVisible = (element: Element | null) => { - if (!(element instanceof HTMLElement)) { - return false - } - - const style = window.getComputedStyle(element) - const rect = element.getBoundingClientRect() - return ( - style.display !== 'none' && - style.visibility !== 'hidden' && - style.opacity !== '0' && - rect.width > 0 && - rect.height > 0 - ) - } - - const qrImage = document.querySelector('#qrlogin_img') - const bodyText = String(document.body?.textContent || '').replace(/\s+/g, ' ').trim() - - return { - qrVisible: - qrImage instanceof HTMLImageElement && - isVisible(qrImage) && - Number(qrImage.naturalWidth || 0) > 0 && - Number(qrImage.naturalHeight || 0) > 0, - bodyText, - } - }) - .catch(() => ({ - qrVisible: false, - bodyText: '', - })) - - return { - visible: true, - qrVisible, - scanned: !qrVisible && /扫描成功|请在手机上确认登录/.test(bodyText), - expired: qrVisible ? false : /二维码失效|已失效/.test(bodyText), - message: bodyText.slice(0, 200), - frameUrl: frame.url(), - } - } catch { - return { - visible: true, - qrVisible: false, - scanned: false, - expired: false, - message: '', - frameUrl: frame.url(), - } - } -} - -async function resolveQqQrImageUrl(frame: FrameLike): Promise { - return frame - .evaluate(() => { - const qrImage = document.querySelector('#qrlogin_img') - - if (!(qrImage instanceof HTMLImageElement)) { - return '' - } - - return String(qrImage.currentSrc || qrImage.src || qrImage.getAttribute('src') || '').trim() - }) - .then((src) => { - if (!src) { - return '' - } - - try { - return new URL(src, frame.url()).toString() - } catch { - return '' - } - }) - .catch(() => '') -} - -function upgradeQqQrImageUrl(qrUrl: string): string { - if (!qrUrl) { - return '' - } - - try { - const url = new URL(qrUrl) - - if (!/xui\.ptlogin2\.qq\.com$/i.test(url.hostname) || !/\/ptqrshow$/i.test(url.pathname)) { - return qrUrl - } - - const currentSize = Number(url.searchParams.get('d') || 0) - - if (!Number.isFinite(currentSize) || currentSize < QQ_QR_TARGET_SIZE) { - url.searchParams.set('d', String(QQ_QR_TARGET_SIZE)) - } - - return url.toString() - } catch { - return qrUrl - } -} diff --git a/apps/backend/src/services/session/session-redeem.test.ts b/apps/backend/src/services/session/session-redeem.test.ts deleted file mode 100644 index e22f4a89..00000000 --- a/apps/backend/src/services/session/session-redeem.test.ts +++ /dev/null @@ -1,89 +0,0 @@ -import test from 'node:test' -import assert from 'node:assert/strict' - -import { - classifyTencentRedeemResult, - isCaptchaRejectedResult, - resolveTencentRedeemMessage, -} from './session-redeem.js' - -test('resolveTencentRedeemMessage prefers popup detail over generic title', () => { - assert.equal( - resolveTencentRedeemMessage({ - popup: { - text: '兑换结果', - detail: '兑换码错误,请确认兑换码信息是否准确。', - }, - }), - '兑换码错误,请确认兑换码信息是否准确。', - ) -}) - -test('classifyTencentRedeemResult marks used codes as replacement retries', () => { - const result = classifyTencentRedeemResult({ - popup: { - text: '兑换结果', - detail: '兑换码已使用。', - }, - }) - - assert.equal(result.outcome, 'code_used') - assert.equal(result.success, false) - assert.equal(result.retryWithReplacementCode, true) -}) - -test('classifyTencentRedeemResult marks invalid codes as replacement retries', () => { - const result = classifyTencentRedeemResult({ - popup: { - text: '兑换结果', - detail: '兑换码错误,请确认兑换码信息是否准确。', - }, - }) - - assert.equal(result.outcome, 'code_invalid') - assert.equal(result.success, false) - assert.equal(result.retryWithReplacementCode, true) -}) - -test('classifyTencentRedeemResult treats missing CDKEY responses as invalid replacement retries', () => { - const result = classifyTencentRedeemResult({ - iRet: -183, - sMsg: '该CDKEY不存在,请您确认后输入!', - }) - - assert.equal(result.outcome, 'code_invalid') - assert.equal(result.success, false) - assert.equal(result.retryWithReplacementCode, true) -}) - -test('classifyTencentRedeemResult recognizes successful gift popup', () => { - const result = classifyTencentRedeemResult({ - popup: { - text: '恭喜您获得了礼包:动作-干员庆生,请注意:游戏虚拟道具奖品将会在24小时内到账,请登录游戏查看邮件。', - detail: '', - }, - }) - - assert.equal(result.outcome, 'success') - assert.equal(result.success, true) -}) - -test('isCaptchaRejectedResult only treats captcha problems as same-code retries', () => { - assert.equal( - isCaptchaRejectedResult({ - iRet: -100, - sMsg: '验证码错误', - }), - true, - ) - - assert.equal( - isCaptchaRejectedResult({ - popup: { - text: '兑换结果', - detail: '兑换码已使用。', - }, - }), - false, - ) -}) diff --git a/apps/backend/src/services/session/session-redeem.ts b/apps/backend/src/services/session/session-redeem.ts deleted file mode 100644 index af6be4bc..00000000 --- a/apps/backend/src/services/session/session-redeem.ts +++ /dev/null @@ -1,718 +0,0 @@ -/// - -import fs from 'node:fs/promises' - -type JsonObject = Record - -type BrowserResponseLike = { - url: () => string - request: () => { method: () => string } - text: () => Promise - status: () => number -} - -type BrowserLocatorLike = { - screenshot: (options: { path: string }) => Promise - getAttribute: (name: string) => Promise - click: (options?: JsonObject) => Promise - evaluate: (callback: (element: Element) => T | Promise) => Promise -} - -type BrowserPageLike = { - waitForFunction: (callback: any, argOrOptions?: any, options?: JsonObject) => Promise - waitForResponse: ( - predicate: (response: BrowserResponseLike) => boolean, - options?: JsonObject, - ) => Promise - waitForTimeout: (milliseconds: number) => Promise - locator: (selector: string) => BrowserLocatorLike - evaluate: (callback: any, arg?: any) => Promise -} - -type ActivityInfo = { - role?: JsonObject & { - ready?: boolean - area?: string - } - form?: { - cdkeyInputId?: string - verifyInputId?: string - verifyImgId?: string - submitId?: string - } -} - -type TencentRedeemPopup = { - visible?: boolean - text?: string - detail?: string -} - -type TencentRedeemResult = JsonObject & { - iRet?: number | string - sMsg?: string - msg?: string - popup?: TencentRedeemPopup | null -} - -type TencentRedeemOutcome = 'success' | 'captcha_rejected' | 'code_used' | 'code_invalid' | 'failed' - -type TencentRedeemClassification = { - outcome: TencentRedeemOutcome - success: boolean - retryWithSameCode: boolean - retryWithReplacementCode: boolean - retCode: number | null - message: string -} - -type RedeemAttemptRecord = { - attempt: number - verifyCode: string - verifysession: string - ocrSample: unknown - redeem: TencentRedeemResult - classification: TencentRedeemClassification - role: ActivityInfo['role'] -} - -type TencentBrowserRedeemSession = JsonObject & { - status: string - notice: string - updatedAt: string - lastError?: string - lastRedeem?: JsonObject - page: BrowserPageLike - browserContext: unknown - sessionDir: string - sessionId: string -} - -type RunTencentBrowserRedeemOptions = { - session: TencentBrowserRedeemSession - code: string - maxAttempts: number - ensureLoggedInPresentation: (session: TencentBrowserRedeemSession, options?: JsonObject) => Promise - ensureActivityInfoReady: (page: BrowserPageLike, options?: JsonObject) => Promise - fillRedeemCodeInBrowser: (page: BrowserPageLike, code: string, options?: JsonObject) => Promise - capturePageCaptchaForOcr: ( - page: BrowserPageLike, - options: { sessionDir: string; attempt: number }, - ) => Promise<{ - imageBuffer: Buffer - imagePath: string - imageExtension: string - contentType: string - verifyImgId: string - verifysession: string - }> - recognizeTencentCaptcha: (payload: JsonObject) => Promise - submitRedeemInBrowser: ( - page: BrowserPageLike, - payload: { code: string; verifyCode: string }, - ) => Promise - isCaptchaRejectedResult: (result: TencentRedeemResult) => boolean - refreshPageCaptcha: (page: BrowserPageLike, verifyImgId?: string) => Promise - persistSessionState: (session: TencentBrowserRedeemSession) => Promise - buildSessionPayload: (session: TencentBrowserRedeemSession) => JsonObject - saveRedeemArtifacts: (payload: JsonObject) => Promise - activityUrl: string -} - -type ActivityInfoProvider = { - ensureActivityInfoReady: (page: BrowserPageLike, options?: JsonObject) => Promise -} - -declare global { - interface Window { - Milo?: { - get?: (key: string) => unknown - } - closeDialog?: () => void - } -} - -export async function runTencentBrowserRedeem({ - session, - code, - maxAttempts, - ensureLoggedInPresentation, - ensureActivityInfoReady, - fillRedeemCodeInBrowser, - capturePageCaptchaForOcr, - recognizeTencentCaptcha, - submitRedeemInBrowser, - isCaptchaRejectedResult, - refreshPageCaptcha, - persistSessionState, - buildSessionPayload, - saveRedeemArtifacts, - activityUrl, -}: RunTencentBrowserRedeemOptions): Promise { - session.status = 'redeeming' - session.notice = '正在识别验证码并提交兑换' - session.updatedAt = new Date().toISOString() - await persistSessionState(session) - - const attempts: RedeemAttemptRecord[] = [] - let finalResult: RedeemAttemptRecord | null = null - - try { - await ensureLoggedInPresentation(session, { credentialReady: true }) - const activityInfo = await ensureActivityInfoReady(session.page, { triggerRender: true }) - - if (!activityInfo?.role?.ready) { - throw new Error('浏览器会话尚未拿到角色信息,请稍后重试') - } - - for (let attempt = 1; attempt <= maxAttempts; attempt += 1) { - await fillRedeemCodeInBrowser(session.page, code) - - const captcha = await capturePageCaptchaForOcr(session.page, { - sessionDir: session.sessionDir, - attempt, - }) - const ocr = await recognizeTencentCaptcha({ - imageBase64: captcha.imageBuffer.toString('base64'), - imageExtension: captcha.imageExtension, - imageContentType: captcha.contentType, - finalUrl: captcha.imagePath, - saveSample: true, - tag: `browser-session-${session.sessionId}-attempt-${attempt}`, - }) - - if (ocr?.code !== 0) { - throw new Error(ocr?.msg || 'OCR 识别失败') - } - - const verifyCode = String(ocr?.data?.text || ocr?.data?.recognizedText || '').trim() - - if (!verifyCode) { - throw new Error('OCR 没有识别出验证码') - } - - const redeem = await submitRedeemInBrowser(session.page, { - code, - verifyCode, - }) - const classification = classifyTencentRedeemResult(redeem) - - const attemptRecord = { - attempt, - verifyCode, - verifysession: captcha.verifysession, - ocrSample: ocr?.data?.saved || null, - redeem, - classification, - role: activityInfo.role, - } - - attempts.push(attemptRecord) - finalResult = attemptRecord - - if (!isCaptchaRejectedResult(redeem)) { - break - } - - await dismissRedeemRetryPopup(session.page) - await refreshPageCaptcha(session.page, captcha.verifyImgId) - } - - if (!finalResult) { - throw new Error('浏览器会话兑换没有拿到结果') - } - - const artifacts = await saveRedeemArtifacts({ - browserContext: session.browserContext, - page: session.page, - sessionDir: session.sessionDir, - sessionId: session.sessionId, - activityUrl, - code, - finalResult, - attempts, - }) - - session.lastRedeem = { - code, - area: finalResult?.role?.area || '', - attempts, - final: finalResult, - proofMode: artifacts.proofMode, - screenshotPath: artifacts.screenshotPath, - htmlPath: artifacts.htmlPath, - resultPath: artifacts.resultPath, - finishedAt: new Date().toISOString(), - } - session.status = 'redeemed' - session.notice = resolveTencentRedeemMessage(finalResult?.redeem) - session.updatedAt = new Date().toISOString() - await persistSessionState(session) - - return { - ...buildSessionPayload(session), - redeem: session.lastRedeem, - } - } catch (error) { - session.status = 'failed' - session.notice = error instanceof Error ? error.message : '浏览器会话兑换失败' - session.lastError = session.notice - session.updatedAt = new Date().toISOString() - await persistSessionState(session) - throw error - } -} - -export async function capturePageCaptchaForOcr( - page: BrowserPageLike, - { sessionDir, attempt, ensureActivityInfoReady }: { sessionDir: string; attempt: number } & ActivityInfoProvider, -) { - const activityInfo = await ensureActivityInfoReady(page, { timeoutMs: 5_000 }) - - if (!activityInfo?.form?.verifyImgId) { - throw new Error('页面里没有找到验证码图片节点') - } - - const verifySelector = `#${activityInfo.form.verifyImgId}` - - await page.waitForFunction( - (selector) => { - const element = document.querySelector(selector) - - if (!(element instanceof HTMLImageElement)) { - return false - } - - const style = window.getComputedStyle(element) - return ( - style.display !== 'none' && - style.visibility !== 'hidden' && - style.opacity !== '0' && - element.naturalWidth > 0 - ) - }, - verifySelector, - { timeout: 8_000 }, - ) - - const imagePath = `${sessionDir}/captcha-attempt-${attempt}.png` - await page.locator(verifySelector).screenshot({ path: imagePath }) - - const verifysession = await page.evaluate(() => { - try { - if (window.Milo && typeof window.Milo.get === 'function') { - return String(window.Milo.get('verifysession') || '') - } - } catch { - // ignore Milo access failures - } - - return '' - }) - - return { - imageBuffer: await fs.readFile(imagePath), - imagePath, - imageExtension: '.png', - contentType: 'image/png', - verifyImgId: activityInfo.form.verifyImgId, - verifysession, - } -} - -export async function submitRedeemInBrowser( - page: BrowserPageLike, - payload: { code: string; verifyCode: string }, - { ensureActivityInfoReady }: ActivityInfoProvider, -): Promise { - const activityInfo = await ensureActivityInfoReady(page, { timeoutMs: 2_000 }) - - if (!activityInfo?.form?.verifyInputId || !activityInfo?.form?.submitId) { - throw new Error('页面里没有找到兑换输入框或提交按钮') - } - - const verifySelector = `#${activityInfo.form.verifyInputId}` - const submitSelector = `#${activityInfo.form.submitId}` - - await setFormControlValue(page, verifySelector, payload.verifyCode) - await resetRedeemPopup(page) - await normalizeRedeemOverlay(page) - - const responsePromise = page - .waitForResponse( - (response) => - response.url().includes('dfm.ams.game.qq.com/ide/') && - response.request().method().toUpperCase() === 'POST', - { timeout: 10_000 }, - ) - .catch(() => null) - const popupPromise = waitForRedeemPopup(page).catch(() => null) - - await clickRedeemSubmit(page, submitSelector) - - const networkResponse = await responsePromise - const popup = await popupPromise - const parsedNetwork = networkResponse ? tryParseJson(await networkResponse.text()) : null - - if (parsedNetwork && typeof parsedNetwork === 'object') { - return { - httpStatus: networkResponse.status(), - popup: popup || null, - ...parsedNetwork, - } - } - - if (!popup) { - throw new Error('页面内兑换没有等到结果弹窗或接口响应') - } - - return { - httpStatus: networkResponse?.status?.() || 0, - iRet: inferRedeemCodeFromPopup(popup), - sMsg: popup.text || popup.detail || '兑换完成', - popup, - } -} - -export function fillRedeemCodeInBrowser( - page: BrowserPageLike, - code: string, - { activityInfo }: { activityInfo?: ActivityInfo } = {}, -) { - const cdkeySelector = activityInfo?.form?.cdkeyInputId - ? `#${activityInfo.form.cdkeyInputId}` - : '[id^="milo_cdkeyInfo_"]' - - const verifySelector = activityInfo?.form?.verifyInputId - ? `#${activityInfo.form.verifyInputId}` - : '[id^="milo_verifyInput_"]' - - return Promise.all([ - setFormControlValue(page, cdkeySelector, code), - setFormControlValue(page, verifySelector, ''), - ]) -} - -export async function prepareRedeemCodeFill( - page: BrowserPageLike, - code: string, - { ensureActivityInfoReady }: ActivityInfoProvider, -) { - const activityInfo = await ensureActivityInfoReady(page, { triggerRender: true, timeoutMs: 2_000 }) - await fillRedeemCodeInBrowser(page, code, { activityInfo }) -} - -export async function refreshPageCaptcha(page: BrowserPageLike, verifyImgId?: string) { - if (!verifyImgId) { - return - } - - const selector = `#${verifyImgId}` - const currentSrc = await page.locator(selector).getAttribute('src').catch(() => '') - await page.locator(selector).click({ timeout: 3_000 }).catch(() => null) - await page.waitForFunction( - ({ selector: targetSelector, previousSrc }) => { - const element = document.querySelector(targetSelector) - - if (!(element instanceof HTMLImageElement)) { - return false - } - - return element.naturalWidth > 0 && String(element.getAttribute('src') || '') !== String(previousSrc || '') - }, - { selector, previousSrc: currentSrc || '' }, - { timeout: 5_000 }, - ).catch(() => null) -} - -export function isCaptchaRejectedResult(result: TencentRedeemResult): boolean { - if (classifyTencentRedeemResult(result).outcome === 'captcha_rejected') { - return true - } - - return false -} - -export function classifyTencentRedeemResult(result: TencentRedeemResult): TencentRedeemClassification { - const retCode = Number(result?.iRet) - const message = resolveTencentRedeemMessage(result) - const normalized = normalizeTencentRedeemText(message) - - if (Number.isFinite(retCode) && retCode === -100) { - return buildTencentRedeemClassification('captcha_rejected', retCode, message) - } - - if (/兑换码已使用|cdk已使用|cdkey已使用|已被使用/.test(normalized)) { - return buildTencentRedeemClassification('code_used', retCode, message || '兑换码已使用') - } - - if ( - retCode === -165 || - retCode === -183 || - /兑换码错误|cdk错误|cdkey错误|请确认兑换码信息是否准确|兑换码信息是否准确|兑换码不存在|cdk不存在|cdkey不存在|不存在请您确认后输入/.test(normalized) - ) { - return buildTencentRedeemClassification('code_invalid', retCode, message || '兑换码错误,请确认兑换码信息是否准确') - } - - if (/验证码|校验码/.test(normalized)) { - return buildTencentRedeemClassification('captcha_rejected', retCode, message || '验证码错误,请稍后重试') - } - - if ( - retCode === 0 || - /成功|已兑换|领取成功|恭喜您获得了礼包|查看邮件|到账/.test(normalized) - ) { - return buildTencentRedeemClassification('success', retCode, message || '兑换成功') - } - - return buildTencentRedeemClassification('failed', retCode, message || '兑换失败') -} - -function resetRedeemPopup(page: BrowserPageLike) { - return page.evaluate(() => { - const popup = document.querySelector('#pop2') - const popupText = document.querySelector('#PopText') - const popupDetail = document.querySelector('#PopText2') - - if (popup instanceof HTMLElement) { - popup.style.display = 'none' - } - - if (popupText instanceof HTMLElement) { - popupText.textContent = '' - } - - if (popupDetail instanceof HTMLElement) { - popupDetail.classList.add('hide') - popupDetail.textContent = '' - } - }) -} - -async function dismissRedeemRetryPopup(page: BrowserPageLike) { - await page.evaluate(() => { - try { - if (typeof window.closeDialog === 'function') { - window.closeDialog() - } - } catch { - // ignore page close hook failures - } - - const closeButton = document.querySelector('#pop2 .pop_close') - - if (closeButton instanceof HTMLElement) { - closeButton.click() - } - - const popup = document.querySelector('#pop2') - - if (popup instanceof HTMLElement) { - popup.style.display = 'none' - popup.style.visibility = 'hidden' - } - }) - - await normalizeRedeemOverlay(page) - await page.waitForTimeout(150) -} - -async function normalizeRedeemOverlay(page: BrowserPageLike) { - await page.evaluate(() => { - const overlayIds = ['_overlay_', 'overlay_mask', 'overlay'] - - for (const id of overlayIds) { - const element = document.getElementById(id) - - if (!(element instanceof HTMLElement)) { - continue - } - - element.style.pointerEvents = 'none' - element.style.display = 'none' - element.style.opacity = '0' - } - }) -} - -async function clickRedeemSubmit(page: BrowserPageLike, submitSelector: string) { - const locator = page.locator(submitSelector) - - try { - await locator.click({ timeout: 3_000 }) - return - } catch { - await normalizeRedeemOverlay(page) - } - - try { - await locator.click({ timeout: 3_000, force: true }) - return - } catch { - await locator.evaluate((element) => { - if (element instanceof HTMLElement) { - element.click() - } - }) - } -} - -async function setFormControlValue(page: BrowserPageLike, selector: string, value: unknown) { - const nextValue = String(value ?? '') - - const appliedValue = await page.evaluate( - ({ targetSelector, targetValue }) => { - const isVisible = (element: Element) => { - if (!(element instanceof HTMLElement)) { - return false - } - - const style = window.getComputedStyle(element) - return ( - style.display !== 'none' && - style.visibility !== 'hidden' && - style.opacity !== '0' && - element.getClientRects().length > 0 - ) - } - - const matches = [...document.querySelectorAll(targetSelector)] - const element = - matches.find( - (node) => - (node instanceof HTMLInputElement || node instanceof HTMLTextAreaElement) && - !node.disabled && - node.type !== 'hidden' && - isVisible(node), - ) || - matches.find( - (node) => - (node instanceof HTMLInputElement || node instanceof HTMLTextAreaElement) && - !node.disabled && - node.type !== 'hidden', - ) || - null - - if (!(element instanceof HTMLInputElement) && !(element instanceof HTMLTextAreaElement)) { - return { - found: false, - value: '', - } - } - - const prototype = - element instanceof HTMLTextAreaElement - ? window.HTMLTextAreaElement.prototype - : window.HTMLInputElement.prototype - const descriptor = Object.getOwnPropertyDescriptor(prototype, 'value') - - if (descriptor?.set) { - descriptor.set.call(element, targetValue) - } else { - element.value = targetValue - } - - element.setAttribute('value', targetValue) - element.focus() - element.dispatchEvent(new Event('input', { bubbles: true })) - element.dispatchEvent(new Event('change', { bubbles: true })) - element.dispatchEvent(new KeyboardEvent('keyup', { bubbles: true, key: 'Enter' })) - element.blur() - - return { - found: true, - value: String(element.value || ''), - } - }, - { targetSelector: selector, targetValue: nextValue }, - ) - - if (!appliedValue?.found) { - throw new Error(`页面里没有找到可填写的表单节点: ${selector}`) - } - - if (String(appliedValue.value || '') !== nextValue) { - throw new Error(`页面表单写值失败: ${selector}`) - } -} - -async function waitForRedeemPopup(page: BrowserPageLike): Promise { - await page.waitForFunction(() => { - const popup = document.querySelector('#pop2') - const popupText = document.querySelector('#PopText') - - if (!(popup instanceof HTMLElement) || !(popupText instanceof HTMLElement)) { - return false - } - - const style = window.getComputedStyle(popup) - return style.display !== 'none' && String(popupText.textContent || '').trim().length > 0 - }, { timeout: 10_000 }) - - return page.evaluate(() => ({ - visible: true, - text: String(document.querySelector('#PopText')?.textContent || '').trim(), - detail: String(document.querySelector('#PopText2')?.textContent || '').trim(), - })) -} - -function tryParseJson(text: string): any { - try { - return JSON.parse(text) - } catch { - return null - } -} - -function inferRedeemCodeFromPopup(popup: TencentRedeemPopup) { - const text = `${String(popup?.text || '')} ${String(popup?.detail || '')}`.trim() - - if (!text) { - return 1 - } - - if (/验证码|校验码/.test(text)) { - return -100 - } - - if (/成功|已兑换|领取成功/.test(text)) { - return 0 - } - - return 1 -} - -export function resolveTencentRedeemMessage(result: TencentRedeemResult): string { - const candidates = [ - result?.popup?.detail, - result?.popup?.text, - result?.sMsg, - result?.msg, - ] - .map((item) => String(item || '').trim()) - .filter(Boolean) - - return candidates[0] || '兑换完成' -} - -function buildTencentRedeemClassification( - outcome: TencentRedeemOutcome, - retCode: number, - message: string, -): TencentRedeemClassification { - return { - outcome, - success: outcome === 'success', - retryWithSameCode: outcome === 'captcha_rejected', - retryWithReplacementCode: outcome === 'code_used' || outcome === 'code_invalid', - retCode: Number.isFinite(retCode) ? retCode : null, - message: String(message || '').trim(), - } -} - -function normalizeTencentRedeemText(value: unknown): string { - return String(value || '') - .replace(/\s+/g, '') - .trim() - .toLowerCase() -} diff --git a/apps/backend/src/services/session/session-review.ts b/apps/backend/src/services/session/session-review.ts deleted file mode 100644 index bcef4bcf..00000000 --- a/apps/backend/src/services/session/session-review.ts +++ /dev/null @@ -1,68 +0,0 @@ -import path from 'node:path' - -import { logBrowserSessionDebug } from './session-login-shared.js' - -type BrowserPageLike = { - screenshot: (options: { path: string; fullPage?: boolean }) => Promise -} - -type ReviewSession = { - sessionId: string - loginType: string - sessionDir: string - page: BrowserPageLike - lastReview?: { - screenshotPath?: string - signature?: string - [key: string]: unknown - } | null -} - -type ActivityInfoForReview = { - role?: { - roleId?: string - roleName?: string - } -} - -export function resolveReviewSignature(session: Pick, activityInfo: ActivityInfoForReview) { - const roleId = String(activityInfo?.role?.roleId || '').trim() - const roleName = String(activityInfo?.role?.roleName || '').trim() - - return { - roleId, - roleName, - signature: `${session.loginType}|${roleId}|${roleName}`, - } -} - -export async function ensureReviewScreenshot(session: ReviewSession, activityInfo: ActivityInfoForReview): Promise { - const { roleId, roleName, signature } = resolveReviewSignature(session, activityInfo) - - if (session.lastReview?.screenshotPath && session.lastReview?.signature === signature) { - return false - } - - const screenshotPath = path.join(session.sessionDir, 'role-review.png') - - try { - await session.page.screenshot({ - path: screenshotPath, - fullPage: true, - }) - session.lastReview = { - screenshotPath, - capturedAt: new Date().toISOString(), - roleId, - roleName, - signature, - } - return true - } catch (error) { - logBrowserSessionDebug('reviewScreenshot.capture.error', { - sessionId: session.sessionId, - message: error instanceof Error ? error.message : String(error), - }) - return false - } -} diff --git a/apps/backend/src/services/session/session-service-config.ts b/apps/backend/src/services/session/session-service-config.ts deleted file mode 100644 index 2adb7d4b..00000000 --- a/apps/backend/src/services/session/session-service-config.ts +++ /dev/null @@ -1,11 +0,0 @@ -import path from 'node:path' - -import { runtimeConfig } from '../../config/runtime.js' -import { resolveDefaultViewport } from './session-browser-config.js' - -export const BROWSER_SESSION_DATA_ROOT = path.join(runtimeConfig.data.root, 'browser-sessions') -export const ACTIVITY_URL = 'https://df.qq.com/cp/a20240812cdk/index.html' -export const CHROME_PATH = String(runtimeConfig.browser.chromePath || '').trim() -export const REDEEM_SUCCESS_AUTO_CLOSE_MS = 3 * 60 * 1000 -export const DEFAULT_VIEWPORT = resolveDefaultViewport(runtimeConfig.browser.vncResolution) -export const SESSION_DEBUG_ENABLED = Boolean(runtimeConfig.session.debug) diff --git a/apps/backend/src/services/session/session-service-runtime.ts b/apps/backend/src/services/session/session-service-runtime.ts deleted file mode 100644 index 22e6ab17..00000000 --- a/apps/backend/src/services/session/session-service-runtime.ts +++ /dev/null @@ -1,102 +0,0 @@ -import { chromium } from 'playwright' - -import { runtimeConfig } from '../../config/runtime.js' -import { - buildChromiumLaunchOptions as buildChromiumLaunchOptionsBase, - resolveBrowserLaunchOptions as resolveBrowserLaunchOptionsBase, -} from './session-browser-config.js' -import { CHROME_PATH } from './session-service-config.js' - -type BrowserLike = { - on: (event: 'disconnected', listener: () => void) => void - close: () => Promise - newContext: (options?: Record) => Promise -} - -type SessionServiceErrorOptions = { - statusCode?: number - errorCode?: string -} - -let browserPromise: Promise | null = null - -export class SessionServiceError extends Error { - statusCode: number - errorCode: string - - constructor(message: string, { statusCode = 500, errorCode = 'session_error' }: SessionServiceErrorOptions = {}) { - super(message) - this.name = 'SessionServiceError' - this.statusCode = statusCode - this.errorCode = errorCode - } -} - -export async function warmupTencentBrowser() { - const browserLaunch = resolveBrowserLaunchOptions() - - if (!browserLaunch.prewarm) { - return { - warmed: false, - reason: 'disabled', - browserLaunch, - } - } - - await ensureBrowser() - - return { - warmed: true, - reason: 'ready', - browserLaunch, - } -} - -export async function ensureBrowser(): Promise { - if (!browserPromise) { - const launchOptions = resolveBrowserLaunchOptions() - browserPromise = (chromium.launch(buildChromiumLaunchOptions(launchOptions)) as Promise) - .then((browser) => { - browser.on('disconnected', () => { - browserPromise = null - }) - return browser - }) - .catch((error) => { - browserPromise = null - throw error - }) - } - - return browserPromise -} - -export function resolveBrowserLaunchOptions() { - return resolveBrowserLaunchOptionsBase({ - browserConfig: runtimeConfig.browser, - chromePath: CHROME_PATH, - }) -} - -export async function closeBrowserIfIdle({ activeSessionCount = 0 }: { activeSessionCount?: number } = {}): Promise { - if (activeSessionCount > 0 || !browserPromise) { - return - } - - if (resolveBrowserLaunchOptions().keepAlive) { - return - } - - try { - const browser = await browserPromise - await browser.close() - } catch { - // ignore browser close failures - } finally { - browserPromise = null - } -} - -function buildChromiumLaunchOptions(launchOptions: ReturnType) { - return buildChromiumLaunchOptionsBase(launchOptions, { chromePath: CHROME_PATH }) -} diff --git a/apps/backend/src/services/session/session-service-store.ts b/apps/backend/src/services/session/session-service-store.ts deleted file mode 100644 index 49e28e0c..00000000 --- a/apps/backend/src/services/session/session-service-store.ts +++ /dev/null @@ -1,205 +0,0 @@ -import crypto from 'node:crypto' -import fs from 'node:fs/promises' -import path from 'node:path' - -import { - BROWSER_SESSION_DATA_ROOT, - REDEEM_SUCCESS_AUTO_CLOSE_MS, -} from './session-service-config.js' -import { SessionServiceError } from './session-service-runtime.js' -import { - resolveInitialQrSessionState, - SESSION_TTL_MS, -} from './session-state.js' - -type BrowserContextLike = { - close?: () => Promise -} - -export type TencentBrowserSession = Record & { - sessionId: string - loginType: string - status: string - notice: string - createdAt: string - updatedAt: string - expiresAt: number - autoCloseAt: number - autoCloseTimer: ReturnType | null - sessionDir: string - browserContext: any - page: any - browserContextClosed?: boolean - presentationSyncAttempts: number -} - -type RegisterSessionInput = { - loginType: string - browserContext: BrowserContextLike - page: unknown -} - -type CloseSessionDependency = { - closeSession: (session: TencentBrowserSession, options: { markClosed: boolean }) => Promise -} - -type CloseSessionOptions = { - markClosed: boolean - persistSessionState: (session: TencentBrowserSession) => Promise - closeBrowserIfIdle: () => Promise -} - -type PersistSessionStateOptions = { - buildSessionPayload: (session: TencentBrowserSession) => unknown -} - -const sessions = new Map() - -export async function registerTencentBrowserSession({ - loginType, - browserContext, - page, -}: RegisterSessionInput): Promise { - const sessionId = createSessionId() - const sessionDir = path.join(BROWSER_SESSION_DATA_ROOT, sessionId) - await fs.mkdir(sessionDir, { recursive: true }) - - const now = new Date().toISOString() - const session = { - sessionId, - loginType, - status: 'created', - notice: '浏览器会话已创建', - createdAt: now, - updatedAt: now, - expiresAt: Date.now() + SESSION_TTL_MS, - autoCloseAt: 0, - autoCloseTimer: null, - sessionDir, - browserContext, - page, - qrImageBase64: '', - qrImagePath: '', - qrUpdatedAt: '', - lastState: null, - lastRedeem: null, - lastReview: null, - lastError: '', - presentationSyncAttempts: 0, - } - - sessions.set(sessionId, session) - return session -} - -export function listActiveTencentBrowserSessions(): TencentBrowserSession[] { - return [...sessions.values()] -} - -export async function getRequiredSession(sessionId: unknown): Promise { - const key = String(sessionId || '').trim() - - if (!key) { - throw new SessionServiceError('缺少 sessionId', { - statusCode: 400, - errorCode: 'missing_session_id', - }) - } - - const session = sessions.get(key) - - if (!session) { - throw new SessionServiceError('浏览器会话不存在或已过期', { - statusCode: 404, - errorCode: 'session_not_found', - }) - } - - return session -} - -export function applyInitialQrSessionState(session: TencentBrowserSession): void { - resetTencentSessionAutoClose(session) - Object.assign(session, resolveInitialQrSessionState(session.loginType)) -} - -export function armRedeemedSessionAutoClose(session: TencentBrowserSession, { closeSession }: CloseSessionDependency): void { - resetTencentSessionAutoClose(session) - - const autoCloseAt = Date.now() + REDEEM_SUCCESS_AUTO_CLOSE_MS - session.autoCloseAt = autoCloseAt - session.expiresAt = autoCloseAt - session.autoCloseTimer = setTimeout(() => { - void closeSession(session, { markClosed: true }).catch(() => { - // ignore delayed auto-close failures - }) - }, REDEEM_SUCCESS_AUTO_CLOSE_MS) -} - -export function resetTencentSessionAutoClose(session?: TencentBrowserSession | null): void { - if (session?.autoCloseTimer) { - clearTimeout(session.autoCloseTimer) - session.autoCloseTimer = null - } - - if (session) { - session.autoCloseAt = 0 - } -} - -export async function cleanupExpiredTencentBrowserSessions({ closeSession }: CloseSessionDependency): Promise { - const expired = listActiveTencentBrowserSessions().filter((session) => session.expiresAt <= Date.now()) - - await Promise.all(expired.map((session) => closeSession(session, { markClosed: false }))) -} - -export async function closeSession( - session: TencentBrowserSession, - { - markClosed, - persistSessionState, - closeBrowserIfIdle, - }: CloseSessionOptions, -): Promise { - resetTencentSessionAutoClose(session) - sessions.delete(session.sessionId) - - try { - await session.browserContext?.close() - } catch { - // ignore browser context close failures - } - - session.browserContextClosed = true - session.updatedAt = new Date().toISOString() - - if (markClosed) { - session.status = 'closed' - session.notice = '浏览器会话已关闭' - await persistSessionState(session) - } - - await closeBrowserIfIdle() -} - -export async function persistSessionState( - session: TencentBrowserSession, - { buildSessionPayload }: PersistSessionStateOptions, -): Promise { - await fs.mkdir(session.sessionDir, { recursive: true }) - await fs.writeFile( - path.join(session.sessionDir, 'session.json'), - JSON.stringify(buildSessionPayload(session), null, 2), - 'utf8', - ) -} - -function createSessionId(): string { - let sessionId = '' - - do { - sessionId = `txbs-${crypto.randomBytes(6).toString('hex')}` - } while (sessions.has(sessionId)) - - return sessionId -} diff --git a/apps/backend/src/services/session/session-state.ts b/apps/backend/src/services/session/session-state.ts deleted file mode 100644 index 9fa29631..00000000 --- a/apps/backend/src/services/session/session-state.ts +++ /dev/null @@ -1,137 +0,0 @@ -import { getLoginTypeLabel, type LoginType } from './session-browser-config.js' - -export const SESSION_TTL_MS = 30 * 60 * 1000 - -type InitialQrSessionStateOptions = { - updatedAt?: string - nowMs?: number - sessionTtlMs?: number - loginTypeLabel?: string -} - -type HostState = { - loginText?: string - unloginVisible?: boolean - loginedVisible?: boolean -} - -type QrState = { - expired?: boolean - scanned?: boolean -} - -type ActivityInfo = { - role?: { - ready?: boolean - roleName?: string - } -} - -type SessionStatusInput = { - hostState: HostState - qrState: QrState - credentialReady?: boolean - activityInfo?: ActivityInfo | null -} - -type SessionNoticeInput = SessionStatusInput & { - loginType: LoginType | string -} - -type CookieMap = { - openid?: string - access_token?: string - appid?: string -} - -export function resolveInitialQrSessionState( - loginType: LoginType | string, - { - updatedAt = new Date().toISOString(), - nowMs = Date.now(), - sessionTtlMs = SESSION_TTL_MS, - loginTypeLabel = getLoginTypeLabel(loginType), - }: InitialQrSessionStateOptions = {}, -) { - return { - status: 'waiting_scan', - notice: `请使用${loginTypeLabel}扫码登录`, - updatedAt, - expiresAt: nowMs + sessionTtlMs, - } -} - -export function resolveTencentSessionExpiresAt( - session: { autoCloseAt?: number | string } | null | undefined, - nowMs = Date.now(), - sessionTtlMs = SESSION_TTL_MS, -): number { - const autoCloseAt = Number(session?.autoCloseAt || 0) - if (autoCloseAt > 0) { - return autoCloseAt - } - - return nowMs + sessionTtlMs -} - -export function shouldRefreshLoggedInPresentation(hostState: HostState): boolean { - const loginText = String(hostState?.loginText || '').trim() - - return Boolean(hostState?.unloginVisible || !hostState?.loginedVisible || !loginText) -} - -export function resolveSessionStatus({ hostState, qrState, credentialReady, activityInfo }: SessionStatusInput): string { - if (hostState.loginedVisible || credentialReady) { - if (credentialReady && activityInfo?.role?.ready) { - return 'ready_to_redeem' - } - - return 'logged_in' - } - - if (qrState.expired) { - return 'expired' - } - - if (qrState.scanned) { - return 'scanned' - } - - return 'waiting_scan' -} - -export function resolveSessionNotice({ - hostState, - qrState, - credentialReady, - activityInfo, - loginType, -}: SessionNoticeInput): string { - const loginTypeLabel = getLoginTypeLabel(loginType) - - if (credentialReady) { - if (activityInfo?.role?.ready && activityInfo.role.roleName) { - return `登录已完成,当前角色 ${activityInfo.role.roleName}` - } - - return '登录已完成,正在同步角色信息' - } - - if (hostState.loginedVisible) { - return '页面已识别登录态' - } - - if (qrState.expired) { - return '二维码已失效,准备刷新' - } - - if (qrState.scanned) { - return '二维码已扫描,请在手机上确认登录' - } - - return `请使用${loginTypeLabel}扫码登录` -} - -export function hasRedeemCredential(cookieMap: CookieMap): boolean { - return Boolean(cookieMap.openid && cookieMap.access_token && cookieMap.appid) -} diff --git a/apps/backend/src/services/session/session-wx.ts b/apps/backend/src/services/session/session-wx.ts deleted file mode 100644 index e66fcab7..00000000 --- a/apps/backend/src/services/session/session-wx.ts +++ /dev/null @@ -1,377 +0,0 @@ -/// - -import fs from 'node:fs/promises' - -import { downloadRemoteQrImage, logBrowserSessionDebug, waitForFrame } from './session-login-shared.js' - -type LocatorLike = { - first: () => LocatorLike - waitFor: (options?: { state?: string; timeout?: number }) => Promise - screenshot: (options: { path: string }) => Promise - isVisible: () => Promise - click: (options?: { timeout?: number }) => Promise - evaluate: (callback: (element: Element) => T | Promise) => Promise -} - -type FrameLike = { - url: () => string - locator: (selector: string) => LocatorLike - waitForTimeout: (milliseconds: number) => Promise - evaluate: (callback: any) => Promise -} - -type PageLike = { - frames: () => FrameLike[] - waitForTimeout: (milliseconds: number) => Promise - context: () => { - request: { - get: (url: string, options?: any) => Promise<{ - ok: () => boolean - status: () => number - body: () => Promise - }> - } - } -} - -type WxLoginViewState = { - qrVisible: boolean - quickLoginVisible: boolean - switchToNormalVisible: boolean - qrCandidates: Array<{ - index: number - visible: boolean - src: string - naturalWidth: number - }> - bodyText: string -} - -type WxQrState = { - visible: boolean - qrVisible?: boolean - scanned: boolean - expired: boolean - message: string - frameUrl?: string -} - -export async function ensureWxLoginReady(page: PageLike): Promise { - try { - const frame = await waitForFrame( - page, - (item) => item.url().includes('open.weixin.qq.com/connect/qrconnect'), - 10_000, - ) - - for (let attempt = 1; attempt <= 12; attempt += 1) { - const viewState = await inspectWxLoginView(frame) - logBrowserSessionDebug('wx.ensureQrMode.viewState', { - attempt, - frameUrl: frame.url(), - qrVisible: viewState.qrVisible, - quickLoginVisible: viewState.quickLoginVisible, - switchToNormalVisible: viewState.switchToNormalVisible, - qrCandidates: viewState.qrCandidates, - bodyText: viewState.bodyText.slice(0, 160), - }) - - if (viewState.qrVisible) { - return - } - - if ( - viewState.quickLoginVisible || - viewState.switchToNormalVisible || - /使用其他头像、昵称或账号|微信快捷登录/.test(viewState.bodyText) - ) { - const switched = await switchWxQuickLoginToQr(frame) - logBrowserSessionDebug('wx.ensureQrMode.switchToNormal', { - attempt, - switched, - }) - - if (switched) { - await frame.waitForTimeout(700) - continue - } - } - - await frame.waitForTimeout(400) - } - } catch { - // ignore wx quick-login switch failures; the screenshot retry path will try again - } -} - -export async function captureWxQrImage(page: PageLike, qrImagePath: string): Promise { - const qrLocator = await findWxQrLocator(page) - const frame = await waitForFrame( - page, - (item) => item.url().includes('open.weixin.qq.com/connect/qrconnect'), - 10_000, - ) - - const qrUrl = await resolveWxQrImageUrl(frame) - logBrowserSessionDebug('wx.capture.qrUrl', { - qrUrl, - frameUrl: frame.url(), - }) - - if (qrUrl) { - const downloaded = await downloadRemoteQrImage(page, qrUrl, qrImagePath, frame.url(), 'wx') - logBrowserSessionDebug('wx.capture.downloadResult', { - qrUrl, - downloaded: Buffer.isBuffer(downloaded) && downloaded.length > 0, - qrImagePath, - }) - - if (Buffer.isBuffer(downloaded) && downloaded.length > 0) { - await fs.writeFile(qrImagePath, downloaded) - return - } - } - - logBrowserSessionDebug('wx.capture.fallbackScreenshot', { - qrImagePath, - }) - await qrLocator.screenshot({ path: qrImagePath }) -} - -export async function findWxQrLocator(page: PageLike): Promise { - await ensureWxLoginReady(page) - - const frame = await waitForFrame( - page, - (item) => item.url().includes('open.weixin.qq.com/connect/qrconnect'), - ) - const viewState = await inspectWxLoginView(frame) - logBrowserSessionDebug('wx.findQrLocator.beforeWait', { - frameUrl: frame.url(), - qrVisible: viewState.qrVisible, - qrCandidates: viewState.qrCandidates, - bodyText: viewState.bodyText.slice(0, 160), - }) - - const locator = frame.locator('.js_qrcode_img:visible').first() - await locator.waitFor({ state: 'visible', timeout: 15_000 }) - logBrowserSessionDebug('wx.findQrLocator.visibleReady', { - frameUrl: frame.url(), - }) - return locator -} - -export async function extractWxQrState(page: Pick): Promise { - const frame = page.frames().find((item) => item.url().includes('open.weixin.qq.com/connect/qrconnect')) - - if (!frame) { - return { - visible: false, - scanned: false, - expired: false, - message: '', - } - } - - try { - const viewState = await inspectWxLoginView(frame) - const qrVisible = viewState.qrVisible - const bodyText = viewState.bodyText - - return { - visible: true, - qrVisible, - scanned: !qrVisible && /扫描成功|请在手机上确认登录/.test(bodyText), - expired: qrVisible ? false : /二维码失效|已失效/.test(bodyText), - message: bodyText.slice(0, 200), - frameUrl: frame.url(), - } - } catch { - return { - visible: true, - qrVisible: false, - scanned: false, - expired: false, - message: '', - frameUrl: frame.url(), - } - } -} - -async function inspectWxLoginView(frame: FrameLike): Promise { - return frame - .evaluate(() => { - const isVisible = (element: Element | null) => { - if (!(element instanceof HTMLElement)) { - return false - } - - const style = window.getComputedStyle(element) - const rect = element.getBoundingClientRect() - return ( - style.display !== 'none' && - style.visibility !== 'hidden' && - style.opacity !== '0' && - rect.width > 0 && - rect.height > 0 - ) - } - - const quickLogin = document.querySelector('.js_quick_login') - const switchToNormal = document.querySelector('.js_switchToNormal') - const qrCandidates = Array.from(document.querySelectorAll('.js_qrcode_img')).map((item, index) => { - if (!(item instanceof HTMLImageElement)) { - return { - index, - visible: false, - src: '', - naturalWidth: 0, - } - } - - return { - index, - visible: isVisible(item), - src: String(item.currentSrc || item.src || item.getAttribute('src') || '').trim(), - naturalWidth: Number(item.naturalWidth || 0), - } - }) - const visibleQrCandidate = qrCandidates.find((item) => item.visible && item.naturalWidth > 0) - - return { - qrVisible: Boolean(visibleQrCandidate), - quickLoginVisible: isVisible(quickLogin), - switchToNormalVisible: isVisible(switchToNormal), - qrCandidates, - bodyText: String(document.body?.textContent || '').replace(/\s+/g, ' ').trim(), - } - }) - .catch(() => ({ - qrVisible: false, - quickLoginVisible: false, - switchToNormalVisible: false, - qrCandidates: [], - bodyText: '', - })) -} - -async function switchWxQuickLoginToQr(frame: FrameLike): Promise { - const switcherCandidates = [ - frame.locator('.js_switchToNormal:visible').first(), - frame.locator('.js_switchToNormal').first(), - frame.locator('button:has-text("使用其他头像、昵称或账号")').first(), - frame.locator('text=使用其他头像、昵称或账号').first(), - ] - - for (const candidate of switcherCandidates) { - const visible = await candidate.isVisible().catch(() => false) - - if (!visible) { - continue - } - - logBrowserSessionDebug('wx.switchToNormal.candidateVisible', { - candidateIndex: switcherCandidates.indexOf(candidate), - }) - - const clicked = - (await candidate - .click({ timeout: 2_000 }) - .then(() => true) - .catch(() => false)) || - (await candidate - .evaluate((element) => { - if (!(element instanceof HTMLElement)) { - return false - } - - element.click() - return true - }) - .catch(() => false)) - - if (clicked) { - logBrowserSessionDebug('wx.switchToNormal.clicked', { - candidateIndex: switcherCandidates.indexOf(candidate), - }) - return true - } - } - - return frame - .evaluate(() => { - const candidates = Array.from(document.querySelectorAll('button, a, div')).filter((element) => { - if (!(element instanceof HTMLElement)) { - return false - } - - const text = String(element.textContent || '').replace(/\s+/g, ' ').trim() - const style = window.getComputedStyle(element) - const rect = element.getBoundingClientRect() - - return ( - /使用其他头像、昵称或账号/.test(text) && - style.display !== 'none' && - style.visibility !== 'hidden' && - style.opacity !== '0' && - rect.width > 0 && - rect.height > 0 - ) - }) - - const target = candidates[0] - - if (!(target instanceof HTMLElement)) { - return false - } - - target.click() - return true - }) - .catch(() => false) -} - -async function resolveWxQrImageUrl(frame: FrameLike): Promise { - return frame - .evaluate(() => { - const isVisible = (element: Element | null) => { - if (!(element instanceof HTMLElement)) { - return false - } - - const style = window.getComputedStyle(element) - const rect = element.getBoundingClientRect() - return ( - style.display !== 'none' && - style.visibility !== 'hidden' && - style.opacity !== '0' && - rect.width > 0 && - rect.height > 0 - ) - } - - const qrImages = Array.from(document.querySelectorAll('.js_qrcode_img')) - const qrImage = - qrImages.find((item) => item instanceof HTMLImageElement && isVisible(item)) || - qrImages.find((item) => item instanceof HTMLImageElement) || - null - - if (!(qrImage instanceof HTMLImageElement)) { - return '' - } - - return String(qrImage.currentSrc || qrImage.src || qrImage.getAttribute('src') || '').trim() - }) - .then((src) => { - if (!src) { - return '' - } - - try { - return new URL(src, frame.url()).toString() - } catch { - return '' - } - }) - .catch(() => '') -} diff --git a/apps/backend/src/services/session/session.test.ts b/apps/backend/src/services/session/session.test.ts deleted file mode 100644 index c5143d8e..00000000 --- a/apps/backend/src/services/session/session.test.ts +++ /dev/null @@ -1,376 +0,0 @@ -import test from 'node:test' -import assert from 'node:assert/strict' - -import { - normalizeLoginType, - parseViewportSpec, -} from './session-browser-config.js' -import { normalizeTencentCookieMap } from './session-cookie-map.js' -import { - createEmptyActivityInfo, - ensureActivityInfoReady, - ensureLoggedInPresentation, - extractActivityInfo, -} from './session-activity.js' -import { - captureQrImageWithRetry, - ensureLoginTab, -} from './session-page.js' -import { buildArtifactsPayload, buildRedeemPayload } from './session-payload.js' -import { ensureReviewScreenshot } from './session-review.js' -import { resolveInitialQrSessionState } from './session-state.js' -import { - REDEEM_SUCCESS_AUTO_CLOSE_MS, - resolveTencentSessionExpiresAt, -} from './session.js' - -test('resolveTencentSessionExpiresAt uses normal ttl when no auto-close is armed', () => { - const nowMs = Date.UTC(2026, 3, 14, 9, 30, 0) - assert.equal( - resolveTencentSessionExpiresAt({ autoCloseAt: 0 }, nowMs), - nowMs + (30 * 60 * 1000), - ) -}) - -test('resolveTencentSessionExpiresAt keeps redeemed auto-close deadline once armed', () => { - const nowMs = Date.UTC(2026, 3, 14, 9, 30, 0) - const autoCloseAt = nowMs + REDEEM_SUCCESS_AUTO_CLOSE_MS - - assert.equal( - resolveTencentSessionExpiresAt({ autoCloseAt }, nowMs + 60_000), - autoCloseAt, - ) -}) - -test('normalizeLoginType collapses aliases and falls back to qq', () => { - assert.equal(normalizeLoginType(' qc '), 'qq') - assert.equal(normalizeLoginType('wechat'), 'wx') - assert.equal(normalizeLoginType('unknown'), 'qq') - assert.equal(normalizeLoginType(''), 'qq') -}) - -test('parseViewportSpec accepts valid viewport strings and rejects invalid values', () => { - assert.deepEqual(parseViewportSpec('1920x1080'), { width: 1920, height: 1080 }) - assert.deepEqual(parseViewportSpec('1920x1080x24'), { width: 1920, height: 1080 }) - assert.equal(parseViewportSpec('799x600'), null) - assert.equal(parseViewportSpec('broken'), null) -}) - -test('buildRedeemPayload strips internal paths unless explicitly requested', () => { - const redeem = { - code: 'ABC123', - area: '微信区', - proofMode: 'basic', - attempts: [ - { - attempt: 1, - redeem: { success: false, message: '验证码错误' }, - }, - ], - final: { - attempt: 2, - redeem: { success: true, message: '兑换成功' }, - }, - finishedAt: '2026-04-14T09:40:00.000Z', - screenshotPath: '/tmp/redeem.png', - htmlPath: '/tmp/redeem.html', - resultPath: '/tmp/redeem.json', - } - - assert.deepEqual(buildRedeemPayload(redeem), { - code: 'ABC123', - area: '微信区', - proofMode: 'basic', - attempts: [ - { - attempt: 1, - redeem: { success: false, message: '验证码错误' }, - }, - ], - final: { - attempt: 2, - redeem: { success: true, message: '兑换成功' }, - }, - finishedAt: '2026-04-14T09:40:00.000Z', - }) - - assert.deepEqual(buildRedeemPayload(redeem, { includeInternalPaths: true }), { - code: 'ABC123', - area: '微信区', - proofMode: 'basic', - attempts: [ - { - attempt: 1, - redeem: { success: false, message: '验证码错误' }, - }, - ], - final: { - attempt: 2, - redeem: { success: true, message: '兑换成功' }, - }, - finishedAt: '2026-04-14T09:40:00.000Z', - screenshotPath: '/tmp/redeem.png', - htmlPath: '/tmp/redeem.html', - resultPath: '/tmp/redeem.json', - }) -}) - -test('buildArtifactsPayload only exposes internal artifact paths in debug mode', () => { - const session = { - sessionDir: '/tmp/session', - qrImagePath: '/tmp/session/qq-qr.png', - qrImageBase64: 'base64data', - lastReview: { - screenshotPath: '/tmp/session/review.png', - }, - lastRedeem: { - screenshotPath: '/tmp/session/redeem.png', - htmlPath: '/tmp/session/redeem.html', - resultPath: '/tmp/session/redeem.json', - }, - } - - assert.deepEqual(buildArtifactsPayload(session), { - hasQrImage: true, - hasReviewScreenshot: true, - hasScreenshot: true, - }) - - assert.deepEqual(buildArtifactsPayload(session, { includeInternalPaths: true }), { - hasQrImage: true, - hasReviewScreenshot: true, - hasScreenshot: true, - sessionDir: '/tmp/session', - qrImagePath: '/tmp/session/qq-qr.png', - screenshotPath: '/tmp/session/redeem.png', - htmlPath: '/tmp/session/redeem.html', - resultPath: '/tmp/session/redeem.json', - reviewScreenshotPath: '/tmp/session/review.png', - }) -}) - -test('normalizeTencentCookieMap fills required defaults and derived proxy cookie', () => { - assert.deepEqual( - normalizeTencentCookieMap([ - { name: 'openid', value: 'openid-1' }, - { name: 'access_token', value: 'token-1' }, - { name: '', value: 'ignored' }, - ]), - { - openid: 'openid-1', - access_token: 'token-1', - acctype: 'qc', - appid: '101491592', - iegams_milo_proxylogin_qc: '101491592_$$_openid-1_$$_token-1', - }, - ) -}) - -test('resolveInitialQrSessionState creates waiting-scan snapshot with ttl', () => { - assert.deepEqual( - resolveInitialQrSessionState('wx', { - updatedAt: '2026-04-14T09:30:00.000Z', - nowMs: 1_700_000_000_000, - sessionTtlMs: 1_800_000, - }), - { - status: 'waiting_scan', - notice: '请使用微信扫码登录', - updatedAt: '2026-04-14T09:30:00.000Z', - expiresAt: 1_700_001_800_000, - }, - ) -}) - -test('ensureReviewScreenshot writes screenshot state and skips unchanged signature', async () => { - const screenshotCalls = [] - const session = { - sessionId: 'txbs-demo', - loginType: 'qq', - sessionDir: '/tmp/session-demo', - page: { - async screenshot(options) { - screenshotCalls.push(options) - }, - }, - lastReview: null, - } - - const activityInfo = { - role: { - roleId: '1001', - roleName: '测试角色', - }, - } - - assert.equal(await ensureReviewScreenshot(session, activityInfo), true) - assert.equal(screenshotCalls.length, 1) - assert.equal(session.lastReview.roleId, '1001') - assert.equal(session.lastReview.roleName, '测试角色') - assert.equal(session.lastReview.signature, 'qq|1001|测试角色') - assert.match(session.lastReview.screenshotPath, /role-review\.png$/) - - assert.equal(await ensureReviewScreenshot(session, activityInfo), false) - assert.equal(screenshotCalls.length, 1) -}) - -test('ensureLoginTab clicks login UI and delegates to qq readiness', async () => { - const calls = [] - const makeLocator = (name) => ({ - async count() { - return 1 - }, - first() { - const firstLocator = { - async count() { - return 1 - }, - async click(options) { - calls.push({ type: 'click', name, options }) - }, - } - return firstLocator - }, - }) - - const page = { - locator(selector) { - if (selector === '#unlogin') { - return makeLocator('loginButton') - } - if (selector === '.qc-tab') { - return makeLocator('qqTab') - } - throw new Error(`unexpected selector: ${selector}`) - }, - async waitForTimeout(ms) { - calls.push({ type: 'wait', ms }) - }, - } - - await ensureLoginTab(page, 'qq', { - async ensureQqLoginReady() { - calls.push({ type: 'ensure', name: 'qq' }) - }, - }) - - assert.deepEqual(calls, [ - { type: 'click', name: 'loginButton', options: { timeout: 2_000 } }, - { type: 'click', name: 'qqTab', options: { timeout: 2_000 } }, - { type: 'wait', ms: 250 }, - { type: 'ensure', name: 'qq' }, - ]) -}) - -test('captureQrImageWithRetry retries once after retryable error', async () => { - const calls = [] - const page = { - async waitForTimeout(ms) { - calls.push({ type: 'wait', ms }) - }, - } - - let attempt = 0 - await captureQrImageWithRetry(page, 'wx', '/tmp/wx-qr.png', { - async captureWxQrImage() { - attempt += 1 - calls.push({ type: 'capture', attempt }) - if (attempt === 1) { - throw new Error('Timeout while waiting for qr') - } - }, - async ensureLoginTab(_page, loginType) { - calls.push({ type: 'ensureLoginTab', loginType }) - }, - isRetryableQrCaptureError(error) { - return String(error.message).includes('Timeout') - }, - logBrowserSessionDebug() {}, - }) - - assert.equal(attempt, 2) - assert.deepEqual(calls, [ - { type: 'capture', attempt: 1 }, - { type: 'wait', ms: 500 }, - { type: 'ensureLoginTab', loginType: 'wx' }, - { type: 'capture', attempt: 2 }, - ]) -}) - -test('ensureLoggedInPresentation reloads page when logged-in host state is stale', async () => { - const session = { - page: { marker: 'page' }, - presentationSyncAttempts: 0, - } - const calls = [] - let hostCall = 0 - - const result = await ensureLoggedInPresentation(session, { - credentialReady: true, - activityUrl: 'https://example.com/activity', - async extractHostState() { - hostCall += 1 - return hostCall === 1 - ? { unloginVisible: true, loginedVisible: false, loginText: '' } - : { unloginVisible: false, loginedVisible: true, loginText: '已登录' } - }, - async reloadActivityPageForPresentation(_page, activityUrl) { - calls.push(activityUrl) - }, - }) - - assert.equal(session.presentationSyncAttempts, 1) - assert.deepEqual(calls, ['https://example.com/activity']) - assert.deepEqual(result, { - reloaded: true, - hostState: { unloginVisible: false, loginedVisible: true, loginText: '已登录' }, - }) -}) - -test('ensureActivityInfoReady triggers render and polls until role becomes ready', async () => { - const calls = [] - const page = { - async waitForTimeout(ms) { - calls.push({ type: 'wait', ms }) - }, - } - const snapshots = [ - { role: { ready: false, roleName: '' } }, - { role: { ready: true, roleName: '测试角色' } }, - ] - let nowValue = 0 - - const result = await ensureActivityInfoReady(page, { - triggerRender: true, - timeoutMs: 1000, - async triggerActivityRoleRender() { - calls.push({ type: 'trigger' }) - }, - async extractActivityInfo() { - calls.push({ type: 'extract' }) - return snapshots.shift() - }, - now() { - nowValue += 300 - return nowValue - }, - }) - - assert.deepEqual(calls, [ - { type: 'trigger' }, - { type: 'extract' }, - { type: 'wait', ms: 400 }, - { type: 'extract' }, - ]) - assert.deepEqual(result, { role: { ready: true, roleName: '测试角色' } }) -}) - -test('extractActivityInfo falls back to empty structure when page evaluation fails', async () => { - const page = { - async evaluate() { - throw new Error('page closed') - }, - } - - assert.deepEqual(await extractActivityInfo(page), createEmptyActivityInfo()) -}) diff --git a/apps/backend/src/services/session/session.ts b/apps/backend/src/services/session/session.ts deleted file mode 100644 index 928495b0..00000000 --- a/apps/backend/src/services/session/session.ts +++ /dev/null @@ -1,410 +0,0 @@ -import { - getLoginTypeLabel, - normalizeLoginType, -} from './session-browser-config.js' -import { logBrowserSessionDebug } from './session-login-shared.js' -import { - loadCookieMapFromContext, -} from './session-cookie-map.js' -import { - ensureActivityInfoReady, - ensureLoggedInPresentation, - extractActivityInfo, -} from './session-activity.js' -import { - buildSessionPayload as buildSessionPayloadBase, -} from './session-payload.js' -import { - captureSessionQr, - ensureLoginTab, - extractHostState, - extractQrState, - reloadActivityPageForPresentation, -} from './session-page.js' -import { - saveRedeemArtifacts, -} from './session-proof.js' -import { - ensureReviewScreenshot, -} from './session-review.js' -import { - capturePageCaptchaForOcr, - isCaptchaRejectedResult, - prepareRedeemCodeFill, - refreshPageCaptcha, - runTencentBrowserRedeem, - submitRedeemInBrowser, -} from './session-redeem.js' -import { - ensureWxLoginReady, -} from './session-wx.js' -import { recognizeTencentCaptcha } from './ocr.js' -import { - hasRedeemCredential, - resolveInitialQrSessionState, - resolveSessionNotice, - resolveSessionStatus, - resolveTencentSessionExpiresAt, -} from './session-state.js' -import { - ACTIVITY_URL, - DEFAULT_VIEWPORT, - REDEEM_SUCCESS_AUTO_CLOSE_MS, - SESSION_DEBUG_ENABLED, -} from './session-service-config.js' -import { - closeBrowserIfIdle, - ensureBrowser, - resolveBrowserLaunchOptions, - SessionServiceError, - warmupTencentBrowser, -} from './session-service-runtime.js' -import { - applyInitialQrSessionState, - armRedeemedSessionAutoClose, - cleanupExpiredTencentBrowserSessions, - closeSession as closeStoredSession, - getRequiredSession, - listActiveTencentBrowserSessions, - persistSessionState as persistStoredSessionState, - registerTencentBrowserSession, - resetTencentSessionAutoClose, -} from './session-service-store.js' -import type { TencentBrowserSession } from './session-service-store.js' - -type JsonObject = Record - -type IncludeQrImageOptions = { - includeQrImage?: boolean -} - -type CloseAllSessionsOptions = { - markClosed?: boolean -} - -type CloseManagedSessionOptions = { - markClosed: boolean -} - -export { resolveTencentSessionExpiresAt } -export { REDEEM_SUCCESS_AUTO_CLOSE_MS, warmupTencentBrowser } - -export async function createTencentBrowserSession(payload: JsonObject = {}): Promise { - await cleanupExpiredTencentBrowserSessions({ closeSession: closeManagedSession }) - - const loginType = normalizeLoginType(payload.loginType || payload.qrType) - - const browser = await ensureBrowser() - const browserContext = await browser.newContext({ - viewport: DEFAULT_VIEWPORT, - deviceScaleFactor: 1, - }) - - const page = await browserContext.newPage() - page.setDefaultTimeout(20_000) - - const session = await registerTencentBrowserSession({ - loginType, - browserContext, - page, - }) - const sessionId = session.sessionId - - try { - await page.goto(ACTIVITY_URL, { waitUntil: 'domcontentloaded' }) - await ensureLoginTab(page, loginType) - await captureSessionQr(session, { ensureLoginReady: false }) - applyInitialQrSessionState(session) - await persistSessionState(session) - void refreshTencentBrowserSession(sessionId).catch((error) => { - logBrowserSessionDebug('createSession.backgroundRefresh.error', { - sessionId, - message: error instanceof Error ? error.message : String(error), - }) - }) - return buildSessionPayload(session) - } catch (error) { - session.status = 'failed' - session.notice = error instanceof Error ? error.message : `${getLoginTypeLabel(loginType)} 浏览器会话初始化失败` - session.lastError = session.notice - session.updatedAt = new Date().toISOString() - await persistSessionState(session) - throw error - } -} - -export async function getTencentBrowserSession( - sessionId: unknown, - options: IncludeQrImageOptions = {}, -): Promise { - return refreshTencentBrowserSession(sessionId, options) -} - -export async function getTencentBrowserSessionSummary(sessionId: unknown): Promise { - return refreshTencentBrowserSession(sessionId, { includeQrImage: false }) -} - -export async function reloadTencentBrowserSession(sessionId: unknown): Promise { - await cleanupExpiredTencentBrowserSessions({ closeSession: closeManagedSession }) - - const session = await getRequiredSession(sessionId) - - if (session.browserContextClosed) { - throw new SessionServiceError('浏览器会话已关闭', { - statusCode: 410, - errorCode: 'session_closed', - }) - } - - try { - await reloadActivityPageForPresentation(session.page, ACTIVITY_URL) - - const cookieMap = await loadCookieMapFromContext(session.browserContext) - const credentialReady = hasRedeemCredential(cookieMap) - - if (!credentialReady) { - session.qrImageBase64 = '' - session.qrImagePath = '' - session.qrUpdatedAt = '' - await ensureLoginTab(session.page, session.loginType) - await captureSessionQr(session) - } - - session.notice = credentialReady - ? '后端页面已刷新,正在同步最新状态' - : `已刷新${getLoginTypeLabel(session.loginType)}登录页,准备重新抓取二维码` - session.updatedAt = new Date().toISOString() - - return await refreshTencentBrowserSession(sessionId) - } catch (error) { - session.status = 'failed' - session.notice = error instanceof Error ? error.message : '刷新后端页面失败' - session.lastError = session.notice - session.updatedAt = new Date().toISOString() - await persistSessionState(session) - throw error - } -} - -export async function refreshTencentBrowserSession( - sessionId: unknown, - { includeQrImage = true }: IncludeQrImageOptions = {}, -): Promise { - await cleanupExpiredTencentBrowserSessions({ closeSession: closeManagedSession }) - - const session = await getRequiredSession(sessionId) - - if (session.browserContextClosed) { - throw new SessionServiceError('浏览器会话已关闭', { - statusCode: 410, - errorCode: 'session_closed', - }) - } - - try { - let hostState = await extractHostState(session.page) - const cookieMap = await loadCookieMapFromContext(session.browserContext) - const credentialReady = hasRedeemCredential(cookieMap) - - if (credentialReady) { - const presentation = await ensureLoggedInPresentation(session, { - hostState, - credentialReady, - activityUrl: ACTIVITY_URL, - }) - hostState = presentation.hostState - } else { - session.presentationSyncAttempts = 0 - } - - if (!credentialReady && session.loginType === 'wx') { - await ensureWxLoginReady(session.page) - } - - const qrState = await extractQrState(session.page, session.loginType) - const activityInfo = credentialReady - ? await ensureActivityInfoReady(session.page, { triggerRender: true }) - : await extractActivityInfo(session.page) - - session.lastState = { - hostState, - qrState, - credentialReady, - activityInfo, - cookieKeys: Object.keys(cookieMap), - } - session.status = resolveSessionStatus({ hostState, qrState, credentialReady, activityInfo }) - session.notice = resolveSessionNotice({ - hostState, - qrState, - credentialReady, - activityInfo, - loginType: session.loginType, - }) - - if (credentialReady && activityInfo?.role?.ready) { - await ensureReviewScreenshot(session, activityInfo) - } - - session.updatedAt = new Date().toISOString() - session.expiresAt = resolveTencentSessionExpiresAt(session) - - if (!session.qrImageBase64 || session.status === 'expired') { - if (session.status === 'expired') { - await reloadActivityPageForPresentation(session.page, ACTIVITY_URL) - await ensureLoginTab(session.page, session.loginType) - } - - await captureSessionQr(session) - if (session.status === 'expired') { - session.notice = '二维码已刷新,请重新扫码' - session.status = 'waiting_scan' - } - } - - await persistSessionState(session) - return buildSessionPayload(session, { includeQrImage }) - } catch (error) { - session.status = 'failed' - session.notice = error instanceof Error ? error.message : `查询 ${getLoginTypeLabel(session.loginType)} 浏览器会话状态失败` - session.lastError = session.notice - session.updatedAt = new Date().toISOString() - await persistSessionState(session) - return buildSessionPayload(session, { includeQrImage }) - } -} - -export async function redeemTencentBrowserSession(sessionId: unknown, payload: JsonObject = {}): Promise { - const session = await getRequiredSession(sessionId) - const code = String(payload.code || payload.sCdKey || '').trim() - const maxAttempts = Math.max(1, Math.min(Number(payload.maxAttempts || 6), 12)) - - if (!code) { - throw new SessionServiceError('缺少兑换码', { - statusCode: 400, - errorCode: 'missing_redeem_code', - }) - } - - const refreshed = await refreshTencentBrowserSession(sessionId) - - if (!['logged_in', 'ready_to_redeem', 'redeemed'].includes(refreshed.status)) { - throw new SessionServiceError(`当前会话还不能兑换,状态为 ${refreshed.status}`, { - statusCode: 409, - errorCode: 'session_not_ready', - }) - } - - resetTencentSessionAutoClose(session) - - await runTencentBrowserRedeem({ - session, - code, - maxAttempts, - ensureLoggedInPresentation: (redeemSession, options) => - ensureLoggedInPresentation(redeemSession as any, options as any), - ensureActivityInfoReady, - fillRedeemCodeInBrowser, - capturePageCaptchaForOcr: (page, options) => - capturePageCaptchaForOcr(page, { ...options, ensureActivityInfoReady }), - recognizeTencentCaptcha, - submitRedeemInBrowser: (page, redeemPayload) => - submitRedeemInBrowser(page, redeemPayload, { ensureActivityInfoReady }), - isCaptchaRejectedResult, - refreshPageCaptcha, - persistSessionState, - buildSessionPayload, - saveRedeemArtifacts, - activityUrl: ACTIVITY_URL, - }) - - armRedeemedSessionAutoClose(session, { closeSession: closeManagedSession }) - await persistSessionState(session) - - return { - ...buildSessionPayload(session), - redeem: session.lastRedeem, - } -} - -export async function closeTencentBrowserSession(sessionId: unknown): Promise { - const session = await getRequiredSession(sessionId) - await closeManagedSession(session, { markClosed: true }) - return { - sessionId, - closed: true, - } -} - -export async function closeAllTencentBrowserSessions( - { markClosed = true }: CloseAllSessionsOptions = {}, -): Promise { - const activeSessions = listActiveTencentBrowserSessions() - - await Promise.all(activeSessions.map((session) => closeManagedSession(session, { markClosed }))) - - return { - closedCount: activeSessions.length, - } -} - -export async function getTencentBrowserSessionScreenshotPath(sessionId: unknown): Promise { - const session = await getRequiredSession(sessionId) - const screenshotPath = session.lastRedeem?.screenshotPath - - if (!screenshotPath) { - throw new SessionServiceError('当前会话还没有兑换截图', { - statusCode: 404, - errorCode: 'screenshot_not_ready', - }) - } - - return screenshotPath -} - -export async function getTencentBrowserSessionReviewScreenshotPath(sessionId: unknown): Promise { - const session = await getRequiredSession(sessionId) - const screenshotPath = session.lastReview?.screenshotPath - - if (!screenshotPath) { - throw new SessionServiceError('当前会话还没有客服复核截图', { - statusCode: 404, - errorCode: 'review_screenshot_not_ready', - }) - } - - return screenshotPath -} - -async function persistSessionState(session: TencentBrowserSession): Promise { - await persistStoredSessionState(session, { buildSessionPayload }) -} - -function buildSessionPayload( - session: TencentBrowserSession, - { includeQrImage = true }: IncludeQrImageOptions = {}, -): JsonObject { - return buildSessionPayloadBase(session, { - activityUrl: ACTIVITY_URL, - includeQrImage, - sessionDebugEnabled: SESSION_DEBUG_ENABLED, - browserDebug: SESSION_DEBUG_ENABLED ? resolveBrowserLaunchOptions() : null, - }) -} - -async function fillRedeemCodeInBrowser(page: any, code: string): Promise { - await prepareRedeemCodeFill(page, code, { ensureActivityInfoReady }) -} - -async function closeManagedSession( - session: TencentBrowserSession, - { markClosed }: CloseManagedSessionOptions, -): Promise { - await closeStoredSession(session, { - markClosed, - persistSessionState, - closeBrowserIfIdle: () => closeBrowserIfIdle({ - activeSessionCount: listActiveTencentBrowserSessions().length, - }), - }) -} diff --git a/apps/backend/src/startup/bootstrap-kuaishou.ts b/apps/backend/src/startup/bootstrap-kuaishou.ts deleted file mode 100644 index f14d45a0..00000000 --- a/apps/backend/src/startup/bootstrap-kuaishou.ts +++ /dev/null @@ -1,71 +0,0 @@ -import { runDatabaseMigrations } from "../db/migrate.js"; -import { ensureAdminUsersBootstrapped } from "../services/admin/admin-auth-service.js"; -import { ensureFulfillmentCatalogBootstrapped } from "../services/bootstrap/fulfillment-bootstrap-service.js"; -import { startScheduledJobs } from "../services/scheduler/scheduler-service.js"; -import { logError, logInfo } from "../utils/logger.js"; -import { formatStartupError, type StartupState } from "./state.js"; - -const CORE_BOOT_RETRY_DELAY_MS = 5_000; - -export async function bootstrapKuaishouCoreServices( - startupState: StartupState, - isShutdownStarted: () => boolean -) { - if (startupState.core.running || isShutdownStarted()) { - return; - } - - startupState.core.running = true; - startupState.browser.status = "skipped"; - startupState.browser.message = "kuaishou lite mode"; - startupState.ocr.status = "skipped"; - startupState.ocr.message = "kuaishou lite mode"; - - while (!isShutdownStarted() && !startupState.core.ready) { - startupState.phase = - startupState.core.attemptCount === 0 ? "starting" : "retrying"; - startupState.core.attemptCount += 1; - startupState.core.lastAttemptAt = new Date().toISOString(); - - try { - logInfo("[startup:kuaishou]", "开始执行快手轻量核心启动步骤", { - attempt: startupState.core.attemptCount, - }); - - await runDatabaseMigrations(); - await ensureFulfillmentCatalogBootstrapped(); - await ensureAdminUsersBootstrapped(); - - startupState.core.ready = true; - startupState.core.lastError = ""; - startupState.phase = "ready"; - startupState.core.readyAt = new Date().toISOString(); - - logInfo("[startup:kuaishou]", "快手轻量核心启动步骤完成,服务已就绪", { - attempt: startupState.core.attemptCount, - }); - - startScheduledJobs(); - break; - } catch (error) { - const message = formatStartupError(error); - startupState.phase = "retrying"; - startupState.core.lastError = message; - logError("[startup:kuaishou]", "快手轻量核心启动步骤失败,将自动重试", { - attempt: startupState.core.attemptCount, - retryDelayMs: CORE_BOOT_RETRY_DELAY_MS, - error, - }); - - await sleep(CORE_BOOT_RETRY_DELAY_MS); - } - } - - startupState.core.running = false; -} - -function sleep(ms: number) { - return new Promise((resolve) => { - setTimeout(resolve, ms); - }); -} diff --git a/apps/backend/src/startup/bootstrap.ts b/apps/backend/src/startup/bootstrap.ts index 4d831656..2dc932b9 100644 --- a/apps/backend/src/startup/bootstrap.ts +++ b/apps/backend/src/startup/bootstrap.ts @@ -1,13 +1,8 @@ -import process from "node:process"; - -import { runtimeConfig } from "../config/runtime.js"; import { runDatabaseMigrations } from "../db/migrate.js"; import { ensureAdminUsersBootstrapped } from "../services/admin/admin-auth-service.js"; import { ensureFulfillmentCatalogBootstrapped } from "../services/bootstrap/fulfillment-bootstrap-service.js"; import { startScheduledJobs } from "../services/scheduler/scheduler-service.js"; -import { warmupOcrService } from "../services/session/ocr.js"; -import { warmupTencentBrowser } from "../services/session/session.js"; -import { logError, logInfo, logWarn } from "../utils/logger.js"; +import { logError, logInfo } from "../utils/logger.js"; import { formatStartupError, type StartupState } from "./state.js"; const CORE_BOOT_RETRY_DELAY_MS = 5_000; @@ -21,14 +16,19 @@ export async function bootstrapCoreServices( } startupState.core.running = true; + startupState.browser.status = "skipped"; + startupState.browser.message = "kuaishou lite mode"; + startupState.ocr.status = "skipped"; + startupState.ocr.message = "kuaishou lite mode"; while (!isShutdownStarted() && !startupState.core.ready) { - startupState.phase = startupState.core.attemptCount === 0 ? "starting" : "retrying"; + startupState.phase = + startupState.core.attemptCount === 0 ? "starting" : "retrying"; startupState.core.attemptCount += 1; startupState.core.lastAttemptAt = new Date().toISOString(); try { - logInfo("[startup]", "开始执行核心启动步骤", { + logInfo("[startup]", "开始执行快手核心启动步骤", { attempt: startupState.core.attemptCount, }); @@ -41,19 +41,17 @@ export async function bootstrapCoreServices( startupState.phase = "ready"; startupState.core.readyAt = new Date().toISOString(); - logInfo("[startup]", "核心启动步骤完成,服务已就绪", { + logInfo("[startup]", "快手核心启动步骤完成,服务已就绪", { attempt: startupState.core.attemptCount, }); startScheduledJobs(); - void bootstrapBrowser(startupState); - void bootstrapOcr(startupState); break; } catch (error) { const message = formatStartupError(error); startupState.phase = "retrying"; startupState.core.lastError = message; - logError("[startup]", "核心启动步骤失败,将自动重试", { + logError("[startup]", "快手核心启动步骤失败,将自动重试", { attempt: startupState.core.attemptCount, retryDelayMs: CORE_BOOT_RETRY_DELAY_MS, error, @@ -66,87 +64,6 @@ export async function bootstrapCoreServices( startupState.core.running = false; } -async function bootstrapBrowser(startupState: StartupState) { - startupState.browser.status = "starting"; - startupState.browser.lastAttemptAt = new Date().toISOString(); - - try { - const result = await warmupTencentBrowser(); - - if (!result.warmed) { - startupState.browser.status = "skipped"; - startupState.browser.message = "browser prewarm skipped"; - logInfo("[startup]", "browser prewarm skipped"); - return; - } - - startupState.browser.status = "ready"; - startupState.browser.message = "browser prewarm ready"; - logInfo("[startup]", "browser prewarm ready"); - } catch (error) { - if (isMissingPlaywrightBrowserError(error)) { - const installCommand = resolveBrowserInstallCommand(); - startupState.browser.status = "degraded"; - startupState.browser.message = formatErrorMessage(error); - logWarn("[startup]", `browser prewarm skipped: ${formatErrorMessage(error)}`); - logWarn("[startup]", `请先安装浏览器依赖: ${installCommand}`); - - if (String(runtimeConfig.browser.chromePath || "").trim()) { - logWarn("[startup]", "当前设置了 CHROME_PATH,也请确认该路径指向的浏览器可执行文件真实存在"); - } - - return; - } - - startupState.browser.status = "degraded"; - startupState.browser.message = formatStartupError(error); - logError("[startup]", "browser prewarm failed", error); - } -} - -async function bootstrapOcr(startupState: StartupState) { - startupState.ocr.status = "starting"; - startupState.ocr.lastAttemptAt = new Date().toISOString(); - - try { - await warmupOcrService(); - startupState.ocr.status = "ready"; - startupState.ocr.message = "OCR service ready"; - logInfo("[startup]", "OCR service ready"); - } catch (error) { - startupState.ocr.status = "degraded"; - startupState.ocr.message = formatStartupError(error); - logWarn("[startup]", `OCR service skipped: ${formatStartupError(error)}`); - } -} - -function isMissingPlaywrightBrowserError(error: unknown) { - const message = formatErrorMessage(error).toLowerCase(); - - return ( - message.includes("executable doesn't exist") || - message.includes("please run the following command to download new browsers") || - (message.includes("browserType.launch".toLowerCase()) && - message.includes("please run")) - ); -} - -function formatErrorMessage(error: unknown) { - if (error instanceof Error && error.message) { - return error.message.split("\n")[0].trim(); - } - - return String(error || "未知错误").trim(); -} - -function resolveBrowserInstallCommand() { - if (process.platform === "linux") { - return "npm run browser:install:linux"; - } - - return "npm run browser:install"; -} - function sleep(ms: number) { return new Promise((resolve) => { setTimeout(resolve, ms); diff --git a/apps/backend/src/startup/shutdown-kuaishou.ts b/apps/backend/src/startup/shutdown-kuaishou.ts deleted file mode 100644 index dd3f60e1..00000000 --- a/apps/backend/src/startup/shutdown-kuaishou.ts +++ /dev/null @@ -1,44 +0,0 @@ -import type { Server } from "node:http"; - -import { stopScheduledJobs } from "../services/scheduler/scheduler-service.js"; -import { logError, logInfo } from "../utils/logger.js"; -import type { StartupState } from "./state.js"; - -export function createKuaishouShutdownController( - server: Server, - startupState: StartupState -) { - let shutdownStarted = false; - - async function shutdown(signal: string) { - if (shutdownStarted) { - return; - } - - shutdownStarted = true; - startupState.phase = "shutting_down"; - logInfo("[shutdown:kuaishou]", `received ${signal}, closing HTTP server`); - - stopScheduledJobs(); - - if (!server.listening) { - return; - } - - await new Promise((resolve) => { - server.close((error) => { - if (error) { - logError("[shutdown:kuaishou]", "failed to close HTTP server", error); - process.exitCode = 1; - } - - resolve(); - }); - }); - } - - return { - isShutdownStarted: () => shutdownStarted, - shutdown, - }; -} diff --git a/apps/backend/src/startup/shutdown.ts b/apps/backend/src/startup/shutdown.ts index e87c36c4..2ee3134a 100644 --- a/apps/backend/src/startup/shutdown.ts +++ b/apps/backend/src/startup/shutdown.ts @@ -1,12 +1,13 @@ import type { Server } from "node:http"; import { stopScheduledJobs } from "../services/scheduler/scheduler-service.js"; -import { closeOcrService } from "../services/session/ocr.js"; -import { closeAllTencentBrowserSessions } from "../services/session/session.js"; import { logError, logInfo } from "../utils/logger.js"; import type { StartupState } from "./state.js"; -export function createShutdownController(server: Server, startupState: StartupState) { +export function createShutdownController( + server: Server, + startupState: StartupState +) { let shutdownStarted = false; async function shutdown(signal: string) { @@ -16,22 +17,10 @@ export function createShutdownController(server: Server, startupState: StartupSt shutdownStarted = true; startupState.phase = "shutting_down"; - logInfo("[shutdown]", `received ${signal}, closing browser sessions and HTTP server`); + logInfo("[shutdown]", `received ${signal}, closing HTTP server`); stopScheduledJobs(); - try { - await closeAllTencentBrowserSessions({ markClosed: true }); - } catch (error) { - logError("[shutdown]", "failed to close browser sessions", error); - } - - try { - await closeOcrService(); - } catch (error) { - logError("[shutdown]", "failed to close OCR service", error); - } - if (!server.listening) { return; } diff --git a/apps/backend/src/types/admin-read-models.ts b/apps/backend/src/types/admin-read-models.ts index 582597f1..71e1aab2 100644 --- a/apps/backend/src/types/admin-read-models.ts +++ b/apps/backend/src/types/admin-read-models.ts @@ -6,17 +6,6 @@ export type AdminTaskBindingSummary = { roleKeys: string[] } -export type AdminAgisoAutoDeliveryStatus = { - status: string - trigger: string - reason: string - platformOrderId: string - responseStatus: number - errorMessage: string - requestId: string - updatedAt: string | null -} - export type AdminOrderListItem = { orderId: number provider: string @@ -78,7 +67,6 @@ export type AdminTaskListItem = { inventoryCredentialType: string claimToken: string screenshotPath: string - agisoAutoDelivery: AdminAgisoAutoDeliveryStatus | null } export type AdminInventoryItemListItem = { diff --git a/apps/backend/src/types/admin-route-inputs.ts b/apps/backend/src/types/admin-route-inputs.ts index 328ee9be..114b55e0 100644 --- a/apps/backend/src/types/admin-route-inputs.ts +++ b/apps/backend/src/types/admin-route-inputs.ts @@ -8,7 +8,6 @@ import type { AdminWebhookEventListQueryInput, } from './admin-read-inputs.js' import type { - AdminAgisoShopConfigSaveInput, AdminCloudtentaclesCatalogQueryInput, AdminCloudtentaclesFullFlowInput, AdminCloudtentaclesSendSmsCodeInput, @@ -18,8 +17,6 @@ import type { AdminCloudtentaclesTestLoginInput, AdminCloudtentaclesValidateSessionInput, AdminCloudtentaclesVirtualNumberInput, - AdminFulfillmentBindingConfigSaveInput, - AdminFulfillmentBindingLookupInput, AdminInventoryCreateInput, AdminInventoryImportInput, AdminInventoryInvalidateInput, @@ -28,7 +25,6 @@ import type { AdminKuaishouEticketDetailQueryInput, AdminKuaishouEticketShopInfoInput, AdminKuaishouEticketSourceConfigInput, - AdminManualRedeemCreateInput, AdminNotificationConfigInput, AdminNotificationTestInput, AdminScheduledJobsConfigInput, @@ -49,7 +45,6 @@ export type AdminKuaishouEticketSourceConfigRouteBody = AdminKuaishouEticketSour export type AdminNotificationConfigRouteBody = AdminNotificationConfigInput export type AdminNotificationTestRouteBody = AdminNotificationTestInput export type AdminScheduledJobsConfigRouteBody = AdminScheduledJobsConfigInput -export type AdminAgisoShopConfigRouteBody = AdminAgisoShopConfigSaveInput export type AdminKuaishouEticketDetailQueryRouteBody = AdminKuaishouEticketDetailQueryInput export type AdminKuaishouEticketConsumeRouteBody = AdminKuaishouEticketConsumeInput export type AdminKuaishouEticketShopInfoRouteBody = AdminKuaishouEticketShopInfoInput @@ -63,20 +58,16 @@ export type AdminCloudtentaclesSkuBuyRouteBody = AdminCloudtentaclesSkuBuyInput export type AdminCloudtentaclesSkuUseRouteBody = AdminCloudtentaclesSkuUseInput export type AdminCloudtentaclesVirtualNumberRouteBody = AdminCloudtentaclesVirtualNumberInput export type AdminCloudtentaclesFullFlowRouteBody = AdminCloudtentaclesFullFlowInput -export type AdminFulfillmentBindingConfigRouteBody = AdminFulfillmentBindingConfigSaveInput -export type AdminFulfillmentBindingLookupRouteBody = AdminFulfillmentBindingLookupInput export type AdminInventoryCreateRouteBody = AdminInventoryCreateInput export type AdminInventoryImportRouteBody = AdminInventoryImportInput export type AdminInventoryInvalidateRouteBody = AdminInventoryInvalidateInput export type AdminUserInventoryGroupsRouteBody = AdminUserInventoryGroupsInput export type AdminTaskManualDispatchRouteBody = AdminTaskManualDispatchInput export type AdminTaskKuaishouCloudDispatchRouteBody = AdminTaskKuaishouCloudDispatchInput -export type AdminManualRedeemCreateRouteBody = AdminManualRedeemCreateInput export type AdminInventoryRouteParams = { inventoryItemId?: string } export type AdminOrderRouteParams = { orderId?: string } export type AdminTaskRouteParams = { taskId?: string, bindingId?: string } -export type AdminManualRedeemRouteParams = { taskId?: string } export type AdminWebhookEventRouteParams = { eventId?: string } export type AdminEntityRouteParams = { id?: string } export type AdminCloudtentaclesSourceRouteParams = { sourceKey?: string } diff --git a/apps/backend/src/types/admin-write-inputs.ts b/apps/backend/src/types/admin-write-inputs.ts index 2abaca02..3fb27fc5 100644 --- a/apps/backend/src/types/admin-write-inputs.ts +++ b/apps/backend/src/types/admin-write-inputs.ts @@ -27,28 +27,6 @@ export type AdminUserInventoryGroupsInput = { inventoryGroupCodes?: string[] } -export type AdminAgisoMessagingDefaultsInput = { - messageTemplate?: string - autoDeliveryMessageTemplate?: string -} - -export type AdminAgisoShopConfigWriteItemInput = { - shopId?: string - shopName?: string - accessToken?: string - enabled?: boolean | null - messageTemplate?: string - autoDeliveryMessageTemplate?: string - appSecret?: string - apiVersion?: string - sendMessageEndpoint?: string -} - -export type AdminAgisoShopConfigSaveInput = { - defaults?: AdminAgisoMessagingDefaultsInput - shops?: AdminAgisoShopConfigWriteItemInput[] -} - export type AdminKuaishouCloudFulfillmentItemInput = { id?: string enabled?: boolean @@ -271,36 +249,6 @@ export type AdminCloudtentaclesFullFlowInput = { vnKey?: string } -export type AdminFulfillmentBindingLookupInput = { - provider?: string - platform?: string - shopId?: string - platformOrderId?: string -} - -export type AdminFulfillmentBindingConfigWriteItemInput = { - provider?: string - platform?: string - shopId?: string - shopName?: string - skuCode?: string - skuName?: string - profileKey?: string - enabled?: boolean - priority?: number | string - config?: Record - match?: { - externalSkuCode?: string - externalItemId?: string - externalSkuName?: string - config?: Record - } -} - -export type AdminFulfillmentBindingConfigSaveInput = { - bindings?: AdminFulfillmentBindingConfigWriteItemInput[] -} - export type AdminInventoryInvalidateInput = { reason?: string } @@ -315,10 +263,3 @@ export type AdminTaskManualDispatchInput = { export type AdminTaskKuaishouCloudDispatchInput = { ticketCode?: string } - -export type AdminManualRedeemCreateInput = { - proofValue?: string - skuCode?: string - skuName?: string - remark?: string -} diff --git a/apps/backend/src/types/admin-write-models.ts b/apps/backend/src/types/admin-write-models.ts index 674d86ad..150f1f04 100644 --- a/apps/backend/src/types/admin-write-models.ts +++ b/apps/backend/src/types/admin-write-models.ts @@ -19,30 +19,6 @@ export type AdminInventoryGroupListResponse = { items: AdminInventorySkuSuggestion[] } -export type AdminAgisoMessagingDefaults = { - messageTemplate: string - autoDeliveryMessageTemplate: string -} - -export type AdminAgisoShopConfigItem = { - shopId: string - shopName: string - accessToken: string - accessTokenMasked: string - enabled: boolean | null - messageTemplate: string - autoDeliveryMessageTemplate: string - appSecretConfigured: boolean - apiVersion: string - sendMessageEndpoint: string -} - -export type AdminAgisoShopConfigSaveResponse = { - filePath: string - defaults: AdminAgisoMessagingDefaults - shops: AdminAgisoShopConfigItem[] -} - export type AdminWebhookReplayResponse = { eventId: number replayed: boolean diff --git a/apps/backend/src/types/runtime-config.ts b/apps/backend/src/types/runtime-config.ts index d14a32c4..cb953d73 100644 --- a/apps/backend/src/types/runtime-config.ts +++ b/apps/backend/src/types/runtime-config.ts @@ -5,41 +5,10 @@ export type AdminDefaultUser = { status?: string; }; -export type AgisoMessagingShopConfig = { - enabled?: boolean; - shopName?: string; - accessToken?: string; - messageTemplate?: string; - autoDeliveryMessageTemplate?: string; - appSecret?: string; - apiVersion?: string; - sendMessageEndpoint?: string; -}; - -export type AgisoMessagingShopsConfig = Record< - string, - AgisoMessagingShopConfig ->; - export type RuntimeConfig = { server: { port: number; }; - browser: { - chromePath: string; - headless: boolean | null; - devtools: boolean | null; - keepAlive: boolean | null; - prewarm: boolean | null; - slowMoMs: number | null; - vncResolution: string; - }; - session: { - debug: boolean; - }; - ocr: { - baseUrl: string; - }; data: { root: string; }; @@ -61,27 +30,6 @@ export type RuntimeConfig = { defaultUsers: AdminDefaultUser[]; }; platforms: { - agiso: { - appSecret: string; - tradeDetail: { - endpoint: string; - apiVersion: string; - timeoutMs: number; - }; - autoDelivery: { - enabled: boolean; - endpoint: string; - apiVersion: string; - }; - messaging: { - enabled: boolean; - apiVersion: string; - sendMessageEndpoint: string; - messageTemplate: string; - autoDeliveryMessageTemplate: string; - shops: AgisoMessagingShopsConfig; - }; - }; ninetyone: { userId: string; secret: string; @@ -132,7 +80,4 @@ export type RuntimeConfig = { cors: { allowedOrigins: string[]; }; - redeem: { - proofMode: string; - }; }; diff --git a/apps/backend/tsconfig.kuaishou.build.json b/apps/backend/tsconfig.kuaishou.build.json deleted file mode 100644 index ed08e0e5..00000000 --- a/apps/backend/tsconfig.kuaishou.build.json +++ /dev/null @@ -1,10 +0,0 @@ -{ - "extends": "./tsconfig.kuaishou.json", - "compilerOptions": { - "noEmit": false, - "outDir": "dist", - "rootDir": "src", - "declaration": false, - "sourceMap": true - } -} diff --git a/apps/backend/tsconfig.kuaishou.json b/apps/backend/tsconfig.kuaishou.json deleted file mode 100644 index 95aff275..00000000 --- a/apps/backend/tsconfig.kuaishou.json +++ /dev/null @@ -1,14 +0,0 @@ -{ - "extends": "./tsconfig.json", - "files": [ - "src/index-kuaishou.ts" - ], - "include": [], - "exclude": [ - "node_modules", - "data", - "dist", - "src/**/*.test.js", - "src/**/*.test.ts" - ] -} diff --git a/apps/frontend/src/types/admin/index.ts b/apps/frontend/src/types/admin/index.ts index c354ca19..31952edb 100644 --- a/apps/frontend/src/types/admin/index.ts +++ b/apps/frontend/src/types/admin/index.ts @@ -16,7 +16,6 @@ export type { AdminDashboardSummary } from './dashboard' // Orders types export type { AdminOrderBindingSummary, - AdminOrderAgisoAutoDeliverySummary, AdminOrderListItem, AdminOrderDetail, } from './orders' @@ -24,7 +23,6 @@ export type { // Tasks types export type { AdminTaskBindingSummary, - AdminAgisoAutoDeliveryStatus, AdminTaskListItem, AdminTaskOperations, AdminTaskActionResponse, diff --git a/apps/frontend/src/types/admin/orders.ts b/apps/frontend/src/types/admin/orders.ts index 8f16c568..cf6e7a4e 100644 --- a/apps/frontend/src/types/admin/orders.ts +++ b/apps/frontend/src/types/admin/orders.ts @@ -1,5 +1,4 @@ import type { AdminTaskListItem } from './tasks' -import type { AdminAgisoAutoDeliveryStatus } from './tasks' export interface AdminOrderBindingSummary { totalTaskCount: number @@ -13,13 +12,6 @@ export interface AdminOrderBindingSummary { userBindingStatus: string } -export interface AdminOrderAgisoAutoDeliverySummary extends AdminAgisoAutoDeliveryStatus { - totalTaskCount: number - deliveredTaskCount: number - sourceTaskId: number | null - sourceTaskNo: string -} - export interface AdminOrderListItem { orderId: number provider: string @@ -74,7 +66,6 @@ export interface AdminOrderDetail { itemSummary: string rawPayload: Record bindingSummary: AdminOrderBindingSummary - agisoAutoDelivery: AdminOrderAgisoAutoDeliverySummary | null } items: Array<{ orderItemId: number diff --git a/apps/frontend/src/types/admin/tasks.ts b/apps/frontend/src/types/admin/tasks.ts index 8e0c76dc..a5cb0d56 100644 --- a/apps/frontend/src/types/admin/tasks.ts +++ b/apps/frontend/src/types/admin/tasks.ts @@ -6,17 +6,6 @@ export interface AdminTaskBindingSummary { roleKeys: string[] } -export interface AdminAgisoAutoDeliveryStatus { - status: string - trigger: string - reason: string - platformOrderId: string - responseStatus: number - errorMessage: string - requestId: string - updatedAt: string | null -} - export interface AdminTaskListItem { taskId: number taskNo: string @@ -46,7 +35,6 @@ export interface AdminTaskListItem { inventoryCredentialType: string claimToken: string screenshotPath: string - agisoAutoDelivery: AdminAgisoAutoDeliveryStatus | null } export interface AdminTaskOperations { diff --git a/apps/frontend/src/utils/admin-display.ts b/apps/frontend/src/utils/admin-display.ts index 9dead2ba..70e9b9e4 100644 --- a/apps/frontend/src/utils/admin-display.ts +++ b/apps/frontend/src/utils/admin-display.ts @@ -61,9 +61,6 @@ export function formatTaskEventType(eventType: string) { claim_redeem_code_invalid: "兑换码错误", claim_redeem_inventory_replaced: "已切换新兑换码", claim_redeem_completed: "兑换链路完成", - agiso_auto_delivery_success: "咸鱼自动发货成功", - agiso_auto_delivery_failed: "咸鱼自动发货失败", - agiso_auto_delivery_skipped: "咸鱼自动发货已跳过", }; return formatStatusWithRaw(eventType, labelMap); @@ -96,48 +93,6 @@ export function formatRedeemResolutionStatus(value: string) { : value || "-"; } -export function formatAutoDeliveryTrigger(value: string) { - const normalized = String(value || "").trim(); - - switch (normalized) { - case "claim_redeemed": - return "用户兑换完成后触发"; - case "manual_dispatch_completed": - return "人工履约完成后触发"; - case "task_delivered": - return "任务交付完成后触发"; - default: - return normalized || "-"; - } -} - -export function formatAutoDeliveryReason(value: string) { - const normalized = String(value || "").trim(); - - switch (normalized) { - case "waiting_other_tasks": - return "订单下仍有任务未完成交付"; - case "already_sent": - return "该订单已经执行过自动发货"; - case "auto_delivery_disabled": - return "自动发货开关已关闭"; - case "missing_config": - return "缺少 Agiso 自动发货配置"; - case "request_failed": - return "Agiso 接口返回失败"; - case "request_error": - return "请求 Agiso 接口时发生异常"; - case "delivery_not_confirmed": - return "接口返回成功,但订单状态未确认进入已发货"; - case "task_not_delivered": - return "当前任务尚未进入已交付状态"; - case "not_supported": - return "当前订单不支持 Agiso 咸鱼自动发货"; - default: - return normalized || "-"; - } -} - export function formatKuaishouRoleInfoLabel(value: string) { const normalized = String(value || "").trim(); @@ -177,9 +132,7 @@ export function formatTaskEventPayload(payload: Record) { : "", payload.deliveryReference ? `单号 ${payload.deliveryReference}` : "", payload.platformOrderId ? `平台单 ${payload.platformOrderId}` : "", - payload.trigger - ? `触发 ${formatAutoDeliveryTrigger(String(payload.trigger || ""))}` - : "", + payload.trigger ? `触发 ${payload.trigger}` : "", payload.reason ? `原因 ${payload.reason}` : "", payload.purchaseTriggered === true ? "已触发购买" : "", payload.usedKnapsack === true ? "使用背包库存" : "", @@ -211,24 +164,3 @@ export function formatBindingCountSummary(summary: { }) { return `共 ${summary.totalBindingCount} 条 · 预占 ${summary.reservedBindingCount} · 已消费 ${summary.consumedBindingCount} · 已释放 ${summary.releasedBindingCount}`; } - -export function resolveTaskAutoDeliveryStatus(task: { - agisoAutoDelivery?: { status?: string } | null; - deliveryStatus: string; -}) { - if (task.agisoAutoDelivery?.status) return task.agisoAutoDelivery.status; - return task.deliveryStatus === "delivered" ? "pending" : "waiting"; -} - -export function formatTaskAutoDeliveryReason(task: { - agisoAutoDelivery?: { errorMessage?: string; reason?: string } | null; - deliveryStatus: string; -}) { - if (task.agisoAutoDelivery?.errorMessage) - return task.agisoAutoDelivery.errorMessage; - if (task.agisoAutoDelivery?.reason) - return formatAutoDeliveryReason(task.agisoAutoDelivery.reason); - return task.deliveryStatus === "delivered" - ? "当前任务已交付,等待订单统一自动发货" - : "当前任务尚未完成交付"; -} diff --git a/apps/frontend/src/utils/admin-options.ts b/apps/frontend/src/utils/admin-options.ts index 0225b131..4da0f0a9 100644 --- a/apps/frontend/src/utils/admin-options.ts +++ b/apps/frontend/src/utils/admin-options.ts @@ -34,7 +34,7 @@ export const adminInventoryStatusOptions = [ export const adminInventoryCredentialTypeOptions = [ { label: '全部凭据类型', value: '' }, - { label: '腾讯兑换码(自动领取)', value: 'tencent_code' }, + { label: '兑换码(领取链路)', value: 'tencent_code' }, { label: '卡号卡密', value: 'card_password' }, { label: '账号密码', value: 'account_password' }, { label: '激活链接', value: 'activation_link' }, diff --git a/apps/frontend/src/views/admin/orders/AdminOrderDetailView.vue b/apps/frontend/src/views/admin/orders/AdminOrderDetailView.vue index 37780fbe..a53f1242 100644 --- a/apps/frontend/src/views/admin/orders/AdminOrderDetailView.vue +++ b/apps/frontend/src/views/admin/orders/AdminOrderDetailView.vue @@ -6,11 +6,8 @@ import AdminStatusTag from '@/components/admin/AdminStatusTag.vue' import { fetchAdminOrderDetail } from '@/services/admin' import type { AdminOrderDetail } from '@/types/admin' import { - formatAutoDeliveryReason, formatBindingRoleSummary, formatBindingCountSummary, - resolveTaskAutoDeliveryStatus, - formatTaskAutoDeliveryReason, } from '@/utils/admin-display' import { formatAdminDateTime } from '@/utils/admin-time' import { formatAdminWebhookEventType } from '@/utils/admin-webhook' @@ -116,26 +113,6 @@ function formatSpecSummary(spec: Record) { {{ detail.order.bindingSummary.releasedBindingCount }} - - 自动发货 -
- -
- - {{ - detail.order.agisoAutoDelivery.errorMessage || - formatAutoDeliveryReason(detail.order.agisoAutoDelivery.reason) || - '全部任务交付后会自动触发' - }} - - - 已交付 {{ detail.order.agisoAutoDelivery.deliveredTaskCount }}/{{ - detail.order.agisoAutoDelivery.totalTaskCount - }} - · 最近执行 - {{ formatAdminDateTime(detail.order.agisoAutoDelivery.updatedAt) }} - -
@@ -199,16 +176,6 @@ function formatSpecSummary(spec: Record) { - - -