前阵子帮某高校开发了一套大学生创新创业训练项目管理系统技术栈定的是SpringBoot2 Vue3 MyBatis-Plus MySQL8.0前后端分离项目源码和配套文档目前都在维护中。这套系统主要解决“项目申报、立项审批、中期检查、经费管理、结题验收”全流程数字化的问题用户覆盖学生、指导教师、学院管理员和校级管理员四种角色。如果你正准备做类似的管理平台或者正在学SpringBoot和Vue3的整合开发这篇文章可以直接拿来当参考。整套代码我拆过很多遍也踩了不少坑比如权限拦截的顺序、MyBatis-Plus的字段映射、前端路由守卫的循环重定向等等。下面我从需求拆解开始把技术选型、数据库设计、后端实现、前端交互、部署流程、常见问题全部过一遍。文章里会穿插真实开发中的决策逻辑不光是告诉你怎么写代码还会解释为什么这么写。1. 项目概述与核心需求解析1.1 这类系统到底在管什么大学生创新创业训练项目简称“大创项目”是很多高校都在推的一项实践教学环节。表面上看是学生提交一个项目申报书老师审批一下但实际上涉及的角色、流程和状态远比想象中复杂。学生要填写项目名称、项目类型创新训练、创业训练还是创业实践、项目成员、指导教师、项目简介、预期成果、经费预算。指导教师要审核申报书提出修改意见。学院管理员要组织专家评审分配评审专家汇总评审结果。校级管理员要发布通知、设置申报批次、管理立项名单、下发经费、组织中期检查和结题验收。如果没有系统这些工作基本靠微信群、Excel表格和纸质材料来回传递后期统计的时候特别痛苦。我见过用共享表格管理的几十个项目字段一多公式一乱月底汇总全是错。所以这个项目的核心不只是做一个CRUD的增删改查页面而是要把“申报—审批—执行—验收”的业务规则落到系统里。1.2 从需求到功能模块的映射把业务需求翻译成系统功能其实就是一个“角色 流程 数据”的模型。我拆完需求之后把功能模块划分成了六个部分。用户管理模块负责学生、教师、学院管理员、校级管理员的账号维护、角色分配和个人信息修改。项目申报模块让学生在线填写申报书支持上传附件、自动保存草稿、正式提交。审批管理模块分成指导教师审核和学院评审两个环节审核人可以填写意见并回退。项目过程管理模块覆盖中期检查、结题报告、经费使用记录和成果登记。统计报表模块按学院、项目类型、立项年度等维度汇总数据方便管理员导出。系统管理模块包括菜单权限、公告通知和操作日志。这六个模块里面项目申报和审批是核心因为流程的状态一旦设计不好后面每一次状态变更都会牵扯到数据一致性问题。我们后面单独讲状态设计。1.3 角色权限设计三种身份的分工与协作权限设计不只是在后端加一个角色判断那么简单。我见过很多系统把权限做成“用户登录之后根据角色显示不同的菜单”这远远不够。真正要处理好的是“同一个角色在不同流程节点上的操作范围”。以教师角色为例教师既是“指导老师”也可能是“评审专家”。如果某位老师同时指导了A项目又是B项目的评审专家那么他在系统里看到的数据列表应该区分开指导的项目进入“我的指导项目”评审的项目进入“待我评审列表”。这属于数据权限不能光靠角色字符串控制。所以我们实现了两套权限机制接口权限用自定义注解校验角色数据权限通过SQL条件动态拼接过滤比如指导老师只能查询tutor_id等于自己ID的项目。管理员又分成学院和校级两级。学院管理员只能看到本学院的项目数据校级管理员能看全校。这个范围控制在ServiceImpl里通过DataScope参数实现避免把过滤逻辑散落在各个Controller中。2. 技术栈选型为什么是SpringBoot2 Vue3 MyBatis-Plus MySQL8.02.1 后端框架SpringBoot2的稳定性与生态选择SpringBoot2而不是SpringBoot3最主要的原因是当前很多高校的课程设计和毕业设计都基于SpringBoot2积累了大量代码生态资料最全。SpringBoot2搭配JDK8是目前兼容性最好的组合各种中间件、云服务商的SDK基本都支持不用担心版本冲突问题。SpringBoot2的核心优势是自动配置和开箱即用。我们只需要在pom里引入spring-boot-starter-web内嵌Tomcat就起了。相比传统的SSH框架配置文件减少了一大半。另外SpringBoot2自带的Spring MVC注解和校验组件在做RESTful API时非常方便Controller层可以用一堆注解把参数校验、数据绑定、响应封装全部搞定团队里新手也能快速上手。有人问为什么不选SpringBoot3因为SpringBoot3强制要求JDK17并且底层是Jakarta EE命名空间很多老项目迁移成本高。除非你要用GraalVM等新特性否则SpringBoot2在维护周期内依然是非常稳妥的选择。2.2 数据库持久层MyBatis-Plus如何提升开发效率MyBatis-Plus是一个增强工具它在MyBatis的基础上只做增强不做改变。最实用的两个功能是通用Mapper和条件构造器。通用Mapper意味着单表操作不需要写SQL。比如userMapper.selectById(id)、projectMapper.selectPage(page, wrapper)这些简单的CRUD方法开箱即用。我们只需要定义实体类用TableName注解关联表名用TableId(type IdType.AUTO)标注主键剩下的交给MyBatis-Plus自动生成SQL。条件构造器QueryWrapper和LambdaQueryWrapper也很强大。比如查询状态为“待审批”且申请时间在某个范围内的项目可以直接这样写LambdaQueryWrapperProject wrapper Wrappers.ProjectlambdaQuery() .eq(Project::getStatus, PENDING) .ge(Project::getCreateTime, startTime) .le(Project::getCreateTime, endTime);这样写的好处是避免了拼接SQL的烦恼而且类型安全——如果字段名写错在编译期就能发现。配合分页插件MybatisPlusInterceptor几句话就实现了物理分页不用自己手写LIMIT了。2.3 前端工程Vue3组合式API与Element Plus前端选Vue3而不是Vue2核心原因是组合式API让代码复用变得特别干净。以前Vue2写逻辑复用要靠mixins混入多了之后变量来源根本查不清。Vue3里用setup语法糖配合ref、computed、watch把一个业务逻辑封装到自定义的useUser.js或useProject.js文件里页面组件里引用起来一目了然。UI组件库选了Element Plus它专门适配Vue3。表单验证、表格、分页、对话框、消息提示这些后台管理系统的“标配”都能很顺手地调用。尤其是el-form的校验规则配合rules属性能实现申报书里的各种必填校验、字符长度校验、邮箱格式校验。另外Vue3的响应式原理从Object.defineProperty改成了Proxy对数组下标更新和动态新增属性都能自动化响应开发过程中少了很多“数据变了页面不刷新”的坑。2.4 数据库MySQL8.0的新特性与兼容性用MySQL8.0是因为它已经是当前主流的长期支持版本了。相比5.78.0的默认字符集是utf8mb4能完整存储中文和emoji。而且8.0增加了窗口函数、公用表表达式WITH ... AS、CHECK约束等实用功能报表统计时可以写更简洁的SQL。举个例子统计每个学院的项目数量并按数量排序传统写法可能需要临时表而8.0可以用窗口函数SELECT college_name, COUNT(*) AS cnt, RANK() OVER (ORDER BY COUNT(*) DESC) AS rank_no FROM project GROUP BY college_name;另外MySQL8.0的索引优化也改了Like模糊查询在某些场景下可以利用索引下推比5.7快不少。唯一要注意的是8.0默认的密码加密方式是caching_sha2_password有些老版本的可视化工具可能连不上建议下载最新版的Navicat或DataGrip或者把客户端连接驱动更新到8.0以上。3. 数据库表设计与核心业务实现3.1 用户、角色、项目申报等核心表结构数据库设计是整个系统的地基我画了大概二十多张表这里挑最核心的来说。用户表sys_user主要字段是id、username、password、real_name、phone、email、avatar、status、create_time。密码不能明文存储我们用BCrypt加密每次登录时校验哈希值。角色表sys_role存储角色编码和角色名称。用户和角色是多对多关系所以有中间表sys_user_role。教师和学生信息我们做了扩展表teacher_info和student_info因为不同角色有不同属性。比如学生需要学号和年级教师需要职称和研究方向。不过实际开发里为了简化查询我把部分冗余字段直接加到了sys_user表里比如user_type、college_id、student_no、teacher_title。查询用户列表时避免多次JOIN牺牲一点规范化换查询效率。项目表project是核心表字段包括project_name、project_type、category、apply_user_id、tutor_id、college_id、status、budget、start_year、description、create_time、submit_time。其中status状态字段是关键具体值我们后续用状态机管理。项目成员表project_member记录了项目学生的姓名、学号和分工一对多关联项目表。附件表project_file保存项目申报书、中期报告、结题报告等文件路径字段有file_name、file_url、upload_user_id、file_type、project_id。评审表project_review包含project_id、reviewer_id、review_status、review_comment、review_score。经费表project_fund记录每一笔经费的申请、审核、使用情况。3.2 项目申报与审批流程的状态机设计项目状态是整个系统最复杂的部分。我用了状态机的方式而不是简单地在代码里写一堆if-else。状态定义如下DRAFT草稿学生保存但未提交。PENDING_TUTOR待指导老师审核。TUTOR_REJECTED指导老师退回修改。PENDING_COLLEGE待学院管理员分配评审专家。PENDING_REVIEW待评审专家评审。REVIEW_REJECTED评审不通过退回。PENDING_APPROVAL待校级管理员立项审批。APPROVED立项通过。MIDTERM_PENDING待提交中期检查材料。MIDTERM_REVIEWING中期检查评审中。COMPLETION_PENDING待提交结题材料。COMPLETING结题验收中。COMPLETED已结题。TERMINATED终止。每个状态都有允许执行的动作。比如从PENDING_TUTOR只能执行“通过”到PENDING_COLLEGE或者“退回”到DRAFT不能直接跳到APPROVED。我在后端实现了一个ProjectStateMachine组件用Map维护状态转移规则防止非法流转。这样设计的好处是前端可以根据当前状态动态渲染按钮——比如学生看到DRAFT时可以提交和编辑看到PENDING_TUTOR时只能查看管理员看到PENDING_REVIEW时可以指派专家但不能修改学生填写的内容。3.3 经费管理、中期检查、结题验收的关键逻辑经费管理不是简单记一笔账要区分“预算总额”和“已使用额度”。项目立项时会把预算总额写入project.budget字段。当经费支出申请被审核通过系统自动累加used_amount同时校验used_amount不能超过budget。这个校验必须在事务内完成否则并发申请时会出现超支。中期检查和结题验收都复用了“材料提交—评审”的模式。学生上传中期检查报告系统生成一个check_task记录状态为待专家评审。评审专家打分之后项目状态根据平均分决定是否通过。结题验收比中期检查多了一个环节需要上传成果附件比如论文、专利、竞赛获奖证明并且要填写成果描述。这些信息最后汇总到“项目成果登记表”里。为了保证流程可追溯每个状态变更都会写一条operation_log记录操作人、操作类型、原始状态、目标状态和备注。这样后续出了问题可以查日志看到到底是谁在什么时间做了什么操作。4. 后端核心模块开发细节4.1 基于JWT的登录认证与拦截器配置登录认证我们用了JWT方案无状态适合前后端分离部署。用户登录成功后后端生成一个带过期时间的Token返回给前端。前端存储在localStorage里每次请求时放在请求头Authorization中。JWT的密钥我们放在配置文件中生成Token时加入用户ID和角色列表。代码大致如下String token Jwts.builder() .setSubject(userId.toString()) .claim(roles, roles) .setExpiration(new Date(System.currentTimeMillis() EXPIRE_TIME)) .signWith(SignatureAlgorithm.HS256, secretKey) .compact();后端拦截器实现HandlerInterceptor在preHandle中解析Token、校验有效期、把用户信息存入ThreadLocal方便Controller直接获取当前用户。这个细节要注意ThreadLocal在请求结束后必须清理否则线程池复用会导致用户信息串号。所以我们在afterCompletion里调用UserContext.clear()。拦截器注册时需要排除登录接口和静态资源路径。我踩过一个坑放行规则写成了/*结果/api/login倒是放行了但/api/project/list也被匹配到导致所有业务接口全被拦了。正确写法是/**匹配所有路径然后通过excludePathPatterns排除不需要拦截的地址。4.2 自定义注解实现权限校验光靠拦截器做认证还不够接口权限需要更细粒度控制。我自定义了一个RequireRole注解可以标注在Controller方法上指定允许访问的角色编码。Target(ElementType.METHOD) Retention(RetentionPolicy.RUNTIME) public interface RequireRole { String[] value(); }在拦截器完成登录校验后进入方法前用HandlerMethod判断是否标注了RequireRole。如果标注了就从当前用户信息中获取角色列表判断是否有交集。如果没有权限直接返回403错误响应。这种做法的好处是权限判断和业务逻辑完全解耦。Controller里不会出现大段权限判断代码只需要在方法上写RequireRole({ROLE_STUDENT, ROLE_TEACHER})即可。后面如果权限规则变了也只需要修改注解参数或者检查逻辑不影响业务方法。4.3 文件上传与静态资源映射项目申报时需要上传Word、PDF、图片等附件。前端通过el-upload组件把文件POST到后端的/api/file/upload接口。后端接收MultipartFile保存到本地磁盘的uploadDir目录中文件名用UUID重命名避免中文文件名乱码和冲突。保存之后返回一个相对路径比如/upload/2024/05/21/xxxx.pdf然后在前端表单里把这个相对路径和原始文件名存到附件表。不要直接存完整URL因为将来如果换服务器完整URL就会失效。静态资源映射需要在SpringBoot中配置registry.addResourceHandler(/upload/**) .addResourceLocations(file: uploadDir);这样浏览器就能直接通过URL访问上传的文件。生产环境下建议把上传目录配置成环境变量或NFS共享目录避免镜像重启后文件丢失。4.4 MyBatis-Plus多表查询与条件构造器复杂列表查询是后台管理系统的重头戏。以“项目列表”为例前端需要支持按项目名称模糊搜索、按状态筛选、按年份筛选、按学院筛选同时还要带出学生姓名、指导教师姓名、学院名称。如果这些信息都靠主表JOIN三张表还得做分页。MyBatis-Plus的分页查询无法直接关联多表并返回VO所以我在Service层用了两步查询方案先根据筛选条件查询出符合条件的项目分页数据再批量查询关联的用户信息和学院信息手工组装VO。虽然多了一次查询但避免了复杂SQL。数据量大时这种方案性能依然可控。当然遇到特别复杂的统计报表时我直接写了XML里的自定义SQL。比如统计每个学院各类型项目的数量手写SQL加GROUP BY反而更清晰select idselectProjectCountByCollege resultTypemap SELECT c.name AS collegeName, p.project_type AS projectType, COUNT(*) AS count FROM project p LEFT JOIN sys_college c ON p.college_id c.id GROUP BY c.name, p.project_type /select接口层只需要调用projectMapper.selectProjectCountByCollege(param)返回的Map集合直接转成前端表格数据。5. 前端Vue3页面设计与交互5.1 项目初始化与路由守卫前端用的是Vite构建工具。相比WebpackVite开发服务器的启动速度真的快很多尤其是修改代码后的热更新基本是秒级响应。我在vite.config.js里配置了开发代理把/api前缀的请求转发到后端8080端口这样就避免了跨域问题。路由守卫是登录控制的关键。我在router/index.js里配置了全局前置守卫每次路由跳转时检查localStorage里是否有Token。如果访问的是需要登录的页面没有Token就跳转到登录页。如果已经登录但访问登录页就强制跳转到首页。还有一个细节是动态路由。不同角色的菜单不同所以登录后从后端拉取菜单权限通过router.addRoute动态添加。这样学生端永远不会访问到管理端的路由即使有人手工修改URL前端会因为没有匹配路由而跳转到404页面。不过要强调前端路由守卫只是用户体验层面的控制真正的安全校验必须依赖后端接口权限。前端隐藏按钮、路由拦截都只是防君子不防小人。5.2 核心页面项目申报表单的动态校验项目申报页是整个系统里最复杂的表单。包含项目基本信息、成员列表、指导教师、项目简介、预期成果、经费预算、附件上传等区域。因为字段太多我把它拆成了ProjectBasicForm.vue、MemberListForm.vue、FundBudgetForm.vue几个子组件。表单校验用Element Plus的rules。比如项目名称必填且不超过50个字符项目类型必选成员人数最多5人经费预算必须是正整数。学生点击“暂存”时只校验必填和格式点击“提交”时额外校验成员列表是否至少有一人预算总额是否合理。这里我处理了一个“联动校验”问题如果项目类型是“创业实践”那么需要填写企业导师信息如果是“创新训练”则不需要。判断逻辑写在watch里监听project_type字段动态更新rules对象里的规则。Element Plus的表单校验支持动态规则只要el-form绑定的rules对象发生替换重新校验时就会生效。成员列表我用v-for循环渲染每行是一个成员表单。新增成员时需要给每个成员行一个唯一key否则删除和新增时DOM会复用导致数据错乱。5.3 审批流程的进度可视化为了让用户直观看到项目走到哪一步我设计了一个进度条组件。根据项目状态映射成四个阶段申报与审核、立项与下拨、过程管理、结题验收。进度条的当前阶段由后端返回的状态码计算。比如状态是MIDTERM_REVIEWING就显示第二阶段之后的进度。这个组件用Element Plus的el-steps实现每个步骤的标题和描述从配置文件中读取维护起来很方便。在审批页教师和管理员看到的操作按钮不同。教师端有“通过”和“退回”退回时必须填写意见管理员端有“指派评审专家”和“汇总评审结果”。操作完成后调用后端接口刷新进度条并显示新的状态。为了避免重复提交我在按钮点击后先禁用按钮等请求完成再恢复。进度可视化在真实使用中非常受欢迎因为以前学生们总是发消息问“老师我的项目审批到哪一步了”现在打开系统就一目了然大大减少了沟通成本。5.4 与后端接口联调时的常见问题联调阶段最容易出问题的就是接口返回结构和错误处理不统一。我们约定后端统一返回Result对象包含code、message、data三个字段。code200表示成功其他均为失败。前端在axios响应拦截器里统一处理axios.interceptors.response.use( response { const res response.data; if (res.code 200) { return res; } Message.error(res.message); return Promise.reject(res); }, error { if (error.response error.response.status 401) { router.push(/login); } Message.error(网络异常); return Promise.reject(error); } );这样每个页面请求数据时就不需要重复处理错误提示了。不过要注意文件上传接口的响应可能不是JSON格式所以上传请求单独用原生axios不经过这个拦截器或者在后端返回结果时强制设置Content-Type为application/json。另一个常见问题是Token过期。Token有效期我设为2小时但用户可能在页面停留很久突然操作时报401。前端捕获到401后不直接跳登录页而是先用刷新Token接口尝试续期。由于这次没有做RefreshToken机制暂时采用了简单方案401时弹出提示“登录已过期”点击按钮跳转登录页。6. 项目部署与环境配置6.1 本地环境搭建JDK、Maven、Node、MySQL要把这套源码跑起来首先保证本机的环境版本一致。JDK推荐1.8版本Maven推荐3.6.3以上Node推荐16.14以上MySQL必须是8.0。版本差异可能会导致某些依赖无法解析。我在部署文档里写明了大约5分钟的快速启动流程。第一步用npm install安装前端依赖。第二步用IDEA打开后端工程等待Maven自动下载依赖然后修改application.yml里的数据库连接配置。第三步执行doc/sql目录下的init.sql初始化数据库它会自动创建所有表结构和初始管理员账号。第四步启动后端然后npm run dev启动前端开发服务器。有个小坑Maven下载依赖很慢的建议在settings.xml里配置阿里云镜像。如果拉包一直报错先检查Maven本地仓库是否残留损坏的.lastUpdated文件把对应目录清掉再重新拉。6.2 前后端打包与部署方式后端打包用Maven的package命令生成jar包然后直接运行java -jar xxx.jar。前端执行npm run build生成dist静态目录。生产环境可以用Nginx托管前端静态资源同时配置反向代理转发/api请求到后端端口。Nginx的关键配置大致如下server { listen 80; server_name your-domain.com; location / { root /usr/share/nginx/html/dist; index index.html; 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; } }try_files那一行一定不能少。因为Vue是单页应用路由模式用了history如果不配置用户刷新页面时Nginx会去找一个不存在的真实路径结果404。后端接口通过location /api/转发后前端请求URL里要保留/api前缀所以代理配置是proxy_pass http://127.0.0.1:8080;不带路径这样/api/login会原样转发到后端。6.3 启动失败的排查思路部署时遇到最多的问题其实是环境问题而不是代码问题。我整理了一个快速排查顺序。先看后端日志有没有报错。没有日志的话用java -jar命令在前台启动这样控制台会直接输出错误。如果报数据库连接失败看MySQL是否启动、账号密码是否正确、数据库名是否存在。如果报端口占用用netstat -ano | findstr 8080查一下端口是被哪个进程占用换端口或杀掉进程。前端如果访问时页面能打开但接口报404先在浏览器开发者工具里看请求的URL检查Nginx代理是否正确。如果接口返回401检查Token是否带上登录后再操作。如果页面白屏多半是构建路径不对检查vite.config.js里的base配置如果部署在子路径下要设置成对应路径。7. 开发过程中踩过的坑与解决实录7.1 camelCase和snake_case的映射问题Java实体类习惯用驼峰命名比如projectName、submitTime数据库字段用下划线project_name、submit_time。MyBatis-Plus默认开启了map-underscore-to-camel-case只要实体类字段和数据库字段能对应上自动映射没问题。但问题出在自定义SQL返回Map结果时。MyBatis不会自动帮Map的key做下划线转驼峰数据库返回的列名是什么Map的key就是什么。如果前端需要projectName而后端直接返回了project_name前端就取不到数据。解决办法是在application.yml里开启map-underscore-to-camel-case或者在SQL里用别名比如SELECT project_name AS projectName。还有一个坑是TableField的exist属性。如果实体类里有非数据库字段比如ListProjectMember membersMyBatis-Plus默认会把它当成数据库字段去查询SQL执行时报“Unknown column members”。所以非数据库字段上必须加TableField(exist false)注解。7.2 跨域配置与代理转发的冲突开发阶段前端通过Vite代理解决了跨域。但有时候后端也会开启全局CORS配置两套机制叠加反而会出现问题。比如后端配置了CorsFilter允许所有来源跨域。前端的请求先在代理层转发代理设置里又添加了Origin头或Access-Control-Allow-Origin可能产生重复响应头。浏览器被搞蒙了直接拦截。我的解决方案是开发环境只用Vite代理后端不开启CORS生产环境通过Nginx反向代理后端也不开CORS。这样跨域问题只在浏览器-代理层解决后端接口保持纯粹。如果你确实需要让后端支持跨域比如给其他第三方系统调用接口那就在后端配置CorsFilter并且确保allowedOriginPatterns设置准确。如果方法对OPTIONS预检请求没有特殊处理SpringBoot的CorsFilter已经处理了不用自己写拦截器。7.3 时间格式处理与Jackson配置前后端传时间经常出现“前端传字符串后端收到的是数组”或者“后端返回时间戳前端显示一串数字”的乱象。我统一规定后端接收和返回时间都使用字符串格式yyyy-MM-dd HH:mm:ss在实体类的LocalDateTime字段上加JsonFormat注解。JsonFormat(pattern yyyy-MM-dd HH:mm:ss, timezone GMT8) private LocalDateTime submitTime;同时还要配置fastjson或Jackson的全局格式避免每个字段都加注解。我在WebMvcConfig里注册了一个全局的Jackson2ObjectMapperBuilderCustomizer。如果使用SpringBoot默认Jackson则可以在application.yml里这样设置spring: jackson: date-format: yyyy-MM-dd HH:mm:ss time-zone: GMT8这个配置只对java.util.Date生效对LocalDateTime不完全生效所以稳妥方式还是全局自定义ObjectMapper。我踩过一次坑前端传入2024-05-21 10:00:00后端LocalDateTime字段反序列化失败报“Cannot deserialize value of typejava.time.LocalDateTimefrom String”。原因是没有加DateTimeFormat或Jackson的JSR310模块没注册。SpringBoot2默认支持但要注意包的引入我最终通过自定义ObjectMapper注册了JavaTimeModule解决。7.4 事务不生效的三种原因项目审批过程中既要更新项目状态又要插入操作日志还要更新经费记录这些操作必须放在同一个事务里。否则中途失败会导致状态不一致。我在开发时遇到过事务不生效的坑总结下来有三个原因。第一事务方法被类内调用。比如一个Service类的methodA调用了同一个类里的Transactional methodB此时Spring代理不会拦截这次内部调用事务根本不开启。解决办法是把需要事务的方法拆到另一个Service类中或者注入自身的代理。第二异常被捕获事务无法回滚。Transactional默认只在RuntimeException和Error时回滚如果是受检异常必须显式指定rollbackFor Exception.class。我之前就在审批逻辑里try-catch了这个自定义异常结果事务倒是提交了状态和数据错乱。第三事务方法不是public。Spring的声明式事务基于AOP私有方法无法代理直接不生效。所以事务方法一定要改成public并且通过Spring管理的Bean调用不能new一个对象来调。审查这些坑之后我把关键代码改成了这样Transactional(rollbackFor Exception.class) public void approveProject(Long projectId, boolean pass, String comment) { Project project projectMapper.selectById(projectId); // 校验状态 // 更新状态 projectMapper.updateById(project); // 插入日志 operationLogService.saveLog(...); // 如果有经费更新经费 fundService.updateFund(...); }这样无论中间哪一步抛异常整个审批操作都能完整回滚避免了用户看到“审批通过了但日志没有记录”的诡异情况。8. 项目源码结构与文档设计8.1 后端项目的分层结构拿到源码后先看包结构是否清晰。我按经典的三层架构分了controller、service、mapper、entity、dto、vo、config、common等包。common里放了统一返回结果Result、异常处理、常量枚举。config里放了Web配置、MyBatis-Plus配置、拦截器配置。entity包对应数据库表实体dto包用于接收前端参数vo包用于返回前端数据。不要一个对象从头用到尾因为前端传参可能包含校验属性返回数据可能包含关联信息混在一起容易把字段暴露出去。比如用户实体里有密码哈希如果直接返回给前端就麻烦了。必须用VO只返回需要的字段。我在代码注释上写得很详细每个关键业务方法旁边都有段说明解释这个方法是干什么的、在什么状态下被调用、可能出现什么问题。前端页面代码也做了组件拆分公共组件放到components目录视图页面放到views目录API请求封装到api目录。整体一看就能明白页面和接口的对应关系。8.2 配套文档写了哪些内容这份源码“含文档”文档是我后期花了很多精力整理出来的。除了常规的README我还写了《需求规格说明书》和《系统设计说明书》。《需求规格说明书》主要包含项目背景、功能需求列表、角色说明和业务流程说明。文档里用文字描述和状态图配合把每个角色能做什么、不能做什么写清楚方便二次开发时快速理解业务。《系统设计说明书》则包含数据库表结构说明、接口文档、核心类图和部署文档。接口文档采用Swagger风格把所有接口的请求参数、返回结果、示例都列出来了。写文档的初衷很实在虽然源码能跑但如果接手的人不懂业务规则维护改版会很痛苦。尤其是状态机这种逻辑代码里虽然有状态枚举但“为什么这个状态只能跳转到另一个状态”必须靠文档说明白。我在文档最后还加了一个“常见问题FAQ”章节记录了从环境搭建到部署上线遇到的问题及解决办法基本上新人在本地跑起来看这份文档就能自己解决80%的问题。9. 验收测试与实际使用体验9.1 功能测试的侧重点系统开发完成后我组织了一次比较完整的功能验收测试。测试重点放在三个地方流程完整性、权限隔离性、并发一致性。流程完整性测试就是走一遍完整的项目生命周期学生提交申报书指导教师审核学院分配专家专家评审校级审批项目立项中期检查结题验收。每个环节都要检查状态是否正确流转、操作日志是否记录、对应角色能否看到正确按钮。权限隔离性测试是让不同角色账号同时登录试着访问对方不该访问的接口。比如学生账号尝试调用管理员汇总评审结果的接口看是否会被拦截教师账号查询项目时看返回的数据是否只包含自己指导的项目。并发一致性测试主要针对经费使用。用两个账号同时为同一个项目提交经费申请总额刚好等于预算时看系统是否会超支。由于我们在更新经费字段时加了乐观锁Version数据库会拒绝后提交的一个请求返回“资金余额不足”这是预期结果。9.2 实际使用中的反馈与迭代系统在内部试运行了两周最直接的反馈是“真省事”。以前学生打印纸质申报书到处找老师签字现在线上提交老师在线审省了很多跑腿时间。管理员的反馈是“统计方便”以前结题时汇总成果要用Excel手工整理现在系统能按学院导出表格格式还统一。当然也有吐槽。比较集中的一点是表单填写的时间太长有些项目申报书内容多学生不想一次性填完。所以我们后来加了草稿自动保存功能每30秒自动把表单数据存到本地刷新后提示是否恢复草稿。另一个实际需求是消息提醒。审批节点变化后相关用户没有通知的话流程很容易卡住。后来我加入了站内消息模块在待办列表里显示“您的项目已通过指导教师审核请等待学院分配专家”之类的提示。学生登录后看到待办红点就知道该做什么了。这套消息模块代码不复杂但确实提升了系统体验。10. 这套方案的核心心得与后续扩展可能开发这个系统的过程中我最大的感受是管理类项目表面上是增删改查实际难点全在业务规则的拆分和数据的一致性。如果把流程状态、权限范围、数据范围这些抽象层面设计清楚后面的编码就是按图索骥如果一上来就写接口后面会改到怀疑人生。对于同样想做类似系统的人我建议别急着写代码先花两天时间把角色和状态列出来画一遍流程图。别小看这一步它决定了你整体开发的顺畅程度。后续这个系统还有很多可以扩展的地方。比如接入企业微信或邮箱通知审批通过后自动发送消息把项目申报评分功能做成专家在线打分并自动计算平均分再比如引入简单的数据看板用图表展示各学院项目数量和经费使用趋势。技术上SpringBoot2 Vue3这套组合足够支撑这些功能扩展只要遵循现有的分层和状态机约定加模块不会破坏原有结构。最后说一个实践中的小细节如果你要把这套系统部署到校园服务器上记得把MySQL和Redis的密码改成强密码并且不要在配置文件中写明文。本地跑固然方便但一暴露到公网各种扫描工具立马就会撞库。代码里我已经把数据库密码改为从环境变量读取你只需要在启动时设置DB_PASSWORD环境变量即可。这是当前项目的一次完整复盘看到这里相信你已经对这套技术栈和业务实现有了全面的了解。如果决定基于它做二次开发先从数据库设计和状态机入手准没错。