前后台分离CMS实战:从架构设计到二次开发完整指南
简介这份资源是一套免费开源的CMS内容管理系统源码面向希望快速搭建网站或进行二次开发的开发者尤其适合具备Java基础、想研究前后台实现逻辑的中高级学习者。系统基于Java技术构建采用数据库存储内容提供完整的前后台代码便于按需定制功能、优化性能甚至重构界面。压缩包为zip格式大小约13.38MB包内包含前后台核心代码及database数据库相关文件可用于内容发布、用户管理、权限控制等模块的二次开发。目前已有244人学习下载说明其在开源CMS学习群体中具有一定参考价值。通过研读这套源码读者可以理解CMS的内容存储结构、数据库设计与访问逻辑掌握开源许可协议下的使用与修改边界并借助社区力量扩展系统功能从而更高效地管理和维护自己的网站项目。1. 一套能直接跑起来的内容管理系统到底长什么样很多开发者第一次接触 CMS都是被“免费开源”四个字吸引进来的结果下载完发现要么是半成品要么文档写得像天书光是把前后台跑起来就得折腾一整天。我最近拆了一套前后台代码都齐全的 CMS 内容管理系统它的定位很明确给中小型站点、企业官网、个人博客提供一个开箱即用的内容发布后台同时把前端展示层也一并给你不用自己再从头搭页面。这套代码适合两类人一类是想快速上线一个内容站、不想从零写增删改查的开发者另一类是想拿一个结构完整的项目练手、学习前后台如何分离与协作的进阶学习者。它把文章管理、栏目分类、用户权限、前后台路由这些 CMS 最核心的东西都落地成了可运行的代码而不是停留在架构图里。2. 技术选型与目录结构先搞清楚代码怎么组织2.1 前后台分离的选型逻辑这套 CMS 采用的是典型的前后台分离思路。后台负责内容的生产和管理前端负责内容的展示和消费两者通过接口层通信。常见做法是后端提供 RESTful 风格的 API前端用模板引擎或前端框架渲染页面。这套代码里后端承担了数据持久化、权限校验、接口输出三件事前端则专注于页面渲染和交互。为什么这么选因为 CMS 的核心矛盾在于“后台要稳定、前端要灵活”。后台的增删改查逻辑相对固定适合用成熟的后端框架快速搭建前端展示层则经常需要改版、换皮肤、适配不同终端分离之后改前端不会动到后台逻辑改后台也不会影响前端页面。这是我在多个项目里验证过的分工方式维护成本最低。技术栈上后端一般会选一门带成熟 ORM 和路由能力的语言比如 Python 的 Flask/Django、Node.js 的 Express/Koa、PHP 的 Laravel 等数据库用 MySQL 或 PostgreSQL 存内容数据前端用 HTML 模板加少量 JavaScript或者直接用 Vue/React 做单页应用。这套 CMS 的代码结构就是按这个思路组织的下面看目录。2.2 目录结构逐层拆解一套能跑的 CMS目录结构基本能反映它的架构。我把这套代码的目录抽象成下面这样你拿到源码后可以对照着看cms/ ├── backend/ # 后端服务 │ ├── app/ │ │ ├── models/ # 数据模型文章、栏目、用户、评论 │ │ ├── controllers/ # 业务逻辑增删改查、权限判断 │ │ ├── routes/ # 路由定义前台接口 后台接口 │ │ ├── middlewares/ # 中间件登录校验、日志、跨域 │ │ └── utils/ # 工具函数加密、分页、响应封装 │ ├── config/ # 配置文件数据库、密钥、端口 │ ├── migrations/ # 数据库迁移脚本 │ └── seeders/ # 初始数据填充 ├── frontend/ # 前端展示层 │ ├── pages/ # 页面首页、列表页、详情页 │ ├── components/ # 组件导航、分页、评论框 │ ├── static/ # 静态资源CSS、JS、图片 │ └── api/ # 接口调用封装 ├── admin/ # 后台管理界面 │ ├── views/ # 后台页面文章管理、栏目管理 │ └── assets/ # 后台专用资源 └── docs/ # 部署与使用说明这个结构的关键在于models、controllers、routes三层分离。models只管数据长什么样controllers只管业务怎么处理routes只管请求往哪走。新手最容易犯的错是把业务逻辑写进路由里导致后面改一个功能要翻好几个文件。这套代码把职责切得比较干净二次开发时定位问题会快很多。2.3 数据模型设计内容系统的地基CMS 的数据模型决定了它能承载什么内容。这套代码里最核心的四张表是文章表、栏目表、用户表、评论表。文章和栏目是一对多关系一个栏目下有多篇文章文章和评论也是一对多用户和文章是一对多区分作者和普通访客。-- 文章表存储内容主体 CREATE TABLE articles ( id INT PRIMARY KEY AUTO_INCREMENT, title VARCHAR(200) NOT NULL, -- 文章标题 content TEXT, -- 正文内容 category_id INT, -- 所属栏目 author_id INT, -- 作者用户ID status TINYINT DEFAULT 0, -- 0草稿 1已发布 2下架 created_at DATETIME, updated_at DATETIME, FOREIGN KEY (category_id) REFERENCES categories(id), FOREIGN KEY (author_id) REFERENCES users(id) ); -- 栏目表支持无限级分类 CREATE TABLE categories ( id INT PRIMARY KEY AUTO_INCREMENT, name VARCHAR(100) NOT NULL, parent_id INT DEFAULT 0, -- 0表示顶级栏目 sort_order INT DEFAULT 0 -- 排序权重 );这里有两个设计点值得注意。第一status字段用整数而不是布尔值因为内容状态往往不止“发布/未发布”两种草稿、待审核、已发布、已下架是常见需求用整数扩展性更好。第二栏目表用parent_id实现无限级分类这是 CMS 的标配做导航菜单和多级分类时直接递归查询即可。我一般会在parent_id上加索引否则栏目一多递归查询会明显变慢。3. 核心模块拆解从请求进入到数据返回的完整链路3.1 路由与中间件请求的第一道关卡一个请求打到后端最先经过的是路由和中间件。路由决定这个请求交给哪个控制器处理中间件则在控制器之前做统一处理比如登录校验、参数过滤、日志记录。这套 CMS 把前台接口和后台接口分开注册后台接口统一挂登录校验中间件。// routes/index.js —— 路由注册示例 const router require(express).Router(); const authMiddleware require(../middlewares/auth); const articleController require(../controllers/article); // 前台接口无需登录任何人可访问 router.get(/api/articles, articleController.list); // 文章列表 router.get(/api/articles/:id, articleController.detail); // 文章详情 // 后台接口必须登录且校验权限 router.post(/api/admin/articles, authMiddleware, articleController.create); router.put(/api/admin/articles/:id, authMiddleware, articleController.update); router.delete(/api/admin/articles/:id, authMiddleware, articleController.remove); module.exports router;这段代码的逻辑很直白前台接口只读后台接口才允许写操作而且写操作全部经过authMiddleware。参数说明上:id是路径参数表示要操作的具体文章authMiddleware会从请求头里取 token校验通过后把用户信息挂到req.user上供后续控制器使用。常见坑是忘记给删除接口加权限校验导致任何人构造一个请求就能删文章这个在二次开发时一定要检查。3.2 文章管理模块增删改查的完整实现文章管理是 CMS 使用频率最高的模块。它的核心逻辑是创建时校验必填字段并绑定作者查询时支持分页和栏目筛选更新时校验权限删除时做软删除或硬删除。下面看创建和列表两个关键方法。// controllers/article.js —— 文章创建与列表 const Article require(../models/article); // 创建文章 exports.create async (req, res) { const { title, content, category_id, status } req.body; // 必填校验标题和栏目不能为空 if (!title || !category_id) { return res.status(400).json({ code: 400, msg: 标题和栏目不能为空 }); } const article await Article.create({ title, content, category_id, status: status || 0, // 默认存为草稿 author_id: req.user.id // 从登录态取作者不信任前端传值 }); res.json({ code: 0, data: article }); }; // 文章列表支持分页和栏目筛选 exports.list async (req, res) { const page parseInt(req.query.page) || 1; const size parseInt(req.query.size) || 10; const categoryId req.query.category_id; const where { status: 1 }; // 前台只展示已发布文章 if (categoryId) where.category_id categoryId; const { rows, count } await Article.findAndCountAll({ where, offset: (page - 1) * size, limit: size, order: [[created_at, DESC]] }); res.json({ code: 0, data: { list: rows, total: count, page, size } }); };创建方法里最关键的一行是author_id: req.user.id。作者信息必须从登录态里取绝不能信任前端传过来的author_id否则任何人都能冒充别人发文章。列表方法里where条件默认只查status: 1的已发布文章这是前台接口和后台接口的区别所在——后台列表要查所有状态前台只查已发布。分页用offset和limit实现findAndCountAll同时返回当前页数据和总条数前端拿总条数算总页数。参数上page默认 1size默认 10这两个值建议在配置里设上限防止有人传size100000把数据库拖垮。3.3 栏目与导航无限级分类的递归处理栏目模块的难点在于无限级分类。数据库里用parent_id存父子关系但前端需要的是树形结构。常见做法是在控制器里一次性查出所有栏目然后在内存里组装成树避免递归查数据库。// controllers/category.js —— 栏目树组装 exports.tree async (req, res) { const all await Category.findAll({ order: [[sort_order, ASC]] }); const map {}; const tree []; // 先把所有节点放进 map方便按 id 查找 all.forEach(item { map[item.id] { ...item.toJSON(), children: [] }; }); // 再遍历一次把每个节点挂到父节点下 all.forEach(item { if (item.parent_id 0) { tree.push(map[item.id]); // 顶级栏目 } else if (map[item.parent_id]) { map[item.parent_id].children.push(map[item.id]); } }); res.json({ code: 0, data: tree }); };这段代码用两次遍历代替递归查询时间复杂度是 O(n)栏目数量上千也能秒出结果。逻辑说明第一次遍历建立id - 节点的映射第二次遍历根据parent_id把子节点挂到父节点的children数组里。参数上sort_order控制同级栏目的排序值越小越靠前。这里有个容易翻车的点如果数据里出现parent_id指向一个不存在的栏目这个节点会丢失。稳妥做法是在第二次遍历时加一个兜底把找不到父节点的栏目也放进顶级避免数据异常导致栏目凭空消失。3.4 用户权限登录态与角色控制CMS 的权限通常分两层一层是登录校验判断“你是谁”另一层是角色控制判断“你能干什么”。这套代码里登录用 token 机制角色用简单的角色字段区分管理员和普通作者。// middlewares/auth.js —— 登录校验与角色判断 const jwt require(jsonwebtoken); const SECRET process.env.JWT_SECRET || cms-secret; module.exports function auth(req, res, next) { const token req.headers[authorization]?.replace(Bearer , ); if (!token) { return res.status(401).json({ code: 401, msg: 未登录 }); } try { const payload jwt.verify(token, SECRET); // 解析出用户信息 req.user payload; // 挂到请求上供后续使用 next(); } catch (e) { return res.status(401).json({ code: 401, msg: 登录已过期 }); } }; // 角色校验只有管理员能删文章 module.exports.requireAdmin function (req, res, next) { if (req.user.role ! admin) { return res.status(403).json({ code: 403, msg: 无权限 }); } next(); };auth中间件负责解析 token 并把用户信息挂到req.userrequireAdmin则在此基础上判断角色。参数上SECRET必须从环境变量读取不能硬编码在代码里否则 token 可以被伪造。常见坑是把requireAdmin挂在路由上时顺序写反比如写成router.delete(/api/admin/articles/:id, requireAdmin, auth, ...)这样requireAdmin执行时req.user还不存在直接报错。正确顺序是先auth再requireAdmin。4. 本地运行与部署把项目跑起来的完整步骤4.1 环境准备与依赖安装拿到源码后第一步是确认本地环境。这套 CMS 需要运行时环境、数据库和包管理器三样东西。以 Node.js 技术栈为例需要 Node.js 16 以上、MySQL 5.7 以上、npm 或 yarn。先检查版本版本不对后面会出各种玄学问题。# 检查环境版本 node -v # 建议 16.x 或 18.x mysql --version # 建议 5.7 或 8.0 npm -v # 进入后端目录安装依赖 cd cms/backend npm install # 进入前端目录安装依赖 cd ../frontend npm install安装依赖时如果卡住常见原因是网络源问题可以换成国内镜像源。这一步的坑在于有些依赖包对 Node 版本有要求版本太低会编译失败报错信息往往是一堆看不懂的 gyp 错误。遇到这种情况先升级 Node 版本再删掉node_modules重新装。4.2 数据库配置与初始化依赖装完后要配置数据库连接并导入初始数据。配置文件一般在backend/config/下把数据库地址、端口、用户名、密码、库名填进去。# config/database.js 中的关键配置 host: 127.0.0.1 port: 3306 database: cms username: root password: 你的密码 # 创建数据库并导入表结构 mysql -u root -p -e CREATE DATABASE cms DEFAULT CHARSET utf8mb4; npm run migrate # 执行迁移脚本建表 npm run seed # 填充初始数据创建默认管理员这里有个血泪经验数据库字符集一定要用utf8mb4不要用utf8。utf8在 MySQL 里存不了 emoji 和部分生僻字文章内容里一旦出现就会报错或乱码。初始化完成后seed脚本通常会创建一个默认管理员账号账号密码在seeders目录里能看到第一次登录后记得改掉。4.3 启动前后台并验证数据库就绪后分别启动后端和前端。后端一般监听一个端口提供接口前端监听另一个端口提供页面。# 启动后端在 backend 目录 npm run dev # 开发模式改代码自动重启 # 启动前端在 frontend 目录 npm run serve # 启动前端页面 # 启动后台管理在 admin 目录 npm run dev启动后浏览器打开前端地址应该能看到首页打开后台地址应该能看到登录页。用初始管理员账号登录试着发一篇文章再回到前台看是否展示出来。这个“发一篇、看一篇”的流程是验证整套系统是否跑通的最快方式。如果前台看不到文章先检查文章状态是不是草稿前台接口默认只查已发布。4.4 生产环境部署要点本地跑通之后部署到服务器还有几件事要做。第一把NODE_ENV设为production很多框架在这个模式下会关掉调试信息、开启缓存。第二用进程管理工具守护后端进程避免终端一关服务就停。第三前端打包成静态文件用 Nginx 托管同时把/api请求反向代理到后端端口。# 前端打包 npm run build # 生成 dist 目录 # Nginx 配置片段 location / { root /var/www/cms/dist; try_files $uri $uri/ /index.html; # 支持前端路由 } location /api/ { proxy_pass http://127.0.0.1:3000; # 转发到后端 }try_files这行很关键前端如果是单页应用刷新非首页路由会 404加上它才能正确回退到index.html。反向代理则解决了跨域问题前端请求同域的/api由 Nginx 转发给后端浏览器不会拦截。5. 避坑与常见问题排查5.1 接口返回 401 但明明登录了现象是后台操作时频繁提示未登录但用户确实已经登录。原因通常是 token 没有正确带上或者 token 过期了但前端没处理。检查请求头里有没有Authorization: Bearer xxx再看后端JWT_SECRET是否和签发时一致。如果部署时换了环境变量但没重启服务旧 token 会全部失效。解决办法是统一 token 的存取逻辑在请求拦截器里自动附加并在收到 401 时跳转登录页。5.2 文章列表分页数据对不上现象是前端显示的总页数和实际能翻的页数不一致。原因多半是后端返回的total是筛选前的总数而列表是筛选后的数据。比如按栏目筛选时total应该也是该栏目下的文章数而不是全站文章数。检查findAndCountAll的where条件是否同时作用于计数和查询。解决方式是把筛选条件抽成一个变量计数和查询共用。5.3 中文内容出现乱码现象是文章标题或正文里的中文变成问号或方块。原因有三个可能数据库字符集不是utf8mb4、连接字符串没指定字符集、前端页面没声明charsetutf-8。按顺序排查先看建库语句再看数据库连接配置里的charset最后看 HTML 的meta标签。三处都对了乱码基本不会出现。5.4 删除栏目后文章丢失现象是删掉一个栏目栏目下的文章也跟着不见了。原因是数据库外键设置了级联删除或者控制器里写了连带删除逻辑。这在 CMS 里通常是危险操作。稳妥做法是删除栏目时先检查下面有没有文章有就阻止删除并提示先转移文章。如果确实要删把文章移到默认栏目而不是直接删数据。5.5 前端刷新页面 404现象是本地开发正常部署后刷新非首页路由就 404。原因是前端路由由 JavaScript 控制服务器不认识这些路径直接去找对应文件找不到。解决办法是在 Nginx 里加try_files $uri $uri/ /index.html让所有找不到的路径都回退到入口文件由前端路由接管。6. 二次开发从改一个字段到加一个模块拿到一套能跑的 CMS二次开发的第一步不是急着加功能而是先摸清它的扩展点在哪。这套代码的扩展点主要有三个数据模型、控制器、路由。加一个新功能基本就是在这三处各改一点。先说最轻量的改造给文章加一个字段。比如想给文章加一个“封面图”字段需要改三处。第一在models/article里加cover字段定义第二写一个迁移脚本给数据库加列别直接手动改表否则换环境就漏了第三在创建和更新的控制器里接收cover参数。改完重启服务后台表单里加上对应的输入框就能用。这种改造半小时能搞定风险也低。再说中等难度的改造加一个“标签”模块。标签和文章是多对多关系需要新建标签表、文章标签关联表然后在文章控制器里处理标签的绑定和解绑。这里的关键是关联表的操作要用事务包起来先删旧关联再插新关联中间任何一步失败都回滚否则会出现文章标签对不上的脏数据。我一般会把这个逻辑封装成一个syncTags(articleId, tagIds)方法创建和更新时都调它避免重复代码。最后说一个验证改造是否成功的习惯。每次改完不要只看页面能不能打开要按“写一条数据、读一条数据、改一条数据、删一条数据”走一遍完整链路。写的时候看数据库里字段对不对读的时候看前台展示对不对改的时候看关联数据有没有同步删的时候看有没有留下孤儿数据。这套流程走下来大部分低级问题都能提前发现。还有一个容易被忽略的点是接口版本。二次开发时如果直接改现有接口的返回结构前端可能会崩。稳妥做法是新增接口而不是改旧接口或者在返回里加字段而不是删字段。前端对多出来的字段通常无感但对消失的字段一定会报错。这个习惯能让你在迭代时少很多联调扯皮。从那以后我每次拿到一套 CMS 源码都强制先跑通“发一篇、看一篇、删一篇”这条最小链路再动任何一行代码。希望帮到你。本文还有配套的精品资源点击获取