Appearance
医药电商怎么按地区做合规限售?处方药禁售地区校验的 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 实现可以直接接进现有的订单创建流程里。