集合与记录

二十六种记录集合、它们共用的那个快照信封,以及如何走完一个集合。

一个集合就是一种记录类型:temperature-records、cooling、 deliveries,以及另外二十三个。每一个都以同样的方式读取,走的是同样 那四个端点,而且每条记录回来时都装在同一个信封里:data 周围那些字段,它们说明 这条记录是什么时候被采集的、以及它是否还存在。

每个集合都是一份单独的授权。一把密钥能读 temperature-records,是因为有人授予了 temperature-records:read,此外不会附带任何别的东西。

你能读到什么

GET /v1/restaurants/{restaurantId}/collections
GET /v1/restaurants/{restaurantId}/collections/{collection}/records?limit=…&cursor=…
GET /v1/restaurants/{restaurantId}/collections/{collection}/records/{recordId}
GET /v1/restaurants/{restaurantId}/collections/{collection}/records/{recordId}/assets

先从第一个开始。它只列出你的密钥被授予的那些集合,每个都带上打开它的那个权限 范围,这样你永远不必去猜:

{
  "data": [
    {
      "collection": "temperature-records",
      "readScope": "temperature-records:read",
      "description": "Received shadow snapshots for temperature-records; not complete primary-store history.",
      "stateKind": "received-shadow-snapshot",
      "payloadVersion": 1
    }
  ]
}

一个空的 data 不是错误,也不是故障:它就是一把没有任何集合授权的密钥。在这套 接口出现之前签发的大多数密钥正是这样,而扩大一把密钥是一封邮件 的事。

信封

每个集合里的每一条记录都携带同样的外层字段。只有 data 的结构会变。

字段类型含义
idstring这条记录的标识符,稳定,且在该集合内唯一。
restaurantIdstring记录所属的餐厅。与路径中的一致。
collectionstring它来自哪个集合。与路径中的一致。
dataobject | 缺席记录本身。只有墓碑(tombstone)这一种情形它才可能缺席,所以其他任何地方都可以当它一定在。
deletedbooleantrue 表示这是一条 tombstone:这条记录在应用里被删除了。
capturedAtstring设备记录下这次改动的时间,以字符串表示的 Unix 毫秒时间戳。
receivedAtstring这个 API 收到它的时间。晚于 capturedAt,有时晚几个小时。
sequencestring在变更流中单调递增的位置。拿两个来比较;不要拿一个去做算术。
mutationIdstring产生这个状态的那次改动的标识符。在我们这边是幂等键,在你那边是去重键。
sourceVersionstring | null源记录的版本戳,在它有版本戳的时候。
payloadVersionnumber目前始终是 1。如果 data 的含义哪天变了,它会加一。
stateKindstring始终是 received-shadow-snapshot。请读下一节。

capturedAt 和 receivedAt 都要紧。一台放在冷库里、没有信号的平板在 09:00 记录, 到 14:00 才上传;用 receivedAt 给你自己的流水线排序,能让它保持正确,而用 capturedAt 做报表,能让它保持真实。

快照是什么,不是什么

stateKind 写的是 received-shadow-snapshot,这个措辞是斟酌过的。

  • 它是一条记录的当前状态,不是每一次编辑的日志。把一条记录读两遍,两次拿到的 都是它此刻的样子。
  • 它是我们收到的东西,不是餐厅持有的东西。一台从未上传过某次改动的设备,意味 着这个 API 从未见过那条记录。
  • 它不是回填。 一个集合对某家餐厅而言,始于为它开启采集的那一天。在那之前创建 的记录在应用里,不在这里。收货记录是唯一的例外:那两个 集合带着它们开通时应用里仍保有的历史。

所以一条缺席的记录确实是含糊的:可能从未被记录,也可能记录了但还没到。不要在它 之上建一个读起来像审计的数字,并且在把一个计数报给任何人之前,先看 关于完整性的说明。

tombstone 是缺席唯一不含糊的那种情形。deleted: true 且没有 data,意味着这条 记录曾经存在并且被删除了,而这也是你得知某个东西消失了的唯一途径。

记录在这里保留多久

业务记录在这里保留 90 天,从它在厨房里发生的时间(data.timestamp)算起, 而不是从我们收到它的时间算起。之后,每晚的任务会把它们移入一个锁定的冷归档, 保存五年,它们也不再出现在任何读取中:列表、单条记录以及它们的附件。

保留 90 天在账户存续期间一直保留
temperature-readings, temperature-records, cooking-temperature-records, cleaning-task-records, cleaning-task-pictures, cooling, freezing, reheating, transport, fryer-checks, surface-analyses, traceability-labels, deliveries, delivery-picturesrestaurants, users, areas, equipment, sensors, cooking-equipment, suppliers, products, preparations, cleaning-tasks, fryer-equipment, drive-files

被归档的记录不是 tombstone,它也是上面 tombstone 规则的唯一例外。 它离开时没有 deleted: true,因为没有人删除它: 它只是被移走了。如果你保存了一份副本,请保留你已有的内容,永远不要把一次遍历中 缺失的记录当作删除。超过 90 天的数据以你的副本为准;需要用到我们归档的检查请通过 支持。

这二十六个集合

它们每一个都以 <collection>:read 作为自己的权限范围:cooling 需要 cooling:read,依此类推。它们按领域、逐个字段地记录在下面这些页面上。

页面集合
温度temperature-records、temperature-readings、cooking-temperature-records
收货记录deliveries、delivery-pictures
追溯标签traceability-labels
清洁cleaning-tasks、cleaning-task-records、cleaning-task-pictures
炸锅fryer-equipment、fryer-checks
冷却与食品处理流程cooling、freezing、reheating、transport
表面采样surface-analyses
文档drive-files
基础目录suppliers、products、preparations
场所与人员restaurants、users、areas、equipment、sensors、cooking-equipment

收货记录里的温度始终是摄氏度:以华氏度取得的读数会在到达时被换算,每一个 temperatureRecords[].unit 写的都是 "C"。

如果你要读其中任何一页,请先从场所与人员开始:正是设备的阈值和那些区域,把一 个光秃秃的测量值变成某种你能据以判断的东西。

OpenAPI 文档承载着同样的结构,它是从正在 运行的服务生成的。当这里的某一页和那份文档说法不一致时,是文档对,而我们有一页 要修。

走完一个集合

GET /v1/restaurants/{restaurantId}/collections/{collection}/records?limit=100
参数必填规则
limit否1 到 100。默认 50。
cursor否上一页的 nextCursor,一字不改地传回。

这里没有时间区间:你走完的是一个集合,而不是它的一段窗口。要读一周的收货记录,就 遍历 deliveries,留下 data.timestamp 落在那一周里的记录。

一次遍历就是一份一致的快照。 第一页把这条变更流的位置钉住,之后的每一页都按 同一个位置作答。你在翻页期间写入的记录不会让页在你脚下移位,也不会在遍历中途冒 出来。你会在下一次遍历时看到它们。排序按 sequence 升序。

游标绑定到餐厅、集合以及那个 limit。在一次遍历中途更改每页大小会被拒绝:选定 一个 limit,整趟遍历都别改。

async function* records({ apiKey, restaurantId, collection }) {
  const base = `https://api.backresto.com/v1/restaurants/${restaurantId}/collections/${collection}/records`;
  let cursor = null;

  do {
    const url = new URL(base);
    url.searchParams.set('limit', '100');
    if (cursor !== null) {
      url.searchParams.set('cursor', cursor);
    }

    const response = await fetch(url, {
      headers: { Authorization: `Bearer ${apiKey}` }
    });
    if (!response.ok) {
      throw new Error(`BackResto ${response.status}`);
    }

    const page = await response.json();
    yield* page.data;
    cursor = page.nextCursor;
  } while (cursor !== null);
}

让一份副本保持最新

今天还没有 since 参数。一次刷新就是又一次遍历,然后你拿它和已经持有的数据对账:

  • 在一个集合内以 id 为键,当收到的 sequence 高于你存下的那个时就覆盖。
  • 认真对待 tombstone。 deleted: true 就是那条删除指令;照做是让你的副本不再 越跑越偏的唯一办法。
  • 挑个像样的时间点去遍历。 对单独一家餐厅来说,用 limit=100 走完一整个集合 不过是几个请求,而额度是每分钟 300 个。但几百家餐厅挤在同 一个 cron 分钟上,那道尖峰是你自己造出来的。

如果一个增量游标会改变你能做出来的东西,就说出来。对一条本来就有序的流来说,这是 一个很小的改动。它之所以还不存在,只是因为还没有人需要它。

附件

有四个集合带文件:cleaning-task-pictures、delivery-pictures、traceability-labels 和 drive-files。 记录在 data.asset 里点出它,字节内容则来自 assets 端点。

GET /v1/restaurants/{restaurantId}/collections/{collection}/records/{recordId}/assets
{
  "data": [
    {
      "id": "a17c93be4f02",
      "contentType": "application/pdf",
      "byteLength": 184320,
      "sha256": "9f2a…",
      "uploadedAt": "1789000000000",
      "url": "https://…?X-Amz-Signature=…",
      "urlExpiresAt": "1789000900000"
    }
  ]
}

它返回一组签名 URL,每个有效十五分钟,而且同一次响应里的每个 URL 共享同一个 过期时间。每个都自带授权,所以下载不需要 Authorization 请求头,这也意味着这个 链接本身就是一份持有即可用的凭据:在这十五分钟里,任何拿到它的人都能读那个文件。 有两点值得知道。

这些是文件,不只是图片。 一份清洁凭证或一张收货照片是图片,但一张追溯标签或一个云盘文件可能 是 PDF、CSV、表格或 Word 文档。请读 contentType,而不要假定它是图片,也不要在 入库时把所有东西都重命名成 .jpg。

sha256 放在那里是为了让你省事。 它是这些字节的摘要:如果它和你已经存下的某个 东西对得上,你就不需要再下载一次。

只有上传已经完成并通过校验的附件才会出现:一个还在路上的附件是缺席,而不是坏掉, 父记录被删除之后同样如此。

其余的建议值得再说一遍,因为这种失败是悄无声息的:现在就把字节取下来,存字节而不 要存链接,绝不要把 URL 放到它的用途结束之后还留着的地方(一封邮件、一张工单、一个 长期缓存),需要一个新 URL 的时候再调一次这个端点。它很便宜,而且会重新检查授权。

你会遇到的那些失败

403:你的密钥能触及这家餐厅,但没有带着那个集合的权限范围。集合端点是弄清 它究竟有哪些权限范围的廉价办法。

404:对这家餐厅根本没有任何授权,或者没有这条记录。两者被有意做成无法区分。

400:一个不在上面那二十六个之内的集合,或者一个不属于这家餐厅、这个集合和 这个 limit 的游标。

这三种都是带 requestId 的 problem 文档。

最后更新于 2026-09-29。