ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

Java保险理赔系统源码:Spring Boot状态机与赔付链路

Java保险理赔系统源码:Spring Boot状态机与赔付链路

简介:面向具备Java Web基础、希望理解企业级理赔业务落地的开发者及课程设计、毕业设计学习者,这份Java保险理赔系统源码围绕理赔申请提交、审核处理、状态流转到赔付等环节,展示MVC分层、Spring/Spring Boot整合、MyBatis持久化、RESTful接口、权限验证与前后端分离的典型实现。压缩包共1729个文件,约75.19MB,以js、html、css、png/gif等页面与静态资源,java、jsp、class、xml配置、sql脚本及jar依赖为主,并含readme、properties等说明与配置,目录结构便于按业务模块和技术层梳理。已有158人学习,可作为保险业务系统开发的参考案例。读者可获取较完整的理赔业务代码骨架、数据库表与SQL、页面交互资源、权限与异常日志处理思路,以及接口分层、事务管理和单元测试的实践参照,便于对照自身项目查漏补缺,理解企业级Java应用的组织方式与常见设计模式。

1. 从一张理赔台账说起:这套 Java 保险理赔系统到底管什么

去年帮一个做车险业务的朋友梳理他们的理赔台账,报案、查勘、定损、核赔、支付散在四张 Excel 里,一个案子流转到哪一步,只有经办人自己心里清楚,人一休假就断线。这套 Java 保险理赔系统源码解决的正是这类问题:把报案登记、案件受理、查勘定损、核赔审核、赔付支付串成一条带状态的数据流,谁在什么时间把案子推到了哪一步,全程留痕可查。它的功能没有铺得很开,重心压在「一个案子从进来到出去」这条主链上,权限、字典、日志这些配套模块也都有,够用。适合两类人拿去改:一类是想快速搭一套理赔业务底座的开发者,另一类是拿它做课程设计或毕设的学生——技术栈是 Spring Boot + MyBatis-Plus + MySQL 这套主流组合,本地跑起来门槛不高,二次开发也不用先去啃一堆冷门框架。

2. 技术栈与工程结构:拿到 zip 先翻这三个地方

压缩包解开之后不要急着点启动类,先花十分钟把依赖、目录、建表脚本这三样过一遍,后面调 bug 会省掉很多来回。理赔类系统和普通 CRUD 项目的差别主要在状态流转和金额计算上,所以看结构的时候要重点找「状态字段在哪维护」「金额用什么类型存」这两件事。

2.1 技术选型:为什么是 Spring Boot + MyBatis-Plus 这套组合

后端走的是标准的 Spring Boot 单体分层结构,Maven 管依赖,持久层用 MyBatis-Plus 而不是原生 MyBatis,这个选择在理赔场景里挺合理:保单、案件、用户、字典这些表绝大多数操作都是单表条件查询加分页,MyBatis-Plus 的LambdaQueryWrapper和Page能省掉大量 XML;真正复杂的多表统计报表再单独写 XML,两边不打架。登录鉴权用 JWT 做无状态会话,理赔系统角色多(报案员、查勘员、核赔员、财务),把权限塞进 token 里比维护 session 省事。前端如果打包在同一个工程里,一般是 Vue + Element UI 的静态资源放在resources/static下,前后端分离的话就单独一个目录。

<!-- pom.xml 里几个关键依赖,版本号以工程实际为准 --> <dependencies> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-web</artifactId> </dependency> <!-- 持久层用 MyBatis-Plus,单表 CRUD 基本不用写 SQL --> <dependency> <groupId>com.baomidou</groupId> <artifactId>mybatis-plus-boot-starter</artifactId> <version>3.5.3</version> </dependency> <dependency> <groupId>mysql</groupId> <artifactId>mysql-connector-java</artifactId> <scope>runtime</scope> </dependency> <!-- JWT 做无状态登录,多角色场景不依赖 session --> <dependency> <groupId>io.jsonwebtoken</groupId> <artifactId>jjwt-api</artifactId> <version>0.11.5</version> </dependency> <!-- 参数校验,报案表单字段多,@Valid 能挡掉一半脏数据 --> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-validation</artifactId> </dependency> </dependencies>

这里要留意两点。一是mybatis-plus-boot-starter的版本要和 Spring Boot 大版本对齐,2.7.x 的 Boot 配 3.5.x 的 MP 是常见组合,配错了启动时会在MybatisSqlSessionFactoryBean那里报类型不兼容。二是mysql-connector-java的 scope 是runtime,意味着编译期看不到驱动类,如果你在代码里直接Class.forName引驱动会编译不过——正常走数据源配置就不受影响。

2.2 目录结构:controller / service / mapper 三层怎么分

工程是典型的单模块分层,包名一般按com.xxx.claim打头,往下按职责切。理赔业务线不多,没必要上微服务多模块,一个模块里分清楚就行。

claim-system ├── src/main/java/com/xxx/claim │ ├── ClaimApplication.java # 启动类,@MapperScan 在这里 │ ├── common/ # 统一返回、异常、常量、工具 │ │ ├── Result.java │ │ ├── BizException.java │ │ └── GlobalExceptionHandler.java │ ├── config/ # 拦截器、跨域、MyBatis-Plus 配置 │ ├── controller/ # 只做参数接收和结果包装 │ │ ├── ClaimReportController.java │ │ ├── ClaimAuditController.java │ │ └── ClaimPaymentController.java │ ├── service/ # 业务逻辑,状态流转都在这层 │ │ └── impl/ │ ├── mapper/ # 接口 + 少量复杂 SQL │ ├── entity/ # 与表一一对应的实体 │ ├── dto/ # 入参对象,带校验注解 │ └── enums/ # ClaimStatus 这类枚举 └── src/main/resources ├── application.yml ├── mapper/ # 复杂查询的 XML └── db/claim_db.sql # 建库建表脚本,先跑这个

看目录能发现一个信号:enums包单独拎出来了,说明状态值是收敛管理的,不是散落在各处的字符串魔法值。这一点对理赔系统很关键,后面第 3 章会细讲。另外db/claim_db.sql一定要先执行,很多新手直接启动然后报「Table 'claim_db.claim_case' doesn't exist」,回头翻半天配置,其实就是漏了这一步。

2.3 数据模型:六张核心表撑起整条链路

理赔的实体关系其实很线性:一份保单可以产生多个案件,一个案件对应一次报案、可能多次查勘、一条核赔结论、一条或多条赔付流水。建表脚本里最关键的是下面这几张。

-- 案件主表,整条链路的锚点,status 是核心字段 CREATE TABLE `claim_case` ( `id` BIGINT NOT NULL AUTO_INCREMENT, `case_no` VARCHAR(32) NOT NULL COMMENT '案件号,业务唯一', `policy_id` BIGINT NOT NULL COMMENT '关联保单', `accident_time` DATETIME NOT NULL COMMENT '出险时间', `accident_type` VARCHAR(32) DEFAULT NULL COMMENT '事故类型,字典值', `claim_amount` DECIMAL(14,2) DEFAULT 0.00 COMMENT '报案金额', `paid_amount` DECIMAL(14,2) DEFAULT 0.00 COMMENT '实赔金额', `status` INT NOT NULL DEFAULT 10 COMMENT '状态码,见 ClaimStatus', `report_user_id` BIGINT NOT NULL COMMENT '报案人', `create_time` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, `update_time` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, PRIMARY KEY (`id`), UNIQUE KEY `uk_case_no` (`case_no`), -- 同一保单同一次出险只允许报一次案,靠唯一索引兜底防重 UNIQUE KEY `uk_policy_accident` (`policy_id`, `accident_time`), KEY `idx_status` (`status`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; -- 状态流水表,谁在什么时候把案子推到了哪一步,全靠它 CREATE TABLE `claim_status_log` ( `id` BIGINT NOT NULL AUTO_INCREMENT, `case_id` BIGINT NOT NULL, `from_status` INT DEFAULT NULL, `to_status` INT NOT NULL, `operator_id` BIGINT NOT NULL, `remark` VARCHAR(255) DEFAULT NULL, `create_time` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (`id`), KEY `idx_case` (`case_id`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

字段选型上有个细节值得说:金额一律用DECIMAL(14,2),不用DOUBLE。理赔金额要参与免赔额扣减、比例计算、上限截断,浮点数在这种多步运算里会出现0.009999这类误差,最后财务对账对不上,查起来是纯玄学。status用INT而不是VARCHAR,是因为状态要参与比较和流转校验,数字比字符串稳定,也方便加索引过滤。uk_policy_accident这个联合唯一索引是防重复报案的最后一道闸,业务层也会判一次,但并发下还是得靠数据库。

3. 核心链路拆解:一个理赔案件从报案到结案的完整流转

这条链路是整套源码里最值得逐行读的部分。报案只是入口,真正决定系统能不能用下去的是中间的状态管理和金额计算——状态乱了,案子会卡死在某一步推不动;金额算错了,财务那边直接拒收。

3.1 报案入口:Controller 只做校验,业务全在 Service

Controller 层保持极薄,只负责接收参数、取当前登录人、把异常往上抛,业务判断全部下沉到 Service。这样做的直接好处是后面加定时任务或者批量导入的时候,可以直接调 Service,不用去模拟一个 HTTP 请求。

@RestController @RequestMapping("/api/claim") public class ClaimReportController { @Autowired private ClaimCaseService claimCaseService; /** * 报案登记:提交保单号 + 出险信息,落一条 claim_case 主记录 * 这一步只登记,不做定损,状态推进到 REPORTED */ @PostMapping("/report") public Result<Long> report(@RequestBody @Valid ClaimReportDTO dto, HttpServletRequest request) { // 操作人从 JWT 里取,绝不信任前端传的 userId Long operatorId = JwtUtil.getUserId(request); if (dto.getAccidentTime().isAfter(LocalDateTime.now())) { throw new BizException("出险时间不能晚于当前时间"); } Long caseId = claimCaseService.createCase(dto, operatorId); return Result.ok(caseId); } }

@Valid配合 DTO 上的@NotBlank、@NotNull能把空保单号、空出险时间这类脏数据挡在 Service 之前。JwtUtil.getUserId从 token 解析,是权限校验的第一道关。返回Result<Long>而不是整个实体,是因为前端拿到 caseId 之后紧接着要跳详情页,返回太多字段反而增加序列化开销,也让接口契约变模糊。

Service 里才是真正的活:

@Override @Transactional(rollbackFor = Exception.class) public Long createCase(ClaimReportDTO dto, Long operatorId) { // 1. 保单必须存在且在有效期内,这是后面所有金额计算的前提 Policy policy = policyMapper.selectByPolicyNo(dto.getPolicyNo()); if (policy == null) { throw new BizException("保单不存在"); } if (policy.getExpireDate().isBefore(dto.getAccidentTime().toLocalDate())) { throw new BizException("出险时间不在保单有效期内"); } // 2. 组装主记录,状态初始化为已报案 ClaimCase entity = new ClaimCase(); entity.setCaseNo(generateCaseNo()); entity.setPolicyId(policy.getId()); entity.setAccidentTime(dto.getAccidentTime()); entity.setAccidentPlace(dto.getAccidentPlace()); entity.setAccidentType(dto.getAccidentType()); entity.setClaimAmount(dto.getClaimAmount()); entity.setStatus(ClaimStatus.REPORTED.getCode()); entity.setReportUserId(operatorId); claimCaseMapper.insert(entity); // 3. 状态流水必须写,出问题查"谁改的"全靠这张表 statusLogMapper.insert(ClaimStatusLog.of(entity.getId(), null, ClaimStatus.REPORTED, operatorId, "报案登记")); return entity.getId(); }

@Transactional(rollbackFor = Exception.class)里的rollbackFor不能省,Spring 默认只对RuntimeException回滚,BizException如果继承的是受检异常,主表插进去了、流水没插,数据就残了。generateCaseNo()一般是「前缀 + yyyyMMdd + 6 位序列」,序列用 Redis 自增或者数据库号段表都行,注意别用System.currentTimeMillis()拼随机数,高并发下会撞。

3.2 状态机:把 status 字段从字符串魔法值里救出来

理赔系统最容易翻车的地方就是状态管理。见过太多项目在 Service 里写if ("已报案".equals(status)),加一个「拒赔」状态就得全局搜一遍字符串。这套源码的做法是用枚举加流转表,状态推进必须先过校验。

public enum ClaimStatus { REPORTED(10, "已报案"), SURVEYING(20, "查勘中"), SURVEYED(30, "已定损"), AUDITING(40, "核赔中"), APPROVED(50, "核赔通过"), REJECTED(55, "核赔拒赔"), PAID(60, "已赔付"), CLOSED(70, "已结案"); private final int code; private final String desc; ClaimStatus(int code, String desc) { this.code = code; this.desc = desc; } public int getCode() { return code; } public String getDesc() { return desc; } public static ClaimStatus of(int code) { for (ClaimStatus s : values()) { if (s.code == code) return s; } throw new BizException("未知状态码: " + code); } }

枚举只定义了「有哪些状态」,真正约束流转的是下面这张静态表:

public final class ClaimStatusMachine { // key 是当前状态,value 是允许流转到的下一批状态 private static final Map<ClaimStatus, Set<ClaimStatus>> ALLOWED = new EnumMap<>(ClaimStatus.class); static { ALLOWED.put(ClaimStatus.REPORTED, EnumSet.of(ClaimStatus.SURVEYING, ClaimStatus.CLOSED)); ALLOWED.put(ClaimStatus.SURVEYING, EnumSet.of(ClaimStatus.SURVEYED, ClaimStatus.CLOSED)); ALLOWED.put(ClaimStatus.SURVEYED, EnumSet.of(ClaimStatus.AUDITING)); ALLOWED.put(ClaimStatus.AUDITING, EnumSet.of(ClaimStatus.APPROVED, ClaimStatus.REJECTED)); ALLOWED.put(ClaimStatus.APPROVED, EnumSet.of(ClaimStatus.PAID)); ALLOWED.put(ClaimStatus.REJECTED, EnumSet.of(ClaimStatus.CLOSED)); ALLOWED.put(ClaimStatus.PAID, EnumSet.of(ClaimStatus.CLOSED)); ALLOWED.put(ClaimStatus.CLOSED, EnumSet.noneOf(ClaimStatus.class)); } /** * 状态推进统一入口:校验 + 更新 + 记流水,三步绑在一个事务里 */ public static void transfer(ClaimCase c, ClaimStatus target, Long operatorId, String remark) { ClaimStatus current = ClaimStatus.of(c.getStatus()); if (!ALLOWED.get(current).contains(target)) { throw new BizException( String.format("不允许从[%s]流转到[%s]", current.getDesc(), target.getDesc())); } c.setStatus(target.getCode()); // 调用方负责 update 主表和插流水,这里只做校验和赋值 } }

把流转规则抽成表之后,加一个新状态的改动成本就很低:枚举里加一行,流转表里加一对映射,业务代码基本不用动。我一般还会在claim_case表上加个version字段做乐观锁,两个人同时审核同一个案子时,后提交的会拿到更新失败,而不是把前一个人的结论覆盖掉。

3.3 核赔计算:免赔额、赔付比例和保额上限怎么落地

赔付金额的计算看着简单,实际是最容易出争议的环节。常见的规则是:损失金额先扣免赔额,剩下的乘赔付比例,最后不能超过保额上限。

/** * 计算应赔金额 * @param lossAmount 定损金额,来自查勘环节 * @param deductible 免赔额,保单条款里约定 * @param rate 赔付比例,比如 0.8 表示赔 80% * @param limit 保额上限,超出部分不赔 */ public BigDecimal calculatePayable(BigDecimal lossAmount, BigDecimal deductible, BigDecimal rate, BigDecimal limit) { // 空值兜底,宁可赔 0 也不能 NPE 把整个审核流程打断 if (lossAmount == null || lossAmount.compareTo(BigDecimal.ZERO) <= 0) { return BigDecimal.ZERO; } BigDecimal base = lossAmount.subtract(deductible == null ? BigDecimal.ZERO : deductible); if (base.compareTo(BigDecimal.ZERO) <= 0) { return BigDecimal.ZERO; } // 先乘比例再四舍五入到分,顺序反了会出现分位误差 BigDecimal payable = base.multiply(rate == null ? BigDecimal.ONE : rate) .setScale(2, RoundingMode.HALF_UP); return payable.min(limit == null ? payable : limit); }

几个参数要特别注意。deductible是绝对免赔额,如果条款写的是「损失金额的 10%」,那就得先算出相对免赔额再传进来,不要混着用。setScale(2, RoundingMode.HALF_UP)必须显式指定舍入模式,BigDecimal用divide时不指定会抛ArithmeticException,而乘法的 scale 会跟着操作数走,不收敛的话数据库存进去会被截断。payable.min(limit)这一步很多人会漏,结果出现「赔得比保额还多」的离谱数据。

3.4 赔付与流水:幂等和事务边界

财务付款是所有业务系统里最不能出错的一步,重复打款比不打款更难处理。理赔系统里常见做法是引入一个幂等号,由前端在进入支付页面时生成,同一个幂等号只允许成功一次。

@Override @Transactional(rollbackFor = Exception.class) public void pay(Long caseId, BigDecimal amount, String requestId, Long operatorId) { // 1. 幂等校验:request_id 在流水表上有唯一索引,重复请求直接返回 ClaimPayment exist = paymentMapper.selectByRequestId(requestId); if (exist != null) { return; // 已经处理过,静默返回,前端拿到的是成功 } ClaimCase c = claimCaseMapper.selectById(caseId); ClaimStatus current = ClaimStatus.of(c.getStatus()); if (current != ClaimStatus.APPROVED) { throw new BizException("只有核赔通过的案件才能赔付,当前状态:" + current.getDesc()); } // 2. 写赔付流水,这里用插入而不是更新,保留完整的支付历史 ClaimPayment payment = new ClaimPayment(); payment.setCaseId(caseId); payment.setRequestId(requestId); payment.setAmount(amount); payment.setPayStatus(1); payment.setOperatorId(operatorId); paymentMapper.insert(payment); // 3. 回写案件实赔金额并推进状态,两步在同一事务内 c.setPaidAmount(amount); ClaimStatusMachine.transfer(c, ClaimStatus.PAID, operatorId, "财务赔付完成"); claimCaseMapper.updateById(c); statusLogMapper.insert(ClaimStatusLog.of(caseId, current, ClaimStatus.PAID, operatorId, "赔付")); }

幂等的关键不在selectByRequestId那次查询,而在流水表上request_id的唯一索引——两个请求同时穿过查询时,数据库会拦住第二条。所以paymentMapper.insert抛DuplicateKeyException是预期行为,可以在全局异常处理器里转换成「重复提交」的友好提示,而不是当成系统错误。另外注意pay方法把「插流水」和「改主表」放在同一个事务,中途失败会整体回滚,不会出现「钱记了但案子没推进」的半截状态。

4. 跑起来:本地建库、改配置、走通第一笔理赔

前面看的是设计,现在落到操作。这一套流程走完,本机应该能起服务、能登录、能提交一笔报案并推到核赔通过。

4.1 环境准备与建库

需要的东西不多:JDK 8 或 11、Maven 3.6+、MySQL 5.7 或 8.0。Redis 是可选的,只有在用号段模式生成案件号或者做 token 黑名单时才需要,工程里一般会做成可开关的配置,没装也不影响主流程。

# 1. 建库,字符集一定要 utf8mb4,事故描述里可能有生僻字 mysql -uroot -p -e "CREATE DATABASE claim_db DEFAULT CHARSET utf8mb4 COLLATE utf8mb4_general_ci;" # 2. 导入建表脚本,路径以实际工程为准 mysql -uroot -p claim_db < src/main/resources/db/claim_db.sql # 3. 确认表都建出来了,核心应该是 6 张左右 mysql -uroot -p claim_db -e "SHOW TABLES;"

脚本里一般会带几条初始化数据:一个管理员账号、几个角色、几条字典项。如果执行完发现sys_user是空的,说明脚本分成了建表和初始化两个文件,得挨个跑一遍,别只跑了第一个。

4.2 配置修改与启动

application.yml里要改的基本只有数据源和文件上传路径,其他保持默认。

server: port: 8080 spring: datasource: url: jdbc:mysql://127.0.0.1:3306/claim_db?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai&allowPublicKeyRetrieval=true username: root password: 你的密码 driver-class-name: com.mysql.cj.jdbc.Driver servlet: multipart: # 理赔材料要传照片和 PDF,默认 1MB 肯定不够 max-file-size: 20MB max-request-size: 60MB mybatis-plus: mapper-locations: classpath*:/mapper/**/*.xml configuration: map-underscore-to-camel-case: true # 下划线转驼峰,实体不用逐个写 @TableField log-impl: org.apache.ibatis.logging.stdout.StdOutImpl # 开发期打 SQL,上线记得关 claim: upload-path: /data/claim/upload # 附件落盘目录,Windows 下换成 D:/claim/upload

serverTimezone=Asia/Shanghai这个参数不要省,MySQL 8 的驱动在没指定时区时会按 UTC 解析,出险时间和创建时间会整体差 8 小时,排查起来非常费劲。allowPublicKeyRetrieval=true是 MySQL 8 默认加密方式下本地连接需要的,不加会报Public Key Retrieval is not allowed。

启动就是标准的mvn spring-boot:run或者直接跑ClaimApplication。看到控制台打出Started ClaimApplication in x.xxx seconds就说明起来了,接下来访问http://127.0.0.1:8080,默认账号密码一般在claim_db.sql的注释里或者 README 里。

4.3 走通第一笔理赔:接口验证清单

页面能打开不代表链路通,建议按状态顺序手动打一遍接口,出问题能立刻定位到是哪一环。

# 1. 登录拿 token curl -X POST http://127.0.0.1:8080/api/auth/login \ -H "Content-Type: application/json" \ -d '{"username":"admin","password":"123456"}' # 2. 报案,返回 caseId curl -X POST http://127.0.0.1:8080/api/claim/report \ -H "Authorization: Bearer $TOKEN" \ -H "Content-Type: application/json" \ -d '{"policyNo":"P20240001","accidentTime":"2024-05-12 09:30:00","accidentPlace":"某路段","accidentType":"COLLISION","claimAmount":8000.00}' # 3. 查勘定损,推进到 SURVEYED curl -X POST http://127.0.0.1:8080/api/claim/survey \ -H "Authorization: Bearer $TOKEN" \ -H "Content-Type: application/json" \ -d '{"caseId":1,"lossAmount":6500.00,"surveyRemark":"左前叶子板更换"}' # 4. 提交核赔 curl -X POST http://127.0.0.1:8080/api/claim/audit \ -H "Authorization: Bearer $TOKEN" \ -H "Content-Type: application/json" \ -d '{"caseId":1,"approved":true,"remark":"材料齐全"}' # 5. 财务赔付,requestId 由前端生成,重放同一个值应当返回成功但不重复记账 curl -X POST http://127.0.0.1:8080/api/claim/pay \ -H "Authorization: Bearer $TOKEN" \ -H "Content-Type: application/json" \ -d '{"caseId":1,"amount":5200.00,"requestId":"req-20240512-0001"}'

每一步之后去数据库里确认一下claim_case.status有没有跟着变,claim_status_log里有没有多出一条记录。如果状态没动但接口返回成功,多半是 Service 里漏了updateById,或者事务没生效——@Transactional加在 private 方法或者同类内部调用的方法上是无效的,这个坑后面还会提到。

5. 踩坑排查:启动报错、权限 403 和金额精度这几个坑

这套代码在不同机器上跑,翻车点集中在这几处。下面每一条都是「现象 → 原因 → 解决」的结构,对照着看能省不少时间。

启动报Invalid bound statement (not found)

现象是服务能起来,但一调接口就抛这个异常,日志里能看到 Mapper 接口名。原因是mapper-locations配的路径和 XML 实际位置对不上,或者 Maven 打包时把src/main/java下的 XML 过滤掉了。MyBatis 默认只扫resources下的 XML,如果你把 XML 放在了java包路径旁边,就得在pom.xml的<build><resources>里显式把**/*.xml包含进来。解决办法有两个:要么统一把 XML 挪到resources/mapper,要么补上 resources 配置,我一般选前者,省得后面换构建工具又出问题。

登录后所有接口返回 403,控制台没有异常

现象是能拿到 token,但带 token 请求一直 403。原因通常是 JWT 过滤器注册的顺序不对,或者拦截器把/api/auth/login也拦了。Spring Security 的过滤器链有明确顺序,自定义的 JWT 过滤器要放在UsernamePasswordAuthenticationFilter之前;如果是用拦截器方案,WebMvcConfigurer的addInterceptors里一定要excludePathPatterns("/api/auth/**", "/error"),否则登录接口自己也被拦,形成死循环。排查时把日志级别调到 DEBUG,看SecurityContextHolder里有没有塞进Authentication,一眼就能分清是没解析还是没放行。

出险时间和创建时间相差 8 小时

现象是前端显示的时间和数据库里的时间对不上,差整 8 小时。原因是 MySQL 连接串没指定serverTimezone,驱动按 UTC 解析DATETIME字面量。解决就是在 URL 后面加serverTimezone=Asia/Shanghai,同时确认 MySQL 服务端的time_zone也是东八区。另外一个容易忽略的点:实体里用LocalDateTime时,MyBatis-Plus 默认的LocalDateTimeTypeHandler会按 JVM 时区处理,如果服务器时区不是东八区,光改连接串也不够,得在启动参数里加-Duser.timezone=Asia/Shanghai。

赔付金额出现 0.01 的误差

现象是审核页面算出来的金额和财务复核的金额差一分钱。原因基本锁定在金额用了double或者Float,多步乘除之后精度丢失。解决是把所有涉及金额的字段、参数、中间变量全部换成BigDecimal,并且每次乘除都显式setScale(2, RoundingMode.HALF_UP)。还有一条血泪经验:BigDecimal.equals会比较 scale,new BigDecimal("1.0").equals(new BigDecimal("1.00"))是false,做金额相等判断一律用compareTo(...) == 0。

同一笔赔付被记了两次

现象是财务那边说打了两笔,但系统里只该有一笔。原因是支付接口没有幂等控制,用户手抖点了两次,或者网络超时后前端重试。解决是前端在进入支付页时生成一个requestId并随请求带上,后端在流水表的request_id上建唯一索引,插入冲突时捕获DuplicateKeyException并返回「已处理」。注意requestId不能用caseId代替,同一个案子可能有分次赔付,用案件号做幂等键反而会挡住正常的第二次付款。

6. 二次开发:给理赔系统加一个自动立案规则引擎

案子量上来之后,人工判断「这笔报案要不要立案」会变成瓶颈。我一般会在报案和查勘之间插一个轻量的规则引擎,用配置化的方式决定案件是自动通过、转人工还是直接拒赔,改造点不多,收益挺明显。

落地的位置选在ClaimCaseServiceImpl.createCase的最后一步,主记录和流水写完但事务还没提交的时候。规则本身存一张claim_rule表,字段包括rule_code、condition_json、action、priority和enabled,条件用 JSON 描述,比如「事故类型为 COLLISION 且报案金额小于 5000」。

/** * 自动立案判定 * @param c 已落库的案件 * @return AUTO_PASS / MANUAL / REJECT */ public RuleAction evaluate(ClaimCase c) { // 按优先级升序取启用的规则,命中第一条就返回,避免规则互相打架 List<ClaimRule> rules = ruleMapper.selectEnabledOrderByPriority(); for (ClaimRule rule : rules) { JsonNode cond = JsonUtil.readTree(rule.getConditionJson()); boolean hit = match(cond, c); if (hit) { log.info("案件 {} 命中规则 {}", c.getCaseNo(), rule.getRuleCode()); return RuleAction.valueOf(rule.getAction()); } } // 一条规则都没命中,默认转人工,宁可多看一眼也不要误放 return RuleAction.MANUAL; }

match方法按字段名反射取值再比较,条件 JSON 里支持eq、gt、lt、in四种操作符就够用了,别一上来就上 Drools,那套东西学习成本高、调试也不直观。规则命中后写一条claim_rule_hit记录,把命中的规则码和当时的案件快照存下来,后面业务方质疑「为什么这单自动过了」,直接拿记录说话。

验证方式也简单:造三组数据,一组金额小、类型常见的应该走AUTO_PASS直接推到查勘;一组金额大或者类型罕见的走MANUAL;一组保单已过期的走REJECT。跑完查claim_status_log和claim_rule_hit两张表,状态和规则码都对得上,改造就算成了。注意规则引擎本身要包在报案那个事务里,规则判定抛异常时整笔报案应该回滚,而不是留下一个状态不明的孤儿案件。

从那以后我每次往业务系统里加这类「自动判定」模块,都强制先把「没命中任何规则时怎么办」写死在代码里,默认转人工,绝不留空。规则可以慢慢补,但系统不能因为少配一条规则就把案子放行或者卡死。这套 Java 保险理赔系统源码的结构足够清爽,改起来不费劲,希望帮到你。

本文还有配套的精品资源,点击获取

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进