Appearance
IP 风控总误伤正常用户?公司、校园网这类共享出口 IP 该怎么豁免
有次听一个做社区的朋友吐槽:他们上了条规则"同一 IP 关联 5 个以上账号就封",上线当天误伤一片——某公司员工集体登录,全公司共用一个公网出口 IP,五十多号人整整齐齐被封。客服被骂惨,最后紧急回滚。
这不是个例。IP 风控的经典矛盾:羊毛党和机器人靠"同 IP 大量账号"露出马脚,但公司、学校、商场 WiFi 的用户天然共享同一个 IP。规则越严,误伤越狠。这篇就讲共享出口 IP 场景下,规则怎么设计才不"一刀切"。
一、先搞清楚:哪些场景在共享出口 IP
- 公司/单位:办公室几百号人,出网就一个(或几个)公网 IP,走 NAT。
- 学校/校园网:比公司更夸张,一个校区几个出口,学生全挤在一个 IP 段。
- 商场/酒店/机场 WiFi:公共 WiFi 出口集中,一天几千人共用。
- 手机基站出口:移动网络下,一片区域的流量从运营商核心节点出去,IP 高度聚合。
这些场景里全是真人、行为正常,但在"同 IP 账号数"这个维度上,他们和刷子长得一模一样——只盯 IP 计数,必然误伤。
反过来,真正的批量风险长这样:IDC 机房 IP(脚本跑在云服务器上,一个 IP 段几百上千个账号,注册时间集中、操作路径重复)、代理/秒拨 IP(打一枪换一个 IP,IP 类型异常)。
看出来了吗——光数"一个 IP 几个账号"不够,得看这个 IP 是什么来路。
二、识别共享出口:ip_type 是关键信号
IP 查询接口里的 ip_type 字段把 IP 分成三类:
ISP:家庭宽带,正常个人用户,同 IP 账号一般 1-3 个。BUS:企业专线,公司出网用的,同 IP 下几十个账号是常态——这就是"正常共享"。IDC:机房 IP,服务器用的,同 IP 下一多账号大概率是脚本。
ip_type 是 VIP 字段(免费版只给省市、运营商)。没有它也能凑合:看归属地里运营商描述,企业专线、教育网出口通常有特征,但精确区分 BUS 和 IDC 还得靠 VIP。规则可以这么分层:
| IP 类型 | 同 IP 账号多 | 怎么处置 |
|---|---|---|
| ISP(家庭) | 3 个以内 | 放行 |
| ISP(家庭) | 超过阈值 | 验证码/短信确认 |
| BUS(企业出口) | 几十上百 | 放行,加观察标记 |
| IDC(机房) | 3 个以上 | 人工审核 |
| 已报备出口(名单) | 不限 | 直接放行 |
三、Java 实现:分级处置 + 豁免名单
下面这段 Java 演示核心逻辑:事件流按 IP 聚合账号 → 查归属地/类型 → 分级处置。处置分四级:PASS 放行、CHALLENGE 验证码、REVIEW 人工审核、BLOCK 拒绝——默认不直接封号,这是不误伤的第一原则。
java
import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
import com.fasterxml.jackson.databind.ObjectMapper;
import java.net.InetAddress;
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.*;
public class IpRiskDemo {
enum Decision { PASS, CHALLENGE, REVIEW, BLOCK }
// ---------- 1. 查 IP 归属地(IP9 免费接口;ip_type 是 VIP 字段,免费返回 null) ----------
static final HttpClient HTTP = HttpClient.newBuilder()
.connectTimeout(Duration.ofSeconds(3)).build();
static final ObjectMapper JSON = new ObjectMapper();
@JsonIgnoreProperties(ignoreUnknown = true)
static class GeoResp {
public int ret;
public Data data;
@JsonIgnoreProperties(ignoreUnknown = true) // 响应里还有 ip/country_code 等字段,忽略掉
static class Data {
public String country, prov, city, isp;
public String ip_type; // ISP/BUS/IDC,VIP 版才返回
}
}
static GeoResp.Data lookup(String ip) {
try {
HttpRequest req = HttpRequest.newBuilder(
URI.create("https://ip9.com.cn/get?ip=" + ip))
.timeout(Duration.ofSeconds(3)).GET().build();
HttpResponse<String> resp = HTTP.send(req, HttpResponse.BodyHandlers.ofString());
GeoResp geo = JSON.readValue(resp.body(), GeoResp.class);
return geo.ret == 200 ? geo.data : null;
} catch (Exception e) {
return null; // 超时/限流:查不到就按普通 IP 处理,别影响主流程
}
}
// ---------- 2. 核心:分级处置 ----------
static Decision decide(String ip, int accountCount, String ipType) {
if (isWhitelisted(ip)) return Decision.PASS; // 报备过的出口,直接放行
if ("IDC".equals(ipType)) // 机房 IP:重点怀疑
return accountCount >= 3 ? Decision.REVIEW : Decision.CHALLENGE;
if ("BUS".equals(ipType)) return Decision.PASS; // 企业专线:多人正常,加观察
// 家庭宽带 / 类型未知(免费档):只对账号特别多的做验证码,不直接封
return accountCount >= 5 ? Decision.CHALLENGE : Decision.PASS;
}
// 豁免名单:公司/校园出口段,管理员报备后加入,支持 CIDR
static final List<String> WHITELIST = List.of("118.112.66.0/24", "202.115.0.0/16");
static boolean isWhitelisted(String ip) {
try {
byte[] addr = InetAddress.getByName(ip).getAddress();
for (String cidr : WHITELIST) {
String[] parts = cidr.split("/");
byte[] net = InetAddress.getByName(parts[0]).getAddress();
int prefix = Integer.parseInt(parts[1]);
int full = prefix / 8, remain = prefix % 8;
boolean ok = true;
for (int i = 0; i < full; i++) if (addr[i] != net[i]) ok = false;
if (remain > 0) {
int mask = 0xFF << (8 - remain);
if ((addr[full] & mask) != (net[full] & mask)) ok = false;
}
if (ok) return true;
}
} catch (Exception ignored) { }
return false;
}
// ---------- 3. 演示:事件流 → 聚合 → 决策 ----------
public static void main(String[] args) {
// 模拟一小时内的注册/登录事件:(账号, IP)
List<String[]> events = List.of(
// 某公司员工,出口 118.112.66.20(已报备段内)
new String[]{"zhang3", "118.112.66.20"}, new String[]{"li4", "118.112.66.20"},
new String[]{"wang5", "118.112.66.20"}, new String[]{"zhao6", "118.112.66.20"},
// 某机房 IP 上冒出 5 个新注册账号(典型刷子)
new String[]{"bot01", "47.98.111.22"}, new String[]{"bot02", "47.98.111.22"},
new String[]{"bot03", "47.98.111.22"}, new String[]{"bot04", "47.98.111.22"},
new String[]{"bot05", "47.98.111.22"},
// 普通家庭宽带:两口子加串门的亲戚
new String[]{"homeA", "112.17.88.99"}, new String[]{"homeB", "112.17.88.99"},
new String[]{"homeC", "112.17.88.99"});
Map<String, Set<String>> accountsByIp = new HashMap<>();
for (String[] e : events) {
accountsByIp.computeIfAbsent(e[1], k -> new HashSet<>()).add(e[0]);
}
System.out.printf("%-16s %-8s %-6s %-12s %s%n", "IP", "账号数", "类型", "归属地", "处置");
for (Map.Entry<String, Set<String>> entry : accountsByIp.entrySet()) {
String ip = entry.getKey();
int count = entry.getValue().size();
GeoResp.Data geo = lookup(ip); // 同一 IP 只查一次;量大记得加缓存和限速
String ipType = geo == null ? null : geo.ip_type;
String loc = geo == null ? "查不到" : geo.prov + geo.city;
Decision d = decide(ip, count, ipType);
System.out.printf("%-16s %-8d %-6s %-12s %s%n", ip, count,
ipType == null ? "-" : ipType, loc, d);
}
}
}用免费接口跑,输出大概是:公司出口 4 个账号 PASS(豁免名单兜底)、机房那 5 个账号 CHALLENGE(免费档拿不到 ip_type,只能靠账号数兜到验证码这一级)、家庭宽带 3 个账号 PASS。接上 VIP 后 ip_type 返回 IDC,同一段代码不用改,机房组自动从 CHALLENGE 升级成 REVIEW(人工审核)——这就是为什么说识别共享出口得靠 IP 类型,光数账号数不够。
代码依赖 Jackson(Spring Boot 自带);lookup() 调的是免费接口,演示样本小没做限速,真实批量跑时每 IP 间隔 1 秒以上并加缓存,免费版 60 次/分钟够用。
四、落地注意事项
- 处置分四级,别用两级(放行/封号)。拿不准的先 CHALLENGE 或 REVIEW,BLOCK 留给实锤——误伤一个真实用户的代价,比放过十个刷子高。
- 豁免名单走报备流程。公司、校园网管理员提交出口 IP 段,审核后加白;名单别做太大,公共 WiFi 那种不可控的段不加。
- BUS 和 IDC 的用法别搞反。BUS 是"正常的多人"要放宽,IDC 是"可疑的批量"要严查;反过来一刀切"IDC 全封"照样误伤,有些正经公司就把服务放在 IDC。
- 动态 IP 会漂。家庭宽带重启路由就换 IP,短时间窗口的计数别当永久记录;手机流量出口聚合更夸张,登录场景阈值要比注册场景宽松得多。
- 新规则先 REVIEW 再收紧,并留申诉通道。上线观察一两周,看误伤占比再决定要不要升级成自动处置;被 CHALLENGE 的用户能自助申诉,复核属实后进豁免名单。
总结
IP 风控不误伤,靠的不是"阈值调大一点",是分层:把 IP 分出家庭/企业/机房,把处置分成放行/验证/审核/拦截,再给真实共享出口留一条报备豁免的路。刷子照样拦得住,公司员工也不会集体被封。
判断 IP 类型用 ip_type 字段(ISP/BUS/IDC,VIP 版提供);免费版用 https://ip9.com.cn/get?ip=<ip> 也能拿省市和运营商做基础判断,免注册、支持 IPv4/IPv6,详见官网 https://www.ip9.com.cn 。规则要软、处置要分级、豁免要留门——记住这三条,误伤能少一大半。