SpringBootVue这套前后端分离的组合到今天依然是中小型网站管理系统的主流选择。不是因为它最时髦而是因为它省钱、省心、能落地——SpringBoot把后端服务的配置复杂度降下来了Vue把页面的交互响应速度提上去了再配上MyBatis的灵活SQL和MySQL的稳定存储一套能跑、能改、能交付的CMS系统基本就是这个配方。这篇文章我把整套项目从0到1的搭建过程、核心模块设计、遇到的坑和排查方法全拆开讲适合正在做毕业设计、公司内部系统、或者想快速搭一套内容管理后台的朋友直接参考复现。1. 项目拆解与整体设计思路1.1 为什么是这套技术栈组合先说选型逻辑。市面上的网站管理系统方案很多PHP系有WordPress、LaravelNode系有Express、NestJSPython系有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.8SpringBoot 2.x系列JDK 17配SpringBoot 3.xMaven3.6.3项目依赖管理SpringBoot2.7.x稳定版生态资料最多MySQL5.7 或 8.0建议8.0默认utf8mb4Node.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换到PostgreSQLController代码一行都不用动。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_userCREATE 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) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT用户表;角色表sys_roleid、role_name、role_code、remark。用户角色关联表sys_user_roleuser_id role_id的组合主键。文章内容表cms_contentCREATE 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) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT内容表;分类表cms_categoryid、parent_id、name、sort_order。树形结构parent_id为0的是顶级分类。系统配置表sys_configid、config_key、config_value、remark。key-value结构存网站标题、上传大小限制等。有一张表要特别提一下文件表。做了文件管理模块之后每一次上传都会往文件表里插一条记录存文件的原始名称、存储路径、大小、类型、上传人、上传时间。这样后续做附件列表、素材库、或者删除清理都很方便。3. 后端核心模块实现3.1 统一返回结果与全局异常处理后台管理系统的接口格式最好是统一的这样前端axios拦截器处理起来只需要判断一个code字段就行。我在common包里定义一个Result类Data public class ResultT { private Integer code; // 200成功500失败 private String message; // 提示信息 private T data; // 数据 public static T ResultT success() { return success(null); } public static T ResultT success(T data) { ResultT result new Result(); result.setCode(200); result.setMessage(操作成功); result.setData(data); return result; } public static T ResultT error(String message) { ResultT result new Result(); result.setCode(500); result.setMessage(message); return result; } }全局异常处理用RestControllerAdvice把业务异常、参数校验异常、系统异常分别处理返回统一的Result格式。这样前端不管遇到什么错都能拿到结构一致的响应弹提示框也好写。3.2 用户登录与JWT鉴权网站管理系统的后台肯定不能裸奔必须做登录鉴权。我用的方案是JWTJSON Web Token无状态鉴权前后端分离场景下最合适。登录接口的处理流程前端把username和password传给后端。后端根据username查用户表拿到用户和盐值。用BCrypt算法校验密码是否正确注意不能用MD5MD5没有加盐保护彩虹表一查就破。Spring Security的BCryptPasswordEncoder或者Hutool里的BCrypt工具都能用。校验通过后生成JWT把user_id、username、角色信息封装进token里。返回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 idselectContentPage resultTypecom.example.cms.entity.CmsContent SELECT * FROM cms_content where if testtitle ! null and title ! AND title LIKE CONCAT(%, #{title}, %) /if if testcategoryId ! null AND category_id #{categoryId} /if if teststatus ! null AND status #{status} /if /where ORDER BY create_time DESC /selectwhere标签最大的好处是会自动处理第一个条件前面的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 ResultPageInfoCmsContentVO page(ContentQueryDTO queryDTO) { PageHelper.startPage(queryDTO.getPageNum(), queryDTO.getPageSize()); ListCmsContentVO list contentMapper.selectList(queryDTO); PageInfoCmsContentVO 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.jsnpm install hls.js在Vue组件里video idplayer 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.htmllocation / { 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-editorQuill封装、wangEditor、UEditor等。我推荐wangEditor中文文档好、使用简单、界面清爽够用。富文本编辑器插入图片时默认是把图片转成base64存进content字段。这样做的后果是数据库表瞬间膨胀一篇5张图的文章内容字段可能几MB查询和备份都很痛苦。正确做法是编辑器里的图片上传走FileStorageService上传成功返回一个图片URL然后在正文里插入img src得到的URL /数据库存的是正文HTML文本图片是引用地址。5. 核心功能模块的实战实现5.1 内容发布与编辑流程内容管理模块做起来不复杂但有几个细节值得打磨。发布流程表单页面填写标题、分类、正文、封面图点击保存草稿或者发布。保存草稿时status0发布时status1。列表页根据状态筛选草稿和已发布内容。编辑流程点击编辑根据id查出内容详情回填表单修改后重新保存。这里有一个经典坑回填富文本内容时直接把后端返回的HTML放入编辑器的初始化内容即可但要确保编辑器在Vue的nextTick之后初始化否则编辑器还没渲染完赋值失效。5.2 分类管理树的增删改查分类表是parent_id自关联的树形结构。前端展示用el-tree后端返回的格式需要转换成树形结构。后端查出来是平铺的List需要做一个递归组装public ListCategoryVO buildTree(ListCategory allCategories, Long parentId) { ListCategoryVO 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-echartsVue 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连接串后加上useSSLfalseallowPublicKeyRetrievaltruespring: datasource: url: jdbc:mysql://localhost:3306/cms?useUnicodetruecharacterEncodingutf8serverTimezoneAsia/ShanghaiuseSSLfalseallowPublicKeyRetrievaltrueallowPublicKeyRetrievaltrue是为了解决MySQL 8.0在非SSL连接时客户端需要从服务端获取公钥的报错。开发环境这样配没问题生产环境如果对安全有要求应该配置正确的SSL证书而不是关掉SSL。问题二时区问题报错The server time zone value Öйú±ê׼ʱ¼ä is unrecognized这是因为MySQL时区没设置。解决方案是URL里加serverTimezoneAsia/Shanghai或在MySQL里执行SET GLOBAL time_zone 8:00。问题三MySQL服务安装失败热词里的e0434352Windows上安装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_UNSUPPORTEDNode.js 17版本对之前OpenSSL的md5算法做了限制旧项目npm install会直接挂。解决方案set NODE_OPTIONS--openssl-legacy-providerWindows或者export NODE_OPTIONS--openssl-legacy-providerMac/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 部署与持续发布项目做完之后要部署上线。我的简化版步骤后端部署mvn clean package -DskipTests打出jar包。上传到服务器nohup java -jar cms.jar --spring.profiles.activeprod 。生产环境的数据库配置放在application-prod.yml里用环境变量引用账号密码别硬编码。前端部署npm run build生成dist目录。把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做代码推送自动构建自动部署。这一套自动化流程就够出去面试讲半天了。最后说两点心得。一是项目别追求一步到位先把最核心的链路跑通。很多新手朋友一上来就想把所有模块所有角色所有功能全做好结果三个月过去了登录还没调通。我的习惯是先做出一个最简单的主流程——登录、内容列表、新增文章、展示文章这个链路通了剩下的功能都是往这个框架里加。二是凡是配置项都用配置中心和数据库存不要写死在代码里。上传路径、文件大小上限、网站标题、发送通知的开关都放到配置表或配置文件里。否则每次修改都要重新打包发布对运维是巨大的折磨。这套源码我已经在实际部署中跑了真实项目稳定性没有问题如果你正好要开发网站管理系统直接拿这套架构做底子去改业务代码就行。祝各位一步到位少走弯路。