从零落地一套项目申报管理系统:技术选型、库表设计与实战全过程
最近这段时间不少做内部管理系统开发的朋友都在聊同一个需求:申报类业务系统。小到高校的科研立项申报,大到企业的项目补贴申请,核心流程其实高度相似——用户提交材料、管理员逐级审核、结果反馈归档。这套基于SpringBoot + Vue + MySQL + MyBatis的web项目申报管理系统,就是围绕这条主线做的完整实现。它在技术栈上用了当前Java后端最主流的一套组合,前端采用Vue渐进式框架处理交互,数据库用MySQL支撑申报数据存储,数据访问层用MyBatis解决灵活SQL问题。整套源码涵盖从登录鉴权、申报单填写、材料上传、审批流状态流转到统计导出全链路,适合正在做毕设、企业内部小工具或想快速掌握前后端分离开发套路的同学直接参考。
这类系统看起来业务简单,真正做起来还是会踩不少坑。比如审批流的字段设计怎么保证扩展性、文件上传怎么限制大小和类型、前端路由和按钮权限怎么跟后端接口权限对齐,这些细节如果你直接照着简单CRUD去做,后面改起来会非常痛苦。我这篇就按实际开发的推进顺序,把需求拆解、库表设计、后端实现、前端实现和部署调试几个环节完整梳理一遍,每步都会讲清楚为什么这么设计。
1. 项目申报系统的核心业务模型:从需求到模块划分
开发任何管理系统,第一步不是写代码,而是把业务上下游摸透。项目申报系统的服务对象一般分成三类角色:申报人(也叫申请人)、审核人(各部门初审/终审人员)、系统管理员。大部分系统还会有第四类角色是查看统计的决策层,但基础版本里可以通过管理员账号分配权限来覆盖。
1.1 业务主流程与状态定义
把一条申报业务走完,核心链路是:申报人创建申报单 -> 填写项目基本信息 -> 上传附件材料 -> 提交给指定审核节点 -> 审核人逐级审批 -> 审批通过立项归档 / 被驳回退回修改 -> 申报人可查看全流程状态。
这里最关键的建模点是状态机设计。别把状态只定义成"待审核/已通过/已驳回"三个值,实际业务里至少要有这些状态:
- 草稿:申报人保存了但未提交,可编辑可删除
- 待初审:已提交进入初审节点
- 初审通过:等待终审节点处理
- 初审驳回:退回给申请人修改
- 终审通过:项目正式立项
- 终审驳回:退回申请人,可重新编辑再提交
有些业务还会存在补充材料、二次送审、终止申报等状态。所以数据库里状态字段建议预留一个varchar或tinyint,不要用过于狭窄的枚举写死。我习惯的做法是在代码里定义一个常量类或枚举类统一管理状态值,数据库里存数字或简短字符串,方便后续加状态不动表结构。
1.2 模块边界划分
从系统功能维度看,可拆成七个核心模块:
- 登录认证模块:账号密码登录、验证码、JWT令牌颁发与拦截
- 用户管理模块:用户CRUD、角色分配、账号启停用
- 申报项目管理模块:申报单CRUD、提交、撤回、修改
- 审核流模块:待审列表、审批操作、审批意见记录、流转历史
- 附件管理模块:本地上传、文件类型/大小校验、下载
- 通知消息模块:审批通过/驳回后给申报人发送站内消息
- 数据统计模块:按项目类型、年度、状态做聚合统计
一个小系统的教训是:不要一开始就做复杂的流程引擎,比如Activity,多数申报场景用一张审批记录表加状态字段完全能支撑。引入工作流引擎会增加学习成本和部署复杂度,对小项目反而拖慢进度。
1.3 为什么这个项目选前后端分离
这套系统选择SpringBoot做后端API、Vue做前端SPA,核心原因是两类技术栈的工程化成熟度在目前是最高的。后端SpringBoot具备自动配置特性,写Web接口、整合MyBatis、做参数校验都很简洁;前端Vue生态里的Element UI或Element Plus组件库,用于快速搭建表格、表单、弹窗界面效率很高,尤其适合后台管理类系统。MySQL作为关系型数据库,处理申报明细、审批记录这种结构化数据稳定可靠,加之这套体系学习资料丰富,遇到问题基本都能查到解决方案。
2. 数据库设计:申报业务的状态流与表结构落地方案
数据库设计决定系统能走多远。申报系统核心要建的表没有想象中多,但字段设计需要结合实际业务流程反复推敲。下面是我在项目里落地的一套表结构,按业务域分组列出,并给出关键字段说明。
2.1 用户权限域
用户表和角色表需要支持多对多关系。用户表的核心字段是登录账号、密码密文(BCrypt加密后存储)、姓名、联系电话、所属部门、账号状态。角色表用于区分申报人、审核人、管理员三类。用户角色关联表存userId和roleId的对应关系,如果一个人既是申报人又是审核人,这在业务上其实是常见情况,所以用户与角色必须多对多而不是给用户表加一个role字段。
菜单权限这块,小系统可以只做前端路由控制,后端接口做角色级鉴权。如果希望做到按钮级权限,一般需要额外的菜单表、角色菜单关联表,再配合Vue的指令或权限拦截器实现。这套源码里我实现了路由级别的权限控制,后端用拦截器加注解完成了接口权限校验。
2.2 申报业务域
项目申报主表是整个系统的核心,字段设计要集中处理。我的建议是至少包含:申报单号、申报标题、项目类型、申报年度、申报人ID、所属部门、项目预算金额、立项时间、结题时间等,加上最关键的申报状态字段以及创建时间、更新时间。申报单号推荐用带业务含义的字符串,比如年份+类型编码+流水号,如PRJ202506001,后端通过Redis或数据库序列生成,避免并发重复。
项目类型表和申报主表做成关联查询即可,项目类型编码可用于数据统计聚合。申报明细扩展字段的设计,很多系统会遇到不同项目类型字段不一样的问题,第一版不要急着做EAV模型,直接在申报主表预留几个扩展字段,比如text类型的remark,或者干脆用json类型字段存储额外参数。MySQL5.7及以上支持JSON字段,查询用JSON_EXTRACT也能兼容,我实测对于中小系统够用。
审批记录表是审计追踪的关键。每条记录至少包含:申报单ID、审批节点(初审或终审)、审批人ID、审批动作(通过/驳回)、审批意见、审批时间。这套设计保证任意时刻可追溯完整的审批链路,前端详情页直接查询这张表渲染时间线效果。
2.3 文件附件与消息通知
附件表字段设计得好不好,直接影响文件管理的运维成本。我在项目里用的是这个结构:附件ID、关联业务类型(申报单/立项书/结题报告)、关联业务ID、原始文件名、存储文件名(UUID重命名)、文件路径或者OSS的URL、文件大小、上传人ID、上传时间。文件存储优先选择本地磁盘目录存储,配置一个统一的上传根路径,文件名用UUID重命名避免中文乱码和文件名冲突,需要扩展MinIO或阿里云OSS时,只需要把文件路径从本地路径改为URL地址即可。
站内消息表相对简单:消息ID、接收人ID、消息标题、消息内容、是否已读、创建时间。审批动作发生后,后端在业务事务里同步插入消息记录,申报人登录后通过轮询或者刷新列表方式展示。
2.4 索引与查询优化要点
申报系统最常见的几个查询模式是:申报人查看自己的申报列表、审核人查看待审列表、管理员按条件筛选导出。对应索引设计建议:
- 申报主表:
status、applicant_id、apply_year单独建索引,多条件组合查询时联合索引更高效,比如(status, applicant_id),实测权限内数据量大后全表扫描会明显卡顿 - 审批记录表:
project_id和approver_id需要建索引,按项目查时间线时避免全表扫描 - 附件表:
biz_type+biz_id建联合索引 - 用户表登录查询:
username建唯一索引,登录接口的查询频率极高
排序字段比如create_time,建议默认按创建时间倒序,配合分页查询做索引优化。我在开发中常遇到的问题,是粗心在where条件里对索引列套了函数(比如DATE_FORMAT(create_time, '%Y-%m-%d')做等值匹配),这会让索引失效,最终导致慢查询。
3. SpringBoot后端实现:申报流程、权限控制与MyBatis实践
后端是整个系统的发动机。这部分我把核心链路分成接口设计、权限安全、MyBatis动态SQL、文件上传四个方面,每个都与实际申报流程关联说明。
3.1 分层结构与包规划
标准的Controller -> Service -> Mapper三层结构在这个项目里体现得很清晰。Controller层只负责接收参数和返回统一结果集,Service层处理业务逻辑与事务,Mapper层通过MyBatis接口操作MySQL。工程包规划如下:
com.demo.apply ├── controller // 登录、申报、审核、文件、统计等接口入口 ├── service // 业务逻辑层,事务边界在这里 ├── mapper // MyBatis接口 ├── entity // 数据库表对应实体类 ├── dto // 前端交互参数对象 ├── vo // 查询返回视图对象 ├── config // 拦截器、CORS跨域、文件上传等配置 ├── common // 统一返回结果、常量、异常处理、工具类 └── utils // JWT工具、文件处理工具前端Vue项目单独放在独立的vue目录下,通过proxy反向代理配置解决开发环境的跨域访问,生产环境可打包后由SpringBoot托管,也可以部署到Nginx后反向代理后端接口。
3.2 登录鉴权与接口权限控制的完整链路
认证这块用的是JWT方案,具体流程是:登录接口接收账号密码,校验通过后用HMAC或RSA算法签发token,前端每次请求把token放到请求头Authorization中,后端写一个拦截器统一解析token并解析出用户ID和角色信息放到ThreadLocal或Request作用域。这个方案对比传统的Session方案优势是后端无需维护会话状态,适合前后端分离的部署模式;劣势是token注销比较麻烦,需要依赖过期时间或维护黑名单。管理系统中台场景,JWT是性价比最高的方案。
针对接口权限,我采用了自定义注解 + 拦截器的方式。先定义一个@RequireRole注解,作用在Controller方法上,value指定允许访问的角色编码。拦截器在解析token拿到当前用户角色后,判断是否满足注解要求,不满足直接返回403。这样做的好处是权限声明与接口定义放在一起,代码可读性高,而且能精确控制到每个接口的访问角色。比如:
@GetMapping("/review/list") @RequireRole({"REVIEWER", "ADMIN"}) public Result<PageResult<ProjectVO>> reviewList(...) { // 待审列表逻辑 }使用中还发现一个必须注意的问题:文件上传和下载接口如果也走统一token拦截,那在浏览器里通过<a>标签直接点击下载地址时无法携带Header。处理方案有两种,要么下载时前端先请求接口返回文件url并携带token参数,要么把下载接口的路由单独放行,配合自定义签名参数校验。我在源码里选择了第一种方案,同时配合文件路径的临时访问接口实现,既保证安全又不影响前端体验。
3.3 MyBatis在审批流中的动态SQL实战
MyBatis真正的核心价值在于动态SQL能力。项目申报列表页的筛选条件往往不固定,比如按状态、按年度、按项目类型、按关键字搜索,组合条件可能超过五六种。这种场景如果写静态SQL会非常臃肿,而用<where>配合<if>标签可以优雅地实现多条件动态拼接。
以申报列表查询为例:
<select id="selectProjectPage" resultType="com.demo.apply.vo.ProjectVO"> SELECT p.*, u.real_name AS applicant_name, t.type_name FROM t_project p LEFT JOIN t_user u ON p.applicant_id = u.id LEFT JOIN t_project_type t ON p.project_type = t.type_code <where> <if test="applicantId != null"> AND p.applicant_id = #{applicantId} </if> <if test="status != null and status != ''"> AND p.status = #{status} </if> <if test="applyYear != null and applyYear != ''"> AND p.apply_year = #{applyYear} </if> <if test="keyword != null and keyword != ''"> AND (p.project_title LIKE CONCAT('%', #{keyword}, '%') OR p.project_no LIKE CONCAT('%', #{keyword}, '%')) </if> </where> ORDER BY p.create_time DESC </select>这段SQL里LEFT JOIN用户表拿申报人姓名、LEFT JOIN类型表拿类型名称,是列表页展示最常用的写法。<where>标签会自动处理首个子条件前面的AND;配合PageHelper分页插件,只需在Service里PageHelper.startPage(pageNum, pageSize),后面紧跟的查询自动拼接LIMIT,并返回PageInfo对象带上总条数。
审批动作的SQL必须开启事务。在Service层的审批方法上标@Transactional,里面做三件事:更新申报主表状态 -> 插入审批记录 -> 插入站内消息。任何一个步骤抛异常,整体回滚,避免出现状态变更了但历史记录缺失的不一致情况。
3.4 文件上传的大小控制与存储策略
SpringBoot文件上传默认限制单文件大小为1MB,这个不调整的话,实际操作中传个立项书PDF都会失败。配置调整要点:
spring: servlet: multipart: max-file-size: 50MB max-request-size: 100MBController方法里用MultipartFile接收文件,保存到本地目录:
String dirPath = uploadPath + "/" + LocalDate.now().format(DateTimeFormatter.ofPattern("yyyyMMdd")); File dir = new File(dirPath); if (!dir.exists()) { dir.mkdirs(); } String originalFilename = file.getOriginalFilename(); String ext = originalFilename.substring(originalFilename.lastIndexOf(".")); String storageName = UUID.randomUUID().toString().replace("-", "") + ext; file.transferTo(new File(dir, storageName));这里的UUID重命名很重要,一方面是规避中文文件名在跨平台时的兼容问题,另一方面可防止重名覆盖。实际运维时遇到过excel或pdf文件名中包含特殊字符导致保存失败的情况,统一重命名后再存储就彻底没有这类告警了。扩展MinIO时,只需将transferTo逻辑换成调用MinIO客户端的putObject方法即可,业务代码几乎不用动。
4. Vue前端实现:从路由到申报页面的组件化落地
前端这块我采用的组合是Vue2 + Element UI + Vue Router + Vuex(如果初始化用Vue3则对应Element Plus和Pinia),针对后台管理系统的典型界面做了完整实现。
4.1 路由设计与登录守卫
路由设计分两块:基础路由和业务路由。基础路由包括登录页、404页;业务路由是主框架布局下的嵌套路由,包含申报列表、新建申报、审核列表、项目管理、用户管理等页面。动态路由和权限控制的配合方式如下:
- 登录成功后,后端接口返回当前用户的角色及可访问的菜单列表
- 前端把菜单列表保存在Vuex中,路由表提前定义全部业务路由,每个路由的meta里标记需要的角色编码
- 全局前置守卫
router.beforeEach中判断用户是否登录、当前路由是否需要特定角色,不满足就重定向到403或登录页
实际用下来这个方案,比完全动态注册路由要简单好维护。对中小系统来讲,完全动态注册带来的安全和灵活性优势不明显,反而增加了路由文件的管理复杂度。前端权限只能作为体验层面的控制,真正的安全边界还是得靠后端接口鉴权来兜底。
4.2 登录页、表格页、表单页的组件化拆解
登录页是系统门面,但技巧不多,核心点是:记住密码使用localStorage,验证码如果对接后端接口就返回base64图片,点击刷新。除了这些常规处理,比较容易被忽略的是表单校验规则设计,尤其是申报表单页,涉及的项目标题、项目类型、申报年度、预算金额、项目简介、附件材料等字段建议都做成动态规则校验,不能等提交后才发现漏填字段。
表格页和表单页是这套系统里最核心的两个组件模式。表格页我拆成三个子组件:搜索栏组件、数据表格组件、分页组件。搜索条件和表格数据各自维护state,条件变化后触发查询方法重新拉取接口。表单页包括新建和编辑两个场景,共用一个表单组件,通过props传入的初始数据判断是新增还是编辑,提交成功后再刷新列表。
分页组件与表格组件的联动逻辑在管理系统中非常常见,我贴一下核心思路:
handleQuery() { this.pageData.condition = { ...this.searchForm }; this.pageData.currentPage = 1; this.loadList(); } handlePageChange(page) { this.pageData.currentPage = page; this.loadList(); } async loadList() { const res = await fetchProjectPage({ ...this.pageData.condition, pageNum: this.pageData.currentPage, pageSize: this.pageData.pageSize }); this.tableData = res.data.records; this.pageData.total = res.data.total; }比较容易被忽视的交互点有两个:一是在返回列表后,需要保持之前的搜索条件不丢失,所以搜索表单和分页状态要用相同的数据源来驱动;二是在修改完数据刷新时,最好是回显到当前页,而不是每次刷新都从第一页开始,用上面的结构可以避免这类体验问题。
4.3 附件上传的进度反馈与预览
前端上传文件我用的Element UI的el-upload组件,配置action指向后端接口。需要注意,如果前端有登录鉴权,el-upload默认不会带上Authorization头,需要这样设置:
:headers="{ Authorization: getToken() }"文件类型和大小限制除后端强校验,前端也应当做一层即时反馈。before-upload钩子里用file.type和file.size判断,不满足直接this.$message.error提示并阻止上传,给用户的反馈比后端返回错误更快。上传成功后,把后端返回的附件ID和文件名存入表单附件列表,提交申报单时一并传参。上传过程中progress事件可以展示进度条,实际体验上,局域网环境下小文件秒传,进度条意义不大;但如果文件是几十兆的PDF,进度条对用户的耐心安抚作用还是很明显的。
4.4 审批时间线与状态标签的渲染方案
申报详情页需要展示审批历史,我用el-timeline组件渲染审批记录列表,每条记录显示审批节点名称、审批人、审批时间、审批意见。状态标签订单用el-tag,通过不同类型映射颜色:草稿用灰色、待初审用蓝色、初审通过用青色、终审通过用绿色、被驳回用红色。这里我写了一个过滤器或计算属性:
statusMap: { 'DRAFT': { label: '草稿', type: 'info' }, 'PENDING_FIRST': { label: '待初审', type: 'primary' }, 'FIRST_PASS': { label: '初审通过', type: 'cyan' }, 'PENDING_FINAL': { label: '待终审', type: 'warning' }, 'FINAL_PASS': { label: '终审通过', type: 'success' }, 'REJECTED': { label: '已驳回', type: 'danger' } }状态名称在前后端要严格保持一致,最省事的做法是后端返回状态码,前端映射成中文标签,这样后端可以随时扩展状态而不用去前端改一处硬编码的值。我在实际项目里见过因为前后端各维护一套状态字符串,拼接列表展示时状态对不上导致用户觉得系统数据异常的案例,这里务必注意。
5. 源码工程跑通指南与常见问题排查
如果你拿到完整源码,第一件事不是急着看业务代码,而是先把工程跑起来,理解项目形态,再逐模块调试。以下是我整理的一手跑通过程和排查经验。
5.1 环境准备与启动步骤
基础环境建议这样对齐(版本差异不大一般不会有问题):
| 组件 | 推荐版本 | 说明 |
|---|---|---|
| JDK | 1.8或11 | SpringBoot 2.x对JDK8兼容最好,3.x需要JDK17 |
| Maven | 3.6+ | 后端依赖管理与构建 |
| MySQL | 5.7或8.0 | 整个系统数据存储 |
| Node | 14+ | 前端开发环境的npm依赖安装 |
| Vue CLI | 4.x或5.x | 快速启动前端工程,也可以用Vite |
启动顺序是:先导SQL建库 -> 改后端配置文件 -> 启动SpringBoot -> 启动前端 -> 浏览器访问。
数据库初始化文件在sql目录下,包含建库建表和基础数据(管理员账号、角色、菜单、项目类型字典)。导入后要重点检查账号密码字段,因为存的是BCrypt密文,你无法直接看出明文密码,通常初始数据文档里会单独说明默认密码,比如admin/123456,对应的是预先算好的密文。
后端application.yml里需要调整的配置项主要是三个:server.port端口、spring.datasource.url数据库连接地址、spring.servlet.multipart.max-file-size上传大小限制。MySQL8.0和5.7的驱动类有些差异,版本号也要对应调整。
5.2 创建申报到完成审核的完整功能闭环
系统部署起来后,一条完整的数据流可以按下面的顺序在界面操作验证:管理员创建审核人账号 -> 用申报人账号登录创建申报单 -> 填写项目信息并上传附件 -> 提交申报 -> 切换审核人账号进入待审列表 -> 打开项目详情查看申报材料 -> 执行初审通过 -> 执行终审通过 -> 切换回申报人账号查看审批结果。
这个闭环一旦走通,说明登录鉴权、申报CRUD、文件上传、审批流转、消息通知五个核心链路都是正常的。我在调试时还习惯打开浏览器的开发者工具,Network面板里观察请求是否带上token、上传附件接口是否报413错误(请求体过大),这样很多问题能快速定位。
5.3 高频问题排查清单
开发这个系统的过程中,我在前后端联调和部署阶段遇到了不少问题,这些问题也有一定普遍性。这里展开说说其中三个最有代表性的。
问题一:跨域请求被拦截。
开发环境前端端口8080,后端端口8088,前端fetch请求直接被浏览器拦截。问题原因在于后端没有允许跨域的响应头。在SpringBoot里做全局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); } }生产环境如果前后端部署在同一域下,比如前端打包后放进SpringBoot的static目录,就不会有跨域问题。推荐开发环境打开CORS,生产环境直接同域部署简化网络链路。
问题二:MyBatis查询结果映射后字段为null。
报表联查查询用户姓名,查出来却是null。最常见的原因是数据库字段是下划线风格(applicant_id),而Java实体属性是驼峰风格(applicantId),MyBatis默认并不知道如何映射。解决方案是在application.yml打开自动驼峰映射:
mybatis: configuration: map-underscore-to-camel-case: true这个配置加完,查询结果能自动转为实体类属性,能省去大量resultMap维护工作。
问题三:前后端状态不同步导致权限菜单显示错误。
登录后前端拿到的菜单列表和后端实际分配的权限不一致。排查思路是先看登录接口返回的JSON,角色编码和菜单编码是否匹配;再看前端路由守卫里做角色判断的时候,用的是角色列表还是某个具体角色,千万不要在代码里写死"admin"这种超级用户的判断逻辑,否则权限模型一扩展就会出问题。
5.4 从这套源码里可以继续加的能力
基础源码跑通以后,有几个方向可以直接扩展。第一个是导入导出能力,申报列表按年度汇总时,管理端需要导出Excel,后端引入EasyExcel或POI,前端在搜索栏加一个导出按钮,本质是走一遍查询逻辑然后把结果转换成xlsx文件流返回。第二个是消息通知渠道的扩展,除了站内消息,也可以接入邮件或企业微信机器人推送审批结果,后端在消息服务里加一个适配层就行。第三个是做申报数据的可视化看板,按项目类型、部门、状态维度展示统计图表,前端可以用ECharts,后端写几个聚合查询接口配合。
我个人更建议先做好第一项。申报系统里Excel导出是所有管理角色的刚需,而且实现过程中会用到流式查询、单元格样式、大数据量分页导出等实用技巧,做完以后对整个组件的理解会更深一层。
回到整个项目本身,SpringBoot + Vue + MySQL + MyBatis这套组合非常适合这类中小型业务管理系统的快速落地。核心不在于用了多新的技术,而在于把申报、审批、流转、文件、通知这条业务链路用最直观的方式建模清楚,然后用工程化的手段组织起来。对需要按模板做毕业设计或者启动一个企业内部小工具的朋友,参考这套源码的库表设计思路和权限控制写法,会比从头摸索少走很多弯路。