SpringBoot+Vue3构建合同管理系统:前后端分离架构与权限控制实战
1. 先拆需求合同管理系统到底在管什么说实话看到可盈保险合同管理系统这个项目名很多人的第一反应是又是一个CRUD Demo。但真正在保险公司或者金融业务里待过的人都知道合同管理从来不是简单的增删改查。我见过不少团队把合同管理系统做成了Excel的网页版最后业务部门用了一周就骂着换回去了。这个项目的价值在于它抓住了合同业务的三个核心痛点合同的完整性、审批的可追溯性、以及业务数据的可统计性。保险合同尤其特殊——它涉及投保人信息、险种条款、保费金额、生效日期、终止日期等多个强业务字段中间还穿插着核保、缴费、理赔等流程节点。如果只做一个松散的记录表那这个系统上线等于没上。从我自己的实操经验来看设计这类系统前第一件事不是写代码而是把业务链路画清楚。保险合同的典型生命周期包括新契约录入录入投保人和被保险人信息选择险种和保额核保审批业务员提交后由核保人审核保单内容是否合规合同生成审批通过后生成正式保单并附上条款文件归档与查询保单电子化归档支持按客户、时间、险种检索续保与到期管理到期前提醒支持续保操作生成新合同理赔关联出险时能快速调出对应合同作为依据这六条链路对应到系统功能上就是合同CRUD之外的那部分含金量。也正因为有审批流、状态流转和文件归档这个项目才值得用SpringBootVue3这样的前后端分离架构来做而不是用一个单体模板直接渲染页面。明白了业务边界再来谈技术选型就顺畅多了。SpringBoot负责后端的接口服务和业务逻辑MyBatis负责SQL层面的灵活控制Vue3负责前端的交互和状态管理MySQL作为最终的数据落点这是目前国内中小型管理系统里最成熟、招聘市场上最通用的一套组合。下面我按照项目实际开发的顺序把这些核心点全部过一遍。2. 后端SpringBootMyBatis模块划分和事务边界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包下全是各种Controllerservice包下全是各种Servicemapper包下全是各种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 idselectContractList resultTypecom.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 testcontractNo ! null and contractNo ! AND c.contract_no LIKE CONCAT(%, #{contractNo}, %) /if if testcustomerName ! null and customerName ! AND c.customer_name LIKE CONCAT(%, #{customerName}, %) /if if testinsuranceType ! null and insuranceType ! AND c.insurance_type #{insuranceType} /if if testcontractStatus ! null and contractStatus ! AND c.contract_status #{contractStatus} /if if teststartDate ! null AND c.create_time gt; #{startDate} /if if testendDate ! null AND c.create_time lt; #{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); ListContractVO contractList contractMapper.selectContractList(queryDTO); PageInfoContractVO 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 vitelatest contract-web -- --template vue创建完成后装核心依赖npm install vue-router4 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默认推荐使用组合式APIscript 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-permissionapp.directive(permission, { mounted(el, binding) { const requiredPermission binding.value const userPermissions useUserStore().permissions if (!userPermissions.includes(requiredPermission)) { el.parentNode?.removeChild(el) } } })模板里直接这样用el-button v-permissioncontract:approve typeprimary审批/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, statussys_role角色表id, role_name, role_code, descriptionsys_user_role用户角色关联表user_id, role_idinsurance_contract保险合同主表contract_no, customer_name, customer_phone, insurance_type, premium_amount, sign_date, effective_date, expire_date, contract_status, file_urlinsurance_customer客户信息表customer_name, id_card, phone, address, risk_levelapprove_record审批记录表id, contract_id, approver, action, comment, create_timesys_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或1MyBatis里映射成Boolean类型。金额字段一定要用decimal类型千万不能用double。double有精度问题0.10.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就能搞定。月度统计的SQLSELECT 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按险种分组的SQLSELECT 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里配置一个CorsFilterConfiguration 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.00008:00前后端联调时关于日期的问题我每次都会被问到。典型问题有两个一是后端返回的日期变成了带T的ISO格式2024-01-01T00:00:00.00008:00前端展示出来很难看二是时间差8小时明明数据库里存的是14点页面显示6点。第一个问题是Jackson序列化默认格式导致的。在SpringBoot里全局配置一下即可spring: jackson: date-format: yyyy-MM-dd HH:mm:ss time-zone: GMT8date-format控制了LocalDateTime和Date类型的序列化格式time-zone保证序列化时使用东八区而不是服务器默认时区。第二个问题通常是数据库连接串里没有指定时区导致的。MySQL的JDBC连接URL一定要加serverTimezoneAsia/Shanghaijdbc:mysql://localhost:3306/insurance_db?useUnicodetruecharacterEncodingutf8serverTimezoneAsia/Shanghai不加这个参数如果MySQL服务器的时区是UTCDocker容器经常默认UTCJava取得的时间就会少8小时。数据库连接串是部署阶段最容易踩的时区坑我建议在数据库配置阶段就把它写好别等到数据对接出问题再去翻配置。5.3 接口统一返回格式前端解析不迷路前后端联调最怕的就是每个接口返回的数据格式都不一样。这个后端要提供一个统一响应包装类public class ResultT { private Integer code; private String message; private T data; public static T ResultT success(T data) { ResultT result new Result(); result.setCode(200); result.setMessage(操作成功); result.setData(data); return result; } public static T ResultT error(String message) { ResultT 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 :headersuploadHeaders :on-successhandleUploadSuccess :limit1 accept.pdf el-button上传PDF文件/el-button /el-uploaduploadHeaders一定不能少因为文件上传一样要走认证鉴权需要携带tokenconst uploadHeaders { Authorization: Bearer localStorage.getItem(token) }后端的文件上传接口PostMapping(/api/file/upload) public ResultString 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项目打包成可执行JARMaven的package就能搞定。但是有几个细节要注意。打包前把配置文件按照环境分好推荐用Spring Boot的多环境配置# application.yml spring: profiles: active: profile.active然后在pom.xml里配置profileprofiles profile iddev/id propertiesprofile.activedev/profile.active/properties activationactiveByDefaulttrue/activeByDefault/activation /profile profile idprod/id propertiesprofile.activeprod/profile.active/properties /profile /profiles打包生产环境版本的命令是mvn clean package -Pprod打包完成后在服务器上运行java -jar insurance-system.jar --spring.profiles.activeprod生产环境的数据库连接、上传目录路径、服务器地址都写在application-prod.yml里开发环境的写在application-dev.yml里。这样从开发到部署不用改代码不用改配置只用换启动参数。7.2 前端打包Vue3项目打包就一条命令npm run build打包产物在dist目录下把这些静态文件上传到Nginx的网站根目录。Nginx配置里需要处理两件事第一history路由模式下的刷新404问题。Vue Router默认是history模式URL里没有#号。但如果用户直接在浏览器里访问/contract/listNginx找不到这个路径对应的文件会返回404。解决办法是让所有请求都回退到index.htmllocation / { 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_NAMEinsurance-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.activeprod logs/app.log 21 echo 应用已启动这个脚本配合CI/CD工具Jenkins、GitLab CI都可以基本能做到推送代码到仓库服务器自动构建部署。中小项目有这个脚本就已经够用了不必一开始就上K8s那一套重型设施。8. 从这套代码里还能学到什么做完了整个合同管理系统你会发现它虽然是一个业务项目但覆盖的知识面其实非常广前后端分离架构、数据库建模、事务管理、文件上传、登录鉴权、状态机、操作日志、部署上线。这些都是Java开发日常工作中的核心技能。如果看完这篇文章想自己动手写一套我建议按这个顺序来第一步把数据库表建好写后端基础的增删改查接口第二步把前端的列表页和表单页跑通实现基本的数据展示和录入第三步加入登录和权限控制保护核心接口第四步补上审批流、文件上传、操作日志这些业务功能第五步优化查询性能和代码结构打包部署上线这五步每一步都有独立的完成感而且每一步的技术点都能迁移到其他项目里。这个项目的源代码结构是完整的理论上可以直接作为毕业设计或者求职项目的基础来做二次开发。最后说一个实操里的体会合同管理系统的难点不在于代码本身而在于你必须把保险业务的规则理解透。合同状态怎么流转哪些数据需要必填校验哪些操作需要记录日志这些问题在动手写代码之前就必须有明确答案。我在做完第一版之后发现真正让系统变得好用的不是用了多先进的技术而是那些被仔细定义过的状态流转规则。这些规则让整个系统有了一条清晰的灵魂主线所有代码都在为这条主线服务。