ARTICLE DETAIL

建站实战干货

来自一线的建站与推广经验沉淀,每一条都经过真实交付验证。

合同管理系统前后端分离实战:Spring Boot+Vue+MyBatis部署全流程

2026/9/26 11:55:46 拓冰建站 浏览量
合同管理系统前后端分离实战:Spring Boot+Vue+MyBatis部署全流程 去年接手可盈保险的这套合同管理系统时第一感觉就是业务并不复杂但数据链路长、状态多、权限要求细。旧系统还是单体JSP加Servlet的架构前端页面和后端逻辑全搅在一起改一个保单列表的样式都得重新打一次包。项目定下的技术栈是Spring Boot Vue MyBatis MySQL典型的Spring Boot前后端分离项目。这套组合放在今天虽然不算新潮但胜在稳定、社区资料多、招人容易而且对保险合同这种强业务关联、强查询条件的系统来说反而是最稳妥的选择。这篇文章把整个项目的设计与部署过程完整复盘一遍从数据库表怎么拆、保单状态机怎么设计到后端接口怎么写、前端页面怎么调最后到如何在服务器上用Nginx加Jar包把前后端分离项目跑起来。内容既照顾刚接触前后端分离的新手也希望能给已经在做类似系统的同学一些参考。仓库里附带的完整源码和部署教程配合这篇说明一起看基本可以对着落地一套可用的合同管理系统。1. 项目整体设计与技术选型拆解1.1 为什么坚持前后端分离旧系统最大的问题不是功能少而是改不动。一个保单详情页里HTML、Java代码、SQL查询逻辑全都揉在JSP里。要改一个按钮的颜色得找到对应的JSP文件重启整个Tomcat前端想用点现代交互效果又受限于后端模板引擎的渲染方式。开发效率低不说前后端两个角色还经常互相踩代码。改成前后端分离之后所有交互都通过JSON接口完成。前端团队只管Vue页面和API调用后端团队只管Spring Boot接口、业务逻辑和数据库。两边可以并行开工接口定义好之后互不阻塞。部署上也省心前端构建出来的静态文件直接扔给Nginx托管后端就是一个可执行的Jar包升级任何一端都不影响另一端的运行。我个人的体会是前后端分离带来的最大红利其实是接口契约化。接口一旦定下来联调阶段的问题就集中在字段对齐和边界情况上而不是在混乱的模板渲染里找Bug。这个项目里我们把所有接口都整理成了统一的风格后端返回固定的JSON结构前端请求也统一封装后面扩展新模块时基本就是复制模板接着写。1.2 后端技术栈选型的真实理由Spring Boot在这个项目里是必然选择。它内嵌Tomcat不需要额外部署Servlet容器一个java -jar就能启动服务。自动配置机制帮我们省掉了大量XML配置开发阶段有热部署插件改完代码自动重启调试体验比老Spring项目强太多。MyBatis则是基于业务特征做的选择。保险合同系统的查询条件非常多保单号、客户姓名、险种类型、业务员、起保日期、状态等等需要写大量动态拼接的SQL。MyBatis的XML里可以用if、where这些标签灵活控制查询条件复杂多表查询和统计报表也完全在掌控之内。相比之下JPA或者Spring Data JPA在处理这种复杂的多条件组合查询时非常痛苦要么写JPQL要么靠Specification调试SQL和优化执行计划都很别扭。MySQL作为数据库没有悬念成本低、部署简单、运维资料多。我们开发环境用的MySQL 5.7线上用的8.0兼容性处理得也算顺利。唯一要注意的是MySQL 8.0的驱动类和时区问题这个后面在部署章节专门讲第一次踩坑的人基本都会卡在这里。1.3 前端框架选型和工程化思路前端选择了Vue 2加Element UI。选择Vue 2不是因为它新而是因为这套系统开发时Vue 3的生态还没有完全成熟Element UI的组件比较齐全表格、表单、弹窗、日期选择器这些保险业务每天都在用的东西都有现成的。如果真的现在才开始做我可能会考虑Vue 3加Element Plus不过核心思路完全一致。工程化方面我们用vue-cli生成项目骨架目录结构分成api、router、store、views、components、utils几个模块。API层集中管理所有后端接口的请求组件层抽公共的业务组件页面层做具体功能。开发环境通过vue.config.js里的devServer.proxy把/api开头的请求代理到后端的8080端口这样本地联调阶段就不用处理跨域了。还有一个细节是路由权限的控制。前端根据当前登录用户的角色动态生成菜单和可访问的路由后端也在关键接口上做二次校验双保险。这样的好处是普通用户进不了管理页面也调用不了管理接口安全性比只靠前端隐藏按钮强得多。2. 数据库设计与核心业务模块2.1 合同管理系统的核心表结构设计数据库设计是整个系统的地基地基没打好后面写SQL和接口都在还债。保险合同管理的核心对象就几个客户、保单、保险产品、缴费计划、理赔记录外加批改记录和操作日志。下面给出一张简化版的核心表清单实际项目中可以在此基础上扩展。表名用途关键字段t_customer客户基本信息name, id_card, phone, address, customer_typet_product保险产品信息product_name, product_code, insure_type, premium_ratet_policy保单主表policy_no, customer_id, product_id, insurance_amount, premium, payment_period, policy_status, start_date, end_datet_payment_plan缴费计划表policy_id, period_no, due_date, pay_date, amount, pay_statust_claim理赔记录表claim_no, policy_id, claim_amount, claim_type, claim_status, apply_datet_endorsement批改记录表policy_id, endorsement_type, before_value, after_value, create_timet_sys_user系统用户表username, password, real_name, role_type, statust_operation_log操作日志表user_id, operation, target_table, target_id, detail, create_time保单主表是整个系统的核心承接了客户、产品、缴费、理赔、批改多条线。设计时要注意几个关键点保单号要有唯一索引这是业务上最常用的检索条件金额字段建议用DECIMAL(18,2)而不要用浮点类型否则计算保费和理赔额会出现精度问题状态字段单独建索引因为列表页最常见的筛选条件就是状态。客户表里身份证号也加了唯一索引虽然保险业务里一个客户可能有多张保单但客户主体的唯一性必须保证。缴费计划表是按保单生成的比如一份10年交的保单承保后会批量生成10条缴费计划记录每一条都对应具体年份的应缴日期和金额财务人员每个月就对着这张表做续期提醒和收款确认。2.2 保单状态机设计从新单到退保保险合同的业务核心是状态流转从客户投保到最终满期或退保中间要经历多个环节。状态机设计得清楚后面的业务流程才能跑得顺。我们这个系统把保单状态定义为这样一串待提交→待核保→已承保→缴费中/已缴清→理赔中→已结案中间随时可以进入退保/终止。具体落地时用数字编码维护在t_policy表的policy_status字段里0-草稿、1-待核保、2-已承保、3-缴费中、4-已缴清、5-理赔中、6-已结案、7-已终止。运营人员和业务员看得最多的就是待核保和已承保这两个状态。状态流转不能只靠程序里手动改值那样很容易把状态改乱。我们做了两层控制第一层是后端Service层的状态校验每个方法只允许从特定状态流转到特定状态比如只有待核保状态的保单才能被核保员通过变成已承保第二层是t_endorsement批改记录表每一次状态变更都写一条记录以后追溯这张保单为什么从已承保变成退保的时候查这个表一目了然。2.3 多角色权限控制与数据隔离一套合同管理系统里角色一定是多样的管理员要做系统配置和用户管理业务员要录单和查看自己的客户核保员要审单和决定通过或拒保财务要看缴费和理赔的金额客户本人则只能看自己的保单信息。每类角色能看到的菜单和能操作的按钮完全不同。权限实现采用RBAC思路用户表和角色表关联角色和菜单权限关联。后端用自定义注解加拦截器做接口级校验前端用路由守卫做页面级控制。更细的数据权限通过SQL层控制实现业务员登录后查询保单列表SQL会自动加上AND create_by 当前用户ID条件保证他只能看到自己录的单管理员则可以传一个userId参数走全量查询。这个设计思路的核心原则是前端的隐藏只是体验后端的校验才是安全底线。即使有人绕过前端直接调接口没有对应的角色注解照样拿不到数据。这就是为什么权限不能只依赖前端路由一定要在接口层也做校验。3. Spring Boot后端核心实现与关键代码3.1 Spring Boot工程分层与统一返回结构后端工程按常规的controller、service、mapper、entity四层划分额外加了config、interceptor、common等包。Controller层只做参数接收和结果返回业务逻辑全部放在Service层MyBatis的Mapper接口负责数据库访问Entity类对应数据库表结构。所有接口返回统一的ResultT结构包含code、message、data三个字段。结构确定下来之后好处立刻显现前端Axios拦截器只需要判断code是否为200就能知道请求成功与否不用每个接口单独处理错误情况。异常处理统一用RestControllerAdvice加ExceptionHandler业务异常返回自定义错误码系统级异常返回通用错误信息同时把真正的异常堆栈打到日志里方便排查。Result结构示例 { code: 200, message: success, data: { ... } }这样的统一结构在联调阶段帮了大忙前端不用关心后端到底返回了什么奇形怪状的响应后端也不用为了迁就前端临时改返回格式。项目的完整源码里所有接口都遵循这套规范后面扩展新功能直接照这个模板写就行。3.2 MyBatis动态SQL与分页的正确姿势MyBatis在这个项目里最出彩的地方就是动态SQL。保单查询页面通常有五六个筛选条件每个条件都可能为空用where加if动态拼接就非常舒服。下面这段是最常见的组合查询场景select idselectPolicyByCondition resultTypecom.keyin.insurance.entity.Policy SELECT p.id, p.policy_no, p.customer_id, p.product_id, p.insurance_amount, p.premium, p.policy_status, p.start_date, p.end_date, c.customer_name, c.id_card FROM t_policy p LEFT JOIN t_customer c ON p.customer_id c.id where if testpolicyNo ! null and policyNo ! AND p.policy_no #{policyNo} /if if testcustomerName ! null and customerName ! AND c.customer_name LIKE CONCAT(%, #{customerName}, %) /if if testproductId ! null AND p.product_id #{productId} /if if teststatus ! null AND p.policy_status #{status} /if if teststartDate ! null AND p.start_date gt; #{startDate} /if /where ORDER BY p.create_time DESC /select分页用的是PageHelper插件。用法非常简单查询前调用PageHelper.startPage(pageNum, pageSize)然后执行Mapper查询最后用PageInfo接收分页结果但有几个细节一定要注意。PageHelper.startPage(pageNum, pageSize); ListPolicy policyList policyMapper.selectPolicyByCondition(searchVO); PageInfoPolicy pageInfo new PageInfo(policyList);PageHelper的原理是基于MyBatis的拦截器startPage只对紧接着执行的第一条SQL语句生效。如果startPage之后先执行了别的查询再执行目标查询分页就会失效甚至产生错误结果。另外分页查询后要立即把查询结果放进PageInfo不要在中间穿插其他Mapper操作。我们最早就是因为这两个疏忽排查过好几次为什么第一页正常第二页数据全没了的问题。3.3 JWT登录鉴权与接口安全登录模块采用的是JWTJSON Web Token方案。用户登录成功后后端用密钥生成一个包含用户ID、用户名、角色等信息的Token返回给前端。前端存储在本地每次请求时通过Authorization请求头带在HTTP请求里。后端用一个拦截器统一处理Token校验。白名单配置登录接口和验证码接口其他接口一律先过拦截器。校验逻辑很简单解析Token、验签、判断过期时间通过后把用户信息塞到ThreadLocal里方便后续业务代码取用。关键代码如下Component public class JwtInterceptor implements HandlerInterceptor { Override public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception { String token request.getHeader(Authorization); if (!StringUtils.hasText(token)) { throw new BusinessException(401, 未登录或登录已过期); } Claims claims JwtUtil.parseToken(token); if (claims null) { throw new BusinessException(401, Token无效或已过期); } UserContext.setUserId(claims.get(userId, Integer.class)); UserContext.setRole(claims.get(role, String.class)); return true; } }密码存储用的是BCrypt加密不是简单的MD5。BCrypt每次加密同一个密码得到的密文都不同并且自带盐值即使数据库泄露撞库的成本也高得多。后台用户初始化密码可以统一生成随机密码首次登录强制修改这个逻辑虽然简单但对保险系统来说很有必要。4. Vue前端开发与联调实录4.1 前端工程初始化与环境配置前端工程用vue create创建选择了vue-router、vuex、axios这些基础依赖UI组件库用的Element UI。开发环境的代理配置写在vue.config.js里module.exports { devServer: { port: 3000, proxy: { /api: { target: http://localhost:8080, changeOrigin: true } } } };这样前端开发服务器的请求通过/api前缀直接代理到后端8080端口本地联调阶段完全不需要关心CORS问题。到了生产环境Nginx层再做一次同样的反向代理整个系统同域提供服务跨域这个老大难问题就彻底消失了。项目的目录结构坚持分组划分src/views按业务模块建文件夹比如policy、customer、claim、user每个模块的列表页、编辑页、详情页都独立成组件。公共的搜索栏、分页条、状态标签这类组件抽到src/components里避免每个页面复制粘贴同样的代码。4.2 Axios封装和路由守卫的细节Axios如果不做统一封装项目后期会非常混乱。我们把所有请求都走同一个实例通过请求拦截器统一加入Token通过响应拦截器统一处理HTTP状态码和业务状态码。axios.interceptors.request.use(config { const token localStorage.getItem(token); if (token) { config.headers[Authorization] token; } return config; }); axios.interceptors.response.use( response { const res response.data; if (res.code ! 200) { if (res.code 401) { router.push(/login); } return Promise.reject(new Error(res.message)); } return res; }, error { if (error.response error.response.status 401) { router.push(/login); } return Promise.reject(error); } );路由守卫是用来控制页面访问权限的。我们给每个路由的meta字段配置了roles数组前端在router.beforeEach里拿到当前用户角色判断是否有权进入目标路由。没有权限的直接跳转到403页或者首页。另外需要注意路由守卫只能控制页面展示后端接口鉴权依然不能少。4.3 保单列表和新单录入页面的实现要点保单列表页是全系统使用频率最高的页面核心点有两块搜索条件和表格渲染。搜索表单把查询参数绑定到一个对象上点击查询按钮时把参数传给后端分页接口。表格使用Element UI的el-table组件列字段与后端PageInfo的list字段一一对应。分页组件与el-pagination绑定当前页和每页条数变动时重新拉取数据。这里有个经验表格里的状态字段不要直接显示数字编码而要用状态字典做映射显示成待核保已承保这样的中文标签不然业务人员根本看不懂。新单录入页比列表页复杂一些用el-steps做分步表单大概分三步选择客户、选择产品并填写保额和保费、确认并预览。选择产品后前端通过接口拿到该产品的费率根据保额和缴费期限自动计算保费并将缴费计划预览给用户看。提交时一次性把保单主表数据和缴费计划明细传给后端后端在事务里统一写入保证数据一致性。保险业务里有个常见场景一份保单的缴费期限是10年前端需要动态生成10行缴费计划明细每一行包含期间序号、应缴日期和金额。这块前端用动态表格实现维护一个缴费计划数组根据缴费年限字段的变化重新生成数组。后端接收后循环插入t_payment_plan表全部操作放在一个Transactional事务里任何一个明细插入失败整个保单创建就回滚避免出现主表有记录而缴费计划缺失的脏数据。5. 打包部署上线的完整流程5.1 部署前环境准备与数据库初始化部署一台全新的服务器第一步就是把基础环境配齐。后端需要JDK 1.8或以上版本数据库需要MySQL 5.7或8.0前端构建需要Node.js 14以上的环境静态资源托管需要Nginx。版本对应关系上个小表格说明一下组件推荐版本说明JDK1.8 或 11Spring Boot 2.x 用JDK1.8足够MySQL5.7 或 8.08.0需注意驱动类名和时区Node.js14构建前端用Nginx1.20托管静态资源并反向代理APIMaven3.6打包后端用数据库初始化最稳妥的方式是把项目里的insurance_db.sql文件传到服务器上执行source命令导入。这里强烈建议建表语句里统一指定utf8mb4字符集别用默认的utf8。utf8mb4是完整的Unicode字符集能存表情和生僻字保险客户姓名里偶尔会出现生僻字用utf8就可能存不进去报错。MySQL 8.0下还要特别留意连接串的写法driver-class-name必须改成com.mysql.cj.jdbc.Driver并且加上时区参数否则运行时会报The server time zone value相关错误。完整的连接串长这样spring: datasource: url: jdbc:mysql://localhost:3306/insurance_db?useUnicodetruecharacterEncodingutf8serverTimezoneAsia/ShanghaiuseSSLfalseallowPublicKeyRetrievaltrue username: root password: yourpassword driver-class-name: com.mysql.cj.jdbc.Driver5.2 后端打包与后台运行后端打包用Maven执行mvn clean package -DskipTests。跳过快照依赖下载的话可以加-o离线模式不过这个看网络情况。构建成功后target目录下会生成一个insurance-system.jar文件这个Jar包内嵌了Tomcat可以直接运行。生产环境启动不建议直接在终端前台跑因为SSH断开进程就没了。用nohup配合让Jar包在后台运行并把日志输出到指定文件方便排查问题nohup java -jar insurance-system.jar --spring.profiles.activeprod logs/insurance.log 21 项目里的application.yml按环境做了拆分application-prod.yml里配置生产环境的数据库地址、端口、日志级别。启动后可以用tail -f logs/insurance.log查看启动日志看到Started Application字样说明后端启动成功。首次启动如果报数据库连接失败先检查MySQL服务有没有启动、账号密码对不对、远程访问有没有放行这三个是最高频的问题。5.3 前端构建与Nginx反向代理配置前端构建前先确认依赖装全了执行npm install注意用国内的npm镜像源会快很多比如设置registry为https://registry.npmmirror.com。然后执行npm run build构建完成后会在项目根目录生成dist文件夹这就是整个前端的静态产物。npm install --registryhttps://registry.npmmirror.com npm run build把dist目录下的所有文件上传到服务器的/usr/share/nginx/html/insurance-web目录然后在Nginx配置里加一个Server块。下面是整个部署里最关键的一段配置server { listen 80; server_name your-domain.com; root /usr/share/nginx/html/insurance-web; index index.html; # 前端路由刷新页面时不会404 location / { try_files $uri $uri/ /index.html; } # 后端API反向代理 location /api/ { proxy_pass http://127.0.0.1:8080/api/; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; } }这段配置里两个细节必须讲清楚。第一是try_files $uri $uri/ /index.html这一行它解决的是前端路由刷新404的问题。因为Vue是单页应用路由切换是前端在内部完成的刷新/policy/list这个路径时服务器上并没有对应的物理文件所以Nginx要把请求回退到index.html由前端路由接管。没有这一行页面一刷新就404。第二是location /api/里的反向代理前端所有请求都走/api前缀Nginx把请求转发给后端8080端口浏览器到Nginx是同域访问不存在跨域。后端接口路径也要统一带/api前缀或者在Controller上加server.servlet.context-path/api两种方式效果一样。配置改完后执行nginx -t检查语法没问题再nginx -s reload让配置生效。然后访问http://your-domain.com熟悉的登录页就出来了。5.4 上线后的自测检查清单部署完成后按下面这张清单过一遍确认系统真正可用而不仅仅是首页能打开访问登录页用管理员账号登录确认登录成功后跳转到首页。首页的统计数字能正常显示说明后端接口和数据库连接都正常。进入保单列表页搜索一个客户姓名确认搜索结果正确。翻到列表第二页确认分页正常没有重复数据或丢数据。创建一个新单走完整个分步流程并提交确认数据库里出现了新保单和对应缴费计划记录。退出登录再用一个无权限的账号登录确认看不到管理菜单直接访问管理页面URL也会被拦截。刷新一个深层链接比如/policy/detail/1确认不会出现404。打开Nginx和后端的日志确认没有报错日志刷屏。这套检查覆盖了登录、查询、分页、新增、权限、路由刷新、日志这几个最容易出问题的环节。实测下来只要这八项全过系统基本可以放心交给业务部门使用了。6. 常见问题与避坑速查手册6.1 环境与数据库层面的高发问题问题1启动时数据库连接报错提示时区问题。MySQL 8.0的驱动要求连接串里显式指定serverTimezone否则默认使用服务器本地时区在部分环境下会直接抛异常。解决办法就是在JDBC连接串后面加上serverTimezoneAsia/Shanghai。问题2数据库连接成功但插入中文数据变乱码或报错。这个几乎都是数据库字符集问题。表结构和数据库都要确认是utf8mb4连接串里加characterEncodingutf8。另外MySQL里查看字符集的命令是SHOW VARIABLES LIKE character%如果character_set_server不是utf8mb4建议在my.cnf里配置后重启MySQL。问题3启动Jar包后日志显示端口被占用。后端默认端口是8080如果服务器上已经跑了其他服务占用这个端口启动会直接失败。先执行netstat -tlnp | grep 8080看端口被谁占用要么换个端口启动要么停掉冲突的服务。生产环境如果多个服务共用一台服务器建议给每个服务配独立端口不要都挤在默认端口上。6.2 联调与部署环节的典型坑问题1前端能打开登录页但登录请求报Network Error或Failed to fetch。先确认请求URL是不是带上了/api前缀再确认Nginx的location /api/配置是否生效最后看后端日志有没有收到请求。很多情况是后端接口没带/api前缀Nginx代理转发到后端后路径对不上接口自然404。统一约定后端所有接口路径都以/api开头这个问题就能避免。问题2开发环境联调正常部署后页面刷新就404。典型的Nginx未配置try_files导致的单页应用路由问题。检查location /块里有没有try_files $uri $uri/ /index.html这一行没加就补上。问题3PageHelper分页第一页正常第二页数据不对。先排查Mapper方法里是否在startPage之前执行了其他数据库操作再确认分页查询后没有继续执行其他查询再创建PageInfo。如果用的是多数据源或自定义拦截器还有可能是拦截器顺序问题这种情况建议检查MyBatis插件配置。6.3 问题排查速查表现象可能原因排查顺序后端启动报时区错误MySQL 8.0驱动要求时区配置检查连接串serverTimezone参数中文数据乱码或报错字符集未用utf8mb4查库表字符集改连接串前端页面刷新404Nginx未配置try_files检查Nginx配置并reload登录请求Network Error前端代理或Nginx反代配置问题从浏览器Network面板看请求URL接口数据能查但分页异常PageHelper使用位置不对检查startPage是否紧邻目标SQL页面能开但菜单打不开角色权限未配置或前端路由守卫拦截查看用户角色和路由meta配置图片上传失败上传目录权限不可写检查服务器目录读写权限后端日志大量异常输出数据库连接池不足或网络抖动调整连接池参数检查网络这张表基本覆盖了前后端分离项目初期最容易遇到的所有问题尤其是把Network Error和404这类现象第一时间定位到Nginx配置还是后端服务上能省下大量排查时间。这套系统从开发到上线走下来我个人最深的感触是做一个合同管理系统真正的难点不在技术有多新而在业务流程梳理得清不清楚、数据关系理得顺不顺、权限边界划得明不明确。前后端分离加上Spring Boot、Vue、MyBatis、MySQL这套组合给这类业务系统提供了可靠的技术底座剩下的就是扎扎实实把每个模块吃透。最后再分享一个小技巧吧部署完成后一定要拿一份真实的保单数据从头到尾跑一遍完整业务流程新单、核保、承保、缴费、退保每个环节都走一遍很多隐藏的问题都是在完整流程里暴露出来的光靠页面点开看一眼远远不够。