前后端分离的Web项目申报系统这套SpringBootVueMyBatisMySQL的组合我前前后后做了不下三遍。第一遍是在学校帮老师做课题申报管理那时还用JSPServlet前后端代码混在一起改个样式都要重启服务第二遍是给一家科技公司做项目立项审批中途换成了PHP后期维护一样头疼。直到第三遍彻底改用前后端分离架构才真正体会到什么叫自己的项目自己说了算。这套源码和部署教程我整理出来后已经拿它做过实训演示也帮几个朋友搭过环境整体跑得很稳今天一次性把设计思路、核心实现和上线部署讲清楚。这个项目解决的典型问题是申报人填表、交材料、查进度审批人在线审核、签字、给意见管理员维护用户、看数据、导报表。如果你正好在找一套可以快速二次开发的后台业务系统或者想弄懂前后端分离项目从开发到部署的全流程这篇可以直接照着做。1. 项目申报系统的真实需求与功能清单很多新手一上来就写代码结果写了一半发现页面和流程对不上根源在于没有先把需求拆清楚。项目申报系统这种业务系统核心不是登录增删改查而是角色、流程和状态。谁在什么时间能做什么事操作后数据变成什么状态这才是系统设计的骨架。1.1 三种角色三条权限线我习惯把用户分成三个角色每一种角色对应一套独立的操作边界申报人维护个人信息创建项目申报单编辑草稿提交申报撤回未审核的申报补充材料查看审批进度。审批人看到分配给自己的待办列表对申报单进行审核选择通过、驳回或退回修改填写审核意见。管理员管理用户和角色分配维护申报分类查看所有申报数据统计申报数量、通过率导出Excel报表。这套权限线看起来简单但在实现时要注意前后端都要拦截。后端用拦截器和注解判断接口权限前端根据角色控制菜单和按钮显示。只做前端隐藏是完全不够的——只要别人懂一点接口测试工具就能绕过页面直接调接口后端必须做权限兜底。1.2 核心业务流程从草稿到立项的完整链路一个标准的申报流程是这样的申报人填写项目基本信息标题、类别、预算、起止时间、立项依据等。可以选择保存草稿下次继续编辑确认无误后点击提交。提交后状态变为待审批系统自动或由管理员分配审批人。审批人看到待办点击进入详情查看资料和附件填写意见并决定通过、驳回或退回修改。通过后状态变为已立项驳回则流程终止退回修改则申报人需要编辑后重新提交。整个过程中申报人可以查看每一步的审批记录和时间线。这里最容易忽略的点是撤回操作。我给申报人提供了撤回功能但只允许撤回待审批状态的数据。一旦审批人已经开始处理就不能再撤回否则审批人手上的数据会错乱。这个规则必须在后端校验不能只靠前端判断。2. 数据库设计与表结构用MyBatis时最容易卡住的关联查询项目申报系统的表结构不复杂但是涉及多表关联尤其是一个项目对应多份材料、多条审批记录这种一对多关系在MyBatis里处理不好查出来的数据就会乱七八糟。2.1 数据表拆分业务表、流程表要分开我设计的核心表一共有六张分开来看反而不容易乱表名用途关键字段sys_user用户表id, username, password, real_name, phone, email, create_timesys_role角色表id, role_code, role_nameuser_role用户角色关联表id, user_id, role_idproject_category申报分类表id, name, sort, statusproject_apply项目申报表id, user_id, category_id, title, budget, duration, project_desc, status, apply_time, audit_user_id, audit_timeproject_attachment项目附件表id, project_id, file_name, file_url, file_size, upload_timeapprove_record审批记录表id, project_id, approver_id, action, comment, create_time值得多说一句的是 status 字段。很多人喜欢用 varchar 存 已提交、已通过 这种中文值看起来直观后续如果要改文案所有数据都要更新。我建议用 int 存状态码例如 0-草稿、1-待审批、2-已通过、3-已驳回、4-已退回然后在枚举类或者常量类里做字典映射。这样既方便比较也方便做统计。2.2 MyBatis的ResultMap写SQL容易配映射才是技术活这个项目用的是 MyBatis关联查询是重灾区。举一个最常见的场景查询项目列表时需要同时显示申报人的姓名、分类名称、最新审批意见。如果简单地把查出来的字段拼在一个Map里字段重名时后一个会盖掉前一个比如 user_name 和 category_name 都包含 name很容易出现所有行的分类名都变成用户名。我的做法是SQL里给每张表的字段都用别名区分然后在 ResultMap 中用 column 属性精确指定。核心的映射写法可以参考下面这段resultMap idProjectApplyVO typecom.example.entity.ProjectApplyVO id columnp_id propertyid/ result columntitle propertytitle/ result columnbudget propertybudget/ result columnp_status propertystatus/ association propertyapplicant javaTypecom.example.entity.User id columnu_id propertyid/ result columnu_real_name propertyrealName/ /association association propertycategory javaTypecom.example.entity.Category id columnc_id propertyid/ result columnc_name propertyname/ /association collection propertyattachments ofTypecom.example.entity.Attachment id columna_id propertyid/ result columna_file_name propertyfileName/ /collection /resultMap对应的查询SQL大致这样SELECT p.id AS p_id, p.title, p.budget, p.status AS p_status, u.id AS u_id, u.real_name AS u_real_name, c.id AS c_id, c.name AS c_name, a.id AS a_id, a.file_name AS a_file_name FROM project_apply p LEFT JOIN sys_user u ON p.user_id u.id LEFT JOIN project_category c ON p.category_id c.id LEFT JOIN project_attachment a ON a.project_id p.id WHERE p.id #{id}这里有一个非常容易踩的坑当项目有一条申报记录对应三条附件时用 collection 查询出来的结果是 1×3 条记录MyBatis 会自动把三条附件放进同一个 attachments 列表但前提是主表的 id 没有重复的列名。如果主查询里没有 id 或用的是联合字段就可能导致主表数据被重复构建出现一个页面里同一个项目显示三遍的问题。解决办法就是上面那样给所有字段都加上表别名前缀。3. SpringBoot后端接口开发状态机比你想的要复杂后端我用 SpringBoot接口全部返回统一结构的JSON状态码、消息、数据三件套。这样前端不管用什么组件都能一致处理。这里不讲太多Crud重点说两个几乎所有前后端分离项目都绕不开的点登录鉴权和审批状态流。3.1 登录鉴权用JWT还是Session前后端分离之后Session 方案最大的问题是不方便扩展——如果以后有几个前端管理端、用户端、小程序同一个用户的 Session 没法天然共享。所以我选了 JWT。流程很简单用户输入用户名密码后端校验通过生成一个带过期时间的 Token。前端把 Token 存在 localStorage 或者内存里每次请求在 header 里带上Authorization: token。后端写一个拦截器对除/api/login之外的接口都校验 Token校验失败返回 401。JWT 本身不是绝对安全的注意不要在里面放密码或敏感信息。我习惯只放 userId 和 roleCode过期时间控制在 4 小时左右。关键代码如下Component public class JwtInterceptor implements HandlerInterceptor { Override public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception { String token request.getHeader(Authorization); if (token null || !JwtUtil.verify(token)) { response.setStatus(401); response.getWriter().write({\code\:401,\msg\:\未登录或登录已过期\}); return false; } // 解析用户信息放进ThreadLocal供后续使用 UserContext.set(JwtUtil.getUserInfo(token)); return true; } }再配合 WebMvcConfigurer 注册拦截器放行登录接口和静态资源路径问题不大。3.2 审批状态流只改状态字段是噩梦一张状态表救了我审批逻辑最容易写成一堆 if else而且一旦中间环节改需求代码就要跟着改。我在这个项目里把状态流转做成了常量管理用 status 字段配合 可转移状态集合 校验。比如提交申报时只有草稿状态0才能变成待审批1否则直接报错。核心判断逻辑类似这样private static final MapInteger, ListInteger TRANSITIONS new HashMap(); static { TRANSITIONS.put(0, Arrays.asList(1)); // 草稿 - 提交 TRANSITIONS.put(1, Arrays.asList(2, 3, 4)); // 待审批 - 通过/驳回/退回 TRANSITIONS.put(4, Arrays.asList(1)); // 已退回 - 重新提交 } public void approve(Integer projectId, Integer oldStatus, Integer newStatus) { ListInteger allowed TRANSITIONS.get(oldStatus); if (allowed null || !allowed.contains(newStatus)) { throw new BizException(非法的状态流转); } int rows projectApplyMapper.updateStatusIfMatch(projectId, oldStatus, newStatus); if (rows 0) { throw new BizException(数据已被其他人更新请刷新后重试); } }updateStatusIfMatch用的是带条件更新的 SQLUPDATE project_apply SET status #{newStatus}, audit_user_id #{userId}, audit_time NOW() WHERE id #{projectId} AND status #{oldStatus}为什么要多传一个 oldStatus因为如果不带条件两个审批人同时打开一个项目A 先点了通过B 后点了驳回最后状态取决于 B 的操作但 A 的通过记录也留在审批表里数据就矛盾了。带上条件之后B 的那次更新影响行数为 0后端就知道状态已经被改过直接提示请不要重复操作。另外审批记录表也要在同一个事务里插入。我用的Transactional保证状态更新和记录插入要么都成功要么都失败。4. Vue前端与Element UI页面布局和联调细节前端我用的 Vue 2 Element UI模板台稳妥组件丰富适合做管理后台。如果你的项目要求更高也可以换成 Vue 3 Element Plus但核心思路差不多。这里只说最容易卡住新人的两个地方。4.1 路由和权限菜单按角色渲染就够了很多教程一上来就讲动态路由要根据后端返回的菜单生成路由表。说实话对于项目申报系统这种级别的后台有点大材小用。我用的方案是路由全部写死在静态配置里通过 meta 记录哪些角色能访问然后在路由守卫里判断。路由示例{ path: /project, component: Layout, meta: { roles: [admin, approver, applicant] }, children: [ { path: apply, component: () import(/views/project/apply.vue), meta: { title: 项目申报, roles: [applicant] } }, { path: approve, component: () import(/views/project/approve.vue), meta: { title: 审批管理, roles: [approver, admin] } } ] }路由守卫里判断router.beforeEach((to, from, next) { const token getToken(); if (!token to.path ! /login) { next(/login); return; } const role getRole(); if (to.meta.roles !to.meta.roles.includes(role)) { next(/403); return; } next(); });这样权限逻辑集中在一处后端返回的 roleCode 存进 Vuex 就可以。菜单栏用 v-if 控制显示代码简单也好维护。4.2 axios封装与代理跨域不解决这个跑不通前后端接口开发环境下前端跑在 8080后端跑在 9090直接请求必然跨域。我见过不少新手把后端加上CrossOrigin解决但上线后前端放在 nginx后端单独跑还是要配跨域不如一开始就用前端代理。在vue.config.js里配置 devServer.proxymodule.exports { devServer: { port: 8080, proxy: { /api: { target: http://localhost:9090, changeOrigin: true, pathRewrite: { ^/api: } } } } };这样前端请求/api/login开发环境下就会被代理到http://localhost:9090/login后端接口不用处理跨域也能通。上线后我再在 nginx 里配一个 /api 的 proxy_pass 指向后端服务即可前后端代码都不用改。axios 封装方面我创建了一个统一实例设置 baseURL请求拦截器自动带 Token响应拦截器统一弹错误提示遇到 401 就清掉登录态跳回登录页。核心代码如下import axios from axios; import { getToken, removeToken } from /utils/auth; import router from /router; const service axios.create({ baseURL: /api, timeout: 10000 }); service.interceptors.request.use(config { const token getToken(); if (token) config.headers.Authorization token; return config; }); service.interceptors.response.use( res { const data res.data; if (data.code 200) return data; alert(data.msg || 请求失败); return Promise.reject(new Error(data.msg)); }, error { if (error.response error.response.status 401) { removeToken(); router.push(/login); } alert(error.message || 网络异常); return Promise.reject(error); } ); export default service;把接口定义单独放到一个 api 模块里比如src/api/project.js统一 export页面里只关心业务不用关心请求细节。这样代码清爽很多。5. 部署教程从Maven打jar到Nginx挂前端开发跑通只是第一步真正上线部署才是试金石。这套系统我用的是标准的两层部署后端 SpringBoot 打 jar 包跑在 9090 端口前端 Vue 构建出的静态文件交给 NginxNginx 再反向代理 /api 到后端。5.1 后端打包Maven多环境配置与上线前必查清单项目里的application.yml我拆成了三份application-common.yml公共配置比如服务端口、文件上传路径。application-dev.yml本地开发环境数据库连接本机。application-prod.yml生产环境数据库连接服务器并且用环境变量替换密码避免代码仓库里出现真实密码。打包命令很简单mvn clean package -DskipTests打出来的 jar 在 target 目录下。启动用java -jar target/project-application-1.0.0.jar --spring.profiles.activeprod上线前我有一条必查清单每一项都是踩过坑换来的端口是否在服务器安全组/防火墙里放行。数据库连接URL是否加了serverTimezoneAsia/Shanghai和characterEncodingutf8。项目上传文件的绝对路径是否存在并且启动用户有写权限。application-prod.yml 里的日志路径是否正确。是否在 pom.xml 里配了 resource 过滤避免 prod 配置被覆盖。5.2 前端构建与Nginx配置注意history路由404前端在项目根目录执行npm install npm run build构建产物在 dist 目录。把 dist 下的所有文件上传到服务器的/usr/share/nginx/html/project/下然后改 Nginx 配置。如果 Vue 路由用的 history 模式刷新页面时 Nginx 会先去磁盘找对应文件找不到就报404。解决办法就是加 try_files。我的 nginx 配置片段server { listen 80; server_name your_domain_or_ip; location /project { root /usr/share/nginx/html; index index.html; try_files $uri $uri/ /project/index.html; } location /api/ { proxy_pass http://127.0.0.1:9090/; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; } }注意location /api/里的 proxy_pass 带了末尾的/表示把/api/前缀去掉后再转发。比如前端请求/api/login实际发到后端的是/login。如果忘了这个细节后端接口全都报404。5.3 数据库导入和初始化时区、字符集、sql_mode一个都不能少我给项目准备了一个sql/init.sql包含了建库建表和基础数据分类、管理员账号。导入命令mysql -u root -p init.sqlMySQL 8 里最要注意的是时区。如果安装后没有配置过时区连接字符串里必须加上serverTimezoneAsia/Shanghai否则查询出来的时间会差8个小时。另外如果你的系统是分页查询MySQL 8 默认的 sql_mode 里如果有NO_ZERO_DATE插入零日期也会报错。我在初始化脚本开头统一执行SET NAMES utf8mb4; SET time_zone 08:00;这样能避免大部分因为环境和配置导致的诡异问题。6. 项目扩展与二次开发建议这套系统交付之后很多人问我下一步怎么加东西。说实话一个稳定的状态机加上清晰的表结构已经能承载很多扩展。6.1 把单人审批扩展成多级审批现在审批逻辑是单个审批人决定通过或驳回。如果以后公司要求先技术总监审再财务审最后总经理签我建议不要上来就集成工作流引擎。可以先在数据库加一个audit_level字段状态状态码扩展到 5、6、7再配一张project_audit_config表去定义每一级由哪个角色处理。效果等同轻量流程引擎实现起来也不复杂。6.2 数据统计和导出别用POI写一堆样板后台需要按月和分类统计申报数量。这个系统里我预留了统计接口SQL 用 group by 就能出来。导出功能建议直接用阿里 EasyExcel比原生 POI 省太多代码三行搞定一个 Sheet。上线之后运维和业务提的需求绝大多数都是再给我加一个报表导出提前把工具集成好省得后面加班。6.3 文件存储本地目录之后可以换OSS上传附件在开发环境直接存到本地路径生产环境如果有多实例部署共享存储会出问题。那时候建议换云对象存储接口不用变只改文件上传服务实现类即可。所以在上传这块我一开始就定义了FileStorage接口本地实现和 OSS 实现分开换的时候不用动 Controller 代码。说句心里话这套系统代码量不大但把后端接口设计、数据库关系、前端联调和线上部署都完整串了一遍。我把它当作前后端分离入门的综合实战项目来用给团队新人讲的时候也只需要从这几个模块切入。项目不追求多华丽但每个细节都能讲出为什么这么设计这比堆功能重要得多。