Appearance
游戏防沉迷:平台怎么识别未成年人异地登录?(Java 实现)
未成年人账号限玩,是 2021 年之后游戏行业的常态:每周只能玩三小时,晚上十点到次日八点禁玩。真按这个规则执行的家长,孩子确实玩不了多少。但搜「防沉迷 解除」依然能搜出一堆生意——租号平台、代过人脸、拿家长身份证重实名。未成年人拿大人的账号玩,平台光靠实名信息根本看不出来,这就是防沉迷绕开的最后一环。
腾讯这类头部厂商的做法是双层:实名 + 人脸抽查。人脸成本高、体验差,不能每次登录都弹。中间缺一层「低成本、不打扰」的初筛——登录时的 IP 归属地核验就是这一层:一个 12 岁小孩的账号深夜在异地城市上线,或一个实名成人的账号长期在中学宿舍区活跃,先打个问号,再决定要不要上人脸。这篇用 Java 写一个这样的辅助核验服务。
一、未成年人绕防沉迷的典型路径
归纳一下常见绕法,每一条都会在 IP 上留痕迹:
- 拿家长账号玩:账号实名是 40 岁成年男性,但登录行为高度集中在晚间、周末和寒暑假,且长期固定在某几个 IP——问家属账号常用地址一核对就露馅;
- 租号:租号平台账号池有限,几十上百个买家共用少量 IP,一个 IP 在深夜高频关联多个账号;
- 异地代练/托管:账号登录 IP 突然从城市 A 跳到城市 B,和账号常用地、绑定的手机归属地都对不上,且常常持续到凌晨。
平台不可能每次都弹人脸,但可以把这几类「疑点」做成规则:IP 归属地 + 活跃时段 + 常用地比对,命中就打标,攒够置信度再触发二次验证。
二、方案设计:登录事件 -> 归属地 -> 规则引擎
服务分三层:
- 接入层:登录接口 / 会话心跳里上报 IP(注意取真实客户端 IP,别被 X-Forwarded-For 伪造骗过去,直接信任 CDN 透传的头字段要校验);
- 特征层:写一个
GeoService,把 IP 送 IP9 接口换归属地(国家/省市/运营商),结果缓存起来,同一 IP 当天不重复查询; - 规则层:对每个账号维护「常用地画像」(过去 30 天出现过的省市集合),新登录时比对。规则:不在常用地 + 时段敏感(深夜/上课时间)+ 账号年龄画像异常,三选二即触发核验队列。
下面代码就是这个服务的骨架:Spring Boot 风格,用 RestClient 调 IP9,ConcurrentHashMap 做缓存,规则判断收敛在一个方法里,方便调阈值。
三、Java 实现:登录核验服务
java
import java.time.LocalTime;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
import org.springframework.web.client.RestClient;
public class LoginGuardService {
// IP9 免费接口:GET https://ip9.com.cn/get?ip=<ip>
private static final String GEO_API = "https://ip9.com.cn/get?ip=";
private final RestClient client = RestClient.create();
// 缓存: ip -> "prov|city",当天有效,省免费额度
private final Map<String, String> geoCache = new ConcurrentHashMap<>();
record Geo(String prov, String city, String isp) {}
/** 查询归属地,失败返回 null,调用方按放行处理(宁可放过,不可误伤) */
private Geo lookup(String ip) {
String key = geoCache.get(ip);
if (key != null) {
String[] p = key.split("\\|");
return new Geo(p[0], p[1], p[2]);
}
try {
Map<?, ?> body = client.get()
.uri(GEO_API + ip)
.retrieve()
.body(Map.class);
if (body == null || !Integer.valueOf(200).equals(body.get("ret"))) {
return null; // 非法 IP 或限流,按放行
}
Map<?, ?> data = (Map<?, ?>) body.get("data");
Geo geo = new Geo(String.valueOf(data.get("prov")),
String.valueOf(data.get("city")),
String.valueOf(data.get("isp")));
geoCache.put(ip, geo.prov() + "|" + geo.city() + "|" + geo.isp());
return geo;
} catch (Exception e) {
return null; // 网络异常一律放行,风控不能阻塞正常登录
}
}
/** 账号画像:常用省市集合(正常现网从 DB/Redis 读) */
private Map<String, Integer> usualCities(String userId) {
// 简化:真实实现查「过去30天登录省市」聚合
return Map.of("广东|广州", 80, "广东|深圳", 15);
}
/** 核验入口:返回 1=放行 2=二次核验(人脸/短信) 3=拦截+人工 */
public int verify(String userId, String ip, LocalTime now) {
Geo geo = lookup(ip);
if (geo == null) {
return 1;
}
String city = geo.prov() + "|" + geo.city();
Map<String, Integer> usual = usualCities(userId);
if (usual.containsKey(city)) {
return 1; // 常用地直接放行,不打扰
}
int hits = 0;
// 信号1:不在常用地
hits++;
// 信号2:深夜登录(22:00-08:00 未成年人禁玩时段)
boolean night = now.isBefore(LocalTime.of(8, 0))
|| !now.isBefore(LocalTime.of(22, 0));
if (night) {
hits++;
}
// 信号3:机房特征,租号/托管常用 IDC 出口(VIP 字段 ip_type=IDC)
if (geo.isp().contains("IDC") || geo.isp().contains("数据中心")) {
hits++;
}
// 信号4:账号画像年龄异常(实名成年但活跃时段全在夜间)——现网由画像服务提供
return hits >= 3 ? 3 : (hits >= 2 ? 2 : 1);
}
}拆开看几个关键点。
缓存与容错:geoCache 保证同一 IP 一天只查一次,几十万日活也就几万次查询;接口异常、超时全部按「放行」处理——防沉迷的风控可以漏,不能把正常玩家挡在门外。返回码语义:ret=400 非法 IP、ret=429 限流,这两类都走 null 分支。
规则收敛:命中计数是刻意做得「看得懂」的。生产环境会把 usualCities 换成真实的 30 天画像服务,把 night 时段和实名年龄做交叉(成年账号深夜常态登录本身就有问题),再加设备指纹、人脸置信度这些外部信号一起打分。
别直接在控制器里调:这个服务应当放在登录链路的事件异步通道上,登录先放行,异步核验命中高危再召回弹人脸,不然每次登录都同步等 HTTP 查询,接口延迟会很难看。
四、落地注意事项
- 数据合规是硬约束:IP 归属地涉及个人信息,用途限定「防沉迷与账号安全」,建议只存省市级结果,不存原始 IP 或存了加密+定期清理,隐私政策里写清楚;
- 误伤兜底:异地登录最常见的原因是「寒暑假回老家」「住校生周末回家」,这两种恰好是未成年人真实场景。所以第一次异地命中只打标不拦截,二次命中且时段敏感才上人脸,申诉渠道必须畅通;
- 租号是猫鼠游戏:租号平台会用住宅代理换 IP,规则 3 会失效,但「一个 IP 高频关联多账号」这类聚集特征还在,配合账号侧的设备指纹一起看;
- 阈值要灰度:先跑两周「只打标不处置」,统计正常未成年用户(寒暑假轨迹)的误报率,再决定哪些信号加权。
总结
防沉迷的最后一公里是「识别账号背后的真实使用者」,IP 归属地核验是成本最低的初筛层:常用地画像 + 时段敏感 + 机房特征三条规则,就能把「拿家长账号深夜在异地登录」这类典型行为筛出来送二次核验。示例里用的 https://ip9.com.cn/get?ip=xxx,免费版 60 次/分钟够小体量平台做缓存后的核验;要 ip_type(ISP/BUS/IDC)精确区分家庭宽带与机房出口,看官网 https://www.ip9.com.cn 的 VIP 说明。把这段逻辑接进登录事件流,先灰度打标两周,用真实数据校准阈值,再决定什么时候升级成人脸核验——未成年人保护和玩家体验不是二选一。