Appearance
保险理赔怎么核验报案地点?用 IP 归属地和报案信息交叉比对(Java 实现)
核赔岗每天要看几十上百份材料:事故描述、报案地点、就诊医院、被保险人常住地。人工逐份核,看的是「哪几份前后对不上」。这些矛盾很少写在明面上——报案写着「在杭州出的事」,操作却全部来自一千公里外的某个 IP,或者同一时段几十个案子从同一个机房网段提交上来。
IP 归属地在这个环节里有用,但它是个弱信号:只能用来打标、排序、决定抽检比例,不能直接下结论。这篇给一套 Java 实现,把「属地一致性 + 时间线 + 网段聚合」三层判断做成可配置的服务,输出给核赔员看的是人能读懂的原因,不是一个冷冰冰的分数。
一、三类值得盯的矛盾
投保 / 保全环节的属地不一致。 被保险人申报常住地在江苏,但半年内的投保、受益人变更、保单贷款操作几乎全部来自广东的 IP。这不是证据,只是一个值得核实的线索——真实的解释可能是长期异地工作,也可能是「跑腿代办」。把它标出来,让核保去问一句。
理赔报案的地点和操作属地矛盾。 报案描述的事故地在 A 市,上传材料、补充资料的 IP 属地一直在 B 市,且时间上集中在报案前后两小时。这类案子优先核实,收益比随机抽检高得多。
同一网段的批量提交。 某个时间段内,几十份不同被保险人的案件从同一个 IDC 机房出口网段提交。免费版拿不到 IP 类型,只能靠 isp 文本和网段聚合观察;VIP 版的 ip_type 能直接区分是不是 IDC 机房,判断更干净。
二、设计原则:只做标记,不做裁决
这一点必须写在最前面。IP 归属地的误差来源太多:出差、异地就医、家属代办、保险代理人统一协助报案、企业的统一出口、手机基站 IP 漂移。任何一条都可能把一个真实案件打成「可疑」。
所以规则的设计目标不是「识别欺诈」,而是把有限的人工核赔产能放到最该看的那批案件上。具体落到三件事:
- 输出的是标签和优先级,不是「通过 / 拒绝」;
- 每条判定都带可读原因,核赔员看了知道要问什么;
- 所有分值、阈值放配置里,业务侧能调,代码不用改。
三层规则的权重建议这样安排:
| 维度 | 判断内容 | 输出 |
|---|---|---|
| 属地一致性 | 报案描述地点 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