一、项目背景
场景很朴素:家里做饭,家人点菜。
传统做法是群里问「今天想吃什么」,信息杂乱、不好统计、没有记录。于是萌生了一个想法:做一个家庭内部专用的点单小程序,让家里人像点外卖一样点菜。
核心需求:
- 双角色:店长(管理员)+ 家庭成员(普通用户)
- 虚拟货币:店长给家人「发币」,家人用币点菜,不用真实支付
- 菜品带价格(虚拟币计价)
- 完整的点单闭环:点菜 → 下单扣款 → 订单跟踪 → 取消退款
二、技术选型
| 层 | 方案 | 理由 |
|---|---|---|
| 前端 | 微信原生小程序 | 家庭内部用,不需要多端 |
| 后端 | 微信云开发(云函数 + 云数据库) | 免服务器、免备案、免域名,个人开发者首选 |
| 支付 | 虚拟货币 | 个人主体不能开通微信支付,虚拟币完美规避 |
| 账号 | 自定义账户密码体系 | 家庭内部用,比微信授权更直接 |
成本:个人主体注册小程序免费 + 云开发免费环境(未正式发布无限期免费)+ 虚拟货币不走支付,全程 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,失败不拖累主流程。
七、部署上线
- 注册小程序(个人主体)→ 拿到 AppID
- 微信开发者工具导入项目,开通云开发,创建环境
- 上传所有云函数
- 运行 initDB 云函数初始化数据库
- 上传代码 → 后台「版本管理」→「选为体验版」
- 「成员管理」→「体验成员」→ 添加家人微信
- 家人扫码 → 注册 → 点菜
八、总结
这个项目最大的价值在于:用最小的成本(0 元)完成了一个真实可用的家庭工具。
几个关键决策:
- 虚拟货币替代真实支付:既绕开了个人主体的支付限制,又完美契合「店长发币」的家庭场景;
- 微信云开发:免去了服务器、域名、备案的运维负担;
- 事务保证资金安全:发币、下单、退款全部用
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/ # 初始化数据库
原创文章,欢迎交流指正。

394

被折叠的 条评论
为什么被折叠?



