☰
ClawHub 下载计量设计:不存原始 IP、不改写历史的 Skill/Package 下载统计实现
2026/9/25 2:59:15 网站建设 项目流程
  • 后端
  • 前端
  • AI 技能
  • AI 插件
  • 搜索引擎

【免费下载链接】clawhub

Skill + Plugin Registry for OpenClaw

项目地址:https://gitcode.com/gh_mirrors/mo/clawhub
点击查看免费下载

本篇技术文章基于 ClawHub 仓库中的 specs/download-metering.md 展开,系统讲解 ClawHub(OpenClaw 的 Skill + Plugin 注册表)如何在不存储原始 IP、不改写历史计数的前提下,为 Skill 与 Package 下载构建一条共享的计量管线:从身份哈希与每日去重、到多来源指标字段的严格隔离、再到托管归档流式下载场景下的 best-effort 指标投递机制。读完本文,你可以理解一套“按来源归因、可审计、永不回改”的下载计量体系是如何在 convex/downloadMetrics.ts、convex/downloads.ts 与 convex/schema.ts 中落地的。

一、设计意图:三条不可妥协的原则

specs/download-metering.md 的 Intent 一节给出了三条核心约束,它们是整个计量系统所有实现决策的出发点:

  1. 不存储原始 IP 地址:下载指标采集全程不落库明文 IP,只保留哈希后的身份标识;
  2. 不改写历史下载次数:任何来源刷新、内容替换、回滚或 GitHub 同步操作都不得重置或改写既有指标来源;
  3. 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 身份优先级:用户身份优先于 IP

convex/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不并入公开 Downloads
OpenClaw 安装遥测(累计)statsInstallsAllTime不并入公开 Downloads
GitHub 热度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 缺失则在签名前直接返回 410(Skill archive file missing from storage),不会发出半成品清单。清单本体是 schema 为clawhub.skill-archive-manifest.v1的签名 JWS,其中可选携带metricToken(clawhub.archive-download-metric.v1载荷,含target/identityKind/identityHash/dayStart/occurredAt)。

6.2 指标回执:验证—调度—204

recordArchiveDownloadMetricHandler接收回执 POST 后的处理链路:

  1. 限长读取:readBoundedRequestText按 16 KiB 上限读取请求体,超限立即拒绝;
  2. JWS 验证:verifyArchivePayloadWithLocalJwks(token, ARCHIVE_METRIC_JWS_TYPE)校验签名,失败返回 401;
  3. 载荷校验:parseArchiveMetricPayload核对 schema、issuer、audience、issuedAt <= now + 5s(时钟偏移)、expiresAt > now、expiresAt - issuedAt <= 30s以及metric字段完整性;
  4. 异步调度:校验通过后仅执行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,

由此得到三条判定顺序:

  1. 无linkedUserId的个人发布者,仅当当前认证活跃用户存储的personalPublisherId与所请求发布者一致时可访问——调用方自报 ID 不构成遗留所有权的证据;
  2. 当前存在的linkedUserId优先于上述遗留链接;
  3. 组织发布者访问遵循当前成员关系(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-effort:30 秒能力令牌 + 有界回执 + 调度器抖动投递,保证“指标源站故障 ≠ 下载失败”(convex/downloads.ts)。

相关测试可进一步验证行为:convex/downloadMetrics.test.ts 覆盖去重与身份解析,convex/lib/skillStats.test.ts 覆盖规范读取与来源拆解,convex/downloads.test.ts 覆盖下载与清单路径。

  • 后端
  • 前端
  • AI 技能
  • AI 插件
  • 搜索引擎

【免费下载链接】clawhub

Skill + Plugin Registry for OpenClaw

项目地址:https://gitcode.com/gh_mirrors/mo/clawhub
点击查看免费下载
上一篇:Tesseract页面分割模式终极指南:13种PSM参数的高级使用技巧
下一篇:Inngest安全最佳实践:事件认证、函数隔离和数据保护

创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询