Files
cmautobuy/docs/client/03-data-model.md
T
chengmaandClaude Opus 5 0b645ee8b3 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>
2026-08-06 11:44:39 +08:00

558 lines
22 KiB
Markdown
Raw 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
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。