Files
cmautobuy/docs/client/03-data-model.md
T

28 KiB
Raw Blame History

03 Client 数据模型

  • 文档状态:基线草案,待数据评审
  • 数据库:SQLite
  • 数据库位置:程序目录下的 data/client.db(见 §2.1;怎么打开看数据见 上手指南 §7)

本文档中 [必须] / [建议] / [待定] 的含义见 文档索引。没有标注的默认是 [必须]。

1. 设计原则

  • [必须] Admin 是任务定义和最终业务状态的权威来源;SQLite 是 Client 执行状态和未提交结果的权威来源。
  • [必须] 金额统一使用人民币分的整数,禁止使用浮点数。
  • [必须] 时间使用带时区的 ISO 8601 字符串,数据库内部优先保存 UTC。
  • [必须] 原始 Admin 数据和 PDD 数据使用版本化 JSON,规范化字段用于表格和查询。
  • [必须] 完成 PDD 操作后,结果保存与 Outbox 创建必须处于同一事务,写法见 §5.1。
  • [必须] 无障碍 XML、截图和长日志存文件,数据库只保存路径、哈希和摘要。

2. 文件位置与数据库初始化

2.1 data 目录

[必须] 所有程序自己产生的、需要写入的东西,全部放在 data/ 目录下,不散落到别处:

data/
├── client.db          SQLite 数据库(本文档描述的所有表)
├── logs/              运行日志
└── artifacts/         失败截图、控件树 XML、步骤日志

data/ 的位置:

什么情况 data/ 在哪
打包成 exe 后 根目录 Launcher.exe 旁边(见 01 需求 §8.1 的目录结构)
直接跑源码 client/data/(已在 .gitignore 里,不会被提交)

这叫便携模式:整个程序文件夹拷到哪都能用,出问题把文件夹打包发出来就能复现。 代价是不许把程序装到 C:\Program Files\——那个目录普通用户没有写权限, Windows 会把写入悄悄重定向到 VirtualStore,然后你会遇到"明明改了设置却没生效"这种极难查的问题。 装到 D:\CMAutoBuy\ 这类用户可写的目录。

[必须] 访问令牌、密码、Cookie 绝不能写进 data/。 data/ 是明文的,而这个目录的设计目的就是"整个拷走",拷一次等于泄露一次。 凭据按 06 质量与安全 §7 存 Windows 凭据管理器。

2.2 路径解析(只准用这两个函数)

打包后只读资源和可写数据在两个完全不同的地方,这是最容易搞混的点。 统一封装成下面两个函数,[必须] 代码里不许出现第二处拼路径的写法:

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]

用法:

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 连接,并至少设置:

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。

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 接口契约 §1
[必须] 提交一定会被接受 Admin 必须无条件接受已派发过的结果,见 04 §6.1。所以本地不需要"提交被拒"的处理分支

关于"永久保留": 任务记录不设保留期,一直留着。界面上的“删除”只写入 removed_at,不会执行 SQL DELETE,也不会删除 task_runs 或 outbox_events。 但诊断产物(截图、XML、长日志)仍然按 diagnostics.retention_days 定期清理——两者是不同的东西,别搞混。清理诊断文件不影响任务记录和审计字段。

数据量增长靠索引和增量加载扛(见 §3 的索引和 05 界面规范 §5.3)。 将来真的大到影响性能,再单独开工单做归档,不要现在提前设计。

列表默认查询:

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,不得覆盖历史。

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 的可靠事件。

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、没写结果,发出去的是空的。手机上已经操作过的事没法撤销,所以数据库这边必须一次成功或一次都不做。

照抄这段:

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 接口契约 §6、§7 保持一致:

<remote_task_id>:<attempt_id>:result-v1     # 提交成功结果
<remote_task_id>:<attempt_id>:failure-v1    # 提交失败/人工处理
_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

保存非敏感设置和设置版本。

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 通用商品数据

{
  "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 采购结果

采购任务在同一结构中增加:

{
  "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。