上篇写「小家」时,我想解决的问题很具体:两个人过日子,不要再靠翻聊天记录找购物清单、对账和记日子。
于是有了共享清单、记账、纪念日和日程,也有了它们背后的空间权限、事件表、乐观更新和农历换算。
但一个功能出现在页面上,和它在各种操作之后都还能说得通,是两回事。
比如,动态里明明有对方的记录,点「只看 TA」却变成空白;日期选择器可以点,编辑时选的新日期却没有保存;月账单里已经删掉的一笔,在搜索结果里还好端端地待着。
这些问题不至于让我重写一套架构,却也不能拿「只有两个人用」糊弄过去。
这一期不重新讲怎么搭小程序,而是沿着一期的架构,把动态流和共享账本从「能用」往「用得对」推进一步。
系列第一篇:我用 AI 给两个人写了个微信小程序:共享清单、记账和纪念日
本文聚焦动态流的分页、筛选与聚合,记账的日期状态、撤销与多视图一致性,以及年度统计的取数边界。导出和未登录体验放在后面简短补充;权限、农历和 AI 草稿的基础方案沿用一期,不再展开。
先交代一件事:上篇结尾说,下一步想把「常用一键记」的学习窗口做成可配置的。这件事目前还没做,仍然是最近 60 天、最多三项。 这轮先补的是更基础的问题:已有操作会不会让人看错、记错,出了问题能不能反悔。
这次改了什么,又没改什么
五个 tab 还是首页、清单、记账、日子、我的。没有为了第二篇文章再造一个大功能。
和一期相比,变化主要在这里:
| 位置 | 一期已经介绍的基础 | 本期重点改进 |
|---|---|---|
| 动态流 | events 记录新增、修改和删除 |
历史分页、查询层筛选、相邻同类事件折叠 |
| 记账 | 金额以分存储、两步录入、常用一键记 | 补齐日期编辑与取消复位,增加撤销,修复搜索状态下的删除 |
| 年度账单 | 沿用现有账目及统计能力 | 从一次整年查询改为分季度取数,补充洞察和可能不完整的提示 |
| 数据导出 | 已有数据出口 | 将文件准备和发送拆成两次交互 |
| 未登录体验 | 登录后使用真实家庭空间 | 新增只读的本地示例内容 |
底层分层也没推翻,仍然是原生小程序通过云 SDK 访问托管服务,没有另外搭一套应用服务器:
bash
页面 pages/
交互、表单状态、请求编排与渲染
│
├── utils/activity、expense、view
│ 事件文案、聚合、金额计算与展示派生
│
└── utils/api
表查询、CRUD 工厂、RPC 调用
│
utils/cloud
│
托管云服务
数据表、行级权限、数据库函数与触发器
共享启动链路:page.refresher → boot.bootstrap
新增体验分支:未登录 → 本地 demo 数据;写操作引导登录
没有自建应用服务器,不等于没有服务端逻辑。一期的行级权限和数据库函数仍是权限边界;这次新增的本地筛选、展示折叠和按钮拦截,都不能替代它。
这轮反而更能看出分层的用处:查询范围的问题改 API,聚合规则改纯函数,编辑状态的问题改页面,不需要每次都从数据库一路改到 WXML。
动态流:有记录,不等于能找到
一期给动态流补了 events 追加表,解决的是「改了什么、删了什么,有没有留下记录」。
这次解决的是下一层问题:记录越来越多之后,人能不能读懂,能不能翻到,筛选会不会骗人。
先加分页,结果把筛选的问题暴露了
动态页原先固定取最近 30 条,这次改成首屏 20 条,向下加载历史。
往更早翻时,游标取的是当前已加载原始事件里最旧那条的 id 。查询按 id 倒序,下一页取 id < beforeId,不是按页面上显示了多少行算偏移。
这里有一个特别容易写顺手的实现:
csharp
// 错误示意:它只能找到"最近 20 条里属于对方的记录"。
const recent = await fetchLatestEvents(20)
const theirs = recent.filter(function (event) {
return event.actor === otherId
})
只要构造一组数据,问题就很明显:最新 20 条全是我加的,对方的两条在更早的位置。点「只看 TA」,本地筛完得到空数组。
空的是这 20 条里的子集,不是对方的动态。
修复后,筛选条件进入查询层。下面是当前查询链的核心逻辑节选,省略结果和错误处理:
perl
let query = cloud.database
.from('events')
.select(EVENT_FIELDS)
.eq('household_id', householdId)
if (opts.actor) {
query = query.eq('actor', opts.actor)
}
if (!opts.actor && opts.excludeActor) {
query = query.neq('actor', opts.excludeActor)
}
if (opts.beforeId) {
query = query.lt('id', opts.beforeId)
}
return query.order('id', { ascending: false }).limit(take)
重点不是链式调用写成什么样,而是查询语义变成了:
bash
先限定空间和操作者
→ 再限定历史游标
→ 按 id 倒序
→ 最后取 20 条
切换筛选时,清掉上一组原始事件和展开状态,重新请求首屏。否则「全部」列表的历史游标会被带进「只看 TA」,筛出来的范围还是不对。
分页加进去以后,本地数组不再代表完整数据集。搜索、分类筛选、成员筛选,只要还默认手里有全集,就可能得到一个逻辑自洽、业务上错误的结果。
「双人」仍然只是数据,不是查询结构
一期说过,不把两个人写死在表结构里。这次在筛选上也得守住这句话。
两个人的空间,「只看 TA」可以用 actor = 对方 id。但空间里有三个人,随便挑一个「对方」就会漏掉另一个。
所以当前逻辑是:
- 只有一个其他成员:查这个人的
actor,按钮可以直接显示对方的名字; - 有两个及以上其他成员:用
actor != 我的 id,文案改成「只看别人」。
底层空间模型没有改,只是让查询和文案真正承认它支持多人。扩展性不是表结构画完就有了,每个默认值和每句文案都可能偷偷把它改回双人。
原始事件不动,展示可以折叠
批量录入几样东西,动态页就会连续出现几条几乎一样的「加入待买」。记录都对,但阅读体验像在看数据库日志。
这次加了一个展示层的聚合规则:同一个人、同一类实体、同一种动作,而且动作文案一致,相邻两条间隔不超过 60 秒,连续至少三条,折成一行。点开可以看具体标题。
少于三条不合并。两行折成一行,再让人点一次才能看清,未必是省事。
注意这里是「相邻间隔不超过 60 秒」,不是整组首尾必须在一分钟内;它也不是数据库事务识别,没有 batch_id 来证明这些事件来自同一次提交。
所以我把它当作阅读上的归并 ,不把它当作审计事实。events 里仍然是一条条原始事件,游标也仍然从原始事件取,不能从折叠后的行数推算下一页。
已读状态则继续沿用一期的机制,但把推进时机收紧:只在「全部」首屏加载成功后推进游标,翻历史和只看对方时不推进。
这里仍是「进入动态页,就确认到最新位置」的粗粒度机制,不是逐条阅读回执。首屏之外更早、还没加载的事件,也会落在这一游标之前。
记账日期:选择器只是入口,状态链才是功能
日期问题看起来像少传一个字段,实际修起来得看完整条操作链。
这里也要区分一下:补记日期的入口此前已经有了,本期不是第一次加日期选择器,而是补齐编辑路径。
账目里有两个不能混用的日期:
created_at:这条记录什么时候录进去;spent_on:这笔开销实际记在哪一天。
今天补记上周的午饭,两者就不一样。月度查询和明细分组认 spent_on,不能用创建时间代替。
原先的缺口是:新增时会传日期,编辑时却没有把 spent_on 一起写回。于是用户明明改了顶部日期,保存后的账目还是原来那天。
修复要补三个位置,而不是只补请求参数。
第一,进入编辑时回填原日期。 不能拿「今天」覆盖旧账,也不能沿用上一次表单留下来的日期。
scss
// 编辑回填中的日期部分
spentOn: String(target.spent_on || expenseUtil.todayText()),
spentOnText: this.dateLabelOf(
String(target.spent_on || expenseUtil.todayText())
),
第二,保存编辑时把实际字段写回去。
php
.updateExpense(editingId, {
amount_cents: cents,
category: key,
note: note,
spent_on: this.data.spentOn,
})
第三,取消编辑时,日期也要复位。
这个最容易漏。进入一笔旧账,点取消,接着记今天的奶茶。如果只清掉 editingId,新账会悄悄落在旧账的日期上。
当前取消路径会同时清掉金额、备注、分类等编辑状态,把 spentOn 和显示文案一起还原成今天。
这里的教训不是「记得多清一个字段」,而是:退出一种操作模式时,要把它借用的整组表单状态交还干净。
这次核对还发现一个没收干净的旁路:删除正在编辑的账目时,目前只把文案改回「今天」,实际的 spentOn 没有同步复位。这处还没修,也说明主路径通了,不代表借用同一组表单的其他操作自动跟着正确。
撤销:不是加一条提示,而是把删除拆开
一期的「常用一键记」追求一步完成。这次仍然保留这个速度,但加了误触后的撤销入口。
这里有两种不同的撤销,不能混成一个实现:
- 常用一键记:已经创建账目,撤销时删除刚创建的那笔;
- 删除已有账目:先从本地移除,等 6 秒再真正发删除请求,窗口内撤销就取消待删任务。
第二种不需要把记录删掉后再重新插入。撤销窗口里,服务器上的记录一直都在。
按正常单笔操作路径,它大致是这样的:
markdown
确认删除
├── 本地移除,立即重算汇总
└── 进入 6 秒待删窗口
├── 点撤销:取消待删任务,重拉数据
└── 窗口结束:请求删除,刷新数据
但「本地移除」三个字,恰好藏着这次另一个 bug。
月账单删了,搜索结果怎么还在
记账页里,同一笔账至少会出现在两份数据中:
monthRows:当前月份账目,用来渲染列表和汇总
searchPool:搜索范围内的候选账目,用来生成搜索结果
最初只从 monthRows 移除了目标,重算了月合计。月视图看起来完全正常;搜索池没动,搜索结果里那条就还在。
这不是请求失败,是同一份业务事实在页面里有两个副本,只更新了其中一个。
现在暂删时,两份数据都要处理。下面是搜索池这部分的实现节选:
kotlin
this.searchPool = this.searchPool.filter(function (row) {
return row.id !== target.id
})
this.applySearch()
恢复时也不是随便往数组里塞回一条。当前实现先刷新月份,再刷新搜索池:
kotlin
return this.reloadAfterWrite().then(function () {
if (wasSearching) {
return self.refreshSearchPool()
}
return null
})
顺序有原因:reloadAfterWrite() 内部会清掉最近数据缓存和搜索池。先恢复搜索,再刷新月份,刚恢复的搜索池又被清空了。
写操作的收尾,不是让一个列表看着对了,而是让所有依赖它的视图重新对齐。
先承认这不是一个可靠删除队列
这个方案适合几秒钟的反悔窗口,但当前实现只有一个 pendingDelete 槽位,不是多笔并行队列。窗口内连续删除,会覆盖上一笔待删任务并清掉原定时器,前一笔可能根本没有发出删除请求。上面描述的是正常单笔路径,连续操作还没处理好。
切后台、页面卸载、进程退出和删除失败,也得分别考虑。现在页面卸载时会尝试提交待删项,但它不是持久化任务,也没有覆盖所有异常的补偿机制;删除失败后的搜索视图恢复,同样还有补齐空间。
如果以后要把它做成更可靠的通用能力,我会先处理待删任务的覆盖和失败恢复,而不是先把「撤销」做成全项目公共组件。
一个按钮足够简单,不代表它背后的状态足够通用。
年度账单:最怕的不是报错,是算少了还像真的
月度账单回答「这个月花在哪」,年度账单回答「这一年是什么样」。
年度页此前已经存在,这轮不是从零加页面,而是改取数方式和统计表达。
旧写法尝试一次取整年,传一个很大的 limit。它背后有个危险假设:客户端说想要多少,服务端就一定返回多少。
托管查询有返回条数边界,不能只看请求里的数字。对年度汇总来说,少了一部分记录,不一定会报错;总额、分类图、最大单笔照样能算出来,而且每个数字都像那么回事。
这种错比空白页更难发现。
当前做法:分季度取,触顶就明确提示
目前改成四段查询,每季度最多取 500 条:
lua
const QUARTERS = [[1, 3], [4, 6], [7, 9], [10, 12]]
const PER_QUARTER_LIMIT = 500
四段请求完成后,检查是否有任意一段达到上限:
javascript
const truncated = lists.some(function (rows) {
return (rows || []).length >= PER_QUARTER_LIMIT
})
返回条数达到 500,界面就给出数据可能不完整的提示。
这里要说准确:分季度不是全量分页,500 条也不是完整性的证明。 恰好 500 条和实际还有更多条,单看这一页无法区分,所以采取保守提示。而 500 是客户端请求上限,本轮并未核验线上实际返回上限;如果服务端在更低条数截断,这个检查也抓不到。没有触发提示,同样不能证明取全了。
真正要保证取完,后续仍要按稳定游标继续分页,或者把合适的聚合放到服务端。本轮只是缩小单次查询范围,并让已知的上限风险显性化,没有消灭数据规模边界。
提示文案本身也留下了一处待修:当前仍写着「某几个月只统计了前 500 笔」,实际限制单位却是季度。提示已经接入,不代表提示说得准确;数字没错、单位错了,也会误导用户。
同一批账目,派生更多信息
这次还从同一批返回账目里算出最大单笔、花得最多的一天、记账天数和按记录人归集的金额,没有为每张洞察卡再发一遍请求。
但比多几张卡片更重要的是把口径讲清楚:
- 最大单笔不等于最贵的一天。 一天可能记了很多笔,后者要先按
spent_on聚合; - 日均按有记录的天数算。 它不是全年所有自然日的平均消费;
- 月均按有记录的月份算。 没记账的月份,不等于一分钱没花;
- 按
created_by分组,得到的是谁记录了多少。 没有真实付款人字段,就不能说谁实际承担了多少,更不是自动 AA 结算。
当年的月份图只画到本月,避免把未来月份排成一串零。加载和错误状态只替换内容区,年份导航还留着,让人知道自己在看哪一年,也能切年重试。
这些都很小,但统计产品里的小字和分母,往往比那根彩色柱子更重要。
还有一处没有完成:月视图已有请求序号保护,年度页目前还没有同等保护。快速切年时,迟到的旧请求仍是需要补测试和处理的竞态风险,不能把月视图的保证直接算到年度页头上。
两个补充:文件要真能发,登录前要能看
这轮还有两处不在主线里,但很能说明「入口有了」和「链路完成了」的区别。
导出拆成「准备文件」和「发送文件」
小家此前已经有导出,这次没有把它包装成新能力,而是调整交互链路:先生成 XLSX,准备好后,再由用户点击发送。
也就是从:
点一次 → 异步查数据 → 异步生成文件 → 继续尝试分享
改成:
点导出 → 文件准备完成 → 点发送 → 立即调用分享
原先把准备和分享串在一条异步链里,调用分享时的用户点击上下文并不可靠。现在给「文件准备好了」一个明确状态,让发送从新的点击处理函数直接发起。
多了一次点击,但这次点击承担的是一个真实动作,不是用来补产品仪式感。代码改了,具体机型和微信版本下的分享、预览,仍然要真机验收。
未登录先看本地示例,真实写入再登录
新加的体验模式,不是给匿名用户开放家庭数据,而是准备一份独立的本地示例。
启动链路在拿不到会话时返回 demo: true 和示例空间,主页面展示示例内容,并明确标识「示例数据」。
写入则有两层处理:页面侧引导登录,数据访问层也拦住示例模式下的保存。登录成功后退出示例模式,重新走真实空间加载。
这里不是在替代数据库权限,而是在做体验隔离:看看长什么样不需要先交出登录,真正保存自己的内容才进入真实账号和空间。
目前这条路径覆盖主页面,不代表所有二级页面都有匿名体验版,也不代表新版本已经通过平台审核。实现完成、真机验收和审核通过,是三件不同的事。
Vibe Coding 的第二阶段:别只测功能,要测操作之间的缝
一期里,我把 AI 的价值概括成写代码、写测试、查约束,人负责产品判断、权限推演和真机验收。
这一轮又补了一条:人要决定哪些操作值得串起来测,AI 再把它们写成回归。
如果只测「能不能查动态」「能不能改账」「能不能删除」,上面的问题很容易全漏掉。真正容易出错的是它们之间的连接。下面列的是应该覆盖的回归目标,不代表每项都已有自动化测试:
| 回归场景 | 要检查的不是一句"成功" |
|---|---|
| 最新 20 条全是自己,再切「只看 TA」 | 能查到对方更早的记录,不是空列表 |
| 编辑旧账,取消,再准备记新账 | 实际日期字段复位,不能只改显示文字 |
| 在搜索结果里删除,再撤销 | 搜索结果立即减少,撤销后从服务器恢复 |
| 从搜索进入编辑,再保存 | 改动落库,同时回到原搜索上下文 |
| 季度查询达到上限 | 出现可能不完整的提示,不能把现有结果当全量 |
仓库现有的页面桩测试已经包含其中的动态筛选、取消编辑复位、搜索删除与撤销等场景。桩环境也得真的执行 eq、neq、limit 等条件;如果它永远把整张表返回,查询写错了也能测出一个「成功」。
这些测试不等于真机,更不等于线上数据库验证。它们擅长抓的是状态和查询语义;登录、分享、文件打开等运行时行为,还得在微信里验。
另外,一期强调过 README 里的架构决策有价值。这轮要加一句限制:文档不是不会过期的真相。
注释还说「切筛选不用重新请求」,实现可能已经改成重查;文档写「两个人各花了多少」,字段实际只证明谁录入了多少。让 AI 先读文档是对的,但还要让它拿当前代码和提交差异校对,不能把不同阶段的描述拼成一套从来不存在的架构。
写在最后
一期最值得保留的是底座:空间和成员、数据库权限、事件记录、纯函数,以及对平台约束的敬畏。
第二期没有换技术栈,也没有为了「架构升级」加一堆新层。做的更多是把原来没接严实的地方接起来:
- 有了分页,筛选就不能继续假装拿着全集;
- 有了编辑,取消就必须清掉它留下的状态;
- 有了搜索,同一笔账就不能只在月视图里消失;
- 有了年度统计,就必须说明数据是否取全、分母到底是什么。
还有不少没做完的事:常用学习窗口仍然不可配置,年度查询还不是全量分页,删除撤销也还不是可靠队列。把这些边界留在文章里,不是为了显得谨慎,而是下一次迭代必须从真实的位置出发。
只有两个人用,可以少做功能,不能少解释一个错误的数字。
下一步我更想先收敛记账页的数据与状态,再把常用学习窗口做成配置项------先拆开推荐窗口和月度汇总的取数依赖,避免用户把窗口调短后,本月账单跟着少算。这个点,留到真正做完再展开。
如果你也做过共享清单、家庭账本,或者任何「有列表、有搜索、还能撤销」的小工具,欢迎聊聊你踩过的状态一致性问题。