
1. 项目概述与总体设计思路1.1 用一句话讲清这套系统在解决什么问题我在高校信息中心做过好几年科研管理系统见过太多老师拿着纸质申报书满楼跑审批的场景。实际上“项目申报”这件事绝大多数单位都还在用最原始的方式运转申报人写文档、发给科研秘书、秘书手动登记台账、领导逐个看邮件、再通知修改意见。这个过程最大的痛点不是流程本身复杂而是信息停留在个人邮箱和微信聊天记录里状态不可见、进度不透明、汇总要手动做。这个基于SpringBootVueMyBatisMySQL的Web项目申报管理系统说白了就是把整个申报流程搬到线上申报人在线填写项目信息、上传附件、提交后自动进入审批队列审批人按角色看到待办列表可以一键通过或退回并填写审核意见管理员负责维护项目类型、评审阶段、账号分配和数据统计。整个系统用前后端分离架构后端SpringBoot提供RESTful接口前端Vue负责页面交互MyBatis做持久层映射MySQL存储业务数据。这个项目适合谁两类人最对口。一类是正在做毕业设计或课程设计的学生往届的选题大多是“图书管理系统”“学生选课系统”这类老掉牙场景换成“项目申报系统”后整个流程涉及多角色、多状态、附件上传、流程审批功能复杂度足够撑起一篇高质量论文也更容易在答辩时讲出亮点另一类是有真实业务需求的单位技术人员比如医院科室、研究所、企业研发部门的内部管理系统这套结构可以直接改改就能落地使用。1.2 为什么偏偏是SpringBootVueMyBatisMySQL这四件套技术选型这件事我在帮学生改代码时反复强调过一句话不要选最潮的要选最能证明你理解系统的。这套四件套组合放在2025年的今天依然是最稳的一个搭配没有之一。SpringBoot作为后端基础框架它的意义在于解决了传统SSH项目里大量XML配置的噩梦。你在SpringBoot里只需要一个spring-boot-starter-web依赖就能把Web服务跑起来内置Tomcat免去部署war包的麻烦。更重要的是SpringBoot的自动配置机制让我在写这套系统时几乎不用关心Bean管理、事务配置等底层琐事专注业务逻辑本身。对于需要快速交付的项目申报系统来说这一点非常实际——一个新手花在配SpringMVC的ComponentScan上的时间在SpringBoot里几乎为零。Vue这边我选的是Vue 2还是Vue 3要说明一下。如果你下载的这套源码是2025年整理的前端大概率是Vue 2 Element UI的组合。为什么很多教育类课程和开源项目还在用Vue 2因为生态成熟、资料多、踩坑方案一搜就有。但如果你想自己重构完全可以升级到Vue 3 Element Plus。我自己在重写这套系统时用的是Vue 3的Composition API配合Vue Router做动态路由Vuex做用户状态管理交互体验明显比原来的Options API版本舒服得多。MyBatis的选择则体现了这套系统的数据访问层设计思路。它让你可以精确控制SQL语句而不像JPA那样帮你封装一切。项目申报系统里最核心的就是那些多表关联查询——申报表关联项目类型表、关联审批记录表、关联附件表、关联用户表。这种场景用MyBatis的resultMap自定义映射比任何自动生成SQL的框架都更可控。再加上动态SQL能力可以轻松实现“按项目名称模糊搜索、按类型精确筛选、按状态区分列表”这类复杂查询。MySQL没什么悬念。人员规模几千人的单位申报系统一天产生的数据量也就几千条MySQL完全吃得消。配合Navicat或DBeaver可视化操作建表、导数据、调索引都非常方便。说实话很多学校和企业选这套方案最关键的一个原因是招人容易、维护成本低、出了问题网上答案多。2. 核心功能拆解与需求分析2.1 角色权限设计三种角色三条不同的功能线任何带审批性质的管理系统权限设计都是第一个要解决的问题。这套项目申报系统把用户分成三类申报人、审批人、系统管理员。申报人的功能线围绕“我的申报”展开。登录后能看到自己提交过的所有项目每条记录都有当前状态——待审核、审核中、已通过、已驳回。支持新增申报、编辑草稿、撤销未审核的申报、重新提交被驳回的项目。这里有个关键设计被驳回的项目不能直接改而是必须创建一个修订版本重新走流程。这个设计的好处是可以完整保留审批痕迹避免申请人偷偷修改内容绕过审核。在数据库里我为此单独设计了一张version_record表记录每次申报的版本号变更。审批人的功能线是标准的任务处理模式。登录后看到待办列表能查看完整申报书和附件填写审核意见选择通过或退回。这套系统支持多级审批——比如先由科研秘书初审再由分管领导终审。每一级的审批意见都会追加到审批记录链上形成完整的流水账。审批人在项目列表里还能按时间、类型、状态做筛选快速处理积压任务。管理员的功能线最重。除了维护基础数据——项目类型字典、评审阶段配置、通知公告发布——还要能看到全单位的项目信息总览和统计报表。我在前端用ECharts画了申报数量趋势图、各类型占比饼图、各审批人待办量排名。虽然这套系统的报表功能没有商业BI那么强但胜在轻量所有统计数据都通过后端一个/api/statistics/summary接口返回前端直接渲染。从下单到上线的角色配置逻辑核心思想就一句每个角色只看到自己职责范围内的数据和操作按钮。这套权限模型是基于SpringBoot的拦截器实现的——在Controller上定义RequiresPermission(admin)这样的注解拦截器在方法执行前验证当前登录用户的角色无权限直接返回403。相比Spring Security这种方式更轻量代码也更好懂适合教学和中小型项目。2.2 申报流程设计状态机是这套系统的灵魂我在写这套系统时最花心思的部分不是CRUD而是申报状态的流转。一开始我直接用简单的if-else判断状态能不能跳转结果发现状态一多代码就开始臭不可闻。后来我改用状态机模式把每种状态的合法转移路径提前定义好在代码里用一张Map存起来。比如草稿(0) → 待审核(1)申报人点击提交待审核(1) → 审核中(2)审批人开始处理审核中(2) → 已通过(3)审批人点击通过审核中(2) → 已驳回(4)审批人点击退回已驳回(4) → 待审核(1)申报人重新提交非法转移比如从草稿直接跳到已通过代码层面直接抛异常前端也会拦截。这套设计让整个业务流程非常严谨而且后续如果要增加流程分支——比如增加“专家评审中”状态——只需要在状态机配置表里加上一条转移规则不用改业务逻辑代码。2.3 数据库表结构一张简化版的设计图这套系统的数据库我一共设计了10张核心表分布在业务层、流程层和权限层三个层面数据层表名功能说明用户权限sys_user用户表含用户名、密码、角色ID、所属部门用户权限sys_role角色表含角色编码、名称用户权限sys_user_role用户角色关联表基础配置category项目类型表如“自然科学类”“社会科学类”基础配置review_stage审批阶段配置表如初审、终审业务核心project申报项目主表存项目名称、简介、预算、状态等业务核心project_attachment附件表记录上传文件的文件名、存储路径、大小流程记录approval_record审批记录表每条记录有审批人、审批意见、动作类型版本管理project_version项目版本表记录每次修改的版本号和改动说明通知消息notification站内信表用于通知申报人审批结果这里有一个容易忽略的细节项目表里我故意没有直接存“项目类型名称”而是存的类型ID。理由很简单——如果有一天你要把“自然科学类”改名叫“基础研究类”只需要改category表里一行记录project表不用动。这种字段设计方式在数据库范式里叫“关联代替冗余”实际项目中能省非常多的事。3. 核心技术实现与关键代码解析3.1 SpringBoot后端的三层架构是怎么落地的这套系统的后端采用标准的Controller-Service-Mapper三层架构。每个模块的Controller只做参数接收和结果返回不写业务逻辑Service负责核心规则和事务控制Mapper只负责SQL执行。这么拆的核心目的是当业务规则发生变化时只改Service层Controller和Mapper可以不动。举一个实际例子。申报人提交项目时Service层要做三件事保存项目基本信息、把状态从草稿改成待审核、记录一条操作日志。这三件事必须保证“要么都成功要么都失败”否则就会出现“项目存进去了但状态没改”的脏数据。解决办法是在Service方法上加Transactional事务注解。SpringBoot的事务管理默认遵循“遇到RuntimeException自动回滚”的规则所以你在代码里只需要处理业务异常不需要手动写TransactionTemplate。上传附件这块用的是MinIO。可能有的同学对MinIO比较陌生简单来说它是一个开源的对象存储服务兼容亚马逊S3协议部署起来就是一个单文件可执行程序。我选择MinIO而不是存数据库BLOB字段是因为附件文件往往有几MB甚至几十MB如果全部塞进MySQL数据库体积会爆炸膨胀备份恢复都会变得非常慢。MinIO存储路径数据库里只存attachment_id和文件元信息就够了。来看一个实际的Controller接口长什么样。假设我们要实现“查询当前用户的项目列表”RestController RequestMapping(/api/project) public class ProjectController { Autowired private ProjectService projectService; GetMapping(/list) public Result list(RequestParam(required false) String keyword, RequestParam(required false) Integer categoryId, RequestParam(required false) Integer status, RequestParam(defaultValue 1) Integer pageNum, RequestParam(defaultValue 10) Integer pageSize) { return Result.success(projectService.queryProjectList(keyword, categoryId, status, pageNum, pageSize)); } }请注意这个接口的参数设计keyword、categoryId、status都是非必传这就是“条件筛选”型接口的标准写法。前端可以根据用户在搜索框输入的内容决定传哪些参数不用为每一种组合单独写接口。如果用户没填关键词SQL层就会忽略这个条件的拼接。3.2 MyBatis动态SQL多条件查询的优雅解法为什么这个项目要用MyBatis而不是Spring Data JPA重点就在这里。你看下面对应的Mapper XML文件select idqueryProjectList resultMapProjectResultMap SELECT p.*, c.category_name, u.real_name AS creator_name FROM project p LEFT JOIN category c ON p.category_id c.id LEFT JOIN sys_user u ON p.creator_id u.id where if testkeyword ! null and keyword ! AND (p.project_name LIKE CONCAT(%, #{keyword}, %) OR p.project_code LIKE CONCAT(%, #{keyword}, %)) /if if testcategoryId ! null AND p.category_id #{categoryId} /if if teststatus ! null AND p.status #{status} /if /where ORDER BY p.create_time DESC LIMIT #{pageNum}, #{pageSize} /select这个动态SQL的精髓在where标签和if标签的配合。MyBatis会帮我们自动处理AND前缀问题——如果第一个条件不成立where会自动去掉SQL语句中开头的AND避免语法错误。这种写法比在Java代码里拼字符串优雅得多也是MyBatis面试时最常问的知识点——动态SQL的实现原理其实就是在XML解析时基于OGNL表达式做判断拼接。我把项目表和用户表、类型表做了LEFT JOIN关联这样前端拿到列表数据后直接就能显示项目类型名称和申报人姓名无需前端再发多个请求去查对应关系。你可能会问为什么不直接存类型名称原因我前面提过——表和表之间必须用ID关联杜绝冗余数据导致的不一致问题。分页这块我用了PageHelper插件。它的用法非常简单在查询前调用一行代码PageHelper.startPage(pageNum, pageSize);这行代码会自动在后续执行的SQL后面追加LIMIT语句并把查询结果封装成PageInfo对象里面已经算好了总记录数、总页数、当前页数据等分页信息。实际开发中分页是这个系统的高频用法但几乎所有学生自己做项目时都会忽略PageHelper的一个坑——它只能在紧接其后的第一条查询上生效。如果你在startPage和查询之间插入了一个无关的查询分页就会跑到那个无关查询上数据错乱还不报错。这条经验是我实际调试中发现的希望大家不要再踩。3.3 MyBatis的TypeHandler处理MySQLJSON字段的利器项目申报系统里大概率需要存“项目成员列表”。如果建一张子表来存成员每次增删改都要先删后插很烦。我在这个项目里用了MySQL的JSON类型在实体类里对应一个List对象中间就靠TypeHandler完成转换。自定义TypeHandler的核心是继承BaseTypeHandlerT重写setNonNullParameter和getNullableResult几个方法MappedTypes(List.class) MappedJdbcTypes(JdbcType.VARCHAR) public class JsonListTypeHandler extends BaseTypeHandlerListString { Override public void setNonNullParameter(PreparedStatement ps, int i, ListString parameter, JdbcType jdbcType) throws SQLException { ps.setString(i, JSON.toJSONString(parameter)); } Override public ListString getNullableResult(ResultSet rs, String columnName) throws SQLException { String value rs.getString(columnName); return value null ? null : JSON.parseArray(value, String.class); } // 还有两个重载方法略 }配置好TypeHandler后Mapper里的字段映射就很简洁result columnmember_list propertymemberList typeHandlercom.example.handler.JsonListTypeHandler/这里要注意一个细节如果你要写的字段用了自定义TypeHandlerinsert语句里同样要声明typeHandler否则MyBatis不会主动调用它数据会存成Java对象默认的toString格式。3.4 Vue前端从路由到状态管理的完整实现前端部分这套系统的核心页面分为三大块登录页和权限路由、申报列表页、审批处理页。我在写Vue前端时最得意的一个设计是动态路由。普通系统在路由表里写死所有页面路径登录后直接跳转到第一个页面。但这套系统是按角色动态组装菜单的——登录成功后后端返回当前用户拥有的菜单权限编码列表前端根据权限编码动态拼接路由表。比如menu:[project:submit]对应申报入口menu:[project:review]对应审批入口。这样管理员不用为不同角色部署单独的前端包一个前端包就能适配所有角色。看一段Vue Router的动态路由注册逻辑// 动态添加路由 function addDynamicRoutes(menuList) { const routes [] menuList.forEach(menu { const comp () import(/views/${menu.componentPath}) routes.push({ path: menu.path, name: menu.name, component: comp, meta: { title: menu.title, icon: menu.icon } }) }) router.addRoute(routes) }做这个功能时我踩过一个比较隐蔽的坑动态render一个不存在的组件路径时Webpack会直接报错导致整个路由注册失败。所以componentPath必须和views目录下的文件名严格对应任何一个字母大小写不一致都会导致页面空白。建议在设计菜单表时就把组件路径作为一条静态字段存好不要靠前端拼接规则。状态管理方面我用了Vuex存登录用户信息、审批记录筛选的状态、通知未读数。其实一个中大型项目还在用Vuex可能会被认为是老古董毕竟Pinia是官方推荐的下一代状态库。但这套系统是在Vue 2时代起步的如果你们拿到的是Vue 2版本继续用Vuex没问题。如果你自己用Vue 3重写我更推荐Pinia——它的语法更简洁去掉了mutations、modules嵌套等概念TypeScript支持也更好。4. 从零到一本地快速跑通项目实操笔记4.1 环境准备各组件版本怎么配才不打架这套系统的本地环境搭建其实比想象中简单但版本搭配非常讲究。我直接把亲测可用的版本组合列出来组件推荐版本备注JDK1.8 或 11Spring Boot 2.x系列不支持JDK 17以上Maven3.6.3建议用阿里云镜像加速MySQL5.7 或 8.08.0需要调整驱动和连接配置Node.js14 LTS 或 16 LTS与Vue 2的webpack兼容Redis不强制如果做记住登录状态可以用有一个非常常见的报错我多说一嘴如果你用的是MySQL 8.0但pom里引的是mysql-connector-java 5.x驱动启动时大概率报SSL连接错误。正确的配置是使用com.mysql.cj.jdbc.Driver驱动类同时在JDBC连接串里加上useSSLfalseserverTimezoneAsia/Shanghai。MySQL 8默认开启SSL和UTC时区不设置这两项连数据库时会报Communications link failure或者时间差8小时。4.2 数据库初始化执行SQL脚本的两种姿势拿到源码压缩包后里面通常会带一个sql/init.sql文件。建议用Navicat或命令行客户端执行。执行前注意检查脚本开头有没有CREATE DATABASE语句如果没有你需要自己先建库CREATE DATABASE IF NOT EXISTS project_apply DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci; USE project_apply; SOURCE /path/to/init.sql;这里使用utf8mb4字符集非常关键不要用默认的utf8。因为utf8在MySQL里最多只能存3字节字符而emoji表情和一些生僻汉字需要4字节一旦有人在上传文件的描述里写了这类字符数据就直接报错无法入库。utf8mb4是utf8的超集完全向下兼容能用它就不要用utf8这条原则适用于所有新项目。导入完成后检查一下三张最有代表性的表sys_user、project、approval_record。sys_user表里应该有几条初始化数据密码可能是明文也可能是MD5加密串。如果项目代码里配置了MD5密码校验那你需要在初始化数据时就用MD5加密后的密码串。用123456的MD5值e10adc3949ba59abbe56e057f20f883e是很常见的默认值。4.3 后端启动SpringBoot项目的配置细节打开后端代码第一步看application.yml。最核心的配置块是数据源spring: datasource: url: jdbc:mysql://localhost:3306/project_apply?useSSLfalseserverTimezoneAsia/ShanghaicharacterEncodingutf8 username: root password: 你的密码 driver-class-name: com.mysql.cj.jdbc.Driver servlet: multipart: max-file-size: 20MB max-request-size: 100MB上传附件的大小限制要在这里配置。如果不配置SpringBoot默认单文件最大1MB你传一个几MB的项目申请书直接报FileSizeLimitExceededException前端会显示上传失败。这个限制是全局的所以即使前端上传组件里设置了limit: 3最多3个文件后端也会严格校验单个文件的大小。启动类通常长这样SpringBootApplication MapperScan(com.example.mapper) public class Application { public static void main(String[] args) { SpringApplication.run(Application.class, args); } }MapperScan这个注解非常容易漏。没有它SpringBoot就找不到Mapper接口启动后一切正常但一调用接口就报Invalid bound statement (not found)。排查这个问题有个小技巧——先看控制台启动日志里有没有ClassPathMapperScanner的扫描日志如果没打印那基本就是没扫到。4.4 前端启动npm install和npm run dev的坑前端项目根目录下执行npm install npm run dev理论上就这么两步但实际执行时很多人会在npm install阶段卡住。解决办法是设置npm镜像为国内源npm config set registry https://registry.npmmirror.com安装完成后启动dev服务。Vue CLI默认端口是8080如果后端接口地址配的是8080那就会冲突。解决方式两种要么改前端vue.config.js的devServer.port要么改后端的server.port。我习惯把后端端口设为9000前端保持8080并在vue.config.js里配置跨域代理devServer: { proxy: { /api: { target: http://localhost:9000, changeOrigin: true, pathRewrite: { ^/api: /api } } } }这个proxy配置解决了Vue开发环境最常见的跨域问题。原理是浏览器页面在8080端口调用/api开头的请求时会先由webpack dev server转发给9000端口的后端因为服务端之间没有跨域限制所以请求能成功。而生产环境部署时则需要用Nginx来做同样的反向代理。4.5 生产构建Vue打包后如何放进SpringBoot项目做完要演示或者部署时最省事的方式是把前端打包后的静态文件放进SpringBoot的静态资源目录做一个单服务部署。npm run build打包完成后dist目录下会生成index.html和static文件夹。把它们拷贝到后端项目的src/main/resources/static目录重新启动SpringBoot应用。这样访问http://localhost:9000直接就是前端页面前端通过相对路径/api/xxx请求后端接口完全不需要Nginx。这种方式适用小型内部系统的快速部署。这里有个需要特别注意的地方Vue Router默认是history模式打包后单文件部署会出现刷新页面404的问题。比如你访问/project/detail/1时刷新一下SpringBoot的静态资源处理器找不到对应路径报404。解决方式有两种一是在前端改用hash模式——路由URL会变成/#/project/detail/1刷新没问题二是在后端加一个转发规则把未匹配的路径重定向到index.htmlController public class IndexForwardController { RequestMapping(value {/, /{path:[^\\.]*}}) public String forward() { return forward:/index.html; } }第二种方案体验更干净但要小心正则表达式排除掉带后缀的静态资源请求否则js、css文件也会被错误转发。5. 常见问题与排查技巧实录5.1 数据库连接与SQL执行问题问题1Access denied for user rootlocalhost这个报错大多数时候不是密码错了而是MySQL 8.0的默认认证插件问题。MySQL 8默认的caching_sha2_password插件和旧版连接驱动不兼容。处理方式有两种一种是升级驱动到mysql-connector-java8.0.11以上另一种是在MySQL里把用户认证改回mysql_native_passwordALTER USER rootlocalhost IDENTIFIED WITH mysql_native_password BY 你的密码; FLUSH PRIVILEGES;问题2MyBatis查询结果字段全是null这个问题的根源通常是resultMap里类型映射搞错了比如数据库字段叫project_name而实体类属性叫projectName如果项目配置为下划线转驼峰MyBatis可以自动转换。确保在application.yml里开启mybatis: configuration: map-underscore-to-camel-case: true如果不开启这个配置你只能用一个挨一个写resultMap的方式映射非常繁琐。这个开关强烈建议开启节省的不只是写代码的时间还有排查字段对应错误的时间。5.2 前后端联调与交互问题问题3前端请求接口报404或CORS错误先区分是哪一种404多半是后端接口URL路径写错了检查Controller的RequestMapping和前端axios请求的URL是否完全一致CORS报错说明后端没有允许跨域访问。对于全栈分离开发模式在后端配置一个全局CORS过滤器最方便Configuration public class CorsConfig implements WebMvcConfigurer { Override public void addCorsMappings(CorsRegistry registry) { registry.addMapping(/**) .allowedOriginPatterns(*) .allowedMethods(GET, POST, PUT, DELETE) .allowCredentials(true) .maxAge(3600); } }还有一种更隐蔽的情况接口明明存在但前端带上了token后端没有处理OPTIONS预检请求。跨域请求会让浏览器先发一个OPTIONS请求探路如果后端接口直接被Spring Security或拦截器拦截前端就会看到CORS校验失败。验证方法很简单打开浏览器Network面板看失败的请求是不是OPTIONS类型。问题4前端上传文件时请求体格式不对我用Element UI的el-upload组件时踩过一回坑——组件默认用multipart/form-data格式上传但后端接口要求的接收参数名是file如果前端在上传时自定义了fileList参数名就会导致后端MultipartFile file接收不到报400。我现在的习惯是写一个固定的上传方法const uploadFile (file) { const formData new FormData() formData.append(file, file) return request.post(/api/project/upload, formData, { headers: { Content-Type: multipart/form-data } }) }不要依赖组件默认的自动上传手动用FormData组装每个环节都在自己控制之下出了问题也好排查。5.3 从源码学习的三条核心经验先把这套源码完整跑通再动手改。我见过太多人拿到源码就把代码一删直接自己写最后卡在一个接口上几天没进展。正确做法是先跑通然后逐行看Controller到Mapper的调用链读懂后再从最小功能开始改——比如把列表页加一个排序字段。做完这一轮你对这套系统的理解深度会远超照抄一遍。第二学会用接口测试工具验证后端功能。我强烈建议把Postman或Apifox用起来实际上后端代码调试时根本不打开前端页面直接用工具调接口确定数据返回正常才去联调页面。出问题时先判断是后端的问题还是前端的问题不要两头乱抓。第三把这套系统的部署能力也练一遍。把Java后端打成jar包前端打包成dist然后用命令行启动jar包再把dist放进jar同级的static目录或独立静态目录。很多人在开发环境跑得溜一谈到部署就抓瞎。实际上掌握一套系统从开发到部署的全过程才是这个项目对你最大的价值比多写100行CRUD代码重要得多。我个人在实际操作中的体会是这套项目申报系统的核心难点从来不是某段代码写不出来而是业务流程的建模是否清晰——角色怎么分、状态怎么流转、每条数据怎么关联。你把这些想透了SpringBoot、Vue、MyBatis这些技术栈都只是顺手使用的工具。后续如果你想扩展可以在这个基础上增加消息推送、Excel批量导入导出、甚至接一个工作流引擎比如Flowable都能很自然地演进过去。