skillhub/docs/2026-03-20-skill-label-system-design.md
Mikhail Neradkov 6d714c68ab docs(labels): document detail chip navigation and filter wrap
Align the label system design with click-through chips on skill detail
and wrapping search filter chips when many labels are present.

Signed-off-by: Mikhail Neradkov <michael.neradkov@gmail.com>
2026-07-29 22:57:51 +03:00

20 KiB
Raw Permalink Blame History

Skill Label System Design

Date: 2026-03-20 Status: Draft Scope: Phase 1 — 系统推荐标签 + 特权标签

1. Overview

为 SkillHub 引入 label 系统,为 skill 提供分类和标记能力。Label 挂在 skill 级别(与版本无关),支持多语言展示和搜索。

注意:本系统使用 "label" 而非 "tag",因为 skill_tag 已被版本分发通道功能占用。

1.1 一期范围

包含:

  • 系统推荐标签RECOMMENDED管理员 CRUD + 多语言翻译 + 排序,用于搜索页分类筛选
  • 特权标签PRIVILEGED管理员专属赋予如"官方推荐"、"官方认证"、"从Clawhub镜像"
  • Skill 详情页 label 展示与管理
  • 搜索页分类板块(单选互斥筛选)
  • 多语言搜索命中(所有语言翻译写入搜索文档)

不包含(保留兼容性):

  • 用户自定义标签
  • 用户自定义标签审核流程

1.2 关键决策

  • label_* 是全新模型,与现有 skill_tag 彻底隔离;skill_tag 继续只承担“版本分发别名”的职责不复用表、Service、Controller、DTO、API 路径
  • skill_search_document.keywords 是搜索文档的共享聚合字段,不是 label 专属字段;一期在搜索文档重建时,将“现有业务 keywords 来源”和“label 翻译文本”作为两个独立来源重新组合写入
  • label 搜索集成只允许“基于权威源全量重建单个 skill 的搜索文档”,不允许读取现有 skill_search_document.keywords 后做增量 append
  • promotion 在当前系统中会创建新的 target skill而不是把 source skill 移动到新空间;因此 label 生命周期必须按“source skill / target skill 两条独立 skill 记录”建模

2. Data Model

注意:新表统一使用 TIMESTAMPTZ 作为时间戳类型标准(现有旧表使用 TIMESTAMP,后续统一迁移)。

2.1 label_definition标签定义表

CREATE TABLE label_definition (
    id          BIGSERIAL PRIMARY KEY,
    slug        VARCHAR(64) UNIQUE NOT NULL,   -- 英文标识,必填,如 code-generation
    type        VARCHAR(16) NOT NULL CHECK (type IN ('RECOMMENDED', 'PRIVILEGED')),
    visible_in_filter BOOLEAN NOT NULL DEFAULT true, -- 是否在搜索页分类板块展示
    sort_order  INTEGER NOT NULL DEFAULT 0,    -- 分类板块显示顺序
    created_by  VARCHAR(128) REFERENCES user_account(id),
    created_at  TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
    updated_at  TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP
);
  • slug 即英文名称,作为语言无关的唯一标识
  • type 区分系统推荐标签和特权标签决定权限控制策略。DDL 层通过 CHECK 约束限制合法值;应用层权限校验对未知 type 采用 deny-by-default 策略
  • visible_in_filter 控制是否出现在搜索页分类板块RECOMMENDED 和 PRIVILEGED 均可配置

2.2 label_translation标签翻译表

CREATE TABLE label_translation (
    id          BIGSERIAL PRIMARY KEY,
    label_id    BIGINT NOT NULL REFERENCES label_definition(id) ON DELETE CASCADE,
    locale      VARCHAR(16) NOT NULL,          -- 语言代码,如 en、zh、ja
    display_name VARCHAR(128) NOT NULL,        -- 该语言的显示名称
    created_at  TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
    updated_at  TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
    UNIQUE(label_id, locale)
);
  • 支持动态语言:管理员可为任意语言添加翻译,不限于系统当前支持的语言列表
  • 前端展示 fallback 顺序:当前语言 → en → slug
  • 后端返回 displayName 时,“当前语言”以请求 locale 为准;实现上使用 Spring locale 解析结果(等价于基于 Accept-Language / request locale再 fallback 到 enslug

2.3 skill_labelskill 与 label 关联表)

CREATE TABLE skill_label (
    id          BIGSERIAL PRIMARY KEY,
    skill_id    BIGINT NOT NULL REFERENCES skill(id) ON DELETE CASCADE,
    label_id    BIGINT NOT NULL REFERENCES label_definition(id) ON DELETE CASCADE,
    created_by  VARCHAR(128) REFERENCES user_account(id),
    created_at  TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
    UNIQUE(skill_id, label_id)
);

CREATE INDEX idx_skill_label_label_id ON skill_label(label_id);
  • Label 挂在 skill 级别,与版本无关
  • 级联删除:删除 label_definition 时自动清理关联
  • (label_id) 索引用于分类筛选时按 label 查找关联 skill 的性能优化
  • 单个 skill 最多关联 10 个 label应用层校验

2.5 与现有 skill_tag 的关系

现有 skill_tag 已用于版本分发通道,例如 latestbeta 等 tag 指向某个发布版本。它具备以下特征:

  • 语义是“版本别名”,不是 skill 分类
  • version_id 必填tag 必须解析到某个 skill version
  • API 和前端心智都已围绕“安装/下载某个版本别名”展开

因此:

  • 新 label 系统不得复用 skill_tag 表结构
  • 新 label 系统不得复用 /tags 相关 API 路径
  • 代码实现中必须使用独立的命名:LabelDefinition / SkillLabel / LabelTranslation

2.4 兼容性设计:用户自定义标签

未来用户自定义标签可通过以下方式扩展,无需新建表:

  1. label_definition.type 增加 USER_DEFINED 枚举值
  2. label_definition 增加字段:
    • status VARCHAR(16)PENDING_REVIEW / APPROVED / REJECTED,用于审核流程
    • submitted_by VARCHAR(128) — 提交人
  3. label_translation 对于用户自定义标签只需存储用户输入的原始语言,无需多语言翻译
  4. 搜索行为:用户自定义标签以原始文本写入搜索文档 keywords只能搜索用户输入的语言

这种设计保证了:

  • 现有表结构无需破坏性变更
  • 权限模型自然扩展(USER_DEFINED 类型有独立的权限规则)
  • 搜索集成方式一致(均通过 keywords 字段)

3. Permission Model

操作 对象 超级管理员 命名空间管理员 Skill Owner 普通用户
CRUD 标签定义 label_definition
管理翻译 label_translation
赋予/移除 RECOMMENDED label skill_label (本空间,仅限搜索页可见的 RECOMMENDED 标签) (自己的 skill仅限搜索页可见的 RECOMMENDED 标签)
赋予/移除 PRIVILEGED label skill_label
查看 label 所有表 (受 skill 可见性约束)
  • 标签定义和翻译的管理是全局操作,仅超级管理员
  • 赋予 label 到 skill 的权限取决于 label 的 type
  • 查看权限跟随 skill 本身的可见性规则,不额外控制
  • 实现上必须抽出统一的 LabelPermissionCheckerSUPER_ADMIN 始终可绕过 namespace membership 直接执行 label 管理操作,避免 controller / service 各自复制权限逻辑

3.1 跨空间权限边界

  • 命名空间管理员只能管理其所管理空间内 skill 的 label

3.2 Promotion 后的 label 生命周期

当前 promotion 的事实模型是“审批后在目标全局空间创建一个新的 target skill”而不是把 source skill 迁移到全局空间。

一期采用以下规则:

  • promotion 不自动复制 source skill 的任何 label 到 target skill
  • source skill 和 target skill 各自维护独立的 skill_label
  • source 空间管理员对 source skill 的 label 权限不变
  • target skill 的 label 由 target skill 当前权限模型控制source 空间管理员不会因 source skill 的管理权限而自动获得 target skill 的 label 管理权

这样做的原因:

  • 避免在一期引入“promotion 时 label 复制/回写/同步”的额外复杂度
  • 与当前 promotion “创建新 skill 副本”的领域模型一致
  • 后续如需复制策略,可在 promotion approval 流程中显式扩展,而不破坏现有表结构

4. Search Integration

采用翻译文本展开写入搜索文档方案。

4.1 搜索架构现状

当前搜索基于 PostgreSQL Full-Text Search

  • skill_search_document 表有 search_vector 列,类型为 tsvector GENERATED ALWAYS AS ... STORED
  • 权重体系title (A) > summary/keywords (B) > search_text (C)
  • search_vector 在 keywords 等字段更新时自动重新生成,无需手动维护
  • 查询时通过 d.search_vector @@ to_tsquery('simple', :tsQuery) 进行全文匹配

4.2 Keywords 字段写入

在构建 SkillSearchDocument 时,将 skill 关联的所有 label 的所有语言翻译文本写入 keywords 字段。

重要: skill_search_document.keywords 不是 label 专属字段,而是搜索文档的共享聚合字段。当前系统中,该字段已经承载来自 skill metadata/frontmatter 的 keywords/tag 信息。label 翻译文本只是新增来源之一,不能覆盖或破坏现有来源。

实现要求:

  • 搜索文档重建时,从权威源重新计算完整的 keywords
  • 现有业务 keywords 来源与 label 翻译文本作为两个独立来源进行组合
  • 不允许读取旧的 skill_search_document.keywords 后做增量 append
  • label 删除、翻译修改、skill 移除 label 后,旧 label 文本必须通过重建被彻底清理,不得残留

建议实现上的组合顺序:

  1. 保留现有搜索重建逻辑产出的原有 keywords 内容
  2. 追加该 skill 关联 label 的全部翻译文本
  3. 最终统一写回新的 SkillSearchDocument.keywords

示例:

[原有 keywords 内容] Code Generation 代码生成 Official 官方推荐

4.3 搜索文档重建触发时机

事件 影响范围 处理方式
Skill 被赋予/移除 label 单个 skill 同步重建该 skill 搜索文档
label_translation 被修改 所有关联该 label 的 skill 异步批量重建
label_definition 被删除 所有关联该 label 的 skill 异步批量重建

异步批量重建方案

  • 使用 Spring @Async 执行异步任务
  • skillhub-app 层的 application service 中实现 label 相关的搜索同步入口(不放在 skillhub-search 模块的 SearchRebuildService 中,避免搜索模块对 skill_label 表的直接依赖,保持模块边界清晰)
  • label_translation 修改、label_definition 删除等“影响多个 skill”的变更在事务内先收集受影响的 skill_id 列表,再在 AFTER_COMMIT 阶段触发异步任务
  • label_definition 删除场景严禁在删除后再通过 skill_label 反查,因为 skill_label 已被级联删除;必须在删除前快照受影响的 skill_id
  • 异步任务分批调用 SearchRebuildService.rebuildBySkill(Long)
  • 批量大小:每批 50 个 skill批次间无需间隔数据库写入压力可控系统推荐标签数量有限
  • 失败隔离策略:批量重建循环中必须对每个 skill 单独 try/catch 并记录日志,保证单个 skill 失败不影响后续 skill
  • 错误处理:单个 skill 重建失败记录错误日志,不自动重试(下次 label 变更或手动 rebuildAll 时会修复)
  • 如单次受影响 skill 数过多,应允许后台人工触发搜索全量重建作为兜底手段
  • 对“热门 label 导致大量 skill 批量重建”的场景,一期不单独引入任务表;优先依赖现有异步线程池执行,小规模批量直接处理,超大批量由后台人工触发 rebuildAll 兜底

4.4 分类筛选

搜索页分类板块的筛选不走全文搜索,而是通过 skill_label JOIN label_definition 按 slug 做大小写不敏感过滤,再与搜索结果取交集。避免全文搜索的模糊性问题。

当前搜索入口为:

GET /api/web/skills?q=xxx

一期在现有入口上增加可选 query parameter label,支持多值以预留未来组合筛选能力(一期前端只做单选):

GET /api/web/skills?q=xxx&label=code-generation
GET /api/web/skills?q=xxx&label=code-generation&label=official  (未来)

SearchQuery 改动

SearchQuery 需要新增 labelSlugs 字段:

public record SearchQuery(
    String keyword,
    Long namespaceId,
    SearchVisibilityScope visibilityScope,
    String sortBy,
    int page,
    int size,
    List<String> labelSlugs
) {}

注意:

  • 这不是“零成本追加字段”;当前 controller、application service、query service、测试代码都需要同步修改
  • 实现时应显式梳理以下变更点HTTP 参数解析、SkillSearchAppService 参数透传、PostgresFullTextQueryService SQL 条件、相关单元测试/控制器测试
  • 若后续搜索过滤条件继续增加,应考虑把 SearchQuery 从位置参数 record 演进为更可扩展的请求对象

PostgresFullTextQueryService 的 SQL 拼接逻辑中,当 labelSlugs 非空时追加:

AND d.skill_id IN (
    SELECT sl.skill_id FROM skill_label sl
    JOIN label_definition ld ON ld.id = sl.label_id
    WHERE ld.slug IN (:labelSlugs)
)

count 查询同步追加相同条件。语义重排在 label 过滤后的候选集上执行,无需额外处理。

当前多 label 筛选采用 OR 语义(匹配任一 label 即命中)。未来如需 AND 语义(同时具有所有 label可通过 GROUP BY skill_id HAVING COUNT(*) = :labelCount 扩展API 层增加 labelMode=any|all 参数区分。

4.5 tsvector 权重

不改变现有权重体系。search_vectorGENERATED ALWAYS AS ... STOREDkeywords 字段更新后自动重新生成,无需手动维护:

  • A 权重title (displayName)
  • B 权重summary / keywords含 label 翻译文本)
  • C 权重searchText

5. API Design

5.1 管理后台 API超级管理员

所有响应遵循项目统一响应规范 { code, msg, data, timestamp, requestId }

列出所有标签定义

GET /api/v1/admin/labels

Response data:

[
  {
    "slug": "code-generation",
    "type": "RECOMMENDED",
    "visibleInFilter": true,
    "sortOrder": 10,
    "translations": [
      { "locale": "en", "displayName": "Code Generation" },
      { "locale": "zh", "displayName": "代码生成" }
    ],
    "createdAt": "2026-03-20T10:00:00Z"
  }
]

不分页,系统标签数量有限(建议上限 100 个 label_definition

创建标签定义

POST /api/v1/admin/labels
{
  "slug": "code-generation",
  "type": "RECOMMENDED",
  "visibleInFilter": true,
  "sortOrder": 10,
  "translations": [
    { "locale": "en", "displayName": "Code Generation" },
    { "locale": "zh", "displayName": "代码生成" }
  ]
}

更新标签定义

PUT /api/v1/admin/labels/{slug}

Body 不包含 slug 字段slug 不可修改,以 path 参数为准):

{
  "type": "RECOMMENDED",
  "visibleInFilter": true,
  "sortOrder": 10,
  "translations": [
    { "locale": "en", "displayName": "Code Generation" },
    { "locale": "zh", "displayName": "代码生成" }
  ]
}

translations 采用全量替换策略:请求中的 translations 列表完全替代现有翻译。如果删除了某个语言的翻译,会触发关联 skill 的异步搜索文档重建。

删除标签定义

DELETE /api/v1/admin/labels/{slug}

硬删除。级联删除关联的 translations 和 skill_label 记录,触发异步搜索文档重建。删除操作会记录到 audit_log。

批量更新排序

PUT /api/v1/admin/labels/sort-order
{
  "items": [
    { "slug": "code-generation", "sortOrder": 1 },
    { "slug": "official", "sortOrder": 2 }
  ]
}

5.2 Skill Label 管理 API

路由约定:

  • 为与现有 skill read 接口风格保持一致skill 详情读取类 label API 采用双路由暴露:/api/v1/.../api/web/...
  • 管理后台 label definition API 继续只暴露在 /api/v1/admin/...
  • 搜索页所需的公开 labels 列表 API 一期同时暴露 /api/v1/labels/api/web/labels,前端默认使用 /api/web/labels

获取 skill 的所有 label

GET /api/v1/skills/{namespace}/{slug}/labels
GET /api/web/skills/{namespace}/{slug}/labels

Response data:

[
  {
    "slug": "code-generation",
    "type": "RECOMMENDED",
    "displayName": "代码生成"
  },
  {
    "slug": "official",
    "type": "PRIVILEGED",
    "displayName": "官方推荐"
  }
]

displayName 根据请求语言返回fallback 顺序:当前语言 → en → slug。

DTO 约定:

  • 一期统一返回 slugtypedisplayName
  • 如后续需要区分视觉样式,可在前端基于 type 判断

赋予 label

PUT /api/v1/skills/{namespace}/{slug}/labels/{labelSlug}
PUT /api/web/skills/{namespace}/{slug}/labels/{labelSlug}

权限校验RECOMMENDED → owner / 命名空间管理员 / 超级管理员PRIVILEGED → 仅超级管理员。

移除 label

DELETE /api/v1/skills/{namespace}/{slug}/labels/{labelSlug}
DELETE /api/web/skills/{namespace}/{slug}/labels/{labelSlug}

权限校验同赋予。

5.3 公开查询 API

获取可用标签列表(搜索页分类板块)

GET /api/v1/labels
GET /api/web/labels

返回 visible_in_filter=truetype='RECOMMENDED' 的标签,按 sort_order 排序。PRIVILEGED 一期不出现在搜索页分类筛选中,避免运营/特权标签与功能分类混淆。Response data:

[
  {
    "slug": "code-generation",
    "type": "RECOMMENDED",
    "displayName": "代码生成"
  }
]

displayName 根据请求语言返回fallback 顺序:当前语言 → en → slug。不分页。

6. ClawHub 兼容层

ClawHub CLI 兼容层的搜索接口 GET /api/v1/search 一期不支持 label 筛选。ClawHub 协议中没有 label 概念,无需兼容。

7. Frontend Design

7.1 搜索页

  • 搜索框下方增加分类板块,水平排列 label 列表(数据来自 GET /api/v1/labels);标签过多时应允许换行(flex-wrap),避免单行溢出
  • 每个 label 显示当前语言的 display_namefallback 顺序:当前语言 → en → slug
  • 点击某个 label 高亮选中,搜索请求追加 label 参数;再次点击取消选中
  • Label 之间单选互斥:点击另一个 label 切换选中,不支持组合筛选
  • 选中状态通过 URL query parameter 同步,支持分享链接

7.2 Skill 详情页

  • 在 skill 信息区域以 chip/badge 形式展示该 skill 的所有 label
  • 特权标签使用不同的视觉样式区分(不同颜色或图标)
  • 点击 chip 导航到搜索页并带上 label=<slug>(与 §7.1 同一筛选语义,便于从详情发现同标签技能);默认清空关键词、sort=newestpage=0
  • 若该 slug 不在搜索页可见筛选列表中(例如 visible_in_filter=false 的 PRIVILEGEDURL 仍可携带 label 并生效,但筛选条上可能没有对应高亮按钮
  • 有权限的用户owner / 命名空间管理员 / 超级管理员)看到编辑入口
  • 编辑交互:弹出面板;超级管理员可从全部 label definition 中勾选/取消勾选owner / 命名空间管理员仅可操作搜索页可见的 RECOMMENDED 标签
  • 特权标签区域仅超级管理员可见和可操作

补充说明:

  • 这不是仅靠新增独立 label API 就能完成的能力skill detail DTO / OpenAPI / 前端类型 / 详情页查询链路都需要增加 labels 字段
  • 建议 skill 详情首屏直接返回 labels避免详情页再额外发起一次 label 查询导致展示和权限状态碎片化
  • 一期仅要求 SkillDetailResponse 增加 labels: List<SkillLabelDto>SkillSummaryResponse 暂不增加 labels保持搜索结果与列表卡片改动最小
  • SkillLabelDto 字段固定为 slugtypedisplayName

7.3 管理后台

  • 标签管理页面:列表展示所有标签定义,支持拖拽排序
  • 创建/编辑标签:表单包含 slug创建时填写不可修改、type 选择、visible_in_filter 开关,以及动态翻译条目(可添加任意语言的翻译)
  • 删除标签需二次确认,提示会影响已关联的 skill

8. Testing

一期至少补充以下测试:

  • PostgresSearchRebuildService:验证原有 keywords 来源与 label translations 的组合结果
  • PostgresSearchRebuildService:验证 label 删除/翻译修改后,旧 label 词不会残留
  • PostgresFullTextQueryService:验证 labelSlugs 过滤 SQL 生效,且 count 查询同步生效
  • SkillSearchController / SkillSearchAppService:验证 label 参数透传
  • promotion 相关测试:验证 source skill 与 target skill 的 labels 独立,不发生隐式复制

9. Audit

以下动作需记录到 audit_log,供后台追踪:

  • LABEL_CREATE
  • LABEL_UPDATE
  • LABEL_DELETE
  • LABEL_SORT_ORDER_UPDATE
  • SKILL_LABEL_ATTACH
  • SKILL_LABEL_DETACH