Skip to content

电商多仓就近发货怎么做?Java 按 IP 归属地自动选仓 ​

做电商的都知道这个矛盾:仓库建得越多,物流越快,但成本也越高。几个仓放哪、订单走哪个仓,直接决定「今天下单明天到」还是「路上飘三天」。手动分仓不现实——几千单里买家遍布全国,靠人工判断早爆仓了。自动选仓的核心输入就一个:买家在哪个城市。城市从哪来?下单选填的收货地址当然准,但没填对、或者想提前预估运费时效的时候,买家 IP 归属地就是最省事的数据源。这篇用 Java 写一套完整的「IP 归属地 → 就近仓库」选仓逻辑。

一、多仓路由为什么难 ​

多仓发货的常规做法是「按省分区」:华东仓管华东的订单。但问题在于:

  • 买家在边界城市:比如廊坊的订单,离华北仓和华东仓都很近,按行政区域切容易切错;
  • 仓库在挪:新仓上线、旧仓搬迁,省区映射表要跟着改,维护成本高;
  • 缺兜底:收货地址异常、解析失败的单,总得有个默认仓接着。

用 IP 归属地 + 经纬度距离来算,能把这些问题一起解决:不用维护省区表,拿仓库和买家的经纬度直接算直线距离,谁近发谁。IP 归属地的经纬度是城市中心点,对选仓来说精度完全够用——你不需要精确到楼,需要的是「这个买家离哪个仓最近」。

二、方案设计 ​

  1. 数据准备:仓库表(仓库名 + 经纬度),维护在配置里或数据库;
  2. 拿买家 IP:服务端从请求里取真实 IP(注意取 X-Forwarded-For 不能直接信,要校验格式);
  3. 查归属地:调 https://ip9.com.cn/get?ip=xxx 拿省份、城市、经纬度;
  4. 算距离:Haversine 公式算买家城市中心和每个仓库的球面距离;
  5. 选仓:距离最近的仓库为发货仓;归属地查询失败就回退默认仓。

加上缓存:同城市买家很多,按「城市」缓存归属地(而不是按 IP 缓存),命中率更高,免费接口 60 次/分钟/IP 的额度完全够日常订单量。

三、Java 实现 ​

java
// WarehouseRouter.java —— 按买家 IP 归属地选择最近仓库
// 运行:java WarehouseRouter.java(Java 11+,单文件,无外部依赖)
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.util.*;
import java.util.concurrent.ConcurrentHashMap;

public class WarehouseRouter {

    // 仓库列表:名称 + 经度 + 纬度(城市中心级即可)
    static final List<Warehouse> WAREHOUSES = List.of(
        new Warehouse("华北仓(北京)", 116.40, 39.90),
        new Warehouse("华东仓(上海)", 121.47, 31.23),
        new Warehouse("华南仓(广州)", 113.26, 23.13),
        new Warehouse("西南仓(成都)", 104.07, 30.67),
        new Warehouse("华中仓(武汉)", 114.31, 30.59)
    );

    static final HttpClient HTTP = HttpClient.newHttpClient();
    // 城市 -> 归属地 缓存(选仓场景按城市缓存命中率最高)
    static final Map<String, GeoInfo> CITY_CACHE = new ConcurrentHashMap<>();

    static class GeoInfo { String prov, city; double lng, lat; }
    static class Warehouse { String name; double lng, lat;
        Warehouse(String n, double lng, double lat) { this.name = n; this.lng = lng; this.lat = lat; } }

    /** 调 ip9.com.cn/get 查询 IP 归属地,免费版 60 次/分钟/IP */
    static GeoInfo geoByIP(String ip) throws Exception {
        String url = "https://ip9.com.cn/get?ip=" + ip;
        HttpRequest req = HttpRequest.newBuilder(URI.create(url)).GET().build();
        HttpResponse<String> resp = HTTP.send(req, HttpResponse.BodyHandlers.ofString());
        // 返回 {"ret":200,"data":{...,"prov":"江苏","city":"南京","lng":"118.77","lat":"32.04",...}}
        String body = resp.body();
        GeoInfo g = new GeoInfo();
        g.prov  = strOf(body, "prov");
        g.city  = strOf(body, "city");
        g.lng   = Double.parseDouble(strOf(body, "lng"));   // 城市中心经度
        g.lat   = Double.parseDouble(strOf(body, "lat"));   // 城市中心纬度
        return g;
    }

    /** 从 JSON 字符串里抠字段值(样例数据,生产建议用 Jackson/Gson) */
    static String strOf(String json, String key) {
        int i = json.indexOf("\"" + key + "\"");
        if (i < 0) return "";
        i = json.indexOf('"', i + key.length() + 2);  // 跳到值起始引号
        int j = json.indexOf('"', i + 1);
        return json.substring(i + 1, j);
    }

    /** Haversine 球面距离,返回公里数 */
    static double distanceKm(double lat1, double lng1, double lat2, double lng2) {
        double R = 6371.0;
        double dLat = Math.toRadians(lat2 - lat1);
        double dLng = Math.toRadians(lng2 - lng1);
        double a = Math.sin(dLat / 2) * Math.sin(dLat / 2)
                 + Math.cos(Math.toRadians(lat1)) * Math.cos(Math.toRadians(lat2))
                 * Math.sin(dLng / 2) * Math.sin(dLng / 2);
        return 2 * R * Math.asin(Math.sqrt(a));
    }

    /** 核心:给定买家 IP,返回最近仓库 */
    static Warehouse nearestWarehouse(String buyerIP) throws Exception {
        GeoInfo g = CITY_CACHE.get(buyerIP);          // 简化:演示用 IP 做 key,生产按城市
        if (g == null) {
            g = geoByIP(buyerIP);
            CITY_CACHE.put(buyerIP, g);
        }
        System.out.println("买家归属地: " + g.prov + g.city
                + " (" + g.lat + "," + g.lng + ")");
        Warehouse best = null;
        double bestDist = Double.MAX_VALUE;
        for (Warehouse w : WAREHOUSES) {
            double d = distanceKm(g.lat, g.lng, w.lat, w.lng);
            System.out.printf("  -> %s 距离 %.0f km%n", w.name, d);
            if (d < bestDist) { bestDist = d; best = w; }
        }
        return best;
    }

    public static void main(String[] args) throws Exception {
        // 示例:查两个不同地区的买家
        for (String ip : new String[]{"114.114.114.114", "183.14.28.11"}) {
            Warehouse w = nearestWarehouse(ip);
            System.out.println("选仓结果: " + w.name + "\n");
        }
    }
}

跑起来的思路:程序会把买家的归属地和每个仓库的距离都打出来。江苏南京的 IP 距离上海仓最近,选华东仓;广东的 IP 选华南仓。归属地查询那段做了字段解析,生产环境建议直接换 Jackson,字段更多更稳(免费版还返回城市编码、邮编、大区、运营商等)。

四、落地注意事项 ​

  • IP 取法要小心:服务器前面挂了 CDN/负载均衡,X-Forwarded-For 由客户端可控——先取最后一个可信代理追加的 IP,别直接把整段 XFF 当买家 IP,否则会被伪造。
  • 经纬度精度认知:IP 归属地给的是城市中心坐标,不是买家精确位置。选仓要的是「城市级」判断,城市中心距离误差几十公里不影响结论;但如果你拿它去算「小时级送达范围」就会翻车,那是另一套数据。
  • 缓存策略:同一城市买家共享归属地,按 城市编码 或 省+市 缓存,而不是按 IP 缓存,免费额度能用得非常省。缓存失效时间设 24 小时,IP 归属地不会频繁变。
  • 兜底逻辑:查询失败、解析异常、海外 IP(无对应国内仓)都要有默认仓;海外订单单独走国际仓线,别硬塞国内仓。
  • 拆单场景:一单多件、部分仓缺货时,允许「就近仓为主 + 次近仓补发」,跨仓拆单前先算下运费和时效哪个划算。

总结 ​

多仓选仓的本质是「拿买家位置和仓库位置做距离排序」,IP 归属地接口提供了这个位置——城市级精度、免费、不用买家授权,最适合做下单前的预估和默认路由。接口就是 https://ip9.com.cn/get?ip=xxx,返回国家、省、市、经纬度、运营商等字段,免费版 60 次/分钟/IP、无需注册;要更细的区县级或其他字段可看官网 https://www.ip9.com.cn 的 VIP 说明。代码量不大,核心就是「查归属地 → 算距离 → 排序选仓」三步,接进订单服务后,运费预估和时效承诺都能自动算。