为什么编辑共享支出必须重述分摊
编辑的默认值就是资金错误,所以 Dimesum 在每次支出 PATCH 上都要求 participants 和 split_type,并用 base_version 拒绝过期的编辑而不是合并它。
修正一个错别字不应该改变谁欠谁的钱。在 Dimesum 中,编辑一笔共享支出会重述整个分摊。每一次 PATCH 都必须带上 participants 和 split_type,缺少任何一个的编辑都会返回 400,而不是替你填入默认值。创建时的默认值是当前所有成员并且平均分摊,这两个默认值放在编辑里就是资金错误。
两个故障可以把问题讲清楚。八月才加入的室友被拉进了七月的那顿晚餐,因为"当前所有成员"是在编辑落地时才计算的,而不是在这笔支出写入时。一个刻意设定的 70/30 房租分摊被压平成 50/50,因为缺少 split_type 就意味着 EQUAL。这两个故障都不会报错,而且都会挪动真实的钱。
编辑的默认值就是资金错误。创建时的默认值猜测的是作者此刻正在看的那个群组。编辑是稍后才到来的,面对的是一个已经变化的群组,而同样的猜测会悄悄改写大家各自欠的钱。
创建时的默认值描述的是一笔新支出,而不是一笔旧支出
在创建时,这些默认值是诚实的。作者看到的是群组当下的样子,而在所有人之间平均分摊是常见情形,因此 Dimesum 会把两者都填好。这笔支出保存的是它的输入,而不只是它的结果:splits.percent_bp、splits.weight 和 splits.exact_minor 都保留了用户输入的内容,因此稍后的编辑可以重新打开它。
编辑是另一回事。同一笔支出可能在几周之后才被更正,那时可能已经有新室友加入,或者某个占位成员已被认领。成员在不断变化,而分摊没有。重复使用创建时的默认值,等于让今天的群组去回答一个旧支出早已回答过的问题。
在这里,拒绝猜测并不是新做法。一笔有多个付款人的支出同样必须重述它的付款人。soleStoredPayer 只有在支出恰好只有一个付款人时才会复用已保存的付款人,因此一笔对两个付款人保持沉默的编辑会被拒绝,而不是被重新指派。一笔对付款人只字未提的编辑会保留这笔支出自己的付款人,而绝不会换成正在做编辑的那个人。
接口会拒绝一笔没有重述分摊的编辑
网关检查会在计算任何金额之前运行。当 participants 为空或 split_type 留空时,请求会带着错误码 invalid_expense 返回 400,并附上消息 "an edit must restate the split: participants and split_type are required"。支出服务在 validateAmend 中重复了同一条规则,因此通过另一条路径到达服务的调用方会遇到同样的拒绝。
| 字段 | 创建时 | 编辑时 | 默认值会带来什么代价 |
|---|---|---|---|
participants | 可选。默认为当前所有成员 | 必填 | 之后才加入的成员会被并入一笔旧支出 |
split_type | 可选。默认为 EQUAL | 必填 | 70/30 的分摊被压平成 50/50 |
payers | 可选。默认为作者 | 省略时保留这笔支出唯一的付款人;两个付款人必须重述 | 编辑者会变成付款人,颠倒谁欠谁 |
base_version | 不发送 | 必填,且必须等于当前版本 | 一笔过期的编辑会覆盖一处它作者从未看到的更改 |
revision_id | 客户端 UUIDv7,幂等键 | 相同,每个版本一个 | 一笔被重试的编辑会向群组收两次钱 |
currency | 按每笔支出声明 | 必须匹配;更改会被拒绝 | 一行余额为每个成员只保存一种货币 |
货币属于同一类拒绝。一笔编辑不能给支出重新计价,因为一行余额为每个成员只保存一种货币。写入端会拒绝这项更改,而仅追加账本会在这样的修订万一到达时把它搁置。在门口就拒绝,能让两边不至于互相矛盾。
一笔过期的编辑会退回给它的作者,绝不会被合并
base_version 是这份约定的另一半。每一次 PATCH 都会带上它作者读到的版本,而 checkTransition 会把它和事务刚刚锁定的那一行做比较。相等,编辑就在版本号加一处生效。不同,调用方就会拿到 HTTP 409,附带错误码 stale_version。
合并是个诱人的替代方案,但它是错的。对同一笔支出的两次编辑,是关于这张账单含义的两份完整陈述。把它们合并会产生第三份谁都没写过的陈述,其中的份额两位作者都不会认得。拒绝,则把这个冲突交回给唯一能够解决它的那个人。
幂等性和并发是被刻意分开的。修订插入在版本检查之前运行,因为一笔被重试的编辑带的是它最初读到的基础版本,而那个版本现在已经过期。一次重放必须被读作重放,而不是冲突,因此 revision_id 先作答,并返回已保存的结果。
响应里已经带上了下一次编辑所需要的东西
只有当客户端能廉价地拿到这些字段时,在 PATCH 上要求更多字段才算公平。每个支出响应都带有 version,因此刚刚写入一笔支出的客户端不需要第二次读取就能编辑它。创建响应、修订响应以及每一行列表都带有同一个字段。
对于并非刚刚写入这笔支出的客户端,GET /v1/groups/{id}/expenses/{id} 会返回版本、计算得到的份额,以及它们背后的输入。这些输入以 PATCH 接受的同样名称返回:participants、split_type、percents、weights、shares、items、pools。客户端读取一种结构,再把带着编辑的它提交回去,而不必为同一张账单在两套词汇之间来回转换。
这种对称性对那些无法被重建的分摊最为重要。一个 PERCENT 分摊或一张逐项列出的账单,无法仅凭它解析后的份额来重述,因为舍入已经被应用过了,而基点和明细行已经不在了。修订快照也会带上这些输入,因此历史表可以显示任何过去版本上逐项列了些什么。
现在就要求,因为一项要求无法在以后再加上
在第一天就要求一个字段,是一个关于未来而非关于今天的决定。以后放宽一个必填字段是向后兼容的:客户端本来就在发送它,而服务器开始接受不带它的请求。以后再加上一项要求,则会破坏每一个依赖旧默认值的客户端。
所以方向要趁早一次选定。Dimesum 在客户端数量还小到可以改动的时候,就在编辑上要求 participants、split_type 和 base_version。如果哪天找到了适用于编辑的安全默认值,这些字段就会变为可选,而任何已经发布的东西都不会停止工作。
让一笔编辑重述它的含义
默认值属于创建,在那里作者能看到自己正在认可的群组。在编辑上,同样的默认值是对一个此后已经变化的群组的猜测。你的下一步:打开你自己的读取接口,检查它是否以你的写入接口所接受的完全相同的字段名交回分摊输入。一个不得不在两者之间转换的客户端,终将会把其中之一转换错。
常见问题
编辑共享支出为什么要填 participants 和 split_type?
Dimesum 之所以要求这两个字段,是因为创建时的默认值对编辑来说是错的。创建时,Dimesum 默认取当前所有成员并平均分摊,这与作者当下看到的群组一致。编辑可能几周之后才到来,那时已经有人加入。重复使用那些默认值会把一个新室友拉进一顿旧晚餐,并把刻意设定的 70/30 房租分摊压平回 50/50,而且不会显示任何错误。
两个人同时编辑同一笔支出会怎么样?
第二笔编辑会被以 HTTP 409 和错误码 stale_version 拒绝。每一次 PATCH 都带上 base_version,也就是它作者读到的版本,修订路径会把它和被锁定的那一行做比较。不匹配就意味着这笔支出已经变动,因此编辑会退回给它的作者,让其针对现在能看到的版本重新应用。什么都不会被合并。
编辑支出会直接改动账本记录吗?
不会,在 Dimesum 中编辑支出从不更新账本记录。一笔编辑会在一个事务里记两条账:一条 EXPENSE_REVERSAL 逐条抵消旧版本,然后一条针对新版本的 EXPENSE 账。UPDATE 和 DELETE 已从账本自身的数据库角色上被收回,因此即使是有缺陷的代码也无法做出改动。你会看到一个"已编辑"标记和一张历史表。
编辑共享支出前需要再调一次接口吗?
不需要,一个刚刚写入这笔支出的客户端已经握有编辑所需要的版本。每个支出响应都带有 version,它就是下一次 PATCH 作为 base_version 发送的内容。没有写入这笔支出的客户端会调用 GET /v1/groups/{id}/expenses/{id},它会返回版本,以及以 PATCH 接受的同样字段名给出的分摊输入。
为什么现在就要求 participants 和 split_type 而不是以后再加?
在第一天就要求一个字段是可逆的,而以后再加则不是。以后放宽一个必填字段是向后兼容的:客户端本来就在发送它,而服务器开始接受不带它的请求。以后再加上一项要求,则会破坏每一个依赖旧默认值的客户端,而在一个资金接口里,这种破坏在有人的余额出错之前都是无声的。
热门文章
- 只增账本让分摊余额保持精确阅读约 6 分钟
- 多币种分账中的六个金额缺陷阅读约 6 分钟
- 结清账目:用更少的转账清理团体开销阅读约 6 分钟
- 如何分摊有一道菜没有共享的分项账单阅读约 6 分钟
- 合租房租分摊指南:如何和室友公平分房租阅读约 6 分钟