Appearance
银行「属地化经营」怎么校验?把 IP 归属地做成多要素研判里的一票(Java 实现)
地方法人银行做本地化经营,这几年监管盯得越来越细。公开报道里能看到两个方向:一是存款端,民营银行、城商行通过自有 App 或小程序异地揽储被反复点名;二是贷款端,属地化判定被细化成一组可以量化的要素——据媒体报道,有城商行接到口径,要求对个贷客户的身份证地址、常住地、主要业务经营地、手机号归属地、设备位置定位等信息做综合研判,本地客户应至少满足其中若干项;更早的贷款口径里,"IP" 本身就是要素之一。(各地区的具体要求在变,落地一律以监管正式口径和本行制度为准,这篇只讲技术怎么接。)
这类要求落到科技部门头上,问题就变成很具体的一件事:开户、放款、提额这些节点上,系统要能把这些要素取到、算出来、并且留下痕迹。 本文讲怎么用 IP 归属地接口,把其中"IP"这一票接进 Java 服务里——包括为什么它只能算一票、未知情况怎么处理、审计日志记什么。
一、先明确要素的边界,别张冠李戴
把要素列清楚,能省掉很多返工:
| 要素 | 数据从哪来 | 备注 |
|---|---|---|
| 身份证地址 | 身份证影像 OCR / 身份核验渠道 | 不是本文范围 |
| 常住地 | 客户填报 + 佐证材料 | 需人工核验 |
| 主要经营地 | 营业执照、纳税、账户流水 | 企业客户必查 |
| 手机号归属地 | 运营商号段库 / 运营商核验 | IP 归属地接口不提供这个 |
| 设备定位 | App 定位权限、SDK | 用户可拒绝授权,会缺项 |
| IP 归属地 | IP 归属地查询接口 | 本文重点 |
这里有个容易踩的坑:手机号归属地和 IP 归属地是两回事,一个跟着卡走、一个跟着网络走。别拿一个接口去凑另一项的数据,审计的时候解释不通。
另一个现实问题是"设备定位"会缺项——用户拒绝授权、或者干脆走的是纯 Web 渠道。所以系统设计上必须假定有些要素取不到,而不是假定要素总是齐全。
二、为什么 IP 只能当"一票"
想把 IP 归属地当硬门槛用的人,多半会被两件事打回来:
准确度是城市级,不是门牌号。 IP 归属地返回的是城市中心点级别的信息,跨省用户在城市交界处、运营商双出口、动态 IP 重新分配,都会让归属地漂移。用它做"是不是本地"的判断,本身就有天然误差。
共享出口和机房 IP 会大面积误伤。 一家公司、一个园区、一所高校几千号人共用一个出口 IP;云手机、代理、加速器的出口归属地更是随便落在哪个省。把这些 IP 一律判成"异地",正常客户就投诉上门了。
所以合理的用法是多要素研判:每个要素独立判断"是否与本地口径一致",算的是满足项数,而不是某一个要素的"一票否决"。IP 归属地在其中贡献一票,权重可以调,但绝不能单独决定结论。这也正好符合"综合研判"的字面意思。
工程上还有两条原则必须坚持:
- 取不到的要素记为"未知",不参与计分,而不是记成"不符"。接口超时、被限速、内网地址查不出位置,都会导致未知,把这些当成负面证据等于自己制造误判。
- 结论分档,而不是二值。满足项数达标 → 通过;处在临界 → 补充材料后重判;明显不足 → 按制度处理并留痕。中间那档是关键,它把判断权留给了人。
三、Java 实现
下面是一个可以直接跑的完整示例,只用 JDK 自带的 HttpClient,不需要任何依赖。生产环境里通常跑在 Spring Boot 里、用 Jackson 反序列化,这里为了能独立验证,用正则取字段。
java
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.time.Duration;
import java.util.HashMap;
import java.util.Map;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
public class ShudiCheck {
private static final String API = "https://ip9.com.cn/get";
// 必须带正常 UA:默认的 Java/urllib UA 可能被前置防护拦掉
private static final String UA = "Mozilla/5.0 (X11; Linux x86_64) IP9-shudi/1.0";
private static final HttpClient HTTP = HttpClient.newBuilder()
.connectTimeout(Duration.ofSeconds(3))
.build();
private static final Map<String, IpInfo> CACHE = new HashMap<>();
/** IP 归属地结果;unknown=true 表示没查出来(超时/非 200),这一票不参与计分 */
static class IpInfo {
String country = "", prov = "", city = "", isp = "";
boolean unknown = false;
public String toString() { return country + prov + city + "/" + isp + (unknown ? "(未知)" : ""); }
}
static String pick(String json, String key) {
Matcher m = Pattern.compile("\"" + key + "\"\\s*:\\s*\"([^\"]*)\"").matcher(json);
return m.find() ? m.group(1) : "";
}
static IpInfo queryIp(String ip) {
if (CACHE.containsKey(ip)) return CACHE.get(ip);
IpInfo info = new IpInfo();
try {
HttpRequest req = HttpRequest.newBuilder(URI.create(API + "?ip=" + ip))
.header("User-Agent", UA)
.timeout(Duration.ofSeconds(5))
.GET().build();
HttpResponse<String> resp = HTTP.send(req, HttpResponse.BodyHandlers.ofString());
String body = resp.body();
if (resp.statusCode() == 200 && body.contains("\"ret\":200")) {
info.country = pick(body, "country");
info.prov = pick(body, "prov");
info.city = pick(body, "city");
info.isp = pick(body, "isp");
// 内网/保留地址:country=保留,prov/city 为空,不能当成「非本地」直接判负
if (info.prov.isEmpty() && info.city.isEmpty()) info.unknown = true;
} else {
info.unknown = true; // 400/429/403 一律按未知处理,走人工复核
}
} catch (Exception e) {
info.unknown = true; // 超时/网络异常:降级,不阻断主流程
}
CACHE.put(ip, info);
return info;
}
/** 地市名归一:把「北京市」「北京」统一成「北京」 */
static String norm(String city) {
if (city == null) return "";
return city.replace("市", "").replace("地区", "").replace("自治州", "").trim();
}
static boolean sameCity(String a, String b) {
String x = norm(a), y = norm(b);
return !x.isEmpty() && !y.isEmpty() && (x.equals(y) || x.startsWith(y) || y.startsWith(x));
}
/**
* @param localCity 网点所在地市(本地口径)
* @param other 其余要素:身份证地址、常住地、主要经营地、手机号归属地、设备定位
*/
static String judge(String ip, String localCity, Map<String, String> other) {
IpInfo ipInfo = queryIp(ip);
int hit = 0, total = 0;
StringBuilder detail = new StringBuilder();
if (!ipInfo.unknown) { // IP 是其中一票
total++;
boolean ok = sameCity(ipInfo.city, localCity) || sameCity(ipInfo.prov, localCity);
if (ok) hit++;
detail.append("IP归属地=").append(ipInfo.toString()).append(ok ? " 命中" : " 不符").append(";");
} else {
detail.append("IP归属地=未取到(不参与计分);");
}
for (Map.Entry<String, String> e : other.entrySet()) {
total++;
boolean ok = sameCity(e.getValue(), localCity);
if (ok) hit++;
detail.append(e.getKey()).append("=").append(e.getValue()).append(ok ? " 命中" : " 不符").append(";");
}
String action;
if (total == 0) action = "材料不足,转人工";
else if (hit >= 3) action = "通过(满足属地条件)";
else if (hit == 2) action = "补充材料(常住/经营地证明)后再判";
else action = "不满足属地条件,按监管口径处理并留痕";
// 审计留痕:要素、命中项数、结论、时间(生产环境落库,不要只打日志)
System.out.println("[审计] ip=" + ip + " 本地=" + localCity
+ " 命中=" + hit + "/" + total + " 结论=" + action + " :: " + detail);
return action;
}
public static void main(String[] args) {
Map<String, String> elements = new HashMap<>();
elements.put("身份证地址", "南京");
elements.put("常住地", "南京");
elements.put("主要经营地", "南京");
elements.put("手机号归属地", "南京");
elements.put("设备定位", "南京");
System.out.println(judge("114.114.114.114", "南京", elements)); // 江苏南京 -> 命中多
System.out.println("---");
elements.put("常住地", "苏州");
System.out.println(judge("101.226.4.6", "南京", elements)); // 上海 IP -> 命中少
System.out.println("---");
System.out.println(judge("192.168.1.1", "南京", elements)); // 内网 IP -> 该票未知
}
}单文件直接跑:java ShudiCheck.java。跑出来的结果:
[审计] ip=114.114.114.114 本地=南京 命中=6/6 结论=通过(满足属地条件) :: IP归属地=中国江苏南京/114DNS 命中;常住地=南京 命中;…
通过(满足属地条件)
---
[审计] ip=101.226.4.6 本地=南京 命中=4/6 结论=通过(满足属地条件) :: IP归属地=中国上海上海/DNSpai/电信 不符;常住地=苏州 不符;…
通过(满足属地条件)
---
[审计] ip=192.168.1.1 本地=南京 命中=4/5 结论=通过(满足属地条件) :: IP归属地=未取到(不参与计分);…
通过(满足属地条件)三组用例值得琢磨:
- 第一组是标准的本地客户,六个要素全中。
- 第二组是上海 IP + 常住地改成"苏州",IP 那一票明确判成"不符",但因为 IP 只有一票,总分仍然落在通过线以上。这正是多要素研判的意义:单点异常不至于把客户挡在门外。
- 第三组是内网地址,接口返回
ret=200但省市为空、isp写着"内网地址",代码把它标成未知、不参与计分,兜住了这种脏数据。
(顺带一句:这个示例的通过线设得很宽松,是为了演示。真实阈值要拿你们自己半年的开户样本回归——本地客户占比多少、每个要素各自的一致率多少,把阈值调到既不放过异地、也不误伤本地,这一步没有捷径。)
四、落地要注意的事
接口失败要降级,不要阻断。 上面代码里超时、非 200 一律记为未知。开户这种主流程如果因为三方接口抖动就报错,业务方第二天就会来投诉。属地校验是加分项,不是准入门槛——至少不能是唯一的门槛。
调用时机要挑,别做成全量轮询。 免费版接口是 60 次/分钟的量级,如果不加缓存、每次登录都查一遍,一万人同时在线立刻就顶爆。合理做法是:只在关键节点查(开户、大额交易、提额、风险事件触发时),并且按 IP 做缓存(同一个 IP 的归属地几天内不会变,缓存 1~3 天完全够)。查询结果连同"查的是哪个 IP、什么时候查的、接口返回了什么"一起入库,这才是审计要的东西。
要素不全时不要硬算。 设备定位缺项、身份证地址是集体户、客户是纯对公账户——这些都会让某个要素没有意义。系统的输出应该是"命中 N 项 / 有效 M 项",而不是一个漂亮但无意义的百分比。
留痕的度要合规。 IP 地址属于个人信息,属地化校验又是监管要求,两边都得照顾:只存必要字段(最好存到省市级别,不长期保留原始 IP),明确告知客户(隐私政策里写清用途),按制度定期清理。这部分建议让合规部门在需求阶段就签字,别等系统上线了再补。
要更细的粒度(区县、机房类型、AS 号)得换版本。 免费版返回的是国家/省/市/大区/运营商这一级;判定"这个 IP 是不是机房/IDC 出口"需要 ip_type(ISP家庭 / BUS企业 / IDC机房),要判"是不是同一家代理运营商出口"需要 ip_asn,这两个字段是 VIP 版才有的。属地化场景里这两项价值不低——能识别出共用出口、代理出口,是减少误伤的关键。
总结
属地化校验的核心不是"查一个 IP 得出结论",而是把可量化的要素凑成一组合格证据:每个要素独立判断、未知不计分、结论分档、全程留痕。IP 归属地在其中是最容易自动化的一票,也是最不该被单独使用的一票。
上面的 Java 类只有一百多行,接进 Spring Boot 用起来没什么障碍——把 judge() 换成注入的 Service,System.out.println 换成落库和消息通知就行。示例里查归属地用的接口是 IP9 的免费接口 https://ip9.com.cn/get?ip=<IP>(不传参数则返回调用方自己的归属地,IPv4/IPv6 都支持,60 次/分钟、无需注册),省/市/大区/运营商这些字段免费版就有;区县、ip_type、ip_asn 在 VIP 版,字段说明和额度政策见官网 https://www.ip9.com.cn 。相关阅读:想了解 IP 属地与个人信息的合规边界,可以看本站《IP 地址是个人信息吗?网站记录 IP、调用归属地接口,个保法下怎么才合规》;想了解异地登录这类实时风控怎么落地,看《Java怎么获取用户IP归属地?在线接口版工具类(Spring Boot 可用)》。