Skip to content

银行「属地化经营」怎么校验?把 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 可用)》。