后台管理系统是 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 完全可用。另外LIMIT与OFFSET在深度分页时性能会下降,可以结合查询条件缩小范围(例如要求至少选择一个时间段)。
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 等,但生成稍复杂,可借助
exceljs或node-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 行并write到res,避免内存爆增。
- 如果需要 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 时应遵循几个让前端更易用的约定:
- 统一返回格式:所有接口返回
{ code: 200/400/500, data: ..., message: ... },方便前端全局拦截处理。 - 分页参数使用
page和pageSize,避免使用limit/offset暴露数据库细节。 - 筛选参数使用明确的 Query 参数,并写好接口文档(Swagger/JSDoc),前端可以按文档传参。
- 导出接口应直接返回文件流,前端通过
window.open('/api/articles/export?status=published')或 Axios 配置responseType: 'blob'下载。 - 错误信息可读:返回中文错误描述或错误码,帮助前端展示合适提示。
26.2.6 安全与性能加固
- 权限校验:每个 CRUD 接口必须验证用户身份和操作权限(比如只有管理员能删除)。在 Express 中通过中间件统一校验。
- 参数校验:不要信任前端提交的任何数据,使用 Joi 或 Zod 做强校验,拒绝非法参数。
- 软删除不可忽略:读取和列表查询时刻谨记
deleted_at IS NULL条件,避免数据泄漏。 - 防止大导出拖垮服务器:导出接口设置单独的超时和限流,甚至可将其设计为异步任务(提交导出请求,后台生成文件后提供下载链接),尤其数据量极大时。
- 数据库索引优化:对筛选条件中用到的字段(如
status、created_at)建立单列或复合索引,确保查询速度。
26.2.7 总结
后台管理系统的 API 看似简单,但它的健壮性直接影响运营效率和系统安全。核心的 CRUD 操作配合分页、筛选和导出,构成了后台数据流的基本运转。在实际开发中,建议在完成基础功能后,反复审视权限、性能和安全这三点,避免“功能跑通即上线”的疏忽。
下一节我们将探讨文件服务的设计,包括图片上传、缩略图生成、云存储对接等同样高频率的管理后台需求。这些 API 的设计模式与本节是相同的分层思想,可以平滑迁移。