集合与记录
二十六种记录集合、它们共用的那个快照信封,以及如何走完一个集合。
一个集合就是一种记录类型: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 的结构会变。
| 字段 | 类型 | 含义 |
|---|---|---|
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 从未见过那条记录。
- 它不是回填。 一个集合对某家餐厅而言,始于为它开启采集的那一天。在那之前创建 的记录在应用里,不在这里。收货记录是唯一的例外:那两个 集合带着它们开通时应用里仍保有的历史。
所以一条缺席的记录确实是含糊的:可能从未被记录,也可能记录了但还没到。不要在它 之上建一个读起来像审计的数字,并且在把一个计数报给任何人之前,先看 关于完整性的说明。
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 天的数据以你的副本为准;需要用到我们归档的检查请通过
支持。
这二十六个集合
它们每一个都以 <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 文档。