集合与记录
收货记录之外的二十四种记录类型——它们共用的那个快照信封、快照是什么,以及如何走完一个集合。
收货记录是一个精心打磨过的资源,由人手工塑形,因为收货是最早 有人提出需求的那件事。集合则是这份记录的其余部分:温度、冷却循环、清洁、炸锅 检查、标签,一共二十四个,通过同一种通用结构提供。
收货记录端点给你的是一个设计过的对象,而一个集合给你的是应用里就那样存着的那条 记录,外面裹着一个信封,告诉你它是什么时候被采集的、以及它是否还存在。这个取舍 是有意的:正是它让一个新模块能在上线的那一周就接到这个 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 的结构会变。
| 字段 | 类型 | 含义 |
|---|---|---|
id | string | 这条记录的标识符,稳定,且在该集合内唯一。 |
restaurantId | string | 记录所属的餐厅。与路径中的一致。 |
collection | string | 它来自哪个集合。与路径中的一致。 |
data | object | 缺席 | 记录本身。只有墓碑这一种情形它才可能缺席,所以其他任何地方都可以当它一定在。 |
deleted | boolean | true 表示这是一块墓碑:这条记录在应用里被删除了。 |
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 从未见过那条记录。
- 它不是回填。 一个集合对某家餐厅而言,始于为它开启采集的那一天。在那之前创建 的记录在应用里,不在这里。
所以一条缺席的记录确实是含糊的:可能从未被记录,也可能记录了但还没到。不要在它 之上建一个读起来像审计的数字,并且在把一个计数报给任何人之前,先看 关于完整性的说明。
墓碑是缺席唯一不含糊的情形。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
| 参数 | 必填 | 规则 |
|---|---|---|
limit | 否 | 1 到 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-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,每个有效 十五分钟,每个都自带授权,所以取回字节内容不需要任何请求头。有两点差别值得 知道。
这些是文件,不只是图片。 一份清洁凭证是照片,但一张追溯标签或一个云盘文件可能
是 PDF、CSV、表格或 Word 文档——请读 contentType,而不要假定它是图片,也不要在
入库时把所有东西都重命名成 .jpg。
sha256 放在那里是为了让你省事。 它是这些字节的摘要:如果它和你已经存下的某个
东西对得上,你就不需要再下载一次。
只有上传已经完成并通过校验的附件才会出现——一个还在路上的附件是缺席,而不是坏掉, 父记录被删除之后同样如此。
其余的建议一模一样,而且值得再说一遍,因为这种失败是悄无声息的:现在就把字节取 下来,存字节而不要存链接,需要一个新链接的时候再调一次这个端点。
你会遇到的那些失败
403——你的密钥能触及这家餐厅,但没有带着那个集合的权限范围。集合端点是弄清
它究竟有哪些权限范围的廉价办法。
404——对这家餐厅根本没有任何授权,或者没有这条记录。两者被有意做成无法区分。
400——一个不在上面那二十四个之内的集合,或者一个不属于这家餐厅、这个集合和
这个 limit 的游标。
这三种都是带 requestId 的 problem 文档。