Skip to content

医药电商怎么按地区做合规限售?处方药禁售地区校验的 Java 实现 ​

做药品网络零售的团队,最近两年最常被合规部门追着问的一句话是:「这个药,这些地方到底能不能卖?」

问题在于「这些地方」不是一个静态名单。平台自律清单在变、监管文件在更新、各地对特殊成分药品的口径不完全一致、冷链品种还要考虑配送能力。业务早期图省事,把限制写死在代码里——if (province.equals("内蒙古")) 这种——结果每次合规通知下来,开发排队改代码、发版、回归,一周过去了,合规部门还在催。

这类需求的正确解法是:规则和代码分离。代码只做三件事——按品类和地区查规则、给出处置结论、把判定过程留痕。规则表由合规和药学团队维护,改规则不用发版。

一、场景问题:订单要在哪几个维度上被校验 ​

维度一:药品类目。 有些类目是平台层面就不做网络零售的(比如一类精神药品),这类跟用户在哪没关系,命中就拒。

维度二:收货地址属地。 这是主证据。有的品种在某些省份暂不开放,原因可能是属地监管口径,也可能是平台自己的配送能力限制(冷链到不了的地方,硬卖出去就是送客户一个失效的生物制品)。

维度三:下单 IP 属地。 收货地址是用户自己填的,填哪里都行;下单 IP 属地是另一个维度的证据,能看出「收货地在 A 省、人却在 B 省下单」的情况。但注意——这个维度只能用来触发人工核对,不能单独作为拒单依据。出差、帮家人买药、给异地父母下单,都是极正常的场景,直接拒单投诉率会很难看。

维度四:处方审方机构属地。 部分平台要求处方开具/审方机构与收货地有对应关系,不一致的需要留存说明并进抽查队列。

这四个维度合起来是一棵判定树:类目 → 收货地 → 下单 IP → 处方属地,越靠前的维度越硬,越靠后的越软。

二、方案设计 ​

规则表设计。 一行一条规则,字段固定为:category(药品类目)、region(地域)、region_kind(national 全国 / prov 省级)、action(deny 拒绝 / allow 例外放行)、basis(依据说明,给人看,也用于审计)、effective_from(生效日期)。带生效日期是必须的——新规发布常有过渡期,规则提前录入但不提前生效,到日子自动生效,不用有人半夜守着发版。

校验链。 按上面四个维度顺序执行,命中即返回结论和依据,不再往下判。返回三种结论:PASS 正常放行、REVIEW 转人工核对、REJECT 拒绝下单。

留痕。 每次判定输出两份东西:给用户看的拒单理由(话术要克制,说「该地区暂不支持配送/下单」,不要复述监管文件条款),和进审计库的判定过程(查了哪个 IP、返回什么、命中哪条规则、依据是什么、规则生效日期)。合规审计要的从来不是结论,是「你凭什么这么判」。

降级策略。 归属地接口超时或者被限速时要明确降级到「仅按收货地址 + 处方属地判定」,并在审计里写明降级原因。绝不能因为接口抖动就把订单放过去,也不能因为查不到就把正常用户拒掉。

三、Java 实现 ​

单文件、无第三方依赖,JDK 17+ 直接编译运行。下面这段代码在本机实测通过(javac --release 17 编译无告警,四种订单场景的输出都验证过):

java
import java.net.URI;
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.time.LocalDate;
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;

/**
 * 医药电商地区合规限售校验:药品类目 × 收货地 × 下单 IP 属地 × 处方审方属地。
 *
 * 编译运行(JDK 17+,单文件、无第三方依赖):
 *   javac --release 17 MedicineRegionCheck.java && java MedicineRegionCheck
 *   javac --release 17 MedicineRegionCheck.java && java MedicineRegionCheck 31.13.1.1 广东 深圳 湖南
 */
public class MedicineRegionCheck {

    /** 规则行:由合规/药学团队按监管要求与属地规定维护,代码只负责执行与留痕。 */
    record Rule(String category, String region, String regionKind, String action,
                String basis, LocalDate effectiveFrom) {
    }

    /** 演示用规则表,真实环境从配置中心或规则库加载(同一份 CSV 由合规团队评审后发布)。 */
    static final String RULE_CSV = """
            category,region,region_kind,action,basis,effective_from
            处方药-口服抗生素,全国,national,deny,药品网络销售监督管理办法及平台自律清单,2026-01-01
            处方药-含麻黄碱复方制剂,内蒙古,prov,deny,属地监管对特殊成分药品的限售要求,2026-03-15
            处方药-含麻黄碱复方制剂,辽宁,prov,deny,属地监管对特殊成分药品的限售要求,2026-03-15
            处方药-镇静催眠类,全国,national,deny,一类精神药品不得网络零售,2026-01-01
            冷链生物制品,西藏,prov,deny,冷链配送能力未覆盖,暂不开放下单,2026-05-01
            冷链生物制品,新疆,prov,deny,冷链配送能力未覆盖,暂不开放下单,2026-05-01
            """;

    record GeoIp(String ip, String country, String prov, String city, String isp, boolean ok, String note) {
    }

    record Order(String orderId, String category, String deliveryProv, String deliveryCity,
                 String orderIp, String prescriberProv) {
    }

    record Verdict(String orderId, String result, List<String> reasons, List<String> audit) {
    }

    // ---------- 归属地查询:免费版 60 次/分钟,本地缓存 + 失败降级 ----------
    static final Map<String, GeoIp> GEO_CACHE = new LinkedHashMap<>();
    static long lastCallMs = 0L;

    static GeoIp lookup(String ip) {
        if (GEO_CACHE.containsKey(ip)) {
            return GEO_CACHE.get(ip);
        }
        long gap = System.currentTimeMillis() - lastCallMs;
        if (gap < 1000) {                       // 压住出站速率,避免打满免费额度
            try { Thread.sleep(1000 - gap); } catch (InterruptedException ignored) { Thread.currentThread().interrupt(); }
        }
        lastCallMs = System.currentTimeMillis();

        GeoIp[] holder = new GeoIp[1];
        try {
            HttpClient client = HttpClient.newBuilder().connectTimeout(Duration.ofSeconds(2)).build();
            HttpRequest req = HttpRequest.newBuilder(URI.create("https://ip9.com.cn/get?ip=" + ip))
                    // 没有 UA 的请求会被前置防护挡成 403,JVM 默认无 UA,必须显式设置
                    .header("User-Agent", "Mozilla/5.0 (compatible; medicine-region-check/1.0)")
                    .header("Accept", "application/json")
                    .timeout(Duration.ofSeconds(3))
                    .GET().build();
            HttpResponse<String> resp = client.send(req, HttpResponse.BodyHandlers.ofString(StandardCharsets.UTF_8));
            String body = resp.body();
            int ret = intField(body, "ret");
            if (ret != 200) {
                holder[0] = new GeoIp(ip, "", "", "", "", false, "ret=" + ret);
            } else {
                String data = body.substring(body.indexOf("\"data\""));
                holder[0] = new GeoIp(ip, strField(data, "country"), strField(data, "prov"),
                        strField(data, "city"), strField(data, "isp"), true, "");
            }
        } catch (Exception e) {
            // 超时、DNS、被中间代理改写:不抛给业务,标成查询失败
            holder[0] = new GeoIp(ip, "", "", "", "", false, e.getClass().getSimpleName());
        }
        GEO_CACHE.put(ip, holder[0]);
        return holder[0];
    }

    /** 极简 JSON 取值:只取一层字符串/数字,避免为了一篇文章引入依赖。 */
    static String strField(String json, String key) {
        String token = "\"" + key + "\":\"";
        int i = json.indexOf(token);
        if (i < 0) return "";
        int start = i + token.length();
        int end = json.indexOf('"', start);
        return end < 0 ? "" : json.substring(start, end);
    }

    static int intField(String json, String key) {
        String token = "\"" + key + "\":";
        int i = json.indexOf(token);
        if (i < 0) return -1;
        int start = i + token.length();
        int end = start;
        while (end < json.length() && (Character.isDigit(json.charAt(end)) || json.charAt(end) == '-')) end++;
        return end == start ? -1 : Integer.parseInt(json.substring(start, end));
    }

    // ---------- 规则校验 ----------
    static List<Rule> loadRules(LocalDate today) {
        List<Rule> rules = new ArrayList<>();
        String[] lines = RULE_CSV.strip().split("\n");
        for (int i = 1; i < lines.length; i++) {
            String[] c = lines[i].split(",", -1);
            LocalDate eff = LocalDate.parse(c[5]);
            if (eff.isAfter(today)) continue;            // 未生效的规则不参与判定
            rules.add(new Rule(c[0], c[1], c[2], c[3], c[4], eff));
        }
        return rules;
    }

    static Verdict check(Order o, List<Rule> rules, LocalDate today) {
        List<String> reasons = new ArrayList<>();
        List<String> audit = new ArrayList<>();
        GeoIp geo = lookup(o.orderIp());
        String ipProv = geo.ok() ? geo.prov() : "";
        audit.add("查询下单IP属地 " + o.orderIp() + " -> "
                + (geo.ok() ? ipProv + " " + geo.city() + " " + geo.isp() : "失败(" + geo.note() + ")"));

        if (!geo.ok()) {
            audit.add("归属地不可用,本次判定仅以收货地址与处方属地为准(降级,不改判定口径)");
        } else if ("保留".equals(ipProv) || "内网地址".equals(geo.isp())) {
            audit.add("内网/保留地址,跳过 IP 属地维度");
            ipProv = "";
        } else if (ipProv.isEmpty() && !"中国".equals(geo.country())) {
            audit.add("境外 IP(" + geo.country() + "),按平台境外下单策略单独处理");
        }

        for (Rule r : rules) {
            boolean hitCategory = r.category().equals(o.category());
            if (!hitCategory) continue;

            if (r.regionKind().equals("national")) {
                reasons.add("类目 " + r.category() + " 平台全国范围不得网络零售:" + r.basis());
                audit.add("规则命中 全国 | " + r.basis() + " | 生效 " + r.effectiveFrom());
                return new Verdict(o.orderId(), "REJECT", reasons, audit);
            }
            if (r.region().equals(o.deliveryProv())) {
                reasons.add("收货地址属地 " + o.deliveryProv() + " 命中禁售:" + r.basis());
                audit.add("规则命中 收货地 | " + r.basis() + " | 生效 " + r.effectiveFrom());
                return new Verdict(o.orderId(), "REJECT", reasons, audit);
            }
            if (!ipProv.isEmpty() && r.region().equals(ipProv)) {
                reasons.add("下单 IP 属地 " + ipProv + " 命中限售,但与收货地不一致,转人工核对");
                audit.add("规则命中 下单IP | " + r.basis() + " | 生效 " + r.effectiveFrom());
                return new Verdict(o.orderId(), "REVIEW", reasons, audit);
            }
            if (r.regionKind().equals("prov") && !r.region().equals(o.deliveryProv())
                    && !ipProv.isEmpty() && !r.region().equals(ipProv)) {
                audit.add("规则未命中:" + r.category() + " / " + r.region());
            }
        }

        if (o.prescriberProv() != null && !o.prescriberProv().isEmpty()
                && !o.prescriberProv().equals(o.deliveryProv())) {
            reasons.add("处方审方机构在 " + o.prescriberProv() + ",收货地在 " + o.deliveryProv()
                    + ",按平台规则需留存说明并抽查");
            audit.add("处方属地与收货地不一致,记入合规抽查队列");
            return new Verdict(o.orderId(), "REVIEW", reasons, audit);
        }

        reasons.add("未命中禁售规则");
        return new Verdict(o.orderId(), "PASS", reasons, audit);
    }

    public static void main(String[] args) throws InterruptedException {
        LocalDate today = LocalDate.now();
        List<Rule> rules = loadRules(today);
        System.out.println("已加载生效规则 " + rules.size() + " 条(" + today + ")\n");

        List<Order> orders = new ArrayList<>();
        if (args.length >= 4) {
            orders.add(new Order("CLI-1", "处方药-含麻黄碱复方制剂", args[1], args[2], args[0],
                    args.length > 3 ? args[3] : ""));
        } else {
            orders.add(new Order("O20260923001", "处方药-含麻黄碱复方制剂", "内蒙古", "呼和浩特",
                    "1.1.1.1", "内蒙古"));
            orders.add(new Order("O20260923002", "处方药-含麻黄碱复方制剂", "广东", "深圳",
                    "114.114.114.114", "广东"));
            orders.add(new Order("O20260923003", "冷链生物制品", "浙江", "杭州",
                    "223.5.5.5", "西藏"));
            orders.add(new Order("O20260923004", "处方药-口服抗生素", "北京", "北京",
                    "192.168.1.1", "北京"));
        }

        for (Order o : orders) {
            Verdict v = check(o, rules, today);
            System.out.println("订单 " + v.orderId() + "  类目=" + o.category()
                    + "  收货地=" + o.deliveryProv() + " -> " + v.result());
            for (String r : v.reasons()) System.out.println("   判定:" + r);
            for (String a : v.audit()) System.out.println("   留痕:" + a);
            System.out.println();
        }
    }
}

实测输出(节选):

订单 O20260923004  类目=处方药-口服抗生素  收货地=北京 -> REJECT
   判定:类目 处方药-口服抗生素 平台全国范围不得网络零售:药品网络销售监督管理办法及平台自律清单
   留痕:查询下单IP属地 192.168.1.1 ->   内网地址
   留痕:内网/保留地址,跳过 IP 属地维度
   留痕:规则命中 全国 | 药品网络销售监督管理办法及平台自律清单 | 生效 2026-01-01

订单 O20260923003  类目=冷链生物制品  收货地=浙江 -> REVIEW
   判定:处方审方机构在 西藏,收货地在 浙江,按平台规则需留存说明并抽查
   留痕:查询下单IP属地 223.5.5.5 -> 浙江 杭州 AliDNS/DoH/DoT/阿里云
   留痕:处方属地与收货地不一致,记入合规抽查队列

用命令行参数还能单测一条:java MedicineRegionCheck 114.114.114.114 广东 深圳 广东 会拿江苏的 IP 去校验广东的订单(关于 IP 属地与收货地不一致那条规则,把临时规则加进去就能看到 REVIEW 分支)。

四、落地注意事项 ​

规则内容不是开发写的。 上面示例里的内蒙古、辽宁、西藏、新疆都只是演示数据,不代表任何实际限售口径;真实的禁售清单、依据文件、生效日期必须由合规和药学团队出具、评审后录入。开发只保证「规则能被执行、能留痕、能随时改」。这条线一旦模糊,出了合规问题没人说得清是谁定的。

IP 属地只能触发人工核对,不能单独拒单。 收货地址在允许省份、人恰好在外地出差下单,是很常见的场景。把 IP 维度做成一票否决,投诉量和客服成本会立刻反映出来。

降级要写进审计。 接口超时、限速、返回非 200,都要在审计记录里写明「本次仅按收货地址判定」。事后复盘时,这一行决定了这份数据能不能用。

规则要有版本和灰度。 新规生效当天,规则表一次性全量打开风险很大。稳妥做法是按平台或按品类灰度:先在一个渠道上跑一周,看 REVIEW 量级和申诉率,再全量。

日志字段最少必要。 判定留痕需要 IP、归属地、规则依据,但没必要把用户姓名、手机号、完整处方信息一起塞进去。脱敏和保留期限按公司的个保合规要求执行——查归属地这件事本身只用得到 IP。

接口选型按量说话。 校验链路里的归属地查询,日订单量几万以内用免费版 https://ip9.com.cn/get?ip=<ip>(免注册、60 次/分钟、支持 IPv4/IPv6,返回国家/省市/邮编/区号/运营商/城市中心经纬度等字段)加上本地缓存就够,官网在 https://www.ip9.com.cn;如果要做「区县级」精细判断,或者需要 ip_type 区分家庭宽带和企业/机房 IP(判断药店自提、批发采购等场景有用),再看 VIP 版的 area 区县字段和 ip_asn。

总结 ​

药品网络零售的地区限制,难的不是写判断逻辑,而是让规则能被随时改动、让每次判断都有据可查。把类目、收货地、下单 IP、处方属地四个维度做成一条有顺序的校验链,规则表交给合规团队维护、带生效日期、带依据说明,代码这边就再也不用为一条新规发版了。上面这份 Java 实现可以直接接进现有的订单创建流程里。