/** * 取本机对外 IP —— 用于 DMS 的**访客归属**(用户 2026-09-22 要求)。 * * 接口:`GET {VITE_IP_API}/proxy/verify/getIPByBrowser` * 返回**纯文本 IP**(响应头 Content-Type 标的是 `application/json`,但正文就是裸 IP, * 实测 `116.232.248.128`)—— 所以**按 text 读、不要 JSON.parse**。 * * ⚠️ **必须走同源代理**:接口是 `http://121.43.55.7:10011`,而页面是 HTTPS。 * 接口本身带 `Access-Control-Allow-Origin: *`(跨域不成问题),但**混合内容**会被浏览器 * 直接拦掉 —— 与聊天后端同一类问题。所以 dev 走 vite 代理(`vite.config.ts` 的 `/ip-api/`), * 生产需要 nginx 等价转发。 * * 拿不到(没配代理 / 超时 / 返回不是 IP)一律返回空串、**不抛异常**: * 调用方会退回旧的「访客_<浏览器随机 id>」——**绝不能因为取不到 IP 就写不进 DMS**。 */ import { getClientIpApiBaseUrl } from "@/utils/runtime-config"; /** 进程内缓存:一次会话里 IP 不会变,没必要每轮都问 */ let cachedIp = ""; /** 并发去重:多个写入同时要 IP 时只发一次请求 */ let inflight: Promise | null = null; /** 形如 `1.2.3.4` 才算数(IPv6 也放行:含冒号的裸地址) */ const isIpLike = (text: string): boolean => /^\d{1,3}(\.\d{1,3}){3}$/.test(text) || /^[0-9a-f:]{6,}$/i.test(text); /** 已经拿到的 IP(没拿到就是空串)。同步可用,给不方便 await 的地方读缓存。 */ export const getCachedClientIp = (): string => cachedIp; /** 取 IP(并发调用只发一次请求;失败返回空串,下次调用会重试) */ export const ensureClientIp = (): Promise => { if (cachedIp) return Promise.resolve(cachedIp); if (inflight) return inflight; const base = getClientIpApiBaseUrl(); if (!base) { return Promise.resolve(""); // 没配代理:静默退回随机 id,不打扰用户 } inflight = (async () => { const controller = new AbortController(); const timer = setTimeout(() => controller.abort(), 3000); try { const res = await fetch(`${base}/proxy/verify/getIPByBrowser`, { signal: controller.signal }); const text = (await res.text()).trim(); if (isIpLike(text)) { cachedIp = text; } else { console.warn("[client-ip] 返回的内容不像 IP,已忽略:", JSON.stringify(text.slice(0, 40))); } } catch (err) { console.warn("[client-ip] 取 IP 失败(DMS 访客归属退回浏览器随机 id):", (err as Error)?.message || err); } finally { clearTimeout(timer); inflight = null; } return cachedIp; })(); return inflight; };