Skip to content

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 。规则要软、处置要分级、豁免要留门——记住这三条,误伤能少一大半。