1. 先拆需求:合同管理系统到底在管什么
说实话,看到"可盈保险合同管理系统"这个项目名,很多人的第一反应是"又是一个CRUD Demo"。但真正在保险公司或者金融业务里待过的人都知道,合同管理从来不是简单的增删改查。我见过不少团队把合同管理系统做成了"Excel的网页版",最后业务部门用了一周就骂着换回去了。
这个项目的价值在于它抓住了合同业务的三个核心痛点:合同的完整性、审批的可追溯性、以及业务数据的可统计性。保险合同尤其特殊——它涉及投保人信息、险种条款、保费金额、生效日期、终止日期等多个强业务字段,中间还穿插着核保、缴费、理赔等流程节点。如果只做一个松散的记录表,那这个系统上线等于没上。
从我自己的实操经验来看,设计这类系统前,第一件事不是写代码,而是把业务链路画清楚。保险合同的典型生命周期包括:
- 新契约录入:录入投保人和被保险人信息,选择险种和保额
- 核保审批:业务员提交后,由核保人审核保单内容是否合规
- 合同生成:审批通过后生成正式保单,并附上条款文件
- 归档与查询:保单电子化归档,支持按客户、时间、险种检索
- 续保与到期管理:到期前提醒,支持续保操作生成新合同
- 理赔关联:出险时能快速调出对应合同作为依据
这六条链路对应到系统功能上,就是合同CRUD之外的那部分"含金量"。也正因为有审批流、状态流转和文件归档,这个项目才值得用SpringBoot+Vue3这样的前后端分离架构来做,而不是用一个单体模板直接渲染页面。
明白了业务边界,再来谈技术选型就顺畅多了。SpringBoot负责后端的接口服务和业务逻辑,MyBatis负责SQL层面的灵活控制,Vue3负责前端的交互和状态管理,MySQL作为最终的数据落点,这是目前国内中小型管理系统里最成熟、招聘市场上最通用的一套组合。下面我按照项目实际开发的顺序,把这些核心点全部过一遍。
2. 后端SpringBoot+MyBatis:模块划分和事务边界
2.1 项目初始化时最容易犯的错误
SpringBoot的初始化本身不难,但很多人一上来就用Spring Initializr把依赖全部勾上,结果项目没写几行业务代码,启动倒是报出一堆莫名其妙的错。这个项目实际只需要四个核心依赖:Spring Web、MyBatis Starter、MySQL Driver、Lombok。如果需要后续做权限,再引入Spring Security或者Sa-Token。
我用Sa-Token的次数比Spring Security多。不是说Spring Security不好,而是对于这种以后台管理为主的系统,Sa-Token的配置量小一个数量级,登录认证、权限注解、Token续期都是开箱即用。Spring Security的过滤器链对于新手来说,光理解SecurityContextHolder就能卡掉三天时间。
依赖选好之后,目录结构按功能分包,不要按技术分包。这是我见过最影响后期维护的问题。按技术分包长这样:controller包下全是各种Controller,service包下全是各种Service,mapper包下全是各种Mapper。按功能分包长这样:
com.keying.insurance ├── common // 通用类:返回结果、异常处理、工具类 ├── config // 配置类:跨域、拦截器、文件上传 ├── controller // 控制层 ├── service // 业务层 ├── mapper // 数据访问层 ├── entity // 实体类 ├── dto // 接收前端参数的DTO ├── vo // 返回前端的视图对象 └── security // 登录、权限相关按功能分包的意义在于:当你想找"合同审批"这块逻辑时,从controller到service到mapper三层放在不同的包里也能找,但你要通过文件名猜测它属于哪个模块。按功能包分好,业务归属一目了然。
2.2 MyBatis的真正用法:不要全写XML,也不要全写注解
MyBatis的使用上,我看到两类极端的人。一类是Mapper接口里全是注解SQL,文章超过三行就挤成一团;另一类是宁可写二十行XML也不肯在注解里写一个简单的@Select。
我的习惯是:单表简单查询用注解,多表关联和动态SQL用XML。比如合同查询这种带条件拼接的查询,用注解写动态SQL简直是一场灾难——<script>标签包着<if>写在注解里,既没有缩进也没有高亮,错一个括号找半天。放到XML里就清晰得多。
举一个实际查询的例子。合同列表的筛选条件可能有:合同编号、客户姓名、险种类型、合同状态、创建时间范围,每个条件都是可选的。XML里的写法长这样:
<select id="selectContractList" resultType="com.keying.insurance.vo.ContractVO"> SELECT c.id, c.contract_no, c.customer_name, c.insurance_type, c.contract_status, c.premium_amount, c.sign_date, c.effective_date, c.expire_date, c.create_time FROM contract c <where> <if test="contractNo != null and contractNo != ''"> AND c.contract_no LIKE CONCAT('%', #{contractNo}, '%') </if> <if test="customerName != null and customerName != ''"> AND c.customer_name LIKE CONCAT('%', #{customerName}, '%') </if> <if test="insuranceType != null and insuranceType != ''"> AND c.insurance_type = #{insuranceType} </if> <if test="contractStatus != null and contractStatus != ''"> AND c.contract_status = #{contractStatus} </if> <if test="startDate != null"> AND c.create_time >= #{startDate} </if> <if test="endDate != null"> AND c.create_time <= #{endDate} </if> </where> ORDER BY c.create_time DESC </select>用<where>标签是为了自动处理第一个条件前面的AND,这是MyBatis做得比较贴心的一个地方,比在Java代码里拼SQL干净太多。
分页方面,这个项目用的是MyBatis的分页插件PageHelper。用法就是查询前调用PageHelper.startPage(pageNum, pageSize),然后紧接着执行下一条查询,PageHelper会通过拦截器自动拼接LIMIT语句,并把总记录数放进一个Page对象里。
PageHelper.startPage(pageNum, pageSize); List<ContractVO> contractList = contractMapper.selectContractList(queryDTO); PageInfo<ContractVO> pageInfo = new PageInfo<>(contractList);需要注意两点。第一,startPage只对紧接着的下一条查询生效,所以中间千万不要插入其他Mapper查询,否则分页会作用到错误的SQL上。第二,查询结果要封装成PageInfo,因为PageInfo里带了total、pageNum、pageSize、pages等分页元数据,前端分页组件拿到这些字段就能直接渲染。
2.3 合同审批的事务边界:状态流转的原子性
保险合同的审批是一个典型的需要事务控制的场景。业务员提交合同草稿,主管审批通过(或驳回),每一步操作都会改变合同状态。这个过程中涉及两件事:更新合同主表的状态字段,以及写入一条审批记录。
如果这两步之间没有事务保护,就会出现合同状态已经变成"已通过",但审批记录没有写进去(或者反过来)的脏数据。这在合同管理里属于严重事故,因为后续理赔、续保都会引用审批结果。
所以我实际写代码时,会把审批逻辑抽成一个带@Transactional注解的服务方法:
@Transactional(rollbackFor = Exception.class) public void approveContract(Long contractId, String approver, String comment) { // 1. 查询合同并校验当前状态 Contract contract = contractMapper.selectById(contractId); if (contract == null) { throw new BusinessException("合同不存在"); } if (!"待审批".equals(contract.getContractStatus())) { throw new BusinessException("当前状态不允许审批操作"); } // 2. 更新合同状态 contract.setContractStatus("已通过"); contract.setApprover(approver); contract.setApproveTime(new Date()); contractMapper.updateById(contract); // 3. 写入审批记录 ApproveRecord record = new ApproveRecord(); record.setContractId(contractId); record.setApprover(approver); record.setAction("通过"); record.setComment(comment); record.setCreateTime(new Date()); approveRecordMapper.insert(record); }rollbackFor = Exception.class必须写。Spring默认只回滚RuntimeException,如果业务代码里抛出的是自定义的CheckedException,不加这个参数事务不会回滚,数据就悄悄写进去了。
那状态判断为什么要用字符串写死在代码里而不是用数字?我承认数字存储更省空间,但可读性太差。合同状态这个字段,建议用字符串配合枚举定义,Java类里定义一个枚举,数据库里存枚举的code,查询时再映射成中文描述给前端。这样既保证了代码可读性,又避免了数据库里出现"0、1、2"还需要翻设计文档才知道什么意思的尴尬。
事务还有一个容易被忽略的点:文件上传和数据库操作不能放在同一个事务里。保险合同必然要上传PDF保单文件,如果流程是先上传文件再写合同记录,事务回滚时文件已经传到服务器上了,就会产生孤儿文件。我的做法是:先写数据库记录拿到合同ID,返回给前端后,前端再根据合同ID单独调用上传接口,上传完成后更新文件的存储路径字段。这样文件上传不占用事务,上传失败也不影响合同核心数据。
3. Vue3前端:组合式API、路由守卫和Pinia状态管理
3.1 前端工程结构怎么搭才顺手
Vue3前端这个项目用的是Vite作为构建工具,比Webpack快非常多,启动项目基本是秒开。初始化的命令很简单:
npm create vite@latest contract-web -- --template vue创建完成后装核心依赖:
npm install vue-router@4 pinia element-plus axiosElement Plus是这套管理系统的主力UI组件库,表格、表单、弹窗、消息提示都非常齐全,不用自己造轮子。
前端的目录结构,我建议按"视图+组件+状态"来分:
src ├── api // 所有接口请求封装 ├── assets // 静态资源 ├── components // 公共组件 ├── router // 路由配置 ├── stores // Pinia状态管理 ├── views // 页面组件 │ ├── login │ ├── dashboard │ ├── contract │ ├── customer │ └── system ├── utils // 工具函数:请求封装、日期格式化等 └── App.vueapi目录里每个模块一个文件,比如contract.js里放所有合同相关的接口调用。这样做的好处是页面组件里不会散落一堆axios.get,接口路径修改时只需要改一个文件。
3.2 组合式API比选项式好在哪里
Vue3默认推荐使用组合式API(<script setup>),这个项目的代码也是这么写的。和Vue2时代的选项式API相比,最大的区别是:相关逻辑放在一起,而不是分散在不同的选项里。
举个例子,合同列表页面需要做的事情有:加载列表数据、处理筛选条件、处理分页变化、删除合同。选项式API把这些逻辑拆成data、methods、computed三块,如果页面再复杂一点,还会加上watch和mounted。当你需要理解"筛选"这个功能时,得同时看五个地方。
组合式API的写法是把同一个功能的代码集中在一起:
<script setup> import { ref, onMounted } from 'vue' import { getContractList, deleteContract } from '@/api/contract' const loading = ref(false) const contractList = ref([]) const total = ref(0) const queryParams = ref({ pageNum: 1, pageSize: 10, contractNo: '', customerName: '', contractStatus: '' }) const loadContractList = async () => { loading.value = true try { const res = await getContractList(queryParams.value) contractList.value = res.data.list total.value = res.data.total } finally { loading.value = false } } const handleSearch = () => { queryParams.value.pageNum = 1 loadContractList() } const handleDelete = async (id) => { await deleteContract(id) loadContractList() } onMounted(() => { loadContractList() }) </script>这段代码的核心逻辑清晰且集中:数据定义、加载方法、搜索和删除,都围绕同一个业务场景展开。对于合同列表这种不算特别复杂但也绝不简单的页面,组合式API的维护成本明显更低。
组合式API的另一个好处是逻辑复用。比如"获取当前登录用户"这个逻辑,如果多个页面都需要,可以抽成一个useCurrentUser()函数,任何组件里直接调用即可。对比选项式API的mixin混入,组合式函数在命名冲突和数据来源可追溯性上都要好得多。
3.3 路由守卫和权限控制的前端部分
前后端分离项目里,前端路由守卫解决的是"未登录用户不能进入系统页面"和"已登录用户不能访问无权限页面"这两个问题。
用Vue Router的beforeEach全局前置守卫实现:
router.beforeEach((to, from, next) => { const token = localStorage.getItem('token') if (to.path === '/login') { next() return } if (!token) { next('/login') return } next() })这只解决了"有没有登录"的问题,还没有解决"登录了但没权限"的问题。菜单和按钮级别的权限通常通过后端返回的权限列表来控制。后端在登录成功后返回一个权限标识数组(比如['contract:add', 'contract:approve']),前端把它存进Pinia,在需要控制的地方用v-if判断。
我觉得这一层用指令封装会更优雅一些。定义一个自定义指令v-permission:
app.directive('permission', { mounted(el, binding) { const requiredPermission = binding.value const userPermissions = useUserStore().permissions if (!userPermissions.includes(requiredPermission)) { el.parentNode?.removeChild(el) } } })模板里直接这样用:
<el-button v-permission="'contract:approve'" type="primary">审批</el-button>这样没有审批权限的用户,看不到审批按钮,而不是点了才提示"无权限"。体验上的差异非常明显,属于典型的"细节见真章"。
3.4 Pinia比Vuex简单在哪
Vuex和Pinia的区别,我用一句话概括:Pinia去掉了Vuex里所有繁琐的样板代码。Vuex里要定义state、mutations、actions、getters,还要注意mutations必须同步,actions才能处理异步。Pinia里所有逻辑都平铺在一个defineStore里,异步直接写普通async函数,没有那些限制。
登录状态和用户信息的存储是一个典型场景:
import { defineStore } from 'pinia' export const useUserStore = defineStore('user', { state: () => ({ token: localStorage.getItem('token') || '', userInfo: null, permissions: [] }), actions: { setToken(token) { this.token = token localStorage.setItem('token', token) }, setUserInfo(info) { this.userInfo = info }, setPermissions(perms) { this.permissions = perms }, logout() { this.token = '' this.userInfo = null this.permissions = [] localStorage.removeItem('token') } } })在axios的响应拦截器里,遇到401状态码就调用logout()并跳转登录页,这是前后端分离项目里处理登录过期的主流方案。
我特别要提醒一点:不要把用户身份信息全部放在localStorage里。localStorage是明文存储,XSS攻击时可以直接被读取。前端只存放token,用户详情和权限列表在需要时通过接口获取,或者放在Pinia内存状态里,刷新页面后重新拉取。
4. MySQL数据库设计:合同业务的数据根基
4.1 核心表结构设计思路
合同管理系统的数据库设计,和业务需求是强绑定的。基于第一节拆解的六条业务链路,核心表我设计了以下七张:
| 表名 | 用途 | 关键字段 |
|---|---|---|
| sys_user | 系统用户表 | id, username, password, real_name, phone, status |
| sys_role | 角色表 | id, role_name, role_code, description |
| sys_user_role | 用户角色关联表 | user_id, role_id |
| insurance_contract | 保险合同主表 | contract_no, customer_name, customer_phone, insurance_type, premium_amount, sign_date, effective_date, expire_date, contract_status, file_url |
| insurance_customer | 客户信息表 | customer_name, id_card, phone, address, risk_level |
| approve_record | 审批记录表 | id, contract_id, approver, action, comment, create_time |
| sys_operation_log | 操作日志表 | id, user_id, operation, module, ip, create_time |
客户信息单独建表而不是直接冗余在合同里,这是设计上一个很重要的决定。一个人在保险公司可能有多份合同(不同险种、不同时间),如果把客户信息冗余到合同表里,一个客户改了联系方式,要同步所有历史合同,极度容易出错。客户单独建表,合同表通过customer_id关联即可。
那为什么合同表里又有customer_name和customer_phone这两个冗余字段?因为在合同列表页要展示客户信息,合同查询又往往是高频操作。每次查询都去JOIN客户表,数据量上来之后会明显变慢。把常用字段冗余到合同表,查询时单表搞定,更新时通过客户管理功能去同步。这是典型的"用空间换时间"思想,在业务系统里非常实用。
4.2 合同状态字段用枚举还是数字
合同状态我强烈推荐用字符串枚举存varchar类型,值是"草稿、待审批、已通过、已驳回、已失效"。直接的原因就是可读性。你写WHERE contract_status = 'PENDING'和WHERE contract_status = 2,三年后维护系统的人绝对会感谢你选了前者。
很多人担心的"字符串浪费存储空间"在业务系统里根本不是问题。一张合同表开个几十万行,每个字段多个十几个字节,总占用也就几十MB,现在云数据库动辄几百GB的空间,完全不需要在意这点开销。
布尔字段比如"是否删除",用tinyint(1),值是0或1,MyBatis里映射成Boolean类型。
金额字段一定要用decimal类型,千万不能用double。double有精度问题,0.1+0.2算出来是0.30000000000000004,做保费统计时结果会有偏差,这是金融项目的大忌。保费金额用decimal(12, 2),意思是最多10位整数加2位小数,已经能覆盖绝大多数保险业务场景了。
日期字段上,双方签署日期和生效日期用date类型,创建时间用datetime类型。因为创建时间需要精确到时分秒,而签署日期只需要年月日就够。
唯一索引一定要加。合同编号contract_no是自然主键之外的业务主键,前端可以通过它精确查找一份合同。在contract_no上加唯一索引,一是查询时走索引更快,二是防止并发情况下产生重复合同编号。我踩过一次这个坑,并发提交时两张合同拿到一模一样的编号,后面做统计分析时对不上账,排查了整整一下午。
4.3 分页查询的SQL优化思路
合同列表页的数据量上来之后,分页查询全表扫描会越来越慢。不要等出了性能问题再优化,设计阶段就要把索引规划好。
查询条件里常用的字段是:contract_no、customer_name、insurance_type、contract_status、create_time。这几个字段都适合建索引。
ALTER TABLE insurance_contract ADD INDEX idx_contract_no (contract_no); ALTER TABLE insurance_contract ADD INDEX idx_customer_name (customer_name); ALTER TABLE insurance_contract ADD INDEX idx_contract_status (contract_status); ALTER TABLE insurance_contract ADD INDEX idx_create_time (create_time);组合索引要谨慎。如果查询条件经常是"合同状态+创建时间"同时出现,可以建idx_status_time (contract_status, create_time)。组合索引遵循最左前缀原则,只有最左边的字段出现在查询条件中,索引才会生效。
还有一个很隐蔽的性能问题:深分页。当用户翻到第1000页时,LIMIT 10000, 10会扫描前10000行然后丢弃,效率很低。一个常见的优化方案是:先查主键,再关联查询:
SELECT c.* FROM insurance_contract c INNER JOIN ( SELECT id FROM insurance_contract ORDER BY create_time DESC LIMIT 10000, 10 ) t ON c.id = t.id这个方案在数据量达到几十万行时效果非常明显。不过对于合同系统来说,如果用户真的需要翻到第1000页看数据,说明他的查询条件太宽泛了,更实际的做法是引导用户缩小筛选范围。
4.4 数据统计:月度保费和合同数量的SQL写法
业务方总是要看各种报表。月度新增合同数量、月度保费总额、按险种分布的占比,这些小报表不需要用专门的大数据组件,几条SQL就能搞定。
月度统计的SQL:
SELECT DATE_FORMAT(sign_date, '%Y-%m') AS month, COUNT(*) AS contract_count, SUM(premium_amount) AS total_premium FROM insurance_contract WHERE sign_date >= '2024-01-01' AND contract_status IN ('已通过') GROUP BY DATE_FORMAT(sign_date, '%Y-%m') ORDER BY month DESC按险种分组的SQL:
SELECT insurance_type, COUNT(*) AS contract_count, SUM(premium_amount) AS total_premium FROM insurance_contract WHERE sign_date BETWEEN '2024-01-01' AND '2024-12-31' AND contract_status = '已通过' GROUP BY insurance_type ORDER BY total_premium DESCDATE_FORMAT函数把日期格式化成"年-月"字符串再分组,这是MySQL里非常常用的时间维度统计方式。注意WHERE条件里要使用原始字段sign_date做范围过滤,而不是在WHERE里对sign_date调用函数,例如DATE_FORMAT(sign_date, '%Y-%m') = '2024-01',这样索引会失效,查询速度会大幅下降。
5. 前后端联调:能预判到的意外至少有这五个
5.1 跨域问题:开发环境怎么配
前后端分离项目一启动,第一个遇到的就是跨域。前端跑在5173端口,后端跑在8080端口,两个端口不同,浏览器就会拦截跨域请求。
开发环境最省事的方案是前端Vite配置代理:
// vite.config.js export default defineConfig({ server: { port: 5173, proxy: { '/api': { target: 'http://localhost:8080', changeOrigin: true } } } })前端请求/api/contract/list时,Vite会把请求转发到http://localhost:8080/api/contract/list。这样浏览器看到的请求是同源的(都是5173端口),不触发跨域拦截。changeOrigin: true的意义在于把请求头里的Host字段改成目标地址的,后端日志里看到的是后端真实的访问地址。
生产环境不能靠前端代理,因为前端打包后是静态文件,没有代理能力。生产环境的跨域由后端解决,SpringBoot里配置一个CorsFilter:
@Configuration public class CorsConfig { @Bean public CorsFilter corsFilter() { CorsConfiguration config = new CorsConfiguration(); config.addAllowedOriginPattern("*"); config.addAllowedMethod("*"); config.addAllowedHeader("*"); config.setAllowCredentials(true); UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource(); source.registerCorsConfiguration("/**", config); return new CorsFilter(source); } }allowedOriginPattern("*")配合allowCredentials(true)是必须的组合。早期版本用addAllowedOrigin("*")配allowCredentials(true)会被浏览器拒绝,因为*通配符和携带Cookie的凭证请求不兼容。如果你在Nginx里配置了反向代理,也可以让所有/api开头的请求都由Nginx转发到后端,这样前后端同源,跨域问题彻底消失。
无论哪种方案,开发环境用Vite代理,生产环境用Nginx反向代理或后端CorsFilter。不要为了省事生产环境也开前端那种代理思路,坑很多。
5.2 日期格式序列化:显示差8小时和"2024-01-01T00:00:00.000+08:00"
前后端联调时关于日期的问题,我每次都会被问到。典型问题有两个:一是后端返回的日期变成了带T的ISO格式2024-01-01T00:00:00.000+08:00,前端展示出来很难看;二是时间差8小时,明明数据库里存的是14点,页面显示6点。
第一个问题是Jackson序列化默认格式导致的。在SpringBoot里全局配置一下即可:
spring: jackson: date-format: yyyy-MM-dd HH:mm:ss time-zone: GMT+8date-format控制了LocalDateTime和Date类型的序列化格式,time-zone保证序列化时使用东八区而不是服务器默认时区。
第二个问题通常是数据库连接串里没有指定时区导致的。MySQL的JDBC连接URL一定要加serverTimezone=Asia/Shanghai:
jdbc:mysql://localhost:3306/insurance_db?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai不加这个参数,如果MySQL服务器的时区是UTC(Docker容器经常默认UTC),Java取得的时间就会少8小时。
数据库连接串是部署阶段最容易踩的时区坑,我建议在数据库配置阶段就把它写好,别等到数据对接出问题再去翻配置。
5.3 接口统一返回格式:前端解析不迷路
前后端联调最怕的就是每个接口返回的数据格式都不一样。这个后端要提供一个统一响应包装类:
public class Result<T> { private Integer code; private String message; private T data; public static <T> Result<T> success(T data) { Result<T> result = new Result<>(); result.setCode(200); result.setMessage("操作成功"); result.setData(data); return result; } public static <T> Result<T> error(String message) { Result<T> result = new Result<>(); result.setCode(500); result.setMessage(message); return result; } }加上全局异常处理器,把业务异常、参数校验异常、未知异常都统一转换成Result格式返回:
@RestControllerAdvice public class GlobalExceptionHandler { @ExceptionHandler(BusinessException.class) public Result<?> handleBusinessException(BusinessException e) { return Result.error(e.getMessage()); } @ExceptionHandler(MethodArgumentNotValidException.class) public Result<?> handleValidException(MethodArgumentNotValidException e) { String msg = e.getBindingResult().getFieldErrors().stream() .map(FieldError::getDefaultMessage) .collect(Collectors.joining("; ")); return Result.error(msg); } }这样前端axios封装里就可以统一拦截:code为200时返回data给页面使用,code非200时弹出错误提示。前端代码里不需要每个接口都写一遍错误处理逻辑。
有一个细节要注意:登录接口的code约定要特殊处理。如果token过期,后端返回401状态码,axios响应拦截器里要识别这个code并跳转登录页;如果业务校验失败返回500,就不能跳登录页,只弹出错误信息。很多项目把这两个场景混在一起,用户token过期提示却是"操作失败"而不是"请重新登录",体验很差。
5.4 文件上传:PDF保单文件怎么存
保险合同必然涉及PDF文件的归档和下载。项目里的实现思路是这样的:
前端用Element Plus的上传组件el-upload,指定action指向后端的/api/file/upload接口:
<el-upload action="/api/file/upload" :headers="uploadHeaders" :on-success="handleUploadSuccess" :limit="1" accept=".pdf"> <el-button>上传PDF文件</el-button> </el-upload>uploadHeaders一定不能少,因为文件上传一样要走认证鉴权,需要携带token:
const uploadHeaders = { Authorization: 'Bearer ' + localStorage.getItem('token') }后端的文件上传接口:
@PostMapping("/api/file/upload") public Result<String> upload(@RequestParam("file") MultipartFile file) { if (file.isEmpty()) { throw new BusinessException("上传文件不能为空"); } // 判断文件类型 String originalFilename = file.getOriginalFilename(); if (!originalFilename.endsWith(".pdf")) { throw new BusinessException("只能上传PDF文件"); } // 生成唯一的存储文件名 String fileName = UUID.randomUUID().toString().replace("-", "") + ".pdf"; // 按日期分目录存储 String datePath = new SimpleDateFormat("yyyy/MM/dd").format(new Date()); String filePath = "/upload/" + datePath + "/" + fileName; File dest = new File(uploadDir + filePath); if (!dest.getParentFile().exists()) { dest.getParentFile().mkdirs(); } file.transferTo(dest); return Result.success(filePath); }两个关键点:第一,存储文件名要用UUID重命名,不要直接用用户上传的原始文件名,否则会存在路径穿越攻击的风险(比如文件名里包含../);第二,按日期分目录存储,避免单个目录下文件过多导致文件系统性能下降。
文件上传完成后,把返回的文件路径存储到合同记录里的file_url字段。下载时,前端拿到的是存储的相对路径,通过后端的下载接口转为绝对路径提供给前端。
5.5 登录鉴权:Token方案背后的完整闭环
Sa-Token的登录逻辑相比Spring Security简单很多,核心就三步:
// 登录成功 StpUtil.login(userId); // 获取token,可以存入返回结果 String tokenValue = StpUtil.getTokenValue(); // 后续请求通过拦截器自动校验Sa-Token默认会把token存储在后端内存中,通过请求头satoken: <token值>传递。但前端项目统一用Authorization: Bearer <token>更常见,就需要配置token名称:
sa-token: token-name: Authorization token-prefix: Bearer配置完成后,Sa-Token的拦截器会从请求头里读取Authorization: Bearer xxxxx,自动校验token的有效性。配置一个拦截器注册类:
@Configuration public class SaTokenConfig implements WebMvcConfigurer { @Override public void addInterceptors(InterceptorRegistry registry) { registry.addInterceptor(new SaInterceptor(handle -> StpUtil.checkLogin())) .addPathPatterns("/**") .excludePathPatterns("/api/user/login", "/api/file/upload/**不是登录就能访问的路径考虑放行"); } }这里有一个我在实际项目中反复强调的细节:/api/file/upload这个路径不要放在拦截器放行清单里。文件上传接口本身已经通过token校验了,不需要放行。有些项目图省事把所有上传类路径都放行了,等于给攻击者开了一个免费的文件写入通道。文件上传接口本身已经通过token校验了,不需要放行。文件上传接口本身已经通过token校验了,不需要放行(此处笔误,纠正:文件上传接口依赖token校验,不应该放行)。
然后就没有然后了。Sa-Token的checkLogin()会在token无效时自动抛出异常,配合全局异常处理器,返回"未登录"的提示。权限控制再加一个@SaCheckPermission("contract:approve")注解,就能实现细粒度的操作鉴权。
6. 打通业务闭环:从合同录入到审批的完整流程演示
6.1 前端表单如何和后端DTO对齐
保险合同的录入页面,字段非常多:客户姓名、身份证号、联系电话、险种类型、保费金额、签署日期、生效日期、失效日期,可能还有可选的投资类型、受益人信息。前端把这些字段拆成几个区块(客户信息、合同信息、险种信息),每个区块对应一个el-form。
表单提交时,前端把整个对象发给后端:
{ "customerId": 1001, "contractNo": "HT20240001", "insuranceType": "寿险", "premiumAmount": 6800.00, "signDate": "2024-06-01", "effectiveDate": "2024-06-15", "expireDate": "2025-06-14", "remark": "标准寿险产品,无附加条款" }后端接收时用一个DTO类,而不是直接接收实体类:
@Data public class ContractCreateDTO { @NotNull(message = "客户ID不能为空") private Long customerId; @NotBlank(message = "合同编号不能为空") @Size(max = 32, message = "合同编号长度不能超过32") private String contractNo; @NotBlank(message = "险种类型不能为空") private String insuranceType; @NotNull(message = "保费金额不能为空") @DecimalMin(value = "0.01", message = "保费金额必须大于0") private BigDecimal premiumAmount; @NotNull(message = "签署日期不能为空") @JsonFormat(pattern = "yyyy-MM-dd") private Date signDate; }使用DTO而不是直接用实体类的核心原因是:接口参数和数据库表结构解耦。前端传来的字段可能比表字段少(比如不传创建时间和创建人),也可能比表字段多(比如带确认密码或者额外备注)。把DTO和Entity分开,接口层可以灵活控制哪些字段可以接收,避免前端传了意料之外的字段覆盖了不该改的数据。
@NotBlank、@NotNull这些JSR-303校验注解在SpringBoot里默认开启,校验失败时抛出的异常会被全局异常处理器捕获,返回统一的错误信息格式。前端拿到错误信息直接展示,省去了一堆手动判断的代码。
6.2 审批流程的状态机设计
审批功能如果做得顺手,整个系统用起来就有重度系统的质感;如果做成"攒一个按钮改个状态",那和Excel没区别。
我刚才在2.3节讲了事务性,这里补充状态机的完整设计。合同状态流转用一张状态机表来梳理:
| 当前状态 | 可执行操作 | 目标状态 | 所需权限 |
|---|---|---|---|
| 草稿 | 提交审批 | 待审批 | 业务员 |
| 待审批 | 通过 | 已通过 | 核保人 |
| 待审批 | 驳回 | 已驳回 | 核保人 |
| 已驳回 | 提交审批 | 待审批 | 业务员 |
| 已通过 | 终止合同 | 已失效 | 管理员 |
这么一梳理,后端的操作逻辑就异常清晰了。每个操作都是"校验当前状态+校验当前用户权限+执行状态变更+写入记录"四步走,任何一步校验失败都直接抛出业务异常,事务回滚。
状态机的价值在于:它把业务规则显式化、集中化。新来的开发只要看这张表,就知道系统里有哪些状态、每个状态可以怎么流转,不需要去翻代码里散落的if else。这也是项目后期维护最重要的资产之一。
6.3 操作日志:不留痕的系统,上线心里没底
合同管理系统里,每一次创建、编辑、审批、下载,都应该留下操作日志。这里单独建了一张操作日志表,通过AOP切面统一处理。
为了不过度侵入业务代码,我推荐用Spring AOP的注解方式。定义一个@OperationLog注解:
@Target(ElementType.METHOD) @Retention(RetentionPolicy.RUNTIME) public @interface OperationLog { String module() default ""; String action() default ""; }然后在需要记录日志的接口方法上标注注解:
@OperationLog(module = "合同管理", action = "审批通过") @PostMapping("/api/contract/approve") public Result<?> approve(@RequestBody ApproveDTO dto) { contractService.approveContract(dto); return Result.success(null); }切面里统一记录日志:
@Aspect @Component public class OperationLogAspect { @Around("@annotation(operationLog)") public Object around(ProceedingJoinPoint joinPoint, OperationLog operationLog) throws Throwable { // 获取当前登录用户 StpUser user = (StpUser) StpUtil.getSession().get("user"); // 请求参数 Object[] args = joinPoint.getArgs(); // 执行方法前记录操作人、操作模块、操作动作、请求参数 Object result = joinPoint.proceed(); // 执行成功后记录结果 return result; } }操作日志表字段包括:user_id、operation、module、method、params、ip、create_time。后续如果审计需求升级,再单独把日志做异步落库都来得及。但前提是系统从第一天起就在记录,这就避免了后期追溯历史数据时一片空白的困境。
7. 部署和上线:从开发机到服务器
7.1 后端打包
SpringBoot项目打包成可执行JAR,Maven的package就能搞定。但是有几个细节要注意。
打包前把配置文件按照环境分好,推荐用Spring Boot的多环境配置:
# application.yml spring: profiles: active: @profile.active@然后在pom.xml里配置profile:
<profiles> <profile> <id>dev</id> <properties><profile.active>dev</profile.active></properties> <activation><activeByDefault>true</activeByDefault></activation> </profile> <profile> <id>prod</id> <properties><profile.active>prod</profile.active></properties> </profile> </profiles>打包生产环境版本的命令是:
mvn clean package -Pprod打包完成后,在服务器上运行:
java -jar insurance-system.jar --spring.profiles.active=prod生产环境的数据库连接、上传目录路径、服务器地址都写在application-prod.yml里,开发环境的写在application-dev.yml里。这样从开发到部署,不用改代码,不用改配置,只用换启动参数。
7.2 前端打包
Vue3项目打包就一条命令:
npm run build打包产物在dist目录下,把这些静态文件上传到Nginx的网站根目录。Nginx配置里需要处理两件事:
第一,history路由模式下的刷新404问题。Vue Router默认是history模式,URL里没有#号。但如果用户直接在浏览器里访问/contract/list,Nginx找不到这个路径对应的文件,会返回404。解决办法是让所有请求都回退到index.html:
location / { try_files $uri $uri/ /index.html; }第二,API请求反向代理到后端。Nginx配置:
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; }try_files和proxy_pass这两个配置,是前端部署到Nginx的两个核心支柱。搞定了它们,前后端分离的生产部署就顺畅了。
7.3 启动脚本和一键部署
服务器上的操作,我一般会写一个部署脚本,避免每次上线都要手打一串命令:
#!/bin/bash # 部署后端 APP_NAME=insurance-system.jar APP_PATH=/opt/insurance/backend cd $APP_PATH # 停掉旧进程 PID=$(ps -ef | grep $APP_NAME | grep -v grep | awk '{print $2}') if [ -n "$PID" ]; then kill -9 $PID echo "旧进程已停止: $PID" fi # 备份旧包 if [ -f "$APP_NAME" ]; then mv $APP_NAME $APP_NAME.bak.$(date +%Y%m%d%H%M%S) fi # 上传新包后启动 nohup java -jar $APP_NAME --spring.profiles.active=prod > logs/app.log 2>&1 & echo "应用已启动"这个脚本配合CI/CD工具(Jenkins、GitLab CI都可以),基本能做到"推送代码到仓库,服务器自动构建部署"。中小项目有这个脚本,就已经够用了,不必一开始就上K8s那一套重型设施。
8. 从这套代码里还能学到什么
做完了整个合同管理系统,你会发现它虽然是一个业务项目,但覆盖的知识面其实非常广:前后端分离架构、数据库建模、事务管理、文件上传、登录鉴权、状态机、操作日志、部署上线。这些都是Java开发日常工作中的核心技能。
如果看完这篇文章想自己动手写一套,我建议按这个顺序来:
- 第一步:把数据库表建好,写后端基础的增删改查接口
- 第二步:把前端的列表页和表单页跑通,实现基本的数据展示和录入
- 第三步:加入登录和权限控制,保护核心接口
- 第四步:补上审批流、文件上传、操作日志这些业务功能
- 第五步:优化查询性能和代码结构,打包部署上线
这五步每一步都有独立的完成感,而且每一步的技术点都能迁移到其他项目里。这个项目的源代码结构是完整的,理论上可以直接作为毕业设计或者求职项目的基础来做二次开发。
最后说一个实操里的体会:合同管理系统的难点不在于代码本身,而在于你必须把保险业务的规则理解透。"合同状态怎么流转""哪些数据需要必填校验""哪些操作需要记录日志",这些问题在动手写代码之前就必须有明确答案。我在做完第一版之后发现,真正让系统变得好用的,不是用了多先进的技术,而是那些被仔细定义过的状态流转规则。这些规则让整个系统有了一条清晰的灵魂主线,所有代码都在为这条主线服务。