Skip to content

民宿、短租平台的订单怎么防异常?用 IP 归属地识别盗号下单和刷单(Java 实现) ​

民宿和短租的订单风控有个特殊的难点:「人在外地订房」是这门生意的常态。用户在家订旅游城市的房间、出差党一周换三个城市,如果按「下单 IP 和账号常用地不一致就拦」,误伤率会高到业务方直接关掉这个规则。

所以真正的做法不是看单次 IP,而是给账号建一条基线,再看订单是不是偏离了这条基线。这篇文章给一套 Java 实现,服务于两类团队:自己做民宿/短租平台的,或者给民宿做订单 SaaS 的。规则输出的是「放行 / 加强验证 / 人工复核」三种处置建议和可读的原因,不做静默拒单。

一、先分清四类异常,它们的特征完全不同 ​

盗号下单。 黑产用撞库或买来的账号下单,目的是占房、套取退款、刷信用卡。特征最明显:账号近期登录地突然从「华东」跳到「西南」,中间没有合理的过渡;或者账号半年没动,突然在两小时内完成登录 + 下单 + 支付。

房东方刷单。 民宿运营方为了把房源顶到列表前面,会制造一批假订单和好评。特征是同一 IP(或者同一个 /24 网段)在短时间内下的订单集中在同一批房源上,下单账号注册时间都很新。这类判断靠的是「聚合」——单看一条订单没任何问题。

代订中转。 有一类订单是「代订」:旅行社、拼房中介拿客户信息批量下单。它通常不违规,但会带来入住人与订单人不一致、退改纠纷多的麻烦。特征是同一出口 IP 关联大量不同入住人。

账号被盗用后快速改密 + 提现。 民宿平台的账户里常有钱包余额和押金,盗号链条的终点往往是提现。这条链路上的 IP 跳变通常就在几分钟内完成。

与之相对,下面这些是正常现象,规则不能把它们判死:

  • 用户在三亚旅游,用酒店 WiFi 下单下一站的民宿——IP 城市和平台账号常用地完全不同。
  • 出差党的手机连着不同城市的 4G 出口,一天换两三个城市属正常。
  • 用户用公司网络或 VPN(企业专线)下单,出口显示为机房或总部所在城市。
  • 微信/支付宝小程序里下单,IP 来自微信侧的出口——这条尤其要注意,小程序的请求 IP 很多时候不是用户真实出口。

二、方案设计 ​

整体结构是「一个属地客户端 + 一份账号基线 + 一组规则 + 一个决策层」:

下单 / 支付 / 入住前
  → 取真实客户端 IP(反代要按可信链路取)
  → IpLocationClient:本地缓存 + 限速,查省市、运营商、经纬度(IDC 判断需 VIP 的 ip_type)
  → AccountProfile:读账号历史城市集合、最近一次登录地、账号年龄
  → 规则引擎逐条打分,每条规则给出人类可读的理由
  → 决策:ALLOW / STEP_UP(短信验证、加收押金)/ MANUAL_REVIEW
  → 命中后回写账号基线,作为下一次判断的依据

设计上几个必须做对的点:

时间是关键维度,不只是地点。 判断「南京到成都」本身没意义,判断「两小时内从南京到成都」才有意义。用两个城市的中心经纬度算大圆距离,除以时间差得到等效速度,超过 900km/h 就是物理上不可达——这条规则误伤极少,解释起来也直观。

账号基线要冷启动。 新账号没有历史,任何城市都是「新城市」。做法是:账号注册 7 天内或历史登录次数少于 3 次时,不启用「新城市」规则,只启用聚合规则(同 IP 多订单)。

聚合规则要用滑动窗口存在内存里。 「同一 IP 24 小时内的订单数」这类计数,放 Redis(多实例)或 Caffeine(单实例)都行,别每次查数据库。

归属地查不到不是错误。 接口超时、限速、IP 无法归属,都要正常走完下单流程,只是这条订单的风控依据少一项。规则引擎里用 Optional 或者可空对象,绝不能让风控异常冒泡成下单失败。

三、Java 实现 ​

依赖:JDK 11+ 自带的 java.net.http.HttpClient,加上 Jackson(com.fasterxml.jackson.core:jackson-databind)解析 JSON。下面这份可以直接编译运行,main 里跑了三个典型场景。

java
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;

import java.net.URI;
import java.net.URLEncoder;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.nio.charset.StandardCharsets;
import java.time.Duration;
import java.util.ArrayDeque;
import java.util.ArrayList;
import java.util.Deque;
import java.util.HashMap;
import java.util.HashSet;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.concurrent.ConcurrentHashMap;

public class StayOrderRisk {

    // ================= 属地客户端 =================

    static class Geo {
        String ip, country, prov, city, isp, bigArea;
        double lng, lat;
        String ipType;          // VIP 版字段:IDC / ISP / BUS,免费版为空
    }

    /** 带 TTL 缓存和限速的归属地查询:任何失败都返回 null,不抛异常 */
    static class GeoClient {
        private static final String API = "https://ip9.com.cn/get";
        private static final long TTL_MS = 6 * 3600_000L;   // 归属地变化慢,缓存 6 小时
        private static final long MIN_GAP_MS = 1100L;       // 免费版 60 次/分钟 → 按 ~55 次/分钟打

        private final HttpClient http = HttpClient.newBuilder()
                .connectTimeout(Duration.ofMillis(500)).build();
        private final ObjectMapper mapper = new ObjectMapper();
        private final Map<String, Object[]> cache = new ConcurrentHashMap<>();
        private volatile long lastCallAt = 0L;

        Geo lookup(String ip) {
            if (ip == null || ip.isBlank()) return null;
            Object[] hit = cache.get(ip);
            if (hit != null && (long) hit[1] > System.currentTimeMillis()) return (Geo) hit[0];

            Geo geo = fetch(ip, true);
            if (geo != null) {
                cache.put(ip, new Object[]{geo, System.currentTimeMillis() + TTL_MS});
            }
            return geo;
        }

        private Geo fetch(String ip, boolean retryOnLimit) {
            throttle();
            try {
                HttpRequest req = HttpRequest.newBuilder()
                        .uri(URI.create(API + "?ip=" + URLEncoder.encode(ip, StandardCharsets.UTF_8)))
                        .timeout(Duration.ofMillis(900))
                        .header("User-Agent", "stay-order-risk/1.0")
                        .GET().build();
                HttpResponse<String> resp = http.send(req, HttpResponse.BodyHandlers.ofString());
                JsonNode root = mapper.readTree(resp.body());
                int ret = root.path("ret").asInt();

                if (ret == 400) return null;                    // IP 非法,重试无意义
                if (ret == 429 && retryOnLimit) {               // 撞限速,退避一次
                    Thread.sleep(1500);
                    return fetch(ip, false);
                }
                if (ret != 200) return null;

                JsonNode d = root.path("data");
                if (d.isMissingNode() || d.isEmpty()) return null;
                Geo g = new Geo();
                g.ip      = d.path("ip").asText(ip);
                g.country = d.path("country").asText("");
                g.prov    = d.path("prov").asText("");
                g.city    = d.path("city").asText("");
                g.isp     = d.path("isp").asText("");
                g.bigArea = d.path("big_area").asText("");
                g.lng     = d.path("lng").asDouble(0);
                g.lat     = d.path("lat").asDouble(0);
                g.ipType  = d.path("ip_type").asText("");       // 免费版为空,VIP 版才有
                return g;
            } catch (Exception e) {
                return null;                                    // 超时/解析失败:当成本次没有位置信号
            }
        }

        private void throttle() {
            long wait = MIN_GAP_MS - (System.currentTimeMillis() - lastCallAt);
            if (wait > 0) {
                try { Thread.sleep(wait); } catch (InterruptedException ignored) { Thread.currentThread().interrupt(); }
            }
            lastCallAt = System.currentTimeMillis();
        }
    }

    // ================= 账号基线 =================

    static class AccountProfile {
        final String accountId;
        final long registeredAt;
        final Set<String> knownCities = new HashSet<>();        // 历史出现的城市
        final Deque<long[]> trails = new ArrayDeque<>();        // 最近一次登录:[时间, lng*1e6, lat*1e6] 以整型存
        String lastCity = "";
        long lastSeenAt = 0L;
        int events = 0;                                         // 历史活动次数(登录 / 下单)

        AccountProfile(String accountId, long registeredAt) {
            this.accountId = accountId;
            this.registeredAt = registeredAt;
        }

        boolean coldStart() {
            return events < 3 || System.currentTimeMillis() - registeredAt < 7 * 86400_000L;
        }
    }

    // ================= 规则引擎 =================

    enum Action { ALLOW, STEP_UP, MANUAL_REVIEW }

    static class RiskResult {
        Action action = Action.ALLOW;
        int score = 0;
        String level = "low";
        final List<String> reasons = new ArrayList<>();
    }

    static class OrderRiskService {
        private static final double MAX_SPEED_KMH = 900.0;

        private final GeoClient geo;
        private final Map<String, AccountProfile> accounts = new ConcurrentHashMap<>();
        private final Map<String, Deque<Long>> ordersByIp = new ConcurrentHashMap<>();   // IP → 订单时间戳

        OrderRiskService(GeoClient geo) { this.geo = geo; }

        RiskResult check(String accountId, String orderCity, String clientIp, long nowMs) {
            RiskResult r = new RiskResult();
            Geo ipGeo = geo.lookup(clientIp);
            AccountProfile acc = accounts.computeIfAbsent(
                    accountId, k -> new AccountProfile(k, nowMs - 30L * 86400_000L));

            if (ipGeo == null) {
                r.reasons.add("未取得 IP 属地信号(接口异常或 IP 无法归属),本次不做地域判断");
                return r;                                        // 宁可不判,不可误判
            }

            boolean isCmccLike = ipGeo.bigArea != null && !ipGeo.bigArea.isEmpty();

            // 规则 1:账号历史城市里从没出现过的新城市 —— 只在账号不处于冷启动时生效
            if (!acc.coldStart() && !acc.knownCities.isEmpty()
                    && !ipGeo.city.isEmpty() && !acc.knownCities.contains(ipGeo.city)) {
                long prevTs = acc.lastSeenAt;
                double km = acc.lastCity.isEmpty() ? -1 : distanceKm(acc.trails.peekLast(), ipGeo);
                double hours = prevTs == 0 ? 0 : Math.max((nowMs - prevTs) / 3600_000.0, 0.01);
                double speed = km < 0 ? -1 : km / hours;

                if (speed > MAX_SPEED_KMH) {
                    r.score += 60;
                    r.reasons.add(String.format(
                            "账号上一次活动在 %s,%.0f 分钟后在 %s 下单,相距 %.0f 公里(≈%.0f km/h),时间上不可达",
                            acc.lastCity, hours * 60, ipGeo.city, km, speed));
                } else {
                    r.score += 15;
                    r.reasons.add("下单城市 " + ipGeo.city + " 不在账号历史活动城市("
                            + String.join("、", acc.knownCities) + ")中");
                }
            }

            // 规则 2:同一出口 IP 短时间内的订单量(刷单/代订聚合)
            Deque<Long> q = ordersByIp.computeIfAbsent(clientIp, k -> new ArrayDeque<>());
            q.addLast(nowMs);
            while (!q.isEmpty() && nowMs - q.peekFirst() > 86400_000L) q.pollFirst();
            if (q.size() >= 5) {
                r.score += 35;
                r.reasons.add(String.format("同一出口 IP 24 小时内下单 %d 次,疑似刷单或代订批量下单", q.size()));
            }

            // 规则 3:出口是机房 / 代理(免费版看 isp 文本,VIP 版直接看 ip_type=IDC)
            boolean idc = "IDC".equalsIgnoreCase(ipGeo.ipType)
                    || ipGeo.isp.contains("云") || ipGeo.isp.contains("数据中心") || ipGeo.isp.contains("IDC");
            if (idc) {
                r.score += 20;
                r.reasons.add("下单出口疑似机房/云服务器(" + ipGeo.isp + "),正常住客极少这样下单"
                        + ("IDC".equalsIgnoreCase(ipGeo.ipType) ? "" : "(精确判断建议用 VIP 版 ip_type 字段)"));
            }

            // 规则 4:跨境订单的国别与手机号区号不匹配
            if (!"中国".equals(ipGeo.country)) {
                r.score += 25;
                r.reasons.add("下单 IP 归属地为「" + ipGeo.country + (ipGeo.city.isEmpty() ? "" : " " + ipGeo.city)
                        + "」,请核对与预留手机号国际区号、支付方式是否一致");
            }

            // 决策分层:先加强验证,最后才是人工
            if (r.score >= 70)      { r.action = Action.MANUAL_REVIEW; r.level = "high"; }
            else if (r.score >= 35) { r.action = Action.STEP_UP;       r.level = "medium"; }
            else                    { r.action = Action.ALLOW;         r.level = "low"; }

            // 回写基线:这条轨迹就成为账号的历史,供下一次判断使用
            if (!ipGeo.city.isEmpty()) {
                acc.knownCities.add(ipGeo.city);
                acc.lastCity = ipGeo.city;
                acc.lastSeenAt = nowMs;
                acc.trails.addLast(new long[]{nowMs, (long) (ipGeo.lng * 1e6), (long) (ipGeo.lat * 1e6)});
                while (acc.trails.size() > 20) acc.trails.pollFirst();
            }
            acc.events++;
            return r;
        }

        /** 缓存里存的轨迹点与当前 IP 的城市中心点之间的大圆距离(公里) */
        private double distanceKm(long[] trail, Geo cur) {
            if (trail == null) return -1;
            return haversine(trail[2] / 1e6, trail[1] / 1e6, cur.lat, cur.lng);
        }
    }

    static double haversine(double lat1, double lng1, double lat2, double lng2) {
        double R = 6371.0;
        double p1 = Math.toRadians(lat1), p2 = Math.toRadians(lat2);
        double dp = Math.toRadians(lat2 - lat1), dl = Math.toRadians(lng2 - lng1);
        double a = Math.pow(Math.sin(dp / 2), 2) + Math.cos(p1) * Math.cos(p2) * Math.pow(Math.sin(dl / 2), 2);
        return 2 * R * Math.asin(Math.sqrt(a));
    }

    // ================= 演示 =================

    public static void main(String[] args) {
        GeoClient geo = new GeoClient();
        OrderRiskService svc = new OrderRiskService(geo);
        long now = System.currentTimeMillis();
        long day = 86400_000L;

        // 场景 A:上海老账号,6 分钟前还在上海,随后从西安下单(时间不可达)
        svc.check("u_8851", "上海·外滩公寓", "114.80.68.1", now - 3 * day);
        svc.check("u_8851", "上海·外滩公寓", "114.80.68.1", now - 2 * day);
        svc.check("u_8851", "上海·外滩公寓", "114.80.68.1", now - day);
        svc.check("u_8851", "上海·外滩公寓", "114.80.68.1", now - 360_000);
        print("A 疑似盗号下单", svc.check("u_8851", "西安·钟楼民宿", "218.30.19.40", now - 180_000));

        // 场景 B:同一出口 IP 连续下单(刷单)
        RiskResult last = null;
        for (int i = 0; i < 6; i++) {
            last = svc.check("u_host_" + i, "杭州·西湖公寓", "202.96.128.86", now + i * 1000L);
        }
        print("B 房东方刷单", last);

        // 场景 C:海外 IP 下单(业务上可能合理,提示核对支付/区号)
        print("C 海外 IP 下单", svc.check("u_3312", "东京·浅草民宿", "8.8.8.8", now + 30_000L));
    }

    static void print(String tag, RiskResult r) {
        System.out.println("=== " + tag + " → " + r.action + "(score=" + r.score + ")");
        if (r.reasons.isEmpty()) System.out.println("    无异常,直接放行");
        for (String s : r.reasons) System.out.println("    · " + s);
        System.out.println();
    }
}

编译运行:

bash
javac -cp jackson-databind.jar:jackson-core.jar:jackson-annotations.jar StayOrderRisk.java
java -cp .:jackson-databind.jar:jackson-core.jar:jackson-annotations.jar StayOrderRisk

输出是这样(实测,城市与距离都来自真实接口返回):

=== A 疑似盗号下单 → MANUAL_REVIEW(score=80)
    · 账号上一次活动在 上海,3 分钟后在 西安 下单,相距 1224 公里(≈24483 km/h),时间上不可达
    · 下单出口疑似机房/云服务器(中国电信/IDC/DNS),正常住客极少这样下单(精确判断建议用 VIP 版 ip_type 字段)

=== B 房东方刷单 → STEP_UP(score=35)
    · 同一出口 IP 24 小时内下单 6 次,疑似刷单或代订批量下单

=== C 海外 IP 下单 → ALLOW(score=25)
    · 下单 IP 归属地为「美国」,请核对与预留手机号国际区号、支付方式是否一致

三个场景的处置各不相同,正好是这个方案的思路:场景 A 命中「时间不可达」这个硬信号,直接进人工复核队列;场景 B 只要求一次加强验证(短信 + 加收押金),因为同 IP 多单也可能是民宿管家在帮客人代下单;场景 C 虽然命中了跨境规则,但分值不足以升级处置,只在下单流程里显示一句提示——海外住客本来就是正常业务。

场景 A 里那个账号的基线,是用前四次上海的活动喂出来的。接线上时这一步要换成真实数据:从账号的登录日志里初始化 knownCities、lastCity、lastSeenAt 和最近一条轨迹,否则每个账号上线第一天都是冷启动状态,「新城市」规则不会生效。

四、上线要注意的边界 ​

冷启动期不能启用「新城市」规则。 民宿平台的新注册用户占比通常不低,一个刚注册的账号在任何城市下单都是「新城市」。上面用「历史城市少于 3 个或注册不满 7 天」做冷启动判断,这个条件可以按自家数据调整,但一定要有。

跨城预订本身完全正常。 规则设计里唯一的重锤是「时间不可达」——它要求短时间内的长距离跳变,正常用户几乎不可能触发。而「新城市」只给 15 分,配上决策分层最多也就是要求一次短信验证,属于可接受的打扰。

同省移动要放过。 江浙沪用户一天内上海、苏州、杭州来回是常事,免费接口返回的 city 会因为出口变化而跳。生产环境建议加一层判断:prov 相同或 big_area 相同(华东、华南这类大区字段)时,「新城市」规则直接豁免。移动网络出口跨省的情况还可以再放宽到相邻大区。

小程序和 App 的 IP 要单独看。 微信小程序里下单,服务端拿到的可能是微信侧出口的 IP,不是用户真实出口。这类渠道的订单建议只做聚合判断(规则 2、3),不做地域一致性判断;或者在客户端 SDK 里带上运营商侧信息做交叉验证。

限速和缓存的账要算清楚。 免费版 60 次/分钟按出口 IP 计,上面客户端按约 55 次/分钟做了节流,配合 6 小时的属地缓存,一个中型平台(日均订单几千)是够的。如果订单量继续涨,先扩大缓存 TTL(IP 段归属地变化很慢,24 小时也合理),再考虑 VIP 版;订单量上万、并发校验要求毫秒级响应的,就该按站内《高并发下怎么调 IP 接口才不被打爆》的思路把缓存做成分层:本地 Guava/Caffeine 一层、Redis 一层。

付费版本才有的两个字段值得投资。 ip_type(IDC / ISP / BUS)是判断机房出口最可靠的依据,免费版只能靠 isp 文本里有没有「云」这类字眼去猜;area(区县)对判断「房源所在地和下单环境是否同一片区」有帮助。订单风控到这个阶段,VIP 版的按次计费(0.05 元/千次起)通常比误判带来的损失便宜。

合规上留痕要克制。 订单风控采集的是 IP 和归属地,属于个人信息处理,隐私政策里要写明用途(识别异常订单、保障账号与交易安全)和保存期限。给客服看的后台里展示到省市就够,完整 IP 加密存储或做脱敏,按站内《IP 地址是个人信息吗》那篇的口径处理。

总结 ​

民宿短租的订单风控,难点不在规则多,而在「异地预订是常态」这个业务前提。可落地的顺序是:先把账号基线建起来(历史城市 + 最近一次活动时间和位置),再启用一条几乎不会误伤的硬规则(时间不可达),最后叠加聚合类规则(同 IP 多订单、机房出口、跨境国别)。处置上永远是先加强验证、再人工复核,不给业务方「风控一刀切拦住订单」的印象。

IP 属地这一步用 IP9 就够:https://ip9.com.cn/get?ip=<IP> 查指定 IP(IPv4/IPv6 都支持),不传参数返回请求方自己的归属地,响应里有 country、prov、city、post_code、area_code、isp、城市中心经纬度(lng/lat)、big_area 大区等字段,免注册、无需鉴权、60 次/分钟;区县、ip_type、ip_asn 需要 VIP 版。官网:https://www.ip9.com.cn