package cn.lihu.jh.module.ecg.utils;
|
|
import java.net.Inet4Address;
|
import java.net.InetAddress;
|
import java.net.NetworkInterface;
|
import java.util.Enumeration;
|
import java.util.LinkedHashSet;
|
import java.util.Set;
|
|
/**
|
* 访问方 IP 的规范化与校验(供「选择工位模式」使用)。
|
*
|
* <h3>地址族策略:只采用 IPv4</h3>
|
* 本系统现场工位绑定统一使用 IPv4(`clinic_room.ip` 里存的就是 `32.88.65.x` 这类地址)。
|
* 因此:
|
* <ul>
|
* <li>{@link #isBindableIp(String)} **只接受 IPv4**;IPv6(含回环 {@code ::1})一律不参与匹配与绑定,
|
* 只用于诊断提示;</li>
|
* <li>为兼容"浏览器 ↔ 服务器同机访问"与"容器/网关把 IPv4 表示成 IPv4-mapped IPv6"这两种情况,
|
* 规范化时会做两件事:把 {@code ::ffff:32.88.65.208} **还原成 {@code 32.88.65.208}**(还原后可用),
|
* 把 {@code ::1} / {@code 0:0:0:0:0:0:0:1} 识别为<b>回环</b>(不可用,但提示语要说清"本机访问");
|
* <li>去掉 IPv6 的方括号与区域号({@code %eth0})、统一小写、代理转发链取第一个。</li>
|
* </ul>
|
*
|
* <h3>「服务器自身地址」不作为工位标识(本次新增)</h3>
|
* 现场反馈过"把登录系统的网址上的 IP 当成了本机 IP"。根因通常是:**在服务器上 / 通过远程桌面访问系统**,
|
* 或前置代理没透传客户端地址 —— 此时后端看到的"客户端"其实就是服务器自己。
|
* 这种地址必须拒绝(否则会把服务器 IP 写进 `clinic_room.ip`,那个工位以后永远匹配不到医生电脑),
|
* 并由 {@link #isLocalAddress(String)} 识别、给出可操作提示。
|
*
|
* <h3>运维须知:如何避免客户端走 IPv6</h3>
|
* 若服务器所在机器同时监听 IPv6,客户端可能优先用 IPv6 访问(此时后端看到的就是 IPv6 地址,
|
* 无法通过代码"退化成"IPv4,因为连接本身就是 IPv6 建立的)。解决办法是让服务只监听 IPv4:
|
* <pre>
|
* # 方式一:JVM 参数(推荐,jarboot/启动脚本里加)
|
* -Djava.net.preferIPv4Stack=true
|
* # 方式二:Tomcat 只绑 IPv4 地址
|
* server.address=0.0.0.0 + 系统关闭 IPv6,或直接绑本机 IPv4 地址
|
* </pre>
|
* 若上述都不便调整,请确保医生端用服务器的 **IPv4 地址** 访问(不要用 IPv6 地址或域名解析到 IPv6)。
|
*
|
* <p>注意:包名用 {@code utils}(小写)—— 模块里已有 {@code cn.lihu.jh.module.ecg.Utils} 这个类,
|
* 若建同名的 {@code Utils} 包会与类名冲突(实测编译报"类与程序包冲突")。
|
* 另注意 Windows 路径大小写不敏感,删除 {@code Utils} 目录会连带删掉 {@code utils}。</p>
|
*/
|
public final class IpUtils {
|
|
/** 是否只采用 IPv4(现场约定)。改为 false 即可放开 IPv6,无需改动调用方。 */
|
public static final boolean IPV4_ONLY = true;
|
|
/** 服务器本机 IPv4 网卡地址的缓存(见 {@link #localIpv4Addresses()}) */
|
private static volatile Set<String> localIpv4Cache = null;
|
|
private IpUtils() {
|
}
|
|
/**
|
* 规范化来访 IP。
|
*
|
* @return 规范化后的字符串;入参为空时返回 null
|
*/
|
public static String normalize(String ip) {
|
if (ip == null) {
|
return null;
|
}
|
String v = ip.trim();
|
if (v.isEmpty()) {
|
return null;
|
}
|
// ServletUtil 可能返回逗号分隔的转发链,取第一个(最靠近客户端的地址)
|
if (v.contains(",")) {
|
v = v.split(",")[0].trim();
|
}
|
// 去掉 IPv6 的方括号与区域号,例如 [fe80::1%eth0] → fe80::1
|
if (v.startsWith("[")) {
|
v = v.substring(1);
|
}
|
if (v.endsWith("]")) {
|
v = v.substring(0, v.length() - 1);
|
}
|
int zoneIdx = v.indexOf('%');
|
if (zoneIdx > 0) {
|
v = v.substring(0, zoneIdx);
|
}
|
v = v.toLowerCase();
|
// IPv4-mapped IPv6:::ffff:32.88.65.208 → 32.88.65.208
|
if (v.startsWith("::ffff:") && isIpv4(v.substring(7))) {
|
return v.substring(7);
|
}
|
return v;
|
}
|
|
/**
|
* 是否是可用于绑定工位的 IP(**只采用 IPv4**):
|
* 必须是合法 IPv4,且不是回环(127.x)或未指定(0.0.0.0)。
|
*
|
* <p>IPv6 一律返回 false —— 包括回环 {@code ::1} 与真实 IPv6 地址。
|
* 注意:{@code ::ffff:32.88.65.208} 这类 IPv4-mapped 地址会先被规范化还原成
|
* {@code 32.88.65.208},因此**仍然可用**(这是 IPv4 客户端在双栈环境下最容易被误判的情况)。</p>
|
*/
|
public static boolean isBindableIp(String ip) {
|
String v = normalize(ip);
|
if (v == null || v.isEmpty()) {
|
return false;
|
}
|
if (isLoopback(v) || isUnspecified(v)) {
|
return false;
|
}
|
// 【本次新增】服务器自身地址不作为工位标识:现场若在服务器上(或远程桌面里)访问系统,
|
// 后端看到的"客户端"就是服务器自己,写进 clinic_room.ip 后该工位永远匹配不到医生电脑。
|
if (isLocalAddress(v)) {
|
return false;
|
}
|
boolean usable = isIpv4(v);
|
if (IPV4_ONLY && isIpv6(v)) {
|
usable = false; // 只采用 IPv4:IPv6 不参与绑定与匹配
|
}
|
return usable;
|
}
|
|
/**
|
* 服务器本机所有 IPv4 网卡地址(首次调用后缓存)。
|
*
|
* <p>取不到网卡信息时返回**空集合**,此时 {@link #isLocalAddress} 恒为 false ——
|
* 宁可少拦,也不要因为环境取不到网卡就误判所有地址都是服务器的。
|
*/
|
public static Set<String> localIpv4Addresses() {
|
Set<String> cached = localIpv4Cache;
|
if (null != cached) {
|
return cached;
|
}
|
Set<String> result = new LinkedHashSet<>();
|
try {
|
Enumeration<NetworkInterface> nis = NetworkInterface.getNetworkInterfaces();
|
while (null != nis && nis.hasMoreElements()) {
|
NetworkInterface ni = nis.nextElement();
|
Enumeration<InetAddress> addrs = ni.getInetAddresses();
|
while (addrs.hasMoreElements()) {
|
InetAddress addr = addrs.nextElement();
|
if (addr instanceof Inet4Address) {
|
String ip = normalize(addr.getHostAddress());
|
if (null != ip && !ip.isEmpty()) {
|
result.add(ip);
|
}
|
}
|
}
|
}
|
} catch (Exception e) {
|
// 忽略:拿不到网卡信息时不做"服务器地址"判定(返回空集合)
|
}
|
localIpv4Cache = result;
|
return result;
|
}
|
|
/**
|
* 该地址是否是**服务器自身**的 IPv4 地址。
|
*
|
* <p>用途:识别"在服务器上/远程桌面里访问系统"这种访问方式(此时后端看到的客户端就是服务器自己),
|
* 并给出可操作提示,而不是把它当成医生的本机 IP。
|
* <p>注意:回环 {@code 127.0.0.1} 通常也在网卡列表里,但调用方一般先用 {@link #isLoopback} 判定,
|
* 提示语会更贴切("本机访问")。
|
*/
|
public static boolean isLocalAddress(String ip) {
|
String v = normalize(ip);
|
return null != v && !v.isEmpty() && localIpv4Addresses().contains(v);
|
}
|
|
/** 该地址是否为 IPv6(规范化之后判断;IPv4-mapped 已还原成 IPv4,不会命中这里) */
|
public static boolean isIpv6Address(String ip) {
|
return isIpv6(normalize(ip));
|
}
|
|
/** 回环地址:127.0.0.0/8、::1 */
|
public static boolean isLoopback(String ip) {
|
String v = normalize(ip);
|
if (v == null) {
|
return false;
|
}
|
if (v.startsWith("127.")) {
|
return true;
|
}
|
return "::1".equals(v) || "0:0:0:0:0:0:0:1".equals(v);
|
}
|
|
/** 未指定地址:0.0.0.0、::(含全零写法) */
|
public static boolean isUnspecified(String ip) {
|
String v = normalize(ip);
|
if (v == null) {
|
return false;
|
}
|
if ("0.0.0.0".equals(v) || "::".equals(v)) {
|
return true;
|
}
|
// 0:0:0:0:0:0:0:0
|
return v.matches("^0(:0){2,7}$");
|
}
|
|
/** IPv4 点分十进制 */
|
public static boolean isIpv4(String ip) {
|
String v = normalize(ip);
|
if (v == null || !v.matches("^(\\d{1,3}\\.){3}\\d{1,3}$")) {
|
return false;
|
}
|
for (String seg : v.split("\\.")) {
|
int n;
|
try {
|
n = Integer.parseInt(seg);
|
} catch (NumberFormatException e) {
|
return false;
|
}
|
if (n > 255) {
|
return false;
|
}
|
}
|
return true;
|
}
|
|
/**
|
* IPv6(含压缩写法)。这里做"结构级"校验:必须是十六进制分段 + 冒号,
|
* 允许压缩 {@code ::},允许结尾 IPv4-mapped({@code ::ffff:1.2.3.4})。
|
* 不做严格的全展开校验(工业现场地址形态多,宽松校验更实用)。
|
*/
|
public static boolean isIpv6(String ip) {
|
String v = normalize(ip);
|
if (v == null || v.isEmpty() || !v.contains(":")) {
|
return false;
|
}
|
if (v.contains(":::")) {
|
return false;
|
}
|
int doubleColon = v.indexOf("::");
|
if (doubleColon >= 0 && v.indexOf("::", doubleColon + 1) >= 0) {
|
return false; // 只能出现一次 "::"
|
}
|
String body = v.endsWith("::") ? v.substring(0, v.length() - 2) : v;
|
if (body.isEmpty()) {
|
return true; // 就是 "::"
|
}
|
for (String seg : body.split("::", -1)) {
|
if (seg.isEmpty()) {
|
continue;
|
}
|
for (String part : seg.split(":", -1)) {
|
if (part.isEmpty()) {
|
return false;
|
}
|
if (part.contains(".")) {
|
if (!isIpv4(part)) {
|
return false;
|
}
|
} else if (!part.matches("[0-9a-f]{1,4}")) {
|
return false;
|
}
|
}
|
}
|
return true;
|
}
|
}
|