Skip to content

招聘平台怎么识别异地虚假简历?用 IP 归属地给投递做风控(Java 实现)

做招聘系统的朋友应该都遇到过这种数据:一份简历写着「现居杭州,期望杭州岗位」,投递 IP 却在河南某机房,同一小时这个 IP 投出去了两百多份简历。这种情况未必是候选人撒谎——中介批量代投、简历工厂、猎头工具号,都会出现这种特征。问题在于:平台侧如果一点信号都没有,HR 就得挨个打电话确认,成本高得离谱。

IP 归属地在这里不是用来「抓坏人」的,它是一条几乎零成本的交叉验证信号:把简历里写的城市和投递时的 IP 城市对一下,不一致就打个标,HR 看简历时能一眼看到,高风险的行为(同一 IP 疯狂投递)再单独拦。这篇用 Java(Spring Boot)把整套东西写出来。

一、业务侧到底要判断什么

先明确边界,否则规则很容易写歪:

  • 同 IP 高频投递——这是最硬的信号。一个家庭宽带 IP 一天投三五十份都算多了,一小时两百份只可能是脚本或中介后台。
  • 简历城市与 IP 省份不一致——只能算「需要留意」。人在北京实习、简历写老家郑州,太正常了。
  • IP 类型是 IDC 机房——脚本跑在云服务器上的典型特征。这个字段(ip_type)IP9 免费版没有,VIP 版才提供,所以免费方案里只能靠「同 IP 高频 + 城市不符」两条。
  • 多个账号共用同一个 IP 投递——同 IP 关联账号数,比单看投递量更准。

这里的关键取舍:不要把不一致直接判为作弊。误伤一个正常求职者,比漏掉十个中介更伤平台口碑。所以下面代码里,不一致只出「可疑」标签,高频投递才出「高风险」。

二、方案设计(Java 侧分三层)

  1. 取真实 IP:招聘系统前面一般有 Nginx/网关,request.getRemoteAddr() 拿到的是网关地址。只信自己网关写入的 X-Real-IP,不要直接信任 X-Forwarded-For 的第一段——那个用户自己就能伪造。
  2. 查归属地 + 缓存:调用 https://ip9.com.cn/get?ip=<IP>,用 Caffeine 做本地缓存,缓存 30 分钟。同一 IP 反复投递时不会反复打接口,也避开了免费版 60 次/分钟/IP 的限制。
  3. 规则与埋点:拿到 country / prov / city 后跟简历字段比对,再叠加「IP 维度 1 小时投递计数」,结果写进投递记录的 risk_tag 字段,同时把 IP 城市存下来供后续分析。接口超时必须降级——风控挂了不能挡着候选人投简历。

三、Java 实现

1) 归属地查询客户端(带缓存 + 降级)

java
package com.example.recruit.ip;

import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.github.benmanes.caffeine.cache.Cache;
import com.github.benmanes.caffeine.cache.Caffeine;
import org.springframework.stereotype.Component;

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.concurrent.TimeUnit;

@Component
public class IpLocationClient {

    private static final String API = "https://ip9.com.cn/get?ip=";

    private final HttpClient http = HttpClient.newBuilder()
            .connectTimeout(Duration.ofSeconds(2))
            .build();
    private final ObjectMapper mapper = new ObjectMapper();

    // IP 归属地短期内不会变,缓存 30 分钟;单机 20 万条足够覆盖一天的量
    private final Cache<String, IpLocation> cache = Caffeine.newBuilder()
            .maximumSize(200_000)
            .expireAfterWrite(30, TimeUnit.MINUTES)
            .build();

    public IpLocation query(String ip) {
        if (ip == null || ip.isBlank()) {
            return IpLocation.empty();
        }
        return cache.get(ip, key -> {
            try {
                HttpRequest req = HttpRequest.newBuilder(URI.create(API + key))
                        .timeout(Duration.ofMillis(800))   // 风控查询必须给死超时
                        .GET()
                        .build();
                HttpResponse<String> resp = http.send(req, HttpResponse.BodyHandlers.ofString());
                JsonNode root = mapper.readTree(resp.body());
                if (root.path("ret").asInt() != 200) {     // 非法 IP 返回 400
                    return IpLocation.empty();
                }
                JsonNode d = root.path("data");
                return new IpLocation(
                        d.path("country").asText(),
                        d.path("prov").asText(),
                        d.path("city").asText(),
                        d.path("isp").asText());
            } catch (Exception e) {
                // 超时或网络异常一律降级为空对象:宁可没标签,也不能卡住投递
                return IpLocation.empty();
            }
        });
    }
}
java
package com.example.recruit.ip;

public record IpLocation(String country, String prov, String city, String isp) {

    private static final IpLocation EMPTY = new IpLocation("", "", "", "");

    public static IpLocation empty() {
        return EMPTY;
    }

    public boolean valid() {
        return !country.isBlank();
    }
}

2) 风控规则

java
package com.example.recruit.risk;

import com.example.recruit.ip.IpLocation;
import com.example.recruit.ip.IpLocationClient;
import org.springframework.stereotype.Service;

import java.time.Duration;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.atomic.AtomicInteger;

@Service
public class ResumeRiskService {

    private final IpLocationClient client;

    // 简化版频次统计:生产环境换成 Redis 的 INCR + EXPIRE,天然支持多实例
    private final Map<String, AtomicInteger> ipCounter = new ConcurrentHashMap<>();
    private long windowStart = System.currentTimeMillis();

    public ResumeRiskService(IpLocationClient client) {
        this.client = client;
    }

    /** 返回风险标签:高风险 / 可疑 / 正常 / 未知 */
    public String evaluate(String ip, String resumeProv, String resumeCity) {
        int oneHour = countInWindow(ip);

        if (oneHour > 200) {
            return "高风险";              // 脚本或中介后台,直接进人工复核队列
        }

        IpLocation loc = client.query(ip);
        if (!loc.valid()) {
            return "未知";                // 降级:查不到就不给结论
        }

        boolean domestic = "中国".equals(loc.country());
        boolean provMismatch = domestic && !loc.prov().isBlank()
                && !resumeProv.isBlank() && !loc.prov().equals(resumeProv);

        if (oneHour > 30 || provMismatch) {
            return "可疑";                // 打标给 HR 参考,不拦
        }
        return "正常";
    }

    private int countInWindow(String ip) {
        synchronized (this) {
            long now = System.currentTimeMillis();
            if (now - windowStart > Duration.ofHours(1).toMillis()) {
                ipCounter.clear();
                windowStart = now;
            }
        }
        return ipCounter.computeIfAbsent(ip, k -> new AtomicInteger()).incrementAndGet();
    }
}

3) 在投递接口里挂上去

java
@PostMapping("/api/resume/apply")
public ApplyResult apply(@RequestBody ApplyForm form, HttpServletRequest req) {
    // 只信任自家网关写入的头;X-Forwarded-For 可伪造,不可直接用
    String ip = req.getHeader("X-Real-IP");
    if (ip == null || ip.isBlank()) {
        ip = req.getRemoteAddr();
    }

    String tag = riskService.evaluate(ip, form.getResumeProv(), form.getResumeCity());
    applyRecordService.save(form, tag, ip);

    // 高风险不阻断投递,只标记;真要拦也应给申诉入口
    return ApplyResult.ok(tag);
}

四、落地注意事项

阈值要按业务调。上面 200 / 30 是拍出来的经验值,上线前先跑一周只记录不拦截,把真实分布拉出来再定线。批量投递功能上线时,正常用户量会突然抬升,阈值别卡死在活动当天。

误伤要留兜底。异地求职本身太常见了,所以「不一致」只给「可疑」标签,HR 端展示成「简历城市与投递地不同(浙江杭州 → 河南)」这种中性描述,别写「疑似虚假」。真要联系候选人时,HR 有上下文就不会开口就质疑。

接口侧的成本控制。缓存 30 分钟、只对投递接口这类关键路径查询,不要全站每个请求都查。IP9 免费版是 60 次/分钟/IP,招聘平台这种量级通常够,但如果你的网关是单一出口、又没做缓存,很容易把额度打满——缓存层一定要有。VIP 版有不限量方案和 18 万次/分钟的容量,量大再考虑。

合规别漏。IP 地址在能关联到自然人时属于个人信息,投递 IP 至少要在隐私政策里披露用途(安全风控、反作弊),并且限制访问权限——HR 能看标签,但别把完整 IP 直接展示给所有后台账号。留存时间也按「实现目的所需最短」来定,风控埋点保留半年基本够用,具体看你们的安全审计要求。

总结

招聘场景用 IP 归属地,核心是三件事:取到真实 IP、缓存查询结果、规则分层(可疑打标 vs 高风险拦截)。落地成本极低,一个 IpLocationClient 加几十行规则就能跑起来。数据源用 IP9 的免费接口就够:https://ip9.com.cn/get?ip=<IP>,不传参则查当前请求方 IP,支持 IPv4/IPv6,返回国家、省市、运营商等字段,免注册、60 次/分钟。要判断投递 IP 是不是机房脚本,可以看 VIP 版的 ip_type 字段(ISP 家庭 / BUS 企业 / IDC 机房)。官网:https://www.ip9.com.cn