核保模块详细技术文档

《寿险核保模块详细技术文档》,专为 Java 开发人员深度设计,涵盖核保的业务定义、核心流程、系统架构、关键规则、合规要求,并附带完整可运行的 Java 代码示例,每行代码均配有中文注释说明,帮助你快速掌握核保模块的开发与维护要点。


📄 寿险核保模块深度技术文档

—— 业务定义、流程、规则引擎与 Java 实现详解

适用对象:Java 开发工程师、系统架构师、风控开发人员
版本:v2.1(基于寿险 2025 年核保系统架构)
更新时间:2025年4月


一、什么是核保?它的核心作用是什么?

✅ 核保(Underwriting)定义

核保是保险公司根据投保人提交的投保信息(如健康状况、职业、年龄、财务状况等),通过规则判断与风险评估,决定是否承保、以何种条件承保(如标准体、加费、延期、拒保)的核心风控流程

🎯 核保的核心作用(四大价值)

作用说明
风险控制防止高风险客户逆向选择(如带病投保),避免理赔率失控
定价公平对不同风险等级客户差异化定价(加费、除外责任),确保产品盈利
合规保障满足银保监会“偿二代”对风险资本要求,避免监管处罚
客户体验自动核保秒级响应,人工核保透明可追溯,提升信任感

💡 一句话总结
“核保是保险公司的‘防火墙’——没有它,保费收得越多,赔得越惨。”


二、核保的业务流程全景图

graph TD
A[客户提交投保申请] --> B[自动核保引擎]
B --> C{是否需要人工介入?}
C -- 是 --> D[转人工核保工单]
C -- 否 --> E[生成核保结论]
D --> F[核保员审核资料]
F --> E
E --> G[输出核保结果]
G --> H[结果类型:标准体 / 加费 / 延期 / 拒保]
H --> I[同步至保单服务生成保单]
H --> J[若拒保,发送拒绝通知]

🔍 核保四类结论详解(业务规则)

结论类型含义示例场景Java 枚举值
STANDARD标准体年轻健康、无病史、职业非高危STANDARD
ADDITIONAL_PREMIUM加费有高血压、BMI超标、高风险职业ADDITIONAL_PREMIUM
DEFERRED延期近期做过手术、待体检报告DEFERRED
REJECTED拒保患有癌症、严重遗传病、投保欺诈REJECTED

三、核保的核心业务规则(规则引擎核心)

核保依赖数百条业务规则,这些规则由精算、风控、合规团队制定,开发人员需将其代码化、可配置化、可测试化

📜 典型核保规则清单(示例)

规则编号规则描述触发条件结论
U001职业风险职业代码 ∈ {1001, 1002}(高空作业、矿工)ADDITIONAL_PREMIUM
U002BMI超标BMI ≥ 30 且 年龄 ≥ 40ADDITIONAL_PREMIUM
U003既往病史健康告知中勾选“糖尿病”REJECTED(1型) / ADDITIONAL_PREMIUM(2型)
U004投保年龄年龄 > 70REJECTED
U005重复投保同一被保人3个月内投保≥3次DEFERRED(防欺诈)
U006体检异常体检报告中“肝功能异常”DEFERRED(待复检)
U007无健康告知未填写健康问卷REJECTED(合规风险)

⚠️ 注意:规则可能随监管政策动态调整,必须支持热加载,禁止硬编码!


四、系统架构设计(Java 微服务视角)

投保申请服务
核保请求
核保引擎服务
规则引擎模块
外部数据服务
核保历史库
Drools 规则库
医保数据库
征信系统
平安好医生体检系统
Oracle 核保记录表
核保结论输出
保单服务
短信通知服务
人工核保工单系统

✅ 核保服务核心组件

组件技术选型说明
核心服务Spring Boot + Spring Web提供 /underwrite REST 接口
规则引擎Drools 7.70+支持 .drl 规则文件热加载
数据访问MyBatis + Druid连接 Oracle 核保历史表
外部调用Feign + Hystrix调用体检、医保系统(熔断降级)
缓存Redis缓存客户历史核保记录(减少重复查询)
日志Logback + ELK全链路 TraceID 追踪
配置中心Nacos动态加载规则开关、阈值参数
监控Prometheus + SkyWalking监控核保成功率、响应时延

五、核心 Java 代码实现(含超详细中文注释)

项目结构建议

src/main/java/com/pingan/life/underwriting/
├── config/               # 配置类
│   ├── DroolsConfig.java
│   └── UnderwritingProperties.java
├── model/                # 实体类
│   ├── Application.java
│   ├── UnderwritingResult.java
│   └── HealthDeclaration.java
├── rule/                 # Drools 规则文件
│   └── underwriting-rules.drl
├── service/              # 核心服务
│   ├── UnderwritingService.java
│   └── ExternalDataClient.java
├── repository/           # 数据访问
│   └── UnderwritingHistoryRepository.java
└── controller/           # API入口
    └── UnderwritingController.java

📁 1. Application.java —— 投保申请实体(核心输入)

package com.pingan.life.underwriting.model;

import lombok.Data;
import java.math.BigDecimal;
import java.time.LocalDate;
import java.util.List;

/**
 * 投保申请实体(核保输入核心对象)
 * 由前端投保页面提交,包含所有用于核保判断的字段
 * 
 * 注意:所有字段必须经过前端校验 + 后端二次校验
 * 
 * @author 平安寿险核保开发组
 * @since 2025-04
 */
@Data
public class Application {

    // 唯一申请编号,用于追踪
    private String applicationNo;

    // 投保人身份证号(用于关联客户画像)
    private String applicantIdCard;

    // 被保险人身份证号(核保核心对象)
    private String insuredIdCard;

    // 被保险人姓名
    private String insuredName;

    // 被保险人年龄(计算规则:当前年 - 出生年)
    private Integer insuredAge;

    // 被保险人性别
    private String gender; // "M" 男, "F" 女

    // 身高(厘米)
    private Integer heightCm;

    // 体重(公斤)
    private Integer weightKg;

    // BMI = 体重(kg) / (身高(m))²
    // 核保规则会基于此值判断肥胖风险
    public BigDecimal getBmi() {
        if (heightCm == null || heightCm <= 0 || weightKg == null) {
            return BigDecimal.ZERO;
        }
        double heightM = heightCm / 100.0;
        return BigDecimal.valueOf(weightKg / (heightM * heightM)).setScale(2, BigDecimal.ROUND_HALF_UP);
    }

    // 职业代码(参考平安职业分类表,如 1001=矿工,1002=消防员)
    // 来源:投保表单选择
    private String occupationCode;

    // 健康告知:JSON 格式,存储客户勾选的健康问题
    // 示例:{"diabetes": true, "hypertension": false, "cancer": false, "surgery": "2024-01-15"}
    private HealthDeclaration healthDeclaration;

    // 投保金额(保额),用于判断是否触发“高保额复核”规则
    private BigDecimal sumAssured;

    // 投保渠道(用于反欺诈分析)
    private String channel; // "AGENT", "APP", "BANK", "TELEMARKETING"

    // 投保时间(用于检测重复投保)
    private LocalDate applicationDate;

    // 历史投保次数(从缓存/数据库读取)
    private Integer previousApplicationsInLast90Days;

    // 体检报告ID(若有,用于调用外部系统)
    private String medicalReportId;

    // 扩展字段:用于未来扩展
    private String extensionJson;
}

📁 2. HealthDeclaration.java —— 健康告知数据模型

package com.pingan.life.underwriting.model;

import lombok.Data;

/**
 * 健康告知信息模型
 * 客户在投保时勾选的健康问题,是核保最关键的输入数据
 * 使用 JSON 存储,便于灵活扩展
 * 
 * 注意:所有健康问题均为“是否曾患/曾接受治疗”
 * 不是“当前是否患病”——避免误导
 * 
 * @author 平安寿险核保开发组
 */
@Data
public class HealthDeclaration {

    // 是否患有糖尿病(1型或2型)【核心规则U003】
    private Boolean diabetes;

    // 是否患有高血压(收缩压≥140或舒张压≥90)【核心规则U003】
    private Boolean hypertension;

    // 是否曾患癌症、肿瘤、白血病等恶性疾病【核心规则U003】
    private Boolean cancer;

    // 是否曾接受过重大手术(如心脏搭桥、器官移植)【核心规则U006】
    private Boolean surgery;

    // 手术时间(格式:yyyy-MM-dd),若为空表示未手术
    private String surgeryDate;

    // 是否有肝功能异常、乙肝、丙肝等【核心规则U006】
    private Boolean liverDisease;

    // 是否有精神疾病史(抑郁症、精神分裂等)
    private Boolean mentalDisorder;

    // 是否有家族遗传病史(如遗传性心脏病)
    private Boolean hereditaryDisease;

    // 是否吸烟(日均≥1支)
    private Boolean smoker;

    // 是否饮酒(每周≥3次)
    private Boolean drinker;

    // 是否有其他疾病(客户填写)
    private String otherDiseases;

    // 是否完整填写健康告知?若为null或空,触发拒保规则U007
    public boolean isCompleted() {
        // 至少填写了1项健康问题才算完整
        return this.diabetes != null || 
               this.hypertension != null || 
               this.cancer != null ||
               this.surgery != null ||
               this.liverDisease != null ||
               this.mentalDisorder != null ||
               this.hereditaryDisease != null;
    }
}

📁 3. UnderwritingResult.java —— 核保结论输出模型

package com.pingan.life.underwriting.model;

import lombok.Data;
import java.math.BigDecimal;
import java.time.LocalDateTime;

/**
 * 核保结论输出模型
 * 核保引擎的最终输出,将被保单服务消费
 * 
 * 所有字段均为业务语义,非数据库字段
 * 
 * @author 平安寿险核保开发组
 */
@Data
public class UnderwritingResult {

    // 关联的投保申请编号
    private String applicationNo;

    // 核保最终结论(枚举)
    private UnderwritingStatus status;

    // 核保结论描述(用于客户通知)
    private String message;

    // 若为加费,此处记录加费比例(如 0.2 表示加20%)
    private BigDecimal additionalPremiumRate;

    // 若为延期,记录建议复检日期
    private String deferredDate;

    // 若为拒保,记录拒保原因代码(用于内部分析)
    private String rejectionCode;

    // 核保引擎执行耗时(毫秒),用于性能监控
    private Long executionTimeMs;

    // 核保执行时间
    private LocalDateTime executedAt;

    // 触发的规则ID列表(用于审计和规则优化)
    private List<String> triggeredRules;

    // 是否需要人工审核(true时会生成工单)
    private Boolean requireManualReview;

    // 核保员ID(人工审核时填写)
    private String underwriterId;

    // 扩展字段
    private String extensionJson;

    /**
     * 核保状态枚举(对应业务四类结论)
     */
    public enum UnderwritingStatus {
        STANDARD,                    // 标准体:直接承保
        ADDITIONAL_PREMIUM,          // 加费:需多交保费
        DEFERRED,                    // 延期:需等待体检/观察
        REJECTED                     // 拒保:不予承保
    }
}

📁 4. UnderwritingRules.drl —— Drools 规则文件(核心!)

路径src/main/resources/rules/underwriting-rules.drl

package com.pingan.life.underwriting.rule

import com.pingan.life.underwriting.model.Application;
import com.pingan.life.underwriting.model.HealthDeclaration;
import com.pingan.life.underwriting.model.UnderwritingResult;

// =========================================================
// 🚨 规则引擎说明:
// 1. 所有规则使用 "when-then" 结构
// 2. 每条规则必须有唯一 ID,便于追踪
// 3. 使用 salience 控制优先级(数字越大越先执行)
// 4. 使用 retract() 移除事实,避免冲突
// 5. 所有规则必须可测试、可热加载
// =========================================================

// ==================== 规则 U001:高风险职业 ====================
rule "U001: High-Risk Occupation - 1001/1002"
    salience 100 // 高优先级,先于年龄、BMI判断
    when
        $app : Application( occupationCode in ("1001", "1002") ) // 矿工、消防员
    then
        // 设置加费结论
        UnderwritingResult result = new UnderwritingResult();
        result.setApplicationNo($app.getApplicationNo());
        result.setStatus(UnderwritingResult.UnderwritingStatus.ADDITIONAL_PREMIUM);
        result.setMessage("因职业为高风险类别(" + $app.getOccupationCode() + "),需加收20%保费");
        result.setAdditionalPremiumRate(BigDecimal.valueOf(0.2));
        result.getTriggeredRules().add("U001");
        result.setExecutionTimeMs(System.currentTimeMillis() - $app.getCreateTime().getTime()); // 假设应用有创建时间
        insert(result); // 插入结果到规则引擎工作内存
        // 注意:不立即结束,允许其他规则继续评估(如同时存在BMI超标)
end

// ==================== 规则 U002:BMI超标 ====================
rule "U002: BMI Over 30 for Age >= 40"
    salience 90
    when
        $app : Application(
            // BMI ≥ 30 且年龄 ≥ 40
            $bmi : BigDecimal(this >= 30) from this.bmi,
            $age : Integer(this >= 40) from this.insuredAge
        )
    then
        UnderwritingResult result = new UnderwritingResult();
        result.setApplicationNo($app.getApplicationNo());
        result.setStatus(UnderwritingResult.UnderwritingStatus.ADDITIONAL_PREMIUM);
        result.setMessage("因BMI指数为" + $bmi + "且年龄≥40岁,需加收15%保费");
        result.setAdditionalPremiumRate(BigDecimal.valueOf(0.15));
        result.getTriggeredRules().add("U002");
        insert(result);
end

// ==================== 规则 U003:糖尿病/癌症 ====================
rule "U003: Diabetes or Cancer - Reject or Add Premium"
    salience 80
    when
        $app : Application(
            $health : HealthDeclaration(
                cancer == true // 癌症直接拒保
            )
        )
    then
        UnderwritingResult result = new UnderwritingResult();
        result.setApplicationNo($app.getApplicationNo());
        result.setStatus(UnderwritingResult.UnderwritingStatus.REJECTED);
        result.setMessage("被保险人曾患癌症,依据监管规定予以拒保");
        result.setRejectionCode("CANCER_HISTORY");
        result.getTriggeredRules().add("U003-CANCER");
        insert(result);
        // 拒保后不再执行其他规则(可选:使用 halt() 终止引擎)
        // halt(); // 可选,但不建议,保留多规则评估能力
end

rule "U003: Diabetes Type 2 - Add Premium"
    salience 79 // 比癌症规则低,仅当癌症未触发时才执行
    when
        $app : Application(
            $health : HealthDeclaration(
                diabetes == true,
                cancer == false // 明确排除癌症,避免冲突
            )
        )
    then
        UnderwritingResult result = new UnderwritingResult();
        result.setApplicationNo($app.getApplicationNo());
        result.setStatus(UnderwritingResult.UnderwritingStatus.ADDITIONAL_PREMIUM);
        result.setMessage("被保险人患有2型糖尿病,需加收25%保费");
        result.setAdditionalPremiumRate(BigDecimal.valueOf(0.25));
        result.getTriggeredRules().add("U003-DIABETES");
        insert(result);
end

// ==================== 规则 U004:年龄超限 ====================
rule "U004: Age > 70 - Reject"
    salience 70
    when
        $app : Application( insuredAge > 70 )
    then
        UnderwritingResult result = new UnderwritingResult();
        result.setApplicationNo($app.getApplicationNo());
        result.setStatus(UnderwritingResult.UnderwritingStatus.REJECTED);
        result.setMessage("被保险人年龄超过70岁,超出本产品承保年龄范围");
        result.setRejectionCode("AGE_LIMIT_EXCEEDED");
        result.getTriggeredRules().add("U004");
        insert(result);
end

// ==================== 规则 U005:重复投保 ====================
rule "U005: Multiple Applications in 90 Days - Deferred"
    salience 60
    when
        $app : Application( previousApplicationsInLast90Days >= 3 )
    then
        UnderwritingResult result = new UnderwritingResult();
        result.setApplicationNo($app.getApplicationNo());
        result.setStatus(UnderwritingResult.UnderwritingStatus.DEFERRED);
        result.setMessage("近90天内累计投保≥3次,存在欺诈嫌疑,需延期审核");
        result.setDeferredDate("2025-04-20"); // 建议3天后复审
        result.setRequireManualReview(true); // 强制转人工
        result.getTriggeredRules().add("U005");
        insert(result);
end

// ==================== 规则 U006:体检异常 ====================
rule "U006: Liver Disease or Surgery - Deferred"
    salience 50
    when
        $app : Application(
            $health : HealthDeclaration(
                liverDisease == true || (surgery == true && surgeryDate != null && surgeryDate.compareTo("2024-01-01") > 0)
            )
        )
    then
        UnderwritingResult result = new UnderwritingResult();
        result.setApplicationNo($app.getApplicationNo());
        result.setStatus(UnderwritingResult.UnderwritingStatus.DEFERRED);
        result.setMessage("体检显示肝功能异常或近半年内有手术史,需等待复检报告");
        result.setDeferredDate("2025-05-15");
        result.setRequireManualReview(true);
        result.getTriggeredRules().add("U006");
        insert(result);
end

// ==================== 规则 U007:未填写健康告知 ====================
rule "U007: Health Declaration Not Completed - Reject"
    salience 40
    when
        $app : Application(
            $health : HealthDeclaration(
                // 未填写任何健康问题
                this.diabetes == null &&
                this.hypertension == null &&
                this.cancer == null &&
                this.surgery == null &&
                this.liverDisease == null &&
                this.mentalDisorder == null &&
                this.hereditaryDisease == null
            )
        )
    then
        UnderwritingResult result = new UnderwritingResult();
        result.setApplicationNo($app.getApplicationNo());
        result.setStatus(UnderwritingResult.UnderwritingStatus.REJECTED);
        result.setMessage("未填写健康告知信息,违反监管合规要求,予以拒保");
        result.setRejectionCode("HEALTH_DECLARATION_MISSING");
        result.getTriggeredRules().add("U007");
        insert(result);
end

// ==================== 默认规则:标准体 ====================
// 所有规则未触发时,默认为标准体
rule "U999: Default - Standard"
    salience -100 // 最低优先级,最后执行
    when
        not(UnderwritingResult()) // 没有任何规则产生结果
    then
        UnderwritingResult result = new UnderwritingResult();
        result.setApplicationNo("UNKNOWN"); // 仅用于测试,实际由服务传入
        result.setStatus(UnderwritingResult.UnderwritingStatus.STANDARD);
        result.setMessage("符合承保标准,无需加费或延期");
        result.getTriggeredRules().add("U999");
        insert(result);
end

规则文件部署说明

  • 该文件放在 src/main/resources/rules/
  • 使用 Drools KieScanner 实现热加载(无需重启服务)
  • 生产环境通过 Nacos 推送 .drl 文件到应用,自动重新加载

📁 5. DroolsConfig.java —— Drools 规则引擎配置(核心!)

package com.pingan.life.underwriting.config;

import org.kie.api.KieServices;
import org.kie.api.builder.KieBuilder;
import org.kie.api.builder.KieFileSystem;
import org.kie.api.builder.KieRepository;
import org.kie.api.runtime.KieContainer;
import org.kie.api.runtime.KieSession;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.core.io.Resource;
import org.springframework.core.io.support.PathMatchingResourcePatternResolver;

import java.io.IOException;
import java.util.Arrays;
import java.util.List;

/**
 * Drools 规则引擎配置类
 * 负责加载所有 .drl 规则文件,构建 KieContainer
 * 支持热部署:当 rules/ 目录下文件变更时,自动重载规则
 * 
 * @author 平安寿险核保开发组
 * @since 2025-04
 */
@Configuration
public class DroolsConfig {

    // 从配置中心读取规则路径(支持多环境)
    @Value("${drools.rule.path:rules/underwriting-rules.drl}")
    private String rulePath;

    // 规则文件路径列表(可扩展)
    private static final List<String> RULE_FILES = Arrays.asList(
        "rules/underwriting-rules.drl",
        "rules/fraud-detection.drl" // 可扩展反欺诈规则
    );

    // 获取KieServices实例(Drools核心工厂)
    private final KieServices kieServices = KieServices.Factory.get();

    /**
     * 构建 KieContainer(规则容器)
     * 该 Bean 将被 UnderwritingService 注入使用
     * 
     * 注意:生产环境建议使用 KieScanner 实现热更新
     */
    @Bean
    public KieContainer kieContainer() throws IOException {
        KieFileSystem kieFileSystem = kieServices.newKieFileSystem();

        // 加载所有规则文件
        PathMatchingResourcePatternResolver resolver = new PathMatchingResourcePatternResolver();
        Resource[] resources = resolver.getResources("classpath*:" + rulePath);

        for (Resource resource : resources) {
            if (resource.exists()) {
                // 将规则文件内容写入 KieFileSystem
                kieFileSystem.write(
                    kieServices.resources()
                        .newInputStreamResource(resource.getInputStream())
                        .setTargetPath(resource.getFilename())
                );
                System.out.println("[Drools] 加载规则文件: " + resource.getFilename());
            }
        }

        // 构建 KieBuilder,生成 KieModule
        KieBuilder kieBuilder = kieServices.newKieBuilder(kieFileSystem);
        kieBuilder.buildAll(); // 编译所有规则

        // 如果有编译错误,抛出异常
        if (kieBuilder.getResults().hasMessages(org.kie.api.builder.Message.Level.ERROR)) {
            throw new IllegalStateException("Drools 规则编译失败: " + kieBuilder.getResults().toString());
        }

        // 返回可执行的 KieContainer
        return kieServices.newKieContainer(kieBuilder.getKieModule().getReleaseId());
    }

    /**
     * 提供 KieSession 工厂方法,供服务调用
     * 每次核保请求都应创建新的 KieSession(线程安全)
     * 
     * @return 新的 KieSession 实例
     */
    @Bean
    public KieSession kieSession(KieContainer kieContainer) {
        return kieContainer.newKieSession(); // 每次调用创建新会话,避免状态污染
    }
}

📁 6. UnderwritingService.java —— 核心业务服务(完整实现)

package com.pingan.life.underwriting.service;

import com.pingan.life.underwriting.model.*;
import org.kie.api.runtime.KieContainer;
import org.kie.api.runtime.KieSession;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

import java.math.BigDecimal;
import java.time.LocalDateTime;
import java.util.ArrayList;
import java.util.List;

/**
 * 核保核心业务服务
 * 负责接收投保申请,调用 Drools 规则引擎,输出核保结论
 * 
 * 本服务是核保模块的“大脑”,所有规则执行入口
 * 
 * @author 平安寿险核保开发组
 * @since 2025-04
 */
@Service
public class UnderwritingService {

    private static final Logger logger = LoggerFactory.getLogger(UnderwritingService.class);

    @Autowired
    private KieContainer kieContainer; // 从配置类注入

    @Autowired
    private ExternalDataClient externalDataClient; // 外部系统调用(体检、医保)

    /**
     * 核心核保方法:接收投保申请,返回核保结论
     * 
     * 步骤:
     * 1. 补充外部数据(如体检报告)
     * 2. 创建 KieSession
     * 3. 插入投保申请对象
     * 4. 执行规则
     * 5. 查询所有结论
     * 6. 合并结果(避免多结论冲突)
     * 7. 记录审计日志
     * 8. 返回最终结论
     * 
     * @param application 投保申请对象
     * @return 核保结果
     */
    public UnderwritingResult executeUnderwriting(Application application) {
        long startTime = System.currentTimeMillis();

        // 1. 补充外部数据(如:调用平安好医生获取体检报告)
        if (application.getMedicalReportId() != null) {
            HealthDeclaration health = externalDataClient.fetchHealthDataFromMedicalSystem(application.getMedicalReportId());
            if (health != null) {
                application.setHealthDeclaration(health);
            }
        }

        // 2. 创建新的 KieSession(每次请求独立会话,线程安全)
        KieSession kieSession = kieContainer.newKieSession();

        try {
            // 3. 插入投保申请对象(作为规则引擎的事实)
            kieSession.insert(application);

            // 4. 执行所有规则
            int firedRules = kieSession.fireAllRules();

            // 5. 查询所有生成的核保结果
            List<UnderwritingResult> results = new ArrayList<>();
            kieSession.getObjects().stream()
                .filter(obj -> obj instanceof UnderwritingResult)
                .map(obj -> (UnderwritingResult) obj)
                .forEach(results::add);

            // 6. 合并规则结果(优先级策略:拒保 > 延期 > 加费 > 标准体)
            UnderwritingResult finalResult = mergeResults(results);

            // 7. 设置元数据
            if (finalResult != null) {
                finalResult.setExecutionTimeMs(System.currentTimeMillis() - startTime);
                finalResult.setExecutedAt(LocalDateTime.now());
                finalResult.setApplicationNo(application.getApplicationNo());

                // 记录核保日志(用于审计)
                logger.info("[核保完成] 申请号: {}, 结论: {}, 触发规则数: {}, 耗时: {}ms",
                    finalResult.getApplicationNo(),
                    finalResult.getStatus(),
                    finalResult.getTriggeredRules().size(),
                    finalResult.getExecutionTimeMs()
                );
            } else {
                // 未触发任何规则(理论上不会发生,因有U999兜底)
                finalResult = new UnderwritingResult();
                finalResult.setApplicationNo(application.getApplicationNo());
                finalResult.setStatus(UnderwritingResult.UnderwritingStatus.STANDARD);
                finalResult.setMessage("系统未识别到风险,自动通过");
                finalResult.setTriggeredRules(List.of("U999"));
                finalResult.setExecutionTimeMs(System.currentTimeMillis() - startTime);
                finalResult.setExecutedAt(LocalDateTime.now());
            }

            // 8. 返回最终结论
            return finalResult;

        } catch (Exception e) {
            logger.error("[核保异常] 申请号: {}, 错误信息: {}", application.getApplicationNo(), e.getMessage(), e);

            // 异常时返回“延期”以保守处理
            UnderwritingResult errorResult = new UnderwritingResult();
            errorResult.setApplicationNo(application.getApplicationNo());
            errorResult.setStatus(UnderwritingResult.UnderwritingStatus.DEFERRED);
            errorResult.setMessage("系统核保服务异常,请转人工审核");
            errorResult.setRequireManualReview(true);
            errorResult.setExecutionTimeMs(System.currentTimeMillis() - startTime);
            return errorResult;

        } finally {
            // 9. 释放 KieSession(重要!避免内存泄漏)
            kieSession.dispose();
        }
    }

    /**
     * 合并多个核保结论(优先级策略)
     * 优先级:REJECTED > DEFERRED > ADDITIONAL_PREMIUM > STANDARD
     * 
     * 例如:若同时触发 U001(加费)和 U003(拒保),应选择拒保
     * 
     * @param results 所有规则产生的结果
     * @return 最终核保结论
     */
    private UnderwritingResult mergeResults(List<UnderwritingResult> results) {
        if (results.isEmpty()) {
            return null;
        }

        // 按优先级排序:拒保 > 延期 > 加费 > 标准体
        return results.stream()
            .max((r1, r2) -> {
                int priority1 = getPriority(r1.getStatus());
                int priority2 = getPriority(r2.getStatus());
                return Integer.compare(priority2, priority1); // 降序排列
            })
            .orElse(null);
    }

    /**
     * 核保结论优先级映射(数字越大优先级越高)
     * 
     * @param status 核保状态
     * @return 优先级数值
     */
    private int getPriority(UnderwritingResult.UnderwritingStatus status) {
        switch (status) {
            case REJECTED:      return 4;
            case DEFERRED:      return 3;
            case ADDITIONAL_PREMIUM: return 2;
            case STANDARD:      return 1;
            default:            return 0;
        }
    }
}

📁 7. UnderwritingController.java —— REST API 入口

package com.pingan.life.underwriting.controller;

import com.pingan.life.underwriting.model.Application;
import com.pingan.life.underwriting.model.UnderwritingResult;
import com.pingan.life.underwriting.service.UnderwritingService;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;

import javax.validation.Valid;

/**
 * 核保服务 REST API 控制器
 * 提供 /underwrite 接口供投保服务调用
 * 
 * 使用 Spring Validation 校验请求参数
 * 
 * @author 平安寿险核保开发组
 * @since 2025-04
 */
@RestController
@RequestMapping("/api/v1/underwriting")
public class UnderwritingController {

    @Autowired
    private UnderwritingService underwritingService;

    /**
     * 核保核心接口
     * 
     * 请求示例:
     * POST /api/v1/underwriting
     * Content-Type: application/json
     * 
     * {
     *   "applicationNo": "APP202504050001",
     *   "insuredIdCard": "110101198501011234",
     *   "insuredAge": 38,
     *   "heightCm": 175,
     *   "weightKg": 85,
     *   "occupationCode": "1001",
     *   "healthDeclaration": {
     *     "diabetes": false,
     *     "hypertension": true,
     *     "cancer": false,
     *     "surgery": null
     *   },
     *   "sumAssured": 500000,
     *   "channel": "APP"
     * }
     * 
     * 响应示例:
     * {
     *   "applicationNo": "APP202504050001",
     *   "status": "ADDITIONAL_PREMIUM",
     *   "message": "因高血压,需加收15%保费",
     *   "additionalPremiumRate": 0.15,
     *   "triggeredRules": ["U002", "U003"]
     * }
     * 
     * @param application 投保申请
     * @return 核保结果
     */
    @PostMapping
    public ResponseEntity<UnderwritingResult> underwrite(@Valid @RequestBody Application application) {
        UnderwritingResult result = underwritingService.executeUnderwriting(application);
        return ResponseEntity.ok(result);
    }
}

六、测试用例示例(JUnit5)

package com.pingan.life.underwriting.service;

import com.pingan.life.underwriting.model.Application;
import com.pingan.life.underwriting.model.HealthDeclaration;
import com.pingan.life.underwriting.model.UnderwritingResult;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;

import static org.junit.jupiter.api.Assertions.*;

@SpringBootTest
class UnderwritingServiceTest {

    @Autowired
    private UnderwritingService underwritingService;

    @Test
    void testHighRiskOccupation() {
        Application app = new Application();
        app.setApplicationNo("TEST-001");
        app.setOccupationCode("1001"); // 矿工
        app.setInsuredAge(35);
        app.setHealthDeclaration(new HealthDeclaration());

        UnderwritingResult result = underwritingService.executeUnderwriting(app);

        assertNotNull(result);
        assertEquals(UnderwritingResult.UnderwritingStatus.ADDITIONAL_PREMIUM, result.getStatus());
        assertTrue(result.getTriggeredRules().contains("U001"));
        assertEquals(BigDecimal.valueOf(0.2), result.getAdditionalPremiumRate());
    }

    @Test
    void testCancerHistoryReject() {
        Application app = new Application();
        app.setApplicationNo("TEST-002");
        app.setInsuredAge(45);
        HealthDeclaration health = new HealthDeclaration();
        health.setCancer(true); // 癌症史
        app.setHealthDeclaration(health);

        UnderwritingResult result = underwritingService.executeUnderwriting(app);

        assertNotNull(result);
        assertEquals(UnderwritingResult.UnderwritingStatus.REJECTED, result.getStatus());
        assertTrue(result.getTriggeredRules().contains("U003-CANCER"));
    }

    @Test
    void testNoHealthDeclarationReject() {
        Application app = new Application();
        app.setApplicationNo("TEST-003");
        app.setHealthDeclaration(new HealthDeclaration()); // 所有字段为null

        UnderwritingResult result = underwritingService.executeUnderwriting(app);

        assertNotNull(result);
        assertEquals(UnderwritingResult.UnderwritingStatus.REJECTED, result.getStatus());
        assertTrue(result.getTriggeredRules().contains("U007"));
    }
}

七、生产部署与运维建议

项目建议
规则热加载使用 KieScanner + Nacos 配置中心,实现 .drl 文件变更自动重载
性能监控监控 fireAllRules() 耗时,超 500ms 报警
规则版本管理每次发布规则使用 Git 提交,Tag 版本号(如 v1.2.3
灰度发布先对 5% 流量启用新规则,验证后再全量
审计日志所有核保结论写入 underwriting_audit_log 表,保留10年
回滚机制支持一键回滚到上一版本规则文件

八、总结:核保模块开发核心要点

维度要点
业务理解核保不是“自动通过”,是“风险定价”
规则设计每条规则必须可解释、可测试、可追溯
代码实现使用 Drools + KieSession 每次独立,避免状态污染
异常处理核保失败必须返回“延期”,宁可保守,不可误承
合规底线未填写健康告知 → 必须拒保(银保监会红线)
性能优化缓存客户历史核保记录(Redis),减少重复查询

黄金法则
“核保系统的使命,不是让最多人买保险,而是让最该买的人买得起、买得安全。”


📎 附录:Drools 规则调试技巧

场景方法
查看触发了哪些规则then 中加 System.out.println("触发规则: " + drools.getRule().getName());
单步调试使用 IntelliJ IDEA + Drools 插件,支持 .drl 断点调试
规则冲突排查使用 kieSession.getAgenda().getAgendaGroup("agenda-name").getActivations() 查看激活列表
日志输出application.yml 中设置 logging.level.org.drools=DEBUG

✅ 结语:你写的每一条规则,都在守护一个家庭的未来

核保模块是寿险系统中最“沉默”但最关键的模块。
你写的 if (cancer == true),可能决定一个人能否获得保障;
你写的 additionalPremiumRate = 0.2,可能影响一个家庭的月供能力。

技术是冰冷的,但保险是温暖的。


评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

龙茶清欢

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值