Files
cmbuyer/docs/04-architecture.md
T

26 KiB
Raw Blame History

架构设计

本文讲「怎么把技术栈搭起来」:系统结构、职责划分、数据模型、技术难点、开发顺序。 具体用了哪些框架 / 库 / 数据库 / 部署方式,见 技术栈。

一、系统结构

第三方 ERP(顺运宝)          Excel 表格          人工填链接
        │                        │                    │
        └────────────┬───────────┴────────────────────┘
                     v
        ┌─────────────────────────────────────────┐
        │  web 端(Go 单二进制)                    │
        │  · 建单、批量开始试选与任务生命周期          │
        │  · 候选确认与下单授权(唯一决策权威)        │
        │  · 证据存储与审计                          │
        │  · 管理 Web(服务端渲染)                  │
        └─────────────────────────────────────────┘
                     ^  HTTP / JSON
                     │  Bearer Token + 设备绑定
                     v
        ┌─────────────────────────────────────────┐
        │  desk 端(Python + PySide6)             │
        │  · 领任务、跑流程、回传结果                 │
        │  · 本地执行轨迹与证据落盘                   │
        │  · AI 辅助调用(P1)                       │
        └─────────────────────────────────────────┘
                     │  ADB(USB / WiFi)
                     v
        ┌─────────────────────────────────────────┐
        │  Android 手机(拼多多 App)                │
        └─────────────────────────────────────────┘

组件落位:

  • web 端:Go + gin,入口 web/cmd/server/main.go,模板 web/internal/transport/webui/templates/
  • desk 端:Python,入口 desk/src/main.py,真机流程 desk/src/android/pdd_flow.py
  • 数据库:SQLite,迁移由 goose 管理
  • 证据存储:web 端本地文件系统,SHA-256 寻址
  • 外部服务:顺运宝 ERP(只读)、AI provider(P1,仅 desk 端调用)

二、职责划分

web 端

独占:

  • 任务的创建、状态流转和终态判定
  • 候选商品的接收与展示
  • 下单授权的签发与作废——这是唯一的资金决策权威
  • 金额上限的判定
  • 证据资产的存储与访问控制
  • 管理员会话与设备凭据

不做:

  • 不连接手机、不发 ADB 命令
  • 不保存、代理或下发任何 AI provider 凭据
  • 不解析拼多多页面

desk 端

独占:

  • ADB 连接与设备健康检查
  • 拼多多页面识别、点击、选规格、设数量
  • 页面截图与节点树采集
  • AI 调用(P1)
  • 完整执行轨迹的本地留档

不做:

  • 不自行决定买哪个候选——必须等 web 端的授权
  • 不自行放宽金额上限——本地校验只能更严,不能更松
  • 不直接读 Excel 或访问 ERP
  • 不在没有授权的情况下执行任何创建订单的动作

权威冲突规则

两端都会校验规格、数量、金额。判定不一致时一律转人工,不取任一方结论。 这条是硬规则:双闸门的价值在于分歧能被发现,自动选一边等于把双闸门降级成单闸门。

三、两趟执行

这是本项目最核心的结构决策。MVP 只做 A 路径(任务自带商品链接),分两趟跑完。

        ┌──────────── web 端开始第一趟 ────────────┐
        │ 新任务先保存为 DRAFT                         │
        │ 管理员在任务表格勾选一条或多条                  │
        │ 原子转为 PENDING,只进入试选队列                │
        └────────────────────┬─────────────────────┘
                             v
        ┌──────────────── 第一趟:试选 ────────────────┐
        │ desk 端轮询领取 PENDING 任务                    │
        │ 1. open_product(url)                          │
        │ 2. 打开规格面板                                │
        │ 3. 按维度精确勾选颜色分类、尺码                  │
        │ 4. 【闸门一】读该 SKU 单价,算合计                │
        │ 5. 截图                                       │
        │ 6. 退出商品,释放手机                           │
        │ 7. 回传标题 / 选中规格 / 单价 / 合计 / 截图        │
        └────────────────────┬─────────────────────────┘
                             v
                   任务转 WAITING_CONFIRMATION
                             │
        ┌────────────────────┴─────────────────────────┐
        │ 人在 web 端确认:机器选对了吗                    │
        │ 看:需求 vs 选中规格、单价、合计、截图             │
        │ 点「确认下单(不付款)」→ 签发授权,锁定授权价      │
        │ 或「退回,不买」→ 任务终止                        │
        └────────────────────┬─────────────────────────┘
                             v
        ┌──────────────── 第二趟:下单 ────────────────┐
        │ desk 端轮询拿到授权                            │
        │ 1. 重新 open_product(url)                     │
        │ 2. 重新按维度精确勾选同一规格                    │
        │ 3. 【闸门二】重读单价,必须与授权价一致            │
        │ 4. 设数量并复核                                │
        │ 5. 进订单确认页                                │
        │ 6. 【闸门三】读「实付款」,不得超授权上限          │
        │ 7. 三个闸门全过 → 点一次「提交订单」              │
        │ 8. 回传订单截图                                │
        └────────────────────┬─────────────────────────┘
                             v
                    任务转 WAITING_PAYMENT
                             │
                   人工在拼多多核对后付款

为什么分两趟而不是停在面板上等人

一台手机是瓶颈。若第一趟停在规格面板等人确认,手机被占住跑不了别的任务,面板还可能 超时或被拼多多重置。第一趟必须退出并释放手机,第二趟重新进入。

代价是同一商品走两遍,但第二趟很快,而且换来两个好处:手机可以在人思考时继续跑别的 任务的试选;价格变动能被第二趟抓住。

三道价格闸门

闸门 位置 作用 不通过时
一 第一趟规格面板 读该 SKU 单价,算合计,回传给人看 读不到即停,转人工
二 第二趟规格面板 重读单价,必须与授权时锁定的价格一致 不一致即停,转人工
三 订单确认页 读「实付款」,不得超授权总额上限 超出即停,转人工

闸门二不可省略。 人确认的是「32.50 元这一单」,不是「这个商品」。拼多多价格波动 常见,不能因为「人已经确认过」就照下不误。

价格只在规格面板和订单确认页读。 商品详情页正文和搜索结果卡片上的价格文本在真机上 被拆成多个节点(¥ 与数字分离)、带 券后 一类前缀、实付价 / 原价 / 促销价难以区分 ——前序项目在这里耗掉大量时间且无可靠结论。

B 路径(图片搜索)——V2,MVP 不做

任务只有参考图、没有链接时,需要先搜图找出商品。该路径推迟到 V2,MVP 阶段建单必须 提供商品链接。

V2 实现时仍遵守:图搜的唯一产出是 goods_id,不在搜索结果卡片上读价格或据价筛选, 拿到 goods_id 后汇入本节的两趟流程。

四、安全边界(硬约束)

以下每一条都必须有单元测试证明,且不得在任务中「顺手放宽」。

边界 规则 违反后果
不付款 任何路径都不点击支付、免密支付、先用后付或扣款控件 真实资金损失
提交订单四条件 见下方专节。四者缺一不可,且只允许点击一次 误下单 / 重复下单
订单确认页其余零点击 除「提交订单」与返回外,不点击确认页上任何控件 误触发未知动作
规格精确匹配 按维度等值匹配,防前缀碰撞(红/粉红、1/10);找不到即停 买错货
提交订单控件唯一 文本精确等于「提交订单」且可点击祖先唯一,否则停 点到未知控件
数量必须复核 设置后读回确认精确等于要求值,否则停 买错数量
价格三道闸门 见第三节。任一道读不到或不通过即停,不用其他位置的数字凑合 超预算采购
第一趟不下单 试选阶段只勾选规格和读价,绝不点击「现在买」或任何进入下单流程的入口 无授权下单
外部支付页 检测到微信等外部支付交接立即停止、转人工、保留证据 凭据泄露
安全校验 检测到验证码、风控、人脸、短信校验立即停止,不尝试绕过 封号 / 违规
敏感信息 只读非敏感摘要,不提取收货地址原文、手机号、支付凭据 隐私泄露
授权一次性 一笔授权只能产生一笔订单,重复提交幂等 重复采购
服务端提交围栏 真机点击前必须由 web 端原子冻结授权并创建唯一提交记录;失败或响应不明不得点击 并发 / 断网导致重复下单
App 版本失配即停 运行版本与本项目已取证版本不一致时停止领取真机任务,先重新取证 旧判据误点新页面

提交订单的四个前置条件

这是本项目唯一会创建真实待付款订单的动作。四者同时满足才允许点击,且只点一次:

  1. 授权存在且未消费,并已建立服务端提交围栏——web 端已签发、desk 端已 ack; 真机点击前,web 端在一个原子事务中把授权从可执行态冻结为本次唯一 order_submission。围栏接口失败或响应不明时不得点击。
  2. 闸门二通过——第二趟重读的单价与授权时锁定的价格一致。
  3. 闸门三通过——订单确认页「实付款」不超过授权总额上限。
  4. 控件唯一——文本精确等于「提交订单」且可点击祖先唯一。

点击之后,无论发生什么都不重试:

点击后观察到 处置
正常进入订单结果页 回传订单截图,任务转 WAITING_PAYMENT
跳转微信等外部支付 立即停止,转人工,提示「订单可能已创建、支付未完成」
安全校验 立即停止,转人工,保留证据
超时或页面无法判定 转人工,预留金额额度,提示订单状态不明

后三种情况一律禁止自动重试点击。授权保持永久围栏,结果明确后再记为已消费;在此之前 也绝不能重新开放——宁可人工核实一遍,不可能重复下单。

dry-run、提交围栏与结果调和

下单被拆成三个不可逆程度不同的阶段,任何客户端本地判断都不能替代服务端围栏:

  1. dry-run(只读演练):进入订单确认页,读取规格、数量和「实付款」,确认提交控件 唯一,上传证据后退出。该阶段绝不点击「提交订单」,也不消费授权。
  2. 提交围栏:真实第二趟再次读取并通过三道闸门后,desk 端向 web 端申请围栏。web 端 原子校验任务版本、命令、未消费授权和唯一性,创建 order_submissions 记录并冻结授权。 只有明确收到成功响应,desk 端才可点击一次。
  3. 结果调和:点击后只上报观察结果。明确创建则转 WAITING_PAYMENT;超时、外部支付、 安全校验或断连均转 RECONCILIATION_REQUIRED,保留额度并由人核查。不得释放围栏、 重新签发授权或自动重试点击。

授权超时和主动放弃只允许发生在提交围栏建立之前。围栏之后即使租约过期,也只能恢复 同一提交记录并进入调和,不能把任务重新放回可领取队列。

五、数据模型

5.1 核心实体

-- 采购任务:创建后业务约束不可变
CREATE TABLE tasks (
  id                TEXT PRIMARY KEY,          -- UUID
  source            TEXT NOT NULL,             -- MANUAL | EXCEL | ERP
  source_ref        TEXT,                      -- 外部单号(如虾皮订单号),非内部 id
  title             TEXT NOT NULL,
  goods_id          TEXT NOT NULL,             -- MVP 必填;B 路径(可为空)推迟到 V2
  sku_color         TEXT NOT NULL,
  sku_size          TEXT NOT NULL,
  quantity          INTEGER NOT NULL CHECK (quantity > 0),
  max_total_price   TEXT NOT NULL,             -- 十进制字符串,资金边界不得为空
  reference_asset_id TEXT,                     -- 参考图,MVP 可选;B 路径(V2)必填
  status            TEXT NOT NULL,
  version           INTEGER NOT NULL DEFAULT 1,-- 乐观锁
  created_at        TEXT NOT NULL,
  updated_at        TEXT NOT NULL
);

-- 第一趟试选结果:人做确认决策的依据
CREATE TABLE spec_trials (
  id                 TEXT PRIMARY KEY,
  task_id            TEXT NOT NULL REFERENCES tasks(id),
  attempt            INTEGER NOT NULL,
  product_title      TEXT NOT NULL,            -- 商品页读到的标题
  selected_color     TEXT NOT NULL,            -- 实际勾选到的颜色分类
  selected_size      TEXT NOT NULL,            -- 实际勾选到的尺码
  unit_price         TEXT NOT NULL,            -- 闸门一读到的单价
  total_price        TEXT NOT NULL,            -- unit_price × quantity
  evidence_sha256    TEXT NOT NULL,            -- 规格面板截图
  created_at         TEXT NOT NULL,
  UNIQUE (task_id, attempt)
);

-- 下单授权:唯一的资金决策记录
CREATE TABLE order_authorizations (
  id                 TEXT PRIMARY KEY,
  task_id            TEXT NOT NULL REFERENCES tasks(id),
  spec_trial_id      TEXT NOT NULL REFERENCES spec_trials(id),
  version            INTEGER NOT NULL,
  goods_id           TEXT NOT NULL,
  sku_color          TEXT NOT NULL,
  sku_size           TEXT NOT NULL,
  quantity           INTEGER NOT NULL,
  authorized_unit_price TEXT NOT NULL,         -- 锁定价:闸门二据此比对
  total_price_cap    TEXT NOT NULL,            -- 授权总额上限:闸门三据此比对
  note               TEXT,                     -- 可选备注
  status             TEXT NOT NULL,
  created_by         TEXT NOT NULL,
  created_at         TEXT NOT NULL,
  expires_at         TEXT NOT NULL,            -- 围栏前超时自动作废;围栏后不再释放
  UNIQUE (task_id, version)
);

-- 真实点击前的服务端一次性围栏;一笔授权最多一条
CREATE TABLE order_submissions (
  id                 TEXT PRIMARY KEY,
  task_id            TEXT NOT NULL REFERENCES tasks(id),
  authorization_id   TEXT NOT NULL REFERENCES order_authorizations(id),
  command_id          TEXT NOT NULL,
  dry_run_id          TEXT NOT NULL,
  status              TEXT NOT NULL,            -- FENCED | SUBMITTED | RECONCILIATION_REQUIRED | MANUAL_RESOLVED
  verified_unit_price TEXT NOT NULL,
  quantity_read       INTEGER NOT NULL,
  confirm_page_amount TEXT NOT NULL,
  created_at          TEXT NOT NULL,
  resolved_at         TEXT,
  UNIQUE (authorization_id),
  UNIQUE (command_id)
);

authorized_unit_price 是第二趟闸门二的比对基准,必须来自人确认时看到的那个试选 结果,不能在签发时重新取值。expires_at 见 5.3 节。

MVP 的授权没有「选择理由 / 拒绝理由」——那是从多个候选里挑一个时的留档需求。这里人 只回答「机器选对了吗」,保留一个可选 note 即可。

金额一律用十进制字符串存储和传输,不用浮点数。

5.2 状态机

任务状态(web 端权威)。创建与开始试选分离;两趟执行对应两次 CLAIMED → RUNNING:

DRAFT ─start trial→ PENDING ─┐
PENDING_RETRIAL ─────────────┴─claim→ CLAIMED ─start→ RUNNING(TRIAL)
       ↑                     │                   ├→ NEEDS_MANUAL
       └────────release──────┘                   └→ WAITING_CONFIRMATION
                                                        ├→ CANCELED
                                                        └→ AUTHORIZED
                                                              └claim→ ORDERING
                                                                          ├→ NEEDS_MANUAL(围栏前失败)
                                                                          └→ [submission FENCED]
                                                                                   ├→ WAITING_PAYMENT
                                                                                   │        └→ SUCCEEDED
                                                                                   └→ RECONCILIATION_REQUIRED
                                                                                            └→ 人工核查 / 调和
状态 含义
DRAFT 已保存、等待管理员开始试选;设备不可领取,也不存在下单授权
RUNNING(TRIAL) 第一趟试选中:正在勾选规格、读价、截图
WAITING_CONFIRMATION 试选已回传,等人确认机器选对了没
PENDING_RETRIAL 旧授权已过期或在围栏前被放弃,必须重新跑第一趟取得新价格
AUTHORIZED 已签发授权,等 desk 端下一轮轮询领走
ORDERING 第二趟下单中:重新选规格、过闸门二三、提交订单
WAITING_PAYMENT 订单已创建,等人在拼多多付款。这不是成功
RECONCILIATION_REQUIRED 已建立提交围栏,但点击结果不明确;可能已创建订单,只能核查,不能重试
NEEDS_MANUAL 围栏前的转人工情形(规格不匹配、价格不符、页面识别失败等)
SUCCEEDED 订单已付款且核对通过

授权状态:PENDING_DELIVERY → DELIVERED → ACKNOWLEDGED → EXECUTING → FENCED → CONSUMED。 人退回或重新确认时旧授权转 SUPERSEDED;超时转 EXPIRED。

DRAFT → PENDING 只能由管理端“开始试选”动作触发。批量开始在一个事务中校验全部任务仍为 DRAFT 且版本一致后统一流转;任一冲突时整批不变,避免用户误以为选中的任务都已开始。 这个动作只开放第一趟领取资格,不创建 order_authorizations 或 order_submissions。

5.3 授权超时(MVP 必做,不得推后)

前序项目的教训:曾出现 EXECUTING 授权永不推进,导致确认表单被永久隐藏、任务 锁死,只能新建任务绕过。

规则:

  • 每笔授权带 expires_at。仅在尚未建立提交围栏时,超时自动转 EXPIRED。
  • 授权 EXPIRED 后任务转 PENDING_RETRIAL,先重新跑第一趟取得新价格,再回到人工确认; 不允许在旧 spec_trials 上直接重新确认。
  • web 端在围栏建立前提供「放弃当前授权」入口;围栏建立后改为「进入人工核查」,不得 作废或释放授权。
  • 任何时候都不允许出现「任务停在某状态且界面上没有任何可用动作」的组合。 这是验收项,不是实现细节。

授权过期后重新确认时,必须重新走第一趟试选取得新的 spec_trials 记录——不能复用旧的 锁定价,因为价格可能已经变了。已建立围栏的授权不参与本超时流程。

5.4 证据分层

数据 位置 理由
候选商品页 / 规格页截图 上传 web 端 管理员做授权决策必须看
订单确认页截图 上传 web 端 授权后核对与审计必须留
订单核对截图 上传 web 端 资金核对证据
完整节点树 XML 仅 desk 端本地 体积大、含页面全文、只用于排障
AI 调用记录(P1) 仅 desk 端本地 含 prompt / 响应全文,脱敏成本高
失败现场快照 仅 desk 端本地,可按需手工导出 同上

上传前必须脱敏:不上传含收货地址、手机号、支付凭据的截图区域或文本。

六、关键技术难点

难点 说明 应对
拼多多页面结构随版本变化 前序项目已观察到详情页无独立规格入口、价格节点拆分等变化 每条判据先做真机 spike 取证再写代码;判据与 App 版本一并记录
规格面板上的价格位置 选中 SKU 后价格显示在哪、是否含券后前缀,未取证 T-103 必须一并取证,闸门一依赖它;读不到就转人工,不用详情页数字凑合
同一商品两趟结果不一致 第二趟价格变了、规格选项变了或商品下架 闸门二拦截;一律转人工,不自动放弃也不自动继续
图搜结果含跨类目商品(V2) 搜服装出现纸巾 B 路径只产 goods_id 且限 5 个;后续用 VLM 看截图筛同款
WiFi ADB 稳定性 息屏、换网、DHCP 续租会断连 超时可配置;断连视为技术失败并保留现场,不重试点击
同一手机 USB + WiFi 同时在线 adb devices 列出两条,自动选设备会失败 设备档案必须显式指定 serial,不允许留空自动选
不可逆动作的重试 点击「现在买」后超时,无法判断订单是否已创建 一律转人工并预留金额额度,禁止自动重试点击
双端契约漂移 两端独立演进会静默不兼容 契约改动必跑完整门禁;api.md 是唯一权威

高风险功能先做最小原型。 Phase 1 的真机 spike 必须先于 Phase 2 的界面开发完成。

七、推荐开发顺序

  1. Phase 0 地基:两端骨架、测试命令、init 脚本可运行。
  2. Phase 1 真机取证:WiFi ADB 连通;打开商品 → 打开规格面板 → 按维度精确勾选颜色 分类和尺码 → 读到该 SKU 单价 → 设数量 → 进订单确认页 → 读「实付款」。 结论写入文档,判据带拼多多 App 版本。
  3. Phase 2 web 端核心:数据模型与状态机、手工建单、任务查询、试选结果接收、 确认页与授权签发、授权超时与放弃。
  4. Phase 3 双端打通:设备侧 API、desk 端 HttpTaskSource/HttpResultSink、 定时轮询、第一趟试选端到端。
  5. Phase 4 闭环收尾:第二趟下单(含三道闸门与提交)、失败分类、完整验收、打包。
  6. V2 及以后:图片搜索路径、候选对照台、Excel 导入、ERP 建单、订单自动核对、AI 辅助。

不要在 Phase 1 结论出来之前写 Phase 2 的页面——确认页要显示什么,取决于真机上 究竟能读到什么。尤其是闸门一的单价,如果规格面板上读不可靠,整个确认页的设计要改。

八、项目结构

cmbuyer/
├── docs/
├── web/                        # Go
│   ├── cmd/server/
│   ├── internal/
│   │   ├── domain/             # 实体与状态机,无外部依赖
│   │   ├── usecase/            # 业务用例
│   │   ├── transport/
│   │   │   ├── httpapi/        # 设备侧 API
│   │   │   └── webui/          # 管理页面 + 模板 + 静态资源
│   │   └── storage/            # SQLite 与证据资产
│   └── migrations/
├── desk/                       # Python
│   ├── src/
│   │   ├── android/            # adb / device / pdd_flow
│   │   ├── core/               # models / task_runner / sources 抽象
│   │   ├── remote/             # HttpTaskSource / HttpResultSink
│   │   └── app/                # PySide6 GUI
│   └── tests/
└── scripts/

desk/src/core/sources.py 必须保留 TaskSource / ResultSink 抽象,执行器只依赖抽象。 这样离线 Excel 模式可作为降级路径存在,且执行器不因来源变化而改动。

九、架构纪律

  • 业务事实和 schema 变化必须同步更新本文与 api.md。
  • 不在代码里发明文档没有的接口、字段和状态。
  • 第四节的安全边界不得在任务中放宽;确需变更时先改本文并说明理由。
  • 高风险模块先单独真机验证,再接入完整流程。
  • 前序项目 cmroubao / cmpdd 是设计依据,不是事实来源;引用其结论时必须在本项目 重新验证。