yxh
7 天以前 68ff33beff11310e830b0615b5a81f83fad0f683
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
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;
    }
}