Files

691 lines
30 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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
executable_dir = Path(sys.executable).resolve().parent
install_root = executable_dir.parent if executable_dir.name.casefold() == "app" else executable_dir
base = install_root / "data"
else:
base = Path(__file__).resolve().parents[1] / "data"
base.mkdir(parents=True, exist_ok=True)
return base
def app_dir() -> Path:
"""只读资源目录:图标、内置 qss 之类,**不要往这里写东西**。
打包后 = app/dependencies/(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` 管理顺序迁移。数据库升级必须支持从所有已发布版本迁移,不得在启动时直接删除旧库重建。
这条在打包后尤其重要:升级 = 换掉 `app/`,`Launcher.exe` 和 `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')),
execution_mode TEXT NOT NULL DEFAULT 'dry_run'
CHECK (execution_mode IN ('dry_run', 'live')),
goods_id TEXT,
goods_url TEXT NOT NULL,
title TEXT,
shop_name 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,
removed_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);
CREATE INDEX idx_pdd_tasks_visible_list
ON pdd_tasks(removed_at, updated_at DESC, id DESC);
```
### 字段语义
- `remote_task_id`:跨 Admin、Client、日志、提交和归档使用的稳定任务编号。Mock 任务也必须分配稳定编号。
- `execution_mode`:Admin 领取时确定的不可变执行模式;历史记录默认为 `dry_run`,Client 不得把任务自行提升为 `live`。
- `price_cent`:列表摘要价格;采集任务通常为最低可用 SKU 价格,详细价格以 `pdd_data.skus` 为准。
- `shop_name`:列表使用的店铺名摘要;保存结果时从 `pdd_data.shop_name` 同步,避免列表查询解析完整 JSON。
- `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` 成功的时刻。
- `removed_at`:用户把终态任务从普通列表移除的时间。为空表示正常显示;有值只表示隐藏,数据库记录和关联审计数据仍永久保留。
- `updated_at`:本地更新时间,也是表格默认排序字段。
### 3.1 本地只存已领取任务
`[必须]` 任务只在 `claim` 成功那一刻落库,初始状态就是 `claimed`。
**为什么这么设计:** 如果本地也存一份"待领取"的任务,本地库就同时是"任务池镜像"和"执行账本"两个角色。
这两个角色的数据所有权是打架的——Admin 说任务是 `pending`,本地说正在 `running`,到底听谁的?
为了调和它就得引入游标同步、版本合并、"同步不得覆盖本地执行状态"一大堆规则,而这些复杂度换来的
只是"提前知道任务池里有什么",对 Client 的行为没有任何影响。任务分配本来就是 Admin 说了算。
由此带来的规则:
| 规则 | 说明 |
|---|---|
| `[必须]` 状态机没有 `pending` | 起点是 `claimed`,见 §7 |
| `[必须]` 已完成任务永久保留 | `succeeded` / `failed` / `cancelled` 的记录**不删**。崩溃恢复防重复下单依赖历史记录,见 §7.3 |
| `[必须]` 本地不保存 Admin 侧状态 | Client 拿到任务就做完,中途不查 Admin 调度状态。采购规格无法精确选择时可以提交一次候选解析命令,但它不返回取消、重派等状态,见 [04 接口契约](04-admin-api-contract.md) §1、§7.1 |
| `[必须]` 提交一定会被接受 | Admin 必须无条件接受已派发过的结果,见 [04](04-admin-api-contract.md) §6.1。所以本地不需要"提交被拒"的处理分支 |
**关于"永久保留":** 任务记录不设保留期,一直留着。界面上的“删除”只写入
`removed_at`,不会执行 SQL `DELETE`,也不会删除 `task_runs` 或 `outbox_events`。
但**诊断产物(截图、XML、长日志)仍然按
`diagnostics.retention_days` 定期清理**——两者是不同的东西,别搞混。清理诊断文件不影响任务记录和审计字段。
数据量增长靠索引和增量加载扛(见 §3 的索引和 [05 界面规范](05-ui-specification.md) §5.3)。
将来真的大到影响性能,再单独开工单做归档,不要现在提前设计。
列表默认查询:
```sql
SELECT pdd_tasks.id, remote_task_id, task_type, title, shop_name,
target_color, target_size, price_cent, quantity, status,
latest_run.run_status, latest_run.started_at, latest_run.finished_at,
pdd_tasks.updated_at
FROM pdd_tasks
LEFT JOIN task_runs AS latest_run ON /* 每个任务 attempt_no 最大的一条执行记录 */
WHERE removed_at IS NULL
AND /* 本地搜索与筛选条件 */
ORDER BY updated_at DESC, id DESC
LIMIT :limit OFFSET :offset;
```
列表必须在同一条 SQL 中读取最新执行记录,不能逐行查询。已结束执行的用时按
`finished_at - started_at` 取整秒,异常负数按 0 秒显示;正在执行显示“进行中”。
批量软删除必须在一个事务内先校验全部勾选任务,再统一写入 `removed_at`。
只允许 `succeeded`、`failed`、`cancelled`,且所有 Outbox 都已发送、所有执行记录的
`irreversible_action_at` 都为空。任一任务不满足时整体失败,不能只隐藏其中一部分。
## 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,
result_data 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` 一旦写入,恢复逻辑不得再次下单,只能核对订单或转人工处理。
`result_data` 保存这次成功采集的完整结果;`pdd_tasks.pdd_data` 只保存最新结果。
这样重新采集可以更新当前数据,同时仍能按 `attempt_no` 追查旧结果。
### 4.1 采购运行时规格解析的持久化边界
[接口契约 §7.1](04-admin-api-contract.md) 定义了一个与 `task_runs.attempt_id` 绑定的一次性
规格解析命令。Admin 的 `purchase_spec_resolutions` 是服务端候选观察和最终决策的权威
审计;Client 本地仍必须在发送前保存以下最小信息:
- `task_id`(本地外键)与 `attempt_id`;
- 完整且大小受限的请求 JSON、确定性 `Idempotency-Key` 和请求哈希;
- `candidate_snapshot_hash`、Admin `resolution_id`、outcome、source、可空置信度;
- matched 时 Admin 返回的候选短编号、原始文字和 options,以及收到时间。
这类请求需要同步取得响应才能决定本次采购是否继续,**不能伪装成普通结果 Outbox**;
但发送前仍必须持久化,网络重试只能重放相同键和相同内容。原始
`pdd_tasks.admin_payload`、`target_color/target_size` 永远不覆盖;解析结果只是当前
`task_runs` 的执行期有效规格。进入过 `irreversible_action_at` 的运行不得新建或重放解析
以继续采购,只能核对订单。
Client 使用 `purchase_spec_resolutions` 保存这些执行期记录:
```sql
CREATE TABLE purchase_spec_resolutions (
id INTEGER PRIMARY KEY AUTOINCREMENT,
task_id INTEGER NOT NULL,
attempt_id TEXT NOT NULL,
idempotency_key TEXT NOT NULL UNIQUE,
request_json TEXT NOT NULL,
request_hash TEXT NOT NULL,
candidate_snapshot_hash TEXT NOT NULL,
status TEXT NOT NULL, -- pending / resolved
resolution_id TEXT,
outcome TEXT,
source TEXT,
confidence_bps INTEGER,
match_candidate_id TEXT,
match_raw_text TEXT,
match_options_json TEXT,
reason TEXT,
resolved_at TEXT,
received_at 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_id, candidate_snapshot_hash)
);
```
`pending` 表示请求已落库但尚未得到可验证的业务响应,不能据此继续采购;`resolved`
表示完整响应已经落库,仍须通过候选白名单和真机快照复核。网络调用不放在 SQLite
事务中。旧任务原始规格不覆盖,最终结果分别保存原始请求规格、实际采用规格和
`resolution_id`。
## 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 # 提交失败/人工处理
```
运行时规格解析不进入本表的普通结果 Outbox。它的确定性键是
`spec-resolution-v1:<identity-sha256>`,完整算法见 [接口契约 §7.1](04-admin-api-contract.md);
#257 必须把该键和原请求保存到独立的执行期解析记录后才发送。
```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`。
### 5.3 重新上报不能覆盖最新执行状态
“重新上报”只重发已经保存在 Outbox 的原始事件,不创建新事件。选择和状态更新遵守下面四条:
1. 任务存在 `pending`、`sending` 或 `failed` 事件时,优先处理最早一条未发送事件,包括 `task_failure`;全部已发送时,才重发最新结果。
2. 重发时继续使用事件原有的 `idempotency_key` 和 `payload_json`,不能重新组装。
3. 已经发送过的历史结果再次得到 Admin 确认,只更新该 Outbox,不把任务主状态改成“已完成”。
4. 只有事件的 `attempt_id` 等于最新 `task_runs.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.client_name`
- `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 不得存入本表。
历史版本可能遗留 `purchase.live_*` 设置;当前版本不再读取或写入这些键,也不依赖它们决定领取能力。
## 7. 任务状态转换
### 7.1 完整转换表
`[必须]` 下表以外的状态变化一律不允许。写代码和写测试都以这张表为准。
**起点是 `claimed`,没有 `pending`。** 任务在 `claim` 成功那一刻才落库,理由见 §3.1。
| 当前状态 | 可以变成 | 什么时候 | 谁来改 |
|---|---|---|---|
| (无记录) | `claimed` | `claim` 成功,任务首次写入本地库 | 任务协调器 |
| `claimed` 已领取 | `running` | 工作线程开始操作设备 | 任务执行器 |
| `claimed` | `failed` | 任务数据不合法(缺必填字段、数量为 0 等) | 任务执行器 |
| `claimed` | `cancelled` | 用户停止,且尚未开始执行 | 任务协调器 |
| `running` 执行中 | `result_pending` | PDD 操作完成,**且结果已落库**(见 §5.1) | 任务执行器 |
| `running` | `manual_review` | 登录、验证码、风控或不可逆采购等必须停队列并由人处理 | 任务执行器 |
| `running` | `failed` | 本次采集或采购失败;失败上报成功后继续下一条,不自动重试当前任务 | 任务执行器 |
| `running` | `cancelled` | 用户停止,且已到安全点、未进入不可逆阶段 | 任务协调器 |
| `result_pending` 结果待提交 | `succeeded` | Admin 返回 `accepted: true` | 结果提交服务 |
| `result_pending` | `manual_review` | 重试次数超上限仍提交不上去 | 结果提交服务 |
| `manual_review / reconcile_purchase` | `result_pending` | 严格核对到唯一未付款订单,结果和 Outbox 已在同一事务落库 | 只读核单服务 |
| `retry_wait` 旧数据/中断恢复 | `claimed` | 用户明确确认重新采集;自动获取不会选择该状态 | 人 |
| `manual_review` / `succeeded` / `failed` / `cancelled` | `claimed` | 用户在 Client 明确确认重新采集;仅限采集任务,且没有未发送 Outbox | 人 |
自动流程对一条任务只执行一次。任务自身失败时写入 `failed`,界面按类型显示
“采集失败”或“采购失败”;失败原因保存在 `last_error_code` 和
`last_error_message`。只有用户明确点击重新采集或重新采购,才创建新的
`task_runs` 记录再次执行。
`retry_wait` 不再表示程序正在等待。它只兼容旧数据和“采集运行中异常退出”的恢复
结果,界面同样显示为“采集失败”,并且不会被自动任务查询选中。
### 7.2 补充规则
- `[必须]` 任务只能通过 `claim` 成功进入本地库,不许本地自己造一条 `claimed` 记录。
- `[必须]` 只有 `running` 状态才允许操作设备、产生业务执行步骤。
- `[必须]` 只有 Admin 返回 `accepted: true` 才能进入 `succeeded`,本地不许自己判定成功。
- `[必须]` `manual_review` 不自动重新执行;只有用户在 Client 明确确认重新采集,才允许先回到 `claimed`。
- `[必须]` 已经是 `succeeded`、`failed`、`cancelled` 的任务,不得被迟到的后台回调改回运行中状态;人工确认的重新采集除外。
- `[必须]` 每次用户手动重新执行都要新建一条 `task_runs` 记录(`attempt_no` 加 1),不许覆盖上一次的记录。
- `[必须]` 采购任务不能从“重新执行”入口启动;重新采集不处理任何采购动作。
### 7.3 崩溃重启后怎么恢复
`[必须]` 程序启动时,把上次残留的状态按下表处理:
| 重启时发现 | 怎么处理 |
|---|---|
| 采集 `status = 'running'` | 改成 `retry_wait`,原执行记录结束为失败 |
| 采购 `status = 'running'`,且 `task_runs.irreversible_action_at` **为空** | 原执行记录先结束为失败,任务改回 `claimed / purchase_recovery_ready`;再次演练必须新建执行记录 |
| 采购 `status = 'running'`,且 `irreversible_action_at` **不为空** | 原执行记录和任务都改成 `manual_review / reconcile_purchase`;只调用只读核对,**任何情况下都不许重新下单** |
| `status = 'claimed'` | 说明领到了还没开始动手,保持不变,等协调器重新调度执行。**不需要再向 Admin 领一次** |
| `status = 'result_pending'` | 不动状态,交给 Outbox 继续重试提交 |
| `outbox_events.status = 'sending'` | 改回 `pending`,让它能被重新发送 |
一句话记住:**`irreversible_action_at` 有值 = 只准查,不准买。**
采集和采购都不自动重试。设备断开、登录、验证码、风控、Admin 不可用以及
不可逆采购待核单属于全局阻塞,必须停止队列;普通任务自身失败并成功上报后继续
下一条。
## 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,
"price_observed_at": {"color": "黑色"},
"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": []
}
```
当前采集任务使用 `price_granularity=color`。Client 逐个选中颜色读取价格,
尺码只读取文字;因此同一颜色的多个尺码组合可以共享颜色价格,但
`price_observed_at` 只能包含实际选中的颜色。它不能填写未点击、未确认的尺码。
这里的 `available` 来自当前规格节点显示状态,不表示 Client 已逐个验证了每个
颜色与尺码组合的实时库存。
颜色无法选中或没有采到稳定价格时,该颜色对应组合的 `price_cent`、`raw_price`
和 `list_price_cent` 使用 `null`,`price_observed_at` 使用空对象。缺价不改变
`available`,也不阻止尺码采集、结果入库和提交。
### 8.2 采购结果
采购任务在同一结构中增加:
```json
{
"purchase": {
"mode": "dry_run",
"requested": {
"options": {
"color": "黑色",
"size": "L",
"bundle": "标准版"
},
"quantity": 2,
"max_price_cent": 4200
},
"confirmed": {
"options": {
"color": "黑色",
"size": "L",
"bundle": "标准版"
},
"quantity": 2,
"unit_price_cent": 3990,
"total_price_cent": 7980
},
"confirmation_reached": true,
"order_submitted": false,
"payment_attempted": false,
"payment_status": null,
"order_no": null,
"ordered_at": null,
"ordered_at_raw": null,
"match_status": "not_submitted"
}
}
```
`requested.options` 和 `confirmed.options` 都是动态对象,键名来自 Admin 和 PDD
页面,不允许写死成颜色、尺码,也不做相似匹配。`dry_run` 必须同时满足
`order_submitted=false`、`payment_attempted=false` 和
`match_status=not_submitted`;只表示已经安全到达最终提交前确认页。
真实下单成功核对后 `mode` 为 `live`、`payment_status` 为 `unpaid`、
`match_status` 为 `matched`。订单页必须提供非空订单编号、有效下单时间和未付款
状态;下单时间位于本地 `order_submitted_at` 前后 5 分钟且候选唯一时才生成采购
结果。`goods_id` 来自原任务,`confirmed.options`、数量和金额来自下单前持久化的
`final_confirmation`。无候选、多候选、订单号或时间缺失、超出时间窗口或非未付款
订单只保存脱敏诊断并进入人工处理,不生成成功 `pdd_data`。
## 9. JSON 兼容规则
- 所有 JSON 根对象必须包含整数 `schema_version`。
- 新版本只允许新增可选字段或通过新版本迁移,不得改变现有字段含义。
- 未识别字段应保留,不得在同步或重新保存时静默丢弃。
- 写入数据库和提交 Admin 前必须验证 JSON 结构。
- 敏感信息不得放入 `admin_payload`、`pdd_data` 或诊断 JSON。