ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

SpringBoot+Vue3+MyBatis+MySQL:创新创业项目申报管理系统开发实战

SpringBoot+Vue3+MyBatis+MySQL:创新创业项目申报管理系统开发实战 把创新创业教育中心的项目申报管理从Excel表格解放出来是我今年做的比较有成就感的一件事。这套系统采用SpringBootVue3MyBatisMySQL的前后端分离架构覆盖了从项目申报、指导教师审核、专家评审到结题验收的完整闭环。前后端分离开发MySQL做数据持久化Web端操作流畅跑在校园网里非常稳定。如果你正准备做类似的管理系统或者正在纠结SpringBootVue3这套技术栈怎么选、怎么搭建这篇内容会给你一个可以直接参考的完整落地路径。1. 项目概述与核心需求拆解1.1 创新创业教育中心申报管理的现实痛点做这个系统之前创新创业教育中心的项目申报基本停留在“纸质提交Excel汇总人工流转”的模式。学生提交申报书靠发邮件或者线下交材料指导老师签字靠跑办公室中心管理员统计项目信息要手动复制粘贴评审专家打分更是各看各的最后汇总成绩要花好几天。整个过程至少有四个问题流程进度不透明学生不知道自己的项目卡在哪一步材料版本混乱同一个项目可能提交了好几个版本的申报书数据统计靠人工项目类别、经费预算、负责人信息这些字段整理起来又慢又容易错历史项目资料散落在不同人的电脑里第二年想查一下之前的项目数据根本找不到。所以立项时候的目标很清晰做一个Web应用让学生在线填写申报书、上传附件、查看审核进度让指导老师和评审专家在线审核打分让管理员可以灵活配置评审流程、导出各类统计报表。核心不是“做个网站”而是把整个申报业务规则用系统固化下来。1.2 技术选型为什么是SpringBootVue3MyBatis选型这件事很多人喜欢追新但实际做项目要的是稳定、好招人、好维护。这套组合在2026年的校园项目和中小型企业管理系统中依然具有很强的普适性。后端选SpringBoot理由非常直接简化配置内嵌Tomcat一个SpringBootApplication注解就能启动一个可运行的Web服务。它不像老式SSH框架那样写一堆XML配置也不像Spring Cloud那样引入全套微服务组件——对这样一个单体应用来说SpringBoot属于“开箱即用、按需扩展”的定位。Java语言本身类型安全适合承载申报管理这类业务规则比较严谨的系统而且高校里用Java做服务端的团队占比很高后续找人接手维护也容易。前端选Vue3而不是Vue2不是跟风而是Vue3的组合式API(Composition API)确实更适合中后台开发。申报管理系统的页面逻辑集中在“表单表格状态流转”如果用选项式API(Options API)一个申请表单页可能需要维护data、computed、methods多个部分跳来跳去很割裂。组合式API可以把“申报表单校验”“附件上传逻辑”“审核状态展示”各自封装成setup里的逻辑单元代码可读性和复用性都明显提升。配合Vite构建工具开发热更新速度快打包体积也能控制。MyBatis的存在感不像SpringBoot那么强但它是这套系统里跟数据库打交道最顺手的一层。为什么不用Spring Data JPA因为申报管理系统的SQL查询非常具体统计某个学院某年各类别项目数量、联表查询用户和申报书、按条件动态筛选……MyBatis直接用XML写SQL控制力更强尤其是多表联查和动态SQL的场景写起来是“所见即所得”优化起来也更直观。MySQL作为数据库不用多解释开源、免费、生态成熟校园项目部署在服务器上连授权费都不用考虑。用InnoDB引擎做事务支持保证申报书提交、审核操作等关键数据操作的安全可靠。2. 系统整体架构与数据库设计2.1 前后端分离架构的职责划分前后端分离这个词被说滥了真正落地的时候还是要清晰的边界。系统分成两个独立的工程innovation-server后端API服务端口默认8080和innovation-web前端Vue3应用开发环境端口5173生产环境打成静态文件部署在Nginx或者直接塞进SpringBoot的static目录。后端只负责三件事提供RESTful API、做业务逻辑处理、和MySQL交互。它不关心页面长什么样只返回JSON数据。前端只负责两件事渲染页面、收集用户操作并通过Axios调用API。两端通过统一的接口协议通信我用的响应格式是{ code, message, data }code为0表示成功其他值为异常码。部署形态上有两种选择方案一是前后端完全分离前端打包后配Nginx反向代理/api到后端8080端口方案二是把前端打包后的dist目录拷贝到后端src/main/resources/static下直接用SpringBoot托管静态资源。我给中心部署时用的是方案二因为校园网里就一台Windows服务器装一个Tomcat窗口跑一个Jar包最简单少维护一个Nginx进程。开发调试阶段则完全分开跑用Vite代理解决跨域问题。2.2 数据库表设计与MySQL配置要点数据库设计是这类业务系统的地基我一开始就定了八张核心表表名职责关键字段sys_user用户表id, username, password, real_name, role_id, college_idsys_role角色表id, role_code, role_namesys_college学院表id, college_nameproject_category项目类别表id, category_name, max_fundingproject_application申报书主表id, user_id, project_name, category_id, summary, status, budget, submit_timeproject_member项目成员表id, application_id, member_name, student_no, majorreview_record审核记录表id, application_id, reviewer_id, score, comment, review_timeattachment_info附件表id, application_id, file_name, file_path, upload_time其中project_application.status是整个系统的业务核心我用一个tinyint字段存储状态码0草稿、1已提交待初核、2初核通过待评审、3初核驳回、4评审中、5已立项、6已结题、7已终止。状态流转图其实不需要在数据库里做复杂设计只要在Service层写好状态机校验逻辑就行禁止从“草稿”直接跳到“已立项”每个状态的合法去向都定义清楚。MySQL配置方面有两点值得说道。第一点是字符集建库时我明确指定CREATE DATABASE innovation_center DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci;utf8mb4是必须的因为申报书摘要里经常有emoji表情、特殊字符老版utf8存不下。第二点是连接池配置我在application.yml里设置了HikariCP的核心参数spring: datasource: url: jdbc:mysql://localhost:3306/innovation_center?useUnicodetruecharacterEncodingutf8serverTimezoneAsia/ShanghaiuseSSLfalseallowPublicKeyRetrievaltrue username: root password: xxxxxx driver-class-name: com.mysql.cj.jdbc.Driver hikari: maximum-pool-size: 20 minimum-idle: 5 connection-timeout: 30000serverTimezoneAsia/Shanghai一定不能漏MySQL 8.x默认时区是UTCJava服务跑在本地时区两者不一致会导致java.time.LocalDateTime查询结果差8个小时。allowPublicKeyRetrievaltrue是MySQL 8.x配合RSA加密连接时的常见连接错误解法不加的话会报Public Key Retrieval is not allowed。这些细节不踩一次坑根本记不住。3. 后端核心实现SpringBootMyBatis3.1 项目骨架与依赖配置创建SpringBoot项目我用的是Spring InitializrIDEA内置的也可以Java版本选了17SpringBoot版本选了2.7.18。这里有个实际经验不要盲目用SpringBoot 3.x因为3.x基于Jakarta命名空间很多老教程和第三方包还没完全跟上校园项目里2.7.x足够稳定Spring Security的用法也更常见。核心依赖如下dependencies dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-web/artifactId /dependency dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-validation/artifactId /dependency dependency groupIdorg.mybatis.spring.boot/groupId artifactIdmybatis-spring-boot-starter/artifactId version2.3.2/version /dependency dependency groupIdcom.mysql/groupId artifactIdmysql-connector-j/artifactId scoperuntime/scope /dependency dependency groupIdorg.projectlombok/groupId artifactIdlombok/artifactId optionaltrue/optional /dependency /dependencies包结构我习惯按业务模块划分而不是严格按技术类型划分也就是controller/service/mapper/entity先建一层基础包再在entity里按system和project两个业务域拆子包。这样一方面不会让mapper目录堆几十个接口另一方面也方便后期拆微服务时直接按包迁移。3.2 用户认证与权限控制申报管理系统的用户角色有学生、指导教师、评审专家、中心管理员四类权限控制必须做但不能做得太重。我最终选了Spring Security JWT的组合没有引入Spring Security OAuth2那套复杂体系。JWT登录的流程很直接用户提交用户名密码后端校验通过后生成一个包含用户ID和角色编码的Token客户端存在LocalStorage里每次请求在Authorization请求头带上Bearer {token}。后端用OncePerRequestFilter拦截请求解析Token后把用户信息塞进SecurityContextHolder然后在PreAuthorize注解里写权限规则。这里有个新手容易踩的坑Spring Security的默认行为会拦截所有请求并生成登录页接入JWT之后一定要显式放行登录接口和静态资源。我花了大半天排查为什么登录接口返回401最后发现是WebSecurityConfigurerAdapter的配置顺序问题。正确的配置核心部分是http.csrf().disable() .sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS) .and() .authorizeRequests() .antMatchers(/api/auth/login, /api/auth/register).permitAll() .antMatchers(/api/admin/**).hasRole(ADMIN) .anyRequest().authenticated();认证通过只是第一层业务数据层面的权限还要靠Service层控制。比如学生只能查询自己的申报记录我就在查询方法的SQL里强制拼接where user_id #{currentUserId}而不是只依赖前端隐藏按钮。这类“数据级权限”如果漏了很容易出现越权查看别人申报书的大问题。3.3 申报书提交与审核的Service事务处理申报书提交和审核是系统最关键的写入操作绝对不能在事务上偷懒。我用一个粗粒度的事务方法处理申报书提交逻辑一次事务里完成三件事保存申报书主表数据、批量插入项目成员列表、记录一条状态变更日志。Transactional(rollbackFor Exception.class) public Long submitApplication(ApplicationSubmitDTO dto) { // 校验项目名称是否重复、预算是否超过类别上限 ProjectApplication application new ProjectApplication(); application.setUserId(dto.getUserId()); application.setProjectName(dto.getProjectName()); // ...省略字段拷贝 application.setStatus(0); // 草稿 projectApplicationMapper.insert(application); // 批量插入成员 ListProjectMember members dto.getMembers().stream().map(...).collect(Collectors.toList()); if (!members.isEmpty()) { projectMemberMapper.batchInsert(members); } return application.getId(); }Transactional里面还有一个容易忽视的点事务内调用同类的方法rollbackFor必须指定Exception.class否则运行时异常以外的业务异常不会触发回滚。我一开始只写了Transactional测试时故意在批量插入成员前加了一个包含文件写入失败的逻辑结果主表数据还是保存进去了排查半天才发现是回滚策略没写对。MyBatis的批量插入这里也补充一下不要用循环里单条insert的方式几百条数据会有明显的性能问题。直接在XML里写foreach批量插入insert idbatchInsert insert into project_member(application_id, member_name, student_no, major) values foreach collectionlist itemmember separator, (#{member.applicationId}, #{member.memberName}, #{member.studentNo}, #{member.major}) /foreach /insert3.4 MyBatis动态SQL与多表联查申报列表的查询是最容易写成一团浆糊的地方因为筛选条件太多了按状态查、按项目类别查、按学院查、按关键词模糊搜索、按提交时间段查。如果每个条件组合都写一条SQL那得写几十条。MyBatis的where配合if标签可以让一条SQL自动适配所有筛选组合select idselectApplicationPage resultTypecom.innovation.entity.ProjectApplicationVO select a.id, a.project_name, c.category_name, u.real_name, col.college_name, a.budget, a.status, a.submit_time from project_application a left join project_category c on a.category_id c.id left join sys_user u on a.user_id u.id left join sys_college col on u.college_id col.id where if teststatus ! null and a.status #{status} /if if testcategoryId ! null and a.category_id #{categoryId} /if if testcollegeId ! null and u.college_id #{collegeId} /if if testkeyword ! null and keyword ! and (a.project_name like concat(%, #{keyword}, %) or u.real_name like concat(%, #{keyword}, %)) /if /where order by a.update_time desc /select这里的left join而不是inner join也是一个细节因为有一部分申报书可能还没关联指导老师用内连接会把孤儿数据过滤掉。用ProjectApplicationVO承接联查结果时MyBatis会自动做驼峰映射前提是在application.yml或MyBatis配置里开启了map-underscore-to-camel-case: true否则project_name映射不到projectName列表页会显示一排空字段。给新手一个我踩过的坑动态SQL里的if标签判断字符串时if testkeyword ! null and keyword ! 中间注意不能有特殊字符问题还有test里判断参数类型为Long时status ! null没问题但如果传的是字符串0要用status 0的写法两个等号不能少。4. 前端核心实现Vue3 组合式API4.1 项目初始化与目录组织前端用Vite创建Vue3项目命令很简单npm create vitelatest innovation-web -- --template vue然后安装Vue Router、Pinia、Element Plus、Axios。目录结构我是按页面功能来组织的这样后期增加一个模块比如“创新创业大赛管理”直接复制一个已有模块的目录改改就能用src/ ├── api/ # 按模块拆分的接口定义 │ ├── auth.js │ ├── application.js │ └── review.js ├── components/ # 通用组件 │ ├── StatusTag.vue │ └── UploadFile.vue ├── router/ # 路由配置 ├── stores/ # Pinia状态管理 │ └── user.js ├── views/ # 页面级组件 │ ├── student/ │ │ ├── ApplicationList.vue │ │ ├── ApplicationForm.vue │ │ └── ApplicationDetail.vue │ ├── teacher/ │ │ └── ReviewList.vue │ └── admin/ │ ├── ManageApplications.vue │ └── StatisticsBoard.vue └── utils/ └── request.js # Axios封装Element Plus是我中后台开发的首选组件库表格、分页、表单、上传组件都有现成的项目进度会快很多。需要留意的版本问题是Element Plus 2.x必须配合Vue 3使用不要再下成Element UI那是Vue 2的。组件库引入方式我选全量引入虽然打包体积大一点但开发效率高一个校园内网系统对首屏体积没那么敏感。4.2 申报表单的设计与校验逻辑申报表单是整个系统使用频率最高的页面学生填一份申报书至少要十分钟所以表单交互做得好不好直接影响使用体验。我把申报表单拆成了四个分区基本信息项目名称、项目类别、预算金额、项目成员动态增删成员行表、项目摘要多行文本、附件上传申报书PDF、支撑材料。Element Plus的el-form配合rules校验规则非常方便重点说两个校验细节。第一个是动态成员行的校验。el-form-item数组进行动态校验时需要给每个成员行绑定prop格式是members.${index}.memberName校验规则写到rules.members下面const rules { projectName: [{ required: true, message: 请输入项目名称, trigger: blur }], categoryId: [{ required: true, message: 请选择项目类别, trigger: change }], members: { memberName: [{ required: true, message: 请输入成员姓名, trigger: blur }], studentNo: [{ required: true, message: 请输入学号, trigger: blur }] } }第二个是预算金额的联动检查。预算上限由项目类别决定学生选择“创新训练项目”后maxFunding会从接口拉回来提交时前端先校验一次“预算不得超过类别上限”后端Service层再校验一次前端校验是为体验后端校验才是真防线。这里设计一个小提示语在预算输入框旁边实时显示“该项目类别最高支持XXX元”学生就很少填超预算了。4.3 组合式API封装数据请求与状态管理Vue3组合式API最顺手的用法是配合自定义Hooks。我抽了一个useApplicationList的Hook负责加载列表、处理分页、筛选条件这类复用逻辑任何组件需要展示申报列表时直接引入这个Hook即可。import { ref, onMounted } from vue import { getApplicationPage } from /api/application export function useApplicationList(fetchParams) { const list ref([]) const total ref(0) const loading ref(false) const loadData async () { loading.value true try { const res await getApplicationPage(fetchParams.value) list.value res.data.records total.value res.data.total } finally { loading.value false } } onMounted(loadData) return { list, total, loading, loadData } }状态管理方面很多中后台项目其实用不上Pinia但申报管理系统里有一个典型的全局状态——当前登录用户的信息和权限按钮列表。每个页面都要判断“当前用户是不是管理员能不能看到审核按钮”如果每个页面都从LocalStorage重新解析一遍Token太啰嗦我用Pinia存一份userStore页面里直接调用。4.4 Axios封装与跨域联调Axios封装有几个要点统一请求前缀、统一响应拦截处理业务码、统一错误提示。我的request.js核心逻辑如下service.interceptors.response.use( (response) { const res response.data if (res.code ! 0) { ElMessage.error(res.message || 请求失败) return Promise.reject(new Error(res.message)) } return res }, (error) { if (error.response?.status 401) { // Token过期清理本地信息并跳转登录页 localStorage.removeItem(token) router.push(/login) } ElMessage.error(error.response?.data?.message || 网络异常) return Promise.reject(error) } )这里比较关键的是要把res直接返回这样调用方拿到的就是完整的业务响应体不用每页都写res.data.data这种嵌套取值的代码。开发环境跨域问题我用的是Vite代理。在vite.config.js里配置一个/api开头的代理转发前端请求/api/auth/loginVite会把请求转发到http://localhost:8080浏览器看不到跨域访问因为浏览器只跟Vite开发服务器通信server: { port: 5173, proxy: { /api: { target: http://localhost:8080, changeOrigin: true } } }关键点提醒changeOrigin: true必须配置否则后端拿到的Host请求头是前端地址部分接口会出问题。5. 常见问题与排查技巧实录5.1 MySQL连接与版本兼容问题部署阶段遇到最多的是MySQL连接问题。最典型的场景本地开发连MySQL 8.0没问题部署到服务器上只装了MySQL 5.7用同一个Jar包跑起来直接报Access denied for user或者Communications link failure。原因在于MySQL 5.7和8.0的认证插件不同8.0默认caching_sha2_password5.7默认mysql_native_password而驱动版本不一样行为也有差异。我的处理办法是数据库统一用MySQL 8.0驱动用mysql-connector-j注意新版Maven坐标已经改名了不再是mysql-connector-java并在连接串里明确指定useSSLfalse。如果你只能用5.7就在连接串里加allowMultiQueriestrue部分SQL里可能有多语句需求同时把驱动降级到5.1.49版本别用8.0驱动去连5.7会有一堆兼容性警告。还有一个高频问题中文乱码。检查顺序依次为数据库字符集show variables like %character%、连接串编码characterEncodingutf8、前端页面编码Vue3项目默认UTF-8基本不用管。大概率问题出在前两个。5.2 MyBatis结果映射和动态SQL的坑MyBatis的坑基本集中在映射和动态SQL上。第一个坑是resultType和resultMap混用。如果查询的列名需要加别名做成VO我倾向于用resultTypecom.innovation.entity.ProjectApplicationVO然后SQL里明确写别名开启驼峰映射后就不用写繁琐的resultMap。但如果SQL里用了group by聚合函数比如统计各学院项目数量resultType对应一个统计VO倒是没问题有连表查询加复杂字段映射时还是老实写resultMap更清楚。第二个坑是foreach里collection参数名不对会报There is no getter for property named错误。当Mapper方法是ListProjectMember batchInsert(Param(list) ListProjectMember list)时collection必须写成list。如果没加Param注解XML里collection要写collection数组或list集合规则比较绕我的习惯是Mapper方法一律加Param这样XML里一目了然。第三个值得一提的坑是MyBatis一级缓存和二级缓存的失效问题。在同一事务中多次查询同一份数据第一次之后走一级缓存没问题但如果中途执行了任何写操作一级缓存自动清空第二次查询又会走数据库。在审核统计的场景里先查一次申报数量再插入一条审核记录再查一次数量两次都实时查库其实是对的。我就是因为一时没想清楚这个机制怀疑查询SQL写错了排查了半天。如果系统存在“先查数据再在后台修改数据然后再查数据”的需求缓存机制的行为反而能保证数据一致性。5.3 前端部署与打包的典型问题前端部署阶段的第一个坑是生产环境API地址写死。开发环境用Vite代理很舒服打包之后代理没了如果request.js里的baseURL还是/api部署到Nginx还好但直接塞进SpringBoot静态目录时请求/api/auth/login会打到SpringBoot后端本身这时路由能对上但如果后端路径也是/api前缀就没问题。我的做法是统一后端所有Controller加/api前缀前端baseURL就写/api这样后端托管静态资源时也能直接工作不需要Nginx改写路径。第二个坑是前端路由的history模式打包后刷新404。Vue Router如果用createWebHistory()部署在SpringBoot里刷新某个子路由路径比如/student/application后端找不到这个路径就会返回404。解决方案在SPA里通常有两种一是用createWebHashHistory()改用哈希路由URL带个#不太好看但零配置二是在SpringBoot写一个WebMvcConfigurer把不存在的路径转发到index.html但需要排除/api开头的真实接口路径。我选了第一种省心。5.4 申报管理系统特有的业务逻辑陷阱最后说几个这个系统特有的业务逻辑问题。第一个是审核状态的幂等性。审核按钮如果被用户双击前端可能同时发出两个审核请求后端会执行两次审核操作导致状态错乱。我的处理方案是在审核接口里加一个前置状态校验用“当前状态必须等于期望的前置状态”作为乐观锁类似update ... where id ? and status #{expectStatus}受影响行数为0就说明状态已被更新直接提示“请勿重复操作”。第二个是预算统计的精度问题。项目经费金额涉及小数数据库字段我用了DECIMAL(10,2)而不是FLOAT。FLOAT的二进制存储会导致0.1 0.2这类浮点误差累计最终统计报表金额对不上预算额。MyBatis映射到Java用BigDecimal前端展示时保留两位小数这套组合在金额处理上是公认的安全方案。第三个问题跟角色有关评审专家也能是指导老师同一个用户有多个角色。这时候简单的role_id单字段设计就撑不住了我把用户和角色的关系改成了用户表一对一主角色、另加一张sys_user_extra_role表存附加角色。后端权限判断从“你是什么角色”变成“你包不包含某角色”Spring Security里的hasRole就不好直接用了改为在Service层判断userStore.getRoles().contains(REVIEWER)。这种多角色场景在高校系统里很常见设计之初就要考虑进去。6. 从项目落地到后续扩展的经验沉淀这套系统从零到上线大概用了三周时间去掉需求沟通和反复调整UI的时间真正开发时间两周多一点。我个人最想强调的一点是做这类业务管理系统技术上反而不要追求花哨稳定和可用才是第一位的。SpringBootVue3MyBatis这套组合的好处在于它的每一个环节都有大量成熟资料可查遇到问题不至于卡死。如果后续要扩展我会优先加两个能力一是基于ECharts做统计分析大屏把各学院申报数量、项目经费分布、评审通过率做成可视化图表管理者一眼就能掌握全局二是接入消息通知项目状态变更时给相关用户发送站内信或者邮件提醒减少学生反复刷新页面查进度的焦虑。另外一个很想提醒后来者的细节部署完成后一定要做一次完整的数据备份和恢复演练。系统上线半年后数据就是最宝贵的资产申报书、审核记录、成绩数据一旦丢了业务很难恢复。MySQL的mysqldump定时备份任务很少有人一开始就配好都是出了事故才后悔。做这套系统最大的成就感不是代码写得多漂亮而是中心真正用它替代了原来的Excel流程学生查进度、老师做审核、管理员出报表都变成几分钟的事。技术选型、模块拆分、踩坑排查这些经验如果你也要做类似的项目希望能帮你少走几步弯路。
返回列表