集合与记录

收货记录之外的二十四种记录类型——它们共用的那个快照信封、快照是什么,以及如何走完一个集合。

收货记录是一个精心打磨过的资源,由人手工塑形,因为收货是最早 有人提出需求的那件事。集合则是这份记录的其余部分:温度、冷却循环、清洁、炸锅 检查、标签,一共二十四个,通过同一种通用结构提供。

收货记录端点给你的是一个设计过的对象,而一个集合给你的是应用里就那样存着的那条 记录,外面裹着一个信封,告诉你它是什么时候被采集的、以及它是否还存在。这个取舍 是有意的:正是它让一个新模块能在上线的那一周就接到这个 API 上,而不是等到下个 季度。

每个集合都是一份单独的授权。一把密钥能读 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 | 缺席记录本身。只有墓碑这一种情形它才可能缺席,所以其他任何地方都可以当它一定在。
deletedbooleantrue 表示这是一块墓碑:这条记录在应用里被删除了。
capturedAtstring设备记录下这次改动的时间,以字符串表示的 Unix 毫秒时间戳。
receivedAtstring这个 API 收到它的时间。晚于 capturedAt,有时晚几个小时。
sequencestring在变更流中单调递增的位置。拿两个来比较;不要拿一个去做算术。
mutationIdstring产生这个状态的那次改动的标识符。在我们这边是幂等键,在你那边是去重键。
sourceVersionstring | null源记录的版本戳,在它有版本戳的时候。
payloadVersionnumber目前始终是 1。如果 data 的含义哪天变了,它会加一。
stateKindstring始终是 received-shadow-snapshot。请读下一节。

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

快照是什么,不是什么

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

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

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

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

这二十四个集合

这里面的每一个都以 <collection>:read 作为自己的权限范围——cooling 需要 cooling:read,整张表依此类推。

集合一条记录是什么
restaurants门店本身:名称、地址、休息日、订阅与设置。
users记录查验的那些员工账号,以及他们使用哪些模块。
areas一家餐厅被划分成的那些区域——厨房、冷库、吧台。
equipment冰箱、冷冻柜和冷链设备,带着各自的最低/最高阈值。
sensors无线探头,按 MAC 地址标识,以及每个探头看着的是哪台设备。
temperature-records由人在某台设备上测得的一个温度,带着班次和任何纠正措施。
temperature-readings由传感器自己上报的一个温度,无人值守。
suppliers谁来送货,带着联系方式和账户编号。
products收到并使用的产品。
preparations自制的备料,带着保质期和过敏原。
cleaning-tasks清洁计划:每一项任务、它所在的区域、它的周期,以及它是否要求拍照。
cleaning-task-records一项确实做过的清洁任务——什么时候做的,谁做的。
cleaning-task-pictures证明它做过的那张照片。带文件;见下文的附件一节。
cooling一次冷却循环:产品、起止温度、起止时间。
freezing一次冷冻操作,结构与冷却相同。
reheating一次复热操作,结构与冷却相同。
transport一件被运输的产品,带着出发和到达的地点、时间与温度。
fryer-equipment那些炸锅。
fryer-checks一次油品质量检查,以及当时的决定——过滤、更换、留着。
cooking-equipment烤箱和加热设备,带着各自的阈值。
cooking-temperature-records一个加热温度,由人在一台加热设备上测得。
surface-analyses一次表面采样:测的是什么、是否通过,不通过时的行动计划。
traceability-labels一张打印出来的追溯标签。带文件;见下文的附件一节。
drive-files归档在餐厅云盘里的一份文档。带文件;见下文的附件一节。

每一个 data 对象逐字段的结构都在 OpenAPI 文档里,它是从正在运行的服务生成 的——这是唯一一份不可能与已部署内容脱节的描述。

走完一个集合

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

这里没有时间区间,和收货记录不一样:你走完的是一个集合,而不是 它的一段窗口。

一次遍历就是一份一致的快照。 第一页把这条变更流的位置钉住,之后的每一页都按 同一个位置作答。你在翻页期间写入的记录不会让页在你脚下移位,也不会在遍历中途冒 出来——你会在下一次遍历时看到它们。排序按 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 高于你存下的那个时就覆盖。
  • 尊重墓碑。 deleted: true 就是那条删除指令;照做是让你的副本不再越跑越偏的 唯一办法。
  • 挑个像样的时间点去遍历。 对单独一家餐厅来说,用 limit=100 走完一整个集合 不过是几个请求,而额度是每分钟 300 个——但几百家餐厅挤在同 一个 cron 分钟上,那道尖峰是你自己造出来的。

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

附件

有三个集合带文件:cleaning-task-picturestraceability-labelsdrive-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,每个有效 十五分钟,每个都自带授权,所以取回字节内容不需要任何请求头。有两点差别值得 知道。

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

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

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

其余的建议一模一样,而且值得再说一遍,因为这种失败是悄无声息的:现在就把字节取 下来,存字节而不要存链接,需要一个新链接的时候再调一次这个端点。

你会遇到的那些失败

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

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

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

这三种都是带 requestIdproblem 文档

最后更新于 2026-09-19。