ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

ClawHub 下载计量设计:不存原始 IP、不改写历史的 Skill/Package 下载统计实现

ClawHub 下载计量设计:不存原始 IP、不改写历史的 Skill/Package 下载统计实现 后端前端AI 技能AI 插件搜索引擎【免费下载链接】clawhubSkill Plugin Registry for OpenClaw项目地址https://gitcode.com/gh_mirrors/mo/clawhub点击查看免费下载本篇技术文章基于 ClawHub 仓库中的 specs/download-metering.md 展开系统讲解 ClawHubOpenClaw 的 Skill Plugin 注册表如何在不存储原始 IP、不改写历史计数的前提下为 Skill 与 Package 下载构建一条共享的计量管线从身份哈希与每日去重、到多来源指标字段的严格隔离、再到托管归档流式下载场景下的 best-effort 指标投递机制。读完本文你可以理解一套“按来源归因、可审计、永不回改”的下载计量体系是如何在 convex/downloadMetrics.ts、convex/downloads.ts 与 convex/schema.ts 中落地的。一、设计意图三条不可妥协的原则specs/download-metering.md 的 Intent 一节给出了三条核心约束它们是整个计量系统所有实现决策的出发点不存储原始 IP 地址下载指标采集全程不落库明文 IP只保留哈希后的身份标识不改写历史下载次数任何来源刷新、内容替换、回滚或 GitHub 同步操作都不得重置或改写既有指标来源Skill 与 Package 下载共用一条计量路径该路径对“每个目标、每种身份类型、每个身份哈希、每个 UTC 天”只记录一次被计数的下载one counted download per target, identity kind, identity hash, and UTC day。从源码结构看这条共享路径就是 convex/downloadMetrics.ts 中的recordDownloadMetricInternalinternal mutation它同时接受skill和package两种目标const targetValidator v.union( v.object({ kind: v.literal(skill), id: v.id(skills) }), v.object({ kind: v.literal(package), id: v.id(packages) }), );二、身份识别与哈希user:与ip:双域2.1 身份输入格式规范明确身份哈希的输入必须带上身份类型前缀user:user id ip:client ip这样做的目的是让“恰好字符串相同的 user id 与 IP”落在不同的哈希域中用于去重和本地诊断时互不干扰。2.2 身份优先级用户身份优先于 IPconvex/downloadMetrics.ts 的getDownloadIdentity实现了身份解析export function getDownloadIdentity( request: Request, userId: string | null, ): DownloadIdentity | null { if (userId) return { identityKind: user, identityValue: userId }; const ip getClientIp(request); if (!ip) return null; return { identityKind: ip, identityValue: ip }; }而在 convex/downloads.ts 中getOptionalDownloadUserId会先尝试 API Token 对应的用户再回退到当前会话的活跃用户const apiTokenUserId await getOptionalApiTokenUserId(ctx, request); if (apiTokenUserId) return apiTokenUserId; return (await getOptionalActiveAuthUserIdFromAction(ctx)) ?? null;即携带有效 API Token 或处于登录态的下载会记为user:id匿名下载才降级为ip:client ip两者都取不到时不产生任何指标下载本身不受影响。2.3 哈希构造convex/downloadMetrics.ts 的buildDownloadMetricArgs将身份值与类型拼接后哈希并附带 UTC 天起点与发生时间return { target: params.target, identityKind: params.identity.identityKind, identityHash: await hashToken( ${params.identity.identityKind}:${params.identity.identityValue}, ), dayStart: getDayStart(params.now), occurredAt: params.now, };其中getDayStart以 86,400,000 ms 为一天取整Math.floor(timestamp / DAY_MS) * DAY_MS保证跨时区客户端都落在同一 UTC 天桶内。hashToken定义于 convex/lib/tokens.ts明文身份值仅存在于内存中落库的只有identityHash。三、去重表一天一目标一身份只计一次3.1 表结构与唯一性索引convex/schema.ts 中的downloadMetricDedupes表只存“已计数事实”不存用户/来源计数器const downloadMetricDedupes defineTable({ targetKind: downloadMetricTargetKind, // skill | package targetId: v.string(), identityKind: downloadMetricIdentityKind, // user | ip identityHash: v.string(), dayStart: v.number(), createdAt: v.number(), }) .index(by_target_identity_day, [ targetKind, targetId, identityKind, identityHash, dayStart, ]) .index(by_day, [dayStart]);by_target_identity_day复合索引精确覆盖规范中“target identity kind identity hash UTC day”四元组使“是否已计数”成为一次索引点查。3.2 去重门控只决定是否发射既有统计事件recordDownloadMetricInternal的核心逻辑是“查表 → 已存在则直接返回否则插入去重行并发射对应目标类型的既有统计事件”if (existing) return; // ... if (args.target.kind skill) { await insertStatEvent(ctx, { skillId: args.target.id, kind: download, occurredAt: args.occurredAt, }); return; } await ctx.db.insert(packageStatEvents, { packageId: args.target.id, kind: download, occurredAt: args.occurredAt ?? now, processedAt: undefined, });这印证了规范中的关键设计去重表本身不存储 user-vs-IP 计数器它只作为“闸门”决定本次下载是否应发射既有的 skill / package 统计事件。对 Skill事件经 convex/skillStatEvents.ts 的insertStatEvent进入事件管线对 Package则写入 schema 中packageStatEvents表kind取值为download/install/install_clear带by_unprocessed索引供后续批处理消费。3.3 14 天保留期与批量清理pruneDownloadMetricDedupesInternal按DEDUPE_RETENTION_MS 14 * DAY_MS清理过期去重行并顺带清理 Package 安装侧的packageInstallMetricDedupes每次批量删除RETENTION_STANDARD_BATCH_SIZE后若仍有剩余则通过ctx.scheduler.runAfter(0, ...)自我续跑避免单次 mutation 内做无界循环。这也从工程侧解释了“每个 UTC 天计一次”的口径——去重行保留 14 天即可覆盖回溯场景。四、来源归因计数器绝不合并、绝不回改4.1 各指标来源的存储字段规范为每个指标来源指定了独立的存储字段彼此永不相加指标来源存储字段语义ClawHub 原生制品下载statsDownloads公开的 “Downloads” 计数skills.sh 上游安装statsSkillsShInstalls上游终身lifetime安装数OpenClaw 安装遥测当前statsInstallsCurrent不并入公开 DownloadsOpenClaw 安装遥测累计statsInstallsAllTime不并入公开 DownloadsGitHub 热度statsGithubStars独立展示ClawHub Bookmarksstars行 statsStars保留旧存储/API 名称以兼容对应关系可概括为public Downloads: statsDownloads skills.sh installs: statsSkillsShInstalls (lifetime)convex/schema.ts 中skills表同时保留了顶层字段与嵌套stats.*字段后者已标注deprecated并为其建了可排序索引如by_stats_downloads、by_active_stats_downloads供目录排序与聚合查询使用。4.2 规范读法readCanonicalStat与指标来源拆解convex/lib/skillStats.ts 把上述约定固化成了唯一读取入口/** * Top-level fields (statsDownloads, etc.) are the source of truth — they are * indexable and kept up-to-date by the event pipeline. The nested stats.* * fields are only used as a fallback for pre-migration documents ... */ export function readCanonicalStat(skill, field) { const topLevelKey stats${field[0].toUpperCase()}${field.slice(1)}; return typeof skill[topLevelKey] number ? skill[topLevelKey]! : (skill.stats[field] ?? 0); } export function readSkillMetricSources(skill) { return { clawHubDownloads: readCanonicalStat(skill, downloads), skillsShInstalls: optionalNonNegativeCount(skill.statsSkillsShInstalls), openClawInstallsCurrent: readCanonicalStat(skill, installsCurrent), openClawInstallsAllTime: readCanonicalStat(skill, installsAllTime), githubStars: optionalNonNegativeCount(skill.statsGithubStars), bookmarks: readCanonicalStat(skill, stars), }; }要点有二其一顶层字段是事实来源嵌套stats.*仅为迁移前文档的回退读取路径其二readSkillMetricSources按来源拆出六个独立值applySkillStatDeltas也只对四个可累加计数downloads / stars / installsCurrent / installsAllTime做增量更新并强制Math.max(0, ...)防止负数。规范中“这些值永不合并为单一 Downloads 计数、canonical search 对 lifetime downloads 与 skills.sh installs 的排名权重为零、来源刷新/内容替换/回滚/GitHub 同步不得重置任何指标来源”等约束均与该读取/写入路径的设计一一对应仪表盘拿到的永远是按来源拆解的 breakdown而普通公开 Skill 数据形状只把statsDownloads暴露为 Downloads。五、Package 每日图30 天窗口 零值填充规范对 Package 图表的口径描述是渲染可见 30 天窗口内可用的packageDailyStats行缺失的天补零历史累计数不会被重新分摊到每日行。因此“全时总下载数 可见每日图之和”是预期行为而非数据错误。convex/schema.ts 中packageDailyStats表的结构支撑了这一口径const packageDailyStats defineTable({ packageId: v.id(packages), day: v.number(), downloads: v.number(), installs: v.number(), bookmarks: v.optional(v.number()), rankingDatasetVersion: v.optional(v.string()), rankingImportedAt: v.optional(v.number()), updatedAt: v.number(), }) .index(by_package_day, [packageId, day]) .index(by_day, [day]);by_package_day索引让“取某 Package 某天的行”是 O(1) 点查前端或查询层在 30 天窗口内对缺失天补零即可无需任何“把历史总量摊回每日”的回填逻辑——这正是“不改写历史”原则在图表层的体现。六、托管归档流式下载best-effort 指标与 30 秒能力令牌当请求经由 Convex 代理按签名清单signed archive manifest重建 zip 时即托管归档场景规范对指标投递提出了严格约束指标 POST 是 best-effort 的绝不能挂在“发出第一个 zip 字节”的同步路径上被 await指标源站挂起不能拖死下载。convex/downloads.ts 完整实现了这套机制。6.1 清单请求与令牌签发客户端以请求头x-clawhub-archive-manifest: v1触发清单路径并需通过x-clawhub-archive-identity携带的 OIDC 令牌完成身份校验verifyClawHubVercelOidcToken。清单签发前有一组硬性边界常量const ARCHIVE_MANIFEST_TTL_MS 30_000; // 能力令牌 30 秒有效期 const ARCHIVE_MANIFEST_CLOCK_SKEW_MS 5_000; // 允许 5 秒时钟偏移 const MAX_ARCHIVE_MANIFEST_FILES 8_192; // 清单最多 8192 个条目 const MAX_ARCHIVE_MANIFEST_BYTES 4 * 1024 * 1024; // 签名清单不超过 4 MiB const MAX_ARCHIVE_METRIC_TOKEN_BYTES 16 * 1024;清单中每个条目由ctx.storage.getUrl(file.storageId)生成存储 URL——任一存储 URL 缺失则在签名前直接返回 410Skill archive file missing from storage不会发出半成品清单。清单本体是 schema 为clawhub.skill-archive-manifest.v1的签名 JWS其中可选携带metricTokenclawhub.archive-download-metric.v1载荷含target/identityKind/identityHash/dayStart/occurredAt。6.2 指标回执验证—调度—204recordArchiveDownloadMetricHandler接收回执 POST 后的处理链路限长读取readBoundedRequestText按 16 KiB 上限读取请求体超限立即拒绝JWS 验证verifyArchivePayloadWithLocalJwks(token, ARCHIVE_METRIC_JWS_TYPE)校验签名失败返回 401载荷校验parseArchiveMetricPayload核对 schema、issuer、audience、issuedAt now 5s时钟偏移、expiresAt now、expiresAt - issuedAt 30s以及metric字段完整性异步调度校验通过后仅执行ctx.scheduler.runAfter(随机抖动, internal.downloadMetrics.recordDownloadMetricInternal, ...)随即返回 204。整个 try 块包裹在catch中静默吞错注释明确写着 “Metrics remain best-effort and must not affect an archive already being streamed.”。对非托管的直接 zip 路径downloadZipHandler指标同样以“调度而非等待”的方式处理scheduleSkillDownloadMetric在返回 zip 流响应之前仅用runAfter(Math.floor(Math.random() * DOWNLOAD_STAT_JITTER_MS), ...)抖动上限 60 秒DOWNLOAD_STAT_JITTER_MS 60_000把recordDownloadMetricInternal排入调度器整段被 try/catch 保护——“Best-effort metric path; do not fail downloads.” 下载还先经过applyRateLimit(ctx, request, download)限流与getPublicSkillVersionDownloadBlock等审核moderation检查审核未通过的版本不产生下载、自然不产生指标。6.3 计数的精确触发条件规范对“何时才算完成一次下载”的判定规则与源码行为逐条对应仅在清单声明的每个条目都成功流式传输且 ZIP 完整组装后计数指标令牌在清单阶段就嵌入了完整去重参数回执端不再触碰流ZIP 组装完成前的取消不发射指标客户端放弃组装即不会发出回执 POST指标能力保留 30 秒原始寿命健康的归档下载完全可以在 30 秒后完成但其 best-effort 指标会因过期被拒expiresAt now校验失败 → 401。规范明确禁止为此延长能力令牌寿命或复用过期令牌——“下载完成的达成绝不依赖指标被接受”有界指标 POST 注册在请求生命周期上托管执行可以在流式响应关闭后完成它而 ZIP 路径本身不 await 它。七、仪表盘访问当前所有权优先的校验规则规范最后一节约束了下载指标仪表盘dashboard metrics的访问控制仪表盘指标要求当前发布者所有权。具体规则在 convex/dashboard.ts 中可见其对应实现// publisher.kind user 时归属用户取 linkedUserId ?? userId return publisher.kind user ? (publisher.linkedUserId ?? userId) : undefined; // 遗留个人发布者链接仅当认证用户的 personalPublisherId 匹配请求的发布者时才生效 legacyOwnerUserId: user?.personalPublisherId args.publisherId ? userId : undefined,由此得到三条判定顺序无linkedUserId的个人发布者仅当当前认证活跃用户存储的personalPublisherId与所请求发布者一致时可访问——调用方自报 ID 不构成遗留所有权的证据当前存在的linkedUserId优先于上述遗留链接组织发布者访问遵循当前成员关系current membership。小结这套计量体系值得借鉴的设计点ClawHub 的下载计量方案specs/download-metering.md给出了四个可复用的工程范式身份先哈希后落库user:/ip:双域拼接 hashToken原始 IP 永不持久化convex/downloadMetrics.ts去重表只做闸门不做计数四元组唯一索引决定“是否发射既有统计事件”计数器本身归属既有事件管线职责清晰downloadMetricDedupes×skillStatEvents/packageStatEvents指标来源物理隔离statsDownloads、statsSkillsShInstalls、statsInstallsCurrent/AllTime、statsGithubStars、statsStars各管各的公开 API 只暴露单一口径排名层面对跨来源数值零权重convex/lib/skillStats.ts流式路径上指标永远 best-effort30 秒能力令牌 有界回执 调度器抖动投递保证“指标源站故障 ≠ 下载失败”convex/downloads.ts。相关测试可进一步验证行为convex/downloadMetrics.test.ts 覆盖去重与身份解析convex/lib/skillStats.test.ts 覆盖规范读取与来源拆解convex/downloads.test.ts 覆盖下载与清单路径。赞分享后端前端AI 技能AI 插件搜索引擎【免费下载链接】clawhubSkill Plugin Registry for OpenClaw项目地址https://gitcode.com/gh_mirrors/mo/clawhub点击查看免费下载相关推荐VimWiki表格自动对齐算法详解从设计文档到代码实现VimWiki表格自动对齐算法详解从设计文档到代码实现 VimWiki 是 Vim 中著名的个人 Wiki 插件其招牌能力之一便是 表格自动对齐 在插入模Parabolic数据统计功能分析下载历史与习惯Parabolic数据统计功能分析下载历史与习惯 你是否经常忘记上个月下载了哪些视频想知道自己最常下载的平台是哪个Parabolic原TubeConve桌面应用音视频如何通过Script-IDE插件彻底改变你的Godot开发工作流如何通过Script IDE插件彻底改变你的Godot开发工作流 如果你正在使用Godot引擎开发游戏可能会对内置脚本编辑器的某些限制感到困扰——单文件编辑、上一篇Tesseract页面分割模式终极指南13种PSM参数的高级使用技巧下一篇Inngest安全最佳实践事件认证、函数隔离和数据保护创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考
返回列表