init
This commit is contained in:
@@ -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
|
||||||
@@ -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}
|
||||||
+19
@@ -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
|
||||||
@@ -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 卷
|
||||||
@@ -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
|
||||||
@@ -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/<sessionId>/qq-qr.png`
|
||||||
|
- `data/browser-sessions/<sessionId>/session.json`
|
||||||
|
- `data/browser-sessions/<sessionId>/captcha-attempt-*.png`
|
||||||
|
- `data/browser-sessions/<sessionId>/redeem-result.png`
|
||||||
|
- `data/browser-sessions/<sessionId>/page.html`
|
||||||
|
- `data/browser-sessions/<sessionId>/result.json`
|
||||||
|
|
||||||
|
这些都是运行时产物,默认不提交 Git。
|
||||||
|
|
||||||
|
`TENCENT_REDEEM_PROOF_MODE` 对应的配置文件项会影响生成强度:
|
||||||
|
|
||||||
|
- `full`:完整证明
|
||||||
|
- `basic`:只保留最终截图和结果 JSON
|
||||||
|
- `off`:不生成证明文件
|
||||||
@@ -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
|
||||||
@@ -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',
|
||||||
|
},
|
||||||
|
}
|
||||||
@@ -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',
|
||||||
|
},
|
||||||
|
}
|
||||||
Generated
+871
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
Executable
+99
@@ -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 <<EOF
|
||||||
|
{"biz_order_id":"${ORDER_ID}","buyer_id":"buyer-local-001","buyer_name":"本地测试用户","receiver_contact":"13800000000","total_fee":"1","currency":"CNY","items":[{"goods_id":"${SKU_ID}","goods_name":"${SKU_NAME}","quantity":1}]}
|
||||||
|
EOF
|
||||||
|
}
|
||||||
|
|
||||||
|
paid_payload() {
|
||||||
|
cat <<EOF
|
||||||
|
{"biz_order_id":"${ORDER_ID}","buyer_id":"buyer-local-001","buyer_name":"本地测试用户","receiver_contact":"13800000000","total_fee":"1","currency":"CNY","order_status":"3","items":[{"goods_id":"${SKU_ID}","goods_name":"${SKU_NAME}","quantity":1}]}
|
||||||
|
EOF
|
||||||
|
}
|
||||||
|
|
||||||
|
confirm_payload() {
|
||||||
|
cat <<EOF
|
||||||
|
{"biz_order_id":"${ORDER_ID}","buyer_id":"buyer-local-001","buyer_name":"本地测试用户","receiver_contact":"13800000000","total_fee":"1","currency":"CNY","order_status":"4","items":[{"goods_id":"${SKU_ID}","goods_name":"${SKU_NAME}","quantity":1}]}
|
||||||
|
EOF
|
||||||
|
}
|
||||||
|
|
||||||
|
push_create() {
|
||||||
|
push_agiso_event "32" "$(create_payload)"
|
||||||
|
}
|
||||||
|
|
||||||
|
push_paid() {
|
||||||
|
push_agiso_event "1" "$(paid_payload)"
|
||||||
|
}
|
||||||
|
|
||||||
|
push_confirm() {
|
||||||
|
push_agiso_event "256" "$(confirm_payload)"
|
||||||
|
}
|
||||||
|
|
||||||
|
case "${2:-all}" in
|
||||||
|
create)
|
||||||
|
push_create
|
||||||
|
;;
|
||||||
|
paid)
|
||||||
|
push_paid
|
||||||
|
;;
|
||||||
|
confirm)
|
||||||
|
push_confirm
|
||||||
|
;;
|
||||||
|
all)
|
||||||
|
push_create
|
||||||
|
push_paid
|
||||||
|
;;
|
||||||
|
*)
|
||||||
|
echo "用法: $0 [ORDER_ID] [create|paid|confirm|all]"
|
||||||
|
exit 1
|
||||||
|
;;
|
||||||
|
esac
|
||||||
@@ -0,0 +1,255 @@
|
|||||||
|
import fs from 'node:fs'
|
||||||
|
import path from 'node:path'
|
||||||
|
import process from 'node:process'
|
||||||
|
import { createRequire } from 'node:module'
|
||||||
|
import { fileURLToPath } from 'node:url'
|
||||||
|
|
||||||
|
const require = createRequire(import.meta.url)
|
||||||
|
const CURRENT_DIR = path.dirname(fileURLToPath(import.meta.url))
|
||||||
|
export const PROJECT_ROOT = path.resolve(CURRENT_DIR, '../..')
|
||||||
|
const CONFIG_ROOT = path.join(PROJECT_ROOT, 'config')
|
||||||
|
|
||||||
|
const defaultConfig = loadConfig(path.join(CONFIG_ROOT, 'default.cjs'))
|
||||||
|
const localConfig = loadConfig(path.join(CONFIG_ROOT, 'local.cjs'))
|
||||||
|
const mergedConfig = deepMerge(defaultConfig, localConfig)
|
||||||
|
|
||||||
|
export const runtimeConfig = applyEnvOverrides(mergedConfig)
|
||||||
|
|
||||||
|
function loadConfig(configPath) {
|
||||||
|
if (!fs.existsSync(configPath)) {
|
||||||
|
return {}
|
||||||
|
}
|
||||||
|
|
||||||
|
const loaded = require(configPath)
|
||||||
|
return isPlainObject(loaded) ? loaded : {}
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyEnvOverrides(baseConfig) {
|
||||||
|
const nextConfig = deepMerge(baseConfig, {})
|
||||||
|
|
||||||
|
const port = parseInteger(process.env.PORT)
|
||||||
|
if (port !== null) {
|
||||||
|
nextConfig.server.port = port
|
||||||
|
}
|
||||||
|
|
||||||
|
const chromePath = String(process.env.CHROME_PATH || '').trim()
|
||||||
|
if (chromePath) {
|
||||||
|
nextConfig.browser.chromePath = chromePath
|
||||||
|
}
|
||||||
|
|
||||||
|
const browserHeadless = parseBoolean(process.env.TENCENT_BROWSER_HEADLESS)
|
||||||
|
if (browserHeadless !== null) {
|
||||||
|
nextConfig.browser.headless = browserHeadless
|
||||||
|
}
|
||||||
|
|
||||||
|
const browserDevtools = parseBoolean(process.env.TENCENT_BROWSER_DEVTOOLS)
|
||||||
|
if (browserDevtools !== null) {
|
||||||
|
nextConfig.browser.devtools = browserDevtools
|
||||||
|
}
|
||||||
|
|
||||||
|
const browserKeepAlive = parseBoolean(process.env.TENCENT_BROWSER_KEEP_ALIVE)
|
||||||
|
if (browserKeepAlive !== null) {
|
||||||
|
nextConfig.browser.keepAlive = browserKeepAlive
|
||||||
|
}
|
||||||
|
|
||||||
|
const browserPrewarm = parseBoolean(process.env.TENCENT_BROWSER_PREWARM)
|
||||||
|
if (browserPrewarm !== null) {
|
||||||
|
nextConfig.browser.prewarm = browserPrewarm
|
||||||
|
}
|
||||||
|
|
||||||
|
const browserSlowMoMs = parseInteger(process.env.TENCENT_BROWSER_SLOW_MO)
|
||||||
|
if (browserSlowMoMs !== null) {
|
||||||
|
nextConfig.browser.slowMoMs = browserSlowMoMs
|
||||||
|
}
|
||||||
|
|
||||||
|
const sessionDebug = parseBoolean(process.env.TENCENT_SESSION_DEBUG)
|
||||||
|
if (sessionDebug !== null) {
|
||||||
|
nextConfig.session.debug = sessionDebug
|
||||||
|
}
|
||||||
|
|
||||||
|
const ocrProjectRoot = String(process.env.OCR_PROJECT_ROOT || '').trim()
|
||||||
|
if (ocrProjectRoot) {
|
||||||
|
nextConfig.ocr.projectRoot = ocrProjectRoot
|
||||||
|
}
|
||||||
|
|
||||||
|
const proofMode = String(process.env.TENCENT_REDEEM_PROOF_MODE || '').trim()
|
||||||
|
if (proofMode) {
|
||||||
|
nextConfig.redeem.proofMode = proofMode
|
||||||
|
}
|
||||||
|
|
||||||
|
const databaseFilePath = String(process.env.DATABASE_FILE_PATH || '').trim()
|
||||||
|
if (databaseFilePath) {
|
||||||
|
nextConfig.database.filePath = databaseFilePath
|
||||||
|
}
|
||||||
|
|
||||||
|
const claimBaseUrl = String(process.env.CLAIM_BASE_URL || '').trim()
|
||||||
|
if (claimBaseUrl) {
|
||||||
|
nextConfig.orders.claimBaseUrl = claimBaseUrl
|
||||||
|
}
|
||||||
|
|
||||||
|
const skuMappings = parseJsonObject(process.env.ORDER_SKU_MAPPINGS_JSON)
|
||||||
|
if (skuMappings) {
|
||||||
|
nextConfig.orders.skuMappings = skuMappings
|
||||||
|
}
|
||||||
|
|
||||||
|
const adminSessionSecret = String(process.env.ADMIN_SESSION_SECRET || '').trim()
|
||||||
|
if (adminSessionSecret) {
|
||||||
|
nextConfig.admin.sessionSecret = adminSessionSecret
|
||||||
|
}
|
||||||
|
|
||||||
|
const adminSessionTtlHours = parseInteger(process.env.ADMIN_SESSION_TTL_HOURS)
|
||||||
|
if (adminSessionTtlHours !== null) {
|
||||||
|
nextConfig.admin.sessionTtlHours = adminSessionTtlHours
|
||||||
|
}
|
||||||
|
|
||||||
|
const adminDefaultUsers = parseJsonArray(process.env.ADMIN_DEFAULT_USERS_JSON)
|
||||||
|
if (adminDefaultUsers) {
|
||||||
|
nextConfig.admin.defaultUsers = adminDefaultUsers
|
||||||
|
}
|
||||||
|
|
||||||
|
const agisoAppSecret = String(process.env.AGISO_APP_SECRET || '').trim()
|
||||||
|
if (agisoAppSecret) {
|
||||||
|
nextConfig.platforms.agiso.appSecret = agisoAppSecret
|
||||||
|
}
|
||||||
|
|
||||||
|
const agisoAppId = String(process.env.AGISO_APP_ID || '').trim()
|
||||||
|
if (agisoAppId) {
|
||||||
|
nextConfig.platforms.agiso.messaging.appId = agisoAppId
|
||||||
|
}
|
||||||
|
|
||||||
|
const agisoAccessToken = String(process.env.AGISO_ACCESS_TOKEN || '').trim()
|
||||||
|
if (agisoAccessToken) {
|
||||||
|
nextConfig.platforms.agiso.messaging.accessToken = agisoAccessToken
|
||||||
|
}
|
||||||
|
|
||||||
|
const agisoMessageAppSecret = String(process.env.AGISO_MESSAGE_APP_SECRET || '').trim()
|
||||||
|
if (agisoMessageAppSecret) {
|
||||||
|
nextConfig.platforms.agiso.messaging.appSecret = agisoMessageAppSecret
|
||||||
|
}
|
||||||
|
|
||||||
|
const agisoMessageApiVersion = String(process.env.AGISO_MESSAGE_API_VERSION || '').trim()
|
||||||
|
if (agisoMessageApiVersion) {
|
||||||
|
nextConfig.platforms.agiso.messaging.apiVersion = agisoMessageApiVersion
|
||||||
|
}
|
||||||
|
|
||||||
|
const agisoSendMessageEndpoint = String(process.env.AGISO_SEND_MESSAGE_ENDPOINT || '').trim()
|
||||||
|
if (agisoSendMessageEndpoint) {
|
||||||
|
nextConfig.platforms.agiso.messaging.sendMessageEndpoint = agisoSendMessageEndpoint
|
||||||
|
}
|
||||||
|
|
||||||
|
const agisoMessagingEnabled = parseBoolean(process.env.AGISO_MESSAGING_ENABLED)
|
||||||
|
if (agisoMessagingEnabled !== null) {
|
||||||
|
nextConfig.platforms.agiso.messaging.enabled = agisoMessagingEnabled
|
||||||
|
}
|
||||||
|
|
||||||
|
const agisoMessageTemplate = String(process.env.AGISO_MESSAGE_TEMPLATE || '').trim()
|
||||||
|
if (agisoMessageTemplate) {
|
||||||
|
nextConfig.platforms.agiso.messaging.messageTemplate = agisoMessageTemplate
|
||||||
|
}
|
||||||
|
|
||||||
|
return nextConfig
|
||||||
|
}
|
||||||
|
|
||||||
|
function deepMerge(baseValue, overrideValue) {
|
||||||
|
if (!isPlainObject(baseValue)) {
|
||||||
|
return cloneValue(overrideValue)
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = cloneValue(baseValue)
|
||||||
|
|
||||||
|
if (!isPlainObject(overrideValue)) {
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const [key, value] of Object.entries(overrideValue)) {
|
||||||
|
if (isPlainObject(value) && isPlainObject(result[key])) {
|
||||||
|
result[key] = deepMerge(result[key], value)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
result[key] = cloneValue(value)
|
||||||
|
}
|
||||||
|
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
function cloneValue(value) {
|
||||||
|
if (Array.isArray(value)) {
|
||||||
|
return value.map((item) => 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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -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()
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
@@ -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);
|
||||||
@@ -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);
|
||||||
@@ -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);
|
||||||
@@ -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')
|
||||||
|
})
|
||||||
@@ -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),
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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),
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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),
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -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]
|
||||||
|
}
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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 {}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)}`
|
||||||
|
}
|
||||||
@@ -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())
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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')
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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 = `
|
||||||
|
<div class="codex-redeem-result__eyebrow">Tencent Browser Session</div>
|
||||||
|
<div class="codex-redeem-result__title">兑换结果</div>
|
||||||
|
<div class="codex-redeem-result__message"></div>
|
||||||
|
`
|
||||||
|
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 `<!doctype html>
|
||||||
|
<html lang="zh-CN">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<title>兑换与北京时间凭证</title>
|
||||||
|
<style>
|
||||||
|
:root {
|
||||||
|
color-scheme: light;
|
||||||
|
}
|
||||||
|
* {
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
body {
|
||||||
|
margin: 0;
|
||||||
|
font-family: "PingFang SC", "Microsoft YaHei", sans-serif;
|
||||||
|
background:
|
||||||
|
radial-gradient(circle at top left, rgba(33, 150, 243, 0.16), transparent 28%),
|
||||||
|
linear-gradient(180deg, #edf4ff 0%, #f6f8fc 52%, #eef1f7 100%);
|
||||||
|
color: #142033;
|
||||||
|
}
|
||||||
|
.sheet {
|
||||||
|
width: 1480px;
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: 30px 24px 28px;
|
||||||
|
}
|
||||||
|
.hero {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 20px;
|
||||||
|
align-items: flex-end;
|
||||||
|
padding: 0 6px 18px;
|
||||||
|
}
|
||||||
|
.eyebrow {
|
||||||
|
margin: 0 0 12px;
|
||||||
|
color: #1c78d0;
|
||||||
|
font-size: 13px;
|
||||||
|
letter-spacing: 0.2em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
h1 {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 44px;
|
||||||
|
line-height: 1.08;
|
||||||
|
}
|
||||||
|
.summary {
|
||||||
|
margin: 14px 0 0;
|
||||||
|
font-size: 18px;
|
||||||
|
line-height: 1.7;
|
||||||
|
color: #52627a;
|
||||||
|
}
|
||||||
|
.meta {
|
||||||
|
min-width: 320px;
|
||||||
|
padding: 18px 20px;
|
||||||
|
border-radius: 24px;
|
||||||
|
background: rgba(255, 255, 255, 0.76);
|
||||||
|
border: 1px solid rgba(20, 32, 51, 0.08);
|
||||||
|
box-shadow: 0 18px 48px rgba(29, 55, 90, 0.08);
|
||||||
|
}
|
||||||
|
.meta strong,
|
||||||
|
.meta span {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
.meta strong {
|
||||||
|
font-size: 13px;
|
||||||
|
letter-spacing: 0.1em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
color: #60748d;
|
||||||
|
}
|
||||||
|
.meta span {
|
||||||
|
margin-top: 8px;
|
||||||
|
font-size: 20px;
|
||||||
|
color: #142033;
|
||||||
|
line-height: 1.5;
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
}
|
||||||
|
.card {
|
||||||
|
margin-top: 16px;
|
||||||
|
padding: 20px;
|
||||||
|
border-radius: 28px;
|
||||||
|
background: rgba(255, 255, 255, 0.82);
|
||||||
|
border: 1px solid rgba(20, 32, 51, 0.08);
|
||||||
|
box-shadow: 0 24px 80px rgba(24, 41, 72, 0.1);
|
||||||
|
}
|
||||||
|
.card h2 {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 30px;
|
||||||
|
}
|
||||||
|
.card p {
|
||||||
|
margin: 8px 0 0;
|
||||||
|
color: #5a6b83;
|
||||||
|
line-height: 1.65;
|
||||||
|
font-size: 16px;
|
||||||
|
}
|
||||||
|
.preview {
|
||||||
|
margin-top: 14px;
|
||||||
|
overflow: hidden;
|
||||||
|
border-radius: 20px;
|
||||||
|
border: 1px solid rgba(20, 32, 51, 0.08);
|
||||||
|
background: #dfe7f3;
|
||||||
|
}
|
||||||
|
.preview img {
|
||||||
|
display: block;
|
||||||
|
width: 100%;
|
||||||
|
height: auto;
|
||||||
|
}
|
||||||
|
.preview--time {
|
||||||
|
max-height: 500px;
|
||||||
|
}
|
||||||
|
.preview--time img {
|
||||||
|
object-fit: cover;
|
||||||
|
object-position: top center;
|
||||||
|
}
|
||||||
|
.url {
|
||||||
|
margin-top: 10px;
|
||||||
|
font-family: ui-monospace, "SFMono-Regular", Menlo, monospace;
|
||||||
|
font-size: 14px;
|
||||||
|
color: #58708f;
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<main class="sheet">
|
||||||
|
<section class="hero">
|
||||||
|
<div>
|
||||||
|
<p class="eyebrow">Tencent Redeem Proof</p>
|
||||||
|
<h1>兑换截图与北京时间截图</h1>
|
||||||
|
<p class="summary">用于留存兑换结果与北京时间检索页的组合凭证。</p>
|
||||||
|
</div>
|
||||||
|
<div class="meta">
|
||||||
|
<strong>Captured At</strong>
|
||||||
|
<span>${escapeHtml(capturedAt)}</span>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
<section class="card">
|
||||||
|
<h2>兑换结果截图</h2>
|
||||||
|
<p>${escapeHtml(redeemMessage || '兑换完成')}</p>
|
||||||
|
<div class="preview">
|
||||||
|
<img src="data:image/png;base64,${redeemImageBase64}" alt="兑换结果截图" />
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
<section class="card">
|
||||||
|
<h2>百度检索“北京时间”截图</h2>
|
||||||
|
<p>单独标签页打开百度搜索结果并截图。</p>
|
||||||
|
<div class="url">${escapeHtml(timePageUrl)}</div>
|
||||||
|
<div class="preview preview--time">
|
||||||
|
<img src="data:image/png;base64,${timeImageBase64}" alt="北京时间截图" />
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</main>
|
||||||
|
</body>
|
||||||
|
</html>`
|
||||||
|
}
|
||||||
|
|
||||||
|
function escapeHtml(value) {
|
||||||
|
return String(value || '')
|
||||||
|
.replaceAll('&', '&')
|
||||||
|
.replaceAll('<', '<')
|
||||||
|
.replaceAll('>', '>')
|
||||||
|
.replaceAll('"', '"')
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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(() => '')
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -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 {}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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}`
|
||||||
|
}
|
||||||
@@ -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')}`
|
||||||
|
}
|
||||||
@@ -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()
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
```
|
||||||
@@ -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"
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
__all__ = [
|
||||||
|
"get_ocr_engine",
|
||||||
|
"recognize_image",
|
||||||
|
]
|
||||||
@@ -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()
|
||||||
@@ -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()
|
||||||
+315
@@ -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" },
|
||||||
|
]
|
||||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1 @@
|
|||||||
|
VITE_API_TARGET=http://127.0.0.1:3000
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
VITE_API_TARGET=http://127.0.0.1:3000
|
||||||
@@ -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/
|
||||||
+3
@@ -0,0 +1,3 @@
|
|||||||
|
{
|
||||||
|
"recommendations": ["Vue.volar"]
|
||||||
|
}
|
||||||
@@ -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,所有状态都围绕单页流程局部管理
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="zh-CN">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<title>自动兑换前端</title>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="app"></div>
|
||||||
|
<script type="module" src="/src/main.ts"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
Generated
+2170
File diff suppressed because it is too large
Load Diff
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
File diff suppressed because one or more lines are too long
|
After Width: | Height: | Size: 9.3 KiB |
@@ -0,0 +1,3 @@
|
|||||||
|
<template>
|
||||||
|
<RouterView />
|
||||||
|
</template>
|
||||||
Vendored
+78
@@ -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')
|
||||||
|
}
|
||||||
Vendored
+29
@@ -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']
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,62 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
const props = defineProps<{
|
||||||
|
page: number
|
||||||
|
pageSize: number
|
||||||
|
total: number
|
||||||
|
loading?: boolean
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
(event: 'change', page: number): void
|
||||||
|
}>()
|
||||||
|
|
||||||
|
function goPrev() {
|
||||||
|
if (props.page <= 1 || props.loading) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
emit('change', props.page - 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
function goNext() {
|
||||||
|
if (props.loading || props.page * props.pageSize >= props.total) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
emit('change', props.page + 1)
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<footer class="pagination-bar">
|
||||||
|
<span>第 {{ page }} 页,共 {{ Math.max(1, Math.ceil(total / pageSize)) }} 页,合计 {{ total }} 条</span>
|
||||||
|
<div class="actions">
|
||||||
|
<el-button :disabled="page <= 1 || loading" round @click="goPrev">上一页</el-button>
|
||||||
|
<el-button :disabled="page * pageSize >= total || loading" round @click="goNext">下一页</el-button>
|
||||||
|
</div>
|
||||||
|
</footer>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.pagination-bar {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 12px;
|
||||||
|
align-items: center;
|
||||||
|
margin-top: 14px;
|
||||||
|
color: #64748b;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 780px) {
|
||||||
|
.pagination-bar {
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: flex-start;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,130 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { formatStatusWithRaw } from '@/utils/admin-display'
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
status: string
|
||||||
|
}>()
|
||||||
|
|
||||||
|
function resolveTone(status: string) {
|
||||||
|
const normalized = String(status || '').trim().toLowerCase()
|
||||||
|
|
||||||
|
if (['paid', 'link_generated', 'redeemed', 'available', 'delivered', 'success', 'active', 'system_bound', 'binding_completed'].includes(normalized)) {
|
||||||
|
return 'success'
|
||||||
|
}
|
||||||
|
|
||||||
|
if (['claimed', 'role_confirmed', 'redeeming', 'reserved', 'processing', 'admin', 'waiting_user_claim', 'link_opened', 'binding_confirmed', 'binding_in_progress', 'user_binding'].includes(normalized)) {
|
||||||
|
return 'primary'
|
||||||
|
}
|
||||||
|
|
||||||
|
if (['waiting_inventory', 'retry_pending', 'manual_review', 'invalid', 'expired', 'operator', 'pending_binding', 'binding_exception', 'not_started'].includes(normalized)) {
|
||||||
|
return 'warning'
|
||||||
|
}
|
||||||
|
|
||||||
|
if (['closed', 'failed', 'unpaid', 'revoked', 'disabled'].includes(normalized)) {
|
||||||
|
return 'danger'
|
||||||
|
}
|
||||||
|
|
||||||
|
return 'default'
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveLabel(status: string) {
|
||||||
|
const normalized = String(status || '').trim()
|
||||||
|
|
||||||
|
if (!normalized) {
|
||||||
|
return '-'
|
||||||
|
}
|
||||||
|
|
||||||
|
const labelMap: Record<string, string> = {
|
||||||
|
created: '已创建',
|
||||||
|
paid: '已支付',
|
||||||
|
unpaid: '未支付',
|
||||||
|
failed: '失败',
|
||||||
|
refunded: '已退款',
|
||||||
|
refunding: '退款中',
|
||||||
|
closed: '已关闭',
|
||||||
|
pending_payment: '待支付',
|
||||||
|
waiting_inventory: '待库存',
|
||||||
|
cdk_reserved: '已预占',
|
||||||
|
link_generated: '已生成链接',
|
||||||
|
claimed: '已创建会话',
|
||||||
|
role_confirmed: '已确认角色',
|
||||||
|
redeeming: '兑换中',
|
||||||
|
redeemed: '已兑换',
|
||||||
|
retry_pending: '待重试',
|
||||||
|
manual_review: '人工处理',
|
||||||
|
expired: '已过期',
|
||||||
|
available: '可用',
|
||||||
|
reserved: '已预占',
|
||||||
|
delivered: '已发放',
|
||||||
|
invalid: '已作废',
|
||||||
|
active: '生效中',
|
||||||
|
admin: '管理员',
|
||||||
|
operator: '普通运营',
|
||||||
|
revoked: '已撤销',
|
||||||
|
disabled: '已停用',
|
||||||
|
success: '成功',
|
||||||
|
processing: '处理中',
|
||||||
|
system_bound: '系统已绑定',
|
||||||
|
pending_binding: '待系统绑定',
|
||||||
|
not_started: '未开始',
|
||||||
|
waiting_user_claim: '待用户打开链接',
|
||||||
|
link_opened: '已打开链接',
|
||||||
|
binding_confirmed: '已提交绑定',
|
||||||
|
binding_in_progress: '绑定处理中',
|
||||||
|
binding_completed: '完整绑定成功',
|
||||||
|
binding_exception: '绑定异常',
|
||||||
|
user_binding: '用户绑定中',
|
||||||
|
}
|
||||||
|
|
||||||
|
return formatStatusWithRaw(normalized, labelMap)
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<span class="status-tag" :data-tone="resolveTone(props.status)">
|
||||||
|
{{ resolveLabel(props.status) }}
|
||||||
|
</span>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.status-tag {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
min-height: 28px;
|
||||||
|
padding: 0 10px;
|
||||||
|
border-radius: 999px;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 700;
|
||||||
|
border: 1px solid transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-tag[data-tone='success'] {
|
||||||
|
color: #0f766e;
|
||||||
|
background: #ecfdf3;
|
||||||
|
border-color: #a6f4c5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-tag[data-tone='primary'] {
|
||||||
|
color: #175cd3;
|
||||||
|
background: #eff8ff;
|
||||||
|
border-color: #b2ddff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-tag[data-tone='warning'] {
|
||||||
|
color: #b54708;
|
||||||
|
background: #fffaeb;
|
||||||
|
border-color: #fedf89;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-tag[data-tone='danger'] {
|
||||||
|
color: #b42318;
|
||||||
|
background: #fef3f2;
|
||||||
|
border-color: #fecdca;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-tag[data-tone='default'] {
|
||||||
|
color: #475467;
|
||||||
|
background: #f2f4f7;
|
||||||
|
border-color: #d0d5dd;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,389 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import type { TencentLoginType } from '@/types/tencent/session'
|
||||||
|
|
||||||
|
type LoginTab = {
|
||||||
|
value: TencentLoginType
|
||||||
|
label: string
|
||||||
|
}
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
currentLoginTabLabel: string
|
||||||
|
hasSession: boolean
|
||||||
|
initButtonLabel: string
|
||||||
|
loginTabs: LoginTab[]
|
||||||
|
loginType: TencentLoginType
|
||||||
|
loginTypeLabel: string
|
||||||
|
qrFigureStyle: { width: string; maxWidth: string }
|
||||||
|
qrImage: string
|
||||||
|
scanInstruction: string
|
||||||
|
sessionStatus?: string
|
||||||
|
sessionId: string
|
||||||
|
sessionLoading: boolean
|
||||||
|
sessionNotice: string
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
createSession: [loginType: TencentLoginType]
|
||||||
|
openQrPreview: []
|
||||||
|
qrImageLoad: [event: Event]
|
||||||
|
reloadSession: []
|
||||||
|
switchLoginType: [loginType: TencentLoginType]
|
||||||
|
}>()
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<section class="auth-card">
|
||||||
|
<div class="login-tabs">
|
||||||
|
<button
|
||||||
|
v-for="tab in props.loginTabs"
|
||||||
|
:key="tab.value"
|
||||||
|
:class="['login-tab', { active: props.loginType === tab.value }]"
|
||||||
|
type="button"
|
||||||
|
@click="emit('switchLoginType', tab.value)"
|
||||||
|
>
|
||||||
|
{{ tab.label }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="qr-stage">
|
||||||
|
<button
|
||||||
|
v-if="props.qrImage && props.sessionStatus === 'waiting_scan'"
|
||||||
|
class="qr-figure"
|
||||||
|
:style="props.qrFigureStyle"
|
||||||
|
type="button"
|
||||||
|
@click="emit('openQrPreview')"
|
||||||
|
>
|
||||||
|
<img
|
||||||
|
:src="props.qrImage"
|
||||||
|
:alt="`Tencent ${props.loginTypeLabel} Login QR`"
|
||||||
|
decoding="async"
|
||||||
|
@load="emit('qrImageLoad', $event)"
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<div v-else-if="props.sessionStatus === 'scanned'" class="status-state confirm-state">
|
||||||
|
<strong>确认中</strong>
|
||||||
|
<p>扫码已完成,请在手机上点击确认登录。确认后页面会继续同步角色信息。</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
v-else-if="['logged_in', 'ready_to_redeem', 'redeeming', 'redeemed'].includes(String(props.sessionStatus || ''))"
|
||||||
|
class="status-state success-state"
|
||||||
|
>
|
||||||
|
<strong>已完成登录</strong>
|
||||||
|
<p>二维码已自动收起,当前会话已登录,正在同步角色或等待后续兑换操作。</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-else class="qr-empty">
|
||||||
|
<strong>{{ props.hasSession ? '二维码生成中' : props.currentLoginTabLabel }}</strong>
|
||||||
|
<p>
|
||||||
|
{{
|
||||||
|
props.hasSession
|
||||||
|
? '浏览器会话已启动,等待二维码加载。'
|
||||||
|
: `先选择${props.loginTypeLabel},再点击下方按钮开始初始化。`
|
||||||
|
}}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="auth-copy">
|
||||||
|
<p class="scan-hint">
|
||||||
|
{{ props.hasSession ? props.scanInstruction : `已选择${props.loginTypeLabel},等待开始初始化` }}
|
||||||
|
</p>
|
||||||
|
<p class="scan-note">
|
||||||
|
{{
|
||||||
|
props.sessionNotice ||
|
||||||
|
(props.hasSession
|
||||||
|
? `请使用${props.loginTypeLabel}扫码登录`
|
||||||
|
: `点击“${props.initButtonLabel}”后,页面才会真正创建浏览器会话`)
|
||||||
|
}}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="auth-footer">
|
||||||
|
<div class="session-strip">
|
||||||
|
<span>当前会话</span>
|
||||||
|
<strong>{{ props.sessionId || '尚未创建' }}</strong>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="action-row">
|
||||||
|
<el-button
|
||||||
|
:loading="props.sessionLoading"
|
||||||
|
class="primary-action"
|
||||||
|
round
|
||||||
|
size="large"
|
||||||
|
type="primary"
|
||||||
|
@click="emit('createSession', props.loginType)"
|
||||||
|
>
|
||||||
|
{{ props.hasSession ? '重新生成二维码' : props.initButtonLabel }}
|
||||||
|
</el-button>
|
||||||
|
<el-button
|
||||||
|
:disabled="!props.hasSession"
|
||||||
|
:loading="props.sessionLoading"
|
||||||
|
class="secondary-action"
|
||||||
|
round
|
||||||
|
size="large"
|
||||||
|
@click="emit('reloadSession')"
|
||||||
|
>
|
||||||
|
刷新后端页面
|
||||||
|
</el-button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.auth-card {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
height: 100%;
|
||||||
|
padding: 22px;
|
||||||
|
border-radius: 30px;
|
||||||
|
background: rgba(255, 255, 255, 0.94);
|
||||||
|
border: 1px solid rgba(86, 108, 138, 0.1);
|
||||||
|
box-shadow: 0 22px 70px rgba(30, 49, 78, 0.08);
|
||||||
|
backdrop-filter: blur(18px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-tabs {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
|
gap: 0;
|
||||||
|
padding: 0;
|
||||||
|
border-radius: 16px;
|
||||||
|
overflow: hidden;
|
||||||
|
border: 1px solid #dbe4ee;
|
||||||
|
background: #f8fafc;
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-tab {
|
||||||
|
min-height: 60px;
|
||||||
|
padding: 0 16px;
|
||||||
|
border: none;
|
||||||
|
background: transparent;
|
||||||
|
color: #606f84;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 17px;
|
||||||
|
font-weight: 700;
|
||||||
|
transition:
|
||||||
|
background-color 180ms ease,
|
||||||
|
color 180ms ease,
|
||||||
|
box-shadow 180ms ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-tab.active {
|
||||||
|
color: #ffffff;
|
||||||
|
background: linear-gradient(180deg, #6ba5f7 0%, #5b8ff0 100%);
|
||||||
|
box-shadow: inset 0 -1px 0 rgba(255, 255, 255, 0.12);
|
||||||
|
}
|
||||||
|
|
||||||
|
.qr-stage {
|
||||||
|
margin-top: 18px;
|
||||||
|
padding: 18px 16px;
|
||||||
|
border-radius: 20px;
|
||||||
|
border: 1px solid rgba(86, 108, 138, 0.08);
|
||||||
|
background:
|
||||||
|
linear-gradient(180deg, rgba(245, 248, 252, 0.98), rgba(252, 253, 255, 0.96)),
|
||||||
|
radial-gradient(circle at top, rgba(101, 146, 233, 0.08), transparent 48%);
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.qr-figure {
|
||||||
|
display: block;
|
||||||
|
width: min(220px, 100%);
|
||||||
|
padding: 0;
|
||||||
|
border: none;
|
||||||
|
background: transparent;
|
||||||
|
cursor: zoom-in;
|
||||||
|
}
|
||||||
|
|
||||||
|
.qr-figure img {
|
||||||
|
display: block;
|
||||||
|
width: 100%;
|
||||||
|
height: auto;
|
||||||
|
border-radius: 16px;
|
||||||
|
border: 1px solid #d9e2ef;
|
||||||
|
box-shadow: 0 10px 24px rgba(31, 50, 79, 0.08);
|
||||||
|
image-rendering: -moz-crisp-edges;
|
||||||
|
image-rendering: crisp-edges;
|
||||||
|
image-rendering: pixelated;
|
||||||
|
}
|
||||||
|
|
||||||
|
.qr-empty {
|
||||||
|
width: min(220px, 100%);
|
||||||
|
min-height: 220px;
|
||||||
|
padding: 18px;
|
||||||
|
border-radius: 16px;
|
||||||
|
border: 1px dashed #d4ddeb;
|
||||||
|
background: rgba(247, 250, 254, 0.92);
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.qr-empty strong {
|
||||||
|
font-size: 18px;
|
||||||
|
color: #27384f;
|
||||||
|
}
|
||||||
|
|
||||||
|
.qr-empty p {
|
||||||
|
margin: 10px 0 0;
|
||||||
|
color: #67778d;
|
||||||
|
font-size: 14px;
|
||||||
|
line-height: 1.6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-state {
|
||||||
|
width: min(220px, 100%);
|
||||||
|
min-height: 220px;
|
||||||
|
padding: 18px;
|
||||||
|
border-radius: 16px;
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-state strong {
|
||||||
|
font-size: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-state p {
|
||||||
|
margin: 10px 0 0;
|
||||||
|
font-size: 14px;
|
||||||
|
line-height: 1.7;
|
||||||
|
}
|
||||||
|
|
||||||
|
.confirm-state {
|
||||||
|
border: 1px solid #f9c74f;
|
||||||
|
background: linear-gradient(180deg, rgba(255, 247, 214, 0.95), rgba(255, 251, 235, 0.98));
|
||||||
|
}
|
||||||
|
|
||||||
|
.confirm-state strong {
|
||||||
|
color: #9a3412;
|
||||||
|
}
|
||||||
|
|
||||||
|
.confirm-state p {
|
||||||
|
color: #b45309;
|
||||||
|
}
|
||||||
|
|
||||||
|
.success-state {
|
||||||
|
border: 1px solid #86efac;
|
||||||
|
background: linear-gradient(180deg, rgba(220, 252, 231, 0.96), rgba(240, 253, 244, 0.98));
|
||||||
|
}
|
||||||
|
|
||||||
|
.success-state strong {
|
||||||
|
color: #166534;
|
||||||
|
}
|
||||||
|
|
||||||
|
.success-state p {
|
||||||
|
color: #15803d;
|
||||||
|
}
|
||||||
|
|
||||||
|
.auth-copy {
|
||||||
|
margin-top: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.scan-hint {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 16px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: #243852;
|
||||||
|
}
|
||||||
|
|
||||||
|
.scan-note {
|
||||||
|
margin: 8px 0 0;
|
||||||
|
color: #64748b;
|
||||||
|
line-height: 1.7;
|
||||||
|
}
|
||||||
|
|
||||||
|
.auth-footer {
|
||||||
|
margin-top: auto;
|
||||||
|
padding-top: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.session-strip {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 10px;
|
||||||
|
min-height: 46px;
|
||||||
|
padding: 0 16px;
|
||||||
|
border-radius: 16px;
|
||||||
|
background: #f6f9fd;
|
||||||
|
border: 1px solid #e2e9f2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.session-strip span {
|
||||||
|
color: #64748b;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.session-strip strong {
|
||||||
|
display: block;
|
||||||
|
min-width: 0;
|
||||||
|
flex: 1;
|
||||||
|
text-align: right;
|
||||||
|
color: #22354e;
|
||||||
|
font-size: 13px;
|
||||||
|
font-family: 'JetBrains Mono', 'SFMono-Regular', ui-monospace, monospace;
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
|
||||||
|
.action-row {
|
||||||
|
margin-top: 16px;
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: minmax(0, 1.45fr) minmax(132px, 0.95fr);
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.primary-action :deep(span) {
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.action-row :deep(.el-button) {
|
||||||
|
width: 100%;
|
||||||
|
min-width: 0;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.action-row :deep(.el-button > span) {
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.primary-action,
|
||||||
|
.secondary-action {
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 760px) {
|
||||||
|
.auth-card {
|
||||||
|
padding: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-tab {
|
||||||
|
font-size: 15px;
|
||||||
|
min-height: 54px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.scan-hint {
|
||||||
|
font-size: 15px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.session-strip,
|
||||||
|
.action-row {
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: flex-start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.action-row {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
.action-row :deep(.el-button) {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,284 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
type FactItem = {
|
||||||
|
label: string
|
||||||
|
value: string
|
||||||
|
accent?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
canRedeem: boolean
|
||||||
|
canConfirmRole?: boolean
|
||||||
|
confirmActionLabel?: string
|
||||||
|
confirmActionLoading?: boolean
|
||||||
|
confirmActionVisible?: boolean
|
||||||
|
confirmReadonly?: boolean
|
||||||
|
hideRedeemForm?: boolean
|
||||||
|
loginTypeLabel: string
|
||||||
|
maxAttempts: number
|
||||||
|
redeemBlockedReason: string
|
||||||
|
redeemButtonLabel: string
|
||||||
|
redeemCode: string
|
||||||
|
redeemLoading: boolean
|
||||||
|
roleConfirmed: boolean
|
||||||
|
roleFacts: FactItem[]
|
||||||
|
roleReady: boolean
|
||||||
|
statusLabel: string
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
confirmRole: []
|
||||||
|
redeem: []
|
||||||
|
'update:maxAttempts': [value: number]
|
||||||
|
'update:redeemCode': [value: string]
|
||||||
|
'update:roleConfirmed': [value: boolean]
|
||||||
|
}>()
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<section class="panel-card">
|
||||||
|
<div class="section-head">
|
||||||
|
<strong>角色与兑换</strong>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="inline-summary">
|
||||||
|
<div class="inline-summary-item">
|
||||||
|
<span>会话状态</span>
|
||||||
|
<strong>{{ props.statusLabel }}</strong>
|
||||||
|
</div>
|
||||||
|
<div class="inline-summary-item">
|
||||||
|
<span>登录方式</span>
|
||||||
|
<strong>{{ props.loginTypeLabel }}</strong>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="fact-grid role-grid">
|
||||||
|
<article
|
||||||
|
v-for="fact in props.roleFacts"
|
||||||
|
:key="fact.label"
|
||||||
|
:class="['fact-card', { accent: fact.accent }]"
|
||||||
|
>
|
||||||
|
<span>{{ fact.label }}</span>
|
||||||
|
<strong>{{ fact.value }}</strong>
|
||||||
|
</article>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="props.confirmActionVisible" class="warning-action-box">
|
||||||
|
<div class="warning-copy">
|
||||||
|
<strong>请先确认当前角色与大区准确无误</strong>
|
||||||
|
<p>确认后将按当前识别到的角色继续兑换,选错角色可能导致发放错误。</p>
|
||||||
|
</div>
|
||||||
|
<el-button
|
||||||
|
:disabled="!props.canConfirmRole"
|
||||||
|
:loading="props.confirmActionLoading"
|
||||||
|
class="warning-action-button"
|
||||||
|
round
|
||||||
|
size="large"
|
||||||
|
type="danger"
|
||||||
|
@click="emit('confirmRole')"
|
||||||
|
>
|
||||||
|
{{ props.confirmActionLabel || '确认当前角色' }}
|
||||||
|
</el-button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-else class="confirm-box">
|
||||||
|
<el-checkbox
|
||||||
|
:model-value="props.roleConfirmed"
|
||||||
|
:disabled="props.confirmReadonly || !props.roleReady"
|
||||||
|
@update:model-value="emit('update:roleConfirmed', Boolean($event))"
|
||||||
|
>
|
||||||
|
我已确认当前角色与大区无误
|
||||||
|
</el-checkbox>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<el-form v-if="!props.hideRedeemForm" label-position="top" class="redeem-form">
|
||||||
|
<el-form-item label="兑换码">
|
||||||
|
<el-input
|
||||||
|
:model-value="props.redeemCode"
|
||||||
|
placeholder="请输入兑换码"
|
||||||
|
@update:model-value="emit('update:redeemCode', String($event))"
|
||||||
|
/>
|
||||||
|
</el-form-item>
|
||||||
|
|
||||||
|
<div class="field-row compact">
|
||||||
|
<el-form-item label="验证码重试次数">
|
||||||
|
<el-input-number
|
||||||
|
:max="12"
|
||||||
|
:min="1"
|
||||||
|
:model-value="props.maxAttempts"
|
||||||
|
@update:model-value="emit('update:maxAttempts', Number($event || 1))"
|
||||||
|
/>
|
||||||
|
</el-form-item>
|
||||||
|
</div>
|
||||||
|
</el-form>
|
||||||
|
|
||||||
|
<div class="redeem-actions">
|
||||||
|
<el-button
|
||||||
|
:disabled="!props.canRedeem"
|
||||||
|
:loading="props.redeemLoading"
|
||||||
|
class="redeem-button"
|
||||||
|
round
|
||||||
|
size="large"
|
||||||
|
type="success"
|
||||||
|
@click="emit('redeem')"
|
||||||
|
>
|
||||||
|
{{ props.redeemButtonLabel }}
|
||||||
|
</el-button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p class="helper-copy">
|
||||||
|
{{ props.redeemBlockedReason || '当前状态允许直接发起兑换' }}
|
||||||
|
</p>
|
||||||
|
</section>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.panel-card {
|
||||||
|
height: 100%;
|
||||||
|
padding: 22px;
|
||||||
|
border-radius: 30px;
|
||||||
|
background: rgba(255, 255, 255, 0.94);
|
||||||
|
border: 1px solid rgba(86, 108, 138, 0.1);
|
||||||
|
box-shadow: 0 22px 70px rgba(30, 49, 78, 0.08);
|
||||||
|
backdrop-filter: blur(18px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.section-head {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 12px;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.section-head strong {
|
||||||
|
display: block;
|
||||||
|
font-size: 16px;
|
||||||
|
color: #22354e;
|
||||||
|
}
|
||||||
|
|
||||||
|
.inline-summary {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.inline-summary-item {
|
||||||
|
padding: 12px 14px;
|
||||||
|
border-radius: 14px;
|
||||||
|
background: #f7f9fc;
|
||||||
|
}
|
||||||
|
|
||||||
|
.inline-summary-item span,
|
||||||
|
.fact-card span {
|
||||||
|
display: block;
|
||||||
|
font-size: 12px;
|
||||||
|
color: #708096;
|
||||||
|
letter-spacing: 0.02em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.inline-summary-item strong,
|
||||||
|
.fact-card strong {
|
||||||
|
display: block;
|
||||||
|
margin-top: 6px;
|
||||||
|
color: #1f324a;
|
||||||
|
line-height: 1.5;
|
||||||
|
word-break: break-word;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fact-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.role-grid {
|
||||||
|
margin-top: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fact-card {
|
||||||
|
padding: 14px;
|
||||||
|
border-radius: 14px;
|
||||||
|
background: #f7f9fc;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fact-card.accent {
|
||||||
|
background: linear-gradient(180deg, rgba(86, 143, 244, 0.14), rgba(86, 143, 244, 0.08));
|
||||||
|
}
|
||||||
|
|
||||||
|
.confirm-box {
|
||||||
|
margin-top: 14px;
|
||||||
|
padding: 12px 14px;
|
||||||
|
border-radius: 14px;
|
||||||
|
background: rgba(95, 150, 245, 0.08);
|
||||||
|
border: 1px solid rgba(95, 150, 245, 0.14);
|
||||||
|
}
|
||||||
|
|
||||||
|
.warning-action-box {
|
||||||
|
margin-top: 14px;
|
||||||
|
padding: 16px;
|
||||||
|
border-radius: 18px;
|
||||||
|
background: linear-gradient(180deg, rgba(254, 226, 226, 0.92), rgba(254, 242, 242, 0.96));
|
||||||
|
border: 1px solid rgba(248, 113, 113, 0.3);
|
||||||
|
display: grid;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.warning-copy strong {
|
||||||
|
display: block;
|
||||||
|
color: #991b1b;
|
||||||
|
font-size: 15px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.warning-copy p {
|
||||||
|
margin: 6px 0 0;
|
||||||
|
color: #b91c1c;
|
||||||
|
line-height: 1.7;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.warning-action-button {
|
||||||
|
min-width: 220px;
|
||||||
|
justify-self: start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.redeem-form {
|
||||||
|
margin-top: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.field-row.compact {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 220px;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.redeem-actions {
|
||||||
|
margin-top: 16px;
|
||||||
|
display: grid;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.redeem-button {
|
||||||
|
min-width: 220px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.helper-copy {
|
||||||
|
margin: 12px 0 0;
|
||||||
|
color: #64748b;
|
||||||
|
line-height: 1.7;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 1140px) {
|
||||||
|
.inline-summary,
|
||||||
|
.fact-grid,
|
||||||
|
.role-grid,
|
||||||
|
.field-row.compact {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 760px) {
|
||||||
|
.panel-card {
|
||||||
|
padding: 18px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,158 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
type FactItem = {
|
||||||
|
label: string
|
||||||
|
value: string
|
||||||
|
}
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
resultFacts: FactItem[]
|
||||||
|
screenshotEmptyMessage: string
|
||||||
|
screenshotEmptyTitle: string
|
||||||
|
screenshotUrl: string
|
||||||
|
showScreenshot: boolean
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
openScreenshotPreview: []
|
||||||
|
}>()
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<section class="result-card">
|
||||||
|
<div class="section-head">
|
||||||
|
<strong>兑换结果</strong>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="result-layout">
|
||||||
|
<div class="fact-grid result-grid">
|
||||||
|
<article v-for="fact in props.resultFacts" :key="fact.label" class="fact-card">
|
||||||
|
<span>{{ fact.label }}</span>
|
||||||
|
<strong>{{ fact.value }}</strong>
|
||||||
|
</article>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
v-if="props.showScreenshot"
|
||||||
|
class="screenshot-figure"
|
||||||
|
type="button"
|
||||||
|
@click="emit('openScreenshotPreview')"
|
||||||
|
>
|
||||||
|
<img :src="props.screenshotUrl" alt="Tencent redeem screenshot" />
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<div v-else class="empty-block">
|
||||||
|
<strong>{{ props.screenshotEmptyTitle }}</strong>
|
||||||
|
<p>{{ props.screenshotEmptyMessage }}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.result-card {
|
||||||
|
padding: 22px 22px 20px;
|
||||||
|
border-radius: 30px;
|
||||||
|
background: rgba(255, 255, 255, 0.94);
|
||||||
|
border: 1px solid rgba(86, 108, 138, 0.1);
|
||||||
|
box-shadow: 0 22px 70px rgba(30, 49, 78, 0.08);
|
||||||
|
backdrop-filter: blur(18px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.section-head {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 12px;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.section-head strong {
|
||||||
|
display: block;
|
||||||
|
font-size: 16px;
|
||||||
|
color: #22354e;
|
||||||
|
}
|
||||||
|
|
||||||
|
.result-layout {
|
||||||
|
display: grid;
|
||||||
|
gap: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fact-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fact-card {
|
||||||
|
padding: 14px;
|
||||||
|
border-radius: 14px;
|
||||||
|
background: #f7f9fc;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fact-card span {
|
||||||
|
display: block;
|
||||||
|
font-size: 12px;
|
||||||
|
color: #708096;
|
||||||
|
letter-spacing: 0.02em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fact-card strong {
|
||||||
|
display: block;
|
||||||
|
margin-top: 6px;
|
||||||
|
color: #1f324a;
|
||||||
|
line-height: 1.5;
|
||||||
|
word-break: break-word;
|
||||||
|
}
|
||||||
|
|
||||||
|
.screenshot-figure {
|
||||||
|
display: block;
|
||||||
|
width: 100%;
|
||||||
|
padding: 0;
|
||||||
|
border: 1px solid #e2e9f2;
|
||||||
|
border-radius: 22px;
|
||||||
|
overflow: hidden;
|
||||||
|
background: #f4f8fc;
|
||||||
|
cursor: zoom-in;
|
||||||
|
}
|
||||||
|
|
||||||
|
.screenshot-figure img {
|
||||||
|
display: block;
|
||||||
|
width: 100%;
|
||||||
|
height: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-block {
|
||||||
|
margin-top: 14px;
|
||||||
|
min-height: 180px;
|
||||||
|
border-radius: 18px;
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
text-align: center;
|
||||||
|
padding: 24px;
|
||||||
|
background: #f8fbff;
|
||||||
|
border: 1px dashed #d5dfec;
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-block strong {
|
||||||
|
color: #2c4058;
|
||||||
|
font-size: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-block p {
|
||||||
|
margin: 8px 0 0;
|
||||||
|
color: #6b7a91;
|
||||||
|
line-height: 1.7;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 1140px) {
|
||||||
|
.fact-grid {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 760px) {
|
||||||
|
.result-card {
|
||||||
|
padding: 18px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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<TencentBrowserSessionData | null>
|
||||||
|
sessionLoading: Ref<boolean>
|
||||||
|
notifyActionError: (error: unknown, fallbackMessage: string) => void
|
||||||
|
}) {
|
||||||
|
const { session, sessionLoading, notifyActionError } = options
|
||||||
|
const pollFailureCount = ref(0)
|
||||||
|
const pollWarningMessage = ref('')
|
||||||
|
|
||||||
|
let pollTimer: ReturnType<typeof setTimeout> | 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)
|
||||||
|
}
|
||||||
@@ -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<TencentBrowserActivityInfo | null>
|
||||||
|
hasSession: ComputedRef<boolean>
|
||||||
|
loginTypeLabel: ComputedRef<string>
|
||||||
|
redeemLoading: Ref<boolean>
|
||||||
|
roleConfirmed: Ref<boolean>
|
||||||
|
roleReady: ComputedRef<boolean>
|
||||||
|
session: Ref<TencentBrowserSessionData | null>
|
||||||
|
sessionLoading: Ref<boolean>
|
||||||
|
sessionNotice: ComputedRef<string>
|
||||||
|
}) {
|
||||||
|
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<string, unknown>
|
||||||
|
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<string, unknown>
|
||||||
|
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,
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<ClaimTaskStatus>([
|
||||||
|
'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<TencentLoginType>(DEFAULT_LOGIN_TYPE)
|
||||||
|
const detail = ref<ClaimDetailData | null>(null)
|
||||||
|
const roleConfirmed = ref(false)
|
||||||
|
const pollWarningMessage = ref('')
|
||||||
|
const qrImageNaturalWidth = ref(0)
|
||||||
|
|
||||||
|
let pollTimer: ReturnType<typeof setTimeout> | 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,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<TencentLoginType>(DEFAULT_LOGIN_TYPE)
|
||||||
|
const session = ref<TencentBrowserSessionData | null>(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,
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,101 @@
|
|||||||
|
import axios from 'axios'
|
||||||
|
import { clearAdminSession, getAdminToken } from '@/utils/admin-auth'
|
||||||
|
|
||||||
|
export interface ApiEnvelope<T> {
|
||||||
|
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<T>(url: string, params?: Record<string, unknown>) {
|
||||||
|
return http.get<ApiEnvelope<T>>(url, { params }) as unknown as Promise<ApiEnvelope<T>>
|
||||||
|
}
|
||||||
|
|
||||||
|
export function apiGetBlob(url: string, params?: Record<string, unknown>) {
|
||||||
|
return http.get<Blob>(url, {
|
||||||
|
params,
|
||||||
|
responseType: 'blob',
|
||||||
|
}) as unknown as Promise<Blob>
|
||||||
|
}
|
||||||
|
|
||||||
|
export function apiPost<T>(url: string, data?: Record<string, unknown>) {
|
||||||
|
return http.post<ApiEnvelope<T>>(url, data) as unknown as Promise<ApiEnvelope<T>>
|
||||||
|
}
|
||||||
|
|
||||||
|
export function apiDelete<T>(url: string) {
|
||||||
|
return http.delete<ApiEnvelope<T>>(url) as unknown as Promise<ApiEnvelope<T>>
|
||||||
|
}
|
||||||
@@ -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')
|
||||||
@@ -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
|
||||||
@@ -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<AdminLoginResponse>('/api/v1/admin/auth/login', payload)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function fetchAdminSession() {
|
||||||
|
return apiGet<AdminSessionSummary>('/api/v1/admin/auth/session')
|
||||||
|
}
|
||||||
|
|
||||||
|
export function logoutAdmin() {
|
||||||
|
return apiPost<{ success: boolean }>('/api/v1/admin/auth/logout', {})
|
||||||
|
}
|
||||||
|
|
||||||
|
export function fetchAdminDashboardSummary() {
|
||||||
|
return apiGet<AdminDashboardSummary>('/api/v1/admin/dashboard/summary')
|
||||||
|
}
|
||||||
|
|
||||||
|
export function fetchAdminUsers(params?: Record<string, unknown>) {
|
||||||
|
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<string, unknown>) {
|
||||||
|
return apiGet<{ items: AdminAuditLogItem[]; pagination: AdminPagination }>('/api/v1/admin/audit-logs', params)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function fetchAdminOrders(params?: Record<string, unknown>) {
|
||||||
|
return apiGet<{ items: AdminOrderListItem[]; pagination: AdminPagination }>('/api/v1/admin/orders', params)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function fetchAdminOrderDetail(orderId: number | string) {
|
||||||
|
return apiGet<AdminOrderDetail>(`/api/v1/admin/orders/${orderId}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function fetchAdminTasks(params?: Record<string, unknown>) {
|
||||||
|
return apiGet<{ items: AdminTaskListItem[]; pagination: AdminPagination }>('/api/v1/admin/tasks', params)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function fetchAdminTaskDetail(taskId: number | string) {
|
||||||
|
return apiGet<AdminTaskDetail>(`/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<AdminTaskActionResponse>(`/api/v1/admin/tasks/${taskId}/retry`, {})
|
||||||
|
}
|
||||||
|
|
||||||
|
export function releaseAdminTaskCdk(taskId: number | string) {
|
||||||
|
return apiPost<AdminTaskActionResponse>(`/api/v1/admin/tasks/${taskId}/release-cdk`, {})
|
||||||
|
}
|
||||||
|
|
||||||
|
export function regenerateAdminTaskClaimLink(taskId: number | string) {
|
||||||
|
return apiPost<AdminTaskActionResponse>(
|
||||||
|
`/api/v1/admin/tasks/${taskId}/regenerate-claim-link`,
|
||||||
|
{},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function closeAdminTask(taskId: number | string) {
|
||||||
|
return apiPost<AdminTaskActionResponse>(`/api/v1/admin/tasks/${taskId}/close`, {})
|
||||||
|
}
|
||||||
|
|
||||||
|
export function markAdminTaskManualReview(taskId: number | string) {
|
||||||
|
return apiPost<AdminTaskActionResponse>(`/api/v1/admin/tasks/${taskId}/mark-manual-review`, {})
|
||||||
|
}
|
||||||
|
|
||||||
|
export function fetchAdminCdks(params?: Record<string, unknown>) {
|
||||||
|
return apiGet<{ items: AdminCdkListItem[]; pagination: AdminPagination }>('/api/v1/admin/cdks', params)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function importAdminCdks(payload: Record<string, unknown>) {
|
||||||
|
return apiPost<{ total: number; created: number; duplicated: number }>('/api/v1/admin/cdks/import', payload)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createAdminCdk(payload: Record<string, unknown>) {
|
||||||
|
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<string, unknown>) {
|
||||||
|
return apiPost<{ cdk: AdminCdkListItem | null }>(`/api/v1/admin/cdks/${cdkId}/invalidate`, payload || {})
|
||||||
|
}
|
||||||
|
|
||||||
|
export function fetchAdminWebhookEvents(params?: Record<string, unknown>) {
|
||||||
|
return apiGet<{ items: AdminWebhookEventListItem[]; pagination: AdminPagination }>(
|
||||||
|
'/api/v1/admin/webhook-events',
|
||||||
|
params,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function fetchAdminWebhookEventDetail(eventId: number | string) {
|
||||||
|
return apiGet<AdminWebhookEventDetail>(`/api/v1/admin/webhook-events/${eventId}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function replayAdminWebhookEvent(eventId: number | string) {
|
||||||
|
return apiPost<AdminWebhookReplayResponse>(`/api/v1/admin/webhook-events/${eventId}/replay`, {})
|
||||||
|
}
|
||||||
@@ -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<ClaimDetailData>(`/api/v1/claim/${token}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createClaimSession(token: string, loginType: TencentLoginType) {
|
||||||
|
return apiPost<ClaimDetailData>(`/api/v1/claim/${token}/session`, { loginType })
|
||||||
|
}
|
||||||
|
|
||||||
|
export function fetchClaimSessionSummary(token: string) {
|
||||||
|
return apiGet<ClaimDetailData>(`/api/v1/claim/${token}/session/summary`)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function confirmClaimRole(token: string) {
|
||||||
|
return apiPost<ClaimDetailData>(`/api/v1/claim/${token}/confirm-role`, { confirm: true })
|
||||||
|
}
|
||||||
|
|
||||||
|
export function redeemClaim(token: string) {
|
||||||
|
return apiPost<ClaimDetailData>(`/api/v1/claim/${token}/redeem`, {})
|
||||||
|
}
|
||||||
@@ -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}`
|
||||||
|
}
|
||||||
@@ -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<TencentBrowserSessionData>(
|
||||||
|
`/api/v1/tencent/browser/session/${sessionId}/redeem`,
|
||||||
|
payload as unknown as Record<string, unknown>,
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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<TencentBrowserSessionData>(
|
||||||
|
'/api/v1/tencent/browser/session',
|
||||||
|
payload as Record<string, unknown> | undefined,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function fetchTencentBrowserSession(sessionId: string) {
|
||||||
|
return apiGet<TencentBrowserSessionData>(`/api/v1/tencent/browser/session/${sessionId}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function fetchTencentBrowserSessionSummary(sessionId: string) {
|
||||||
|
return apiGet<TencentBrowserSessionSummaryData>(`/api/v1/tencent/browser/session/${sessionId}/summary`)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function refreshTencentBrowserSessionPage(sessionId: string) {
|
||||||
|
return apiPost<TencentBrowserSessionData>(`/api/v1/tencent/browser/session/${sessionId}/refresh`)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function removeTencentBrowserSession(sessionId: string) {
|
||||||
|
return apiDelete<Record<string, unknown>>(`/api/v1/tencent/browser/session/${sessionId}`)
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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<string, unknown>
|
||||||
|
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<string, unknown>
|
||||||
|
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<string, unknown>
|
||||||
|
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<string, unknown>
|
||||||
|
}>
|
||||||
|
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<string, unknown>
|
||||||
|
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<string, unknown>
|
||||||
|
query: Record<string, unknown>
|
||||||
|
body: Record<string, unknown>
|
||||||
|
}
|
||||||
|
|
||||||
|
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
|
||||||
|
}>
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
export interface TencentBrowserRedeemAttempt {
|
||||||
|
attempt: number
|
||||||
|
redeem?: Record<string, unknown> | null
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TencentBrowserRedeemResult {
|
||||||
|
code: string
|
||||||
|
area: string
|
||||||
|
attempts: TencentBrowserRedeemAttempt[]
|
||||||
|
final?: TencentBrowserRedeemAttempt | null
|
||||||
|
finishedAt: string
|
||||||
|
}
|
||||||
@@ -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<TencentBrowserSessionData, 'qrImageBase64'> {
|
||||||
|
qrImageBase64?: string
|
||||||
|
}
|
||||||
@@ -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())
|
||||||
|
}
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
export function formatStatusWithRaw(status: string, labelMap: Record<string, string> = {}) {
|
||||||
|
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<string, string> = {
|
||||||
|
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<string, string> = {
|
||||||
|
admin_user: '后台用户',
|
||||||
|
task: '交付任务',
|
||||||
|
cdk: 'CDK 库存',
|
||||||
|
webhook_event: 'Webhook 事件',
|
||||||
|
}
|
||||||
|
|
||||||
|
return formatStatusWithRaw(targetType, labelMap)
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user