Appearance
民宿、短租平台的订单怎么防异常?用 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