简介:保险理赔系统源码面向Java开发学习者与保险业务系统开发者,围绕理赔申请、审核、处理到赔付等核心流程,提供一套完整、可运行、可拆解的企业级项目参考。包内共1729个文件,以JS、HTML、CSS、JSP、Java源码及JAR包、XML配置为主,辅以图片、SQL脚本和说明文档,压缩包约75.19MB,便于按前端页面、后端业务逻辑、持久层与部署配置分层查阅。系统涉及用户、保单、理赔申请及状态等多类核心数据表,并包含身份验证、授权、日志记录、单元测试与集成测试等相关配套代码。已有158人学习,适合用于课程设计、毕业设计或技术栈综合练习。读者可结合主流框架、持久层框架、MVC分层与REST接口等实现,理解保险理赔业务的表结构设计、模块划分和异常处理思路,并借鉴源码中的分层组织与设计模式应用,提升企业级项目开发与代码阅读能力。
1. 从一张报案单说起:这套 Java 保险理赔系统源码能跑通什么业务
报案电话打进来的时候,坐席桌面上通常开着三个窗口:一个录报案单,一个查保单有效期,一个翻被保人的历史理赔记录。这套 Java 保险理赔系统源码,把这三个窗口连同后面的查勘、定损、理算、核赔、结案串成了一条能登录、能点、能落库的完整链路,没有停在几张增删改查页面上。车险和意健险的主流程是通的:一张报案单能从「已报案」走到「已结案」,每一步都留操作人、操作时间和状态快照。它适合两类人:刚接手保险行业外包项目、手上缺一份能对得上业务的底稿的开发者,以及想看看状态机、金额计算、数据权限这些硬骨头在 Java 里怎么落地的熟手。下面按我自己拆包的顺序讲,先讲结构与选型,再拆核心代码,最后讲怎么跑、怎么改。
2. 技术栈与工程结构:Spring Boot 单体分层怎么撑起理赔主链路
拿到压缩包先别急着点启动类,先看pom.xml和目录。理赔系统的复杂度不在并发量,在业务规则的密度——一张单子上挂着保单、被保人、标的信息、影像资料、定损项、理算明细、审批意见七八个实体,如果一上来就按微服务切,事务边界会被切碎,一张单子的状态一致性就变成分布式事务问题。所以这类项目绝大多数是单体,我拆过的几个也都是。
2.1 技术选型:为什么是 Spring Boot + MyBatis-Plus + MySQL
常见组合是这样:
| 层次 | 选型 | 在理赔场景里的作用 |
|---|---|---|
| 后端框架 | Spring Boot 2.x | 内嵌 Tomcat,打成可执行 jar 直接部署 |
| ORM | MyBatis-Plus | 单表 CRUD 免写 XML,复杂报表 SQL 仍可手写 |
| 数据库 | MySQL 8.0 | 金额字段统一decimal(14,2),避免浮点误差 |
| 缓存 | Redis | 登录 token、单号自增、报案幂等锁 |
| 权限 | Spring Security + JWT 或 Shiro | 菜单权限 + 数据范围两层控制 |
| 前端 | Vue 2 + Element UI | 后台管理,表格和表单密集 |
| 导出 | EasyExcel | 理赔清单、结案报表批量导出 |
为什么不上 Flowable 这类工作流引擎?理赔的审批链路其实很浅,通常就是「查勘员提交 → 主管核赔 → 财务付款」两三级,节点固定、分支少。引入工作流引擎后,画 BPMN 图、维护流程定义、处理流程实例和业务单据的关联,成本远大于收益。我的判断标准很简单:如果流程节点一年内不会超过三次变更,就用枚举 + 流转规则表自己写;如果审批层级会随机构调整、需要业务人员在页面上配置,再上引擎。这套源码走的是前一条路,代码量小,调试时状态怎么变的能一眼看清。
另外提醒一句,MF这种前缀一般是资源站的编号,跟技术栈没关系,解压后第一件事是把目录名改成你自己的工程名,否则后面包名、模块名对不上,改起来很烦。
2.2 目录结构:controller 和 service 之外还有哪几层
多模块拆法如下,单模块的话把claim-biz里的东西平铺到主模块即可:
claim-system ├── claim-admin # 启动模块,只有 Application 和配置文件 │ └── src/main/resources │ ├── application.yml │ └── db/ # 建库脚本 + 初始化数据 ├── claim-common # 统一返回体 R、业务异常、常量、工具类 ├── claim-framework # 安全、Redis、AOP 切面(数据权限、操作日志) └── claim-biz # 理赔核心 ├── controller # 报案、查勘、定损、理算、核赔、结案 ├── service │ └── impl ├── mapper # 接口 ├── domain # 实体,和表一一对应 ├── dto # 入参,带校验注解 ├── vo # 出参,给前端拼好的视图对象 └── resources/mapper # XML,复杂查询和报表在这里几个容易被忽视的点。domain和dto必须分开,理赔单的字段有六十多个,直接拿实体接前端参数,等于把payAmount、auditStatus这类敏感字段暴露给请求体,前端随便塞一个值就能改赔付金额。vo单独一层是为了列表页——查勘列表要显示报案人姓名、机构名、查勘员名,这些字段实体里没有,要么写 join 查询映射到 vo,要么在 service 里组装,别硬塞进实体当临时字段。
claim-framework里放的是横切逻辑,最典型的是数据权限切面和操作日志切面。这两块后面第 4 章单独讲。db/目录一定要有,里面通常两个文件:schema.sql建表、data.sql初始化管理员账号和字典数据,没有它你连登录页都进不去。
2.3 配置文件与启动顺序:三个必须改的参数
application.yml里真正影响能不能跑起来的是这几行:
server: port: 8080 spring: datasource: driver-class-name: com.mysql.cj.jdbc.Driver url: jdbc:mysql://127.0.0.1:3306/claim_db?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai&allowPublicKeyRetrieval=true username: root password: 123456 redis: host: 127.0.0.1 port: 6379 database: 3 servlet: multipart: max-file-size: 20MB max-request-size: 100MB mybatis-plus: mapper-locations: classpath*:mapper/**/*.xml type-aliases-package: com.example.claim.domain configuration: map-underscore-to-camel-case: true claim: file: root-path: /data/claim/upload # 必须绝对路径 sn: prefix: LP # 单号前缀,LP2024050001逐个说清楚。
serverTimezone=Asia/Shanghai不写,MySQL 驱动会按服务器默认时区解析时间,你存进去的「事故时间」读出来就差 8 小时,报表现场日期一栏全是前一天,这类问题排查起来很费时间。
mapper-locations的路径必须和 XML 的实际位置一致。如果 XML 放在src/main/java下面,Maven 打包默认不会把 XML 复制进 classes 目录,运行时就报Invalid bound statement (not found)。要么把 XML 放resources/mapper,要么在pom.xml的<build><resources>里显式包含**/*.xml。
claim.file.root-path一定要写绝对路径。写成./upload这种相对路径,本地用 IDEA 跑没问题,打成 jar 用java -jar启动后工作目录变了,上传的文件路径全错,第二天打开查勘照片一片 404。
启动顺序我一般是:建库 → 执行db/schema.sql和data.sql→ 改上面三处配置 → 起 Redis →mvn clean package -DskipTests→java -jar claim-admin.jar。前端在vue-ui目录下npm install && npm run dev,把src/utils/request.js里的baseURL指到http://localhost:8080就行。
3. 核心链路拆解:从报案受理到理算结案的三段代码
理赔系统的技术含量集中在两个地方:状态怎么变才不乱,钱怎么算才不差。这两块写崩了,前面界面做得再漂亮也是白搭。
3.1 理赔单状态机:为什么不能靠 if-else 堆
我见过最糟的写法是在 service 里写if (status == 10) { ... } else if (status == 20) { ... },十几个状态铺开之后,谁也不知道从「已定损」能不能直接跳到「已结案」。正确做法是把状态和合法流转都收进枚举和一个静态规则表:
public enum ClaimStatusEnum { REPORTED(10, "已报案"), ACCEPTED(20, "已受理"), SURVEYING(30, "查勘中"), ASSESSED(40, "已定损"), CALCULATED(50, "已理算"), AUDITING(60, "核赔中"), PASSED(70, "核赔通过"), PAID(80, "已结案"), REJECTED(90, "拒赔"), CLOSED(99, "注销"); private final int code; private final String desc; ClaimStatusEnum(int code, String desc) { this.code = code; this.desc = desc; } public int getCode() { return code; } public String getDesc() { return desc; } private static final Map<Integer, ClaimStatusEnum> CACHE = new HashMap<>(); static { for (ClaimStatusEnum s : values()) CACHE.put(s.code, s); } // 数据库里存的是 int,回显时靠这个方法转成枚举,避免到处写 switch public static ClaimStatusEnum of(Integer code) { return CACHE.get(code); } }状态有了,再定义流转规则:
public final class ClaimFlow { private static final Map<ClaimStatusEnum, Set<ClaimStatusEnum>> RULES = new EnumMap<>(ClaimStatusEnum.class); static { allow(ClaimStatusEnum.REPORTED, ClaimStatusEnum.ACCEPTED, ClaimStatusEnum.CLOSED); allow(ClaimStatusEnum.ACCEPTED, ClaimStatusEnum.SURVEYING, ClaimStatusEnum.REJECTED, ClaimStatusEnum.CLOSED); allow(ClaimStatusEnum.SURVEYING, ClaimStatusEnum.ASSESSED, ClaimStatusEnum.CLOSED); allow(ClaimStatusEnum.ASSESSED, ClaimStatusEnum.CALCULATED); allow(ClaimStatusEnum.CALCULATED, ClaimStatusEnum.AUDITING); allow(ClaimStatusEnum.AUDITING, ClaimStatusEnum.PASSED, ClaimStatusEnum.REJECTED); allow(ClaimStatusEnum.PASSED, ClaimStatusEnum.PAID); } private static void allow(ClaimStatusEnum from, ClaimStatusEnum... tos) { RULES.put(from, EnumSet.copyOf(Arrays.asList(tos))); } // 每次改状态前必须过这一关,别信前端传过来的 toStatus public static void check(ClaimStatusEnum from, ClaimStatusEnum to) { Set<ClaimStatusEnum> allowed = RULES.get(from); if (allowed == null || !allowed.contains(to)) { throw new BizException("非法的状态流转:" + from.getDesc() + " -> " + to.getDesc()); } } }EnumMap在这里比HashMap合适,key 是枚举,底层用数组下标寻址,查询常数时间且没有哈希冲突。allow方法用可变参数是为了让规则集中在一处,运维想加一条「已定损可退回查勘中」,改一行就行,不用翻业务代码。
实际改状态的地方统一收口到一个方法里:
@Transactional(rollbackFor = Exception.class) public void changeStatus(String claimNo, ClaimStatusEnum to, String operator, String remark) { ClaimMain claim = claimMapper.selectByNo(claimNo); ClaimStatusEnum from = ClaimStatusEnum.of(claim.getStatus()); ClaimFlow.check(from, to); // 1. 规则校验 // 2. 带原状态条件更新,防止并发覆盖(这条是后面避坑章节的主角) int rows = claimMapper.updateStatus(claimNo, from.getCode(), to.getCode(), operator); if (rows == 0) throw new BizException("单据状态已被他人变更,请刷新后重试"); // 3. 写流转日志,时间、操作人、前后状态一个都不能少 claimLogService.record(claimNo, from, to, operator, remark); }第 2 步的updateStatus对应 SQL 是UPDATE claim_main SET status = #{to} WHERE claim_no = #{claimNo} AND status = #{from},WHERE里带着原状态,等于做了一次乐观锁。三个人同时点「核赔通过」,只有第一个能更新成功,另外两个拿到rows = 0,直接提示刷新。这一行代码能挡掉后面 90% 的重复赔付事故。
3.2 报案受理:一个 POST 请求从校验到落库
报案是整条链路的入口,也是校验最密集的地方。控制器只做参数绑定和校验,业务全在 service:
@RestController @RequestMapping("/claim/report") public class ClaimReportController { @Autowired private ClaimReportService claimReportService; @PostMapping("/submit") public R<String> submit(@Validated @RequestBody ClaimReportDTO dto) { // 返回单号,前端拿到后跳到「查勘资料上传」页 return R.ok(claimReportService.submit(dto)); } }ClaimReportDTO上的校验注解决定了很多脏数据能不能进门:
public class ClaimReportDTO { @NotBlank(message = "保单号不能为空") private String policyNo; @NotBlank(message = "报案人姓名不能为空") private String reporterName; @Pattern(regexp = "^1[3-9]\\d{9}$", message = "报案人手机号格式不正确") private String reporterPhone; @NotNull(message = "事故时间不能为空") @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss", timezone = "Asia/Shanghai") private Date accidentTime; @NotBlank(message = "事故地点不能为空") private String accidentAddress; @NotBlank(message = "事故描述不能为空") @Size(max = 500, message = "事故描述不能超过 500 字") private String accidentDesc; }@JsonFormat上的timezone必须和数据库时区一致,否则前端传2024-05-12 10:00:00,落到库里可能变成02:00:00。这个注解是前端时间显示错乱的常见原因之一。
service 层按五步走:
@Service public class ClaimReportService { @Autowired private PolicyMapper policyMapper; @Autowired private ClaimMainMapper claimMapper; @Autowired private ClaimLogService claimLogService; @Autowired private SnService snService; @Autowired private StringRedisTemplate redisTemplate; @Transactional(rollbackFor = Exception.class) public String submit(ClaimReportDTO dto) { // 1. 幂等:同一身份证 + 同一事故时间,5 分钟内视为重复提交 String idemKey = "claim:report:" + dto.getReporterName() + ":" + dto.getAccidentTime().getTime(); Boolean first = redisTemplate.opsForValue().setIfAbsent(idemKey, "1", Duration.ofMinutes(5)); if (Boolean.FALSE.equals(first)) { throw new BizException("请勿重复提交,稍后可在理赔列表查询"); } // 2. 保单校验:存在性 + 有效期 Policy policy = policyMapper.selectByNo(dto.getPolicyNo()); if (policy == null) { throw new BizException("保单号不存在"); } if (dto.getAccidentTime().before(policy.getEffectiveDate()) || dto.getAccidentTime().after(policy.getExpireDate())) { throw new BizException("事故时间不在保险期间内"); } // 3. 生成单号:LP + yyyyMM + 4 位流水,Redis 自增保证并发不重号 String claimNo = snService.next("LP"); // 4. 主单落库,初始状态固定为「已报案」 ClaimMain main = new ClaimMain(); main.setClaimNo(claimNo); main.setPolicyId(policy.getId()); main.setStatus(ClaimStatusEnum.REPORTED.getCode()); main.setAccidentTime(dto.getAccidentTime()); main.setAccidentAddress(dto.getAccidentAddress()); main.setReporterName(dto.getReporterName()); main.setReporterPhone(dto.getReporterPhone()); main.setCreateBy(dto.getReporterName()); main.setCreateTime(new Date()); claimMapper.insert(main); // 5. 流转日志:起点没有「来源状态」,传 null claimLogService.record(claimNo, null, ClaimStatusEnum.REPORTED, dto.getReporterName(), "报案登记"); return claimNo; } }第 1 步用setIfAbsent而不是先get再set,因为后者在并发下存在窗口期。幂等键里带上事故时间戳,是为了区分同一个报案人不同时间的两起事故,这点在车队客户身上很常见。
第 3 步的SnService内部用 Redis 的INCR加按天过期:claim:sn:LP:202405这个 key 每天零点的计数归零,再配合数据库里单号的唯一索引兜底。别用max(claim_no) + 1,并发下必然撞号。
第 5 步容易被省掉。新手觉得有主单的状态字段就够了,但核赔时客户投诉「我这单在查勘环节趴了半个月」,靠的就是流转日志来判断卡在谁手上、什么时候变的。这张表是后面做理赔时效统计的唯一数据源。
3.3 理算模块:赔付金额怎么算才能一分不差
理算的核心公式不复杂:赔付金额 = (核定损失 − 免赔额) × 赔付比例,再受保额上限约束。难点全在精度和边界条件上。
public BigDecimal calculate(ClaimMain claim, Policy policy, CalcParam param) { // 1. 定损核定的损失金额,来自定损单,不是报案时客户报的数 BigDecimal approvedLoss = param.getApprovedLoss(); // 2. 绝对免赔额,保单维度,没配置就按 0 处理 BigDecimal deductible = policy.getDeductible() == null ? BigDecimal.ZERO : policy.getDeductible(); // 3. 赔付比例,0.8 表示 80%,保单上叫「事故责任免赔率」的反算 BigDecimal payRate = policy.getPayRate(); BigDecimal base = approvedLoss.subtract(deductible); if (base.signum() < 0) { base = BigDecimal.ZERO; // 损失小于免赔额,赔 0,不能出现负数 } BigDecimal payable = base.multiply(payRate).setScale(2, RoundingMode.HALF_UP); // 4. 保额封顶:剩余保额 = 保额 − 已赔付 BigDecimal remain = policy.getSumInsured().subtract(policy.getPaidAmount()); if (payable.compareTo(remain) > 0) { payable = remain; } return payable.max(BigDecimal.ZERO); }参数含义和取值示例:
| 参数 | 含义 | 示例值 |
|---|---|---|
approvedLoss | 定损核定的损失金额 | 12000.00 |
deductible | 保单绝对免赔额 | 500.00 |
payRate | 赔付比例 | 0.80 |
sumInsured | 保单保额 | 100000.00 |
paidAmount | 本保单历史已赔付金额 | 0.00 |
代入上表:(12000 − 500) × 0.8 = 9200.00,小于剩余保额 100000,最终赔付 9200.00。
三个细节。setScale(2, RoundingMode.HALF_UP)不是可选项,不写 scale,BigDecimal会保留乘法产生的全部小数位,前端显示9199.999999999;用了RoundingMode.DOWN则会系统性少赔,客户对账时能发现。全流程杜绝double,从 DTO 到实体到 XML 映射,金额一律BigDecimal,数据库一律decimal(14,2)。
BigDecimal比较大小必须用compareTo,不能用equals。new BigDecimal("0.80").equals(new BigDecimal("0.8"))返回false,因为scale不同。这个错误在判断「赔付金额是否为零」时会让流程多走一遍核赔。
理算结果落库前要存档计算过程,也就是把当时的损失金额、免赔额、比例、保额快照一起写进claim_calc_detail表。保单参数是会改的,三年后客户回头质疑金额,你靠实时反算是算不出当时结果的。
4. 权限与数据隔离:RBAC 模型和查勘员的数据范围控制
理赔系统最容易出问题的不是功能,是数据越权。分公司的查勘员能翻到总公司的单子,或者代理人能看到别人客户的身份证号,这类问题在验收时一抓一个准。
4.1 五张表撑起 RBAC
标准 RBAC 是五张表:
| 表名 | 关键字段 | 说明 |
|---|---|---|
sys_user | user_id、dept_id、user_name、status | 用户,挂在一个机构下 |
sys_role | role_id、role_key、data_scope | 角色,data_scope决定数据范围 |
sys_user_role | user_id、role_id | 用户角色关联,支持一人多角色 |
sys_menu | menu_id、parent_id、perms | 菜单和按钮权限,perms形如claim:audit |
sys_role_menu | role_id、menu_id | 角色菜单关联 |
菜单权限控制的是「能不能点这个按钮」,数据范围控制的是「点了之后能看到哪些数据」。前者靠@PreAuthorize("@ss.hasPermi('claim:audit')")这类注解,后者要自己实现。sys_role.data_scope的取值我一般约定成四种:
1 = 全部数据(总部核赔岗) 2 = 本机构数据 3 = 本机构及下级机构数据(分公司主管) 4 = 仅本人数据(查勘员)第 3 种依赖机构表的ancestors字段存祖先路径,形如0,100,101,用find_in_set判断下级,这是最常见也最省事的写法。
4.2 数据范围过滤:切面拼 SQL 片段的完整实现
先定义注解:
@Target(ElementType.METHOD) @Retention(RetentionPolicy.RUNTIME) public @interface DataScope { String userAlias() default "u"; // 用户表在主查询里的别名 String deptAlias() default "d"; // 机构表在主查询里的别名 }切面在方法执行前把 SQL 片段塞进BaseEntity.params:
@Aspect @Component public class DataScopeAspect { @Before("@annotation(dataScope)") public void doBefore(JoinPoint point, DataScope dataScope) { LoginUser user = SecurityUtils.getLoginUser(); // 超管直接放行,不拼任何条件 if (user.isAdmin()) { return; } StringBuilder sql = new StringBuilder(); for (SysRole role : user.getRoles()) { String scope = role.getDataScope(); if ("1".equals(scope)) { return; // 有一条角色是全部数据,短路 } else if ("2".equals(scope)) { sql.append(" OR ").append(dataScope.deptAlias()) .append(".dept_id = ").append(user.getDeptId()); } else if ("3".equals(scope)) { sql.append(" OR ").append(dataScope.deptAlias()) .append(".dept_id IN (SELECT dept_id FROM sys_dept WHERE find_in_set(") .append(user.getDeptId()).append(", ancestors))"); } else if ("4".equals(scope)) { sql.append(" OR ").append(dataScope.userAlias()) .append(".user_id = ").append(user.getUserId()); } } if (sql.length() > 0) { // 去掉开头的 OR,外面用 AND ( ... ) 包起来 String fragment = " AND (" + sql.substring(4) + ")"; BaseEntity.setDataScope(fragment); } } // 必须清掉 ThreadLocal,否则线程池复用时会串数据 @After("@annotation(dataScope)") public void doAfter() { BaseEntity.clearDataScope(); } }XML 里这样用:
<select id="selectClaimPage" resultType="ClaimVo"> SELECT c.claim_no, c.status, c.accident_time, u.user_name AS surveyorName FROM claim_main c LEFT JOIN sys_user u ON c.surveyor_id = u.user_id LEFT JOIN sys_dept d ON u.dept_id = d.dept_id WHERE c.del_flag = '0' <if test="params.dataScope != null and params.dataScope != ''"> ${params.dataScope} </if> ORDER BY c.create_time DESC </select>这段代码有两个必须记住的点。第一,${}是字符串直接拼接,不是预编译占位符,所以拼进去的内容必须全部来自后端枚举和当前登录用户的信息,绝不能接受前端传参,否则就是标准 SQL 注入。第二,ThreadLocal一定要在@After里清掉,Tomcat 的线程是复用的,不清会导致下一个请求莫名其妙带上前一个用户的数据范围,而且这种 Bug 极难复现——本地单人测试永远正常,一上线多人并发就出事。
@After在方法抛异常时也会执行,比@AfterReturning安全。如果切面里还要处理嵌套调用,BaseEntity里的dataScope建议用栈结构而不是单值,避免内层调用覆盖外层。
4.3 影像资料上传:路径规划和类型校验
理赔的影像资料量大——行驶证、驾驶证、事故现场照、维修发票,一张单子几十个附件很常见。存储策略上,中小项目直接落本地磁盘,按单号分目录:
private static final Set<String> ALLOW_EXT = new HashSet<>(Arrays.asList("jpg", "jpeg", "png", "pdf")); public String upload(MultipartFile file, String claimNo) throws IOException { if (file.isEmpty()) { throw new BizException("上传文件为空"); } String original = file.getOriginalFilename(); String ext = original.substring(original.lastIndexOf('.') + 1).toLowerCase(); if (!ALLOW_EXT.contains(ext)) { throw new BizException("仅支持 jpg/png/pdf 格式"); } if (file.getSize() > 20 * 1024 * 1024) { throw new BizException("单文件不能超过 20MB"); } // 按 单号/年月日 分目录,既方便整单归档,也避免单目录文件过多 String datePath = LocalDate.now().format(DateTimeFormatter.ofPattern("yyyyMMdd")); Path dir = Paths.get(rootPath, claimNo, datePath); Files.createDirectories(dir); // 文件名用 UUID,杜绝中文名、重名和路径穿越 String fileName = UUID.randomUUID().toString().replace("-", "") + "." + ext; file.transferTo(dir.resolve(fileName).toFile()); // 相对路径入库,换了存储根目录只改配置不改数据 String relativePath = claimNo + "/" + datePath + "/" + fileName; attachmentMapper.insert(buildAttachment(claimNo, original, relativePath, file.getSize())); return relativePath; }ALLOW_EXT白名单而不是黑名单,是因为黑名单永远漏——jsp、jspx、war之外还有各种变形。文件名用 UUID 是为了挡掉../../etc/passwd这类路径穿越,同时避免中文名在不同操作系统上的编码问题。入库存相对路径、不存绝对路径,以后从本地磁盘迁到对象存储,只改读取时的前缀拼接,历史数据不用动。
attachment表里至少要有claim_no、original_name、file_path、file_size、upload_by、upload_time六个字段,original_name保留是为了下载时还给用户一个能看懂的名字。
5. 避坑与常见问题排查:上线前踩过的五个坑
这一章记录的是我在这类项目上真实翻车过的场景,每条按「现象 → 原因 → 解决」写,能对上号就直接抄。
5.1 启动与环境类问题
坑一:启动就报Invalid bound statement (not found)
现象:编译能过,启动也不报错,一调接口就抛出org.apache.ibatis.binding.BindingException: Invalid bound statement (not found): com.example.claim.mapper.ClaimMainMapper.selectClaimPage。
原因:XML 没被加载进 classpath。Maven 默认只把src/main/resources里的文件复制到target/classes,XML 如果放在src/main/java/com/example/claim/mapper/下面就不会被打包;另一种情况是mybatis-plus.mapper-locations写的路径和实际位置对不上,比如写成classpath:mapper/*.xml但 XML 在子目录里,少了**/。
解决:统一把 XML 放src/main/resources/mapper/下,mapper-locations写成classpath*:mapper/**/*.xml,注意classpath*带星号,多模块下才能扫到依赖 jar 里的 XML。坚持放 java 目录的话,在pom.xml的<build>里加<resources>把**/*.xml包含进去。改完一定执行mvn clean,只compile不清 target,旧的 classes 会干扰判断。
坑二:上传的查勘照片当天能看,第二天全部 404
现象:本地开发环境一切正常,打成 jar 部署到服务器,当天上传的照片能预览,重启服务或者隔一天再看,所有图片地址都打不开。
原因:root-path配了相对路径./upload。用 IDEA 启动时工作目录是项目根目录,用java -jar启动时工作目录是执行命令的目录,两者不一样;服务重启后工作目录再变一次,之前写的文件就找不到了。
解决:root-path改成绝对路径,比如/data/claim/upload,并在部署脚本里先mkdir -p建好目录、赋好权限。读取时用配置的前缀 + 库里存的相对路径拼接,不要在前端写死 URL。
坑三:所有时间都差 8 小时
现象:前端提交2024-05-12 10:00:00,数据库里是2024-05-12 18:00:00,或者列表页显示的事故时间比实际早 8 小时。
原因:三个地方各管一段时区,任何一处没统一都会偏。JDBC 连接串没写serverTimezone、MySQL 服务端时区是 UTC、Jackson 序列化没指定timezone,三者组合出的偏移量还不一定是 8 小时,有时代码里看着对、页面上是错的。
解决:三处统一成Asia/Shanghai——连接串加serverTimezone=Asia/Shanghai,DTO 和 VO 的日期字段加@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss", timezone = "Asia/Shanghai"),MySQL 用SELECT @@global.time_zone确认。稳妥起见,关键时间字段统一用datetime不要用timestamp,后者会随会话时区做转换。
5.2 业务与数据类问题
坑四:两个核赔员同时点通过,客户收到了两笔赔款
现象:一张单子在核赔列表里被两个人同时打开,先后点击「核赔通过」,财务侧生成了两条付款指令。
原因:更新状态时只按主键更新,没有校验原状态。UPDATE claim_main SET status = 70 WHERE claim_no = 'LP2024050001'这条 SQL 谁执行都成功,两次调用都返回影响行数 1,业务层以为都成功了。
解决:就是 3.1 节那个WHERE status = #{from}的写法,靠影响行数判断是否抢到。财务侧的付款表再加一个claim_no唯一索引兜底,双保险。这类并发问题在测试环境几乎复现不了,因为测试时没人会同时点。
坑五:理算金额和客户自己算的差几分钱
现象:客户按合同自己算出来是 9200.00,系统给出 9199.99 或 9200.01,金额不大但每次对账都要解释一遍。
原因:中间环节用了double。DTO 里字段类型是Double、实体里是double、或者某处new BigDecimal(0.8)用了 double 构造器——new BigDecimal(0.8)的实际值是0.8000000000000000444...,后面怎么舍入都差。
解决:全链路BigDecimal,构造一律用字符串new BigDecimal("0.8")或BigDecimal.valueOf(0.8),禁止new BigDecimal(double)。数据库字段decimal(14,2),乘除之后立刻setScale(2, RoundingMode.HALF_UP),不要让不设 scale 的中间结果往下传。
6. 二次开发:给理赔进度加一条消息推送链路
接手这类系统后第一个被提的需求,十有八九是「客户想知道自己的单子到哪一步了」。合理的改法不是在状态变更的每个 service 里插一段发短信的代码——那样以后换通道、加渠道、做重试,要改十几个地方。我一般拆成三步:状态变更发事件、监听器落消息表、定时任务扫表推送。
第一步,在 3.1 节那个统一的changeStatus方法末尾发事件,不要在 controller 里发,也不要在每个业务 service 里各发一次:
// 状态和日志都写成功之后再发,保证事件里的状态一定是落库的 applicationEventPublisher.publishEvent( new ClaimStatusChangedEvent(claimNo, from, to, operator));第二步,监听器只做一件事:把消息写进sys_message表,不做任何网络调用。网络调用放在监听器里,一旦短信网关超时,事务被拖长甚至回滚,状态变更也跟着失败,这是最典型的连环事故:
@Component public class ClaimStatusListener { @Autowired private MessageMapper messageMapper; @EventListener public void onChanged(ClaimStatusChangedEvent e) { SysMessage msg = new SysMessage(); msg.setBizNo(e.getClaimNo()); msg.setTemplate("CLAIM_" + e.getTo().name()); // 模板按目标状态命名 msg.setContent(buildContent(e)); // 内容里带单号和当前状态 msg.setStatus(0); // 0 待发送 1 成功 2 失败 msg.setRetryCount(0); msg.setCreateTime(new Date()); messageMapper.insert(msg); } }第三步,定时任务扫status = 0的记录推送,失败重试三次后置为失败并告警:
@Scheduled(fixedDelay = 60000) public void push() { // 一次捞 100 条,避免全表扫描 List<SysMessage> list = messageMapper.selectPending(100); for (SysMessage msg : list) { try { smsClient.send(msg.getContent()); messageMapper.markSuccess(msg.getId()); } catch (Exception ex) { messageMapper.increaseRetry(msg.getId()); if (msg.getRetryCount() + 1 >= 3) { messageMapper.markFailed(msg.getId()); } } } }验证方法有三条,缺一不可:走一遍完整状态流转,查sys_message表里有没有对应记录、模板名对不对;把短信通道的地址改成一个不可达的地址,看三次重试后状态有没有变成失败、告警有没有发出来;把定时任务的fixedDelay临时改成 5000,观察重复扫描同一条待发送记录时会不会重复推送——这一步能验证你的selectPending有没有加status = 0和行锁,我见过漏了条件导致客户收到七八条同样短信的。
扩展点也留好了:以后要加站内信、要加企业微信,只需在push里按channel字段分流,状态机和业务代码一行不用动。
血泪经验是,我早年在一个类似的理赔项目上,图省事直接在核赔通过的 service 里同步调短信接口,结果网关抖动那半小时,所有核赔操作全部超时回滚,核赔员以为系统坏了,人工记在纸上,对账对了三天。从那以后我每接一个带状态流转的项目,先把「状态变更」和「对外通知」用一张消息表隔开,再动手写业务。这个习惯看着笨,但它能让你半夜不被电话叫醒。希望帮到你。
本文还有配套的精品资源,点击获取