Repository Wiki
jason5ng32/MyIP

测试结果采集、诊断报告与分享

MyIP 将已完成测试的结果采集为按诊断分区组织的最新快照,并通过共享 schema 约束可分享的数据。服务端可将报告写入带自动过期时间的 Cloudflare Workers KV,供只读报告链接读取。

目的与范围

本页覆盖全局采集器的初始化与生命周期、分区快照的数据流、已核验的 schema 字段与 IP 脱敏规则,以及报告创建、存储、读取和部署开关。重点是解释测试结果如何成为可分享的诊断数据,而不是重新介绍每种网络测试的执行算法。

连通性、WebRTC、DNS 泄漏、测速及 Ping/MTR 的测试实现属于各自工具主题;全局限流、通用网络请求超时和路由中间件属于后端基础设施主题。仓库说明还列出 Markdown、JSON 导出,但本页未读取导出格式化器、分享对话框及只读页面实现,因此不推断它们的完整交互、输出模板或渲染规则。

概述

这条链路有三个重要边界:

  1. 采集不是重新测试。 useReportCollector() 订阅测试完成事件,把事件交给对应 builder;分享能力消费已产生的快照。
  2. 快照不是历史记录。 collectedSections 以分区 ID 为键,每次成功构建的新结果覆盖同一分区的旧值,不积累历次运行。
  3. 分享不是无限期存档。 创建接口校验报告并限制序列化大小,通过 KV TTL 提供 1、3、7 天的保留选项。

前端在根组件初始化采集器,因此它不是分享窗口打开时才启动的临时监听。报告路由也被根组件归入独立页面布局,但具体只读视图的实现不在已核验范围内。

依据:App.vue、use-report-collector.js、share-report.js。

架构与数据流

下图将已核验的前后端边界分开表示。采集器到 HTTP 请求之间的分区选择、导出组装和 UI 行为未展开,避免把未读取的调用关系当成事实。

Loading diagram...

Sources: App.vue、use-report-collector.js、share-report.js。

采集层通过事件与测试组件解耦;builder 负责把各工具的结果变成分区数据。前后端共享的 schema 则承担数据契约角色。schema 文件说明字段白名单同时用于限制不可信输入,未知字段会被拒绝;本页读取了字段规范,但未读取其后半部分的 validator 遍历实现,因而不列出完整错误结构。

结果采集:从事件到最新快照

初始化与订阅范围

根组件直接调用采集器,实际调用片段如下:

javascript
// Report collector: keeps the latest schema-shaped snapshot of every finished // test for the shareable diagnostic report. useReportCollector();

Source: App.vue。

useReportCollector() 遍历 REPORT_EVENT_BUILDERS 的全部键,为每种已注册事件调用 onAppEvent(),收到 payload 后调用内部 ingest()。因此,事件到报告分区的对应关系由 builder 注册表决定,不由采集器硬编码。

覆盖规则与时间戳

javascript
1const ingest = (event, payload) => { 2 const { section, build } = REPORT_EVENT_BUILDERS[event]; 3 const built = build(payload); 4 if (built === null) return; 5 collectedSections[section] = { testedAt: new Date().toISOString(), ...built }; 6};

Source: use-report-collector.js。

执行顺序与边界如下:

  • 根据事件名取出 section 和 build,同步执行 build(payload)。
  • 只有严格等于 null 的结果会被显式跳过;跳过不会删除已有分区。
  • 非 null 结果以一次属性赋值替换整个分区,不是与旧快照逐字段合并。
  • testedAt 在采集时生成,而不是从测试开始时间计算;对象展开位于时间戳之后,因此若 builder 返回同名字段,它会覆盖该时间戳。是否存在这样的 builder 未核验。
  • “最新”是最近被采集的事件,不是按测试启动顺序或时间戳比较得出的最新运行。若上游并发测试乱序完成,采集器自身不提供运行 ID 仲裁。

这些规则让采集器非常轻量,但也意味着各工具需要在上游确保完成事件和 payload 语义可靠。ingest() 未包裹 try/catch;builder 抛出的错误在这里不会被转成报告错误状态,事件总线如何处理该异常仍需查看其实现。

共享读取、清空与卸载

collectedSections 是模块级 reactive({})。后续调用 useCollectedReport() 的消费者读取同一个对象,而不是获取隔离副本。

javascript
1export const useCollectedReport = () => ({ 2 sections: collectedSections, 3 availableSectionIds: computed(() => Object.keys(collectedSections)), 4});

Source: use-report-collector.js。

availableSectionIds 只表示当前存在数据的分区键,不承诺显示顺序。源码注释指出显示顺序由 schema 中的 REPORT_SECTION_IDS 决定,本页未读取该常量的具体内容。

虽然注释将此接口称为只读访问,实际返回的是原始响应式对象,并未使用 Vue readonly() 包装;调用者应遵循只读约定,不能把它当成运行时不可修改的对象。

生命周期方面,采集器通过 onScopeDispose() 调用所有退订函数,避免开发环境 HMR 重复挂载监听。该清理只退订,不清空快照。resetCollectedReport() 则逐键删除快照,保留共享对象本身;源码注明它用于测试,并可供未来的“清除我的数据”控件使用,不能据此认定现有 UI 已提供该按钮。

依据:use-report-collector.js。

报告数据契约与隐私边界

已核验的分区约束

schema 的每个分区均包含 testedAt。以下是已读取部分的重要约束,不是完整报告顶层 JSON 定义。

分区核心数据已核验的限制与特点
ipinfocards、IP、国家/地区、ASN、ISP、匿名性最多 8 张卡;qualityScore 为 0–100;增强字段可选
connectivitytargets、状态、耗时、自定义标识最多 72 项;状态只允许 ok、unreachable、timeout;耗时为 0–600000 的整数
webrtcservers、URL、IP、NAT 类型最多 8 项;natType 是固定枚举,含 unknown、unavailable、error
dnsleakproviders、IP、国家、组织最多 8 项;IP 等信息可选
speedtest下载/上传速度、延迟、抖动、负载延迟、评分多个指标既可缺省也可为 null;速度上限 1000000 Mbps,延迟类上限 600000 ms
pingtesttarget、probes、stats目标为 IP 或域名;最多 32 个 probe;统计字段可选
mtrtesttarget、probe 元信息、hops已读定义中最多 32 个 probe、每个最多 64 hop;hop 序号为 1–64

opt(spec) 允许键缺省,nullable(spec) 允许显式 null,二者不是同一种语义。例如测速指标允许“存在但还没有数值”,而普通可选 IP 字段未在这些定义中声明 nullable。

白名单仅面向“我的网络”诊断;schema 注释明确将 Whois、MAC 查询等输入查询工具排除在此范围之外。这避免报告变成任意查询结果的通用容器。

依据:report-schema.js。

数据级 IP 脱敏

maskIpTail() 与页面 CSS 模糊不同:它转换的是 IP 字符串本身。IPv4 替换最后一段;IPv6 展开压缩表示后保留前四组,将后 64 位替换为 x。有效的脱敏形式由 isMaskedIP() 识别。

javascript
1export const maskIpTail = (ip) => { 2 if (!isValidIP(ip)) return ip; 3 if (!isIPv6(ip)) { 4 const octets = ip.split('.'); 5 octets[3] = 'x'; 6 return octets.join('.'); 7 } 8 const groups = expandIPv6Groups(ip).slice(0, 4) 9 .map((group) => group.replace(/^0+(?=.)/, '') || '0'); 10 return `${groups.join(':')}:x`; 11};

Source: report-schema.js。

非有效 IP 原样返回,包括已脱敏值和错误占位符,便于重复调用。该转换仍保留 IPv4 前三段或 IPv6 路由前缀,所以不是完全匿名化。

文件注释将脱敏定位在导出阶段,且脱敏后的形式仍可作为 schema 值。创建接口本身没有调用脱敏函数,不能把启用分享服务理解为服务端自动抹去完整 IP。具体哪些字段被导出遍历器处理、UI 是否默认启用脱敏,未在本页读取范围内确认。

分享创建与读取的核心流程

Loading diagram...

Source: share-report.js。

创建:先验证,再付出存储成本

createReport(req, res) 按以下顺序执行:

  1. 非 POST 请求直接返回 405。
  2. 在请求时读取 KV 配置;任何必需值缺失均返回 503。
  3. 从 req.body ?? {} 获取 report 和 ttlDays,调用 validateReport(report)。
  4. 校验失败返回 400,并将 errors 放入 details。
  5. JSON.stringify(report) 后使用 Buffer.byteLength(serialized, 'utf8') 检查 256 KiB 上限;超限返回 413。
  6. 生成 16 字节随机值的 base64url ID,即 128 位随机标识。
  7. 将 TTL 天数归一化后换算为秒,并用当前时间计算 ISO 格式 expiresAt。
  8. 使用 multipart/form-data 向 KV 写入包装对象,成功后返回 201。

大小判断针对 report 的 UTF-8 JSON 字节数,不是 JavaScript 字符数,也不是包含 expiresAt 的最终包装值大小。校验和第一次序列化位于存储 try/catch 之前,不能将存储错误处理视为覆盖整个函数的通用异常处理。

持久化形态与 KV 请求

报告 ID 直接作为 KV key;value 是 { expiresAt, report },另以查询参数 expiration_ttl 设置 KV 的实际保留时间。包装对象中的 expiresAt 让消费者能够显示到期时间,因为普通 KV value GET 不返回 TTL 元信息。

javascript
1const form = new FormData(); 2form.append('value', JSON.stringify({ expiresAt, report })); 3form.append('metadata', '{}'); 4const response = await fetchUpstream( 5 kvValueUrl(config, id, `?expiration_ttl=${ttlSeconds}`), 6 { 7 method: 'PUT', 8 headers: { 'Authorization': `Bearer ${config.apiKey}` }, 9 body: form, 10 }, 11);

Source: share-report.js。

这里不手工设置 Content-Type,让 fetch 根据 FormData 生成 multipart boundary。源码注释特别指出该调用使用 value 和 metadata 字段,避免直接发送原始 body 导致 KV 拒绝。

每次创建生成新 ID;当前 handler 未实现请求幂等键、重复内容去重或更新已有报告的接口。若调用方对结果未知的 POST 自行重试,可能产生多个独立报告。

读取:依赖 KV 到期,不在 handler 中重新判定日期

getReport(req, res) 检查方法和配置后读取 req.params.id 对应的值:

  • KV 返回 404,统一转换为“报告不存在或已过期”。
  • 其他非成功状态进入异常处理。
  • 成功响应先读取文本,再 JSON.parse(),最后通过 res.json() 返回。

这一步可以发现损坏的 JSON,但并不重新调用 validateReport(),也没有比较 expiresAt 与当前时间。即:JSON 可解析不等于重新验证了存量数据的完整 schema;正常创建链路保证入库校验,到期则交由 KV TTL。

handler 注释说明 ID 格式由 requireValidReportId() 在路由层检查。该中间件的具体正则、拒绝状态以及路由缓存策略未在本页读取的实现中核验,不能仅凭 handler 断言其细节。

依据:share-report.js。

配置选项与固定限制

部署环境变量

配置类型默认值/回退作用
CLOUDFLARE_API_KEY字符串无;回退到 CLOUDFLARE_APIKV REST API 的 Bearer 凭据
CLOUDFLARE_API字符串无API key 的兼容变量名;优先级低于 CLOUDFLARE_API_KEY
CLOUDFLARE_ACCOUNT_ID字符串无Cloudflare 账户 ID
CLOUDFLARE_KV_NAMESPACE_ID字符串无报告 KV namespace ID

kvConfig() 在每次请求时读取环境变量,而不是在模块加载时缓存,源码明确说明这样支持测试和较晚加载的 dotenv。isReportSharingConfigured() 仅判断三类配置是否均为真值;它不是凭据有效性、权限或 KV 可用性的健康检查。

文件说明 /api/configs 会将其用于 reportSharing 开关,并据此控制分享链接 UI;本页未读取配置端点及 UI 实现。即使没有配置 KV,也不应据此推断本地快照采集或所有导出功能都被禁用,因为采集器没有依赖这些环境变量。

依据:share-report.js。

报告常量与请求参数

名称类型值/默认行为说明
REPORT_VERSIONnumber2schema 注释指出 v2 将 IP 卡片的 isProxy / proxy* 改为 anonymity / anonymity*
REPORT_TTL_DAYSnumber[][1, 3, 7]允许保留天数;首项也是回退值
请求 ttlDaysnumber无效值回退至 1使用 includes() 严格匹配,字符串形式的天数不会被自动转换
REPORT_MAX_BYTESnumber256 * 1024序列化报告的 UTF-8 字节上限
javascript
export const normalizeTtlDays = (ttlDays) => (REPORT_TTL_DAYS.includes(ttlDays) ? ttlDays : REPORT_TTL_DAYS[0]);

Source: share-report.js。

非法 TTL 不触发 400,而是回退到最短保留时间。这在不阻断合法报告分享的同时,避免不受支持的输入延长数据保留。schema 注释还指出只读 viewer 只渲染自身支持的版本,旧版本链接展示不支持状态;具体 UI 分支未在本页核验。

常量依据:report-schema.js。

API 与函数参考

源码使用 JavaScript,以下保留真实函数形态,不额外杜撰 TypeScript 类型或框架接口。

HTTP 接口

接口输入成功响应明确的失败响应
POST /api/reportbody 中的 report、ttlDays;报告需通过共享 validator201,{ id, expiresAt }400:报告校验失败;413:报告过大;503:缺少配置;500:存储阶段异常
GET /api/report/:id路径参数 id200,解析后的存储值,正常为 { expiresAt, report }404:KV 未找到或已过期;503:缺少配置;500:KV 读取失败或 JSON 解析失败

两个 handler 还各自保留方法检查:被非预期方法直接调用时返回 405 和 { message: 'Method Not Allowed' }。这不表示路由层一定将所有方法都转交给这些 handler。

采集与辅助函数

函数参数与返回关键副作用或限制
useReportCollector()无参数;无显式返回值注册事件监听,并在 scope 销毁时退订;应在根组件调用一次
useCollectedReport()无参数;返回 { sections, availableSectionIds }sections 是共享响应式对象;分区 ID 列表是 computed
resetCollectedReport()无参数;无显式返回值删除全部采集分区,不退订事件
isReportSharingConfigured()无参数;返回 boolean只检查环境配置是否存在
normalizeTtlDays(ttlDays)接受待检查值;返回白名单内的天数不合法时返回 1,不抛出专用输入异常
maskIpTail(ip)接受待转换值;有效 IP 返回脱敏字符串,非有效 IP 原样返回不提供完全匿名化
isMaskedIP(value)接受待检查值;返回 boolean仅认可定义的 IPv4/IPv6 脱敏形式

服务端 createReport 是默认导出,getReport 是具名导出。validateReport(report) 的已核验调用契约为返回可解构的 { ok, errors };完整顶层字段、错误条目格式及 validator 的异常行为未读取,不在这里补造请求示例。

依据:use-report-collector.js、report-schema.js、share-report.js。

故障、边界与并发

场景已核验的处理调试要点
builder 返回 null忽略事件,保留旧快照不能将旧分区仍存在误认为新一次测试已入报告
重复初始化采集器每次调用均注册监听scope 清理能避免正常 HMR 堆叠,但没有模块级“已初始化”锁
多个测试完成事件覆盖同一分区最后一次赋值获胜没有历史队列、运行 ID 或时间戳排序
KV 配置缺失503先核验三个必需配置类别,而不是排查 schema
KV 配置非空但凭据错误请求 KV 后进入失败路径配置检测不验证凭据有效性
上游返回非成功状态生成含状态码与上游文本的 Error上游文本截断为最多 300 个字符;读写失败均记录日志
KV value 不是合法 JSON读取 handler 捕获解析错误并返回 500与正常过期的 404 区分
链接过期或 key 不存在404 使用同一个错误信息API 不区分“从未存在”和“已经到期”

存储 catch 将 error.message 直接放入 500 响应;日志分别使用 Report share create failed 和 Report share read failed,读取日志还包含 reportId。这便于诊断 KV 错误,也意味着对外错误响应不是完全脱敏的固定文案,需要在部署安全审查时关注。

报告 handler 本身没有登录鉴权判断,读取逻辑也不关联用户所有权。随机 ID 用于降低猜测风险,不应把它视为身份验证;是否存在额外路由或部署层访问限制需单独核验。分享前应确认报告中的 IP、目标及网络信息符合预期披露范围。

依据:share-report.js、share-report.js。

性能、运维与扩展

性能与运维

  • 前端只保存每个分区的最新对象。 重跑测试不会由采集器追加历史记录;其内存边界更多取决于分区数量和 builder 产物大小。
  • 服务端先校验、再调用 KV。 无效或超大报告不会进入写入分支。处理过程仍会序列化报告,随后再次序列化带 expiresAt 的包装值,不是流式上传。
  • 读写各有一个显式 fetchUpstream() 调用。 本 handler 未实现重试、批处理或本地缓存;通用 fetch 包装器是否重试、超时时长是多少,未读取实现,不能推断。
  • 限流留在部署层。 文件注释说明托管站使用边缘规则,自部署由 SECURITY_RATE_LIMIT 控制全局 /api limiter。此处没有报告专用计数器,具体限额和默认值需查阅后端部署配置。
  • 保留期有硬边界。 有效 TTL 最大 7 天;每次分享创建独立 key,不存在本 handler 提供的续期、撤销或删除 API。

安全扩展顺序

新增可分享测试时,应沿现有职责边界扩展,而不是在采集器中嵌入测试逻辑:

  1. 明确该能力是否属于“我的网络”诊断,而非任意输入查询工具。
  2. 在共享 schema 中定义字段类型、数组上限、可选性及范围;涉及破坏性变化时按 REPORT_VERSION 约定处理。
  3. 在 REPORT_EVENT_BUILDERS 中维护事件到分区和 builder 的映射;采集器会依据注册表订阅,但实际 builder 实现仍需单独核验。
  4. 核对导出、IP 脱敏遍历和只读 viewer 是否都支持新分区,不要只让采集器出现新键就认定分享链路完成。
  5. 检查完整报告仍能满足大小上限,以及重复、乱序完成事件是否符合期望。

测试证据与建议核验点

已读取代码包含为测试暴露 TTL 归一化函数、清空共享采集状态及按请求读取环境变量的明确设计,但本页未读取相关测试文件,不能据此声称已有完整自动化覆盖或测试已通过。

后续验证应重点覆盖:合法/非法 TTL、schema 拒绝与字节上限、KV 404/非成功响应/损坏 JSON、builder 返回 null、重复测试覆盖、scope 退订,以及脱敏结果仍能通过共享 schema。这些是依据实现边界提出的验证建议,而非已执行的测试结果。

相关链接

Sources

(4 files)