☰
SpringBoot+Vue前后端分离合同管理系统实战:权限、审批、部署全解析
2026/9/26 18:01:23 网站建设 项目流程

1. 项目概述:这套系统到底能做什么

最近整理前后端分离项目源码时,把“可盈保险合同管理系统”又重新过了一遍。这是一个基于 SpringBoot + Vue + MyBatis + MySQL 的完整项目,后端是 Spring Boot 提供的 REST API,前端是 Vue 单页应用,数据层由 MyBatis 的 Mapper XML 来管,存储统一放在 MySQL。它不是一个只有 CRUD 的教学 demo,而是把保险合同从录入、审批、缴费、到期提醒到统计分析这条业务链路完整跑通的系统,源码拿来就能部署,部署教程也直接照着做就行。

保险公司的合同管理比普通合同要麻烦得多。普通合同签完归档就完事,保险合同要长期跟踪投保人、被保人、缴费计划、保单生效日期、终止日期、续保状态,还要走内部审批流程。用 Excel 管这些数据,数量少的时候还能应付,一旦过百份合同,查一份历史审批意见都要翻半天。可盈这套系统就是要解决这个痛点:把合同主档、审批记录、缴费计划全部结构化存库,通过前后端分离的页面操作,让业务员、审批人、财务、系统管理员各司其职。

这套系统最适合两类人参考:一类是正在做 Java 课设、毕设的学生,项目代码量适中,没有若依那种大而全的框架包袱,但完整度足够让你讲清楚业务和架构;另一类是刚入门的初级开发,想看看一个真实的前后端分离项目里,用户权限、分页查询、状态流转、部署发布这些环节到底是怎么串起来的。如果你已经能写简单的 SSM 增删改查,但还没独立做过一个带权限、带审批、带报表的完整系统,那这套源码就是很好的下一步练手对象。

2. 技术选型:为什么固定用 SpringBoot + Vue + MyBatis + MySQL

2.1 后端用 Spring Boot 而不是 SSM 手写配置

后端核心用的是 Spring Boot,这在 2024 年已经没有太多争议。选它不是因为“大家都在用”,而是因为它解决了一套真实项目最烦的问题:环境配置。SSM 时代光 spring-mvc.xml、spring-mybatis.xml、web.xml 就能折腾半天,Spring Boot 用自动配置把这些都隐掉了,一个 application.yml 就能管住数据源、MyBatis 和端口。

保险合同管理这种业务,对事务和权限的要求很直接。审批通过要同时更新合同状态、插入审批记录、记录操作日志,任何一个环节失败都不能留下脏数据,所以我大量使用 Spring 的@Transactional,回滚逻辑由框架保证。Spring Boot 自带的 Tomcat 让部署也简单,后端打成 jar 包,只要目标机器装了 JDK,一条命令就能跑起来,不用再单独装 Tomcat。

2.2 前端用 Vue + Element Plus 做后台交互

前端用的是 Vue 3 + Vite + Element Plus。Vue 做这类管理系统非常顺手,组件化开发让合同列表、审批表单、用户管理这些页面可以拆成一个个组件,互相不干扰。配合 Vue Router 做前端路由,Axios 统一处理接口请求和 token 携带,Element Plus 直接提供表格、分页、弹窗、表单校验组件,省去自己造轮子的时间。

我特别在意的一点是,前端和后端必须彻底分离。后端只返回 JSON,不做页面跳转;前端只调接口,不碰 JSP、Thymeleaf。这样开发时前端可以独立启动 mock 调试,部署时前端打包成静态资源扔给 Nginx,后端 jar 独立跑,两边互不阻塞。对于一个小团队来说,这种前后端分离模式是能长期维护下去的。

2.3 MyBatis 适合保险合同这种复杂动态查询

选 MyBatis 而非 JPA 的原因并不复杂:合同管理里的查询条件太不固定了。按合同状态查、按投保人姓名查、按产品类型查、按承保日期范围查、按缴费逾期状态查,这些条件经常会组合在一起,用 MyBatis 的 XML 写动态 SQL 非常顺手,一眼就能看清最终的 SQL 长什么样。

这套源码没有强行用 MyBatis-Plus,而是保留了原生 MyBatis + XML Mapper 的方式。我故意这样做的原因是希望初学者能理解Mapper 接口 -> XML SQL -> 结果映射这条链路,而不是无脑继承一个BaseMapper<T>,到出了问题都不知道 SQL 在哪。你之后想换 MyBatis-Plus,把 Mapper 继承改一下,SQL 逻辑基本不用动。

2.4 MySQL 撑住合同数据的存取

存储层是 MySQL,版本 8.0。坦白说,这个项目的数据量级别用 MySQL 完全足够,而且生态最成熟、资料最多,学生和初级开发最容易上手。建库时字符集统一用utf8mb4,排序规则utf8mb4_general_ci,这样投保人姓名里出现生僻字,或者备注里写 emoji,都不会存不进去。对于合同管理系统,单库单表是合理的起点,以后数据量真涨起来了,再按合同年度拆表加归档策略也不迟。

3. 核心模块与数据库设计

3.1 功能模块拆成四大块

整套系统按照业务边界拆成四个核心模块,没有为了凑功能硬塞东西。

合同管理:合同的新增、编辑、查询、删除,支持上传附件,合同编号唯一,列表页支持多条件组合筛选和分页。审批流管理:业务员提交审批,主管审批通过或驳回,所有审批意见写入审批记录表,形成可追溯的链条。缴费管理:记录每份合同的缴费计划,包括每期保费、缴费日期、实缴状态,逾期部分有标记。系统管理:用户管理、角色管理、菜单权限管理、操作日志。这四个模块正好构成一个能跑真实业务的完整闭环。

3.2 合同主表如何设计

保险合同的业务核心都放在biz_contract表里。我建表的思路是:主表只存合同本身的稳定信息,像投保人、被保人、产品、保费、起止日期、状态;缴费计划、审批记录单独建表,通过外键关联主表 ID。这样查询主表列表时不会因为关联子表而出现重复行,分页时 total 也永远是对的。

CREATE TABLE `biz_contract` ( `id` bigint NOT NULL AUTO_INCREMENT, `contract_no` varchar(32) NOT NULL COMMENT '合同编号', `contract_name` varchar(128) NOT NULL COMMENT '合同名称', `customer_name` varchar(64) NOT NULL COMMENT '投保人姓名', `insured_name` varchar(64) DEFAULT NULL COMMENT '被保人姓名', `product_name` varchar(128) DEFAULT NULL COMMENT '保险产品', `premium_amount` decimal(12,2) NOT NULL DEFAULT '0.00' COMMENT '总保费', `start_date` date DEFAULT NULL COMMENT '生效日期', `end_date` date DEFAULT NULL COMMENT '终止日期', `status` varchar(20) NOT NULL DEFAULT 'DRAFT' COMMENT 'DRAFT-草稿/PENDING-审批中/ACTIVE-生效/REJECTED-驳回/EXPIRED-到期/TERMINATED-终止', `create_by` bigint DEFAULT NULL, `create_time` datetime DEFAULT CURRENT_TIMESTAMP, `update_time` datetime DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, PRIMARY KEY (`id`), UNIQUE KEY `uk_contract_no` (`contract_no`), KEY `idx_status` (`status`), KEY `idx_customer_name` (`customer_name`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='保险合同主表';

合同编号一定要建唯一索引。业务上合同编号是线下纸质合同和系统电子合同的对应凭证,一旦允许重复,后续对账和检索都会乱套。状态字段我用的是字符串而不是数字,虽然多占一点空间,但可读性非常好,查数据时不用对着数字翻字典。

3.3 权限模型用 RBAC

权限部分用的是经典 RBAC 模型,三张基础表:用户表、角色表、菜单权限表,再加用户角色关联表和角色菜单关联表。登录成功后,后端会算出当前用户拥有的权限码列表,连同用户 ID、过期时间一起放进 JWT 返回给前端。前端拿到 token 存进 localStorage,每次请求通过 Axios 拦截器塞到请求头里;后端写了一个基于 AOP 的自定义权限注解,接口上标@RequirePermission("contract:approve")就能自动校验。

这套权限模型不算新颖,但它真实、可靠、好解释。课上和面试里你都能讲清楚:用户属于哪些角色,角色有哪些菜单和按钮权限,后接口怎么拦截,前端菜单怎么动态渲染。不用无脑把整个若依框架搬进来,自己手动实现一遍之后,你对“权限系统”的理解会完全不一样。

4. 关键功能实现细节

4.1 登录鉴权和前端路由守卫

登录接口做的事很常规:接收用户名密码,校验验证码(如果有),比对密码,生成 JWT。JWT 我用的 HS256 签名,密钥写在配置里。生产环境密钥一定要单独配置且长度至少 32 字节,别用默认值,否则别人能直接仿造 token。生成代码核心如下:

String token = Jwts.builder() .setSubject(username) .claim("userId", userId) .claim("perms", permsList) .setIssuedAt(new Date()) .setExpiration(new Date(System.currentTimeMillis() + 86400000L)) .signWith(SignatureAlgorithm.HS256, secretKey.getBytes(StandardCharsets.UTF_8)) .compact();

前端路由守卫的作用是防止未登录用户直接通过 URL 访问页面。在 Vue Router 的beforeEach里判断一下 token 就行:

router.beforeEach((to, from, next) => { const token = localStorage.getItem('token') if (to.path === '/login') { next() } else if (!token) { next('/login') } else { next() } })

只判断“有没有 token”是不够的,token 可能过期。所以 Axios 的 response 拦截器里还要处理 401:收到 401 就清掉本地 token,跳回登录页,并给出“登录已过期”的提示。这两层配合才能真正拦截掉绝大部分未授权访问。

4.2 MyBatis 分页插件的用法与避坑

列表页的分页是这个项目里最容易出问题的地方。我这里用的是 PageHelper 分页插件,因为配置简单,引入依赖后写 Service 层时逻辑很干净:

PageHelper.startPage(pageNum, pageSize); List<ContractDO> list = contractMapper.selectContractPage(query); PageInfo<ContractDO> pageInfo = new PageInfo<>(list);

PageHelper.startPage会拦截下一个执行的 Mapper SQL,自动把它改写成带LIMIT的分页语句,同时查询 count 总数。执行完selectContractPage之后,PageInfo里就有list、total、pageNum、pageSize、pages等字段,前端分页组件直接消费。

这里有三件事必须注意:

  • startPage后面必须紧跟着执行真正的 Mapper 查询,中间不要再调其它 SQL 查询,否则分页会串到那个查询上,结果总数永远不对。
  • 分页查询的 SQL 尽量不要LEFT JOIN一对多子表。比如一张合同对应多条缴费计划,如果 join 后查询,List 长度会变大,total就会虚高。正确做法是先分页查主表,再按主表 ID 批量补子表数据。
  • 排序字段不要直接拼接用户传入的参数,比如前端传sortField你直接ORDER BY ${sortField},这是 SQL 注入的高危写法。应该维护一个白名单映射,用户传createTime时后端转成真实列名create_time。

另外,MyBatis 二级缓存我建议在这个项目里直接关闭。合同主表会关联审批记录、缴费计划,涉及多表 join 时二级缓存很难保证一致,而一级缓存(默认的 SqlSession 级别)已经够用。为了那点阅读性能提升,换来“数据改了半天界面上还是旧值”的坑,完全划不来。

4.3 合同审批状态流:用乐观更新防并发

合同审批模块很能体现真实项目的严谨性。一张合同提交审批后,业务员和主管可能同时在多个窗口操作,如果后端的更新逻辑只是“先查出合同,判断状态,再 update”,很容易出现两个人同时审批同一张合同,都以为自己批了。

我的做法是状态更新使用条件更新,并检查影响行数。SQL 上大概是这样的:

@Transactional(rollbackFor = Exception.class) public void approve(Long contractId, boolean pass, String comment) { ContractDO contract = contractMapper.selectById(contractId); if (contract == null || !"PENDING".equals(contract.getStatus())) { throw new BizException("当前状态不允许审批"); } String targetStatus = pass ? "ACTIVE" : "REJECTED"; int updated = contractMapper.compareAndSetStatus(contractId, "PENDING", targetStatus); if (updated != 1) { throw new BizException("合同已被其他用户处理,请刷新后重试"); } approvalRecordMapper.insert(contractId, pass, comment, SecurityUtils.getUserId()); }

核心是compareAndSetStatus对应的更新语句:

UPDATE biz_contract SET status = #{targetStatus} WHERE id = #{contractId} AND status = 'PENDING'

只要影响行数不等于 1,说明状态已经被别人改了,事务直接回滚,不会产生两条审批记录。这个“乐观更新”思路在订单、审批、库存这类并发修改场景里非常通用,学会了你在其它项目里也能直接套。

4.4 合同到期提醒任务的实现

保险合同最怕的是到期忘续保。这个项目里用 Spring 的定时任务@Scheduled做了自动扫描,每天早上 7 点查一次end_date在未来 30 天内且状态为ACTIVE的合同,然后给负责的业务员生成站内提醒。核心逻辑大概是:

@Component public class ContractRemindTask { @Scheduled(cron = "0 0 7 * * ?") public void scanExpiringContracts() { List<ContractDO> list = contractMapper.selectExpiringContracts(30); for (ContractDO contract : list) { remindService.createRemind( contract.getCreateBy(), "合同「" + contract.getContractName() + "」将在30天内到期,请及时处理续保" ); } } }

这个任务的坑点是重复提醒。如果没有记录上次提醒时间,定时任务每天都跑,客户会收到三十条一模一样的“即将到期”。所以我额外维护了一张提醒记录表,同一合同在同一个提醒周期内只能生成一次提醒。如果以后系统要部署多个实例,还要给定时任务加分布式锁,防止每个节点都跑一遍,那就会重复发消息了。单实例模式下,当前这种写法是安全且高效的。

4.5 文件上传:PDF 附件要做的双重校验

保险合同的附件经常是 PDF 保单、扫描件和签字页。后端上传接口不能只信前端传来的文件名,有人可以改个后缀把恶意文件传上来。我的做法是双重校验:第一层校验扩展名白名单,只允许.pdf、.jpg、.jpeg、.png;第二层读取文件头,PDF 文件的头部必须是%PDF开头,图片也有对应的魔数。文件名存储时全部用 UUID 重命名,不保留用户原始文件名,从根本上避免路径穿越和特殊字符问题。这些校验在前后端分离项目里尤其重要,因为前端拦截很容易被绕过,后端必须把守最后一道关。

5. 部署教程:从源码到线上环境一步步操作

5.1 环境准备清单

部署前后端分离项目,最怕环境版本不统一。我这里列一个我在本机和 Linux 服务器上都验证过的组合。

组件版本要求说明
JDK1.8+本项目基于 Java 8 开发
Maven3.6+用于后端打包
Node.js16+用于前端安装依赖和构建
MySQL5.7 / 8.0生产建议 8.0
Nginx任意稳定版部署前端 dist 和反向代理接口

如果只是在本地跑通,Nginx 也不是必须的。可以在开发环境用 Vite 的 proxy 把/api转发到后端地址,但真正模拟生产环境,我还是建议装一个 Nginx,毕竟线上部署才是前后端分离项目的终局。

5.2 MySQL 初始化

拿到源码之后,数据库脚本在database/keying_insurance.sql。第一步是建库,然后导入脚本。MySQL 8.0 的建库命令:

CREATE DATABASE keying_insurance DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci;

导入脚本用命令行:

mysql -uroot -p keying_insurance < database/keying_insurance.sql

这里有个实操细节:不要直接用 root 跑生产库,脚本导入完成后创建一个业务账号,单独授权keying_insurance库即可。比如:

CREATE USER 'keying'@'%' IDENTIFIED BY '你的强密码'; GRANT ALL PRIVILEGES ON keying_insurance.* TO 'keying'@'%'; FLUSH PRIVILEGES;

5.3 后端配置和启动

后端配置文件在backend/src/main/resources/application.yml,最核心的是数据源,我用的是 MySQL 8.0 的驱动,连接串里一定要带useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai,否则中文可能乱码,日期也可能差 8 小时。

server: port: 8080 spring: datasource: url: jdbc:mysql://127.0.0.1:3306/keying_insurance?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai&useSSL=false&allowPublicKeyRetrieval=true username: keying password: your_password driver-class-name: com.mysql.cj.jdbc.Driver mybatis: mapper-locations: classpath:mapper/*.xml type-aliases-package: com.keying.insurance.entity configuration: map-underscore-to-camel-case: true

allowPublicKeyRetrieval=true这个参数需要注意:MySQL 8 默认的caching_sha2_password认证协议在首次连接时需要获取 RSA 公钥,不带这个参数很多客户端会直接报错。本地开发加上它没问题,生产环境如果对安全要求高,建议改用mysql_native_password或配置 SSL。另外map-underscore-to-camel-case一定要开,这样数据库的contract_no才能自动映射到 Java 属性contractNo。

后端打包:

mvn clean package -DskipTests

启动:

java -jar target/keying-insurance.jar

看到Started KeyingInsuranceApplication日志,后端就算起来了。可以用curl http://localhost:8080/api/health验证服务是否正常。

5.4 前端构建

前端目录是frontend,构建步骤很常规:

cd frontend npm install npm run build

npm install如果很慢,建议先把镜像切到国内源:

npm config set registry https://registry.npmmirror.com

构建完成后会生成frontend/dist目录。这个目录就是前端的所有静态资源,包括index.html、JS、CSS、图片。接下来要把它交给 Nginx 托管。

5.5 Nginx 配置前后端联调

生产环境中,前端静态资源和后端接口最好都在同一个域名下,用路径区分。这样就不需要处理跨域,浏览器不会发出OPTIONS预检,部署最省心。我的 Nginx 配置如下:

server { listen 80; server_name your-domain.com; root /opt/keying/frontend/dist; index 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; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; } # 前端页面路由 location / { try_files $uri $uri/ /index.html; } }

有几个细节我必须单独拎出来说。第一,location /api/里的proxy_pass末尾带了斜杠/,这意味着/api/contract/page会被转发成http://127.0.0.1:8080/contract/page,相当于把/api前缀去掉了。如果你的后端接口统一带/api前缀,proxy_pass就不要加斜杠,写成http://127.0.0.1:8080;。很多人就是栽在这个斜杠上,接口 404 或者 302 循环半天。第二,try_files $uri $uri/ /index.html;这一行是 Vue Router history 模式的生命线。没有它,用户刷新/contract/list这个页面时,Nginx 会去磁盘找contract/list目录,找不到就返回 404;有了它,找不到文件就会回退到index.html,交给前端路由渲染。

配置改完后:

nginx -t nginx -s reload

然后访问http://your-domain.com,能看到登录页,这套前后端分离项目就算部署成功了。

5.6 如果必须用 Tomcat 部署怎么办

有些院校或公司环境要求必须用传统 Tomcat webapps 部署,那也可以,但我不建议把前端塞进 war 包。改造思路是:后端 pom 里把<packaging>jar</packaging>改成<packaging>war</packaging>,然后排除 Spring Boot 内嵌 Tomcat 依赖,再把启动类改成继承SpringBootServletInitializer,最后mvn package生成 war 放进 Tomcat 的webapps目录。前端 dist 还是放到 Nginx 或其它静态服务器。这样做的本质还是前后端分离,只不过后端宿主由内置 Tomcat 换成了外部 Tomcat。如果你不是被环境硬性要求,老老实实用 jar + Nginx 的部署方式最简单。

6. 常见问题与排查经验

做这套项目时我踩过不少坑,有些问题到现在回头看还觉得挺值得写出来。整理一份速查表方便你直接对照。

问题现象根本原因解决办法
页面刷新后 404Vue Router history 模式没有回退配置Nginx 加try_files $uri $uri/ /index.html;
后端接口 404Nginxproxy_pass路径拼接不符合预期检查代理 URI 是否带斜杠,和后端实际路径匹配
登录后接口 401token 没被请求头携带Axios 请求拦截器里统一加Authorization
前端跨域报错前端和后端不在同一个源生产用 Nginx 同源反代;开发用 Vite proxy,不要图省事开allow-origin *
启动报 Public Key Retrieval is not allowedMySQL 8 认证协议问题连接串加allowPublicKeyRetrieval=true
中文乱码库表字符集不一致建库表统一utf8mb4,连接串加characterEncoding=utf8
Mapper XML 没生效后端找不到映射文件确认mybatis.mapper-locations: classpath:mapper/*.xml
分页 total 比实际多分页查询 join 了一对多子表先查主表分页,再批量查子表明细
审批重复提交并发更新覆盖了状态用UPDATE ... WHERE status='PENDING'并检查影响行数

还有一个经常被忽略的问题:后端 jar 包明明启起来了,但前端拉取不到数据。排查时要先绕开前端,直接用浏览器打开http://localhost:8080/api/合同接口,看返回的是 JSON 还是 404。如果是 404,大概率是接口路径和前端baseURL对不上;如果返回 401,大概率是 token 问题。这个先后顺序能帮你省下大量“无畏”的调试时间。

7. 最后再分享一点实操体会

这套系统从头到尾跑通一遍之后,我最大的体会是:前后端分离项目的“难点”从来不在某个单独技术点上,而在把 CRUD、权限、状态流转、文件上传、定时任务、部署发布这些环节合理地粘合在一起。很多人写代码时,单看每个接口都很简单,但真正做项目时会被“PageHelper 分页为什么不准”“Nginx 刷新为什么 404”“JWT 过期后前端为什么不跳转”这类组合问题卡住。我把这些问题连同解决方案一起写在这篇博文里,就是为了让你不重走这些弯路。

如果你想进一步扩展这个项目,我建议优先做两件事:一是把合同到期提醒升级成短信或邮件通知,那个只增加一个消息发送接口,业务收益立竿见影;二是把统计报表模块补充起来,用 Excel 导出替代后端临时拼数据,日常运营会舒服很多。整个项目源码的拆分方式很清晰,你拿它当脚手架往里面加新模块,不会觉得别扭。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询