做保险合同管理系统这件事,我最初是被一个很现实的需求推着走的。保险公司也好,保险中介也好,合同管理的痛点是共通的:合同量大、版本多、审批流程长、Excel维护根本跟不上。所以我用 Java SpringBoot + Vue3 + MyBatis + MySQL 这套前后端分离组合,做了一版可盈保险合同管理系统。这篇文章不是简单地贴源码,而是把我在设计数据库、拆分权限、处理状态流转、联调和部署过程中踩过的坑、验证过的方案一起讲清楚,给准备做管理类系统的同学一个可以直接参考的实战样本。
1. 项目背景与整体设计思路
1.1 合同管理的核心痛点有哪些
合同管理系统听起来简单,做起来其实比普通CRUD要复杂不少。最开始我把合同只理解成一个“信息登记表”,后来真正接触业务才发现,保险合同至少牵扯几个层面:客户信息、保单信息、产品信息、费率信息、理赔记录、续保状态,还有合同从草稿、审批、生效、变更到终止的完整生命周期。
这些环节放在Excel里会是什么情况?一个稍有规模的团队,合同可能上千份,光靠文件名命名规则根本找不准。审批靠邮件来回传,版本一多就乱。到期提醒全凭人工记录,漏掉续保期直接影响业务收入。而且合同数据一旦涉及审计,操作留痕是刚需,谁在什么时候改了什么字段,必须能追溯。这些问题加起来,就是需要一个系统来管的理由。
所以我在设计这个系统时,没有急着写代码,而是先把合同的生命周期拆清楚:录入阶段、审批阶段、生效阶段、变更阶段、终止归档阶段。每个阶段的关注点不一样——录入阶段看重表单校验和查重,审批阶段看重流程流转和权限控制,生效阶段看重和保单、产品的关联,终止阶段看重数据归档和操作日志。整个系统就是围绕这条主线展开的。
1.2 为什么选定SpringBoot+Vue3+MyBatis这套组合
技术选型这块,我见过太多“跟风”的团队:一听说微服务火就上微服务,一听说K8s火就搞容器化。其实对于合同管理这种典型的企业级管理系统,稳定性、可维护性、上手难度才是第一位的。
SpringBoot的胜出不用多说,它解决了Spring配置地狱的问题,内嵌Tomcat让部署直接从“装环境”变成“一条Jar命令”,而且生态极其成熟。你要什么能力,找一个Starter就行,这一点对企业项目太重要了。
前端选择Vue3,主要是看中组合式API带来的代码组织能力。合同管理页面交互不算特别复杂,但表单校验、动态字段、步骤条、弹窗确认这些场景不少,用Vue3的setup语法写起来比Options API清爽很多。加上Vite的构建速度,开发体验确实好。
MyBatis是后端开发里争论比较多的选择,有人说JPA更省事。我的看法是:合同管理这类系统SQL逻辑复杂,有大量的多表关联查询、动态条件拼接、报表统计,MyBatis可以精确控制SQL,排查问题也直观。JPA的自动建表和懒加载策略在复杂查询场景下反而容易出幺蛾子。配合PageHelper分页插件,开发效率完全不输JPA。
MySQL作为存储层没什么悬念,开源、稳定、运维成本低,InnoDB引擎在处理事务和行级锁方面表现足够。这套组合的搭配逻辑就是:每一层都选最成熟、最不容易踩坑的选项,而不是最新最潮的选项。
1.3 前后端分离架构的核心考量和工作模式
既然定了前后端分离,那么整个工作模式就要跟着变。后端不再关心页面渲染,只提供JSON接口;前端负责页面展示和交互,通过HTTP请求获取数据。这样有几个明显的好处:前后端可以并行开发,谁也不用等谁;后端接口可以被多个客户端复用(Web端、管理端、后续的移动端);部署时前后端各自独立扩容,互不拖累。
架构上需要提前考虑几件事。第一是接口规范,我统一采用RESTful风格,资源用名词复数,操作靠HTTP方法和状态码表达。第二是跨域问题,开发环境下前端跑在5173端口、后端跑在8080端口,跨域是必然的,用CORS配置解决。第三是权限认证,前后端分离后Session不一定好使,我用JWT做无状态认证,后端只需要校验Token合法性。
实际开发中,我给团队定了一个简单的协作流程:先定义好接口文档(参数、返回结构、错误码),前后端按照契约各自开发。这样即使在联调阶段出问题,也能快速定位是前端调用方式不对,还是后端返回结构不符。这个习惯帮我节省了大量无效沟通的时间。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 数据库设计与核心表结构
2.1 合同主表与关联表的设计思路
数据库设计是整个项目的根基。表结构没设计好,后面写SQL、做查询、加功能都会痛苦。我先从合同主表说起。
合同主表contract的核心字段包括:合同编号、客户ID、产品ID、保单号、合同状态、生效日期、终止日期、保费金额、渠道来源、创建人、审批人等。需要注意的一个设计原则是“适度冗余”——客户名称、产品名称这些字段我建议直接冗余到合同表里,而不是每次都去关联查询。为什么?因为合同列表页要在分页情况下展示客户名和产品名,如果每次都JOIN两张表,数据量上来以后性能就难看了。
金额字段一定要用DECIMAL,这是新手最容易犯的错误。用FLOAT或DOUBLE存金额,经过多次计算会出现精度丢失。保费、费率、佣金这些字段关系到钱,一点偏差都不允许,所以全部用DECIMAL(10,2)这种定点数类型。
关联表的设计上,我把客户、产品、保单分别独立成表。客户表存基础身份信息,产品表存产品名称、险种类型、费率方案,保单表存的是具体某张保单的详细信息。这样做的好处是数据可复用,一个客户可以对应多份合同,一个产品可以被多个合同引用,避免一张大表里塞满重复信息。
2.2 合同状态流转与操作日志表设计
合同系统里最关键的一张表,其实是操作日志表contract_log。从审计角度来说,谁在什么时间把合同从“审批中”改成了“已生效”,这个信息必须永久保留。我之前见过一些系统只记录最终状态不记录过程,出了问题完全无法回溯。
日志表字段设计为:日志ID、合同ID、操作人、操作类型(新增、修改、审批通过、驳回、变更、终止)、操作前状态、操作后状态、操作内容描述、操作时间。这里有个细节:操作前状态和操作后状态都单独存字段,不要只存一个“当前状态”,这样才能完整还原操作链路。
合同状态本身用字符串类型还是数字类型?我建议用字符串。状态可读性是第一位的,PENDING、EFFECTIVE、TERMINATED这些枚举值在排查问题时一眼就能看懂。数字状态对Java来说多一层翻译成本,而且一旦枚举顺序调整,历史数据就全乱了。
状态流转我单独写了一个状态机工具类,明确每个状态可以转到哪些状态。比如“审批中”只能转“已生效”或“已驳回”,“已生效”只能转“变更中”或“已终止”。这样做的好处是,接口层在改变状态前先校验合法性,非法流转直接拒绝,而不是等到数据错了再补救。
2.3 索引设计与MySQL性能优化细节
表设计完成后的下一步是索引,索引设计得好不好,直接决定系统在数据量增长后的表现。
我在核心表上加了这些索引:合同表的contract_no字段加唯一索引,保证合同编号不重复;customer_id和product_id加普通索引,支撑关联查询;status字段加普通索引,支撑按状态筛选;effective_date和termination_date加索引,支撑日期范围查询和到期提醒的扫描。
有几个MySQL优化的细节值得展开说。第一,LIKE查询如果写成%keyword%,索引会失效,全表扫描跑不掉。合同编号搜索这种场景,我改成keyword%的前缀匹配,索引可以命中,性能差别在数据量10万以上时非常明显。第二,排序字段和WHERE条件字段最好建联合索引,比如按状态筛选同时按生效日期排序,(status, effective_date)联合索引能同时服务过滤和排序。第三,分页查询不要用LIMIT 100000, 20这种深分页写法,偏移量越大越慢,我改成基于游标的方案,用上次查询的最后一条记录的ID作为下一页的起点。
MySQL字符集一定要用utf8mb4而不是utf8mb3,不然存emoji或者特殊符号的时候字段会报错,甚至有截断数据的风险。排序规则我用utf8mb4_general_ci,对中文和英文的模糊查询兼容性都不错。
3. 后端核心实现与踩坑记录
3.1 SpringBoot项目结构与分层说明
后端工程结构我是按标准的分层架构组织的,没有搞复杂的DDD,因为合同管理系统的业务复杂度还不到必须用DDD的程度。基础包结构如下:
code复制com.keying.contract
├── controller # 接口层,只做参数接收和响应封装
├── service # 业务层,核心业务逻辑都在这里
├── mapper # MyBatis的Mapper接口,对应XML文件
├── entity # 数据库实体类
├── dto # 接口传输对象,隔离实体和前端参数
├── vo # 视图对象,组装接口返回给前端的数据
├── config # 配置类:跨域、拦截器、MyBatis等
├── utils # 工具类:JWT、日期处理、编号生成
└── exception # 统一异常类和全局异常处理器
application.yml里比较关键的配置我单独说一下。数据源配置重点是连接池参数,我用HikariCP,这是SpringBoot默认推荐的,性能确实好。连接池大小不是越大越好,我用的是maximum-pool-size: 20,配合minimum-idle: 5,这个配置在常规业务量下足够。MyBatis配置方面,map-underscore-to-camel-case: true必须开,这样数据库的contract_no字段才能自动映射到实体的contractNo属性,省掉一堆结果映射配置。
日志打印也在这里配置好。开发环境打印SQL、生产环境关闭,我用logging.level.com.keying.contract.mapper: debug控制。这个配置比任何SQL分析工具都直观,排查MyBatis问题时能直接看到执行的SQL语句和参数。
3.2 MyBatis缓存机制与分页插件实战用法
MyBatis的缓存机制是面试常考的点,实际项目中更是容易踩坑。一级缓存是SqlSession级别的,默认开启,同一个SqlSession内执行两次相同查询,第二次会走缓存。听起来不错,但在Spring管理的事务里,SqlSession的生命周期和事务绑定,如果两次查询之间发生了其他Mapper的修改操作,MyBatis会清空一级缓存,这个机制本身问题不大。
真正需要警惕的是二级缓存。二级缓存是Mapper级别的,跨SqlSession共享,默认关闭,但我见过很多人为了性能盲目开启。这里有个经典问题:如果开启了二级缓存,又有多表关联查询,其中一个表的数据被更新了,但另一个Mapper的缓存没有失效,就会出现脏读。缓存里的数据已经过期了,但查询结果还是旧值。我在合同项目里明确不开启MyBatis二级缓存,合同数据变更频繁、对一致性要求又高,宁可适当增加查询压力,也不能让脏数据出现在审计场景里。
分页插件我用的是PageHelper,用法很简单:
java复制PageHelper.startPage(pageNum, pageSize);
List<ContractVO> list = contractMapper.selectContractList(query);
PageInfo<ContractVO> pageInfo = new PageInfo<>(list);
这里有几个使用细节一定得注意。第一,PageHelper.startPage()只对紧接着的下一条查询语句生效,如果你在调用它和实际查询之间插入了其他查询或逻辑,分页就会失效。第二,不要对startPage()之后再调用两次查询,第二次查询不会分页但也不会报错,这种隐蔽问题排查起来很浪费时间。第三,PageHelper执行count查询时会对原SQL做包装,如果原SQL复杂度很高,count语句也可能性能不佳,这时可以改写SQL或者手动指定count语句。
3.3 合同编号生成与并发控制方案
合同编号是系统里比较容易被忽视但很重要的环节。合同编号必须是唯一的、有序的、可读性强的,格式我设计成:HT + 年月日 + 四位流水号,比如HT202501150001。看起来简单,但并发场景下生成编号必须要考虑线程安全问题。
最初我用了SimpleDateFormat和自增变量生成流水号,单机测试没问题,但并发一高就出现重复编号。后来改成Redis的INCR命令生成流水号,同时用日期作为key的一部分,保证同一天内的流水号连续递增。如果项目环境里没有Redis,也可以用数据库表的自增ID做流水号生成,配合唯一索引兜底防重。
合同状态变更的并发控制同样重要。两个操作员同时审批同一份合同,可能一个批通过,一个批驳回,最终状态以谁为准?我用的是乐观锁方案。在合同表加一个version字段,每次更新时检查版本号:
sql复制UPDATE contract
SET status = 'EFFECTIVE', version = version + 1
WHERE contract_id = #{contractId} AND version = #{version}
如果更新影响行数为0,说明版本号不匹配,数据已被其他操作修改,这时抛出异常让前端提示“合同状态已变化,请刷新后重试”。这个方案成本低、有效,比数据库行锁简单得多。
3.4 权限认证与拦截器实现细节
前后端分离后的权限认证,我用的是JWT方案。登录成功与否,后端在登录接口校验用户名密码,匹配后生成一个Token返回给前端,前端后续请求在HTTP头里带上Authorization: Bearer <token>,后端拦截器统一校验。
JWT本身分为三部分:Header、Payload、Signature。生成Token时有几个要点:过期时间不能太长,我设成8小时;密钥要足够复杂,用至少32位随机字符串;Payload里不要放敏感信息,因为JWT的Payload只是Base64编码,不是加密,谁都能解码看内容。
拦截器实现权限校验时,要注意排除登录接口和静态资源路径。我用WebMvcConfigurer的addInterceptors方法注册拦截器,并设置excludePathPatterns排除/api/auth/login、/error等路径。这里有个很容易踩的坑:SpringBoot对静态资源的处理路径和接口路径要区分开,拦截器只拦截/api/**下的接口,避免把静态资源的访问也拦截掉。
统一异常处理这块,我用@RestControllerAdvice定义全局异常处理器。自定义业务异常返回400,参数校验异常返回422,未知异常返回500。接口的返回结构统一为{code, message, data},前端只用判断code是否为200就能知道请求是否成功。这个返回值规范在前端联调阶段帮了大忙,不用每个接口都单独看返回结构。
4. 前端Vue3实现与前后端联调
4.1 Vue3工程搭建与项目结构组织
前端我用Vite + Vue3搭建,Vite的启动速度和热更新相比Webpack是碾压级的,尤其在开发了大半天之后,保存文件等编译的体验差距非常明显。项目结构是这样组织的:
code复制src
├── api # 接口请求封装,一个模块一个文件
├── assets # 静态资源
├── components # 公共组件:分页、搜索表单、弹窗等
├── router # 路由配置 + 路由守卫
├── store # Pinia状态管理
├── views # 页面视图
│ ├── contract # 合同管理相关页面
│ ├── customer # 客户管理
│ ├── report # 报表统计
│ └── system # 系统管理:用户、角色、菜单
├── utils # 工具函数:格式化、Token管理、请求封装
└── App.vue
Vue3的组合式API是新项目选它的核心原因。以合同列表页为例,搜索条件、分页参数、表格数据、加载状态全部通过ref和reactive管理,逻辑代码集中在setup中,维护起来一目了然。如果用Options API,数据在data、方法在methods、计算属性在computed,一个功能散落在三处,东西一多就乱。
状态管理用Pinia而不是Vuex,原因是Pinia的API更简洁,去掉了mutations这一层,直接改State即可,TypeScript的支持也更好。在合同管理项目里,我把用户信息、登录状态、菜单权限这三个全局状态放到了Pinia里管理。
4.2 核心页面设计与组件拆分逻辑
合同列表页是整个系统的门面,设计上要兼顾查询效率和展示清晰度。顶部是搜索区域:合同编号、客户名称、合同状态、生效日期范围,这四个筛选条件是使用频率最高的。下面是表格区域,展示核心字段,状态用Tag标签呈现不同颜色:待审批是黄色、已生效是绿色、已驳回是红色、已终止是灰色。操作列放“详情、编辑、审批记录”按钮,权限不足时按钮隐藏。
这个页面的组件拆分我花了不少心思。搜索表单拆成独立组件,提交搜索和重置方法通过事件抛给父组件;分页组件公共化,所有列表页复用;状态Tag封装成通用组件,传入状态值就显示对应的颜色和文案。组件拆分的好处是后续新增客户管理、产品管理页面时,列表模式直接复用,大大减少了重复开发。
合同表单页用的是动态表单方案,根据合同类型动态渲染不同的字段。比如财险合同需要填“标的物信息”,寿险合同需要填“被保人信息”。Vue3里用v-if根据当前选中的合同类型控制字段显示,配合Element Plus的表单校验规则,在提交前拦截掉大部分必填项缺失的情况。这里有个经验:校验规则不要全部依赖前端,后端的参数校验@Validated同样要写完整,前端的校验只是用户体验,后端的校验才是数据安全防线。
4.3 Axios封装与前后端联调的关键问题
Axios封装是所有Vue管理系统的刚需。我统一在一个文件里创建Axios实例,设置baseURL和timeout,通过请求拦截器自动附带Authorization请求头,通过响应拦截器统一处理错误码。这里最关键的是对Token失效的处理:拦截到401状态码时,清除本地Token,跳转登录页,并给出友好提示。这个逻辑如果不做,用户Token过期后接口会持续报错,页面全挂,但根本不知道发生了什么。
javascript复制service.interceptors.response.use(
(response) => {
const res = response.data
if (res.code !== 200) {
ElMessage.error(res.message || '请求失败')
return Promise.reject(new Error(res.message))
}
return res
},
(error) => {
if (error.response && error.response.status === 401) {
store.dispatch('logout')
router.push('/login')
}
return Promise.reject(error)
}
)
联调阶段最容易出问题的就是跨域。开发环境跨域由Vite代理解决,配置里加一层server.proxy,把/api前缀转发到http://localhost:8080。这样浏览器看到的请求是同源的,不触发CORS。生产环境跨域靠Nginx反向代理解决,把/api路径转发到后端服务。我特别强调一下:不要把跨域配置写死在后端代码里用@CrossOrigin注解解决生产环境问题,生产环境正确的做法是让前后端走同一个域名,通过不同路径区分,也就是Nginx代理。
5. 部署上线与性能优化
5.1 MySQL初始化与数据导入注意事项
数据库初始化用Navicat或者命令行执行SQL脚本都行。几个关键配置需要在初始化时就确认好:数据库字符集用utf8mb4,排序规则用utf8mb4_general_ci,存储引擎用InnoDB。这三个参数直接影响后续使用,数据量大之后再改字符集,迁移成本会很高。
SQL脚本的执行顺序也要注意:先建表再插数据,先建主表再建子表。如果存在外键约束,插入数据的顺序和外键依赖关系要一致,否则会报外键约束错误。项目里我习惯把所有建表语句和初始数据(比如管理员账号、基础字典数据)放在同一个初始化脚本里,保证新环境一键就能跑起来。
数据字典这类基础数据,我的做法是初始化脚本里直接固定插入。比如合同类型、合同状态、险种类型、操作类型这些枚举值,统一存到dict表里,前端字典接口动态加载。这样做的好处是后续扩展状态或类型时,不需要改代码、重新发版,只需要往字典表里加数据就行。
5.2 前后端构建与部署方案
后端部署在Linux服务器上,环境需要Java 8或以上版本。构建命令很简单:
bash复制mvn clean package -DskipTests
nohup java -jar keying-contract.jar --spring.profiles.active=prod > app.log 2>&1 &
这里有一个重要的部署心得:生产环境一定要用--spring.profiles.active=prod指定生产配置,单独维护一个application-prod.yml,把数据库连接、日志级别等参数做区分。开发环境的配置和生成环境混在一起,是部署时最容易出问题的点。
前端构建后部署到Nginx:
bash复制npm run build
# 将dist目录下的文件上传到服务器的nginx html目录,配置代理
Nginx配置里需要特别注意history路由的配置。Vue3用history模式时,刷新某个子页面会404,因为Nginx找不到对应的物理文件。需要配置try_files让所有路由都回退到index.html:
nginx复制location / {
root /usr/share/nginx/html;
index index.html;
try_files $uri $uri/ /index.html;
}
location /api/ {
proxy_pass http://127.0.0.1:8080/api/;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
}
5.3 常见问题与排查技巧梳理
最后把我在这个项目中遇到比较典型的问题整理成一个速查表,都是真实排查过的场景。
| 现象 | 可能原因 | 解决方案 |
|---|---|---|
| 前端跨域报错 | 开发环境代理未配置或生产环境Nginx未转发 | 开发用Vite proxy,生产用Nginx location /api分发 |
| Token过期后反复弹窗 | 响应拦截器未对401统一处理 | 401时清除Token、跳转登录页、提示重新登录 |
| 合同编号重复 | 未用Redis或数据库锁,并发生成流水号冲突 | 改用Redis INCR或数据库唯一索引兜底 |
| MyBatis返回null字段 | 实体字段和数据库列名映射不上 | 开启map-underscore-to-camel-case,或加@Results映射 |
| 分页数据不对 | PageHelper.startPage和查询之间插入了其他操作 | 保证startPage后紧跟第一条查询就是目标查询 |
| 中文乱码 | 数据库字符集不是utf8mb4 | 建库时指定utf8mb4,连接串加characterEncoding=utf8 |
| 深分页查询越来越慢 | LIMIT偏移量过大 | 改用游标方式,通过ID定位减少扫描行数 |
| 接口返回数据结构不稳定 | 开发过程中频繁修改VO | 联调前先定好接口文档,严格按契约开发 |
排查问题时,MyBatis日志打印是最高效的线索。我会优先看SQL语句是否正确、参数是否绑定成功、走了哪些索引,绝大多数问题在SQL日志层面都能定位到。再配合EXPLAIN分析执行计划,看是否出现全表扫描、文件排序这些性能隐患。
这里有个我反复遇到的坑:修改了实体类字段后,忘了同步修改Mapper XML里的结果映射,导致前端拿到接口返回值时字段全是null。排查了半天,最后发现是resultMap里少配了一列。所以修订实体时,养成同步检查XML的习惯很重要。
数据库连接池耗尽也是一个高发问题。如果发现请求卡住不动、后台日志报连接超时,第一时间检查连接池是否被耗尽。常见原因是某个查询没有走索引,导致查询时间过长,把连接池占满。把慢查询日志打开,把查询时间超过1秒的SQL全部捞出来优化,这个问题就解决了。
写在最后
这个项目完整做下来,我最深的体会是:做管理系统,技术栈不是难点,难的是把业务流程和技术设计真正对齐。我先花大量时间梳理合同状态流转规则,再反向设计表结构和接口,整个过程虽然慢,但后面写代码的时候方向非常清晰,几乎没有返工。我在实际编码时也会有意参考若依这类框架的组织方式,但它庞大的菜单权限体系对普通项目有点过度设计,我更倾向于自己按需裁剪。如果后续你还想扩展,可以在合同审批中接入Flowable工作流引擎,或者引入MQ做到期提醒的异步通知,也可以在报表模块引入专业报表服务器做复杂图表。这些方向我都验证过可行,等项目跑量以后,值得一步步加上去。
