# 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 生成列定义 ```sql 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 接口 ```java 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 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 配置示例 ```yaml # 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 - 只返回 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 getMemberRole(namespaceId, userId) → Optional ``` > **Repository 补充** — Phase 1 的 `NamespaceRepository` 需新增 `Page findByStatus(NamespaceStatus status, Pageable pageable)` 方法。`NamespaceMemberRepository` 需新增 `Page findByNamespaceId(Long namespaceId, Pageable pageable)` 和 `void deleteByNamespaceIdAndUserId(Long namespaceId, String userId)` 方法。 ### 3.2 Slug 校验规则 ```java public class SlugValidator { private static final Pattern SLUG_PATTERN = Pattern.compile("^[a-z0-9]([a-z0-9-]*[a-z0-9])?$"); private static final Set 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` 包): ```java public interface SkillRepository { Optional findById(Long id); Optional findByNamespaceIdAndSlug(Long namespaceId, String slug); Page findByNamespaceIdAndStatus(Long namespaceId, SkillStatus status, Pageable pageable); Skill save(Skill skill); List findByOwnerId(Long ownerId); void incrementDownloadCount(Long skillId); // UPDATE skill SET download_count = download_count + 1 WHERE id = ? } public interface SkillVersionRepository { Optional findById(Long id); Optional findBySkillIdAndVersion(Long skillId, String version); Page findBySkillIdAndStatus(Long skillId, SkillVersionStatus status, Pageable pageable); SkillVersion save(SkillVersion version); } public interface SkillFileRepository { List findByVersionId(Long versionId); SkillFile save(SkillFile file); void saveAll(List files); void deleteByVersionId(Long versionId); } public interface SkillTagRepository { Optional findBySkillIdAndTagName(Long skillId, String tagName); List findBySkillId(Long skillId); SkillTag save(SkillTag tag); void delete(SkillTag tag); } ``` ### 4.2 技能包校验器(`domain.skill.validation` 包) #### SkillPackageValidator 负责解压 zip 后的完整校验: ``` validate(List 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. 版本号不与已有版本冲突 ``` 配置化限制: ```yaml 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 扩展点 ```java public interface PrePublishValidator { ValidationResult validate(SkillPackageContext context); } public record SkillPackageContext( List entries, SkillMetadata metadata, String publisherId, Long namespaceId ) {} public record ValidationResult(boolean passed, List errors) { public static ValidationResult pass() { return new ValidationResult(true, List.of()); } public static ValidationResult fail(List 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` 包) ```java 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 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 ④ 技能包校验 — 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 模型 ```java 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 - 可见性过滤 - 默认只返回 ACTIVE 状态 getVersionDetail(namespaceSlug, skillSlug, version) → SkillVersionDetailDTO - 返回:版本元数据 + parsed_metadata_json + manifest_json listVersions(namespaceSlug, skillSlug, page, size) → Page - 只返回 PUBLISHED 版本(Phase 2) listFiles(namespaceSlug, skillSlug, version) → List - 返回文件清单(路径、大小、contentType、sha256) getFileContent(namespaceSlug, skillSlug, version, filePath) → FileContentDTO - 从对象存储读取文件内容 - 文本文件返回内容字符串,二进制文件返回 base64 或下载链接 ``` ### 5.2 可见性检查器(`domain.skill.visibility.VisibilityChecker`) ```java public class VisibilityChecker { /** * 检查当前用户是否有权访问指定 skill * @param skill 目标技能 * @param currentUser 当前用户(null 表示匿名) * @param userNamespaceRoles 用户在各 namespace 的角色(预加载) */ public boolean canAccess(Skill skill, String currentUserId, Map 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 ``` ```java 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 - 返回所有自定义标签 + 虚拟 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) ### 6.1 SPI 实现(`skillhub-search`) #### PostgresFullTextIndexService ```java @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 docs); // DELETE FROM skill_search_document WHERE skill_id = ? void remove(Long skillId); } ``` #### PostgresFullTextQueryService ```java @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 ```java @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` 包) ```java 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` 发布: ```java @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 ```java @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 ```java @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 异步线程池配置 ```java @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 自定义注解 ```java @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 拦截器 ```java @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) - 底部:命名空间快速入口卡片 - 现代产品风:大留白、渐变背景、卡片悬浮阴影 #### 搜索页(`/search`) - 顶部搜索栏 + 过滤器行(命名空间下拉、排序选择器) - 网格布局 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. 前端测试通过