给这个项目起名叫 weixin131,听起来像一串随机编号,其实是我本地工程里的第131个微信小程序 demo,后来这个 demo 越做越完整,干脆当正式项目用了。最初要解决的问题很朴素:家里买菜、缴费、接送孩子、周末安排、水电账单,全在微信群里来回刷,消息一多就漏事。做出来的东西,就是一套能共享给全家的家庭事务管理微信小程序,把琐碎的家务变成一张明确的、所有家庭成员都能看懂的清单,再配上到期提醒和账本记录。
这个项目适合两类人参考:一类是家里事务庞杂、想在微信里低成本协调分工的家庭用户;另一类是刚入门微信小程序开发、想找一个真实业务场景练手的人。它不复杂但五脏俱全,涉及多人协作、数据隔离、订阅消息、定时任务、账单分摊这些常见诉求,把它跑通一遍,后面再做大应用会顺手很多。下面按我的实际开发顺序,把需求和实现细节完整拆一遍。
1. 项目立项:为什么一个家庭需要专用事务管理工具
1.1 家庭场景里的真实需求拆解
在动手写代码之前,我先在亲戚群里观察了小半个月。结论是:绝大多数家庭不是没有“管理系统”,而是把微信当成了管理系统,但微信群天然不适合处理任务。
几个典型场景我相信大家都有印象:妈妈说下班路上买鸡蛋,爸爸回了个“好”,结果忘买了;周末要交电费,有人说“回头交”,就再也没有下文;月底算开销,谁花的钱都对不上。这些问题的根源不是记性差,而是信息散落在聊天记录里,没有责任人、没有截止时间、没有完成状态。
我把家庭事务拆成三类:任务类,包括买菜、打扫、接送、维修、缴费;时间类,比如水电费每月几号交、孩子的兴趣班哪天上课;钱类,家庭日常支出、共享购物、水电燃气分摊。家庭事务管理小程序只要把这三类事情闭环掉,就解决了90%的问题。
所以需求清单可以定量表述:家里人能创建任务,指定某人负责,设置截止时间;任务到期能通过微信订阅消息提醒;任何一个家庭成员能记录家庭支出,月底能自动算出每人该付多少;家里所有人能看到同一个任务视图,而不是各自手机里一个孤立的待办列表。
1.2 为什么选微信小程序而不是独立App
这个选择我犹豫过几天,因为身边有同事用原生App做得挺重,功能强大但从推广角度很尴尬。让我实际放弃App和纯网页方案的原因,主要是这几个。
第一是安装成本。让家里六十岁的长辈单独装一个App,还要注册账号,基本可以宣告项目失败。微信小程序扫一下或者从聊天窗口点进去就能用,对不熟悉电子设备的人友好很多。第二是消息触达,小程序可以直接使用微信的订阅消息,用户授权后能在任务到期时收到服务通知,这是网页端做不到的。第三是开发成本,这个项目使用了微信云开发,数据库、云函数、文件存储都在云端,不用自己买服务器。
我把三种方案做了个简单对比:
| 方案 | 亲人上手成本 | 消息触达能力 | 开发维护成本 | 适合场景 |
|---|---|---|---|---|
| 原生App | 高,需下载安装 | 受系统策略限制 | 高,两套平台 | 商业化产品 |
| 网页应用 | 低,发链接即可 | 基本无推送能力 | 中 | 工具型后台 |
| 微信小程序 | 最低,扫码即用 | 支持订阅消息提醒 | 低,云开发省去运维 | 家庭/小团队协作 |
还有一个方案是写个微信机器人管群,它能发消息提醒,但没有统一家庭视图、没有账单字段、任务多了没法归档,最后放弃了。核心需求不是“发通知”,而是“一家人基于同一组任务协作”。这个判断直接决定了选型。
2. 功能框架与数据结构:先把底子打牢
2.1 功能模块清单与优先级规划
我一开始很容易想复杂,比如做互动板块、家庭日记、积分商城、排行榜。冷静下来以后,我把所有想法收敛成四个核心模块:
- 家庭空间:创建家庭、邀请成员、成员角色管理
- 任务管理:发布任务、指派成员、设置截止时间、任务状态流转
- 提醒中心:订阅消息的授权管理、到期提醒、每日汇总
- 家庭账本:记账、分类统计、人均分摊
优先级排序是任务第一,因为这是刚需;提醒第二,因为它是小程序相较于其他工具的差异化能力;账本第三,可以放到第二版;家庭空间从第一天就要做,否则所有数据没有归属。
页面结构也跟着功能走:底部 tabBar 分成四页,“今日”看当天安排,“任务”看全部事务,“账单”看收支,“我的”管成员和设置。第一版千万不要再加第五个 tab,家人用起来会发懵,开发成本也高。
2.2 云开发数据库集合设计
这个项目用的是微信云开发,数据库是文档型的,没有传统关系型数据库里的外键和 JOIN,所以在设计集合时要做一些数据冗余。一开始没想清楚,后面有些查询就很别扭,这里直接给出我最终用的结构。
第一个集合是houses,一个文档代表一个家庭:
{ "_id": "house-id", "name": "幸福小窝", "ownerOpenid": "创建者openid", "members": [ { "openid": "xxx", "role": "admin", "joinedAt": 202401011200 } ], "inviteCode": "ADF8K2", "inviteExpireAt": 202401111200, "createdAt": 202401011200 }第二个集合是users,记录用户的基础信息:
{ "_id": "user-id", "openid": "用户openid", "nickname": "昵称", "avatarUrl": "头像地址", "activeHouseId": "当前选中的家庭", "createdAt": 202401011200 }第三个集合是tasks,家庭任务的主体:
{ "_id": "task-id", "houseId": "house-id", "title": "交电费", "description": "顺便看下余额", "category": "缴费", "assigneeOpenid": "负责人的openid", "dueDate": "2024-01-15", "priority": "high", "status": "todo", "createdByOpenid": "创建者openid", "createdAt": 202401011200, "updatedAt": 202401011200 }第四个集合是bills,记录家庭收支:
{ "_id": "bill-id", "houseId": "house-id", "amount": 268.50, "type": "expense", "category": "日常采购", "recordedByOpenid": "记账人openid", "payDate": "2024-01-10", "note": "周末超市采购", "createdAt": 202401101200 }设计过程中最重要的是每个集合都带houseId,之后所有查询都以它为第一过滤条件,既保证数据隔离,也方便建索引。tasks集合中我冗余了assigneeOpenid而不是像关系型数据库那样单独建一张关联表,目的就是减少一次查询,毕竟微信小程序云函数调用数据库也有延迟,能在一张表里拿全数据就别拆。
索引方面,houses的inviteCode建唯一索引;tasks建议联合索引houseId + status + dueDate;bills建议联合索引houseId + payDate。云开发控制台里新建索引很快,但一开始只建单字段索引的话,数据量到几千条时查询速度就会明显下降。
2.3 成员权限与家庭组织关系的设计
多人协作用户最怕的就是权限混乱。我实现的方案是:家庭创建者自动成为owner,可以邀请成员、移除成员、解散家庭;其他成员统一是member,可以创建任务、记账,但不能解散家庭。管理员和普通成员在操作上实际没做差异,因为家庭场景不需要复杂的审批流,刻意区分反而让家人不知所措。
家庭成员加入时用邀请码方式。先由已有成员在“我的”页面生成邀请码,新成员输入邀请码后加入对应家庭。邀请码存在houses集合的inviteCode字段里,生成时顺手写个过期时间,默认24小时有效,避免邀请码被随意使用。
权限检查放在两个层面:前端根据当前用户是不是家庭成员控制按钮显示;后端云函数每次写操作前再查一次houses,确认 openid 在 members 数组里才继续。前端隐藏按钮只是交互逻辑,不能作为安全边界,这个认知后面排查问题时帮了我大忙。
3. 关键功能实现:任务、提醒与实时协作
3.1 任务清单与“今日待办”的前端逻辑
任务模块是整个小程序的主干。“今日”页面看起来简单,但它不是简单查一下dueDate等于今天,而是要做三层筛选:第一层取当前家庭的未完成任务;第二层取“到期日小于等于今天”的任务;第三层按截止时间排序,越紧急的越靠前。这样过了期的未完成任务会一直留在今天视图里,提醒家人去收尾,而不是悄悄沉底。
查询直接放在云函数里做,不把全表拉到前端再过滤。原因有两个:一是家庭成员手机性能差距大,老手机处理几百条数据也有卡顿;二是安全边界需要统一在云函数控制,前端只接收自己家庭的数据。云函数里的核心查询逻辑大概是:
const cloud = require('wx-server-sdk') cloud.init({ env: cloud.DYNAMIC_CURRENT_ENV }) const db = cloud.database() const _ = db.command exports.main = async (event) => { const { openid, houseId } = event const today = new Date() const dateStr = today.toISOString().split('T')[0] const res = await db.collection('tasks') .where({ houseId, status: 'todo', dueDate: _.lte(dateStr) }) .orderBy('dueDate', 'asc') .limit(50) .get() return { tasks: res.data } }服务器返回后,前端按dueDate显示在对应分组里,已完成的任务可以点击切换状态。这里我特意没做“恢复已完成”的撤销功能,因为家庭场景里误操作不多,撤销交互会让代码复杂很多,收益很小。
3.2 订阅消息实现任务到期提醒
订阅消息是这个项目区别于普通账本工具的核心能力,也是最容易踩坑的部分,我放在技术实现最前面讲。
微信小程序的订阅消息分“一次性订阅”和“长期订阅”。家庭事务类目下,个人小程序的长期订阅基本申请不到,所以实际方案是用一次性订阅:用户授权一次,只能发送一条消息。这意味着不能在用户创建任务时就默认帮他订阅所有提醒,因为授权次数很快就用完了。
我最终设计的规则是:任务创建者和任务负责人可以对任务本身授权提醒。用户在任务详情页点“接收提醒”按钮时,小程序弹起订阅授权面板,用户点了同意后前端把授权记录写入users表的subscribeTimes字段里。到这里只是拿到“凭证”,真正发送在云函数里执行。
发送订阅消息的云函数核心代码:
// index.js const cloud = require('wx-server-sdk') cloud.init({ env: cloud.DYNAMIC_CURRENT_ENV }) const db = cloud.database() exports.main = async (event, context) => { const { taskId, templateId } = event const openid = cloud.getWXContext().OPENID const task = (await db.collection('tasks').doc(taskId).get()).data if (!task) return { code: -1, msg: '任务不存在' } const res = await cloud.openapi.subscribeMessage.send({ touser: task.assigneeOpenid, page: `pages/task/detail?id=${task._id}`, data: { thing1: { value: task.title.slice(0, 20) }, time2: { value: task.dueDate + ' 23:59' } }, miniprogramState: 'formal' }) return res }这里 templateId 是申请订阅消息模板后生成的,动态从参数传入会更好维护。发送时要注意字段类型,thing1只能填20个字符以内的短文本,超了会报错;time2必须是标准时间格式。页面跳转路径也要认真配置,点通知后要能进到任务详情页,否则用户会有“假通知”的感觉。
定时提醒用的是云开发定时触发器,在云函数目录下config.json里配置:
{ "triggers": [ { "name": "dailyReminderCheck", "type": "timer", "config": "0 0 8 * * * *" } ] }这个表达式的意思是每天早上8点触发一次,注意云开发的 cron 表达式是七位,包含秒。定时器被触发后,云函数去查所有状态为todo、截止时间为当天的任务,给每个任务负责人发订阅消息,把当天要办的琐事一次性汇总成一条消息。为了让推送不超限,每个用户每天最多收到3条提醒,这是我用云函数里计数逻辑控制的。
3.3 账单记录与人均分摊计算
家庭账本相比专业个人记账软件要轻量很多,不需要复杂的资产账户和预算体系,只记录日常收支和家庭成员开支分配。所以我的bills集合刻意没有做“借款人”和“还款人”字段,只用两条规则:
- 每笔支出记录实际付款人和金额;
- 月底按参与分摊的成员数算人均。
分摊计算的逻辑很简单:某成员当月实际支出总额减去当月人均应摊值,正数代表别人欠他钱,负数代表他欠别人钱。我直接用db.collection('bills').aggregate()按月分组求amount的和,然后遍历家庭成员算出差额。
const $ = db.command.aggregate const res = await db.collection('bills') .aggregate() .match({ houseId, payDate: _.gte(monthStart).and(_.lte(monthEnd)), type: 'expense' }) .group({ _id: '$recordedByOpenid', total: $.sum('$amount') }) .end()结果集里就能看到每位成员当月花了多少钱,再除以人数得到人均。页面里直接展示“本月人均支出XXX,你超支了XX”这种口语化文案,家里人一看就明白。为了让这个模块不至于太重,我没有做逐笔记账的分类统计图,只在顶部保留收入、支出、结余三个数字,再往下是明细列表,这样妈妈们用起来压力小。
3.4 家庭成员实时同步的监听方案
多人协作最怕一个人改了任务状态,另一个人看到的还是旧数据。云开发的数据库支持watch实时监听,前端可以监听某个查询条件的数据变化,但这里有两个坑:一是watch只能在真机或开发者工具中测试,普通get查询不会触发;二是监听范围不能太大,否则每次变化都会拉全量数据。
我的做法是在任务列表页开启一个限定范围监听,只监听当前家庭下状态不是done的任务:
const watcher = db.collection('tasks') .where({ houseId, status: _.neq('done') }) .watch({ onChange: snapshot => { // 用 snapshot.docs 更新页面数据 this.setData({ taskList: snapshot.docs }) }, onError: err => { console.error('watch error:', err) } })watch返回的snapshot类型是QuerySnapshot,里面包含了查询结果和变更类型。页面卸载时需要调用watcher.close()关闭监听,否则会一直占用资源,在 iOS 上会有卡顿感。
并发更新我遇到过一个实际问题:两个人同时打开任务页,A把任务标记完成,B也已经打开了详情页,然后B也点了完成,后写入的数据可能覆盖掉先写入的数据,状态本身没有歧义,但如果在完成时附带时间戳、操作人这类信息,就会被覆盖成错的。解决办法是云函数更新前比对updatedAt,只允许基于最新版本执行更新,这是典型的乐观锁方案。
4. 从开发到上线的避坑经验
4.1 订阅消息下发失败的排查实录
订阅消息是高频踩坑点。我整理了实际遇到的几类问题,发现原因高度集中。
第一种是错误码 43101,意思是用户拒绝授权或者授权次数用完。家庭场景里常见的情况是,长辈试了一下,嫌弹窗烦就拒绝了,后面任务到期自然收不到提醒。这个现象不是 bug,但体验很差。我的处理方法是在任务详情页做一个醒目的卡片,显示“提醒剩余次数:0次,点此再次授权”,而不是让用户误以为系统坏了。
第二种是模板字段不匹配。云开发发送时要求touser是消息接收者的 openid,页面路径必须已经发布上线,miniprogramState如果填developer只能在开发者工具里收到,正式版本里要改回formal。我调试时被这个参数卡了很久,最后在云函数日志里看到返回码才反应过来。
第三种是短时间内发送频率过高,微信会对同一个用户的消息下发做限流。家庭用户每天操作不超过几十次,不容易触发,但如果定时任务全家人统一上午8点发,周末可能瞬间集中很多消息。我在云函数里加了一个简单的节流:同一用户当天最多发3条,多出来的任务合并到晚间推送里。
| 错误码 | 常见原因 | 排查方向 |
|---|---|---|
| 43101 | 用户拒绝或订阅次数耗尽 | 检查订阅授权记录是否存在 |
| 40001 | 缺少参数或格式非法 | 检查模板字段和长度 |
| 40037 | 模板ID无效 | 核对模板ID与标题字段类型 |
| 45009 | 接口调用频率超限 | 控制批量发送节奏 |
4.2 数据库权限与并发造成的脏数据
云开发数据库的默认权限策略我一开始用的是“仅创建者可读写”,这个配置下发现自己创建的houses文档没问题,但其他家庭成员写不了共享的tasks,因为任务是 A 创建的,B 点击完成时会提示无权限。后来又改成“所有用户可读”,结果发现任何用户都能读到所有家庭的房屋编号,这显然不能接受。
最终方案是用安全规则,让用户只能访问自己的houseId数据。比如tasks集合的安全规则可以写成:
{ "read": "doc.houseId == auth.openid || doc.houseId in auth.openid.houses", "write": "doc.houseId in auth.openid.houses" }但需要说明的是,安全规则的表达式其实不适合表达从另一个集合里查 membership 的复杂逻辑,所以我的所有数据操作都强制走云函数,云函数内通过db.collection('houses').where({ _id: houseId, members: _.elemMatch(_.eq(openid)) })校验身份,然后再读写。前端数据库权限一律设置为“仅创建者可读写”,服务端用管理员权限绕过限制。这样既隔离了数据,又不用纠结安全规则的表达能力。
并发问题在任务状态更新上表现最明显。两个家庭成员同一秒对一个任务操作,可能导致status被覆盖。我的做法是用updatedAt比较,更新前先查一次任务详情,然后把updateTime大于本地updatedAt的请求拒绝。家庭应用数据量没那么大,这种轻量乐观锁完全够用。
4.3 定时触发器时间设置的坑
定时触发器第一次配置时,我按传统cron的六位格式写了0 8 * * *,结果一直没触发。后来查文档发现微信云开发的定时触发器是七位表达式,顺序是“秒 分 时 日 月 星期 年”,所以每天8点要写成0 0 8 * * * *,秒位不能省略。
第二个坑是时区。云开发定时器用的是 UTC 时间,国内用户默认东八区,如果写0 0 0 * * * *,实际上跑的是北京时间早上8点。我把提醒任务配置成0 0 8 * * * *,其实是 UTC 8点,也就是北京时间16点,完全背离了“早上提醒”的预期。后来在代码里做了时区换算:
function getBeijingTimeString(date) { const utc = date.getTime() + date.getTimezoneOffset() * 60000 return new Date(utc + 8 * 3600000) }更稳妥的做法依然是触发后取当前时间,再通过Intl或者手动加8小时得到北京时间,再进行查询和业务判断。这个坑不踩一次很难意识到,写在这里给后面的人提个醒。
第三个坑是触发器部署后不是立即生效,需要重新上传部署云函数,并且在云开发控制台的“云函数-触发器”页面确认已经出现定时任务。有时候改完config.json忘了重新部署,看起来配了实际没生效,这个问题只能靠自查解决。
4.4 隐私合规与云函数安全边界
做任何涉及家庭成员数据的小程序都要重视隐私保护。在小程序后台需要配置用户隐私保护指引,声明收集昵称、头像、家庭信息等用途;采集用户信息前必须有弹窗说明,不能偷偷传数据。
头像昵称这块有个明显的变化:过去可以直接调用wx.getUserProfile,现在政策调整后,建议优先使用小程序提供的头像昵称填写能力,让用户自己选择头像和昵称,这样省去授权弹窗,也减少隐私风险。我在“我的”页面布局了微信原生提供的昵称输入框和头像选择组件,接起来比自定义上传要省很多事。
后端日志里也要小心,避免把 openid 明文打印出来。openid 作为用户的持久标识,一旦泄露结合其他信息可能追踪到个人。云函数返回的错误信息我统一改成语义化消息,把内部错误码和 openid 藏在日志里,只向前端返回业务提示。清理日志是上线前必做的一件事。
最后提一个我个人的习惯:所有用户产生的数据都属于家庭这个组织,而不是个人。所以数据库集合里每个文档都带houseId,每次云函数调用都先校验这个标识,中途出现过一次因为忘记带上houseId导致查全表的情况,虽然数据量小没爆出问题,但这种习惯一旦养成,项目大了会很难追查。
多说两句
项目做完以后,我自己在家里用了半年,最大的感受是:工具做得再好,最终还是要落到家庭成员的理解成本上。有些功能技术上是合理的,但老年人用起来就是别扭。比如订阅消息第一次弹窗,他们根本不知道这是什么,后来我把授权入口改得极其直白,写了一句“点这里,明天早上提醒你交水费”,接受度立刻上来了。
最后分享一个小技巧:不要把“今日待办”和“历史任务”完全分开。我把过期任务每天自动留在今日列表里,同时附上一个红色的“超期”标记,家庭成员养成习惯后,每天早上只需要看一屏就能知道今天谁欠着什么。这个设计没有太复杂,但确实是家里老人愿意长期用的核心原因。