每次面对“校园管理系统”这类题目我都会先问对方一句你是拿来交课程设计还是要应付毕业答辩因为这两者的写法完全不一样。交课设能跑通演示就行答辩答辩老师一定会揪着“你的权限怎么控制的”“数据表为什么这样设计”“MyBatis的SQL是自动生成还是手写的”这类问题往下问。这套基于SpringBootVue的微乐校园pf管理系统就是我在带学生毕设的过程中把Java、MySQL、MyBatis这条老牌路子重新捋了一遍之后沉淀下来的完整方案。它不涉及推荐算法、不涉及高并发、不涉及微服务技术栈老老实实但每一层都有值得展开讲的细节非常适合做毕设、课设也适合刚接触前后端分离的开发者拿来当练习项目。这套项目里的“pf”对应的是Platform也就是平台端。和普通前台门户不一样平台端面向的是“管理员、教师、学生”三类角色错位的后台操作场景管理员配置基础数据教师录入成绩和发布通知学生查看课表和报名活动。整个系统的核心不是功能多而是“权限链路清晰、CRUD规范、数据表关系合理”。所以我先给你交代清楚这个项目的定位然后从技术选型、数据库设计、后端搭建、前端实现、部署踩坑五个方向把整个从零到一的过程完整拆开讲。1. 选型动机为什么SpringBootVue能成为这类管理系统的主流答案1.1 前后的分离模式对“管理系统”到底意味着什么先说结论对于校园管理这类典型的管理后台前后端分离不是炫技而是让“展示层”和“业务层”各自做擅长的事。管理系统的核心操作莫过于增删改查、条件搜索、分页列表、表单提交、权限按钮控制。如果用传统的JSP或Thymeleaf模板渲染前端页面和后端Java代码耦合在一个应用里每改一个按钮权限就要重新走一遍控制器和模板编译调试体验非常差。而前后端分离之后后端只提供接口前端只关心页面交互两边只要把接口文档对齐就可以并行开发。对毕设而言这意味着你可以先把后端所有接口用Postman调通再安心做页面也可以让同组的同学在前端跑着的时候你继续调整SQL逻辑。当然也有一个必须承认的代价前后端分离引入了跨域问题、token管理、路由守卫这些新概念上手成本比单体模板项目高一些。但正因为有这些概念这个项目的“技术含量”才能撑起一次答辩。我在带学生的时候就明确说过如果你做的项目跟别人一样用JSPServlet评委老师三分钟就能问到底但如果你讲得清Vue的路由守卫、SpringBoot拦截器、MyBatis动态SQL整个答辩的走向就完全不一样了。1.2 JavaMySQLMyBatis这条技术路线的合理性在这套技术栈里Java是语言、SpringBoot是框架、MySQL是存储、MyBatis是持久层组件。它们在校园管理这类业务场景里几乎各自都踩在最稳妥的点上。SpringBoot内置Tomcat简化了Spring配置一个main方法启动项目对新手极其友好。同时社区资料多遇到问题几乎能搜到现成答案。MySQL免费、轻量、大学课程本身就教这个。8.0版本的窗口函数、CTE语法足够应付日常统计需求性能对这种小体量项目完全溢出。MyBatis半自动ORMSQL由自己手写灵活性和可控性都是最强的。MyBatis让你清楚每条SQL干了什么而不是像全自动ORM那样在背后替你生成一堆不可预期的查询。很多学生问我为什么不用MyBatis-Plus我的回答是毕设阶段最好先用MyBatis理解SQL和Java对象的映射过程等你把MyBatis的动态SQL写熟了再用MyBatis-Plus只是加一个依赖的事。而且手写SQL在答辩时是加分项“这个累计成绩统计的SQL是我写的用了子查询和关联查询”这句话比“MyBatis-Plus自动生成的”要有说服力得多。1.3 技术版本选择上容易被忽略的坑版本问题是我最想提醒的部分。SpringBoot别盲目上3.x如果本地JDK还是8老老实实用SpringBoot 2.7.x否则启动时会遇到一堆兼容问题。MySQL这边推荐8.0以上驱动类名是com.mysql.cj.jdbc.Driver连接串务必带上serverTimezoneAsia/ShanghaicharacterEncodingutf8useSSLfalseallowPublicKeyRetrievaltrue。前端Vue建议用Vue3新脚手架Vue2的官方维护已经进入停滞状态新项目没必要踩旧坑。2. 系统模块划分与数据库表设计先把地基打稳2.1 微乐校园pf平台的功能模块整理我习惯拿到需求之后先画模块清单而不是直接建表。这套系统的模块最终收敛为六大块清晰且贴合“校园平台后台”的场景模块核心功能涉及角色登录认证登录、登出、获取当前登录用户信息所有用户用户管理用户查询、新增、编辑、禁用、重置密码管理员角色权限角色维护、菜单权限分配管理员教学管理学院/专业/班级维护、课程管理管理员、教师成绩管理成绩录入、成绩查询、成绩统计教师录入、学生查看信息发布通知公告、校园活动创建与报名管理员、教师发布学生查看/报名每个模块在页面上的体现就是侧边栏的菜单项。这些菜单不是写死在前端的而是后端按登录用户的角色动态返回再通过Vue Router动态注册路由。这个设计比“所有用户看到同一套页面只是操作按钮隐藏”要高级得多也属于答辩时的核心亮点。2.2 RBAC权限模型的落地五张表撑起的控制链路权限模型我选了最经典的RBAC基于角色的访问控制。它用一套非常朴素的思想解决权限问题用户不直接绑定权限而是绑定角色角色再绑定权限。这样一来新增一个用户只需要给他分配角色新增一个功能只需要给角色配置菜单完全不需要改动用户表。数据库层面的核心表和关系如下sys_user用户主表sys_role角色表sys_user_role用户与角色的关联表sys_menu菜单权限表sys_role_menu角色与菜单的关联表我贴一下最关键的建表SQL这里有几个字段设计上的细节需要注意CREATE TABLE sys_user ( id BIGINT NOT NULL AUTO_INCREMENT COMMENT 主键, username VARCHAR(50) NOT NULL COMMENT 登录账号, password VARCHAR(100) NOT NULL COMMENT 密码BCrypt加密, real_name VARCHAR(50) DEFAULT NULL COMMENT 姓名, user_type TINYINT DEFAULT NULL COMMENT 用户类型1管理员 2教师 3学生, phone VARCHAR(20) DEFAULT NULL, status TINYINT DEFAULT 1 COMMENT 状态1启用 0禁用, del_flag TINYINT DEFAULT 0 COMMENT 逻辑删除0未删除 1已删除, create_time DATETIME DEFAULT NULL, update_time DATETIME DEFAULT NULL, PRIMARY KEY (id) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT用户表;注意几个点第一password必须留够长度BCrypt加密后的字符串长度是60位第二user_type用TINYINT存数字类型比直接用VARCHAR存“管理员”三个字更规范第三del_flag做逻辑删除而不是物理删除学生误删了还能恢复第四create_time和update_time交给后端统一填充建表时不搞数据库触发器逻辑更可控。角色表和菜单表我就不贴完整SQL了重点说菜单表的一个设计习惯sys_menu里用parent_id做父子级菜单menu_type区分“目录/菜单/按钮”通过perms字段存按钮权限标识例如system:user:add、system:user:delete。后端做接口鉴权时就是判断当前用户是否有对应的perms标识。2.3 业务表设计学生、课程、成绩之间的关系权限表搞定后业务表相对简单。核心是学生、课程、成绩三张表构成一个典型的“多对多”关系——一个学生选多门课一门课被多个学生选所以需要中间表student_course连接但成绩字段挂在关联表上更合理因为“某学生某门课的成绩”天然是一对一事实。CREATE TABLE student_info ( id BIGINT NOT NULL AUTO_INCREMENT, user_id BIGINT NOT NULL COMMENT 关联sys_user用户表, student_no VARCHAR(20) NOT NULL COMMENT 学号, college VARCHAR(50) DEFAULT NULL COMMENT 学院, major VARCHAR(50) DEFAULT NULL COMMENT 专业, class_name VARCHAR(50) DEFAULT NULL COMMENT 班级, grade VARCHAR(10) DEFAULT NULL COMMENT 年级, enroll_date DATE DEFAULT NULL COMMENT 入学日期, PRIMARY KEY (id), UNIQUE KEY uk_student_no (student_no) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4;为什么要单独建一套student_info而不是把学院班级字段直接堆在sys_user里原因有两点一是sys_user要面向所有类型用户字段应该保留公共属性二是学生的专属属性学号、学院、专业、班级单独维护后查询和统计都更清爽。sys_user负责“账号身份”student_info负责“学生在校身份”两张表通过user_id关联。课程表、成绩表的字段设计也是围绕这个模式展开唯一要注意的是成绩表建议加一个semester字段否则将来想查“2024-2025学年第1学期成绩”就会很痛苦。3. 后端工程搭建与MyBatis使用细节接口层面的规范性最重要3.1 Maven工程结构和配置文件的正确姿势这个项目的后端是一个单体Maven工程没有拆多模块。毕设项目拆模块往往带来不必要的复杂度单体结构更直观目录按业务分包就足够清晰。我的推荐包结构是这样com.weileschool ├── config # 配置类跨域、拦截器、Mybatis、Jackson ├── controller # 控制器只做参数接收和结果返回 ├── service # 业务接口 ├── service.impl # 业务实现 ├── mapper # MyBatis Mapper接口 ├── entity # 数据库实体类 ├── dto # 前端入参对象避免直接暴露实体 ├── vo # 返回给前端的视图对象 ├── common # 通用结果封装、常量、异常 └── interceptor # JWT拦截器application.yml里最关键的几个配置项我直接列出来server: port: 8080 servlet: context-path: /api spring: datasource: driver-class-name: com.mysql.cj.jdbc.Driver url: jdbc:mysql://localhost:3306/weile_campus?useUnicodetruecharacterEncodingutf8useSSLfalseserverTimezoneAsia/ShanghaiallowPublicKeyRetrievaltrue username: root password: 123456 mybatis: mapper-locations: classpath:mapper/*.xml configuration: map-underscore-to-camel-case: true log-impl: org.apache.ibatis.logging.stdout.StdOutImpl jwt: secret: weile-campus-admin-secret-key-2024 expire: 2592000 # 30天这里有个我踩过的坑map-underscore-to-camel-case如果不开启MySQL字段的create_time根本映射不到Java实体的createTime查询结果会全为null。另外log-impl配置成StdOutImpl后控制台会打印完整SQL排错效率能提升一大截但项目上线前记得关掉否则日志会非常臃肿。3.2 统一返回体、全局异常处理、JWT鉴权一整套规范接口规范决定了前后端联调能否顺畅。我从一开始就定了一套所有接口遵循的返回格式{ code: 200, message: 操作成功, data: {} }对应的Java统一返回类核心代码如下Data public class ResultT { private Integer code; private String message; private T data; 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; } }有了统一返回体前端的Axios响应拦截器只需要判断code 200即可不需要每个接口单独写错误处理。全局异常处理是整个后端最容易被忽视但价值极高的部分。SpringBoot提供了RestControllerAdvice注解可以在一个类里捕获所有未被处理的异常MethodArgumentNotValidException参数校验异常返回校验的具体提示BusinessException自定义业务异常比如“用户名已存在”Exception兜底异常统一返回“系统异常请稍后重试”这套机制让我在写业务代码时非常激进登录失败直接抛new BusinessException(用户名或密码错误)不用在每个Controller里写try-catch。代码里没有了try-catch的噪音可读性会好很多。认证方式选用了JWT流程是用户登录成功后后端生成一个包含用户ID和角色信息的Token返回给前端前端每次请求在Header里带上Authorization: Bearer token后端写了一个拦截器拦截非白名单的/api/**请求解析Token并把当前登录用户信息存入ThreadLocal。我们在preHandle里校验不通过直接返回401同时把/login、/captcha这些接口加入白名单。3.3 MyBatis动态SQL和多表联查的实用写法手写MyBatis XML是这套项目的核心基本功也是答辩时的重头戏。我举两个高频场景说明。场景一用户列表的条件搜索。前端会传username、status、userType这几个可选条件不可能为每种组合写一个查询方法所以必须用where标签动态拼接SQLselect idselectUserPage resultTypecom.weileschool.vo.UserVO SELECT u.id, u.username, u.real_name, u.user_type, u.phone, u.status, u.create_time, GROUP_CONCAT(r.role_name) AS roleNames FROM sys_user u LEFT JOIN sys_user_role ur ON u.id ur.user_id LEFT JOIN sys_role r ON ur.role_id r.id where if testusername ! null and username ! AND u.username LIKE CONCAT(%, #{username}, %) /if if teststatus ! null AND u.status #{status} /if if testuserType ! null AND u.user_type #{userType} /if AND u.del_flag 0 /where GROUP BY u.id ORDER BY u.create_time DESC /select这段SQL有两点值得讲。一是where标签会自动去掉第一个多余的AND这个特性极大简化了条件拼接二是用GROUP_CONCAT把用户拥有的角色名聚合成一个字符串让前端列表直接展示“管理员,教师”而不用二次查询角色表。这种写法在管理后台非常实用。场景二批量插入选课记录。学生选课、管理员批量排课都要用到批量插入用MyBatis的foreach实现insert idbatchInsertStudentCourse INSERT INTO student_course (student_id, course_id, create_time) VALUES foreach collectionlist itemitem separator, (#{item.studentId}, #{item.courseId}, NOW()) /foreach /insert这里要注意MySQL默认限制了单条SQL的max_allowed_packet大小批量量控制在500条以内比较安全超出后分批次执行。分页方面我用了PageHelper插件它本质是MyBatis的一个拦截器在SQL执行前自动拼接LIMIT。用法极简PageHelper.startPage(pageNum, pageSize); ListUserVO userList userMapper.selectUserPage(query); PageInfoUserVO pageInfo new PageInfo(userList);调用startPage之后再执行Mapper查询PageHelper就会拦截并分页返回的PageInfo里包含总记录数、总页数、当前页数据。唯一要强调的是startPage必须紧跟着要分页的Mapper查询语句中间不能穿插其他查询否则分页会作用到错误的SQL上。3.4 后端接口设计的几个加分细节除了功能实现接口设计上还有几个习惯值得长期坚持。第一个是查询参数用DTO接收比如UserQueryDTO里放pageNum、pageSize、username等搜索条件新增和修改用不同的DTOAddUserDTO里不含idUpdateUserDTO里则必须有id。这样做可以把“创建”和“修改”的入参规则分开校验避免实体类大而全导致校验逻辑混乱。第二个是Controller返回值永远用Result包装不允许直接返回实体对象否则异常场景下前端拿到的数据结构不统一。第三个是删除接口做成逻辑删除也就是执行UPDATE把del_flag改成1而不是执行DELETE。4. Vue前端从脚手架到管理后台的完整搭建4.1 用Vite还是Vue CLI以及目录规划创建Vue3项目我推荐直接走Vite。相比Vue CLIVite冷启动速度快了不止一个量级开发时修改代码的热更新几乎是秒级反馈这对调试管理后台这种频繁改表单和表格的场面至关重要。npm create vitelatest weile-campus-web -- --template vue cd weile-campus-web npm install安装依赖时建议先装这几类包路由vue-router、状态管理pinia、UI组件库element-plus、HTTP库axios。Element Plus的介绍我们放到下一节路由和Pinia在权限控制里承担核心职责。目录结构我这样规划src ├── api # 接口请求模块按业务模块拆分 ├── assets # 静态资源 ├── components # 公共组件 ├── layout # 后台布局侧边栏、顶部栏、主内容区 ├── router # 路由配置 ├── store # pinia状态管理 ├── utils # 工具函数请求封装、token存储等 ├── views # 页面视图按模块分目录4.2 Axios二次封装统一处理Token、错误码、重新登录Axios如果不做封装每个页面里都要重复写Authorization请求头遇到401还要重复跳转登录页。我统一封装了一个request实例把所有共性逻辑集中在一处。import axios from axios import { ElMessage } from element-plus import router from /router import { useUserStore } from /store/user const request axios.create({ baseURL: /api, timeout: 15000 }) request.interceptors.request.use(config { const userStore useUserStore() if (userStore.token) { config.headers.Authorization Bearer ${userStore.token} } return config }) request.interceptors.response.use( response { const res response.data if (res.code 200) { return res } if (res.code 401) { const userStore useUserStore() userStore.logout() router.push(/login) ElMessage.error(登录状态已过期请重新登录) return Promise.reject(new Error(res.message)) } ElMessage.error(res.message || 请求失败) return Promise.reject(new Error(res.message)) }, error { ElMessage.error(error.message || 网络异常请稍后重试) return Promise.reject(error) } )几个细节baseURL在开发环境靠Vite代理解决跨域所以我写成/api相对路径业务状态码为200时直接返回res每个接口拿到的都是整包数据如果是401说明Token过期或非法主动清空本地登录状态并跳转登录页。Token存储我放在Pinia里并同步持久化到localStorage。刷新页面后从localStorage恢复避免刷新后登录态丢失。这个逻辑看似简单但很多新手项目会漏掉导致一刷新页面就要求重新登录。4.3 动态路由与按钮级权限的实现前端权限控制是这个项目的亮点也是答辩时最吸引评委的点。它的核心思想是路由表拆成两部分一部分是公共路由登录页、404一部分是业务路由用户管理、课程管理、成绩管理等。业务路由不写死在router里而是登录后拉取当前用户的菜单权限再通过router.addRoute()动态注册。Pinia里维护用户信息和菜单列表const userStore useUserStore() const menuList await userStore.getUserMenu() menuList.forEach(menu { const route { path: menu.path, name: menu.name, component: () import(/views/${menu.component}), meta: { title: menu.title, icon: menu.icon } } router.addRoute(Layout, route) }) router.addRoute({ path: /:pathMatch(.*)*, redirect: /404 })这里有个需要留意的地方import的动态加载不支持纯变量/views/${menu.component}这种写法其实是Vite通过AST分析支持的组件路径必须能被静态识别。我的解决办法是后端返回的component字段是一个固定字符串比如user/UserList前端组件文件必须保证和这个字符串一一对应。按钮级权限则用自定义指令v-permission实现。后端返回的菜单里包含了按钮权限标识把这些标识存到Pinia的permissions数组中然后在按钮上使用el-button v-permissionsystem:user:add typeprimary新增用户/el-button指令内部判断当前用户是否有该权限标识如果没有就把DOM元素移除。这样前端界面上不可操作的按钮直接不展示而不是点了之后才报错。4.4 Element Plus的表格、表单和二次确认管理后台的页面形态高度统一几乎都是“搜索区表格区分页”的组合。Element Plus默认风格简洁但我建议做一次统一的封装把el-tableel-pagination封装成ProTable组件把“查询、重置”按钮和表单封装成一个SearchForm组件。虽然第一次封装会花一些时间但后续新增页面的成本会急剧下降。表单提交有一个最常见的坑当表单字段是日期区间时提交给后端应该是一个startTime和一个endTime但组件直接绑定的是一个数组。我在提交前的处理一般是这样的const queryParams reactive({ pageNum: 1, pageSize: 10, username: , dateRange: [] }) function handleQuery() { const [startTime, endTime] queryParams.dateRange || [] params.value { pageNum: queryParams.pageNum, pageSize: queryParams.pageSize, username: queryParams.username, startTime, endTime } }删除操作必须加二次确认弹窗防止手滑。我习惯用ElMessageBox.confirm搭配await语法确认后调用删除接口再刷新当前列表。这套交互逻辑虽然基础但它是管理后台体验的底线。5. 联调、打包、部署与踩坑记录从能跑到能交差5.1 开发环境的跨域Vite代理是最优解前端跑在5173端口后端跑在8080端口直接请求必然跨域。我推荐的方式是前端发起/api前缀的请求在Vite的vite.config.js里配置代理把请求转发到后端服务。server: { port: 5173, proxy: { /api: { target: http://localhost:8080, changeOrigin: true, rewrite: path path.replace(/^\/api/, ) } } }这里要注意后端的context-path也是/api所以代理时要不要重写前缀取决于你后端接口的实际路径。如果后端的context-path就是/api那前端代理就不需要rewrite如果后端接口路径里没有/api前缀就需要用rewrite把它去掉。我前后端都用了/api前缀所以开发时不需要重写但打包部署到Nginx后又是另一种处理方式别混了。后端我也同时配置了CORS跨域支持作为兜底是的你没看错我两边都配了。开发时用Vite代理就够了但有时候用Postman或者临时页面直接调用后端接口时CORS配置能让调试更省心。两边同时允许并不会冲突。5.2 前端打包与Nginx部署的完整链路项目上线或交付演示时前后端可以分开跑但更稳妥的方式是统一通过Nginx托管。前端执行npm run build后会生成dist目录里面是纯静态文件。后端执行mvn clean package -DskipTests后生成一个weile-campus.jar。我把整个部署流程整理成一套固定的步骤后端jar包扔到服务器某个目录比如/opt/weile-campus执行nohup java -jar weile-campus.jar app.log 21 。前端dist目录里的内容上传到Nginx的html/weile-campus目录。修改Nginx配置把location /指向前端目录同时把/api请求反代到后端的8080端口。关键Nginx配置如下server { listen 80; server_name localhost; root /usr/share/nginx/html/weile-campus; index index.html; location / { try_files $uri $uri/ /index.html; } location /api { proxy_pass http://127.0.0.1:8080; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; } }try_files $uri $uri/ /index.html这行代码有一个非常重要的作用Vue Router如果使用history模式刷新某个子页面时Nginx会先尝试找真实文件找不到就回退到index.html再由前端路由接管。如果没有这行刷新/user/list页面时Nginx会返回404。这个问题我在早期的项目里踩过后来就成了部署清单里的必查项。5.3 我攒下来的几个高频问题和解决办法这一节算是我带毕设过程中反复被追问的问题集锦每次联调报错大多数都逃不出这几个原因。第一个是MySQL 8的密码加密规则。MySQL 8默认使用caching_sha2_password某些老驱动或者客户端工具可能不支持。我建议直接在连接串里加allowPublicKeyRetrievaltrue同时给程序用的账号单独设置为主机localhost。如果你用的驱动是com.mysql.jdbc.Driver请务必换成com.mysql.cj.jdbc.Driver否则启动时会报驱动类不存在的错误。第二个是MyBatis实体类字段映射不上。前面提过要开map-underscore-to-camel-case此外还有一个更容易忽略的点如果实体类使用了Boolean类型对应数据库的tinyint(1)某些驱动版本下读取会异常。更稳妥的做法是数据库字段叫status实体类用Integer status用0和1表达状态语义避免歧义。第三个是后端接口返回的时间格式。SpringBoot默认序列化LocalDateTime时输给前端的是类似2024-05-20T12:00:00的ISO字符串前端展示不够友好。我在统一配置类里加了Jackson的全局配置把LocalDateTime格式化为yyyy-MM-dd HH:mm:ss。网络出问题时这也是一个非常常见的报错点。第四个是IDEA里Lombok失效的问题。新版的IDEA自带Lombok插件但有时候Maven缓存里依赖下载不完整会看到java: 找不到符号 getter方法。解决办法是执行mvn clean compile强制重新编译同时检查Maven的settings.xml有没有配置好阿里云镜像。第五个是长列表性能。如果成绩列表几千条数据后端一次性返回再加前端渲染会明显卡顿。除了分页还应该在前端表格上设置索引列而不是直接用数据库主键暴露出来。这个属于体验优化但对答辩印象分有实际帮助。5.4 正式答辩前建议自己先复盘的几个问题项目跑通只是第一步真正到答辩环节老师更关注的是你“有没有想明白”。我建议你在交之前用下面这套问题清单自测一遍答不上来的地方赶紧补课登录接口的完整链路是怎样的JWT是在哪里生成、在哪里校验、失效后前端怎么处理的角色和菜单权限的数据模型为什么需要五张表直接给用户加一个role_id字段不行吗动态SQL里的where标签是怎么去掉第一个AND的前端的动态路由是怎么实现的刷新页面之后路由还在吗成绩统计的那个SQL用了哪些聚合函数和分组逻辑这些问题表面上是问技术实现底层是在考察你有没有真的把每个环节弄明白。能流畅讲清楚这几个点比功能多出几个模块都管用。写在最后的一点经验我不太喜欢在文章结尾做整齐的总结但有一条非常具体的建议想留给你如果你准备把这个项目作为毕业设计或求职项目拿到源码之后千万不要只停在“跑起来”这一步。先把用户表、角色表、菜单表这三张表的数据关系捋明白再手动写一条登录接口从Mapper到Controller的完整调用链然后尝试在前端新增一个页面并配上动态路由和按钮权限。这四步走完你对这套系统的理解深度就会远超绝大多数拿现成项目演示的同学。这个项目里我最满意的地方其实是它的“克制”——没有堆砌花哨的组件和冗余功能每一张表、每一条SQL、每一个前端页面对应的问题都非常具体。技术上解决问题的方式有很多种这套方案不是最快的但一定是踩坑最少、最容易讲清楚的那一种。