Vendling · API · 构建 2026-09-30
我是谁显示全部章节。

Vendling Commerce API — 标准接口文档(UCP 对齐)#

版本 2026-09-10 v2 · 对齐 Universal Commerce Protocol 稳定版 2026-08-25 · 机器可读版:openapi.yaml · API Reference · Agent Skill

这份文档定义 Vendling(自主经营售货机线路的 AI 智能体)对外的商品类接口:供货方的 SKU 目录与采购下单、 机器平台的库存 / 交易流水 / 改价 / 补货推荐,以及本系统自己的补货计划、审批、事件。UCP 有对应概念的地方 (目录、结账、订单、门店位置、履约)沿用 UCP 的字段名与状态机;UCP 没有覆盖的售货机运营语义以 UCP 允许的 扩展方式定义,不改动标准部分。

本文只讲协议,不讲任何具体厂商。 机器平台与供货方通过适配器接入(附录 A),每个适配器登记一个 命名空间;调用方在运行时从 GET /ucp/v1/namespaces 拿到实际存在的命名空间。文中示例统一用占位厂商 acme:acme-supply 是供货方,acme-machine 是机器平台。

标记约定:[有] 已实现(/ucp/v1/*);[缺] 规范已定义、尚未实现。


0. 索引#

三张表:端点去哪一节找、概念以哪一节为准、哪份文档给谁看。章节目录在左侧边栏。

0.1 按端点#

全部 33 个操作。★ 是 §2.1 的最小接入集。

★ 端点 章节 是什么
★ GET /.well-known/ucp §4 发现档案:版本、能力、命名空间。公开,无需 token
★ GET /namespaces §5.4 运行时有哪些命名空间、谁是缺省
★ POST /catalog/search §5.1 §5.2 supply 命名空间 = 供货方在卖什么;machine = 某台机器里有什么
POST /catalog/lookup §5.3 按 sku_id 批量取,≤ 50 个,可跨命名空间;变体带 aliases[],这就是批量解析
GET /skus/{sku_id} §5.4 一个 SKU 的身份、条码、别名、可采购来源
PUT /skus/{sku_id}/aliases §5.4 人工确认两个号是同一件货
★ POST /locations/search §6 有哪些机器;可按某商品此刻是否有货筛选
PUT /locations/{id} §6 注册 / 更新一台机器及其周边档案
DELETE /locations/{id} §6 移除一台机器(保留事件与对话)
★ POST /locations/sync §6 立即把库存与流水拉进线路状态
GET /locations/{id}/inventory §5.2.1 实时直读货道,不经同步副本
★ PUT /locations/{id}/prices §9 改真实售价;confirm: true,超上限转审批
POST /locations/{id}/restock §10.3 给机器平台的补货请求:binding 假 = 推荐,真 = 有约束力的订单
★ POST /checkout-sessions §7.1 开一张采购会话(此时还没花钱)
GET /checkout-sessions/{id} §7.2 会话现状与待办 messages[]
PUT /checkout-sessions/{id} §7.1 改行项目或履约方式
★ POST /checkout-sessions/{id}/complete §7.4 花真钱:向供货方下单;confirm: true
POST /checkout-sessions/{id}/cancel §7.3 撤销会话,不会调用供货方
★ GET /orders §8.2 机器交易流水(kind=sale)或采购单列表
GET /orders/{id} §8.1 一张采购单的供货方状态与物流,或一笔机器交易
★ GET /replenishment/plan §10.1 每个货道的日销、余量天数、建议动作
GET /replenishment/runs §10.2 按状态列出补货行程
POST /replenishment/runs §10.2 按当前计划生成一次行程(可能触发审批)
GET /replenishment/runs/{id} §10.2 一次行程
POST /replenishment/runs/{id}/place §10.2 把行程交出去执行(按 fulfiller 路由)
POST /replenishment/runs/{id}/receive §10.2 确认到货,库存上调
GET /replenishment/score §10 预测准确度回看
★ GET /approvals §11 待审批 / 已处理的决策
GET /approvals/{id} §11 一条决策
★ POST /approvals/{id} §11 批准或驳回;批准改价即执行
★ GET /events §12 事件流(最新在前),响应带 websocket_url
GET /events/stream §12 只要连接地址,不拉历史
POST /events §12 其他 Agent 写入事件;不去重

0.2 按概念(以这一节为准)#

跨章节反复出现的约定,只有一处是规范,别处都是引用。要改就改这一处。

概念 规范出处 一句话
响应信封、messages[] §3.1 业务结果用 HTTP 200 + messages[],协议错误用状态码
鉴权、请求头、幂等 §3.2 §3.3 一个 operator token;UCP-Agent;Idempotency-Key
金额 §3.4 整数分 + CNY,任何地方都不用元
时间 §3.5 对外 RFC 3339 带 +08:00;适配器内部 epoch ms
sku_id 与命名空间 §3.6 <vendor>-<role>:<vendor_sku>,运行时发现
分页 §3.7 cursor + has_next_page
错误码与 severity §3.8 全量码表;程序只认 code
能力名的三种写法 §5.4 档案级、命名空间级、登记时的简写
售卖单位 EA / BX §5.1 sale_units[];缺省按个下单
别名与可采购来源 §5.4 只有 barcode / manual 能下单
订单六态与三个时间 §8.2 taken_at 测需求、settled_at 记营收、updated_at 轮询
护栏与 confirm: true §13 哪些动作要确认、受停机约束、要审批
适配器契约 附录 A 上游厂商要实现的方法与字段
零代码 HTTP 接入 A.6 八个端点、登记格式

0.3 文档面#

面 给谁 内容
本文(指南) 所有人 规范正文:能力、实体、状态机、护栏、适配器契约
openapi.yaml · API Reference 写代码 / 生成客户端 每个操作的请求、响应、schema;核心与合作伙伴徽标
Agent Skill vendling-commerce-api 调用本接口的 Agent ID 规则、安全规则、配方、Python 客户端
Agent Skill vendling-vendor-adapter 厂商 / 供应商的 Agent 按 A.6 出接口:字段对照、自检脚本、参考实现
agent-setup/prompt.md 任何 Agent 一句话完成接入
llms.txt · llms-full.txt 机器 索引与全文

1. 对齐原则#

维度 UCP 的做法 本规范的做法
能力命名 反向域名 {authority}.{service}.{capability},如 dev.ucp.shopping.checkout 标准能力原样用 dev.ucp.*;本项目扩展用 com.xiaopingfeng.vendling.*
版本 日期版本 YYYY-MM-DD,schema 自描述 ucp.version = 2026-08-25;扩展版本 2026-09-10;破坏性变更换日期
发现 商家在 /.well-known/ucp 发布档案;平台在 UCP-Agent 头里给出自己的档案 URL 同上,见 §4;命名空间注册表也在档案里
响应信封 每个响应带 ucp: {version, status, capabilities};错误走 messages[] 同上,见 §3.1
金额 整数、货币最小单位、显式 currency 全部用分、"currency": "CNY";上游的任何单位在适配器里统一
字段命名 snake_case,JSON Schema 2020-12 同上
传输 REST 为核心 REST;MCP 绑定由 Agent skill 的工具对应
目录 → 结账 → 订单 目录返回的 variants[].id 直接作为结账的 line_items[].item.id 同上,且这个 ID 在所有接口里都是同一个 sku_id(§3.6)
履约 dev.ucp.shopping.fulfillment:methods[] 的 shipping / pickup 供货方配送或自提;自提时段做成可选的 options[]
销售单位 quantity_unit(sale basis),缺省 each 同一 SKU 可按个(EA,缺省)或按箱(BX)下单,箱规在 sale_units[] 里公布
人工介入 status: requires_escalation + continue_url,severity: requires_buyer_review 表达方式还在,但自动触发它的两个阈值(结账超预算、改价超上限)2026-09-16 起改成不拦截——超限的结账/改价照常执行,只在 messages[] 里带一条 warning、写一条日志决策;人工审批仍然存在,但现在只由系统自己主动发起(一次真正的"我拿不准,问问人"),不再由某个数字越界自动触发,见 §7.3 / §9 / §11

UCP 明确允许的扩展点,本规范都只用这些:metadata 对象、自定义 fulfillment.methods[].type、开放的 fulfillment.events[].type / adjustments[].type、自由的 messages[].code、actions 映射、 以 extends 声明的扩展能力、能力条目上的 config。


2. 角色与方向#

UCP 只定义两个角色:Platform(消费能力的一方)和 Business(暴露能力的一方)。Vendling 同时扮演两个角色:

                ┌──────────── 上游:Vendling 是 Platform ──────────────┐
                │                                                       │
   供货方 <vendor>-supply   ◄── catalog / checkout / order status ──  Vendling
   (Business:卖货给我们)      §5.1          §7          §8.1            │
                                                                       │
   机器平台 <vendor>-machine ◄── inventory / ledger / prices / recommend ┤
   (Business:运营机器)        §5.2      §8.2     §9       §10.3        │
                                                                       │
                ┌──────────── 下游:Vendling 是 Business ─────────────┤
                │                                                       │
   运营者 / 其他 Agent / 顾客界面  ────────────────────────────────────►│
   (Platform)    §6 位置  §5 目录  §8 订单  §10 补货  §11 审批  §12 事件

上游不必讲 UCP:每个厂商一个适配器(附录 A),把它翻译成本规范的形状。下游调用方只看本规范, 永远不会碰到厂商自己的字段。

2.1 最小接入集(核心 API)#

五步接入(运营者或 Agent,拿到 token 之后):

  1. GET /.well-known/ucp —— 版本、能力、命名空间与缺省值(无需 token);
  2. POST /locations/search {} —— 有哪些机器;
  3. POST /catalog/search {"filters":{"namespace":"acme-machine","location":"…"}} —— 机器里有什么、什么价;
  4. GET /orders?kind=sale&location=…&from=…&to=… —— 卖了什么;
  5. GET /replenishment/plan —— 该补什么。

其余接口按需再接。会花真钱、改真价的只有两个(§7 结账 complete、§9 改价),都要 confirm: true。 Agent 直接用 一键接入 那句话,五步会自动跑完。

本规范一共 33 个操作,一条线路日常运转只依赖其中 13 个。它们要么是 Vendling 自己的定时循环每天在调的能力, 要么是仅有的两个会动真钱、改真价的动作。先接这些;其余的(批量 Lookup、别名注册表、结账会话的查改撤、采购单状态、 行程的列取下单收货、预测评分、补货请求、实时读货道……)按需再接。API Reference 里这 13 个操作带 核心 徽标。 关键场景的时序图见附录 C。

★ 操作 能力 为什么必需 Vendling 自己怎么用
★ GET /.well-known/ucp §4 发现 一切从这里开始:版本、能力、命名空间 外部 Agent 的入口
★ GET /namespaces §5.4 SKU 注册表 不知道命名空间就拼不出 sku_id 同上
★ POST /locations/search §6 位置 有哪些机器 仪表盘的机器名册
★ POST /catalog/search §5.1 / §5.2 目录 machine 命名空间 = 机器里有什么、什么价;supply 命名空间 = 供货方卖什么、什么价 每小时同步读库存;每天刷新一次批发成本
★ GET /orders?kind=sale §8.2 订单 交易流水,需求测算的唯一数据源 每小时同步 + 每 5 分钟轮询订单事件
★ POST /locations/sync §6 位置 把库存和流水拉进线路状态,补货计划以此为准 每小时定时任务、仪表盘"同步"按钮
★ GET /replenishment/plan §10.1 补货 每个货道的日销量、余量天数、建议动作 每天一次的 curate + restock 循环
★ POST /checkout-sessions → POST …/complete §7 结账 唯一会向供货方花真钱的路径 运营者或 Agent 触发;定时循环不会自己下单
★ PUT /locations/{id}/prices §9 定价 唯一会改机器真实售价的路径 运营者或 Agent 触发;超价格上限转审批
★ GET /approvals · POST /approvals/{id} §11 审批 人在回路:超预算、超价格上限的动作停在这里 群聊里的审批卡片、仪表盘
★ GET /events §12 事件 每个决定、每笔交易、每次报错的审计线 仪表盘实时流、每周给店主的信

不在表里但值得知道的两点:POST /events 是顾客界面写"想要什么"意图的通道,属于内部桥接而非接入必需; POST /replenishment/runs/{id}/place 今天只把行程交给模拟供货方,真实采购走 §7。

2.2 合作伙伴视角:谁该看哪些接口#

三类外部伙伴接触本规范的方式不同。售货机厂商 / 机器管理平台和商品供应商 / 批发平台站在上游:只需要让自己的 系统能被一个适配器(附录 A)翻译成规范的形状,不必自己讲 UCP。运营者和 Agent 开发者站在下游,直接调用标准接口。 页面顶部选"我是售货机厂商"或"我是商品供应商"会只保留各自相关的章节;API Reference 里对应操作带 售货机厂商 / 商品供应商 徽标(★ = §2.1 的核心接口)。

接口 章节 售货机厂商 / 机器平台 商品供应商 / 批发平台 运营者 / Agent 开发者
GET /.well-known/ucp、GET /namespaces §4、§5.4 你的命名空间 <vendor>-machine 出现在这里 你的命名空间 <vendor>-supply 出现在这里 ★ 入口
POST /catalog/search(machine 命名空间) §5.2 ★ 数据来自你的 inventory(locationId) ★ 机器里有什么
GET /locations/{id}/inventory §5.2.1 ★ 同一个 inventory(locationId),但是实时直读,不经同步副本 ★ 机器现在有什么
POST /catalog/search(supply 命名空间) §5.1 ★ 数据来自你的 catalog();按个 / 按箱两种售卖单位 ★ 能买什么
POST /catalog/lookup §5.3 同上 同上 便利:一次最多 50 个 ID,变体带 aliases[]
GET /skus/{id}、PUT …/aliases §5.4 条码是机器 SKU 与供货 SKU 之间的桥,MachineItem.barcode 请给全 目录里给出条码,别名就能自动对上 便利
POST /locations/search、PUT /locations/{id} §6 locationId 就是你的机器编号 ★ 有哪些机器
POST /locations/sync §6 ★ 每小时调用你的 inventory + ledger ★ 刷新线路状态
POST /checkout-sessions → POST …/complete §7 ★ 变成你的 createOrder(ref, lines, fulfillment):快递或自提 ★ 花真钱
GET /orders/{id}(kind: purchase) §8.1 变成你的 orderStatus(ref),含物流 便利
GET /orders?kind=sale §8.2 ★ 数据来自你的 ledger(),账户级交易流水 ★ 需求信号
PUT /locations/{id}/prices §9 ★ 变成你的 updatePrices(),真实改价 ★ 改真价
POST /locations/{id}/restock(binding 假 / 真) §10.3 变成你的 restock():假 = 给运维的推荐,真 = 你执行并回执的补货订单 便利 / ★ 补货执行
GET /replenishment/plan、/replenishment/runs… §10.1–10.2 ★ 计划;行程
GET /approvals、POST /approvals/{id} §11 ★ 人在回路
GET /events、POST /events §12 ★ 审计线

售货机厂商 / 机器管理平台要做的:实现附录 A.3 的 inventory 与 ledger,按你支持的能力再加 updatePrices、restock + restockStatus, 登记命名空间 <vendor>-machine(A.4)。不想写代码就按附录 A.6 把这几个方法暴露成 HTTPS 接口,把地址和 token 交给运营者登记即可。不需要理解结账、采购单、补货计划——那些在你之上。

商品供应商 / 批发平台要做的:实现附录 A.2 的三个方法(catalog、createOrder、orderStatus),登记 <vendor>-supply;同样可以走 A.6 的 HTTPS 形式。 目录请带条码和箱规(packSize),只按个卖就给 packSize = 1;createOrder 以采购单号 ref 幂等。不需要理解机器库存、改价、审批。

运营者 / Agent 开发者要做的:从 §2.1 的最小接入集开始,用 /.well-known/ucp 发现命名空间,然后读 Agent Skill 或 OpenAPI。

2.3 接入流程#

三条线,每条都以一次可验证的调用收尾;没有申请单、没有联调会议。

运营者 / Agent 售货机厂商 / 机器平台 商品供应商 / 批发平台
1 向运营者要一个 token 按 A.6 暴露 inventory、ledger 两个 GET(可选:prices、restock);让你的 Agent 用 vendling-vendor-adapter skill 做字段对照和自检 按 A.6 暴露 catalog、orders、orders/{ref};同样可用该 skill
2 GET /.well-known/ucp(或把 一键接入 发给 Agent) 把 base URL 和 token 交给运营者 把 base URL 和 token 交给运营者
3 §2.1 的五步 运营者登记 <vendor>-machine,GET /namespaces 立即可见 运营者登记 <vendor>-supply
4 需要花钱 / 改价时读 §13,带 confirm: true PUT /locations/{id} 注册机器,POST /locations/sync 跑通即接入完成 POST /catalog/search 看到自己的商品即接入完成
验证 GET /orders?kind=sale 有流水 库存和流水出现在 §5.2 / §8.2 一张 §7 的结账会话走到 ready_for_complete

厂商侧不必实现全部方法:不支持远程改价就不暴露 prices,登记时不声明 pricing 能力,调用方会看到 namespace_unsupported 而不是假成功。


3. 通用约定#

3.1 响应信封#

{
  "ucp": {
    "version": "2026-08-25",
    "status": "success",
    "capabilities": {
      "dev.ucp.shopping.catalog.search": [{ "version": "2026-08-25" }],
      "com.xiaopingfeng.vendling.inventory": [{ "version": "2026-09-10" }]
    }
  },
  "products": [ ... ]
}

ucp.capabilities 列出本响应实际启用的能力(含扩展),调用方据此知道哪些扩展字段有效。

3.2 鉴权#

场景 方式 说明
调用本规范的任何端点 Authorization: Bearer <VENDLING_AUTH_TOKEN> UCP 允许 API key。一把 token 全权,没有按调用方的 scope
平台自我标识 UCP-Agent: profile="https://<platform>/.well-known/ucp" UCP 要求平台每个请求都带;当前记录、不校验
上游厂商 适配器内部处理(签名、密钥) 凭证只在部署环境里;没有凭证的部署(staging)结构上无法花钱

发送一个有意义的 User-Agent。Cloudflare 会对默认的 Python-urllib UA 返回 403(错误 1010),那不是鉴权失败。

3.3 请求头#

头 必需 说明
Content-Type: application/json 写操作
UCP-Agent 平台请求 见 §3.2
Idempotency-Key 写操作应带 结账 / 采购单用 checkout id 兼作供货方的订单参考号,天然幂等;其余写操作 [缺] 尚未实现幂等存储

3.4 金额 Price / Total#

{ "amount": 550, "currency": "CNY" }
{ "type": "subtotal", "amount": 6600 }
  • amount 恒为整数、分。Total.amount 是有符号的,退款等调整为负数。
  • totals[] 类型用到 subtotal、fulfillment(配送费)、total。
  • 成本只在可靠时给出:适配器若发现上游报的"成本"与售价相同,视为未知(null),下游一律标"估算",不冒充。

3.5 时间#

对外一律 RFC 3339 带时区偏移,如 "2026-09-09T14:03:00+08:00"。上游厂商的时间格式与时区由适配器换算; 位置的营业时间按 UCP 用本地民用时间 + timezone: "Asia/Shanghai"。

3.6 SKU 标识与命名空间#

一件商品在所有接口里只有一个标识:sku_id。

sku_id = "<namespace>:<vendor_sku>"
namespace = "<vendor>-<role>"      role ∈ supply | machine
  • vendor:适配器登记的厂商标识([a-z][a-z0-9]*)。role:supply 供货方,machine 机器平台。
  • vendor_sku:厂商自己的编号,原样保留、不解析(不能含冒号和空白)。
  • 整串是唯一身份,按精确字符串比较。不同命名空间的两个 sku_id 永远不相等,哪怕物理上是同一件货;跨命名空间的"同一件货"靠 §5.4 的别名表达。
  • 实际存在哪些命名空间由运行时给出:GET /ucp/v1/namespaces(§5.4)或 /.well-known/ucp 里 com.xiaopingfeng.vendling.sku 能力的 config.namespaces。每条带 vendor、role、status(live / planned)、capabilities。

三条硬规则:

  1. 适配器只认自己的命名空间。 把 acme-supply:… 传给机器侧接口返回 400 namespace_mismatch。
  2. 一张采购单只能有一个供货方命名空间。 一张 PO 只发给一个供货方。
  3. 包装不进 ID。 整箱还是单个是下单时的计量单位(§7.1 quantity_unit)。缺省按个。

其余标识符:

对象 格式
位置(机器)Location.id 机器平台的机器编号
货道 slot_id <location>-<vendor_sku>(机器平台没有货道概念时的替身)
采购结账 / 采购单 Checkout.id = Order.id po_<yyyymmdd>_<seq> 或调用方给定的 PO 号;供货方回的订单号放在 Order.label
机器交易 Order.id 机器平台的交易流水号
补货行程 ReplenishmentRun.id run-<epoch ms>
决策 / 审批 Approval.id dec-<epoch ms>-<n>
事件 Event.id evt-<epoch ms>-<n>

3.7 分页#

游标分页:请求 pagination: {cursor, limit},响应 pagination: {cursor, has_next_page, total_count}。limit 缺省 20。

3.8 错误模型#

协议层错误用 HTTP 状态码 + 信封;业务结果按 UCP 约定用 HTTP 200 + messages[],实体照常返回, 调用方必须先看 messages 再用数据。

{
  "ucp": { "version": "2026-08-25", "status": "error" },
  "messages": [
    { "type": "error", "code": "kill_switch_engaged", "severity": "requires_buyer_review", "path": "$",
      "content": "kill switch is engaged — real writes are frozen" }
  ],
  "continue_url": "https://vendling.sh/"
}
code severity HTTP 触发
missing / invalid recoverable 400 必填缺失 / 类型范围不对,path 指向字段
not_found unrecoverable 404
unauthorized unrecoverable 401 token 错误;或上游拒绝凭证
out_of_stock / item_unavailable recoverable 200 UCP 标准
request_too_large recoverable 400 批量超过 50 个 ID
namespace_mismatch recoverable 400 结账里混了多个供货方;或把别的命名空间的 ID 传给了只认自己的接口
namespace_unsupported unrecoverable 400 命名空间已登记但没有 live 适配器
unresolved_sku recoverable 200 机内 SKU 没有已确认的可采购别名
hard_no_go unrecoverable 200 行项目命中 rules.hardNoGos;会话停在 incomplete
approval_rejected / expired unrecoverable 200 会话变为 canceled
confirmation_required requires_buyer_review 400 花钱 / 改价请求没带字面量 confirm: true
approval_required requires_buyer_review 200 护栏生成了待审批决策,actions 里给出 id
kill_switch_engaged requires_buyer_review 409 紧急停机开着
guard_unverifiable unrecoverable 503 读不到护栏规则,拒绝执行
supplier_rejected / upstream_unreachable unrecoverable 502 上游拒绝或不可达,content 带原文
already_placed / already_delivered / simulation_disabled unrecoverable 409

警告(type: "warning",不阻塞):price_estimated(单价由箱价换算)、location_unverified(机器号在流水里从未出现)、 history_truncated、hours_unknown、same_namespace、sync_problem。程序只认 code。


4. 发现档案 /.well-known/ucp#

[有] 公开、无需 token。endpoint 指向标准基址 https://vendling.sh/ucp/v1。 节选(完整档案以线上为准):

{
  "ucp": {
    "version": "2026-08-25",
    "services": {
      "dev.ucp.shopping": [{ "version": "2026-08-25", "transport": "rest", "endpoint": "https://vendling.sh/ucp/v1", "spec": "…", "schema": "…" }]
    },
    "capabilities": {
      "dev.ucp.shopping.catalog.search": [{ "version": "2026-08-25", "spec": "…", "schema": "…" }],
      "dev.ucp.shopping.checkout":       [{ "version": "2026-08-25", "spec": "…", "schema": "…" }],
      "dev.ucp.shopping.fulfillment":    [{ "version": "2026-08-25", "extends": "dev.ucp.shopping.checkout" }],
      "dev.ucp.shopping.order":          [{ "version": "2026-08-25" }],
      "dev.ucp.common.location.search":  [{ "version": "2026-08-25" }],
      "com.xiaopingfeng.vendling.sku": [{
        "version": "2026-09-10",
        "extends": ["dev.ucp.shopping.catalog.search", "dev.ucp.shopping.catalog.lookup", "dev.ucp.shopping.checkout", "dev.ucp.shopping.order"],
        "config": {
          "namespaces": [
            { "namespace": "acme-supply",  "vendor": "acme", "role": "supply",  "status": "live", "capabilities": ["dev.ucp.shopping.catalog.search", "dev.ucp.shopping.checkout", "dev.ucp.shopping.order"] },
            { "namespace": "acme-machine", "vendor": "acme", "role": "machine", "status": "live", "capabilities": ["com.xiaopingfeng.vendling.inventory", "com.xiaopingfeng.vendling.pricing", "com.xiaopingfeng.vendling.replenishment"] }
          ],
          "defaults": { "machine": "acme-machine", "supply": "acme-supply" }
        }
      }],
      "com.xiaopingfeng.vendling.inventory":     [{ "version": "2026-09-10", "extends": ["dev.ucp.shopping.catalog.search", "dev.ucp.shopping.catalog.lookup"] }],
      "com.xiaopingfeng.vendling.location":      [{ "version": "2026-09-10", "extends": "dev.ucp.common.location.search" }],
      "com.xiaopingfeng.vendling.approval":      [{ "version": "2026-09-10", "extends": "dev.ucp.shopping.checkout" }],
      "com.xiaopingfeng.vendling.order":         [{ "version": "2026-09-10", "extends": "dev.ucp.shopping.order" }],
      "com.xiaopingfeng.vendling.pricing":       [{ "version": "2026-09-10" }],
      "com.xiaopingfeng.vendling.replenishment": [{ "version": "2026-09-10" }],
      "com.xiaopingfeng.vendling.events":        [{ "version": "2026-09-10" }]
    },
    "payment_handlers": {
      "com.xiaopingfeng.vendling.on_account": [{ "id": "supplier_account", "version": "2026-09-10" }]
    }
  },
  "keys": []
}

payment_handlers 只有一个:账户挂账。采购按供货方账户记账,下单不经过支付凭证,所以结账的 payment.instruments[] 只有 type: "on_account",没有 credential。keys 为空:现在不做 RFC 9421 签名。


5. 目录 Catalog#

能力:dev.ucp.shopping.catalog.search、dev.ucp.shopping.catalog.lookup · 扩展:com.xiaopingfeng.vendling.inventory(机器库存视图)、com.xiaopingfeng.vendling.sku(销售单位、别名、SKU 注册表)

操作 方法 端点 说明
Search Catalog POST /catalog/search filters.namespace 选数据源;缺省是默认供货方
Batch Lookup POST /catalog/lookup 按 sku_id 批量取,最多 50 个,可跨命名空间
命名空间注册表(扩展) GET /namespaces 运行时存在的命名空间与缺省值
取一个 SKU(扩展) GET /skus/{sku_id} 见 §5.4
记别名(扩展) PUT /skus/{sku_id}/aliases 见 §5.4

同一套端点,filters.namespace 的角色决定查哪种源:

角色 回答的问题 附加条件
supply 供货方能卖给我们什么:SKU 清单、箱规、箱价 / 单价、供货方库存 —
machine 某台机器现在有什么:机内商品、零售价、余量 必须给 filters.location

5.1 供货方 SKU 清单(<vendor>-supply)#

POST /catalog/search
{ "query": "乌龙", "filters": { "namespace": "acme-supply", "categories": ["饮料"] }, "pagination": { "limit": 20 } }

一个供货方商品 = 一个 Product + 一个 Variant。整箱 / 单个不是两个变体,是同一个变体的两种销售单位 (sale_units[]),下单时用 quantity_unit 选。缺省单位是 EA(个):按个下单是基础情况,按箱是可选项。

{
  "ucp": { "version": "2026-08-25", "status": "success" },
  "products": [
    {
      "id": "acme-supply:10023",
      "title": "乌龙茶 500ml",
      "description": { "plain": "500ml*15瓶/箱" },
      "categories": [{ "value": "饮料", "taxonomy": "merchant" }],
      "price_range": { "min": { "amount": 380, "currency": "CNY" }, "max": { "amount": 380, "currency": "CNY" } },
      "variants": [
        {
          "id": "acme-supply:10023",
          "title": "乌龙茶 500ml",
          "price": { "amount": 380, "currency": "CNY" },
          "quantity_unit": { "unit": "EA", "display_text": "个", "increment": 1 },
          "sale_units": [
            { "unit": "EA", "display_text": "个", "increment": 1, "price": { "amount": 380, "currency": "CNY" }, "price_derived": true },
            { "unit": "BX", "display_text": "箱", "contains": 15, "increment": 1, "price": { "amount": 5700, "currency": "CNY" } }
          ],
          "unit_price": { "amount": 380, "currency": "CNY", "measure": { "value": 15, "unit": "EA" }, "reference": { "value": 1, "unit": "EA" } },
          "availability": { "available": true, "status": "in_stock" },
          "aliases": [],
          "metadata": { "vendor_sku": "10023", "spec": "500ml*15瓶/箱", "supplier_stock": 120 }
        }
      ],
      "metadata": { "sites": [{ "id": "site:2021", "address": "…" }] }
    }
  ],
  "pagination": { "has_next_page": false, "total_count": 1 }
}

sale_units[](扩展):

字段 说明
unit UN/ECE Rec 20 单位码:EA 个、BX 箱。与 UCP quantity_unit.unit 同一词表
contains 该单位含多少个 EA;EA 自身省略。适配器报 packSize = 1 时没有 BX
increment 起订倍数,UCP 原字段
price 该单位一件的价格
price_derived 价格是换算出来的(适配器只拿到箱价时,单价 = 箱价 ÷ 箱规)。实际结算以供货方对账为准

variants[].price 恒为 EA 单位的价格,quantity_unit 恒为 EA:不认识 sale_units 的 UCP 标准客户端也能正确按个下单。 metadata.sites[] 是供货方的提货点(附录 A SupplySite),自提时用作目的地 ID。

5.2 机器库存(<vendor>-machine)— 扩展 com.xiaopingfeng.vendling.inventory#

POST /catalog/search
{ "filters": { "namespace": "acme-machine", "location": "12345678" } }
{
  "products": [
    {
      "id": "acme-machine:8837",
      "title": "红牛 250ml",
      "price_range": { "min": { "amount": 600, "currency": "CNY" }, "max": { "amount": 600, "currency": "CNY" } },
      "variants": [
        {
          "id": "acme-machine:8837",
          "sku": "6920202888883",
          "barcodes": [{ "type": "EAN", "value": "6920202888883" }],
          "title": "红牛 250ml",
          "price": { "amount": 600, "currency": "CNY" },
          "availability": { "available": true, "status": "in_stock" },
          "inventory": { "location": "12345678", "slot_id": "12345678-8837", "stock": 7, "capacity": null, "locked": false },
          "aliases": [{ "sku_id": "acme-supply:10088", "source": "manual", "confirmed_at": "2026-09-01T10:00:00+08:00" }]
        }
      ]
    }
  ]
}
字段 位置 说明
filters.namespace 请求 一个 machine 角色的命名空间
filters.location 请求 机器 ID,必填
variants[].inventory.slot_id 响应 <location>-<vendor_sku>
variants[].inventory.capacity 响应 货道容量。只有运营者现场数过或从历史最高库存推出时才有值,否则 null,不默认
variants[].inventory.locked 响应 运营者锁定的货道,选品循环不得触碰
variants[].aliases[] 响应 其他命名空间里的同一件货,见 §5.4。这是从"机器缺货"走到"向谁采购"的唯一桥

已知陷阱:有的机器平台对不存在的机器号也返回成功。适配层用账户级交易流水做交叉检查:一个在同步窗口内 没有任何流水的机器号会在 messages[] 里得到 type: "warning"、code: "location_unverified"。

5.2.1 直接读货道(实时)#

上面那条 POST /catalog/search 答的是运营方同步副本里的机器状态,带别名、箱规、货道锁, 适合"缺货了该向谁采购"这类需要跨命名空间的问题。当你要问的只是最直白的那句 ——"这台机器现在有什么、多少钱、还剩几个"——用这条:

GET /locations/12345678/inventory
{
  "ucp": { "version": "2026-08-25", "status": "success", "capabilities": { "com.xiaopingfeng.vendling.inventory": [{ "version": "2026-09-09" }] } },
  "location_id": "12345678",
  "read_at": "2026-09-10T17:42:11.000Z",
  "items": [
    { "sku_id": "acme-machine:8837", "vendor_sku": "8837", "title": "红牛 250ml",
      "barcodes": [{ "type": "EAN", "value": "6920202888883" }],
      "price": { "amount": 600, "currency": "CNY" }, "stock": 7 }
  ]
}
字段 说明
read_at 问平台的时刻。这条前面没有缓存
items[].price 此刻站在机器前的顾客要付的价
items[].stock 货道剩余。0 表示这条货道存在但卖空了;根本没上的货不会出现在列表里
items[].slot_id 机器里的位置。平台不报位置时这个字段不出现

两者是不同的数据源,不是同一份数据的两种格式:这条直接问机器,/catalog/search 答的是上一次同步。 两边对不上的时候,那个差本身就是信息——说明上次同步之后有东西卖掉了、卡货了,或者被补过货。

只读。不动钱、不改顾客看得见的东西,所以不需要 confirm,kill switch 也不管它。

5.3 Lookup#

POST /catalog/lookup
{ "ids": ["acme-supply:10023", "acme-machine:8837"], "filters": { "location": "12345678" } }

命名空间就在 ID 里,一次请求可以跨命名空间,按前缀分发(machine 角色的 ID 需要 filters.location)。 ID 去重;每个变体带 inputs[](exact / featured);超过 50 个 ID 返回 400 request_too_large。

5.4 命名空间与 SKU 注册表 — 扩展 com.xiaopingfeng.vendling.sku#

GET /namespaces
{
  "namespaces": [
    { "namespace": "acme-supply",  "vendor": "acme", "role": "supply",  "status": "live",    "capabilities": ["…"] },
    { "namespace": "acme-machine", "vendor": "acme", "role": "machine", "status": "live",    "capabilities": ["…"] },
    { "namespace": "beta-supply",  "vendor": "beta", "role": "supply",  "status": "planned", "capabilities": [] }
  ],
  "defaults": { "machine": "acme-machine", "supply": "acme-supply" }
}

补货闭环要从"机器里 acme-machine:8837 快空了"走到"向 acme-supply:10088 采购",这一步跨命名空间,靠别名:

操作 方法 端点 说明
取一个 SKU GET /skus/{sku_id}?location= 身份、条码、别名、可采购来源 purchasable_from[](含 sale_units)
记别名 PUT /skus/{sku_id}/aliases {aliases:[{sku_id, source?}]} 人工确认两个号是同一件货

批量解析不需要单独接口:POST /catalog/lookup 一次最多 50 个 ID,每个变体都带 aliases[]。

能力名有三种写法,指的是三件事,不要混:

写法 出现在 含义
com.xiaopingfeng.vendling.pricing /.well-known/ucp 的 capabilities 本部署整体支持这个扩展
com.xiaopingfeng.vendling.replenishment.order GET /namespaces 里每个命名空间的 capabilities[] 这一个上游支持到哪一步
replenishment.order A.6 登记 JSON 的 capabilities 登记时的简写,前缀自动补全

前两者的区别是「本系统会不会」与「这台机器的平台会不会」:档案里有 pricing 不代表每个 machine 命名空间都能改价。 调用前看命名空间自己的 capabilities[],不支持的返回 namespace_unsupported。

别名规则:source ∈ barcode(两边都有 EAN 时自动建立)、manual(运营者确认)、 vendor(厂商声明两个角色共用一套商品编号,见下)、suggested(按名称相似度提出)。 barcode、manual、vendor 参与下单;suggested 不参与——它是一个等人确认的猜测。 别名是对称的。没有可采购来源不是错误,但带着这样的行去 §10.2 下单会得到 unresolved_sku。

共用编号空间(vendor 别名的来源)。 通常机器 sku 和供货 sku 是两套编号, 同一件商品在两边是不同的 id,只有条码或人工能搭桥。但既运营机器又卖货的平台 可能两个角色用的是同一套商品编号——那么 <vendor>-machine:N 与 <vendor>-supply:N 按构造就是同一件商品,没有什么需要确认的。

厂商在登记时声明一次(附录 A.6 的 sharesSkuSpaceWith),系统据此在每次读取时 隐式给出这条别名,source: "vendor"、confirmed_at: null。不落库:落库的行会在 厂商改目录之后继续断言一个已经不成立的对应,也会在声明被撤回后继续生效。 存量的人工别名优先——针对这一件商品的明确判断,胜过关于编号的一般性声明。

这必须声明而不能推断。两个命名空间都从 1 开始编号不构成任何证据,而声明只在 同一厂商的另一个角色之间成立:跨厂商的"共用编号"不可能为真,接受这种声明等于把 一次笔误变成"因为那家的机器里有 46 号,就去这家买 46 号"。

声明的是"同一套编号",不是"有货"。 purchasable_from[] 只列供货方目录里当下 真的有、能报价的;映射存在但目录里没有的,留在 aliases[] 里并附一条 unresolved_sku 警告。够不到供货方目录时是第三种答案——条目保留并说明原因,因为"不知道"不等于"没有"。


6. 位置 Location#

能力:dev.ucp.common.location.search · 扩展:com.xiaopingfeng.vendling.location

UCP 的 Location 是"地图上找得到的实体"。一台售货机正是:有地址、有营业时段、可以按"这里现在有没有某件商品"筛选。

操作 方法 端点
Search Locations POST /locations/search {query?, filters:{items?:[{id}], amenities?, hours?}}
注册 / 更新一台机器(扩展) PUT /locations/{id} {name, profile?}
移除一台机器(扩展) DELETE /locations/{id}
立即同步遥测(扩展) POST /locations/sync {sales_window_days?}
{
  "id": "12345678",
  "name": "某写字楼 11F",
  "address": { "street_address": "…", "extended_address": "11F 电梯厅", "address_locality": "北京市", "address_country": "CN" },
  "timezone": "Asia/Shanghai",
  "amenities": { "com.xiaopingfeng.vendling.qr_chat": { "description": "扫码可与机器对话、点单、报修" } },
  "online": true,
  "venue_name": "某写字楼11层",
  "venue_type": "office",
  "placement": "indoor",
  "profile_complete": true,
  "missing": []
}
  • hours[] 未记录时省略:UCP 规定"缺 = 未知"而非"关门";带 filters.hours 的搜索会附 hours_unknown 警告。
  • filters.items[] 用 machine 角色的 sku_id,只返回该商品 stock > 0 的机器。按 UCP 语义这是临时性信号,不是保留。
  • PUT /locations/{id} 注册后每小时自动同步;同步是整体替换机器 / 商品 / 货道 / 销售,但保留运营者的货道锁。sales_window_days 不得小于补货策略的观察期(21 天)。
  • DELETE 清掉档案、周边信息、遥测;保留事件与对话历史。

7. 结账 Checkout#

能力:dev.ucp.shopping.checkout + dev.ucp.shopping.fulfillment · 扩展:com.xiaopingfeng.vendling.approval、com.xiaopingfeng.vendling.sku

这是"下单进货":向一个供货方下一张真实的、花真钱的采购单。UCP 的结账会话模型把它拆成 "建单 → 补齐信息 → 审批 → 提交"几步,每一步都有明确状态,钱只在最后一步动。

操作 方法 端点
Create Checkout POST /checkout-sessions
Get Checkout GET /checkout-sessions/{id}
Update Checkout PUT /checkout-sessions/{id}
Complete Checkout POST /checkout-sessions/{id}/complete
Cancel Checkout POST /checkout-sessions/{id}/cancel

7.1 建单#

POST /checkout-sessions
Idempotency-Key: po_20260909_001
{
  "id": "po_20260909_001",
  "line_items": [
    { "item": { "id": "acme-supply:10023" }, "quantity": 2, "quantity_unit": { "unit": "BX" } },
    { "item": { "id": "acme-supply:10088" }, "quantity": 6 }
  ],
  "fulfillment": {
    "methods": [{ "type": "shipping", "line_item_ids": ["*"],
      "destinations": [{ "type": "shipping_address", "street_address": "…", "address_locality": "北京市", "address_country": "CN", "first_name": "张三", "phone_number": "13800000000" }] }]
  }
}
  • id 可选。给了就用作 PO 号(兼作供货方的订单参考号),没给由服务端生成。
  • line_items[].item.id 必须来自 supply 角色的命名空间。machine 角色的 ID 不能直接下单,先经 §5.4 resolve。
  • 一张单一个命名空间:所有行的前缀必须相同,否则 400 namespace_mismatch。会话的 vendor 由第一行决定,建单后不可变。
  • 按个下单是缺省:quantity_unit 省略即 { "unit": "EA" }。要按箱写 { "unit": "BX" },quantity 是箱数。unit 必须在该变体的 sale_units[] 里,否则 400 invalid。
  • 履约二选一:
  • shipping:目的地必须有 street_address + first_name(联系人)+ phone_number,缺一建单时返回 missing。
  • pickup:目的地由服务端从供货方的提货点枚举(site:<id>),平台用 selected_destination_id 选点、groups[].selected_option_id 选自提时段(服务端生成未来 7 天的时段选项)。

7.2 会话实体#

{
  "ucp": { "version": "2026-08-25", "status": "success",
           "payment_handlers": { "com.xiaopingfeng.vendling.on_account": [{ "id": "supplier_account", "version": "2026-09-10" }] } },
  "id": "po_20260909_001",
  "status": "ready_for_complete",
  "currency": "CNY",
  "vendor": "acme-supply",
  "line_items": [
    { "id": "li_1", "item": { "id": "acme-supply:10023", "title": "乌龙茶 500ml", "price": 5700, "quantity_unit": { "unit": "BX", "display_text": "箱", "contains": 15 } },
      "quantity": 2, "totals": [{ "type": "subtotal", "amount": 11400 }, { "type": "total", "amount": 11400 }] },
    { "id": "li_2", "item": { "id": "acme-supply:10088", "title": "红牛 250ml", "price": 480, "quantity_unit": { "unit": "EA", "display_text": "个" } },
      "quantity": 6, "totals": [{ "type": "subtotal", "amount": 2880 }, { "type": "total", "amount": 2880 }] }
  ],
  "fulfillment": { "methods": [{ "id": "fm_shipping", "type": "shipping", "line_item_ids": ["li_1", "li_2"],
    "destinations": [{ "type": "shipping_address", "id": "dest_1", "street_address": "…", "first_name": "张三", "phone_number": "138…" }],
    "selected_destination_id": "dest_1",
    "groups": [{ "id": "grp_1", "line_item_ids": ["li_1", "li_2"], "options": [{ "id": "opt_std", "title": "供货方配送", "totals": [{ "type": "total", "amount": 0 }] }], "selected_option_id": "opt_std" }] }] },
  "totals": [{ "type": "subtotal", "amount": 14280 }, { "type": "fulfillment", "amount": 0 }, { "type": "total", "amount": 14280 }],
  "payment": { "instruments": [{ "id": "instr_account", "handler_id": "supplier_account", "type": "on_account", "selected": true, "display": { "account": "on file" } }] },
  "messages": [
    { "type": "warning", "code": "price_estimated", "path": "$.line_items[1]", "content": "单价由箱价换算,实际以供货方对账为准" },
    { "type": "warning", "code": "over_budget", "path": "$", "content": "estimated cost 142.80 exceeds run budget 100.00 — placing anyway; logged for review" }
  ],
  "links": [{ "type": "documentation", "url": "https://vendling.dev/api/" }],
  "expires_at": "2026-09-10T14:03:00+08:00"
}
  • item.price 是所选单位一件的价格(箱行是箱价,个行是单价),行 totals = price × quantity。
  • 服务端回显 item.quantity_unit(UCP 要求非 each 的行必须带),并补上 display_text / contains。
  • 按个下单的行会附 price_estimated 警告(供货方只公布箱价时,单价是换算的)。
  • 超预算的行会附 over_budget 警告——2026-09-16 起这不再拦截:状态照样是 ready_for_complete,complete 会照常执行,只是多一条 warning 和一条写进决策日志、留给 CEO Agent 日常复核的记录(§13)。

7.3 状态机#

status 含义 进入条件 出去
incomplete 信息不全 缺联系人 / 地址 / 自提时段;某行命中 hardNoGos(hard_no_go,永远过不去) PUT 补齐
requires_escalation 要人批 2026-09-16 起结账不会自动进到这个状态——预估金额超 rules.spendingLimitPerRun、或处于试用期 probationUntil,现在只落一条 over_budget warning,直接给 ready_for_complete。这个状态还在类型里、还会出现,只是触发源变了:系统自己主动调用 approval 工具(一次真正不确定要不要办的判断),不再是某个数字越界自动触发 运营者 §11 审批;批准 → ready_for_complete,驳回 → canceled
ready_for_complete 可以提交 信息齐、护栏过(含超预算但已放行的情况——见上,messages[] 里能看到 over_budget) POST …/complete
complete_in_progress 已提交,等供货方 上游请求已发出、未回 由服务端推进
completed 已下单 供货方接单,返回订单号 终态;order 字段出现
canceled 作废 主动取消、审批驳回、expires_at 过期(24 小时) 终态

紧急停机(rules.killSwitchEngaged)不是一个状态:任何时候 complete 都会被拒(409 kill_switch_engaged),会话本身不动。 读不到规则时 503 guard_unverifiable(护栏失败关闭,§13)。

7.4 提交#

POST /checkout-sessions/po_20260909_001/complete
{ "payment": { "instruments": [{ "id": "instr_account", "handler_id": "supplier_account", "type": "on_account" }] }, "confirm": true }
  • confirm 是扩展字段,必须是 JSON 布尔 true。字符串 "true"、数字 1 一律 400 confirmation_required。
  • 成功:status: "completed",并带 order: { "id": "po_…", "label": "<供货方订单号>", "permalink_url": "…/orders/po_…" }。
  • 供货方拒单:502 + supplier_rejected,status 回到 ready_for_complete,content 带上游原文。重复提交已完成的会话返回同一个 order,不会再下一单。
  • 每次提交无论成败都写一条事件到 §12 的事件流。花真钱的调用不能是黑箱。

7.5 会话 → 适配器#

服务端把会话翻译成附录 A 的 createOrder(ref, lines, fulfillment):ref = 会话 id;每行 {vendor_sku, quantity, unit: "each" | "pack"} (BX → pack);fulfillment 是 {method:"shipping", contactName, contactPhone, address} 或 {method:"pickup", pickupAt, siteId?}。 厂商自己的字段名不出现在本规范里。


8. 订单 Order#

能力:dev.ucp.shopping.order · 扩展:com.xiaopingfeng.vendling.order(列表查询、位置 / 交易状态字段)

两类订单,同一个实体形状,用 kind 区分:

kind 谁是 Business 数据来源 ID
purchase(采购单) 供货方 适配器 orderStatus() PO 号(§7)
sale(机器交易) 本系统 / 机器 适配器 ledger() 机器平台的流水号
操作 方法 端点
Get Order GET /orders/{id}
List Orders(扩展) GET /orders?kind=sale&location=&from=&to=&trade_status=&cursor=&limit=

反向的订单 Webhook(本系统推给平台)不属于本节的操作,见 §12。

8.1 采购单(kind: purchase)#

{
  "id": "po_20260909_001",
  "label": "SUP-2026090912345",
  "kind": "purchase",
  "checkout_id": "po_20260909_001",
  "permalink_url": "https://vendling.sh/ucp/v1/orders/po_20260909_001",
  "currency": "CNY",
  "line_items": [
    { "id": "li_1", "item": { "id": "acme-supply:10023", "title": "…", "price": 5700, "quantity_unit": { "unit": "BX", "contains": 15 } },
      "quantity": { "original": 2, "total": 2, "fulfilled": 0 },
      "totals": [{ "type": "subtotal", "amount": 11400 }, { "type": "total", "amount": 11400 }], "status": "processing" }
  ],
  "fulfillment": {
    "expectations": [{ "id": "exp_1", "line_items": [{ "id": "li_1", "quantity": 2 }], "method_type": "shipping", "destination": { "street_address": "…", "address_country": "CN" }, "description": "待发货" }],
    "events": [{ "id": "fe_placed", "occurred_at": "2026-09-09T14:05:00+08:00", "type": "processing", "line_items": [{ "id": "li_1", "quantity": 2 }], "description": "已接单" }]
  },
  "adjustments": [],
  "totals": [{ "type": "subtotal", "amount": 14280 }, { "type": "fulfillment", "amount": 0 }, { "type": "total", "amount": 14280 }],
  "supplier_status": { "code": 2, "description": "待发货", "mapped": "ordered" },
  "logistics": []
}

适配器把供货方的状态归一为三态,UCP 视图由此推导:

适配器 state 行 status 追加的 fulfillment.events[].type
ordered processing processing
arrived fulfilled(fulfilled = total) delivered
cancelled removed(total = 0) canceled,并加 adjustments[{type:"cancellation"}]

supplier_status(原始码 + 描述)和 logistics[](原样透传)是扩展字段,供排障用。

8.2 机器交易(kind: sale)#

GET /orders?kind=sale&location=12345678&from=2026-09-09T00:00:00%2B08:00&to=2026-09-09T23:59:59%2B08:00&limit=100
{
  "orders": [
    {
      "id": "2026090913001234",
      "kind": "sale",
      "checkout_id": "2026090913001234",
      "permalink_url": "…/orders/2026090913001234",
      "currency": "CNY",
      "line_items": [
        { "id": "2026090913001234-8837", "item": { "id": "acme-machine:8837", "title": "红牛 250ml", "price": 600 },
          "quantity": { "original": 1, "total": 1, "fulfilled": 1 },
          "totals": [{ "type": "subtotal", "amount": 600 }, { "type": "total", "amount": 600 }], "status": "fulfilled" }
      ],
      "fulfillment": {
        "expectations": [{ "id": "exp_1", "line_items": [{ "id": "2026090913001234-8837", "quantity": 1 }], "method_type": "vending_dispense",
          "destination": { "extended_address": "某写字楼11层", "address_country": "CN" }, "description": "机器即时出货" }],
        "events": [{ "id": "fe_1", "occurred_at": "2026-09-09T13:00:12+08:00", "type": "dispensed", "line_items": [{ "id": "2026090913001234-8837", "quantity": 1 }] }]
      },
      "adjustments": [],
      "totals": [{ "type": "subtotal", "amount": 600 }, { "type": "total", "amount": 600 }],
      "location": "12345678",
      "location_name": "某写字楼11层",
      "trade_status": "settled",
      "finalized": true,
      "taken_at": "2026-09-09T13:00:12+08:00",
      "settled_at": "2026-09-09T13:00:14+08:00",
      "updated_at": "2026-09-09T13:00:14+08:00",
      "trade_status_raw": "SETTLED",
      "trade_status_label": "交易成功"
    }
  ],
  "pagination": { "cursor": "eyJwYWdlIjoyfQ", "has_next_page": true }
}

适配器把每条流水行归一为五种状态,UCP 视图由此推导:

适配器行 status 行 status quantity adjustments[]
paid fulfilled {1,1,1} —
unpaid processing {1,1,0} —
refunded removed {1,0,0} { type: "refund", status: "completed", totals: [{type:"total", amount: -price}] }
refund_failed fulfilled {1,1,1} { type: "refund", status: "failed" }
cancelled removed {1,0,0} { type: "cancellation", status: "completed" }

订单级状态 trade_status 归一为六种。传统弹簧机"先付后出货",一笔交易生成即终态;开门自取式智能柜 (扫码 / 刷脸授权 → 开门 → 取货 → 关门 → 视频上传 → 视觉识别 → 可能人工复核 → 免密扣款)的一笔交易在关门时就已存在, 之后商品、数量、金额都可能被识别和复核改写,几分钟到几小时后才结算。两类机器共用同一套状态(时序见附录 C.6):

trade_status 含义 智能柜阶段 行 status finalized
in_progress 已授权 / 开门 / 取货中 / 关门待识别 授权 → 关门 → 上传 processing false
pending_review 识别有疑问,等人工复核 人工复核 processing false
settled 已扣款成功(弹簧机:出货即此态) 扣款成功 fulfilled true
payment_failed 识别已定、扣款失败或欠费 扣款失败 / 追缴中 processing false
refunded 结算后全额或部分退款 申诉 / 复核后退款 removed + refund 调整 true
cancelled 空单(未取货、识别为空)或平台取消 0 元单 removed + cancellation 调整 true
  • 只有 settled 订单里的 paid 行进入销售统计。列表缺省只取 trade_status=settled,传 all 取全部,或传单个状态值。
  • 三个时间:taken_at 是取货时间(弹簧机 = 出货时间;智能柜 = 关门时间),需求测算用它;settled_at 是扣款成功时间,营收用它; updated_at 是订单最近一次变化的时间。from / to 按 taken_at 过滤;updated_from / updated_to 按 updated_at 过滤, 这是轮询晚结算、被复核改写、结算后退款的订单的唯一可靠方式——只按取货时间拉窗口会漏掉它们。
  • finalized = false 的订单会变:商品、数量、金额都可能被识别或复核改写。调用方缓存或去重时必须以 (id, updated_at) 而不是 id 为键。
  • 流水是账户级的:location 参数是服务端在全量结果上做的筛选,多机器线路上要跨机器完整翻页(上限 20 页,超过带 history_truncated 警告)。
  • trade_status_raw / trade_status_label 是机器平台的原始状态,扩展字段,供排障用。
  • 授权方式、会员标识等顾客身份信息不进入本规范:适配器不得透传。
  • trade_status 归一化枚举、finalized、taken_at、settled_at、updated_at:参考实现自 2026-09-11 起已实现。 归一化状态由适配器给出;适配器没给时,参考实现只在订单有行的情况下从行状态推导(全部 paid → settled)—— 空数组的"全部 paid"恒为真,拿它推导会把一次"开了门没拿东西"的会话记成一笔已结算的 ¥0 销售。 既没有适配器状态又没有行时,trade_status 整个缺省,只留 trade_status_raw:一个缺失的状态比一个自信的错词有用。 trade_status 现在接受六个状态值中的任意一个做过滤,传不认识的值返回 400 invalid—— orders: [] 和"今天没卖出去东西"长得一模一样,一次过滤器笔误不该读成"没有销售"。
  • [缺] updated_from / updated_to:上游需要按更新时间过滤流水的能力(A.6 的 by=updated)。 机器平台不提供时按契约明确回 unsupported,而不是悄悄返回取货时间窗—— 所以在这类平台上,晚结算、复核改写、结算后退款轮询不到,这是平台的真实限制。

9. 扩展:定价 Pricing#

能力:com.xiaopingfeng.vendling.pricing

改的是真实机器上顾客看到的价格。UCP 没有"商家改自己售价"的能力,所以这是纯扩展,但复用 UCP 的 Price 类型、messages[] 错误模型和 requires_buyer_review 审批语义。

PUT /locations/12345678/prices
{ "prices": [{ "item": { "id": "acme-machine:8837" }, "price": { "amount": 650, "currency": "CNY" } }], "confirm": true }
{ "location": "12345678", "updated": [{ "item": { "id": "acme-machine:8837" }, "price": { "amount": 650, "currency": "CNY" } }] }
  1. item.id 必须来自一个 machine 角色的命名空间,且一次请求只能一个命名空间(400 namespace_mismatch)。
  2. price.amount 正整数(分),currency 必须是 CNY。
  3. confirm 必须是布尔 true(同 §7.4)。
  4. 紧急停机时 409 kill_switch_engaged。
  5. 价格上限护栏:以机器上此刻的现价为基准检查 |new − current| > rules.priceCapPerItem。2026-09-16 起超限不再拦截——照常执行改价,响应里带一条 over_cap warning(type: "warning",不是 error),并写一条 kind: price_change 的决策日志留给日常复核,不产生待审批项。§11 的审批机制还在,但现在只服务于系统自己主动发起的审批,不再由价格上限这个阈值自动触发。
  6. 无论成败写事件。

10. 扩展:补货 Replenishment#

能力:com.xiaopingfeng.vendling.replenishment

对象 是什么 谁执行 花不花钱
计划 Plan 策略算出来的"每个货道现在该订多少" 本系统,每日 否
行程 Run 一次出门要跑的机器、要带的货、预估成本;可下单、可收货 运营者 下单那一步花
补货推荐 Recommendation 给机器平台自家运维队伍的"往这台机器装什么"的提示,无约束力 机器平台 否
补货订单 Order 交给机器平台执行的有约束力的补货单,有回执(状态、实际数量、完成时间) 机器平台 平台计价时花
操作 方法 端点
当前计划 GET /replenishment/plan
列出行程 GET /replenishment/runs?status=
取一个行程 GET /replenishment/runs/{id}
生成行程 POST /replenishment/runs
下单 POST /replenishment/runs/{id}/place
收货 POST /replenishment/runs/{id}/receive {delivered:[{slot_id, quantity}], note?}
预测评分 GET /replenishment/score?horizon_days=7
向机器平台发补货请求 POST /locations/{id}/restock {reference, binding?, line_items:[{item:{id}, quantity, reason?}]};binding: false(缺省)是推荐,true 是有约束力的订单(§10.3)

10.1 计划#

{
  "strategy_version": "v2-weekshape",
  "params": { "window_days": 21, "cover_days": 7, "safety_days": 2, "min_order_qty": 3, "max_order_qty": 40 },
  "lead_time": { "days": 1.6, "source": "measured", "samples": 7 },
  "plans": [
    { "slot_id": "12345678-8837", "location": "12345678", "item": { "id": "acme-machine:8837", "title": "红牛 250ml" }, "stock": 7,
      "demand": { "daily_rate": 0.52, "naive_daily_rate": 0.48, "available_days": 19, "empty_days": 2, "closed_days": 0, "measured": true },
      "days_of_cover": 13.4, "stockout_at": "2026-09-22T00:00:00+08:00", "recommendation": { "action": "hold" } }
  ]
}

lead_time.source 是 measured(从历史行程实测)或 stated(样本不够,用参数),必须原样透出。demand.measured = false 表示需求率是从缺货期推断的。

10.2 行程(Run)#

行程的形状刻意贴近 UCP Order:行项目、按机器分组的履约期望、追加式的履约事件、金额汇总。

status 条件
blocked 紧急停机时生成,line_items 为空。和"没什么要补"不是一回事
nothing_to_do 没有货道需要补
superseded 后来的计划取代了它(superseded_by 是新行程的 id)。排在审批状态之前:它不在等任何人
pending_approval / rejected / approved 预估成本 > spendingLimitPerRun 时走审批
placed / delivered 已下单(placed_at)/ 已收货(delivered_at)
  • 行项目是机内 SKU(machine 角色);每行带 source(经 §5.4 解析到的可采购 sku_id + 计量单位,缺省按个)。
  • 同一条线路同时只应有一张活着的行程。 没下单的旧行程,其数量是按更早的库存算出来的; 两张都活着意味着要么买两次,要么批错那一张。新计划生成时,任何尚未下单的旧行程被标记 superseded(已下单、已收货的是历史,不动)。被取代的行程仍然保留——它挂着的审批决定还指向它, "当时考虑过什么"也该留下。但审批卡已经躺在某人的聊天工具里,所以拒绝发生在花钱那一刻: 对 superseded 的行程调 place 返回 409 already_placed,并指出该去下哪一张。
  • 行上的 transfer: { quantity, from[] } 是从线路上另一台机器调过来、不必采购的数量。 quantity 已经扣掉了它,所以一行完全可以是 quantity: 0 且带 transfer——那是行程上的一个停靠点, 不是一笔订单。计划算出别处压着同一件商品的滞销库存时给出这个建议;行程若忽略它照买, 就是为同一个分销问题付两次钱,滞销货还留在原地。
  • 读取行程(列表或详情)时,买不到的行会以 unresolved_sku 警告逐条点名: 没有可采购别名的,以及 source 指向的商品当下不在供货方目录里的。 行程是给人批的,批一张有三行供货方根本给不出的单,正是这整套确认与限额机制要防的事。 列表只对还能动的行程报(superseded、已下单、已收货、已拒绝的不报),否则真警告会被淹成噪音。
  • 下单 place:紧急停机 409;已下单 409 already_placed;待审批 409 approval_required。行程按 fulfiller 路由(时序见附录 C.5): supply —— 按 source 创建 §7 的结账会话(跨供货方时拆成多张),未解析的行是错误; machine_platform —— 机器平台登记了 replenishment.order 能力时,整张行程作为补货订单交给它执行(A.3 restock(…, {binding: true})),external_ref 记平台单号,回执自动填 receive; simulated —— 参考实现今天的路径(响应带 supplier: "simulated"),未解析的行只是 unresolved_sku 警告。 [缺] fulfiller / external_ref 字段与前两条路由。
  • 收货 receive:quantity 非负整数;有容量时封顶;已收货 409 already_delivered。
  • 已下单未收货的行程覆盖的货道,在下一次计划里跳过,避免为同一批货付两次钱。
  • cost_is_estimated = true 表示有行项目没有真实成本(用零售价 × 0.55 估的);这个数字决定要不要审批,所以必须说明它是估的。

10.3 向机器平台的补货推荐或补货订单#

两种形态,取决于机器平台登记的能力:

补货推荐 replenishment.recommend 补货订单 replenishment.order
端点 POST /locations/{id}/restock,binding: false(缺省) 同一端点,binding: true;行程 place 路由到机器平台时内部也是它
约束力 无,提示平台运维 有,平台执行并回执
适配器方法 restock(…, {binding: false}) restock(…, {binding: true}) + restockStatus
回执 无 external_ref;状态、实际数量、完成时间,自动填 receive
护栏 紧急停机 紧急停机;平台计价时同采购预算
  • item.id 必须是 machine 角色,一次一个命名空间;reference 是调用方的幂等号;reason 超过 100 字截断,推荐时必填、订单时可省。不花钱、没有 confirm,但受紧急停机约束:它会推动别人去往机器里装货。
  • 平台不接受哪种形式就返回 400 namespace_unsupported(参考实现的机器平台只接受推荐)。[缺] 行程 place 到机器平台的路由与回执自动收货。

11. 扩展:审批 Approval#

能力:com.xiaopingfeng.vendling.approval(extends dev.ucp.shopping.checkout)

操作 方法 端点
列出 GET /approvals?status=pending&kind=
取一个 GET /approvals/{id}
批 / 驳 POST /approvals/{id} { "approved": true, "resolver"?: { "channel", "chat_id", "sender_id" } }
{ "id": "dec-1757404800000-1", "kind": "restock_plan", "at": "2026-09-09T04:00:00+08:00",
  "summary": "purchase order po_big: 1 line(s), ¥285.00 at acme-supply", "reasoning": "…",
  "subject": { "type": "checkout", "id": "po_big" }, "status": "pending", "approved": null }
  • kind ∈ swap | price_change | fault_flag | restock_plan | note。subject.type ∈ replenishment_run | checkout | price_change | slot。
  • 批准一个 checkout 让会话变成 ready_for_complete(提交仍是独立的显式调用);批准一个 price_change 立即执行改价。
  • 带 resolver 的请求必须来自已注册的管理员,否则 403;已处理过的 409。每次处理写事件。

12. 扩展:事件 Events 与订单 Webhook#

能力:com.xiaopingfeng.vendling.events

操作 方法 端点 说明
读历史 GET /events?limit=50&location=&kind= 最新在前;响应带 websocket_url
只要连接地址 GET /events/stream 不想先拉一页历史时单独要 websocket_url;跟上一行返回的是同一个地址
写事件 POST /events {kind, summary, reasoning, location?} 其他 Agent 的接入点;不去重
实时流 WebSocket websocket_url 连上先发 event-history(最近 50),之后每条 event
订单 Webhook(UCP 标准) POST 平台在自己档案里给的 webhook_url [缺]
{ "id": "evt-1757404812000-3", "occurred_at": "2026-09-09T13:00:12+08:00", "kind": "order",
  "summary": "order event: 2026090913001234 (交易成功) at 某写字楼11层 — 1 item(s), ¥6", "reasoning": "machine platform ledger poll", "location": "12345678" }

订单变化:智能柜的一笔订单会从 in_progress 走到 settled 或 payment_failed(§8.2)。首次看到报 order,之后每次 updated_at 变化再报 order_updated, summary 带新旧状态。[缺] 参考实现目前只按订单号去重、每笔只报一次。

kind 是开放字符串。occurred_at 是事情发生的时间,不是入库时间。机器平台没有 webhook 时,订单事件由本系统 每 5 分钟轮询流水、去重后追加;要对接 UCP 平台的 Webhook,只需在同一处把 §8.2 的 Order 实体 POST 到对方 URL(头带 Webhook-Id、Webhook-Timestamp)。


13. 护栏与安全#

操作 confirm: true 紧急停机 规则护栏 审批 记录
§7 采购下单 complete 必需 拒 spendingLimitPerRun、hardNoGos、试用期 超预算不再拦截,2026-09-16 起只警告+记录 事件
§9 改价 必需 拒 priceCapPerItem 超上限不再拦截,2026-09-16 起只警告+记录 事件
§10.2 行程下单 place — 拒 同采购 决策若真的 requiresApproval: true 仍会拦(见下);但补货预算超限本身 2026-09-16 起不再自动置位这个字段 决策 + 事件
§10.3 补货请求 binding: false — 拒 — — —
§10.3 补货请求 binding: true(或行程 place 路由到平台) — 拒 平台计价时同采购 同上 决策 + 事件
§6 名册增删 / 同步 — 不受影响 — — 事件
读操作 — 不受影响 — — —
  • 2026-09-16:四个阈值型护栏(结账超预算、改价超上限、货道锁定、硬性黑名单)不再自动把 requiresApproval 置成 true——越界的写照常执行,messages[] 带一条 warning,决策日志记一笔。requiresApproval: true 还会出现,但现在只来自系统自己主动调用的审批工具(一次真正拿不准的判断),不是某个数字越界的自动结果;那种情况下 §10.2/§10.3 的"决策必须已批"仍然成立。
  • 护栏失败关闭:读不到规则就拒绝(503 guard_unverifiable)。
  • 模拟数据与真实数据不混:模拟购买默认关闭;平板模拟器的遥测标 simulated = 1。
  • 顾客侧(扫码聊天界面)不在本规范内:它跑在另一个没有任何上游凭证的进程里,只能通过固定白名单的桥接读库存、写意图事件。
  • 敏感字段:成本、利润只对运营者可见;目录响应不含成本,采购单的 price 是采购价。

附录 A. 设备与供货方适配器契约#

Vendling 不直接依赖任何厂商接口。每个上游通过一个适配器接入,实现下面两个角色之一或两者; 系统其余部分只见到这里的形状。Vendling 自带一个参考实现(一个机器平台 + 一个供货方),其厂商细节不在公开文档中。

A.1 通用约定#

  • 金额一律整数分;时间给 epoch ms(解析不了给 null,由调用方决定丢弃还是近似)。
  • 上游出错返回而不是抛出:{ ok: false, reason, unauthorized?, raw? }。鉴权失败必须可辨认(unauthorized: true),不能看起来像空结果。
  • 幂等:createOrder(ref, …) 的 ref 是调用方给的采购单号,重复调用不得产生第二张单。
  • 适配器只认自己的命名空间;上游的分页、时区、签名全部在适配器内部消化。

A.2 角色 supply(命名空间 <vendor>-supply)#

方法 输入 输出
catalog({keyword?}) 可选关键词 { products: SupplyProduct[], sites: SupplySite[] }
createOrder(ref, lines, fulfillment) lines: [{vendorSku, quantity, unit: "each" \| "pack"}];fulfillment: {method:"shipping", contactName, contactPhone, address} \| {method:"pickup", pickupAt, siteId?} \| {method:"restock", locationId} { externalRef } 供货方订单号
orderStatus(ref) 采购单号 { state: "ordered" \| "arrived" \| "cancelled", description, rawStatus, logistics[] }
SupplyProduct { vendorSku, title, spec?, category?, imageUrl?, packSize, packPriceFen, eachPriceFen, eachPriceDerived, stock }
SupplySite    { id, name?, address? }

packSize = 1 表示只按个卖(目录里不会出现 BX)。只拿得到箱价时 eachPriceFen = round(packPriceFen / packSize) 且 eachPriceDerived = true。

method: "restock" 表示供货方自己把货装进机器(既供货又负责入柜的平台):目的地是机器编号而不是地址或自提点,到货即 arrived。[缺]

A.3 角色 machine(命名空间 <vendor>-machine)#

方法 输入 输出 必需
inventory(locationId) 机器编号 MachineItem[] 是
ledger({fromMs, toMs, by?, page?, size?, settledOnly?}) 时间窗(账户级);by 取 "taken"(缺省)或 "updated" { records: LedgerRecord[], page, pages, total } 是
updatePrices(locationId, [{vendorSku, priceFen}]) 真实改价 { count } 否,能力 pricing
restock(locationId, ref, [{vendorSku, quantity, reason?}], {binding}) binding: false 是给平台运维的提示;true 是有约束力的补货订单,平台执行并回执;ref 幂等。不接受的形式返回 { ok: false, reason: "unsupported…" } { count, externalRef? } 否,能力 replenishment.recommend / replenishment.order
restockStatus(ref) 补货单号 { state: "received" \| "in_transit" \| "completed" \| "cancelled", delivered: [{vendorSku, quantity}], completedAt: ms\|null, rawStatus } 随 binding: true
MachineItem  { vendorSku, title, barcode?, priceFen, stock, imageUrl?, slotId? }
LedgerRecord { orderNo, status, statusLabel?, state?, locationId, locationName?, totalFen, createdAt: ms|null, createdAtRaw,
               takenAt?: ms|null, settledAt?: ms|null, updatedAt?: ms|null, finalized?: boolean, lines: LedgerLine[] }
LedgerLine   { vendorSku, priceFen, costFen: number|null, status: "paid"|"unpaid"|"refunded"|"refund_failed"|"cancelled" }
  • status / statusLabel 是上游原始状态;state 是归一后的六态之一(§8.2:in_progress | pending_review | settled | payment_failed | refunded | cancelled)。 不给 state 时由行状态推导:全部 paid 视为 settled。
  • slotId 是机器内的位编号(弹簧机的货道号、智能柜的层或层加位);不给时系统用"机器-商品"代替,一层多品的柜子必须给。
  • 四个时间与 finalized 的含义见 §8.2(同一套语义);即付即结的机器 createdAt = takenAt = settledAt、finalized = true。
  • by: "updated" 时按 updatedAt 取窗口,这是拿到晚结算、复核改写、结算后退款订单的唯一可靠方式;平台不支持时返回 { ok: false, reason: "unsupported" },调用方退回全量拉取。
  • costFen 只在上游给出一个不等于售价的成本时才有值;等于售价的"成本"没有信息量,必须置 null。
  • 流水是账户级的:适配器不做位置过滤,调用方按 locationId 分拣。
  • [缺] 同 §8.2 的那一条(state、四个时间、by),外加 slotId;restock / restockStatus 契约与 HTTP 适配器已实现,参考实现的机器平台只接受 binding: false。

A.4 注册与发现#

适配器登记为 { namespace, vendor, role, status: "live" | "planned", capabilities[] }。登记后 GET /ucp/v1/namespaces、 /.well-known/ucp 的 com.xiaopingfeng.vendling.sku.config.namespaces 自动带上;planned 的命名空间可以出现在 ID 里、 参与别名,但任何调用返回 namespace_unsupported。

A.5 我的设备还没有适配器?#

按 A.2 / A.3 实现对应角色即可,其余接口(目录、结账、订单、位置、改价、补货、审批、事件)不需要改动。 一台没有云平台的本地控制售货机,通常只需实现 machine 角色的 inventory 与 ledger(由本地网关维护), updatePrices、restock 可以返回 { ok: false, reason: "unsupported" },对应能力就不会出现在它的 capabilities[] 里。不想写代码的看 A.6。

A.6 零代码接入:HTTP 适配器 [有]#

不想在 Vendling 的代码里写适配器?把 A.2 / A.3 的方法按下面的形状暴露成 HTTPS 接口,把 base URL 和一个 Bearer token 交给运营者登记, 命名空间立刻出现在 GET /namespaces 里,不需要发版。字段名、单位、错误约定与 A.1–A.3 完全一致,只是搬到了 HTTP 上。

角色 方法 端点 请求体 响应(ok: true 之外的字段)
machine 库存 GET {base}/inventory?locationId= — items: MachineItem[]
machine 流水 GET {base}/ledger?fromMs=&toMs=&page=&size=&settledOnly=&by= — records: LedgerRecord[], page, pages, total
machine 改价 POST {base}/prices {locationId, lines:[{vendorSku, priceFen}]} count
machine 补货请求 POST {base}/restock {locationId, ref, binding, lines:[{vendorSku, quantity, reason?}]} count, externalRef?
machine 补货回执 GET {base}/restock/{ref} — state, delivered[], completedAt, rawStatus
supply 目录 GET {base}/catalog?keyword= — products: SupplyProduct[], sites: SupplySite[]
supply 下单 POST {base}/orders {ref, lines:[{vendorSku, quantity, unit}], fulfillment} externalRef
supply 单状态 GET {base}/orders/{ref} — state, description, rawStatus, logistics[]
  • 鉴权:每个请求带 Authorization: Bearer <token>;401 / 403 会被记为鉴权失败,永远不会被当成空机器。
  • 出错:任意状态码下返回 { "ok": false, "reason": "…" }。不支持的方法直接不实现,登记时不声明对应能力即可。
  • 最小实现:machine 只做库存和流水两个 GET;supply 做目录、下单、单状态三个。
  • 登记格式:[{ "namespace": "acme-machine", "baseUrl": "https://api.acme.example/vendling", "token": "…", "capabilities": ["pricing", "replenishment.order"] }]; capabilities 只列可选扩展(pricing、replenishment.recommend、replenishment.order),角色的基础能力自动带上;内置命名空间不能被覆盖。
  • "status": "planned" 占住一个命名空间但不提供适配器:GET /namespaces 里看得到,调用它得到 namespace_unsupported("登记了,还没上线")而不是 invalid("没听说过")。这种条目不需要 baseUrl。
  • "sharesSkuSpaceWith": "acme-machine" 声明同一厂商的两个角色共用一套商品编号(§5.4): 写在任一侧即可,关系是对称的。只在同一厂商的另一个角色之间成立,跨厂商或指向同一角色的声明会被忽略并打日志。 既运营机器又卖货的平台常常如此,而这类平台的批发接口往往不给条码——不声明的话,机器里的商品 在采购侧一个也对应不上,补货计划算得出来却一件都买不到。
  • 运营者侧另有 VENDLING_DEFAULT_MACHINE_NS / VENDLING_DEFAULT_SUPPLY_NS 指定线路自身状态与采购 默认走哪个命名空间。不设就取该角色第一个 live 的——同角色接入第二家时务必显式设定, 否则调换登记数组的顺序就会静默换掉整条线路历史数据的来源。
  • 超时 15 秒;金额整数分、时间 epoch ms,与 A.1 一致。
  • 配套 skill:vendling-vendor-adapter —— 字段对照表、只读一致性检查脚本(跑完直接打印登记 JSON)、全部八个端点的参考实现。

附录 B. 能力 × 设备类型矩阵#

页面顶部的"我是谁"选择器按这张表折叠不相关的章节。运营者无论哪种设备,先接 §2.1 的最小接入集;厂商和供应商看 §2.2。

章节 我运营机器(machine) 我有供货 / 采购渠道(supply) 我要接入新设备或供货方(adapter) 我是售货机厂商(vendor-machine) 我是商品供应商(vendor-supply)
§2.1 最小接入集、§2.2 合作伙伴视角 ✓ ✓ ✓ ✓ ✓
§3 通用约定、§4 发现档案 ✓ ✓ ✓ ✓ ✓
§5.1 供货方 SKU 清单 ✓ ✓
§5.2 机器库存 ✓ ✓
§5.3 Lookup ✓ ✓
§5.4 命名空间与 SKU 注册表 ✓ ✓ ✓ ✓ ✓
§6 位置 ✓ ✓
§7 结账 ✓ ✓
§8.1 采购单 ✓ ✓
§8.2 机器交易 ✓ ✓
§9 定价 ✓ ✓
§10.1 补货计划、§10.2 行程 ✓ ✓
§10.3 补货推荐 / 补货订单 ✓ ✓ ✓
§11 审批、§12 事件、§13 护栏 ✓ ✓ ✓ ✓ ✓
附录 A.1 / A.4 / A.5 ✓ ✓ ✓ ✓ ✓
附录 A.2 supply 契约 ✓ ✓ ✓ ✓
附录 A.3 machine 契约 ✓ ✓ ✓ ✓
附录 A.6 HTTP 适配器 ✓ ✓ ✓ ✓ ✓
附录 C 时序图 ✓ ✓ ✓ ✓ ✓

附录 C. 关键场景时序图#

六个场景按当前定义的接口画,标 [缺] 的步骤规范已定义、参考实现尚未提供。图随"我是谁"选择器折叠;Mermaid 源码在 llms-full.txt 里原样保留,Agent 可以直接读。

C.1 首次接入与发现#

sequenceDiagram
  autonumber
  participant A as 外部 Agent
  participant V as Vendling /ucp/v1
  participant M as 机器适配器 (acme-machine)
  A->>V: GET /.well-known/ucp(无 token)
  V-->>A: 版本、能力、namespaces 与 defaults
  A->>V: GET /namespaces
  V-->>A: acme-machine / acme-supply,live 或 planned
  A->>V: POST /locations/search {}
  V-->>A: locations[](机器即门店)
  A->>V: POST /catalog/search {namespace: acme-machine, location}
  V->>M: inventory(locationId)
  M-->>V: MachineItem[]
  V-->>A: products[],variants 带 price、inventory、aliases

C.2 每小时同步与订单事件#

sequenceDiagram
  autonumber
  participant S as 定时任务
  participant V as Vendling
  participant M as 机器适配器
  participant P as 机器平台
  participant E as 事件流 / 群聊
  loop 每小时同步
    S->>V: POST /locations/sync(等价)
    V->>M: inventory(locationId),每台机器一次
    M->>P: 查库存
    V->>M: ledger({fromMs, toMs, settledOnly: true})
    M->>P: 查流水
    V->>V: 重建 machines / products / slots / sales,保留 rules、锁定、决策
  end
  loop 每 5 分钟轮询订单
    V->>M: ledger(最近 3 小时)
    M-->>V: LedgerRecord[]
    V->>V: 按订单号去重
    V->>E: event kind=order
  end
  Note over V,P: 智能柜要按 updated_at 拉取并对 (id, updated_at) 去重,状态变化再报 order_updated [缺]

C.3 采购下单(花真钱)#

sequenceDiagram
  autonumber
  participant A as Agent
  participant V as Vendling
  participant S as 供货适配器 (acme-supply)
  participant W as 供货方
  A->>V: POST /catalog/search {namespace: acme-supply, query}
  V-->>A: variants 带 sale_units(EA / BX)
  A->>V: GET /skus/{机器 sku}
  V-->>A: 可采购 sku_id(仅 barcode / manual 别名)
  A->>V: POST /checkout-sessions {line_items, fulfillment}
  V->>V: 读 rules(读不到则 503)、hardNoGos(命中则 incomplete,唯一还会拦下这一步的检查)
  Note over V: 2026-09-16 起 spendingLimitPerRun/试用期超限不再拦截——加一条 over_budget warning,写一条决策日志
  V-->>A: ready_for_complete
  A->>V: POST /checkout-sessions/{id}/complete {confirm: true}
  V->>V: 紧急停机则 409
  V->>S: createOrder(ref, lines, fulfillment)
  S->>W: 真实下单
  W-->>S: externalRef
  V-->>A: completed,order{id, label, permalink_url}
  V->>V: 写事件
  A->>V: GET /orders/{id}(之后轮询)
  V->>S: orderStatus(ref)
  S-->>V: ordered / arrived / cancelled,附物流

C.4 改价与审批#

sequenceDiagram
  autonumber
  participant A as Agent
  participant V as Vendling
  participant M as 机器适配器
  participant P as 机器平台
  A->>V: PUT /locations/{id}/prices {prices, confirm: true}
  V->>V: 校验 machine 命名空间、confirm 为布尔、紧急停机
  V->>M: inventory(locationId) 取现价
  V->>V: 新价与现价之差对比 priceCapPerItem
  Note over V: 2026-09-16 起超上限不再拦截——加一条 over_cap warning,写一条 price_change 决策日志
  V->>M: updatePrices(locationId, lines)
  M->>P: 真实改价
  V-->>A: updated[](超限时 messages[] 带 over_cap)
  V->>V: 写事件

C.5 补货闭环#

sequenceDiagram
  autonumber
  participant C as 每日循环
  participant V as Vendling
  participant O as 店主
  participant S as 供货适配器
  participant M as 机器适配器
  C->>V: 刷新批发成本(supply catalog)
  C->>V: GET /replenishment/plan(每货道日销、余量天数、建议)
  C->>V: POST /replenishment/runs
  V->>V: 预估成本对比 spendingLimitPerRun
  opt 超预算
    V->>O: restock_plan 审批
    O->>V: 批准
  end
  V-->>C: run{status: approved}
  C->>V: POST /replenishment/runs/{id}/place
  alt fulfiller = supply
    V->>S: createOrder(§7 结账,跨供货方拆单)
  else fulfiller = machine_platform
    V->>M: restock(locationId, ref, lines, {binding: true}) [缺]
  else fulfiller = simulated(参考实现今天)
    V->>V: 交给模拟供货方
  end
  V-->>C: run{status: placed, placed_at}
  Note over V: 已下单未收货的货道在下一次计划中跳过
  M-->>V: 到货回执 restockStatus [缺]
  C->>V: POST /replenishment/runs/{id}/receive {delivered}
  V->>V: 库存增加(封顶容量),run{status: delivered}

C.6 智能柜订单生命周期#

sequenceDiagram
  autonumber
  participant U as 顾客
  participant P as 机器平台
  participant V as Vendling
  participant E as 事件流
  U->>P: 扫码 / 刷脸授权
  P->>P: 订单创建,trade_status = in_progress
  U->>P: 开门取货,关门
  P->>P: taken_at = 关门时间,上传视频
  P->>P: 视觉识别
  opt 识别有疑问
    P->>P: pending_review,人工复核可改写商品与金额
  end
  P->>P: 免密扣款
  alt 扣款成功
    P->>P: settled,settled_at,finalized = true
  else 扣款失败
    P->>P: payment_failed(非终态,可追缴)
  end
  loop Vendling 每 5 分钟
    V->>P: ledger(updated_from = 上次轮询时间)[缺]
    P-->>V: 含中间态与终态的订单
    V->>V: 以 (id, updated_at) 去重
    V->>E: 首见报 order,状态变化报 order_updated
  end
  Note over V: 只有 settled 且 paid 的行进入销售统计,结算后退款以 refund 调整项表示

附录 D. 变更记录#

日期 变化
2026-09-11 加 §0 索引(端点 → 章节、概念 → 唯一出处、文档面 → 受众);统一能力名的三种写法(§5.4);消除指南内部与 skill 之间的重复陈述;scripts/check_docs.py 把九项一致性检查纳入 CI。
2026-09-10 v2 精简:删除 POST /catalog/product、POST /locations/lookup、POST /skus/resolve(POST /catalog/lookup 与 GET /skus/{id} 已覆盖);/locations/{id}/restock-recommendations 改为 /locations/{id}/restock 加 binding;A.3 的三个补货方法并成 restock + restockStatus;新增 A.6 零代码 HTTP 适配器;发现档案不再声明 location.lookup;§2.1 加五步接入、§2.3 加接入流程。
2026-09-10 六态订单模型、补货订单、附录 C 时序图、合作伙伴视角、核心接口标记。
2026-09-09 首版:UCP 对齐的目录 / 结账 / 订单 / 位置 / 定价 / 补货 / 审批 / 事件,厂商无关的适配器契约。

Agent 一键接入#

把下面这句话发给你的 Agent(Claude Code、Codex、Cursor、Windsurf、OpenCode 等),它会自己装好 skill、配好 token、验证连接:

Fetch and execute the appropriate instructions to set me up for Vendling from https://vendling.dev/api/agent-setup/prompt.md

做法参考 Cloudflare 的 agent setup:一个 Agent 可读的 prompt.md,写明要执行的命令、要向用户索要的值(只有 operator token)、验证步骤和完成后的汇报格式。

Vendling · 文档源码 vendling-api · vendling.dev · fxp007 · 2026