测试结果采集、诊断报告与分享
MyIP 将已完成测试的结果采集为按诊断分区组织的最新快照,并通过共享 schema 约束可分享的数据。服务端可将报告写入带自动过期时间的 Cloudflare Workers KV,供只读报告链接读取。
目的与范围
本页覆盖全局采集器的初始化与生命周期、分区快照的数据流、已核验的 schema 字段与 IP 脱敏规则,以及报告创建、存储、读取和部署开关。重点是解释测试结果如何成为可分享的诊断数据,而不是重新介绍每种网络测试的执行算法。
连通性、WebRTC、DNS 泄漏、测速及 Ping/MTR 的测试实现属于各自工具主题;全局限流、通用网络请求超时和路由中间件属于后端基础设施主题。仓库说明还列出 Markdown、JSON 导出,但本页未读取导出格式化器、分享对话框及只读页面实现,因此不推断它们的完整交互、输出模板或渲染规则。
概述
这条链路有三个重要边界:
- 采集不是重新测试。
useReportCollector()订阅测试完成事件,把事件交给对应 builder;分享能力消费已产生的快照。 - 快照不是历史记录。
collectedSections以分区 ID 为键,每次成功构建的新结果覆盖同一分区的旧值,不积累历次运行。 - 分享不是无限期存档。 创建接口校验报告并限制序列化大小,通过 KV TTL 提供 1、3、7 天的保留选项。
前端在根组件初始化采集器,因此它不是分享窗口打开时才启动的临时监听。报告路由也被根组件归入独立页面布局,但具体只读视图的实现不在已核验范围内。
依据:App.vue、use-report-collector.js、share-report.js。
架构与数据流
下图将已核验的前后端边界分开表示。采集器到 HTTP 请求之间的分区选择、导出组装和 UI 行为未展开,避免把未读取的调用关系当成事实。
Sources: App.vue、use-report-collector.js、share-report.js。
采集层通过事件与测试组件解耦;builder 负责把各工具的结果变成分区数据。前后端共享的 schema 则承担数据契约角色。schema 文件说明字段白名单同时用于限制不可信输入,未知字段会被拒绝;本页读取了字段规范,但未读取其后半部分的 validator 遍历实现,因而不列出完整错误结构。
结果采集:从事件到最新快照
初始化与订阅范围
根组件直接调用采集器,实际调用片段如下:
// 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 注册表决定,不由采集器硬编码。
覆盖规则与时间戳
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() 的消费者读取同一个对象,而不是获取隔离副本。
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 已提供该按钮。
报告数据契约与隐私边界
已核验的分区约束
schema 的每个分区均包含 testedAt。以下是已读取部分的重要约束,不是完整报告顶层 JSON 定义。
| 分区 | 核心数据 | 已核验的限制与特点 |
|---|---|---|
ipinfo | cards、IP、国家/地区、ASN、ISP、匿名性 | 最多 8 张卡;qualityScore 为 0–100;增强字段可选 |
connectivity | targets、状态、耗时、自定义标识 | 最多 72 项;状态只允许 ok、unreachable、timeout;耗时为 0–600000 的整数 |
webrtc | servers、URL、IP、NAT 类型 | 最多 8 项;natType 是固定枚举,含 unknown、unavailable、error |
dnsleak | providers、IP、国家、组织 | 最多 8 项;IP 等信息可选 |
speedtest | 下载/上传速度、延迟、抖动、负载延迟、评分 | 多个指标既可缺省也可为 null;速度上限 1000000 Mbps,延迟类上限 600000 ms |
pingtest | target、probes、stats | 目标为 IP 或域名;最多 32 个 probe;统计字段可选 |
mtrtest | target、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() 识别。
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 是否默认启用脱敏,未在本页读取范围内确认。
分享创建与读取的核心流程
Source: share-report.js。
创建:先验证,再付出存储成本
createReport(req, res) 按以下顺序执行:
- 非 POST 请求直接返回 405。
- 在请求时读取 KV 配置;任何必需值缺失均返回 503。
- 从
req.body ?? {}获取report和ttlDays,调用validateReport(report)。 - 校验失败返回 400,并将
errors放入details。 JSON.stringify(report)后使用Buffer.byteLength(serialized, 'utf8')检查 256 KiB 上限;超限返回 413。- 生成 16 字节随机值的 base64url ID,即 128 位随机标识。
- 将 TTL 天数归一化后换算为秒,并用当前时间计算 ISO 格式
expiresAt。 - 使用 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 元信息。
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_API | KV 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_VERSION | number | 2 | schema 注释指出 v2 将 IP 卡片的 isProxy / proxy* 改为 anonymity / anonymity* |
REPORT_TTL_DAYS | number[] | [1, 3, 7] | 允许保留天数;首项也是回退值 |
请求 ttlDays | number | 无效值回退至 1 | 使用 includes() 严格匹配,字符串形式的天数不会被自动转换 |
REPORT_MAX_BYTES | number | 256 * 1024 | 序列化报告的 UTF-8 字节上限 |
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/report | body 中的 report、ttlDays;报告需通过共享 validator | 201,{ id, expiresAt } | 400:报告校验失败;413:报告过大;503:缺少配置;500:存储阶段异常 |
GET /api/report/:id | 路径参数 id | 200,解析后的存储值,正常为 { 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控制全局/apilimiter。此处没有报告专用计数器,具体限额和默认值需查阅后端部署配置。 - 保留期有硬边界。 有效 TTL 最大 7 天;每次分享创建独立 key,不存在本 handler 提供的续期、撤销或删除 API。
安全扩展顺序
新增可分享测试时,应沿现有职责边界扩展,而不是在采集器中嵌入测试逻辑:
- 明确该能力是否属于“我的网络”诊断,而非任意输入查询工具。
- 在共享 schema 中定义字段类型、数组上限、可选性及范围;涉及破坏性变化时按
REPORT_VERSION约定处理。 - 在
REPORT_EVENT_BUILDERS中维护事件到分区和 builder 的映射;采集器会依据注册表订阅,但实际 builder 实现仍需单独核验。 - 核对导出、IP 脱敏遍历和只读 viewer 是否都支持新分区,不要只让采集器出现新键就认定分享链路完成。
- 检查完整报告仍能满足大小上限,以及重复、乱序完成事件是否符合期望。
测试证据与建议核验点
已读取代码包含为测试暴露 TTL 归一化函数、清空共享采集状态及按请求读取环境变量的明确设计,但本页未读取相关测试文件,不能据此声称已有完整自动化覆盖或测试已通过。
后续验证应重点覆盖:合法/非法 TTL、schema 拒绝与字节上限、KV 404/非成功响应/损坏 JSON、builder 返回 null、重复测试覆盖、scope 退订,以及脱敏结果仍能通过共享 schema。这些是依据实现边界提出的验证建议,而非已执行的测试结果。
相关链接
- 项目中文功能说明:诊断报告分享:了解只读链接、Markdown 与 JSON 的产品入口。
- 结果采集生命周期:继续追踪事件覆盖、共享读取和清空行为。
- 报告字段规范与脱敏约定:扩展分区或评估隐私边界时的契约入口。
- 分享 API 与部署边界:排查配置、TTL、KV 读写及错误响应。