从零开发一个「家庭点单」小程序:微信云开发 + 虚拟货币实战全记录

一、项目背景

场景很朴素:家里做饭,家人点菜

传统做法是群里问「今天想吃什么」,信息杂乱、不好统计、没有记录。于是萌生了一个想法:做一个家庭内部专用的点单小程序,让家里人像点外卖一样点菜。

核心需求:

  • 双角色:店长(管理员)+ 家庭成员(普通用户)
  • 虚拟货币:店长给家人「发币」,家人用币点菜,不用真实支付
  • 菜品带价格(虚拟币计价)
  • 完整的点单闭环:点菜 → 下单扣款 → 订单跟踪 → 取消退款

二、技术选型

方案理由
前端微信原生小程序家庭内部用,不需要多端
后端微信云开发(云函数 + 云数据库)免服务器、免备案、免域名,个人开发者首选
支付虚拟货币个人主体不能开通微信支付,虚拟币完美规避
账号自定义账户密码体系家庭内部用,比微信授权更直接

成本:个人主体注册小程序免费 + 云开发免费环境(未正式发布无限期免费)+ 虚拟货币不走支付,全程 0 成本,用「体验版」给家人用即可。

三、功能清单

最终交付的功能:

  • 账户:注册、登录、修改密码、角色判定、成员管理(停用/删除/重置密码/改昵称/改角色)
  • 菜品:增删改查、定价、库存、上下架、图片上传、可选辣度
  • 点餐:左右分栏、搜索、收藏、点赞点踩、销量、今日菜单
  • 交易:店长发币、余额、流水、下单扣款、订单备注、再来一单
  • 订单:接单、完成、取消、退单退款、新订单广播
  • 个性化:口味偏好/忌口

四、数据库设计

云数据库是文档型(类 MongoDB),用「集合」代替「表」:

集合用途关键字段
user用户username, password(哈希), role, balance, status, preference
category菜品分类name, sort
dish菜品name, price, stock, spicy, spicySelectable, sales, likes, on
order订单userId, items, total, status, remark
order_item订单明细orderId, dishId, qty
favorite收藏userId, dishId
rating评价userId, dishId, type
transaction流水userId, type, amount, balanceAfter
menu_plan今日菜单date, dishIds

五、核心代码

5.1 密码复杂度校验(参考 QQ 注册规则)

账户密码登录必须做强度校验,规则:密码 8-16 位,至少含字母/数字/符号中的两类,禁止连续序列、连续重复、弱密码。

function validatePassword(password, username) {
  if (!password || password.length < 8 || password.length > 16) return '密码长度需为 8-16 位'
  const hasLetter = /[a-zA-Z]/.test(password)
  const hasDigit = /\d/.test(password)
  const hasSymbol = /[^a-zA-Z0-9]/.test(password)
  const typeCount = (hasLetter ? 1 : 0) + (hasDigit ? 1 : 0) + (hasSymbol ? 1 : 0)
  if (typeCount < 2) return '密码需包含字母、数字、符号中的至少两类'
  if (username) {
    if (password === username) return '密码不能与账户名相同'
    if (username.length >= 3 && password.indexOf(username) !== -1) return '密码不能包含账户名'
  }
  // ... 连续序列、重复字符、弱密码黑名单判断
  return null
}

关键点:前端实时校验提示 + 服务端强制二次校验,防止绕过前端直接调接口。

5.2 登录 + bcrypt 加密

密码绝不明文存储,用 bcrypt 加盐哈希:

const bcrypt = require('bcryptjs')

async function login(username, password) {
  const res = await users.where({ username }).limit(1).get()
  if (res.data.length === 0) return { code: 400, msg: '账户或密码错误' }
  const user = res.data[0]
  if (user.status === '停用') return { code: 403, msg: '该账户已被停用' }
  const match = bcrypt.compareSync(password, user.password)
  if (!match) return { code: 400, msg: '账户或密码错误' }
  // 签发 token 并写入用户记录
  const token = crypto.randomBytes(24).toString('hex')
  await users.doc(user._id).update({ data: { token } })
  return { code: 0, msg: '登录成功', token, user: sanitize(user) }
}

5.3 店长发币(事务保证一致性)

发币 = 加余额 + 写流水,必须用数据库事务保证原子性:

async function recharge(user, input) {
  if (user.role !== '店长') return { code: 403, msg: '仅店长可操作' }
  const amount = Number(input.amount)
  const target = await db.collection('user').doc(input.userId).get()
  const newBalance = (target.data.balance || 0) + amount

  await db.runTransaction(async (t) => {
    await t.collection('user').doc(input.userId).update({ data: { balance: newBalance } })
    await t.collection('transaction').add({
      data: { userId: input.userId, type: '充值', amount, balanceAfter: newBalance, createdAt: db.serverDate() }
    })
  })
  return { code: 0, msg: '发币成功' }
}

5.4 下单扣款(防篡改 + 事务)

下单是核心,价格必须从数据库重新查(不能信前端传的价格),余额校验 + 扣款 + 写订单 + 写流水全部在一个事务里:

async function createOrder(user, input) {
  const items = input.items
  // 从数据库重新取菜品,防止前端篡改价格
  const dishRes = await db.collection('dish').where({ _id: _.in(items.map(i => i.dishId)) }).get()
  const dishMap = {}
  dishRes.data.forEach(d => { dishMap[d._id] = d })

  let total = 0
  const detailItems = []
  for (const it of items) {
    const dish = dishMap[it.dishId]
    if (!dish || dish.on === false) return { code: 400, msg: '菜品不可用' }
    total += dish.price * it.qty
    detailItems.push({ dishId: dish._id, name: dish.name, price: dish.price, qty: it.qty })
  }

  const me = await db.collection('user').doc(user._id).get()
  if ((me.data.balance || 0) < total) return { code: 400, msg: '余额不足' }
  const newBalance = me.data.balance - total

  await db.runTransaction(async (t) => {
    await t.collection('user').doc(user._id).update({ data: { balance: newBalance } })
    await t.collection('order').add({ data: { userId: user._id, items: detailItems, total, status: '待接单', ... } })
    await t.collection('transaction').add({ data: { userId: user._id, type: '消费', amount: -total, balanceAfter: newBalance, ... } })
  })
  return { code: 0, msg: '下单成功', total, balance: newBalance }
}

5.5 可选辣度

部分菜品支持顾客选辣度(如麻婆豆腐),部分菜品不支持(如清炒时蔬)。在菜品集合中,我们设计了 spicy(默认辣度)和 spicySelectable(是否可选辣度)两个字段。前端根据 spicySelectable 的值决定是否展示辣度选择器。

数据库字段设计

// dish 集合示例文档
{
  "_id": "dish_001",
  "name": "麻婆豆腐",
  "price": 30,
  "spicy": "中辣", // 默认辣度
  "spicySelectable": true, // 支持顾客选择辣度
  "spicyOptions": ["微辣", "中辣", "特辣"] // 可选辣度列表
}

前端点餐逻辑

// 小程序页面逻辑
Page({
  data: {
    selectedSpicy: '中辣' // 默认选中项
  },
  // 选择辣度
  onSpicyChange(e) {
    this.setData({
      selectedSpicy: e.detail.value
    })
  },
  // 加入购物车
  addToCart(dish) {
    const item = {
      dishId: dish._id,
      name: dish.name,
      price: dish.price,
      qty: 1
    }
    // 如果菜品支持选辣度,则记录用户选择的辣度
    if (dish.spicySelectable) {
      item.spicy = this.data.selectedSpicy
    }
    // ... 加入购物车逻辑
  }
})

后端下单处理

// 在 createOrder 函数中,处理带辣度的订单项
async function createOrder(user, input) {
  // ... 前面的菜品查询和校验逻辑不变
  const detailItems = []
  for (const it of items) {
    const dish = dishMap[it.dishId]
    if (!dish || dish.on === false) return { code: 400, msg: '菜品不可用' }
    total += dish.price * it.qty
    // 构建订单明细项,包含辣度信息
    const detailItem = {
      dishId: dish._id,
      name: dish.name,
      price: dish.price,
      qty: it.qty
    }
    // 如果前端传了辣度且菜品支持选辣度,则记录
    if (it.spicy && dish.spicySelectable) {
      // 可选:校验辣度是否在可选范围内
      if (dish.spicyOptions && !dish.spicyOptions.includes(it.spicy)) {
        return { code: 400, msg: `辣度选项无效,请在 ${dish.spicyOptions.join('、')} 中选择` }
      }
      detailItem.spicy = it.spicy
    } else if (dish.spicy) {
      // 否则使用菜品默认辣度
      detailItem.spicy = dish.spicy
    }
    detailItems.push(detailItem)
  }
  // ... 后续的余额校验和事务操作不变
}

订单展示:在订单详情页和店长接单界面,需要展示每道菜品的具体辣度要求,以便后厨准确制作。

通过以上设计,我们实现了灵活的辣度选择功能,既满足了不同家庭成员的口味偏好,又保持了后端数据的一致性和安全性。

六、踩坑记录(重点)

坑 1:测试号不支持云开发

最初一直卡在「云开发按钮灰色」,查了半天发现:微信测试号不支持云开发。必须正式注册小程序(个人主体免费,邮箱注册 + 身份证认证),才能用云开发。

坑 2:AppID 填错导致「不是小程序」

注册时容易把「公众号」和「小程序」搞混,导致 AppID 类型不对。确认 mp.weixin.qq.com 后台账号类型是「小程序」。

坑 3:云开发按钮消失/变灰

官方给出的原因就三个:① 用了测试号;② 登录微信不是管理员/开发者;③ 是公众号。逐一排查即可。

坑 4:WXSS 不支持中文 class 名

.st-待接单 这种中文选择器会编译报错 unexpected '�'class 名必须用英文,中文状态用 JS 映射:

statusClass(status) {
  return { '待接单': 'st-pending', '已接单': 'st-accepted' }[status] || 'st-pending'
}

坑 5:height: 0 导致列表「消失」

做左右分栏布局时,滚动区从单栏改为包在横向 flex 容器里后,height: 0 会把列表高度压成 0,看起来像「数据没了」。改成 height: 100% 解决。

坑 6:云函数未更新导致「未知操作」

前端调了新接口,但云函数还是旧版,会返回「未知操作」,若多个请求放在 Promise.all 里会导致整体失败。建议主数据单独请求,附加功能单独 try-catch,失败不拖累主流程。

七、部署上线

  1. 注册小程序(个人主体)→ 拿到 AppID
  2. 微信开发者工具导入项目,开通云开发,创建环境
  3. 上传所有云函数
  4. 运行 initDB 云函数初始化数据库
  5. 上传代码 → 后台「版本管理」→「选为体验版」
  6. 「成员管理」→「体验成员」→ 添加家人微信
  7. 家人扫码 → 注册 → 点菜

八、总结

这个项目最大的价值在于:用最小的成本(0 元)完成了一个真实可用的家庭工具

几个关键决策:

  1. 虚拟货币替代真实支付:既绕开了个人主体的支付限制,又完美契合「店长发币」的家庭场景;
  2. 微信云开发:免去了服务器、域名、备案的运维负担;
  3. 事务保证资金安全:发币、下单、退款全部用 db.runTransaction,绝不出现账目不一致。

如果你也想做类似的小工具(家庭记账、物品借用、值班排班等),这套「云开发 + 双角色 + 虚拟货币」的架构可以直接套用。


项目结构(共 77 个文件,6 个云函数):

family-ordering/
├── project.config.json
├── miniprogram/          # 前端
│   ├── app.js / app.json / app.wxss
│   ├── pages/            # login/register/menu/wallet/orders/mine/admin等
│   └── utils/            # request.js / validator.js
└── cloudfunctions/       # 云函数(后端)
    ├── auth/             # 登录注册改密
    ├── dish/             # 菜品CRUD+收藏+评价+今日菜单
    ├── wallet/           # 发币/余额/流水
    ├── order/            # 下单扣款/订单/退款
    ├── member/           # 成员管理
    └── initDB/           # 初始化数据库

原创文章,欢迎交流指正。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值