Skip to content

保险理赔怎么核验报案地点?用 IP 归属地和报案信息交叉比对(Java 实现)

核赔岗每天要看几十上百份材料:事故描述、报案地点、就诊医院、被保险人常住地。人工逐份核,看的是「哪几份前后对不上」。这些矛盾很少写在明面上——报案写着「在杭州出的事」,操作却全部来自一千公里外的某个 IP,或者同一时段几十个案子从同一个机房网段提交上来。

IP 归属地在这个环节里有用,但它是个弱信号:只能用来打标、排序、决定抽检比例,不能直接下结论。这篇给一套 Java 实现,把「属地一致性 + 时间线 + 网段聚合」三层判断做成可配置的服务,输出给核赔员看的是人能读懂的原因,不是一个冷冰冰的分数。

一、三类值得盯的矛盾

投保 / 保全环节的属地不一致。 被保险人申报常住地在江苏,但半年内的投保、受益人变更、保单贷款操作几乎全部来自广东的 IP。这不是证据,只是一个值得核实的线索——真实的解释可能是长期异地工作,也可能是「跑腿代办」。把它标出来,让核保去问一句。

理赔报案的地点和操作属地矛盾。 报案描述的事故地在 A 市,上传材料、补充资料的 IP 属地一直在 B 市,且时间上集中在报案前后两小时。这类案子优先核实,收益比随机抽检高得多。

同一网段的批量提交。 某个时间段内,几十份不同被保险人的案件从同一个 IDC 机房出口网段提交。免费版拿不到 IP 类型,只能靠 isp 文本和网段聚合观察;VIP 版的 ip_type 能直接区分是不是 IDC 机房,判断更干净。

二、设计原则:只做标记,不做裁决

这一点必须写在最前面。IP 归属地的误差来源太多:出差、异地就医、家属代办、保险代理人统一协助报案、企业的统一出口、手机基站 IP 漂移。任何一条都可能把一个真实案件打成「可疑」。

所以规则的设计目标不是「识别欺诈」,而是把有限的人工核赔产能放到最该看的那批案件上。具体落到三件事:

  1. 输出的是标签和优先级,不是「通过 / 拒绝」;
  2. 每条判定都带可读原因,核赔员看了知道要问什么;
  3. 所有分值、阈值放配置里,业务侧能调,代码不用改。

三层规则的权重建议这样安排:

维度判断内容输出
属地一致性报案描述地点 vs 操作 IP 的省 / 市 / 大区同市 / 同省 / 跨大区
时间线报案时间、事故时间、操作行为的时间分布是否集中在异常时段
网段聚合同一 IP 网段在时间窗内的案件数是否疑似代办集中提交

三、Java 实现

两个部分:一个带缓存和限速的属地查询客户端,一个规则服务。依赖只需要 Jackson(解析 JSON)和 JDK 11+ 自带的 HttpClient

java
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;

import java.net.URI;
import java.net.URLEncoder;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.nio.charset.StandardCharsets;
import java.time.Duration;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;

public class ClaimRiskCheck {

    // ============ 属地查询客户端 ============

    static class IpLocation {
        String ip, country, prov, city, bigArea, isp;
    }

    static class IpLocationClient {
        private static final String API = "https://ip9.com.cn/get";
        private static final long TTL_MILLIS = 6 * 3600_000L;      // 归属地变化慢,缓存 6 小时
        private static final long MIN_GAP_MILLIS = 1000L;          // 免费版 60 次/分钟,按 1 QPS 打

        private final HttpClient http = HttpClient.newBuilder()
                .connectTimeout(Duration.ofMillis(500))
                .build();
        private final ObjectMapper mapper = new ObjectMapper();

        private final Map<String, Object[]> cache = new ConcurrentHashMap<>(); // ip -> [IpLocation, expireAt]
        private volatile long lastCallAt = 0L;

        /** 查询失败返回 null:调用方必须能接受「没有属地」这种状态,绝不能让核赔流程中断。 */
        public IpLocation lookup(String ip) {
            if (ip == null || ip.isBlank()) return null;

            Object[] hit = cache.get(ip);
            if (hit != null && (long) hit[1] > System.currentTimeMillis()) {
                return (IpLocation) hit[0];
            }
            IpLocation loc = fetch(ip, true);
            if (loc != null) {
                cache.put(ip, new Object[]{loc, System.currentTimeMillis() + TTL_MILLIS});
            }
            return loc;
        }

        private IpLocation fetch(String ip, boolean retryOnLimit) {
            throttle();
            try {
                HttpRequest req = HttpRequest.newBuilder()
                        .uri(URI.create(API + "?ip=" + URLEncoder.encode(ip, StandardCharsets.UTF_8)))
                        .timeout(Duration.ofMillis(800))
                        .header("User-Agent", "claim-risk/1.0")
                        .GET().build();
                HttpResponse<String> resp = http.send(req, HttpResponse.BodyHandlers.ofString());
                JsonNode root = mapper.readTree(resp.body());
                int ret = root.path("ret").asInt();

                if (ret == 400) return null;                      // IP 不合法,重试没意义
                if (ret == 429 && retryOnLimit) {                 // 撞限速,退避一次
                    Thread.sleep(1500);
                    return fetch(ip, false);
                }
                if (ret != 200) return null;

                JsonNode d = root.path("data");
                IpLocation loc = new IpLocation();
                loc.ip = d.path("ip").asText();
                loc.country = d.path("country").asText();
                loc.prov = d.path("prov").asText();
                loc.city = d.path("city").asText();
                loc.bigArea = d.path("big_area").asText();        // 海外 IP 可能为空
                loc.isp = d.path("isp").asText();
                return loc;
            } catch (Exception e) {
                return null;                                      // 超时、网络异常一律降级
            }
        }

        private synchronized void throttle() {
            long wait = lastCallAt + MIN_GAP_MILLIS - System.currentTimeMillis();
            if (wait > 0) {
                try { Thread.sleep(wait); } catch (InterruptedException ignored) { Thread.currentThread().interrupt(); }
            }
            lastCallAt = System.currentTimeMillis();
        }
    }

    // ============ 规则与结果 ============

    enum RiskTag { SAME_CITY, SAME_PROV, CROSS_AREA, UNKNOWN_LOCATION, CLUSTER_SUSPECT }

    static class ClaimContext {
        String ip;              // 本次操作来源 IP
        String claimProv;       // 报案描述的事故省份
        String claimCity;       // 报案描述的事故城市
        int    casesFromSameSubnetInWindow; // 时间窗内同网段案件数(由离线任务预计算)
    }

    static class ClaimRiskResult {
        final List<RiskTag> tags = new ArrayList<>();
        final List<String> reasons = new ArrayList<>();
        int score = 0;
        String suggestion = "正常流转";

        void add(RiskTag tag, int delta, String reason) {
            tags.add(tag);
            score += delta;
            reasons.add(reason);
        }
    }

    static class ClaimRiskService {
        private final IpLocationClient client = new IpLocationClient();

        // 阈值与权重放配置中心,这里写默认值
        private static final int WEIGHT_SAME_CITY = 0;
        private static final int WEIGHT_SAME_PROV = 10;
        private static final int WEIGHT_CROSS_AREA = 30;
        private static final int WEIGHT_UNKNOWN = 5;
        private static final int WEIGHT_CLUSTER = 25;
        private static final int MANUAL_REVIEW_SCORE = 30;   // 达到就该进人工优先队列

        public ClaimRiskResult check(ClaimContext ctx) {
            ClaimRiskResult r = new ClaimRiskResult();
            IpLocation loc = client.lookup(ctx.ip);

            if (loc == null) {
                r.add(RiskTag.UNKNOWN_LOCATION, WEIGHT_UNKNOWN, "归属地查询失败或 IP 不合法,属地维度缺失");
            } else if (same(ctx.claimCity, loc.city)) {
                r.add(RiskTag.SAME_CITY, WEIGHT_SAME_CITY,
                        "操作 IP 属地与报案城市一致(" + loc.city + ")");
            } else if (same(ctx.claimProv, loc.prov)) {
                r.add(RiskTag.SAME_PROV, WEIGHT_SAME_PROV,
                        "操作 IP 属地为 " + loc.prov + loc.city + ",与报案所在省份一致,跨市");
            } else {
                r.add(RiskTag.CROSS_AREA, WEIGHT_CROSS_AREA,
                        "操作 IP 属地为 " + loc.prov + loc.city + "(" + loc.bigArea + ","
                                + loc.isp + "),与报案地 " + ctx.claimProv + ctx.claimCity + " 不一致");
            }

            if (ctx.casesFromSameSubnetInWindow >= 10) {
                r.add(RiskTag.CLUSTER_SUSPECT, WEIGHT_CLUSTER,
                        "时间窗内同一 IP 网段提交案件 " + ctx.casesFromSameSubnetInWindow + " 件,疑似集中代办");
            }

            r.suggestion = r.score >= MANUAL_REVIEW_SCORE
                    ? "进入人工优先核实队列(建议电话确认出险地)"
                    : "正常流转";
            return r;
        }

        private boolean same(String a, String b) {
            return a != null && !a.isBlank() && a.equals(b);
        }
    }

    // ============ 调用示例 ============

    public static void main(String[] args) {
        ClaimContext ctx = new ClaimContext();
        ctx.ip = "58.246.100.1";
        ctx.claimProv = "浙江";
        ctx.claimCity = "杭州";
        ctx.casesFromSameSubnetInWindow = 3;

        ClaimRiskResult result = new ClaimRiskService().check(ctx);
        System.out.println("分值:" + result.score + "|" + result.suggestion);
        for (String reason : result.reasons) {
            System.out.println(" - " + reason);
        }
    }
}

main 里这个例子的输出是:分值 30,建议进入人工优先核实队列,原因是「操作 IP 属地为 上海上海(华东,中国联通),与报案地 浙江杭州 不一致」。核赔员看到的是这样一句话,而不是一个需要自己解读的数字。

几个实现上的小点:throttle()synchronized 把整个客户端的调用串起来,在日案件量几百到几千的规模完全够用;如果接入实时风控链路(每个操作都查一次),把限速和缓存搬到 Redis,让多实例共享,命中率能到 95% 以上。

四、落地时的边界

误伤清单先列出来,再上线。 至少这几种要能解释:家属代办(不同城市操作很正常)、异地就医后报销、保险代理人统一协助报案(往往就是同一个出口 IP)、企业统一出单出口(同一个办公网)、手机基站 IP 漂移导致同一用户在不同城市跳动。规则上线前拿历史案件跑一遍,看被标出来的案子里有多少属于这几类,比例太高就说明阈值定得激进。

自动拒赔这条线不要碰。 你能拿到的最多是一个「报案地描述」和「IP 属地」的比对结果。前者是用户填的,后者是运营商网络位置,两者都不足以支撑拒赔决定。规则触发的动作只能是「进人工队列」「提高抽检比例」这类。

IP 和查询结果都属于个人信息。 记录 IP、做归属地查询、把结果和案件关联起来,都落在《个人信息保护法》的范围内,最好在投保须知里把目的和范围写清楚,原始 IP 按需脱敏、到期清理。这块站内《IP 地址是个人信息吗》那篇讲得比较细,做保险业务前值得看一遍。

IDC 出口要单独看。 大量可疑案件往往从机房 IP 提交,而不是家庭宽带。免费版只能看 isp 文本和网段聚合,VIP 版提供 ip_type(ISP 家庭 / BUS 企业 / IDC 机房)和 ip_asn,能干净地把机房出口挑出来,配合 area(区县)判断也更细。

别把经纬度当成事故地坐标。 接口给的 lng / lat 是城市中心点,一整个城市共用一个坐标,用来在地图上看大盘分布可以,用来比对事故地点就会得出荒唐结论——同一个城市里所有案件的距离都是零。

代码里调的是 IP9 的免费接口 https://ip9.com.cn/get?ip=<IP>,不传参数时返回调用方自身 IP 的归属地,IPv4 / IPv6 都支持,返回国家、省市、大区、运营商、邮编、区号、城市中心经纬度等字段,免注册、无需鉴权,免费版 60 次/分钟,批量核验务必配缓存和限速。接口字段和 VIP 能力以官网为准:https://www.ip9.com.cn