Skip to content

电商怎么识别虚假收货地址?用 IP 归属地和收货地址交叉校验(Python 实现)

做电商风控的人都有过这种经历:一个订单地址填得工工整整,「广东省深圳市南山区某某路 88 号」,下单 IP 却在内蒙古赤峰,收货人手机号是新号,付完款第二天申请仅退款。客服去核实,电话打不通,地址是菜鸟驿站或者代收点,包裹寄过去无人签收,来回运费自己吞。

这类订单靠地址文本是筛不出来的——地址写得越规范,反而越像真的。能露馅的地方在地址和 IP 属地之间的错位。这篇讲怎么用 IP9 的归属地接口和收货地址做交叉校验,给订单打一个风险分。

一、假地址的几种常见形态

先把要打的靶子列清楚:

  • 黑产代收点:地址是某个驿站、便利店、快递柜,全国多个账号共用同一个地址下单,收货人和手机号每次都不一样。
  • 跨区下单:地址在深圳,下单 IP 在东北,收货人从未在深圳有过任何行为轨迹,后面多半是拒收或者纠纷单。
  • 机房下单:IP 属于云服务器/数据中心(不是家庭宽带),一个人批量操作几百个订单时常见。
  • 地址与 IP 随机漂移:同一账号今天地址在上海、IP 在成都,明天地址在西安、IP 在海南,纯粹是脚本随机填的。

这几种各有各的特征,但都绕不开一个环节:地址里的省市,和下单 IP 的省市,能不能对上

二、为什么不能只看其中一边

只信地址,等于信用户填的文本;只信 IP,会大面积误伤。日常运营里至少有三种正常情况会造成地址与 IP 错位:

  1. 用户在外地出差、旅游、上学,给家里买东西;
  2. 公司、学校、产业园共用出口 IP,出口注册地可能在总部城市,也可能是运营商机房所在城市;
  3. 用手机流量下单,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