docs: 建立面向初级程序员的文档基线
按初级程序员可读、可维护的目标重写项目文档,并定案三项设计决策。 新增 - docs/client/00-getting-started.md 上手指南:装环境、跑起来、常见报错 - docs/client/00-glossary.md 术语表:Outbox、幂等、不可逆阶段等 - docs/templates/task.md 工单与归档模板 - client/requirements.txt 固定依赖版本 - .gitignore 屏蔽 data/、打包产物和调试产物 设计定案 - 本地库只存已领取任务,不再镜像 Admin 任务池;已完成任务永久保留 - 去掉租约、心跳和状态回查;Admin 接口从 5 个降到 3 个 (本项目人工付款,重复下单只产生未付款订单,由人工审核处理) - 打包采用 PyInstaller one-dir:launcher.exe + app/ + data/,便携模式 文档改进 - 补齐可直接抄的代码模板:Worker 线程、事务写库、幂等键、增量加载、错误提示、路径解析 - 状态机由 ASCII 图改为完整转换表,并补充崩溃恢复规则 - 消除文档与现有代码的冲突,02 新增“现状与目标差异”清单 - 待确认事项一律给出临时默认值,避免阻塞开发 - AGENTS.md 新增“小改动直通”,工单必填项由 8 项压缩到 6 项 说明:Gitea 尚未配置,本次无对应工单号;AGENTS.md §0 的 Gitea 信息待补。 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,557 @@
|
||||
# 03 Client 数据模型
|
||||
|
||||
- 文档状态:基线草案,待数据评审
|
||||
- 数据库:SQLite
|
||||
- 数据库位置:程序目录下的 `data/client.db`(见 §2.1;怎么打开看数据见 [上手指南](00-getting-started.md) §7)
|
||||
|
||||
本文档中 `[必须]` / `[建议]` / `[待定]` 的含义见 [文档索引](../README.md#文档标注说明)。没有标注的默认是 `[必须]`。
|
||||
|
||||
## 1. 设计原则
|
||||
|
||||
- `[必须]` Admin 是任务定义和最终业务状态的权威来源;SQLite 是 Client 执行状态和未提交结果的权威来源。
|
||||
- `[必须]` 金额统一使用人民币分的整数,禁止使用浮点数。
|
||||
- `[必须]` 时间使用带时区的 ISO 8601 字符串,数据库内部优先保存 UTC。
|
||||
- `[必须]` 原始 Admin 数据和 PDD 数据使用版本化 JSON,规范化字段用于表格和查询。
|
||||
- `[必须]` 完成 PDD 操作后,结果保存与 Outbox 创建必须处于同一事务,写法见 §5.1。
|
||||
- `[必须]` 无障碍 XML、截图和长日志存文件,数据库只保存路径、哈希和摘要。
|
||||
|
||||
## 2. 文件位置与数据库初始化
|
||||
|
||||
### 2.1 `data` 目录
|
||||
|
||||
`[必须]` 所有程序自己产生的、需要写入的东西,**全部放在 `data/` 目录下**,不散落到别处:
|
||||
|
||||
```text
|
||||
data/
|
||||
├── client.db SQLite 数据库(本文档描述的所有表)
|
||||
├── logs/ 运行日志
|
||||
└── artifacts/ 失败截图、控件树 XML、步骤日志
|
||||
```
|
||||
|
||||
`data/` 的位置:
|
||||
|
||||
| 什么情况 | `data/` 在哪 |
|
||||
|---|---|
|
||||
| 打包成 exe 后 | `launcher.exe` 旁边(见 [01 需求](01-requirements.md) §8.1 的目录结构) |
|
||||
| 直接跑源码 | `client/data/`(已在 `.gitignore` 里,不会被提交) |
|
||||
|
||||
这叫**便携模式**:整个程序文件夹拷到哪都能用,出问题把文件夹打包发出来就能复现。
|
||||
代价是**不许把程序装到 `C:\Program Files\`**——那个目录普通用户没有写权限,
|
||||
Windows 会把写入悄悄重定向到 VirtualStore,然后你会遇到"明明改了设置却没生效"这种极难查的问题。
|
||||
装到 `D:\CMAutoBuy\` 这类用户可写的目录。
|
||||
|
||||
> `[必须]` **访问令牌、密码、Cookie 绝不能写进 `data/`。**
|
||||
> `data/` 是明文的,而这个目录的设计目的就是"整个拷走",拷一次等于泄露一次。
|
||||
> 凭据按 [06 质量与安全](06-quality-security.md) §7 存 Windows 凭据管理器。
|
||||
|
||||
### 2.2 路径解析(只准用这两个函数)
|
||||
|
||||
打包后**只读资源**和**可写数据**在两个完全不同的地方,这是最容易搞混的点。
|
||||
统一封装成下面两个函数,`[必须]` 代码里不许出现第二处拼路径的写法:
|
||||
|
||||
```python
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
def data_dir() -> Path:
|
||||
"""可写数据目录:数据库、日志、截图都放这儿。
|
||||
|
||||
打包后 = launcher.exe 旁边的 data/
|
||||
跑源码 = client/data/
|
||||
"""
|
||||
if getattr(sys, "frozen", False): # frozen=True 说明是打包后的 exe
|
||||
base = Path(sys.executable).parent / "data"
|
||||
else:
|
||||
base = Path(__file__).resolve().parents[1] / "data"
|
||||
base.mkdir(parents=True, exist_ok=True)
|
||||
return base
|
||||
|
||||
|
||||
def app_dir() -> Path:
|
||||
"""只读资源目录:图标、内置 qss 之类,**不要往这里写东西**。
|
||||
|
||||
打包后 = app/ 目录(PyInstaller 解包位置)
|
||||
跑源码 = client/
|
||||
"""
|
||||
if getattr(sys, "frozen", False):
|
||||
return Path(sys._MEIPASS)
|
||||
return Path(__file__).resolve().parents[1]
|
||||
```
|
||||
|
||||
用法:
|
||||
|
||||
```python
|
||||
db_path = data_dir() / "client.db"
|
||||
log_dir = data_dir() / "logs"
|
||||
icon = app_dir() / "resources" / "app.ico"
|
||||
```
|
||||
|
||||
| 别这么做 | 为什么 |
|
||||
|---|---|
|
||||
| 往 `app_dir()` 里写文件 | 打包后那是只读的解包目录,升级时整个被替换掉 |
|
||||
| 用 `os.getcwd()` 定位数据 | 双击 exe 和从命令行启动,当前目录不一样 |
|
||||
| 用 `__file__` 直接拼路径 | 打包后 `__file__` 指向的是压缩包里的虚拟路径 |
|
||||
| 硬编码 `D:\CMAutoBuy\data` | 换台机器就废了 |
|
||||
|
||||
### 2.3 数据库初始化
|
||||
|
||||
每个线程使用独立 SQLite 连接,并至少设置:
|
||||
|
||||
```sql
|
||||
PRAGMA foreign_keys = ON;
|
||||
PRAGMA journal_mode = WAL;
|
||||
PRAGMA busy_timeout = 5000;
|
||||
```
|
||||
|
||||
使用 `PRAGMA user_version` 管理顺序迁移。数据库升级必须支持从所有已发布版本迁移,不得在启动时直接删除旧库重建。
|
||||
|
||||
这条在打包后尤其重要:升级 = 换掉 `launcher.exe` 和 `app/`,`data/` 原样保留,
|
||||
所以新版本必须能读旧数据库。
|
||||
|
||||
## 3. `pdd_tasks`
|
||||
|
||||
保存**本机已领取**任务的定义、执行状态和结果。
|
||||
|
||||
> **重要:本地库不是 Admin 任务池的镜像。** 只有 `claim` 成功的任务才会写进这张表,
|
||||
> 所以这里没有"待领取"的任务。Admin 那边有哪些任务还没分下来,Client 不需要知道也不去查。
|
||||
> 这条决定影响很多地方,背景见 §3.1。
|
||||
|
||||
```sql
|
||||
CREATE TABLE pdd_tasks (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
remote_task_id TEXT NOT NULL UNIQUE,
|
||||
task_type TEXT NOT NULL
|
||||
CHECK (task_type IN ('collect', 'purchase')),
|
||||
goods_id TEXT,
|
||||
goods_url TEXT NOT NULL,
|
||||
title TEXT,
|
||||
target_color TEXT,
|
||||
target_size TEXT,
|
||||
price_cent INTEGER CHECK (price_cent IS NULL OR price_cent >= 0),
|
||||
quantity INTEGER CHECK (quantity IS NULL OR quantity > 0),
|
||||
status TEXT NOT NULL DEFAULT 'claimed'
|
||||
CHECK (status IN (
|
||||
'claimed', 'running',
|
||||
'result_pending', 'retry_wait',
|
||||
'manual_review', 'succeeded',
|
||||
'failed', 'cancelled'
|
||||
)),
|
||||
current_step TEXT,
|
||||
priority INTEGER NOT NULL DEFAULT 0,
|
||||
version INTEGER NOT NULL DEFAULT 1 CHECK (version > 0),
|
||||
admin_payload TEXT NOT NULL DEFAULT '{}',
|
||||
pdd_data TEXT,
|
||||
retry_count INTEGER NOT NULL DEFAULT 0 CHECK (retry_count >= 0),
|
||||
last_error_code TEXT,
|
||||
last_error_message TEXT,
|
||||
received_at TEXT NOT NULL,
|
||||
started_at TEXT,
|
||||
finished_at TEXT,
|
||||
created_at TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL
|
||||
);
|
||||
|
||||
CREATE INDEX idx_pdd_tasks_list
|
||||
ON pdd_tasks(updated_at DESC, id DESC);
|
||||
|
||||
CREATE INDEX idx_pdd_tasks_status_type
|
||||
ON pdd_tasks(status, task_type);
|
||||
|
||||
CREATE INDEX idx_pdd_tasks_goods_id
|
||||
ON pdd_tasks(goods_id);
|
||||
```
|
||||
|
||||
### 字段语义
|
||||
|
||||
- `remote_task_id`:跨 Admin、Client、日志、提交和归档使用的稳定任务编号。Mock 任务也必须分配稳定编号。
|
||||
- `price_cent`:列表摘要价格;采集任务通常为最低可用 SKU 价格,详细价格以 `pdd_data.skus` 为准。
|
||||
- `status`:**本机执行状态**,只由本机的执行流程和 Outbox 提交响应驱动。Admin 那边把任务标成什么,本地不知道也不需要知道(见 §3.1)。
|
||||
- `current_step`:当前安全步骤,例如 `open_goods`、`collect_skus`、`select_options`、`placing_order`、`reconcile_order`。
|
||||
- `admin_payload`:`claim` 时收到的原始 Admin 任务 JSON,用于审计和向前兼容。
|
||||
- `pdd_data`:采集或采购结果 JSON,未产生结果时为空。
|
||||
- `received_at`:**领取时间**,即 `claim` 成功的时刻。
|
||||
- `updated_at`:本地更新时间,也是表格默认排序字段。
|
||||
|
||||
### 3.1 本地只存已领取任务
|
||||
|
||||
`[必须]` 任务只在 `claim` 成功那一刻落库,初始状态就是 `claimed`。
|
||||
|
||||
**为什么这么设计:** 如果本地也存一份"待领取"的任务,本地库就同时是"任务池镜像"和"执行账本"两个角色。
|
||||
这两个角色的数据所有权是打架的——Admin 说任务是 `pending`,本地说正在 `running`,到底听谁的?
|
||||
为了调和它就得引入游标同步、版本合并、"同步不得覆盖本地执行状态"一大堆规则,而这些复杂度换来的
|
||||
只是"提前知道任务池里有什么",对 Client 的行为没有任何影响。任务分配本来就是 Admin 说了算。
|
||||
|
||||
由此带来的规则:
|
||||
|
||||
| 规则 | 说明 |
|
||||
|---|---|
|
||||
| `[必须]` 状态机没有 `pending` | 起点是 `claimed`,见 §7 |
|
||||
| `[必须]` 已完成任务永久保留 | `succeeded` / `failed` / `cancelled` 的记录**不删**。崩溃恢复防重复下单依赖历史记录,见 §7.3 |
|
||||
| `[必须]` 本地不保存 Admin 侧状态 | Client 拿到任务就做完,中途不查 Admin 怎么想。Admin 取消了、重派了,Client 一律不感知,照做完照提交,见 [04 接口契约](04-admin-api-contract.md) §1 |
|
||||
| `[必须]` 提交一定会被接受 | Admin 必须无条件接受已派发过的结果,见 [04](04-admin-api-contract.md) §6.1。所以本地不需要"提交被拒"的处理分支 |
|
||||
|
||||
**关于"永久保留":** 任务记录不设保留期,一直留着。但**诊断产物(截图、XML、长日志)仍然按
|
||||
`diagnostics.retention_days` 定期清理**——两者是不同的东西,别搞混。清理诊断文件不影响任务记录和审计字段。
|
||||
|
||||
数据量增长靠索引和增量加载扛(见 §3 的索引和 [05 界面规范](05-ui-specification.md) §5.3)。
|
||||
将来真的大到影响性能,再单独开工单做归档,不要现在提前设计。
|
||||
|
||||
列表默认查询:
|
||||
|
||||
```sql
|
||||
SELECT id, remote_task_id, task_type, title, target_color, target_size,
|
||||
price_cent, quantity, status, updated_at
|
||||
FROM pdd_tasks
|
||||
WHERE /* 本地搜索与筛选条件 */
|
||||
ORDER BY updated_at DESC, id DESC
|
||||
LIMIT :limit OFFSET :offset;
|
||||
```
|
||||
|
||||
## 4. `task_runs`
|
||||
|
||||
每次自动执行生成一条记录,任务重试时创建新的 `attempt_no`,不得覆盖历史。
|
||||
|
||||
```sql
|
||||
CREATE TABLE task_runs (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
task_id INTEGER NOT NULL,
|
||||
attempt_id TEXT NOT NULL UNIQUE,
|
||||
attempt_no INTEGER NOT NULL CHECK (attempt_no > 0),
|
||||
device_address TEXT NOT NULL,
|
||||
run_status TEXT NOT NULL
|
||||
CHECK (run_status IN (
|
||||
'running', 'succeeded', 'failed',
|
||||
'cancelled', 'manual_review'
|
||||
)),
|
||||
current_step TEXT,
|
||||
started_at TEXT NOT NULL,
|
||||
finished_at TEXT,
|
||||
irreversible_action_at TEXT,
|
||||
order_submitted_at TEXT,
|
||||
error_code TEXT,
|
||||
error_message TEXT,
|
||||
diagnostics_json TEXT,
|
||||
artifact_directory TEXT,
|
||||
created_at TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL,
|
||||
FOREIGN KEY (task_id) REFERENCES pdd_tasks(id) ON DELETE CASCADE,
|
||||
UNIQUE (task_id, attempt_no)
|
||||
);
|
||||
|
||||
CREATE INDEX idx_task_runs_task
|
||||
ON task_runs(task_id, attempt_no DESC);
|
||||
```
|
||||
|
||||
`irreversible_action_at` 一旦写入,恢复逻辑不得再次下单,只能核对订单或转人工处理。
|
||||
|
||||
## 5. `outbox_events`
|
||||
|
||||
保存等待提交 Admin 的可靠事件。
|
||||
|
||||
```sql
|
||||
CREATE TABLE outbox_events (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
task_id INTEGER NOT NULL,
|
||||
event_type TEXT NOT NULL
|
||||
CHECK (event_type IN (
|
||||
'collect_result', 'purchase_result', 'task_failure'
|
||||
)),
|
||||
idempotency_key TEXT NOT NULL UNIQUE,
|
||||
payload_json TEXT NOT NULL,
|
||||
status TEXT NOT NULL DEFAULT 'pending'
|
||||
CHECK (status IN ('pending', 'sending', 'sent', 'failed')),
|
||||
attempt_count INTEGER NOT NULL DEFAULT 0 CHECK (attempt_count >= 0),
|
||||
next_retry_at TEXT,
|
||||
last_error TEXT,
|
||||
created_at TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL,
|
||||
sent_at TEXT,
|
||||
FOREIGN KEY (task_id) REFERENCES pdd_tasks(id) ON DELETE CASCADE
|
||||
);
|
||||
|
||||
CREATE INDEX idx_outbox_pending
|
||||
ON outbox_events(status, next_retry_at, id);
|
||||
```
|
||||
|
||||
状态为 `sending` 的记录在异常退出后必须恢复为可重试状态。相同 `idempotency_key` 的事件不得生成第二条记录。
|
||||
|
||||
### 5.1 结果和 Outbox 必须一起写(事务模板)
|
||||
|
||||
`[必须]` 更新任务结果和插入 Outbox 记录**必须在同一个事务里**。
|
||||
|
||||
为什么:如果只写了任务结果、没写 Outbox,这条结果就永远发不出去了;如果只写了 Outbox、没写结果,发出去的是空的。手机上已经操作过的事没法撤销,所以数据库这边必须一次成功或一次都不做。
|
||||
|
||||
照抄这段:
|
||||
|
||||
```python
|
||||
import json
|
||||
import sqlite3
|
||||
|
||||
|
||||
def save_result_and_enqueue(
|
||||
conn: sqlite3.Connection,
|
||||
task_id: int,
|
||||
remote_task_id: str,
|
||||
attempt_id: str,
|
||||
event_type: str,
|
||||
pdd_data: dict,
|
||||
) -> None:
|
||||
"""把任务结果和待发送事件一次性写进数据库。
|
||||
|
||||
要么两条都成功,要么两条都不写。中间失败会自动回滚。
|
||||
"""
|
||||
now = utc_now_iso()
|
||||
key = build_idempotency_key(remote_task_id, attempt_id, event_type)
|
||||
payload = json.dumps({"pdd_data": pdd_data}, ensure_ascii=False)
|
||||
|
||||
# 关键就是这个 with:正常走完自动 COMMIT,中间抛异常自动 ROLLBACK
|
||||
with conn:
|
||||
conn.execute(
|
||||
"UPDATE pdd_tasks"
|
||||
" SET status = 'result_pending', pdd_data = ?, updated_at = ?"
|
||||
" WHERE id = ?",
|
||||
(json.dumps(pdd_data, ensure_ascii=False), now, task_id),
|
||||
)
|
||||
conn.execute(
|
||||
"INSERT INTO outbox_events"
|
||||
" (task_id, event_type, idempotency_key, payload_json,"
|
||||
" created_at, updated_at)"
|
||||
" VALUES (?, ?, ?, ?, ?, ?)",
|
||||
(task_id, event_type, key, payload, now, now),
|
||||
)
|
||||
```
|
||||
|
||||
注意:
|
||||
|
||||
| 别这么做 | 为什么 |
|
||||
|---|---|
|
||||
| 在 `with conn:` 里手动 `conn.commit()` | 会把事务提前切断,后面那条就不在同一个事务里了 |
|
||||
| 在 `with conn:` 里发网络请求 | 请求慢的时候数据库一直被锁着,别的线程全卡住 |
|
||||
| 两个 `with conn:` 分开写 | 那就是两个事务,等于没保护 |
|
||||
| 多个线程共用一个 `conn` | SQLite 连接不能跨线程用,见 §2 |
|
||||
|
||||
### 5.2 幂等键怎么生成
|
||||
|
||||
`[必须]` 幂等键的格式与 [04 接口契约](04-admin-api-contract.md) §6、§7 保持一致:
|
||||
|
||||
```text
|
||||
<remote_task_id>:<attempt_id>:result-v1 # 提交成功结果
|
||||
<remote_task_id>:<attempt_id>:failure-v1 # 提交失败/人工处理
|
||||
```
|
||||
|
||||
```python
|
||||
_KEY_SUFFIX = {
|
||||
"collect_result": "result-v1",
|
||||
"purchase_result": "result-v1",
|
||||
"task_failure": "failure-v1",
|
||||
}
|
||||
|
||||
|
||||
def build_idempotency_key(
|
||||
remote_task_id: str, attempt_id: str, event_type: str
|
||||
) -> str:
|
||||
"""生成提交给 Admin 的幂等键。
|
||||
|
||||
重试时必须用完全相同的键,所以键生成后要存进
|
||||
outbox_events.idempotency_key,不要每次重新算。
|
||||
"""
|
||||
try:
|
||||
suffix = _KEY_SUFFIX[event_type]
|
||||
except KeyError:
|
||||
raise ValueError(f"未知的 event_type: {event_type}") from None
|
||||
return f"{remote_task_id}:{attempt_id}:{suffix}"
|
||||
```
|
||||
|
||||
三条规则:
|
||||
|
||||
1. **用 `remote_task_id`,不要用本地自增 `id`。** 本地 id 换台电脑、重建数据库就变了,Admin 那边认不出来。
|
||||
2. **键一旦入库就不能变。** 重试是拿库里存的键去发,不是现算一个。
|
||||
3. **键相同,内容也必须相同。** 同一个键发不同内容,Admin 会返回 `409 IDEMPOTENCY_CONFLICT`。内容真的变了,说明这是一次新的执行,应该用新的 `attempt_id`。
|
||||
|
||||
## 6. `app_settings`
|
||||
|
||||
保存非敏感设置和设置版本。
|
||||
|
||||
```sql
|
||||
CREATE TABLE app_settings (
|
||||
setting_key TEXT PRIMARY KEY,
|
||||
value_json TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL
|
||||
);
|
||||
```
|
||||
|
||||
设置键 `[建议]`(新增键沿用 `<分组>.<名称>` 的写法):
|
||||
|
||||
- `admin.base_url`
|
||||
- `admin.client_id`
|
||||
- `admin.request_timeout_seconds`
|
||||
- `device.address`
|
||||
- `device.pdd_package`
|
||||
- `automation.poll_interval_seconds`
|
||||
- `automation.max_retries`
|
||||
- `automation.dry_run`
|
||||
- `safety.max_quantity`
|
||||
- `safety.price_tolerance_cent`
|
||||
- `diagnostics.artifact_directory`
|
||||
- `diagnostics.retention_days`
|
||||
|
||||
访问令牌、密码和 Cookie 不得存入本表。
|
||||
|
||||
## 7. 任务状态转换
|
||||
|
||||
### 7.1 完整转换表
|
||||
|
||||
`[必须]` 下表以外的状态变化一律不允许。写代码和写测试都以这张表为准。
|
||||
|
||||
**起点是 `claimed`,没有 `pending`。** 任务在 `claim` 成功那一刻才落库,理由见 §3.1。
|
||||
|
||||
| 当前状态 | 可以变成 | 什么时候 | 谁来改 |
|
||||
|---|---|---|---|
|
||||
| (无记录) | `claimed` | `claim` 成功,任务首次写入本地库 | 任务协调器 |
|
||||
| `claimed` 已领取 | `running` | 工作线程开始操作设备 | 任务执行器 |
|
||||
| `claimed` | `retry_wait` | 设备连不上等可恢复错误,**还没碰过设备** | 任务执行器 |
|
||||
| `claimed` | `failed` | 任务数据不合法(缺必填字段、数量为 0 等) | 任务执行器 |
|
||||
| `claimed` | `cancelled` | 用户停止,且尚未开始执行 | 任务协调器 |
|
||||
| `running` 执行中 | `result_pending` | PDD 操作完成,**且结果已落库**(见 §5.1) | 任务执行器 |
|
||||
| `running` | `retry_wait` | 可恢复失败(页面超时、网络抖动),**且未进入不可逆阶段** | 任务执行器 |
|
||||
| `running` | `manual_review` | 需要人判断:多个订单候选、验证码、登录失效、价格超限、规格不确定 | 任务执行器 |
|
||||
| `running` | `failed` | 不可恢复且不需要人处理(商品下架、链接失效) | 任务执行器 |
|
||||
| `running` | `cancelled` | 用户停止,且已到安全点、未进入不可逆阶段 | 任务协调器 |
|
||||
| `result_pending` 结果待提交 | `succeeded` | Admin 返回 `accepted: true` | 结果提交服务 |
|
||||
| `result_pending` | `manual_review` | 重试次数超上限仍提交不上去 | 结果提交服务 |
|
||||
| `retry_wait` 重试等待 | `running` | 退避时间到,**本地直接重跑**,新建 `task_runs` 记录(`attempt_no` 加 1) | 任务协调器 |
|
||||
| `retry_wait` | `failed` | 超过最大重试次数,且从未进入不可逆阶段 | 任务协调器 |
|
||||
| `retry_wait` | `manual_review` | 超过最大重试次数,但**曾经进入过不可逆阶段** | 任务协调器 |
|
||||
| `manual_review` 需要人工 | 不自动变 | 只能由人在 Admin 侧处理;需要再执行时由 Admin 下发新任务 | 人 |
|
||||
| `succeeded` / `failed` / `cancelled` | **终态,不再变化** | — | — |
|
||||
|
||||
**注意 `retry_wait` → `running` 是纯本地操作。** 任务已经在本地库里了,重试直接重跑就行,
|
||||
**不需要再向 Admin 要一次**。没有租约,也就没有"重新获取执行权"这回事。
|
||||
|
||||
### 7.2 补充规则
|
||||
|
||||
- `[必须]` 任务只能通过 `claim` 成功进入本地库,不许本地自己造一条 `claimed` 记录。
|
||||
- `[必须]` 只有 `running` 状态才允许操作设备、产生业务执行步骤。
|
||||
- `[必须]` 只有 Admin 返回 `accepted: true` 才能进入 `succeeded`,本地不许自己判定成功。
|
||||
- `[必须]` `manual_review` 不自动重新执行,任何自动流程都不许把它改回 `running`。
|
||||
- `[必须]` 已经是 `succeeded`、`failed`、`cancelled` 的任务,不得被迟到的后台回调改回运行中状态。
|
||||
- `[必须]` 每次重试都要新建一条 `task_runs` 记录(`attempt_no` 加 1),不许覆盖上一次的记录。
|
||||
|
||||
### 7.3 崩溃重启后怎么恢复
|
||||
|
||||
`[必须]` 程序启动时,把上次残留的状态按下表处理:
|
||||
|
||||
| 重启时发现 | 怎么处理 |
|
||||
|---|---|
|
||||
| `status = 'running'`,且 `task_runs.irreversible_action_at` **为空** | 说明还没下单,改成 `retry_wait`,正常重试 |
|
||||
| `status = 'running'`,且 `irreversible_action_at` **不为空** | 说明可能已经下单了。**保持 `running`**,把 `current_step` 改成 `reconcile_order` 去核对订单。核对不到就转 `manual_review`。**任何情况下都不许重新下单** |
|
||||
| `status = 'claimed'` | 说明领到了还没开始动手,保持不变,等协调器重新调度执行。**不需要再向 Admin 领一次** |
|
||||
| `status = 'result_pending'` | 不动状态,交给 Outbox 继续重试提交 |
|
||||
| `outbox_events.status = 'sending'` | 改回 `pending`,让它能被重新发送 |
|
||||
|
||||
一句话记住:**`irreversible_action_at` 有值 = 只准查,不准买。**
|
||||
|
||||
## 8. `pdd_data` JSON
|
||||
|
||||
### 8.1 通用商品数据
|
||||
|
||||
```json
|
||||
{
|
||||
"schema_version": 1,
|
||||
"goods": {
|
||||
"goods_id": "737116531267",
|
||||
"url": "https://mobile.yangkeduo.com/goods.html?goods_id=737116531267",
|
||||
"title": "商品标题"
|
||||
},
|
||||
"shop": {
|
||||
"name": "店铺名称"
|
||||
},
|
||||
"metrics": {
|
||||
"sales": {
|
||||
"value": 12000,
|
||||
"raw": "已拼1.2万+",
|
||||
"approximate": true
|
||||
},
|
||||
"reviews": {
|
||||
"value": 2356,
|
||||
"raw": "2356条评价",
|
||||
"approximate": false
|
||||
}
|
||||
},
|
||||
"dimensions": [
|
||||
{
|
||||
"key": "color",
|
||||
"name": "颜色分类",
|
||||
"values": [
|
||||
{"text": "黑色", "available": true},
|
||||
{"text": "白色", "available": true}
|
||||
]
|
||||
},
|
||||
{
|
||||
"key": "size",
|
||||
"name": "尺码",
|
||||
"values": [
|
||||
{"text": "M", "available": true},
|
||||
{"text": "L", "available": true}
|
||||
]
|
||||
}
|
||||
],
|
||||
"skus": [
|
||||
{
|
||||
"options": {"color": "黑色", "size": "L"},
|
||||
"price_cent": 3990,
|
||||
"currency": "CNY",
|
||||
"available": true,
|
||||
"raw_price": "¥39.90"
|
||||
}
|
||||
],
|
||||
"purchase": null,
|
||||
"captured_at": "2026-08-06T08:00:00Z",
|
||||
"source": {
|
||||
"client_id": "client-001",
|
||||
"device_address": "192.168.0.173:5555",
|
||||
"pdd_package": "com.xunmeng.pinduoduo"
|
||||
},
|
||||
"artifacts": []
|
||||
}
|
||||
```
|
||||
|
||||
### 8.2 采购结果
|
||||
|
||||
采购任务在同一结构中增加:
|
||||
|
||||
```json
|
||||
{
|
||||
"purchase": {
|
||||
"mode": "dry_run",
|
||||
"requested": {
|
||||
"color": "黑色",
|
||||
"size": "L",
|
||||
"quantity": 2,
|
||||
"max_price_cent": 4200
|
||||
},
|
||||
"confirmed": {
|
||||
"color": "黑色",
|
||||
"size": "L",
|
||||
"quantity": 2,
|
||||
"unit_price_cent": 3990,
|
||||
"total_price_cent": 7980
|
||||
},
|
||||
"order_no": null,
|
||||
"ordered_at": null,
|
||||
"ordered_at_raw": null,
|
||||
"match_status": "not_submitted"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
真实下单后 `mode` 为 `live`,`match_status` 只能是 `matched`、`ambiguous` 或 `not_found`。只有 `matched` 可以自动报告采购成功。
|
||||
|
||||
## 9. JSON 兼容规则
|
||||
|
||||
- 所有 JSON 根对象必须包含整数 `schema_version`。
|
||||
- 新版本只允许新增可选字段或通过新版本迁移,不得改变现有字段含义。
|
||||
- 未识别字段应保留,不得在同步或重新保存时静默丢弃。
|
||||
- 写入数据库和提交 Admin 前必须验证 JSON 结构。
|
||||
- 敏感信息不得放入 `admin_payload`、`pdd_data` 或诊断 JSON。
|
||||
Reference in New Issue
Block a user