From 313c036845776d047e6076016e8351596e2cfbb2 Mon Sep 17 00:00:00 2001 From: yml Date: Wed, 8 Apr 2026 16:30:42 +0800 Subject: [PATCH] init --- .dockerignore | 13 + .env.example | 24 + .gitignore | 19 + README.md | 64 + apps/backend/.gitignore | 15 + apps/backend/README.md | 111 + apps/backend/app信息.md | 53 + apps/backend/config/default.cjs | 112 + apps/backend/config/local.example.cjs | 85 + apps/backend/data/.gitkeep | 0 apps/backend/package-lock.json | 871 +++++++ apps/backend/package.json | 17 + apps/backend/scripts/test-order-flow.sh | 99 + apps/backend/src/config/runtime.js | 255 ++ apps/backend/src/db/client.js | 43 + apps/backend/src/db/migrate.js | 47 + apps/backend/src/db/migrations/001_init.sql | 129 + .../src/db/migrations/002_admin_users.sql | 12 + .../db/migrations/003_admin_audit_logs.sql | 17 + .../db/migrations/004_message_deliveries.sql | 29 + apps/backend/src/index.js | 170 ++ .../src/repositories/admin-audit-log-repo.js | 86 + .../src/repositories/admin-user-repo.js | 117 + apps/backend/src/repositories/cdk-repo.js | 143 ++ .../src/repositories/claim-token-repo.js | 69 + .../src/repositories/message-delivery-repo.js | 99 + .../src/repositories/order-item-repo.js | 80 + apps/backend/src/repositories/order-repo.js | 144 ++ apps/backend/src/repositories/task-repo.js | 228 ++ .../src/repositories/webhook-event-repo.js | 127 + apps/backend/src/routes/admin.js | 432 ++++ apps/backend/src/routes/claims.js | 73 + apps/backend/src/routes/tencent.js | 87 + apps/backend/src/routes/webhooks.js | 25 + .../src/services/admin-audit-service.js | 83 + .../src/services/admin-auth-service.js | 453 ++++ apps/backend/src/services/admin-service.js | 924 +++++++ apps/backend/src/services/cdk-service.js | 16 + apps/backend/src/services/claim-service.js | 30 + .../src/services/claim-session-service.js | 407 ++++ .../src/services/delivery-task-service.js | 119 + apps/backend/src/services/message-service.js | 231 ++ apps/backend/src/services/ocr.js | 213 ++ apps/backend/src/services/order-service.js | 81 + .../src/services/session-login-shared.js | 83 + apps/backend/src/services/session-proof.js | 500 ++++ apps/backend/src/services/session-qq.js | 171 ++ apps/backend/src/services/session-redeem.js | 510 ++++ apps/backend/src/services/session-wx.js | 326 +++ apps/backend/src/services/session.js | 1051 ++++++++ apps/backend/src/services/webhook-service.js | 359 +++ apps/backend/src/utils/http.js | 161 ++ apps/backend/src/utils/random.js | 9 + apps/backend/src/utils/time.js | 8 + apps/backend/subservices/ocr-worker/README.md | 16 + .../subservices/ocr-worker/pyproject.toml | 15 + .../ocr-worker/src/ocr_worker/__init__.py | 4 + .../ocr-worker/src/ocr_worker/cli.py | 179 ++ .../ocr-worker/src/ocr_worker/engine.py | 27 + apps/backend/subservices/ocr-worker/uv.lock | 315 +++ apps/backend/方案A-订单编排与自动兑换设计.md | 1077 ++++++++ apps/backend/阶段1-4实施规格说明.md | 1111 +++++++++ apps/frontend/.env.development | 1 + apps/frontend/.env.example | 1 + apps/frontend/.gitignore | 25 + apps/frontend/.vscode/extensions.json | 3 + apps/frontend/README.md | 64 + apps/frontend/index.html | 13 + apps/frontend/package-lock.json | 2170 +++++++++++++++++ apps/frontend/package.json | 28 + apps/frontend/public/favicon.svg | 1 + apps/frontend/src/App.vue | 3 + apps/frontend/src/auto-imports.d.ts | 78 + apps/frontend/src/components.d.ts | 29 + .../components/admin/AdminPaginationBar.vue | 62 + .../src/components/admin/AdminStatusTag.vue | 130 + .../components/tencent/TencentAuthCard.vue | 389 +++ .../components/tencent/TencentRedeemPanel.vue | 284 +++ .../components/tencent/TencentResultPanel.vue | 158 ++ .../src/composables/tencent/session-errors.ts | 48 + .../useTencentBrowserSessionPolling.ts | 196 ++ .../useTencentBrowserSessionPresentation.ts | 208 ++ apps/frontend/src/composables/useClaimPage.ts | 525 ++++ .../useTencentBrowserSessionPage.ts | 240 ++ apps/frontend/src/lib/http.ts | 101 + apps/frontend/src/main.ts | 11 + apps/frontend/src/router/index.ts | 108 + apps/frontend/src/services/admin.ts | 136 ++ apps/frontend/src/services/claim.ts | 23 + .../src/services/tencent/artifacts.ts | 4 + apps/frontend/src/services/tencent/redeem.ts | 17 + apps/frontend/src/services/tencent/session.ts | 33 + apps/frontend/src/styles/main.css | 67 + apps/frontend/src/types/admin.ts | 259 ++ apps/frontend/src/types/claim.ts | 66 + apps/frontend/src/types/tencent/activity.ts | 45 + apps/frontend/src/types/tencent/redeem.ts | 12 + apps/frontend/src/types/tencent/session.ts | 36 + apps/frontend/src/utils/admin-auth.ts | 68 + apps/frontend/src/utils/admin-display.ts | 45 + apps/frontend/src/utils/admin-options.ts | 73 + apps/frontend/src/utils/admin-time.ts | 1 + apps/frontend/src/utils/admin-webhook.ts | 22 + apps/frontend/src/utils/date-time.ts | 51 + apps/frontend/src/views/NotFoundView.vue | 48 + .../src/views/admin/AdminAuditLogsView.vue | 232 ++ .../src/views/admin/AdminCdksView.vue | 327 +++ .../src/views/admin/AdminDashboardView.vue | 113 + apps/frontend/src/views/admin/AdminLayout.vue | 164 ++ .../src/views/admin/AdminLoginView.vue | 136 ++ .../src/views/admin/AdminOrderDetailView.vue | 142 ++ .../src/views/admin/AdminOrdersView.vue | 253 ++ .../src/views/admin/AdminTaskDetailView.vue | 223 ++ .../src/views/admin/AdminTasksView.vue | 588 +++++ .../src/views/admin/AdminUsersView.vue | 362 +++ .../admin/AdminWebhookEventDetailView.vue | 165 ++ .../views/admin/AdminWebhookEventsView.vue | 237 ++ apps/frontend/src/views/claim/ClaimView.vue | 368 +++ .../src/views/tx/TencentBrowserView.vue | 299 +++ apps/frontend/tsconfig.app.json | 19 + apps/frontend/tsconfig.json | 7 + apps/frontend/tsconfig.node.json | 26 + apps/frontend/vite.config.ts | 44 + deploy/caddy/Caddyfile | 16 + deploy/docker/backend.Dockerfile | 29 + deploy/docker/frontend.Dockerfile | 14 + docker-compose.yml | 52 + docs/服务器部署教程.md | 174 ++ docs/生产环境变量模板.env | 24 + docs/迁移与部署计划.md | 66 + docs/首次上线检查清单.md | 57 + 131 files changed, 22393 insertions(+) create mode 100644 .dockerignore create mode 100644 .env.example create mode 100644 .gitignore create mode 100644 README.md create mode 100644 apps/backend/.gitignore create mode 100644 apps/backend/README.md create mode 100644 apps/backend/app信息.md create mode 100644 apps/backend/config/default.cjs create mode 100644 apps/backend/config/local.example.cjs create mode 100644 apps/backend/data/.gitkeep create mode 100644 apps/backend/package-lock.json create mode 100644 apps/backend/package.json create mode 100755 apps/backend/scripts/test-order-flow.sh create mode 100644 apps/backend/src/config/runtime.js create mode 100644 apps/backend/src/db/client.js create mode 100644 apps/backend/src/db/migrate.js create mode 100644 apps/backend/src/db/migrations/001_init.sql create mode 100644 apps/backend/src/db/migrations/002_admin_users.sql create mode 100644 apps/backend/src/db/migrations/003_admin_audit_logs.sql create mode 100644 apps/backend/src/db/migrations/004_message_deliveries.sql create mode 100644 apps/backend/src/index.js create mode 100644 apps/backend/src/repositories/admin-audit-log-repo.js create mode 100644 apps/backend/src/repositories/admin-user-repo.js create mode 100644 apps/backend/src/repositories/cdk-repo.js create mode 100644 apps/backend/src/repositories/claim-token-repo.js create mode 100644 apps/backend/src/repositories/message-delivery-repo.js create mode 100644 apps/backend/src/repositories/order-item-repo.js create mode 100644 apps/backend/src/repositories/order-repo.js create mode 100644 apps/backend/src/repositories/task-repo.js create mode 100644 apps/backend/src/repositories/webhook-event-repo.js create mode 100644 apps/backend/src/routes/admin.js create mode 100644 apps/backend/src/routes/claims.js create mode 100644 apps/backend/src/routes/tencent.js create mode 100644 apps/backend/src/routes/webhooks.js create mode 100644 apps/backend/src/services/admin-audit-service.js create mode 100644 apps/backend/src/services/admin-auth-service.js create mode 100644 apps/backend/src/services/admin-service.js create mode 100644 apps/backend/src/services/cdk-service.js create mode 100644 apps/backend/src/services/claim-service.js create mode 100644 apps/backend/src/services/claim-session-service.js create mode 100644 apps/backend/src/services/delivery-task-service.js create mode 100644 apps/backend/src/services/message-service.js create mode 100644 apps/backend/src/services/ocr.js create mode 100644 apps/backend/src/services/order-service.js create mode 100644 apps/backend/src/services/session-login-shared.js create mode 100644 apps/backend/src/services/session-proof.js create mode 100644 apps/backend/src/services/session-qq.js create mode 100644 apps/backend/src/services/session-redeem.js create mode 100644 apps/backend/src/services/session-wx.js create mode 100644 apps/backend/src/services/session.js create mode 100644 apps/backend/src/services/webhook-service.js create mode 100644 apps/backend/src/utils/http.js create mode 100644 apps/backend/src/utils/random.js create mode 100644 apps/backend/src/utils/time.js create mode 100644 apps/backend/subservices/ocr-worker/README.md create mode 100644 apps/backend/subservices/ocr-worker/pyproject.toml create mode 100644 apps/backend/subservices/ocr-worker/src/ocr_worker/__init__.py create mode 100644 apps/backend/subservices/ocr-worker/src/ocr_worker/cli.py create mode 100644 apps/backend/subservices/ocr-worker/src/ocr_worker/engine.py create mode 100644 apps/backend/subservices/ocr-worker/uv.lock create mode 100644 apps/backend/方案A-订单编排与自动兑换设计.md create mode 100644 apps/backend/阶段1-4实施规格说明.md create mode 100644 apps/frontend/.env.development create mode 100644 apps/frontend/.env.example create mode 100644 apps/frontend/.gitignore create mode 100644 apps/frontend/.vscode/extensions.json create mode 100644 apps/frontend/README.md create mode 100644 apps/frontend/index.html create mode 100644 apps/frontend/package-lock.json create mode 100644 apps/frontend/package.json create mode 100644 apps/frontend/public/favicon.svg create mode 100644 apps/frontend/src/App.vue create mode 100644 apps/frontend/src/auto-imports.d.ts create mode 100644 apps/frontend/src/components.d.ts create mode 100644 apps/frontend/src/components/admin/AdminPaginationBar.vue create mode 100644 apps/frontend/src/components/admin/AdminStatusTag.vue create mode 100644 apps/frontend/src/components/tencent/TencentAuthCard.vue create mode 100644 apps/frontend/src/components/tencent/TencentRedeemPanel.vue create mode 100644 apps/frontend/src/components/tencent/TencentResultPanel.vue create mode 100644 apps/frontend/src/composables/tencent/session-errors.ts create mode 100644 apps/frontend/src/composables/tencent/useTencentBrowserSessionPolling.ts create mode 100644 apps/frontend/src/composables/tencent/useTencentBrowserSessionPresentation.ts create mode 100644 apps/frontend/src/composables/useClaimPage.ts create mode 100644 apps/frontend/src/composables/useTencentBrowserSessionPage.ts create mode 100644 apps/frontend/src/lib/http.ts create mode 100644 apps/frontend/src/main.ts create mode 100644 apps/frontend/src/router/index.ts create mode 100644 apps/frontend/src/services/admin.ts create mode 100644 apps/frontend/src/services/claim.ts create mode 100644 apps/frontend/src/services/tencent/artifacts.ts create mode 100644 apps/frontend/src/services/tencent/redeem.ts create mode 100644 apps/frontend/src/services/tencent/session.ts create mode 100644 apps/frontend/src/styles/main.css create mode 100644 apps/frontend/src/types/admin.ts create mode 100644 apps/frontend/src/types/claim.ts create mode 100644 apps/frontend/src/types/tencent/activity.ts create mode 100644 apps/frontend/src/types/tencent/redeem.ts create mode 100644 apps/frontend/src/types/tencent/session.ts create mode 100644 apps/frontend/src/utils/admin-auth.ts create mode 100644 apps/frontend/src/utils/admin-display.ts create mode 100644 apps/frontend/src/utils/admin-options.ts create mode 100644 apps/frontend/src/utils/admin-time.ts create mode 100644 apps/frontend/src/utils/admin-webhook.ts create mode 100644 apps/frontend/src/utils/date-time.ts create mode 100644 apps/frontend/src/views/NotFoundView.vue create mode 100644 apps/frontend/src/views/admin/AdminAuditLogsView.vue create mode 100644 apps/frontend/src/views/admin/AdminCdksView.vue create mode 100644 apps/frontend/src/views/admin/AdminDashboardView.vue create mode 100644 apps/frontend/src/views/admin/AdminLayout.vue create mode 100644 apps/frontend/src/views/admin/AdminLoginView.vue create mode 100644 apps/frontend/src/views/admin/AdminOrderDetailView.vue create mode 100644 apps/frontend/src/views/admin/AdminOrdersView.vue create mode 100644 apps/frontend/src/views/admin/AdminTaskDetailView.vue create mode 100644 apps/frontend/src/views/admin/AdminTasksView.vue create mode 100644 apps/frontend/src/views/admin/AdminUsersView.vue create mode 100644 apps/frontend/src/views/admin/AdminWebhookEventDetailView.vue create mode 100644 apps/frontend/src/views/admin/AdminWebhookEventsView.vue create mode 100644 apps/frontend/src/views/claim/ClaimView.vue create mode 100644 apps/frontend/src/views/tx/TencentBrowserView.vue create mode 100644 apps/frontend/tsconfig.app.json create mode 100644 apps/frontend/tsconfig.json create mode 100644 apps/frontend/tsconfig.node.json create mode 100644 apps/frontend/vite.config.ts create mode 100644 deploy/caddy/Caddyfile create mode 100644 deploy/docker/backend.Dockerfile create mode 100644 deploy/docker/frontend.Dockerfile create mode 100644 docker-compose.yml create mode 100644 docs/服务器部署教程.md create mode 100644 docs/生产环境变量模板.env create mode 100644 docs/迁移与部署计划.md create mode 100644 docs/首次上线检查清单.md diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 00000000..21f091af --- /dev/null +++ b/.dockerignore @@ -0,0 +1,13 @@ +.git +.DS_Store +.env +.env.local + +apps/*/node_modules +apps/*/dist +apps/*/.vite +apps/backend/data/*.db +apps/backend/data/*.db-shm +apps/backend/data/*.db-wal +apps/backend/data/browser-sessions +apps/backend/data/redeem-screenshots diff --git a/.env.example b/.env.example new file mode 100644 index 00000000..2757e57d --- /dev/null +++ b/.env.example @@ -0,0 +1,24 @@ +APP_DOMAIN=order.example.com +APP_BASE_URL=https://order.example.com + +BACKEND_PORT=3000 +CLAIM_BASE_URL=https://order.example.com/#/claim +DATABASE_FILE_PATH=/app/data/order-site.db +OCR_PROJECT_ROOT=/app/subservices/ocr-worker +TENCENT_REDEEM_PROOF_MODE=basic +TENCENT_BROWSER_HEADLESS=true +TENCENT_BROWSER_PREWARM=true +TENCENT_BROWSER_KEEP_ALIVE=true +TENCENT_SESSION_DEBUG=false + +ADMIN_SESSION_SECRET=replace-with-a-long-random-secret +ADMIN_DEFAULT_USERS_JSON=[{"username":"admin","password":"replace-with-strong-admin-password","role":"admin"},{"username":"operator","password":"replace-with-strong-operator-password","role":"operator"}] + +ORDER_SKU_MAPPINGS_JSON={"32768":"dnf-cdk-a","dnf-cdk-a":"dnf-cdk-a"} + +AGISO_APP_SECRET= +AGISO_MESSAGING_ENABLED=false +AGISO_APP_ID= +AGISO_ACCESS_TOKEN= +AGISO_MESSAGE_APP_SECRET= +AGISO_MESSAGE_TEMPLATE=您的订单 {platformOrderId} 已创建领取链接,请尽快打开并完成领取:{claimUrl} diff --git a/.gitignore b/.gitignore new file mode 100644 index 00000000..a80a2355 --- /dev/null +++ b/.gitignore @@ -0,0 +1,19 @@ +node_modules +.env +.env.local +.DS_Store + +apps/*/node_modules +apps/*/dist +apps/*/.vite +apps/*/.env.local + +apps/backend/config/local.cjs +apps/backend/data/*.db +apps/backend/data/*.db-shm +apps/backend/data/*.db-wal +apps/backend/data/browser-sessions +apps/backend/data/redeem-screenshots + +.ace-tool +.claude diff --git a/README.md b/README.md new file mode 100644 index 00000000..3ed6b07b --- /dev/null +++ b/README.md @@ -0,0 +1,64 @@ +# order-site-workspace + +统一工作区版本的订单自动兑换系统。 + +## 目录结构 + +```text +apps/ + backend/ Node + Express + Playwright + OCR worker + frontend/ Vue 3 + Vite + Element Plus +deploy/ + caddy/ Caddy 配置 + docker/ Dockerfile +docs/ 迁移与部署文档 +docker-compose.yml +``` + +## 本地开发 + +前端: + +```bash +cd apps/frontend +npm install +npm run dev +``` + +后端: + +```bash +cd apps/backend +npm install +cd subservices/ocr-worker +uv sync +cd ../.. +cp config/local.example.cjs config/local.cjs +npm run dev +``` + +## Docker 部署 + +```bash +cp .env.example .env +docker compose build +docker compose up -d +``` + +默认入口: + +- 前端与领取页:`https://你的域名/` +- 后端健康检查:`https://你的域名/health` +- 后端接口前缀:`https://你的域名/api/v1/...` + +详细文档: + +- [服务器部署教程](/Users/yml/codes/order-site-workspace/docs/%E6%9C%8D%E5%8A%A1%E5%99%A8%E9%83%A8%E7%BD%B2%E6%95%99%E7%A8%8B.md) +- [首次上线检查清单](/Users/yml/codes/order-site-workspace/docs/%E9%A6%96%E6%AC%A1%E4%B8%8A%E7%BA%BF%E6%A3%80%E6%9F%A5%E6%B8%85%E5%8D%95.md) +- [生产环境变量模板.env](/Users/yml/codes/order-site-workspace/docs/%E7%94%9F%E4%BA%A7%E7%8E%AF%E5%A2%83%E5%8F%98%E9%87%8F%E6%A8%A1%E6%9D%BF.env) + +## 迁移原则 + +- 不重写现有业务代码 +- 先统一目录、部署和配置 +- 生产环境优先使用 `.env` 和 Docker 卷 diff --git a/apps/backend/.gitignore b/apps/backend/.gitignore new file mode 100644 index 00000000..0766591c --- /dev/null +++ b/apps/backend/.gitignore @@ -0,0 +1,15 @@ +node_modules +.env +.ace-tool/ +.claude/ +.venv/ +subservices/**/.venv/ +__pycache__/ +*.pyc +config/local.cjs + +data/browser-sessions/ +data/redeem-screenshots/ +data/*.db +data/*.db-shm +data/*.db-wal diff --git a/apps/backend/README.md b/apps/backend/README.md new file mode 100644 index 00000000..32544525 --- /dev/null +++ b/apps/backend/README.md @@ -0,0 +1,111 @@ +# order-site-backend + +腾讯活动浏览器会话后端。 + +当前主线只有一条: + +- 后端托管 Playwright 浏览器 +- 前端展示二维码并轮询会话状态 +- 登录完成后,后端直接在活动页填写 CDK、识别验证码、执行兑换 +- 兑换完成后返回状态,并按配置决定是否生成截图/证明产物 + +## 快速启动 + +先安装 Node 依赖: + +```bash +npm install +``` + +首次使用前同步 OCR 子服务依赖: + +```bash +cd /Users/yml/codes/order-site-backend/subservices/ocr-worker +uv sync +``` + +回到项目根目录启动: + +```bash +cd /Users/yml/codes/order-site-backend +npm run dev +``` + +Linux 部署: + +```bash +npm install +npm run browser:install:linux +npm run start +``` + +## 配置文件 + +现在推荐直接改配置文件,不需要每次在终端里手动拼环境变量。 + +- 默认配置:`config/default.cjs` +- 本地覆盖:`config/local.cjs` +- 参考模板:`config/local.example.cjs` + +推荐做法: + +1. 通用默认值放在 `config/default.cjs` +2. 你自己机器上的配置放在 `config/local.cjs` +3. 只有 CI、部署脚本、临时排查时再用环境变量覆盖 + +当前最常改的配置有: + +- 服务端口 +- 浏览器是否无头、是否预热、是否常驻、slowMo +- OCR 子服务目录 +- 会话调试开关 +- 兑换证明模式 `full | basic | off` + +## 接口 + +- `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` + 关闭浏览器会话 + +## 目录 + +- `src/index.js` + Express 入口 +- `src/routes/tencent.js` + 接口路由 +- `src/services/session.js` + 会话编排与浏览器生命周期 +- `src/services/session-*.js` + 登录、兑换、OCR、凭证产物等模块 +- `subservices/ocr-worker` + 内嵌 OCR 子服务 + +## 产物 + +浏览器会话产物默认保存在: + +- `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。 + +`TENCENT_REDEEM_PROOF_MODE` 对应的配置文件项会影响生成强度: + +- `full`:完整证明 +- `basic`:只保留最终截图和结果 JSON +- `off`:不生成证明文件 diff --git a/apps/backend/app信息.md b/apps/backend/app信息.md new file mode 100644 index 00000000..dbbd05c2 --- /dev/null +++ b/apps/backend/app信息.md @@ -0,0 +1,53 @@ +order_cdk + +AppId: 2026040753219154857 +AppSecret: tccxk5c7ppy7xpr43rvastceyydskfha + + +AccessToken: +TbAlds54ez66ateztecyprxtn6kb98w23ghy6r9c5yxt2zueknd +可将AccessToken复制给应用商! + + + +拼接用户授权需访问url ,示例及参数说明如下: + + +https://alds.agiso.com/authorize.aspx?appId=2026040753219154857&state=order_cdk + + +https://alds.agiso.com/authorize.aspx?appId={$开发者应用的AppId}&state={$开发者自定义参数} + + + +https://open.agiso.com/document/#/alds/push/refundClosePush + +https://open.agiso.com/document/#/aldsIdle/guide + + + +目前这个系统的 /Users/yml/codes/order-site-backend +/Users/yml/codes/order-site-rewrite +前后端, 我如果想增加自动化 +1. 根据 订单创建成功后通知 获取订单信息, 然后根据订单信息等等匹配 需要的 cdk 等等信息 +2. 然后系统创建一个包含订单号 的连接, 可以自动 发给用户 +3. 用户收到链接后, 打开 通过qq或者wx 绑定自己的角色后, 系统可以后端自动提交cdk, 自动绑定, 截图 发给用户前端 + +这是我的初步设定 你分析合理性, 或者有哪些更好的方式 + + + +--- +admin / dev-admin-123456 +operator / dev-operator-123456 + + +DJQFf7Hg0VKY82JG76 +DJQFf7HgONCL4XD4EH + + +---- +ngrok +https://5cc8-193-176-84-38.ngrok-free.app + +https://5cc8-193-176-84-38.ngrok-free.app/api/v1/webhooks/agiso/trade diff --git a/apps/backend/config/default.cjs b/apps/backend/config/default.cjs new file mode 100644 index 00000000..5bab49e4 --- /dev/null +++ b/apps/backend/config/default.cjs @@ -0,0 +1,112 @@ +const path = require('node:path') + +module.exports = { + server: { + // 后端监听端口。 + port: 3000, + }, + + browser: { + // 自定义浏览器可执行文件路径。 + // 留空时默认使用 Playwright 自带的 Chromium。 + chromePath: '', + + // 是否启用无头模式。 + // true / false 表示强制指定,null 表示按环境自动判断。 + headless: null, + + // 是否自动打开 DevTools。 + // true / false 表示强制指定,null 表示按环境自动判断。 + devtools: null, + + // 是否让浏览器在空闲时继续常驻后台。 + // true / false 表示强制指定,null 表示按环境自动判断。 + keepAlive: null, + + // 服务启动后是否立即预热浏览器,减少第一次创建会话的冷启动。 + // true / false 表示强制指定,null 表示按环境自动判断。 + prewarm: null, + + // Playwright slowMo,单位毫秒。 + // 设为 null 时,开发环境默认 150,生产环境默认 0。 + slowMoMs: null, + }, + + session: { + // 是否输出浏览器会话调试日志,并在接口里带出调试字段。 + debug: false, + }, + + ocr: { + // 本地 OCR 子服务目录。 + projectRoot: path.resolve(__dirname, '../subservices/ocr-worker'), + }, + + database: { + // SQLite 数据文件。 + filePath: path.resolve(__dirname, '../data/order-site.db'), + }, + + orders: { + // 用户领取页基础链接,不带末尾 token。 + claimBaseUrl: 'http://127.0.0.1:5173/#/claim', + + // 领取 token 默认有效时长。 + tokenTtlHours: 24, + + // 平台商品标识到内部 SKU 的映射。 + skuMappings: {}, + }, + + admin: { + // 后台 session 签名密钥,留空表示禁用后台登录。 + sessionSecret: '', + + // 后台登录态默认有效时长。 + sessionTtlHours: 12, + + // 默认初始化账号。仅在数据库里不存在同名用户时创建。 + defaultUsers: [], + }, + + platforms: { + agiso: { + // Agiso 开放平台应用密钥,用于 webhook 验签。 + appSecret: '', + + messaging: { + // 是否启用 Agiso 站内消息发送。 + enabled: false, + + // 发送消息接口。 + sendMessageEndpoint: 'https://gw-api.agiso.com/aldsIdle/ImMsg/SendMsg', + + // Header 中的 ApiVersion。 + apiVersion: '1', + + // 兼容保留字段,当前发送消息固定使用 Bearer 鉴权。 + authMode: 'bearer', + + // 开放平台应用标识。 + appId: '', + + // 开放平台 AccessToken。 + accessToken: '', + + // 若单独配置则优先于 platforms.agiso.appSecret。 + appSecret: '', + + // 发送给用户的默认消息模板。 + messageTemplate: '您的订单 {platformOrderId} 已创建领取链接,请在 {expiredAt} 前完成领取:{claimUrl}', + }, + }, + }, + + redeem: { + // 兑换后的证明产物模式: + // full = 完整证明(最终图 + 北京时间 + HTML + result.json) + // basic = 只保留最终截图和 result.json + // off = 完全不生成证明文件 + proofMode: 'full', + }, +} diff --git a/apps/backend/config/local.example.cjs b/apps/backend/config/local.example.cjs new file mode 100644 index 00000000..b93a5d13 --- /dev/null +++ b/apps/backend/config/local.example.cjs @@ -0,0 +1,85 @@ +module.exports = { + server: { + // 例如:本机改成 3100 + port: 3000, + }, + + browser: { + // 例如:'/Applications/Google Chrome.app/Contents/MacOS/Google Chrome' + chromePath: '', + + // 例如:本机调试时可改成 false + headless: null, + devtools: null, + keepAlive: null, + prewarm: null, + slowMoMs: null, + }, + + session: { + // 需要排查问题时可临时改成 true + debug: false, + }, + + ocr: { + // 如果 OCR 子服务目录不是仓库默认位置,就在这里覆盖 + projectRoot: '', + }, + + database: { + // 如果想把 SQLite 放到别的位置,就在这里覆盖 + filePath: '', + }, + + orders: { + // 用户领取页基础地址,例如:https://your-domain.com/#/claim + claimBaseUrl: 'http://127.0.0.1:5173/#/claim', + tokenTtlHours: 24, + skuMappings: { + // "32768": "dnf-cdk-a", + }, + }, + + admin: { + // 后台 session 签名密钥,至少 32 位随机串 + sessionSecret: 'change-this-session-secret', + + sessionTtlHours: 12, + + defaultUsers: [ + { + username: 'admin', + password: 'change-this-admin-password', + role: 'admin', + }, + { + username: 'operator', + password: 'change-this-operator-password', + role: 'operator', + }, + ], + }, + + platforms: { + agiso: { + // Agiso 回调验签密钥 + appSecret: '', + + messaging: { + enabled: false, + sendMessageEndpoint: 'https://gw-api.agiso.com/aldsIdle/ImMsg/SendMsg', + apiVersion: '1', + authMode: 'bearer', + appId: '', + accessToken: '', + appSecret: '', + messageTemplate: '您的订单 {platformOrderId} 已创建领取链接,请在 {expiredAt} 前完成领取:{claimUrl}', + }, + }, + }, + + redeem: { + // 可选:full / basic / off + proofMode: 'full', + }, +} diff --git a/apps/backend/data/.gitkeep b/apps/backend/data/.gitkeep new file mode 100644 index 00000000..e69de29b diff --git a/apps/backend/package-lock.json b/apps/backend/package-lock.json new file mode 100644 index 00000000..0bc992a6 --- /dev/null +++ b/apps/backend/package-lock.json @@ -0,0 +1,871 @@ +{ + "name": "order-site-backend", + "version": "0.1.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "order-site-backend", + "version": "0.1.0", + "dependencies": { + "express": "^5.1.0", + "playwright": "^1.59.1" + } + }, + "node_modules/accepts": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/accepts/-/accepts-2.0.0.tgz", + "integrity": "sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng==", + "license": "MIT", + "dependencies": { + "mime-types": "^3.0.0", + "negotiator": "^1.0.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/body-parser": { + "version": "2.2.2", + "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.2.2.tgz", + "integrity": "sha512-oP5VkATKlNwcgvxi0vM0p/D3n2C3EReYVX+DNYs5TjZFn/oQt2j+4sVJtSMr18pdRr8wjTcBl6LoV+FUwzPmNA==", + "license": "MIT", + "dependencies": { + "bytes": "^3.1.2", + "content-type": "^1.0.5", + "debug": "^4.4.3", + "http-errors": "^2.0.0", + "iconv-lite": "^0.7.0", + "on-finished": "^2.4.1", + "qs": "^6.14.1", + "raw-body": "^3.0.1", + "type-is": "^2.0.1" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/bytes": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", + "integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/call-bind-apply-helpers": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/call-bound": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz", + "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "get-intrinsic": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/content-disposition": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-1.0.1.tgz", + "integrity": "sha512-oIXISMynqSqm241k6kcQ5UwttDILMK4BiurCfGEREw6+X9jkkpEe5T9FZaApyLGGOnFuyMWZpdolTXMtvEJ08Q==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/content-type": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.5.tgz", + "integrity": "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/cookie": { + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz", + "integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/cookie-signature": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.2.2.tgz", + "integrity": "sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg==", + "license": "MIT", + "engines": { + "node": ">=6.6.0" + } + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/depd": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", + "integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/dunder-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/ee-first": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", + "integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==", + "license": "MIT" + }, + "node_modules/encodeurl": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz", + "integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/es-define-property": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-object-atoms": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz", + "integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/escape-html": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz", + "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==", + "license": "MIT" + }, + "node_modules/etag": { + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz", + "integrity": "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/express": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/express/-/express-5.2.1.tgz", + "integrity": "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==", + "license": "MIT", + "dependencies": { + "accepts": "^2.0.0", + "body-parser": "^2.2.1", + "content-disposition": "^1.0.0", + "content-type": "^1.0.5", + "cookie": "^0.7.1", + "cookie-signature": "^1.2.1", + "debug": "^4.4.0", + "depd": "^2.0.0", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "etag": "^1.8.1", + "finalhandler": "^2.1.0", + "fresh": "^2.0.0", + "http-errors": "^2.0.0", + "merge-descriptors": "^2.0.0", + "mime-types": "^3.0.0", + "on-finished": "^2.4.1", + "once": "^1.4.0", + "parseurl": "^1.3.3", + "proxy-addr": "^2.0.7", + "qs": "^6.14.0", + "range-parser": "^1.2.1", + "router": "^2.2.0", + "send": "^1.1.0", + "serve-static": "^2.2.0", + "statuses": "^2.0.1", + "type-is": "^2.0.1", + "vary": "^1.1.2" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/finalhandler": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-2.1.1.tgz", + "integrity": "sha512-S8KoZgRZN+a5rNwqTxlZZePjT/4cnm0ROV70LedRHZ0p8u9fRID0hJUZQpkKLzro8LfmC8sx23bY6tVNxv8pQA==", + "license": "MIT", + "dependencies": { + "debug": "^4.4.0", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "on-finished": "^2.4.1", + "parseurl": "^1.3.3", + "statuses": "^2.0.1" + }, + "engines": { + "node": ">= 18.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/forwarded": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz", + "integrity": "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/fresh": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/fresh/-/fresh-2.0.0.tgz", + "integrity": "sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A==", + "license": "MIT", + "engines": { + "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", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-intrinsic": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "function-bind": "^1.1.2", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/gopd": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-symbols": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/hasown": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", + "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==", + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/http-errors": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz", + "integrity": "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==", + "license": "MIT", + "dependencies": { + "depd": "~2.0.0", + "inherits": "~2.0.4", + "setprototypeof": "~1.2.0", + "statuses": "~2.0.2", + "toidentifier": "~1.0.1" + }, + "engines": { + "node": ">= 0.8" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/iconv-lite": { + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.2.tgz", + "integrity": "sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw==", + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, + "engines": { + "node": ">=0.10.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/inherits": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", + "license": "ISC" + }, + "node_modules/ipaddr.js": { + "version": "1.9.1", + "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz", + "integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==", + "license": "MIT", + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/is-promise": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/is-promise/-/is-promise-4.0.0.tgz", + "integrity": "sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ==", + "license": "MIT" + }, + "node_modules/math-intrinsics": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/media-typer": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-1.1.0.tgz", + "integrity": "sha512-aisnrDP4GNe06UcKFnV5bfMNPBUw4jsLGaWwWfnH3v02GnBuXX2MCVn5RbrWo0j3pczUilYblq7fQ7Nw2t5XKw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/merge-descriptors": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-2.0.0.tgz", + "integrity": "sha512-Snk314V5ayFLhp3fkUREub6WtjBfPdCPY1Ln8/8munuLuiYhsABgBVWsozAG+MWMbVEvcdcpbi9R7ww22l9Q3g==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/mime-db": { + "version": "1.54.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.54.0.tgz", + "integrity": "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-3.0.2.tgz", + "integrity": "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A==", + "license": "MIT", + "dependencies": { + "mime-db": "^1.54.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, + "node_modules/negotiator": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-1.0.0.tgz", + "integrity": "sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/object-inspect": { + "version": "1.13.4", + "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz", + "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/on-finished": { + "version": "2.4.1", + "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz", + "integrity": "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==", + "license": "MIT", + "dependencies": { + "ee-first": "1.1.1" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/once": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", + "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", + "license": "ISC", + "dependencies": { + "wrappy": "1" + } + }, + "node_modules/parseurl": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz", + "integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/path-to-regexp": { + "version": "8.4.2", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-8.4.2.tgz", + "integrity": "sha512-qRcuIdP69NPm4qbACK+aDogI5CBDMi1jKe0ry5rSQJz8JVLsC7jV8XpiJjGRLLol3N+R5ihGYcrPLTno6pAdBA==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/playwright": { + "version": "1.59.1", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.59.1.tgz", + "integrity": "sha512-C8oWjPR3F81yljW9o5OxcWzfh6avkVwDD2VYdwIGqTkl+OGFISgypqzfu7dOe4QNLL2aqcWBmI3PMtLIK233lw==", + "license": "Apache-2.0", + "dependencies": { + "playwright-core": "1.59.1" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "fsevents": "2.3.2" + } + }, + "node_modules/playwright-core": { + "version": "1.59.1", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.59.1.tgz", + "integrity": "sha512-HBV/RJg81z5BiiZ9yPzIiClYV/QMsDCKUyogwH9p3MCP6IYjUFu/MActgYAvK0oWyV9NlwM3GLBjADyWgydVyg==", + "license": "Apache-2.0", + "bin": { + "playwright-core": "cli.js" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/proxy-addr": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz", + "integrity": "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==", + "license": "MIT", + "dependencies": { + "forwarded": "0.2.0", + "ipaddr.js": "1.9.1" + }, + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/qs": { + "version": "6.15.0", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.15.0.tgz", + "integrity": "sha512-mAZTtNCeetKMH+pSjrb76NAM8V9a05I9aBZOHztWy/UqcJdQYNsf59vrRKWnojAT9Y+GbIvoTBC++CPHqpDBhQ==", + "license": "BSD-3-Clause", + "dependencies": { + "side-channel": "^1.1.0" + }, + "engines": { + "node": ">=0.6" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/range-parser": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz", + "integrity": "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/raw-body": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-3.0.2.tgz", + "integrity": "sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA==", + "license": "MIT", + "dependencies": { + "bytes": "~3.1.2", + "http-errors": "~2.0.1", + "iconv-lite": "~0.7.0", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/router": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/router/-/router-2.2.0.tgz", + "integrity": "sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ==", + "license": "MIT", + "dependencies": { + "debug": "^4.4.0", + "depd": "^2.0.0", + "is-promise": "^4.0.0", + "parseurl": "^1.3.3", + "path-to-regexp": "^8.0.0" + }, + "engines": { + "node": ">= 18" + } + }, + "node_modules/safer-buffer": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", + "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", + "license": "MIT" + }, + "node_modules/send": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/send/-/send-1.2.1.tgz", + "integrity": "sha512-1gnZf7DFcoIcajTjTwjwuDjzuz4PPcY2StKPlsGAQ1+YH20IRVrBaXSWmdjowTJ6u8Rc01PoYOGHXfP1mYcZNQ==", + "license": "MIT", + "dependencies": { + "debug": "^4.4.3", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "etag": "^1.8.1", + "fresh": "^2.0.0", + "http-errors": "^2.0.1", + "mime-types": "^3.0.2", + "ms": "^2.1.3", + "on-finished": "^2.4.1", + "range-parser": "^1.2.1", + "statuses": "^2.0.2" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/serve-static": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-2.2.1.tgz", + "integrity": "sha512-xRXBn0pPqQTVQiC8wyQrKs2MOlX24zQ0POGaj0kultvoOCstBQM5yvOhAVSUwOMjQtTvsPWoNCHfPGwaaQJhTw==", + "license": "MIT", + "dependencies": { + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "parseurl": "^1.3.3", + "send": "^1.2.0" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/setprototypeof": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz", + "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==", + "license": "ISC" + }, + "node_modules/side-channel": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.0.tgz", + "integrity": "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.3", + "side-channel-list": "^1.0.0", + "side-channel-map": "^1.0.1", + "side-channel-weakmap": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-list": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.0.tgz", + "integrity": "sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-map": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz", + "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-weakmap": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz", + "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3", + "side-channel-map": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/statuses": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz", + "integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/toidentifier": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz", + "integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==", + "license": "MIT", + "engines": { + "node": ">=0.6" + } + }, + "node_modules/type-is": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/type-is/-/type-is-2.0.1.tgz", + "integrity": "sha512-OZs6gsjF4vMp32qrCbiVSkrFmXtG/AZhY3t0iAMrMBiAZyV9oALtXO8hsrHbMXF9x6L3grlFuwW2oAz7cav+Gw==", + "license": "MIT", + "dependencies": { + "content-type": "^1.0.5", + "media-typer": "^1.1.0", + "mime-types": "^3.0.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/unpipe": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz", + "integrity": "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/vary": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz", + "integrity": "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/wrappy": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", + "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", + "license": "ISC" + } + } +} diff --git a/apps/backend/package.json b/apps/backend/package.json new file mode 100644 index 00000000..2d6dc2d1 --- /dev/null +++ b/apps/backend/package.json @@ -0,0 +1,17 @@ +{ + "name": "order-site-backend", + "version": "0.1.0", + "private": true, + "type": "module", + "scripts": { + "browser:install": "playwright install chromium", + "browser:install:linux": "playwright install --with-deps chromium", + "db:migrate": "node src/db/migrate.js", + "dev": "node --watch src/index.js", + "start": "node src/index.js" + }, + "dependencies": { + "express": "^5.1.0", + "playwright": "^1.59.1" + } +} diff --git a/apps/backend/scripts/test-order-flow.sh b/apps/backend/scripts/test-order-flow.sh new file mode 100755 index 00000000..682606f9 --- /dev/null +++ b/apps/backend/scripts/test-order-flow.sh @@ -0,0 +1,99 @@ +#!/usr/bin/env bash +set -euo pipefail + +BASE_URL="http://127.0.0.1:3000" +CONFIG_FILE="/Users/yml/codes/order-site-backend/config/local.cjs" +APP_SECRET=$(node -p "require('$CONFIG_FILE').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 < cloneValue(item)) + } + + if (isPlainObject(value)) { + const output = {} + for (const [key, item] of Object.entries(value)) { + output[key] = cloneValue(item) + } + return output + } + + return value +} + +function isPlainObject(value) { + return Object.prototype.toString.call(value) === '[object Object]' +} + +function parseBoolean(rawValue) { + const normalized = String(rawValue || '') + .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 parseInteger(rawValue) { + const normalized = String(rawValue || '').trim() + + if (!normalized) { + return null + } + + const parsed = Number(normalized) + return Number.isFinite(parsed) ? parsed : null +} + +function parseJsonObject(rawValue) { + const normalized = String(rawValue || '').trim() + + if (!normalized) { + return null + } + + try { + const parsed = JSON.parse(normalized) + return isPlainObject(parsed) ? parsed : null + } catch { + return null + } +} + +function parseJsonArray(rawValue) { + const normalized = String(rawValue || '').trim() + + if (!normalized) { + return null + } + + try { + const parsed = JSON.parse(normalized) + return Array.isArray(parsed) ? parsed : null + } catch { + return null + } +} diff --git a/apps/backend/src/db/client.js b/apps/backend/src/db/client.js new file mode 100644 index 00000000..11678967 --- /dev/null +++ b/apps/backend/src/db/client.js @@ -0,0 +1,43 @@ +import fs from 'node:fs' +import path from 'node:path' +import { DatabaseSync } from 'node:sqlite' + +import { runtimeConfig } from '../config/runtime.js' + +let databaseInstance = null + +export function getDb() { + if (!databaseInstance) { + const filePath = resolveDatabasePath() + fs.mkdirSync(path.dirname(filePath), { recursive: true }) + databaseInstance = new DatabaseSync(filePath) + databaseInstance.exec('PRAGMA foreign_keys = ON;') + databaseInstance.exec('PRAGMA journal_mode = WAL;') + } + + return databaseInstance +} + +export function runInTransaction(fn) { + const db = getDb() + db.exec('BEGIN') + + try { + const result = fn(db) + db.exec('COMMIT') + return result + } catch (error) { + db.exec('ROLLBACK') + throw error + } +} + +function resolveDatabasePath() { + const configured = String(runtimeConfig.database.filePath || '').trim() + + if (!configured) { + throw new Error('缺少数据库文件路径配置') + } + + return path.resolve(configured) +} diff --git a/apps/backend/src/db/migrate.js b/apps/backend/src/db/migrate.js new file mode 100644 index 00000000..176f050d --- /dev/null +++ b/apps/backend/src/db/migrate.js @@ -0,0 +1,47 @@ +import fs from 'node:fs' +import path from 'node:path' +import { fileURLToPath } from 'node:url' +import process from 'node:process' + +import { getDb } from './client.js' + +const CURRENT_DIR = path.dirname(fileURLToPath(import.meta.url)) +const MIGRATIONS_DIR = path.join(CURRENT_DIR, 'migrations') + +export function runDatabaseMigrations() { + const db = getDb() + const files = fs.readdirSync(MIGRATIONS_DIR) + .filter((name) => name.endsWith('.sql')) + .sort() + + db.exec(` + CREATE TABLE IF NOT EXISTS schema_migrations ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + filename TEXT NOT NULL UNIQUE, + applied_at TEXT NOT NULL + ); + `) + + const appliedRows = db.prepare('SELECT filename FROM schema_migrations').all() + const applied = new Set(appliedRows.map((row) => row.filename)) + const insertApplied = db.prepare(` + INSERT INTO schema_migrations (filename, applied_at) + VALUES (?, ?) + `) + + for (const filename of files) { + if (applied.has(filename)) { + continue + } + + const sql = fs.readFileSync(path.join(MIGRATIONS_DIR, filename), 'utf8') + db.exec(sql) + insertApplied.run(filename, new Date().toISOString()) + } +} + +const currentFilePath = fileURLToPath(import.meta.url) + +if (process.argv[1] && path.resolve(process.argv[1]) === currentFilePath) { + runDatabaseMigrations() +} diff --git a/apps/backend/src/db/migrations/001_init.sql b/apps/backend/src/db/migrations/001_init.sql new file mode 100644 index 00000000..0dbfbcd8 --- /dev/null +++ b/apps/backend/src/db/migrations/001_init.sql @@ -0,0 +1,129 @@ +PRAGMA foreign_keys = ON; + +CREATE TABLE IF NOT EXISTS orders ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + platform TEXT NOT NULL, + platform_order_id TEXT NOT NULL, + order_status TEXT NOT NULL DEFAULT 'created', + pay_status TEXT NOT NULL DEFAULT 'unpaid', + buyer_id TEXT NOT NULL DEFAULT '', + buyer_name TEXT NOT NULL DEFAULT '', + receiver_contact TEXT NOT NULL DEFAULT '', + total_amount INTEGER NOT NULL DEFAULT 0, + currency TEXT NOT NULL DEFAULT 'CNY', + raw_payload_json TEXT NOT NULL DEFAULT '{}', + paid_at TEXT, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL, + UNIQUE(platform, platform_order_id) +); + +CREATE TABLE IF NOT EXISTS order_items ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + order_id INTEGER NOT NULL, + sku_code TEXT NOT NULL, + sku_name TEXT NOT NULL DEFAULT '', + quantity INTEGER NOT NULL DEFAULT 1, + spec_json TEXT NOT NULL DEFAULT '{}', + delivery_mode TEXT NOT NULL DEFAULT 'claim_link', + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL, + FOREIGN KEY(order_id) REFERENCES orders(id) ON DELETE CASCADE +); + +CREATE INDEX IF NOT EXISTS idx_order_items_order_id ON order_items(order_id); +CREATE INDEX IF NOT EXISTS idx_order_items_sku_code ON order_items(sku_code); + +CREATE TABLE IF NOT EXISTS cdk_inventory ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + batch_no TEXT NOT NULL DEFAULT '', + sku_code TEXT NOT NULL, + cdk_code TEXT NOT NULL, + status TEXT NOT NULL DEFAULT 'available', + reserved_by_task_id INTEGER, + invalid_reason TEXT NOT NULL DEFAULT '', + delivered_at TEXT, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL, + UNIQUE(sku_code, cdk_code) +); + +CREATE INDEX IF NOT EXISTS idx_cdk_inventory_sku_code ON cdk_inventory(sku_code); +CREATE INDEX IF NOT EXISTS idx_cdk_inventory_status ON cdk_inventory(status); +CREATE INDEX IF NOT EXISTS idx_cdk_inventory_reserved_task ON cdk_inventory(reserved_by_task_id); + +CREATE TABLE IF NOT EXISTS delivery_tasks ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + order_id INTEGER NOT NULL, + order_item_id INTEGER NOT NULL, + platform_order_id TEXT NOT NULL, + task_no TEXT NOT NULL, + task_status TEXT NOT NULL DEFAULT 'pending_payment', + login_type TEXT NOT NULL DEFAULT '', + claim_token_id INTEGER, + reserved_cdk_id INTEGER, + browser_session_id TEXT NOT NULL DEFAULT '', + nickname TEXT NOT NULL DEFAULT '', + role_id TEXT NOT NULL DEFAULT '', + role_name TEXT NOT NULL DEFAULT '', + area TEXT NOT NULL DEFAULT '', + partition_name TEXT NOT NULL DEFAULT '', + result_code TEXT NOT NULL DEFAULT '', + result_message TEXT NOT NULL DEFAULT '', + screenshot_path TEXT NOT NULL DEFAULT '', + artifacts_json TEXT NOT NULL DEFAULT '{}', + last_error TEXT NOT NULL DEFAULT '', + retry_count INTEGER NOT NULL DEFAULT 0, + expires_at TEXT, + claimed_at TEXT, + role_confirmed_at TEXT, + redeemed_at TEXT, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL, + FOREIGN KEY(order_id) REFERENCES orders(id) ON DELETE CASCADE, + FOREIGN KEY(order_item_id) REFERENCES order_items(id) ON DELETE CASCADE, + FOREIGN KEY(reserved_cdk_id) REFERENCES cdk_inventory(id) ON DELETE SET NULL +); + +CREATE UNIQUE INDEX IF NOT EXISTS idx_delivery_tasks_task_no ON delivery_tasks(task_no); +CREATE INDEX IF NOT EXISTS idx_delivery_tasks_order_id ON delivery_tasks(order_id); +CREATE INDEX IF NOT EXISTS idx_delivery_tasks_order_item_id ON delivery_tasks(order_item_id); +CREATE INDEX IF NOT EXISTS idx_delivery_tasks_status ON delivery_tasks(task_status); +CREATE INDEX IF NOT EXISTS idx_delivery_tasks_platform_order_id ON delivery_tasks(platform_order_id); + +CREATE TABLE IF NOT EXISTS claim_tokens ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + task_id INTEGER NOT NULL, + token TEXT NOT NULL, + status TEXT NOT NULL DEFAULT 'active', + expired_at TEXT NOT NULL, + used_at TEXT, + max_use_count INTEGER NOT NULL DEFAULT 1, + used_count INTEGER NOT NULL DEFAULT 0, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL, + FOREIGN KEY(task_id) REFERENCES delivery_tasks(id) ON DELETE CASCADE +); + +CREATE UNIQUE INDEX IF NOT EXISTS idx_claim_tokens_token ON claim_tokens(token); +CREATE INDEX IF NOT EXISTS idx_claim_tokens_task_id ON claim_tokens(task_id); +CREATE INDEX IF NOT EXISTS idx_claim_tokens_status ON claim_tokens(status); + +CREATE TABLE IF NOT EXISTS webhook_events ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + platform TEXT NOT NULL, + event_type TEXT NOT NULL, + event_key TEXT NOT NULL, + signature_valid INTEGER NOT NULL DEFAULT 0, + headers_json TEXT NOT NULL DEFAULT '{}', + query_json TEXT NOT NULL DEFAULT '{}', + body_json TEXT NOT NULL DEFAULT '{}', + processed INTEGER NOT NULL DEFAULT 0, + process_error TEXT NOT NULL DEFAULT '', + related_order_id INTEGER, + created_at TEXT NOT NULL +); + +CREATE INDEX IF NOT EXISTS idx_webhook_events_platform_key ON webhook_events(platform, event_key); +CREATE INDEX IF NOT EXISTS idx_webhook_events_processed ON webhook_events(processed); +CREATE INDEX IF NOT EXISTS idx_webhook_events_created_at ON webhook_events(created_at); diff --git a/apps/backend/src/db/migrations/002_admin_users.sql b/apps/backend/src/db/migrations/002_admin_users.sql new file mode 100644 index 00000000..ccd95272 --- /dev/null +++ b/apps/backend/src/db/migrations/002_admin_users.sql @@ -0,0 +1,12 @@ +CREATE TABLE IF NOT EXISTS admin_users ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + username TEXT NOT NULL UNIQUE, + password_hash TEXT NOT NULL, + role TEXT NOT NULL DEFAULT 'operator', + status TEXT NOT NULL DEFAULT 'active', + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL +); + +CREATE INDEX IF NOT EXISTS idx_admin_users_role ON admin_users(role); +CREATE INDEX IF NOT EXISTS idx_admin_users_status ON admin_users(status); diff --git a/apps/backend/src/db/migrations/003_admin_audit_logs.sql b/apps/backend/src/db/migrations/003_admin_audit_logs.sql new file mode 100644 index 00000000..3bb3dfbe --- /dev/null +++ b/apps/backend/src/db/migrations/003_admin_audit_logs.sql @@ -0,0 +1,17 @@ +CREATE TABLE IF NOT EXISTS admin_audit_logs ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + actor_user_id INTEGER NOT NULL, + actor_username TEXT NOT NULL, + actor_role TEXT NOT NULL, + action TEXT NOT NULL, + target_type TEXT NOT NULL, + target_id TEXT NOT NULL DEFAULT '', + payload_json TEXT NOT NULL DEFAULT '{}', + created_at TEXT NOT NULL, + FOREIGN KEY (actor_user_id) REFERENCES admin_users(id) +); + +CREATE INDEX IF NOT EXISTS idx_admin_audit_logs_actor_user_id ON admin_audit_logs(actor_user_id); +CREATE INDEX IF NOT EXISTS idx_admin_audit_logs_action ON admin_audit_logs(action); +CREATE INDEX IF NOT EXISTS idx_admin_audit_logs_target_type ON admin_audit_logs(target_type); +CREATE INDEX IF NOT EXISTS idx_admin_audit_logs_created_at ON admin_audit_logs(created_at); diff --git a/apps/backend/src/db/migrations/004_message_deliveries.sql b/apps/backend/src/db/migrations/004_message_deliveries.sql new file mode 100644 index 00000000..083fbbd6 --- /dev/null +++ b/apps/backend/src/db/migrations/004_message_deliveries.sql @@ -0,0 +1,29 @@ +CREATE TABLE IF NOT EXISTS message_deliveries ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + platform TEXT NOT NULL, + channel TEXT NOT NULL, + order_id INTEGER, + task_id INTEGER, + platform_order_id TEXT NOT NULL DEFAULT '', + recipient_key TEXT NOT NULL DEFAULT '', + message_content TEXT NOT NULL DEFAULT '', + claim_url TEXT NOT NULL DEFAULT '', + status TEXT NOT NULL DEFAULT 'pending', + request_url TEXT NOT NULL DEFAULT '', + request_headers_json TEXT NOT NULL DEFAULT '{}', + request_body_json TEXT NOT NULL DEFAULT '{}', + response_status INTEGER NOT NULL DEFAULT 0, + response_json TEXT NOT NULL DEFAULT '{}', + error_message TEXT NOT NULL DEFAULT '', + sent_at TEXT, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL, + FOREIGN KEY (order_id) REFERENCES orders(id) ON DELETE SET NULL, + FOREIGN KEY (task_id) REFERENCES delivery_tasks(id) ON DELETE SET NULL +); + +CREATE INDEX IF NOT EXISTS idx_message_deliveries_order_id ON message_deliveries(order_id); +CREATE INDEX IF NOT EXISTS idx_message_deliveries_task_id ON message_deliveries(task_id); +CREATE INDEX IF NOT EXISTS idx_message_deliveries_platform_order_id ON message_deliveries(platform_order_id); +CREATE INDEX IF NOT EXISTS idx_message_deliveries_status ON message_deliveries(status); +CREATE INDEX IF NOT EXISTS idx_message_deliveries_created_at ON message_deliveries(created_at); diff --git a/apps/backend/src/index.js b/apps/backend/src/index.js new file mode 100644 index 00000000..84b297d2 --- /dev/null +++ b/apps/backend/src/index.js @@ -0,0 +1,170 @@ +import express from 'express' +import process from 'node:process' + +import { runtimeConfig } from './config/runtime.js' +import { runDatabaseMigrations } from './db/migrate.js' +import adminRouter from './routes/admin.js' +import claimsRouter from './routes/claims.js' +import webhooksRouter from './routes/webhooks.js' +import { ensureAdminUsersBootstrapped } from './services/admin-auth-service.js' +import { closeLocalOcrWorker, warmupLocalOcrWorker } from './services/ocr.js' +import { closeAllTencentBrowserSessions, warmupTencentBrowser } from './services/session.js' +import tencentRouter from './routes/tencent.js' + +const app = express() +const port = Number(runtimeConfig.server.port || 3000) + +runDatabaseMigrations() +await ensureAdminUsersBootstrapped() + +app.use((req, res, next) => { + res.setHeader('Access-Control-Allow-Origin', '*') + res.setHeader('Access-Control-Allow-Headers', 'Content-Type, Authorization') + res.setHeader('Access-Control-Allow-Methods', 'GET, POST, PUT, PATCH, DELETE, OPTIONS') + + if (req.method === 'OPTIONS') { + res.sendStatus(204) + return + } + + next() +}) + +app.use(express.json({ limit: '2mb' })) +app.use(express.urlencoded({ extended: true })) + +app.get('/health', (_req, res) => { + res.json({ + code: 0, + msg: 'ok', + time: Math.floor(Date.now() / 1000), + }) +}) + +app.use('/api/v1/tencent', tencentRouter) +app.use('/api/v1/webhooks', webhooksRouter) +app.use('/api/v1/claim', claimsRouter) +app.use('/api/v1/admin', adminRouter) + +const server = app.listen(port, () => { + console.log(`order-site-backend listening on http://127.0.0.1:${port}`) + void bootstrapBrowser() + void bootstrapOcr() +}) + +async function bootstrapBrowser() { + try { + const result = await warmupTencentBrowser() + + if (!result.warmed) { + console.log('[startup] browser prewarm skipped') + return + } + + console.log('[startup] browser prewarm ready') + } catch (error) { + if (isMissingPlaywrightBrowserError(error)) { + const installCommand = resolveBrowserInstallCommand() + console.warn(`[startup] browser prewarm skipped: ${formatErrorMessage(error)}`) + console.warn(`[startup] 请先安装浏览器依赖: ${installCommand}`) + + if (String(runtimeConfig.browser.chromePath || '').trim()) { + console.warn('[startup] 当前设置了 CHROME_PATH,也请确认该路径指向的浏览器可执行文件真实存在') + } + + return + } + + console.error('[startup] browser prewarm failed:', error) + } +} + +async function bootstrapOcr() { + try { + await warmupLocalOcrWorker() + console.log('[startup] local OCR worker ready') + } catch (error) { + console.warn(`[startup] local OCR worker skipped: ${formatStartupError(error)}`) + } +} + +function formatStartupError(error) { + if (error instanceof Error && error.message) { + return error.message.split('\n')[0].trim() + } + + return String(error || '未知错误').trim() +} + +function isMissingPlaywrightBrowserError(error) { + 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) { + 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' +} + +let shutdownStarted = false + +async function shutdown(signal) { + if (shutdownStarted) { + return + } + + shutdownStarted = true + console.log(`[shutdown] received ${signal}, closing browser sessions and HTTP server`) + + try { + await closeAllTencentBrowserSessions({ markClosed: true }) + } catch (error) { + console.error('[shutdown] failed to close browser sessions:', error) + } + + try { + await closeLocalOcrWorker() + } catch (error) { + console.error('[shutdown] failed to close local OCR worker:', error) + } + + if (!server.listening) { + return + } + + await new Promise((resolve) => { + server.close((error) => { + if (error) { + console.error('[shutdown] failed to close HTTP server:', error) + process.exitCode = 1 + } + + resolve() + }) + }) +} + +process.on('SIGINT', () => { + void shutdown('SIGINT') +}) + +process.on('SIGTERM', () => { + void shutdown('SIGTERM') +}) diff --git a/apps/backend/src/repositories/admin-audit-log-repo.js b/apps/backend/src/repositories/admin-audit-log-repo.js new file mode 100644 index 00000000..fc6923df --- /dev/null +++ b/apps/backend/src/repositories/admin-audit-log-repo.js @@ -0,0 +1,86 @@ +import { getDb } from '../db/client.js' + +export function createAdminAuditLog(input) { + const result = getDb().prepare(` + INSERT INTO admin_audit_logs ( + actor_user_id, + actor_username, + actor_role, + action, + target_type, + target_id, + payload_json, + created_at + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?) + `).run( + input.actorUserId, + input.actorUsername, + input.actorRole, + input.action, + input.targetType, + input.targetId, + input.payloadJson, + input.createdAt, + ) + + return getAdminAuditLogById(Number(result.lastInsertRowid)) +} + +export function getAdminAuditLogById(logId) { + return getDb().prepare('SELECT * FROM admin_audit_logs WHERE id = ? LIMIT 1').get(logId) || null +} + +export function listAdminAuditLogs(query = {}) { + const conditions = [] + const values = [] + + if (query.actorUsername) { + conditions.push('actor_username = ?') + values.push(query.actorUsername) + } + + if (query.action) { + conditions.push('action = ?') + values.push(query.action) + } + + if (query.targetType) { + conditions.push('target_type = ?') + values.push(query.targetType) + } + + if (query.dateFrom) { + conditions.push('created_at >= ?') + values.push(query.dateFrom) + } + + if (query.dateTo) { + conditions.push('created_at <= ?') + values.push(query.dateTo) + } + + const whereClause = conditions.length > 0 ? `WHERE ${conditions.join(' AND ')}` : '' + const page = Number(query.page) || 1 + const pageSize = Number(query.pageSize) || 20 + const offset = (page - 1) * pageSize + const db = getDb() + + const items = db.prepare(` + SELECT * + FROM admin_audit_logs + ${whereClause} + ORDER BY id DESC + LIMIT ? OFFSET ? + `).all(...values, pageSize, offset) + + const totalRow = db.prepare(` + SELECT COUNT(*) AS total + FROM admin_audit_logs + ${whereClause} + `).get(...values) + + return { + items, + total: Number(totalRow?.total || 0), + } +} diff --git a/apps/backend/src/repositories/admin-user-repo.js b/apps/backend/src/repositories/admin-user-repo.js new file mode 100644 index 00000000..d8384875 --- /dev/null +++ b/apps/backend/src/repositories/admin-user-repo.js @@ -0,0 +1,117 @@ +import { getDb } from '../db/client.js' + +export function getAdminUserByUsername(username) { + return getDb().prepare('SELECT * FROM admin_users WHERE username = ? LIMIT 1').get(username) || null +} + +export function getAdminUserById(userId) { + return getDb().prepare('SELECT * FROM admin_users WHERE id = ? LIMIT 1').get(userId) || null +} + +export function createAdminUser(input) { + const result = getDb().prepare(` + INSERT INTO admin_users ( + username, + password_hash, + role, + status, + created_at, + updated_at + ) VALUES (?, ?, ?, ?, ?, ?) + `).run( + input.username, + input.passwordHash, + input.role, + input.status, + input.createdAt, + input.updatedAt, + ) + + return getAdminUserById(Number(result.lastInsertRowid)) +} + +export function listAdminUsers(query = {}) { + const conditions = [] + const values = [] + + if (query.username) { + conditions.push('username LIKE ?') + values.push(`%${query.username}%`) + } + + if (query.role) { + conditions.push('role = ?') + values.push(query.role) + } + + if (query.status) { + conditions.push('status = ?') + values.push(query.status) + } + + const whereClause = conditions.length > 0 ? `WHERE ${conditions.join(' AND ')}` : '' + const page = Number(query.page) || 1 + const pageSize = Number(query.pageSize) || 20 + const offset = (page - 1) * pageSize + const db = getDb() + + const items = db.prepare(` + SELECT * + FROM admin_users + ${whereClause} + ORDER BY id DESC + LIMIT ? OFFSET ? + `).all(...values, pageSize, offset) + + const totalRow = db.prepare(` + SELECT COUNT(*) AS total + FROM admin_users + ${whereClause} + `).get(...values) + + return { + items, + total: Number(totalRow?.total || 0), + } +} + +export function updateAdminUser(userId, patch = {}) { + const current = getAdminUserById(userId) + + if (!current) { + return null + } + + const next = { + username: Object.prototype.hasOwnProperty.call(patch, 'username') ? patch.username : current.username, + password_hash: Object.prototype.hasOwnProperty.call(patch, 'password_hash') ? patch.password_hash : current.password_hash, + role: Object.prototype.hasOwnProperty.call(patch, 'role') ? patch.role : current.role, + status: Object.prototype.hasOwnProperty.call(patch, 'status') ? patch.status : current.status, + updated_at: Object.prototype.hasOwnProperty.call(patch, 'updated_at') ? patch.updated_at : current.updated_at, + } + + getDb().prepare(` + UPDATE admin_users + SET username = ?, password_hash = ?, role = ?, status = ?, updated_at = ? + WHERE id = ? + `).run( + next.username, + next.password_hash, + next.role, + next.status, + next.updated_at, + userId, + ) + + return getAdminUserById(userId) +} + +export function countActiveAdminUsers() { + const row = getDb().prepare(` + SELECT COUNT(*) AS total + FROM admin_users + WHERE role = 'admin' AND status = 'active' + `).get() + + return Number(row?.total || 0) +} diff --git a/apps/backend/src/repositories/cdk-repo.js b/apps/backend/src/repositories/cdk-repo.js new file mode 100644 index 00000000..c71a52ed --- /dev/null +++ b/apps/backend/src/repositories/cdk-repo.js @@ -0,0 +1,143 @@ +import { getDb } from '../db/client.js' + +export function findFirstAvailableCdkBySkuCode(skuCode) { + return getDb().prepare(` + SELECT * + FROM cdk_inventory + WHERE sku_code = ? AND status = 'available' + ORDER BY id ASC + LIMIT 1 + `).get(skuCode) || null +} + +export function assignReservedCdk(cdkId, taskId, updatedAt) { + getDb().prepare(` + UPDATE cdk_inventory + SET + status = 'reserved', + reserved_by_task_id = ?, + updated_at = ? + WHERE id = ? AND status = 'available' + `).run(taskId, updatedAt, cdkId) + + return getCdkById(cdkId) +} + +export function getCdkById(cdkId) { + return getDb().prepare('SELECT * FROM cdk_inventory WHERE id = ? LIMIT 1').get(cdkId) || null +} + +export function markCdkDelivered(cdkId, deliveredAt) { + getDb().prepare(` + UPDATE cdk_inventory + SET + status = 'delivered', + delivered_at = ?, + updated_at = ? + WHERE id = ? + `).run(deliveredAt, deliveredAt, cdkId) + + return getCdkById(cdkId) +} + +export function listCdks({ + page = 1, + pageSize = 20, + skuCode = '', + status = '', + batchNo = '', +} = {}) { + const offset = (page - 1) * pageSize + const filters = [] + const params = [] + + if (skuCode) { + filters.push('sku_code = ?') + params.push(skuCode) + } + + if (status) { + filters.push('status = ?') + params.push(status) + } + + if (batchNo) { + filters.push('batch_no LIKE ?') + params.push(`%${batchNo}%`) + } + + const whereClause = filters.length > 0 ? `WHERE ${filters.join(' AND ')}` : '' + const db = getDb() + const totalRow = db.prepare(` + SELECT COUNT(*) AS total + FROM cdk_inventory + ${whereClause} + `).get(...params) + + const items = db.prepare(` + SELECT * + FROM cdk_inventory + ${whereClause} + ORDER BY id DESC + LIMIT ? OFFSET ? + `).all(...params, pageSize, offset) + + return { + items, + total: Number(totalRow?.total || 0), + } +} + +export function createCdks(rows) { + const db = getDb() + const stmt = db.prepare(` + INSERT OR IGNORE INTO cdk_inventory ( + batch_no, + sku_code, + cdk_code, + status, + reserved_by_task_id, + invalid_reason, + delivered_at, + created_at, + updated_at + ) VALUES (?, ?, ?, 'available', NULL, '', NULL, ?, ?) + `) + + let created = 0 + + for (const row of rows) { + const result = stmt.run(row.batchNo, row.skuCode, row.cdkCode, row.createdAt, row.updatedAt) + if (Number(result.changes || 0) > 0) { + created += 1 + } + } + + return created +} + +export function releaseReservedCdk(cdkId, updatedAt) { + getDb().prepare(` + UPDATE cdk_inventory + SET + status = 'available', + reserved_by_task_id = NULL, + updated_at = ? + WHERE id = ? AND status = 'reserved' + `).run(updatedAt, cdkId) + + return getCdkById(cdkId) +} + +export function invalidateCdk(cdkId, invalidReason, updatedAt) { + getDb().prepare(` + UPDATE cdk_inventory + SET + status = 'invalid', + invalid_reason = ?, + updated_at = ? + WHERE id = ? AND status = 'available' + `).run(invalidReason, updatedAt, cdkId) + + return getCdkById(cdkId) +} diff --git a/apps/backend/src/repositories/claim-token-repo.js b/apps/backend/src/repositories/claim-token-repo.js new file mode 100644 index 00000000..de94efe3 --- /dev/null +++ b/apps/backend/src/repositories/claim-token-repo.js @@ -0,0 +1,69 @@ +import { getDb } from '../db/client.js' + +export function createClaimToken(input) { + const result = getDb().prepare(` + INSERT INTO claim_tokens ( + task_id, + token, + status, + expired_at, + used_at, + max_use_count, + used_count, + created_at, + updated_at + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) + `).run( + input.taskId, + input.token, + input.status, + input.expiredAt, + input.usedAt, + input.maxUseCount, + input.usedCount, + input.createdAt, + input.updatedAt, + ) + + return getClaimTokenById(Number(result.lastInsertRowid)) +} + +export function getClaimTokenById(tokenId) { + return getDb().prepare('SELECT * FROM claim_tokens WHERE id = ? LIMIT 1').get(tokenId) || null +} + +export function findClaimTokenByToken(token) { + return getDb().prepare('SELECT * FROM claim_tokens WHERE token = ? LIMIT 1').get(token) || null +} + +export function updateClaimToken(tokenId, patch) { + const current = getClaimTokenById(tokenId) + + if (!current) { + return null + } + + const next = { ...current, ...patch } + + getDb().prepare(` + UPDATE claim_tokens + SET + status = ?, + expired_at = ?, + used_at = ?, + max_use_count = ?, + used_count = ?, + updated_at = ? + WHERE id = ? + `).run( + next.status, + next.expired_at, + next.used_at, + next.max_use_count, + next.used_count, + next.updated_at, + tokenId, + ) + + return getClaimTokenById(tokenId) +} diff --git a/apps/backend/src/repositories/message-delivery-repo.js b/apps/backend/src/repositories/message-delivery-repo.js new file mode 100644 index 00000000..9ca6b589 --- /dev/null +++ b/apps/backend/src/repositories/message-delivery-repo.js @@ -0,0 +1,99 @@ +import { getDb } from '../db/client.js' + +export function createMessageDelivery(input) { + const result = getDb().prepare(` + INSERT INTO message_deliveries ( + platform, + channel, + order_id, + task_id, + platform_order_id, + recipient_key, + message_content, + claim_url, + status, + request_url, + request_headers_json, + request_body_json, + response_status, + response_json, + error_message, + sent_at, + created_at, + updated_at + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + `).run( + input.platform, + input.channel, + input.orderId, + input.taskId, + input.platformOrderId, + input.recipientKey, + input.messageContent, + input.claimUrl, + input.status, + input.requestUrl, + input.requestHeadersJson, + input.requestBodyJson, + input.responseStatus, + input.responseJson, + input.errorMessage, + input.sentAt, + input.createdAt, + input.updatedAt, + ) + + return getMessageDeliveryById(Number(result.lastInsertRowid)) +} + +export function getMessageDeliveryById(deliveryId) { + return getDb().prepare('SELECT * FROM message_deliveries WHERE id = ? LIMIT 1').get(deliveryId) || null +} + +export function updateMessageDelivery(deliveryId, patch = {}) { + const current = getMessageDeliveryById(deliveryId) + + if (!current) { + return null + } + + const next = { ...current, ...patch } + + getDb().prepare(` + UPDATE message_deliveries + SET + status = ?, + request_url = ?, + request_headers_json = ?, + request_body_json = ?, + response_status = ?, + response_json = ?, + error_message = ?, + sent_at = ?, + updated_at = ? + WHERE id = ? + `).run( + next.status, + next.request_url, + next.request_headers_json, + next.request_body_json, + next.response_status, + next.response_json, + next.error_message, + next.sent_at, + next.updated_at, + deliveryId, + ) + + return getMessageDeliveryById(deliveryId) +} + +export function findLatestSuccessfulMessageDeliveryByTask(taskId, channel) { + return getDb().prepare(` + SELECT * + FROM message_deliveries + WHERE task_id = ? AND channel = ? AND status = 'success' + ORDER BY id DESC + LIMIT 1 + `).get(taskId, channel) || null +} diff --git a/apps/backend/src/repositories/order-item-repo.js b/apps/backend/src/repositories/order-item-repo.js new file mode 100644 index 00000000..7648d8aa --- /dev/null +++ b/apps/backend/src/repositories/order-item-repo.js @@ -0,0 +1,80 @@ +import { getDb } from '../db/client.js' + +export function listOrderItemsByOrderId(orderId) { + return getDb().prepare(` + SELECT * + FROM order_items + WHERE order_id = ? + ORDER BY id ASC + `).all(orderId) +} + +export function replaceOrderItems(orderId, items) { + const db = getDb() + const existingItems = listOrderItemsByOrderId(orderId) + const updateStatement = db.prepare(` + UPDATE order_items + SET + sku_code = ?, + sku_name = ?, + quantity = ?, + spec_json = ?, + delivery_mode = ?, + updated_at = ? + WHERE id = ? + `) + const insertStatement = db.prepare(` + INSERT INTO order_items ( + order_id, + sku_code, + sku_name, + quantity, + spec_json, + delivery_mode, + created_at, + updated_at + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?) + `) + + for (let index = 0; index < items.length; index += 1) { + const item = items[index] + const existingItem = existingItems[index] || null + + if (existingItem) { + updateStatement.run( + item.skuCode, + item.skuName, + item.quantity, + item.specJson, + item.deliveryMode, + item.updatedAt, + existingItem.id, + ) + continue + } + + insertStatement.run( + orderId, + item.skuCode, + item.skuName, + item.quantity, + item.specJson, + item.deliveryMode, + item.createdAt, + item.updatedAt, + ) + } + + if (existingItems.length > items.length) { + const redundantIds = existingItems.slice(items.length).map((item) => item.id) + const placeholders = redundantIds.map(() => '?').join(', ') + + db.prepare(`DELETE FROM order_items WHERE order_id = ? AND id IN (${placeholders})`).run(orderId, ...redundantIds) + } + + return listOrderItemsByOrderId(orderId) +} + +export function getOrderItemById(orderItemId) { + return getDb().prepare('SELECT * FROM order_items WHERE id = ? LIMIT 1').get(orderItemId) || null +} diff --git a/apps/backend/src/repositories/order-repo.js b/apps/backend/src/repositories/order-repo.js new file mode 100644 index 00000000..e8e071b2 --- /dev/null +++ b/apps/backend/src/repositories/order-repo.js @@ -0,0 +1,144 @@ +import { getDb } from '../db/client.js' + +export function findOrderByPlatformOrderId(platform, platformOrderId) { + return getDb().prepare(` + SELECT * + FROM orders + WHERE platform = ? AND platform_order_id = ? + LIMIT 1 + `).get(platform, platformOrderId) || null +} + +export function createOrder(input) { + const result = getDb().prepare(` + INSERT INTO orders ( + platform, + platform_order_id, + order_status, + pay_status, + buyer_id, + buyer_name, + receiver_contact, + total_amount, + currency, + raw_payload_json, + paid_at, + created_at, + updated_at + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + `).run( + input.platform, + input.platformOrderId, + input.orderStatus, + input.payStatus, + input.buyerId, + input.buyerName, + input.receiverContact, + input.totalAmount, + input.currency, + input.rawPayloadJson, + input.paidAt, + input.createdAt, + input.updatedAt, + ) + + return getOrderById(Number(result.lastInsertRowid)) +} + +export function updateOrder(orderId, input) { + getDb().prepare(` + UPDATE orders + SET + order_status = ?, + pay_status = ?, + buyer_id = ?, + buyer_name = ?, + receiver_contact = ?, + total_amount = ?, + currency = ?, + raw_payload_json = ?, + paid_at = ?, + updated_at = ? + WHERE id = ? + `).run( + input.orderStatus, + input.payStatus, + input.buyerId, + input.buyerName, + input.receiverContact, + input.totalAmount, + input.currency, + input.rawPayloadJson, + input.paidAt, + input.updatedAt, + orderId, + ) + + return getOrderById(orderId) +} + +export function getOrderById(orderId) { + return getDb().prepare('SELECT * FROM orders WHERE id = ? LIMIT 1').get(orderId) || null +} + +export function listOrders({ + page = 1, + pageSize = 20, + platformOrderId = '', + payStatus = '', + skuCode = '', + dateFrom = '', + dateTo = '', +} = {}) { + const offset = (page - 1) * pageSize + const filters = [] + const params = [] + + if (platformOrderId) { + filters.push('o.platform_order_id LIKE ?') + params.push(`%${platformOrderId}%`) + } + + if (payStatus) { + filters.push('o.pay_status = ?') + params.push(payStatus) + } + + if (skuCode) { + filters.push('EXISTS (SELECT 1 FROM order_items oi WHERE oi.order_id = o.id AND oi.sku_code = ?)') + params.push(skuCode) + } + + if (dateFrom) { + filters.push('o.created_at >= ?') + params.push(dateFrom) + } + + if (dateTo) { + filters.push('o.created_at <= ?') + params.push(dateTo) + } + + const whereClause = filters.length > 0 ? `WHERE ${filters.join(' AND ')}` : '' + const db = getDb() + const totalRow = db.prepare(` + SELECT COUNT(*) AS total + FROM orders o + ${whereClause} + `).get(...params) + + const items = db.prepare(` + SELECT + o.*, + (SELECT COUNT(*) FROM delivery_tasks dt WHERE dt.order_id = o.id) AS task_count + FROM orders o + ${whereClause} + ORDER BY o.id DESC + LIMIT ? OFFSET ? + `).all(...params, pageSize, offset) + + return { + items, + total: Number(totalRow?.total || 0), + } +} diff --git a/apps/backend/src/repositories/task-repo.js b/apps/backend/src/repositories/task-repo.js new file mode 100644 index 00000000..ef450a7f --- /dev/null +++ b/apps/backend/src/repositories/task-repo.js @@ -0,0 +1,228 @@ +import { getDb } from '../db/client.js' + +export function listTasksByOrderId(orderId) { + return getDb().prepare(` + SELECT * + FROM delivery_tasks + WHERE order_id = ? + ORDER BY id ASC + `).all(orderId) +} + +export function createTask(input) { + const result = getDb().prepare(` + INSERT INTO delivery_tasks ( + order_id, + order_item_id, + platform_order_id, + task_no, + task_status, + login_type, + claim_token_id, + reserved_cdk_id, + browser_session_id, + nickname, + role_id, + role_name, + area, + partition_name, + result_code, + result_message, + screenshot_path, + artifacts_json, + last_error, + retry_count, + expires_at, + claimed_at, + role_confirmed_at, + redeemed_at, + created_at, + updated_at + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + `).run( + input.orderId, + input.orderItemId, + input.platformOrderId, + input.taskNo, + input.taskStatus, + input.loginType, + input.claimTokenId, + input.reservedCdkId, + input.browserSessionId, + input.nickname, + input.roleId, + input.roleName, + input.area, + input.partitionName, + input.resultCode, + input.resultMessage, + input.screenshotPath, + input.artifactsJson, + input.lastError, + input.retryCount, + input.expiresAt, + input.claimedAt, + input.roleConfirmedAt, + input.redeemedAt, + input.createdAt, + input.updatedAt, + ) + + return getTaskById(Number(result.lastInsertRowid)) +} + +export function updateTask(taskId, patch) { + const current = getTaskById(taskId) + + if (!current) { + return null + } + + const next = { ...current, ...patch } + + getDb().prepare(` + UPDATE delivery_tasks + SET + task_status = ?, + login_type = ?, + claim_token_id = ?, + reserved_cdk_id = ?, + browser_session_id = ?, + nickname = ?, + role_id = ?, + role_name = ?, + area = ?, + partition_name = ?, + result_code = ?, + result_message = ?, + screenshot_path = ?, + artifacts_json = ?, + last_error = ?, + retry_count = ?, + expires_at = ?, + claimed_at = ?, + role_confirmed_at = ?, + redeemed_at = ?, + updated_at = ? + WHERE id = ? + `).run( + next.task_status, + next.login_type, + next.claim_token_id, + next.reserved_cdk_id, + next.browser_session_id, + next.nickname, + next.role_id, + next.role_name, + next.area, + next.partition_name, + next.result_code, + next.result_message, + next.screenshot_path, + next.artifacts_json, + next.last_error, + next.retry_count, + next.expires_at, + next.claimed_at, + next.role_confirmed_at, + next.redeemed_at, + next.updated_at, + taskId, + ) + + return getTaskById(taskId) +} + +export function getTaskById(taskId) { + return getDb().prepare('SELECT * FROM delivery_tasks WHERE id = ? LIMIT 1').get(taskId) || null +} + +export function findTaskByClaimTokenId(claimTokenId) { + return getDb().prepare(` + SELECT * + FROM delivery_tasks + WHERE claim_token_id = ? + LIMIT 1 + `).get(claimTokenId) || null +} + +export function listTasks({ + page = 1, + pageSize = 20, + status = '', + platformOrderId = '', + taskNo = '', + skuCode = '', + roleId = '', + dateFrom = '', + dateTo = '', +} = {}) { + const offset = (page - 1) * pageSize + const filters = [] + const params = [] + + if (status) { + filters.push('dt.task_status = ?') + params.push(status) + } + + if (platformOrderId) { + filters.push('dt.platform_order_id LIKE ?') + params.push(`%${platformOrderId}%`) + } + + if (taskNo) { + filters.push('dt.task_no LIKE ?') + params.push(`%${taskNo}%`) + } + + if (skuCode) { + filters.push('oi.sku_code = ?') + params.push(skuCode) + } + + if (roleId) { + filters.push('dt.role_id LIKE ?') + params.push(`%${roleId}%`) + } + + if (dateFrom) { + filters.push('dt.created_at >= ?') + params.push(dateFrom) + } + + if (dateTo) { + filters.push('dt.created_at <= ?') + params.push(dateTo) + } + + const whereClause = filters.length > 0 ? `WHERE ${filters.join(' AND ')}` : '' + const db = getDb() + const totalRow = db.prepare(` + SELECT COUNT(*) AS total + FROM delivery_tasks dt + LEFT JOIN order_items oi ON oi.id = dt.order_item_id + ${whereClause} + `).get(...params) + + const items = db.prepare(` + SELECT + dt.*, + oi.sku_code, + oi.sku_name, + ci.cdk_code, + ct.token AS claim_token + FROM delivery_tasks dt + LEFT JOIN order_items oi ON oi.id = dt.order_item_id + LEFT JOIN cdk_inventory ci ON ci.id = dt.reserved_cdk_id + LEFT JOIN claim_tokens ct ON ct.id = dt.claim_token_id + ${whereClause} + ORDER BY dt.id DESC + LIMIT ? OFFSET ? + `).all(...params, pageSize, offset) + + return { + items, + total: Number(totalRow?.total || 0), + } +} diff --git a/apps/backend/src/repositories/webhook-event-repo.js b/apps/backend/src/repositories/webhook-event-repo.js new file mode 100644 index 00000000..b4258af8 --- /dev/null +++ b/apps/backend/src/repositories/webhook-event-repo.js @@ -0,0 +1,127 @@ +import { getDb } from '../db/client.js' + +export function createWebhookEvent(input) { + const result = getDb().prepare(` + INSERT INTO webhook_events ( + platform, + event_type, + event_key, + signature_valid, + headers_json, + query_json, + body_json, + processed, + process_error, + related_order_id, + created_at + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + `).run( + input.platform, + input.eventType, + input.eventKey, + input.signatureValid ? 1 : 0, + input.headersJson, + input.queryJson, + input.bodyJson, + input.processed ? 1 : 0, + input.processError, + input.relatedOrderId, + input.createdAt, + ) + + return getWebhookEventById(Number(result.lastInsertRowid)) +} + +export function updateWebhookEvent(eventId, patch) { + const current = getWebhookEventById(eventId) + + if (!current) { + return null + } + + const next = { ...current, ...patch } + + getDb().prepare(` + UPDATE webhook_events + SET + processed = ?, + process_error = ?, + related_order_id = ? + WHERE id = ? + `).run(next.processed ? 1 : 0, next.process_error, next.related_order_id, eventId) + + return getWebhookEventById(eventId) +} + +export function getWebhookEventById(eventId) { + return getDb().prepare('SELECT * FROM webhook_events WHERE id = ? LIMIT 1').get(eventId) || null +} + +export function listWebhookEvents({ + page = 1, + pageSize = 20, + platform = '', + processed = '', + relatedOrderId = '', + dateFrom = '', + dateTo = '', +} = {}) { + const offset = (page - 1) * pageSize + const filters = [] + const params = [] + + if (platform) { + filters.push('platform = ?') + params.push(platform) + } + + if (processed === '0' || processed === '1') { + filters.push('processed = ?') + params.push(Number(processed)) + } + + if (relatedOrderId) { + filters.push('related_order_id = ?') + params.push(Number(relatedOrderId)) + } + + if (dateFrom) { + filters.push('created_at >= ?') + params.push(dateFrom) + } + + if (dateTo) { + filters.push('created_at <= ?') + params.push(dateTo) + } + + const whereClause = filters.length > 0 ? `WHERE ${filters.join(' AND ')}` : '' + const db = getDb() + const totalRow = db.prepare(` + SELECT COUNT(*) AS total + FROM webhook_events + ${whereClause} + `).get(...params) + + const items = db.prepare(` + SELECT * + FROM webhook_events + ${whereClause} + ORDER BY id DESC + LIMIT ? OFFSET ? + `).all(...params, pageSize, offset) + + return { + items, + total: Number(totalRow?.total || 0), + } +} + +export function listWebhookEventsByOrderId(orderId) { + return getDb().prepare(` + SELECT * + FROM webhook_events + WHERE related_order_id = ? + ORDER BY id DESC + `).all(orderId) +} diff --git a/apps/backend/src/routes/admin.js b/apps/backend/src/routes/admin.js new file mode 100644 index 00000000..6997c88e --- /dev/null +++ b/apps/backend/src/routes/admin.js @@ -0,0 +1,432 @@ +import { Router } from 'express' + +import { + closeAdminTask, + createAdminCdk, + getAdminCdks, + getAdminDashboardSummary, + getAdminOrderDetail, + getAdminOrders, + getAdminTaskDetail, + getAdminTaskScreenshotPath, + getAdminTasks, + getAdminWebhookEventDetail, + getAdminWebhookEvents, + importAdminCdks, + invalidateAdminCdk, + markAdminTaskManualReview, + replayAdminWebhookEvent, + releaseAdminInventoryCdk, + regenerateAdminTaskClaimLink, + releaseAdminTaskCdk, + retryAdminTask, +} from '../services/admin-service.js' +import { + createManagedAdminUser, + getAdminUserList, + getAdminSessionSummary, + loginAdmin, + resetManagedAdminUserPassword, + requireAdminRole, + updateManagedAdminUserRole, + updateManagedAdminUserStatus, + verifyAdminSessionToken, +} from '../services/admin-auth-service.js' +import { getAdminAuditLogs, writeAdminAuditLog } from '../services/admin-audit-service.js' +import { buildNotFoundPayload, buildSuccessPayload, createHttpError, sendRouteError } from '../utils/http.js' + +const router = Router() + +router.post('/auth/login', (req, res) => { + try { + const data = loginAdmin(req.body?.username, req.body?.password) + res.json(buildSuccessPayload(data, '登录成功')) + } catch (error) { + sendRouteError(res, error, '后台登录失败', '[admin/auth/login]') + } +}) + +router.get('/auth/session', (req, res) => { + try { + const data = getAdminSessionSummary(extractBearerToken(req)) + res.json(buildSuccessPayload(data, 'ok')) + } catch (error) { + sendRouteError(res, error, '读取后台登录态失败', '[admin/auth/session]') + } +}) + +router.post('/auth/logout', (_req, res) => { + res.json(buildSuccessPayload({ success: true }, '已退出登录')) +}) + +router.use((req, res, next) => { + try { + req.adminSession = verifyAdminSessionToken(extractBearerToken(req)) + next() + } catch (error) { + sendRouteError(res, error, '后台鉴权失败', '[admin/auth]') + } +}) + +router.get('/dashboard/summary', (req, res) => { + try { + const data = getAdminDashboardSummary() + res.json(buildSuccessPayload(data, 'ok')) + } catch (error) { + sendRouteError(res, error, '读取后台概览失败', '[admin/dashboard/summary]') + } +}) + +router.get('/users', (req, res) => { + try { + requireAdminRole(req.adminSession, ['admin']) + const data = getAdminUserList(req.query) + res.json(buildSuccessPayload(data, 'ok')) + } catch (error) { + sendRouteError(res, error, '读取后台用户列表失败', '[admin/users]') + } +}) + +router.post('/users', (req, res) => { + try { + requireAdminRole(req.adminSession, ['admin']) + const data = createManagedAdminUser(req.body) + writeAdminAuditLog(req.adminSession, { + action: 'admin_user_created', + targetType: 'admin_user', + targetId: String(data.user.userId), + data: { + username: data.user.username, + role: data.user.role, + status: data.user.status, + }, + }) + res.json(buildSuccessPayload(data, '后台用户已创建')) + } catch (error) { + sendRouteError(res, error, '创建后台用户失败', '[admin/users:create]') + } +}) + +router.post('/users/:userId/role', (req, res) => { + try { + requireAdminRole(req.adminSession, ['admin']) + const data = updateManagedAdminUserRole(req.params.userId, req.body, req.adminSession) + writeAdminAuditLog(req.adminSession, { + action: 'admin_user_role_updated', + targetType: 'admin_user', + targetId: String(data.user.userId), + data: { + username: data.user.username, + role: data.user.role, + }, + }) + res.json(buildSuccessPayload(data, '用户角色已更新')) + } catch (error) { + sendRouteError(res, error, '更新用户角色失败', '[admin/users/:userId/role]') + } +}) + +router.post('/users/:userId/status', (req, res) => { + try { + requireAdminRole(req.adminSession, ['admin']) + const data = updateManagedAdminUserStatus(req.params.userId, req.body, req.adminSession) + writeAdminAuditLog(req.adminSession, { + action: 'admin_user_status_updated', + targetType: 'admin_user', + targetId: String(data.user.userId), + data: { + username: data.user.username, + status: data.user.status, + }, + }) + res.json(buildSuccessPayload(data, '用户状态已更新')) + } catch (error) { + sendRouteError(res, error, '更新用户状态失败', '[admin/users/:userId/status]') + } +}) + +router.post('/users/:userId/reset-password', (req, res) => { + try { + requireAdminRole(req.adminSession, ['admin']) + const data = resetManagedAdminUserPassword(req.params.userId, req.body) + writeAdminAuditLog(req.adminSession, { + action: 'admin_user_password_reset', + targetType: 'admin_user', + targetId: String(data.user.userId), + data: { + username: data.user.username, + }, + }) + res.json(buildSuccessPayload(data, '用户密码已重置')) + } catch (error) { + sendRouteError(res, error, '重置用户密码失败', '[admin/users/:userId/reset-password]') + } +}) + +router.get('/audit-logs', (req, res) => { + try { + requireAdminRole(req.adminSession, ['admin']) + const data = getAdminAuditLogs(req.query) + res.json(buildSuccessPayload(data, 'ok')) + } catch (error) { + sendRouteError(res, error, '读取操作审计日志失败', '[admin/audit-logs]') + } +}) + +router.get('/orders', (req, res) => { + try { + const data = getAdminOrders(req.query) + res.json(buildSuccessPayload(data, 'ok')) + } catch (error) { + sendRouteError(res, error, '读取订单列表失败', '[admin/orders]') + } +}) + +router.get('/orders/:orderId', (req, res) => { + try { + const data = getAdminOrderDetail(req.params.orderId) + res.json(buildSuccessPayload(data, 'ok')) + } catch (error) { + sendRouteError(res, error, '读取订单详情失败', '[admin/orders/:orderId]') + } +}) + +router.get('/tasks', (req, res) => { + try { + const data = getAdminTasks(req.query) + res.json(buildSuccessPayload(data, 'ok')) + } catch (error) { + sendRouteError(res, error, '读取任务列表失败', '[admin/tasks]') + } +}) + +router.get('/tasks/:taskId', (req, res) => { + try { + const data = getAdminTaskDetail(req.params.taskId) + res.json(buildSuccessPayload(data, 'ok')) + } catch (error) { + sendRouteError(res, error, '读取任务详情失败', '[admin/tasks/:taskId]') + } +}) + +router.get('/tasks/:taskId/screenshot', (req, res) => { + try { + const screenshotPath = getAdminTaskScreenshotPath(req.params.taskId) + res.sendFile(screenshotPath) + } catch (error) { + sendRouteError(res, error, '读取任务截图失败', '[admin/tasks/:taskId/screenshot]') + } +}) + +router.post('/tasks/:taskId/retry', (req, res) => { + try { + const data = retryAdminTask(req.params.taskId) + res.json(buildSuccessPayload(data, '任务已重试')) + } catch (error) { + sendRouteError(res, error, '重试任务失败', '[admin/tasks/:taskId/retry]') + } +}) + +router.post('/tasks/:taskId/release-cdk', (req, res) => { + try { + requireAdminRole(req.adminSession, ['admin']) + const data = releaseAdminTaskCdk(req.params.taskId) + writeAdminAuditLog(req.adminSession, { + action: 'task_release_cdk', + targetType: 'task', + targetId: String(req.params.taskId), + data: { + taskId: data.task.taskId, + taskNo: data.task.taskNo, + reservedCdkId: data.task.reservedCdkId, + }, + }) + res.json(buildSuccessPayload(data, 'CDK 已释放')) + } catch (error) { + sendRouteError(res, error, '释放 CDK 失败', '[admin/tasks/:taskId/release-cdk]') + } +}) + +router.post('/tasks/:taskId/regenerate-claim-link', (req, res) => { + try { + requireAdminRole(req.adminSession, ['admin']) + const data = regenerateAdminTaskClaimLink(req.params.taskId) + writeAdminAuditLog(req.adminSession, { + action: 'task_regenerate_claim_link', + targetType: 'task', + targetId: String(req.params.taskId), + data: { + taskId: data.task.taskId, + taskNo: data.task.taskNo, + claimTokenId: data.task.claimTokenId, + }, + }) + res.json(buildSuccessPayload(data, '领取链接已重新生成')) + } catch (error) { + sendRouteError(res, error, '重新生成领取链接失败', '[admin/tasks/:taskId/regenerate-claim-link]') + } +}) + +router.post('/tasks/:taskId/close', (req, res) => { + try { + requireAdminRole(req.adminSession, ['admin']) + const data = closeAdminTask(req.params.taskId) + writeAdminAuditLog(req.adminSession, { + action: 'task_closed', + targetType: 'task', + targetId: String(req.params.taskId), + data: { + taskId: data.task.taskId, + taskNo: data.task.taskNo, + status: data.task.status, + }, + }) + res.json(buildSuccessPayload(data, '任务已关闭')) + } catch (error) { + sendRouteError(res, error, '关闭任务失败', '[admin/tasks/:taskId/close]') + } +}) + +router.post('/tasks/:taskId/mark-manual-review', (req, res) => { + try { + requireAdminRole(req.adminSession, ['admin']) + const data = markAdminTaskManualReview(req.params.taskId) + writeAdminAuditLog(req.adminSession, { + action: 'task_mark_manual_review', + targetType: 'task', + targetId: String(req.params.taskId), + data: { + taskId: data.task.taskId, + taskNo: data.task.taskNo, + status: data.task.status, + }, + }) + res.json(buildSuccessPayload(data, '任务已转人工处理')) + } catch (error) { + sendRouteError(res, error, '标记人工处理失败', '[admin/tasks/:taskId/mark-manual-review]') + } +}) + +router.get('/cdks', (req, res) => { + try { + const data = getAdminCdks(req.query) + res.json(buildSuccessPayload(data, 'ok')) + } catch (error) { + sendRouteError(res, error, '读取 CDK 列表失败', '[admin/cdks]') + } +}) + +router.post('/cdks', (req, res) => { + try { + const data = createAdminCdk(req.body) + res.json(buildSuccessPayload(data, 'CDK 已新增')) + } catch (error) { + sendRouteError(res, error, '新增 CDK 失败', '[admin/cdks]') + } +}) + +router.post('/cdks/import', (req, res) => { + try { + const data = importAdminCdks(req.body) + res.json(buildSuccessPayload(data, '导入完成')) + } catch (error) { + sendRouteError(res, error, '导入 CDK 失败', '[admin/cdks/import]') + } +}) + +router.post('/cdks/:cdkId/release', (req, res) => { + try { + requireAdminRole(req.adminSession, ['admin']) + const data = releaseAdminInventoryCdk(req.params.cdkId) + writeAdminAuditLog(req.adminSession, { + action: 'inventory_cdk_released', + targetType: 'cdk', + targetId: String(req.params.cdkId), + data: { + cdkId: data.cdk?.cdkId, + skuCode: data.cdk?.skuCode, + cdkCode: data.cdk?.cdkCode, + }, + }) + res.json(buildSuccessPayload(data, 'CDK 已释放')) + } catch (error) { + sendRouteError(res, error, '释放库存 CDK 失败', '[admin/cdks/:cdkId/release]') + } +}) + +router.post('/cdks/:cdkId/invalidate', (req, res) => { + try { + requireAdminRole(req.adminSession, ['admin']) + const data = invalidateAdminCdk(req.params.cdkId, req.body) + writeAdminAuditLog(req.adminSession, { + action: 'inventory_cdk_invalidated', + targetType: 'cdk', + targetId: String(req.params.cdkId), + data: { + cdkId: data.cdk?.cdkId, + skuCode: data.cdk?.skuCode, + invalidReason: data.cdk?.invalidReason, + }, + }) + res.json(buildSuccessPayload(data, 'CDK 已作废')) + } catch (error) { + sendRouteError(res, error, '作废 CDK 失败', '[admin/cdks/:cdkId/invalidate]') + } +}) + +router.get('/webhook-events', (req, res) => { + try { + const data = getAdminWebhookEvents(req.query) + res.json(buildSuccessPayload(data, 'ok')) + } catch (error) { + sendRouteError(res, error, '读取 webhook 列表失败', '[admin/webhook-events]') + } +}) + +router.get('/webhook-events/:eventId', (req, res) => { + try { + const data = getAdminWebhookEventDetail(req.params.eventId) + res.json(buildSuccessPayload(data, 'ok')) + } catch (error) { + sendRouteError(res, error, '读取 webhook 详情失败', '[admin/webhook-events/:eventId]') + } +}) + +router.post('/webhook-events/:eventId/replay', async (req, res) => { + try { + requireAdminRole(req.adminSession, ['admin']) + const data = await replayAdminWebhookEvent(req.params.eventId) + writeAdminAuditLog(req.adminSession, { + action: 'webhook_replayed', + targetType: 'webhook_event', + targetId: String(req.params.eventId), + data: { + eventId: data.eventId, + replayed: data.replayed, + }, + }) + res.json(buildSuccessPayload(data, 'Webhook 已重放')) + } catch (error) { + sendRouteError(res, error, '重放 webhook 失败', '[admin/webhook-events/:eventId/replay]') + } +}) + +router.use((req, res) => { + res.status(404).json(buildNotFoundPayload(req)) +}) + +export default router + +function extractBearerToken(req) { + const authorization = String(req.headers.authorization || '').trim() + const matched = authorization.match(/^Bearer\s+(.+)$/i) + + if (!matched) { + throw createHttpError('未登录或登录已失效', { + statusCode: 401, + errorCode: 'admin_auth_required', + }) + } + + return matched[1] +} diff --git a/apps/backend/src/routes/claims.js b/apps/backend/src/routes/claims.js new file mode 100644 index 00000000..f549abe9 --- /dev/null +++ b/apps/backend/src/routes/claims.js @@ -0,0 +1,73 @@ +import { Router } from 'express' + +import { + confirmClaimRole, + createClaimSession, + getClaimDetail, + getClaimScreenshotPath, + getClaimSessionSummary, + redeemClaimTask, +} from '../services/claim-session-service.js' +import { buildNotFoundPayload, buildSuccessPayload, sendRouteError } from '../utils/http.js' + +const router = Router() + +router.get('/:token', async (req, res) => { + try { + const data = await getClaimDetail(req.params.token) + res.json(buildSuccessPayload(data, 'ok')) + } catch (error) { + sendRouteError(res, error, '查询领取详情失败', '[claims/:token]') + } +}) + +router.post('/:token/session', async (req, res) => { + try { + const data = await createClaimSession(req.params.token, req.body) + res.json(buildSuccessPayload(data, '浏览器会话已创建')) + } catch (error) { + sendRouteError(res, error, '创建领取会话失败', '[claims/:token/session]') + } +}) + +router.get('/:token/session/summary', async (req, res) => { + try { + const data = await getClaimSessionSummary(req.params.token) + res.json(buildSuccessPayload(data, 'ok')) + } catch (error) { + sendRouteError(res, error, '查询领取会话摘要失败', '[claims/:token/session/summary]') + } +}) + +router.post('/:token/confirm-role', async (req, res) => { + try { + const data = await confirmClaimRole(req.params.token) + res.json(buildSuccessPayload(data, '角色已确认')) + } catch (error) { + sendRouteError(res, error, '确认角色失败', '[claims/:token/confirm-role]') + } +}) + +router.post('/:token/redeem', async (req, res) => { + try { + const data = await redeemClaimTask(req.params.token) + res.json(buildSuccessPayload(data, '兑换完成')) + } catch (error) { + sendRouteError(res, error, '执行领取兑换失败', '[claims/:token/redeem]') + } +}) + +router.get('/:token/screenshot', async (req, res) => { + try { + const screenshotPath = await getClaimScreenshotPath(req.params.token) + res.sendFile(screenshotPath) + } catch (error) { + sendRouteError(res, error, '读取领取截图失败', '[claims/:token/screenshot]') + } +}) + +router.use((req, res) => { + res.status(404).json(buildNotFoundPayload(req)) +}) + +export default router diff --git a/apps/backend/src/routes/tencent.js b/apps/backend/src/routes/tencent.js new file mode 100644 index 00000000..2d0ee1de --- /dev/null +++ b/apps/backend/src/routes/tencent.js @@ -0,0 +1,87 @@ +import { Router } from 'express' + +import { + closeTencentBrowserSession, + createTencentBrowserSession, + getTencentBrowserSession, + getTencentBrowserSessionSummary, + getTencentBrowserSessionScreenshotPath, + reloadTencentBrowserSession, + redeemTencentBrowserSession, +} from '../services/session.js' +import { + buildNotFoundPayload, + buildSuccessPayload, + sendRouteError, +} from '../utils/http.js' + +const router = Router() + +router.post('/browser/session', async (req, res) => { + try { + const data = await createTencentBrowserSession(req.body) + res.json(buildSuccessPayload(data, '浏览器会话已创建')) + } catch (error) { + sendRouteError(res, error, '创建浏览器会话失败', '[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', async (req, res) => { + try { + const screenshotPath = await getTencentBrowserSessionScreenshotPath(req.params.sessionId) + res.sendFile(screenshotPath) + } catch (error) { + sendRouteError(res, error, '读取兑换截图失败', '[browser/session/:sessionId/screenshot]') + } +}) + +router.delete('/browser/session/:sessionId', async (req, res) => { + try { + const data = await closeTencentBrowserSession(req.params.sessionId) + res.json(buildSuccessPayload(data, '浏览器会话已关闭')) + } catch (error) { + sendRouteError(res, error, '关闭浏览器会话失败', '[browser/session/:sessionId]') + } +}) + +router.use((req, res) => { + res.status(404).json(buildNotFoundPayload(req)) +}) + +export default router diff --git a/apps/backend/src/routes/webhooks.js b/apps/backend/src/routes/webhooks.js new file mode 100644 index 00000000..40ff688c --- /dev/null +++ b/apps/backend/src/routes/webhooks.js @@ -0,0 +1,25 @@ +import { Router } from 'express' + +import { processAgisoTradeWebhook } from '../services/webhook-service.js' +import { buildNotFoundPayload, buildSuccessPayload, sendRouteError } from '../utils/http.js' + +const router = Router() + +router.post('/agiso/trade', async (req, res) => { + try { + const data = await processAgisoTradeWebhook({ + headers: req.headers, + query: req.query, + body: req.body, + }) + res.json(buildSuccessPayload(data, 'success')) + } catch (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-audit-service.js b/apps/backend/src/services/admin-audit-service.js new file mode 100644 index 00000000..c6d0fc21 --- /dev/null +++ b/apps/backend/src/services/admin-audit-service.js @@ -0,0 +1,83 @@ +import { createAdminAuditLog, listAdminAuditLogs } from '../repositories/admin-audit-log-repo.js' +import { nowIso } from '../utils/time.js' + +export function writeAdminAuditLog(session, payload = {}) { + if (!session?.userId) { + return null + } + + return createAdminAuditLog({ + actorUserId: session.userId, + actorUsername: session.username, + actorRole: session.role, + action: String(payload.action || '').trim(), + targetType: String(payload.targetType || '').trim() || 'unknown', + targetId: String(payload.targetId || '').trim(), + payloadJson: JSON.stringify(payload.data || {}), + createdAt: nowIso(), + }) +} + +export function getAdminAuditLogs(query = {}) { + const page = normalizePage(query.page) + const pageSize = normalizePageSize(query.pageSize) + const { items, total } = listAdminAuditLogs({ + page, + pageSize, + actorUsername: String(query.actorUsername || '').trim(), + action: String(query.action || '').trim(), + targetType: String(query.targetType || '').trim(), + dateFrom: normalizeDateQuery(query.dateFrom), + dateTo: normalizeDateQuery(query.dateTo, true), + }) + + return { + items: items.map((item) => ({ + logId: item.id, + actorUserId: item.actor_user_id, + actorUsername: item.actor_username, + actorRole: item.actor_role, + action: item.action, + targetType: item.target_type, + targetId: item.target_id, + payload: safeParseJson(item.payload_json), + createdAt: item.created_at, + })), + pagination: { page, pageSize, total }, + } +} + +function normalizePage(rawValue) { + const parsed = Number(rawValue) + return Number.isFinite(parsed) && parsed > 0 ? Math.floor(parsed) : 1 +} + +function normalizePageSize(rawValue) { + const parsed = Number(rawValue) + if (!Number.isFinite(parsed) || parsed <= 0) { + return 20 + } + return Math.min(100, Math.floor(parsed)) +} + +function normalizeDateQuery(rawValue, endOfDay = false) { + const normalized = String(rawValue || '').trim() + + if (!normalized) { + return '' + } + + if (/^\d{4}-\d{2}-\d{2}$/.test(normalized)) { + return endOfDay ? `${normalized}T23:59:59.999Z` : `${normalized}T00:00:00.000Z` + } + + return normalized +} + +function safeParseJson(value) { + try { + return JSON.parse(String(value || '{}')) + } catch { + return {} + } +} diff --git a/apps/backend/src/services/admin-auth-service.js b/apps/backend/src/services/admin-auth-service.js new file mode 100644 index 00000000..b537935f --- /dev/null +++ b/apps/backend/src/services/admin-auth-service.js @@ -0,0 +1,453 @@ +import crypto from 'node:crypto' + +import { runtimeConfig } from '../config/runtime.js' +import { + countActiveAdminUsers, + createAdminUser, + getAdminUserById, + getAdminUserByUsername, + listAdminUsers, + updateAdminUser, +} from '../repositories/admin-user-repo.js' +import { addHours, nowIso } from '../utils/time.js' +import { createHttpError } from '../utils/http.js' + +export async function ensureAdminUsersBootstrapped() { + ensureAdminAuthConfigured() + + const configuredUsers = Array.isArray(runtimeConfig.admin?.defaultUsers) ? runtimeConfig.admin.defaultUsers : [] + + for (const configuredUser of configuredUsers) { + const username = String(configuredUser?.username || '').trim() + const password = String(configuredUser?.password || '').trim() + const role = normalizeAdminRole(configuredUser?.role) + + if (!username || !password) { + continue + } + + if (getAdminUserByUsername(username)) { + continue + } + + const now = nowIso() + createAdminUser({ + username, + passwordHash: hashAdminPassword(password), + role, + status: 'active', + createdAt: now, + updatedAt: now, + }) + } +} + +export function loginAdmin(username, password) { + ensureAdminAuthConfigured() + + const normalizedUsername = String(username || '').trim() + const normalizedPassword = String(password || '').trim() + + if (!normalizedUsername || !normalizedPassword) { + throw createHttpError('缺少后台账号或密码', { + statusCode: 400, + errorCode: 'admin_credentials_required', + }) + } + + const user = getAdminUserByUsername(normalizedUsername) + if (!user || user.status !== 'active' || !verifyAdminPassword(normalizedPassword, user.password_hash)) { + throw createHttpError('账号或密码错误', { + statusCode: 401, + errorCode: 'admin_login_failed', + }) + } + + return createAdminSession(user) +} + +export function verifyAdminSessionToken(token) { + ensureAdminAuthConfigured() + + const normalizedToken = String(token || '').trim() + if (!normalizedToken) { + throw createHttpError('未登录或登录已失效', { + statusCode: 401, + errorCode: 'admin_auth_required', + }) + } + + const [encodedPayload, signature] = normalizedToken.split('.') + if (!encodedPayload || !signature) { + throw createHttpError('后台登录态无效', { + statusCode: 401, + errorCode: 'admin_auth_invalid', + }) + } + + const expectedSignature = signPayload(encodedPayload) + if (!safeCompare(signature, expectedSignature)) { + throw createHttpError('后台登录态无效', { + statusCode: 401, + errorCode: 'admin_auth_invalid', + }) + } + + let payload = null + + try { + payload = JSON.parse(Buffer.from(encodedPayload, 'base64url').toString('utf8')) + } catch { + throw createHttpError('后台登录态无效', { + statusCode: 401, + errorCode: 'admin_auth_invalid', + }) + } + + const expiresAt = String(payload?.exp || '').trim() + if (!expiresAt || Date.parse(expiresAt) <= Date.now()) { + throw createHttpError('后台登录已过期,请重新登录', { + statusCode: 401, + errorCode: 'admin_auth_expired', + }) + } + + const userId = Number(payload?.uid || 0) + const user = getAdminUserById(userId) + if (!user || user.status !== 'active') { + throw createHttpError('后台账号已不可用,请重新登录', { + statusCode: 401, + errorCode: 'admin_auth_user_invalid', + }) + } + + return { + sessionId: String(payload.sid || '').trim(), + userId: user.id, + username: user.username, + role: normalizeAdminRole(user.role), + expiresAt, + } +} + +export function getAdminSessionSummary(token) { + const session = verifyAdminSessionToken(token) + + return { + authenticated: true, + expiresAt: session.expiresAt, + user: { + userId: session.userId, + username: session.username, + role: session.role, + }, + } +} + +export function requireAdminRole(session, allowedRoles) { + if (allowedRoles.includes(session.role)) { + return + } + + throw createHttpError('当前账号没有此操作权限', { + statusCode: 403, + errorCode: 'admin_permission_denied', + }) +} + +export function getAdminUserList(query = {}) { + const page = normalizePage(query.page) + const pageSize = normalizePageSize(query.pageSize) + const { items, total } = listAdminUsers({ + page, + pageSize, + username: String(query.username || '').trim(), + role: normalizeRoleQuery(query.role), + status: normalizeStatusQuery(query.status), + }) + + return { + items: items.map(mapAdminUser), + pagination: { page, pageSize, total }, + } +} + +export function createManagedAdminUser(payload = {}) { + const username = normalizeUsername(payload.username) + const password = normalizePassword(payload.password) + + if (!username) { + throw createHttpError('缺少后台账号', { + statusCode: 400, + errorCode: 'admin_user_username_required', + }) + } + + if (!password) { + throw createHttpError('缺少后台密码', { + statusCode: 400, + errorCode: 'admin_user_password_required', + }) + } + + validateUsername(username) + validatePassword(password) + + if (getAdminUserByUsername(username)) { + throw createHttpError('后台账号已存在', { + statusCode: 409, + errorCode: 'admin_user_exists', + }) + } + + const now = nowIso() + const created = createAdminUser({ + username, + passwordHash: hashAdminPassword(password), + role: normalizeAdminRole(payload.role), + status: normalizeAdminUserStatus(payload.status || 'active'), + createdAt: now, + updatedAt: now, + }) + + return { + user: mapAdminUser(created), + } +} + +export function updateManagedAdminUserRole(userId, payload = {}, session) { + const user = getRequiredAdminUser(userId) + const role = normalizeAdminRole(payload.role) + + if (user.role === role) { + return { + user: mapAdminUser(user), + } + } + + ensureAdminUserChangeAllowed(user, { nextRole: role }, session) + const updated = updateAdminUser(user.id, { + role, + updated_at: nowIso(), + }) + + return { + user: mapAdminUser(updated), + } +} + +export function updateManagedAdminUserStatus(userId, payload = {}, session) { + const user = getRequiredAdminUser(userId) + const status = normalizeAdminUserStatus(payload.status) + + if (user.status === status) { + return { + user: mapAdminUser(user), + } + } + + ensureAdminUserChangeAllowed(user, { nextStatus: status }, session) + const updated = updateAdminUser(user.id, { + status, + updated_at: nowIso(), + }) + + return { + user: mapAdminUser(updated), + } +} + +export function resetManagedAdminUserPassword(userId, payload = {}) { + const user = getRequiredAdminUser(userId) + const password = normalizePassword(payload.password) + + if (!password) { + throw createHttpError('缺少新密码', { + statusCode: 400, + errorCode: 'admin_user_password_required', + }) + } + + validatePassword(password) + const updated = updateAdminUser(user.id, { + password_hash: hashAdminPassword(password), + updated_at: nowIso(), + }) + + return { + user: mapAdminUser(updated), + } +} + +export function ensureAdminAuthConfigured() { + const sessionSecret = String(runtimeConfig.admin?.sessionSecret || '').trim() + const configuredUsers = Array.isArray(runtimeConfig.admin?.defaultUsers) ? runtimeConfig.admin.defaultUsers : [] + + if (sessionSecret && configuredUsers.length > 0) { + return + } + + throw createHttpError('后台鉴权未配置,请先设置 admin.sessionSecret 和 admin.defaultUsers', { + statusCode: 503, + errorCode: 'admin_auth_not_configured', + }) +} + +function createAdminSession(user) { + const issuedAt = nowIso() + const expiresAt = addHours(issuedAt, Number(runtimeConfig.admin.sessionTtlHours || 12)) + const payload = { + sid: crypto.randomBytes(12).toString('hex'), + uid: user.id, + usr: user.username, + role: normalizeAdminRole(user.role), + iat: issuedAt, + exp: expiresAt, + } + const encodedPayload = Buffer.from(JSON.stringify(payload)).toString('base64url') + const signature = signPayload(encodedPayload) + + return { + token: `${encodedPayload}.${signature}`, + expiresAt, + user: { + userId: user.id, + username: user.username, + role: normalizeAdminRole(user.role), + }, + } +} + +export function hashAdminPassword(password) { + const salt = crypto.randomBytes(16).toString('hex') + const derived = crypto.scryptSync(password, salt, 64).toString('hex') + return `scrypt$${salt}$${derived}` +} + +function verifyAdminPassword(password, storedHash) { + const [algorithm, salt, expectedHash] = String(storedHash || '').split('$') + if (algorithm !== 'scrypt' || !salt || !expectedHash) { + return false + } + + const actualHash = crypto.scryptSync(password, salt, 64).toString('hex') + return safeCompare(actualHash, expectedHash) +} + +function signPayload(encodedPayload) { + return crypto + .createHmac('sha256', String(runtimeConfig.admin.sessionSecret || '')) + .update(encodedPayload) + .digest('base64url') +} + +export function normalizeAdminRole(role) { + return String(role || '').trim().toLowerCase() === 'admin' ? 'admin' : 'operator' +} + +export function normalizeAdminUserStatus(status) { + return String(status || '').trim().toLowerCase() === 'disabled' ? 'disabled' : 'active' +} + +function safeCompare(input, expected) { + const left = Buffer.from(String(input || ''), 'utf8') + const right = Buffer.from(String(expected || ''), 'utf8') + + if (left.length !== right.length) { + return false + } + + return crypto.timingSafeEqual(left, right) +} + +function normalizePage(rawValue) { + const parsed = Number(rawValue) + return Number.isFinite(parsed) && parsed > 0 ? Math.floor(parsed) : 1 +} + +function normalizePageSize(rawValue) { + const parsed = Number(rawValue) + if (!Number.isFinite(parsed) || parsed <= 0) { + return 20 + } + return Math.min(100, Math.floor(parsed)) +} + +function normalizeRoleQuery(role) { + const normalized = String(role || '').trim().toLowerCase() + return ['admin', 'operator'].includes(normalized) ? normalized : '' +} + +function normalizeStatusQuery(status) { + const normalized = String(status || '').trim().toLowerCase() + return ['active', 'disabled'].includes(normalized) ? normalized : '' +} + +function normalizeUsername(username) { + return String(username || '').trim().toLowerCase() +} + +function normalizePassword(password) { + return String(password || '').trim() +} + +function validateUsername(username) { + if (!/^[a-zA-Z0-9._-]{3,32}$/.test(username)) { + throw createHttpError('后台账号格式无效,需为 3-32 位字母数字或 ._-', { + statusCode: 400, + errorCode: 'admin_user_username_invalid', + }) + } +} + +function validatePassword(password) { + if (password.length < 8) { + throw createHttpError('后台密码至少 8 位', { + statusCode: 400, + errorCode: 'admin_user_password_invalid', + }) + } +} + +function getRequiredAdminUser(userId) { + const user = getAdminUserById(Number(userId)) + + if (!user) { + throw createHttpError('后台用户不存在', { + statusCode: 404, + errorCode: 'admin_user_not_found', + }) + } + + return user +} + +function ensureAdminUserChangeAllowed(user, options = {}, session) { + const nextRole = options.nextRole || user.role + const nextStatus = options.nextStatus || user.status + + if (session?.userId === user.id && (nextRole !== 'admin' || nextStatus !== 'active')) { + throw createHttpError('不能停用或降级当前登录账号', { + statusCode: 409, + errorCode: 'admin_user_self_change_not_allowed', + }) + } + + if (user.role === 'admin' && (nextRole !== 'admin' || nextStatus !== 'active') && countActiveAdminUsers() <= 1) { + throw createHttpError('至少保留一个启用中的管理员账号', { + statusCode: 409, + errorCode: 'admin_user_last_admin_not_allowed', + }) + } +} + +function mapAdminUser(user) { + return { + userId: user.id, + username: user.username, + role: normalizeAdminRole(user.role), + status: normalizeAdminUserStatus(user.status), + createdAt: user.created_at, + updatedAt: user.updated_at, + } +} diff --git a/apps/backend/src/services/admin-service.js b/apps/backend/src/services/admin-service.js new file mode 100644 index 00000000..3e7e3e66 --- /dev/null +++ b/apps/backend/src/services/admin-service.js @@ -0,0 +1,924 @@ +import { getDb } from '../db/client.js' +import { createTaskClaimToken } from './claim-service.js' +import { getClaimTokenById, updateClaimToken } from '../repositories/claim-token-repo.js' +import { createCdks, getCdkById, invalidateCdk, listCdks, releaseReservedCdk } from '../repositories/cdk-repo.js' +import { listOrderItemsByOrderId } from '../repositories/order-item-repo.js' +import { getOrderById, listOrders } from '../repositories/order-repo.js' +import { getTaskById, listTasks, listTasksByOrderId, updateTask } from '../repositories/task-repo.js' +import { getWebhookEventById, listWebhookEvents, listWebhookEventsByOrderId } from '../repositories/webhook-event-repo.js' +import { createHttpError } from '../utils/http.js' +import { nowIso } from '../utils/time.js' +import { reserveCdkForTask } from './cdk-service.js' +import { replayAgisoTradeWebhookEvent } from './webhook-service.js' + +export function getAdminDashboardSummary() { + const db = getDb() + const todayPrefix = nowIso().slice(0, 10) + const summary = db.prepare(` + SELECT + (SELECT COUNT(*) FROM orders WHERE substr(created_at, 1, 10) = ?) AS today_orders, + (SELECT COUNT(*) FROM delivery_tasks WHERE task_status IN ('paid', 'cdk_reserved', 'link_generated')) AS paid_pending_claim, + (SELECT COUNT(*) FROM delivery_tasks WHERE task_status IN ('claimed', 'role_confirmed', 'redeeming')) AS claiming_tasks, + (SELECT COUNT(*) FROM delivery_tasks WHERE task_status = 'redeemed' AND substr(updated_at, 1, 10) = ?) AS redeemed_today, + (SELECT COUNT(*) FROM delivery_tasks WHERE task_status IN ('retry_pending', 'manual_review', 'waiting_inventory')) AS abnormal_tasks, + (SELECT COUNT(DISTINCT sku_code) FROM cdk_inventory WHERE status = 'available') AS sku_with_inventory + `).get(todayPrefix, todayPrefix) + + return { + todayOrders: Number(summary?.today_orders || 0), + paidPendingClaim: Number(summary?.paid_pending_claim || 0), + claimingTasks: Number(summary?.claiming_tasks || 0), + redeemedToday: Number(summary?.redeemed_today || 0), + abnormalTasks: Number(summary?.abnormal_tasks || 0), + skuWithInventory: Number(summary?.sku_with_inventory || 0), + } +} + +export function getAdminOrders(query = {}) { + const page = normalizePage(query.page) + const pageSize = normalizePageSize(query.pageSize) + const { items, total } = listOrders({ + page, + pageSize, + platformOrderId: String(query.platformOrderId || '').trim(), + payStatus: String(query.payStatus || '').trim(), + skuCode: String(query.skuCode || '').trim(), + dateFrom: normalizeDateQuery(query.dateFrom), + dateTo: normalizeDateQuery(query.dateTo, true), + }) + + return { + items: items.map(mapAdminOrderListItem), + pagination: { page, pageSize, total }, + } +} + +export function getAdminOrderDetail(orderId) { + const order = getOrderById(Number(orderId)) + + if (!order) { + throw createHttpError('订单不存在', { + statusCode: 404, + errorCode: 'admin_order_not_found', + }) + } + + const items = listOrderItemsByOrderId(order.id) + const tasks = listTasksByOrderId(order.id) + const webhookEvents = listWebhookEventsByOrderId(order.id) + + return { + order: { + orderId: order.id, + platform: order.platform, + platformOrderId: order.platform_order_id, + orderStatus: order.order_status, + payStatus: order.pay_status, + buyerId: order.buyer_id, + buyerName: order.buyer_name, + receiverContact: order.receiver_contact, + totalAmount: order.total_amount, + currency: order.currency, + paidAt: order.paid_at, + createdAt: order.created_at, + updatedAt: order.updated_at, + rawPayload: safeParseJson(order.raw_payload_json), + bindingSummary: buildOrderBindingSummary(tasks), + }, + items: items.map((item) => ({ + orderItemId: item.id, + skuCode: item.sku_code, + skuName: item.sku_name, + quantity: item.quantity, + deliveryMode: item.delivery_mode, + spec: safeParseJson(item.spec_json), + })), + tasks: tasks.map(mapAdminTaskSummary), + webhookEvents: webhookEvents.map((event) => ({ + eventId: event.id, + platform: event.platform, + eventType: event.event_type, + eventKey: event.event_key, + signatureValid: Boolean(event.signature_valid), + processed: Boolean(event.processed), + processError: event.process_error, + createdAt: event.created_at, + })), + } +} + +export function getAdminTasks(query = {}) { + const page = normalizePage(query.page) + const pageSize = normalizePageSize(query.pageSize) + const { items, total } = listTasks({ + page, + pageSize, + status: String(query.status || '').trim(), + platformOrderId: String(query.platformOrderId || '').trim(), + taskNo: String(query.taskNo || '').trim(), + skuCode: String(query.skuCode || '').trim(), + roleId: String(query.roleId || '').trim(), + dateFrom: normalizeDateQuery(query.dateFrom), + dateTo: normalizeDateQuery(query.dateTo, true), + }) + + return { + items: items.map(mapAdminTaskListItem), + pagination: { page, pageSize, total }, + } +} + +export function getAdminTaskDetail(taskId) { + const task = getTaskById(Number(taskId)) + + if (!task) { + throw createHttpError('任务不存在', { + statusCode: 404, + errorCode: 'admin_task_not_found', + }) + } + + const order = getOrderById(task.order_id) + const orderItem = order ? listOrderItemsByOrderId(order.id).find((item) => item.id === task.order_item_id) || null : null + const claimToken = task.claim_token_id ? getClaimTokenById(task.claim_token_id) : null + const cdk = task.reserved_cdk_id ? getCdkById(task.reserved_cdk_id) : null + + return { + task: mapAdminTaskListItem({ + ...task, + sku_code: orderItem?.sku_code || '', + sku_name: orderItem?.sku_name || '', + cdk_code: cdk?.cdk_code || '', + claim_token: claimToken?.token || '', + }), + order: order + ? { + orderId: order.id, + platformOrderId: order.platform_order_id, + payStatus: order.pay_status, + orderStatus: order.order_status, + } + : null, + orderItem: orderItem + ? { + orderItemId: orderItem.id, + skuCode: orderItem.sku_code, + skuName: orderItem.sku_name, + quantity: orderItem.quantity, + } + : null, + claimToken: claimToken + ? { + claimTokenId: claimToken.id, + token: claimToken.token, + status: claimToken.status, + expiredAt: claimToken.expired_at, + } + : null, + cdk: cdk + ? { + cdkId: cdk.id, + skuCode: cdk.sku_code, + batchNo: cdk.batch_no, + cdkCode: cdk.cdk_code, + status: cdk.status, + } + : null, + artifacts: safeParseJson(task.artifacts_json), + screenshotUrl: task.screenshot_path ? `/api/v1/admin/tasks/${task.id}/screenshot` : '', + operations: { + canRetry: ['retry_pending', 'manual_review', 'waiting_inventory'].includes(task.task_status), + canReleaseCdk: Boolean(task.reserved_cdk_id && ['link_generated', 'retry_pending', 'manual_review', 'waiting_inventory', 'closed'].includes(task.task_status)), + canRegenerateClaimLink: ['link_generated', 'claimed', 'role_confirmed', 'retry_pending', 'manual_review'].includes(task.task_status), + canClose: !['redeemed', 'closed'].includes(task.task_status), + canMarkManualReview: !['redeemed', 'closed', 'manual_review'].includes(task.task_status), + }, + } +} + +export function getAdminTaskScreenshotPath(taskId) { + const task = getRequiredTask(taskId) + + if (!task.screenshot_path) { + throw createHttpError('当前任务还没有截图', { + statusCode: 404, + errorCode: 'admin_task_screenshot_not_found', + }) + } + + return task.screenshot_path +} + +export function getAdminCdks(query = {}) { + const page = normalizePage(query.page) + const pageSize = normalizePageSize(query.pageSize) + const { items, total } = listCdks({ + page, + pageSize, + skuCode: String(query.skuCode || '').trim(), + status: String(query.status || '').trim(), + batchNo: String(query.batchNo || '').trim(), + }) + + return { + items: items.map(mapAdminCdkListItem), + pagination: { page, pageSize, total }, + } +} + +export function createAdminCdk(payload = {}) { + const skuCode = String(payload.skuCode || '').trim() + const cdkCode = String(payload.cdkCode || '').trim() + const batchNo = String(payload.batchNo || '').trim() + + if (!skuCode || !cdkCode) { + throw createHttpError('缺少 skuCode 或 cdkCode', { + statusCode: 400, + errorCode: 'admin_cdk_create_invalid', + }) + } + + const now = nowIso() + const created = createCdks([ + { + skuCode, + cdkCode, + batchNo, + createdAt: now, + updatedAt: now, + }, + ]) + + if (created === 0) { + throw createHttpError('CDK 已存在,不能重复新增', { + statusCode: 409, + errorCode: 'admin_cdk_duplicate', + }) + } + + const { items } = listCdks({ + page: 1, + pageSize: 1, + skuCode, + }) + const createdItem = items.find((item) => item.cdk_code === cdkCode) || null + + return { + cdk: createdItem ? mapAdminCdkListItem(createdItem) : null, + } +} + +export function importAdminCdks(payload = {}) { + const rows = normalizeCdkImportRows(payload) + + if (rows.length === 0) { + throw createHttpError('没有可导入的 CDK 数据', { + statusCode: 400, + errorCode: 'admin_cdk_import_empty', + }) + } + + const now = nowIso() + const normalizedRows = rows.map((row) => ({ + skuCode: row.skuCode, + batchNo: row.batchNo, + cdkCode: row.cdkCode, + createdAt: now, + updatedAt: now, + })) + + const created = createCdks(normalizedRows) + + return { + total: normalizedRows.length, + created, + duplicated: normalizedRows.length - created, + } +} + +export function releaseAdminInventoryCdk(cdkId) { + const cdk = getRequiredCdk(cdkId) + + if (cdk.status !== 'reserved') { + throw createHttpError('当前 CDK 不是预占状态,不能释放', { + statusCode: 409, + errorCode: 'admin_cdk_release_not_allowed', + }) + } + + const updated = releaseReservedCdk(cdk.id, nowIso()) + + return { + cdk: mapAdminCdkListItem(updated), + } +} + +export function invalidateAdminCdk(cdkId, payload = {}) { + const cdk = getRequiredCdk(cdkId) + + if (cdk.status !== 'available') { + throw createHttpError('只有可用 CDK 才能作废,请先释放预占', { + statusCode: 409, + errorCode: 'admin_cdk_invalidate_not_allowed', + }) + } + + const reason = String(payload.reason || '').trim() || '后台手动作废' + const updated = invalidateCdk(cdk.id, reason, nowIso()) + + return { + cdk: mapAdminCdkListItem(updated), + } +} + +export function getAdminWebhookEvents(query = {}) { + const page = normalizePage(query.page) + const pageSize = normalizePageSize(query.pageSize) + const { items, total } = listWebhookEvents({ + page, + pageSize, + platform: String(query.platform || '').trim(), + processed: String(query.processed || '').trim(), + relatedOrderId: String(query.relatedOrderId || '').trim(), + dateFrom: normalizeDateQuery(query.dateFrom), + dateTo: normalizeDateQuery(query.dateTo, true), + }) + + return { + items: items.map((item) => ({ + eventId: item.id, + platform: item.platform, + eventType: item.event_type, + eventKey: item.event_key, + signatureValid: Boolean(item.signature_valid), + processed: Boolean(item.processed), + processError: item.process_error, + relatedOrderId: item.related_order_id, + createdAt: item.created_at, + })), + pagination: { page, pageSize, total }, + } +} + +export function getAdminWebhookEventDetail(eventId) { + const event = getWebhookEventById(Number(eventId)) + + if (!event) { + throw createHttpError('Webhook 事件不存在', { + statusCode: 404, + errorCode: 'admin_webhook_not_found', + }) + } + + return { + eventId: event.id, + platform: event.platform, + eventType: event.event_type, + eventKey: event.event_key, + signatureValid: Boolean(event.signature_valid), + processed: Boolean(event.processed), + processError: event.process_error, + relatedOrderId: event.related_order_id, + createdAt: event.created_at, + headers: safeParseJson(event.headers_json), + query: safeParseJson(event.query_json), + body: safeParseJson(event.body_json), + } +} + +export async function replayAdminWebhookEvent(eventId) { + const event = getWebhookEventById(Number(eventId)) + + if (!event) { + throw createHttpError('Webhook 事件不存在', { + statusCode: 404, + errorCode: 'admin_webhook_not_found', + }) + } + + if (event.platform !== '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, + } +} + +export function retryAdminTask(taskId) { + const task = getRequiredTask(taskId) + const now = nowIso() + + if (!['retry_pending', 'manual_review', 'waiting_inventory'].includes(task.task_status)) { + throw createHttpError('当前任务状态不允许重试', { + statusCode: 409, + errorCode: 'admin_task_retry_not_allowed', + }) + } + + const orderItem = listOrderItemsByOrderId(task.order_id).find((item) => item.id === task.order_item_id) || null + let reservedCdkId = task.reserved_cdk_id + let claimTokenId = task.claim_token_id + let nextStatus = 'link_generated' + let lastError = '' + let expiresAt = task.expires_at + let claimUrl = '' + let token = '' + + if (!reservedCdkId) { + const reserved = reserveCdkForTask(orderItem?.sku_code || '', task.id) + + if (!reserved) { + nextStatus = 'waiting_inventory' + lastError = '库存不足,等待可用 CDK' + } else { + reservedCdkId = reserved.id + } + } + + if (nextStatus === 'link_generated' && !claimTokenId) { + const claimToken = createTaskClaimToken(task.id) + claimTokenId = claimToken.id + expiresAt = claimToken.expired_at + claimUrl = claimToken.claimUrl + token = claimToken.token + } + + const updatedTask = updateTask(task.id, { + task_status: nextStatus, + reserved_cdk_id: reservedCdkId, + claim_token_id: claimTokenId, + expires_at: expiresAt, + last_error: lastError, + updated_at: now, + }) + + const payload = { + task: mapTaskActionPayload(updatedTask), + } + + if (claimUrl) { + payload.claimUrl = claimUrl + payload.token = token + } + + return payload +} + +export function releaseAdminTaskCdk(taskId) { + const task = getRequiredTask(taskId) + + if (!task.reserved_cdk_id) { + throw createHttpError('当前任务没有预占 CDK', { + statusCode: 409, + errorCode: 'admin_task_no_reserved_cdk', + }) + } + + if (task.task_status === 'redeemed') { + throw createHttpError('已兑换任务不能释放 CDK', { + statusCode: 409, + errorCode: 'admin_task_release_not_allowed', + }) + } + + releaseReservedCdk(task.reserved_cdk_id, nowIso()) + const updatedTask = updateTask(task.id, { + reserved_cdk_id: null, + task_status: 'waiting_inventory', + last_error: '已手动释放预占 CDK', + updated_at: nowIso(), + }) + + return { + task: mapTaskActionPayload(updatedTask), + } +} + +export function regenerateAdminTaskClaimLink(taskId) { + const task = getRequiredTask(taskId) + + if (['redeemed', 'closed'].includes(task.task_status)) { + throw createHttpError('当前任务状态不允许重新生成领取链接', { + statusCode: 409, + errorCode: 'admin_task_regenerate_not_allowed', + }) + } + + if (task.claim_token_id) { + updateClaimToken(task.claim_token_id, { + status: 'revoked', + updated_at: nowIso(), + }) + } + + const claimToken = createTaskClaimToken(task.id) + const updatedTask = updateTask(task.id, { + claim_token_id: claimToken.id, + expires_at: claimToken.expired_at, + task_status: 'link_generated', + last_error: '', + updated_at: nowIso(), + }) + + return { + task: mapTaskActionPayload(updatedTask), + claimUrl: claimToken.claimUrl, + token: claimToken.token, + } +} + +export function closeAdminTask(taskId) { + const task = getRequiredTask(taskId) + + if (task.task_status === 'redeemed') { + throw createHttpError('已兑换任务不能关闭', { + statusCode: 409, + errorCode: 'admin_task_close_not_allowed', + }) + } + + const updatedTask = updateTask(task.id, { + task_status: 'closed', + last_error: task.last_error || '已手动关闭任务', + updated_at: nowIso(), + }) + + return { + task: mapTaskActionPayload(updatedTask), + } +} + +export function markAdminTaskManualReview(taskId) { + const task = getRequiredTask(taskId) + const updatedTask = updateTask(task.id, { + task_status: 'manual_review', + last_error: task.last_error || '已转人工处理', + updated_at: nowIso(), + }) + + return { + task: mapTaskActionPayload(updatedTask), + } +} + +function mapAdminTaskSummary(task) { + const binding = buildTaskBindingState(task) + + return { + taskId: task.id, + taskNo: task.task_no, + status: task.task_status, + systemBindingStatus: binding.systemBindingStatus, + userBindingStatus: binding.userBindingStatus, + loginType: task.login_type, + browserSessionId: task.browser_session_id, + claimedAt: task.claimed_at, + roleConfirmedAt: task.role_confirmed_at, + redeemedAt: task.redeemed_at, + lastError: task.last_error, + retryCount: task.retry_count, + createdAt: task.created_at, + updatedAt: task.updated_at, + } +} + +function mapAdminCdkListItem(item) { + const task = item.reserved_by_task_id ? getTaskById(item.reserved_by_task_id) : null + const order = task?.order_id ? getOrderById(task.order_id) : null + const binding = buildTaskBindingState(task) + + return { + cdkId: item.id, + skuCode: item.sku_code, + batchNo: item.batch_no, + cdkCode: item.cdk_code, + status: item.status, + reservedByTaskId: item.reserved_by_task_id, + reservedByTaskNo: task?.task_no || '', + platformOrderId: order?.platform_order_id || '', + systemBindingStatus: item.status === 'delivered' ? 'system_bound' : binding.systemBindingStatus, + userBindingStatus: item.status === 'delivered' ? 'binding_completed' : binding.userBindingStatus, + invalidReason: item.invalid_reason || '', + deliveredAt: item.delivered_at, + createdAt: item.created_at, + updatedAt: item.updated_at, + } +} + +function mapTaskActionPayload(task) { + return { + taskId: task.id, + taskNo: task.task_no, + status: task.task_status, + reservedCdkId: task.reserved_cdk_id, + claimTokenId: task.claim_token_id, + lastError: task.last_error, + updatedAt: task.updated_at, + } +} + +function getRequiredTask(taskId) { + const task = getTaskById(Number(taskId)) + + if (!task) { + throw createHttpError('任务不存在', { + statusCode: 404, + errorCode: 'admin_task_not_found', + }) + } + + return task +} + +function getRequiredCdk(cdkId) { + const cdk = getCdkById(Number(cdkId)) + + if (!cdk) { + throw createHttpError('CDK 不存在', { + statusCode: 404, + errorCode: 'admin_cdk_not_found', + }) + } + + return cdk +} + +function mapAdminTaskListItem(task) { + const binding = buildTaskBindingState(task) + + return { + taskId: task.id, + taskNo: task.task_no, + platformOrderId: task.platform_order_id, + skuCode: task.sku_code || '', + skuName: task.sku_name || '', + status: task.task_status, + systemBindingStatus: binding.systemBindingStatus, + userBindingStatus: binding.userBindingStatus, + loginType: task.login_type, + roleName: task.role_name, + roleId: task.role_id, + browserSessionId: task.browser_session_id, + claimedAt: task.claimed_at, + roleConfirmedAt: task.role_confirmed_at, + redeemedAt: task.redeemed_at, + retryCount: task.retry_count, + lastError: task.last_error, + createdAt: task.created_at, + updatedAt: task.updated_at, + reservedCdkCodeMasked: maskCode(task.cdk_code), + claimToken: task.claim_token || '', + screenshotPath: task.screenshot_path || '', + } +} + +function mapAdminOrderListItem(item) { + const tasks = listTasksByOrderId(item.id) + const bindingSummary = buildOrderBindingSummary(tasks) + + return { + orderId: item.id, + platform: item.platform, + platformOrderId: item.platform_order_id, + orderStatus: item.order_status, + payStatus: item.pay_status, + buyerName: item.buyer_name, + totalAmount: item.total_amount, + currency: item.currency, + createdAt: item.created_at, + updatedAt: item.updated_at, + taskCount: Number(item.task_count || tasks.length || 0), + systemBindingStatus: bindingSummary.systemBindingStatus, + userBindingStatus: bindingSummary.userBindingStatus, + systemBoundTaskCount: bindingSummary.systemBoundTaskCount, + completedBindingTaskCount: bindingSummary.completedBindingTaskCount, + } +} + +function buildOrderBindingSummary(tasks) { + const normalizedTasks = Array.isArray(tasks) ? tasks : [] + const totalTaskCount = normalizedTasks.length + const taskBindings = normalizedTasks.map((task) => buildTaskBindingState(task)) + const systemBoundTaskCount = normalizedTasks.filter((task) => isTaskSystemBound(task)).length + const completedBindingTaskCount = normalizedTasks.filter((task) => String(task?.task_status || '') === 'redeemed').length + + let systemBindingStatus = 'pending_binding' + let userBindingStatus = 'not_started' + + if (totalTaskCount === 0) { + return { + totalTaskCount, + systemBoundTaskCount, + completedBindingTaskCount, + systemBindingStatus, + userBindingStatus, + } + } + + if (normalizedTasks.every((task) => String(task.task_status || '') === 'redeemed')) { + systemBindingStatus = 'system_bound' + userBindingStatus = 'binding_completed' + } else if (taskBindings.some((item) => ['binding_in_progress', 'binding_confirmed', 'link_opened'].includes(item.userBindingStatus))) { + systemBindingStatus = systemBoundTaskCount > 0 ? 'system_bound' : 'pending_binding' + userBindingStatus = 'user_binding' + } else if (taskBindings.some((item) => item.userBindingStatus === 'waiting_user_claim')) { + systemBindingStatus = systemBoundTaskCount > 0 ? 'system_bound' : 'pending_binding' + userBindingStatus = 'waiting_user_claim' + } else if (taskBindings.some((item) => item.userBindingStatus === 'binding_exception')) { + systemBindingStatus = systemBoundTaskCount > 0 ? 'system_bound' : 'pending_binding' + userBindingStatus = 'binding_exception' + } else if (systemBoundTaskCount > 0) { + systemBindingStatus = 'system_bound' + } else if (taskBindings.some((item) => ['manual_review', 'retry_pending', 'waiting_inventory'].includes(item.systemBindingStatus))) { + systemBindingStatus = 'binding_exception' + } + + return { + totalTaskCount, + systemBoundTaskCount, + completedBindingTaskCount, + systemBindingStatus, + userBindingStatus, + } +} + +function buildTaskBindingState(task) { + const normalizedStatus = String(task?.task_status || '').trim() + + if (!normalizedStatus) { + return { + systemBindingStatus: 'pending_binding', + userBindingStatus: 'not_started', + } + } + + if (normalizedStatus === 'pending_payment') { + return { systemBindingStatus: 'pending_payment', userBindingStatus: 'not_started' } + } + + if (normalizedStatus === 'paid') { + return { systemBindingStatus: 'pending_binding', userBindingStatus: 'not_started' } + } + + if (normalizedStatus === 'waiting_inventory') { + return { systemBindingStatus: 'waiting_inventory', userBindingStatus: 'not_started' } + } + + if (normalizedStatus === 'manual_review') { + return { systemBindingStatus: 'manual_review', userBindingStatus: 'binding_exception' } + } + + if (normalizedStatus === 'retry_pending') { + return { systemBindingStatus: 'retry_pending', userBindingStatus: 'binding_exception' } + } + + if (normalizedStatus === 'closed') { + return { systemBindingStatus: 'closed', userBindingStatus: 'closed' } + } + + if (normalizedStatus === 'expired') { + return { systemBindingStatus: 'expired', userBindingStatus: 'expired' } + } + + if (normalizedStatus === 'link_generated') { + return { systemBindingStatus: 'system_bound', userBindingStatus: 'waiting_user_claim' } + } + + if (normalizedStatus === 'claimed') { + return { systemBindingStatus: 'system_bound', userBindingStatus: 'link_opened' } + } + + if (normalizedStatus === 'role_confirmed') { + return { systemBindingStatus: 'system_bound', userBindingStatus: 'binding_confirmed' } + } + + if (normalizedStatus === 'redeeming') { + return { systemBindingStatus: 'system_bound', userBindingStatus: 'binding_in_progress' } + } + + if (normalizedStatus === 'redeemed') { + return { systemBindingStatus: 'system_bound', userBindingStatus: 'binding_completed' } + } + + if (isTaskSystemBound(task)) { + return { systemBindingStatus: 'system_bound', userBindingStatus: 'waiting_user_claim' } + } + + return { systemBindingStatus: 'pending_binding', userBindingStatus: 'not_started' } +} + +function isTaskSystemBound(task) { + return Boolean(task && (task.reserved_cdk_id || task.claim_token_id)) +} + +function normalizeCdkImportRows(payload) { + const rows = [] + const directRows = Array.isArray(payload.rows) ? payload.rows : [] + const bulkCodes = Array.isArray(payload.codes) ? payload.codes : [] + + if (directRows.length > 0) { + for (const row of directRows) { + const skuCode = String(row?.skuCode || '').trim() + const cdkCode = String(row?.cdkCode || '').trim() + const batchNo = String(row?.batchNo || '').trim() + + if (!skuCode || !cdkCode) { + continue + } + + rows.push({ skuCode, cdkCode, batchNo }) + } + } + + if (bulkCodes.length > 0) { + const skuCode = String(payload.skuCode || '').trim() + const batchNo = String(payload.batchNo || '').trim() + + if (!skuCode) { + throw createHttpError('批量导入时缺少 skuCode', { + statusCode: 400, + errorCode: 'admin_cdk_import_missing_sku', + }) + } + + for (const code of bulkCodes) { + const cdkCode = String(code || '').trim() + if (!cdkCode) { + continue + } + rows.push({ skuCode, cdkCode, batchNo }) + } + } + + return dedupeRows(rows) +} + +function dedupeRows(rows) { + const seen = new Set() + const output = [] + + for (const row of rows) { + const key = `${row.skuCode}::${row.cdkCode}` + if (seen.has(key)) { + continue + } + seen.add(key) + output.push(row) + } + + return output +} + +function normalizePage(rawValue) { + const parsed = Number(rawValue) + return Number.isFinite(parsed) && parsed > 0 ? Math.floor(parsed) : 1 +} + +function normalizePageSize(rawValue) { + const parsed = Number(rawValue) + if (!Number.isFinite(parsed) || parsed <= 0) { + return 20 + } + return Math.min(100, Math.floor(parsed)) +} + +function normalizeDateQuery(rawValue, endOfDay = false) { + const normalized = String(rawValue || '').trim() + + if (!normalized) { + return '' + } + + if (/^\d{4}-\d{2}-\d{2}$/.test(normalized)) { + return endOfDay ? `${normalized}T23:59:59.999Z` : `${normalized}T00:00:00.000Z` + } + + return normalized +} + +function safeParseJson(rawText) { + try { + return JSON.parse(String(rawText || '{}')) + } catch { + return {} + } +} + +function maskCode(value) { + const text = String(value || '').trim() + if (!text) { + return '' + } + if (text.length <= 8) { + return `${text.slice(0, 2)}****${text.slice(-2)}` + } + return `${text.slice(0, 4)}****${text.slice(-4)}` +} diff --git a/apps/backend/src/services/cdk-service.js b/apps/backend/src/services/cdk-service.js new file mode 100644 index 00000000..209cd270 --- /dev/null +++ b/apps/backend/src/services/cdk-service.js @@ -0,0 +1,16 @@ +import { assignReservedCdk, findFirstAvailableCdkBySkuCode } from '../repositories/cdk-repo.js' +import { nowIso } from '../utils/time.js' + +export function reserveCdkForTask(skuCode, taskId) { + if (!skuCode) { + return null + } + + const available = findFirstAvailableCdkBySkuCode(skuCode) + + if (!available) { + return null + } + + return assignReservedCdk(available.id, taskId, nowIso()) +} diff --git a/apps/backend/src/services/claim-service.js b/apps/backend/src/services/claim-service.js new file mode 100644 index 00000000..7834233b --- /dev/null +++ b/apps/backend/src/services/claim-service.js @@ -0,0 +1,30 @@ +import { runtimeConfig } from '../config/runtime.js' +import { createClaimToken } from '../repositories/claim-token-repo.js' +import { addHours, nowIso } from '../utils/time.js' +import { randomToken } from '../utils/random.js' + +export function createTaskClaimToken(taskId) { + const createdAt = nowIso() + const expiredAt = addHours(createdAt, Number(runtimeConfig.orders.tokenTtlHours || 24)) + const token = createClaimToken({ + taskId, + token: randomToken(24), + status: 'active', + expiredAt, + usedAt: null, + maxUseCount: 1, + usedCount: 0, + createdAt, + updatedAt: createdAt, + }) + + return { + ...token, + claimUrl: buildClaimUrl(token.token), + } +} + +export function buildClaimUrl(token) { + const baseUrl = String(runtimeConfig.orders.claimBaseUrl || '').trim() + return baseUrl ? `${baseUrl.replace(/\/+$/, '')}/${token}` : token +} diff --git a/apps/backend/src/services/claim-session-service.js b/apps/backend/src/services/claim-session-service.js new file mode 100644 index 00000000..5c1412b5 --- /dev/null +++ b/apps/backend/src/services/claim-session-service.js @@ -0,0 +1,407 @@ +import { + createTencentBrowserSession, + getTencentBrowserSession, + getTencentBrowserSessionScreenshotPath, + redeemTencentBrowserSession, +} from './session.js' +import { findClaimTokenByToken, updateClaimToken } from '../repositories/claim-token-repo.js' +import { getOrderById } from '../repositories/order-repo.js' +import { getOrderItemById } from '../repositories/order-item-repo.js' +import { findTaskByClaimTokenId, updateTask } from '../repositories/task-repo.js' +import { getCdkById, markCdkDelivered, releaseReservedCdk } from '../repositories/cdk-repo.js' +import { buildClaimUrl } from './claim-service.js' +import { createHttpError } from '../utils/http.js' +import { nowIso } from '../utils/time.js' + +const CLAIM_TERMINAL_STATUSES = new Set(['expired', 'closed']) + +export async function getClaimDetail(token, { includeQrImage = true } = {}) { + const context = await getClaimContext(token) + const session = await loadTaskSession(context.task, { includeQrImage }) + const syncedTask = session ? syncTaskWithSession(context.task, session) : context.task + + return buildClaimDetailPayload({ + claimToken: context.claimToken, + task: syncedTask, + order: context.order, + orderItem: context.orderItem, + session, + }) +} + +export async function createClaimSession(token, payload = {}) { + const context = await getClaimContext(token) + assertTaskCanProceed(context.task) + + if (context.task.browser_session_id) { + const existingSession = await getTencentBrowserSession(context.task.browser_session_id) + const syncedTask = syncTaskWithSession(context.task, existingSession) + + return buildClaimDetailPayload({ + claimToken: context.claimToken, + task: syncedTask, + order: context.order, + orderItem: context.orderItem, + session: existingSession, + }) + } + + const session = await createTencentBrowserSession({ + loginType: payload.loginType, + }) + const updatedTask = updateTask(context.task.id, { + task_status: 'claimed', + browser_session_id: session.sessionId, + login_type: session.loginType, + claimed_at: context.task.claimed_at || nowIso(), + updated_at: nowIso(), + }) + + return buildClaimDetailPayload({ + claimToken: context.claimToken, + task: updatedTask, + order: context.order, + orderItem: context.orderItem, + session, + }) +} + +export async function getClaimSessionSummary(token) { + const context = await getClaimContext(token) + + 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 syncedTask = syncTaskWithSession(context.task, session) + + return buildClaimDetailPayload({ + claimToken: context.claimToken, + task: syncedTask, + order: context.order, + orderItem: context.orderItem, + session, + }) +} + +export async function confirmClaimRole(token) { + const context = await getClaimContext(token) + + 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 = 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 || ''), + role_confirmed_at: nowIso(), + updated_at: nowIso(), + last_error: '', + }) + + return buildClaimDetailPayload({ + claimToken: context.claimToken, + task: updatedTask, + order: context.order, + orderItem: context.orderItem, + session, + }) +} + +export async function redeemClaimTask(token) { + const context = await getClaimContext(token) + assertTaskCanProceed(context.task) + + 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 cdk = context.task.reserved_cdk_id ? getCdkById(context.task.reserved_cdk_id) : null + + if (!cdk || !String(cdk.cdk_code || '').trim()) { + throw createHttpError('当前任务没有可用的预占 CDK', { + statusCode: 409, + errorCode: 'claim_cdk_not_reserved', + }) + } + + updateTask(context.task.id, { + task_status: 'redeeming', + updated_at: nowIso(), + last_error: '', + }) + + try { + const session = await redeemTencentBrowserSession(context.task.browser_session_id, { + code: cdk.cdk_code, + }) + const finalRedeem = session.redeem?.final?.redeem || null + const updatedTask = updateTask(context.task.id, { + task_status: 'redeemed', + result_code: String(finalRedeem?.iRet || finalRedeem?.ret || '0'), + result_message: String(finalRedeem?.sMsg || finalRedeem?.msg || session.notice || '兑换完成'), + screenshot_path: session.artifacts?.hasScreenshot ? await getTencentBrowserSessionScreenshotPath(session.sessionId) : '', + artifacts_json: JSON.stringify(session.artifacts || {}), + redeemed_at: nowIso(), + updated_at: nowIso(), + last_error: '', + }) + + markCdkDelivered(cdk.id, nowIso()) + + return buildClaimDetailPayload({ + claimToken: context.claimToken, + task: updatedTask, + order: context.order, + orderItem: context.orderItem, + session, + }) + } catch (error) { + const nextRetryCount = Number(context.task.retry_count || 0) + 1 + const failedTask = updateTask(context.task.id, { + task_status: 'retry_pending', + retry_count: nextRetryCount, + last_error: error instanceof Error ? error.message : String(error || ''), + updated_at: nowIso(), + }) + + throw Object.assign(error instanceof Error ? error : new Error(String(error || '兑换失败')), { + task: failedTask, + }) + } +} + +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 getClaimContext(token) { + const normalized = String(token || '').trim() + + if (!normalized) { + throw createHttpError('缺少领取 token', { + statusCode: 400, + errorCode: 'missing_claim_token', + }) + } + + const claimToken = findClaimTokenByToken(normalized) + + if (!claimToken) { + throw createHttpError('领取链接无效或不存在', { + statusCode: 404, + errorCode: 'claim_token_not_found', + }) + } + + const task = 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 = expireClaimContext(claimToken, task) + + throw createHttpError('领取链接已过期', { + statusCode: 410, + errorCode: 'claim_token_expired', + context: expiredContext, + }) + } + + const order = getOrderById(task.order_id) + const orderItem = getOrderItemById(task.order_item_id) + + if (!order || !orderItem) { + throw createHttpError('领取任务关联订单不完整', { + statusCode: 500, + errorCode: 'claim_order_incomplete', + }) + } + + return { + claimToken, + task, + order, + orderItem, + } +} + +function assertTaskCanProceed(task) { + if (CLAIM_TERMINAL_STATUSES.has(String(task.task_status || ''))) { + throw createHttpError('当前任务已经结束,不能继续操作', { + statusCode: 410, + errorCode: 'claim_task_closed', + }) + } +} + +function expireClaimContext(claimToken, task) { + const now = nowIso() + const nextClaimToken = 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.reserved_cdk_id) { + releaseReservedCdk(task.reserved_cdk_id, now) + } + + nextTask = updateTask(task.id, { + task_status: 'expired', + reserved_cdk_id: null, + last_error: '领取链接已过期,预占 CDK 已释放', + updated_at: now, + }) + } + + return { + claimToken: nextClaimToken, + task: nextTask, + } +} + +async function loadTaskSession(task, { includeQrImage = false } = {}) { + if (!task.browser_session_id) { + return null + } + + return includeQrImage + ? getTencentBrowserSession(task.browser_session_id) + : getTencentBrowserSessionSummary(task.browser_session_id) +} + +function syncTaskWithSession(task, session) { + const activityInfo = session.activityInfo || null + const patch = { + 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.claimed_at = task.claimed_at || nowIso() + } + + if (session.status === 'redeemed' && session.artifacts?.hasScreenshot) { + patch.screenshot_path = task.screenshot_path || '' + } + + return updateTask(task.id, patch) +} + +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 + + return { + tokenStatus: claimToken.status, + claimUrl: buildClaimUrl(claimToken.token), + task: { + taskId: task.id, + taskNo: task.task_no, + status: task.task_status, + expiresAt: task.expires_at, + claimedAt: task.claimed_at, + roleConfirmedAt: task.role_confirmed_at, + redeemedAt: task.redeemed_at, + loginType: task.login_type, + lastError: task.last_error, + browserSessionId: task.browser_session_id, + }, + order: { + orderId: order.id, + platform: order.platform, + platformOrderId: order.platform_order_id, + payStatus: order.pay_status, + orderStatus: order.order_status, + totalAmount: order.total_amount, + currency: order.currency, + }, + orderItem: { + orderItemId: orderItem.id, + skuCode: orderItem.sku_code, + skuName: orderItem.sku_name, + quantity: orderItem.quantity, + }, + session, + result: task.redeemed_at || session?.status === 'redeemed' + ? { + resultCode: String(task.result_code || finalRedeem?.iRet || finalRedeem?.ret || ''), + resultMessage: String(task.result_message || finalRedeem?.sMsg || finalRedeem?.msg || session?.notice || ''), + screenshotReady, + screenshotUrl, + } + : null, + } +} diff --git a/apps/backend/src/services/delivery-task-service.js b/apps/backend/src/services/delivery-task-service.js new file mode 100644 index 00000000..bda2842b --- /dev/null +++ b/apps/backend/src/services/delivery-task-service.js @@ -0,0 +1,119 @@ +import { createTask, listTasksByOrderId, updateTask } from '../repositories/task-repo.js' +import { reserveCdkForTask } from './cdk-service.js' +import { createTaskClaimToken } from './claim-service.js' +import { nowIso } from '../utils/time.js' +import { randomId } from '../utils/random.js' + +export function syncDeliveryTasksForOrder(order, orderItems) { + const existingTasks = listTasksByOrderId(order.id) + + if (existingTasks.length > 0) { + if (order.pay_status !== 'paid') { + return existingTasks + } + + const itemMap = new Map(orderItems.map((item) => [item.id, item])) + return existingTasks.map((task) => preparePaidTask({ + ...task, + skuCode: itemMap.get(task.order_item_id)?.sku_code || '', + skuName: itemMap.get(task.order_item_id)?.sku_name || '', + })) + } + + const tasks = [] + + for (const item of orderItems) { + const quantity = Math.max(1, Number(item.quantity || 1)) + + for (let index = 0; index < quantity; index += 1) { + const createdAt = nowIso() + const initialStatus = order.pay_status === 'paid' ? 'paid' : 'pending_payment' + + const task = createTask({ + orderId: order.id, + orderItemId: item.id, + platformOrderId: order.platform_order_id, + taskNo: randomId('DT'), + taskStatus: initialStatus, + loginType: '', + claimTokenId: null, + reservedCdkId: null, + browserSessionId: '', + nickname: '', + roleId: '', + roleName: '', + area: '', + partitionName: '', + resultCode: '', + resultMessage: '', + screenshotPath: '', + artifactsJson: '{}', + lastError: '', + retryCount: 0, + expiresAt: null, + claimedAt: null, + roleConfirmedAt: null, + redeemedAt: null, + createdAt, + updatedAt: createdAt, + }) + + tasks.push({ + ...task, + skuCode: item.sku_code, + skuName: item.sku_name, + }) + } + } + + if (order.pay_status !== 'paid') { + return tasks + } + + return tasks.map((task) => preparePaidTask(task)) +} + +function preparePaidTask(task) { + const now = nowIso() + + if (['link_generated', 'claimed', 'role_confirmed', 'redeeming', 'redeemed'].includes(task.task_status)) { + return task + } + + if (!task.skuCode) { + return updateTask(task.id, { + task_status: 'manual_review', + last_error: '未匹配到 SKU,无法为任务分配 CDK', + updated_at: now, + }) + } + + if (task.reserved_cdk_id && task.claim_token_id) { + return updateTask(task.id, { + task_status: 'link_generated', + last_error: '', + updated_at: now, + }) + } + + const reserved = reserveCdkForTask(task.skuCode, task.id) + + if (!reserved) { + return updateTask(task.id, { + task_status: 'waiting_inventory', + last_error: '库存不足,等待可用 CDK', + updated_at: now, + }) + } + + const claimToken = createTaskClaimToken(task.id) + + return updateTask(task.id, { + task_status: 'link_generated', + reserved_cdk_id: reserved.id, + claim_token_id: claimToken.id, + last_error: '', + expires_at: claimToken.expired_at, + updated_at: now, + }) +} diff --git a/apps/backend/src/services/message-service.js b/apps/backend/src/services/message-service.js new file mode 100644 index 00000000..5b5c1e6c --- /dev/null +++ b/apps/backend/src/services/message-service.js @@ -0,0 +1,231 @@ +import { runtimeConfig } from '../config/runtime.js' +import crypto from 'node:crypto' +import { + createMessageDelivery, + findLatestSuccessfulMessageDeliveryByTask, + updateMessageDelivery, +} from '../repositories/message-delivery-repo.js' +import { nowIso } from '../utils/time.js' + +const AGISO_MESSAGE_CHANNEL = 'agiso_im' + +export async function ensureAgisoClaimMessageDeliveredForTask({ order, task, claimUrl, expiredAt }) { + if (!order || !task || !claimUrl) { + return { sent: false, skipped: true, reason: 'missing_message_context' } + } + + const config = runtimeConfig.platforms?.agiso?.messaging || {} + 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 = findLatestSuccessfulMessageDeliveryByTask(task.id, AGISO_MESSAGE_CHANNEL) + if (successful) { + return { sent: false, skipped: true, reason: 'already_sent', deliveryId: successful.id } + } + + const messageContent = renderAgisoClaimMessage({ order, task, claimUrl, expiredAt, template: config.messageTemplate }) + 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 = nowIso() + const delivery = createMessageDelivery({ + platform: 'agiso', + channel: AGISO_MESSAGE_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, + }) + + try { + const response = await fetch(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 updated = updateMessageDelivery(delivery.id, { + status: success ? 'success' : 'failed', + response_status: response.status, + response_json: JSON.stringify(parsed ?? { rawText }), + error_message: success ? '' : resolveAgisoErrorMessage(parsed, rawText, response.status), + sent_at: success ? nowIso() : null, + updated_at: nowIso(), + }) + + return { + sent: success, + skipped: false, + deliveryId: updated?.id || delivery.id, + responseStatus: response.status, + response: parsed ?? { rawText }, + errorMessage: success ? '' : resolveAgisoErrorMessage(parsed, rawText, response.status), + } + } catch (error) { + const message = error instanceof Error ? error.message : String(error || '发送消息失败') + updateMessageDelivery(delivery.id, { + status: 'failed', + response_status: 0, + response_json: '{}', + error_message: message, + sent_at: null, + updated_at: nowIso(), + }) + + return { + sent: false, + skipped: false, + deliveryId: delivery.id, + errorMessage: message, + } + } +} + +function buildRequestUrl(endpoint) { + return new URL(endpoint).toString() +} + +function buildRequestHeaders({ accessToken, apiVersion }) { + return { + Authorization: `Bearer ${accessToken}`, + ApiVersion: apiVersion, + 'content-type': 'application/x-www-form-urlencoded; charset=utf-8', + } +} + +function buildRequestBody({ tid, msg, appSecret }) { + const timestamp = String(Math.floor(Date.now() / 1000)) + const payload = { + tid, + msg, + timestamp, + } + + payload.sign = generateSign(payload, appSecret) + + return payload +} + +function generateSign(params, appSecret) { + 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 }) { + const source = String(template || '').trim() || '您的订单 {platformOrderId} 已创建领取链接,请在 {expiredAt} 前完成领取:{claimUrl}' + + return source + .replaceAll('{platformOrderId}', String(order.platform_order_id || '')) + .replaceAll('{taskNo}', String(task.task_no || '')) + .replaceAll('{claimUrl}', claimUrl) + .replaceAll('{expiredAt}', String(expiredAt || '尽快')) +} + +function isAgisoSendSuccess(statusCode, payload) { + if (statusCode < 200 || statusCode >= 300) { + return false + } + + if (!payload || typeof payload !== 'object') { + return true + } + + if (payload.IsSuccess === true) { + return true + } + + if (Number(payload.Error_Code) === 0) { + return true + } + + if (Number(payload.code) === 0) { + return true + } + + return false +} + +function resolveAgisoErrorMessage(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}` +} + +function safeParseJson(rawText) { + const normalized = String(rawText || '').trim() + + if (!normalized) { + return null + } + + try { + return JSON.parse(normalized) + } catch { + return null + } +} + +function maskHeadersForStorage(headers) { + const output = { ...headers } + + if (output.Authorization) { + output.Authorization = '[masked]' + } + + return output +} + +function maskBodyForStorage(body) { + const output = { ...body } + + if (output.sign) { + output.sign = '[masked]' + } + + return output +} diff --git a/apps/backend/src/services/ocr.js b/apps/backend/src/services/ocr.js new file mode 100644 index 00000000..89dd8f37 --- /dev/null +++ b/apps/backend/src/services/ocr.js @@ -0,0 +1,213 @@ +import { spawn } from 'node:child_process' +import readline from 'node:readline' +import process from 'node:process' + +import { runtimeConfig } from '../config/runtime.js' + +const DEFAULT_TIMEOUT_MS = 60_000 + +let workerPromise = null +let requestSequence = 0 +const pendingRequests = new Map() +let stderrBuffer = [] + +export async function recognizeTencentCaptcha(payload) { + return callOcrWorker('recognize', payload) +} + +export async function batchRecognizeTencentCaptcha(payload) { + return callOcrWorker('batch', payload, { timeoutMs: 180_000 }) +} + +export async function warmupLocalOcrWorker() { + await ensureOcrWorker() +} + +export async function closeLocalOcrWorker() { + const worker = await workerPromise?.catch(() => null) + + if (!worker) { + workerPromise = null + return + } + + worker.child.kill() + workerPromise = null +} + +async function callOcrWorker(action, payload, { timeoutMs = DEFAULT_TIMEOUT_MS } = {}) { + const worker = await ensureOcrWorker() + const requestId = `ocr-${Date.now()}-${++requestSequence}` + + return await new Promise((resolve, reject) => { + const timer = setTimeout(() => { + pendingRequests.delete(requestId) + worker.child.kill() + reject(new Error('本地 OCR 识别超时,请确认 uv 环境和 OCR worker 依赖正常')) + }, timeoutMs) + + pendingRequests.set(requestId, { + resolve: (value) => { + clearTimeout(timer) + resolve(value) + }, + reject: (error) => { + clearTimeout(timer) + reject(error) + }, + }) + + worker.child.stdin.write( + `${JSON.stringify({ id: requestId, action, payload: payload || {} })}\n`, + 'utf8', + ) + }) +} + +async function ensureOcrWorker() { + if (!workerPromise) { + workerPromise = createOcrWorker().catch((error) => { + workerPromise = null + throw error + }) + } + + return workerPromise +} + +function createOcrWorker() { + return new Promise((resolve, reject) => { + const projectRoot = resolveOcrProjectRoot() + stderrBuffer = [] + + const child = spawn('uv', ['run', 'ocr-worker', 'worker'], { + cwd: projectRoot, + stdio: ['pipe', 'pipe', 'pipe'], + env: process.env, + }) + child.stdin.setDefaultEncoding('utf8') + + const stdoutReader = readline.createInterface({ input: child.stdout }) + const worker = { child, stdoutReader } + + const readyTimer = setTimeout(() => { + reject(new Error(`本地 OCR worker 启动超时,请检查 ${projectRoot} 下是否已执行 uv sync`)) + child.kill() + }, 15_000) + + let ready = false + + stdoutReader.on('line', (line) => { + const message = tryParseJson(line) + + if (!message || typeof message !== 'object') { + return + } + + if (message.type === 'ready') { + if (!ready) { + ready = true + clearTimeout(readyTimer) + resolve(worker) + } + return + } + + if (message.type !== 'response') { + return + } + + const requestId = String(message.id || '') + const pending = pendingRequests.get(requestId) + + if (!pending) { + return + } + + pendingRequests.delete(requestId) + pending.resolve(message.payload) + }) + + child.stderr.on('data', (chunk) => { + const text = String(chunk || '').trim() + + if (!text) { + return + } + + stderrBuffer.push(text) + if (stderrBuffer.length > 20) { + stderrBuffer = stderrBuffer.slice(-20) + } + }) + + child.on('error', (error) => { + clearTimeout(readyTimer) + const workerError = buildWorkerError(error) + failAllPendingRequests(workerError) + if (!ready) { + reject(workerError) + } + workerPromise = null + }) + + child.on('exit', (code, signal) => { + clearTimeout(readyTimer) + const workerError = new Error( + buildWorkerExitMessage({ + code, + signal, + projectRoot, + stderrText: stderrBuffer.join('\n'), + }), + ) + failAllPendingRequests(workerError) + if (!ready) { + reject(workerError) + } + workerPromise = null + }) + }) +} + +function failAllPendingRequests(error) { + for (const [requestId, pending] of pendingRequests.entries()) { + pendingRequests.delete(requestId) + pending.reject(error) + } +} + +function resolveOcrProjectRoot() { + return String(runtimeConfig.ocr.projectRoot || '').trim() +} + +function buildWorkerError(error) { + if (error instanceof Error && error.message.includes('spawn uv ENOENT')) { + return new Error('未找到 uv 命令,请先安装 uv,并确认它在 PATH 里') + } + + return new Error( + `本地 OCR worker 启动失败: ${error instanceof Error ? error.message : String(error || '未知错误')}`, + ) +} + +function buildWorkerExitMessage({ code, signal, projectRoot, stderrText }) { + const reason = signal + ? `signal ${signal}` + : typeof code === 'number' + ? `exit code ${code}` + : 'unknown reason' + + const installHint = `请先执行: cd ${projectRoot} && uv sync` + const stderrHint = stderrText ? `\n${stderrText}` : '' + + return `本地 OCR worker 已退出(${reason})。${installHint}${stderrHint}` +} + +function tryParseJson(text) { + try { + return JSON.parse(text) + } catch { + return null + } +} diff --git a/apps/backend/src/services/order-service.js b/apps/backend/src/services/order-service.js new file mode 100644 index 00000000..211592ed --- /dev/null +++ b/apps/backend/src/services/order-service.js @@ -0,0 +1,81 @@ +import { createOrder, findOrderByPlatformOrderId, updateOrder } from '../repositories/order-repo.js' +import { replaceOrderItems } from '../repositories/order-item-repo.js' +import { getClaimTokenById } from '../repositories/claim-token-repo.js' +import { buildClaimUrl } from './claim-service.js' +import { syncDeliveryTasksForOrder } from './delivery-task-service.js' +import { ensureAgisoClaimMessageDeliveredForTask } from './message-service.js' +import { nowIso } from '../utils/time.js' + +export async function upsertOrderFromWebhook(event) { + const now = nowIso() + const existing = findOrderByPlatformOrderId(event.platform, event.platformOrderId) + const basePayload = { + platform: event.platform, + platformOrderId: event.platformOrderId, + orderStatus: event.orderStatus, + payStatus: event.payStatus, + buyerId: event.buyerId, + buyerName: event.buyerName, + receiverContact: event.receiverContact, + totalAmount: event.totalAmount, + currency: event.currency, + rawPayloadJson: JSON.stringify(event.rawPayload), + paidAt: event.paidAt, + } + + const order = existing + ? updateOrder(existing.id, { + ...basePayload, + updatedAt: now, + }) + : createOrder({ + ...basePayload, + createdAt: now, + updatedAt: now, + }) + + const orderItems = replaceOrderItems( + order.id, + event.items.map((item) => ({ + skuCode: item.skuCode, + skuName: item.skuName, + quantity: item.quantity, + specJson: JSON.stringify(item.spec || {}), + deliveryMode: 'claim_link', + createdAt: now, + updatedAt: now, + })), + ) + + const tasks = syncDeliveryTasksForOrder(order, orderItems) + const messageDeliveries = [] + + for (const task of tasks) { + if (event.platform !== 'agiso' || String(task.task_status || '') !== 'link_generated' || !task.claim_token_id) { + continue + } + + const claimToken = getClaimTokenById(task.claim_token_id) + if (!claimToken) { + continue + } + + const result = await ensureAgisoClaimMessageDeliveredForTask({ + order, + task, + claimUrl: buildClaimUrl(claimToken.token), + expiredAt: claimToken.expired_at, + }) + messageDeliveries.push({ + taskId: task.id, + ...result, + }) + } + + return { + order, + orderItems, + tasks, + messageDeliveries, + } +} diff --git a/apps/backend/src/services/session-login-shared.js b/apps/backend/src/services/session-login-shared.js new file mode 100644 index 00000000..fa819bc8 --- /dev/null +++ b/apps/backend/src/services/session-login-shared.js @@ -0,0 +1,83 @@ +import { runtimeConfig } from '../config/runtime.js' + +const SESSION_DEBUG_ENABLED = Boolean(runtimeConfig.session.debug) + +export function logBrowserSessionDebug(scope, detail) { + if (!SESSION_DEBUG_ENABLED) { + return + } + + if (typeof detail === 'undefined') { + console.log(`[browser/session][debug] ${scope}`) + return + } + + console.log(`[browser/session][debug] ${scope}:`, detail) +} + +export async function waitForFrame(page, predicate, timeoutMs = 15_000) { + const startedAt = Date.now() + + while (Date.now() - startedAt < timeoutMs) { + const matched = page.frames().find((frame) => { + try { + return predicate(frame) + } catch { + return false + } + }) + + if (matched) { + return matched + } + + await page.waitForTimeout(300) + } + + throw new Error('未找到登录二维码 frame') +} + +export async function downloadRemoteQrImage(page, qrUrl, qrImagePath, referer = '', loginType = 'unknown') { + 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) { + 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-proof.js b/apps/backend/src/services/session-proof.js new file mode 100644 index 00000000..099f8e59 --- /dev/null +++ b/apps/backend/src/services/session-proof.js @@ -0,0 +1,500 @@ +import fs from 'node:fs/promises' +import path from 'node:path' + +import { runtimeConfig } from '../config/runtime.js' + +const BAIDU_BEIJING_TIME_URL = 'https://www.baidu.com/s?wd=%E5%8C%97%E4%BA%AC%E6%97%B6%E9%97%B4' +const BEIJING_TIME_PROOF_VIEWPORT = { width: 1440, height: 860 } +const BEIJING_TIME_PROOF_CLIP = { + x: 0, + y: 0, + width: BEIJING_TIME_PROOF_VIEWPORT.width, + height: 620, +} +const DEFAULT_REDEEM_PROOF_MODE = 'full' + +export async function saveRedeemArtifacts({ + browserContext, + page, + sessionDir, + sessionId, + activityUrl, + code, + finalResult, + attempts, +}) { + 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, +}) { + 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: '', + pageUrl: BAIDU_BEIJING_TIME_URL, + error: '', + } + + 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) + } + + 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 function resolveRedeemProofMode(rawMode = runtimeConfig.redeem.proofMode) { + const normalized = String(rawMode || '') + .trim() + .toLowerCase() + + if (normalized === 'basic' || normalized === 'off') { + return normalized + } + + return DEFAULT_REDEEM_PROOF_MODE +} + +function buildArtifactPaths(outputDir) { + 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'), + } +} + +async function writeRedeemResultFile( + resultPath, + { + proofMode, + sessionId, + activityUrl, + code, + finalResult, + attempts, + screenshotPath, + htmlPath = '', + redeemPageScreenshotPath = '', + beijingTimeScreenshotPath = '', + beijingTimePageUrl = BAIDU_BEIJING_TIME_URL, + beijingTimeError = '', + }, +) { + 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', + ) +} + +async function showResultDialog(page, message) { + await page.evaluate((text) => { + document.querySelectorAll('iframe').forEach((element) => element.remove()) + document.querySelectorAll('.pop').forEach((element) => { + 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) +} + +async function captureBeijingTimeProof(browserContext, { screenshotPath }) { + 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) + } +} + +async function composeProofScreenshot( + browserContext, + { outputPath, redeemImagePath, timeImagePath, redeemMessage, timePageUrl }, +) { + 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) + } +} + +function buildProofHtml({ redeemImageBase64, timeImageBase64, redeemMessage, timePageUrl }) { + const capturedAt = new Date().toLocaleString('zh-CN', { + hour12: false, + timeZone: 'Asia/Shanghai', + }) + + return ` + + + + + 兑换与北京时间凭证 + + + +
+
+
+

Tencent Redeem Proof

+

兑换截图与北京时间截图

+

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

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

兑换结果截图

+

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

+
+ 兑换结果截图 +
+
+
+

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

+

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

+
${escapeHtml(timePageUrl)}
+
+ 北京时间截图 +
+
+
+ +` +} + +function escapeHtml(value) { + return String(value || '') + .replaceAll('&', '&') + .replaceAll('<', '<') + .replaceAll('>', '>') + .replaceAll('"', '"') +} diff --git a/apps/backend/src/services/session-qq.js b/apps/backend/src/services/session-qq.js new file mode 100644 index 00000000..06a80ade --- /dev/null +++ b/apps/backend/src/services/session-qq.js @@ -0,0 +1,171 @@ +import fs from 'node:fs/promises' + +import { downloadRemoteQrImage, logBrowserSessionDebug, waitForFrame } from './session-login-shared.js' + +const QQ_QR_TARGET_SIZE = 144 + +export async function ensureQqLoginReady(page) { + 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, qrImagePath) { + 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: Boolean(body?.length), + qrImagePath, + }) + + if (body?.length) { + 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, frame = null) { + 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) { + 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 = await frame + .locator('#qrlogin_img') + .first() + .isVisible() + .catch(() => false) + const bodyText = String(await frame.locator('body').textContent()).replace(/\s+/g, ' ').trim() + + 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) { + 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) { + 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-redeem.js b/apps/backend/src/services/session-redeem.js new file mode 100644 index 00000000..2aa8e178 --- /dev/null +++ b/apps/backend/src/services/session-redeem.js @@ -0,0 +1,510 @@ +import fs from 'node:fs/promises' + +export async function runTencentBrowserRedeem({ + session, + code, + maxAttempts, + ensureLoggedInPresentation, + ensureActivityInfoReady, + fillRedeemCodeInBrowser, + capturePageCaptchaForOcr, + recognizeTencentCaptcha, + submitRedeemInBrowser, + isCaptchaRejectedResult, + refreshPageCaptcha, + persistSessionState, + buildSessionPayload, + saveRedeemArtifacts, + activityUrl, +}) { + session.status = 'redeeming' + session.notice = '正在识别验证码并提交兑换' + session.updatedAt = new Date().toISOString() + await persistSessionState(session) + + const attempts = [] + let finalResult = 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 attemptRecord = { + attempt, + verifyCode, + verifysession: captcha.verifysession, + ocrSample: ocr?.data?.saved || null, + redeem, + 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 = String(finalResult.redeem?.sMsg || '兑换完成') + 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, { sessionDir, attempt, ensureActivityInfoReady }) { + 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, payload, { ensureActivityInfoReady }) { + 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, code, { 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, code, { ensureActivityInfoReady }) { + const activityInfo = await ensureActivityInfoReady(page, { triggerRender: true, timeoutMs: 2_000 }) + await fillRedeemCodeInBrowser(page, code, { activityInfo }) +} + +export async function refreshPageCaptcha(page, verifyImgId) { + 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) { + const retCode = Number(result?.iRet) + + if (Number.isFinite(retCode) && retCode === -100) { + return true + } + + const text = [ + String(result?.sMsg || ''), + String(result?.msg || ''), + String(result?.popup?.text || ''), + String(result?.popup?.detail || ''), + ] + .join(' ') + .trim() + + return /验证码|校验码/.test(text) +} + +function resetRedeemPopup(page) { + 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) { + 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) { + 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, submitSelector) { + 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, selector, value) { + const nextValue = String(value ?? '') + + const appliedValue = await page.evaluate( + ({ targetSelector, targetValue }) => { + const isVisible = (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) { + 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) { + try { + return JSON.parse(text) + } catch { + return null + } +} + +function inferRedeemCodeFromPopup(popup) { + 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 +} diff --git a/apps/backend/src/services/session-wx.js b/apps/backend/src/services/session-wx.js new file mode 100644 index 00000000..5a681b3d --- /dev/null +++ b/apps/backend/src/services/session-wx.js @@ -0,0 +1,326 @@ +import fs from 'node:fs/promises' + +import { downloadRemoteQrImage, logBrowserSessionDebug, waitForFrame } from './session-login-shared.js' + +export async function ensureWxLoginReady(page) { + 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, qrImagePath) { + 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: Boolean(downloaded?.length), + qrImagePath, + }) + + if (downloaded?.length) { + await fs.writeFile(qrImagePath, downloaded) + return + } + } + + logBrowserSessionDebug('wx.capture.fallbackScreenshot', { + qrImagePath, + }) + await qrLocator.screenshot({ path: qrImagePath }) +} + +export async function findWxQrLocator(page) { + 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) { + 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 qrVisible = await frame + .locator('.js_qrcode_img') + .first() + .isVisible() + .catch(() => false) + const bodyText = String(await frame.locator('body').textContent()).replace(/\s+/g, ' ').trim() + + 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) { + return frame + .evaluate(() => { + const isVisible = (element) => { + 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) { + 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) { + return frame + .evaluate(() => { + const isVisible = (element) => { + 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.js b/apps/backend/src/services/session.js new file mode 100644 index 00000000..9af98ef9 --- /dev/null +++ b/apps/backend/src/services/session.js @@ -0,0 +1,1051 @@ +import crypto from 'node:crypto' +import fs from 'node:fs/promises' +import path from 'node:path' +import process from 'node:process' +import { fileURLToPath } from 'node:url' + +import { chromium } from 'playwright' + +import { runtimeConfig } from '../config/runtime.js' +import { + isRetryableQrCaptureError, + logBrowserSessionDebug, +} from './session-login-shared.js' +import { + captureQqQrImage, + ensureQqLoginReady, + extractQqQrState, +} from './session-qq.js' +import { + saveRedeemArtifacts, +} from './session-proof.js' +import { + capturePageCaptchaForOcr, + isCaptchaRejectedResult, + prepareRedeemCodeFill, + refreshPageCaptcha, + runTencentBrowserRedeem, + submitRedeemInBrowser, +} from './session-redeem.js' +import { + captureWxQrImage, + ensureWxLoginReady, + extractWxQrState, +} from './session-wx.js' +import { recognizeTencentCaptcha } from './ocr.js' + +const CURRENT_DIR = path.dirname(fileURLToPath(import.meta.url)) +const PROJECT_ROOT = path.resolve(CURRENT_DIR, '../..') +const DATA_ROOT = path.resolve(PROJECT_ROOT, 'data/browser-sessions') +const ACTIVITY_URL = 'https://df.qq.com/cp/a20240812cdk/index.html' +const CHROME_PATH = String(runtimeConfig.browser.chromePath || '').trim() +const DEFAULT_LOGIN_TYPE = 'qq' +const SESSION_TTL_MS = 30 * 60 * 1000 +const DEFAULT_VIEWPORT = { width: 1440, height: 1280 } +const SESSION_DEBUG_ENABLED = Boolean(runtimeConfig.session.debug) + +const sessions = new Map() + +let browserPromise = null + +class SessionServiceError extends Error { + constructor(message, { statusCode = 500, errorCode = 'session_error' } = {}) { + 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 createTencentBrowserSession(payload = {}) { + await cleanupExpiredTencentBrowserSessions() + + const loginType = normalizeLoginType(payload.loginType || payload.qrType) + + const browser = await ensureBrowser() + const sessionId = createSessionId() + const sessionDir = path.join(DATA_ROOT, sessionId) + await fs.mkdir(sessionDir, { recursive: true }) + + const browserContext = await browser.newContext({ + viewport: DEFAULT_VIEWPORT, + deviceScaleFactor: 1, + }) + + const page = await browserContext.newPage() + page.setDefaultTimeout(20_000) + + const session = { + sessionId, + loginType, + status: 'created', + notice: '浏览器会话已创建', + createdAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + expiresAt: Date.now() + SESSION_TTL_MS, + sessionDir, + browserContext, + page, + qrImageBase64: '', + qrImagePath: '', + qrUpdatedAt: '', + lastState: null, + lastRedeem: null, + lastError: '', + presentationSyncAttempts: 0, + } + + sessions.set(sessionId, session) + + 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, options = {}) { + return refreshTencentBrowserSession(sessionId, options) +} + +export async function getTencentBrowserSessionSummary(sessionId) { + return refreshTencentBrowserSession(sessionId, { includeQrImage: false }) +} + +export async function reloadTencentBrowserSession(sessionId) { + await cleanupExpiredTencentBrowserSessions() + + const session = await getRequiredSession(sessionId) + + if (session.browserContextClosed) { + throw new SessionServiceError('浏览器会话已关闭', { + statusCode: 410, + errorCode: 'session_closed', + }) + } + + try { + await reloadActivityPageForPresentation(session.page) + + const cookieMap = await loadCookieMapFromContext(session.browserContext) + const credentialReady = hasRedeemCredential(cookieMap) + + if (!credentialReady) { + session.qrImageBase64 = '' + session.qrImagePath = '' + session.qrUpdatedAt = '' + await ensureLoginTab(session.page, session.loginType) + } + + 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, { includeQrImage = true } = {}) { + await cleanupExpiredTencentBrowserSessions() + + 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 }) + 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, + }) + session.updatedAt = new Date().toISOString() + session.expiresAt = Date.now() + SESSION_TTL_MS + + if (!session.qrImageBase64 || session.status === 'expired') { + 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, payload = {}) { + 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', + }) + } + + return runTencentBrowserRedeem({ + session, + code, + maxAttempts, + ensureLoggedInPresentation, + 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, + }) +} + +export async function closeTencentBrowserSession(sessionId) { + const session = await getRequiredSession(sessionId) + await closeSession(session, { markClosed: true }) + return { + sessionId, + closed: true, + } +} + +export async function closeAllTencentBrowserSessions({ markClosed = true } = {}) { + const activeSessions = [...sessions.values()] + + await Promise.all(activeSessions.map((session) => closeSession(session, { markClosed }))) + + return { + closedCount: activeSessions.length, + } +} + +export async function getTencentBrowserSessionScreenshotPath(sessionId) { + const session = await getRequiredSession(sessionId) + const screenshotPath = session.lastRedeem?.screenshotPath + + if (!screenshotPath) { + throw new SessionServiceError('当前会话还没有兑换截图', { + statusCode: 404, + errorCode: 'screenshot_not_ready', + }) + } + + return screenshotPath +} + +async function ensureBrowser() { + if (!browserPromise) { + const launchOptions = resolveBrowserLaunchOptions() + browserPromise = chromium.launch(buildChromiumLaunchOptions(launchOptions)) + .then((browser) => { + browser.on('disconnected', () => { + browserPromise = null + }) + return browser + }) + .catch((error) => { + browserPromise = null + throw error + }) + } + + return browserPromise +} + +function resolveBrowserLaunchOptions() { + const explicitHeadless = runtimeConfig.browser.headless + const explicitDevtools = runtimeConfig.browser.devtools + const explicitKeepAlive = runtimeConfig.browser.keepAlive + const explicitPrewarm = runtimeConfig.browser.prewarm + const explicitSlowMo = runtimeConfig.browser.slowMoMs + const isProductionLike = process.env.NODE_ENV === 'production' || process.env.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: !CHROME_PATH, + keepAlive, + prewarm, + slowMoMs, + } +} + +function buildChromiumLaunchOptions(launchOptions) { + const options = { + headless: launchOptions.headless, + devtools: launchOptions.devtools, + slowMo: launchOptions.slowMoMs, + } + + if (CHROME_PATH) { + return { + ...options, + executablePath: CHROME_PATH, + } + } + + return options +} + +function createSessionId() { + let sessionId = '' + + do { + sessionId = `txbs-${crypto.randomBytes(6).toString('hex')}` + } while (sessions.has(sessionId)) + + return sessionId +} + +function normalizeLoginType(value) { + const normalized = String(value || DEFAULT_LOGIN_TYPE).trim().toLowerCase() + if (['qq', 'qc'].includes(normalized)) { + return 'qq' + } + if (['wx', 'vx', 'wechat', 'weixin'].includes(normalized)) { + return 'wx' + } + return DEFAULT_LOGIN_TYPE +} + +function getLoginTypeLabel(loginType) { + return loginType === 'wx' ? '微信' : 'QQ' +} + +async function getRequiredSession(sessionId) { + 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 +} + +async function captureSessionQr(session, { ensureLoginReady = true } = {}) { + if (ensureLoginReady) { + await ensureLoginTab(session.page, session.loginType) + } + + const qrImagePath = path.join(session.sessionDir, `${session.loginType}-qr.png`) + await captureQrImageWithRetry(session.page, session.loginType, qrImagePath) + + session.qrImagePath = qrImagePath + session.qrImageBase64 = await fs.readFile(qrImagePath, 'base64') + session.qrUpdatedAt = new Date().toISOString() + session.updatedAt = new Date().toISOString() +} + +async function captureQrImageWithRetry(page, loginType, qrImagePath) { + const maxAttempts = loginType === 'qq' ? 4 : 3 + let lastError = null + + for (let attempt = 1; attempt <= maxAttempts; attempt += 1) { + try { + logBrowserSessionDebug('captureQrImageWithRetry.start', { + loginType, + attempt, + maxAttempts, + qrImagePath, + }) + + if (loginType === 'qq') { + await captureQqQrImage(page, qrImagePath) + } else { + await captureWxQrImage(page, qrImagePath) + } + + logBrowserSessionDebug('captureQrImageWithRetry.success', { + loginType, + attempt, + qrImagePath, + }) + + return + } catch (error) { + lastError = error + logBrowserSessionDebug('captureQrImageWithRetry.error', { + loginType, + attempt, + message: error instanceof Error ? error.message : String(error), + }) + + if (!isRetryableQrCaptureError(error) || attempt === maxAttempts) { + throw error + } + + await page.waitForTimeout(500) + await ensureLoginTab(page, loginType) + } + } + + if (lastError) { + throw lastError + } + + throw new Error('二维码截图失败') +} + +async function ensureLoginTab(page, loginType) { + 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 ensureQqLoginReady(page) + return + } + + if (loginType === 'wx') { + await ensureWxLoginReady(page) + } +} + +function applyInitialQrSessionState(session) { + session.status = 'waiting_scan' + session.notice = `请使用${getLoginTypeLabel(session.loginType)}扫码登录` + session.updatedAt = new Date().toISOString() + session.expiresAt = Date.now() + SESSION_TTL_MS +} + +async function extractHostState(page) { + return page.evaluate(() => { + const isVisible = (element) => { + 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(), + } + }) +} + +async function ensureLoggedInPresentation( + session, + { hostState = null, credentialReady = false } = {}, +) { + let nextHostState = hostState || (await extractHostState(session.page)) + + if (!credentialReady || !shouldRefreshLoggedInPresentation(nextHostState)) { + return { + reloaded: false, + hostState: nextHostState, + } + } + + if (session.presentationSyncAttempts >= 2) { + return { + reloaded: false, + hostState: nextHostState, + } + } + + session.presentationSyncAttempts += 1 + await reloadActivityPageForPresentation(session.page) + nextHostState = await extractHostState(session.page) + + return { + reloaded: true, + hostState: nextHostState, + } +} + +function shouldRefreshLoggedInPresentation(hostState) { + const loginText = String(hostState?.loginText || '').trim() + + return Boolean(hostState?.unloginVisible || !hostState?.loginedVisible || !loginText) +} + +async function reloadActivityPageForPresentation(page) { + try { + await page.reload({ waitUntil: 'domcontentloaded' }) + } catch { + await page.goto(ACTIVITY_URL, { waitUntil: 'domcontentloaded' }) + } + + await page.waitForTimeout(2_500) +} + +async function extractQrState(page, loginType) { + if (loginType === 'qq') { + return extractQqQrState(page) + } + + return extractWxQrState(page) +} + +async function ensureActivityInfoReady(page, { triggerRender = false, timeoutMs = 8_000 } = {}) { + if (triggerRender) { + await triggerActivityRoleRender(page) + } + + const startedAt = Date.now() + let lastInfo = await extractActivityInfo(page) + + while (Date.now() - startedAt < timeoutMs) { + if (lastInfo?.role?.ready) { + return lastInfo + } + + await page.waitForTimeout(400) + lastInfo = await extractActivityInfo(page) + } + + return lastInfo +} + +async function triggerActivityRoleRender(page) { + try { + await page.evaluate(() => { + const maybeRender = window.renderCdkey + + if (typeof maybeRender === 'function') { + maybeRender() + } + }) + } catch { + // ignore render trigger failures; polling will still inspect the page + } +} + +async function extractActivityInfo(page) { + try { + return await page.evaluate(() => { + const asString = (value) => (typeof value === 'string' ? value.trim() : '') + const isVisible = (element) => { + if (!element) { + return false + } + + const style = window.getComputedStyle(element) + return style.display !== 'none' && style.visibility !== 'hidden' && style.opacity !== '0' + } + + const pick = (selector) => document.querySelector(selector) + const pickBest = (selector, predicate = 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?.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: Number(verifyImg?.naturalWidth || 0), + naturalHeight: Number(verifyImg?.naturalHeight || 0), + }, + popup: { + visible: isVisible(popup), + text: textOf('#PopText'), + detail: textOf('#PopText2'), + }, + } + }) + } catch { + 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: '', + }, + } + } +} + +function resolveSessionStatus({ hostState, qrState, credentialReady, activityInfo }) { + 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' +} + +function resolveSessionNotice({ hostState, qrState, credentialReady, activityInfo, loginType }) { + 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}扫码登录` +} + +async function cleanupExpiredTencentBrowserSessions() { + const expired = [...sessions.values()].filter((session) => session.expiresAt <= Date.now()) + + await Promise.all(expired.map((session) => closeSession(session, { markClosed: false }))) +} + +async function closeSession(session, { markClosed }) { + 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() +} + +async function persistSessionState(session) { + await fs.mkdir(session.sessionDir, { recursive: true }) + await fs.writeFile( + path.join(session.sessionDir, 'session.json'), + JSON.stringify(buildSessionPayload(session), null, 2), + 'utf8', + ) +} + +function buildSessionPayload(session, { includeQrImage = true } = {}) { + const activityInfo = session.lastState?.activityInfo || null + const payload = { + sessionId: session.sessionId, + loginType: session.loginType, + activityUrl: ACTIVITY_URL, + 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, + redeem: buildRedeemPayload(session.lastRedeem), + artifacts: buildArtifactsPayload(session), + } + + if (includeQrImage) { + payload.qrImageBase64 = session.qrImageBase64 + } + + if (!SESSION_DEBUG_ENABLED) { + return payload + } + + return { + ...payload, + cookieKeys: Array.isArray(session.lastState?.cookieKeys) ? session.lastState.cookieKeys : [], + state: session.lastState, + browserDebug: resolveBrowserLaunchOptions(), + qrImagePath: session.qrImagePath, + redeem: buildRedeemPayload(session.lastRedeem, { includeInternalPaths: true }), + artifacts: buildArtifactsPayload(session, { includeInternalPaths: true }), + } +} + +function buildRedeemPayload(redeem, { includeInternalPaths = false } = {}) { + 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 || '', + } +} + +function buildArtifactsPayload(session, { includeInternalPaths = false } = {}) { + const payload = { + hasQrImage: Boolean(session.qrImageBase64), + 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 || '', + } +} + +async function loadCookieMapFromContext(browserContext) { + const cookies = await browserContext.cookies([ + 'https://df.qq.com', + 'https://ssl.captcha.qq.com', + 'https://graph.qq.com', + 'https://milo.qq.com', + ]) + + const cookieMap = {} + + 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 +} + +function hasRedeemCredential(cookieMap) { + return Boolean(cookieMap.openid && cookieMap.access_token && cookieMap.appid) +} + +async function fillRedeemCodeInBrowser(page, code) { + await prepareRedeemCodeFill(page, code, { ensureActivityInfoReady }) +} + +async function closeBrowserIfIdle() { + if (sessions.size > 0 || !browserPromise) { + return + } + + if (resolveBrowserLaunchOptions().keepAlive) { + return + } + + try { + const browser = await browserPromise + await browser.close() + } catch { + // ignore browser close failures + } finally { + browserPromise = null + } +} diff --git a/apps/backend/src/services/webhook-service.js b/apps/backend/src/services/webhook-service.js new file mode 100644 index 00000000..3ca90eb1 --- /dev/null +++ b/apps/backend/src/services/webhook-service.js @@ -0,0 +1,359 @@ +import crypto from 'node:crypto' + +import { runtimeConfig } from '../config/runtime.js' +import { createWebhookEvent, updateWebhookEvent } from '../repositories/webhook-event-repo.js' +import { upsertOrderFromWebhook } from './order-service.js' +import { createHttpError } from '../utils/http.js' +import { nowIso } from '../utils/time.js' + +export async function processAgisoTradeWebhook(requestLike) { + const parsed = parseAgisoTradeRequest(requestLike) + const webhookEvent = createWebhookEvent(buildWebhookEventInput(requestLike, parsed)) + + return executeAgisoTradeWebhook(parsed, webhookEvent.id) +} + +export async function replayAgisoTradeWebhookEvent(webhookEvent) { + const requestLike = { + headers: safeParseJson(webhookEvent.headers_json), + query: safeParseJson(webhookEvent.query_json), + body: safeParseJson(webhookEvent.body_json), + } + const parsed = parseAgisoTradeRequest(requestLike) + + return executeAgisoTradeWebhook(parsed, webhookEvent.id) +} + +function buildWebhookEventInput(requestLike, parsed) { + return { + platform: 'agiso', + 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) { + try { + if (!parsed.signatureValid) { + throw createHttpError('验签失败', { + statusCode: 400, + errorCode: 'invalid_signature', + }) + } + + if (!parsed.platformOrderId) { + throw createHttpError('缺少平台订单号', { + statusCode: 400, + errorCode: 'missing_platform_order_id', + }) + } + + const result = await upsertOrderFromWebhook(parsed) + updateWebhookEvent(webhookEventId, { + processed: true, + process_error: '', + related_order_id: result.order.id, + }) + + return { + accepted: true, + eventType: parsed.eventType, + platformOrderId: parsed.platformOrderId, + orderId: result.order.id, + 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) { + updateWebhookEvent(webhookEventId, { + processed: false, + process_error: error instanceof Error ? error.message : String(error || ''), + related_order_id: null, + }) + throw error + } +} + +function parseAgisoTradeRequest(requestLike) { + const query = normalizeRecord(requestLike.query) + const body = normalizeRecord(requestLike.body) + const rawJson = String(body.json || '').trim() + const payload = rawJson ? parsePayloadJson(rawJson) : body + const timestamp = String(query.timestamp || '').trim() + const sign = String(query.sign || '').trim().toLowerCase() + const eventType = resolveEventType(query.aopic) + const signatureValid = verifyAgisoSignature({ rawJson, timestamp, sign }) + const platformOrderId = pickFirstNonEmpty([ + payload.biz_order_id, + payload.Tid, + payload.tid, + payload.order_id, + payload.orderId, + ]) + + return { + platform: 'agiso', + eventType, + eventKey: `${platformOrderId || 'unknown'}:${eventType}:${timestamp || 'na'}`, + signatureValid, + platformOrderId, + orderStatus: resolveOrderStatus(eventType, payload), + payStatus: resolvePayStatus(eventType, payload), + buyerId: pickFirstNonEmpty([payload.buyer_id, payload.buyerId, payload.openid]), + buyerName: pickFirstNonEmpty([payload.buyer_name, payload.buyerName, payload.nick]), + receiverContact: pickFirstNonEmpty([ + payload.receiver_contact, + payload.receiverContact, + payload.mobile, + payload.phone, + ]), + totalAmount: normalizeInteger( + pickFirstNonEmpty([payload.total_fee, payload.totalFee, payload.pay_fee, payload.payFee]), + ), + currency: pickFirstNonEmpty([payload.currency, 'CNY']) || 'CNY', + paidAt: resolvePaidAt(eventType, payload), + rawPayload: payload, + items: normalizeOrderItems(payload), + } +} + +function verifyAgisoSignature({ rawJson, timestamp, sign }) { + const appSecret = String(runtimeConfig.platforms.agiso.appSecret || '').trim() + + if (!appSecret) { + return true + } + + if (!rawJson || !timestamp || !sign) { + return false + } + + const documentedDigest = crypto + .createHash('md5') + .update(`${appSecret}json${rawJson}timestamp${timestamp}${appSecret}`, 'utf8') + .digest('hex') + .toLowerCase() + + if (documentedDigest === sign) { + return true + } + + const legacyDigest = crypto + .createHash('md5') + .update(`${appSecret}${rawJson}${timestamp}`, 'utf8') + .digest('hex') + .toLowerCase() + + return legacyDigest === sign +} + +function resolveEventType(aopic) { + const normalized = String(aopic || '').trim() + + if (normalized === '1') { + return 'payment_success' + } + + if (normalized === '32') { + return 'trade_create' + } + + if (normalized === '256') { + return 'buyer_confirm_goods' + } + + if (normalized === '128') { + return 'trade_closed' + } + + return 'trade_event' +} + +function resolveOrderStatus(eventType, payload) { + 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) { + 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) { + 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 {} + } + + try { + const parsed = JSON.parse(rawJson) + return isPlainObject(parsed) ? parsed : {} + } catch { + throw createHttpError('json 参数不是合法 JSON', { + statusCode: 400, + errorCode: 'invalid_json_payload', + }) + } +} + +function normalizeOrderItems(payload) { + const items = Array.isArray(payload.items) && payload.items.length > 0 + ? payload.items + : [payload] + + return items.map((item) => { + const source = isPlainObject(item) ? item : {} + const rawSkuKey = pickFirstNonEmpty([ + source.sku_code, + source.skuCode, + source.goods_sku, + source.item_id, + source.itemId, + source.goods_id, + source.goodsId, + source.num_iid, + ]) + const skuCode = resolveSkuCode(rawSkuKey) + + return { + skuCode, + skuName: pickFirstNonEmpty([ + source.sku_name, + source.skuName, + source.goods_name, + source.goodsName, + skuCode, + ]), + quantity: Math.max( + 1, + normalizeInteger(pickFirstNonEmpty([source.quantity, source.num, source.buy_amount])) || 1, + ), + spec: source, + } + }) +} + +function resolveSkuCode(rawKey) { + const mappings = runtimeConfig.orders.skuMappings + + if (rawKey && mappings && typeof mappings === 'object') { + const direct = mappings[String(rawKey)] + if (typeof direct === 'string' && direct.trim()) { + return direct.trim() + } + } + + if (typeof rawKey === 'string' && rawKey.trim()) { + return rawKey.trim() + } + + return '' +} + +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) { + 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/utils/http.js b/apps/backend/src/utils/http.js new file mode 100644 index 00000000..2c7d683e --- /dev/null +++ b/apps/backend/src/utils/http.js @@ -0,0 +1,161 @@ +export function buildSuccessPayload(data, msg = 'ok') { + return { + code: 0, + msg, + time: Math.floor(Date.now() / 1000), + data: normalizeResponseDateTime(data), + } +} + +export function buildErrorPayload(error, fallbackMessage) { + const classification = classifyRouteError(error) + + return { + code: 1, + msg: error instanceof Error ? error.message : fallbackMessage, + errorCode: classification.errorCode, + time: Math.floor(Date.now() / 1000), + data: null, + } +} + +export function sendRouteError(res, error, fallbackMessage, scope) { + const classification = classifyRouteError(error) + const logger = classification.statusCode >= 500 ? console.error : console.warn + logger(`${scope} ${fallbackMessage}:`, error) + res.status(classification.statusCode).json(buildErrorPayload(error, fallbackMessage)) +} + +export function buildNotFoundPayload(req) { + return { + code: 1, + msg: `未实现接口: ${req.method} ${req.originalUrl}`, + time: Math.floor(Date.now() / 1000), + data: null, + } +} + +export function createHttpError(message, { statusCode = 500, errorCode = '' } = {}) { + const error = new Error(message) + error.statusCode = statusCode + error.errorCode = errorCode || defaultErrorCodeForStatus(statusCode) + return error +} + +function classifyRouteError(error) { + if (error && typeof error === 'object') { + const statusCode = Number(error.statusCode) + const errorCode = String(error.errorCode || '').trim() + + if (Number.isInteger(statusCode) && statusCode >= 400 && statusCode < 600) { + return { + statusCode, + errorCode: errorCode || defaultErrorCodeForStatus(statusCode), + } + } + } + + const message = error instanceof Error ? error.message : String(error || '') + + if (message.includes('缺少') || message.includes('无效') || message.includes('验签失败')) { + return { statusCode: 400, errorCode: 'invalid_request' } + } + + if (message.includes('不存在')) { + return { statusCode: 404, errorCode: 'not_found' } + } + + if (message.includes('已关闭')) { + return { statusCode: 410, errorCode: 'gone' } + } + + if (message.includes('不能') || message.includes('冲突')) { + return { statusCode: 409, errorCode: 'conflict' } + } + + if ( + message.includes('OCR') || + message.includes('uv sync') || + message.includes('spawn uv ENOENT') || + message.includes("Executable doesn't exist") || + message.includes('please run the following command to download new browsers') + ) { + return { statusCode: 503, errorCode: 'dependency_unavailable' } + } + + return { statusCode: 500, errorCode: 'internal_error' } +} + +function defaultErrorCodeForStatus(statusCode) { + if (statusCode === 400) { + return 'invalid_request' + } + + if (statusCode === 401) { + return 'unauthorized' + } + + if (statusCode === 403) { + return 'forbidden' + } + + if (statusCode === 404) { + return 'not_found' + } + + if (statusCode === 409) { + return 'conflict' + } + + if (statusCode === 410) { + return 'gone' + } + + if (statusCode === 503) { + return 'dependency_unavailable' + } + + return 'internal_error' +} + +function normalizeResponseDateTime(value) { + if (Array.isArray(value)) { + return value.map((item) => normalizeResponseDateTime(item)) + } + + if (value && typeof value === 'object') { + return Object.fromEntries( + Object.entries(value).map(([key, currentValue]) => [key, normalizeResponseDateTime(currentValue)]), + ) + } + + if (isIsoDateTimeString(value)) { + return formatResponseDateTime(value) + } + + return value +} + +function isIsoDateTimeString(value) { + return ( + typeof value === 'string' && + /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d+)?(?:Z|[+-]\d{2}:\d{2})?$/.test(value.trim()) + ) +} + +function formatResponseDateTime(value) { + const date = new Date(value) + + if (Number.isNaN(date.getTime())) { + return String(value || '').replace('T', ' ').replace('Z', '') + } + + const year = date.getFullYear() + const month = String(date.getMonth() + 1).padStart(2, '0') + const day = String(date.getDate()).padStart(2, '0') + const hours = String(date.getHours()).padStart(2, '0') + const minutes = String(date.getMinutes()).padStart(2, '0') + const seconds = String(date.getSeconds()).padStart(2, '0') + + return `${year}-${month}-${day} ${hours}:${minutes}:${seconds}` +} diff --git a/apps/backend/src/utils/random.js b/apps/backend/src/utils/random.js new file mode 100644 index 00000000..cd42557d --- /dev/null +++ b/apps/backend/src/utils/random.js @@ -0,0 +1,9 @@ +import crypto from 'node:crypto' + +export function randomToken(bytes = 24) { + return crypto.randomBytes(bytes).toString('hex') +} + +export function randomId(prefix, bytes = 6) { + return `${prefix}${crypto.randomBytes(bytes).toString('hex')}` +} diff --git a/apps/backend/src/utils/time.js b/apps/backend/src/utils/time.js new file mode 100644 index 00000000..f1cd944e --- /dev/null +++ b/apps/backend/src/utils/time.js @@ -0,0 +1,8 @@ +export function nowIso() { + return new Date().toISOString() +} + +export function addHours(baseIso, hours) { + const baseTime = baseIso ? new Date(baseIso).getTime() : Date.now() + return new Date(baseTime + hours * 60 * 60 * 1000).toISOString() +} diff --git a/apps/backend/subservices/ocr-worker/README.md b/apps/backend/subservices/ocr-worker/README.md new file mode 100644 index 00000000..c66db59f --- /dev/null +++ b/apps/backend/subservices/ocr-worker/README.md @@ -0,0 +1,16 @@ +# OCR Worker + +内嵌在 `order-site-backend` 仓库里的本地 OCR worker。 + +只保留最核心的能力: + +- 加载 `ddddocr` +- 从标准输入接收 JSON 行 +- 返回识别结果 JSON 行 + +初始化依赖: + +```bash +cd /Users/yml/codes/order-site-backend/subservices/ocr-worker +uv sync +``` diff --git a/apps/backend/subservices/ocr-worker/pyproject.toml b/apps/backend/subservices/ocr-worker/pyproject.toml new file mode 100644 index 00000000..f02808d0 --- /dev/null +++ b/apps/backend/subservices/ocr-worker/pyproject.toml @@ -0,0 +1,15 @@ +[project] +name = "ocr-worker" +version = "0.1.0" +readme = "README.md" +requires-python = ">=3.11" +dependencies = [ + "ddddocr>=1.6.1", +] + +[project.scripts] +ocr-worker = "ocr_worker.cli:main" + +[build-system] +requires = ["uv_build>=0.9.9,<0.10.0"] +build-backend = "uv_build" diff --git a/apps/backend/subservices/ocr-worker/src/ocr_worker/__init__.py b/apps/backend/subservices/ocr-worker/src/ocr_worker/__init__.py new file mode 100644 index 00000000..28b3e0bf --- /dev/null +++ b/apps/backend/subservices/ocr-worker/src/ocr_worker/__init__.py @@ -0,0 +1,4 @@ +__all__ = [ + "get_ocr_engine", + "recognize_image", +] diff --git a/apps/backend/subservices/ocr-worker/src/ocr_worker/cli.py b/apps/backend/subservices/ocr-worker/src/ocr_worker/cli.py new file mode 100644 index 00000000..0fd93e59 --- /dev/null +++ b/apps/backend/subservices/ocr-worker/src/ocr_worker/cli.py @@ -0,0 +1,179 @@ +from __future__ import annotations + +import argparse +import base64 +import json +import sys +import time +from datetime import datetime, timezone +from typing import Any + +from .engine import get_ocr_engine, recognize_image + + +def main() -> None: + parser = argparse.ArgumentParser(prog="ocr-worker", description="本地 OCR worker") + parser.add_argument("command", nargs="?", default="worker", choices=["worker"]) + args = parser.parse_args() + + if args.command == "worker": + run_worker() + + +def run_worker() -> None: + get_ocr_engine() + write_message({"type": "ready"}) + + for raw_line in sys.stdin: + line = raw_line.strip() + + if not line: + continue + + request_id = "" + + try: + request = json.loads(line) + request_id = str(request.get("id") or "") + action = str(request.get("action") or "recognize").strip().lower() + payload = request.get("payload") or {} + + if action == "recognize": + response_payload = build_success_payload(recognize_payload(payload), "ok") + elif action == "batch": + response_payload = build_success_payload(batch_recognize_payload(payload), "ok") + else: + raise ValueError(f"不支持的 action: {action}") + except Exception as error: # noqa: BLE001 + response_payload = build_error_payload(str(error) or "请求失败") + + write_message( + { + "type": "response", + "id": request_id, + "payload": response_payload, + } + ) + + +def recognize_payload(payload: Any) -> dict[str, Any]: + if not isinstance(payload, dict): + raise ValueError("payload 必须是对象") + + image_bytes = decode_image_base64(payload.get("imageBase64")) + expected_text = normalize_text(payload.get("expectedText"), preserve_empty=True) + started = time.perf_counter() + recognized_text = normalize_text(recognize_image(image_bytes), preserve_empty=True) + duration_ms = round((time.perf_counter() - started) * 1000, 2) + + return { + "sampleId": None, + "imageUrl": normalize_optional_string(payload.get("imageUrl")), + "finalUrl": normalize_optional_string(payload.get("finalUrl")) or normalize_optional_string(payload.get("imageUrl")), + "recognizedText": recognized_text, + "text": recognized_text, + "expectedText": expected_text, + "matched": None if expected_text is None else recognized_text == expected_text, + "createdAt": build_timestamp(), + "durationMs": duration_ms, + "tag": normalize_optional_string(payload.get("tag")), + "contentType": normalize_optional_string(payload.get("imageContentType")) or "application/octet-stream", + "httpStatus": 200, + "saved": None, + } + + +def batch_recognize_payload(payload: Any) -> dict[str, Any]: + if not isinstance(payload, dict): + raise ValueError("payload 必须是对象") + + items = payload.get("items") + + if isinstance(items, list) and items: + results = [recognize_payload(item) for item in items] + else: + repeat = max(1, min(int(payload.get("repeat") or 1), 200)) + results = [recognize_payload(payload) for _ in range(repeat)] + + reviewed = [item for item in results if item.get("expectedText") is not None] + matched = sum(1 for item in reviewed if item.get("matched") is True) + unmatched = sum(1 for item in reviewed if item.get("matched") is False) + pending_review = len(results) - len(reviewed) + + return { + "runId": None, + "runName": normalize_optional_string(payload.get("runName")), + "total": len(results), + "reviewed": len(reviewed), + "matched": matched, + "unmatched": unmatched, + "pendingReview": pending_review, + "accuracy": round(matched / len(reviewed), 4) if reviewed else None, + "paths": None, + "items": results, + } + + +def decode_image_base64(value: Any) -> bytes: + if value is None: + raise ValueError("imageBase64 不能为空") + + try: + text = str(value).strip() + if not text: + raise ValueError("imageBase64 不能为空") + return base64.b64decode(text, validate=True) + except ValueError: + raise + except Exception as error: # noqa: BLE001 + raise ValueError("imageBase64 不是合法的 base64 图片内容") from error + + +def normalize_optional_string(value: Any) -> str | None: + if value is None: + return None + + text = str(value).strip() + return text or None + + +def normalize_text(value: Any, *, preserve_empty: bool) -> str | None: + if value is None: + return None if preserve_empty else "" + + text = "".join(str(value).split()).upper() + + if text: + return text + + if preserve_empty: + return "" + + raise ValueError("OCR 结果为空") + + +def build_success_payload(data: Any, msg: str) -> dict[str, Any]: + return { + "code": 0, + "msg": msg, + "time": int(time.time()), + "data": data, + } + + +def build_error_payload(message: str) -> dict[str, Any]: + return { + "code": 1, + "msg": message or "请求失败", + "time": int(time.time()), + "data": None, + } + + +def build_timestamp() -> str: + return datetime.now(timezone.utc).isoformat() + + +def write_message(payload: dict[str, Any]) -> None: + sys.stdout.write(json.dumps(payload, ensure_ascii=False) + "\n") + sys.stdout.flush() diff --git a/apps/backend/subservices/ocr-worker/src/ocr_worker/engine.py b/apps/backend/subservices/ocr-worker/src/ocr_worker/engine.py new file mode 100644 index 00000000..a160d548 --- /dev/null +++ b/apps/backend/subservices/ocr-worker/src/ocr_worker/engine.py @@ -0,0 +1,27 @@ +from __future__ import annotations + +import threading + +import ddddocr + +_ENGINE_LOCK = threading.Lock() +_ENGINE: ddddocr.DdddOcr | None = None + + +def get_ocr_engine() -> ddddocr.DdddOcr: + global _ENGINE + + if _ENGINE is None: + with _ENGINE_LOCK: + if _ENGINE is None: + _ENGINE = ddddocr.DdddOcr(show_ad=False) + + return _ENGINE + + +def recognize_image(image_bytes: bytes) -> str: + if not image_bytes: + raise ValueError("图片内容为空,无法识别") + + text = get_ocr_engine().classification(image_bytes) + return str(text or "").strip() diff --git a/apps/backend/subservices/ocr-worker/uv.lock b/apps/backend/subservices/ocr-worker/uv.lock new file mode 100644 index 00000000..5ad24738 --- /dev/null +++ b/apps/backend/subservices/ocr-worker/uv.lock @@ -0,0 +1,315 @@ +version = 1 +revision = 3 +requires-python = ">=3.11" + +[[package]] +name = "ddddocr" +version = "1.6.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "numpy" }, + { name = "onnxruntime" }, + { name = "opencv-python", marker = "sys_platform == 'darwin' or sys_platform == 'win32'" }, + { name = "opencv-python-headless", marker = "sys_platform == 'linux'" }, + { name = "pillow" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/07/5f/7c06bbb594b77062e6d0d43f06dc88668aaeb699c8737c84542aaa39da8c/ddddocr-1.6.1.tar.gz", hash = "sha256:1c59d84d63d8703c6c486465a32389c9e41dd92852c794c5e4c0181a5f82d43a", size = 75943309, upload-time = "2026-03-11T10:48:41.681Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0e/48/cbaed3981b8d8d51141b9b4779b811f4728e65d952a1e3e2e5e929539183/ddddocr-1.6.1-py3-none-any.whl", hash = "sha256:c7c70f4ae2d0335440ae8b272eea48c9f6888ecef46785fe2311f0c97a133935", size = 75983593, upload-time = "2026-03-11T10:48:23.702Z" }, +] + +[[package]] +name = "flatbuffers" +version = "25.12.19" +source = { registry = "https://pypi.org/simple" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e8/2d/d2a548598be01649e2d46231d151a6c56d10b964d94043a335ae56ea2d92/flatbuffers-25.12.19-py2.py3-none-any.whl", hash = "sha256:7634f50c427838bb021c2d66a3d1168e9d199b0607e6329399f04846d42e20b4", size = 26661, upload-time = "2025-12-19T23:16:13.622Z" }, +] + +[[package]] +name = "mpmath" +version = "1.3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e0/47/dd32fa426cc72114383ac549964eecb20ecfd886d1e5ccf5340b55b02f57/mpmath-1.3.0.tar.gz", hash = "sha256:7a28eb2a9774d00c7bc92411c19a89209d5da7c4c9a9e227be8330a23a25b91f", size = 508106, upload-time = "2023-03-07T16:47:11.061Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/43/e3/7d92a15f894aa0c9c4b49b8ee9ac9850d6e63b03c9c32c0367a13ae62209/mpmath-1.3.0-py3-none-any.whl", hash = "sha256:a0b2b9fe80bbcd81a6647ff13108738cfb482d481d826cc0e02f5b35e5c88d2c", size = 536198, upload-time = "2023-03-07T16:47:09.197Z" }, +] + +[[package]] +name = "numpy" +version = "2.4.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d7/9f/b8cef5bffa569759033adda9481211426f12f53299629b410340795c2514/numpy-2.4.4.tar.gz", hash = "sha256:2d390634c5182175533585cc89f3608a4682ccb173cc9bb940b2881c8d6f8fa0", size = 20731587, upload-time = "2026-03-29T13:22:01.298Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ef/c6/4218570d8c8ecc9704b5157a3348e486e84ef4be0ed3e38218ab473c83d2/numpy-2.4.4-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:f983334aea213c99992053ede6168500e5f086ce74fbc4acc3f2b00f5762e9db", size = 16976799, upload-time = "2026-03-29T13:18:15.438Z" }, + { url = "https://files.pythonhosted.org/packages/dd/92/b4d922c4a5f5dab9ed44e6153908a5c665b71acf183a83b93b690996e39b/numpy-2.4.4-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:72944b19f2324114e9dc86a159787333b77874143efcf89a5167ef83cfee8af0", size = 14971552, upload-time = "2026-03-29T13:18:18.606Z" }, + { url = "https://files.pythonhosted.org/packages/8a/dc/df98c095978fa6ee7b9a9387d1d58cbb3d232d0e69ad169a4ce784bde4fd/numpy-2.4.4-cp311-cp311-macosx_14_0_arm64.whl", hash = "sha256:86b6f55f5a352b48d7fbfd2dbc3d5b780b2d79f4d3c121f33eb6efb22e9a2015", size = 5476566, upload-time = "2026-03-29T13:18:21.532Z" }, + { url = "https://files.pythonhosted.org/packages/28/34/b3fdcec6e725409223dd27356bdf5a3c2cc2282e428218ecc9cb7acc9763/numpy-2.4.4-cp311-cp311-macosx_14_0_x86_64.whl", hash = "sha256:ba1f4fc670ed79f876f70082eff4f9583c15fb9a4b89d6188412de4d18ae2f40", size = 6806482, upload-time = "2026-03-29T13:18:23.634Z" }, + { url = "https://files.pythonhosted.org/packages/68/62/63417c13aa35d57bee1337c67446761dc25ea6543130cf868eace6e8157b/numpy-2.4.4-cp311-cp311-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8a87ec22c87be071b6bdbd27920b129b94f2fc964358ce38f3822635a3e2e03d", size = 15973376, upload-time = "2026-03-29T13:18:26.677Z" }, + { url = "https://files.pythonhosted.org/packages/cf/c5/9fcb7e0e69cef59cf10c746b84f7d58b08bc66a6b7d459783c5a4f6101a6/numpy-2.4.4-cp311-cp311-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:df3775294accfdd75f32c74ae39fcba920c9a378a2fc18a12b6820aa8c1fb502", size = 16925137, upload-time = "2026-03-29T13:18:30.14Z" }, + { url = "https://files.pythonhosted.org/packages/7e/43/80020edacb3f84b9efdd1591120a4296462c23fd8db0dde1666f6ef66f13/numpy-2.4.4-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:0d4e437e295f18ec29bc79daf55e8a47a9113df44d66f702f02a293d93a2d6dd", size = 17329414, upload-time = "2026-03-29T13:18:33.733Z" }, + { url = "https://files.pythonhosted.org/packages/fd/06/af0658593b18a5f73532d377188b964f239eb0894e664a6c12f484472f97/numpy-2.4.4-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:6aa3236c78803afbcb255045fbef97a9e25a1f6c9888357d205ddc42f4d6eba5", size = 18658397, upload-time = "2026-03-29T13:18:37.511Z" }, + { url = "https://files.pythonhosted.org/packages/e6/ce/13a09ed65f5d0ce5c7dd0669250374c6e379910f97af2c08c57b0608eee4/numpy-2.4.4-cp311-cp311-win32.whl", hash = "sha256:30caa73029a225b2d40d9fae193e008e24b2026b7ee1a867b7ee8d96ca1a448e", size = 6239499, upload-time = "2026-03-29T13:18:40.372Z" }, + { url = "https://files.pythonhosted.org/packages/bd/63/05d193dbb4b5eec1eca73822d80da98b511f8328ad4ae3ca4caf0f4db91d/numpy-2.4.4-cp311-cp311-win_amd64.whl", hash = "sha256:6bbe4eb67390b0a0265a2c25458f6b90a409d5d069f1041e6aff1e27e3d9a79e", size = 12614257, upload-time = "2026-03-29T13:18:42.95Z" }, + { url = "https://files.pythonhosted.org/packages/87/c5/8168052f080c26fa984c413305012be54741c9d0d74abd7fbeeccae3889f/numpy-2.4.4-cp311-cp311-win_arm64.whl", hash = "sha256:fcfe2045fd2e8f3cb0ce9d4ba6dba6333b8fa05bb8a4939c908cd43322d14c7e", size = 10486775, upload-time = "2026-03-29T13:18:45.835Z" }, + { url = "https://files.pythonhosted.org/packages/28/05/32396bec30fb2263770ee910142f49c1476d08e8ad41abf8403806b520ce/numpy-2.4.4-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:15716cfef24d3a9762e3acdf87e27f58dc823d1348f765bbea6bef8c639bfa1b", size = 16689272, upload-time = "2026-03-29T13:18:49.223Z" }, + { url = "https://files.pythonhosted.org/packages/c5/f3/a983d28637bfcd763a9c7aafdb6d5c0ebf3d487d1e1459ffdb57e2f01117/numpy-2.4.4-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:23cbfd4c17357c81021f21540da84ee282b9c8fba38a03b7b9d09ba6b951421e", size = 14699573, upload-time = "2026-03-29T13:18:52.629Z" }, + { url = "https://files.pythonhosted.org/packages/9b/fd/e5ecca1e78c05106d98028114f5c00d3eddb41207686b2b7de3e477b0e22/numpy-2.4.4-cp312-cp312-macosx_14_0_arm64.whl", hash = "sha256:8b3b60bb7cba2c8c81837661c488637eee696f59a877788a396d33150c35d842", size = 5204782, upload-time = "2026-03-29T13:18:55.579Z" }, + { url = "https://files.pythonhosted.org/packages/de/2f/702a4594413c1a8632092beae8aba00f1d67947389369b3777aed783fdca/numpy-2.4.4-cp312-cp312-macosx_14_0_x86_64.whl", hash = "sha256:e4a010c27ff6f210ff4c6ef34394cd61470d01014439b192ec22552ee867f2a8", size = 6552038, upload-time = "2026-03-29T13:18:57.769Z" }, + { url = "https://files.pythonhosted.org/packages/7f/37/eed308a8f56cba4d1fdf467a4fc67ef4ff4bf1c888f5fc980481890104b1/numpy-2.4.4-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f9e75681b59ddaa5e659898085ae0eaea229d054f2ac0c7e563a62205a700121", size = 15670666, upload-time = "2026-03-29T13:19:00.341Z" }, + { url = "https://files.pythonhosted.org/packages/0a/0d/0e3ecece05b7a7e87ab9fb587855548da437a061326fff64a223b6dcb78a/numpy-2.4.4-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:81f4a14bee47aec54f883e0cad2d73986640c1590eb9bfaaba7ad17394481e6e", size = 16645480, upload-time = "2026-03-29T13:19:03.63Z" }, + { url = "https://files.pythonhosted.org/packages/34/49/f2312c154b82a286758ee2f1743336d50651f8b5195db18cdb63675ff649/numpy-2.4.4-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:62d6b0f03b694173f9fcb1fb317f7222fd0b0b103e784c6549f5e53a27718c44", size = 17020036, upload-time = "2026-03-29T13:19:07.428Z" }, + { url = "https://files.pythonhosted.org/packages/7b/e9/736d17bd77f1b0ec4f9901aaec129c00d59f5d84d5e79bba540ef12c2330/numpy-2.4.4-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:fbc356aae7adf9e6336d336b9c8111d390a05df88f1805573ebb0807bd06fd1d", size = 18368643, upload-time = "2026-03-29T13:19:10.775Z" }, + { url = "https://files.pythonhosted.org/packages/63/f6/d417977c5f519b17c8a5c3bc9e8304b0908b0e21136fe43bf628a1343914/numpy-2.4.4-cp312-cp312-win32.whl", hash = "sha256:0d35aea54ad1d420c812bfa0385c71cd7cc5bcf7c65fed95fc2cd02fe8c79827", size = 5961117, upload-time = "2026-03-29T13:19:13.464Z" }, + { url = "https://files.pythonhosted.org/packages/2d/5b/e1deebf88ff431b01b7406ca3583ab2bbb90972bbe1c568732e49c844f7e/numpy-2.4.4-cp312-cp312-win_amd64.whl", hash = "sha256:b5f0362dc928a6ecd9db58868fca5e48485205e3855957bdedea308f8672ea4a", size = 12320584, upload-time = "2026-03-29T13:19:16.155Z" }, + { url = "https://files.pythonhosted.org/packages/58/89/e4e856ac82a68c3ed64486a544977d0e7bdd18b8da75b78a577ca31c4395/numpy-2.4.4-cp312-cp312-win_arm64.whl", hash = "sha256:846300f379b5b12cc769334464656bc882e0735d27d9726568bc932fdc49d5ec", size = 10221450, upload-time = "2026-03-29T13:19:18.994Z" }, + { url = "https://files.pythonhosted.org/packages/14/1d/d0a583ce4fefcc3308806a749a536c201ed6b5ad6e1322e227ee4848979d/numpy-2.4.4-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:08f2e31ed5e6f04b118e49821397f12767934cfdd12a1ce86a058f91e004ee50", size = 16684933, upload-time = "2026-03-29T13:19:22.47Z" }, + { url = "https://files.pythonhosted.org/packages/c1/62/2b7a48fbb745d344742c0277f01286dead15f3f68e4f359fbfcf7b48f70f/numpy-2.4.4-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:e823b8b6edc81e747526f70f71a9c0a07ac4e7ad13020aa736bb7c9d67196115", size = 14694532, upload-time = "2026-03-29T13:19:25.581Z" }, + { url = "https://files.pythonhosted.org/packages/e5/87/499737bfba066b4a3bebff24a8f1c5b2dee410b209bc6668c9be692580f0/numpy-2.4.4-cp313-cp313-macosx_14_0_arm64.whl", hash = "sha256:4a19d9dba1a76618dd86b164d608566f393f8ec6ac7c44f0cc879011c45e65af", size = 5199661, upload-time = "2026-03-29T13:19:28.31Z" }, + { url = "https://files.pythonhosted.org/packages/cd/da/464d551604320d1491bc345efed99b4b7034143a85787aab78d5691d5a0e/numpy-2.4.4-cp313-cp313-macosx_14_0_x86_64.whl", hash = "sha256:d2a8490669bfe99a233298348acc2d824d496dee0e66e31b66a6022c2ad74a5c", size = 6547539, upload-time = "2026-03-29T13:19:30.97Z" }, + { url = "https://files.pythonhosted.org/packages/7d/90/8d23e3b0dafd024bf31bdec225b3bb5c2dbfa6912f8a53b8659f21216cbf/numpy-2.4.4-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:45dbed2ab436a9e826e302fcdcbe9133f9b0006e5af7168afb8963a6520da103", size = 15668806, upload-time = "2026-03-29T13:19:33.887Z" }, + { url = "https://files.pythonhosted.org/packages/d1/73/a9d864e42a01896bb5974475438f16086be9ba1f0d19d0bb7a07427c4a8b/numpy-2.4.4-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c901b15172510173f5cb310eae652908340f8dede90fff9e3bf6c0d8dfd92f83", size = 16632682, upload-time = "2026-03-29T13:19:37.336Z" }, + { url = "https://files.pythonhosted.org/packages/34/fb/14570d65c3bde4e202a031210475ae9cde9b7686a2e7dc97ee67d2833b35/numpy-2.4.4-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:99d838547ace2c4aace6c4f76e879ddfe02bb58a80c1549928477862b7a6d6ed", size = 17019810, upload-time = "2026-03-29T13:19:40.963Z" }, + { url = "https://files.pythonhosted.org/packages/8a/77/2ba9d87081fd41f6d640c83f26fb7351e536b7ce6dd9061b6af5904e8e46/numpy-2.4.4-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:0aec54fd785890ecca25a6003fd9a5aed47ad607bbac5cd64f836ad8666f4959", size = 18357394, upload-time = "2026-03-29T13:19:44.859Z" }, + { url = "https://files.pythonhosted.org/packages/a2/23/52666c9a41708b0853fa3b1a12c90da38c507a3074883823126d4e9d5b30/numpy-2.4.4-cp313-cp313-win32.whl", hash = "sha256:07077278157d02f65c43b1b26a3886bce886f95d20aabd11f87932750dfb14ed", size = 5959556, upload-time = "2026-03-29T13:19:47.661Z" }, + { url = "https://files.pythonhosted.org/packages/57/fb/48649b4971cde70d817cf97a2a2fdc0b4d8308569f1dd2f2611959d2e0cf/numpy-2.4.4-cp313-cp313-win_amd64.whl", hash = "sha256:5c70f1cc1c4efbe316a572e2d8b9b9cc44e89b95f79ca3331553fbb63716e2bf", size = 12317311, upload-time = "2026-03-29T13:19:50.67Z" }, + { url = "https://files.pythonhosted.org/packages/ba/d8/11490cddd564eb4de97b4579ef6bfe6a736cc07e94c1598590ae25415e01/numpy-2.4.4-cp313-cp313-win_arm64.whl", hash = "sha256:ef4059d6e5152fa1a39f888e344c73fdc926e1b2dd58c771d67b0acfbf2aa67d", size = 10222060, upload-time = "2026-03-29T13:19:54.229Z" }, + { url = "https://files.pythonhosted.org/packages/99/5d/dab4339177a905aad3e2221c915b35202f1ec30d750dd2e5e9d9a72b804b/numpy-2.4.4-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:4bbc7f303d125971f60ec0aaad5e12c62d0d2c925f0ab1273debd0e4ba37aba5", size = 14822302, upload-time = "2026-03-29T13:19:57.585Z" }, + { url = "https://files.pythonhosted.org/packages/eb/e4/0564a65e7d3d97562ed6f9b0fd0fb0a6f559ee444092f105938b50043876/numpy-2.4.4-cp313-cp313t-macosx_14_0_arm64.whl", hash = "sha256:4d6d57903571f86180eb98f8f0c839fa9ebbfb031356d87f1361be91e433f5b7", size = 5327407, upload-time = "2026-03-29T13:20:00.601Z" }, + { url = "https://files.pythonhosted.org/packages/29/8d/35a3a6ce5ad371afa58b4700f1c820f8f279948cca32524e0a695b0ded83/numpy-2.4.4-cp313-cp313t-macosx_14_0_x86_64.whl", hash = "sha256:4636de7fd195197b7535f231b5de9e4b36d2c440b6e566d2e4e4746e6af0ca93", size = 6647631, upload-time = "2026-03-29T13:20:02.855Z" }, + { url = "https://files.pythonhosted.org/packages/f4/da/477731acbd5a58a946c736edfdabb2ac5b34c3d08d1ba1a7b437fa0884df/numpy-2.4.4-cp313-cp313t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ad2e2ef14e0b04e544ea2fa0a36463f847f113d314aa02e5b402fdf910ef309e", size = 15727691, upload-time = "2026-03-29T13:20:06.004Z" }, + { url = "https://files.pythonhosted.org/packages/e6/db/338535d9b152beabeb511579598418ba0212ce77cf9718edd70262cc4370/numpy-2.4.4-cp313-cp313t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5a285b3b96f951841799528cd1f4f01cd70e7e0204b4abebac9463eecfcf2a40", size = 16681241, upload-time = "2026-03-29T13:20:09.417Z" }, + { url = "https://files.pythonhosted.org/packages/e2/a9/ad248e8f58beb7a0219b413c9c7d8151c5d285f7f946c3e26695bdbbe2df/numpy-2.4.4-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:f8474c4241bc18b750be2abea9d7a9ec84f46ef861dbacf86a4f6e043401f79e", size = 17085767, upload-time = "2026-03-29T13:20:13.126Z" }, + { url = "https://files.pythonhosted.org/packages/b5/1a/3b88ccd3694681356f70da841630e4725a7264d6a885c8d442a697e1146b/numpy-2.4.4-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:4e874c976154687c1f71715b034739b45c7711bec81db01914770373d125e392", size = 18403169, upload-time = "2026-03-29T13:20:17.096Z" }, + { url = "https://files.pythonhosted.org/packages/c2/c9/fcfd5d0639222c6eac7f304829b04892ef51c96a75d479214d77e3ce6e33/numpy-2.4.4-cp313-cp313t-win32.whl", hash = "sha256:9c585a1790d5436a5374bac930dad6ed244c046ed91b2b2a3634eb2971d21008", size = 6083477, upload-time = "2026-03-29T13:20:20.195Z" }, + { url = "https://files.pythonhosted.org/packages/d5/e3/3938a61d1c538aaec8ed6fd6323f57b0c2d2d2219512434c5c878db76553/numpy-2.4.4-cp313-cp313t-win_amd64.whl", hash = "sha256:93e15038125dc1e5345d9b5b68aa7f996ec33b98118d18c6ca0d0b7d6198b7e8", size = 12457487, upload-time = "2026-03-29T13:20:22.946Z" }, + { url = "https://files.pythonhosted.org/packages/97/6a/7e345032cc60501721ef94e0e30b60f6b0bd601f9174ebd36389a2b86d40/numpy-2.4.4-cp313-cp313t-win_arm64.whl", hash = "sha256:0dfd3f9d3adbe2920b68b5cd3d51444e13a10792ec7154cd0a2f6e74d4ab3233", size = 10292002, upload-time = "2026-03-29T13:20:25.909Z" }, + { url = "https://files.pythonhosted.org/packages/6e/06/c54062f85f673dd5c04cbe2f14c3acb8c8b95e3384869bb8cc9bff8cb9df/numpy-2.4.4-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:f169b9a863d34f5d11b8698ead99febeaa17a13ca044961aa8e2662a6c7766a0", size = 16684353, upload-time = "2026-03-29T13:20:29.504Z" }, + { url = "https://files.pythonhosted.org/packages/4c/39/8a320264a84404c74cc7e79715de85d6130fa07a0898f67fb5cd5bd79908/numpy-2.4.4-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:2483e4584a1cb3092da4470b38866634bafb223cbcd551ee047633fd2584599a", size = 14704914, upload-time = "2026-03-29T13:20:33.547Z" }, + { url = "https://files.pythonhosted.org/packages/91/fb/287076b2614e1d1044235f50f03748f31fa287e3dbe6abeb35cdfa351eca/numpy-2.4.4-cp314-cp314-macosx_14_0_arm64.whl", hash = "sha256:2d19e6e2095506d1736b7d80595e0f252d76b89f5e715c35e06e937679ea7d7a", size = 5210005, upload-time = "2026-03-29T13:20:36.45Z" }, + { url = "https://files.pythonhosted.org/packages/63/eb/fcc338595309910de6ecabfcef2419a9ce24399680bfb149421fa2df1280/numpy-2.4.4-cp314-cp314-macosx_14_0_x86_64.whl", hash = "sha256:6a246d5914aa1c820c9443ddcee9c02bec3e203b0c080349533fae17727dfd1b", size = 6544974, upload-time = "2026-03-29T13:20:39.014Z" }, + { url = "https://files.pythonhosted.org/packages/44/5d/e7e9044032a716cdfaa3fba27a8e874bf1c5f1912a1ddd4ed071bf8a14a6/numpy-2.4.4-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:989824e9faf85f96ec9c7761cd8d29c531ad857bfa1daa930cba85baaecf1a9a", size = 15684591, upload-time = "2026-03-29T13:20:42.146Z" }, + { url = "https://files.pythonhosted.org/packages/98/7c/21252050676612625449b4807d6b695b9ce8a7c9e1c197ee6216c8a65c7c/numpy-2.4.4-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:27a8d92cd10f1382a67d7cf4db7ce18341b66438bdd9f691d7b0e48d104c2a9d", size = 16637700, upload-time = "2026-03-29T13:20:46.204Z" }, + { url = "https://files.pythonhosted.org/packages/b1/29/56d2bbef9465db24ef25393383d761a1af4f446a1df9b8cded4fe3a5a5d7/numpy-2.4.4-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:e44319a2953c738205bf3354537979eaa3998ed673395b964c1176083dd46252", size = 17035781, upload-time = "2026-03-29T13:20:50.242Z" }, + { url = "https://files.pythonhosted.org/packages/e3/2b/a35a6d7589d21f44cea7d0a98de5ddcbb3d421b2622a5c96b1edf18707c3/numpy-2.4.4-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:e892aff75639bbef0d2a2cfd55535510df26ff92f63c92cd84ef8d4ba5a5557f", size = 18362959, upload-time = "2026-03-29T13:20:54.019Z" }, + { url = "https://files.pythonhosted.org/packages/64/c9/d52ec581f2390e0f5f85cbfd80fb83d965fc15e9f0e1aec2195faa142cde/numpy-2.4.4-cp314-cp314-win32.whl", hash = "sha256:1378871da56ca8943c2ba674530924bb8ca40cd228358a3b5f302ad60cf875fc", size = 6008768, upload-time = "2026-03-29T13:20:56.912Z" }, + { url = "https://files.pythonhosted.org/packages/fa/22/4cc31a62a6c7b74a8730e31a4274c5dc80e005751e277a2ce38e675e4923/numpy-2.4.4-cp314-cp314-win_amd64.whl", hash = "sha256:715d1c092715954784bc79e1174fc2a90093dc4dc84ea15eb14dad8abdcdeb74", size = 12449181, upload-time = "2026-03-29T13:20:59.548Z" }, + { url = "https://files.pythonhosted.org/packages/70/2e/14cda6f4d8e396c612d1bf97f22958e92148801d7e4f110cabebdc0eef4b/numpy-2.4.4-cp314-cp314-win_arm64.whl", hash = "sha256:2c194dd721e54ecad9ad387c1d35e63dce5c4450c6dc7dd5611283dda239aabb", size = 10496035, upload-time = "2026-03-29T13:21:02.524Z" }, + { url = "https://files.pythonhosted.org/packages/b1/e8/8fed8c8d848d7ecea092dc3469643f9d10bc3a134a815a3b033da1d2039b/numpy-2.4.4-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:2aa0613a5177c264ff5921051a5719d20095ea586ca88cc802c5c218d1c67d3e", size = 14824958, upload-time = "2026-03-29T13:21:05.671Z" }, + { url = "https://files.pythonhosted.org/packages/05/1a/d8007a5138c179c2bf33ef44503e83d70434d2642877ee8fbb230e7c0548/numpy-2.4.4-cp314-cp314t-macosx_14_0_arm64.whl", hash = "sha256:42c16925aa5a02362f986765f9ebabf20de75cdefdca827d14315c568dcab113", size = 5330020, upload-time = "2026-03-29T13:21:08.635Z" }, + { url = "https://files.pythonhosted.org/packages/99/64/ffb99ac6ae93faf117bcbd5c7ba48a7f45364a33e8e458545d3633615dda/numpy-2.4.4-cp314-cp314t-macosx_14_0_x86_64.whl", hash = "sha256:874f200b2a981c647340f841730fc3a2b54c9d940566a3c4149099591e2c4c3d", size = 6650758, upload-time = "2026-03-29T13:21:10.949Z" }, + { url = "https://files.pythonhosted.org/packages/6e/6e/795cc078b78a384052e73b2f6281ff7a700e9bf53bcce2ee579d4f6dd879/numpy-2.4.4-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c9b39d38a9bd2ae1becd7eac1303d031c5c110ad31f2b319c6e7d98b135c934d", size = 15729948, upload-time = "2026-03-29T13:21:14.047Z" }, + { url = "https://files.pythonhosted.org/packages/5f/86/2acbda8cc2af5f3d7bfc791192863b9e3e19674da7b5e533fded124d1299/numpy-2.4.4-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b268594bccac7d7cf5844c7732e3f20c50921d94e36d7ec9b79e9857694b1b2f", size = 16679325, upload-time = "2026-03-29T13:21:17.561Z" }, + { url = "https://files.pythonhosted.org/packages/bc/59/cafd83018f4aa55e0ac6fa92aa066c0a1877b77a615ceff1711c260ffae8/numpy-2.4.4-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:ac6b31e35612a26483e20750126d30d0941f949426974cace8e6b5c58a3657b0", size = 17084883, upload-time = "2026-03-29T13:21:21.106Z" }, + { url = "https://files.pythonhosted.org/packages/f0/85/a42548db84e65ece46ab2caea3d3f78b416a47af387fcbb47ec28e660dc2/numpy-2.4.4-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:8e3ed142f2728df44263aaf5fb1f5b0b99f4070c553a0d7f033be65338329150", size = 18403474, upload-time = "2026-03-29T13:21:24.828Z" }, + { url = "https://files.pythonhosted.org/packages/ed/ad/483d9e262f4b831000062e5d8a45e342166ec8aaa1195264982bca267e62/numpy-2.4.4-cp314-cp314t-win32.whl", hash = "sha256:dddbbd259598d7240b18c9d87c56a9d2fb3b02fe266f49a7c101532e78c1d871", size = 6155500, upload-time = "2026-03-29T13:21:28.205Z" }, + { url = "https://files.pythonhosted.org/packages/c7/03/2fc4e14c7bd4ff2964b74ba90ecb8552540b6315f201df70f137faa5c589/numpy-2.4.4-cp314-cp314t-win_amd64.whl", hash = "sha256:a7164afb23be6e37ad90b2f10426149fd75aee07ca55653d2aa41e66c4ef697e", size = 12637755, upload-time = "2026-03-29T13:21:31.107Z" }, + { url = "https://files.pythonhosted.org/packages/58/78/548fb8e07b1a341746bfbecb32f2c268470f45fa028aacdbd10d9bc73aab/numpy-2.4.4-cp314-cp314t-win_arm64.whl", hash = "sha256:ba203255017337d39f89bdd58417f03c4426f12beed0440cfd933cb15f8669c7", size = 10566643, upload-time = "2026-03-29T13:21:34.339Z" }, + { url = "https://files.pythonhosted.org/packages/6b/33/8fae8f964a4f63ed528264ddf25d2b683d0b663e3cba26961eb838a7c1bd/numpy-2.4.4-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:58c8b5929fcb8287cbd6f0a3fae19c6e03a5c48402ae792962ac465224a629a4", size = 16854491, upload-time = "2026-03-29T13:21:38.03Z" }, + { url = "https://files.pythonhosted.org/packages/bc/d0/1aabee441380b981cf8cdda3ae7a46aa827d1b5a8cce84d14598bc94d6d9/numpy-2.4.4-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:eea7ac5d2dce4189771cedb559c738a71512768210dc4e4753b107a2048b3d0e", size = 14895830, upload-time = "2026-03-29T13:21:41.509Z" }, + { url = "https://files.pythonhosted.org/packages/a5/b8/aafb0d1065416894fccf4df6b49ef22b8db045187949545bced89c034b8e/numpy-2.4.4-pp311-pypy311_pp73-macosx_14_0_arm64.whl", hash = "sha256:51fc224f7ca4d92656d5a5eb315f12eb5fe2c97a66249aa7b5f562528a3be38c", size = 5400927, upload-time = "2026-03-29T13:21:44.747Z" }, + { url = "https://files.pythonhosted.org/packages/d6/77/063baa20b08b431038c7f9ff5435540c7b7265c78cf56012a483019ca72d/numpy-2.4.4-pp311-pypy311_pp73-macosx_14_0_x86_64.whl", hash = "sha256:28a650663f7314afc3e6ec620f44f333c386aad9f6fc472030865dc0ebb26ee3", size = 6715557, upload-time = "2026-03-29T13:21:47.406Z" }, + { url = "https://files.pythonhosted.org/packages/c7/a8/379542d45a14f149444c5c4c4e7714707239ce9cc1de8c2803958889da14/numpy-2.4.4-pp311-pypy311_pp73-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:19710a9ca9992d7174e9c52f643d4272dcd1558c5f7af7f6f8190f633bd651a7", size = 15804253, upload-time = "2026-03-29T13:21:50.753Z" }, + { url = "https://files.pythonhosted.org/packages/a2/c8/f0a45426d6d21e7ea3310a15cf90c43a14d9232c31a837702dba437f3373/numpy-2.4.4-pp311-pypy311_pp73-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9b2aec6af35c113b05695ebb5749a787acd63cafc83086a05771d1e1cd1e555f", size = 16753552, upload-time = "2026-03-29T13:21:54.344Z" }, + { url = "https://files.pythonhosted.org/packages/04/74/f4c001f4714c3ad9ce037e18cf2b9c64871a84951eaa0baf683a9ca9301c/numpy-2.4.4-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:f2cf083b324a467e1ab358c105f6cad5ea950f50524668a80c486ff1db24e119", size = 12509075, upload-time = "2026-03-29T13:21:57.644Z" }, +] + +[[package]] +name = "ocr-worker" +version = "0.1.0" +source = { editable = "." } +dependencies = [ + { name = "ddddocr" }, +] + +[package.metadata] +requires-dist = [{ name = "ddddocr", specifier = ">=1.6.1" }] + +[[package]] +name = "onnxruntime" +version = "1.24.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "flatbuffers" }, + { name = "numpy" }, + { name = "packaging" }, + { name = "protobuf" }, + { name = "sympy" }, +] +wheels = [ + { url = "https://files.pythonhosted.org/packages/60/69/6c40720201012c6af9aa7d4ecdd620e521bd806dc6269d636fdd5c5aeebe/onnxruntime-1.24.4-cp311-cp311-macosx_14_0_arm64.whl", hash = "sha256:0bdfce8e9a6497cec584aab407b71bf697dac5e1b7b7974adc50bf7533bdb3a2", size = 17332131, upload-time = "2026-03-17T22:05:49.005Z" }, + { url = "https://files.pythonhosted.org/packages/38/e9/8c901c150ce0c368da38638f44152fb411059c0c7364b497c9e5c957321a/onnxruntime-1.24.4-cp311-cp311-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:046ff290045a387676941a02a8ae5c3ebec6b4f551ae228711968c4a69d8f6b7", size = 15152472, upload-time = "2026-03-17T22:03:26.176Z" }, + { url = "https://files.pythonhosted.org/packages/d5/b6/7a4df417cdd01e8f067a509e123ac8b31af450a719fa7ed81787dd6057ec/onnxruntime-1.24.4-cp311-cp311-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e54ad52e61d2d4618dcff8fa1480ac66b24ee2eab73331322db1049f11ccf330", size = 17222993, upload-time = "2026-03-17T22:04:34.485Z" }, + { url = "https://files.pythonhosted.org/packages/dd/59/8febe015f391aa1757fa5ba82c759ea4b6c14ef970132efb5e316665ba61/onnxruntime-1.24.4-cp311-cp311-win_amd64.whl", hash = "sha256:b43b63eb24a2bc8fc77a09be67587a570967a412cccb837b6245ccb546691153", size = 12594863, upload-time = "2026-03-17T22:05:38.749Z" }, + { url = "https://files.pythonhosted.org/packages/32/84/4155fcd362e8873eb6ce305acfeeadacd9e0e59415adac474bea3d9281bb/onnxruntime-1.24.4-cp311-cp311-win_arm64.whl", hash = "sha256:e26478356dba25631fb3f20112e345f8e8bf62c499bb497e8a559f7d69cf7e7b", size = 12259895, upload-time = "2026-03-17T22:05:28.812Z" }, + { url = "https://files.pythonhosted.org/packages/d7/38/31db1b232b4ba960065a90c1506ad7a56995cd8482033184e97fadca17cc/onnxruntime-1.24.4-cp312-cp312-macosx_14_0_arm64.whl", hash = "sha256:cad1c2b3f455c55678ab2a8caa51fb420c25e6e3cf10f4c23653cdabedc8de78", size = 17341875, upload-time = "2026-03-17T22:05:51.669Z" }, + { url = "https://files.pythonhosted.org/packages/aa/60/c4d1c8043eb42f8a9aa9e931c8c293d289c48ff463267130eca97d13357f/onnxruntime-1.24.4-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1a5c5a544b22f90859c88617ecb30e161ee3349fcc73878854f43d77f00558b5", size = 15172485, upload-time = "2026-03-17T22:03:32.182Z" }, + { url = "https://files.pythonhosted.org/packages/6d/ab/5b68110e0460d73fad814d5bd11c7b1ddcce5c37b10177eb264d6a36e331/onnxruntime-1.24.4-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0d640eb9f3782689b55cfa715094474cd5662f2f137be6a6f847a594b6e9705c", size = 17244912, upload-time = "2026-03-17T22:04:37.251Z" }, + { url = "https://files.pythonhosted.org/packages/8b/f4/6b89e297b93704345f0f3f8c62229bee323ef25682a3f9b4f89a39324950/onnxruntime-1.24.4-cp312-cp312-win_amd64.whl", hash = "sha256:535b29475ca42b593c45fbb2152fbf1cdf3f287315bf650e6a724a0a1d065cdb", size = 12596856, upload-time = "2026-03-17T22:05:41.224Z" }, + { url = "https://files.pythonhosted.org/packages/43/06/8b8ec6e9e6a474fcd5d772453f627ad4549dfe3ab8c0bf70af5afcde551b/onnxruntime-1.24.4-cp312-cp312-win_arm64.whl", hash = "sha256:e6214096e14b7b52e3bee1903dc12dc7ca09cb65e26664668a4620cc5e6f9a90", size = 12270275, upload-time = "2026-03-17T22:05:31.132Z" }, + { url = "https://files.pythonhosted.org/packages/e9/f0/8a21ec0a97e40abb7d8da1e8b20fb9e1af509cc6d191f6faa75f73622fb2/onnxruntime-1.24.4-cp313-cp313-macosx_14_0_arm64.whl", hash = "sha256:e99a48078baaefa2b50fe5836c319499f71f13f76ed32d0211f39109147a49e0", size = 17341922, upload-time = "2026-03-17T22:03:56.364Z" }, + { url = "https://files.pythonhosted.org/packages/8b/25/d7908de8e08cee9abfa15b8aa82349b79733ae5865162a3609c11598805d/onnxruntime-1.24.4-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:dc4aaed1e5e1aaacf2343c838a30a7c3ade78f13eeb16817411f929d04040a13", size = 15172290, upload-time = "2026-03-17T22:03:37.124Z" }, + { url = "https://files.pythonhosted.org/packages/7f/72/105ec27a78c5aa0154a7c0cd8c41c19a97799c3b12fc30392928997e3be3/onnxruntime-1.24.4-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e30c972bc02e072911aabb6891453ec73795386c0af2b761b65444b8a4c4745f", size = 17244738, upload-time = "2026-03-17T22:04:40.625Z" }, + { url = "https://files.pythonhosted.org/packages/05/fb/a592736d968c2f58e12de4d52088dda8e0e724b26ad5c0487263adb45875/onnxruntime-1.24.4-cp313-cp313-win_amd64.whl", hash = "sha256:3b6ba8b0181a3aa88edab00eb01424ffc06f42e71095a91186c2249415fcff93", size = 12597435, upload-time = "2026-03-17T22:05:43.826Z" }, + { url = "https://files.pythonhosted.org/packages/ad/04/ae2479e9841b64bd2eb44f8a64756c62593f896514369a11243b1b86ca5c/onnxruntime-1.24.4-cp313-cp313-win_arm64.whl", hash = "sha256:71d6a5c1821d6e8586a024000ece458db8f2fc0ecd050435d45794827ce81e19", size = 12269852, upload-time = "2026-03-17T22:05:33.353Z" }, + { url = "https://files.pythonhosted.org/packages/b4/af/a479a536c4398ffaf49fbbe755f45d5b8726bdb4335ab31b537f3d7149b8/onnxruntime-1.24.4-cp313-cp313t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1700f559c8086d06b2a4d5de51e62cb4ff5e2631822f71a36db8c72383db71ee", size = 15176861, upload-time = "2026-03-17T22:03:40.143Z" }, + { url = "https://files.pythonhosted.org/packages/be/13/19f5da70c346a76037da2c2851ecbf1266e61d7f0dcdb887c667210d4608/onnxruntime-1.24.4-cp313-cp313t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4c74e268dc808e61e63784d43f9ddcdaf50a776c2819e8bd1d1b11ef64bf7e36", size = 17247454, upload-time = "2026-03-17T22:04:46.643Z" }, + { url = "https://files.pythonhosted.org/packages/89/db/b30dbbd6037847b205ab75d962bc349bf1e46d02a65b30d7047a6893ffd6/onnxruntime-1.24.4-cp314-cp314-macosx_14_0_arm64.whl", hash = "sha256:fbff2a248940e3398ae78374c5a839e49a2f39079b488bc64439fa0ec327a3e4", size = 17343300, upload-time = "2026-03-17T22:03:59.223Z" }, + { url = "https://files.pythonhosted.org/packages/61/88/1746c0e7959961475b84c776d35601a21d445f463c93b1433a409ec3e188/onnxruntime-1.24.4-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e2b7969e72d8cb53ffc88ab6d49dd5e75c1c663bda7be7eb0ece192f127343d1", size = 15175936, upload-time = "2026-03-17T22:03:43.671Z" }, + { url = "https://files.pythonhosted.org/packages/5f/ba/4699cde04a52cece66cbebc85bd8335a0d3b9ad485abc9a2e15946a1349d/onnxruntime-1.24.4-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:14ed1f197fab812b695a5eaddb536c635e58a2fbbe50a517c78f082cc6ce9177", size = 17246432, upload-time = "2026-03-17T22:04:49.58Z" }, + { url = "https://files.pythonhosted.org/packages/ef/60/4590910841bb28bd3b4b388a9efbedf4e2d2cca99ddf0c863642b4e87814/onnxruntime-1.24.4-cp314-cp314-win_amd64.whl", hash = "sha256:311e309f573bf3c12aa5723e23823077f83d5e412a18499d4485c7eb41040858", size = 12903276, upload-time = "2026-03-17T22:05:46.349Z" }, + { url = "https://files.pythonhosted.org/packages/7f/6f/60e2c0acea1e1ac09b3e794b5a19c166eebf91c0b860b3e6db8e74983fda/onnxruntime-1.24.4-cp314-cp314-win_arm64.whl", hash = "sha256:3f0b910e86b759a4732663ec61fd57ac42ee1b0066f68299de164220b660546d", size = 12594365, upload-time = "2026-03-17T22:05:35.795Z" }, + { url = "https://files.pythonhosted.org/packages/cf/68/0c05d10f8f6c40fe0912ebec0d5a33884aaa2af2053507e864dab0883208/onnxruntime-1.24.4-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:aa12ddc54c9c4594073abcaa265cd9681e95fb89dae982a6f508a794ca42e661", size = 15176889, upload-time = "2026-03-17T22:03:48.021Z" }, + { url = "https://files.pythonhosted.org/packages/6c/1d/1666dc64e78d8587d168fec4e3b7922b92eb286a2ddeebcf6acb55c7dc82/onnxruntime-1.24.4-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e1cc6a518255f012134bc791975a6294806be9a3b20c4a54cca25194c90cf731", size = 17247021, upload-time = "2026-03-17T22:04:52.377Z" }, +] + +[[package]] +name = "opencv-python" +version = "4.13.0.92" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "numpy" }, +] +wheels = [ + { url = "https://files.pythonhosted.org/packages/fc/6f/5a28fef4c4a382be06afe3938c64cc168223016fa520c5abaf37e8862aa5/opencv_python-4.13.0.92-cp37-abi3-macosx_13_0_arm64.whl", hash = "sha256:caf60c071ec391ba51ed00a4a920f996d0b64e3e46068aac1f646b5de0326a19", size = 46247052, upload-time = "2026-02-05T07:01:25.046Z" }, + { url = "https://files.pythonhosted.org/packages/08/ac/6c98c44c650b8114a0fb901691351cfb3956d502e8e9b5cd27f4ee7fbf2f/opencv_python-4.13.0.92-cp37-abi3-macosx_14_0_x86_64.whl", hash = "sha256:5868a8c028a0b37561579bfb8ac1875babdc69546d236249fff296a8c010ccf9", size = 32568781, upload-time = "2026-02-05T07:01:41.379Z" }, + { url = "https://files.pythonhosted.org/packages/fb/17/de5458312bcb07ddf434d7bfcb24bb52c59635ad58c6e7c751b48949b009/opencv_python-4.13.0.92-cp37-abi3-win32.whl", hash = "sha256:372fe164a3148ac1ca51e5f3ad0541a4a276452273f503441d718fab9c5e5f59", size = 30932638, upload-time = "2026-02-05T07:02:14.98Z" }, + { url = "https://files.pythonhosted.org/packages/e9/a5/1be1516390333ff9be3a9cb648c9f33df79d5096e5884b5df71a588af463/opencv_python-4.13.0.92-cp37-abi3-win_amd64.whl", hash = "sha256:423d934c9fafb91aad38edf26efb46da91ffbc05f3f59c4b0c72e699720706f5", size = 40212062, upload-time = "2026-02-05T07:02:12.724Z" }, +] + +[[package]] +name = "opencv-python-headless" +version = "4.13.0.92" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "numpy" }, +] +wheels = [ + { url = "https://files.pythonhosted.org/packages/21/76/9417a6aef9def70e467a5bf560579f816148a4c658b7d525581b356eda9e/opencv_python_headless-4.13.0.92-cp37-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:5c8cfc8e87ed452b5cecb9419473ee5560a989859fe1d10d1ce11ae87b09a2cb", size = 33703709, upload-time = "2026-02-05T10:24:46.469Z" }, + { url = "https://files.pythonhosted.org/packages/92/ce/bd17ff5772938267fd49716e94ca24f616ff4cb1ff4c6be13085108037be/opencv_python_headless-4.13.0.92-cp37-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:0525a3d2c0b46c611e2130b5fdebc94cf404845d8fa64d2f3a3b679572a5bd22", size = 56016764, upload-time = "2026-02-05T10:26:48.904Z" }, + { url = "https://files.pythonhosted.org/packages/8f/b4/b7bcbf7c874665825a8c8e1097e93ea25d1f1d210a3e20d4451d01da30aa/opencv_python_headless-4.13.0.92-cp37-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:eb60e36b237b1ebd40a912da5384b348df8ed534f6f644d8e0b4f103e272ba7d", size = 35010236, upload-time = "2026-02-05T10:28:11.031Z" }, + { url = "https://files.pythonhosted.org/packages/4b/33/b5db29a6c00eb8f50708110d8d453747ca125c8b805bc437b289dbdcc057/opencv_python_headless-4.13.0.92-cp37-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:0bd48544f77c68b2941392fcdf9bcd2b9cdf00e98cb8c29b2455d194763cf99e", size = 60391106, upload-time = "2026-02-05T10:30:14.236Z" }, +] + +[[package]] +name = "packaging" +version = "26.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/65/ee/299d360cdc32edc7d2cf530f3accf79c4fca01e96ffc950d8a52213bd8e4/packaging-26.0.tar.gz", hash = "sha256:00243ae351a257117b6a241061796684b084ed1c516a08c48a3f7e147a9d80b4", size = 143416, upload-time = "2026-01-21T20:50:39.064Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b7/b9/c538f279a4e237a006a2c98387d081e9eb060d203d8ed34467cc0f0b9b53/packaging-26.0-py3-none-any.whl", hash = "sha256:b36f1fef9334a5588b4166f8bcd26a14e521f2b55e6b9de3aaa80d3ff7a37529", size = 74366, upload-time = "2026-01-21T20:50:37.788Z" }, +] + +[[package]] +name = "pillow" +version = "12.2.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/8c/21/c2bcdd5906101a30244eaffc1b6e6ce71a31bd0742a01eb89e660ebfac2d/pillow-12.2.0.tar.gz", hash = "sha256:a830b1a40919539d07806aa58e1b114df53ddd43213d9c8b75847eee6c0182b5", size = 46987819, upload-time = "2026-04-01T14:46:17.687Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/68/e1/748f5663efe6edcfc4e74b2b93edfb9b8b99b67f21a854c3ae416500a2d9/pillow-12.2.0-cp311-cp311-macosx_10_10_x86_64.whl", hash = "sha256:8be29e59487a79f173507c30ddf57e733a357f67881430449bb32614075a40ab", size = 5354347, upload-time = "2026-04-01T14:42:44.255Z" }, + { url = "https://files.pythonhosted.org/packages/47/a1/d5ff69e747374c33a3b53b9f98cca7889fce1fd03d79cdc4e1bccc6c5a87/pillow-12.2.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:71cde9a1e1551df7d34a25462fc60325e8a11a82cc2e2f54578e5e9a1e153d65", size = 4695873, upload-time = "2026-04-01T14:42:46.452Z" }, + { url = "https://files.pythonhosted.org/packages/df/21/e3fbdf54408a973c7f7f89a23b2cb97a7ef30c61ab4142af31eee6aebc88/pillow-12.2.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:f490f9368b6fc026f021db16d7ec2fbf7d89e2edb42e8ec09d2c60505f5729c7", size = 6280168, upload-time = "2026-04-01T14:42:49.228Z" }, + { url = "https://files.pythonhosted.org/packages/d3/f1/00b7278c7dd52b17ad4329153748f87b6756ec195ff786c2bdf12518337d/pillow-12.2.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:8bd7903a5f2a4545f6fd5935c90058b89d30045568985a71c79f5fd6edf9b91e", size = 8088188, upload-time = "2026-04-01T14:42:51.735Z" }, + { url = "https://files.pythonhosted.org/packages/ad/cf/220a5994ef1b10e70e85748b75649d77d506499352be135a4989c957b701/pillow-12.2.0-cp311-cp311-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3997232e10d2920a68d25191392e3a4487d8183039e1c74c2297f00ed1c50705", size = 6394401, upload-time = "2026-04-01T14:42:54.343Z" }, + { url = "https://files.pythonhosted.org/packages/e9/bd/e51a61b1054f09437acfbc2ff9106c30d1eb76bc1453d428399946781253/pillow-12.2.0-cp311-cp311-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e74473c875d78b8e9d5da2a70f7099549f9eb37ded4e2f6a463e60125bccd176", size = 7079655, upload-time = "2026-04-01T14:42:56.954Z" }, + { url = "https://files.pythonhosted.org/packages/6b/3d/45132c57d5fb4b5744567c3817026480ac7fc3ce5d4c47902bc0e7f6f853/pillow-12.2.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:56a3f9c60a13133a98ecff6197af34d7824de9b7b38c3654861a725c970c197b", size = 6503105, upload-time = "2026-04-01T14:42:59.847Z" }, + { url = "https://files.pythonhosted.org/packages/7d/2e/9df2fc1e82097b1df3dce58dc43286aa01068e918c07574711fcc53e6fb4/pillow-12.2.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:90e6f81de50ad6b534cab6e5aef77ff6e37722b2f5d908686f4a5c9eba17a909", size = 7203402, upload-time = "2026-04-01T14:43:02.664Z" }, + { url = "https://files.pythonhosted.org/packages/bd/2e/2941e42858ebb67e50ae741473de81c2984e6eff7b397017623c676e2e8d/pillow-12.2.0-cp311-cp311-win32.whl", hash = "sha256:8c984051042858021a54926eb597d6ee3012393ce9c181814115df4c60b9a808", size = 6378149, upload-time = "2026-04-01T14:43:05.274Z" }, + { url = "https://files.pythonhosted.org/packages/69/42/836b6f3cd7f3e5fa10a1f1a5420447c17966044c8fbf589cc0452d5502db/pillow-12.2.0-cp311-cp311-win_amd64.whl", hash = "sha256:6e6b2a0c538fc200b38ff9eb6628228b77908c319a005815f2dde585a0664b60", size = 7082626, upload-time = "2026-04-01T14:43:08.557Z" }, + { url = "https://files.pythonhosted.org/packages/c2/88/549194b5d6f1f494b485e493edc6693c0a16f4ada488e5bd974ed1f42fad/pillow-12.2.0-cp311-cp311-win_arm64.whl", hash = "sha256:9a8a34cc89c67a65ea7437ce257cea81a9dad65b29805f3ecee8c8fe8ff25ffe", size = 2463531, upload-time = "2026-04-01T14:43:10.743Z" }, + { url = "https://files.pythonhosted.org/packages/58/be/7482c8a5ebebbc6470b3eb791812fff7d5e0216c2be3827b30b8bb6603ed/pillow-12.2.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:2d192a155bbcec180f8564f693e6fd9bccff5a7af9b32e2e4bf8c9c69dbad6b5", size = 5308279, upload-time = "2026-04-01T14:43:13.246Z" }, + { url = "https://files.pythonhosted.org/packages/d8/95/0a351b9289c2b5cbde0bacd4a83ebc44023e835490a727b2a3bd60ddc0f4/pillow-12.2.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:f3f40b3c5a968281fd507d519e444c35f0ff171237f4fdde090dd60699458421", size = 4695490, upload-time = "2026-04-01T14:43:15.584Z" }, + { url = "https://files.pythonhosted.org/packages/de/af/4e8e6869cbed569d43c416fad3dc4ecb944cb5d9492defaed89ddd6fe871/pillow-12.2.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:03e7e372d5240cc23e9f07deca4d775c0817bffc641b01e9c3af208dbd300987", size = 6284462, upload-time = "2026-04-01T14:43:18.268Z" }, + { url = "https://files.pythonhosted.org/packages/e9/9e/c05e19657fd57841e476be1ab46c4d501bffbadbafdc31a6d665f8b737b6/pillow-12.2.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:b86024e52a1b269467a802258c25521e6d742349d760728092e1bc2d135b4d76", size = 8094744, upload-time = "2026-04-01T14:43:20.716Z" }, + { url = "https://files.pythonhosted.org/packages/2b/54/1789c455ed10176066b6e7e6da1b01e50e36f94ba584dc68d9eebfe9156d/pillow-12.2.0-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7371b48c4fa448d20d2714c9a1f775a81155050d383333e0a6c15b1123dda005", size = 6398371, upload-time = "2026-04-01T14:43:23.443Z" }, + { url = "https://files.pythonhosted.org/packages/43/e3/fdc657359e919462369869f1c9f0e973f353f9a9ee295a39b1fea8ee1a77/pillow-12.2.0-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:62f5409336adb0663b7caa0da5c7d9e7bdbaae9ce761d34669420c2a801b2780", size = 7087215, upload-time = "2026-04-01T14:43:26.758Z" }, + { url = "https://files.pythonhosted.org/packages/8b/f8/2f6825e441d5b1959d2ca5adec984210f1ec086435b0ed5f52c19b3b8a6e/pillow-12.2.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:01afa7cf67f74f09523699b4e88c73fb55c13346d212a59a2db1f86b0a63e8c5", size = 6509783, upload-time = "2026-04-01T14:43:29.56Z" }, + { url = "https://files.pythonhosted.org/packages/67/f9/029a27095ad20f854f9dba026b3ea6428548316e057e6fc3545409e86651/pillow-12.2.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:fc3d34d4a8fbec3e88a79b92e5465e0f9b842b628675850d860b8bd300b159f5", size = 7212112, upload-time = "2026-04-01T14:43:32.091Z" }, + { url = "https://files.pythonhosted.org/packages/be/42/025cfe05d1be22dbfdb4f264fe9de1ccda83f66e4fc3aac94748e784af04/pillow-12.2.0-cp312-cp312-win32.whl", hash = "sha256:58f62cc0f00fd29e64b29f4fd923ffdb3859c9f9e6105bfc37ba1d08994e8940", size = 6378489, upload-time = "2026-04-01T14:43:34.601Z" }, + { url = "https://files.pythonhosted.org/packages/5d/7b/25a221d2c761c6a8ae21bfa3874988ff2583e19cf8a27bf2fee358df7942/pillow-12.2.0-cp312-cp312-win_amd64.whl", hash = "sha256:7f84204dee22a783350679a0333981df803dac21a0190d706a50475e361c93f5", size = 7084129, upload-time = "2026-04-01T14:43:37.213Z" }, + { url = "https://files.pythonhosted.org/packages/10/e1/542a474affab20fd4a0f1836cb234e8493519da6b76899e30bcc5d990b8b/pillow-12.2.0-cp312-cp312-win_arm64.whl", hash = "sha256:af73337013e0b3b46f175e79492d96845b16126ddf79c438d7ea7ff27783a414", size = 2463612, upload-time = "2026-04-01T14:43:39.421Z" }, + { url = "https://files.pythonhosted.org/packages/4a/01/53d10cf0dbad820a8db274d259a37ba50b88b24768ddccec07355382d5ad/pillow-12.2.0-cp313-cp313-ios_13_0_arm64_iphoneos.whl", hash = "sha256:8297651f5b5679c19968abefd6bb84d95fe30ef712eb1b2d9b2d31ca61267f4c", size = 4100837, upload-time = "2026-04-01T14:43:41.506Z" }, + { url = "https://files.pythonhosted.org/packages/0f/98/f3a6657ecb698c937f6c76ee564882945f29b79bad496abcba0e84659ec5/pillow-12.2.0-cp313-cp313-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:50d8520da2a6ce0af445fa6d648c4273c3eeefbc32d7ce049f22e8b5c3daecc2", size = 4176528, upload-time = "2026-04-01T14:43:43.773Z" }, + { url = "https://files.pythonhosted.org/packages/69/bc/8986948f05e3ea490b8442ea1c1d4d990b24a7e43d8a51b2c7d8b1dced36/pillow-12.2.0-cp313-cp313-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:766cef22385fa1091258ad7e6216792b156dc16d8d3fa607e7545b2b72061f1c", size = 3640401, upload-time = "2026-04-01T14:43:45.87Z" }, + { url = "https://files.pythonhosted.org/packages/34/46/6c717baadcd62bc8ed51d238d521ab651eaa74838291bda1f86fe1f864c9/pillow-12.2.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:5d2fd0fa6b5d9d1de415060363433f28da8b1526c1c129020435e186794b3795", size = 5308094, upload-time = "2026-04-01T14:43:48.438Z" }, + { url = "https://files.pythonhosted.org/packages/71/43/905a14a8b17fdb1ccb58d282454490662d2cb89a6bfec26af6d3520da5ec/pillow-12.2.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:56b25336f502b6ed02e889f4ece894a72612fe885889a6e8c4c80239ff6e5f5f", size = 4695402, upload-time = "2026-04-01T14:43:51.292Z" }, + { url = "https://files.pythonhosted.org/packages/73/dd/42107efcb777b16fa0393317eac58f5b5cf30e8392e266e76e51cff28c3d/pillow-12.2.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:f1c943e96e85df3d3478f7b691f229887e143f81fedab9b20205349ab04d73ed", size = 6280005, upload-time = "2026-04-01T14:43:54.242Z" }, + { url = "https://files.pythonhosted.org/packages/a8/68/b93e09e5e8549019e61acf49f65b1a8530765a7f812c77a7461bca7e4494/pillow-12.2.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:03f6fab9219220f041c74aeaa2939ff0062bd5c364ba9ce037197f4c6d498cd9", size = 8090669, upload-time = "2026-04-01T14:43:57.335Z" }, + { url = "https://files.pythonhosted.org/packages/4b/6e/3ccb54ce8ec4ddd1accd2d89004308b7b0b21c4ac3d20fa70af4760a4330/pillow-12.2.0-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5cdfebd752ec52bf5bb4e35d9c64b40826bc5b40a13df7c3cda20a2c03a0f5ed", size = 6395194, upload-time = "2026-04-01T14:43:59.864Z" }, + { url = "https://files.pythonhosted.org/packages/67/ee/21d4e8536afd1a328f01b359b4d3997b291ffd35a237c877b331c1c3b71c/pillow-12.2.0-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:eedf4b74eda2b5a4b2b2fb4c006d6295df3bf29e459e198c90ea48e130dc75c3", size = 7082423, upload-time = "2026-04-01T14:44:02.74Z" }, + { url = "https://files.pythonhosted.org/packages/78/5f/e9f86ab0146464e8c133fe85df987ed9e77e08b29d8d35f9f9f4d6f917ba/pillow-12.2.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:00a2865911330191c0b818c59103b58a5e697cae67042366970a6b6f1b20b7f9", size = 6505667, upload-time = "2026-04-01T14:44:05.381Z" }, + { url = "https://files.pythonhosted.org/packages/ed/1e/409007f56a2fdce61584fd3acbc2bbc259857d555196cedcadc68c015c82/pillow-12.2.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:1e1757442ed87f4912397c6d35a0db6a7b52592156014706f17658ff58bbf795", size = 7208580, upload-time = "2026-04-01T14:44:08.39Z" }, + { url = "https://files.pythonhosted.org/packages/23/c4/7349421080b12fb35414607b8871e9534546c128a11965fd4a7002ccfbee/pillow-12.2.0-cp313-cp313-win32.whl", hash = "sha256:144748b3af2d1b358d41286056d0003f47cb339b8c43a9ea42f5fea4d8c66b6e", size = 6375896, upload-time = "2026-04-01T14:44:11.197Z" }, + { url = "https://files.pythonhosted.org/packages/3f/82/8a3739a5e470b3c6cbb1d21d315800d8e16bff503d1f16b03a4ec3212786/pillow-12.2.0-cp313-cp313-win_amd64.whl", hash = "sha256:390ede346628ccc626e5730107cde16c42d3836b89662a115a921f28440e6a3b", size = 7081266, upload-time = "2026-04-01T14:44:13.947Z" }, + { url = "https://files.pythonhosted.org/packages/c3/25/f968f618a062574294592f668218f8af564830ccebdd1fa6200f598e65c5/pillow-12.2.0-cp313-cp313-win_arm64.whl", hash = "sha256:8023abc91fba39036dbce14a7d6535632f99c0b857807cbbbf21ecc9f4717f06", size = 2463508, upload-time = "2026-04-01T14:44:16.312Z" }, + { url = "https://files.pythonhosted.org/packages/4d/a4/b342930964e3cb4dce5038ae34b0eab4653334995336cd486c5a8c25a00c/pillow-12.2.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:042db20a421b9bafecc4b84a8b6e444686bd9d836c7fd24542db3e7df7baad9b", size = 5309927, upload-time = "2026-04-01T14:44:18.89Z" }, + { url = "https://files.pythonhosted.org/packages/9f/de/23198e0a65a9cf06123f5435a5d95cea62a635697f8f03d134d3f3a96151/pillow-12.2.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:dd025009355c926a84a612fecf58bb315a3f6814b17ead51a8e48d3823d9087f", size = 4698624, upload-time = "2026-04-01T14:44:21.115Z" }, + { url = "https://files.pythonhosted.org/packages/01/a6/1265e977f17d93ea37aa28aa81bad4fa597933879fac2520d24e021c8da3/pillow-12.2.0-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:88ddbc66737e277852913bd1e07c150cc7bb124539f94c4e2df5344494e0a612", size = 6321252, upload-time = "2026-04-01T14:44:23.663Z" }, + { url = "https://files.pythonhosted.org/packages/3c/83/5982eb4a285967baa70340320be9f88e57665a387e3a53a7f0db8231a0cd/pillow-12.2.0-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:d362d1878f00c142b7e1a16e6e5e780f02be8195123f164edf7eddd911eefe7c", size = 8126550, upload-time = "2026-04-01T14:44:26.772Z" }, + { url = "https://files.pythonhosted.org/packages/4e/48/6ffc514adce69f6050d0753b1a18fd920fce8cac87620d5a31231b04bfc5/pillow-12.2.0-cp313-cp313t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2c727a6d53cb0018aadd8018c2b938376af27914a68a492f59dfcaca650d5eea", size = 6433114, upload-time = "2026-04-01T14:44:29.615Z" }, + { url = "https://files.pythonhosted.org/packages/36/a3/f9a77144231fb8d40ee27107b4463e205fa4677e2ca2548e14da5cf18dce/pillow-12.2.0-cp313-cp313t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:efd8c21c98c5cc60653bcb311bef2ce0401642b7ce9d09e03a7da87c878289d4", size = 7115667, upload-time = "2026-04-01T14:44:32.773Z" }, + { url = "https://files.pythonhosted.org/packages/c1/fc/ac4ee3041e7d5a565e1c4fd72a113f03b6394cc72ab7089d27608f8aaccb/pillow-12.2.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:9f08483a632889536b8139663db60f6724bfcb443c96f1b18855860d7d5c0fd4", size = 6538966, upload-time = "2026-04-01T14:44:35.252Z" }, + { url = "https://files.pythonhosted.org/packages/c0/a8/27fb307055087f3668f6d0a8ccb636e7431d56ed0750e07a60547b1e083e/pillow-12.2.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:dac8d77255a37e81a2efcbd1fc05f1c15ee82200e6c240d7e127e25e365c39ea", size = 7238241, upload-time = "2026-04-01T14:44:37.875Z" }, + { url = "https://files.pythonhosted.org/packages/ad/4b/926ab182c07fccae9fcb120043464e1ff1564775ec8864f21a0ebce6ac25/pillow-12.2.0-cp313-cp313t-win32.whl", hash = "sha256:ee3120ae9dff32f121610bb08e4313be87e03efeadfc6c0d18f89127e24d0c24", size = 6379592, upload-time = "2026-04-01T14:44:40.336Z" }, + { url = "https://files.pythonhosted.org/packages/c2/c4/f9e476451a098181b30050cc4c9a3556b64c02cf6497ea421ac047e89e4b/pillow-12.2.0-cp313-cp313t-win_amd64.whl", hash = "sha256:325ca0528c6788d2a6c3d40e3568639398137346c3d6e66bb61db96b96511c98", size = 7085542, upload-time = "2026-04-01T14:44:43.251Z" }, + { url = "https://files.pythonhosted.org/packages/00/a4/285f12aeacbe2d6dc36c407dfbbe9e96d4a80b0fb710a337f6d2ad978c75/pillow-12.2.0-cp313-cp313t-win_arm64.whl", hash = "sha256:2e5a76d03a6c6dcef67edabda7a52494afa4035021a79c8558e14af25313d453", size = 2465765, upload-time = "2026-04-01T14:44:45.996Z" }, + { url = "https://files.pythonhosted.org/packages/bf/98/4595daa2365416a86cb0d495248a393dfc84e96d62ad080c8546256cb9c0/pillow-12.2.0-cp314-cp314-ios_13_0_arm64_iphoneos.whl", hash = "sha256:3adc9215e8be0448ed6e814966ecf3d9952f0ea40eb14e89a102b87f450660d8", size = 4100848, upload-time = "2026-04-01T14:44:48.48Z" }, + { url = "https://files.pythonhosted.org/packages/0b/79/40184d464cf89f6663e18dfcf7ca21aae2491fff1a16127681bf1fa9b8cf/pillow-12.2.0-cp314-cp314-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:6a9adfc6d24b10f89588096364cc726174118c62130c817c2837c60cf08a392b", size = 4176515, upload-time = "2026-04-01T14:44:51.353Z" }, + { url = "https://files.pythonhosted.org/packages/b0/63/703f86fd4c422a9cf722833670f4f71418fb116b2853ff7da722ea43f184/pillow-12.2.0-cp314-cp314-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:6a6e67ea2e6feda684ed370f9a1c52e7a243631c025ba42149a2cc5934dec295", size = 3640159, upload-time = "2026-04-01T14:44:53.588Z" }, + { url = "https://files.pythonhosted.org/packages/71/e0/fb22f797187d0be2270f83500aab851536101b254bfa1eae10795709d283/pillow-12.2.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:2bb4a8d594eacdfc59d9e5ad972aa8afdd48d584ffd5f13a937a664c3e7db0ed", size = 5312185, upload-time = "2026-04-01T14:44:56.039Z" }, + { url = "https://files.pythonhosted.org/packages/ba/8c/1a9e46228571de18f8e28f16fabdfc20212a5d019f3e3303452b3f0a580d/pillow-12.2.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:80b2da48193b2f33ed0c32c38140f9d3186583ce7d516526d462645fd98660ae", size = 4695386, upload-time = "2026-04-01T14:44:58.663Z" }, + { url = "https://files.pythonhosted.org/packages/70/62/98f6b7f0c88b9addd0e87c217ded307b36be024d4ff8869a812b241d1345/pillow-12.2.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:22db17c68434de69d8ecfc2fe821569195c0c373b25cccb9cbdacf2c6e53c601", size = 6280384, upload-time = "2026-04-01T14:45:01.5Z" }, + { url = "https://files.pythonhosted.org/packages/5e/03/688747d2e91cfbe0e64f316cd2e8005698f76ada3130d0194664174fa5de/pillow-12.2.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:7b14cc0106cd9aecda615dd6903840a058b4700fcb817687d0ee4fc8b6e389be", size = 8091599, upload-time = "2026-04-01T14:45:04.5Z" }, + { url = "https://files.pythonhosted.org/packages/f6/35/577e22b936fcdd66537329b33af0b4ccfefaeabd8aec04b266528cddb33c/pillow-12.2.0-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8cbeb542b2ebc6fcdacabf8aca8c1a97c9b3ad3927d46b8723f9d4f033288a0f", size = 6396021, upload-time = "2026-04-01T14:45:07.117Z" }, + { url = "https://files.pythonhosted.org/packages/11/8d/d2532ad2a603ca2b93ad9f5135732124e57811d0168155852f37fbce2458/pillow-12.2.0-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4bfd07bc812fbd20395212969e41931001fd59eb55a60658b0e5710872e95286", size = 7083360, upload-time = "2026-04-01T14:45:09.763Z" }, + { url = "https://files.pythonhosted.org/packages/5e/26/d325f9f56c7e039034897e7380e9cc202b1e368bfd04d4cbe6a441f02885/pillow-12.2.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:9aba9a17b623ef750a4d11b742cbafffeb48a869821252b30ee21b5e91392c50", size = 6507628, upload-time = "2026-04-01T14:45:12.378Z" }, + { url = "https://files.pythonhosted.org/packages/5f/f7/769d5632ffb0988f1c5e7660b3e731e30f7f8ec4318e94d0a5d674eb65a4/pillow-12.2.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:deede7c263feb25dba4e82ea23058a235dcc2fe1f6021025dc71f2b618e26104", size = 7209321, upload-time = "2026-04-01T14:45:15.122Z" }, + { url = "https://files.pythonhosted.org/packages/6a/7a/c253e3c645cd47f1aceea6a8bacdba9991bf45bb7dfe927f7c893e89c93c/pillow-12.2.0-cp314-cp314-win32.whl", hash = "sha256:632ff19b2778e43162304d50da0181ce24ac5bb8180122cbe1bf4673428328c7", size = 6479723, upload-time = "2026-04-01T14:45:17.797Z" }, + { url = "https://files.pythonhosted.org/packages/cd/8b/601e6566b957ca50e28725cb6c355c59c2c8609751efbecd980db44e0349/pillow-12.2.0-cp314-cp314-win_amd64.whl", hash = "sha256:4e6c62e9d237e9b65fac06857d511e90d8461a32adcc1b9065ea0c0fa3a28150", size = 7217400, upload-time = "2026-04-01T14:45:20.529Z" }, + { url = "https://files.pythonhosted.org/packages/d6/94/220e46c73065c3e2951bb91c11a1fb636c8c9ad427ac3ce7d7f3359b9b2f/pillow-12.2.0-cp314-cp314-win_arm64.whl", hash = "sha256:b1c1fbd8a5a1af3412a0810d060a78b5136ec0836c8a4ef9aa11807f2a22f4e1", size = 2554835, upload-time = "2026-04-01T14:45:23.162Z" }, + { url = "https://files.pythonhosted.org/packages/b6/ab/1b426a3974cb0e7da5c29ccff4807871d48110933a57207b5a676cccc155/pillow-12.2.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:57850958fe9c751670e49b2cecf6294acc99e562531f4bd317fa5ddee2068463", size = 5314225, upload-time = "2026-04-01T14:45:25.637Z" }, + { url = "https://files.pythonhosted.org/packages/19/1e/dce46f371be2438eecfee2a1960ee2a243bbe5e961890146d2dee1ff0f12/pillow-12.2.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:d5d38f1411c0ed9f97bcb49b7bd59b6b7c314e0e27420e34d99d844b9ce3b6f3", size = 4698541, upload-time = "2026-04-01T14:45:28.355Z" }, + { url = "https://files.pythonhosted.org/packages/55/c3/7fbecf70adb3a0c33b77a300dc52e424dc22ad8cdc06557a2e49523b703d/pillow-12.2.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:5c0a9f29ca8e79f09de89293f82fc9b0270bb4af1d58bc98f540cc4aedf03166", size = 6322251, upload-time = "2026-04-01T14:45:30.924Z" }, + { url = "https://files.pythonhosted.org/packages/1c/3c/7fbc17cfb7e4fe0ef1642e0abc17fc6c94c9f7a16be41498e12e2ba60408/pillow-12.2.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:1610dd6c61621ae1cf811bef44d77e149ce3f7b95afe66a4512f8c59f25d9ebe", size = 8127807, upload-time = "2026-04-01T14:45:33.908Z" }, + { url = "https://files.pythonhosted.org/packages/ff/c3/a8ae14d6defd2e448493ff512fae903b1e9bd40b72efb6ec55ce0048c8ce/pillow-12.2.0-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0a34329707af4f73cf1782a36cd2289c0368880654a2c11f027bcee9052d35dd", size = 6433935, upload-time = "2026-04-01T14:45:36.623Z" }, + { url = "https://files.pythonhosted.org/packages/6e/32/2880fb3a074847ac159d8f902cb43278a61e85f681661e7419e6596803ed/pillow-12.2.0-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8e9c4f5b3c546fa3458a29ab22646c1c6c787ea8f5ef51300e5a60300736905e", size = 7116720, upload-time = "2026-04-01T14:45:39.258Z" }, + { url = "https://files.pythonhosted.org/packages/46/87/495cc9c30e0129501643f24d320076f4cc54f718341df18cc70ec94c44e1/pillow-12.2.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:fb043ee2f06b41473269765c2feae53fc2e2fbf96e5e22ca94fb5ad677856f06", size = 6540498, upload-time = "2026-04-01T14:45:41.879Z" }, + { url = "https://files.pythonhosted.org/packages/18/53/773f5edca692009d883a72211b60fdaf8871cbef075eaa9d577f0a2f989e/pillow-12.2.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:f278f034eb75b4e8a13a54a876cc4a5ab39173d2cdd93a638e1b467fc545ac43", size = 7239413, upload-time = "2026-04-01T14:45:44.705Z" }, + { url = "https://files.pythonhosted.org/packages/c9/e4/4b64a97d71b2a83158134abbb2f5bd3f8a2ea691361282f010998f339ec7/pillow-12.2.0-cp314-cp314t-win32.whl", hash = "sha256:6bb77b2dcb06b20f9f4b4a8454caa581cd4dd0643a08bacf821216a16d9c8354", size = 6482084, upload-time = "2026-04-01T14:45:47.568Z" }, + { url = "https://files.pythonhosted.org/packages/ba/13/306d275efd3a3453f72114b7431c877d10b1154014c1ebbedd067770d629/pillow-12.2.0-cp314-cp314t-win_amd64.whl", hash = "sha256:6562ace0d3fb5f20ed7290f1f929cae41b25ae29528f2af1722966a0a02e2aa1", size = 7225152, upload-time = "2026-04-01T14:45:50.032Z" }, + { url = "https://files.pythonhosted.org/packages/ff/6e/cf826fae916b8658848d7b9f38d88da6396895c676e8086fc0988073aaf8/pillow-12.2.0-cp314-cp314t-win_arm64.whl", hash = "sha256:aa88ccfe4e32d362816319ed727a004423aab09c5cea43c01a4b435643fa34eb", size = 2556579, upload-time = "2026-04-01T14:45:52.529Z" }, + { url = "https://files.pythonhosted.org/packages/4e/b7/2437044fb910f499610356d1352e3423753c98e34f915252aafecc64889f/pillow-12.2.0-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:0538bd5e05efec03ae613fd89c4ce0368ecd2ba239cc25b9f9be7ed426b0af1f", size = 5273969, upload-time = "2026-04-01T14:45:55.538Z" }, + { url = "https://files.pythonhosted.org/packages/f6/f4/8316e31de11b780f4ac08ef3654a75555e624a98db1056ecb2122d008d5a/pillow-12.2.0-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:394167b21da716608eac917c60aa9b969421b5dcbbe02ae7f013e7b85811c69d", size = 4659674, upload-time = "2026-04-01T14:45:58.093Z" }, + { url = "https://files.pythonhosted.org/packages/d4/37/664fca7201f8bb2aa1d20e2c3d5564a62e6ae5111741966c8319ca802361/pillow-12.2.0-pp311-pypy311_pp73-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:5d04bfa02cc2d23b497d1e90a0f927070043f6cbf303e738300532379a4b4e0f", size = 5288479, upload-time = "2026-04-01T14:46:01.141Z" }, + { url = "https://files.pythonhosted.org/packages/49/62/5b0ed78fce87346be7a5cfcfaaad91f6a1f98c26f86bdbafa2066c647ef6/pillow-12.2.0-pp311-pypy311_pp73-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:0c838a5125cee37e68edec915651521191cef1e6aa336b855f495766e77a366e", size = 7032230, upload-time = "2026-04-01T14:46:03.874Z" }, + { url = "https://files.pythonhosted.org/packages/c3/28/ec0fc38107fc32536908034e990c47914c57cd7c5a3ece4d8d8f7ffd7e27/pillow-12.2.0-pp311-pypy311_pp73-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4a6c9fa44005fa37a91ebfc95d081e8079757d2e904b27103f4f5fa6f0bf78c0", size = 5355404, upload-time = "2026-04-01T14:46:06.33Z" }, + { url = "https://files.pythonhosted.org/packages/5e/8b/51b0eddcfa2180d60e41f06bd6d0a62202b20b59c68f5a132e615b75aecf/pillow-12.2.0-pp311-pypy311_pp73-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:25373b66e0dd5905ed63fa3cae13c82fbddf3079f2c8bf15c6fb6a35586324c1", size = 6002215, upload-time = "2026-04-01T14:46:08.83Z" }, + { url = "https://files.pythonhosted.org/packages/bc/60/5382c03e1970de634027cee8e1b7d39776b778b81812aaf45b694dfe9e28/pillow-12.2.0-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:bfa9c230d2fe991bed5318a5f119bd6780cda2915cca595393649fc118ab895e", size = 7080946, upload-time = "2026-04-01T14:46:11.734Z" }, +] + +[[package]] +name = "protobuf" +version = "7.34.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/6b/6b/a0e95cad1ad7cc3f2c6821fcab91671bd5b78bd42afb357bb4765f29bc41/protobuf-7.34.1.tar.gz", hash = "sha256:9ce42245e704cc5027be797c1db1eb93184d44d1cdd71811fb2d9b25ad541280", size = 454708, upload-time = "2026-03-20T17:34:47.036Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ec/11/3325d41e6ee15bf1125654301211247b042563bcc898784351252549a8ad/protobuf-7.34.1-cp310-abi3-macosx_10_9_universal2.whl", hash = "sha256:d8b2cc79c4d8f62b293ad9b11ec3aebce9af481fa73e64556969f7345ebf9fc7", size = 429247, upload-time = "2026-03-20T17:34:37.024Z" }, + { url = "https://files.pythonhosted.org/packages/eb/9d/aa69df2724ff63efa6f72307b483ce0827f4347cc6d6df24b59e26659fef/protobuf-7.34.1-cp310-abi3-manylinux2014_aarch64.whl", hash = "sha256:5185e0e948d07abe94bb76ec9b8416b604cfe5da6f871d67aad30cbf24c3110b", size = 325753, upload-time = "2026-03-20T17:34:38.751Z" }, + { url = "https://files.pythonhosted.org/packages/92/e8/d174c91fd48e50101943f042b09af9029064810b734e4160bbe282fa1caa/protobuf-7.34.1-cp310-abi3-manylinux2014_s390x.whl", hash = "sha256:403b093a6e28a960372b44e5eb081775c9b056e816a8029c61231743d63f881a", size = 340198, upload-time = "2026-03-20T17:34:39.871Z" }, + { url = "https://files.pythonhosted.org/packages/53/1b/3b431694a4dc6d37b9f653f0c64b0a0d9ec074ee810710c0c3da21d67ba7/protobuf-7.34.1-cp310-abi3-manylinux2014_x86_64.whl", hash = "sha256:8ff40ce8cd688f7265326b38d5a1bed9bfdf5e6723d49961432f83e21d5713e4", size = 324267, upload-time = "2026-03-20T17:34:41.1Z" }, + { url = "https://files.pythonhosted.org/packages/85/29/64de04a0ac142fb685fd09999bc3d337943fb386f3a0ec57f92fd8203f97/protobuf-7.34.1-cp310-abi3-win32.whl", hash = "sha256:34b84ce27680df7cca9f231043ada0daa55d0c44a2ddfaa58ec1d0d89d8bf60a", size = 426628, upload-time = "2026-03-20T17:34:42.536Z" }, + { url = "https://files.pythonhosted.org/packages/4d/87/cb5e585192a22b8bd457df5a2c16a75ea0db9674c3a0a39fc9347d84e075/protobuf-7.34.1-cp310-abi3-win_amd64.whl", hash = "sha256:e97b55646e6ce5cbb0954a8c28cd39a5869b59090dfaa7df4598a7fba869468c", size = 437901, upload-time = "2026-03-20T17:34:44.112Z" }, + { url = "https://files.pythonhosted.org/packages/88/95/608f665226bca68b736b79e457fded9a2a38c4f4379a4a7614303d9db3bc/protobuf-7.34.1-py3-none-any.whl", hash = "sha256:bb3812cd53aefea2b028ef42bd780f5b96407247f20c6ef7c679807e9d188f11", size = 170715, upload-time = "2026-03-20T17:34:45.384Z" }, +] + +[[package]] +name = "sympy" +version = "1.14.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "mpmath" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/83/d3/803453b36afefb7c2bb238361cd4ae6125a569b4db67cd9e79846ba2d68c/sympy-1.14.0.tar.gz", hash = "sha256:d3d3fe8df1e5a0b42f0e7bdf50541697dbe7d23746e894990c030e2b05e72517", size = 7793921, upload-time = "2025-04-27T18:05:01.611Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a2/09/77d55d46fd61b4a135c444fc97158ef34a095e5681d0a6c10b75bf356191/sympy-1.14.0-py3-none-any.whl", hash = "sha256:e091cc3e99d2141a0ba2847328f5479b05d94a6635cb96148ccb3f34671bd8f5", size = 6299353, upload-time = "2025-04-27T18:04:59.103Z" }, +] diff --git a/apps/backend/方案A-订单编排与自动兑换设计.md b/apps/backend/方案A-订单编排与自动兑换设计.md new file mode 100644 index 00000000..656b1c92 --- /dev/null +++ b/apps/backend/方案A-订单编排与自动兑换设计.md @@ -0,0 +1,1077 @@ +# 方案 A:订单编排与自动兑换设计 + +## 1. 目标 + +基于现有两个项目: + +- 后端 [order-site-backend](/Users/yml/codes/order-site-backend) +- 前端 [order-site-rewrite](/Users/yml/codes/order-site-rewrite) + +增加一条面向真实订单的自动化交付链路: + +1. 第三方平台通过订单创建成功或支付成功通知,将订单数据推送到我方后端。 +2. 后端根据订单和商品信息匹配需要发放的 CDK,并创建交付任务。 +3. 后端生成一个面向用户的领取链接,并通过可用渠道发送给用户。 +4. 用户打开链接后,通过 QQ 或 WX 完成角色绑定。 +5. 后端复用现有 Playwright 自动化能力,自动提交 CDK、执行绑定、生成截图。 +6. 前端实时展示绑定状态、兑换状态和最终截图。 + +该方案采用“订单编排层 + 浏览器自动化执行层”两层结构,不直接把现有浏览器会话工具当作订单系统使用。 + +## 2. 当前系统现状 + +### 2.1 后端现状 + +当前后端主线是浏览器会话兑换工具,而不是订单系统。 + +- Express 入口在 [src/index.js](/Users/yml/codes/order-site-backend/src/index.js:1) +- 现有路由只覆盖腾讯浏览器会话接口,在 [src/routes/tencent.js](/Users/yml/codes/order-site-backend/src/routes/tencent.js:1) +- 浏览器会话创建、刷新、关闭、兑换都集中在 [src/services/session.js](/Users/yml/codes/order-site-backend/src/services/session.js:80) +- 验证码识别与兑换提交流程在 [src/services/session-redeem.js](/Users/yml/codes/order-site-backend/src/services/session-redeem.js:3) +- 截图、结果 JSON、证明产物生成在 [src/services/session-proof.js](/Users/yml/codes/order-site-backend/src/services/session-proof.js:16) +- OCR 子服务使用 `uv` 启动,在 [src/services/ocr.js](/Users/yml/codes/order-site-backend/src/services/ocr.js:83) + +当前系统没有以下关键能力: + +- 没有订单表 +- 没有 CDK 库存表 +- 没有 webhook 接收和签名校验 +- 没有任务状态机 +- 没有用户领取链接管理 +- 没有消息投递模块 +- 没有浏览器会话与订单任务的绑定关系 + +### 2.2 前端现状 + +当前前端是一个单页腾讯兑换工具。 + +- 路由只包含 `/tx/browser`,在 [src/router/index.ts](/Users/yml/codes/order-site-rewrite/src/router/index.ts:1) +- 主页面在 [src/views/tx/TencentBrowserView.vue](/Users/yml/codes/order-site-rewrite/src/views/tx/TencentBrowserView.vue:1) +- 页面编排在 [src/composables/useTencentBrowserSessionPage.ts](/Users/yml/codes/order-site-rewrite/src/composables/useTencentBrowserSessionPage.ts:1) +- 状态轮询在 [src/composables/tencent/useTencentBrowserSessionPolling.ts](/Users/yml/codes/order-site-rewrite/src/composables/tencent/useTencentBrowserSessionPolling.ts:1) +- 兑换前角色确认、兑换按钮、结果展示已经具备基础 UI,可复用 + +当前前端没有以下能力: + +- 没有订单领取页 +- 没有 token 校验页 +- 没有订单详情页 +- 没有任务状态驱动的前端展示 +- 没有“订单上下文中的浏览器会话”概念 + +## 3. 总体架构 + +### 3.1 分层设计 + +建议分成 4 层: + +1. 平台接入层 + 负责接收订单创建成功、支付成功、售后等通知,完成签名校验和原始数据落库。 +2. 订单编排层 + 负责订单幂等、商品解析、CDK 预占、任务创建、链接生成、状态流转。 +3. 浏览器自动化执行层 + 复用现有腾讯浏览器会话、角色识别、验证码识别、兑换提交、截图生成能力。 +4. 用户交互层 + 用户通过领取链接进入前端页面,完成 QQ/WX 登录、角色确认、查看结果。 + +### 3.2 核心原则 + +- 订单与浏览器会话解耦 +- CDK 先预占、后核销 +- 所有平台通知必须幂等 +- 用户访问链接使用 token,不直接使用订单号鉴权 +- 浏览器自动化失败必须可重试、可人工接管 +- 所有关键节点都要保留审计数据 + +## 4. 业务流程 + +### 4.1 主流程 + +1. 平台发送订单通知到后端 webhook。 +2. 后端校验签名,按平台订单号幂等写入 `orders`。 +3. 根据订单商品、规格、渠道信息生成一个或多个 `delivery_tasks`。 +4. 系统从 `cdk_inventory` 中为任务预占可用 CDK。 +5. 系统生成 `claim_token`,构造领取链接。 +6. 系统通过站内消息、短信、客服人工转发或支付成功页展示,把链接给用户。 +7. 用户打开链接,前端调用后端校验 token 和任务状态。 +8. 用户选择 QQ 或 WX 登录,后端创建浏览器 session,并绑定到 `delivery_task`。 +9. 用户扫码后,后端识别角色、大区、昵称等信息。 +10. 前端展示识别结果,用户确认角色无误。 +11. 后端自动执行 CDK 提交、验证码识别、截图生成。 +12. 成功后任务进入 `redeemed`,CDK 进入 `delivered`。 +13. 前端展示截图、结果消息、订单号、角色信息。 + +### 4.2 异常流程 + +- webhook 重复推送:只更新状态,不重复创建任务 +- 商品无法匹配 CDK:任务进入 `manual_review` +- CDK 库存不足:任务进入 `waiting_inventory` +- token 过期:提示用户重新获取领取链接 +- 浏览器自动化失败:任务进入 `retry_pending` 或 `manual_review` +- 用户长时间未领取:任务进入 `expired`,释放预占 CDK + +## 5. 数据模型设计 + +建议先上 SQLite,跑通后如有并发压力再切 PostgreSQL。当前项目还没有数据库依赖,先用 SQLite 可以把复杂度压低。 + +### 5.1 `orders` + +订单主表。 + +字段建议: + +- `id` +- `platform` +- `platform_order_id` +- `order_status` +- `pay_status` +- `buyer_id` +- `buyer_name` +- `raw_payload_json` +- `created_at` +- `updated_at` +- `paid_at` + +约束建议: + +- `platform + platform_order_id` 唯一 + +### 5.2 `order_items` + +订单子项表,用于一单多商品。 + +字段建议: + +- `id` +- `order_id` +- `sku_code` +- `sku_name` +- `quantity` +- `spec_json` +- `delivery_mode` +- `created_at` +- `updated_at` + +### 5.3 `cdk_inventory` + +CDK 库存表。 + +字段建议: + +- `id` +- `batch_no` +- `sku_code` +- `cdk_code` +- `status` +- `reserved_by_task_id` +- `delivered_at` +- `invalid_reason` +- `created_at` +- `updated_at` + +状态建议: + +- `available` +- `reserved` +- `delivered` +- `invalid` + +### 5.4 `delivery_tasks` + +交付任务表,是整个方案的核心。 + +字段建议: + +- `id` +- `order_id` +- `order_item_id` +- `platform_order_id` +- `task_no` +- `task_status` +- `login_type` +- `claim_token_id` +- `reserved_cdk_id` +- `browser_session_id` +- `role_id` +- `role_name` +- `area` +- `partition` +- `nickname` +- `result_code` +- `result_message` +- `screenshot_path` +- `artifacts_json` +- `last_error` +- `retry_count` +- `expires_at` +- `claimed_at` +- `redeemed_at` +- `created_at` +- `updated_at` + +状态建议: + +- `pending_payment` +- `paid` +- `waiting_inventory` +- `cdk_reserved` +- `link_generated` +- `claimed` +- `role_confirmed` +- `redeeming` +- `redeemed` +- `retry_pending` +- `manual_review` +- `expired` +- `closed` + +### 5.5 `claim_tokens` + +领取链接表。 + +字段建议: + +- `id` +- `task_id` +- `token` +- `status` +- `expired_at` +- `used_at` +- `max_use_count` +- `used_count` +- `created_at` +- `updated_at` + +状态建议: + +- `active` +- `used` +- `expired` +- `revoked` + +### 5.6 `webhook_events` + +保留原始通知,便于审计和重放。 + +字段建议: + +- `id` +- `platform` +- `event_type` +- `event_key` +- `signature_valid` +- `headers_json` +- `query_json` +- `body_json` +- `processed` +- `process_error` +- `created_at` + +## 6. 后端模块拆分 + +建议在现有后端上新增以下模块。 + +### 6.1 路由层 + +新增路由目录建议: + +- `src/routes/webhooks.js` +- `src/routes/orders.js` +- `src/routes/claims.js` +- `src/routes/admin.js` + +说明: + +- `webhooks.js` 接平台推送 +- `orders.js` 给内部系统或管理端查订单和任务 +- `claims.js` 给用户领取页调用 +- `admin.js` 做手工补单、重试、失效处理 + +### 6.2 服务层 + +新增服务建议: + +- `src/services/order-service.js` +- `src/services/order-item-service.js` +- `src/services/delivery-task-service.js` +- `src/services/cdk-service.js` +- `src/services/claim-service.js` +- `src/services/webhook-service.js` +- `src/services/message-service.js` +- `src/services/redeem-executor-service.js` + +职责划分建议: + +- `webhook-service` + 负责签名校验、原始通知记录、事件分发 +- `order-service` + 负责订单幂等写入和状态更新 +- `delivery-task-service` + 负责任务状态机 +- `cdk-service` + 负责 CDK 分配、预占、释放、核销 +- `claim-service` + 负责 token 生成、校验、过期 +- `message-service` + 负责发链接 +- `redeem-executor-service` + 负责把任务桥接到现有腾讯浏览器会话执行器 + +### 6.3 持久化层 + +建议新增: + +- `src/db/` +- `src/repositories/` + +推荐目录: + +- `src/db/client.js` +- `src/repositories/order-repo.js` +- `src/repositories/task-repo.js` +- `src/repositories/cdk-repo.js` +- `src/repositories/claim-repo.js` +- `src/repositories/webhook-repo.js` + +## 7. 对现有浏览器自动化能力的复用方式 + +现有后端能力不废弃,而是作为执行器继续使用。 + +### 7.1 可直接复用的能力 + +- 创建浏览器会话:`createTencentBrowserSession` +- 轮询会话状态:`getTencentBrowserSession`、`getTencentBrowserSessionSummary` +- 扫码登录后识别角色信息:`session.js` 内已有活动页信息抽取逻辑 +- 执行兑换:`redeemTencentBrowserSession` +- 生成截图和结果 JSON:`saveRedeemArtifacts` +- OCR worker:继续使用当前 `uv run ocr-worker worker` + +### 7.2 需要补的一层包装 + +不要让订单系统直接操作裸 `sessionId`,应通过 `redeem-executor-service` 管理: + +1. 根据 `taskId` 创建浏览器 session +2. 把 `taskId <-> sessionId` 关系落库 +3. 用户扫码完成后,把识别到的角色信息同步回任务 +4. 用户确认角色后,再触发兑换 +5. 兑换完成后,把截图路径和最终结果回写到任务 + +## 8. API 设计 + +### 8.1 平台 webhook + +`POST /api/v1/webhooks/agiso/trade` + +职责: + +- 接收订单创建成功通知 +- 接收支付成功通知 +- 验签 +- 幂等入库 +- 推进任务状态 + +### 8.2 用户领取页相关 + +`GET /api/v1/claim/:token` + +返回: + +- token 是否有效 +- 订单号 +- 商品摘要 +- 当前任务状态 +- 是否已绑定角色 +- 是否已有截图 + +`POST /api/v1/claim/:token/session` + +入参: + +- `loginType: qq | wx` + +职责: + +- 校验 token +- 创建腾讯浏览器 session +- 将 session 绑定到任务 +- 返回二维码和 session 摘要 + +`GET /api/v1/claim/:token/session/summary` + +职责: + +- 轮询当前领取任务下的 session 状态 +- 返回角色信息、二维码状态、截图状态 + +`POST /api/v1/claim/:token/confirm-role` + +职责: + +- 用户确认当前角色无误 +- 把任务推进到 `role_confirmed` + +`POST /api/v1/claim/:token/redeem` + +职责: + +- 后端读取预占 CDK +- 调用执行器自动提交 +- 回写结果 + +`GET /api/v1/claim/:token/screenshot` + +职责: + +- 返回最终截图 + +### 8.3 管理后台或内部接口 + +`POST /api/v1/admin/tasks/:taskId/retry` + +`POST /api/v1/admin/tasks/:taskId/release-cdk` + +`POST /api/v1/admin/tasks/:taskId/close` + +`GET /api/v1/admin/orders/:orderId` + +`GET /api/v1/admin/tasks/:taskId` + +## 9. 运营后台设计 + +### 9.1 是否需要后台 + +需要,而且应作为正式模块纳入方案。 + +原因: + +- 订单通知会重复、失败或延迟,必须能人工排查 +- CDK 库存需要日常导入、校验、释放、核销 +- 自动化链路不是 100% 成功,必须有失败任务处理入口 +- 用户咨询时,需要快速看到订单、任务、角色、截图和错误原因 +- 后续一定会出现补发、重试、关闭任务、重新生成链接等运营动作 + +如果没有后台,系统上线后所有异常都会退化成查数据库、查日志、手改数据,维护成本会快速失控。 + +### 9.2 更稳的做法 + +更稳的做法不是把管理页面直接混进用户领取页,而是拆成两套入口: + +1. 用户前台 + 继续服务用户领取、扫码登录、角色确认、结果查看。 +2. 运营后台 + 专门服务订单排查、CDK 管理、任务重试、异常处理。 + +建议形式: + +- 用户前台继续使用 [order-site-rewrite](/Users/yml/codes/order-site-rewrite) +- 运营后台先作为同仓库下独立路由模块实现,例如 `/admin` +- 后端单独提供 `/api/v1/admin/*` 接口 + +这样做的好处: + +- 用户链路和运营链路隔离 +- 权限边界更清楚 +- 页面状态不会互相污染 +- 以后即使单独拆管理后台,也不会影响用户前台 + +### 9.3 后台第一版目标 + +第一版后台不追求复杂权限系统和漂亮界面,目标只有一个: + +让你能稳定运营这条自动交付链路。 + +第一版最少应覆盖: + +1. 查订单 +2. 查交付任务 +3. 导入和维护 CDK +4. 查看 webhook 日志 +5. 重试失败任务 +6. 释放预占 CDK +7. 重新生成领取链接 + +### 9.4 后台页面模块建议 + +#### 1. 概览页 + +展示核心数字: + +- 今日订单数 +- 已支付待领取数 +- 领取中任务数 +- 兑换成功数 +- 异常任务数 +- 库存不足的 SKU 数 + +这页主要用来让你快速判断系统是否在健康运行。 + +#### 2. 订单列表页 + +展示字段建议: + +- 订单号 +- 平台 +- 商品摘要 +- 支付状态 +- 订单状态 +- 创建时间 +- 更新时间 +- 对应任务数 + +支持筛选: + +- 平台订单号 +- 支付状态 +- 时间范围 +- 商品 SKU + +进入详情后可查看: + +- 原始订单通知 +- 子项列表 +- 对应交付任务 + +#### 3. 交付任务列表页 + +这是后台最核心的页面。 + +展示字段建议: + +- `task_no` +- 平台订单号 +- 商品 SKU +- 当前任务状态 +- 预占 CDK +- 登录方式 +- 角色名 +- 角色 ID +- 浏览器 sessionId +- 重试次数 +- 最后错误 +- 创建时间 +- 更新时间 + +支持操作: + +- 查看详情 +- 重新生成链接 +- 重试任务 +- 释放 CDK +- 关闭任务 +- 标记人工处理 + +#### 4. CDK 管理页 + +这是第二核心页面。 + +应支持: + +- 单条新增 +- 批量导入 +- 按 SKU 查看库存 +- 查看状态分布 +- 查看预占任务 +- 手动失效 +- 手动释放 + +展示字段建议: + +- `sku_code` +- `batch_no` +- `cdk_code` +- `status` +- `reserved_by_task_id` +- `created_at` +- `updated_at` + +导入方式建议至少支持: + +- 文本框多行粘贴导入 +- CSV 文件导入 + +导入校验建议: + +- 去重 +- 空值校验 +- SKU 必填 +- 批次号可选但建议保留 + +#### 5. webhook 日志页 + +展示: + +- 平台 +- 事件类型 +- 平台订单号 +- 验签是否通过 +- 是否处理成功 +- 错误信息 +- 到达时间 + +支持查看: + +- headers +- query +- body +- 对应生成的订单和任务 + +可选操作: + +- 手动重放处理 + +#### 6. 任务详情页 + +任务详情页建议整合以下信息: + +- 订单信息 +- 商品和 SKU +- claim token 状态 +- 浏览器 session 状态 +- 角色识别结果 +- 兑换结果 +- 截图预览 +- 原始错误日志 +- 操作按钮 + +这个页面会成为你处理异常单的主工作台。 + +### 9.5 后台最小接口集 + +建议新增后台接口: + +#### 订单 + +- `GET /api/v1/admin/orders` +- `GET /api/v1/admin/orders/:orderId` + +#### 任务 + +- `GET /api/v1/admin/tasks` +- `GET /api/v1/admin/tasks/:taskId` +- `POST /api/v1/admin/tasks/:taskId/retry` +- `POST /api/v1/admin/tasks/:taskId/close` +- `POST /api/v1/admin/tasks/:taskId/release-cdk` +- `POST /api/v1/admin/tasks/:taskId/regenerate-claim-link` +- `POST /api/v1/admin/tasks/:taskId/mark-manual-review` + +#### CDK + +- `GET /api/v1/admin/cdks` +- `POST /api/v1/admin/cdks` +- `POST /api/v1/admin/cdks/import` +- `POST /api/v1/admin/cdks/:cdkId/invalidate` +- `POST /api/v1/admin/cdks/:cdkId/release` + +#### webhook + +- `GET /api/v1/admin/webhook-events` +- `GET /api/v1/admin/webhook-events/:eventId` +- `POST /api/v1/admin/webhook-events/:eventId/replay` + +#### 概览 + +- `GET /api/v1/admin/dashboard/summary` + +### 9.6 后台权限建议 + +第一版建议至少分两层: + +1. `admin` + 可导入 CDK、改任务状态、重试任务、关闭任务、重放 webhook。 +2. `operator` + 可查订单、查任务、看截图、看日志,但不能做高风险修改。 + +如果第一版先不做完整账号体系,也至少要做一个最简单的后台鉴权,例如: + +- 独立后台口令 +- 基础登录态 +- 后端校验管理接口访问权限 + +不要把后台接口直接裸露给公网无鉴权访问。 + +### 9.7 后台与前台的边界 + +建议明确边界: + +- 前台只处理用户领取和结果查看 +- 后台只处理运营和异常处置 + +前台不要出现: + +- 手动改任务状态 +- 手动改 CDK +- 看其他订单 + +后台不要承担: + +- 用户扫码登录入口 +- 用户领取页展示逻辑 + +这样职责清晰,后续维护成本最低。 + +### 9.8 后台的落地顺序 + +建议放在整体计划的阶段 4 中作为正式内容,但有两个能力应提前做: + +1. CDK 导入 +2. 任务列表与详情查看 + +原因: + +- 没有 CDK 导入能力,阶段 1 根本跑不顺 +- 没有任务查看能力,阶段 2 和阶段 3 出问题时无法排查 + +因此更合适的执行顺序是: + +- 阶段 1 完成订单骨架后,先补一个极简后台 +- 阶段 2 和 3 继续完善业务闭环 +- 阶段 4 再补全 webhook 重放、统计面板、权限细化 + +### 9.9 后台最小可行版本 + +后台 MVP 建议包含: + +1. 登录页 +2. 概览页 +3. 订单列表页 +4. 任务列表页 +5. 任务详情页 +6. CDK 导入页 +7. webhook 日志页 + +这已经足够支撑第一版系统上线和日常运营。 + +### 9.10 结论 + +后端侧需要一个管理界面,而且应当作为正式模块设计,不建议临时拼接。 + +更稳的做法是: + +- 用户前台与运营后台分离 +- 后台先做最小可行版本 +- 优先覆盖查单、查任务、导入 CDK、重试异常 +- 后台接口独立鉴权 + +## 10. 前端设计 + +### 9.1 路由设计 + +现有前端只有 `/tx/browser`,建议新增: + +- `/claim/:token` + +这个页面才是正式业务入口。 + +`/tx/browser` 保留为内部调试工具页,不作为正式交付页面。 + +### 9.2 页面结构 + +建议页面拆成 4 个区域: + +1. 订单摘要区 + 展示订单号、商品名、状态说明 +2. 登录绑定区 + 复用现有二维码卡片能力 +3. 角色确认区 + 复用现有角色展示和确认能力 +4. 结果展示区 + 复用现有截图和结果卡片能力 + +### 9.3 可复用的前端模块 + +可以复用: + +- [TencentAuthCard.vue](/Users/yml/codes/order-site-rewrite/src/components/tencent/TencentAuthCard.vue:1) +- [TencentRedeemPanel.vue](/Users/yml/codes/order-site-rewrite/src/components/tencent/TencentRedeemPanel.vue:1) +- [TencentResultPanel.vue](/Users/yml/codes/order-site-rewrite/src/components/tencent/TencentResultPanel.vue:1) +- [useTencentBrowserSessionPolling.ts](/Users/yml/codes/order-site-rewrite/src/composables/tencent/useTencentBrowserSessionPolling.ts:1) +- [useTencentBrowserSessionPresentation.ts](/Users/yml/codes/order-site-rewrite/src/composables/tencent/useTencentBrowserSessionPresentation.ts:1) + +但需要改造: + +- 不再让前端自己输入兑换码 +- 不再让前端自己决定是否创建裸 session +- 页面所有操作都基于 `claim token` + +### 9.4 前端状态设计 + +建议页面状态围绕任务状态展开,而不是围绕裸 session: + +- `tokenValid` +- `taskStatus` +- `orderSummary` +- `session` +- `activityInfo` +- `roleConfirmed` +- `redeemResult` +- `screenshotUrl` + +## 11. 状态机设计 + +### 10.1 任务状态流转 + +主状态机建议如下: + +1. `pending_payment` +2. `paid` +3. `waiting_inventory` +4. `cdk_reserved` +5. `link_generated` +6. `claimed` +7. `role_confirmed` +8. `redeeming` +9. `redeemed` + +异常分支: + +- `retry_pending` +- `manual_review` +- `expired` +- `closed` + +### 10.2 CDK 状态流转 + +1. `available` +2. `reserved` +3. `delivered` + +异常分支: + +- `invalid` + +### 10.3 token 状态流转 + +1. `active` +2. `used` + +异常分支: + +- `expired` +- `revoked` + +## 12. 安全与风控 + +### 11.1 token 安全 + +- token 必须使用高熵随机串 +- 不要把订单号作为唯一访问凭证 +- token 要支持过期 +- token 要支持撤销 +- token 校验失败不要泄露订单是否存在 + +### 11.2 webhook 安全 + +- 验签必须在业务处理前完成 +- 保存原始 query、body、headers +- 根据平台订单号和事件类型做幂等 +- 对重复通知返回成功,避免平台无限重试 + +### 11.3 自动化风险 + +- 登录状态和验证码都可能失败 +- 活动页 DOM 变更会导致自动化失效 +- 平台风控弹窗可能导致流程卡死 +- 必须保留重试和人工兜底入口 + +## 13. 失败补偿机制 + +### 12.1 自动重试 + +可自动重试的场景: + +- OCR 识别失败 +- 页面刷新失败 +- 临时网络超时 +- 可恢复的验证码错误 + +不建议自动无限重试,应限制次数并回写错误。 + +### 12.2 人工接管 + +以下场景建议直接转人工: + +- 角色识别异常 +- 商品和 CDK 映射无法确认 +- 自动化连续失败 +- CDK 已疑似提交但返回结果不明确 + +### 12.3 过期处理 + +- 领取链接超时未使用,任务转 `expired` +- 任务过期后释放 `reserved` 状态的 CDK +- 如已创建浏览器 session,应主动清理 + +## 14. 技术选型建议 + +### 13.1 后端 + +当前后端是 Node.js + Express + Playwright,建议保持不变。 + +新增建议: + +- 数据库先用 SQLite +- 数据访问先用简单 repository 封装 +- 后续需要时再引入 ORM + +原因: + +- 先把主流程跑通 +- 当前系统规模小,没必要过早引入太重的基础设施 +- 浏览器自动化已经在 Node 侧,订单编排继续留在同一进程更简单 + +### 13.2 前端 + +当前前端是 Vue 3 + Element Plus,建议保持不变。 + +只需要增加: + +- claim 页面 +- claim 相关 API service +- task 视图模型 + +### 13.3 Python 相关 + +当前 Python 仅用于 OCR worker,继续使用 `uv` 管理即可。 + +如果后续需要: + +- 图像增强 +- 模板匹配 +- 截图后处理 + +仍然应放在独立 Python 子服务里,并统一通过 `uv` 维护依赖。 + +## 15. 推荐执行顺序 + +建议分 4 个阶段实施。 + +### 阶段 1:后端订单骨架 + +目标: + +- 接 webhook +- 落订单 +- 建任务 +- 预占 CDK +- 生成领取链接 + +要做的事: + +1. 增加数据库和表结构 +2. 实现 webhook 验签和幂等 +3. 实现订单、任务、CDK、token 的 repository 和 service +4. 实现任务状态机基础逻辑 +5. 实现领取链接生成 + +阶段产出: + +- 支付成功后,系统能自动生成领取任务和领取链接 + +### 阶段 2:极简后台 + 前端领取页 + +目标: + +- 有基本运营能力 +- 用户通过 token 打开页面 +- 看见订单信息 +- 创建登录 session +- 扫码并确认角色 + +要做的事: + +1. 新增 `/admin` 路由和极简后台骨架 +2. 实现订单列表、任务列表、任务详情、CDK 导入 +3. 新增 `/claim/:token` 路由 +4. 新增 claim 页面和 API service +5. 复用当前二维码、轮询、角色确认组件 +6. 页面状态从 `session 驱动` 改成 `task + session 双驱动` + +阶段产出: + +- 运营能查任务和导入 CDK,用户能进入正式领取页面并完成角色绑定 + +### 阶段 3:自动兑换闭环 + +目标: + +- 用户确认角色后自动提交 CDK +- 生成截图并前端展示 + +要做的事: + +1. 封装 `redeem-executor-service` +2. 将任务与 session 建立绑定关系 +3. 后端从预占 CDK 读取兑换码,不再由前端输入 +4. 回写截图路径、业务返回码、错误信息 + +阶段产出: + +- 从订单通知到最终截图形成完整闭环 + +### 阶段 4:补偿和完整运营能力 + +目标: + +- 可重试 +- 可过期释放 +- 可人工处理 +- 有更完整的后台能力 + +要做的事: + +1. 增加任务重试接口 +2. 增加过期清理任务 +3. 增加 webhook 重放 +4. 增加概览统计和异常筛选 +5. 增加后台权限细化 +6. 增加关键日志和审计落库 + +阶段产出: + +- 系统具备基本上线可运维性 + +## 16. 最小可行版本定义 + +第一版上线目标不建议追求“完全无人值守”,建议定义为: + +- 平台支付成功后自动创建领取任务 +- 自动预占 CDK +- 自动生成领取链接 +- 用户进入页面后自行扫码登录 +- 用户确认角色后系统自动提交 CDK +- 成功后展示截图 +- 运营可在后台查看订单、任务、库存和 webhook 日志 +- 失败后进入重试或人工处理 + +这是当前代码基础上最稳、最容易落地、售后风险最低的版本。 + +## 17. 关键决策结论 + +- 采用方案 A 是合理的 +- 不建议直接用订单号做访问凭证 +- 不建议前端继续保留“手输 CDK”的正式业务模式 +- 不建议一开始就做全自动零确认绑定 +- 建议先做订单编排层,再复用现有浏览器自动化执行层 +- 建议优先做 SQLite 版本跑通链路 +- 建议把 `/tx/browser` 保留为内部调试页,把 `/claim/:token` 做成正式业务入口 +- 建议把运营后台作为正式模块纳入,而不是后补工具页 + +## 18. 下一步落地建议 + +下一步建议直接进入“阶段 1 实施设计”,输出更细的开发清单: + +1. 数据库表结构 SQL +2. 后端目录和文件改造清单 +3. API 请求和响应示例 +4. 前台 `/claim/:token` 页面状态图 +5. 后台 `/admin` 页面结构与接口契约 +6. 任务状态机枚举定义 + +如果继续推进,我建议下一步先把: + +- 表结构 +- 后端 API 草案 +- 前端 `/claim/:token` 页面接口契约 +- 后台 `/admin` 核心接口契约 + +这三部分先定死,然后再开始编码。 diff --git a/apps/backend/阶段1-4实施规格说明.md b/apps/backend/阶段1-4实施规格说明.md new file mode 100644 index 00000000..ade398c7 --- /dev/null +++ b/apps/backend/阶段1-4实施规格说明.md @@ -0,0 +1,1111 @@ +# 阶段 1-4 实施规格说明 + +## 1. 目标 + +本文件把《方案A-订单编排与自动兑换设计》收敛成可直接编码的规格,覆盖: + +1. SQLite 表结构 SQL +2. 后端 API 契约 +3. 前台 `/claim/:token` 页面接口契约 +4. 后台 `/admin` 页面结构与接口契约 + +默认约束: + +- 后端仍使用 Node.js + Express +- 浏览器自动化仍复用现有 `src/services/session.js` +- Python 子服务继续使用 `uv` +- 第一版数据库使用 SQLite + +## 2. SQLite 表结构 + +建议新建目录: + +- `src/db/` +- `src/db/migrations/` + +建议首个 migration 文件: + +- `src/db/migrations/001_init.sql` + +以下 SQL 为第一版建议结构。 + +```sql +PRAGMA foreign_keys = ON; + +CREATE TABLE IF NOT EXISTS orders ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + platform TEXT NOT NULL, + platform_order_id TEXT NOT NULL, + order_status TEXT NOT NULL DEFAULT 'created', + pay_status TEXT NOT NULL DEFAULT 'unpaid', + buyer_id TEXT NOT NULL DEFAULT '', + buyer_name TEXT NOT NULL DEFAULT '', + receiver_contact TEXT NOT NULL DEFAULT '', + total_amount INTEGER NOT NULL DEFAULT 0, + currency TEXT NOT NULL DEFAULT 'CNY', + raw_payload_json TEXT NOT NULL DEFAULT '{}', + paid_at TEXT, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL, + UNIQUE(platform, platform_order_id) +); + +CREATE TABLE IF NOT EXISTS order_items ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + order_id INTEGER NOT NULL, + sku_code TEXT NOT NULL, + sku_name TEXT NOT NULL DEFAULT '', + quantity INTEGER NOT NULL DEFAULT 1, + spec_json TEXT NOT NULL DEFAULT '{}', + delivery_mode TEXT NOT NULL DEFAULT 'claim_link', + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL, + FOREIGN KEY(order_id) REFERENCES orders(id) ON DELETE CASCADE +); + +CREATE INDEX IF NOT EXISTS idx_order_items_order_id ON order_items(order_id); +CREATE INDEX IF NOT EXISTS idx_order_items_sku_code ON order_items(sku_code); + +CREATE TABLE IF NOT EXISTS cdk_inventory ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + batch_no TEXT NOT NULL DEFAULT '', + sku_code TEXT NOT NULL, + cdk_code TEXT NOT NULL, + status TEXT NOT NULL DEFAULT 'available', + reserved_by_task_id INTEGER, + invalid_reason TEXT NOT NULL DEFAULT '', + delivered_at TEXT, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL, + UNIQUE(sku_code, cdk_code) +); + +CREATE INDEX IF NOT EXISTS idx_cdk_inventory_sku_code ON cdk_inventory(sku_code); +CREATE INDEX IF NOT EXISTS idx_cdk_inventory_status ON cdk_inventory(status); +CREATE INDEX IF NOT EXISTS idx_cdk_inventory_reserved_task ON cdk_inventory(reserved_by_task_id); + +CREATE TABLE IF NOT EXISTS delivery_tasks ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + order_id INTEGER NOT NULL, + order_item_id INTEGER NOT NULL, + platform_order_id TEXT NOT NULL, + task_no TEXT NOT NULL, + task_status TEXT NOT NULL DEFAULT 'pending_payment', + login_type TEXT NOT NULL DEFAULT '', + claim_token_id INTEGER, + reserved_cdk_id INTEGER, + browser_session_id TEXT NOT NULL DEFAULT '', + nickname TEXT NOT NULL DEFAULT '', + role_id TEXT NOT NULL DEFAULT '', + role_name TEXT NOT NULL DEFAULT '', + area TEXT NOT NULL DEFAULT '', + partition_name TEXT NOT NULL DEFAULT '', + result_code TEXT NOT NULL DEFAULT '', + result_message TEXT NOT NULL DEFAULT '', + screenshot_path TEXT NOT NULL DEFAULT '', + artifacts_json TEXT NOT NULL DEFAULT '{}', + last_error TEXT NOT NULL DEFAULT '', + retry_count INTEGER NOT NULL DEFAULT 0, + expires_at TEXT, + claimed_at TEXT, + role_confirmed_at TEXT, + redeemed_at TEXT, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL, + FOREIGN KEY(order_id) REFERENCES orders(id) ON DELETE CASCADE, + FOREIGN KEY(order_item_id) REFERENCES order_items(id) ON DELETE CASCADE, + FOREIGN KEY(reserved_cdk_id) REFERENCES cdk_inventory(id) ON DELETE SET NULL +); + +CREATE UNIQUE INDEX IF NOT EXISTS idx_delivery_tasks_task_no ON delivery_tasks(task_no); +CREATE INDEX IF NOT EXISTS idx_delivery_tasks_order_id ON delivery_tasks(order_id); +CREATE INDEX IF NOT EXISTS idx_delivery_tasks_order_item_id ON delivery_tasks(order_item_id); +CREATE INDEX IF NOT EXISTS idx_delivery_tasks_status ON delivery_tasks(task_status); +CREATE INDEX IF NOT EXISTS idx_delivery_tasks_platform_order_id ON delivery_tasks(platform_order_id); + +CREATE TABLE IF NOT EXISTS claim_tokens ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + task_id INTEGER NOT NULL, + token TEXT NOT NULL, + status TEXT NOT NULL DEFAULT 'active', + expired_at TEXT NOT NULL, + used_at TEXT, + max_use_count INTEGER NOT NULL DEFAULT 1, + used_count INTEGER NOT NULL DEFAULT 0, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL, + FOREIGN KEY(task_id) REFERENCES delivery_tasks(id) ON DELETE CASCADE +); + +CREATE UNIQUE INDEX IF NOT EXISTS idx_claim_tokens_token ON claim_tokens(token); +CREATE INDEX IF NOT EXISTS idx_claim_tokens_task_id ON claim_tokens(task_id); +CREATE INDEX IF NOT EXISTS idx_claim_tokens_status ON claim_tokens(status); + +CREATE TABLE IF NOT EXISTS webhook_events ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + platform TEXT NOT NULL, + event_type TEXT NOT NULL, + event_key TEXT NOT NULL, + signature_valid INTEGER NOT NULL DEFAULT 0, + headers_json TEXT NOT NULL DEFAULT '{}', + query_json TEXT NOT NULL DEFAULT '{}', + body_json TEXT NOT NULL DEFAULT '{}', + processed INTEGER NOT NULL DEFAULT 0, + process_error TEXT NOT NULL DEFAULT '', + related_order_id INTEGER, + created_at TEXT NOT NULL +); + +CREATE INDEX IF NOT EXISTS idx_webhook_events_platform_key ON webhook_events(platform, event_key); +CREATE INDEX IF NOT EXISTS idx_webhook_events_processed ON webhook_events(processed); +CREATE INDEX IF NOT EXISTS idx_webhook_events_created_at ON webhook_events(created_at); +``` + +## 3. 枚举建议 + +### 3.1 `order_status` + +- `created` +- `paid` +- `closed` +- `refunding` +- `refunded` + +### 3.2 `pay_status` + +- `unpaid` +- `paid` +- `failed` +- `refunded` + +### 3.3 `task_status` + +- `pending_payment` +- `paid` +- `waiting_inventory` +- `cdk_reserved` +- `link_generated` +- `claimed` +- `role_confirmed` +- `redeeming` +- `redeemed` +- `retry_pending` +- `manual_review` +- `expired` +- `closed` + +### 3.4 `cdk_inventory.status` + +- `available` +- `reserved` +- `delivered` +- `invalid` + +### 3.5 `claim_tokens.status` + +- `active` +- `used` +- `expired` +- `revoked` + +## 4. 目录改造建议 + +建议后端增加以下文件: + +```text +src/ + db/ + client.js + migrate.js + migrations/ + 001_init.sql + repositories/ + order-repo.js + order-item-repo.js + task-repo.js + cdk-repo.js + claim-token-repo.js + webhook-event-repo.js + routes/ + webhooks.js + claims.js + admin.js + services/ + webhook-service.js + order-service.js + delivery-task-service.js + cdk-service.js + claim-service.js + redeem-executor-service.js + admin-service.js + utils/ + time.js + random.js + pagination.js +``` + +## 5. 后端 API 契约 + +统一响应建议: + +```json +{ + "code": 0, + "msg": "ok", + "data": {} +} +``` + +失败响应建议: + +```json +{ + "code": 40001, + "msg": "token 无效", + "data": null +} +``` + +--- + +## 6. webhook 接口 + +### 6.1 接收订单/支付通知 + +`POST /api/v1/webhooks/agiso/trade` + +说明: + +- 接收平台订单创建成功或支付成功通知 +- query 中可能带 `timestamp`、`sign`、`aopic` +- body 中可能带 `json` +- 具体字段以平台最终联调为准,但内部处理应统一为标准事件结构 + +处理步骤: + +1. 保存原始请求 +2. 验签 +3. 解析出平台订单号、事件类型、订单数据 +4. 按 `platform + platform_order_id + event_type` 做幂等 +5. 更新订单与交付任务 + +成功响应: + +```json +{ + "code": 0, + "msg": "success", + "data": { + "accepted": true, + "platformOrderId": "592823138", + "eventType": "payment_success" + } +} +``` + +--- + +## 7. 前台领取链路接口 + +### 7.1 校验领取链接 + +`GET /api/v1/claim/:token` + +用途: + +- 页面首次打开时获取任务和订单摘要 +- 校验 token 是否有效 + +响应示例: + +```json +{ + "code": 0, + "msg": "ok", + "data": { + "tokenStatus": "active", + "task": { + "taskId": 12, + "taskNo": "DT202604070001", + "status": "link_generated", + "expiresAt": "2026-04-08T12:00:00.000Z", + "claimedAt": null, + "roleConfirmedAt": null, + "redeemedAt": null, + "lastError": "" + }, + "order": { + "platformOrderId": "592823138", + "skuCode": "dnf-cdk-a", + "skuName": "DNF 活动礼包", + "quantity": 1 + }, + "session": null, + "result": null + } +} +``` + +失败场景: + +- token 不存在 +- token 已过期 +- token 已撤销 +- task 已关闭 + +### 7.2 创建领取会话 + +`POST /api/v1/claim/:token/session` + +请求体: + +```json +{ + "loginType": "qq" +} +``` + +说明: + +- 创建腾讯浏览器 session +- 将 session 与 task 绑定 +- task 状态推进到 `claimed` + +成功响应: + +```json +{ + "code": 0, + "msg": "浏览器会话已创建", + "data": { + "task": { + "taskId": 12, + "taskNo": "DT202604070001", + "status": "claimed" + }, + "session": { + "sessionId": "txbs-ab12cd34ef56", + "status": "waiting_scan", + "loginType": "qq", + "notice": "请使用QQ扫描二维码", + "qrImageBase64": "base64...", + "updatedAt": "2026-04-07T12:00:00.000Z", + "activityInfo": { + "nickname": "", + "role": { + "ready": false, + "roleName": "", + "roleId": "" + } + } + } + } +} +``` + +### 7.3 轮询领取会话状态 + +`GET /api/v1/claim/:token/session/summary` + +说明: + +- 轮询 task 绑定的 session +- 同时返回 task 最新状态 + +成功响应: + +```json +{ + "code": 0, + "msg": "ok", + "data": { + "task": { + "taskId": 12, + "taskNo": "DT202604070001", + "status": "claimed", + "lastError": "" + }, + "session": { + "sessionId": "txbs-ab12cd34ef56", + "status": "ready_to_redeem", + "loginType": "qq", + "notice": "登录已完成,当前角色 阿修罗", + "updatedAt": "2026-04-07T12:03:00.000Z", + "activityInfo": { + "nickname": "xxx", + "role": { + "ready": true, + "roleName": "阿修罗", + "roleId": "123456", + "area": "36", + "partition": "跨五" + } + }, + "artifacts": { + "hasScreenshot": false + } + } + } +} +``` + +### 7.4 确认角色 + +`POST /api/v1/claim/:token/confirm-role` + +请求体: + +```json +{ + "confirm": true +} +``` + +说明: + +- 只有当后端已识别到角色时才能确认 +- task 状态推进到 `role_confirmed` + +成功响应: + +```json +{ + "code": 0, + "msg": "角色已确认", + "data": { + "task": { + "taskId": 12, + "taskNo": "DT202604070001", + "status": "role_confirmed", + "roleConfirmedAt": "2026-04-07T12:05:00.000Z" + } + } +} +``` + +### 7.5 执行兑换 + +`POST /api/v1/claim/:token/redeem` + +请求体: + +```json +{} +``` + +说明: + +- 不再由前端传 CDK +- 后端从 `reserved_cdk_id` 读取对应兑换码 +- 执行成功后更新 task、cdk、截图路径 + +成功响应: + +```json +{ + "code": 0, + "msg": "兑换完成", + "data": { + "task": { + "taskId": 12, + "taskNo": "DT202604070001", + "status": "redeemed", + "resultCode": "0", + "resultMessage": "兑换成功", + "redeemedAt": "2026-04-07T12:06:00.000Z", + "screenshotReady": true + }, + "session": { + "sessionId": "txbs-ab12cd34ef56", + "status": "redeemed", + "notice": "兑换成功", + "artifacts": { + "hasScreenshot": true + } + } + } +} +``` + +失败响应建议: + +- `409` 当前状态不允许兑换 +- `400` 未确认角色 +- `409` 未预占 CDK +- `500` 自动化执行失败 + +### 7.6 获取结果截图 + +`GET /api/v1/claim/:token/screenshot` + +说明: + +- 返回图片流 +- 内部根据 task 取 `screenshot_path` + +--- + +## 8. 后台接口契约 + +### 8.1 登录 + +第一版可以先做简单登录: + +`POST /api/v1/admin/auth/login` + +请求体: + +```json +{ + "username": "admin", + "password": "******" +} +``` + +响应: + +```json +{ + "code": 0, + "msg": "ok", + "data": { + "token": "admin-session-token", + "role": "admin" + } +} +``` + +### 8.2 仪表盘 + +`GET /api/v1/admin/dashboard/summary` + +响应示例: + +```json +{ + "code": 0, + "msg": "ok", + "data": { + "todayOrders": 23, + "paidPendingClaim": 7, + "claimingTasks": 5, + "redeemedToday": 14, + "abnormalTasks": 2, + "lowInventorySkuCount": 1 + } +} +``` + +### 8.3 订单列表 + +`GET /api/v1/admin/orders` + +查询参数建议: + +- `page` +- `pageSize` +- `platformOrderId` +- `payStatus` +- `skuCode` +- `dateFrom` +- `dateTo` + +响应示例: + +```json +{ + "code": 0, + "msg": "ok", + "data": { + "items": [ + { + "orderId": 1, + "platform": "agiso", + "platformOrderId": "592823138", + "orderStatus": "paid", + "payStatus": "paid", + "buyerName": "", + "totalAmount": 300, + "createdAt": "2026-04-07T11:00:00.000Z", + "updatedAt": "2026-04-07T11:02:00.000Z", + "taskCount": 1 + } + ], + "pagination": { + "page": 1, + "pageSize": 20, + "total": 1 + } + } +} +``` + +### 8.4 订单详情 + +`GET /api/v1/admin/orders/:orderId` + +返回: + +- 订单主信息 +- 子项信息 +- 对应任务列表 +- webhook 摘要 + +### 8.5 任务列表 + +`GET /api/v1/admin/tasks` + +查询参数建议: + +- `page` +- `pageSize` +- `status` +- `platformOrderId` +- `taskNo` +- `skuCode` +- `roleId` +- `dateFrom` +- `dateTo` + +响应示例: + +```json +{ + "code": 0, + "msg": "ok", + "data": { + "items": [ + { + "taskId": 12, + "taskNo": "DT202604070001", + "platformOrderId": "592823138", + "skuCode": "dnf-cdk-a", + "status": "retry_pending", + "reservedCdkCodeMasked": "ABCD****WXYZ", + "loginType": "qq", + "roleName": "阿修罗", + "roleId": "123456", + "browserSessionId": "txbs-ab12cd34ef56", + "retryCount": 1, + "lastError": "OCR 识别失败", + "createdAt": "2026-04-07T11:00:00.000Z", + "updatedAt": "2026-04-07T11:06:00.000Z" + } + ], + "pagination": { + "page": 1, + "pageSize": 20, + "total": 1 + } + } +} +``` + +### 8.6 任务详情 + +`GET /api/v1/admin/tasks/:taskId` + +返回内容建议: + +- 任务基础信息 +- 订单摘要 +- token 信息 +- 预占 CDK 信息 +- 浏览器 session 摘要 +- 角色信息 +- 兑换结果 +- 截图 URL +- 操作按钮可用状态 + +### 8.7 重试任务 + +`POST /api/v1/admin/tasks/:taskId/retry` + +用途: + +- 对 `retry_pending` 或 `manual_review` 的任务发起重试 + +请求体: + +```json +{ + "reason": "manual retry" +} +``` + +### 8.8 重新生成领取链接 + +`POST /api/v1/admin/tasks/:taskId/regenerate-claim-link` + +说明: + +- 作废旧 token +- 创建新 token + +响应示例: + +```json +{ + "code": 0, + "msg": "领取链接已重新生成", + "data": { + "taskId": 12, + "claimUrl": "https://your-domain.com/#/claim/xxxxxxxx" + } +} +``` + +### 8.9 释放 CDK + +`POST /api/v1/admin/tasks/:taskId/release-cdk` + +说明: + +- 将任务绑定的 `reserved` CDK 释放回库存 +- 一般只允许未成功兑换任务执行 + +### 8.10 关闭任务 + +`POST /api/v1/admin/tasks/:taskId/close` + +用途: + +- 人工确认无需继续执行 + +### 8.11 CDK 列表 + +`GET /api/v1/admin/cdks` + +查询参数建议: + +- `page` +- `pageSize` +- `skuCode` +- `status` +- `batchNo` + +### 8.12 单条新增 CDK + +`POST /api/v1/admin/cdks` + +请求体: + +```json +{ + "skuCode": "dnf-cdk-a", + "batchNo": "20260407", + "cdkCode": "ABCDEFGH12345678" +} +``` + +### 8.13 批量导入 CDK + +`POST /api/v1/admin/cdks/import` + +请求体建议支持两种形式: + +形式一: + +```json +{ + "skuCode": "dnf-cdk-a", + "batchNo": "20260407", + "codes": [ + "AAAA-BBBB-CCCC", + "DDDD-EEEE-FFFF" + ] +} +``` + +形式二: + +```json +{ + "rows": [ + { + "skuCode": "dnf-cdk-a", + "batchNo": "20260407", + "cdkCode": "AAAA-BBBB-CCCC" + }, + { + "skuCode": "dnf-cdk-b", + "batchNo": "20260407", + "cdkCode": "DDDD-EEEE-FFFF" + } + ] +} +``` + +响应示例: + +```json +{ + "code": 0, + "msg": "导入完成", + "data": { + "total": 10, + "created": 8, + "duplicated": 2, + "invalid": 0 + } +} +``` + +### 8.14 webhook 列表 + +`GET /api/v1/admin/webhook-events` + +### 8.15 webhook 详情 + +`GET /api/v1/admin/webhook-events/:eventId` + +### 8.16 webhook 重放 + +`POST /api/v1/admin/webhook-events/:eventId/replay` + +--- + +## 9. 前台 `/claim/:token` 页面契约 + +### 9.1 路由 + +建议新增: + +- `/#/claim/:token` + +### 9.2 页面状态机 + +前台页面建议使用以下视图状态: + +- `loading` +- `invalid_token` +- `ready_to_start` +- `waiting_scan` +- `waiting_confirm_login` +- `waiting_role_ready` +- `waiting_role_confirm` +- `redeeming` +- `redeemed` +- `failed` +- `expired` + +### 9.3 页面数据模型 + +建议前端整理成: + +```ts +type ClaimPageModel = { + tokenStatus: 'active' | 'expired' | 'revoked' | 'used' + task: { + taskId: number + taskNo: string + status: string + lastError: string + expiresAt: string | null + claimedAt: string | null + roleConfirmedAt: string | null + redeemedAt: string | null + } + order: { + platformOrderId: string + skuCode: string + skuName: string + quantity: number + } + session: null | { + sessionId: string + status: string + loginType: 'qq' | 'wx' + notice: string + qrImageBase64?: string + updatedAt: string + activityInfo?: { + nickname: string + role: { + ready: boolean + roleName: string + roleId: string + area?: string + partition?: string + } + } + artifacts?: { + hasScreenshot: boolean + } + } + result: null | { + resultCode: string + resultMessage: string + screenshotUrl: string + } +} +``` + +### 9.4 页面动作 + +前台页面仅保留 4 个核心动作: + +1. 选择 QQ / WX 并创建 session +2. 轮询 session 状态 +3. 确认角色 +4. 发起兑换 + +前台不允许: + +- 手输 CDK +- 修改任务状态 +- 查看其他订单 + +### 9.5 页面展示区块 + +建议拆成: + +1. 订单摘要卡片 +2. 登录二维码卡片 +3. 角色确认卡片 +4. 结果卡片 + +现有可复用组件: + +- `TencentAuthCard.vue` +- `TencentRedeemPanel.vue` +- `TencentResultPanel.vue` + +### 9.6 轮询规则 + +轮询间隔建议继续沿用当前 2500ms。 + +停止轮询条件: + +- session 进入 `redeemed` +- task 进入 `redeemed` +- task 进入 `expired` +- task 进入 `closed` +- 连续失败达到阈值 + +## 10. 后台 `/admin` 页面契约 + +### 10.1 路由建议 + +- `/#/admin/login` +- `/#/admin/dashboard` +- `/#/admin/orders` +- `/#/admin/orders/:orderId` +- `/#/admin/tasks` +- `/#/admin/tasks/:taskId` +- `/#/admin/cdks` +- `/#/admin/webhook-events` +- `/#/admin/webhook-events/:eventId` + +### 10.2 页面说明 + +#### Dashboard + +展示: + +- 今日订单数 +- 待领取数 +- 兑换成功数 +- 异常任务数 +- 库存告警 + +#### Orders + +功能: + +- 筛选订单 +- 查看订单详情 +- 跳转任务 + +#### Tasks + +功能: + +- 筛选任务状态 +- 查看错误 +- 跳转详情 +- 快速执行重试、释放、关闭、重发链接 + +#### Task Detail + +功能: + +- 统一查看任务全量信息 +- 预览截图 +- 人工操作入口 + +#### CDKs + +功能: + +- 单条新增 +- 批量导入 +- 状态筛选 +- 查看预占关系 + +#### Webhook Events + +功能: + +- 查看原始推送 +- 查看处理结果 +- 重放处理 + +### 10.3 后台前端基础能力 + +后台第一版建议至少补: + +- 登录鉴权 +- 基础分页 +- 列表筛选 +- 操作确认弹窗 +- 错误提示 + +## 11. 实施顺序 + +### 11.1 第一批 + +先做: + +1. SQLite 接入 +2. migration +3. repository 层 +4. webhook 接口骨架 +5. 订单、任务、CDK、token 基础 service + +### 11.2 第二批 + +再做: + +1. `/claim/:token` 前台页 +2. claim 相关接口 +3. session 与 task 绑定 + +### 11.3 第三批 + +再做: + +1. 后端自动兑换桥接 +2. 兑换结果回写 +3. 截图读取 + +### 11.4 第四批 + +最后做: + +1. `/admin` 后台页 +2. 订单/任务/CDK/webhook 查询接口 +3. 重试、释放、重放 + +## 12. 建议的下一步 + +如果直接开始编码,建议按下面顺序落地: + +1. 先实现 `001_init.sql` +2. 实现 `src/db/client.js` +3. 实现 repository 层 +4. 接 `POST /api/v1/webhooks/agiso/trade` +5. 打通“支付成功 -> 建订单 -> 建任务 -> 预占 CDK -> 生成 token” +6. 再开始前台领取页 + +这条顺序最稳,返工也最少。 diff --git a/apps/frontend/.env.development b/apps/frontend/.env.development new file mode 100644 index 00000000..97235773 --- /dev/null +++ b/apps/frontend/.env.development @@ -0,0 +1 @@ +VITE_API_TARGET=http://127.0.0.1:3000 diff --git a/apps/frontend/.env.example b/apps/frontend/.env.example new file mode 100644 index 00000000..97235773 --- /dev/null +++ b/apps/frontend/.env.example @@ -0,0 +1 @@ +VITE_API_TARGET=http://127.0.0.1:3000 diff --git a/apps/frontend/.gitignore b/apps/frontend/.gitignore new file mode 100644 index 00000000..db98efbd --- /dev/null +++ b/apps/frontend/.gitignore @@ -0,0 +1,25 @@ +# Logs +logs +*.log +npm-debug.log* +yarn-debug.log* +yarn-error.log* +pnpm-debug.log* +lerna-debug.log* + +node_modules +dist +dist-ssr +*.local + +# Editor directories and files +.vscode/* +!.vscode/extensions.json +.idea +.DS_Store +*.suo +*.ntvs* +*.njsproj +*.sln +*.sw? +.ace-tool/ diff --git a/apps/frontend/.vscode/extensions.json b/apps/frontend/.vscode/extensions.json new file mode 100644 index 00000000..a7cea0b0 --- /dev/null +++ b/apps/frontend/.vscode/extensions.json @@ -0,0 +1,3 @@ +{ + "recommendations": ["Vue.volar"] +} diff --git a/apps/frontend/README.md b/apps/frontend/README.md new file mode 100644 index 00000000..b9501501 --- /dev/null +++ b/apps/frontend/README.md @@ -0,0 +1,64 @@ +# 腾讯浏览器兑换前端 + +这是一个聚焦腾讯浏览器会话兑换流程的 Vue 3 前端,当前只保留单一业务入口,不再承担历史迁移脚手架的职责。 + +## 功能范围 + +- 提供 `/tx/browser` 单页入口 +- 支持 QQ / 微信扫码登录切换 +- 支持浏览器会话创建、状态轮询、角色确认、兑换提交 +- 支持结果信息展示,以及在后端产物存在时预览截图 + +## 项目原则 + +- 保持页面职责单一,只服务腾讯浏览器兑换链路 +- 与本地后端接口保持一致,优先保证稳定性和可维护性 +- 页面层尽量薄,状态编排、轮询、展示映射分别收口 + +## 本地开发 + +安装依赖并启动开发环境: + +```bash +cd /Users/yml/codes/order-site-rewrite +npm install +npm run dev +``` + +构建生产包: + +```bash +npm run build +``` + +开发环境默认代理到本地后端: + +```bash +http://localhost:3000 +``` + +如需切换后端地址,启动前设置: + +```bash +VITE_API_TARGET=http://你的后端地址 npm run dev +``` + +## 结构说明 + +```text +src/ + lib/ HTTP 基础设施 + router/ 路由 + services/ 腾讯接口调用 + types/ 腾讯会话 / 活动 / 兑换类型 + composables/ 页面编排、轮询、展示映射 + components/ 腾讯页面子组件 + views/ 页面壳 + styles/ 全局样式 +``` + +## 当前状态 + +- 主页面已经拆成扫码卡片、兑换面板、结果面板三个组件 +- 会话轮询、错误提示、展示文案已分别从页面模板中抽离 +- 前端当前没有全局 store,所有状态都围绕单页流程局部管理 diff --git a/apps/frontend/index.html b/apps/frontend/index.html new file mode 100644 index 00000000..3dfc7eba --- /dev/null +++ b/apps/frontend/index.html @@ -0,0 +1,13 @@ + + + + + + + 自动兑换前端 + + +
+ + + diff --git a/apps/frontend/package-lock.json b/apps/frontend/package-lock.json new file mode 100644 index 00000000..87015d2a --- /dev/null +++ b/apps/frontend/package-lock.json @@ -0,0 +1,2170 @@ +{ + "name": "order-site-rewrite", + "version": "0.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "order-site-rewrite", + "version": "0.0.0", + "dependencies": { + "axios": "^1.9.0", + "element-plus": "^2.10.2", + "vue": "^3.5.30", + "vue-router": "^4.5.1" + }, + "devDependencies": { + "@types/node": "^24.12.0", + "@vitejs/plugin-vue": "^6.0.5", + "@vue/tsconfig": "^0.9.0", + "typescript": "~5.9.3", + "unplugin-auto-import": "^21.0.0", + "unplugin-vue-components": "^32.0.0", + "vite": "^8.0.7", + "vue-tsc": "^3.2.5" + } + }, + "node_modules/@babel/helper-string-parser": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz", + "integrity": "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz", + "integrity": "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/parser": { + "version": "7.29.2", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.2.tgz", + "integrity": "sha512-4GgRzy/+fsBa72/RZVJmGKPmZu9Byn8o4MoLpmNe1m8ZfYnz5emHLQz3U4gLud6Zwl0RZIcgiLD7Uq7ySFuDLA==", + "license": "MIT", + "dependencies": { + "@babel/types": "^7.29.0" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/types": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.0.tgz", + "integrity": "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==", + "license": "MIT", + "dependencies": { + "@babel/helper-string-parser": "^7.27.1", + "@babel/helper-validator-identifier": "^7.28.5" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@ctrl/tinycolor": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/@ctrl/tinycolor/-/tinycolor-4.2.0.tgz", + "integrity": "sha512-kzyuwOAQnXJNLS9PSyrk0CWk35nWJW/zl/6KvnTBMFK65gm7U1/Z5BqjxeapjZCIhQcM/DsrEmcbRwDyXyXK4A==", + "license": "MIT", + "engines": { + "node": ">=14" + } + }, + "node_modules/@element-plus/icons-vue": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/@element-plus/icons-vue/-/icons-vue-2.3.2.tgz", + "integrity": "sha512-OzIuTaIfC8QXEPmJvB4Y4kw34rSXdCJzxcD1kFStBvr8bK6X1zQAYDo0CNMjojnfTqRQCJ0I7prlErcoRiET2A==", + "license": "MIT", + "peerDependencies": { + "vue": "^3.2.0" + } + }, + "node_modules/@emnapi/wasi-threads": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.1.tgz", + "integrity": "sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@floating-ui/core": { + "version": "1.7.5", + "resolved": "https://registry.npmjs.org/@floating-ui/core/-/core-1.7.5.tgz", + "integrity": "sha512-1Ih4WTWyw0+lKyFMcBHGbb5U5FtuHJuujoyyr5zTaWS5EYMeT6Jb2AuDeftsCsEuchO+mM2ij5+q9crhydzLhQ==", + "license": "MIT", + "dependencies": { + "@floating-ui/utils": "^0.2.11" + } + }, + "node_modules/@floating-ui/dom": { + "version": "1.7.6", + "resolved": "https://registry.npmjs.org/@floating-ui/dom/-/dom-1.7.6.tgz", + "integrity": "sha512-9gZSAI5XM36880PPMm//9dfiEngYoC6Am2izES1FF406YFsjvyBMmeJ2g4SAju3xWwtuynNRFL2s9hgxpLI5SQ==", + "license": "MIT", + "dependencies": { + "@floating-ui/core": "^1.7.5", + "@floating-ui/utils": "^0.2.11" + } + }, + "node_modules/@floating-ui/utils": { + "version": "0.2.11", + "resolved": "https://registry.npmjs.org/@floating-ui/utils/-/utils-0.2.11.tgz", + "integrity": "sha512-RiB/yIh78pcIxl6lLMG0CgBXAZ2Y0eVHqMPYugu+9U0AeT6YBeiJpf7lbdJNIugFP5SIjwNRgo4DhR1Qxi26Gg==", + "license": "MIT" + }, + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.13", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", + "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/remapping": { + "version": "2.3.5", + "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz", + "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@napi-rs/wasm-runtime": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.2.tgz", + "integrity": "sha512-sNXv5oLJ7ob93xkZ1XnxisYhGYXfaG9f65/ZgYuAu3qt7b3NadcOEhLvx28hv31PgX8SZJRYrAIPQilQmFpLVw==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@tybys/wasm-util": "^0.10.1" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + }, + "peerDependencies": { + "@emnapi/core": "^1.7.1", + "@emnapi/runtime": "^1.7.1" + } + }, + "node_modules/@oxc-project/types": { + "version": "0.123.0", + "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.123.0.tgz", + "integrity": "sha512-YtECP/y8Mj1lSHiUWGSRzy/C6teUKlS87dEfuVKT09LgQbUsBW1rNg+MiJ4buGu3yuADV60gbIvo9/HplA56Ew==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/Boshen" + } + }, + "node_modules/@popperjs/core": { + "name": "@sxzz/popperjs-es", + "version": "2.11.8", + "resolved": "https://registry.npmjs.org/@sxzz/popperjs-es/-/popperjs-es-2.11.8.tgz", + "integrity": "sha512-wOwESXvvED3S8xBmcPWHs2dUuzrE4XiZeFu7e1hROIJkm02a49N120pmOXxY33sBb6hArItm5W5tcg1cBtV+HQ==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/popperjs" + } + }, + "node_modules/@rolldown/binding-android-arm64": { + "version": "1.0.0-rc.13", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.0.0-rc.13.tgz", + "integrity": "sha512-5ZiiecKH2DXAVJTNN13gNMUcCDg4Jy8ZjbXEsPnqa248wgOVeYRX0iqXXD5Jz4bI9BFHgKsI2qmyJynstbmr+g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-darwin-arm64": { + "version": "1.0.0-rc.13", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.0.0-rc.13.tgz", + "integrity": "sha512-tz/v/8G77seu8zAB3A5sK3UFoOl06zcshEzhUO62sAEtrEuW/H1CcyoupOrD+NbQJytYgA4CppXPzlrmp4JZKA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-darwin-x64": { + "version": "1.0.0-rc.13", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.0.0-rc.13.tgz", + "integrity": "sha512-8DakphqOz8JrMYWTJmWA+vDJxut6LijZ8Xcdc4flOlAhU7PNVwo2MaWBF9iXjJAPo5rC/IxEFZDhJ3GC7NHvug==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-freebsd-x64": { + "version": "1.0.0-rc.13", + "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.0.0-rc.13.tgz", + "integrity": "sha512-4wBQFfjDuXYN/SVI8inBF3Aa+isq40rc6VMFbk5jcpolUBTe5cYnMsHZ51nFWsx3PVyyNN3vgoESki0Hmr/4BA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm-gnueabihf": { + "version": "1.0.0-rc.13", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.0.0-rc.13.tgz", + "integrity": "sha512-JW/e4yPIXLms+jmnbwwy5LA/LxVwZUWLN8xug+V200wzaVi5TEGIWQlh8o91gWYFxW609euI98OCCemmWGuPrw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-gnu": { + "version": "1.0.0-rc.13", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.0.0-rc.13.tgz", + "integrity": "sha512-ZfKWpXiUymDnavepCaM6KG/uGydJ4l2nBmMxg60Ci4CbeefpqjPWpfaZM7PThOhk2dssqBAcwLc6rAyr0uTdXg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-musl": { + "version": "1.0.0-rc.13", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.0.0-rc.13.tgz", + "integrity": "sha512-bmRg3O6Z0gq9yodKKWCIpnlH051sEfdVwt+6m5UDffAQMUUqU0xjnQqqAUm+Gu7ofAAly9DqiQDtKu2nPDEABA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-ppc64-gnu": { + "version": "1.0.0-rc.13", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.0.0-rc.13.tgz", + "integrity": "sha512-8Wtnbw4k7pMYN9B/mOEAsQ8HOiq7AZ31Ig4M9BKn2So4xRaFEhtCSa4ZJaOutOWq50zpgR4N5+L/opnlaCx8wQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-s390x-gnu": { + "version": "1.0.0-rc.13", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.0.0-rc.13.tgz", + "integrity": "sha512-D/0Nlo8mQuxSMohNJUF2lDXWRsFDsHldfRRgD9bRgktj+EndGPj4DOV37LqDKPYS+osdyhZEH7fTakTAEcW7qg==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-gnu": { + "version": "1.0.0-rc.13", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.0.0-rc.13.tgz", + "integrity": "sha512-eRrPvat2YaVQcwwKi/JzOP6MKf1WRnOCr+VaI3cTWz3ZoLcP/654z90lVCJ4dAuMEpPdke0n+qyAqXDZdIC4rA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-musl": { + "version": "1.0.0-rc.13", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.0.0-rc.13.tgz", + "integrity": "sha512-PsdONiFRp8hR8KgVjTWjZ9s7uA3uueWL0t74/cKHfM4dR5zXYv4AjB8BvA+QDToqxAFg4ZkcVEqeu5F7inoz5w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-openharmony-arm64": { + "version": "1.0.0-rc.13", + "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.0.0-rc.13.tgz", + "integrity": "sha512-hCNXgC5dI3TVOLrPT++PKFNZ+1EtS0mLQwfXXXSUD/+rGlB65gZDwN/IDuxLpQP4x8RYYHqGomlUXzpO8aVI2w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-wasm32-wasi": { + "version": "1.0.0-rc.13", + "resolved": "https://registry.npmjs.org/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.0.0-rc.13.tgz", + "integrity": "sha512-viLS5C5et8NFtLWw9Sw3M/w4vvnVkbWkO7wSNh3C+7G1+uCkGpr6PcjNDSFcNtmXY/4trjPBqUfcOL+P3sWy/g==", + "cpu": [ + "wasm32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/core": "1.9.1", + "@emnapi/runtime": "1.9.1", + "@napi-rs/wasm-runtime": "^1.1.2" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@rolldown/binding-wasm32-wasi/node_modules/@emnapi/core": { + "version": "1.9.1", + "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.9.1.tgz", + "integrity": "sha512-mukuNALVsoix/w1BJwFzwXBN/dHeejQtuVzcDsfOEsdpCumXb/E9j8w11h5S54tT1xhifGfbbSm/ICrObRb3KA==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/wasi-threads": "1.2.0", + "tslib": "^2.4.0" + } + }, + "node_modules/@rolldown/binding-wasm32-wasi/node_modules/@emnapi/runtime": { + "version": "1.9.1", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.9.1.tgz", + "integrity": "sha512-VYi5+ZVLhpgK4hQ0TAjiQiZ6ol0oe4mBx7mVv7IflsiEp0OWoVsp/+f9Vc1hOhE0TtkORVrI1GvzyreqpgWtkA==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@rolldown/binding-wasm32-wasi/node_modules/@emnapi/wasi-threads": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.0.tgz", + "integrity": "sha512-N10dEJNSsUx41Z6pZsXU8FjPjpBEplgH24sfkmITrBED1/U2Esum9F3lfLrMjKHHjmi557zQn7kR9R+XWXu5Rg==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@rolldown/binding-win32-arm64-msvc": { + "version": "1.0.0-rc.13", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.0.0-rc.13.tgz", + "integrity": "sha512-Fqa3Tlt1xL4wzmAYxGNFV36Hb+VfPc9PYU+E25DAnswXv3ODDu/yyWjQDbXMo5AGWkQVjLgQExuVu8I/UaZhPQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-win32-x64-msvc": { + "version": "1.0.0-rc.13", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.0.0-rc.13.tgz", + "integrity": "sha512-/pLI5kPkGEi44TDlnbio3St/5gUFeN51YWNAk/Gnv6mEQBOahRBh52qVFVBpmrnU01n2yysvBML9Ynu7K4kGAQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/pluginutils": { + "version": "1.0.0-rc.2", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-rc.2.tgz", + "integrity": "sha512-izyXV/v+cHiRfozX62W9htOAvwMo4/bXKDrQ+vom1L1qRuexPock/7VZDAhnpHCLNejd3NJ6hiab+tO0D44Rgw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@tybys/wasm-util": { + "version": "0.10.1", + "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.1.tgz", + "integrity": "sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@types/estree": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", + "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/lodash": { + "version": "4.17.24", + "resolved": "https://registry.npmjs.org/@types/lodash/-/lodash-4.17.24.tgz", + "integrity": "sha512-gIW7lQLZbue7lRSWEFql49QJJWThrTFFeIMJdp3eH4tKoxm1OvEPg02rm4wCCSHS0cL3/Fizimb35b7k8atwsQ==", + "license": "MIT" + }, + "node_modules/@types/lodash-es": { + "version": "4.17.12", + "resolved": "https://registry.npmjs.org/@types/lodash-es/-/lodash-es-4.17.12.tgz", + "integrity": "sha512-0NgftHUcV4v34VhXm8QBSftKVXtbkBG3ViCjs6+eJ5a6y6Mi/jiFGPc1sC7QK+9BFhWrURE3EOggmWaSxL9OzQ==", + "license": "MIT", + "peer": true, + "dependencies": { + "@types/lodash": "*" + } + }, + "node_modules/@types/node": { + "version": "24.12.2", + "resolved": "https://registry.npmjs.org/@types/node/-/node-24.12.2.tgz", + "integrity": "sha512-A1sre26ke7HDIuY/M23nd9gfB+nrmhtYyMINbjI1zHJxYteKR6qSMX56FsmjMcDb3SMcjJg5BiRRgOCC/yBD0g==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "undici-types": "~7.16.0" + } + }, + "node_modules/@types/web-bluetooth": { + "version": "0.0.20", + "resolved": "https://registry.npmjs.org/@types/web-bluetooth/-/web-bluetooth-0.0.20.tgz", + "integrity": "sha512-g9gZnnXVq7gM7v3tJCWV/qw7w+KeOlSHAhgF9RytFyifW6AF61hdT2ucrYhPq9hLs5JIryeupHV3qGk95dH9ow==", + "license": "MIT" + }, + "node_modules/@vitejs/plugin-vue": { + "version": "6.0.5", + "resolved": "https://registry.npmjs.org/@vitejs/plugin-vue/-/plugin-vue-6.0.5.tgz", + "integrity": "sha512-bL3AxKuQySfk1iGcBsQnoRVexTPJq0Z/ixFVM8OhVJAP6ZXXXLtM7NFKWhLl30Kg7uTBqIaPXbh+nuQCuBDedg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@rolldown/pluginutils": "1.0.0-rc.2" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "peerDependencies": { + "vite": "^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0", + "vue": "^3.2.25" + } + }, + "node_modules/@volar/language-core": { + "version": "2.4.28", + "resolved": "https://registry.npmjs.org/@volar/language-core/-/language-core-2.4.28.tgz", + "integrity": "sha512-w4qhIJ8ZSitgLAkVay6AbcnC7gP3glYM3fYwKV3srj8m494E3xtrCv6E+bWviiK/8hs6e6t1ij1s2Endql7vzQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@volar/source-map": "2.4.28" + } + }, + "node_modules/@volar/source-map": { + "version": "2.4.28", + "resolved": "https://registry.npmjs.org/@volar/source-map/-/source-map-2.4.28.tgz", + "integrity": "sha512-yX2BDBqJkRXfKw8my8VarTyjv48QwxdJtvRgUpNE5erCsgEUdI2DsLbpa+rOQVAJYshY99szEcRDmyHbF10ggQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/@volar/typescript": { + "version": "2.4.28", + "resolved": "https://registry.npmjs.org/@volar/typescript/-/typescript-2.4.28.tgz", + "integrity": "sha512-Ja6yvWrbis2QtN4ClAKreeUZPVYMARDYZl9LMEv1iQ1QdepB6wn0jTRxA9MftYmYa4DQ4k/DaSZpFPUfxl8giw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@volar/language-core": "2.4.28", + "path-browserify": "^1.0.1", + "vscode-uri": "^3.0.8" + } + }, + "node_modules/@vue/compiler-core": { + "version": "3.5.32", + "resolved": "https://registry.npmjs.org/@vue/compiler-core/-/compiler-core-3.5.32.tgz", + "integrity": "sha512-4x74Tbtqnda8s/NSD6e1Dr5p1c8HdMU5RWSjMSUzb8RTcUQqevDCxVAitcLBKT+ie3o0Dl9crc/S/opJM7qBGQ==", + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.29.2", + "@vue/shared": "3.5.32", + "entities": "^7.0.1", + "estree-walker": "^2.0.2", + "source-map-js": "^1.2.1" + } + }, + "node_modules/@vue/compiler-dom": { + "version": "3.5.32", + "resolved": "https://registry.npmjs.org/@vue/compiler-dom/-/compiler-dom-3.5.32.tgz", + "integrity": "sha512-ybHAu70NtiEI1fvAUz3oXZqkUYEe5J98GjMDpTGl5iHb0T15wQYLR4wE3h9xfuTNA+Cm2f4czfe8B4s+CCH57Q==", + "license": "MIT", + "dependencies": { + "@vue/compiler-core": "3.5.32", + "@vue/shared": "3.5.32" + } + }, + "node_modules/@vue/compiler-sfc": { + "version": "3.5.32", + "resolved": "https://registry.npmjs.org/@vue/compiler-sfc/-/compiler-sfc-3.5.32.tgz", + "integrity": "sha512-8UYUYo71cP/0YHMO814TRZlPuUUw3oifHuMR7Wp9SNoRSrxRQnhMLNlCeaODNn6kNTJsjFoQ/kqIj4qGvya4Xg==", + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.29.2", + "@vue/compiler-core": "3.5.32", + "@vue/compiler-dom": "3.5.32", + "@vue/compiler-ssr": "3.5.32", + "@vue/shared": "3.5.32", + "estree-walker": "^2.0.2", + "magic-string": "^0.30.21", + "postcss": "^8.5.8", + "source-map-js": "^1.2.1" + } + }, + "node_modules/@vue/compiler-ssr": { + "version": "3.5.32", + "resolved": "https://registry.npmjs.org/@vue/compiler-ssr/-/compiler-ssr-3.5.32.tgz", + "integrity": "sha512-Gp4gTs22T3DgRotZ8aA/6m2jMR+GMztvBXUBEUOYOcST+giyGWJ4WvFd7QLHBkzTxkfOt8IELKNdpzITLbA2rw==", + "license": "MIT", + "dependencies": { + "@vue/compiler-dom": "3.5.32", + "@vue/shared": "3.5.32" + } + }, + "node_modules/@vue/devtools-api": { + "version": "6.6.4", + "resolved": "https://registry.npmjs.org/@vue/devtools-api/-/devtools-api-6.6.4.tgz", + "integrity": "sha512-sGhTPMuXqZ1rVOk32RylztWkfXTRhuS7vgAKv0zjqk8gbsHkJ7xfFf+jbySxt7tWObEJwyKaHMikV/WGDiQm8g==", + "license": "MIT" + }, + "node_modules/@vue/language-core": { + "version": "3.2.6", + "resolved": "https://registry.npmjs.org/@vue/language-core/-/language-core-3.2.6.tgz", + "integrity": "sha512-xYYYX3/aVup576tP/23sEUpgiEnujrENaoNRbaozC1/MA9I6EGFQRJb4xrt/MmUCAGlxTKL2RmT8JLTPqagCkg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@volar/language-core": "2.4.28", + "@vue/compiler-dom": "^3.5.0", + "@vue/shared": "^3.5.0", + "alien-signals": "^3.0.0", + "muggle-string": "^0.4.1", + "path-browserify": "^1.0.1", + "picomatch": "^4.0.2" + } + }, + "node_modules/@vue/reactivity": { + "version": "3.5.32", + "resolved": "https://registry.npmjs.org/@vue/reactivity/-/reactivity-3.5.32.tgz", + "integrity": "sha512-/ORasxSGvZ6MN5gc+uE364SxFdJ0+WqVG0CENXaGW58TOCdrAW76WWaplDtECeS1qphvtBZtR+3/o1g1zL4xPQ==", + "license": "MIT", + "dependencies": { + "@vue/shared": "3.5.32" + } + }, + "node_modules/@vue/runtime-core": { + "version": "3.5.32", + "resolved": "https://registry.npmjs.org/@vue/runtime-core/-/runtime-core-3.5.32.tgz", + "integrity": "sha512-pDrXCejn4UpFDFmMd27AcJEbHaLemaE5o4pbb7sLk79SRIhc6/t34BQA7SGNgYtbMnvbF/HHOftYBgFJtUoJUQ==", + "license": "MIT", + "dependencies": { + "@vue/reactivity": "3.5.32", + "@vue/shared": "3.5.32" + } + }, + "node_modules/@vue/runtime-dom": { + "version": "3.5.32", + "resolved": "https://registry.npmjs.org/@vue/runtime-dom/-/runtime-dom-3.5.32.tgz", + "integrity": "sha512-1CDVv7tv/IV13V8Nip1k/aaObVbWqRlVCVezTwx3K07p7Vxossp5JU1dcPNhJk3w347gonIUT9jQOGutyJrSVQ==", + "license": "MIT", + "dependencies": { + "@vue/reactivity": "3.5.32", + "@vue/runtime-core": "3.5.32", + "@vue/shared": "3.5.32", + "csstype": "^3.2.3" + } + }, + "node_modules/@vue/server-renderer": { + "version": "3.5.32", + "resolved": "https://registry.npmjs.org/@vue/server-renderer/-/server-renderer-3.5.32.tgz", + "integrity": "sha512-IOjm2+JQwRFS7W28HNuJeXQle9KdZbODFY7hFGVtnnghF51ta20EWAZJHX+zLGtsHhaU6uC9BGPV52KVpYryMQ==", + "license": "MIT", + "dependencies": { + "@vue/compiler-ssr": "3.5.32", + "@vue/shared": "3.5.32" + }, + "peerDependencies": { + "vue": "3.5.32" + } + }, + "node_modules/@vue/shared": { + "version": "3.5.32", + "resolved": "https://registry.npmjs.org/@vue/shared/-/shared-3.5.32.tgz", + "integrity": "sha512-ksNyrmRQzWJJ8n3cRDuSF7zNNontuJg1YHnmWRJd2AMu8Ij2bqwiiri2lH5rHtYPZjj4STkNcgcmiQqlOjiYGg==", + "license": "MIT" + }, + "node_modules/@vue/tsconfig": { + "version": "0.9.1", + "resolved": "https://registry.npmjs.org/@vue/tsconfig/-/tsconfig-0.9.1.tgz", + "integrity": "sha512-buvjm+9NzLCJL29KY1j1991YYJ5e6275OiK+G4jtmfIb+z4POywbdm0wXusT9adVWqe0xqg70TbI7+mRx4uU9w==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "typescript": ">= 5.8", + "vue": "^3.4.0" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + }, + "vue": { + "optional": true + } + } + }, + "node_modules/@vueuse/core": { + "version": "12.0.0", + "resolved": "https://registry.npmjs.org/@vueuse/core/-/core-12.0.0.tgz", + "integrity": "sha512-C12RukhXiJCbx4MGhjmd/gH52TjJsc3G0E0kQj/kb19H3Nt6n1CA4DRWuTdWWcaFRdlTe0npWDS942mvacvNBw==", + "license": "MIT", + "dependencies": { + "@types/web-bluetooth": "^0.0.20", + "@vueuse/metadata": "12.0.0", + "@vueuse/shared": "12.0.0", + "vue": "^3.5.13" + }, + "funding": { + "url": "https://github.com/sponsors/antfu" + } + }, + "node_modules/@vueuse/metadata": { + "version": "12.0.0", + "resolved": "https://registry.npmjs.org/@vueuse/metadata/-/metadata-12.0.0.tgz", + "integrity": "sha512-Yzimd1D3sjxTDOlF05HekU5aSGdKjxhuhRFHA7gDWLn57PRbBIh+SF5NmjhJ0WRgF3my7T8LBucyxdFJjIfRJQ==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/antfu" + } + }, + "node_modules/@vueuse/shared": { + "version": "12.0.0", + "resolved": "https://registry.npmjs.org/@vueuse/shared/-/shared-12.0.0.tgz", + "integrity": "sha512-3i6qtcq2PIio5i/vVYidkkcgvmTjCqrf26u+Fd4LhnbBmIT6FN8y6q/GJERp8lfcB9zVEfjdV0Br0443qZuJpw==", + "license": "MIT", + "dependencies": { + "vue": "^3.5.13" + }, + "funding": { + "url": "https://github.com/sponsors/antfu" + } + }, + "node_modules/acorn": { + "version": "8.16.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz", + "integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==", + "dev": true, + "license": "MIT", + "bin": { + "acorn": "bin/acorn" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/alien-signals": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/alien-signals/-/alien-signals-3.1.2.tgz", + "integrity": "sha512-d9dYqZTS90WLiU0I5c6DHj/HcKkF8ZyGN3G5x8wSbslulz70KOxaqCT0hQCo9KOyhVqzqGojvNdJXoTumZOtcw==", + "dev": true, + "license": "MIT" + }, + "node_modules/async-validator": { + "version": "4.2.5", + "resolved": "https://registry.npmjs.org/async-validator/-/async-validator-4.2.5.tgz", + "integrity": "sha512-7HhHjtERjqlNbZtqNqy2rckN/SpOOlmDliet+lP7k+eKZEjPk3DgyeU9lIXLdeLz0uBbbVp+9Qdow9wJWgwwfg==", + "license": "MIT" + }, + "node_modules/asynckit": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", + "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==", + "license": "MIT" + }, + "node_modules/axios": { + "version": "1.14.0", + "resolved": "https://registry.npmjs.org/axios/-/axios-1.14.0.tgz", + "integrity": "sha512-3Y8yrqLSwjuzpXuZ0oIYZ/XGgLwUIBU3uLvbcpb0pidD9ctpShJd43KSlEEkVQg6DS0G9NKyzOvBfUtDKEyHvQ==", + "license": "MIT", + "dependencies": { + "follow-redirects": "^1.15.11", + "form-data": "^4.0.5", + "proxy-from-env": "^2.1.0" + } + }, + "node_modules/call-bind-apply-helpers": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/chokidar": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-5.0.0.tgz", + "integrity": "sha512-TQMmc3w+5AxjpL8iIiwebF73dRDF4fBIieAqGn9RGCWaEVwQ6Fb2cGe31Yns0RRIzii5goJ1Y7xbMwo1TxMplw==", + "dev": true, + "license": "MIT", + "dependencies": { + "readdirp": "^5.0.0" + }, + "engines": { + "node": ">= 20.19.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/combined-stream": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", + "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", + "license": "MIT", + "dependencies": { + "delayed-stream": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/confbox": { + "version": "0.2.4", + "resolved": "https://registry.npmjs.org/confbox/-/confbox-0.2.4.tgz", + "integrity": "sha512-ysOGlgTFbN2/Y6Cg3Iye8YKulHw+R2fNXHrgSmXISQdMnomY6eNDprVdW9R5xBguEqI954+S6709UyiO7B+6OQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/csstype": { + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", + "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", + "license": "MIT" + }, + "node_modules/dayjs": { + "version": "1.11.20", + "resolved": "https://registry.npmjs.org/dayjs/-/dayjs-1.11.20.tgz", + "integrity": "sha512-YbwwqR/uYpeoP4pu043q+LTDLFBLApUP6VxRihdfNTqu4ubqMlGDLd6ErXhEgsyvY0K6nCs7nggYumAN+9uEuQ==", + "license": "MIT" + }, + "node_modules/delayed-stream": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", + "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==", + "license": "MIT", + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=8" + } + }, + "node_modules/dunder-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/element-plus": { + "version": "2.13.6", + "resolved": "https://registry.npmjs.org/element-plus/-/element-plus-2.13.6.tgz", + "integrity": "sha512-XHgwXr8Fjz6i+6BaqFhAbae/dJbG7bBAAlHrY3pWL7dpj+JcqcOyKYt4Oy5KP86FQwS1k4uIZDjCx2FyUR5lDg==", + "license": "MIT", + "dependencies": { + "@ctrl/tinycolor": "^4.2.0", + "@element-plus/icons-vue": "^2.3.2", + "@floating-ui/dom": "^1.0.1", + "@popperjs/core": "npm:@sxzz/popperjs-es@^2.11.7", + "@types/lodash": "^4.17.20", + "@types/lodash-es": "^4.17.12", + "@vueuse/core": "12.0.0", + "async-validator": "^4.2.5", + "dayjs": "^1.11.19", + "lodash": "^4.17.23", + "lodash-es": "^4.17.23", + "lodash-unified": "^1.0.3", + "memoize-one": "^6.0.0", + "normalize-wheel-es": "^1.2.0", + "vue-component-type-helpers": "^3.2.4" + }, + "peerDependencies": { + "vue": "^3.3.0" + } + }, + "node_modules/entities": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/entities/-/entities-7.0.1.tgz", + "integrity": "sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, + "node_modules/es-define-property": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-object-atoms": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz", + "integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-set-tostringtag": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz", + "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.6", + "has-tostringtag": "^1.0.2", + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/escape-string-regexp": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-5.0.0.tgz", + "integrity": "sha512-/veY75JbMK4j1yjvuUxuVsiS/hr/4iHs9FTT6cgTexxdE0Ly/glccBAkloH/DofkjRbZU3bnoj38mOmhkZ0lHw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/estree-walker": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-2.0.2.tgz", + "integrity": "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==", + "license": "MIT" + }, + "node_modules/exsolve": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/exsolve/-/exsolve-1.0.8.tgz", + "integrity": "sha512-LmDxfWXwcTArk8fUEnOfSZpHOJ6zOMUJKOtFLFqJLoKJetuQG874Uc7/Kki7zFLzYybmZhp1M7+98pfMqeX8yA==", + "dev": true, + "license": "MIT" + }, + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/follow-redirects": { + "version": "1.15.11", + "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.11.tgz", + "integrity": "sha512-deG2P0JfjrTxl50XGCDyfI97ZGVCxIpfKYmfyrQ54n5FO/0gfIES8C/Psl6kWVDolizcaaxZJnTS0QSMxvnsBQ==", + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/RubenVerborgh" + } + ], + "license": "MIT", + "engines": { + "node": ">=4.0" + }, + "peerDependenciesMeta": { + "debug": { + "optional": true + } + } + }, + "node_modules/form-data": { + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.5.tgz", + "integrity": "sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==", + "license": "MIT", + "dependencies": { + "asynckit": "^0.4.0", + "combined-stream": "^1.0.8", + "es-set-tostringtag": "^2.1.0", + "hasown": "^2.0.2", + "mime-types": "^2.1.12" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "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", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-intrinsic": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "function-bind": "^1.1.2", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/gopd": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-symbols": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-tostringtag": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz", + "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==", + "license": "MIT", + "dependencies": { + "has-symbols": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/hasown": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", + "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==", + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/js-tokens": { + "version": "9.0.1", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-9.0.1.tgz", + "integrity": "sha512-mxa9E9ITFOt0ban3j6L5MpjwegGz6lBQmM1IJkWeBZGcMxto50+eWdjC/52xDbS2vy0k7vIMK0Fe2wfL9OQSpQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/lightningcss": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz", + "integrity": "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==", + "dev": true, + "license": "MPL-2.0", + "dependencies": { + "detect-libc": "^2.0.3" + }, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + }, + "optionalDependencies": { + "lightningcss-android-arm64": "1.32.0", + "lightningcss-darwin-arm64": "1.32.0", + "lightningcss-darwin-x64": "1.32.0", + "lightningcss-freebsd-x64": "1.32.0", + "lightningcss-linux-arm-gnueabihf": "1.32.0", + "lightningcss-linux-arm64-gnu": "1.32.0", + "lightningcss-linux-arm64-musl": "1.32.0", + "lightningcss-linux-x64-gnu": "1.32.0", + "lightningcss-linux-x64-musl": "1.32.0", + "lightningcss-win32-arm64-msvc": "1.32.0", + "lightningcss-win32-x64-msvc": "1.32.0" + } + }, + "node_modules/lightningcss-android-arm64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.32.0.tgz", + "integrity": "sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-arm64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.32.0.tgz", + "integrity": "sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-x64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.32.0.tgz", + "integrity": "sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-freebsd-x64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.32.0.tgz", + "integrity": "sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm-gnueabihf": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.32.0.tgz", + "integrity": "sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-gnu": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.32.0.tgz", + "integrity": "sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-musl": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.32.0.tgz", + "integrity": "sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-gnu": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.32.0.tgz", + "integrity": "sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-musl": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.32.0.tgz", + "integrity": "sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-arm64-msvc": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.32.0.tgz", + "integrity": "sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-x64-msvc": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.32.0.tgz", + "integrity": "sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/local-pkg": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/local-pkg/-/local-pkg-1.1.2.tgz", + "integrity": "sha512-arhlxbFRmoQHl33a0Zkle/YWlmNwoyt6QNZEIJcqNbdrsix5Lvc4HyyI3EnwxTYlZYc32EbYrQ8SzEZ7dqgg9A==", + "dev": true, + "license": "MIT", + "dependencies": { + "mlly": "^1.7.4", + "pkg-types": "^2.3.0", + "quansync": "^0.2.11" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/antfu" + } + }, + "node_modules/lodash": { + "version": "4.18.1", + "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.18.1.tgz", + "integrity": "sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==", + "license": "MIT", + "peer": true + }, + "node_modules/lodash-es": { + "version": "4.18.1", + "resolved": "https://registry.npmjs.org/lodash-es/-/lodash-es-4.18.1.tgz", + "integrity": "sha512-J8xewKD/Gk22OZbhpOVSwcs60zhd95ESDwezOFuA3/099925PdHJ7OFHNTGtajL3AlZkykD32HykiMo+BIBI8A==", + "license": "MIT", + "peer": true + }, + "node_modules/lodash-unified": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/lodash-unified/-/lodash-unified-1.0.3.tgz", + "integrity": "sha512-WK9qSozxXOD7ZJQlpSqOT+om2ZfcT4yO+03FuzAHD0wF6S0l0090LRPDx3vhTTLZ8cFKpBn+IOcVXK6qOcIlfQ==", + "license": "MIT", + "peerDependencies": { + "@types/lodash-es": "*", + "lodash": "*", + "lodash-es": "*" + } + }, + "node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, + "node_modules/math-intrinsics": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/memoize-one": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/memoize-one/-/memoize-one-6.0.0.tgz", + "integrity": "sha512-rkpe71W0N0c0Xz6QD0eJETuWAJGnJ9afsl1srmwPrI+yBCkge5EycXXbYRyvL29zZVUWQCY7InPRCv3GDXuZNw==", + "license": "MIT" + }, + "node_modules/mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "license": "MIT", + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mlly": { + "version": "1.8.2", + "resolved": "https://registry.npmjs.org/mlly/-/mlly-1.8.2.tgz", + "integrity": "sha512-d+ObxMQFmbt10sretNDytwt85VrbkhhUA/JBGm1MPaWJ65Cl4wOgLaB1NYvJSZ0Ef03MMEU/0xpPMXUIQ29UfA==", + "dev": true, + "license": "MIT", + "dependencies": { + "acorn": "^8.16.0", + "pathe": "^2.0.3", + "pkg-types": "^1.3.1", + "ufo": "^1.6.3" + } + }, + "node_modules/mlly/node_modules/confbox": { + "version": "0.1.8", + "resolved": "https://registry.npmjs.org/confbox/-/confbox-0.1.8.tgz", + "integrity": "sha512-RMtmw0iFkeR4YV+fUOSucriAQNb9g8zFR52MWCtl+cCZOFRNL6zeB395vPzFhEjjn4fMxXudmELnl/KF/WrK6w==", + "dev": true, + "license": "MIT" + }, + "node_modules/mlly/node_modules/pkg-types": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/pkg-types/-/pkg-types-1.3.1.tgz", + "integrity": "sha512-/Jm5M4RvtBFVkKWRu2BLUTNP8/M2a+UwuAX+ae4770q1qVGtfjG+WTCupoZixokjmHiry8uI+dlY8KXYV5HVVQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "confbox": "^0.1.8", + "mlly": "^1.7.4", + "pathe": "^2.0.1" + } + }, + "node_modules/muggle-string": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/muggle-string/-/muggle-string-0.4.1.tgz", + "integrity": "sha512-VNTrAak/KhO2i8dqqnqnAHOa3cYBwXEZe9h+D5h/1ZqFSTEFHdM65lR7RoIqq3tBBYavsOXV84NoHXZ0AkPyqQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/nanoid": { + "version": "3.3.11", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", + "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/normalize-wheel-es": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/normalize-wheel-es/-/normalize-wheel-es-1.2.0.tgz", + "integrity": "sha512-Wj7+EJQ8mSuXr2iWfnujrimU35R2W4FAErEyTmJoJ7ucwTn2hOUSsRehMb5RSYkxXGTM7Y9QpvPmp++w5ftoJw==", + "license": "BSD-3-Clause" + }, + "node_modules/obug": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/obug/-/obug-2.1.1.tgz", + "integrity": "sha512-uTqF9MuPraAQ+IsnPf366RG4cP9RtUi7MLO1N3KEc+wb0a6yKpeL0lmk2IB1jY5KHPAlTc6T/JRdC/YqxHNwkQ==", + "dev": true, + "funding": [ + "https://github.com/sponsors/sxzz", + "https://opencollective.com/debug" + ], + "license": "MIT" + }, + "node_modules/path-browserify": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/path-browserify/-/path-browserify-1.0.1.tgz", + "integrity": "sha512-b7uo2UCUOYZcnF/3ID0lulOJi/bafxa1xPe7ZPsammBSpjSWQkjNxlt635YGS2MiR9GjvuXCtz2emr3jbsz98g==", + "dev": true, + "license": "MIT" + }, + "node_modules/pathe": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", + "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", + "dev": true, + "license": "MIT" + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", + "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/pkg-types": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/pkg-types/-/pkg-types-2.3.0.tgz", + "integrity": "sha512-SIqCzDRg0s9npO5XQ3tNZioRY1uK06lA41ynBC1YmFTmnY6FjUjVt6s4LoADmwoig1qqD0oK8h1p/8mlMx8Oig==", + "dev": true, + "license": "MIT", + "dependencies": { + "confbox": "^0.2.2", + "exsolve": "^1.0.7", + "pathe": "^2.0.3" + } + }, + "node_modules/postcss": { + "version": "8.5.8", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.8.tgz", + "integrity": "sha512-OW/rX8O/jXnm82Ey1k44pObPtdblfiuWnrd8X7GJ7emImCOstunGbXUpp7HdBrFQX6rJzn3sPT397Wp5aCwCHg==", + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.11", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/proxy-from-env": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-2.1.0.tgz", + "integrity": "sha512-cJ+oHTW1VAEa8cJslgmUZrc+sjRKgAKl3Zyse6+PV38hZe/V6Z14TbCuXcan9F9ghlz4QrFr2c92TNF82UkYHA==", + "license": "MIT", + "engines": { + "node": ">=10" + } + }, + "node_modules/quansync": { + "version": "0.2.11", + "resolved": "https://registry.npmjs.org/quansync/-/quansync-0.2.11.tgz", + "integrity": "sha512-AifT7QEbW9Nri4tAwR5M/uzpBuqfZf+zwaEM/QkzEjj7NBuFD2rBuy0K3dE+8wltbezDV7JMA0WfnCPYRSYbXA==", + "dev": true, + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/antfu" + }, + { + "type": "individual", + "url": "https://github.com/sponsors/sxzz" + } + ], + "license": "MIT" + }, + "node_modules/readdirp": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-5.0.0.tgz", + "integrity": "sha512-9u/XQ1pvrQtYyMpZe7DXKv2p5CNvyVwzUB6uhLAnQwHMSgKMBR62lc7AHljaeteeHXn11XTAaLLUVZYVZyuRBQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 20.19.0" + }, + "funding": { + "type": "individual", + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/rolldown": { + "version": "1.0.0-rc.13", + "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.0.0-rc.13.tgz", + "integrity": "sha512-bvVj8YJmf0rq4pSFmH7laLa6pYrhghv3PRzrCdRAr23g66zOKVJ4wkvFtgohtPLWmthgg8/rkaqRHrpUEh0Zbw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@oxc-project/types": "=0.123.0", + "@rolldown/pluginutils": "1.0.0-rc.13" + }, + "bin": { + "rolldown": "bin/cli.mjs" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "optionalDependencies": { + "@rolldown/binding-android-arm64": "1.0.0-rc.13", + "@rolldown/binding-darwin-arm64": "1.0.0-rc.13", + "@rolldown/binding-darwin-x64": "1.0.0-rc.13", + "@rolldown/binding-freebsd-x64": "1.0.0-rc.13", + "@rolldown/binding-linux-arm-gnueabihf": "1.0.0-rc.13", + "@rolldown/binding-linux-arm64-gnu": "1.0.0-rc.13", + "@rolldown/binding-linux-arm64-musl": "1.0.0-rc.13", + "@rolldown/binding-linux-ppc64-gnu": "1.0.0-rc.13", + "@rolldown/binding-linux-s390x-gnu": "1.0.0-rc.13", + "@rolldown/binding-linux-x64-gnu": "1.0.0-rc.13", + "@rolldown/binding-linux-x64-musl": "1.0.0-rc.13", + "@rolldown/binding-openharmony-arm64": "1.0.0-rc.13", + "@rolldown/binding-wasm32-wasi": "1.0.0-rc.13", + "@rolldown/binding-win32-arm64-msvc": "1.0.0-rc.13", + "@rolldown/binding-win32-x64-msvc": "1.0.0-rc.13" + } + }, + "node_modules/rolldown/node_modules/@rolldown/pluginutils": { + "version": "1.0.0-rc.13", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-rc.13.tgz", + "integrity": "sha512-3ngTAv6F/Py35BsYbeeLeecvhMKdsKm4AoOETVhAA+Qc8nrA2I0kF7oa93mE9qnIurngOSpMnQ0x2nQY2FPviA==", + "dev": true, + "license": "MIT" + }, + "node_modules/scule": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/scule/-/scule-1.3.0.tgz", + "integrity": "sha512-6FtHJEvt+pVMIB9IBY+IcCJ6Z5f1iQnytgyfKMhDKgmzYG+TeH/wx1y3l27rshSbLiSanrR9ffZDrEsmjlQF2g==", + "dev": true, + "license": "MIT" + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/strip-literal": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/strip-literal/-/strip-literal-3.1.0.tgz", + "integrity": "sha512-8r3mkIM/2+PpjHoOtiAW8Rg3jJLHaV7xPwG+YRGrv6FP0wwk/toTpATxWYOW0BKdWwl82VT2tFYi5DlROa0Mxg==", + "dev": true, + "license": "MIT", + "dependencies": { + "js-tokens": "^9.0.1" + }, + "funding": { + "url": "https://github.com/sponsors/antfu" + } + }, + "node_modules/tinyglobby": { + "version": "0.2.15", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz", + "integrity": "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.3" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "dev": true, + "license": "0BSD", + "optional": true + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "devOptional": true, + "license": "Apache-2.0", + "peer": true, + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/ufo": { + "version": "1.6.3", + "resolved": "https://registry.npmjs.org/ufo/-/ufo-1.6.3.tgz", + "integrity": "sha512-yDJTmhydvl5lJzBmy/hyOAA0d+aqCBuwl818haVdYCRrWV84o7YyeVm4QlVHStqNrrJSTb6jKuFAVqAFsr+K3Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/undici-types": { + "version": "7.16.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.16.0.tgz", + "integrity": "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==", + "dev": true, + "license": "MIT" + }, + "node_modules/unimport": { + "version": "5.7.0", + "resolved": "https://registry.npmjs.org/unimport/-/unimport-5.7.0.tgz", + "integrity": "sha512-njnL6sp8lEA8QQbZrt+52p/g4X0rw3bnGGmUcJnt1jeG8+iiqO779aGz0PirCtydAIVcuTBRlJ52F0u46z309Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "acorn": "^8.16.0", + "escape-string-regexp": "^5.0.0", + "estree-walker": "^3.0.3", + "local-pkg": "^1.1.2", + "magic-string": "^0.30.21", + "mlly": "^1.8.0", + "pathe": "^2.0.3", + "picomatch": "^4.0.3", + "pkg-types": "^2.3.0", + "scule": "^1.3.0", + "strip-literal": "^3.1.0", + "tinyglobby": "^0.2.15", + "unplugin": "^2.3.11", + "unplugin-utils": "^0.3.1" + }, + "engines": { + "node": ">=18.12.0" + } + }, + "node_modules/unimport/node_modules/estree-walker": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", + "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0" + } + }, + "node_modules/unplugin": { + "version": "2.3.11", + "resolved": "https://registry.npmjs.org/unplugin/-/unplugin-2.3.11.tgz", + "integrity": "sha512-5uKD0nqiYVzlmCRs01Fhs2BdkEgBS3SAVP6ndrBsuK42iC2+JHyxM05Rm9G8+5mkmRtzMZGY8Ct5+mliZxU/Ww==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/remapping": "^2.3.5", + "acorn": "^8.15.0", + "picomatch": "^4.0.3", + "webpack-virtual-modules": "^0.6.2" + }, + "engines": { + "node": ">=18.12.0" + } + }, + "node_modules/unplugin-auto-import": { + "version": "21.0.0", + "resolved": "https://registry.npmjs.org/unplugin-auto-import/-/unplugin-auto-import-21.0.0.tgz", + "integrity": "sha512-vWuC8SwqJmxZFYwPojhOhOXDb5xFhNNcEVb9K/RFkyk/3VnfaOjzitWN7v+8DEKpMjSsY2AEGXNgt6I0yQrhRQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "local-pkg": "^1.1.2", + "magic-string": "^0.30.21", + "picomatch": "^4.0.3", + "unimport": "^5.6.0", + "unplugin": "^2.3.11", + "unplugin-utils": "^0.3.1" + }, + "engines": { + "node": ">=20.19.0" + }, + "funding": { + "url": "https://github.com/sponsors/antfu" + }, + "peerDependencies": { + "@nuxt/kit": "^4.0.0", + "@vueuse/core": "*" + }, + "peerDependenciesMeta": { + "@nuxt/kit": { + "optional": true + }, + "@vueuse/core": { + "optional": true + } + } + }, + "node_modules/unplugin-utils": { + "version": "0.3.1", + "resolved": "https://registry.npmjs.org/unplugin-utils/-/unplugin-utils-0.3.1.tgz", + "integrity": "sha512-5lWVjgi6vuHhJ526bI4nlCOmkCIF3nnfXkCMDeMJrtdvxTs6ZFCM8oNufGTsDbKv/tJ/xj8RpvXjRuPBZJuJog==", + "dev": true, + "license": "MIT", + "dependencies": { + "pathe": "^2.0.3", + "picomatch": "^4.0.3" + }, + "engines": { + "node": ">=20.19.0" + }, + "funding": { + "url": "https://github.com/sponsors/sxzz" + } + }, + "node_modules/unplugin-vue-components": { + "version": "32.0.0", + "resolved": "https://registry.npmjs.org/unplugin-vue-components/-/unplugin-vue-components-32.0.0.tgz", + "integrity": "sha512-uLdccgS7mf3pv1bCCP20y/hm+u1eOjAmygVkh+Oa70MPkzgl1eQv1L0CwdHNM3gscO8/GDMGIET98Ja47CBbZg==", + "dev": true, + "license": "MIT", + "dependencies": { + "chokidar": "^5.0.0", + "local-pkg": "^1.1.2", + "magic-string": "^0.30.21", + "mlly": "^1.8.2", + "obug": "^2.1.1", + "picomatch": "^4.0.3", + "tinyglobby": "^0.2.15", + "unplugin": "^3.0.0", + "unplugin-utils": "^0.3.1" + }, + "engines": { + "node": ">=20.19.0" + }, + "funding": { + "url": "https://github.com/sponsors/antfu" + }, + "peerDependencies": { + "@nuxt/kit": "^3.2.2 || ^4.0.0", + "vue": "^3.0.0" + }, + "peerDependenciesMeta": { + "@nuxt/kit": { + "optional": true + } + } + }, + "node_modules/unplugin-vue-components/node_modules/unplugin": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/unplugin/-/unplugin-3.0.0.tgz", + "integrity": "sha512-0Mqk3AT2TZCXWKdcoaufeXNukv2mTrEZExeXlHIOZXdqYoHHr4n51pymnwV8x2BOVxwXbK2HLlI7usrqMpycdg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/remapping": "^2.3.5", + "picomatch": "^4.0.3", + "webpack-virtual-modules": "^0.6.2" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/vite": { + "version": "8.0.7", + "resolved": "https://registry.npmjs.org/vite/-/vite-8.0.7.tgz", + "integrity": "sha512-P1PbweD+2/udplnThz3btF4cf6AgPky7kk23RtHUkJIU5BIxwPprhRGmOAHs6FTI7UiGbTNrgNP6jSYD6JaRnw==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "lightningcss": "^1.32.0", + "picomatch": "^4.0.4", + "postcss": "^8.5.8", + "rolldown": "1.0.0-rc.13", + "tinyglobby": "^0.2.15" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^20.19.0 || >=22.12.0", + "@vitejs/devtools": "^0.1.0", + "esbuild": "^0.27.0 || ^0.28.0", + "jiti": ">=1.21.0", + "less": "^4.0.0", + "sass": "^1.70.0", + "sass-embedded": "^1.70.0", + "stylus": ">=0.54.8", + "sugarss": "^5.0.0", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "@vitejs/devtools": { + "optional": true + }, + "esbuild": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/vscode-uri": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/vscode-uri/-/vscode-uri-3.1.0.tgz", + "integrity": "sha512-/BpdSx+yCQGnCvecbyXdxHDkuk55/G3xwnC0GqY4gmQ3j+A+g8kzzgB4Nk/SINjqn6+waqw3EgbVF2QKExkRxQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/vue": { + "version": "3.5.32", + "resolved": "https://registry.npmjs.org/vue/-/vue-3.5.32.tgz", + "integrity": "sha512-vM4z4Q9tTafVfMAK7IVzmxg34rSzTFMyIe0UUEijUCkn9+23lj0WRfA83dg7eQZIUlgOSGrkViIaCfqSAUXsMw==", + "license": "MIT", + "peer": true, + "dependencies": { + "@vue/compiler-dom": "3.5.32", + "@vue/compiler-sfc": "3.5.32", + "@vue/runtime-dom": "3.5.32", + "@vue/server-renderer": "3.5.32", + "@vue/shared": "3.5.32" + }, + "peerDependencies": { + "typescript": "*" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/vue-component-type-helpers": { + "version": "3.2.6", + "resolved": "https://registry.npmjs.org/vue-component-type-helpers/-/vue-component-type-helpers-3.2.6.tgz", + "integrity": "sha512-O02tnvIfOQVmnvoWwuSydwRoHjZVt8UEBR+2p4rT35p8GAy5VTlWP8o5qXfJR/GWCN0nVZoYWsVUvx2jwgdBmQ==", + "license": "MIT" + }, + "node_modules/vue-router": { + "version": "4.6.4", + "resolved": "https://registry.npmjs.org/vue-router/-/vue-router-4.6.4.tgz", + "integrity": "sha512-Hz9q5sa33Yhduglwz6g9skT8OBPii+4bFn88w6J+J4MfEo4KRRpmiNG/hHHkdbRFlLBOqxN8y8gf2Fb0MTUgVg==", + "license": "MIT", + "dependencies": { + "@vue/devtools-api": "^6.6.4" + }, + "funding": { + "url": "https://github.com/sponsors/posva" + }, + "peerDependencies": { + "vue": "^3.5.0" + } + }, + "node_modules/vue-tsc": { + "version": "3.2.6", + "resolved": "https://registry.npmjs.org/vue-tsc/-/vue-tsc-3.2.6.tgz", + "integrity": "sha512-gYW/kWI0XrwGzd0PKc7tVB/qpdeAkIZLNZb10/InizkQjHjnT8weZ/vBarZoj4kHKbUTZT/bAVgoOr8x4NsQ/Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@volar/typescript": "2.4.28", + "@vue/language-core": "3.2.6" + }, + "bin": { + "vue-tsc": "bin/vue-tsc.js" + }, + "peerDependencies": { + "typescript": ">=5.0.0" + } + }, + "node_modules/webpack-virtual-modules": { + "version": "0.6.2", + "resolved": "https://registry.npmjs.org/webpack-virtual-modules/-/webpack-virtual-modules-0.6.2.tgz", + "integrity": "sha512-66/V2i5hQanC51vBQKPH4aI8NMAcBW59FVBs+rC7eGHupMyfn34q7rZIE+ETlJ+XTevqfUhVVBgSUNSW2flEUQ==", + "dev": true, + "license": "MIT" + } + } +} diff --git a/apps/frontend/package.json b/apps/frontend/package.json new file mode 100644 index 00000000..bc013ab3 --- /dev/null +++ b/apps/frontend/package.json @@ -0,0 +1,28 @@ +{ + "name": "order-site-rewrite", + "private": true, + "version": "0.0.0", + "type": "module", + "scripts": { + "dev": "vite", + "build": "vue-tsc -b && vite build", + "preview": "vite preview", + "typecheck": "vue-tsc --noEmit -p tsconfig.app.json" + }, + "dependencies": { + "axios": "^1.9.0", + "element-plus": "^2.10.2", + "vue": "^3.5.30", + "vue-router": "^4.5.1" + }, + "devDependencies": { + "@types/node": "^24.12.0", + "@vitejs/plugin-vue": "^6.0.5", + "@vue/tsconfig": "^0.9.0", + "typescript": "~5.9.3", + "unplugin-auto-import": "^21.0.0", + "unplugin-vue-components": "^32.0.0", + "vite": "^8.0.7", + "vue-tsc": "^3.2.5" + } +} diff --git a/apps/frontend/public/favicon.svg b/apps/frontend/public/favicon.svg new file mode 100644 index 00000000..6893eb13 --- /dev/null +++ b/apps/frontend/public/favicon.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/apps/frontend/src/App.vue b/apps/frontend/src/App.vue new file mode 100644 index 00000000..7c2aa3f3 --- /dev/null +++ b/apps/frontend/src/App.vue @@ -0,0 +1,3 @@ + diff --git a/apps/frontend/src/auto-imports.d.ts b/apps/frontend/src/auto-imports.d.ts new file mode 100644 index 00000000..72370df8 --- /dev/null +++ b/apps/frontend/src/auto-imports.d.ts @@ -0,0 +1,78 @@ +/* eslint-disable */ +/* prettier-ignore */ +// @ts-nocheck +// noinspection JSUnusedGlobalSymbols +// Generated by unplugin-auto-import +// biome-ignore lint: disable +export {} +declare global { + const EffectScope: typeof import('vue').EffectScope + const computed: typeof import('vue').computed + const createApp: typeof import('vue').createApp + const customRef: typeof import('vue').customRef + const defineAsyncComponent: typeof import('vue').defineAsyncComponent + const defineComponent: typeof import('vue').defineComponent + const effectScope: typeof import('vue').effectScope + const getCurrentInstance: typeof import('vue').getCurrentInstance + const getCurrentScope: typeof import('vue').getCurrentScope + const getCurrentWatcher: typeof import('vue').getCurrentWatcher + const h: typeof import('vue').h + const inject: typeof import('vue').inject + const isProxy: typeof import('vue').isProxy + const isReactive: typeof import('vue').isReactive + const isReadonly: typeof import('vue').isReadonly + const isRef: typeof import('vue').isRef + const isShallow: typeof import('vue').isShallow + const markRaw: typeof import('vue').markRaw + const nextTick: typeof import('vue').nextTick + const onActivated: typeof import('vue').onActivated + const onBeforeMount: typeof import('vue').onBeforeMount + const onBeforeRouteLeave: typeof import('vue-router').onBeforeRouteLeave + const onBeforeRouteUpdate: typeof import('vue-router').onBeforeRouteUpdate + const onBeforeUnmount: typeof import('vue').onBeforeUnmount + const onBeforeUpdate: typeof import('vue').onBeforeUpdate + const onDeactivated: typeof import('vue').onDeactivated + const onErrorCaptured: typeof import('vue').onErrorCaptured + const onMounted: typeof import('vue').onMounted + const onRenderTracked: typeof import('vue').onRenderTracked + const onRenderTriggered: typeof import('vue').onRenderTriggered + const onScopeDispose: typeof import('vue').onScopeDispose + const onServerPrefetch: typeof import('vue').onServerPrefetch + const onUnmounted: typeof import('vue').onUnmounted + const onUpdated: typeof import('vue').onUpdated + const onWatcherCleanup: typeof import('vue').onWatcherCleanup + const provide: typeof import('vue').provide + const reactive: typeof import('vue').reactive + const readonly: typeof import('vue').readonly + const ref: typeof import('vue').ref + const resolveComponent: typeof import('vue').resolveComponent + const shallowReactive: typeof import('vue').shallowReactive + const shallowReadonly: typeof import('vue').shallowReadonly + const shallowRef: typeof import('vue').shallowRef + const toRaw: typeof import('vue').toRaw + const toRef: typeof import('vue').toRef + const toRefs: typeof import('vue').toRefs + const toValue: typeof import('vue').toValue + const triggerRef: typeof import('vue').triggerRef + const unref: typeof import('vue').unref + const useAttrs: typeof import('vue').useAttrs + const useCssModule: typeof import('vue').useCssModule + const useCssVars: typeof import('vue').useCssVars + const useId: typeof import('vue').useId + const useLink: typeof import('vue-router').useLink + const useModel: typeof import('vue').useModel + const useRoute: typeof import('vue-router').useRoute + const useRouter: typeof import('vue-router').useRouter + const useSlots: typeof import('vue').useSlots + const useTemplateRef: typeof import('vue').useTemplateRef + const watch: typeof import('vue').watch + const watchEffect: typeof import('vue').watchEffect + const watchPostEffect: typeof import('vue').watchPostEffect + const watchSyncEffect: typeof import('vue').watchSyncEffect +} +// for type re-export +declare global { + // @ts-ignore + export type { Component, Slot, Slots, ComponentPublicInstance, ComputedRef, DirectiveBinding, ExtractDefaultPropTypes, ExtractPropTypes, ExtractPublicPropTypes, InjectionKey, PropType, Ref, ShallowRef, MaybeRef, MaybeRefOrGetter, VNode, WritableComputedRef } from 'vue' + import('vue') +} diff --git a/apps/frontend/src/components.d.ts b/apps/frontend/src/components.d.ts new file mode 100644 index 00000000..21f1402c --- /dev/null +++ b/apps/frontend/src/components.d.ts @@ -0,0 +1,29 @@ +/* eslint-disable */ +// @ts-nocheck +// biome-ignore lint: disable +// oxlint-disable +// ------ +// Generated by unplugin-vue-components +// Read more: https://github.com/vuejs/core/pull/3399 + +export {} + +/* prettier-ignore */ +declare module 'vue' { + export interface GlobalComponents { + AdminPaginationBar: typeof import('./components/admin/AdminPaginationBar.vue')['default'] + AdminStatusTag: typeof import('./components/admin/AdminStatusTag.vue')['default'] + ElButton: typeof import('element-plus/es')['ElButton'] + ElCheckbox: typeof import('element-plus/es')['ElCheckbox'] + ElDialog: typeof import('element-plus/es')['ElDialog'] + ElForm: typeof import('element-plus/es')['ElForm'] + ElFormItem: typeof import('element-plus/es')['ElFormItem'] + ElInput: typeof import('element-plus/es')['ElInput'] + ElInputNumber: typeof import('element-plus/es')['ElInputNumber'] + RouterLink: typeof import('vue-router')['RouterLink'] + RouterView: typeof import('vue-router')['RouterView'] + TencentAuthCard: typeof import('./components/tencent/TencentAuthCard.vue')['default'] + TencentRedeemPanel: typeof import('./components/tencent/TencentRedeemPanel.vue')['default'] + TencentResultPanel: typeof import('./components/tencent/TencentResultPanel.vue')['default'] + } +} diff --git a/apps/frontend/src/components/admin/AdminPaginationBar.vue b/apps/frontend/src/components/admin/AdminPaginationBar.vue new file mode 100644 index 00000000..87acc10b --- /dev/null +++ b/apps/frontend/src/components/admin/AdminPaginationBar.vue @@ -0,0 +1,62 @@ + + + + + diff --git a/apps/frontend/src/components/admin/AdminStatusTag.vue b/apps/frontend/src/components/admin/AdminStatusTag.vue new file mode 100644 index 00000000..1cda9846 --- /dev/null +++ b/apps/frontend/src/components/admin/AdminStatusTag.vue @@ -0,0 +1,130 @@ + + + + + diff --git a/apps/frontend/src/components/tencent/TencentAuthCard.vue b/apps/frontend/src/components/tencent/TencentAuthCard.vue new file mode 100644 index 00000000..37c731f1 --- /dev/null +++ b/apps/frontend/src/components/tencent/TencentAuthCard.vue @@ -0,0 +1,389 @@ + + + + + diff --git a/apps/frontend/src/components/tencent/TencentRedeemPanel.vue b/apps/frontend/src/components/tencent/TencentRedeemPanel.vue new file mode 100644 index 00000000..e8991006 --- /dev/null +++ b/apps/frontend/src/components/tencent/TencentRedeemPanel.vue @@ -0,0 +1,284 @@ + + + + + diff --git a/apps/frontend/src/components/tencent/TencentResultPanel.vue b/apps/frontend/src/components/tencent/TencentResultPanel.vue new file mode 100644 index 00000000..1585aad4 --- /dev/null +++ b/apps/frontend/src/components/tencent/TencentResultPanel.vue @@ -0,0 +1,158 @@ + + + + + diff --git a/apps/frontend/src/composables/tencent/session-errors.ts b/apps/frontend/src/composables/tencent/session-errors.ts new file mode 100644 index 00000000..5525c454 --- /dev/null +++ b/apps/frontend/src/composables/tencent/session-errors.ts @@ -0,0 +1,48 @@ +import { ElMessage } from 'element-plus' + +export type TencentActionError = Error & { + errorCode?: string + status?: number +} + +export function notifyTencentActionError(error: unknown, fallbackMessage: string) { + const message = resolveTencentActionMessage(error, fallbackMessage) + console.error(error) + ElMessage.error(message) +} + +export function resolveTencentActionMessage(error: unknown, fallbackMessage: string) { + const normalizedError = error as TencentActionError + const errorCode = String(normalizedError?.errorCode || '').trim() + const message = normalizedError instanceof Error ? normalizedError.message.trim() : '' + + if (errorCode === 'missing_redeem_code') { + return '请输入兑换码' + } + + if (errorCode === 'session_not_ready') { + return '当前会话还不能兑换,请先完成扫码并确认角色信息' + } + + if (errorCode === 'session_not_found' || errorCode === 'not_found') { + return '当前会话不存在或已过期,请重新生成二维码' + } + + if (errorCode === 'session_closed' || errorCode === 'gone') { + return '当前会话已关闭,请重新生成二维码' + } + + if (errorCode === 'dependency_unavailable') { + return '后端依赖暂不可用,请检查浏览器或 OCR 服务是否已就绪' + } + + if (errorCode === 'invalid_request') { + return '请求参数不完整,请检查输入后重试' + } + + if (errorCode === 'conflict') { + return '当前会话状态已变化,请刷新后重试' + } + + return message || fallbackMessage +} diff --git a/apps/frontend/src/composables/tencent/useTencentBrowserSessionPolling.ts b/apps/frontend/src/composables/tencent/useTencentBrowserSessionPolling.ts new file mode 100644 index 00000000..a7eba2c6 --- /dev/null +++ b/apps/frontend/src/composables/tencent/useTencentBrowserSessionPolling.ts @@ -0,0 +1,196 @@ +import { computed, ref, type Ref } from 'vue' + +import { + fetchTencentBrowserSession, + fetchTencentBrowserSessionSummary, +} from '@/services/tencent/session' +import type { + TencentBrowserSessionData, + TencentBrowserSessionStatus, + TencentBrowserSessionSummaryData, +} from '@/types/tencent/session' + +import { resolveTencentActionMessage } from './session-errors' + +const POLL_INTERVAL_MS = 2_500 +const POLL_FAILURE_LIMIT = 3 + +export const ACTIVE_TENCENT_SESSION_STATUSES = new Set([ + 'waiting_scan', + 'scanned', + 'logged_in', + 'ready_to_redeem', + 'redeeming', +]) + +export function useTencentBrowserSessionPolling(options: { + session: Ref + sessionLoading: Ref + notifyActionError: (error: unknown, fallbackMessage: string) => void +}) { + const { session, sessionLoading, notifyActionError } = options + const pollFailureCount = ref(0) + const pollWarningMessage = ref('') + + let pollTimer: ReturnType | null = null + let pollToken = 0 + + const sessionNotice = computed(() => pollWarningMessage.value || session.value?.notice || '') + + async function refreshSession({ silent = false } = {}) { + if (!session.value?.sessionId) { + return false + } + + if (!silent) { + sessionLoading.value = true + resetPollingWarning() + } + + try { + const currentSession = session.value + const response = await fetchTencentBrowserSessionSummary(currentSession.sessionId) + + if (response.code !== 0) { + throw new Error(response.msg || '获取浏览器会话状态失败') + } + + const nextSession = mergeSessionData(currentSession, response.data) + session.value = nextSession + + if (shouldRefreshQrImage(currentSession, response.data)) { + const fullResponse = await fetchTencentBrowserSession(currentSession.sessionId) + + if (fullResponse.code !== 0) { + throw new Error(fullResponse.msg || '获取浏览器会话二维码失败') + } + + session.value = mergeSessionData(nextSession, fullResponse.data) + } + + if (silent) { + resetPollingWarning() + } + + return true + } catch (error) { + if (silent) { + handleSilentPollingError(error) + } else { + notifyActionError(error, '获取浏览器会话状态失败') + } + + return false + } finally { + if (!silent) { + sessionLoading.value = false + } + } + } + + function startPolling() { + const token = ++pollToken + + const loop = async () => { + if (token !== pollToken || !session.value?.sessionId) { + return + } + + await refreshSession({ silent: true }) + + if (token !== pollToken || !isActiveTencentSessionStatus(session.value?.status)) { + return + } + + pollTimer = setTimeout(() => { + void loop() + }, POLL_INTERVAL_MS) + } + + pollTimer = setTimeout(() => { + void loop() + }, POLL_INTERVAL_MS) + } + + function resetPolling() { + pollToken += 1 + + if (pollTimer) { + clearTimeout(pollTimer) + pollTimer = null + } + } + + function resetPollingWarning() { + pollFailureCount.value = 0 + pollWarningMessage.value = '' + } + + function restartPollingIfActive(nextSession = session.value) { + if (isActiveTencentSessionStatus(nextSession?.status)) { + startPolling() + } + } + + function handleSilentPollingError(error: unknown) { + console.error(error) + pollFailureCount.value += 1 + + if (pollFailureCount.value < POLL_FAILURE_LIMIT) { + return + } + + pollWarningMessage.value = `${resolveTencentActionMessage(error, '会话状态刷新失败')},已暂停自动轮询,请手动刷新或重新生成二维码。` + resetPolling() + } + + return { + refreshSession, + resetPolling, + resetPollingWarning, + restartPollingIfActive, + sessionNotice, + startPolling, + } +} + +export function isActiveTencentSessionStatus(status: TencentBrowserSessionStatus | null | undefined) { + return Boolean(status && ACTIVE_TENCENT_SESSION_STATUSES.has(status)) +} + +function mergeSessionData( + current: TencentBrowserSessionData | null, + next: TencentBrowserSessionSummaryData | TencentBrowserSessionData, +) { + if (!current) { + return { + ...next, + qrImageBase64: next.qrImageBase64 || '', + } + } + + return { + ...current, + ...next, + qrImageBase64: + typeof next.qrImageBase64 === 'string' ? next.qrImageBase64 : current.qrImageBase64, + activityInfo: next.activityInfo ?? current.activityInfo ?? null, + redeem: next.redeem ?? current.redeem ?? null, + artifacts: next.artifacts || current.artifacts, + } +} + +function shouldRefreshQrImage( + current: TencentBrowserSessionData, + next: TencentBrowserSessionSummaryData, +) { + if (!next.artifacts?.hasQrImage) { + return false + } + + if (!current.qrImageBase64) { + return true + } + + return Boolean(next.qrUpdatedAt && next.qrUpdatedAt !== current.qrUpdatedAt) +} diff --git a/apps/frontend/src/composables/tencent/useTencentBrowserSessionPresentation.ts b/apps/frontend/src/composables/tencent/useTencentBrowserSessionPresentation.ts new file mode 100644 index 00000000..98556d8c --- /dev/null +++ b/apps/frontend/src/composables/tencent/useTencentBrowserSessionPresentation.ts @@ -0,0 +1,208 @@ +import { computed, type ComputedRef, type Ref } from 'vue' + +import { buildTencentBrowserSessionScreenshotUrl } from '@/services/tencent/artifacts' +import type { TencentBrowserActivityInfo } from '@/types/tencent/activity' +import type { TencentBrowserSessionData } from '@/types/tencent/session' + +const REDEEM_ALLOWED_STATUSES = new Set(['logged_in', 'ready_to_redeem', 'redeemed']) + +export function useTencentBrowserSessionPresentation(options: { + activityInfo: ComputedRef + hasSession: ComputedRef + loginTypeLabel: ComputedRef + redeemLoading: Ref + roleConfirmed: Ref + roleReady: ComputedRef + session: Ref + sessionLoading: Ref + sessionNotice: ComputedRef +}) { + const { + activityInfo, + hasSession, + loginTypeLabel, + redeemLoading, + roleConfirmed, + roleReady, + session, + sessionLoading, + sessionNotice, + } = options + + const initButtonLabel = computed(() => `开始初始化 ${loginTypeLabel.value} 登录`) + const scanInstruction = computed(() => `请使用${loginTypeLabel.value}扫描二维码,并在手机上确认登录`) + + const qrImage = computed(() => + session.value?.qrImageBase64 ? `data:image/png;base64,${session.value.qrImageBase64}` : '', + ) + + const screenshotUrl = computed(() => { + if (!session.value?.sessionId || !session.value?.artifacts?.hasScreenshot) { + return '' + } + + return buildTencentBrowserSessionScreenshotUrl(session.value.sessionId, session.value.updatedAt) + }) + + const statusLabel = computed(() => { + if (!hasSession.value) { + return '待初始化' + } + + switch (session.value?.status) { + case 'waiting_scan': + return '等待扫码' + case 'scanned': + return '已扫码待确认' + case 'logged_in': + return '页面已登录' + case 'ready_to_redeem': + return '可以兑换' + case 'redeeming': + return '兑换中' + case 'redeemed': + return '已完成' + case 'failed': + return '会话异常' + default: + return '初始化中' + } + }) + + const canRedeem = computed(() => { + if (redeemLoading.value || sessionLoading.value || !session.value?.sessionId) { + return false + } + + return ( + REDEEM_ALLOWED_STATUSES.has(String(session.value.status || '')) && + roleReady.value && + roleConfirmed.value + ) + }) + + const redeemBlockedReason = computed(() => { + if (!session.value?.sessionId) { + return `请先选择${loginTypeLabel.value}登录,并手动开始初始化浏览器会话` + } + + if (redeemLoading.value) { + return '兑换任务正在执行中' + } + + if (sessionLoading.value) { + return '正在刷新浏览器会话状态' + } + + if (!roleReady.value) { + return sessionNotice.value || '扫码成功后,后端正在同步角色和大区信息' + } + + if (!roleConfirmed.value) { + return '请先确认当前角色与大区无误,再开始兑换' + } + + if (REDEEM_ALLOWED_STATUSES.has(String(session.value.status || ''))) { + return '' + } + + switch (session.value.status) { + case 'waiting_scan': + return `请先使用${loginTypeLabel.value}扫码` + case 'scanned': + return '请在手机上确认登录后再兑换' + case 'failed': + return sessionNotice.value || '浏览器会话异常,请重新生成二维码' + default: + return sessionNotice.value || '当前状态还不能开始兑换' + } + }) + + const finalRedeemMessage = computed(() => { + const result = session.value?.redeem?.final?.redeem + + if (!result || typeof result !== 'object') { + return '' + } + + const record = result as Record + return String(record.sMsg || record.msg || '') + }) + + const finalRetCode = computed(() => { + const result = session.value?.redeem?.final?.redeem + + if (!result || typeof result !== 'object') { + return '' + } + + const record = result as Record + return String(record.iRet ?? '') + }) + + const roleFacts = computed(() => [ + { + label: '登录昵称', + value: activityInfo.value?.nickname || '等待扫码登录', + }, + { + label: '当前角色', + value: activityInfo.value?.role?.roleName || '后端浏览器同步中', + accent: true, + }, + { + label: '角色 ID', + value: activityInfo.value?.role?.roleId || '未识别', + }, + { + label: '验证码', + value: + activityInfo.value?.form?.verifyValue || + (activityInfo.value?.verify?.visible ? '等待识别' : '等待显示'), + }, + ]) + + const resultFacts = computed(() => [ + { + label: '业务返回码', + value: finalRetCode.value || '-', + }, + { + label: '业务消息', + value: finalRedeemMessage.value || '尚未兑换', + }, + { + label: 'OCR 尝试次数', + value: String(session.value?.redeem?.attempts.length || 0), + }, + { + label: '证明截图', + value: session.value?.artifacts?.hasScreenshot ? '已生成' : '未生成', + }, + ]) + + const redeemButtonLabel = computed(() => '开始兑换') + const screenshotEmptyTitle = computed(() => + session.value?.status === 'redeemed' ? '本次没有可展示的截图' : '等待截图', + ) + const screenshotEmptyMessage = computed(() => + session.value?.status === 'redeemed' + ? '本次兑换可能未生成截图,或截图产物已被后端配置关闭。' + : '如本次兑换生成了结果截图,这里会展示。' + ) + + return { + canRedeem, + initButtonLabel, + qrImage, + redeemBlockedReason, + redeemButtonLabel, + resultFacts, + roleFacts, + scanInstruction, + screenshotEmptyMessage, + screenshotEmptyTitle, + screenshotUrl, + statusLabel, + } +} diff --git a/apps/frontend/src/composables/useClaimPage.ts b/apps/frontend/src/composables/useClaimPage.ts new file mode 100644 index 00000000..06c1e16d --- /dev/null +++ b/apps/frontend/src/composables/useClaimPage.ts @@ -0,0 +1,525 @@ +import { computed, onBeforeUnmount, ref, watch } from 'vue' +import { ElMessage } from 'element-plus' + +import { + confirmClaimRole, + createClaimSession, + fetchClaimDetail, + fetchClaimSessionSummary, + redeemClaim, +} from '@/services/claim' +import type { ClaimDetailData, ClaimTaskStatus } from '@/types/claim' +import type { TencentLoginType } from '@/types/tencent/session' + +import { notifyTencentActionError } from './tencent/session-errors' + +const DEFAULT_LOGIN_TYPE: TencentLoginType = 'qq' +const POLL_INTERVAL_MS = 2500 +const POLL_FAILURE_LIMIT = 3 +const ACTIVE_TASK_STATUSES = new Set([ + 'claimed', + 'role_confirmed', + 'redeeming', +]) + +export function useClaimPage(token: string) { + const detailLoading = ref(true) + const sessionLoading = ref(false) + const redeemLoading = ref(false) + const roleConfirmLoading = ref(false) + const loginType = ref(DEFAULT_LOGIN_TYPE) + const detail = ref(null) + const roleConfirmed = ref(false) + const pollWarningMessage = ref('') + const qrImageNaturalWidth = ref(0) + + let pollTimer: ReturnType | null = null + let pollToken = 0 + let pollFailureCount = 0 + + const session = computed(() => detail.value?.session || null) + const task = computed(() => detail.value?.task || null) + const order = computed(() => detail.value?.order || null) + const orderItem = computed(() => detail.value?.orderItem || null) + const result = computed(() => detail.value?.result || null) + const tokenStatus = computed(() => detail.value?.tokenStatus || 'active') + const activityInfo = computed(() => session.value?.activityInfo || null) + const roleReady = computed(() => Boolean(activityInfo.value?.role?.ready)) + const hasSession = computed(() => Boolean(session.value?.sessionId)) + const loginTypeLabel = computed(() => (loginType.value === 'wx' ? '微信' : 'QQ')) + const sessionNotice = computed(() => pollWarningMessage.value || session.value?.notice || task.value?.lastError || '') + const qrImage = computed(() => + session.value?.qrImageBase64 ? `data:image/png;base64,${session.value.qrImageBase64}` : '', + ) + const screenshotUrl = computed(() => result.value?.screenshotUrl || '') + const showScreenshot = computed(() => Boolean(screenshotUrl.value)) + const loginTabs = [ + { value: 'qq' as const, label: 'QQ账号登录' }, + { value: 'wx' as const, label: '微信账号登录' }, + ] + + const statusLabel = computed(() => resolveTaskStatusLabel(task.value?.status, session.value?.status)) + const initButtonLabel = computed(() => `开始初始化 ${loginTypeLabel.value} 登录`) + const scanInstruction = computed(() => `请使用${loginTypeLabel.value}扫描二维码,并在手机上确认登录`) + const roleFacts = computed(() => [ + { + label: '登录昵称', + value: activityInfo.value?.nickname || '等待扫码登录', + }, + { + label: '当前角色', + value: activityInfo.value?.role?.roleName || '后端浏览器同步中', + accent: true, + }, + { + label: '角色 ID', + value: activityInfo.value?.role?.roleId || '未识别', + }, + { + label: '任务状态', + value: statusLabel.value, + }, + ]) + const resultFacts = computed(() => [ + { + label: '订单号', + value: order.value?.platformOrderId || '-', + }, + { + label: '商品', + value: orderItem.value?.skuName || '-', + }, + { + label: '业务返回码', + value: result.value?.resultCode || '-', + }, + { + label: '业务消息', + value: result.value?.resultMessage || '尚未兑换', + }, + ]) + const canConfirmRole = computed(() => + Boolean(task.value && hasSession.value && roleReady.value && task.value.status === 'claimed'), + ) + const canRedeem = computed(() => + Boolean(task.value && hasSession.value && roleConfirmed.value && !redeemLoading.value && ( + task.value.status === 'role_confirmed' || task.value.status === 'redeeming' + )), + ) + const redeemBlockedReason = computed(() => { + if (!detail.value) { + return '正在加载领取信息' + } + + if (tokenStatus.value !== 'active') { + return '当前领取链接不可用' + } + + if (!hasSession.value) { + return `请先选择${loginTypeLabel.value}并初始化登录会话` + } + + if (redeemLoading.value) { + return '兑换任务正在执行中' + } + + if (!roleReady.value) { + return sessionNotice.value || '扫码成功后,后端正在同步角色和大区信息' + } + + if (!roleConfirmed.value) { + return '请先确认当前角色与大区无误,再开始兑换' + } + + return '' + }) + const redeemButtonLabel = computed(() => '开始兑换') + const screenshotEmptyTitle = computed(() => + task.value?.status === 'redeemed' ? '本次没有可展示的截图' : '等待截图', + ) + const screenshotEmptyMessage = computed(() => + task.value?.status === 'redeemed' + ? '本次兑换可能未生成截图,或截图产物还未同步完成。' + : '领取完成后,如本次生成了结果截图,这里会展示。' + ) + + function applyLoginTypeFromDetail(nextDetail: ClaimDetailData) { + loginType.value = syncLoginTypeFromDetail(nextDetail) + } + + watch( + () => + [ + task.value?.taskId || '', + activityInfo.value?.nickname || '', + activityInfo.value?.role?.roleId || '', + activityInfo.value?.role?.roleName || '', + activityInfo.value?.role?.area || '', + activityInfo.value?.role?.partition || '', + ].join('|'), + () => { + roleConfirmed.value = Boolean(task.value?.status === 'role_confirmed' || task.value?.status === 'redeeming' || task.value?.status === 'redeemed') + }, + { immediate: true }, + ) + + async function loadDetail() { + detailLoading.value = true + + try { + const response = await fetchClaimDetail(token) + + if (response.code !== 0) { + throw new Error(response.msg || '领取详情加载失败') + } + + detail.value = mergeClaimDetailData(detail.value, response.data) + applyLoginTypeFromDetail(response.data) + restartPollingIfNeeded() + } catch (error) { + notifyTencentActionError(error, '领取详情加载失败') + } finally { + detailLoading.value = false + } + } + + async function createSessionFlow(nextLoginType = loginType.value) { + sessionLoading.value = true + resetPolling() + resetPollingWarning() + + try { + loginType.value = nextLoginType + const response = await createClaimSession(token, nextLoginType) + + if (response.code !== 0) { + throw new Error(response.msg || '创建领取会话失败') + } + + detail.value = mergeClaimDetailData(detail.value, response.data) + applyLoginTypeFromDetail(response.data) + startPolling() + } catch (error) { + notifyTencentActionError(error, '创建领取会话失败') + } finally { + sessionLoading.value = false + } + } + + async function refreshSessionSummary({ silent = false } = {}) { + if (!hasSession.value) { + return false + } + + if (!silent) { + sessionLoading.value = true + resetPollingWarning() + } + + try { + const response = await fetchClaimSessionSummary(token) + + if (response.code !== 0) { + throw new Error(response.msg || '领取会话状态刷新失败') + } + + detail.value = mergeClaimDetailData(detail.value, response.data) + applyLoginTypeFromDetail(response.data) + + if (silent) { + resetPollingWarning() + } + + return true + } catch (error) { + if (silent) { + handleSilentPollingError(error) + } else { + notifyTencentActionError(error, '领取会话状态刷新失败') + } + + return false + } finally { + if (!silent) { + sessionLoading.value = false + } + } + } + + async function confirmRoleNow() { + if (!canConfirmRole.value) { + return + } + + roleConfirmLoading.value = true + + try { + const response = await confirmClaimRole(token) + + if (response.code !== 0) { + throw new Error(response.msg || '角色确认失败') + } + + detail.value = mergeClaimDetailData(detail.value, response.data) + roleConfirmed.value = true + ElMessage.success(response.msg || '角色已确认') + restartPollingIfNeeded(response.data) + } catch (error) { + notifyTencentActionError(error, '角色确认失败') + await refreshSessionSummary({ silent: true }) + } finally { + roleConfirmLoading.value = false + } + } + + async function redeemNow() { + if (!canRedeem.value) { + return + } + + redeemLoading.value = true + resetPolling() + resetPollingWarning() + + try { + const response = await redeemClaim(token) + + if (response.code !== 0) { + throw new Error(response.msg || '领取兑换失败') + } + + detail.value = mergeClaimDetailData(detail.value, response.data) + ElMessage.success(response.msg || '兑换完成') + restartPollingIfNeeded(response.data) + } catch (error) { + notifyTencentActionError(error, '领取兑换失败') + await refreshSessionSummary({ silent: true }) + restartPollingIfNeeded() + } finally { + redeemLoading.value = false + } + } + + async function switchLoginType(nextLoginType: TencentLoginType) { + if (nextLoginType === loginType.value) { + return + } + + loginType.value = nextLoginType + + if (hasSession.value) { + await createSessionFlow(nextLoginType) + } + } + + function startPolling() { + const tokenId = ++pollToken + + const loop = async () => { + if (tokenId !== pollToken || !hasSession.value) { + return + } + + await refreshSessionSummary({ silent: true }) + + if (tokenId !== pollToken || !shouldKeepPolling(detail.value)) { + return + } + + pollTimer = setTimeout(() => { + void loop() + }, POLL_INTERVAL_MS) + } + + pollTimer = setTimeout(() => { + void loop() + }, POLL_INTERVAL_MS) + } + + function restartPollingIfNeeded(nextDetail = detail.value) { + resetPolling() + + if (shouldKeepPolling(nextDetail)) { + startPolling() + } + } + + function resetPolling() { + pollToken += 1 + + if (pollTimer) { + clearTimeout(pollTimer) + pollTimer = null + } + } + + function resetPollingWarning() { + pollFailureCount = 0 + pollWarningMessage.value = '' + } + + function handleSilentPollingError(error: unknown) { + console.error(error) + pollFailureCount += 1 + + if (pollFailureCount < POLL_FAILURE_LIMIT) { + return + } + + pollWarningMessage.value = '领取状态刷新失败,已暂停自动轮询,请手动刷新页面。' + resetPolling() + } + + function handleQrImageLoad(event: Event) { + const target = event.target + + if (!(target instanceof HTMLImageElement)) { + return + } + + qrImageNaturalWidth.value = target.naturalWidth || 0 + } + + const qrDisplayWidth = computed(() => { + const naturalWidth = qrImageNaturalWidth.value + + if (!naturalWidth) { + return 220 + } + + if (naturalWidth < 160) { + return Math.min(naturalWidth * 2, 220) + } + + return Math.min(naturalWidth, 240) + }) + + const qrFigureStyle = computed(() => ({ + width: `${qrDisplayWidth.value}px`, + maxWidth: '100%', + })) + + const qrPreviewWidth = computed(() => `${Math.min(Math.max(qrDisplayWidth.value + 120, 360), 480)}px`) + + loadDetail() + + onBeforeUnmount(() => { + resetPolling() + }) + + return { + detailLoading, + sessionLoading, + redeemLoading, + roleConfirmLoading, + loginType, + loginTypeLabel, + loginTabs, + detail, + task, + order, + orderItem, + activityInfo, + hasSession, + qrImage, + qrFigureStyle, + qrPreviewWidth, + statusLabel, + session, + sessionNotice, + roleFacts, + resultFacts, + roleConfirmed, + roleReady, + canConfirmRole, + canRedeem, + redeemBlockedReason, + redeemButtonLabel, + initButtonLabel, + scanInstruction, + screenshotEmptyTitle, + screenshotEmptyMessage, + screenshotUrl, + showScreenshot, + createSessionFlow, + refreshSessionSummary, + switchLoginType, + confirmRoleNow, + redeemNow, + handleQrImageLoad, + } +} + +function syncLoginTypeFromDetail(detail: ClaimDetailData) { + const nextLoginType = String(detail.session?.loginType || detail.task.loginType || '').trim() + return nextLoginType === 'wx' ? 'wx' : 'qq' +} + +function shouldKeepPolling(detail: ClaimDetailData | null) { + if (!detail?.session?.sessionId) { + return false + } + + return ACTIVE_TASK_STATUSES.has(detail.task.status) +} + +function resolveTaskStatusLabel(taskStatus?: string, sessionStatus?: string) { + switch (taskStatus) { + case 'link_generated': + return '等待开始领取' + case 'claimed': + if (sessionStatus === 'scanned') { + return '确认中' + } + if (sessionStatus === 'logged_in' || sessionStatus === 'ready_to_redeem') { + return '已登录' + } + return '领取中' + case 'role_confirmed': + return '角色已确认' + case 'redeeming': + return '正在兑换' + case 'redeemed': + return '兑换成功' + case 'waiting_inventory': + return '等待库存' + case 'retry_pending': + return '等待重试' + case 'manual_review': + return '等待人工处理' + case 'expired': + return '链接已过期' + case 'closed': + return '任务已关闭' + default: + return sessionStatus || taskStatus || '等待中' + } +} + +function mergeClaimDetailData(current: ClaimDetailData | null, next: ClaimDetailData) { + if (!current?.session) { + return next + } + + if (!next.session) { + return { + ...next, + session: current.session, + } + } + + return { + ...next, + session: { + ...current.session, + ...next.session, + qrImageBase64: + typeof next.session.qrImageBase64 === 'string' + ? next.session.qrImageBase64 + : current.session.qrImageBase64, + activityInfo: next.session.activityInfo ?? current.session.activityInfo ?? null, + redeem: next.session.redeem ?? current.session.redeem ?? null, + artifacts: next.session.artifacts || current.session.artifacts, + }, + } +} diff --git a/apps/frontend/src/composables/useTencentBrowserSessionPage.ts b/apps/frontend/src/composables/useTencentBrowserSessionPage.ts new file mode 100644 index 00000000..46d44acc --- /dev/null +++ b/apps/frontend/src/composables/useTencentBrowserSessionPage.ts @@ -0,0 +1,240 @@ +import { computed, onBeforeUnmount, ref, watch } from 'vue' +import { ElMessage } from 'element-plus' + +import { + createTencentBrowserSession, + refreshTencentBrowserSessionPage, + removeTencentBrowserSession, +} from '@/services/tencent/session' +import { redeemTencentBrowserSession } from '@/services/tencent/redeem' +import type { TencentBrowserSessionData, TencentLoginType } from '@/types/tencent/session' + +import { notifyTencentActionError } from './tencent/session-errors' +import { useTencentBrowserSessionPolling } from './tencent/useTencentBrowserSessionPolling' +import { useTencentBrowserSessionPresentation } from './tencent/useTencentBrowserSessionPresentation' + +const DEFAULT_LOGIN_TYPE: TencentLoginType = 'qq' + +export function useTencentBrowserSessionPage() { + const sessionLoading = ref(false) + const redeemLoading = ref(false) + const loginType = ref(DEFAULT_LOGIN_TYPE) + const session = ref(null) + const redeemCode = ref('') + const maxAttempts = ref(6) + const roleConfirmed = ref(false) + + const activityInfo = computed(() => session.value?.activityInfo || null) + const roleReady = computed(() => Boolean(activityInfo.value?.role?.ready)) + const hasSession = computed(() => Boolean(session.value?.sessionId)) + const loginTypeLabel = computed(() => (loginType.value === 'wx' ? '微信' : 'QQ')) + + const loginTabs = [ + { value: 'qq' as const, label: 'QQ账号登录' }, + { value: 'wx' as const, label: '微信账号登录' }, + ] + + const { + refreshSession, + resetPolling, + resetPollingWarning, + restartPollingIfActive, + sessionNotice, + startPolling, + } = useTencentBrowserSessionPolling({ + session, + sessionLoading, + notifyActionError: notifyTencentActionError, + }) + + const { + canRedeem, + initButtonLabel, + qrImage, + redeemBlockedReason, + redeemButtonLabel, + resultFacts, + roleFacts, + scanInstruction, + screenshotEmptyMessage, + screenshotEmptyTitle, + screenshotUrl, + statusLabel, + } = useTencentBrowserSessionPresentation({ + activityInfo, + hasSession, + loginTypeLabel, + redeemLoading, + roleConfirmed, + roleReady, + session, + sessionLoading, + sessionNotice, + }) + + watch( + () => + [ + session.value?.sessionId || '', + activityInfo.value?.nickname || '', + activityInfo.value?.role?.roleId || '', + activityInfo.value?.role?.roleName || '', + activityInfo.value?.role?.area || '', + activityInfo.value?.role?.partition || '', + ].join('|'), + () => { + roleConfirmed.value = false + }, + ) + + async function createSessionFlow(nextLoginType = loginType.value) { + resetPolling() + resetPollingWarning() + sessionLoading.value = true + + try { + loginType.value = nextLoginType + await teardownSession() + + const response = await createTencentBrowserSession({ + loginType: nextLoginType, + }) + + if (response.code !== 0) { + throw new Error(response.msg || '创建浏览器会话失败') + } + + session.value = response.data + startPolling() + } catch (error) { + notifyTencentActionError(error, '创建浏览器会话失败') + } finally { + sessionLoading.value = false + } + } + + async function reloadSessionPage() { + if (!session.value?.sessionId) { + return + } + + resetPolling() + resetPollingWarning() + sessionLoading.value = true + + try { + const response = await refreshTencentBrowserSessionPage(session.value.sessionId) + + if (response.code !== 0) { + throw new Error(response.msg || '刷新后端页面失败') + } + + session.value = response.data + restartPollingIfActive(response.data) + } catch (error) { + notifyTencentActionError(error, '刷新后端页面失败') + await refreshSession({ silent: true }) + restartPollingIfActive() + } finally { + sessionLoading.value = false + } + } + + async function switchLoginType(nextLoginType: TencentLoginType) { + if (nextLoginType === loginType.value) { + return + } + + loginType.value = nextLoginType + + if (session.value?.sessionId) { + await createSessionFlow(nextLoginType) + } + } + + async function redeemNow() { + if (!session.value?.sessionId) { + ElMessage.error('请先创建浏览器会话') + return + } + + if (!redeemCode.value.trim()) { + ElMessage.error('请输入兑换码') + return + } + + redeemLoading.value = true + resetPolling() + resetPollingWarning() + + try { + const response = await redeemTencentBrowserSession(session.value.sessionId, { + code: redeemCode.value.trim(), + maxAttempts: maxAttempts.value, + }) + + if (response.code !== 0) { + throw new Error(response.msg || '浏览器兑换失败') + } + + session.value = response.data + ElMessage.success(response.msg || '兑换完成') + } catch (error) { + notifyTencentActionError(error, '浏览器兑换失败') + await refreshSession({ silent: true }) + } finally { + redeemLoading.value = false + } + } + + async function teardownSession() { + if (!session.value?.sessionId) { + return + } + + const sessionId = session.value.sessionId + session.value = null + + try { + await removeTencentBrowserSession(sessionId) + } catch { + // ignore close failures on local teardown + } + } + + onBeforeUnmount(() => { + resetPolling() + void teardownSession() + }) + + return { + activityInfo, + canRedeem, + createSessionFlow, + hasSession, + initButtonLabel, + loginTabs, + loginType, + loginTypeLabel, + maxAttempts, + qrImage, + redeemBlockedReason, + redeemButtonLabel, + redeemCode, + redeemLoading, + redeemNow, + reloadSessionPage, + resultFacts, + roleConfirmed, + roleFacts, + scanInstruction, + screenshotEmptyMessage, + screenshotEmptyTitle, + screenshotUrl, + session, + sessionLoading, + sessionNotice, + statusLabel, + switchLoginType, + } +} diff --git a/apps/frontend/src/lib/http.ts b/apps/frontend/src/lib/http.ts new file mode 100644 index 00000000..cef67098 --- /dev/null +++ b/apps/frontend/src/lib/http.ts @@ -0,0 +1,101 @@ +import axios from 'axios' +import { clearAdminSession, getAdminToken } from '@/utils/admin-auth' + +export interface ApiEnvelope { + code: number + msg: string + data: T + errorCode?: string +} + +const http = axios.create({ + baseURL: '/', + timeout: 50_000, + headers: { + 'Content-Type': 'application/json', + }, +}) + +http.interceptors.response.use( + (response) => response.data, + (error) => { + const responseMessage = + typeof error?.response?.data?.msg === 'string' ? error.response.data.msg.trim() : '' + const fallbackMessage = resolveFallbackHttpMessage(error) + const normalizedError = new Error(responseMessage || fallbackMessage) as Error & { + errorCode?: string + status?: number + } + + if (error?.response?.data?.errorCode) { + normalizedError.errorCode = String(error.response.data.errorCode) + } + + if (typeof error?.response?.status === 'number') { + normalizedError.status = error.response.status + } + + if (error?.config?.url && String(error.config.url).startsWith('/api/v1/admin')) { + const status = Number(error?.response?.status || 0) + + if (status === 401) { + clearAdminSession() + + if (window.location.hash.startsWith('#/admin') && !window.location.hash.startsWith('#/admin/login')) { + window.location.hash = '#/admin/login' + } + } + } + + return Promise.reject(normalizedError) + }, +) + +http.interceptors.request.use((config) => { + if (String(config.url || '').startsWith('/api/v1/admin')) { + const token = getAdminToken() + + if (token) { + config.headers.Authorization = `Bearer ${token}` + } + } + + return config +}) + +function resolveFallbackHttpMessage(error: unknown) { + const message = String((error as { message?: string })?.message ?? '') + + if (message.includes('timeout')) { + return '网络超时' + } + + if (message === 'Network Error') { + return '网络连接错误' + } + + if (typeof (error as { response?: { statusText?: string } })?.response?.statusText === 'string') { + return String((error as { response: { statusText: string } }).response.statusText).trim() + } + + return '接口请求失败' +} + +export function apiGet(url: string, params?: Record) { + return http.get>(url, { params }) as unknown as Promise> +} + +export function apiGetBlob(url: string, params?: Record) { + return http.get(url, { + params, + responseType: 'blob', + }) as unknown as Promise +} + +export function apiPost(url: string, data?: Record) { + return http.post>(url, data) as unknown as Promise> +} + +export function apiDelete(url: string) { + return http.delete>(url) as unknown as Promise> +} diff --git a/apps/frontend/src/main.ts b/apps/frontend/src/main.ts new file mode 100644 index 00000000..215bd5c9 --- /dev/null +++ b/apps/frontend/src/main.ts @@ -0,0 +1,11 @@ +import { createApp } from 'vue' + +import App from './App.vue' +import router from './router' +import './styles/main.css' + +const app = createApp(App) + +app.use(router) + +app.mount('#app') diff --git a/apps/frontend/src/router/index.ts b/apps/frontend/src/router/index.ts new file mode 100644 index 00000000..10aaaa2e --- /dev/null +++ b/apps/frontend/src/router/index.ts @@ -0,0 +1,108 @@ +import { createRouter, createWebHashHistory } from 'vue-router' +import { hasAdminSession } from '@/utils/admin-auth' + +const router = createRouter({ + history: createWebHashHistory(), + routes: [ + { + path: '/', + redirect: '/tx/browser', + }, + { + path: '/tx', + redirect: '/tx/browser', + }, + { + path: '/tx/browser', + component: () => import('@/views/tx/TencentBrowserView.vue'), + }, + { + path: '/claim/:token', + component: () => import('@/views/claim/ClaimView.vue'), + }, + { + path: '/admin', + redirect: '/admin/dashboard', + }, + { + path: '/admin/login', + component: () => import('@/views/admin/AdminLoginView.vue'), + }, + { + path: '/admin', + component: () => import('@/views/admin/AdminLayout.vue'), + children: [ + { + path: '', + redirect: '/admin/dashboard', + }, + { + path: 'dashboard', + component: () => import('@/views/admin/AdminDashboardView.vue'), + }, + { + path: 'users', + component: () => import('@/views/admin/AdminUsersView.vue'), + }, + { + path: 'orders', + component: () => import('@/views/admin/AdminOrdersView.vue'), + }, + { + path: 'orders/:orderId', + component: () => import('@/views/admin/AdminOrderDetailView.vue'), + }, + { + path: 'tasks', + component: () => import('@/views/admin/AdminTasksView.vue'), + }, + { + path: 'tasks/:taskId', + component: () => import('@/views/admin/AdminTaskDetailView.vue'), + }, + { + path: 'cdks', + component: () => import('@/views/admin/AdminCdksView.vue'), + }, + { + path: 'webhook-events', + component: () => import('@/views/admin/AdminWebhookEventsView.vue'), + }, + { + path: 'webhook-events/:eventId', + component: () => import('@/views/admin/AdminWebhookEventDetailView.vue'), + }, + { + path: 'audit-logs', + component: () => import('@/views/admin/AdminAuditLogsView.vue'), + }, + ], + }, + { + path: '/:pathMatch(.*)*', + component: () => import('@/views/NotFoundView.vue'), + }, + ], +}) + +router.beforeEach((to) => { + if (!to.path.startsWith('/admin')) { + return true + } + + if (to.path === '/admin/login') { + if (hasAdminSession()) { + return '/admin/dashboard' + } + + return true + } + + if (!hasAdminSession()) { + return '/admin/login' + } + + return true +}) + +export default router diff --git a/apps/frontend/src/services/admin.ts b/apps/frontend/src/services/admin.ts new file mode 100644 index 00000000..13412964 --- /dev/null +++ b/apps/frontend/src/services/admin.ts @@ -0,0 +1,136 @@ +import { apiGet, apiGetBlob, apiPost } from '@/lib/http' +import type { + AdminAuditLogItem, + AdminTaskActionResponse, + AdminCdkListItem, + AdminDashboardSummary, + AdminLoginResponse, + AdminOrderDetail, + AdminOrderListItem, + AdminPagination, + AdminSessionSummary, + AdminTaskDetail, + AdminTaskListItem, + AdminUserListItem, + AdminWebhookEventDetail, + AdminWebhookEventListItem, + AdminWebhookReplayResponse, +} from '@/types/admin' + +export function loginAdmin(payload: { username: string; password: string }) { + return apiPost('/api/v1/admin/auth/login', payload) +} + +export function fetchAdminSession() { + return apiGet('/api/v1/admin/auth/session') +} + +export function logoutAdmin() { + return apiPost<{ success: boolean }>('/api/v1/admin/auth/logout', {}) +} + +export function fetchAdminDashboardSummary() { + return apiGet('/api/v1/admin/dashboard/summary') +} + +export function fetchAdminUsers(params?: Record) { + return apiGet<{ items: AdminUserListItem[]; pagination: AdminPagination }>('/api/v1/admin/users', params) +} + +export function createAdminUser(payload: { username: string; password: string; role: string; status?: string }) { + return apiPost<{ user: AdminUserListItem }>('/api/v1/admin/users', payload) +} + +export function updateAdminUserRole(userId: number | string, payload: { role: string }) { + return apiPost<{ user: AdminUserListItem }>(`/api/v1/admin/users/${userId}/role`, payload) +} + +export function updateAdminUserStatus(userId: number | string, payload: { status: string }) { + return apiPost<{ user: AdminUserListItem }>(`/api/v1/admin/users/${userId}/status`, payload) +} + +export function resetAdminUserPassword(userId: number | string, payload: { password: string }) { + return apiPost<{ user: AdminUserListItem }>(`/api/v1/admin/users/${userId}/reset-password`, payload) +} + +export function fetchAdminAuditLogs(params?: Record) { + return apiGet<{ items: AdminAuditLogItem[]; pagination: AdminPagination }>('/api/v1/admin/audit-logs', params) +} + +export function fetchAdminOrders(params?: Record) { + return apiGet<{ items: AdminOrderListItem[]; pagination: AdminPagination }>('/api/v1/admin/orders', params) +} + +export function fetchAdminOrderDetail(orderId: number | string) { + return apiGet(`/api/v1/admin/orders/${orderId}`) +} + +export function fetchAdminTasks(params?: Record) { + return apiGet<{ items: AdminTaskListItem[]; pagination: AdminPagination }>('/api/v1/admin/tasks', params) +} + +export function fetchAdminTaskDetail(taskId: number | string) { + return apiGet(`/api/v1/admin/tasks/${taskId}`) +} + +export function fetchAdminTaskScreenshot(taskId: number | string) { + return apiGetBlob(`/api/v1/admin/tasks/${taskId}/screenshot`) +} + +export function retryAdminTask(taskId: number | string) { + return apiPost(`/api/v1/admin/tasks/${taskId}/retry`, {}) +} + +export function releaseAdminTaskCdk(taskId: number | string) { + return apiPost(`/api/v1/admin/tasks/${taskId}/release-cdk`, {}) +} + +export function regenerateAdminTaskClaimLink(taskId: number | string) { + return apiPost( + `/api/v1/admin/tasks/${taskId}/regenerate-claim-link`, + {}, + ) +} + +export function closeAdminTask(taskId: number | string) { + return apiPost(`/api/v1/admin/tasks/${taskId}/close`, {}) +} + +export function markAdminTaskManualReview(taskId: number | string) { + return apiPost(`/api/v1/admin/tasks/${taskId}/mark-manual-review`, {}) +} + +export function fetchAdminCdks(params?: Record) { + return apiGet<{ items: AdminCdkListItem[]; pagination: AdminPagination }>('/api/v1/admin/cdks', params) +} + +export function importAdminCdks(payload: Record) { + return apiPost<{ total: number; created: number; duplicated: number }>('/api/v1/admin/cdks/import', payload) +} + +export function createAdminCdk(payload: Record) { + return apiPost<{ cdk: AdminCdkListItem | null }>('/api/v1/admin/cdks', payload) +} + +export function releaseAdminInventoryCdk(cdkId: number | string) { + return apiPost<{ cdk: AdminCdkListItem | null }>(`/api/v1/admin/cdks/${cdkId}/release`, {}) +} + +export function invalidateAdminCdk(cdkId: number | string, payload?: Record) { + return apiPost<{ cdk: AdminCdkListItem | null }>(`/api/v1/admin/cdks/${cdkId}/invalidate`, payload || {}) +} + +export function fetchAdminWebhookEvents(params?: Record) { + return apiGet<{ items: AdminWebhookEventListItem[]; pagination: AdminPagination }>( + '/api/v1/admin/webhook-events', + params, + ) +} + +export function fetchAdminWebhookEventDetail(eventId: number | string) { + return apiGet(`/api/v1/admin/webhook-events/${eventId}`) +} + +export function replayAdminWebhookEvent(eventId: number | string) { + return apiPost(`/api/v1/admin/webhook-events/${eventId}/replay`, {}) +} diff --git a/apps/frontend/src/services/claim.ts b/apps/frontend/src/services/claim.ts new file mode 100644 index 00000000..5900a4b3 --- /dev/null +++ b/apps/frontend/src/services/claim.ts @@ -0,0 +1,23 @@ +import { apiGet, apiPost } from '@/lib/http' +import type { ClaimDetailData } from '@/types/claim' +import type { TencentLoginType } from '@/types/tencent/session' + +export function fetchClaimDetail(token: string) { + return apiGet(`/api/v1/claim/${token}`) +} + +export function createClaimSession(token: string, loginType: TencentLoginType) { + return apiPost(`/api/v1/claim/${token}/session`, { loginType }) +} + +export function fetchClaimSessionSummary(token: string) { + return apiGet(`/api/v1/claim/${token}/session/summary`) +} + +export function confirmClaimRole(token: string) { + return apiPost(`/api/v1/claim/${token}/confirm-role`, { confirm: true }) +} + +export function redeemClaim(token: string) { + return apiPost(`/api/v1/claim/${token}/redeem`, {}) +} diff --git a/apps/frontend/src/services/tencent/artifacts.ts b/apps/frontend/src/services/tencent/artifacts.ts new file mode 100644 index 00000000..0387e34d --- /dev/null +++ b/apps/frontend/src/services/tencent/artifacts.ts @@ -0,0 +1,4 @@ +export function buildTencentBrowserSessionScreenshotUrl(sessionId: string, cacheKey = '') { + const suffix = cacheKey ? `?t=${encodeURIComponent(cacheKey)}` : '' + return `/api/v1/tencent/browser/session/${sessionId}/screenshot${suffix}` +} diff --git a/apps/frontend/src/services/tencent/redeem.ts b/apps/frontend/src/services/tencent/redeem.ts new file mode 100644 index 00000000..3deb1026 --- /dev/null +++ b/apps/frontend/src/services/tencent/redeem.ts @@ -0,0 +1,17 @@ +import { apiPost } from '@/lib/http' +import type { TencentBrowserSessionData } from '@/types/tencent/session' + +export interface TencentBrowserRedeemPayload { + code: string + maxAttempts?: number +} + +export function redeemTencentBrowserSession( + sessionId: string, + payload: TencentBrowserRedeemPayload, +) { + return apiPost( + `/api/v1/tencent/browser/session/${sessionId}/redeem`, + payload as unknown as Record, + ) +} diff --git a/apps/frontend/src/services/tencent/session.ts b/apps/frontend/src/services/tencent/session.ts new file mode 100644 index 00000000..48c44086 --- /dev/null +++ b/apps/frontend/src/services/tencent/session.ts @@ -0,0 +1,33 @@ +import { apiDelete, apiGet, apiPost } from '@/lib/http' +import type { + TencentBrowserSessionData, + TencentBrowserSessionSummaryData, + TencentLoginType, +} from '@/types/tencent/session' + +export interface CreateTencentBrowserSessionPayload { + loginType?: TencentLoginType +} + +export function createTencentBrowserSession(payload?: CreateTencentBrowserSessionPayload) { + return apiPost( + '/api/v1/tencent/browser/session', + payload as Record | undefined, + ) +} + +export function fetchTencentBrowserSession(sessionId: string) { + return apiGet(`/api/v1/tencent/browser/session/${sessionId}`) +} + +export function fetchTencentBrowserSessionSummary(sessionId: string) { + return apiGet(`/api/v1/tencent/browser/session/${sessionId}/summary`) +} + +export function refreshTencentBrowserSessionPage(sessionId: string) { + return apiPost(`/api/v1/tencent/browser/session/${sessionId}/refresh`) +} + +export function removeTencentBrowserSession(sessionId: string) { + return apiDelete>(`/api/v1/tencent/browser/session/${sessionId}`) +} diff --git a/apps/frontend/src/styles/main.css b/apps/frontend/src/styles/main.css new file mode 100644 index 00000000..0aff28e6 --- /dev/null +++ b/apps/frontend/src/styles/main.css @@ -0,0 +1,67 @@ +:root { + --color-bg: #eef4ff; + --color-bg-accent: #dfe9ff; + --color-surface: rgba(255, 255, 255, 0.92); + --color-panel-soft: rgba(15, 93, 216, 0.06); + --color-text: #152238; + --color-muted: #5f708a; + --color-accent: #0f5dd8; + --color-border: rgba(20, 47, 95, 0.08); + --font-sans: 'PingFang SC', 'SF Pro Display', 'Segoe UI', sans-serif; + --font-mono: 'JetBrains Mono', 'SFMono-Regular', ui-monospace, monospace; + color: var(--color-text); + background: + radial-gradient(circle at top left, rgba(44, 133, 247, 0.22), transparent 32%), + radial-gradient(circle at top right, rgba(114, 184, 255, 0.18), transparent 26%), + linear-gradient(180deg, var(--color-bg) 0%, #f7fbff 100%); + font-family: var(--font-sans); + line-height: 1.5; + font-weight: 400; + font-synthesis: none; + text-rendering: optimizeLegibility; + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; +} + +*, +*::before, +*::after { + box-sizing: border-box; +} + +html, +body, +#app { + min-height: 100%; +} + +body { + margin: 0; +} + +body::before { + content: ''; + position: fixed; + inset: 0; + pointer-events: none; + background-image: + linear-gradient(rgba(20, 47, 95, 0.02) 1px, transparent 1px), + linear-gradient(90deg, rgba(20, 47, 95, 0.02) 1px, transparent 1px); + background-size: 24px 24px; + mask-image: radial-gradient(circle at center, black, transparent 75%); +} + +img { + max-width: 100%; +} + +button, +input, +select, +textarea { + font: inherit; +} + +a { + color: inherit; +} diff --git a/apps/frontend/src/types/admin.ts b/apps/frontend/src/types/admin.ts new file mode 100644 index 00000000..a0e686a7 --- /dev/null +++ b/apps/frontend/src/types/admin.ts @@ -0,0 +1,259 @@ +export interface AdminPagination { + page: number + pageSize: number + total: number +} + +export type AdminRole = 'admin' | 'operator' +export type AdminUserStatus = 'active' | 'disabled' + +export interface AdminLoginResponse { + token: string + expiresAt: string + user: { + userId: number + username: string + role: AdminRole + } +} + +export interface AdminSessionSummary { + authenticated: boolean + expiresAt: string + user: { + userId: number + username: string + role: AdminRole + } +} + +export interface AdminUserListItem { + userId: number + username: string + role: AdminRole + status: AdminUserStatus + createdAt: string + updatedAt: string +} + +export interface AdminAuditLogItem { + logId: number + actorUserId: number + actorUsername: string + actorRole: AdminRole + action: string + targetType: string + targetId: string + payload: Record + createdAt: string +} + +export interface AdminDashboardSummary { + todayOrders: number + paidPendingClaim: number + claimingTasks: number + redeemedToday: number + abnormalTasks: number + skuWithInventory: number +} + +export interface AdminOrderListItem { + orderId: number + platform: string + platformOrderId: string + orderStatus: string + payStatus: string + buyerName: string + totalAmount: number + currency: string + createdAt: string + updatedAt: string + taskCount: number + systemBindingStatus: string + userBindingStatus: string + systemBoundTaskCount: number + completedBindingTaskCount: number +} + +export interface AdminTaskListItem { + taskId: number + taskNo: string + platformOrderId: string + skuCode: string + skuName: string + status: string + systemBindingStatus: string + userBindingStatus: string + loginType: string + roleName: string + roleId: string + browserSessionId: string + claimedAt: string | null + roleConfirmedAt: string | null + redeemedAt: string | null + retryCount: number + lastError: string + createdAt: string + updatedAt: string + reservedCdkCodeMasked: string + claimToken: string + screenshotPath: string +} + +export interface AdminTaskOperations { + canRetry: boolean + canReleaseCdk: boolean + canRegenerateClaimLink: boolean + canClose: boolean + canMarkManualReview: boolean +} + +export interface AdminTaskActionResponse { + task: Record + claimUrl?: string + token?: string +} + +export interface AdminOrderDetail { + order: { + orderId: number + platform: string + platformOrderId: string + orderStatus: string + payStatus: string + buyerId: string + buyerName: string + receiverContact: string + totalAmount: number + currency: string + paidAt: string | null + createdAt: string + updatedAt: string + rawPayload: Record + bindingSummary: { + totalTaskCount: number + systemBoundTaskCount: number + completedBindingTaskCount: number + systemBindingStatus: string + userBindingStatus: string + } + } + items: Array<{ + orderItemId: number + skuCode: string + skuName: string + quantity: number + deliveryMode: string + spec: Record + }> + tasks: Array<{ + taskId: number + taskNo: string + status: string + systemBindingStatus: string + userBindingStatus: string + loginType: string + browserSessionId: string + claimedAt: string | null + roleConfirmedAt: string | null + redeemedAt: string | null + lastError: string + retryCount: number + createdAt: string + updatedAt: string + }> + webhookEvents: Array<{ + eventId: number + platform: string + eventType: string + eventKey: string + signatureValid: boolean + processed: boolean + processError: string + createdAt: string + }> +} + +export interface AdminTaskDetail { + task: AdminTaskListItem + order: null | { + orderId: number + platformOrderId: string + payStatus: string + orderStatus: string + } + orderItem: null | { + orderItemId: number + skuCode: string + skuName: string + quantity: number + } + claimToken: null | { + claimTokenId: number + token: string + status: string + expiredAt: string + } + cdk: null | { + cdkId: number + skuCode: string + batchNo: string + cdkCode: string + status: string + } + artifacts: Record + screenshotUrl: string + operations: AdminTaskOperations +} + +export interface AdminCdkListItem { + cdkId: number + skuCode: string + batchNo: string + cdkCode: string + status: string + reservedByTaskId: number | null + reservedByTaskNo: string + platformOrderId: string + systemBindingStatus: string + userBindingStatus: string + invalidReason: string + deliveredAt: string | null + createdAt: string + updatedAt: string +} + +export interface AdminWebhookEventListItem { + eventId: number + platform: string + eventType: string + eventKey: string + signatureValid: boolean + processed: boolean + processError: string + relatedOrderId: number | null + createdAt: string +} + +export interface AdminWebhookEventDetail extends AdminWebhookEventListItem { + headers: Record + query: Record + body: Record +} + +export interface AdminWebhookReplayResponse { + eventId: number + replayed: boolean + result: { + accepted: boolean + eventType: string + platformOrderId: string + orderId: number + taskCount: number + tasks: Array<{ + taskId: number + taskNo: string + status: string + }> + } +} diff --git a/apps/frontend/src/types/claim.ts b/apps/frontend/src/types/claim.ts new file mode 100644 index 00000000..6f3dbc1b --- /dev/null +++ b/apps/frontend/src/types/claim.ts @@ -0,0 +1,66 @@ +import type { TencentBrowserSessionData, TencentBrowserSessionSummaryData } from './tencent/session' + +export type ClaimTokenStatus = 'active' | 'used' | 'expired' | 'revoked' | (string & {}) + +export type ClaimTaskStatus = + | 'pending_payment' + | 'paid' + | 'waiting_inventory' + | 'cdk_reserved' + | 'link_generated' + | 'claimed' + | 'role_confirmed' + | 'redeeming' + | 'redeemed' + | 'retry_pending' + | 'manual_review' + | 'expired' + | 'closed' + | (string & {}) + +export interface ClaimTaskInfo { + taskId: number + taskNo: string + status: ClaimTaskStatus + expiresAt: string | null + claimedAt: string | null + roleConfirmedAt: string | null + redeemedAt: string | null + loginType: string + lastError: string + browserSessionId: string +} + +export interface ClaimOrderInfo { + orderId: number + platform: string + platformOrderId: string + payStatus: string + orderStatus: string + totalAmount: number + currency: string +} + +export interface ClaimOrderItemInfo { + orderItemId: number + skuCode: string + skuName: string + quantity: number +} + +export interface ClaimResultInfo { + resultCode: string + resultMessage: string + screenshotReady: boolean + screenshotUrl: string +} + +export interface ClaimDetailData { + tokenStatus: ClaimTokenStatus + claimUrl: string + task: ClaimTaskInfo + order: ClaimOrderInfo + orderItem: ClaimOrderItemInfo + session: TencentBrowserSessionData | TencentBrowserSessionSummaryData | null + result: ClaimResultInfo | null +} diff --git a/apps/frontend/src/types/tencent/activity.ts b/apps/frontend/src/types/tencent/activity.ts new file mode 100644 index 00000000..3de13bf3 --- /dev/null +++ b/apps/frontend/src/types/tencent/activity.ts @@ -0,0 +1,45 @@ +export interface TencentBrowserSessionArtifacts { + hasQrImage: boolean + hasScreenshot: boolean +} + +export interface TencentBrowserRoleInfo { + ready: boolean + roleName: string + roleId: string + area: string + partition: string + platId: string + md5str: string + checkparam: string +} + +export interface TencentBrowserFormInfo { + cdkeyInputId: string + cdkeyValue: string + verifyInputId: string + verifyValue: string + verifyImgId: string + submitId: string +} + +export interface TencentBrowserVerifyInfo { + visible: boolean + src: string + naturalWidth: number + naturalHeight: number +} + +export interface TencentBrowserPopupInfo { + visible: boolean + text: string + detail: string +} + +export interface TencentBrowserActivityInfo { + nickname: string + role: TencentBrowserRoleInfo + form: TencentBrowserFormInfo + verify: TencentBrowserVerifyInfo + popup: TencentBrowserPopupInfo +} diff --git a/apps/frontend/src/types/tencent/redeem.ts b/apps/frontend/src/types/tencent/redeem.ts new file mode 100644 index 00000000..4df77a64 --- /dev/null +++ b/apps/frontend/src/types/tencent/redeem.ts @@ -0,0 +1,12 @@ +export interface TencentBrowserRedeemAttempt { + attempt: number + redeem?: Record | null +} + +export interface TencentBrowserRedeemResult { + code: string + area: string + attempts: TencentBrowserRedeemAttempt[] + final?: TencentBrowserRedeemAttempt | null + finishedAt: string +} diff --git a/apps/frontend/src/types/tencent/session.ts b/apps/frontend/src/types/tencent/session.ts new file mode 100644 index 00000000..846efe3f --- /dev/null +++ b/apps/frontend/src/types/tencent/session.ts @@ -0,0 +1,36 @@ +import type { TencentBrowserActivityInfo, TencentBrowserSessionArtifacts } from './activity' +import type { TencentBrowserRedeemResult } from './redeem' + +export type TencentLoginType = 'qq' | 'wx' + +export type TencentBrowserSessionStatus = + | 'waiting_scan' + | 'scanned' + | 'logged_in' + | 'ready_to_redeem' + | 'redeeming' + | 'redeemed' + | 'failed' + | (string & {}) + +export interface TencentBrowserSessionData { + sessionId: string + loginType: TencentLoginType | string + activityUrl: string + status: TencentBrowserSessionStatus + notice: string + createdAt: string + updatedAt: string + expiresAt: string + qrImageBase64: string + qrUpdatedAt: string + credentialReady: boolean + activityInfo?: TencentBrowserActivityInfo | null + redeem?: TencentBrowserRedeemResult | null + artifacts: TencentBrowserSessionArtifacts +} + +export interface TencentBrowserSessionSummaryData + extends Omit { + qrImageBase64?: string +} diff --git a/apps/frontend/src/utils/admin-auth.ts b/apps/frontend/src/utils/admin-auth.ts new file mode 100644 index 00000000..de204aaa --- /dev/null +++ b/apps/frontend/src/utils/admin-auth.ts @@ -0,0 +1,68 @@ +const ADMIN_TOKEN_KEY = 'order-site-admin-token' +const ADMIN_EXPIRES_AT_KEY = 'order-site-admin-token-expires-at' +const ADMIN_USER_ID_KEY = 'order-site-admin-user-id' +const ADMIN_USERNAME_KEY = 'order-site-admin-username' +const ADMIN_ROLE_KEY = 'order-site-admin-role' + +export function getAdminToken() { + return localStorage.getItem(ADMIN_TOKEN_KEY) || '' +} + +export function getAdminTokenExpiresAt() { + return localStorage.getItem(ADMIN_EXPIRES_AT_KEY) || '' +} + +export function getAdminUserId() { + const value = Number(localStorage.getItem(ADMIN_USER_ID_KEY) || 0) + return Number.isFinite(value) && value > 0 ? value : 0 +} + +export function getAdminUsername() { + return localStorage.getItem(ADMIN_USERNAME_KEY) || '' +} + +export function getAdminRole() { + const role = localStorage.getItem(ADMIN_ROLE_KEY) + return role === 'admin' ? 'admin' : 'operator' +} + +export function hasAdminRole(role: 'admin' | 'operator') { + if (role === 'operator') { + return Boolean(getAdminToken()) + } + + return getAdminRole() === 'admin' +} + +export function setAdminSession( + token: string, + expiresAt: string, + user?: { userId: number; username: string; role: 'admin' | 'operator' }, +) { + localStorage.setItem(ADMIN_TOKEN_KEY, token) + localStorage.setItem(ADMIN_EXPIRES_AT_KEY, expiresAt) + + if (user?.userId) { + localStorage.setItem(ADMIN_USER_ID_KEY, String(user.userId)) + } + + if (user?.username) { + localStorage.setItem(ADMIN_USERNAME_KEY, user.username) + } + + if (user?.role) { + localStorage.setItem(ADMIN_ROLE_KEY, user.role) + } +} + +export function clearAdminSession() { + localStorage.removeItem(ADMIN_TOKEN_KEY) + localStorage.removeItem(ADMIN_EXPIRES_AT_KEY) + localStorage.removeItem(ADMIN_USER_ID_KEY) + localStorage.removeItem(ADMIN_USERNAME_KEY) + localStorage.removeItem(ADMIN_ROLE_KEY) +} + +export function hasAdminSession() { + return Boolean(getAdminToken()) +} diff --git a/apps/frontend/src/utils/admin-display.ts b/apps/frontend/src/utils/admin-display.ts new file mode 100644 index 00000000..0bc878e6 --- /dev/null +++ b/apps/frontend/src/utils/admin-display.ts @@ -0,0 +1,45 @@ +export function formatStatusWithRaw(status: string, labelMap: Record = {}) { + const normalized = String(status || '').trim() + + if (!normalized) { + return '-' + } + + const key = normalized.toLowerCase() + const label = labelMap[key] + + if (!label || label === normalized) { + return normalized + } + + return `${label} (${normalized})` +} + +export function formatAuditAction(action: string) { + const labelMap: Record = { + admin_user_created: '创建后台用户', + admin_user_role_updated: '修改用户角色', + admin_user_status_updated: '修改用户状态', + admin_user_password_reset: '重置用户密码', + task_release_cdk: '释放任务 CDK', + task_regenerate_claim_link: '重发领取链接', + task_closed: '关闭任务', + task_mark_manual_review: '转人工处理', + inventory_cdk_released: '释放库存 CDK', + inventory_cdk_invalidated: '作废库存 CDK', + webhook_replayed: '重放 Webhook', + } + + return formatStatusWithRaw(action, labelMap) +} + +export function formatAuditTargetType(targetType: string) { + const labelMap: Record = { + admin_user: '后台用户', + task: '交付任务', + cdk: 'CDK 库存', + webhook_event: 'Webhook 事件', + } + + return formatStatusWithRaw(targetType, labelMap) +} diff --git a/apps/frontend/src/utils/admin-options.ts b/apps/frontend/src/utils/admin-options.ts new file mode 100644 index 00000000..f236d40c --- /dev/null +++ b/apps/frontend/src/utils/admin-options.ts @@ -0,0 +1,73 @@ +export const adminPayStatusOptions = [ + { label: '全部', value: '' }, + { label: '未支付', value: 'unpaid' }, + { label: '已支付', value: 'paid' }, + { label: '失败', value: 'failed' }, + { label: '已退款', value: 'refunded' }, +] + +export const adminTaskStatusOptions = [ + { label: '全部', value: '' }, + { label: '待支付', value: 'pending_payment' }, + { label: '已支付', value: 'paid' }, + { label: '待库存', value: 'waiting_inventory' }, + { label: '已预占', value: 'cdk_reserved' }, + { label: '已生成链接', value: 'link_generated' }, + { label: '已创建会话', value: 'claimed' }, + { label: '已确认角色', value: 'role_confirmed' }, + { label: '兑换中', value: 'redeeming' }, + { label: '已兑换', value: 'redeemed' }, + { label: '待重试', value: 'retry_pending' }, + { label: '人工处理', value: 'manual_review' }, + { label: '已过期', value: 'expired' }, + { label: '已关闭', value: 'closed' }, +] + +export const adminCdkStatusOptions = [ + { label: '全部', value: '' }, + { label: '可用', value: 'available' }, + { label: '已预占', value: 'reserved' }, + { label: '已发放', value: 'delivered' }, + { label: '已作废', value: 'invalid' }, +] + +export const adminWebhookProcessedOptions = [ + { label: '全部', value: '' }, + { label: '处理成功', value: '1' }, + { label: '处理失败', value: '0' }, +] + +export const adminUserRoleOptions = [ + { label: '全部', value: '' }, + { label: '管理员', value: 'admin' }, + { label: '普通运营', value: 'operator' }, +] + +export const adminUserStatusOptions = [ + { label: '全部', value: '' }, + { label: '生效中', value: 'active' }, + { label: '已停用', value: 'disabled' }, +] + +export const adminAuditActionOptions = [ + { label: '全部', value: '' }, + { label: '创建后台用户', value: 'admin_user_created' }, + { label: '修改用户角色', value: 'admin_user_role_updated' }, + { label: '修改用户状态', value: 'admin_user_status_updated' }, + { label: '重置用户密码', value: 'admin_user_password_reset' }, + { label: '释放任务 CDK', value: 'task_release_cdk' }, + { label: '重发领取链接', value: 'task_regenerate_claim_link' }, + { label: '关闭任务', value: 'task_closed' }, + { label: '转人工处理', value: 'task_mark_manual_review' }, + { label: '释放库存 CDK', value: 'inventory_cdk_released' }, + { label: '作废库存 CDK', value: 'inventory_cdk_invalidated' }, + { label: '重放 Webhook', value: 'webhook_replayed' }, +] + +export const adminAuditTargetTypeOptions = [ + { label: '全部', value: '' }, + { label: '后台用户', value: 'admin_user' }, + { label: '交付任务', value: 'task' }, + { label: 'CDK 库存', value: 'cdk' }, + { label: 'Webhook 事件', value: 'webhook_event' }, +] diff --git a/apps/frontend/src/utils/admin-time.ts b/apps/frontend/src/utils/admin-time.ts new file mode 100644 index 00000000..b3696153 --- /dev/null +++ b/apps/frontend/src/utils/admin-time.ts @@ -0,0 +1 @@ +export { formatDateTime as formatAdminDateTime } from '@/utils/date-time' diff --git a/apps/frontend/src/utils/admin-webhook.ts b/apps/frontend/src/utils/admin-webhook.ts new file mode 100644 index 00000000..32af4328 --- /dev/null +++ b/apps/frontend/src/utils/admin-webhook.ts @@ -0,0 +1,22 @@ +import { formatStatusWithRaw } from '@/utils/admin-display' + +export function formatAdminWebhookEventType(eventType: string) { + const normalized = String(eventType || '').trim() + + if (!normalized) { + return '-' + } + + const labelMap: Record = { + payment_success: '支付成功', + trade_create: '订单创建', + trade_closed: '订单关闭', + buyer_confirm_goods: '确认收货', + auto_delivery_done: '自动发货完成', + auto_delivery_confirmed: '自动发货确认完成', + refund_apply: '买家申请退款', + trade_event: '交易事件', + } + + return formatStatusWithRaw(normalized, labelMap) +} diff --git a/apps/frontend/src/utils/date-time.ts b/apps/frontend/src/utils/date-time.ts new file mode 100644 index 00000000..df5319f2 --- /dev/null +++ b/apps/frontend/src/utils/date-time.ts @@ -0,0 +1,51 @@ +const ISO_DATE_TIME_PATTERN = + /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d+)?(?:Z|[+-]\d{2}:\d{2})?$/ + +export function isIsoDateTimeString(value: unknown): value is string { + return typeof value === 'string' && ISO_DATE_TIME_PATTERN.test(value.trim()) +} + +export function formatDateTime(value: string | null | undefined, fallback = '-') { + const normalized = String(value || '').trim() + + if (!normalized) { + return fallback + } + + const date = new Date(normalized) + + if (Number.isNaN(date.getTime())) { + return normalized.replace('T', ' ').replace('Z', '') + } + + const year = date.getFullYear() + const month = String(date.getMonth() + 1).padStart(2, '0') + const day = String(date.getDate()).padStart(2, '0') + const hours = String(date.getHours()).padStart(2, '0') + const minutes = String(date.getMinutes()).padStart(2, '0') + const seconds = String(date.getSeconds()).padStart(2, '0') + + return `${year}-${month}-${day} ${hours}:${minutes}:${seconds}` +} + +export function normalizeDateTimeValue(value: unknown): unknown { + if (Array.isArray(value)) { + return value.map((item) => normalizeDateTimeValue(item)) + } + + if (value && typeof value === 'object') { + return Object.fromEntries( + Object.entries(value).map(([key, currentValue]) => [key, normalizeDateTimeValue(currentValue)]), + ) + } + + if (isIsoDateTimeString(value)) { + return formatDateTime(value, '') + } + + return value +} + +export function stringifyDisplayJson(value: unknown, space = 2) { + return JSON.stringify(normalizeDateTimeValue(value), null, space) +} diff --git a/apps/frontend/src/views/NotFoundView.vue b/apps/frontend/src/views/NotFoundView.vue new file mode 100644 index 00000000..d6f04c77 --- /dev/null +++ b/apps/frontend/src/views/NotFoundView.vue @@ -0,0 +1,48 @@ + + + diff --git a/apps/frontend/src/views/admin/AdminAuditLogsView.vue b/apps/frontend/src/views/admin/AdminAuditLogsView.vue new file mode 100644 index 00000000..4041d838 --- /dev/null +++ b/apps/frontend/src/views/admin/AdminAuditLogsView.vue @@ -0,0 +1,232 @@ + + + + + diff --git a/apps/frontend/src/views/admin/AdminCdksView.vue b/apps/frontend/src/views/admin/AdminCdksView.vue new file mode 100644 index 00000000..9912b920 --- /dev/null +++ b/apps/frontend/src/views/admin/AdminCdksView.vue @@ -0,0 +1,327 @@ + + +