前言
做工具类小程序,多人协作是常见需求。最近在礼账小程序里实现了三级权限(管理员/编辑者/查看者),记录一下前端和云函数的实现,给做协作功能的同学参考。
权限模型
三级权限定义
| 操作 | 管理员(admin) | 编辑者(editor) | 查看者(viewer) |
|---|---|---|---|
| 查看记录 | ✅ | ✅ | ✅ |
| 添加记录 | ✅ | ✅ | ❌ |
| 修改自己的记录 | ✅ | ✅ | ❌ |
| 修改他人的记录 | ✅ | ❌ | ❌ |
| 删除记录 | ✅ | ❌ | ❌ |
| 邀请成员 | ✅ | ❌ | ❌ |
| 移除成员 | ✅ | ❌ | ❌ |
| 修改权限 | ✅ | ❌ | ❌ |
| 删除账本 | ✅ | ❌ | ❌ |
| 导出数据 | ✅ | ✅ | ❌ |
场景对应
- 夫妻共享婚礼账本:双方都是admin或editor
- 子女为老人寿宴记账:子女是editor,老人是viewer
- 兄弟姐妹共操办白事:多人都是editor
- 临时帮忙记账:给editor权限,事后改成viewer
数据模型
权限信息存在books表的members数组里:
// books 表
{
_id: "book_xxx",
name: "2026年婚礼",
ownerId: "openid_admin", // 创建者(天然admin)
members: [
{
openid: "openid_admin",
role: "admin",
joinedAt: Date,
invitedBy: "openid_admin"
},
{
openid: "openid_spouse",
role: "editor",
joinedAt: Date,
invitedBy: "openid_admin"
},
{
openid: "openid_parent",
role: "viewer",
joinedAt: Date,
invitedBy: "openid_admin"
}
]
}
云函数:权限校验
所有写操作前先校验权限:
// utils/permission.js
const PERMISSIONS = {
admin: ['view', 'add', 'editOwn', 'editAny', 'delete', 'invite', 'removeMember', 'changeRole', 'deleteBook', 'export'],
editor: ['view', 'add', 'editOwn', 'export'],
viewer: ['view']
}
async function checkPermission(openid, bookId, action) {
const db = cloud.database()
const book = await db.collection('books').doc(bookId).get()
if (!book.data) {
throw new Error('账本不存在')
}
const member = book.data.members.find(m => m.openid === openid)
if (!member) {
throw new Error('无权访问此账本')
}
if (!PERMISSIONS[member.role].includes(action)) {
throw new Error(`权限不足:需要${action}权限`)
}
return { book: book.data, member }
}
module.exports = { checkPermission, PERMISSIONS }
云函数:邀请成员
用邀请码机制,避免直接暴露openid:
// cloudfunctions/inviteMember/index.js
const cloud = require('wx-server-sdk')
cloud.init()
exports.main = async (event, context) => {
const { bookId, role } = event
const openid = cloud.getWXContext().OPENID
// 校验权限:只有admin能邀请
const { checkPermission } = require('./permission')
await checkPermission(openid, bookId, 'invite')
// 生成邀请码
const inviteCode = generateInviteCode()
// 存入invitations表,24小时有效
const db = cloud.database()
await db.collection('invitations').add({
data: {
code: inviteCode,
bookId,
role,
inviter: openid,
createdAt: new Date(),
expiresAt: new Date(Date.now() + 24 * 60 * 60 * 1000),
used: false
}
})
return { inviteCode }
}
function generateInviteCode() {
return Math.random().toString(36).substring(2, 10).toUpperCase()
}
云函数:接受邀请
// cloudfunctions/acceptInvite/index.js
exports.main = async (event, context) => {
const { inviteCode } = event
const openid = cloud.getWXContext().OPENID
const db = cloud.database()
// 查询邀请码
const invite = await db.collection('invitations')
.where({ code: inviteCode, used: false })
.get()
if (invite.data.length === 0) {
return { error: '邀请码无效或已使用' }
}
const invitation = invite.data[0]
// 检查是否过期
if (new Date() > new Date(invitation.expiresAt)) {
return { error: '邀请码已过期' }
}
// 检查是否已是成员
const book = await db.collection('books').doc(invitation.bookId).get()
const isMember = book.data.members.some(m => m.openid === openid)
if (isMember) {
return { error: '已是该账本成员' }
}
// 加入账本
const _ = db.command
await db.collection('books').doc(invitation.bookId).update({
data: {
members: _.push([{
openid,
role: invitation.role,
joinedAt: new Date(),
invitedBy: invitation.inviter
}])
}
})
// 标记邀请码已使用
await db.collection('invitations').doc(invitation._id).update({
data: { used: true, usedBy: openid, usedAt: new Date() }
})
return { bookId: invitation.bookId, role: invitation.role }
}
前端实现
邀请页面
<!-- pages/invite/invite.wxml -->
<view class="container">
<view class="form">
<view class="form-item">
<text>权限角色</text>
<radio-group bindchange="onRoleChange">
<label class="radio">
<radio value="admin" />管理员(完全权限)
</label>
<label class="radio">
<radio value="editor" checked/>编辑者(可录入)
</label>
<label class="radio">
<radio value="viewer" />查看者(只读)
</label>
</radio-group>
</view>
<button type="primary" bindtap="onGenerate" loading="{{loading}}">
生成邀请链接
</button>
</view>
<view wx:if="{{inviteCode}}" class="result">
<view class="tip">把链接发给家人,24小时内有效:</view>
<view class="link" bindtap="onCopy">
{{shareLink}}
</view>
<button bindtap="onShare">分享</button>
</view>
</view>
// pages/invite/invite.js
Page({
data: {
bookId: '',
role: 'editor',
inviteCode: '',
shareLink: '',
loading: false
},
onLoad(options) {
this.setData({ bookId: options.bookId })
},
onRoleChange(e) {
this.setData({ role: e.detail.value })
},
async onGenerate() {
this.setData({ loading: true })
try {
const res = await wx.cloud.callFunction({
name: 'inviteMember',
data: {
bookId: this.data.bookId,
role: this.data.role
}
})
const inviteCode = res.result.inviteCode
const shareLink = `https://example.com/invite?code=${inviteCode}`
this.setData({ inviteCode, shareLink })
} catch (err) {
wx.showToast({ title: '生成失败', icon: 'error' })
} finally {
this.setData({ loading: false })
}
},
onCopy() {
wx.setClipboardData({
data: this.data.shareLink,
success: () => wx.showToast({ title: '已复制' })
})
},
onShare() {
wx.shareFileMessage({
// 分享到会话
})
}
})
权限控制的前端显示
根据权限显示/隐藏按钮:
// pages/book-detail/book-detail.js
Page({
data: {
book: null,
myRole: 'viewer',
canEdit: false,
canDelete: false,
canInvite: false,
canExport: false
},
async onLoad(options) {
await this.loadBook(options.bookId)
},
async loadBook(bookId) {
const res = await wx.cloud.callFunction({
name: 'getBookDetail',
data: { bookId }
})
const { book, myRole } = res.result
this.setData({
book,
myRole,
canEdit: ['admin', 'editor'].includes(myRole),
canDelete: myRole === 'admin',
canInvite: myRole === 'admin',
canExport: ['admin', 'editor'].includes(myRole)
})
}
})
<!-- pages/book-detail/book-detail.wxml -->
<view class="container">
<view class="header">
<text class="title">{{book.name}}</text>
<text class="role">我的角色:{{roleText}}</text>
</view>
<!-- 记录列表 -->
<view class="record-list">
<block wx:for="{{records}}" wx:key="_id">
<view class="record-item">
<view class="info">
<text>{{item.contactName}}</text>
<text class="amount">{{item.amountText}}</text>
</view>
<view class="actions" wx:if="{{canEdit}}">
<button size="mini" bindtap="onEdit" data-id="{{item._id}}">编辑</button>
<button wx:if="{{canDelete}}" size="mini" type="warn" bindtap="onDelete" data-id="{{item._id}}">删除</button>
</view>
</view>
</block>
</view>
<!-- 底部操作 -->
<view class="footer">
<button wx:if="{{canEdit}}" type="primary" bindtap="onAdd">添加记录</button>
<button wx:if="{{canInvite}}" bindtap="onInvite">邀请家人</button>
<button wx:if="{{canExport}}" bindtap="onExport">导出Excel</button>
</view>
</view>
踩坑记录
坑1:权限校验要在云函数做
// ❌ 错误:只在前端隐藏按钮,云函数不校验
// 用户可以通过直接调用云函数绕过
// ✅ 正确:云函数必须校验权限
exports.main = async (event, context) => {
const openid = cloud.getWXContext().OPENID
await checkPermission(openid, event.bookId, 'delete') // 必须校验
// ...执行删除
}
坑2:邀请码要设过期时间
// ❌ 邀请码永久有效,有安全风险
// ✅ 24小时过期
{
expiresAt: new Date(Date.now() + 24 * 60 * 60 * 1000)
}
坑3:成员上限
// 避免一个账本成员过多
async function inviteMember(bookId, role) {
const book = await db.collection('books').doc(bookId).get()
if (book.data.members.length >= 20) {
return { error: '成员数量已达上限(20人)' }
}
// ...继续邀请
}
总结
三级权限的实现核心:
- 权限矩阵明确:每个角色能做什么,一目了然
- 云函数校验:前端隐藏按钮只是体验优化,真正的权限校验在云函数
- 邀请码机制:不暴露openid,24小时有效,点击即加入
- 前端权限控制:根据role显示/隐藏按钮,减少无效操作
- 成员上限:避免一个账本成员过多
适合任何需要多人协作且权限分级的小程序场景,如团队记账、家庭相册、协作笔记等。
作者简介: 本文由"1024House"技术团队撰写。简记往来是一款专注于人情礼金管理的小程序,支持批量录入、双向差额统计、多人协作和云端备份。搜索"简记往来"即可体验。