skillhub/docs/superpowers/specs/2026-03-12-phase2-namespace-skill-core-design.md

46 KiB
Raw Permalink Blame History

Phase 2: 命名空间 + Skill 核心链路 设计文档

Goal: 在 Phase 1 工程骨架和认证体系基础上,实现命名空间管理、对象存储、技能发布/查询/下载完整链路、标签管理、PostgreSQL 全文搜索、异步事件基础设施和应用层精细限流。

前置条件: Phase 1 全部 3 个 Chunk 完成(后端骨架 + 认证授权 + 前端骨架)

重要修订:身份主键约束 用户身份主键全链路统一使用 string。本文中涉及 user_id、owner_id、created_by、updated_by、submitted_by、reviewed_by 等用户关联字段时,均应按字符串类型实现,任何整型用户主键描述都不再有效。

关键设计决策

决策 选择 理由
发布流程 直接到 PUBLISHED,跳过审核 Phase 3 再加审核拦截,Phase 2 先跑通完整链路
对象存储 LocalFile + S3 双实现,Profile 可配置 本地开发零依赖,集成测试/生产用 MinIO/S3
架构模式 领域服务集中式(方案 A) 与 Phase 1 一致,domain 模块包含领域服务 + 应用服务
Chunk 策略 后端先行(Chunk 1 后端,Chunk 2 前端) API 稳定后再做前端,减少联调返工
前端风格 现代产品风(Vercel/Linear 风格) 使用 frontend-design 技能优化设计质量
CLI publish 接口 Phase 2 去掉 auto_submit 参数,直接返回 PUBLISHED 与 05-business-flows.md 有意偏差,Phase 3 回补 auto_submit 并调整默认 status
Web publish 接口 POST /api/v1/skills/{namespace}/publish 与 CLI 分开路径但共用 service,06-api-design.md 未定义此路径,Phase 2 新增

Tech Stack(沿用 Phase 1 + 新增)

  • 沿用: Spring Boot 3.x + JDK 21 + PostgreSQL 16 + Redis 7 + Spring Security + Spring Data JPA + Flyway
  • 新增后端: AWS SDK for Java v2 (S3 Client) + SnakeYAML (frontmatter 解析)
  • 沿用前端: React 19 + TypeScript + Vite + TanStack Router + TanStack Query + shadcn/ui + Tailwind CSS
  • 新增前端: react-markdown + rehype-highlight (Markdown 渲染) + react-dropzone (文件上传)

1. 数据库迁移(V2__phase2_skill_tables.sql)

Phase 1 已有表:user_account, identity_binding, api_token, role, permission, role_permission, user_role_binding, namespace, namespace_member, audit_log

1.1 新增表

skill

字段 类型 说明
id BIGSERIAL PK
namespace_id BIGINT NOT NULL FK → namespace 所属命名空间
slug VARCHAR(128) NOT NULL URL 友好标识,来自 SKILL.md name
display_name VARCHAR(256)
summary VARCHAR(512)
owner_id VARCHAR(128) NOT NULL FK → user_account 主要维护人
source_skill_id BIGINT 派生来源(团队提升到全局时记录)
visibility VARCHAR(32) NOT NULL DEFAULT 'PUBLIC' PUBLIC / NAMESPACE_ONLY / PRIVATE
status VARCHAR(32) NOT NULL DEFAULT 'ACTIVE' ACTIVE / HIDDEN / ARCHIVED
latest_version_id BIGINT 最新已发布版本
download_count BIGINT NOT NULL DEFAULT 0
star_count INT NOT NULL DEFAULT 0
rating_avg DECIMAL(3,2) NOT NULL DEFAULT 0.00
rating_count INT NOT NULL DEFAULT 0
created_by VARCHAR(128) FK → user_account
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
updated_by VARCHAR(128) FK → user_account
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP

索引:

  • UNIQUE(namespace_id, slug)
  • (namespace_id, status) — 命名空间内技能列表

skill_version

字段 类型 说明
id BIGSERIAL PK
skill_id BIGINT NOT NULL FK → skill
version VARCHAR(64) NOT NULL semver 版本号
status VARCHAR(32) NOT NULL DEFAULT 'DRAFT' DRAFT / PENDING_REVIEW / PUBLISHED / REJECTED
changelog TEXT 变更说明
parsed_metadata_json JSONB SKILL.md frontmatter 完整解析
manifest_json JSONB 文件清单摘要
file_count INT NOT NULL DEFAULT 0
total_size BIGINT NOT NULL DEFAULT 0 总字节数
published_at TIMESTAMP 发布时间
created_by VARCHAR(128) FK → user_account
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP

索引:

  • UNIQUE(skill_id, version)
  • (skill_id, status) — 版本列表

skill_file

字段 类型 说明
id BIGSERIAL PK
version_id BIGINT NOT NULL FK → skill_version
file_path VARCHAR(512) NOT NULL 包内相对路径
file_size BIGINT NOT NULL 字节数
content_type VARCHAR(128) MIME 类型
sha256 VARCHAR(64) NOT NULL 文件哈希
storage_key VARCHAR(512) NOT NULL 对象存储 key
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP

索引:

  • UNIQUE(version_id, file_path)

skill_tag

字段 类型 说明
id BIGSERIAL PK
skill_id BIGINT NOT NULL FK → skill
tag_name VARCHAR(64) NOT NULL 标签名
version_id BIGINT NOT NULL FK → skill_version 指向的版本
created_by VARCHAR(128) FK → user_account
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP

索引:

  • UNIQUE(skill_id, tag_name)

skill_search_document

字段 类型 说明
id BIGSERIAL PK
skill_id BIGINT NOT NULL UNIQUE FK → skill 一 skill 一条
namespace_id BIGINT NOT NULL 用于空间过滤
namespace_slug VARCHAR(64) NOT NULL 冗余,搜索结果直接返回无需 join
owner_id VARCHAR(128) NOT NULL 用于 PRIVATE 可见性判定
title VARCHAR(256)
summary VARCHAR(512)
keywords VARCHAR(512)
search_text TEXT SKILL.md 正文 + frontmatter 拼接
visibility VARCHAR(32) NOT NULL 冗余,避免搜索时 join
status VARCHAR(32) NOT NULL
search_vector TSVECTOR GENERATED ALWAYS AS (...) STORED 全文搜索向量
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP

索引:

  • UNIQUE(skill_id)
  • GIN 索引 on search_vector
  • (namespace_id) — 空间过滤
  • (visibility) — 可见性过滤

1.2 search_vector 生成列定义

ALTER TABLE skill_search_document
ADD COLUMN search_vector tsvector
GENERATED ALWAYS AS (
    setweight(to_tsvector('simple', coalesce(title, '')), 'A') ||
    setweight(to_tsvector('simple', coalesce(summary, '')), 'B') ||
    setweight(to_tsvector('simple', coalesce(keywords, '')), 'B') ||
    setweight(to_tsvector('simple', coalesce(search_text, '')), 'C')
) STORED;

CREATE INDEX idx_search_vector ON skill_search_document USING GIN (search_vector);

权重分配:title(A 最高) > summary+keywords(B) > search_text(C)。使用 simple 分词配置,零依赖,对中文支持有限但满足一期需求。


2. 对象存储模块(skillhub-storage)

2.1 SPI 接口

package com.iflytek.skillhub.storage;

public interface ObjectStorageService {
    void putObject(String key, InputStream data, long size, String contentType);
    InputStream getObject(String key);
    void deleteObject(String key);
    void deleteObjects(List<String> keys);
    boolean exists(String key);
    ObjectMetadata getMetadata(String key);
}

public record ObjectMetadata(long size, String contentType, Instant lastModified) {}

2.2 双实现

实现类 激活条件 存储位置
LocalFileStorageService skillhub.storage.provider=local 本地文件系统 ${skillhub.storage.local.base-path}
S3StorageService skillhub.storage.provider=s3 MinIO / AWS S3

通过 @ConditionalOnProperty(name = "skillhub.storage.provider") 切换。

2.3 LocalFileStorageService 实现要点

  • 基于 java.nio.file.Path 操作
  • key 映射为文件路径:basePath/key(key 中的 / 映射为目录层级)
  • putObject:创建父目录 + 写入文件(原子写入:先写 .tmp 再 rename)
  • deleteObjects:逐个删除 + 清理空目录
  • 线程安全:文件操作本身是原子的,无需额外锁

2.4 S3StorageService 实现要点

  • 使用 AWS SDK for Java v2 的 S3Client(同步客户端,一期同步发布足够)
  • 配置类 S3StorageProperties:endpoint, bucket, accessKey, secretKey, region
  • putObject:PutObjectRequest + RequestBody.fromInputStream()
  • getObject:GetObjectRequest → ResponseInputStream
  • bucket 不存在时自动创建(启动时检查)

2.5 配置示例

# application.yml(默认 local)
skillhub:
  storage:
    provider: local
    local:
      base-path: ./data/storage

# application-s3.yml
skillhub:
  storage:
    provider: s3
    s3:
      endpoint: http://localhost:9000
      bucket: skillhub
      access-key: minioadmin
      secret-key: minioadmin
      region: us-east-1

2.6 对象 Key 规则

  • 文件:skills/{skillId}/{versionId}/{filePath}
  • 打包 zip:packages/{skillId}/{versionId}/bundle.zip
  • 使用不可变 ID(skillId/versionId),避免 slug 变更导致 key 失效

3. 命名空间管理

3.1 领域服务

Phase 1 已创建 Namespace 和 NamespaceMember 实体及 JPA Repository。Phase 2 新增领域服务。

注意:Phase 1 实体补齐 — 现有 Namespace.java 缺少 type(NamespaceType 枚举:GLOBAL/TEAM)和 avatar_url 字段,NamespaceMember.java 缺少 updatedAt 字段,但 V1 数据库表已包含这些列。Phase 2 实现时需先补齐这些字段,确保 JPA 实体与数据库 schema 完全对齐。新增 NamespaceType 枚举。

NamespaceService(skillhub-domain)

createNamespace(slug, displayName, description, creatorUserId) → Namespace
  - slug 格式校验 + 保留词校验 + 唯一性校验
  - type 固定为 TEAM(用户不可创建 GLOBAL 类型,GLOBAL 由 Flyway 预置)
  - 创建 namespace 记录
  - 创建者自动成为 OWNER(插入 namespace_member)

updateNamespace(namespaceId, displayName, description, avatarUrl) → Namespace

getNamespaceBySlug(slug) → Namespace

listPublicNamespaces(page, size) → Page<Namespace>
  - 只返回 ACTIVE 状态的命名空间

changeNamespaceStatus(namespaceId, newStatus)
  - ACTIVE ↔ FROZEN ↔ ARCHIVED 状态流转
  - FROZEN:只读不可发布新版本
  - ARCHIVED:对外不可见

NamespaceMemberService(skillhub-domain)

addMember(namespaceId, userId, role) → NamespaceMember
  - 唯一约束:一个用户在一个空间只有一个角色
  - 不可直接添加为 OWNER

removeMember(namespaceId, userId)
  - OWNER 不可被移除

updateMemberRole(namespaceId, userId, newRole)
  - 不可通过此方法设置 OWNER(需用 transferOwnership)

transferOwnership(namespaceId, currentOwnerId, newOwnerId)
  - 原 OWNER → ADMIN
  - 目标用户 → OWNER
  - 事务内完成

listMembers(namespaceId, page, size) → Page<NamespaceMember>

getMemberRole(namespaceId, userId) → Optional<NamespaceRole>

Repository 补充 — Phase 1 的 NamespaceRepository 需新增 Page<Namespace> findByStatus(NamespaceStatus status, Pageable pageable) 方法。NamespaceMemberRepository 需新增 Page<NamespaceMember> findByNamespaceId(Long namespaceId, Pageable pageable) 和 void deleteByNamespaceIdAndUserId(Long namespaceId, String userId) 方法。

3.2 Slug 校验规则

public class SlugValidator {
    private static final Pattern SLUG_PATTERN =
        Pattern.compile("^[a-z0-9]([a-z0-9-]*[a-z0-9])?$");
    private static final Set<String> RESERVED_SLUGS = Set.of(
        "admin", "api", "dashboard", "search", "auth",
        "me", "global", "system", "static", "assets", "health"
    );

    public static void validate(String slug) {
        // 长度 2-64
        // 匹配 SLUG_PATTERN
        // 不含连续双连字符 --
        // 不在保留词列表中
    }
}

3.3 Controller 层

方法 路径 权限
GET /api/v1/namespaces 公开
GET /api/v1/namespaces/{slug} 公开
POST /api/v1/namespaces 已登录
PUT /api/v1/namespaces/{slug} namespace ADMIN+
POST /api/v1/namespaces/{slug}/members namespace ADMIN+
DELETE /api/v1/namespaces/{slug}/members/{userId} namespace ADMIN+
PUT /api/v1/namespaces/{slug}/members/{userId}/role namespace OWNER
GET /api/v1/namespaces/{slug}/members namespace MEMBER+

权限判定复用 Phase 1 的 RbacService,新增 namespace 级别检查方法。

SecurityConfig 更新 — Phase 1 的 SecurityConfig 中 .requestMatchers("/api/v1/skills/**", "/api/v1/namespaces/**").permitAll() 放行了所有 skills/namespaces 路径。Phase 2 新增了需要认证的写操作(publish、tag 管理等),需要细化安全配置:GET 请求 permitAll,POST/PUT/DELETE 请求 authenticated。具体做法:按 HTTP method + path 组合配置,或在 Controller 层通过 @PreAuthorize 注解做权限校验(推荐后者,更灵活)。


4. 技能发布核心链路

4.1 领域实体(domain.skill 包)

新增实体:

实体 说明
Skill 技能主表,含 namespace 关联、可见性、统计计数
SkillVersion 版本记录,含 status 状态机、元数据 JSON
SkillFile 文件清单,含 storage_key 和 sha256
SkillTag 自定义标签,指向某个 version

新增枚举:

枚举 值
SkillVersionStatus DRAFT, PENDING_REVIEW, PUBLISHED, REJECTED(Phase 2 只用 DRAFT/PUBLISHED)
SkillVisibility PUBLIC, NAMESPACE_ONLY, PRIVATE
SkillStatus ACTIVE, HIDDEN, ARCHIVED

新增 Repository 接口(domain.skill 包):

public interface SkillRepository {
    Optional<Skill> findById(Long id);
    Optional<Skill> findByNamespaceIdAndSlug(Long namespaceId, String slug);
    Page<Skill> findByNamespaceIdAndStatus(Long namespaceId, SkillStatus status, Pageable pageable);
    Skill save(Skill skill);
    List<Skill> findByOwnerId(Long ownerId);
    void incrementDownloadCount(Long skillId);  // UPDATE skill SET download_count = download_count + 1 WHERE id = ?
}

public interface SkillVersionRepository {
    Optional<SkillVersion> findById(Long id);
    Optional<SkillVersion> findBySkillIdAndVersion(Long skillId, String version);
    Page<SkillVersion> findBySkillIdAndStatus(Long skillId, SkillVersionStatus status, Pageable pageable);
    SkillVersion save(SkillVersion version);
}

public interface SkillFileRepository {
    List<SkillFile> findByVersionId(Long versionId);
    SkillFile save(SkillFile file);
    void saveAll(List<SkillFile> files);
    void deleteByVersionId(Long versionId);
}

public interface SkillTagRepository {
    Optional<SkillTag> findBySkillIdAndTagName(Long skillId, String tagName);
    List<SkillTag> findBySkillId(Long skillId);
    SkillTag save(SkillTag tag);
    void delete(SkillTag tag);
}

4.2 技能包校验器(domain.skill.validation 包)

SkillPackageValidator

负责解压 zip 后的完整校验:

validate(List<PackageEntry> entries) → ValidationResult

校验项:
1. SKILL.md 存在性 — 根目录必须包含 SKILL.md
2. frontmatter 格式 — name 和 description 必需
3. name 字段格式 — 符合 slug 校验规则
4. version 字段 — 必需,semver 格式
5. 文件类型白名单 — .md/.txt/.json/.yaml/.yml/.js/.ts/.py/.sh/.png/.jpg/.svg
6. 单文件大小 ≤ 1MB(可配置)
7. 总包大小 ≤ 10MB(可配置)
8. 文件数量 ≤ 100(可配置)
9. 版本号不与已有版本冲突

配置化限制:

skillhub:
  publish:
    max-file-size: 1MB
    max-package-size: 10MB
    max-file-count: 100
    allowed-extensions:
      - .md
      - .txt
      - .json
      - .yaml
      - .yml
      - .js
      - .ts
      - .py
      - .sh
      - .png
      - .jpg
      - .svg

PrePublishValidator 扩展点

public interface PrePublishValidator {
    ValidationResult validate(SkillPackageContext context);
}

public record SkillPackageContext(
    List<PackageEntry> entries,
    SkillMetadata metadata,
    String publisherId,
    Long namespaceId
) {}

public record ValidationResult(boolean passed, List<String> errors) {
    public static ValidationResult pass() { return new ValidationResult(true, List.of()); }
    public static ValidationResult fail(List<String> errors) { return new ValidationResult(false, errors); }
}

// Phase 2 默认实现
@Component
@ConditionalOnMissingBean(PrePublishValidator.class)
public class NoOpPrePublishValidator implements PrePublishValidator {
    public ValidationResult validate(SkillPackageContext context) {
        return ValidationResult.pass();
    }
}

4.3 SKILL.md 解析器(domain.skill.metadata 包)

public class SkillMetadataParser {
    /**
     * 解析 SKILL.md 内容,提取 frontmatter 和正文
     */
    public SkillMetadata parse(String skillMdContent) { ... }
}

public record SkillMetadata(
    String name,           // → skill.slug(首次发布后不可变更)
    String description,    // → skill.summary
    String version,        // semver 版本号
    String body,           // Markdown 正文
    Map<String, Object> frontmatter  // 完整 frontmatter → parsed_metadata_json
) {}

解析规则:

  • 使用 SnakeYAML 解析 --- 之间的 frontmatter
  • name 和 description 为必需字段
  • version 为必需字段,semver 格式校验
  • x-astron-* 前缀的扩展字段保留在 frontmatter map 中
  • 正文部分(frontmatter 之后的内容)作为 body 返回

4.4 发布服务(domain.skill.service 包)

SkillPublishService

publishSkill(namespaceSlug, zipInputStream, publisherId, visibility) → SkillVersion

完整流程:
① 解析 namespace — 通过 slug 查找,校验 ACTIVE 状态
② 权限校验 — 用户是该 namespace 的 MEMBER 以上
③ 解压 zip — 内存中解压为 List<PackageEntry>
④ 技能包校验 — SkillPackageValidator.validate()
⑤ PrePublishValidator 链 — 扩展点校验
⑥ 解析 SKILL.md — SkillMetadataParser.parse()
⑦ 创建/关联 skill 记录
   - 首次发布:创建 skill(slug = metadata.name)
   - 后续发布:校验 slug 一致性(不可变更)
⑧ 写入对象存储
   - 逐文件上传到 skills/{skillId}/{versionId}/{filePath}
   - 计算每个文件的 SHA-256
   - 生成 bundle.zip:在服务端内存中重新打包(仅包含通过校验的文件),而非直接使用用户上传的原始 zip
   - bundle.zip 写入 packages/{skillId}/{versionId}/bundle.zip
⑨ 持久化
   - 创建 skill_version(status=PUBLISHED)
   - 批量创建 skill_file 记录
   - 更新 skill.latest_version_id
   - 更新 skill.display_name 和 skill.summary(取最新版本)
⑩ 发布事件
   - 发布 SkillPublishedEvent(触发搜索索引更新)

PackageEntry 模型

public record PackageEntry(
    String path,          // 包内相对路径
    byte[] content,       // 文件内容
    long size,            // 字节数
    String contentType    // MIME 类型
) {}

4.5 Controller 层

CLI 发布接口

POST /api/v1/cli/publish
Content-Type: multipart/form-data
Parts:
  - file: zip 包(必需)
  - namespace: 目标命名空间 slug(必需)
  - visibility: PUBLIC / NAMESPACE_ONLY / PRIVATE(可选,默认 PUBLIC)

Response 200:
{
  "code": 0,
  "data": {
    "skillId": 1,
    "namespace": "global",
    "slug": "my-skill",
    "version": "1.0.0",
    "status": "PUBLISHED",
    "fileCount": 5,
    "totalSize": 12345
  }
}

Web 端发布接口

POST /api/v1/skills/{namespace}/publish
Content-Type: multipart/form-data
Parts:
  - file: zip 包(必需)
  - visibility: PUBLIC / NAMESPACE_ONLY / PRIVATE(可选,默认 PUBLIC)

Response: 同 CLI 发布接口

Web 端和 CLI 共用 SkillPublishService,Controller 层分开。


5. 技能查询 + 下载 + 标签管理

5.1 技能查询服务(domain.skill.service.SkillQueryService)

getSkillDetail(namespaceSlug, skillSlug, currentUser) → SkillDetailDTO
  - 查找 skill + latest_version 信息
  - 可见性检查
  - 返回:skill 基本信息 + latest version 元数据 + 统计数据

listSkillsByNamespace(namespaceSlug, status, page, size, currentUser) → Page<SkillSummaryDTO>
  - 可见性过滤
  - 默认只返回 ACTIVE 状态

getVersionDetail(namespaceSlug, skillSlug, version) → SkillVersionDetailDTO
  - 返回:版本元数据 + parsed_metadata_json + manifest_json

listVersions(namespaceSlug, skillSlug, page, size) → Page<SkillVersionSummaryDTO>
  - 只返回 PUBLISHED 版本(Phase 2)

listFiles(namespaceSlug, skillSlug, version) → List<SkillFileDTO>
  - 返回文件清单(路径、大小、contentType、sha256)

getFileContent(namespaceSlug, skillSlug, version, filePath) → FileContentDTO
  - 从对象存储读取文件内容
  - 文本文件返回内容字符串,二进制文件返回 base64 或下载链接

5.2 可见性检查器(domain.skill.visibility.VisibilityChecker)

public class VisibilityChecker {
    /**
     * 检查当前用户是否有权访问指定 skill
     * @param skill 目标技能
     * @param currentUser 当前用户(null 表示匿名)
     * @param userNamespaceRoles 用户在各 namespace 的角色(预加载)
     */
    public boolean canAccess(Skill skill, String currentUserId,
                             Map<Long, NamespaceRole> userNamespaceRoles) {
        return switch (skill.getVisibility()) {
            case PUBLIC -> true;
            case NAMESPACE_ONLY -> userNamespaceRoles.containsKey(skill.getNamespaceId());
            case PRIVATE -> skill.getOwnerId().equals(currentUserId)
                || isAdminOrAbove(userNamespaceRoles.get(skill.getNamespaceId()));
        };
    }
}

查询服务和搜索服务共用此检查器。

5.3 下载服务(domain.skill.service.SkillDownloadService)

downloadLatest(namespaceSlug, skillSlug, currentUser) → DownloadResult
  - 可见性检查
  - 读取 skill.latest_version_id 对应的 bundle.zip
  - 发布 SkillDownloadedEvent(异步更新下载计数)

downloadVersion(namespaceSlug, skillSlug, version, currentUser) → DownloadResult
  - 可见性检查
  - 读取指定版本的 bundle.zip

downloadByTag(namespaceSlug, skillSlug, tagName, currentUser) → DownloadResult
  - 解析 tag → version_id
  - 委托给 downloadVersion
public record DownloadResult(
    InputStream content,
    String filename,       // {slug}-{version}.zip
    long contentLength,
    String contentType     // application/zip
) {}

5.4 标签管理服务(domain.skill.service.SkillTagService)

listTags(namespaceSlug, skillSlug) → List<SkillTagDTO>
  - 返回所有自定义标签 + 虚拟 latest 标签(指向 skill.latest_version_id)

createOrMoveTag(namespaceSlug, skillSlug, tagName, targetVersion, operatorId) → SkillTag
  - tagName 不可为 "latest"(系统保留)
  - targetVersion 必须是 PUBLISHED 状态
  - 标签已存在则移动(更新 version_id),不存在则创建
  - 权限:skill owner 或 namespace ADMIN+

deleteTag(namespaceSlug, skillSlug, tagName, operatorId)
  - tagName 不可为 "latest"
  - 权限:skill owner 或 namespace ADMIN+

5.5 Controller 层

Public API(controller.portal.SkillController)

方法 路径 说明
GET /api/v1/skills/{namespace}/{slug} 技能详情
GET /api/v1/skills/{namespace}/{slug}/versions 版本列表
GET /api/v1/skills/{namespace}/{slug}/versions/{version} 版本详情
GET /api/v1/skills/{namespace}/{slug}/versions/{version}/files 文件清单
GET /api/v1/skills/{namespace}/{slug}/versions/{version}/file?path=... 读取单个文件
GET /api/v1/skills/{namespace}/{slug}/download 下载最新版本
GET /api/v1/skills/{namespace}/{slug}/versions/{version}/download 下载指定版本
GET /api/v1/skills/{namespace}/{slug}/tags 标签列表
GET /api/v1/skills/{namespace}/{slug}/tags/{tagName}/download 按标签下载
GET /api/v1/skills/{namespace}/{slug}/tags/{tagName}/files 按标签查看文件
GET /api/v1/skills/{namespace}/{slug}/tags/{tagName}/file?path=... 按标签读取文件
GET /api/v1/skills/{namespace}/{slug}/resolve 解析版本(query: version/tag/hash)

Authenticated API(controller.portal.SkillManageController)

方法 路径 权限
PUT /api/v1/skills/{namespace}/{slug}/tags/{tagName} owner / ADMIN+
DELETE /api/v1/skills/{namespace}/{slug}/tags/{tagName} owner / ADMIN+
GET /api/v1/me/skills 已登录

6. 搜索实现(PostgreSQL Full-Text)

PostgresFullTextIndexService

@Service
@ConditionalOnProperty(name = "skillhub.search.provider", havingValue = "postgres", matchIfMissing = true)
public class PostgresFullTextIndexService implements SearchIndexService {

    // upsert by skill_id
    // INSERT INTO skill_search_document (...) VALUES (...)
    // ON CONFLICT (skill_id) DO UPDATE SET title=..., summary=..., ...
    void index(SkillSearchDocument doc);

    // 批量 upsert,使用 batch insert
    void batchIndex(List<SkillSearchDocument> docs);

    // DELETE FROM skill_search_document WHERE skill_id = ?
    void remove(Long skillId);
}

PostgresFullTextQueryService

@Service
@ConditionalOnProperty(name = "skillhub.search.provider", havingValue = "postgres", matchIfMissing = true)
public class PostgresFullTextQueryService implements SearchQueryService {

    SearchResult search(SearchQuery query) {
        // 1. 构建 tsquery
        //    - 关键词按空格分词,每个词用 & 连接
        //    - 使用 plainto_tsquery('simple', ?) 简化处理
        //
        // 2. 构建 WHERE 条件
        //    - search_vector @@ tsquery(关键词匹配)
        //    - status = 'ACTIVE'(只搜索活跃技能)
        //    - 可见性条件(由 SearchVisibilityScope 转换):
        //      WHERE (visibility = 'PUBLIC')
        //         OR (visibility = 'NAMESPACE_ONLY' AND namespace_id IN (:memberNsIds))
        //         OR (visibility = 'PRIVATE' AND (namespace_id IN (:adminNsIds) OR owner_id = :userId))
        //    - 可选 namespace_id 过滤
        //
        // 3. 排序
        //    - RELEVANCE: ts_rank_cd(search_vector, tsquery) DESC
        //    - DOWNLOADS: download_count DESC
        //    - RATING: rating_avg DESC
        //    - NEWEST: updated_at DESC
        //
        // 4. 分页
        //    - LIMIT :size OFFSET :page * :size
        //    - COUNT(*) 获取总数
        //
        // 5. 空关键词时退化为列表查询(跳过 tsquery 条件)
    }
}

PostgresSearchRebuildService

@Service
public class PostgresSearchRebuildService implements SearchRebuildService {

    void rebuildAll() {
        // 获取 Redis 分布式锁 search:rebuild:all(TTL 10min)
        // 分批加载所有 ACTIVE skill(batch 100)
        // 对每个 skill 取 latest_version_id 对应版本内容
        // 批量 upsert 搜索文档
    }

    void rebuildByNamespace(Long namespaceId) {
        // 锁 key: search:rebuild:ns:{namespaceId}
    }

    void rebuildBySkill(Long skillId) {
        // 无需锁,单条 upsert
    }
}

6.2 搜索应用服务(skillhub-app 中的 service.SkillSearchAppService)

位于 skillhub-app 模块(非 domain),因为它依赖 SearchQueryService(定义在 skillhub-search),按依赖方向 domain 不应依赖 search:

searchSkills(keyword, namespaceSlug, sortBy, page, size, currentUser) → SearchResultDTO

内部流程:
1. 计算 SearchVisibilityScope(根据 currentUser 的 namespace 成员关系)
2. 构建 SearchQuery
3. 调用 SearchQueryService.search() → 返回匹配的 skillId 列表 + 总数
4. 批量查询 skill 表获取统计字段(downloadCount, starCount, ratingAvg, ratingCount, latestVersion)
5. 组装为 DTO 返回

搜索统计字段策略 — 搜索文档表只负责全文匹配和可见性过滤,不冗余统计字段。排序和统计数据通过 JOIN skill 表获取。具体实现:搜索查询使用子查询先匹配 + 过滤,外层 JOIN skill 表做排序和字段补充。这避免了计数器更新时同步刷新搜索文档的复杂度。latestVersion 通过 JOIN skill_version 表(skill.latest_version_id)获取。

6.3 搜索文档更新时机

触发场景 事件 处理
技能发布 SkillPublishedEvent 用最新版本内容 upsert 搜索文档
技能状态变更 SkillStatusChangedEvent 更新搜索文档 status
技能归档 SkillStatusChangedEvent(ARCHIVED) 删除搜索文档

6.4 搜索 Controller

GET /api/v1/skills?q=keyword&namespace=slug&sort=relevance&page=0&size=20

q 参数为空时退化为列表查询(按 sort 字段排序,返回所有可见技能)。

首页精选/热门/最新列表复用搜索接口:
- 精选:GET /api/v1/skills?sort=relevance&size=6(无 q 参数,按综合排序)
- 热门下载:GET /api/v1/skills?sort=downloads&size=6
- 最新发布:GET /api/v1/skills?sort=newest&size=6

Response:
{
  "code": 0,
  "data": {
    "items": [
      {
        "namespace": "global",
        "slug": "code-review",
        "displayName": "Code Review",
        "summary": "...",
        "downloadCount": 1234,
        "starCount": 56,
        "ratingAvg": 4.5,
        "ratingCount": 10,
        "latestVersion": "1.2.0",
        "updatedAt": "2026-03-12T10:00:00Z"
      }
    ],
    "total": 42,
    "page": 0,
    "size": 20
  }
}

7. 异步事件基础设施

7.1 事件定义(domain.event 包)

public record SkillPublishedEvent(Long skillId, Long versionId, String publisherId) {}
public record SkillDownloadedEvent(Long skillId, Long versionId) {}
public record SkillStatusChangedEvent(Long skillId, SkillStatus oldStatus, SkillStatus newStatus) {}

7.2 事件发布

业务服务通过 Spring ApplicationEventPublisher 发布:

@Service
public class SkillPublishService {
    private final ApplicationEventPublisher eventPublisher;

    public SkillVersion publishSkill(...) {
        // ... 业务逻辑 ...
        eventPublisher.publishEvent(new SkillPublishedEvent(skill.getId(), version.getId(), publisherId));
        return version;
    }
}

7.3 事件监听器

SearchIndexEventListener

@Component
public class SearchIndexEventListener {

    @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)
    @Async("skillhubEventExecutor")
    public void onSkillPublished(SkillPublishedEvent event) {
        // 加载 skill + latest version 内容
        // 构建 SkillSearchDocument
        // 调用 SearchIndexService.index()
    }

    @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)
    @Async("skillhubEventExecutor")
    public void onSkillStatusChanged(SkillStatusChangedEvent event) {
        if (event.newStatus() == SkillStatus.ARCHIVED) {
            searchIndexService.remove(event.skillId());
        } else {
            // 更新搜索文档 status
            searchRebuildService.rebuildBySkill(event.skillId());
        }
    }
}

DownloadCountEventListener

@Component
public class DownloadCountEventListener {

    @EventListener
    @Async("skillhubEventExecutor")
    public void onSkillDownloaded(SkillDownloadedEvent event) {
        // UPDATE skill SET download_count = download_count + 1 WHERE id = ?
        // 原子 SQL,无需乐观锁
        skillRepository.incrementDownloadCount(event.skillId());
    }
}

注意:DownloadCountEventListener 使用 @EventListener 而非 @TransactionalEventListener,因为下载操作本身不在事务中。

7.4 异步线程池配置

@Configuration
@EnableAsync
public class AsyncConfig {

    @Bean("skillhubEventExecutor")
    public TaskExecutor skillhubEventExecutor() {
        ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
        executor.setCorePoolSize(2);
        executor.setMaxPoolSize(4);
        executor.setQueueCapacity(100);
        executor.setThreadNamePrefix("event-");
        executor.setRejectedExecutionHandler(new ThreadPoolExecutor.CallerRunsPolicy());
        executor.setWaitForTasksToCompleteOnShutdown(true);
        executor.setAwaitTerminationSeconds(25);  // 配合 30s shutdown timeout 优雅停机
        executor.initialize();
        return executor;
    }
}
  • CallerRunsPolicy:队列满时由调用线程执行,保证事件不丢失
  • 单体应用规模,core=2 max=4 足够

8. 应用层精细限流

8.1 自定义注解

@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
public @interface RateLimit {
    String category();
    int authenticated() default 60;
    int anonymous() default 20;
    int windowSeconds() default 60;
}

8.2 Redis 滑动窗口实现

使用 ZSET + Lua 脚本实现原子操作:

Key: ratelimit:{category}:{userId 或 IP}
Score: 请求时间戳(毫秒)

Lua 脚本流程:

  1. ZREMRANGEBYSCORE 清理窗口外的过期记录
  2. ZCARD 获取当前窗口内请求数
  3. 如果未超限,ZADD 记录当前请求 + 设置 key TTL
  4. 返回剩余配额

8.3 拦截器

@Component
public class RateLimitInterceptor implements HandlerInterceptor {
    // 1. 从 HandlerMethod 获取 @RateLimit 注解
    // 2. 判断已登录/匿名,选择对应限额
    // 3. 构建 Redis key(已登录用 userId,匿名用 IP)
    // 4. 执行 Lua 脚本判定
    // 5. 超限:返回 429 + Retry-After header
    // 6. 未超限:放行 + 设置 X-RateLimit-Remaining header
}

8.4 限流配置

端点类别 已登录 匿名(按 IP) 窗口
search 60 次 20 次 60s
download 120 次 30 次 60s
publish 10 次 N/A 3600s
auth N/A 30 次 60s

8.5 容错

Redis 不可用时 fail-open(放行请求),记录 WARN 日志。不因限流组件故障阻塞业务。


9. 前端 Chunk 2

9.1 路由结构

/                                      → 首页
/search                                → 搜索页
/@{namespace}                          → 命名空间主页
/@{namespace}/{slug}                   → 技能详情页
/@{namespace}/{slug}/versions          → 版本历史页
/dashboard/skills                      → 我的技能
/dashboard/publish                     → 发布技能
/dashboard/namespaces                  → 我的命名空间
/dashboard/namespaces/{slug}/members   → 成员管理

门户区(/, /search, /@*)匿名可访问。 Dashboard 区需登录(复用 Phase 1 路由守卫)。

9.2 新增依赖

包 用途
react-markdown SKILL.md Markdown 渲染
rehype-highlight 代码块语法高亮
react-dropzone 拖拽上传
zustand 客户端状态(UI 偏好、过滤器)

9.3 共享组件(shared/)

组件 说明
SkillCard 技能卡片:名称、摘要、namespace 标签、下载量、版本号
SearchBar 搜索输入框,debounce 300ms
MarkdownRenderer react-markdown + rehype-highlight 渲染 SKILL.md
FileTree 文件树组件,展示技能包目录结构
CopyButton 一键复制安装命令
Pagination 分页组件
EmptyState 空状态占位图
SkeletonLoader 骨架屏加载态
NamespaceBadge 命名空间标签(@global 绿色,@team 蓝色)

9.4 页面设计

首页(/)

  • Hero 区域:居中大标题 + 副标题 + 搜索框
  • 三栏卡片区:精选 / 热门下载 / 最新发布(各 top 6)
  • 底部:命名空间快速入口卡片
  • 现代产品风:大留白、渐变背景、卡片悬浮阴影
  • 顶部搜索栏 + 过滤器行(命名空间下拉、排序选择器)
  • 网格布局 SkillCard 列表(响应式 1-3 列)
  • URL query params 驱动:?q=xxx&namespace=xxx&sort=relevance&page=1
  • 空结果状态 + 搜索建议
  • TanStack Query keepPreviousData: true 避免翻页闪烁

命名空间主页(/@{namespace})

  • 顶部:namespace 头像 + 名称 + 描述 + 成员数
  • 技能列表:该空间下的技能卡片网格
  • 分页 + 排序

技能详情页(/@{namespace}/{slug})

  • 左侧主区域(70%):
    • Tab 切换:README / 文件列表 / 版本历史
    • README tab:MarkdownRenderer 渲染 SKILL.md 正文
    • 文件列表 tab:FileTree + 文件内容预览
    • 版本历史 tab:版本列表 + changelog
  • 右侧信息栏(30%):
    • 版本号 + 发布时间
    • 下载量统计
    • 命名空间标签(NamespaceBadge)
    • 标签列表(latest + 自定义标签)
    • 安装命令(双格式:skillhub CLI + ClawHub CLI)+ CopyButton
    • 下载按钮
  • 匿名用户可浏览和下载 PUBLIC 技能

版本历史页(/@{namespace}/{slug}/versions)

  • 版本列表:版本号、发布时间、文件数、总大小
  • 每个版本可展开查看 changelog
  • 下载指定版本按钮

发布页(/dashboard/publish)

  • Step 1:选择命名空间(下拉,用户所属的 namespace 列表)
  • Step 2:拖拽上传区域(react-dropzone)+ 进度条
  • Step 3:上传后预览
    • SKILL.md Markdown 渲染
    • 文件树
    • 元数据摘要(name、version、description)
  • Step 4:选择可见性(PUBLIC / NAMESPACE_ONLY / PRIVATE)
  • Step 5:确认发布按钮
  • 发布成功后跳转到技能详情页

我的技能(/dashboard/skills)

  • 表格列表:技能名、命名空间、最新版本、状态、下载量、发布时间
  • 点击跳转到技能详情页
  • 空状态引导发布

我的命名空间(/dashboard/namespaces)

  • 卡片列表:namespace 名称、角色、成员数、技能数
  • 创建命名空间按钮 + 对话框表单
  • 点击进入成员管理

成员管理(/dashboard/namespaces/{slug}/members)

  • 成员表格:用户名、角色、加入时间
  • 添加成员按钮 + 对话框(用户搜索 + 角色选择)
  • 角色变更下拉
  • 移除成员按钮(带确认)
  • OWNER 转让操作(独立按钮,二次确认)

9.5 数据获取模式(TanStack Query)

每个 API 端点封装为自定义 hook:

Hook Query Key 说明
useSkillDetail(ns, slug) ['skills', ns, slug] 技能详情
useSkillVersions(ns, slug) ['skills', ns, slug, 'versions'] 版本列表
useSkillFiles(ns, slug, ver) ['skills', ns, slug, 'versions', ver, 'files'] 文件清单
useSearchSkills(params) ['skills', 'search', params] 搜索
useNamespaceDetail(slug) ['namespaces', slug] 命名空间详情
useNamespaceSkills(slug) ['namespaces', slug, 'skills'] 空间技能列表
useNamespaceMembers(slug) ['namespaces', slug, 'members'] 成员列表
useMySkills() ['me', 'skills'] 我的技能
useMyNamespaces() ['me', 'namespaces'] 我的命名空间
useFeaturedSkills() ['skills', 'featured'] 首页精选
usePopularSkills() ['skills', 'popular'] 首页热门
useRecentSkills() ['skills', 'recent'] 首页最新

Mutation hooks:

Hook 成功后 invalidate
usePublishSkill() ['me', 'skills'], ['namespaces', ns, 'skills']
useCreateNamespace() ['me', 'namespaces'], ['namespaces']
useAddMember() ['namespaces', slug, 'members']
useRemoveMember() ['namespaces', slug, 'members']
useCreateTag() ['skills', ns, slug, 'tags']
useDeleteTag() ['skills', ns, slug, 'tags']

9.6 前端文件结构(Phase 2 新增)

web/src/
├── pages/
│   ├── search.tsx
│   ├── namespace.tsx
│   ├── skill-detail.tsx
│   ├── skill-versions.tsx
│   ├── dashboard/
│   │   ├── skills.tsx
│   │   ├── publish.tsx
│   │   ├── namespaces.tsx
│   │   └── namespace-members.tsx
├── features/
│   ├── skill/
│   │   ├── skill-card.tsx
│   │   ├── skill-detail-view.tsx
│   │   ├── skill-version-list.tsx
│   │   ├── file-tree.tsx
│   │   ├── markdown-renderer.tsx
│   │   ├── install-command.tsx
│   │   ├── use-skill-detail.ts
│   │   ├── use-skill-versions.ts
│   │   ├── use-skill-files.ts
│   │   └── use-search-skills.ts
│   ├── publish/
│   │   ├── publish-form.tsx
│   │   ├── upload-zone.tsx
│   │   ├── publish-preview.tsx
│   │   └── use-publish-skill.ts
│   ├── namespace/
│   │   ├── namespace-card.tsx
│   │   ├── namespace-header.tsx
│   │   ├── member-table.tsx
│   │   ├── add-member-dialog.tsx
│   │   ├── create-namespace-dialog.tsx
│   │   ├── use-namespace-detail.ts
│   │   ├── use-namespace-members.ts
│   │   └── use-my-namespaces.ts
│   └── search/
│       ├── search-bar.tsx
│       ├── search-filters.tsx
│       ├── search-results.tsx
│       └── use-search.ts
├── shared/
│   ├── ui/                    # shadcn/ui 组件(已有)
│   ├── components/
│   │   ├── pagination.tsx
│   │   ├── empty-state.tsx
│   │   ├── skeleton-loader.tsx
│   │   ├── copy-button.tsx
│   │   └── namespace-badge.tsx
│   └── hooks/
│       └── use-debounce.ts
└── api/
    └── client.ts              # 已有,Phase 2 自动生成新类型

10. 测试策略

10.1 后端测试分层

层级 范围 工具 覆盖重点
单元测试 领域服务、校验器、解析器 JUnit 5 + Mockito SkillPackageValidator、SkillMetadataParser、VisibilityChecker、SlugValidator
集成测试 Repository + DB @DataJpaTest + Testcontainers PostgreSQL JPA 映射、唯一约束、全文搜索查询
集成测试 对象存储 @SpringBootTest + LocalFileStorage 上传/下载/删除流程
API 测试 Controller @WebMvcTest + MockMvc 请求/响应格式、权限校验、参数校验
端到端测试 发布全链路 @SpringBootTest + Testcontainers 上传 → 校验 → 存储 → 持久化 → 搜索

10.2 关键测试用例

SkillPackageValidator 测试

  • 有效技能包 → 通过
  • 缺少 SKILL.md → 失败
  • frontmatter 缺少 name → 失败
  • frontmatter 缺少 version → 失败
  • 非法文件类型(.exe) → 失败
  • 单文件超 1MB → 失败
  • 总包超 10MB → 失败
  • 文件数超 100 → 失败
  • 版本号非 semver → 失败
  • 版本号与已有冲突 → 失败

SkillMetadataParser 测试

  • 标准 frontmatter + body → 正确解析
  • 含 x-astron- 扩展字段 → 保留在 frontmatter map
  • 无 frontmatter → 失败
  • frontmatter 格式错误 → 失败

VisibilityChecker 测试

  • PUBLIC skill + 匿名用户 → 可访问
  • NAMESPACE_ONLY skill + 非成员 → 不可访问
  • NAMESPACE_ONLY skill + 成员 → 可访问
  • PRIVATE skill + owner → 可访问
  • PRIVATE skill + namespace ADMIN → 可访问
  • PRIVATE skill + 其他用户 → 不可访问

命名空间管理测试

  • 创建 namespace → 创建者成为 OWNER
  • slug 保留词 → 拒绝
  • slug 含 -- → 拒绝
  • 移除 OWNER → 拒绝
  • 转让 ownership → 原 OWNER 降为 ADMIN

搜索测试

  • 关键词匹配 title → 返回结果
  • 关键词匹配 summary → 返回结果
  • 匿名搜索 → 只返回 PUBLIC
  • namespace 过滤 → 只返回指定空间
  • 排序:RELEVANCE / DOWNLOADS / NEWEST → 正确排序

限流测试

  • 未超限 → 放行 + 返回 X-RateLimit-Remaining
  • 超限 → 429 + Retry-After
  • Redis 不可用 → fail-open 放行

10.3 前端测试

类型 工具 覆盖重点
组件测试 Vitest + React Testing Library SkillCard、SearchBar、FileTree、MarkdownRenderer
Hook 测试 renderHook useSearchSkills、useSkillDetail、usePublishSkill
页面测试 Vitest + MSW (Mock Service Worker) 搜索页交互、发布流程、路由守卫

11. Chunk 划分与验收标准

Chunk 1:后端全部

范围: 数据库迁移 + 对象存储 + 命名空间管理 + 技能发布/查询/下载 + 标签管理 + 搜索 + 异步事件 + 限流

验收标准:

  1. V2__phase2_skill_tables.sql 迁移成功,所有新表和索引创建
  2. Phase 1 实体补齐:Namespace.java 补 type/avatarUrl,NamespaceMember.java 补 updatedAt,新增 NamespaceType 枚举
  3. 对象存储 LocalFile 实现可用,S3 实现可用(Docker Compose MinIO)
  4. 命名空间 CRUD + 成员管理 API 全部可用
  5. CLI 发布接口:上传 zip → 校验 → 存储 → PUBLISHED,返回版本信息
  6. 技能详情、版本列表、文件清单、文件内容 API 可用
  7. 下载 API:latest / 指定版本 / 按标签,返回 zip
  8. 标签 CRUD API 可用,latest 标签不可操作
  9. 搜索 API:关键词搜索 + 可见性过滤 + 排序 + 分页
  10. 异步事件:发布后搜索索引自动更新,下载后计数自动递增
  11. 限流:超限返回 429,Redis 不可用时 fail-open
  12. 所有后端测试通过

Chunk 2:前端全部

范围: 首页 + 搜索页 + 命名空间主页 + 技能详情页 + 版本历史页 + 发布页 + 我的技能 + 我的命名空间 + 成员管理

前置: Chunk 1 后端 API 全部就绪

验收标准:

  1. 首页展示精选/热门/最新技能,搜索框可跳转搜索页
  2. 搜索页:关键词搜索 + 命名空间过滤 + 排序 + 分页,URL 驱动
  3. 命名空间主页:展示空间信息 + 技能列表
  4. 技能详情页:Markdown 渲染 + 文件树 + 版本历史 + 安装命令 + 下载
  5. 发布页:拖拽上传 + 预览 + 选择命名空间/可见性 + 确认发布
  6. 我的技能:列表展示 + 跳转详情
  7. 命名空间管理:创建 + 成员管理(添加/移除/角色变更/转让)
  8. 匿名用户可浏览/搜索/下载 PUBLIC 技能
  9. 现代产品风视觉设计(使用 frontend-design 技能优化)
  10. 前端测试通过