Appearance
电商怎么识别虚假收货地址?用 IP 归属地和收货地址交叉校验(Python 实现)
做电商风控的人都有过这种经历:一个订单地址填得工工整整,「广东省深圳市南山区某某路 88 号」,下单 IP 却在内蒙古赤峰,收货人手机号是新号,付完款第二天申请仅退款。客服去核实,电话打不通,地址是菜鸟驿站或者代收点,包裹寄过去无人签收,来回运费自己吞。
这类订单靠地址文本是筛不出来的——地址写得越规范,反而越像真的。能露馅的地方在地址和 IP 属地之间的错位。这篇讲怎么用 IP9 的归属地接口和收货地址做交叉校验,给订单打一个风险分。
一、假地址的几种常见形态
先把要打的靶子列清楚:
- 黑产代收点:地址是某个驿站、便利店、快递柜,全国多个账号共用同一个地址下单,收货人和手机号每次都不一样。
- 跨区下单:地址在深圳,下单 IP 在东北,收货人从未在深圳有过任何行为轨迹,后面多半是拒收或者纠纷单。
- 机房下单:IP 属于云服务器/数据中心(不是家庭宽带),一个人批量操作几百个订单时常见。
- 地址与 IP 随机漂移:同一账号今天地址在上海、IP 在成都,明天地址在西安、IP 在海南,纯粹是脚本随机填的。
这几种各有各的特征,但都绕不开一个环节:地址里的省市,和下单 IP 的省市,能不能对上。
二、为什么不能只看其中一边
只信地址,等于信用户填的文本;只信 IP,会大面积误伤。日常运营里至少有三种正常情况会造成地址与 IP 错位:
- 用户在外地出差、旅游、上学,给家里买东西;
- 公司、学校、产业园共用出口 IP,出口注册地可能在总部城市,也可能是运营商机房所在城市;
- 用手机流量下单,IP 归属地跟着基站走,跨省边界上经常跳到邻省。
所以不能做「不一致就拦」的二值判断,得做分档打分:错得越远、叠加的异常特征越多,分越高,再按分档决定放行、人工审核还是拦截。
三、校验逻辑怎么设计
四个维度累加:
- 省份一致性:地址解析出的省 vs IP 的
prov。一致不加分;同大区不同省加 2 分;跨大区(IP9 返回的big_area,如华东/华北/华南/西南)加 3 分。 - 城市一致性:同省不同市加 1 分——这一条单独看很弱,容易误伤出差用户,但叠在其他特征上就有意义。
- 地址复用度:同一个收货地址在 24 小时内被 3 个以上不同 IP 使用,加 2 分。这是抓代收点最有效的一条。
- 网络类型:
isp字段命中「云/数据中心/IDC/科技」这类关键词,加 2 分。判断是否机房 IP,VIP 版的ip_type字段(ISP 家庭/BUS 企业/IDC 机房)更准,免费版只能拿isp文本做粗筛。
分数汇总到 5 分及以上进拦截队列、3-4 分转人工审核、3 分以下放行。阈值不要拍脑袋,用最近一个月的已确认坏单和正常单回放一遍,看误伤率再定。
四、Python 实现
python
import re
import time
import hashlib
from collections import defaultdict
import requests
API = "https://ip9.com.cn/get"
SESSION = requests.Session()
SESSION.headers.update({"User-Agent": "risk-check/1.0"})
_CACHE = {} # 进程内缓存:IP -> 归属地
ADDR_HITS = defaultdict(set) # 地址指纹 -> {IP 指纹, ...}
ADDR_WINDOW = 24 * 3600
PROVINCES = ["北京", "天津", "上海", "重庆", "河北", "山西", "辽宁", "吉林", "黑龙江",
"江苏", "浙江", "安徽", "福建", "江西", "山东", "河南", "湖北", "湖南",
"广东", "海南", "四川", "贵州", "云南", "陕西", "甘肃", "青海", "台湾",
"内蒙古", "广西", "西藏", "宁夏", "新疆", "香港", "澳门"]
IDC_HINT = ("云", "数据中心", "IDC", "科技有限", "网络科技", "计算")
def ip_location(ip: str, timeout: float = 1.5):
"""查 IP 归属地。ret=400 是非法 IP,ret=429 是超了免费版 60 次/分钟,都返回 None 走兜底。"""
if ip in _CACHE:
return _CACHE[ip]
try:
resp = SESSION.get(API, params={"ip": ip}, timeout=timeout)
body = resp.json()
except (requests.RequestException, ValueError):
return None
if body.get("ret") != 200:
return None
d = body.get("data") or {}
loc = {
"country": d.get("country") or "",
"prov": d.get("prov") or "",
"city": d.get("city") or "",
"big_area": d.get("big_area") or "",
"isp": d.get("isp") or "",
}
_CACHE[ip] = loc
return loc
def parse_address(addr: str):
"""从收货地址里粗解析省市。够用就行,不要上 NLP 模型。"""
prov = next((p for p in PROVINCES if p in addr), "")
# 从省名之后开始找城市,并剥掉「省 / 自治区」这类后缀,
# 否则「广东省深圳市」会被整段当成城市名,同城订单全变异常
tail = addr.split(prov, 1)[1] if prov and prov in addr else addr
tail = re.sub(r"^(?:省|市|自治区|维吾尔|壮族|回族|特别行政区)+", "", tail)
m = re.search(r"([\u4e00-\u9fa5]{2,8}?)(?:市|自治州|地区|盟)", tail)
city = m.group(1) if m else ""
if not prov and city in PROVINCES: # 地址里只写了「深圳市」这种情况
prov = city
return prov, city
def addr_fingerprint(addr: str) -> str:
"""地址指纹:去掉门牌和空格,按天+地址哈希,用于统计复用度,不落原始地址。"""
norm = re.sub(r"[\s\d\-号室栋单元楼座]", "", addr)
return hashlib.sha1(norm.encode()).hexdigest()[:16]
def ip_fingerprint(ip: str) -> str:
"""IP 只存 /24 指纹(IPv4),统计复用度不需要精确到单个 IP。"""
return hashlib.sha1(ip.rsplit(".", 1)[0].encode()).hexdigest()[:16] if "." in ip else ip
def reuse_hits(addr: str, ip: str) -> int:
now = time.time()
key = addr_fingerprint(addr)
for stamp, fp in list(ADDR_HITS.get(key, set())):
if now - stamp > ADDR_WINDOW:
ADDR_HITS[key].discard((stamp, fp))
hit = ADDR_HITS.get(key, set())
added = len({fp for _, fp in hit} | {ip_fingerprint(ip)})
ADDR_HITS[key].add((now, ip_fingerprint(ip)))
return added
def risk_score(address: str, client_ip: str):
loc = ip_location(client_ip)
if loc is None: # 查不到就别打分,宁可放过不误杀
return 0, ["归属地查询失败,跳过校验"]
if loc["country"] not in ("", "中国"):
return 0, [f"访客在{loc['country']},不参与国内地址校验"]
if not loc["prov"]: # 少数 IP 只定位到国家级(如未分配段)
return 0, ["IP 只定位到国家,跳过省份校验"]
prov, city = parse_address(address)
reasons, score = [], 0
if not prov:
score += 1
reasons.append("地址解析不出省份")
elif prov != loc["prov"]:
score += 3 if loc["big_area"] else 2
reasons.append(f"地址在{prov},下单 IP 在{loc['prov']}({loc['big_area'] or '未知大区'})")
elif city and loc["city"] and city != loc["city"]:
score += 1
reasons.append(f"同省不同市:地址{city} / IP {loc['city']}")
added = reuse_hits(address, client_ip)
if added >= 3:
score += 2
reasons.append(f"该地址 24 小时内被 {added} 个不同网段使用")
if any(k in loc["isp"] for k in IDC_HINT):
score += 2
reasons.append(f"网络类型可疑:{loc['isp']}")
return score, reasons
def decide(score: int) -> str:
if score >= 5:
return "拦截"
if score >= 3:
return "人工审核"
return "放行"
if __name__ == "__main__":
# 模拟一组待风控的订单:(订单号, 收货地址, 下单 IP)
orders = [
("A1001", "广东省深圳市南山区科技园路 88 号 3 栋 501", "183.14.30.1"), # 广东深圳电信
("A1002", "广东省深圳市南山区科技园路 88 号 3 栋 501", "120.232.0.1"), # 广东移动,同省不同市
("A1003", "广东省深圳市南山区科技园路 88 号 3 栋 501", "14.215.177.38"), # 同一地址的第 3 个网段
("A1004", "内蒙古赤峰市红山区某驿站 12 号柜", "117.185.16.1"), # 地址在华北,IP 在上海
("A1005", "广东省深圳市南山区科技园路 88 号 3 栋 501", "120.53.16.1"), # 北京腾讯云,机房下单
]
for oid, addr, ip in orders:
s, why = risk_score(addr, ip)
print(oid, decide(s), f"score={s}", "|", ";".join(why))
time.sleep(1) # 免费版 60 次/分钟(约 1 次/秒),批量场景必须自己限速跑出来是这样的:A1001 同城同省,0 分放行;A1002 只是同省不同市,1 分放行;A1003 因为同一个地址被第 3 个网段使用,加到 3 分转人工审核;A1004 地址在内蒙古、下单 IP 在上海,跨大区 3 分进审核;A1005 地址在广东、IP 是北京腾讯云,跨区 + 机房 IP + 地址复用,7 分直接拦。要调松调紧,先动 decide() 里的两个阈值。
五、上线前要调的几个地方
阈值一定要用历史单回放。 拿确认过的坏单和正常单各几百条跑一遍,看两个数字:坏单拦截率、正常单误伤率。误伤率超过千分之二就说明分给高了,先降「同省不同市」这条的分值。
共享出口要豁免。 公司、校园网、产业园的出口 IP 每年都一样,这类 IP 出现跨区下单很正常。维护一张出口 IP 白名单(按 ISP + 网段),命中就跳过省份校验。站内那篇讲 IP 风控误伤的文章里列过怎么筛这类出口。
地址复用度别用完整地址当 key。 用户会把门牌写错、加空格、加表情,指纹化之前先去门牌数字和标点,只留到「省市+路名+小区」这一级,命中率更高。
合规上只留聚合结果。 地址和 IP 都是个人信息,风控只需要「某网段 + 某地址」的计数,不需要原始留存。上面的代码里地址和 IP 都是哈希后计数,原始值用完即弃。真要留存做证据,按站内《IP 地址能作为证据吗》那篇的流程固定。
查不到归属地时不要加分。 接口超时、返回 ret=400、触发限速(ret=429)都属于「信息缺失」,缺失不等于可疑。风控最容易出的错就是把查不到当成了坏信号,结果一断网就全站拦截。
总结
虚假收货地址的本质是「地址文本」和「网络位置」两个独立信号对不上,单看任何一个都会翻车。落地顺序建议:先只做「省份一致性 + 跨大区」这一条,跑一段时间积累数据,再叠加地址复用度和机房识别。
查 IP 属地这一步用 IP9 的免费接口就够:https://ip9.com.cn/get?ip=<IP>(不传参则返回请求方自己的归属地,IPv4/IPv6 都支持,返回国家、省市、邮编、区号、运营商、大区、城市中心经纬度等字段),免注册、无需鉴权,免费版 60 次/分钟,批量场景记得加缓存和限速。官网:https://www.ip9.com.cn