尧图网站设计 尧图网站设计YAOTU DESIGN
ARTICLE DETAIL

资讯详情

深耕网站设计与一线实操的经验洞察。

SpringBoot+Vue+MyBatis+MySQL后台管理系统实战:从表结构设计到部署上线

SpringBoot+Vue+MyBatis+MySQL后台管理系统实战:从表结构设计到部署上线 1. 项目整体设计与技术选型1.1 为什么是SpringBootVue这套组合做网站管理系统最常被问到的就是SpringBoot Vue MyBatis MySQL 这套组合到底怎么落地我最近刚把一个后台管理项目完整跑了一遍从搭骨架到上线运维把里面的弯弯绕绕总结一下。说白了这套组合不是“最新潮”的但绝对是中小型项目里最稳、最省心的方案。SpringBoot负责后端接口Vue负责页面交互MyBatis管数据库操作MySQL存数据四者分工非常清晰招人容易资料也多出了问题随便一搜就有解决办法。我见过不少团队一上来就整微服务、上K8s结果业务逻辑还没写清楚光是基础设施就折腾了两周。网站管理系统的核心需求无非就是用户登录、权限控制、内容发布、数据展示、文件上传这几块。用SpringBoot单体应用完全够用后期真需要拆服务按模块拆也不难。Vue这边用Vue 3 Vite或者Vue 2 Webpack都行我这次用的是Vue 3 Element Plus配合Vite开发体验确实快。1.2 前后端分离的目录结构规划前后端分离最大的好处是职责清晰前端只管渲染和交互后端只管数据和逻辑。但很多新手栽在目录结构上前后端代码混在一起后期维护非常痛苦。我的习惯是前端单独一个文件夹后端单独一个文件夹项目根目录下再放一个README.md记录启动方式和接口文档。后端我用的是Maven多模块结构但管理系统的代码量不算大用单模块也没问题。我的目录大致长这样server/ ├── pom.xml ├── src/main/java/com/example/admin/ │ ├── controller/ │ ├── service/ │ ├── mapper/ │ ├── entity/ │ ├── config/ │ ├── common/ │ └── AdminApplication.java ├── src/main/resources/ │ ├── mapper/ # MyBatis XML文件 │ ├── application.yml │ └── sql/ # 初始化脚本 └── src/test/java/前端我用的是Vite创建的项目结构如下web/ ├── index.html ├── vite.config.js ├── package.json └── src/ ├── main.js ├── App.vue ├── router/ ├── store/ ├── api/ ├── views/ ├── components/ └── utils/这样分完之后前后端可以独立开发、独立部署。联调时前端通过代理转发API请求生产环境直接把前端打包后的dist目录扔进SpringBoot的static目录里一个jar包就能跑起来非常方便。1.3 管理系统常见的模块划分网站管理系统这个名字听起来宽泛但落到具体功能上基本跑不出这几块系统管理、内容管理、会员管理、数据统计、文件管理。我这次的项目里系统管理包含用户、角色、菜单、日志内容管理包含文章分类、文章列表、标签另外加了一个公告管理。模块划分要遵循一个原则做减法。别一上来就设计十几张表先把最核心的跑通。比如用户管理一定要有角色权限一定要有因为这是管理系统的地基。内容管理可以根据业务灵活加减。我见过有人把广告位、友情链接、SEO配置全塞进来表设计得极其复杂结果开发周期翻倍上线后一半功能没人用。先做核心后面迭代再加这是管理系统的正道。2. 数据库设计与核心表结构2.1 用户与角色权限模型管理系统的权限模型我建议直接抄经典的RBAC用户表、角色表、菜单表加上用户角色关联表和角色菜单关联表。虽然网上有更复杂的ABAC模型但中小型后台用RBAC足够开发快理解成本低。我这次用的核心表是CREATE TABLE sys_user ( id BIGINT NOT NULL AUTO_INCREMENT COMMENT 用户ID, username VARCHAR(50) NOT NULL COMMENT 用户名, password VARCHAR(100) NOT NULL COMMENT 加密后的密码, nickname VARCHAR(50) DEFAULT NULL COMMENT 昵称, avatar VARCHAR(255) DEFAULT NULL COMMENT 头像地址, status TINYINT DEFAULT 1 COMMENT 状态1启用0禁用, create_time DATETIME DEFAULT CURRENT_TIMESTAMP, update_time DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, PRIMARY KEY (id), UNIQUE KEY uk_username (username) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT系统用户表; CREATE TABLE sys_role ( id BIGINT NOT NULL AUTO_INCREMENT COMMENT 角色ID, role_name VARCHAR(50) NOT NULL COMMENT 角色名称, role_code VARCHAR(50) NOT NULL COMMENT 角色编码, remark VARCHAR(255) DEFAULT NULL, PRIMARY KEY (id) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT角色表; CREATE TABLE sys_menu ( id BIGINT NOT NULL AUTO_INCREMENT COMMENT 菜单ID, parent_id BIGINT DEFAULT 0 COMMENT 父菜单ID, menu_name VARCHAR(50) NOT NULL, path VARCHAR(200) DEFAULT NULL COMMENT 前端路由路径, component VARCHAR(200) DEFAULT NULL COMMENT 前端组件路径, perms VARCHAR(100) DEFAULT NULL COMMENT 权限标识, icon VARCHAR(100) DEFAULT NULL, sort INT DEFAULT 0, visible TINYINT DEFAULT 1, PRIMARY KEY (id) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT菜单表;关联表就不贴了就是user_role(user_id, role_id)和role_menu(role_id, menu_id)两张表主键用联合主键加个索引提高查询效率。这里有个细节要提醒密码字段别用VARCHAR(50)加密后的BCrypt字符串长度固定是60但以后可能换加密算法直接给100比较稳妥。2.2 内容管理相关表设计内容管理系统CMS是网站管理系统的核心业务模块。我这次设计文章表时考虑到可能会有多标签需求额外建了一张标签表和文章标签关联表。文章表的关键字段CREATE TABLE cms_article ( id BIGINT NOT NULL AUTO_INCREMENT, title VARCHAR(200) NOT NULL COMMENT 文章标题, summary VARCHAR(500) DEFAULT NULL COMMENT 摘要, content LONGTEXT COMMENT 正文富文本内容, cover_image VARCHAR(255) DEFAULT NULL COMMENT 封面图, category_id BIGINT DEFAULT NULL COMMENT 分类ID, author_id BIGINT DEFAULT NULL COMMENT 发布人ID, status TINYINT DEFAULT 0 COMMENT 0草稿1已发布2下线, view_count INT DEFAULT 0 COMMENT 浏览量, create_time DATETIME DEFAULT CURRENT_TIMESTAMP, update_time DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, PRIMARY KEY (id), KEY idx_category_id (category_id), KEY idx_status (status) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT文章表;这里有两个值得注意的地方。第一content字段用LONGTEXT而不是TEXT因为富文本编辑器里如果贴大量图片和代码块TEXT可能不够用。第二status字段加上索引因为后台列表页最常见的查询就是按状态筛选没索引的话数据量一上来查询就会变慢。2.3 建表SQL与初始化数据很多新手习惯直接拿PowerDesigner或者在线工具画表导出SQL后一堆外键约束开发时改表结构改得想哭。我的建议是开发阶段不要建外键用代码保证数据一致性外键约束等上线前再根据DBA要求补。建表SQL全部放到sql/init.sql里用DROP TABLE IF EXISTS开头方便反复执行。初始化数据至少要有管理员账号、基础角色、以及一份能支撑前端菜单渲染的菜单数据。我通常会把菜单数据写死成SQL脚本后端启动时加载而不是前端一股脑全写死。这样权限变更时只需要改数据库不用重新发前端包。第一次建表时最容易踩的坑是字符集和排序规则。MySQL 8.0默认字符集是utf8mb4但有的老库还是utf8插入生僻字或者Emoji就会报错。建库时务必指定CREATE DATABASE admin_db DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci;排序规则用utf8mb4_general_ci还是utf8mb4_unicode_ci都行中文场景下差别不大但别用utf8mb4_bin否则模糊查询时会严格区分大小写容易出一些怪问题。3. SpringBoot后端实现3.1 项目初始化与MyBatis配置创建SpringBoot项目我一般用Spring Initializr选好依赖再手动补MyBatis。网上很多教程喜欢用MyBatis-Plus确实省事CRUD不用写SQL但老项目或者对SQL要求精细的场景原生MyBatis更可控。我这次的项目用的是原生MyBatisXML里写SQL这样可以清晰展示每一条SQL是怎么设计的。application.yml里MyBatis的关键配置spring: datasource: driver-class-name: com.mysql.cj.jdbc.Driver url: jdbc:mysql://localhost:3306/admin_db?useUnicodetruecharacterEncodingutf8useSSLfalseserverTimezoneAsia/Shanghai username: root password: 123456 mybatis: mapper-locations: classpath:mapper/*.xml type-aliases-package: com.example.admin.entity configuration: map-underscore-to-camel-case: true log-impl: org.apache.ibatis.logging.stdout.StdOutImplmap-underscore-to-camel-case: true这个配置非常重要它能把数据库里的create_time自动映射到Java实体里的createTime不用手写ResultMap。但注意如果你的实体里某几个字段命名不规范这个配置会失灵所以写数据库字段时一定要统一用下划线命名法。log-impl设置成StdOutImpl可以让SQL打印到控制台开发阶段强烈建议开定位问题会快很多。3.2 MyBatis动态SQL与通用CRUD管理系统的列表页基本逃不开多条件筛选按用户名模糊查询、按状态查询、按时间范围查询。如果每个条件都写一个Mapper方法代码会爆炸。MyBatis的动态SQL就是为这个场景准备的。下面是我用户管理列表的查询select idselectUserList resultTypecom.example.admin.entity.User SELECT * FROM sys_user where if testusername ! null and username ! AND username LIKE CONCAT(%, #{username}, %) /if if teststatus ! null AND status #{status} /if if teststartTime ! null AND create_time gt; #{startTime} /if if testendTime ! null AND create_time lt; #{endTime} /if /where ORDER BY id DESC /selectwhere标签会自动去掉第一个AND这个特性解决了我早年手写SQL时最痛苦的问题。还有一点要提醒小于号在XML里必须转义成lt;不然XML直接报错。也可以用![CDATA[ ]]包起来但我习惯用转义因为更直观。插入操作也要注意坚决别用insert into sys_user values (...)这种顺序敏感的写法。字段一调整就崩正确写法是每个字段都显式声明列名insert idinsertUser useGeneratedKeystrue keyPropertyid INSERT INTO sys_user (username, password, nickname, avatar, status) VALUES (#{username}, #{password}, #{nickname}, #{avatar}, #{status}) /insertuseGeneratedKeystrue会自动把自增主键回填到实体的id字段上省得插入后重新查一遍。3.3 JWT鉴权与拦截器网站管理系统必须做登录鉴权否则只要知道接口地址就能拿数据那这系统就是个摆设。我这次用的是JWT的方案无状态、方便扩展。登录成功后生成一个Token返回给前端前端每次请求都带上Authorization头后端用拦截器统一校验。拦截器代码如下Component public class JwtInterceptor implements HandlerInterceptor { Override public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception { // 放行登录接口 if (request.getRequestURI().contains(/login)) { return true; } String token request.getHeader(Authorization); if (token null || token.isEmpty()) { throw new BusinessException(401, 未登录或Token已过期); } // 解析token校验签名和有效期 Claims claims JwtUtil.parseToken(token.replace(Bearer , )); request.setAttribute(userId, claims.get(userId)); return true; } }这里有个容易忽略的点Token里不要塞太多东西最好只有userId和username这类标识信息。角色和权限信息每次请求实时查数据库虽然慢一点但能保证权限变更立刻生效。如果为了省事把角色全塞进Token一旦改了用户角色老Token在有效期还能访问旧权限这就尴尬了。密码加密一定用BCrypt不要用MD5不要用SHA。MD5可以通过彩虹表直接爆破BCrypt每次加密的盐都不同安全性高得多。Spring Security里自带的BCryptPasswordEncoder可以直接用不用引入整个Security框架。3.4 文件上传与静态资源处理网站管理系统基本上都要有图片上传功能比如头像、文章封面、富文本插图。我这次的做法是上传到本地磁盘然后通过一个映射路径访问。虽然不如OSS方便但胜在免费、直观。SpringBoot的配置app: upload: path: D:/upload/上传接口的核心逻辑PostMapping(/upload) public Result upload(RequestParam(file) MultipartFile file) { String originalFilename file.getOriginalFilename(); String ext originalFilename.substring(originalFilename.lastIndexOf(.)); String newFilename UUID.randomUUID().toString().replace(-, ) ext; File dest new File(uploadPath DateUtil.format(new Date(), yyyyMMdd) / newFilename); // 确保目录存在 dest.getParentFile().mkdirs(); file.transferTo(dest); return Result.success(/files/ DateUtil.format(new Date(), yyyyMMdd) / newFilename); }静态资源映射Configuration public class FileConfig implements WebMvcConfigurer { Value(${app.upload.path}) private String uploadPath; Override public void addResourceHandlers(ResourceHandlerRegistry registry) { registry.addResourceHandler(/files/**) .addResourceLocations(file: uploadPath); } }这里有个大坑Windows下路径里的反斜杠和Spring的解析器会闹矛盾。如果你在Windows上开发路径写成D:/upload/带正斜杠不会出错如果写成D:\upload\在配置文件里需要转义很容易出问题。我建议统一用正斜杠。另外生产环境千万别把上传目录放在服务器系统盘也别和项目jar包放一起否则磁盘一满系统就挂了。如果业务量真的上来了建议尽快迁移到MinIO或者云对象存储我项目后期就是引入的MinIO代码改动量其实很小只需要把上传逻辑从写本地改成调用MinIO SDK。4. Vue前端实现4.1 Vue项目初始化与Element Plus配置前端这块Vue 3 Vite Element Plus是我目前的标配。有人还在用Vue 2 Element UI也能跑但如果是从零开始我建议直接用Vue 3。Vite的启动速度比Webpack快一个数量级而且默认就支持ES Module配置简单。初始化项目npm create vitelatest web cd web npm install npm install element-plus element-plus/icons-vue axios vue-router4 piniaElement Plus的引入方式有两种完整引入和按需引入。管理后台这种项目页面多、组件用得全我直接完整引入省心。但如果你的系统是给用户用的C端按需引入会明显降低打包体积。main.js里配置import { createApp } from vue import ElementPlus from element-plus import element-plus/dist/index.css import App from ./App.vue import router from ./router import { createPinia } from pinia const app createApp(App) app.use(ElementPlus) app.use(router) app.use(createPinia()) app.mount(#app)Vite开发环境一定要配置代理不然每个请求都得写成http://localhost:8080跨域问题一大堆。在vite.config.js里export default defineConfig({ server: { port: 5173, proxy: { /api: { target: http://localhost:8080, changeOrigin: true, rewrite: (path) path.replace(/^\/api/, ) } } } })这样前端请求/api/user/list实际后端收到的是/user/list。接口前缀留着后面部署时可以通过Nginx统一转发也可以直接把前端打包放到SpringBoot里。4.2 Axios请求封装与路由守卫管理系统的接口请求和响应格式高度统一不封装Axios简直没法维护。我的习惯是统一用{ code: 200, data: ..., msg: ... }这种格式返回前端Axios拦截器里统一处理code。封装一个request.jsimport axios from axios import { ElMessage } from element-plus import router from ../router const service axios.create({ baseURL: /api, timeout: 15000 }) service.interceptors.request.use(config { const token localStorage.getItem(token) if (token) { config.headers[Authorization] Bearer token } return config }) service.interceptors.response.use( response { const res response.data if (res.code ! 200) { ElMessage.error(res.msg || 请求失败) if (res.code 401) { localStorage.removeItem(token) router.push(/login) } return Promise.reject(new Error(res.msg)) } return res.data }, error { ElMessage.error(error.message || 网络异常) return Promise.reject(error) } )这里有个细节返回res.data而不是res这样业务代码里直接拿数据本体不用每次.data.data。很多人封装不彻底页面里到处是res.data.data改起来想骂人。路由守卫是前端权限控制的第一道闸门。没有Token就直接踢到登录页router.beforeEach((to, from, next) { const token localStorage.getItem(token) if (!token to.path ! /login) { next(/login) } else { next() } })4.3 动态菜单与页面权限前端如果写死路由后端返回的菜单就没意义了。真正合理的做法是用户登录后后端根据角色返回可访问的菜单列表和对应权限标识前端动态生成路由和菜单。我用的是后端返回菜单树前端遍历生成RouteRecordRaw并router.addRoute()。核心思路const buildRoutes (menus) { const routes [] menus.forEach(menu { if (menu.component) { routes.push({ path: menu.path, name: menu.path, component: () import(../views/${menu.component}.vue), meta: { title: menu.menuName, icon: menu.icon } }) } if (menu.children menu.children.length 0) { routes.push(...buildRoutes(menu.children)) } }) return routes }这里有个需要注意的地方import()的动态路径必须是静态可分析的路径里不能全是变量。../views/${menu.component}.vue这种写法虽然能用但Vite打包时可能警告。安全起见组件路径应该在数据库里就是相对views目录的固定值比如system/user这样打包时能正确解析。在页面内部按钮级权限用指令封装。比如只有管理员才能看到“新增用户”按钮const hasPermi (perm) { const perms localStorage.getItem(perms) || [] return JSON.parse(perms).includes(perm) }指令方式app.directive(perm, { mounted(el, binding) { if (!hasPermi(binding.value)) { el.parentNode?.removeChild(el) } } })这样设计后权限变更只需改数据库前端重新登录即可生效不需要重新打包。4.4 常见页面示例用户管理用户管理页面几乎是每个管理系统必有的模块。表格、搜索、新增、编辑、删除一个大差不差的模板能覆盖70%的页面需求。我用Element Plus的el-table加el-dialog加el-form实现。搜索和分页是这部分的重头戏。表格的分页组件要跟后端的pageNum和pageSize配对默认第一页页码从1开始千万别学某些框架从0开始否则切换分页时会多查一条。el-pagination v-model:current-pagequeryParams.pageNum v-model:page-sizequeryParams.pageSize :totaltotal :page-sizes[10, 20, 50, 100] layouttotal, sizes, prev, pager, next, jumper changegetList /提交表单时有一个新手容易踩的坑el-form的校验规则里如果字段初始值是undefined内容为空时校验不触发。最好在data里把表单对象的所有字段都初始化成空字符串或null。删除操作一定要二次确认不然手滑点一下数据就没了。我一般用ElMessageBox.confirm确认后再发送删除请求而且要在确认框里带上数据名称比如“确定删除用户张三吗”这样误操作的概率会低很多。5. 联调、打包部署与问题排查5.1 前后端联调CORS问题前端开发环境通过Vite代理转发一般不会遇到跨域。但有时候你直接双击index.html或者后端单独调试时前端请求接口就会出现CORS报错。最省事的方法是在后端搞一个全局CORS配置Configuration public class CorsConfig implements WebMvcConfigurer { Override public void addCorsMappings(CorsRegistry registry) { registry.addMapping(/**) .allowedOriginPatterns(*) .allowedMethods(GET, POST, PUT, DELETE, OPTIONS) .allowedHeaders(*) .allowCredentials(true) .maxAge(3600); } }注意allowedOrigins(*)在allowCredentials(true)时会报错必须用allowedOriginPatterns(*)。这个细节坑了很多人SpringBoot版本不一样报错信息也不一样网上搜一圈才发现是这个原因。5.2 Vue打包放入SpringBoot开发模式跑通了接下来就是打包上线。前端打包命令npm run build打包出来的dist目录里有index.html和assets文件夹。把dist里的所有文件复制到SpringBoot的src/main/resources/static目录下重新打包后端jar包访问http://ip:8080就能直接看到页面。这里有几个细节要注意。第一前端路由如果是history模式刷新页面会404因为后端没有这个路径。解决办法有两个一是改前端的createWebHashHistoryhash模式二是后端写一个控制器把非接口路径全部转发到index.html。我倾向于用hash模式省事虽然URL里带个#但对后台系统来说无伤大雅。第二静态资源缓存问题。更新前端代码后浏览器可能还是旧的JS文件。Vite打包默认会给文件加哈希文件名变了就不会缓存但如果index.html被缓存了依然会引用老文件。建议在后端配置Cache-Control对index.html禁用缓存其他静态资源可以缓存。这算是一个小而关键的上线细节。5.3 常见问题速查表我整理了一张FAQ表都是我实际项目中遇到过的。问题现象可能原因解决办法前端能启动但请求后端报404Vite代理没生效检查proxy配置路径前缀是否匹配后端报Public Key Retrieval is not allowedMySQL连接参数缺少allowPublicKeyRetrievaltrueJDBC URL加上参数插入中文变成??数据库连接字符集不对JDBC URL加characterEncodingutf8MyBatis XML里的SQL有AND语句报错没加where标签用where替换WHERE 11JWT解析报JWT signature does not match密钥不一致检查前后端Token和校验密钥是否一致Vue打包后刷新404history路由模式改用hash模式或配置后端转发文件上传能成功但访问403静态映射路径配错检查file:路径盘符和目录结尾斜杠SpringBoot启动后SQL语句不打印MyBatis的log-impl没配检查configuration里log-impl配置这些问题的共同特点是报错信息五花八门但根因往往只有一个。排查时候先看最基础的配置再去查业务逻辑。千万别上来就断点调试先看日志、看SQL打印、看请求参数通常能少走很多弯路。5.4 容易被忽略的几个细节做这种管理系统功能做完只是第一步后续维护才是大头。有几个细节我每次都会提醒自己。第一后端接口的返回格式一定要统一。不管是成功还是失败都返回{ code, msg, data }前端拦截器也好写出来问题也好排查。如果有的接口返回数组有的返回对象有的失败时直接抛异常堆栈联调的时候真要命。第二时间字段一定要统一的格式。数据库DATETIME默认返回的是2025-02-15T10:30:00这种带T的格式前端展示贼难看。SpringBoot的Jackson配置一下全局格式spring: jackson: date-format: yyyy-MM-dd HH:mm:ss time-zone: GMT8第三删除数据尽量用软删除比如给表加一个deleted字段删除操作更新这个字段查询时自动过滤。管理系统里的数据是宝贵的物理删除一时爽客户问起来找不回就抓瞎了。虽然会稍微多写几条SQL但这是值得的。第四正式环境务必把MyBatis的log-impl从StdOutImpl改成Slf4jImpl或者关闭不然高并发下控制台刷SQL日志能把磁盘写满。开发环境打印日志生产环境只记录错误日志这个度一定要把握好。我个人做下来最大的体会是这套SpringBootVueMyBatisMySQL的方案干不了什么炫酷的事但能安安稳稳地把业务跑起来。很多团队看不上这种“老一套”结果微服务、云原生绕了一圈最后发现管理后台的需求其实很简单稳定才是第一位的。如果你也要做类似的管理系统别被各种新框架迷了眼把这套基础组合吃透再把权限和文件处理这两个点打磨好项目就成功了一大半。最后再分享一个小技巧上线之前把数据库初始化脚本、后端部署步骤、前端构建步骤写进README哪怕就几行字。两三个月后你自己回来看都会感谢当时多写了这几行字。管理系统的运维工作琐碎且重复规范化的文档能帮你省下大量时间。
返回列表