news 2026/10/2 3:05:17

SpringBoot+Vue+MyBatis+MySQL:从0到1搭建网站管理系统

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
SpringBoot+Vue+MyBatis+MySQL:从0到1搭建网站管理系统

SpringBoot+Vue这套前后端分离的组合,到今天依然是中小型网站管理系统的主流选择。不是因为它最时髦,而是因为它省钱、省心、能落地——SpringBoot把后端服务的配置复杂度降下来了,Vue把页面的交互响应速度提上去了,再配上MyBatis的灵活SQL和MySQL的稳定存储,一套能跑、能改、能交付的CMS系统,基本就是这个配方。这篇文章我把整套项目从0到1的搭建过程、核心模块设计、遇到的坑和排查方法全拆开讲,适合正在做毕业设计、公司内部系统、或者想快速搭一套内容管理后台的朋友直接参考复现。

1. 项目拆解与整体设计思路

1.1 为什么是这套技术栈组合

先说选型逻辑。市面上的网站管理系统方案很多,PHP系有WordPress、Laravel,Node系有Express、NestJS,Python系有Django、Flask。但如果你面对的是一个需要长期维护、业务逻辑会不断变复杂、并且团队里Java开发更好招人的场景,Java系的SpringBoot几乎是绕不开的答案。

SpringBoot的核心价值在于"约定优于配置"。以前用SSM框架搭项目,光是配置applicationContext.xml、spring-mvc.xml、mybatis-config.xml就得折腾半天,各种bean的声明、扫描路径、事务管理,任何一个地方写错,启动直接抛异常。SpringBoot用自动配置把这些全干了,你只需要在pom.xml里加依赖、在application.yml里写必要的连接信息,一个能跑的服务几十行代码就能起来。

Vue这边,选择Vue而不是React,主要是考虑到团队上手成本和中文社区生态。Vue的模板语法更贴近传统HTML的开发习惯,一个后端工程师转过来写Vue,半天就能上手。而且Vue生态里有现成的Element UI、Vue Router、Pinia(或者Vuex),做后台管理系统的页面几乎就是"搭积木"。

MyBatis的定位是"半自动化ORM"。Hibernate这种东西全自动映射,写起来省事,但SQL一旦复杂起来,优化和执行计划就不好控制。MyBatis把SQL完全交给你写,用一个Mapper接口对应一个XML文件,SQL怎么写、怎么优化完全可控。对于网站管理系统这种包含大量列表查询、条件筛选、关联统计的场景,MyBatis是最顺手的选择。

MySQL就不多说了,开源免费、性能稳定、资料量大。这套组合唯一的争议点可能是:"为什么不用MyBatis-Plus?"——我的回答是:MyBatis-Plus确实能省掉很多单表CRUD的代码,但如果你正在学习阶段,建议先把原生MyBatis的Mapper XML、ResultMap、动态SQL搞明白,Plus只是一个强化工具,底层还是MyBatis。

1.2 网站管理系统的核心功能拆解

一个典型的网站管理系统,不管业务是内容发布、商品管理、用户运营还是企业内部OA,抽象出来核心模块都是这几块:

  • 用户管理:管理员账号、角色、权限的维护。底层是一张用户表 + 一张角色表 + 一张权限表,再加上用户和角色的关联表、角色和权限的关联表。
  • 内容管理:网站的文章、图片、视频等内容的增删改查。这一类功能在代码实现上高度相似,区别只在于表字段不同、展示形态不同。
  • 分类管理:树形结构的分类目录,用于组织内容。核心是一张parent_id指向自己主键的分类表。
  • 系统配置:网站的标题、Logo、发送通知的开关、文件存储路径等全局配置项。通常用一张key-value结构的配置表存储。
  • 文件管理:上传图片、视频、附件,统一管理。这里要用到对象存储,或者最简单的本地磁盘目录存储。
  • 数据统计:访问量、内容发布量、用户活跃等维度的简单统计展示。

我对这套系统的设计原则是:先定数据模型,再定接口,最后写页面。数据模型是地基,接口是骨架,页面是皮肉。很多新手上来就写页面,写到一半发现表结构设计不合理,返工成本极高。

1.3 技术选型的补充方案

标题里出现了MinIO,这也是生产环境里非常常见的需求——文件不能直接存在本地磁盘(容器化部署或分布式部署时本地磁盘难以共享),这时候就需要对象存储。MinIO是开源的对象存储服务,兼容Amazon S3协议,部署非常简单。

我的建议是:在开发阶段,文件先存本地磁盘,用一个FileUtils工具类搞定。到了部署阶段,如果服务器是单机,本地磁盘也够用;但如果打算上Docker、上K8s,或者后续要扩展多台应用服务器,就果断切MinIO。切换成本不高,因为SpringBoot里有统一的Resource接口,你只需要封装一个FileStorageService,把存储实现从"本地"换成"MinIO"即可。

2. 开发环境准备与工程初始化

2.1 环境清单与版本选择

先说环境,这里给出一份我实测稳定运行的版本组合:

软件版本说明
JDK1.8 或 17建议1.8,SpringBoot 2.x系列;JDK 17配SpringBoot 3.x
Maven3.6.3+项目依赖管理
SpringBoot2.7.x稳定版,生态资料最多
MySQL5.7 或 8.0建议8.0,默认utf8mb4
Node.js16.x 或 18.x前端构建环境
Vue2.7 或 3.xVue 3是趋势,Vue 2的Element UI更成熟,按需选
Element UI / Element Plus对应Vue版本后台UI框架

关于版本这里有一个很重要的经验:不要追求最新版本。SpringBoot 3.x和SpringBoot 2.x有本质区别(SpringBoot 3基于Jakarta EE 9规范,javax改成jakarta包名),网上很多资料和代码片段是2.x的,你照着3.x写会发现import都报红。除非你有明确的新技术需求(比如必须要JDK 17+),否则2.7.x是最省心的选择。

MySQL 8.0和5.7的坑也不一样。8.0默认的认证插件是caching_sha2_password,有些老版本的客户端(比如Navicat 11之前的版本)连接会报错,需要改成mysql_native_password。8.0对SQL语法也更严格,group by的查询如果select的列没有包含在group by里或者没有用聚合函数,直接报错。这些细节在后面排错板块会详说。

2.2 后端工程创建与目录结构

我习惯用IDE创建SpringBoot项目(IDEA或Eclipse),选择Spring Initializr的方式,填好Group和Artifact,依赖选择Web、MyBatis、MySQL Driver、Lombok。如果你不想用IDE向导,也可以去 start.spring.io 网站生成压缩包,效果一样。

创建完之后,按照下面的包结构组织代码:

com.example.cms ├── CmsApplication.java // 启动类 ├── controller/ // 控制层,接收请求 ├── service/ // 业务逻辑层 │ └── impl/ // 业务实现类 ├── mapper/ // MyBatis Mapper接口 ├── entity/ // 实体类(对应数据库表) ├── dto/ // 数据传输对象(接收前端请求参数) ├── vo/ // 视图对象(返回给前端的数据结构) ├── config/ // 配置类(跨域、拦截器、对象存储) ├── common/ // 公共组件(统一返回结果、异常处理、工具类) └── resources/ ├── mapper/ // MyBatis XML映射文件 └── application.yml // 配置文件

这个包结构不是拍脑袋定的,它的核心思路是分层清晰、职责单一。Contoller只做参数接收和返回结果封装,不写业务逻辑;Service只做核心业务处理,不直接操作数据库;Mapper只做SQL映射。这样改起来方便,比如你在Controller里调Service,就算是把整个ServiceImpl实现换掉(比如从MySQL换到PostgreSQL),Controller代码一行都不用动。

2.3 前端工程创建与目录结构

前端我用Vue CLI来创建项目(vue create cms-web)。Vue 3项目就选Vue 3预设,Vue 2项目选Vue 2预设,后面手动选择Router、Vuex/Pinia。目录结构如下:

cms-web ├── src │ ├── api/ // API请求封装 │ ├── assets/ // 静态资源 │ ├── components/ // 公共组件 │ ├── router/ // 路由配置 │ ├── store/ // 全局状态管理 │ ├── views/ // 页面组件 │ ├── App.vue │ └── main.js ├── package.json └── vue.config.js // 前端代理配置

前后端联调阶段有一个必配项:devServer代理。在vue.config.js里配置:

module.exports = { devServer: { proxy: { '/api': { target: 'http://localhost:8080', changeOrigin: true } } } }

这样你在前端axios请求里写/api/user/list,它就会自动转发到后端http://localhost:8080/api/user/list,避开开发阶段的跨域问题。后端如果接口路径写的是/api前缀,那代理路径就是/api;如果后端没写前缀,代理时要注意重写路径。

2.4 数据库设计与建表SQL

网站管理系统的数据库设计,我直接给出最常用的几张核心表结构思路:

用户表(sys_user)

CREATE TABLE `sys_user` ( `id` bigint(20) NOT NULL AUTO_INCREMENT, `username` varchar(50) NOT NULL COMMENT '登录名', `password` varchar(255) NOT NULL COMMENT '加密后的密码', `nickname` varchar(50) DEFAULT NULL COMMENT '昵称', `avatar` varchar(255) DEFAULT NULL COMMENT '头像URL', `email` varchar(100) DEFAULT NULL, `status` tinyint(1) DEFAULT '1' COMMENT '1启用 0禁用', `create_time` datetime DEFAULT NULL, `update_time` datetime DEFAULT NULL, PRIMARY KEY (`id`), UNIQUE KEY `uk_username` (`username`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='用户表';

角色表(sys_role):id、role_name、role_code、remark。

用户角色关联表(sys_user_role):user_id + role_id的组合主键。

文章内容表(cms_content)

CREATE TABLE `cms_content` ( `id` bigint(20) NOT NULL AUTO_INCREMENT, `title` varchar(255) NOT NULL COMMENT '标题', `category_id` bigint(20) DEFAULT NULL COMMENT '分类ID', `content` longtext COMMENT '正文内容', `cover_image` varchar(255) DEFAULT NULL COMMENT '封面图', `video_url` varchar(255) DEFAULT NULL COMMENT '视频URL', `status` tinyint(1) DEFAULT '1' COMMENT '1发布 0草稿', `view_count` int(11) DEFAULT '0' COMMENT '浏览量', `create_by` bigint(20) DEFAULT NULL COMMENT '发布人ID', `create_time` datetime DEFAULT NULL, `update_time` datetime DEFAULT NULL, PRIMARY KEY (`id`), KEY `idx_category_id` (`category_id`), KEY `idx_create_time` (`create_time`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='内容表';

分类表(cms_category):id、parent_id、name、sort_order。树形结构,parent_id为0的是顶级分类。

系统配置表(sys_config):id、config_key、config_value、remark。key-value结构,存网站标题、上传大小限制等。

有一张表要特别提一下:文件表。做了文件管理模块之后,每一次上传都会往文件表里插一条记录,存文件的原始名称、存储路径、大小、类型、上传人、上传时间。这样后续做附件列表、素材库、或者删除清理都很方便。

3. 后端核心模块实现

3.1 统一返回结果与全局异常处理

后台管理系统的接口格式最好是统一的,这样前端axios拦截器处理起来只需要判断一个code字段就行。我在common包里定义一个Result类:

@Data public class Result<T> { private Integer code; // 200成功,500失败 private String message; // 提示信息 private T data; // 数据 public static <T> Result<T> success() { return success(null); } public static <T> Result<T> success(T data) { Result<T> result = new Result<>(); result.setCode(200); result.setMessage("操作成功"); result.setData(data); return result; } public static <T> Result<T> error(String message) { Result<T> result = new Result<>(); result.setCode(500); result.setMessage(message); return result; } }

全局异常处理用@RestControllerAdvice,把业务异常、参数校验异常、系统异常分别处理,返回统一的Result格式。这样前端不管遇到什么错,都能拿到结构一致的响应,弹提示框也好写。

3.2 用户登录与JWT鉴权

网站管理系统的后台肯定不能裸奔,必须做登录鉴权。我用的方案是JWT(JSON Web Token),无状态鉴权,前后端分离场景下最合适。

登录接口的处理流程:

  1. 前端把username和password传给后端。
  2. 后端根据username查用户表,拿到用户和盐值。
  3. 用BCrypt算法校验密码是否正确(注意不能用MD5,MD5没有加盐保护,彩虹表一查就破)。Spring Security的BCryptPasswordEncoder或者Hutool里的BCrypt工具都能用。
  4. 校验通过后,生成JWT,把user_id、username、角色信息封装进token里。
  5. 返回token给前端,前端存在localStorage里,后续每个请求都在Authorization头带上。

后面写一个拦截器(HandlerInterceptor),对需要鉴权的接口做token校验。token校验通过就把当前用户信息设置到ThreadLocal里,业务代码随时可以取当前登录人:

public class AuthInterceptor implements HandlerInterceptor { @Override public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception { String token = request.getHeader("Authorization"); if (StrUtil.isBlank(token)) { throw new BusinessException("未登录,请先登录"); } // 解析JWT JWTValidator validator = JWTValidator.of(token); validator.validateDate(); Long userId = validator.getPayload().getLong("userId"); UserContext.set(userId); return true; } }

对于动态菜单和按钮权限,我的做法是在用户登录成功后,一次性返回该用户所有的权限标识(比如菜单的路由路径、按钮的权限编码),前端根据权限数据动态渲染菜单和按钮。

3.3 MyBatis配置与Mapper XML编写

MyBatis的配置在application.yml里完成:

mybatis: mapper-locations: classpath:mapper/*.xml type-aliases-package: com.example.cms.entity configuration: map-underscore-to-camel-case: true log-impl: org.apache.ibatis.logging.stdout.StdOutImpl

这里有几个关键点:

map-underscore-to-camel-case这个配置会让数据库的user_name自动映射到Java的userName字段,省掉大量ResultMap手写。前提是你的实体类字段命名遵循驼峰规范。

log-impl配置成StdOutImpl可以让MyBatis把执行的SQL打印到控制台。开发阶段强烈建议开启,排查问题的时候能看到实际执行的SQL和参数,效率高很多。到了生产环境记得去掉,否则日志会刷爆。

Mapper XML的写法核心是动态SQL。比如内容管理列表页的模糊查询:

<select id="selectContentPage" resultType="com.example.cms.entity.CmsContent"> SELECT * FROM cms_content <where> <if test="title != null and title != ''"> AND title LIKE CONCAT('%', #{title}, '%') </if> <if test="categoryId != null"> AND category_id = #{categoryId} </if> <if test="status != null"> AND status = #{status} </if> </where> ORDER BY create_time DESC </select>

<where>标签最大的好处是会自动处理第一个条件前面的AND,你不用自己拼字符串还担心多一个AND出来。LIKE查询用CONCAT('%', #{title}, '%'),别用'%${title}%'——${}是字符串拼接,存在SQL注入风险,#{}是预编译,能防注入。

重要提醒:使用${}的坑,我在实际项目里不止一次看到新人踩。${}会把参数值直接拼进SQL里,如果参数是从前端传来的,恶意用户构造一个参数把SQL改了,整个表的数据都能给你拖出来。凡是用在SQL值位置上的,一律用#{}。

3.4 分页查询的两种实现方式

网站管理系统的列表页必然要做分页,常用的方案有两种:

第一种是手动分页:用PageHelper插件(SpringBoot的话用pagehelper-spring-boot-starter),在你的Mapper查询之前调用PageHelper.startPage(pageNum, pageSize),后面的第一条查询语句会自动被插件包一层LIMIT。配合它自带的PageInfo,能直接拿到总条数、总页数。

public Result<PageInfo<CmsContentVO>> page(ContentQueryDTO queryDTO) { PageHelper.startPage(queryDTO.getPageNum(), queryDTO.getPageSize()); List<CmsContentVO> list = contentMapper.selectList(queryDTO); PageInfo<CmsContentVO> pageInfo = new PageInfo<>(list); return Result.success(pageInfo); }

第二种是纯手写LIMIT,用MySQL的LIMIT #{offset}, #{pageSize}来实现,前端传pageNum(第几页)和pageSize(每页条数),后端算offset = (pageNum - 1) * pageSize,再写一条COUNT查询统计总数。

两种方案本质一样,只是PageHelper帮你省了COUNT查询和PageInfo封装。我的建议是:如果是自己学习练手,手写LIMIT能帮你把分页的原理彻底搞清楚;如果是公司项目追求效率,直接用PageHelper。

3.5 文件上传本地存储与MinIO接入

文件上传是网站管理系统的刚需,实现方式其实非常标准。

本地存储方案,核心是配置一个上传根目录,用UUID重命名文件,按日期分目录存储:

@Service public class FileStorageService { @Value("${file.upload-path:/data/upload}") private String uploadPath; public String store(MultipartFile file) { String originalName = file.getOriginalFilename(); String extName = StrUtil.extSuffix(originalName); String newName = IdUtil.fastSimpleUUID() + "." + extName; // 按年月日分目录,避免单目录文件过多 String datePath = LocalDate.now().toString().replace("-", ""); File destDir = new File(uploadPath + "/" + datePath); if (!destDir.exists()) { destDir.mkdirs(); } File dest = new File(destDir, newName); file.transferTo(dest); return "/file/" + datePath + "/" + newName; } }

等等,你的tomact/springboot如果要访问这个上传的文件,还得写一个静态资源映射配置:

@Configuration public class WebConfig implements WebMvcConfigurer { @Override public void addResourceHandlers(ResourceHandlerRegistry registry) { registry.addResourceHandler("/file/**") .addResourceMapping("/file/") .addResourceLocations("file:" + uploadPath + "/"); } }

把/file/**请求映射到本地目录。配置好之后,浏览器访问http://localhost:8080/file/20250101/abc.jpg就能看到图片了。

MinIO的方案,核心操作就是4步:装MinIO客户端、创建bucket、上传文件、返回文件URL。SpringBoot里用MinIO的Java SDK,配置好连接地址和密钥:

@Configuration public class MinioConfig { @Bean public MinioClient minioClient(@Value("${minio.endpoint}") String endpoint, @Value("${minio.access-key}") String accessKey, @Value("${minio.secret-key}") String secretKey) { return MinioClient.builder() .endpoint(endpoint) .credentials(accessKey, secretKey) .build(); } }

上传文件的核心代码:

public String upload(MultipartFile file) { String objectName = "cms/" + LocalDate.now().toString() + "/" + UUID.randomUUID() + "." + extName; minioClient.putObject(PutObjectArgs.builder() .bucket(bucketName) .object(objectName) .stream(file.getInputStream(), file.getSize(), -1) .contentType(file.getContentType()) .build()); return minioEndpoint + "/" + bucketName + "/" + objectName; }

MinIO做到这个程度,就是把文件从"存在自己硬盘上"换成了"存在MinIO服务器上"。存储路径清晰、可扩展,后续要做集群、做多副本也方便。

3.6 异常场景:M3U8视频播放

热词里出现"vue播放m3u8",这是网站管理系统做了视频管理模块的常见需求。M3U8是HLS流媒体协议的索引文件,里面存放的是一串TS分片文件的路径。Vue播放M3U8,目前主流方案是hls.js:

npm install hls.js

在Vue组件里:

<video id="player" controls></video> import Hls from 'hls.js' mounted() { const video = document.getElementById('player') const videoUrl = this.content.videoUrl // 后端返回的m3u8地址 if (Hls.isSupported()) { const hls = new Hls() hls.loadSource(videoUrl) hls.attachMedia(video) } }

一个容易踩的坑是:如果你的M3U8文件里有跨域的TS分片地址,播放器会报跨域错误。这种情况下需要在视频服务的响应头里加上CORS配置,或者把分片地址换成同源地址。这个排查思路可以留个心眼。

4. 前端Vue核心搭建与数据对接

4.1 Vue项目初始化和路由配置

Vue项目创建完成后,先把路由配置好。后台管理系统通常用嵌套路由:Layout组件作为外层,包含侧边栏、顶部导航、主内容区,子页面渲染在<router-view>里。

const routes = [ { path: '/', component: Layout, redirect: '/dashboard', children: [ { path: 'dashboard', name: 'Dashboard', component: () => import('../views/dashboard/index.vue') }, { path: 'content/list', name: 'ContentList', component: () => import('../views/content/list.vue') } ] }, { path: '/login', component: Login } ]

这里需要特别注意Vue Router的模式。默认是hash模式(URL带#号),改成history模式就变成干净的URL路径。history模式更美观,但有一个致命问题:刷新页面时,如果后端没有做相应的路由转发,会直接404。因为前端路由是浏览器端的,刷新/content/list这个地址,请求打到后端,后端没有这个接口,就返回404了。

生产环境的解决办法是:Nginx里配置一个try_files规则,把请求都指向前端的index.html:

location / { try_files $uri $uri/ /index.html; }

处理Web项目的通用部署问题时这个是标准做法。SpringBoot自带Tomcat部署Vue时,可以把前端打包后的dist放进resources/static目录,或者也配置一个转发规则。

4.2 动态菜单与权限控制

动态菜单的实现,核心思路是:后端返回菜单列表,前端根据菜单列表动态生成路由。

菜单数据一般长这样:

[ { "id": 1, "name": "内容管理", "path": "/content", "icon": "el-icon-document", "children": [ { "id": 2, "name": "文章列表", "path": "/content/list", "icon": "" }, { "id": 3, "name": "发布文章", "path": "/content/create", "icon": "" } ] } ]

前端拿到这个菜单数据后,用router.addRoute动态注册路由,用组件递归渲染菜单树。这个方案的好处是:前端不再写死菜单结构,权限的变化完全由后端控制,给某个角色加一个菜单权限,前端刷新页面菜单就变了,不需要重新发版。

按钮级权限的控制,可以用Vue的自定义指令实现。比如定义一个v-permission指令,检查当前用户是否有对应权限码,没有就直接把元素移除。

4.3 axios封装与前后端联调

axios封装的核心是统一处理请求头和响应拦截:

import axios from 'axios' const service = axios.create({ baseURL: '/api', timeout: 15000 }) service.interceptors.request.use(config => { const token = localStorage.getItem('token') if (token) { config.headers['Authorization'] = token } return config }) service.interceptors.response.use( response => { const res = response.data if (res.code !== 200) { ElMessage.error(res.message || '请求失败') return Promise.reject(new Error(res.message)) } return res.data }, error => { // 401处理:跳转登录页 if (error.response && error.response.status === 401) { ElMessage.error('登录已过期,请重新登录') router.push('/login') } else { ElMessage.error(error.message || '网络错误') } return Promise.reject(error) } ) export default service

关于baseURL,前后端联调阶段靠devServer代理转发的场景下,写/api就行。到了生产环境,如果是SpringBoot内置Tomcat部署,也需要保证请求路径被前端路由接管或静态资源映射正确处理。

4.4 富文本编辑器与图片上传

内容管理模块的正文编辑肯定要用富文本编辑器。Vue生态里常用的有:vue-quill-editor(Quill封装)、wangEditor、UEditor等。我推荐wangEditor,中文文档好、使用简单、界面清爽,够用。

富文本编辑器插入图片时,默认是把图片转成base64存进content字段。这样做的后果是:数据库表瞬间膨胀,一篇5张图的文章,内容字段可能几MB,查询和备份都很痛苦。正确做法是:编辑器里的图片上传走FileStorageService,上传成功返回一个图片URL,然后在正文里插入<img src="得到的URL" />,数据库存的是正文HTML文本,图片是引用地址。

5. 核心功能模块的实战实现

5.1 内容发布与编辑流程

内容管理模块做起来不复杂,但有几个细节值得打磨。

发布流程:表单页面填写标题、分类、正文、封面图,点击保存草稿或者发布。保存草稿时status=0,发布时status=1。列表页根据状态筛选草稿和已发布内容。

编辑流程:点击编辑,根据id查出内容详情,回填表单,修改后重新保存。这里有一个经典坑:回填富文本内容时,直接把后端返回的HTML放入编辑器的初始化内容即可,但要确保编辑器在Vue的nextTick之后初始化,否则编辑器还没渲染完,赋值失效。

5.2 分类管理树的增删改查

分类表是parent_id自关联的树形结构。前端展示用el-tree,后端返回的格式需要转换成树形结构。

后端查出来是平铺的List,需要做一个递归组装:

public List<CategoryVO> buildTree(List<Category> allCategories, Long parentId) { List<CategoryVO> tree = new ArrayList<>(); for (Category category : allCategories) { if (parentId.equals(category.getParentId())) { CategoryVO vo = new CategoryVO(); BeanUtils.copyProperties(category, vo); vo.setChildren(buildTree(allCategories, category.getId())); tree.add(vo); } } return tree; }

树形结构在新增和删除上有两个容易踩的坑:

第一,新增子分类时选择父级分类,前端用el-tree-select或el-cascader可以选择父节点,后端存parent_id。这个逻辑本身不复杂,但要注意不能选择自己作为自己的父级,否则会形成环。

第二,删除分类时,如果分类下还有子分类或内容,是禁止直接删除的,要么提示"存在子分类,请先删除子分类",要么提示"该分类下还有N篇文章",需要先处理关联数据。这个检查放在Service层做,别在Controller层拼字符串判断。

5.3 系统配置模块

系统配置表是key-value结构,非常适合存"网站标题"、"备案号"、"页脚信息"等全局配置项。实现一个ConfigService,提供getConfig(key)方法:

public String getConfig(String key) { SysConfig sysConfig = sysConfigMapper.selectByKey(key); return sysConfig != null ? sysConfig.getConfigValue() : null; }

因为网站管理系统的配置读取频率很高,每次查数据库性能上不去。升级方案是:启动时加载全量配置到内存Map里,配置修改时同步更新Map。实际使用中,系统配置的修改频率极低,这种"启动加载 + 修改双写"的方案非常稳定。

5.4 数据统计简易看板

管理后台登录后一般会有一个数据看板,展示总内容数、总用户数、本周新增内容数、访问量等。实现方式也简单,写几个统计SQL:

  • 总内容数:SELECT COUNT(*) FROM cms_content
  • 本周新增:SELECT COUNT(*) FROM cms_content WHERE create_time >= DATE_SUB(CURDATE(), INTERVAL 7 DAY)
  • 分类分布:SELECT c.name, COUNT(cc.id) AS cnt FROM cms_category c LEFT JOIN cms_content cc ON c.id = cc.category_id GROUP BY c.id

用ECharts前端画个饼图、柱状图,视觉上就很专业了。ECharts的Vue集成也不复杂,Vue 3用vue-echarts,Vue 2用vue-echarts的旧版本,别装混了版本。

5.5 网站关键词与SEO配置

既然做的是"网站管理系统",那前台的SEO优化也要考虑。最简单实用的是每一个内容记录都带上title和keywords、description字段,页面渲染时动态更新页面的title和meta标签。

Vue里用vue-wechat-title或者route meta扩展,每次路由切换的时候更新document.title。meta标签的更新稍微麻烦一点,可以在router.afterEach里用document.querySelector找到meta标签再设置content。

6. 常见问题与排查技巧实录

6.1 MySQL安装与连接类问题

热词里有一堆"mysql安装教程"、"mysql ssl连接错误"、"mysql e0434352"。我挑几个真实遇到过的说。

问题一:MySQL 8.0的SSL连接错误

代码连接MySQL时报错SSL connection error,通常是因为MySQL服务器证书链不完整或者客户端配置问题。最简单的处理是,在JDBC连接串后加上useSSL=false&allowPublicKeyRetrieval=true:

spring: datasource: url: jdbc:mysql://localhost:3306/cms?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai&useSSL=false&allowPublicKeyRetrieval=true

allowPublicKeyRetrieval=true是为了解决MySQL 8.0在非SSL连接时,客户端需要从服务端获取公钥的报错。开发环境这样配没问题,生产环境如果对安全有要求,应该配置正确的SSL证书而不是关掉SSL。

问题二:时区问题

报错The server time zone value 'Öйú±ê׼ʱ¼ä' is unrecognized,这是因为MySQL时区没设置。解决方案是URL里加serverTimezone=Asia/Shanghai,或在MySQL里执行SET GLOBAL time_zone = '+8:00'。

问题三:MySQL服务安装失败(热词里的e0434352)

Windows上安装MySQL 8.0的MSI安装包时,偶尔会报Windows Installer的1577错误(错误码显示为e0434352),这是.NET环境和Windows Installer缓存的问题。常见的解决方法:卸载重装MySQL相关组件后清理注册表,或者用zip解压版手动初始化:mysqld --initialize-insecure,然后mysqld --install。用zip版的好处是彻底绕开MSI安装器的那些坑,下载解压、初始化、启动,三步搞定。

6.2 MyBatis相关报错

问题一:Mapper接口无法注入,报No qualifying bean of type 'xxxMapper'

排查顺序:启动类上加没有@MapperScan("com.example.cms.mapper")?如果加了,再看扫描路径对不对。还有一种情况:Mapper接口没有加@Mapper注解。经验是:如果项目里mapper接口比较多,启动类上统一用@MapperScan即可,接口上不需要再加@Mapper注解;两者同时使用也没有问题,但没必要。

问题二:invalid bound statement (not found)

意思是找不到Mapper接口对应的方法。排查:Mapper接口的方法名和XML里的id是不是一致?XML文件路径是不是在mapper-locations的配置范围内?XML文件的namespace是不是和接口全限定名一致?最常见的是XML文件根本就没被打包进target目录——Maven默认只打包resources目录下的资源,如果你的XML放在src/main/java目录下(有些人会放错),需要额外配置resource过滤。

问题三:一级缓存导致的查询"一眼旧数据"

MyBatis的默认会话级一级缓存,在同一个SqlSession里执行两次相同的查询,第二次会直接走缓存。在SpringBoot集成场景下,每个方法调用都用新的SqlSession(除非开启了事务),所以这个问题一般不明显。但如果你开了事务,且在一个事务里先查User,其他地方改了User又查User,拿到的还是缓存里的旧值,这种场景可以用SqlSession.clearCache()或者把查询方法改写成带条件变化的SQL解决。

顺便说一句,二级缓存我一般不在项目里开。原因是二级缓存粒度是namespace级别的,不同Mapper对同一张表的操作无法感知缓存变化,数据一致性很容易出问题。网站管理系统的数据实时性要求并不低,与其冒着数据不一致的风险去省那点查询时间,不如优化SQL性能,把缓存留给Redis来做。

6.3 SpringBoot与Vue联调问题

问题:前端请求接口404或405

先检查后端接口路径和前端axios请求路径是否一致;再检查是不是请求方法不一致——后端只写了@GetMapping,前端用post请求,直接405。联调阶段用浏览器F12的Network面板看请求详情,能看到是404(路径问题)还是405(方法问题)还是跨域问题。

问题:SpringBoot版本太高导致的各种诡异报错

打开一个刚生成的SpringBoot 3.x项目,代码是从2.x教程里搬过来的,发现所有javax开头的import都报错。解决方式很简单:把javax.servlet改成jakarta.servlet,或者直接把SpringBoot降级到2.7.x。自己练手,真心建议用2.7.x熟悉整个开发模式。

6.4 Vue生态的经典问题

问题一:npm install卡住/报错ERR_OSSL_EVP_UNSUPPORTED

Node.js 17+版本对之前OpenSSL的md5算法做了限制,旧项目npm install会直接挂。解决方案:set NODE_OPTIONS=--openssl-legacy-provider(Windows),或者export NODE_OPTIONS=--openssl-legacy-provider(Mac/Linux),或者降Node到16.x。

问题二:Vue Router history模式刷新404

生产环境刷新子页面404,原因就是前面说的history模式问题。解决方式:Nginx配try_files;如果项目是打包后放进SpringBoot里跑,需要在SpringBoot的WebMvcConfigurer里注册一个视图控制,把非/API、非静态资源的请求转发到index.html。如果只是本地开发环境,用devServer的historyApiFallback: true配置即可。

问题三:路由参数跳转传参

列表页跳详情页,最简单的是用this.$router.push({ path: '/content/detail', query: { id: 123 } }),详情页用this.$route.query.id接收。这个方案刷新页面参数不会丢(因为参数在URL上),比用动态路径/content/detail/123要省事,课题里如果金额不大,直接用query传参就够了。

6.5 部署与持续发布

项目做完之后要部署上线。我的简化版步骤:

后端部署:

  1. mvn clean package -DskipTests打出jar包。
  2. 上传到服务器,nohup java -jar cms.jar --spring.profiles.active=prod &。
  3. 生产环境的数据库配置放在application-prod.yml里,用环境变量引用账号密码,别硬编码。

前端部署:

  1. npm run build,生成dist目录。
  2. 把dist目录里的文件拷贝到Nginx的html目录,或拷贝到src/main/resources/static里(选择SpringBoot内置Tomcat部署方式时)。

推荐Nginx单独部署前端 + 端口转发到后端的方式,这样前端的静态文件由Nginx高性能服务托管,后端API保持独立,后续要加CDN、加SSL、做缓存都方便。Nginx配置大概如下:

server { listen 80; root /var/www/cms-web/dist; index index.html; location /api/ { proxy_pass http://127.0.0.1:8080/api/; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; } location / { try_files $uri $uri/ /index.html; } }

把/api/开头的请求转发给SpringBoot,其余请求走前端静态资源。这个方案简单、清晰、好排查问题。

7. 这套系统的扩展方向与个人心得

写到这里,一套能用的网站管理系统就算完整落地了。最后聊聊这套系统在实战中还能继续往哪些方向扩展,以及我踩过几次坑之后的一些体会。

第一,缓存可以引入Redis。现在的实现每个请求都打MySQL,用户量上来之后MySQL会成为瓶颈。把热点数据(比如系统配置、内容详情)缓存到Redis,缓存穿透用空值缓存,缓存击穿用互斥锁或逻辑过期,缓存雪崩用随机过期时间,这三个问题解决了,扛住中等流量没有压力。

第二,搜索模块可以用Elasticsearch。内容表的数据量到了几十万、上百万的时候,MySQL的LIKE '%关键词%'查询会全表扫描,性能直线下降。把内容数据同步到ES里,用ES的全文搜索替代MySQL的模糊查询,搜索体验和性能都会好很多。甚至可以把索引同步做成基于binlog的CDC方案(Canal),实现准实时同步。

第三,工作流引擎可以做。如果你的网站管理系统的后台不只是管理员几个角色在用,而是有编辑、审核、发布等不同角色的审批流,那么引入Activiti或者Flowable会非常合适。文章从草稿到审核到发布,每一步都由流程节点控制,审批历史全程可追溯。

第四,前后端部署可以上Docker和CI/CD。SpringBoot项目打镜像、Vue项目打镜像、MinIO和MySQL各一个容器,docker-compose一键拉起,GitLab CI或GitHub Actions做代码推送自动构建自动部署。这一套自动化流程就够出去面试讲半天了。

最后说两点心得。

一是项目别追求"一步到位",先把最核心的链路跑通。很多新手朋友一上来就想把所有模块所有角色所有功能全做好,结果三个月过去了,登录还没调通。我的习惯是先做出一个最简单的主流程——登录、内容列表、新增文章、展示文章,这个链路通了,剩下的功能都是往这个框架里加。

二是凡是配置项,都用配置中心和数据库存,不要写死在代码里。上传路径、文件大小上限、网站标题、发送通知的开关,都放到配置表或配置文件里。否则每次修改都要重新打包发布,对运维是巨大的折磨。

这套源码我已经在实际部署中跑了真实项目,稳定性没有问题,如果你正好要开发网站管理系统,直接拿这套架构做底子去改业务代码就行。祝各位一步到位,少走弯路。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/10/2 3:05:16

SpringBoot+Vue前后端分离旅游平台实战:从架构设计到部署上线全解析

先说个背景。去年我帮桂林本地一家做地接业务的旅游公司搭了套“旅游景点导游平台”&#xff0c;技术栈选了 SpringBoot Vue MyBatis MySQL&#xff0c;前后端完全分离&#xff0c;从数据库设计、接口联调到最终部署上线&#xff0c;整套流程走了一遍。这个系统功能上没有特…

作者头像 李华
网站建设 2026/10/2 3:04:57

DehazeNet 去雾实战:PyTorch 实现、训练与部署全流程

简介&#xff1a;这份资源是面向具备深度学习基础的研究者与图像处理方向学习者的PyTorch版DehazeNet图像去雾实现&#xff0c;提供从网络结构定义、训练流程到推理演示的完整代码链路&#xff0c;并附带已训练好的室内与室外场景预训练权重&#xff0c;可直接加载使用&#xf…

作者头像 李华
网站建设 2026/10/2 3:04:57

云服务器成本优化实战:从选型到架构的降本指南

上个月整理自己的云资源账单时&#xff0c;我发现一台2核4G的云服务器实例已经连续运行了47天&#xff0c;而它承载的只是一个几乎没人访问的内部演示环境。月底看到那笔并没有创造实际价值的支出时&#xff0c;我第一次真正意识到&#xff1a;云服务器这种东西&#xff0c;开起…

作者头像 李华
网站建设 2026/10/2 3:04:48

RealVNC企业级批量部署:基于AD域的静默安装与集中授权方案

1. 项目概述&#xff1a;为什么企业必须把VNC服务激活和管理“当回事”RealVNC是Windows环境下最主流的远程桌面协议&#xff08;RDP&#xff09;补充方案之一&#xff0c;尤其在需要跨平台、低延迟、图形界面交互强的场景中——比如IT支持团队远程协助产线工控机、研发人员调试…

作者头像 李华
网站建设 2026/10/2 3:03:36

维普AIGC检测超标怎么办?比话AI降AI实测全流程记录

每年到了论文送审和软著提交通道开放的那几周&#xff0c;我的私信就会准时热闹起来。问题高度一致&#xff1a;“维普AIGC检测显示我的论文AI疑似度40%&#xff0c;怎么办&#xff1f;”“软著文档AIGC检出率高&#xff0c;补正通知已经下了&#xff0c;还能救吗&#xff1f;”…

作者头像 李华
网站建设 2026/10/2 3:03:23

OpenClaw实战:从WSL2环境配置到跑通第一句Hello

如果你关注AI智能体&#xff08;Agent&#xff09;方向&#xff0c;最近大概率刷到过OpenClaw这个名字。它是一个开源的、本地优先的个人AI助手运行时&#xff0c;和市面上那些套壳ChatBot完全不同&#xff0c;它更像是给大模型装上了一套能收消息、能执行任务、能记住上下文的…

作者头像 李华