我用 AI 给两个人写了个微信小程序:共享清单、记账和纪念日
两个人过日子,最常见的协作事故不是「忘了」,而是「我以为你记得」。
超市里打电话问「酱油家里还有吗」------其实上周就买过了;周末两个人各买了一份牛奶;某个纪念日,一个人提前一周就开始琢磨礼物,另一个人当天晚上才想起来。
这些信息原本散落在微信聊天里,迟早沉底。于是我和 AI 结对,写了一个只有两个人用 的微信小程序:小家。一个人创建「家庭空间」,把邀请码发给对方,之后两个人看到的是同一份数据。
扫码即可快速体验 

它长什么样
五个 tab:首页、清单、记账、日子、我的。没有社交、没有动态广场、没有积分系统------两个人,够用就行。
几个我比较得意的交互:
清单的「记到」选择条。 加一条待买仍然是「打字 + 回车」一步,但底部可以选「记到超市 / 菜市场 / 网上 / 药店」,选一次之后连续录入的条目都归到那里。它对应的真实场景是:「我下班路过超市,把该在超市买的都买了」------待买列表按去处分组,路过哪个店就看哪一段。
「我去买」一键认领。 防的是经典失败模式:都以为对方会买。行内一次点击认领,点自己的可以交回;标记「买好了」时记录的是买家,已买区显示「谁买的」而不是「谁加的」------这两个信息混用,会正好制造它要消灭的误会。
记账的「常用」一键记。 从最近 60 天的历史里学出最常记的三笔,点一下、连金额都不用填。记账类功能如果在录入上多花三秒,几天后就不会有人记了------所以标准动作是「填金额 → 点分类」两步,没有保存按钮。


日子页是纪念日 + 日程:纪念日支持公历 / 农历,可以设「提前 N 天准备」;日程支持单次 / 每周 / 每月 / 每年重复。
架构分层:一个「小」项目的认真分法
项目不大,但分层是认真的。一张全景图:
三条分层纪律:
页面层要薄。 业务规则全部下沉到 utils 的纯函数------好处直接体现在测试上:冒烟测试用桩替身加载真实页面代码,200 多条断言在命令行里跑完,能查到「字段名写错导致某块永不渲染」这类问题,不用打开开发者工具逐页点。
数据访问靠工厂,不靠复制。 四张业务表的 CRUD 只有字段清单和文案不同,统一由 createRepo({ table, fields, text }) 生成。加第五张表 = 一段配置 + 四个一行的方法,不是复制 60 行再改。动态流的取数也从 repo 里读表配置,新表自动能进时间线。
数据库是第二道防线,不是存储桶。 创建人、更新时间由触发器写死------客户端传什么都不算;「谁能读写哪行」由行级安全策略执行。前端过滤只是展示优化,前端过滤不算权限。
还有一个「省请求」的小体系值得单独说:五个 tab 页都是 onShow → 刷新 → bootstrap,bootstrap 要发 3 个请求(我的成员行、空间、成员列表),而这些数据只在六种操作后才变。以前每切一次 tab 重发一遍,首页一次刷新要发 10 个请求 ;现在两层去重------空间信息缓存 30 秒 + 同一时刻多个页面共用一个在途请求------降到 3 个。关键是写操作必须让缓存失效:忘了这一句,「改完还是看到旧的」,要等缓存过期才自愈,是最难查的那类问题。
权限模型:「两个人」不该写进代码
第一个架构决定:不把「只有两个人」写进代码,把它做成「成员数为 2 的空间」。
所有业务表的行级安全(RLS)策略统一只认成员资格:
my_household_ids() 是 SECURITY DEFINER 函数------如果它直接查成员表,会触发成员表自己的策略,形成无限递归;提权成安全定义者绕开这一层。
两条不能让步的规则:
- 成员表对客户端没有写权限。 加入空间只能走服务端函数
join_household(邀请码)。如果开放客户端 INSERT,任何人猜到自增的household_id就能把自己塞进别人的空间,邀请码形同虚设。UPDATE 也只能改自己那一行资料,另有触发器直接禁止改household_id/user_id字段。
- 创建人字段由服务端写死。 业务表挂了
BEFORE INSERT OR UPDATE触发器:插入时用auth.uid()覆盖客户端传的任何值,更新时禁止改归属。
这套模型不改一行代码就能从 2 人扩到 N 人(上限目前只是函数里的一个数字)。「双人」是数据,不是结构。
动态流:把「改了什么」记下来
没有推送可用(后面细说),所以协作的命脉是让「打开小程序」这件事本身有回报。首页会提示:「柚子记了一笔『吃饭 ¥26.5』,还有 1 件」------看过即消失,而且只显示对方做的事,自己的操作不算「未读」。
动态流的第一版是从四张业务表各取最近 N 条倒推的,有两个治不好的毛病:
- 改了什么看不见:改了标题、改了金额、换了去处,表里只留最新一版;
- 删了什么完全看不见:删掉的记录连影子都没有。
解法是加一张 events 追加表,由数据库触发器写入:
| 列 | 内容 |
|---|---|
| entity / entity_id | 哪张表哪一行 |
| action | insert / update / delete |
| actor | coalesce(auth.uid(), created_by)------优先服务端身份,伪造不了 |
| changes | update 才有:{ title: { from, to } },只记变化的字段 |
| snapshot | insert / delete 才有:整行快照 |
events 对客户端只开 SELECT。事件只能由数据库自己写,客户端改不了也删不了------这是它比「客户端上报」可信的原因。将来真接上推送,服务端可以直接拿它做增量同步(sinceId 游标已经留好),客户端一行不用改。
并发:乐观更新 + 条件更新
清单的勾选、认领、改去处,都是先改本地再发请求。理由不是「显得快」:这类动作发生在电梯和地铁里,以前每次都是「点 → 等请求 → 整表重拉」,一秒多的空窗足够让人以为没点上、再点一次。
乐观更新的代价是「我看到的可能已经旧了」。所以每次修改把读到的那一版 updated_at 一起带上,服务端按 id + updated_at 做条件更新:
- 命中 → 正常写入;
- 影响 0 行 → 抛冲突错误,页面撤回本地改动,提示「对方刚改过这条,已刷新」,再拉一次。
不会静默覆盖对方刚做的修改。 updated_at 由触发器写死成 now(),判据用的是服务端时间,不受手机时钟影响。
配套还有一个序号机制:发出整表请求时记下序号,本地刚做过乐观更新就丢弃迟到的响应------否则「刚点完已买」会被一个更早发出的请求覆盖回去。
农历:别信运行时,自己带表
纪念日支持农历,于是撞上一个出乎意料的坑:浏览器内置的中国农历(Intl / ICU)在 2027 年给 02-07、2030 年给 02-02,而官方历表是 2027-02-06、2030-02-03------差一天。
农历差一天,就是生日过错天。所以项目自带 1900--2100 年的农历信息表(每年一个 32 位整数:bit0-11 记十二个月大小、bit12-15 记闰月月份、bit16 记闰月天数),并用 2019--2040 共 22 个权威春节日期做回归测试。
第二个坑更隐蔽:天数运算不能用时间戳加减 86400000 再读本地日期。 1901 年之前的上海时区是地方平时 +08:05:43,与现在的 +08:00 差 5 分 43 秒;从 1900 年的「本地零点」开始累加整天,到现代读出来是前一天 23:54------整套换算整体前移一天 。正确做法是全程用 UTC 整数天序号(Date.UTC(y, m-1, d) / 86400000)。
这个坑是真踩过的:改完之后 22 个春节日期断言全红,才发现换算整体偏了一天。
闰月的口径也定了规则:出生在闰月、目标年有同一个闰月就在闰月过,没有就退回同名正常月;腊月三十遇到小月退到廿九------那年根本没有三十。两条都有测试。
一些设计小巧思
技术之外,这类产品的成败往往在一堆「小到不好意思写进文档」的决定里。挑几个:
倒计时的三种措辞。 一次性且已经过去的日子(比如「我们订婚啦」)不能被套上「下一个纪念日」的标题,也不能只显示「13」------那会被读成「还有 13 天」。所以头部卡片分三种模式:没到的显示「下一个纪念日 · 13 天」,正好今天的显示「就是今天」,全都过去了的显示「最近过去的日子 · 13 天前」,已过去的条目另加灰色弱化。upcoming 永远优先,一条未来的都没有才退到过去。
「记到」选择条。 加清单条目时底部可以选去处(超市 / 菜场 / 网上 / 药店),选一次之后连续录入的条目都归到那里------连加五样东西依然一步一条,不为了分类牺牲录入速度。去处可以事后改,待买只有出现两个以上去处时才分组------全归在一组还显示组标题,只是噪音。
分类占比永远加起来 100%。 用最大余额法取整,不会出现 39% + 62% = 101% 这种「肉眼看不出哪里错」的合计。
金额用「分」存整数。 浮点数存金额迟早出 0.1 + 0.2 的错;解析时有三条硬边界------拒绝负号(-8 以前会被洗成 +8)、拒绝四舍五入到 0 的输入(0.001 会记出 ¥0.00)、上限 100 万元(超了会把整数列撑爆)。
「再来一份」。 把最近买过的重新加回待买,同名只留一次、最多 8 件------买菜场景复购率最高的动作。配对的是「清理 30 天前的已买」:不自动删,必须用户点。清单是写入最频繁的表,只增不减迟早让这一页变慢,但删数据这件事永远让用户自己决定。
邀请码落地的三种分流。 从分享链接点进来的人,可能是「没登录」「已登录没空间」「已在别的空间里」,三种情况三种处理。第三种以前是完全没反应------落地到自己的首页,什么都不发生。双人产品里这很常见(重复建空间、分手后重建),沉默是最差的处理。
空状态也要有引导。 新空间四处空白页是最劝退的时刻,所以首页 / 清单 / 记账 / 日子四个页的空态都配了引导按钮,不是一行「暂无数据」。
提醒:先接受平台约束,再设计
小程序的提醒能力,先接受三条现实:
- 不能后台定时执行:JS 定时器切后台即失效;
- 个人主体拿不到长期订阅消息:长期订阅只对政务 / 医疗 / 交通 / 金融 / 教育类目开放;
- 一次性订阅授权一次只能发一条,还会过期。
所以「到点提醒」只能写进手机系统日历 (wx.addPhoneCalendar),由系统负责响铃。三个容易做错的细节,官方文档不细读不知道:
startTime/endTime是 unix 时间戳,单位是秒不是毫秒;
- 每月重复的事件日期不能大于 28 日(29--31 日系统表达不了,代码降级成单次并说明原因);
- 只能在用户点击行为 里调用,需要
scope.addPhoneCalendar授权。
还有一个产品设计上的取舍:「已加日历」的状态存在本机,不进共享数据。 我在自己手机上加了日历,对方的手机日历里并没有------这个标记如果跟着空间走,对方会看到一个骗人的「已加」。改了标题 / 日期 / 重复规则,本机标记自动作废,否则界面一直显示「已加提醒」而日历里那条还停在旧日期------静默失效比报错更难发现。

一点 AI 功能,但只敢让它出草稿
记账页长按「记一笔」,说一句「中午吃饭花了 26 块 5,顺路去超市买瓶酱油」,大模型拆成一条账目 + 一条清单,确认后落库。
原则是AI 结果必须慢一步:模型给什么都不能直接写库,先卡金额范围、分类白名单、日期格式,用户确认才生效。额度有每日上限、12 秒超时,失败立即降级成手工输入------绝不放进关键路径。确认弹层的标题写明「AI 拆成这些,记吗?」,符合生成内容标识的要求。
先验证这个入口有没有人用,再决定要不要摆到明面上。
Vibe Coding 的真实分工
这个项目全程和 AI 结对完成,说一点真实感受:人的精力应该花在哪。
我花时间的地方:
- 产品判断:「已买」显示买家还是加的人、倒计时文案怎么措辞才不会被读成「还有 13 天」、哪种交互在地铁里能一步完成;
- 权限规则:反复推演「如果客户端可以直接写这张表,最坏能发生什么」;
- 真机验收:一个 9 项的清单,每次发布前过一遍。
AI 花时间的地方:写代码、写测试、查官方文档、记住所有平台约束(上面那些「秒不是毫秒」「农历差一天」的坑,全都是它先发现或查证的)。
但有一个东西比代码更值钱:README 里那些「不要让路的设计决定」 。每条决策都写了为什么------「household_members 为什么不能开放客户端写入」「农历为什么自带信息表」「updated_at 为什么由触发器写死」。每次改代码前 AI 都会先读它,等于把架构决策变成了 AI 的长期记忆,不会第三个星期就忘了当初为什么这么设计。
工程上有两条硬门槛,AI 生成的代码也一样要过:
- 自检脚本:结构校验(配置、页面登记、盒模型、类名撞车、对比度、体积)+ 200 多条业务断言(登录、清单三态、22 个春节日期、并发冲突、未读回执、CSV 转义)。改完必跑,全绿才算数;
- 无障碍当验收项:双主题共 22 组前景 / 背景组合全部 ≥ 4.5:1(WCAG AA),点击热区 ≥ 44pt。肉眼看「还行」的配色实测可能只有 3.8:1,这种只能靠工具卡。

写在最后
这个项目最有意思的地方在于:用户只有两个人,反而把很多问题逼到了正确的答案上。
因为只有两个人,所以不需要增长黑客,只需要把「我去买个酱油」这件事做到一步完成;因为只有两个人,所以权限模型可以做得非常干净------空间、成员、行级安全,一条不多一条不少;因为只有两个人,所以每一个「对方刚做了什么」都值得认真展示。
工具是给两个人的,但方法可以是通用的。欢迎交流。
项目还在迭代,下一个目标是把「常用一键记」的学习窗口做成可配置的。如果你也在做双人 / 家庭类的小产品,欢迎在评论区聊聊你们踩过的坑。