企业级 RBAC 实战 (九):用户管理与 SQL 复杂查询优化》

45 阅读5分钟

前几篇我们搞定了登录、权限和动态菜单。现在,系统的大门已经打开,是时候管理那些“进门的人”了。本篇将手把手带你实现一个标准的企业级“用户管理”模块。我们将重点攻克多条件模糊搜索SQL 分页与连表查询(JOIN)的性能优化,以及如何在前端优雅地处理Loading 状态与数据回显

学习之前先浏览 前置专栏文章

一、 引言:不仅仅是 CRUD

很多新手在写“用户管理”时,往往只是简单地 SELECT * FROM sys_users。但在真实的企业场景中,我们面临着这样的挑战:

  1. 关联数据:用户表只有 role_id,但前端要显示“超级管理员”这个中文名。
  2. 海量数据:如果有 10 万个用户,一次性查出来浏览器会卡死,必须分页。
  3. 复杂搜索:老板说,“我要搜所有姓张的、并且是经理角色的、且账号状态是启用的用户”。

本篇将通过实现这些需求,带你掌握后端 SQL 组装的核心技巧。

二、 后端实现:打造高性能列表接口

1. 数据库回顾

我们主要涉及两张表:

  • sys_users (用户表):存了 username, nickname, role_id 等。
  • sys_roles (角色表):存了 role_name。

2. 核心难点:动态 SQL 与分页

我们需要在一个接口 (GET /user/list) 中同时处理:搜索、分页、排序、连表。

文件:routes/user.js

router.get('/list', authMiddleware, async (req, res, next) => {
  try {
    // 1. 获取前端参数,并设置默认值
    let { pageNum = 1, pageSize = 10, username, phone, status } = req.query
    
    // 转换类型,防止 SQL 注入或计算错误
    pageNum = parseInt(pageNum)
    pageSize = parseInt(pageSize)
    const offset = (pageNum - 1) * pageSize

    // 2. 构建基础 SQL
    // 技巧:WHERE 1=1 是为了后面方便无脑拼接 AND 语句
    let whereSql = ' WHERE 1=1 '
    let params = []

    // 3. 动态拼接查询条件
    if (username) {
      whereSql += ' AND u.username LIKE ? '
      params.push(`%${username}%`) // 模糊查询
    }
    if (phone) {
      whereSql += ' AND u.phone LIKE ? '
      params.push(`%${phone}%`)
    }
    if (status !== undefined && status !== '') {
      whereSql += ' AND u.status = ? '
      params.push(status)
    }

    // 4. 查询总条数 (用于前端计算分页)
    // 优化点:查总数不需要 LEFT JOIN,只查主表即可,性能更高
    const countSql = `SELECT COUNT(*) as total FROM sys_users u ${whereSql}`
    const [countResult] = await pool.query(countSql, params)
    const total = countResult[0].total

    // 5. 查询当前页数据 (LEFT JOIN 角色表)
    // 优化点:只查询需要的字段,不要 SELECT * (虽然写 * 方便,但在大表中影响性能)
    const listSql = `
      SELECT u.id, u.username, u.nickname, u.phone, u.status, u.created_at, u.role_id,
             r.role_name 
      FROM sys_users u
      LEFT JOIN sys_roles r ON u.role_id = r.id
      ${whereSql}
      ORDER BY u.created_at DESC
      LIMIT ? OFFSET ?
    `
    
    // 注意:LIMIT 和 OFFSET 的参数要追加在 params 后面
    const [rows] = await pool.query(listSql, [...params, pageSize, offset])

    res.json({
      code: 200,
      data: {
        list: rows,
        total,
        pageNum,
        pageSize
      }
    })
  } catch (err) {
    next(err)
  }
})

3. SQL 优化深度解析

我在上面的代码中运用了几个关键的优化思想:

  1. Count 分离

    • 很多 ORM 框架喜欢生成一个包含 JOIN 的 Count 语句。
    • 优化前:SELECT COUNT(*) FROM users u LEFT JOIN roles r ... (慢,因为要关联两张表)。
    • 优化后:SELECT COUNT(*) FROM users u ... (快,只查主表)。因为关联角色并不会改变用户的总行数。
  2. LEFT JOIN 获取角色名

    • 新手写法:先查出 10 个用户,然后写个 for 循环,循环 10 次去查角色表。这就是著名的 N+1 问题,性能极差。
    • 高手写法:一个 LEFT JOIN 直接把关联数据带出来,数据库层面一次搞定。
  3. 按需 Select

    • 我明确指定了 u.id, u.username... 而不是 u.*。
    • 原因:用户表里可能包含 password(加密串)、token 等敏感或大字段,不仅浪费带宽,还容易泄露安全信息。

三、 前端实现:交互细节打磨

文件:views/system/user/index.vue

<template> <div class="p-6">
    ... form表单和table 展示就行
</template>

2. 逻辑实现 (Composition API)

<script setup lang="ts">
import { reactive, ref, onMounted } from 'vue'
import { getUserList, updateUserStatus, deleteUser } from '@/api/user'
import { ElMessage, ElMessageBox } from 'element-plus'

// 响应式状态
const loading = ref(false)
const userList = ref([])
const total = ref(0)

// 查询参数:与后端接口字段一一对应
const queryParams = reactive({
  pageNum: 1,
  pageSize: 10,
  username: '',
  phone: '',
  status: undefined // undefined 时后端不拼接 SQL,这很重要
})

// 核心:获取列表
const getList = async () => {
  loading.value = true
  try {
    // 调用 API,Axios 会自动过滤 undefined 的参数
    const res: any = await getUserList(queryParams)
    userList.value = res.list
    total.value = res.total
  } finally {
    loading.value = false
  }
}

// 搜索事件
const handleQuery = () => {
  // 搜索时,通常建议把页码重置为 1,防止你在第 10 页搜一个只有 1 条数据的结果时表格为空
  // 但如果是翻页事件(@current-change)触发的,则不重置
  // 这里做一个简单处理,翻页组件会自动更新 queryParams.pageNum,我们直接调接口即可
  getList()
}

// 重置事件
const resetQuery = () => {
  // 手动清空参数
  queryParams.username = ''
  queryParams.phone = ''
  queryParams.status = undefined
  queryParams.pageNum = 1 // 重置回第一页
  getList()
}

// 状态修改
const handleStatusChange = async (row: any) => {
  try {
    await updateUserStatus({ id: row.id, status: row.status })
    ElMessage.success('状态更新成功')
  } catch (error) {
    // 接口失败,把开关状态切回去
    row.status = row.status === 1 ? 0 : 1
  }
}

// 格式化时间
const formatTime = (time: string) => {
  if(!time) return ''
  return new Date(time).toLocaleString() // 简单格式化,生产环境建议用 dayjs
}

onMounted(() => {
  getList()
})
</script>

四、 进阶:索引与性能优化 (Senior Tips)

当用户量达到 10 万级、100 万级时,上面的代码会变慢。作为一名资深开发,你需要提前布局:

添加索引 (Index)
SQL 中的 WHERE 条件和 ORDER BY 字段必须有索引。

-- 给搜索字段加索引
ALTER TABLE sys_users ADD INDEX idx_username (username);
ALTER TABLE sys_users ADD INDEX idx_phone (phone);
-- 给排序字段加索引
ALTER TABLE sys_users ADD INDEX idx_created_at (created_at);
-- 给外键加索引 (JOIN 时必用)
ALTER TABLE sys_users ADD INDEX idx_role_id (role_id);

五、 下篇预告

用户列表做好了,但我们还需要给用户分配角色,给角色分配权限。这是 RBAC 的核心枢纽。

在下一篇  《全栈 RBAC 实战 (10):角色管理与权限分配的深度递归逻辑》  中,我们将挑战前端最复杂的组件之一:El-Tree 的回显与半选状态处理,以及如何在后端利用事务保证权限数据的一致性。

敬请期待!

具备完整功能后台管理系统的代码仓库:

感谢大家的star

前端:前端

后端:后端