Repository Wiki
jason5ng32/MyIP

IP 历史记录与信息遮罩

IP 历史记录负责在浏览器中按本地日期保存检测到的 IP;信息遮罩负责切换 IP 的视觉隐藏状态。两者分别解决历史回溯与屏幕展示隐私问题:开启遮罩不会停止记录,也不会对持久化数据脱敏。

目的与范围

本页说明 useIpHistory、历史数据纯函数以及 useInfoMask 的实际控制流,涵盖本地存储、按日去重、元数据补齐、保留窗口、统计过滤、遮罩状态与错误边界。

IP 检测供应商、主 store 的构建、账户偏好同步、截图序列化以及报告导出的脱敏算法不属于本页。历史面板模板、全局 CSS 的具体滤镜参数和快捷键绑定未在本次源码摘录中核实,不能将下文的状态契约视为所有界面已正确接入的证明。运行上下文未提供相邻 Wiki 页面的路径,因此不编造跨页链接。

概述

历史模块分为两个层次:

  • 纯数据逻辑:校验和清洗记录,按日合并,裁剪过期桶,生成按日期或按 IP 聚合的视图。该层不访问存储,便于独立验证。
  • Vue composable:读取 store.allIPs 与偏好,维护响应式历史,把变更写入 localStorage,并发送国家数量快照事件。

遮罩模块同样以 composable 提供状态,但不参与历史合并。它把 infoMaskLevel 映射为文档根元素的 data-mask-level 属性;组件以 data-mask="ip" 标注需要遮罩的内容。源码说明实际显示效果由 CSS 驱动,底层值不变。

来源:ip-history.js、use-ip-history.js、use-info-mask.js。

架构

Loading diagram...

Sources: use-ip-history.js、use-info-mask.js。

图中两条状态链没有互相调用:历史是否写入由 ipHistoryEnabled 决定,视觉遮罩由 infoMaskLevel 决定。历史数据不上传账户是源码明确声明的设计;但遮罩切换会调用 trackEvent,不能因此宣称整个功能没有任何统计事件。

历史数据模型与存储边界

存储结构

层级字段实际含义
存储键ipHistory一个 JSON 字符串,读写整个历史对象
根对象version当前生成值为 1
根对象days日期字符串到记录数组的映射
日期键YYYY-MM-DD由本地时区的年、月、日生成,不是 UTC 日期
单条记录ip必须是通过 isValidIP 的字符串
元数据country、location、asn、org只接受字符串;其他类型清洗为空字符串

country 在清洗时转大写,使同一国家不会因大小写不同而分裂为不同筛选项。记录不包含检测时刻、检测次数或检测源;因此“历史”是每日出现集合,不是逐次请求日志。

parseHistory 捕获 JSON 解析错误,并忽略日期键格式不合规、记录桶非数组或 IP 无效的数据。它总是输出当前 version,没有根据输入版本分支迁移。日期只校验字符串格式,不检查真实日历有效性;读取清洗也不会主动合并已有重复 IP。

来源:ip-history.js。

按日去重与补齐

mergeIntoHistory(history, entries, dayKey) 先清洗输入,再复制当天数组中的记录,通过 Map 按 ip 建立索引:

  1. 尚未出现的 IP 追加到当天桶。
  2. 已出现的 IP 只补齐空元数据字段。
  3. 已有非空字段不会被新值覆盖。
  4. 没有实际变化时返回原 history 与 changed: false;有变化时返回新的根对象与 days 映射。

这使先得到地址、后得到地理信息的检测结果可以渐进补齐,同时避免重复检测造成重复写入。代价是同日出现相互冲突的地理数据时,已有非空值优先,不是最后写入优先。

去重使用原始 IP 字符串。此函数没有地址规范化步骤,因此不能把“语义相同但写法不同的 IPv6 必然合并”作为保证。跨日同一 IP 会保留在不同日期桶中。

来源:ip-history.js。

保留窗口

默认保留天数为 30,下限为 1。clampRetentionDays 先执行 Number(value),对有限数四舍五入并限制到 [1, 30];非有限数回退到 30。需注意,null、空字符串和 false 可转换为 0,最终得到 1,并非回退到默认值。

pruneHistory 以“今天减去 retentionDays - 1 天”为最早保留日,保留所有日期键不小于该日的桶。因此 1 天窗口仍保留今天;函数未设置未来日期上限,未来日期桶也会保留。直接调用该纯函数时,天数参数不会在内部再次夹取;composable 在调用前通过计算属性完成夹取。

裁剪只在 composable 初始化与保留天数变化时执行。检测结果合并不会顺带裁剪,也没有午夜定时器。页面长时间不重载且偏好不变时,不能保证过期桶在午夜立即消失。

来源:ip-history.js、ip-history.js、use-ip-history.js。

核心执行流程

Loading diagram...

Source: use-ip-history.js。

初始化与监听

初始化先读本地存储,再清洗和裁剪;随后创建 history ref,并立即发送一次全量历史的不同国家数量快照。即使记录功能已关闭,加载、裁剪和初始化事件仍会发生,因为 enabled 判断只在检测结果 watcher 内。

监听 store.allIPs 时启用 { immediate: true },用于捕获 composable 建立之前已经到达的 IP。此 watcher 没有设置 deep: true,不能据此保证数组或元素任意原地修改都能触发记录;具体响应还取决于 store 如何提供与更新 allIPs。

单独将 ipHistoryEnabled 从关闭切换为开启不会重新执行该 watcher,因为它没有把 enabled 作为监听源。后续 allIPs 触发变化时才会再次检查开关。

事件与清空

事件名为 iphistory:updated,负载只含 countryCount,统计整个保留历史中的非空国家代码。触发位置是初始化,以及合并确实改变历史之后;仅补齐元数据也算变化。

clearHistory() 先把内存历史设为空,再尝试删除存储键。清空和保留窗口裁剪都没有发送新的国家数量快照。事件消费者因此不能把该事件当作“所有历史状态变化”的完整订阅。成就消费规则不在本页展开。

清空不会关闭记录,也不会修改 store.allIPs:同一实例不会仅因清空立刻重合并,但后续检测变化,或重新创建 composable 时的立即监听,都可能重新写入当前 IP。

来源:use-ip-history.js、ip-history.js。

历史视图与统计

函数视图规则需要区分的语义
sortedHistoryDays(history)按日期从新到旧生成 { day, entries }[]保留每日粒度
groupHistoryByIP(days)跨日期按 IP 聚合,生成 dates、firstSeen、lastSeen、dayCount元数据完整取自最近一条匹配记录,不跨日期拼装
countryFacets(days, { uniqueIPs })按国家计数,数量降序、代码升序默认按条目计数;去重模式按“国家 + IP”去重
ipVersionCounts(days, { uniqueIPs })返回 { v4, v6 }去重模式对全部输入日期的 IP 去重
filterHistoryDays(days, { versions, countries })筛选 IP 版本和国家并去掉空桶维度内 OR,维度间 AND;空数组表示不限制

聚合保留最近记录的一整组元数据,是为了避免把不同日期的国家、位置和 ASN 拼成从未实际出现过的组合。国家唯一计数则故意保留一个 IP 曾出现过的多个国家,使历史国家仍可被发现。

countries 中的空字符串匹配缺失国家;版本选项使用数字 4、6。这些函数不访问存储,因此过滤和聚合不删除原始历史。没有任何筛选条件时,filterHistoryDays 直接返回原输入数组。

来源:ip-history.js。

信息遮罩:状态、显示契约与安全边界

两态开关

useInfoMask({ store, t }) 创建三个 ref:infoMaskLevel = 0、isInfosLoaded = false、showMaskButton = false。toggleInfoMask() 按以下顺序工作:

  1. 调用 trackEvent('SideButtons', 'ToggleClick', 'InfoMask')。
  2. 在 0 和 1 之间切换 infoMaskLevel。
  3. 选择对应的标题、消息翻译键。
  4. 调用 store.setAlert;启用时传入 text-success,关闭时传入 text-warning。

watch(infoMaskLevel, syncMaskAttribute, { immediate: true }) 将初始状态和后续变化映射到 <html>:为 0 时删除 data-mask-level,否则写入其字符串值。返回的 ref 允许调用方直接重置为 0,属性监听同样会同步。

此模块没有遮罩状态的 localStorage 读写。每个新实例都以关闭状态初始化,不能假定刷新后继续保持开启。

来源:use-info-mask.js。

加载状态并不是函数调用保护

store.allHasLoaded 的 watcher 没有 immediate。每次触发时,它把值复制到 isInfosLoaded,并无条件将 showMaskButton 置为 true。因此应以实现为准:

  • 若创建 composable 时加载状态已经为真且之后不变,两个初始为假的 ref 不会仅因这个已有值自动更新。
  • 若 watcher 观察到值变为假,它依然会显示按钮,但 isInfosLoaded 为假。
  • toggleInfoMask() 内部没有检查加载状态;是否允许按钮或快捷键调用,需要上层实现约束。

来源:use-info-mask.js。

状态占位文本保持可读

createMaskGate(t) 将九个翻译键对应的文本构建成 Set:WebRTC 的等待、测试、错误、不可用状态,DNS 泄漏检测的等待、测试、错误状态,以及 IPv4、IPv6 错误文本。返回函数对这些文本给出 undefined,其他值给出 'ip'。

这不是 IP 格式验证器,而是已知占位文本的排除器。空值、未知错误消息或尚未纳入集合的新状态不会自动排除。集合只在创建时生成;源码以每次页面加载语言固定、切换语言重启应用为前提。若未来改为原地切换语言,需要重新构建 gate。

来源:use-info-mask.js。

隐私边界

遮罩是视觉处理,不是数据清除、加密或不可逆脱敏。根属性的变化不会修改历史对象、store 地址或已保存 JSON。因此:

  • 开启遮罩后,历史仍按开关与检测结果正常写入。
  • 不能把“截图里不可读”等同于底层 DOM、存储或其他数据通道不含原始 IP。
  • 新 IP 显示组件需要正确输出 data-mask 标记;仅有全局状态不足以保证覆盖所有组件。
  • 具体 CSS 模糊强度、截图工具是否保留效果,以及复制行为不在已核实摘录中,不能作出保证。

来源:use-info-mask.js、use-ip-history.js。

源码使用示例

以下均为仓库中的实际实现摘录,不是虚构调用代码。

1. 夹取历史保留天数

javascript
1export const clampRetentionDays = (value) => { 2 const n = Number(value); 3 if (!Number.isFinite(n)) return IP_HISTORY_RETENTION_DAYS; 4 return Math.min(IP_HISTORY_RETENTION_DAYS, Math.max(IP_HISTORY_MIN_DAYS, Math.round(n))); 5};

Source: ip-history.js。

此处执行数值转换而不是只接受数字类型;接入新偏好表单时,需要明确空值是否应代表 1 天。

2. 只在实际变化时持久化

javascript
1 watch(() => store.allIPs, (ips) => { 2 if (!enabled.value) return; 3 const { history: next, changed } = mergeIntoHistory(history.value, ips, localDayKey()); 4 if (changed) { 5 history.value = next; 6 persist(next); 7 emitSnapshot(next); 8 } 9 }, { immediate: true });

Source: use-ip-history.js。

changed 同时控制 ref 替换、存储写入和事件发送,避免对没有新增信息的重复检测重复处理。

3. 清空内存与本地记录

javascript
1 const clearHistory = () => { 2 history.value = createEmptyHistory(); 3 try { 4 localStorage.removeItem(IP_HISTORY_STORAGE_KEY); 5 } catch { 6 // Same best-effort stance as persist(). 7 } 8 };

Source: use-ip-history.js。

先修改内存确保界面立即变空,但删除失败被吞掉,因此不能以界面变空证明磁盘侧记录已成功移除。

4. 为显示值生成遮罩标记

javascript
1export function createMaskGate(t) { 2 const placeholders = new Set(NON_SENSITIVE_KEYS.map((key) => t(key))); 3 return (value) => (placeholders.has(value) ? undefined : 'ip'); 4}

Source: use-info-mask.js。

组件应将该结果用于 data-mask 属性契约,而不是把返回值当成已经脱敏后的地址字符串。

配置与 API 参考

配置及状态

名称类型/运行时语义默认值作用
store.userPreferences.ipHistoryEnabled与布尔值 false 严格比较未设置时启用仅显式 false 停止记录;不清空历史
store.userPreferences.ipHistoryDays可传给 Number() 的值未定义时为 30 天四舍五入后夹取为 1~30 天
IP_HISTORY_STORAGE_KEY字符串常量ipHistory存储键,非用户可配置选项
CURRENT_VERSION数字常量1当前输出数据版本
infoMaskLevelVue ref,正常切换值为 0 或 100 不遮罩,1 开启遮罩
isInfosLoaded / showMaskButtonVue reffalse / false由加载状态 watcher 更新

来源:use-ip-history.js、ip-history.js、use-info-mask.js。

对外函数

源码为 JavaScript,没有静态类型签名;下表使用实际函数参数形式与返回行为,不补造 TypeScript 声明。

函数参数与返回值错误/调用前提
useIpHistory({ store })返回 enabled、sortedDays、hasHistory、clearHistory要求浏览器存储与预期的 store 结构;初始读取异常未捕获
clearHistory()无参数、无显式返回值删除存储异常被忽略
parseHistory(raw)存储字符串 → { version, days }JSON 错误回退为空;无效条目跳过
mergeIntoHistory(history, entries, dayKey)历史、记录数组、日期键 → { history, changed }假定历史具有 days;不校验传入日期键
pruneHistory(history, todayKey, retentionDays = IP_HISTORY_RETENTION_DAYS)返回 { history, changed }假定日期参数满足契约;内部不夹取天数
localDayKey(date = new Date())Date → 本地日期键不提供无效日期兜底
createEmptyHistory()返回新 { version: 1, days: {} }不访问存储
distinctCountryCount(history)历史 → 非空国家代码数量根级使用可选链;期望桶内记录已经清洗
useInfoMask({ store, t })返回三个 ref 与 toggleInfoMask依赖翻译函数、store 和分析事件函数
toggleInfoMask()无参数、无显式返回值不捕获统计、翻译或提示调用异常
createMaskGate(t)翻译函数 → 值到属性标记的函数返回值为 'ip' 或 undefined

来源:ip-history.js、ip-history.js、use-ip-history.js、use-info-mask.js。

故障、并发与运行注意事项

容错不是所有路径都静默成功

场景已实现的行为运行影响
历史 JSON 损坏parseHistory 返回空历史不因 JSON 语法错误中断初始化
存储配额或写入受限persist 捕获并忽略异常内存可能有记录,但刷新后丢失;事件仍可能发送
初始存储读取受限getItem 外没有 try/catch异常可阻止 composable 初始化
删除存储失败内存已清空,异常被忽略下次读取可能重新看到旧数据
清洗发现坏条目但未裁剪日期初始化仅在 pruneHistory.changed 时写回内存已清洗,不代表原始存储立即被修复
无 document 环境syncMaskAttribute 直接返回避免该函数访问 DOM;不代表历史模块支持无浏览器运行

来源:use-ip-history.js、use-ip-history.js、use-info-mask.js。

多实例与多标签页

历史 composable 每次调用都读取一次存储并持有自己的 history ref。实现中没有 storage 事件监听、版本比较、写入前重新合并或锁。由整对象覆盖写入方式可推得:多个标签页或多个实例写同一键时,较晚写入的旧快照可能覆盖另一个实例新增的数据;这不是跨标签页一致性存储。

遮罩 ref 也属于各实例,但根 DOM 属性是全局共享的。若建立多个 useInfoMask 实例,它们可能分别将自己的状态同步到同一根属性。集成时应明确由哪个实例拥有全局显示状态。

来源:use-ip-history.js、use-info-mask.js。

性能与容量

每日合并使用 Map 查找已有 IP,避免对每条输入重复遍历整个当天桶;没有变化时保留原历史引用。成功变更会同步序列化并写入整个历史对象,没有批量提交或节流。

保留窗口限制日期范围,但没有每桶 IP 数量上限、总字节预算或 LRU 淘汰。频繁产生大量不同地址时,仍可能遇到存储配额。按 IP 聚合与按日期排序均在内存中完成;筛选、分面和版本统计需要遍历输入记录。以上是源码结构推导,不是实际性能测量结果。

来源:ip-history.js、use-ip-history.js。

扩展与验证建议

  • 增加历史字段:优先检查 DETAIL_FIELDS,因为清洗和合并补齐共用该列表;同时明确是否需要版本迁移与视图支持。
  • 增加元数据更新策略:若希望最新值覆盖旧值,应有意识地修改仅补空字段的规则,并保持跨日聚合不混合不同记录的语义。
  • 增加新遮罩区域:遵守 data-mask 标记契约;新状态文本应同步纳入 gate,避免把“检测中”当作敏感数据模糊掉。
  • 需要严格留存保证:可评估增加跨日裁剪触发,但现有实现没有定时裁剪,不能把建议描述成已实现能力。
  • 需要多标签页一致性:需另行设计存储事件同步与合并策略,现有全量覆盖写入不提供该保证。

检索定位到了 ip-history.test.js,其开头说明针对纯历史辅助函数;本次受源码读取预算限制,没有阅读全文或运行测试,因此不列出未经核实的覆盖率或通过结果。建议验证非法存储、1/30 天窗口、同日补齐、跨日元数据冲突、存储拒绝、重新启用记录、标签页竞争,以及占位翻译和已加载状态初始化等边界。这些是验证建议,不代表现有测试已全部覆盖。

扩展依据:ip-history.js、ip-history.js、use-info-mask.js。

相关链接

Sources

(3 files)
frontend/utils