Appearance
券商异地开户、批量开户怎么核验?用 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 为准。