Files
order_site/apps/backend/阶段1-4实施规格说明.md
T
2026-04-08 16:30:42 +08:00

1112 lines
20 KiB
Markdown

# 阶段 1-4 实施规格说明
## 1. 目标
本文件把《方案A-订单编排与自动兑换设计》收敛成可直接编码的规格,覆盖:
1. SQLite 表结构 SQL
2. 后端 API 契约
3. 前台 `/claim/:token` 页面接口契约
4. 后台 `/admin` 页面结构与接口契约
默认约束:
- 后端仍使用 Node.js + Express
- 浏览器自动化仍复用现有 `src/services/session.js`
- Python 子服务继续使用 `uv`
- 第一版数据库使用 SQLite
## 2. SQLite 表结构
建议新建目录:
- `src/db/`
- `src/db/migrations/`
建议首个 migration 文件:
- `src/db/migrations/001_init.sql`
以下 SQL 为第一版建议结构。
```sql
PRAGMA foreign_keys = ON;
CREATE TABLE IF NOT EXISTS orders (
id INTEGER PRIMARY KEY AUTOINCREMENT,
platform TEXT NOT NULL,
platform_order_id TEXT NOT NULL,
order_status TEXT NOT NULL DEFAULT 'created',
pay_status TEXT NOT NULL DEFAULT 'unpaid',
buyer_id TEXT NOT NULL DEFAULT '',
buyer_name TEXT NOT NULL DEFAULT '',
receiver_contact TEXT NOT NULL DEFAULT '',
total_amount INTEGER NOT NULL DEFAULT 0,
currency TEXT NOT NULL DEFAULT 'CNY',
raw_payload_json TEXT NOT NULL DEFAULT '{}',
paid_at TEXT,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL,
UNIQUE(platform, platform_order_id)
);
CREATE TABLE IF NOT EXISTS order_items (
id INTEGER PRIMARY KEY AUTOINCREMENT,
order_id INTEGER NOT NULL,
sku_code TEXT NOT NULL,
sku_name TEXT NOT NULL DEFAULT '',
quantity INTEGER NOT NULL DEFAULT 1,
spec_json TEXT NOT NULL DEFAULT '{}',
delivery_mode TEXT NOT NULL DEFAULT 'claim_link',
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL,
FOREIGN KEY(order_id) REFERENCES orders(id) ON DELETE CASCADE
);
CREATE INDEX IF NOT EXISTS idx_order_items_order_id ON order_items(order_id);
CREATE INDEX IF NOT EXISTS idx_order_items_sku_code ON order_items(sku_code);
CREATE TABLE IF NOT EXISTS cdk_inventory (
id INTEGER PRIMARY KEY AUTOINCREMENT,
batch_no TEXT NOT NULL DEFAULT '',
sku_code TEXT NOT NULL,
cdk_code TEXT NOT NULL,
status TEXT NOT NULL DEFAULT 'available',
reserved_by_task_id INTEGER,
invalid_reason TEXT NOT NULL DEFAULT '',
delivered_at TEXT,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL,
UNIQUE(sku_code, cdk_code)
);
CREATE INDEX IF NOT EXISTS idx_cdk_inventory_sku_code ON cdk_inventory(sku_code);
CREATE INDEX IF NOT EXISTS idx_cdk_inventory_status ON cdk_inventory(status);
CREATE INDEX IF NOT EXISTS idx_cdk_inventory_reserved_task ON cdk_inventory(reserved_by_task_id);
CREATE TABLE IF NOT EXISTS delivery_tasks (
id INTEGER PRIMARY KEY AUTOINCREMENT,
order_id INTEGER NOT NULL,
order_item_id INTEGER NOT NULL,
platform_order_id TEXT NOT NULL,
task_no TEXT NOT NULL,
task_status TEXT NOT NULL DEFAULT 'pending_payment',
login_type TEXT NOT NULL DEFAULT '',
claim_token_id INTEGER,
reserved_cdk_id INTEGER,
browser_session_id TEXT NOT NULL DEFAULT '',
nickname TEXT NOT NULL DEFAULT '',
role_id TEXT NOT NULL DEFAULT '',
role_name TEXT NOT NULL DEFAULT '',
area TEXT NOT NULL DEFAULT '',
partition_name TEXT NOT NULL DEFAULT '',
result_code TEXT NOT NULL DEFAULT '',
result_message TEXT NOT NULL DEFAULT '',
screenshot_path TEXT NOT NULL DEFAULT '',
artifacts_json TEXT NOT NULL DEFAULT '{}',
last_error TEXT NOT NULL DEFAULT '',
retry_count INTEGER NOT NULL DEFAULT 0,
expires_at TEXT,
claimed_at TEXT,
role_confirmed_at TEXT,
redeemed_at TEXT,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL,
FOREIGN KEY(order_id) REFERENCES orders(id) ON DELETE CASCADE,
FOREIGN KEY(order_item_id) REFERENCES order_items(id) ON DELETE CASCADE,
FOREIGN KEY(reserved_cdk_id) REFERENCES cdk_inventory(id) ON DELETE SET NULL
);
CREATE UNIQUE INDEX IF NOT EXISTS idx_delivery_tasks_task_no ON delivery_tasks(task_no);
CREATE INDEX IF NOT EXISTS idx_delivery_tasks_order_id ON delivery_tasks(order_id);
CREATE INDEX IF NOT EXISTS idx_delivery_tasks_order_item_id ON delivery_tasks(order_item_id);
CREATE INDEX IF NOT EXISTS idx_delivery_tasks_status ON delivery_tasks(task_status);
CREATE INDEX IF NOT EXISTS idx_delivery_tasks_platform_order_id ON delivery_tasks(platform_order_id);
CREATE TABLE IF NOT EXISTS claim_tokens (
id INTEGER PRIMARY KEY AUTOINCREMENT,
task_id INTEGER NOT NULL,
token TEXT NOT NULL,
status TEXT NOT NULL DEFAULT 'active',
expired_at TEXT NOT NULL,
used_at TEXT,
max_use_count INTEGER NOT NULL DEFAULT 1,
used_count INTEGER NOT NULL DEFAULT 0,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL,
FOREIGN KEY(task_id) REFERENCES delivery_tasks(id) ON DELETE CASCADE
);
CREATE UNIQUE INDEX IF NOT EXISTS idx_claim_tokens_token ON claim_tokens(token);
CREATE INDEX IF NOT EXISTS idx_claim_tokens_task_id ON claim_tokens(task_id);
CREATE INDEX IF NOT EXISTS idx_claim_tokens_status ON claim_tokens(status);
CREATE TABLE IF NOT EXISTS webhook_events (
id INTEGER PRIMARY KEY AUTOINCREMENT,
platform TEXT NOT NULL,
event_type TEXT NOT NULL,
event_key TEXT NOT NULL,
signature_valid INTEGER NOT NULL DEFAULT 0,
headers_json TEXT NOT NULL DEFAULT '{}',
query_json TEXT NOT NULL DEFAULT '{}',
body_json TEXT NOT NULL DEFAULT '{}',
processed INTEGER NOT NULL DEFAULT 0,
process_error TEXT NOT NULL DEFAULT '',
related_order_id INTEGER,
created_at TEXT NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_webhook_events_platform_key ON webhook_events(platform, event_key);
CREATE INDEX IF NOT EXISTS idx_webhook_events_processed ON webhook_events(processed);
CREATE INDEX IF NOT EXISTS idx_webhook_events_created_at ON webhook_events(created_at);
```
## 3. 枚举建议
### 3.1 `order_status`
- `created`
- `paid`
- `closed`
- `refunding`
- `refunded`
### 3.2 `pay_status`
- `unpaid`
- `paid`
- `failed`
- `refunded`
### 3.3 `task_status`
- `pending_payment`
- `paid`
- `waiting_inventory`
- `cdk_reserved`
- `link_generated`
- `claimed`
- `role_confirmed`
- `redeeming`
- `redeemed`
- `retry_pending`
- `manual_review`
- `expired`
- `closed`
### 3.4 `cdk_inventory.status`
- `available`
- `reserved`
- `delivered`
- `invalid`
### 3.5 `claim_tokens.status`
- `active`
- `used`
- `expired`
- `revoked`
## 4. 目录改造建议
建议后端增加以下文件:
```text
src/
db/
client.js
migrate.js
migrations/
001_init.sql
repositories/
order-repo.js
order-item-repo.js
task-repo.js
cdk-repo.js
claim-token-repo.js
webhook-event-repo.js
routes/
webhooks.js
claims.js
admin.js
services/
webhook-service.js
order-service.js
delivery-task-service.js
cdk-service.js
claim-service.js
redeem-executor-service.js
admin-service.js
utils/
time.js
random.js
pagination.js
```
## 5. 后端 API 契约
统一响应建议:
```json
{
"code": 0,
"msg": "ok",
"data": {}
}
```
失败响应建议:
```json
{
"code": 40001,
"msg": "token 无效",
"data": null
}
```
---
## 6. webhook 接口
### 6.1 接收订单/支付通知
`POST /api/v1/webhooks/agiso/trade`
说明:
- 接收平台订单创建成功或支付成功通知
- query 中可能带 `timestamp``sign``aopic`
- body 中可能带 `json`
- 具体字段以平台最终联调为准,但内部处理应统一为标准事件结构
处理步骤:
1. 保存原始请求
2. 验签
3. 解析出平台订单号、事件类型、订单数据
4.`platform + platform_order_id + event_type` 做幂等
5. 更新订单与交付任务
成功响应:
```json
{
"code": 0,
"msg": "success",
"data": {
"accepted": true,
"platformOrderId": "592823138",
"eventType": "payment_success"
}
}
```
---
## 7. 前台领取链路接口
### 7.1 校验领取链接
`GET /api/v1/claim/:token`
用途:
- 页面首次打开时获取任务和订单摘要
- 校验 token 是否有效
响应示例:
```json
{
"code": 0,
"msg": "ok",
"data": {
"tokenStatus": "active",
"task": {
"taskId": 12,
"taskNo": "DT202604070001",
"status": "link_generated",
"expiresAt": "2026-04-08T12:00:00.000Z",
"claimedAt": null,
"roleConfirmedAt": null,
"redeemedAt": null,
"lastError": ""
},
"order": {
"platformOrderId": "592823138",
"skuCode": "dnf-cdk-a",
"skuName": "DNF 活动礼包",
"quantity": 1
},
"session": null,
"result": null
}
}
```
失败场景:
- token 不存在
- token 已过期
- token 已撤销
- task 已关闭
### 7.2 创建领取会话
`POST /api/v1/claim/:token/session`
请求体:
```json
{
"loginType": "qq"
}
```
说明:
- 创建腾讯浏览器 session
- 将 session 与 task 绑定
- task 状态推进到 `claimed`
成功响应:
```json
{
"code": 0,
"msg": "浏览器会话已创建",
"data": {
"task": {
"taskId": 12,
"taskNo": "DT202604070001",
"status": "claimed"
},
"session": {
"sessionId": "txbs-ab12cd34ef56",
"status": "waiting_scan",
"loginType": "qq",
"notice": "请使用QQ扫描二维码",
"qrImageBase64": "base64...",
"updatedAt": "2026-04-07T12:00:00.000Z",
"activityInfo": {
"nickname": "",
"role": {
"ready": false,
"roleName": "",
"roleId": ""
}
}
}
}
}
```
### 7.3 轮询领取会话状态
`GET /api/v1/claim/:token/session/summary`
说明:
- 轮询 task 绑定的 session
- 同时返回 task 最新状态
成功响应:
```json
{
"code": 0,
"msg": "ok",
"data": {
"task": {
"taskId": 12,
"taskNo": "DT202604070001",
"status": "claimed",
"lastError": ""
},
"session": {
"sessionId": "txbs-ab12cd34ef56",
"status": "ready_to_redeem",
"loginType": "qq",
"notice": "登录已完成,当前角色 阿修罗",
"updatedAt": "2026-04-07T12:03:00.000Z",
"activityInfo": {
"nickname": "xxx",
"role": {
"ready": true,
"roleName": "阿修罗",
"roleId": "123456",
"area": "36",
"partition": "跨五"
}
},
"artifacts": {
"hasScreenshot": false
}
}
}
}
```
### 7.4 确认角色
`POST /api/v1/claim/:token/confirm-role`
请求体:
```json
{
"confirm": true
}
```
说明:
- 只有当后端已识别到角色时才能确认
- task 状态推进到 `role_confirmed`
成功响应:
```json
{
"code": 0,
"msg": "角色已确认",
"data": {
"task": {
"taskId": 12,
"taskNo": "DT202604070001",
"status": "role_confirmed",
"roleConfirmedAt": "2026-04-07T12:05:00.000Z"
}
}
}
```
### 7.5 执行兑换
`POST /api/v1/claim/:token/redeem`
请求体:
```json
{}
```
说明:
- 不再由前端传 CDK
- 后端从 `reserved_cdk_id` 读取对应兑换码
- 执行成功后更新 task、cdk、截图路径
成功响应:
```json
{
"code": 0,
"msg": "兑换完成",
"data": {
"task": {
"taskId": 12,
"taskNo": "DT202604070001",
"status": "redeemed",
"resultCode": "0",
"resultMessage": "兑换成功",
"redeemedAt": "2026-04-07T12:06:00.000Z",
"screenshotReady": true
},
"session": {
"sessionId": "txbs-ab12cd34ef56",
"status": "redeemed",
"notice": "兑换成功",
"artifacts": {
"hasScreenshot": true
}
}
}
}
```
失败响应建议:
- `409` 当前状态不允许兑换
- `400` 未确认角色
- `409` 未预占 CDK
- `500` 自动化执行失败
### 7.6 获取结果截图
`GET /api/v1/claim/:token/screenshot`
说明:
- 返回图片流
- 内部根据 task 取 `screenshot_path`
---
## 8. 后台接口契约
### 8.1 登录
第一版可以先做简单登录:
`POST /api/v1/admin/auth/login`
请求体:
```json
{
"username": "admin",
"password": "******"
}
```
响应:
```json
{
"code": 0,
"msg": "ok",
"data": {
"token": "admin-session-token",
"role": "admin"
}
}
```
### 8.2 仪表盘
`GET /api/v1/admin/dashboard/summary`
响应示例:
```json
{
"code": 0,
"msg": "ok",
"data": {
"todayOrders": 23,
"paidPendingClaim": 7,
"claimingTasks": 5,
"redeemedToday": 14,
"abnormalTasks": 2,
"lowInventorySkuCount": 1
}
}
```
### 8.3 订单列表
`GET /api/v1/admin/orders`
查询参数建议:
- `page`
- `pageSize`
- `platformOrderId`
- `payStatus`
- `skuCode`
- `dateFrom`
- `dateTo`
响应示例:
```json
{
"code": 0,
"msg": "ok",
"data": {
"items": [
{
"orderId": 1,
"platform": "agiso",
"platformOrderId": "592823138",
"orderStatus": "paid",
"payStatus": "paid",
"buyerName": "",
"totalAmount": 300,
"createdAt": "2026-04-07T11:00:00.000Z",
"updatedAt": "2026-04-07T11:02:00.000Z",
"taskCount": 1
}
],
"pagination": {
"page": 1,
"pageSize": 20,
"total": 1
}
}
}
```
### 8.4 订单详情
`GET /api/v1/admin/orders/:orderId`
返回:
- 订单主信息
- 子项信息
- 对应任务列表
- webhook 摘要
### 8.5 任务列表
`GET /api/v1/admin/tasks`
查询参数建议:
- `page`
- `pageSize`
- `status`
- `platformOrderId`
- `taskNo`
- `skuCode`
- `roleId`
- `dateFrom`
- `dateTo`
响应示例:
```json
{
"code": 0,
"msg": "ok",
"data": {
"items": [
{
"taskId": 12,
"taskNo": "DT202604070001",
"platformOrderId": "592823138",
"skuCode": "dnf-cdk-a",
"status": "retry_pending",
"reservedCdkCodeMasked": "ABCD****WXYZ",
"loginType": "qq",
"roleName": "阿修罗",
"roleId": "123456",
"browserSessionId": "txbs-ab12cd34ef56",
"retryCount": 1,
"lastError": "OCR 识别失败",
"createdAt": "2026-04-07T11:00:00.000Z",
"updatedAt": "2026-04-07T11:06:00.000Z"
}
],
"pagination": {
"page": 1,
"pageSize": 20,
"total": 1
}
}
}
```
### 8.6 任务详情
`GET /api/v1/admin/tasks/:taskId`
返回内容建议:
- 任务基础信息
- 订单摘要
- token 信息
- 预占 CDK 信息
- 浏览器 session 摘要
- 角色信息
- 兑换结果
- 截图 URL
- 操作按钮可用状态
### 8.7 重试任务
`POST /api/v1/admin/tasks/:taskId/retry`
用途:
-`retry_pending``manual_review` 的任务发起重试
请求体:
```json
{
"reason": "manual retry"
}
```
### 8.8 重新生成领取链接
`POST /api/v1/admin/tasks/:taskId/regenerate-claim-link`
说明:
- 作废旧 token
- 创建新 token
响应示例:
```json
{
"code": 0,
"msg": "领取链接已重新生成",
"data": {
"taskId": 12,
"claimUrl": "https://your-domain.com/#/claim/xxxxxxxx"
}
}
```
### 8.9 释放 CDK
`POST /api/v1/admin/tasks/:taskId/release-cdk`
说明:
- 将任务绑定的 `reserved` CDK 释放回库存
- 一般只允许未成功兑换任务执行
### 8.10 关闭任务
`POST /api/v1/admin/tasks/:taskId/close`
用途:
- 人工确认无需继续执行
### 8.11 CDK 列表
`GET /api/v1/admin/cdks`
查询参数建议:
- `page`
- `pageSize`
- `skuCode`
- `status`
- `batchNo`
### 8.12 单条新增 CDK
`POST /api/v1/admin/cdks`
请求体:
```json
{
"skuCode": "dnf-cdk-a",
"batchNo": "20260407",
"cdkCode": "ABCDEFGH12345678"
}
```
### 8.13 批量导入 CDK
`POST /api/v1/admin/cdks/import`
请求体建议支持两种形式:
形式一:
```json
{
"skuCode": "dnf-cdk-a",
"batchNo": "20260407",
"codes": [
"AAAA-BBBB-CCCC",
"DDDD-EEEE-FFFF"
]
}
```
形式二:
```json
{
"rows": [
{
"skuCode": "dnf-cdk-a",
"batchNo": "20260407",
"cdkCode": "AAAA-BBBB-CCCC"
},
{
"skuCode": "dnf-cdk-b",
"batchNo": "20260407",
"cdkCode": "DDDD-EEEE-FFFF"
}
]
}
```
响应示例:
```json
{
"code": 0,
"msg": "导入完成",
"data": {
"total": 10,
"created": 8,
"duplicated": 2,
"invalid": 0
}
}
```
### 8.14 webhook 列表
`GET /api/v1/admin/webhook-events`
### 8.15 webhook 详情
`GET /api/v1/admin/webhook-events/:eventId`
### 8.16 webhook 重放
`POST /api/v1/admin/webhook-events/:eventId/replay`
---
## 9. 前台 `/claim/:token` 页面契约
### 9.1 路由
建议新增:
- `/#/claim/:token`
### 9.2 页面状态机
前台页面建议使用以下视图状态:
- `loading`
- `invalid_token`
- `ready_to_start`
- `waiting_scan`
- `waiting_confirm_login`
- `waiting_role_ready`
- `waiting_role_confirm`
- `redeeming`
- `redeemed`
- `failed`
- `expired`
### 9.3 页面数据模型
建议前端整理成:
```ts
type ClaimPageModel = {
tokenStatus: 'active' | 'expired' | 'revoked' | 'used'
task: {
taskId: number
taskNo: string
status: string
lastError: string
expiresAt: string | null
claimedAt: string | null
roleConfirmedAt: string | null
redeemedAt: string | null
}
order: {
platformOrderId: string
skuCode: string
skuName: string
quantity: number
}
session: null | {
sessionId: string
status: string
loginType: 'qq' | 'wx'
notice: string
qrImageBase64?: string
updatedAt: string
activityInfo?: {
nickname: string
role: {
ready: boolean
roleName: string
roleId: string
area?: string
partition?: string
}
}
artifacts?: {
hasScreenshot: boolean
}
}
result: null | {
resultCode: string
resultMessage: string
screenshotUrl: string
}
}
```
### 9.4 页面动作
前台页面仅保留 4 个核心动作:
1. 选择 QQ / WX 并创建 session
2. 轮询 session 状态
3. 确认角色
4. 发起兑换
前台不允许:
- 手输 CDK
- 修改任务状态
- 查看其他订单
### 9.5 页面展示区块
建议拆成:
1. 订单摘要卡片
2. 登录二维码卡片
3. 角色确认卡片
4. 结果卡片
现有可复用组件:
- `TencentAuthCard.vue`
- `TencentRedeemPanel.vue`
- `TencentResultPanel.vue`
### 9.6 轮询规则
轮询间隔建议继续沿用当前 2500ms。
停止轮询条件:
- session 进入 `redeemed`
- task 进入 `redeemed`
- task 进入 `expired`
- task 进入 `closed`
- 连续失败达到阈值
## 10. 后台 `/admin` 页面契约
### 10.1 路由建议
- `/#/admin/login`
- `/#/admin/dashboard`
- `/#/admin/orders`
- `/#/admin/orders/:orderId`
- `/#/admin/tasks`
- `/#/admin/tasks/:taskId`
- `/#/admin/cdks`
- `/#/admin/webhook-events`
- `/#/admin/webhook-events/:eventId`
### 10.2 页面说明
#### Dashboard
展示:
- 今日订单数
- 待领取数
- 兑换成功数
- 异常任务数
- 库存告警
#### Orders
功能:
- 筛选订单
- 查看订单详情
- 跳转任务
#### Tasks
功能:
- 筛选任务状态
- 查看错误
- 跳转详情
- 快速执行重试、释放、关闭、重发链接
#### Task Detail
功能:
- 统一查看任务全量信息
- 预览截图
- 人工操作入口
#### CDKs
功能:
- 单条新增
- 批量导入
- 状态筛选
- 查看预占关系
#### Webhook Events
功能:
- 查看原始推送
- 查看处理结果
- 重放处理
### 10.3 后台前端基础能力
后台第一版建议至少补:
- 登录鉴权
- 基础分页
- 列表筛选
- 操作确认弹窗
- 错误提示
## 11. 实施顺序
### 11.1 第一批
先做:
1. SQLite 接入
2. migration
3. repository 层
4. webhook 接口骨架
5. 订单、任务、CDK、token 基础 service
### 11.2 第二批
再做:
1. `/claim/:token` 前台页
2. claim 相关接口
3. session 与 task 绑定
### 11.3 第三批
再做:
1. 后端自动兑换桥接
2. 兑换结果回写
3. 截图读取
### 11.4 第四批
最后做:
1. `/admin` 后台页
2. 订单/任务/CDK/webhook 查询接口
3. 重试、释放、重放
## 12. 建议的下一步
如果直接开始编码,建议按下面顺序落地:
1. 先实现 `001_init.sql`
2. 实现 `src/db/client.js`
3. 实现 repository 层
4.`POST /api/v1/webhooks/agiso/trade`
5. 打通“支付成功 -> 建订单 -> 建任务 -> 预占 CDK -> 生成 token”
6. 再开始前台领取页
这条顺序最稳,返工也最少。