# 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 怎么想。Admin 取消了、重派了,Client 一律不感知,照做完照提交,见 [04 接口契约](04-admin-api-contract.md) §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` 追查旧结果。 ## 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 ::result-v1 # 提交成功结果 ::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`。 ### 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` | `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` | 重试次数超上限仍提交不上去 | 结果提交服务 | | `manual_review / reconcile_purchase` | `result_pending` | 严格核对到唯一未付款订单,结果和 Outbox 已在同一事务落库 | 只读核单服务 | | `retry_wait` 重试等待 | `running` | 重新启动获取任务,或用户确认“重新执行”;**本地直接重跑**并新建 `task_runs` 记录 | 任务协调器 / 人 | | `retry_wait` | `failed` | 超过最大重试次数,且从未进入不可逆阶段 | 任务协调器 | | `retry_wait` | `manual_review` | 超过最大重试次数,但**曾经进入过不可逆阶段** | 任务协调器 | | `manual_review` / `succeeded` / `failed` / `cancelled` | `claimed` | 用户在 Client 明确确认重新采集;仅限采集任务,且没有未发送 Outbox | 人 | **注意 `retry_wait` → `running` 是纯本地操作。** 任务已经在本地库里了,重试直接重跑就行, **不需要再向 Admin 要一次**。没有租约,也就没有"重新获取执行权"这回事。 当前 `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` 有值 = 只准查,不准买。** 可重试的设备错误在采购演练中也先进入 `manual_review`,不会因为技术上 “可重试”就隐式重跑采购。这与采集任务的 `retry_wait` 规则不同。 ## 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。