小程序云开发数据模型设计:礼账系统的三表结构

前言

做小程序云开发时,数据模型设计是第一步也是最重要的一步。最近开发礼账小程序,踩了一些坑,总结出来给做云开发的同学参考。

为什么不用两表结构?

最初设计时,我用了最简单的两表结构:

复制代码
books(账本表)
gift_records(记录表)

记录表里直接存contactName(联系人姓名字符串)。这样做的问题很快暴露:

问题1:无法按人统计往来

用户想看"和张三的累计往来",需要这样查询:

javascript 复制代码
// ❌ 两表结构:每次查询都要全表扫描
const records = await db.collection('gift_records')
  .where({ bookId, contactName: '张三' })
  .get()

// 然后前端遍历计算收礼总额、送礼总额、净额

当记录数达到几千条时,这个查询很慢。

问题2:无法管理联系人别名

旧账上记的是"老张",新账上记的是"张建国",实际是同一个人。两表结构无法建立别名关系,导致同一个人的往来被分散在多个名字下。

问题3:无法给联系人打标签

用户想按"同事/亲戚/朋友"筛选,两表结构做不到(只能在每条记录上重复打标签)。

三表结构设计

最终采用"账本-联系人-记录"三表结构:

books(账本表)

javascript 复制代码
{
  _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(联系人表)

javascript 复制代码
{
  _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(记录表)

javascript 复制代码
{
  _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. 金额用分存储

javascript 复制代码
// ❌ 错误:用元存储,浮点数精度问题
{ amount: 800.10 }

// ✅ 正确:用分存储,整数运算
{ amount: 80010 }  // 800.10元

前端显示时除以100:

javascript 复制代码
function formatAmount(amountInCents) {
  return (amountInCents / 100).toFixed(2)
}

2. 联系人别名机制

javascript 复制代码
// 查询时,先按所有别名找到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字段,避免每次查询都聚合计算:

javascript 复制代码
// 插入记录后,更新联系人的冗余统计
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. 软删除

javascript 复制代码
// ❌ 硬删除:数据彻底没了
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()

索引设计

云开发数据库支持建索引,对常用查询路径建复合索引:

javascript 复制代码
// gift_records 表索引
// 索引1:按账本+日期查询(账本详情页)
{ bookId: 1, date: -1 }

// 索引2:按联系人查询(联系人详情页)
{ contactId: 1, date: -1 }

// 索引3:按创建者查询(我的记录)
{ createdBy: 1, createdAt: -1 }

// contacts 表索引
// 索引1:按归属用户+姓名查询
{ ownerId: 1, name: 1 }

查询优化

按人查询往来

javascript 复制代码
// 三表结构:一次查询拿到所有数据
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  // 直接用冗余字段,不用再算
  }
}

跨账本合并查询

javascript 复制代码
// 查询某人在所有账本中的总往来
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条件限制

javascript 复制代码
// ❌ 错误:where条件不能超过一定复杂度
db.collection('gift_records').where({
  bookId: 'xxx',
  contactName: _.in(['张三', '李四', '王五', ...])  // 数组太长会报错
})

// ✅ 正确:用contactId关联,where条件简单
db.collection('gift_records').where({
  contactId: 'contact_xxx'
})

坑2:orderBy字段必须有索引

javascript 复制代码
// ❌ 没建索引的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:冗余字段更新不及时

javascript 复制代码
// 插入记录后忘了更新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
}

总结

三表结构(账本-联系人-记录)是礼账系统的合理设计,核心优势:

  1. 按人统计高效:通过contactId关联,一次查询拿到所有数据
  2. 别名管理:contacts表的aliases数组解决"老张=张建国"问题
  3. 标签分类:contacts表的tags数组支持按关系筛选
  4. 冗余统计:用空间换时间,避免每次聚合计算
  5. 软删除:数据可恢复,用户更安心

做云开发的同学,数据模型设计一定要想清楚------后期改表结构成本很高。核心原则:以业务核心维度(这里是"人")组织数据,而不是以最直观的维度("交易")组织。


作者简介: 本文由"1024House"技术团队撰写。简记往来是一款专注于人情礼金管理的小程序,支持批量录入、双向差额统计、多人协作和云端备份。搜索"简记往来"即可体验。

相关推荐
爱折腾的小黑牛1 小时前
礼账小程序的数据安全方案:加密与备份
数据库·安全
JNX_SEMI1 小时前
AT2659S L1频段LNA:0.8dB噪声系数与20dB高增益设计解析
数据库
XUHUOJUN2 小时前
Azure Local GPU 部署与企业应用场景指南(下篇)
microsoft·架构·nvidia·azure local
爱勇宝3 小时前
3位工程师靠“删AI代码”创业,一周收费1万美元:以后最贵的能力,可能不是写代码
前端·后端·架构
吠品3 小时前
PG_GBK 与 PG_UTF8 编码冲突:一次数据导入踩坑复盘
数据库
lld9510273 小时前
我做了一个 AI Agent Skill 云:用 MCP 在 Claude Code、Codex、OpenCode 之间同步技能
数据库·人工智能
●VON3 小时前
HarmonyKit | 鸿蒙开发:项目目录结构与多模块架构最佳实践
华为·架构·单元测试·harmonyos·鸿蒙
没落英雄4 小时前
6. 从零搭建一个 AI Agent —— 构建 Web 前端,让用户能和 agent 实时交互
前端·人工智能·架构
ttwuai4 小时前
GoFrame 连接 MySQL 报错排查:parseTime、时区和 utf8mb4 怎么配
数据库·mysql·adb