# 集合与记录

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

Source: https://developers.backresto.com/zh/docs/collections

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

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

## 你能读到什么

```http
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
```

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

```json
{
  "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` 不是错误，也不是故障：它就是一把没有任何集合授权的密钥。在这套
接口出现之前签发的大多数密钥正是这样，而扩大一把密钥是[一封邮件](https://developers.backresto.com/zh/docs/access)
的事。

## 信封

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

| 字段 | 类型 | 含义 |
| --- | --- | --- |
| `id` | string | 这条记录的标识符，稳定，且在该集合内唯一。 |
| `restaurantId` | string | 记录所属的餐厅。与路径中的一致。 |
| `collection` | string | 它来自哪个集合。与路径中的一致。 |
| `data` | object \| 缺席 | 记录本身。只有墓碑（tombstone）这一种情形它才可能缺席，所以其他任何地方都可以当它一定在。 |
| `deleted` | boolean | `true` 表示这是一条 tombstone：这条记录在应用里被删除了。 |
| `capturedAt` | string | 设备记录下这次改动的时间，以字符串表示的 Unix 毫秒时间戳。 |
| `receivedAt` | string | 这个 API 收到它的时间。晚于 `capturedAt`，有时晚几个小时。 |
| `sequence` | string | 在变更流中单调递增的位置。拿两个来比较；不要拿一个去做算术。 |
| `mutationId` | string | 产生这个状态的那次改动的标识符。在我们这边是幂等键，在你那边是去重键。 |
| `sourceVersion` | string \| null | 源记录的版本戳，在它有版本戳的时候。 |
| `payloadVersion` | number | 目前始终是 `1`。如果 `data` 的含义哪天变了，它会加一。 |
| `stateKind` | string | 始终是 `received-shadow-snapshot`。请读下一节。 |

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

## 快照是什么，不是什么

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

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

所以一条缺席的记录确实是含糊的：可能从未被记录，也可能记录了但还没到。不要在它
之上建一个读起来像审计的数字，并且在把一个计数报给任何人之前，先看
[关于完整性的说明](https://developers.backresto.com/zh/docs/support)。

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-pictures` | `restaurants`, `users`, `areas`, `equipment`, `sensors`, `cooking-equipment`, `suppliers`, `products`, `preparations`, `cleaning-tasks`, `fryer-equipment`, `drive-files` |

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

## 这二十六个集合

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

| 页面 | 集合 |
| --- | --- |
| [温度](https://developers.backresto.com/zh/docs/temperatures) | `temperature-records`、`temperature-readings`、`cooking-temperature-records` |
| [收货记录](https://developers.backresto.com/zh/docs/deliveries) | `deliveries`、`delivery-pictures` |
| [追溯标签](https://developers.backresto.com/zh/docs/traceability-labels) | `traceability-labels` |
| [清洁](https://developers.backresto.com/zh/docs/cleaning) | `cleaning-tasks`、`cleaning-task-records`、`cleaning-task-pictures` |
| [炸锅](https://developers.backresto.com/zh/docs/fryers) | `fryer-equipment`、`fryer-checks` |
| [冷却与食品处理流程](https://developers.backresto.com/zh/docs/food-processes) | `cooling`、`freezing`、`reheating`、`transport` |
| [表面采样](https://developers.backresto.com/zh/docs/surface-analyses) | `surface-analyses` |
| [文档](https://developers.backresto.com/zh/docs/drive-files) | `drive-files` |
| [基础目录](https://developers.backresto.com/zh/docs/catalogue) | `suppliers`、`products`、`preparations` |
| [场所与人员](https://developers.backresto.com/zh/docs/facilities) | `restaurants`、`users`、`areas`、`equipment`、`sensors`、`cooking-equipment` |

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

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

[OpenAPI 文档](https://api.backresto.com/openapi.json)承载着同样的结构，它是从正在
运行的服务生成的。当这里的某一页和那份文档说法不一致时，是文档对，而我们有一页
要修。

## 走完一个集合

```http
GET /v1/restaurants/{restaurantId}/collections/{collection}/records?limit=100
```

| 参数 | 必填 | 规则 |
| --- | --- | --- |
| `limit` | 否 | 1 到 100。默认 50。 |
| `cursor` | 否 | 上一页的 `nextCursor`，一字不改地传回。 |

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

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

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

```js
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` 走完一整个集合
  不过是几个请求，而[额度](https://developers.backresto.com/zh/docs/rate-limits)是每分钟 300 个。但几百家餐厅挤在同
  一个 cron 分钟上，那道尖峰是你自己造出来的。

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

## 附件

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

```http
GET /v1/restaurants/{restaurantId}/collections/{collection}/records/{recordId}/assets
```

```json
{
  "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 文档](https://developers.backresto.com/zh/docs/errors)。
