小程序多人协作的权限设计:三级权限的实现

14 阅读3分钟

前言

做工具类小程序,多人协作是常见需求。最近在礼账小程序里实现了三级权限(管理员/编辑者/查看者),记录一下前端和云函数的实现,给做协作功能的同学参考。

权限模型

三级权限定义

操作管理员(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人)' }
  }
  // ...继续邀请
}

总结

三级权限的实现核心:

  1. 权限矩阵明确:每个角色能做什么,一目了然
  2. 云函数校验:前端隐藏按钮只是体验优化,真正的权限校验在云函数
  3. 邀请码机制:不暴露openid,24小时有效,点击即加入
  4. 前端权限控制:根据role显示/隐藏按钮,减少无效操作
  5. 成员上限:避免一个账本成员过多

适合任何需要多人协作且权限分级的小程序场景,如团队记账、家庭相册、协作笔记等。


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