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。
架构
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 建立索引:
- 尚未出现的 IP 追加到当天桶。
- 已出现的 IP 只补齐空元数据字段。
- 已有非空字段不会被新值覆盖。
- 没有实际变化时返回原
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。
核心执行流程
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() 按以下顺序工作:
- 调用
trackEvent('SideButtons', 'ToggleClick', 'InfoMask')。 - 在
0和1之间切换infoMaskLevel。 - 选择对应的标题、消息翻译键。
- 调用
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. 夹取历史保留天数
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. 只在实际变化时持久化
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. 清空内存与本地记录
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. 为显示值生成遮罩标记
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 | 当前输出数据版本 |
infoMaskLevel | Vue ref,正常切换值为 0 或 1 | 0 | 0 不遮罩,1 开启遮罩 |
isInfosLoaded / showMaskButton | Vue ref | false / 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。
相关链接
- 历史聚合、国家分面与筛选规则:扩展历史展示时的主要数据契约。
- 本地记录与事件发送入口:排查未记录、清空和事件不同步问题。
- 遮罩属性与状态同步入口:排查遮罩开关与组件标记问题。