Repository Wiki
jason5ng32/MyIP

多源 IPv4 与 IPv6 检测及 IP 信息查询

本功能通过多个独立来源检测访问者的 IPv4/IPv6 地址,再通过可切换的 IP 数据库补充地理位置、运营商与 ASN 信息。实现的核心是“每张卡片独立完成地址检测与详情查询”,而不是等待全部来源返回后统一显示。

目的与范围

本文介绍多源卡片编排、地址与详情的分阶段加载、数据库回退、请求合并、刷新命令及完成事件,并以 IPAPI.is 适配器说明服务端响应归一化。

ASN 路由历史、上游连接图、Globalping、报告生成、身份认证及部署属于关联能力,本文只说明其与 IP 查询的交界,不展开内部实现。运行时未提供这些主题的目录路径,因此不构造未经确认的 Wiki 链接。

**证据边界:**本页已核实前端编排与 IPAPI.is 适配器。底层 getips 工具、Store 的数据库 URL 配置、authenticatedFetch、makeGeoHandler 和完整测试实现未在本次限定阅读范围内核实。因此,下文不承诺具体检测域名、超时值、内部备用源顺序、认证方式或服务端缓存策略。

概述

需要区分两个相互独立的“来源”:

  • 地址检测来源:决定某张卡片展示哪个网络出口地址,例如 Cloudflare IPv4 或 IPCheck.ing IPv6。
  • 详情数据库来源:决定这个地址的地理位置、ISP、ASN 等信息从哪里取得,由 ipGeoSource 与 store.ipDBs 控制。

切换详情数据库不会重新检测出口地址;刷新卡片则先重新检测地址,再加载详情。不同检测来源得到相同地址时,可以复用同一份详情请求和缓存。

界面始终按下表顺序取前 ipCardsToShow 张卡片。源码注释说明预期选择数量为 2、4、6,但本组件没有对该偏好值做独立范围校验。

索引卡片 ID展示来源检测函数
0ipchecking_v4IPCheck.ing IPv4getIPFromIPChecking4
1ipchecking_v6IPCheck.ing IPv6getIPFromIPChecking6
2cloudflare_v4Cloudflare IPv4getIPFromCloudflare_V4
3cloudflare_v6Cloudflare IPv6getIPFromCloudflare_V6
4cnsourceCN SourcegetIPFromIPIP
5ipchecking_v64IPCheck.ing IPv6/4getIPFromIPChecking64

卡片身份和调度顺序分别见 IpInfos.vue 与 IpInfos.vue。不能仅根据双栈函数名推断其协议优先级。

架构

Loading diagram...

Source: IpInfos.vue

图中的 getIPFrom… 函数负责地址获取;fetchIPDetails 负责数据库选择、回退和响应转换;IPCard 只通过父组件绑定获得卡片及关联数据,并将刷新事件交回父组件。useAsnInfo、ASN 历史和连接图缓存作为卡片的附加输入存在,不参与这里的地址获取顺序。

地址检测:先显示地址,再补充详情

1. 批量调度不是串行等待

checkAllIPs() 首先清空 fetchStatus,避免旧完成状态使新一轮查询提前发出完成事件。随后按可见卡片数量截取来源表,并并行启动每张卡片的流水线:

javascript
await Promise.allSettled( ipSources.map(([cardID, getFromSource]) => fetchIP(cardID, getFromSource)) );

Source: IpInfos.vue

Promise.allSettled 保证单张卡片的拒绝不会使整批聚合调用直接拒绝,但不会终止一直不完成的任务;总完成时间仍取决于最慢的可见卡片。

2. 地址解析与失败分类

resolveIP(cardID, getFromSource) 将 configs.value.originalSite 传给检测函数,并期望返回 { ip, source }。

  • 当 ip !== null 时,立即写入卡片的地址与来源,同时向 IPArray 追加 { ip, country: '' },供后续 Store 更新使用。
  • 当地址为 null,或检测函数抛错而保持初始 null 时,索引 1、3 使用 IPv6 错误文案,其他索引使用 IPv4 错误文案。
  • 失败会发出 ip-source:exhausted,载荷包含稳定来源标识和 ipVersion,然后将该卡片标记完成,不进入详情阶段。

这里的事件表示当前检测函数未能产出地址,并不独自证明用户没有 IPv6 网络。源码注释明确将“结合其他成功卡片判断网络是否可用”的职责留给订阅方。双栈卡片索引 5 在此失败分类中也走 v4 分支。

详见 IpInfos.vue。

3. 非公网地址保留展示,但不查详情

javascript
1const fetchIP = async (cardID, getFromSource) => { 2 const { ip } = await resolveIP(cardID, getFromSource); 3 if (ip === null) return; // resolveIP already settled the card 4 if (isUsablePublicIP(ip)) { 5 await loadCardDetails(cardID, ip); 6 } else { 7 // A source can hand back reserved space (DNS hijack, captive portal, LAN 8 // echo). The address stays on the card — the visitor really did get it — 9 // but the geo phase is skipped: /api/* rejects non-public IPs with 400. 10 markFetched(cardID); 11 } 12};

Source: IpInfos.vue

这一分支保留了诊断价值:即使检测端返回保留地址,界面仍显示实际得到的结果,而不会把它伪装成正常公网地理信息。具体公网判定集合未在本页核实;代码注释说明详情接口会拒绝非公网地址,本文不据此扩展成所有接口的完整错误契约。

4. 详情失败仍结束卡片加载

loadCardDetails 等待详情查询;成功且卡片有 country_code 时,再向 IPArray 追加包含国家、位置、ASN、组织的信息。异常被吞掉,但 finally 始终执行 markFetched。

因此,“完成”代表处理已结束,而非查询成功。IPArray 在该组件中采用追加方式,不做去重;Store 如何归并这些记录未在本页核实。详见 IpInfos.vue 和 IpInfos.vue。

详情查询:缓存、请求合并与数据库回退

缓存命中与同 IP 合并

fetchIPDetails(cardIndex, ip, sourceID = null) 先用 sourceID || ipGeoSource.value 确定目标源,再设置卡片地址并通过 toApiTag(lang.value) 计算 API 语言。

查询入口按以下顺序工作:

  1. ipDataCache 中有该 IP:直接将缓存对象合并到当前卡片并返回。
  2. pendingIPDetailsRequests 中有该 IP:等待已有 Promise,成功后从缓存复制结果。
  3. 都没有:创建查询 Promise,放入 pending Map;无论成功失败,外层 finally 删除 pending 项。
javascript
1 // Check if there is a query in progress, if so, wait for it to complete 2 if (pendingIPDetailsRequests.has(ip)) { 3 await pendingIPDetailsRequests.get(ip); 4 const cachedData = ipDataCache.get(ip); 5 if (cachedData) { 6 Object.assign(card, cachedData); 7 } 8 return; 9 }

Source: IpInfos.vue

这是一种按 IP 合并并发请求的机制:六张卡片不必发出六次相同详情查询。缓存与 pending Map 都在组件内存中,没有在该组件里设置 TTL、容量限制或持久化,也没有将数据库 ID、语言纳入键。

顺序回退算法

新请求从 store.ipDBs.filter(source => source.enabled) 得到可用数据库列表。若请求的数据库不在列表中,从索引 0 开始。

每次尝试依次执行:

  1. store.getDbUrl(source.id, ip, setLang) 构造地址。
  2. authenticatedFetch(url) 获取响应。
  3. transformDataFromIPapi(response, source.id, t, lang.value) 转成卡片数据。
  4. 若转换结果为真,更新卡片并缓存,结束查询。
  5. 若抛出异常,记录来源错误,索引循环移动到下一项,并增加尝试计数。
javascript
1 } catch (error) { 2 logSourceFetchFailure(`Error fetching IP details from source ${source.id} (${fetchErrorLabel(error)}):`, error); 3 currentSourceIndex = (currentSourceIndex + 1) % sources.length; 4 attempts++; 5 }

Source: IpInfos.vue

当每个启用源都抛错后,函数抛出 Error("All sources failed to fetch IP details for IP: " + ip)。没有启用源时,循环不执行,也会直接进入这一错误分支。

重要边界:attempts 和索引只在 catch 中更新。如果转换器返回假值而不抛错,当前循环会再次请求同一个源,不能保证按源数量结束。转换器完整实现未在本页核实,因此这是调用方代码的条件性风险,而不是已确认的线上故障。

回退不改写用户偏好

成功源不同于请求源,且不同于 usingSource.value 时,组件显示持续 5000 毫秒的警告提示。随后把 ipGeoSource 与 usingSource 更新为成功源,但不写回 userPreferences.ipGeoSource。

这种区分保留了用户长期选择,同时允许当前会话继续使用可用服务。不过这两个运行时引用由各个并发请求共享:不同 IP 若回退到不同数据库,最终值由完成顺序决定,不能将它解释为每张卡片都使用同一个数据库。

实现见 IpInfos.vue。

核心执行时序与完成事件

Loading diagram...

Source: IpInfos.vue

trackFetchStatus 只检查可见索引范围。全部结束后:

  • 将 cardsHaveSettled 设置为 true,用于 InfoBanner 的展示时机。
  • 调用 store.setLoadingStatus('IPInfo', true)。
  • 发出 ipinfo:finished,内容为当前可见卡片快照。

快照包含 source、ip、国家代码、地区、城市、时区、ASN、ISP,以及可选的 anonymityCode、ipTypeCode、isNativeIP、qualityScore、anonymityProtocol、anonymityProvider。错误卡片也在快照中,ip 可能是本地化错误文案;注释把清理错误卡片的职责交给报告收集方。

完成之后,任一卡片再次结束都会重新发事件。cardsHaveSettled 在整批刷新入口没有重置为 false,因此它更接近“曾经全部结束”的标记,而不是严格的每批加载状态。详见 IpInfos.vue。

刷新与切换数据库

单卡刷新

refreshCard(card, index) 先用默认字段覆盖卡片,再通过 switch 选择对应检测函数,并记录 IPCheck / RefreshClick / IPInfos 统计事件。该函数本身不返回内部 fetchIP 的 Promise。

单卡刷新不会清除详情缓存,所以重新检测到同一个 IP 时可能直接复用原详情。这适合快速刷新出口地址,但不等价于强制从上游数据库重新读取。

切换数据库

偏好监听器将新值写入 ipGeoSource;仅当新值与 usingSource 不同时调用 selectIPGeoSource()。后者:

  1. 重置默认详情字段,但保留 IP、亮色和暗色地图。
  2. 清空 ipDataCache。
  3. 固定本批 chosenSource,防止某个回退请求改变同批其他请求的初始来源。
  4. 对所有卡片中通过公网验证的地址并行调用 fetchIPDetails,而非只处理当前可见切片。

此路径直接调用 fetchIPDetails,不经过 loadCardDetails 或 markFetched,因此不能假定切源后必然发布新的 ipinfo:finished 或重新补写 IPArray。

命令入口与挂载边界

javascript
1useAppCommand('ipinfo:refresh', ({ index } = {}) => { 2 const finished = waitForAppEvent('ipinfo:finished'); 3 if (Number.isInteger(index) && ipDataCards[index]) { 4 refreshCard(ipDataCards[index], index); 5 } else { 6 checkAllIPs(); 7 } 8 return finished; 9});

Source: IpInfos.vue

命令先等待下一次事件再发起操作,返回的是事件等待结果,不是某张卡片的直接查询 Promise。有效整数索引刷新一张卡片,其他输入刷新整批。事件没有在此处绑定请求 ID;重叠刷新时,不应推断命令结果一定对应自己的那一轮操作。

onMounted 只调用 store.setMountingStatus('IPInfo', true),没有直接执行 checkAllIPs。自动启动请求的上层机制未在本次源码范围内核实。以上实现见 IpInfos.vue。

服务端适配示例:IPAPI.is

前端如何将数据库 ID 映射到路由,取决于未在本页展开的 store.getDbUrl。已核实的 IPAPI.is 模块注释标识路由为 /api/ipapiis,并将 URL 构建和归一化函数交给共享 makeGeoHandler 工厂。

上游 URL 与密钥选择

javascript
1function buildUrl(req) { 2 const ipAddress = req.query.ip; 3 4 const keys = (process.env.IPAPIIS_API_KEY).split(','); 5 const key = keys[Math.floor(Math.random() * keys.length)]; 6 return `https://api.ipapi.is?q=${ipAddress}&key=${key}`; 7}

Source: ipapi-is.js

密钥用逗号拆分,每次构建 URL 随机选择一个;没有在此函数中去除空白、排除空项或实现配额感知。环境变量未定义时,直接调用 .split 会抛出异常;共享工厂是否事先拦截这一情况,未核实。

归一化数据模型

modifyJsonForIPAPI(json) 将 json.asn 和 json.location 的空值替换为空对象,避免上游成功返回却缺少嵌套对象时发生属性访问错误。

输出字段上游字段/规则
ipjson.ip
city、regionlocation.city、location.state,假值转为 N/A
country、country_codelocation.country_code,假值转为 N/A
country_namelocation.country,假值转为 N/A
latitude、longitude对应位置字段,假值转为 N/A
asnasn.asn === undefined 时为 N/A,否则加 AS 前缀
orgasn.org,假值转为 N/A
isHosting`is_datacenter
isProxy`is_proxy

这里的 || 不是类型校验:纬度或经度为数值 0 时也会转成 N/A;代理字段同样保留 JavaScript 真值语义,而非显式布尔转换。该函数也没有验证整个 json 是否为非空对象。

javascript
export default makeGeoHandler({ name: 'ipapi-is', buildUrl, normalize: modifyJsonForIPAPI });

Source: ipapi-is.js

适配器输出的是归一化 geo 对象,前端仍通过 transformDataFromIPapi 转成卡片字段,不能直接把服务端 org 与界面 isp 当成未经转换的同名字段。完整字段逻辑见 ipapi-is.js。

配置与状态参考

以下仅列出源码消费点;未读取定义的配置不标注推测默认值。

配置/状态代码期望形态默认值或来源用途
userPreferences.ipCardsToShow数量值用户偏好;默认未核实控制可见卡片和整批检测范围,注释预期 2/4/6
userPreferences.ipGeoSource数据库 ID用户偏好;默认未核实初始化运行时源,并触发切源监听
configs.originalSite类型未在组件声明Store 配置原样传给检测函数
store.ipDBs数据库对象列表Store 定义未核实使用 enabled、id、text 做筛选、选择和提示
store.lang语言值Store 定义未核实API 语言标签和前端数据转换
userPreferences.simpleMode布尔式开关用户偏好控制说明文本,不改变此处检测算法
IPAPIIS_API_KEY逗号分隔字符串适配器中无默认值IPAPI.is 上游密钥集合
ipDataCacheMap空 MapIP 到详情对象的组件内存缓存
pendingIPDetailsRequestsMap空 MapIP 到在途 Promise 的请求合并表

前端配置消费见 IpInfos.vue、IpInfos.vue 和 IpInfos.vue;密钥配置见 ipapi-is.js。

函数与事件参考

源码使用 JavaScript,以下是实际参数形式及依据实现归纳的返回行为,不是额外声明的类型接口。

入口参数返回与错误语义
resolveIP(cardID, getFromSource)卡片索引、检测函数异步返回 { cardID, ip };捕获检测函数异常
fetchIP(cardID, getFromSource)卡片索引、检测函数异步完成;串联检测和公网详情查询
loadCardDetails(cardID, ip)卡片索引、地址异步完成;吞掉详情异常并在 finally 标记完成
checkAllIPs()无等待可见卡片的 allSettled;不返回结果数组
fetchIPDetails(cardIndex, ip, sourceID = null)卡片索引、地址、可选源 ID通过修改卡片返回结果,正常完成值为 undefined;全部源失败时抛错
selectIPGeoSource()无等待符合条件卡片的详情请求结束
refreshCard(card, index)卡片对象、索引同步启动异步刷新,未返回刷新 Promise
modifyJsonForIPAPI(json)上游 JSON 对象同步返回归一化对象;不捕获无效顶层输入异常

前端函数实现见 IpInfos.vue,服务端函数见 ipapi-is.js。

事件/命令载荷或输入注意事项
ip-source:exhausted{ source, ipVersion }表示检测失败;不是整个系统网络能力的最终判断
ipinfo:finished{ cards: [...] }所有可见卡片结束后发出,包含失败卡片
ipinfo:refresh可选 { index }返回下一次完成事件等待结果;无请求关联 ID

失败模式、并发与运行建议

已实现的隔离

  • 检测函数异常被 resolveIP 捕获,不会直接中断其他卡片。
  • 详情查询失败仍执行完成标记,避免普通失败导致卡片永久加载。
  • 不同 IP 并行查询,相同 IP 合并在途请求,降低重复网络流量。
  • 数据库抛错后顺序回退;这不是带延迟、退避或健康评分的重试机制。

需要关注的边界

场景源码行为与影响
上游一直不返回此组件没有显式超时或取消;是否能结束取决于底层请求工具
切源时旧查询仍在运行清缓存但不清理或取消 pending Promise;新请求可能等待旧源结果,旧结果也可能回填缓存
快速重复刷新未见代次编号或旧响应屏蔽;较早请求可能在较晚请求之后修改卡片
缓存跨源/跨语言复用键只有 IP;不同数据库和语言的结果没有天然隔离
单卡刷新完成事件不清除此卡片旧 fetchStatus;已有全局完成状态下,其他完成动作也可能满足下一事件等待
默认字段重置Object.assign 只覆盖列出的默认字段,不删除后来添加的字段;country_code、扩展质量字段等不在默认对象中
ip 返回 undefined解析成功分支只检查 !== null;最终能否进入详情仍依赖公网验证函数
转换返回假值回退计数不前进,可能持续重复同源请求

这些结论来自 IpInfos.vue、IpInfos.vue 和 IpInfos.vue。它们描述的是本层边界,不代表底层工具一定没有额外保护。

排查“有 IP、无地理信息”时,应依次检查公网地址过滤、启用数据库列表、转换结果以及回退日志。排查“切源后仍是旧详情”时,优先检查按 IP 缓存和未取消的在途查询,而不是只看数据库偏好值。

扩展与验证

新增检测来源需要同步维护卡片数组、批量来源表、CARD_SOURCE_SLUGS、refreshCard 的索引分支,以及 IPv6 错误分类。当前结构依赖位置一致,单独追加 UI 卡片不足以接入完整流程。

新增详情数据库需要协调 Store 的启用列表和 URL 映射、前端转换器及服务端适配器。IPAPI.is 已展示 makeGeoHandler({ name, buildUrl, normalize }) 的真实接入形式,但工厂的完整接口约束未在本页核实,不应只据此复制出未经验证的新服务。

本次限定源码阅读未覆盖测试文件,不能声明现有测试已覆盖以下场景。建议验证:相同 IP 只发一次详情请求、某源失败不阻塞其他卡片、全部源失败仍结束加载、切源与旧请求交错、转换器返回假值、单卡刷新缓存命中,以及 IPAPI.is 缺失位置对象和零坐标。

相关链接

Sources

(2 files)
frontend/components