前言
做小程序云开发时,数据模型设计是第一步也是最重要的一步。最近开发礼账小程序,踩了一些坑,总结出来给做云开发的同学参考。
为什么不用两表结构?
最初设计时,我用了最简单的两表结构:
books(账本表)
gift_records(记录表)
记录表里直接存contactName(联系人姓名字符串)。这样做的问题很快暴露:
问题1:无法按人统计往来
用户想看"和张三的累计往来",需要这样查询:
// ❌ 两表结构:每次查询都要全表扫描
const records = await db.collection('gift_records')
.where({ bookId, contactName: '张三' })
.get()
// 然后前端遍历计算收礼总额、送礼总额、净额
当记录数达到几千条时,这个查询很慢。
问题2:无法管理联系人别名
旧账上记的是"老张",新账上记的是"张建国",实际是同一个人。两表结构无法建立别名关系,导致同一个人的往来被分散在多个名字下。
问题3:无法给联系人打标签
用户想按"同事/亲戚/朋友"筛选,两表结构做不到(只能在每条记录上重复打标签)。
三表结构设计
最终采用"账本-联系人-记录"三表结构:
books(账本表)
{
_id: "book_xxx",
name: "2026年婚礼",
type: "wedding", // wedding/birthday/funeral/daily
ownerId: "openid_xxx",
members: [
{ openid: "openid_xxx", role: "admin", joinedAt: Date }
],
createdAt: Date,
stats: { // 冗余统计字段
totalReceive: 8600000, // 分为单位
totalSend: 1200000,
recordCount: 156
}
}
contacts(联系人表)
{
_id: "contact_xxx",
ownerId: "openid_xxx", // 归属用户
bookIds: ["book_xxx"], // 关联账本(可多个)
name: "张建国",
aliases: ["老张", "张叔"], // 别名数组
tags: ["同事", "父亲老同事"],
remark: "父亲的同事,每年春节走动",
stats: { // 冗余统计
totalReceive: 80000,
totalSend: 60000,
netAmount: 20000,
recordCount: 3,
lastRecordDate: Date
},
createdAt: Date
}
gift_records(记录表)
{
_id: "record_xxx",
bookId: "book_xxx",
contactId: "contact_xxx", // 关联联系人
contactName: "张建国", // 冗余(方便列表显示)
type: "receive", // receive收礼/send送礼
amount: 80000, // 分为单位
date: Date,
paymentMethod: "cash", // cash现金/wechat
remark: "婚礼当天",
createdBy: "openid_xxx",
createdAt: Date,
isDeleted: false // 软删除标记
}
关键设计决策
1. 金额用分存储
// ❌ 错误:用元存储,浮点数精度问题
{ amount: 800.10 }
// ✅ 正确:用分存储,整数运算
{ amount: 80010 } // 800.10元
前端显示时除以100:
function formatAmount(amountInCents) {
return (amountInCents / 100).toFixed(2)
}
2. 联系人别名机制
// 查询时,先按所有别名找到contactId
async function findContactByName(ownerId, name) {
const db = cloud.database()
const _ = db.command
const res = await db.collection('contacts')
.where({
ownerId,
_.or([
{ name: name },
{ aliases: name }
])
})
.get()
return res.data[0] // 返回第一个匹配的
}
3. 冗余统计字段
contacts表里冗余存了stats字段,避免每次查询都聚合计算:
// 插入记录后,更新联系人的冗余统计
async function updateContactStats(contactId) {
const db = cloud.database()
const records = await db.collection('gift_records')
.where({ contactId, isDeleted: false })
.get()
let totalReceive = 0, totalSend = 0
records.data.forEach(r => {
if (r.type === 'receive') totalReceive += r.amount
else totalSend += r.amount
})
await db.collection('contacts').doc(contactId).update({
data: {
stats: {
totalReceive,
totalSend,
netAmount: totalReceive - totalSend,
recordCount: records.data.length,
lastRecordDate: records.data[records.data.length - 1].date
}
}
})
}
4. 软删除
// ❌ 硬删除:数据彻底没了
await db.collection('gift_records').doc(id).remove()
// ✅ 软删除:可恢复
await db.collection('gift_records').doc(id).update({
data: { isDeleted: true, deletedAt: new Date() }
})
// 查询时过滤已删除的
const records = await db.collection('gift_records')
.where({ bookId, isDeleted: _.neq(true) })
.get()
索引设计
云开发数据库支持建索引,对常用查询路径建复合索引:
// gift_records 表索引
// 索引1:按账本+日期查询(账本详情页)
{ bookId: 1, date: -1 }
// 索引2:按联系人查询(联系人详情页)
{ contactId: 1, date: -1 }
// 索引3:按创建者查询(我的记录)
{ createdBy: 1, createdAt: -1 }
// contacts 表索引
// 索引1:按归属用户+姓名查询
{ ownerId: 1, name: 1 }
查询优化
按人查询往来
// 三表结构:一次查询拿到所有数据
async function getContactRecords(contactId) {
const db = cloud.database()
// 并行查询:联系人信息 + 往来记录
const [contact, records] = await Promise.all([
db.collection('contacts').doc(contactId).get(),
db.collection('gift_records')
.where({ contactId, isDeleted: false })
.orderBy('date', 'desc')
.get()
])
return {
contact: contact.data,
records: records.data,
stats: contact.data.stats // 直接用冗余字段,不用再算
}
}
跨账本合并查询
// 查询某人在所有账本中的总往来
async function getContactAllBooks(ownerId, contactName) {
const db = cloud.database()
const _ = db.command
// 先找到该联系人在所有账本中的记录
const contacts = await db.collection('contacts')
.where({
ownerId,
_.or([{ name: contactName }, { aliases: contactName }])
})
.get()
const contactIds = contacts.data.map(c => c._id)
const records = await db.collection('gift_records')
.where({ contactId: _.in(contactIds) })
.orderBy('date', 'desc')
.get()
// 按账本分组
const byBook = {}
records.data.forEach(r => {
if (!byBook[r.bookId]) byBook[r.bookId] = []
byBook[r.bookId].push(r)
})
return { contactName, totalRecords: records.data.length, byBook }
}
踩坑记录
坑1:云数据库where条件限制
// ❌ 错误:where条件不能超过一定复杂度
db.collection('gift_records').where({
bookId: 'xxx',
contactName: _.in(['张三', '李四', '王五', ...]) // 数组太长会报错
})
// ✅ 正确:用contactId关联,where条件简单
db.collection('gift_records').where({
contactId: 'contact_xxx'
})
坑2:orderBy字段必须有索引
// ❌ 没建索引的orderBy会报错或很慢
db.collection('gift_records')
.where({ bookId })
.orderBy('amount', 'desc') // amount没建索引
// ✅ 建好索引再orderBy
// 先在控制台建 { bookId: 1, date: -1 } 索引
db.collection('gift_records')
.where({ bookId })
.orderBy('date', 'desc') // 用建了索引的字段
坑3:冗余字段更新不及时
// 插入记录后忘了更新contact.stats,导致统计不准
// 解决:用云函数封装插入逻辑,确保统计同步更新
async function insertRecord(record) {
const db = cloud.database()
// 1. 插入记录
const res = await db.collection('gift_records').add({ data: record })
// 2. 更新联系人统计
await updateContactStats(record.contactId)
// 3. 更新账本统计
await updateBookStats(record.bookId)
return res
}
总结
三表结构(账本-联系人-记录)是礼账系统的合理设计,核心优势:
- 按人统计高效:通过contactId关联,一次查询拿到所有数据
- 别名管理:contacts表的aliases数组解决"老张=张建国"问题
- 标签分类:contacts表的tags数组支持按关系筛选
- 冗余统计:用空间换时间,避免每次聚合计算
- 软删除:数据可恢复,用户更安心
做云开发的同学,数据模型设计一定要想清楚——后期改表结构成本很高。核心原则:以业务核心维度(这里是"人")组织数据,而不是以最直观的维度("交易")组织。
作者简介: 本文由"1024House"技术团队撰写。简记往来是一款专注于人情礼金管理的小程序,支持批量录入、双向差额统计、多人协作和云端备份。搜索"简记往来"即可体验。