人人都会AI编程

26.2 后台管理系统 API:CRUD、分页、筛选、导出

更新时间:2026-07-10

后台管理系统是 Node.js 在企业开发中最常见的落地场景之一。无论是管理用户、商品、订单还是内容,前端界面背后的 API 通常都围绕着一组标准能力:增删改查(CRUD)、分页列表、多条件筛选以及数据导出。这一节将用最直接的方式,把这几块的核心实现讲清楚,让你可以在实际项目中直接参考或复用。

假定我们以“文章管理”为例,使用 Express 作为 Web 框架,MySQL + mysql2 作为数据库驱动,不引入 ORM,以便更清晰地展示原始 API 的设计思路。对于使用 Koa、NestJS 或 MongoDB 的项目,核心逻辑完全相同,只需适配对应的中间件和查询语法。

26.2.1 CRUD 基础接口

CRUD 是最基础的四种数据操作:创建(Create)、读取(Read)、更新(Update)、删除(Delete)。我们通常为每个数据模型暴露一组 RESTful 端点:

POST   /api/articles         创建文章
GET    /api/articles/:id     获取单篇文章
PUT    /api/articles/:id     更新文章
DELETE /api/articles/:id     删除文章

创建文章

// routes/articles.js
const express = require('express');
const router = express.Router();
const db = require('../db'); // 数据库连接池

// POST /api/articles
router.post('/', async (req, res) => {
  try {
    const { title, content, status } = req.body;

    // 参数校验(简化示例,实际推荐用 Joi 或 Zod)
    if (!title || !content) {
      return res.status(400).json({ code: 400, message: '标题和内容不能为空' });
    }

    const [result] = await db.execute(
      'INSERT INTO articles (title, content, status) VALUES (?, ?, ?)',
      [title, content, status || 'draft']
    );

    const newArticle = {
      id: result.insertId,
      title,
      content,
      status,
      created_at: new Date()
    };

    res.status(201).json({ code: 200, data: newArticle, message: '创建成功' });
  } catch (error) {
    console.error('创建文章失败:', error);
    res.status(500).json({ code: 500, message: '服务器错误' });
  }
});

获取单篇文章

// GET /api/articles/:id
router.get('/:id', async (req, res) => {
  try {
    const { id } = req.params;
    const [rows] = await db.execute('SELECT * FROM articles WHERE id = ?', [id]);

    if (rows.length === 0) {
      return res.status(404).json({ code: 404, message: '文章不存在' });
    }

    res.json({ code: 200, data: rows[0] });
  } catch (error) {
    console.error('获取文章失败:', error);
    res.status(500).json({ code: 500, message: '服务器错误' });
  }
});

更新文章

// PUT /api/articles/:id
router.put('/:id', async (req, res) => {
  try {
    const { id } = req.params;
    const { title, content, status } = req.body;

    const [result] = await db.execute(
      'UPDATE articles SET title = ?, content = ?, status = ?, updated_at = NOW() WHERE id = ?',
      [title, content, status, id]
    );

    if (result.affectedRows === 0) {
      return res.status(404).json({ code: 404, message: '文章不存在' });
    }

    res.json({ code: 200, message: '更新成功' });
  } catch (error) {
    console.error('更新文章失败:', error);
    res.status(500).json({ code: 500, message: '服务器错误' });
  }
});

删除文章(软删除)

企业后台通常使用软删除而非物理删除,以避免数据误删无法恢复。我们在表中添加一个 deleted_at 字段,删除时设置时间而非删除记录。

// DELETE /api/articles/:id
router.delete('/:id', async (req, res) => {
  try {
    const { id } = req.params;

    const [result] = await db.execute(
      'UPDATE articles SET deleted_at = NOW() WHERE id = ? AND deleted_at IS NULL',
      [id]
    );

    if (result.affectedRows === 0) {
      return res.status(404).json({ code: 404, message: '文章不存在或已删除' });
    }

    res.json({ code: 200, message: '删除成功' });
  } catch (error) {
    console.error('删除文章失败:', error);
    res.status(500).json({ code: 500, message: '服务器错误' });
  }
});

真实注意点:所有查询中都要过滤掉已软删除的数据(加上 WHERE deleted_at IS NULL),如果使用 ORM(如 Sequelize),可以配置默认的查询作用域自动过滤。

26.2.2 分页与列表查询

后台列表页面几乎总是需要分页。常见的分页方式有两种:基于偏移量(offset)和基于游标(cursor)。对大多数管理后台,offset 分页简单直观,完全够用。

接口设计

GET /api/articles?page=1&pageSize=10

返回值包含分页元信息:

{
  "code": 200,
  "data": {
    "list": [...],
    "pagination": {
      "page": 1,
      "pageSize": 10,
      "total": 156,
      "totalPages": 16
    }
  }
}

实现代码

// GET /api/articles
router.get('/', async (req, res) => {
  try {
    // 解析分页参数,提供默认值和校验
    const page = Math.max(1, parseInt(req.query.page) || 1);
    const pageSize = Math.min(100, Math.max(1, parseInt(req.query.pageSize) || 10));
    const offset = (page - 1) * pageSize;

    // 查询总数(过滤软删除)
    const [countRows] = await db.execute(
      'SELECT COUNT(*) as total FROM articles WHERE deleted_at IS NULL'
    );
    const total = countRows[0].total;

    // 查询列表
    const [rows] = await db.execute(
      'SELECT id, title, status, created_at, updated_at FROM articles WHERE deleted_at IS NULL ORDER BY created_at DESC LIMIT ? OFFSET ?',
      [String(pageSize), String(offset)]
    );

    res.json({
      code: 200,
      data: {
        list: rows,
        pagination: {
          page,
          pageSize,
          total,
          totalPages: Math.ceil(total / pageSize)
        }
      }
    });
  } catch (error) {
    console.error('获取文章列表失败:', error);
    res.status(500).json({ code: 500, message: '服务器错误' });
  }
});

性能提示:如果数据量巨大(百万级以上),COUNT(*) 可能会变慢,可以考虑定期使用计数表或者显示“约 xxx 条”的近似值,但在多数管理后台场景中,直接 COUNT 完全可用。另外 LIMITOFFSET 在深度分页时性能会下降,可以结合查询条件缩小范围(例如要求至少选择一个时间段)。

26.2.3 多条件筛选与排序

管理后台通常需要根据多种字段组合筛选,例如标题关键词、状态、创建时间范围等。我们需要在 GET 请求中接收这些查询参数,动态构造 SQL。

接口示例

GET /api/articles?keyword=Node.js&status=published&startDate=2024-01-01&endDate=2024-12-31&sortBy=created_at&sortOrder=desc&page=1&pageSize=10

实现动态查询

router.get('/', async (req, res) => {
  try {
    const page = Math.max(1, parseInt(req.query.page) || 1);
    const pageSize = Math.min(100, Math.max(1, parseInt(req.query.pageSize) || 10));
    const offset = (page - 1) * pageSize;

    const { keyword, status, startDate, endDate, sortBy, sortOrder } = req.query;

    // 构建 WHERE 子句
    const conditions = ['deleted_at IS NULL'];
    const params = [];

    if (keyword) {
      conditions.push('(title LIKE ? OR content LIKE ?)');
      params.push(`%${keyword}%`, `%${keyword}%`);
    }
    if (status) {
      conditions.push('status = ?');
      params.push(status);
    }
    if (startDate) {
      conditions.push('created_at >= ?');
      params.push(startDate);
    }
    if (endDate) {
      conditions.push('created_at < ?');
      params.push(endDate + ' 23:59:59'); // 包含当天
    }

    const whereClause = ' WHERE ' + conditions.join(' AND ');

    // 排序,只允许白名单字段,防止 SQL 注入
    const allowedSortColumns = ['created_at', 'updated_at', 'title'];
    const allowedSortOrders = ['asc', 'desc'];
    const finalSortBy = allowedSortColumns.includes(sortBy) ? sortBy : 'created_at';
    const finalSortOrder = allowedSortOrders.includes(sortOrder?.toLowerCase()) ? sortOrder : 'desc';
    const orderClause = ` ORDER BY ${finalSortBy} ${finalSortOrder}`;

    // 查询总数(同样应用筛选条件)
    const [countResult] = await db.execute(
      `SELECT COUNT(*) as total FROM articles ${whereClause}`,
      params
    );
    const total = countResult[0].total;

    // 查询数据
    const dataQuery = `SELECT id, title, status, created_at, updated_at FROM articles ${whereClause} ${orderClause} LIMIT ? OFFSET ?`;
    const [rows] = await db.execute(dataQuery, [...params, String(pageSize), String(offset)]);

    res.json({
      code: 200,
      data: {
        list: rows,
        pagination: {
          page,
          pageSize,
          total,
          totalPages: Math.ceil(total / pageSize)
        }
      }
    });
  } catch (error) {
    console.error('查询文章列表失败:', error);
    res.status(500).json({ code: 500, message: '服务器错误' });
  }
});

关键安全点

  • 永远不要将用户输入直接拼接到 SQL 字符串中。使用参数化查询(? 占位符)防止 SQL 注入。
  • 排序字段必须白名单校验。不能将 sortBy 直接拼接,必须映射到允许的列名,否则会有 SQL 注入风险。
  • 日期格式建议用 YYYY-MM-DD,可被 MySQL 直接解析,注意结束日期需要加时间部分以包含当天。

26.2.4 数据导出(CSV/Excel)

后台管理中,“导出”通常指将当前筛选后的列表数据导出为 CSV 或 Excel 文件,供运营人员下载分析。常用的方案有:

  • CSV:简单、体积小、生成速度快,适合纯文本数据。用 Node.js 可直接拼接字符串输出。
  • Excel(xlsx):支持样式、多 sheet 等,但生成稍复杂,可借助 exceljsnode-xlsx 库。

我们以 CSV 导出为例,因为它最直接且无需引入额外依赖。

导出接口设计

GET /api/articles/export?keyword=&status=&startDate=&endDate=

不传分页参数,导出所有符合条件的记录。

实现 CSV 导出

// GET /api/articles/export
router.get('/export', async (req, res) => {
  try {
    const { keyword, status, startDate, endDate } = req.query;

    // 构建与列表相同的筛选逻辑
    const conditions = ['deleted_at IS NULL'];
    const params = [];
    if (keyword) {
      conditions.push('(title LIKE ? OR content LIKE ?)');
      params.push(`%${keyword}%`, `%${keyword}%`);
    }
    if (status) {
      conditions.push('status = ?');
      params.push(status);
    }
    if (startDate) {
      conditions.push('created_at >= ?');
      params.push(startDate);
    }
    if (endDate) {
      conditions.push('created_at < ?');
      params.push(endDate + ' 23:59:59');
    }

    const whereClause = ' WHERE ' + conditions.join(' AND ');
    const [rows] = await db.execute(
      `SELECT title, status, created_at FROM articles ${whereClause} ORDER BY created_at DESC`,
      params
    );

    // 生成 CSV 字符串
    const headers = ['标题', '状态', '创建时间'];
    const csvLines = [headers.join(',')];

    for (const row of rows) {
      // 处理单元格中可能含有的逗号或换行符,需要用双引号包裹并转义
      const title = `"${(row.title || '').replace(/"/g, '""')}"`;
      const status = row.status;
      const createdAt = row.created_at ? new Date(row.created_at).toISOString() : '';
      csvLines.push([title, status, createdAt].join(','));
    }

    const csvContent = '\uFEFF' + csvLines.join('\n'); // 添加 BOM 解决中文乱码

    res.setHeader('Content-Type', 'text/csv; charset=utf-8');
    res.setHeader('Content-Disposition', `attachment; filename=articles_${Date.now()}.csv`);
    res.send(csvContent);
  } catch (error) {
    console.error('导出文章失败:', error);
    res.status(500).json({ code: 500, message: '导出失败' });
  }
});

重要细节
- 添加 UTF-8 BOM(\uFEFF)可以让 Microsoft Excel 正确识别中文编码。
- 对于大数据量导出,不要一次将全部数据加载到内存再拼接,应使用流式查询逐步写入响应。mysql2 支持 .query() 时使用 stream 模式,我们可以逐条生成 CSV 行并 writeres,避免内存爆增。
- 如果需要 Excel 格式,可以引入 exceljs 创建 Workbook,设置字段映射后写入流,同样内存友好。

流式导出优化(防止 OOM)

const { Transform } = require('stream');

router.get('/export-stream', async (req, res) => {
  // ... 构建 SQL 与 params ...
  const connection = await db.getConnection();
  const queryStream = connection.query(
    `SELECT title, status, created_at FROM articles ${whereClause} ORDER BY created_at DESC`
  ).stream();

  // 转换流:将每行对象转为 CSV 行
  const csvTransform = new Transform({
    writableObjectMode: true, // 写入是对象模式
    transform(row, encoding, callback) {
      const title = `"${(row.title || '').replace(/"/g, '""')}"`;
      const line = `${title},${row.status},${row.created_at}\n`;
      callback(null, line);
    }
  });

  res.setHeader('Content-Type', 'text/csv; charset=utf-8');
  res.setHeader('Content-Disposition', `attachment; filename=articles_${Date.now()}.csv`);
  res.write('\uFEFF'); // BOM
  res.write('标题,状态,创建时间\n');

  queryStream.pipe(csvTransform).pipe(res);

  queryStream.on('end', () => {
    connection.release();
  });
  queryStream.on('error', (err) => {
    console.error('查询流错误:', err);
    connection.release();
    if (!res.headersSent) {
      res.status(500).end();
    }
  });
});

使用流式导出可以处理数十万甚至百万条记录,而内存占用保持在很低水平。

26.2.5 前端协作提示

后台管理 API 通常由前端同学调用,因此在设计 API 时应遵循几个让前端更易用的约定:

  1. 统一返回格式:所有接口返回 { code: 200/400/500, data: ..., message: ... },方便前端全局拦截处理。
  2. 分页参数使用 pagepageSize,避免使用 limit/offset 暴露数据库细节。
  3. 筛选参数使用明确的 Query 参数,并写好接口文档(Swagger/JSDoc),前端可以按文档传参。
  4. 导出接口应直接返回文件流,前端通过 window.open('/api/articles/export?status=published') 或 Axios 配置 responseType: 'blob' 下载。
  5. 错误信息可读:返回中文错误描述或错误码,帮助前端展示合适提示。

26.2.6 安全与性能加固

  • 权限校验:每个 CRUD 接口必须验证用户身份和操作权限(比如只有管理员能删除)。在 Express 中通过中间件统一校验。
  • 参数校验:不要信任前端提交的任何数据,使用 Joi 或 Zod 做强校验,拒绝非法参数。
  • 软删除不可忽略:读取和列表查询时刻谨记 deleted_at IS NULL 条件,避免数据泄漏。
  • 防止大导出拖垮服务器:导出接口设置单独的超时和限流,甚至可将其设计为异步任务(提交导出请求,后台生成文件后提供下载链接),尤其数据量极大时。
  • 数据库索引优化:对筛选条件中用到的字段(如 statuscreated_at)建立单列或复合索引,确保查询速度。

26.2.7 总结

后台管理系统的 API 看似简单,但它的健壮性直接影响运营效率和系统安全。核心的 CRUD 操作配合分页、筛选和导出,构成了后台数据流的基本运转。在实际开发中,建议在完成基础功能后,反复审视权限、性能和安全这三点,避免“功能跑通即上线”的疏忽。

下一节我们将探讨文件服务的设计,包括图片上传、缩略图生成、云存储对接等同样高频率的管理后台需求。这些 API 的设计模式与本节是相同的分层思想,可以平滑迁移。