Skip to content

券商异地开户、批量开户怎么核验?用 IP 属地 + 设备 + 行为三件套搭风控(Java 实现) ​

一家小券商的合规部去年被点名过一次。检查组的意见写得很直白:账户开立环节对「异地开户、批量开户、代理开户」的识别不到位,实际控制人核验流于形式。翻成业务语言就是——有人拿一批身份证在同一个地方集中开户,系统没拦住,这些账户后来被用来做了涉诈资金的过账通道。

线上开户是个纯网络流程:人脸、证件照、视频见证、电子签名,全在 App 里走完。既然全程都是网络请求,那么「这批开户是从哪儿发起的」就成了最便宜、也最不该忽略的一路信号。

一、开户风控要回答的三个问题 ​

监管通报里点名的三类异常,落到技术上是三个可测的问题:

  • 异地开户——客户证件地址(或拟开营业部属地)和开户网络出口不在一个地方;
  • 批量开户——同一时间段、同一出口或同一小段 IP,集中提交了一批开户申请;
  • 代理开户——不同身份信息的申请,落在同一台设备、同一个网络出口上,说明「有人在替别人开」。

这三类问题单独看都有正常解释(出差开户、营业部集中地推、家人共用网络),所以真正可用的做法是把归属地当一路权重,跟设备指纹、行为序列相加,而不是让它单独出结论。证监会通报的原话也是「采取有效措施核实账户实际控制人」——属地是核实手段之一。

二、三件套怎么算权重 ​

  • IP 属地:prov / city 跟证件地址、营业部属地比对;ip_asn 和 ip_type 能看出这是家宽还是机房(这两个字段 IP9 属于 VIP 版能力),机房出口的集中开户权重更高。
  • 设备指纹:设备 ID、机型、系统版本、传感器哈希。跟属地交叉,才能区分「同一台电脑在同一个出口连续开户」和「一百个人在同一个营业部 WiFi 下各自开户」。
  • 行为序列:从下载 App 到提交开户的耗时、填写速度、视频见证耗时。属地负责挑样本,行为负责给样本排序。

三、Java 实现 ​

下面这段单文件程序吃一批开户申请(身份证属地、营业部属地、出口 IP、设备号),做三件事:查 IP 归属地、跑三条规则计分、把结果按风险排序输出。JDK 11 之后自带的 HttpClient 就够,不用引第三方库,JSON 用正则取字段是为了保持单文件可跑——生产环境换成 Jackson 更稳。

java
// AccountOpenRisk.java —— 券商线上开户属地风控:异地 / 批量 / 代理三件套
// 运行: java AccountOpenRisk.java (JDK 17+,单文件源码运行)
import java.net.URI;
import java.net.http.*;
import java.time.Duration;
import java.util.*;
import java.util.regex.*;

public class AccountOpenRisk {
    static final String API = "https://ip9.com.cn/get";
    static final HttpClient HTTP = HttpClient.newBuilder()
            .connectTimeout(Duration.ofSeconds(5)).build();
    static final Map<String, Map<String, String>> CACHE = new HashMap<>();
    static long lastCall = 0L;

    /** 查 IP 归属地:进程内缓存 + 约 55 次/分钟限速(免费版 60 次/分钟) */
    static synchronized Map<String, String> geo(String ip) {
        if (CACHE.containsKey(ip)) return CACHE.get(ip);
        long wait = 1100 - (System.currentTimeMillis() - lastCall);
        if (wait > 0) { try { Thread.sleep(wait); } catch (InterruptedException ignored) {} }
        lastCall = System.currentTimeMillis();

        Map<String, String> d = new HashMap<>();
        try {
            HttpRequest req = HttpRequest.newBuilder(URI.create(API + "?ip=" + ip))
                    .timeout(Duration.ofSeconds(5)).GET().build();
            String body = HTTP.send(req, HttpResponse.BodyHandlers.ofString()).body();
            if (!body.contains("\"ret\":200")) {          // 非法 IP 返回 ret=400
                d.put("_err", "ret!=200");
            } else {
                for (String k : new String[]{"country", "country_code", "prov", "city", "isp"}) {
                    Matcher m = Pattern.compile("\"" + k + "\":\"([^\"]*)\"").matcher(body);
                    d.put(k, m.find() ? m.group(1) : "");
                }
            }
        } catch (Exception e) {
            d.put("_err", e.getClass().getSimpleName());
        }
        CACHE.put(ip, d);
        return d;
    }

    static String placeOf(Map<String, String> d) {
        if (d.containsKey("_err")) return "查询失败";
        String prov = d.getOrDefault("prov", ""), city = d.getOrDefault("city", "");
        if (prov.isEmpty() && city.isEmpty())
            return d.getOrDefault("country", "").isEmpty() ? "未知" : d.get("country");
        return (city.isEmpty() || city.equals(prov)) ? prov : prov + city;
    }

    record Apply(String id, String certCity, String branchCity, String ip, String device) {}
    record Row(int score, String text) {}

    public static void main(String[] args) {
        List<Apply> applies = List.of(
            new Apply("A001", "浙江杭州", "浙江杭州", "115.192.31.7",  "dev-0a11"),   // 本地家宽,干净
            new Apply("A002", "江苏南京", "浙江杭州", "43.133.110.148", "dev-7f3a"),
            new Apply("A003", "山东济南", "浙江杭州", "43.133.110.148", "dev-7f3a"),
            new Apply("A004", "广东深圳", "浙江杭州", "43.133.110.148", "dev-7f3a"),
            new Apply("A005", "陕西西安", "浙江杭州", "43.133.110.148", "dev-7f3a"),
            new Apply("A006", "广东深圳", "广东深圳", "120.79.4.66",   "dev-91c2")
        );

        Map<String, Long> ipCount = new HashMap<>();
        Map<String, Long> devCount = new HashMap<>();
        for (Apply a : applies) {
            ipCount.merge(a.ip(), 1L, Long::sum);
            devCount.merge(a.device(), 1L, Long::sum);
        }

        List<Row> rows = new ArrayList<>();
        for (Apply a : applies) {
            Map<String, String> d = geo(a.ip());
            String ipCity = placeOf(d), isp = d.getOrDefault("isp", "");
            int score = 0; List<String> hits = new ArrayList<>();

            // 规则一:开户出口属地 与 证件/营业部属地 三方不符
            if (!ipCity.equals("查询失败") && !ipCity.equals(a.certCity())
                    && !ipCity.equals(a.branchCity())) {
                score += 2; hits.add("出口属地 " + ipCity + " 与证件/营业部均不符");
            }
            // 规则二:同一出口 IP 集中开户
            if (ipCount.get(a.ip()) >= 3) {
                score += 3; hits.add("同一出口 IP 当日开户 " + ipCount.get(a.ip()) + " 户");
            }
            // 规则三:不同身份共用同一设备(代理开户特征)
            if (devCount.get(a.device()) >= 3) {
                score += 3; hits.add("同一设备指纹提交 " + devCount.get(a.device()) + " 户");
            }
            // 规则四:出口运营商看着像机房/云厂商,集中开户权重更高
            if (isp.toLowerCase().contains("cloud") || isp.contains("云")
                    || isp.contains("机房") || isp.toLowerCase().contains("idc")) {
                score += 2; hits.add("出口疑似机房运营商:" + isp);
            }

            String lvl = score >= 6 ? "拒绝并转人工见证" : (score >= 3 ? "加强核验(额外视频+问询)" : "正常放行");
            rows.add(new Row(score, String.format("%s 分=%d %s | 出口属地=%s | %s",
                    a.id(), score, lvl, ipCity, hits.isEmpty() ? "无命中" : String.join(";", hits))));
        }
        rows.sort(Comparator.comparingInt(Row::score).reversed());
        rows.forEach(r -> System.out.println(r.text()));
    }
}

示例数据跑出来,A001(本地证件、本地营业部、本地电信家宽出口)0 分放行;A006 证件、营业部和出口都在深圳,只因为出口落在云厂商网段被打了个 2 分——属于可以放行、但值得留一眼的样本;A002 到 A005 是四个不同省份的身份证,全部从同一个境外机房出口、同一台设备指纹提交,四条规则同时命中,分数顶到 10,直接「拒绝并转人工见证」。真实场景里,这一批就是监管通报里说的「代理开户」。

四、落地的几个细节 ​

属地是权重,不是判据。 异地开户里绝大多数是正常客户:人在外地上班、老家证件、线上选了个异地营业部。真正要拦的是「异地 + 批量 + 同设备」的叠加,单条属地不符顶多触发加强核验,别直接拒。

动态 IP 会让属地跳。 同一个家宽用户两次开户请求落到同城不同 IP 段是常事,比对时按城市比、不按 IP 比;同省内的变化基本可以忽略。

机房出口要单独看。 用代理、VPS、加速器开出来的账户,出口往往落在云厂商网段。免费版只能从 isp 字段看出「腾讯云」「阿里云」这类字样,如果要做成硬规则,ip_type(ISP 家宽 / BUS 企业 / IDC 机房)和 ip_asn 是 IP9 的 VIP 字段,值得单独买。

限流和异步。 开户是低频事件,一天几万笔用同步调用就够了,但必须加缓存和限速——免费版 60 次/分钟,几十个进程同时打接口必然撞 429。上面用 synchronized 是为了单文件演示,生产环境换成信号量或令牌桶,别把风控接口变成开户流程的单点。

合规链路要留痕。 每次命中与处置都要落审计日志:申请号、得分、命中规则、属地快照、最终结论。监管检查时看的就是这套材料;IP 地址属于个人信息,留存期限和访问权限按公司自己的数据分级制度执行。

总结 ​

异地开户、批量开户、代理开户这三类问题,本质不是「识别一个坏 IP」,而是「发现一批不该同时出现的组合」。IP 属地是这套组合里最容易拿到、也最好解释的一路信号——一次 HTTP 请求换回省市和运营商,配一个设备指纹就能跑起来。

接口还是那一个:https://ip9.com.cn/get?ip=xxx,IPv4 和 IPv6 都支持,不传参数返回当前请求方 IP,非法 IP 会返回 ret=400。字段清单和 VIP 版(区县、ip_type、ip_asn)以官网 https://www.ip9.com.cn 为准。