From f8e9d8bfd99e01862e71216bfd4ad79f5660c971 Mon Sep 17 00:00:00 2001 From: vsxd Date: Thu, 12 Mar 2026 01:48:46 +0800 Subject: [PATCH] docs: complete Phase 2 implementation plan (Chunk 1 backend + Chunk 2 frontend) --- .../2026-03-12-phase2-namespace-skill-core.md | 7339 +++++++++++++++++ 1 file changed, 7339 insertions(+) create mode 100644 docs/superpowers/plans/2026-03-12-phase2-namespace-skill-core.md diff --git a/docs/superpowers/plans/2026-03-12-phase2-namespace-skill-core.md b/docs/superpowers/plans/2026-03-12-phase2-namespace-skill-core.md new file mode 100644 index 00000000..f6e0ed7e --- /dev/null +++ b/docs/superpowers/plans/2026-03-12-phase2-namespace-skill-core.md @@ -0,0 +1,7339 @@ +# Phase 2: 命名空间 + Skill 核心链路 Implementation Plan + +> **For agentic workers:** REQUIRED: Use superpowers:subagent-driven-development (if subagents available) or superpowers:executing-plans to implement this plan. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** 在 Phase 1 工程骨架和认证体系基础上,实现命名空间管理、对象存储、技能发布/查询/下载完整链路、标签管理、PostgreSQL 全文搜索、异步事件和应用层精细限流。 + +**Architecture:** Maven 多模块后端(6 模块)扩展 + React 前端页面。后端采用领域服务集中式架构,domain 模块包含领域服务和应用服务。对象存储 SPI 双实现(LocalFile + S3),搜索 SPI PostgreSQL Full-Text 实现。发布流程 Phase 2 跳过审核直接到 PUBLISHED。 + +**Tech Stack:** +- Backend: Spring Boot 3.x + JDK 21 + PostgreSQL 16 + Redis 7 + Spring Data JPA + Flyway + AWS SDK v2 (S3) + SnakeYAML +- Frontend: React 19 + TypeScript + Vite + TanStack Router + TanStack Query + shadcn/ui + Tailwind CSS + react-markdown + react-dropzone +- DevOps: Docker Compose (PostgreSQL + Redis + MinIO) + Maven Wrapper + Makefile + +**前置条件:** Phase 1 全部 3 个 Chunk 完成(后端骨架 + 认证授权 + 前端骨架) + +--- + +## Chunk 1: 后端全部 + +本块实现 Phase 2 全部后端功能:数据库迁移、对象存储、命名空间管理、技能发布/查询/下载、标签管理、搜索、异步事件、限流。 + +### 文件结构映射 + +``` +server/ +├── skillhub-app/ +│ └── src/ +│ ├── main/ +│ │ ├── java/com/iflytek/skillhub/ +│ │ │ ├── config/ +│ │ │ │ ├── AsyncConfig.java # 异步线程池配置 +│ │ │ │ └── WebMvcRateLimitConfig.java # 限流拦截器注册 +│ │ │ ├── controller/ +│ │ │ │ └── portal/ +│ │ │ │ ├── NamespaceController.java # 命名空间公开 API +│ │ │ │ ├── SkillController.java # 技能公开查询/下载 API +│ │ │ │ ├── SkillPublishController.java # Web 端发布 API +│ │ │ │ ├── SkillTagController.java # 标签管理 API +│ │ │ │ └── SkillSearchController.java # 搜索 API +│ │ │ │ └── cli/ +│ │ │ │ └── CliPublishController.java # CLI 发布 API +│ │ │ ├── service/ +│ │ │ │ └── SkillSearchAppService.java # 搜索应用服务(app 层) +│ │ │ ├── ratelimit/ +│ │ │ │ ├── RateLimit.java # 限流注解 +│ │ │ │ ├── RateLimitInterceptor.java # 限流拦截器 +│ │ │ │ └── SlidingWindowRateLimiter.java # Redis 滑动窗口实现 +│ │ │ └── dto/ +│ │ │ ├── NamespaceRequest.java # 命名空间请求 DTO +│ │ │ ├── NamespaceResponse.java # 命名空间响应 DTO +│ │ │ ├── MemberRequest.java # 成员管理请求 DTO +│ │ │ ├── MemberResponse.java # 成员管理响应 DTO +│ │ │ ├── SkillDetailResponse.java # 技能详情响应 +│ │ │ ├── SkillSummaryResponse.java # 技能摘要响应 +│ │ │ ├── SkillVersionResponse.java # 版本响应 +│ │ │ ├── SkillFileResponse.java # 文件响应 +│ │ │ ├── PublishRequest.java # 发布请求 +│ │ │ ├── PublishResponse.java # 发布响应 +│ │ │ ├── TagRequest.java # 标签请求 +│ │ │ ├── TagResponse.java # 标签响应 +│ │ │ └── SearchResponse.java # 搜索响应 +│ │ └── resources/ +│ │ ├── db/migration/ +│ │ │ └── V2__phase2_skill_tables.sql # Phase 2 数据库迁移 +│ │ ├── ratelimit.lua # Redis 滑动窗口 Lua 脚本 +│ │ ├── application.yml # 更新:新增 storage/search/publish 配置 +│ │ └── application-local.yml # 更新:新增 local storage 配置 +│ └── test/java/com/iflytek/skillhub/ +│ ├── controller/ +│ │ ├── NamespaceControllerTest.java +│ │ ├── SkillControllerTest.java +│ │ ├── SkillPublishControllerTest.java +│ │ └── SkillSearchControllerTest.java +│ └── ratelimit/ +│ └── RateLimitInterceptorTest.java +├── skillhub-domain/ +│ └── src/ +│ ├── main/java/com/iflytek/skillhub/domain/ +│ │ ├── namespace/ +│ │ │ ├── Namespace.java # 修改:补齐 type/avatarUrl +│ │ │ ├── NamespaceMember.java # 修改:补齐 updatedAt +│ │ │ ├── NamespaceType.java # 新增枚举 +│ │ │ ├── NamespaceRepository.java # 修改:新增方法 +│ │ │ ├── NamespaceMemberRepository.java # 修改:新增方法 +│ │ │ ├── NamespaceService.java # 新增 +│ │ │ ├── NamespaceMemberService.java # 新增 +│ │ │ └── SlugValidator.java # 新增 +│ │ ├── skill/ +│ │ │ ├── Skill.java # 新增实体 +│ │ │ ├── SkillVersion.java # 新增实体 +│ │ │ ├── SkillFile.java # 新增实体 +│ │ │ ├── SkillTag.java # 新增实体 +│ │ │ ├── SkillStatus.java # 新增枚举 +│ │ │ ├── SkillVersionStatus.java # 新增枚举 +│ │ │ ├── SkillVisibility.java # 新增枚举 +│ │ │ ├── SkillRepository.java # 新增接口 +│ │ │ ├── SkillVersionRepository.java # 新增接口 +│ │ │ ├── SkillFileRepository.java # 新增接口 +│ │ │ ├── SkillTagRepository.java # 新增接口 +│ │ │ └── VisibilityChecker.java # 新增 +│ │ │ ├── service/ +│ │ │ │ ├── SkillPublishService.java # 新增 +│ │ │ │ ├── SkillQueryService.java # 新增 +│ │ │ │ ├── SkillDownloadService.java # 新增 +│ │ │ │ └── SkillTagService.java # 新增 +│ │ │ ├── validation/ +│ │ │ │ ├── SkillPackageValidator.java # 新增 +│ │ │ │ ├── PrePublishValidator.java # 新增接口 +│ │ │ │ ├── NoOpPrePublishValidator.java # 新增默认实现 +│ │ │ │ └── PackageEntry.java # 新增 record +│ │ │ └── metadata/ +│ │ │ ├── SkillMetadataParser.java # 新增 +│ │ │ └── SkillMetadata.java # 新增 record +│ │ └── event/ +│ │ ├── SkillPublishedEvent.java # 新增 +│ │ ├── SkillDownloadedEvent.java # 新增 +│ │ └── SkillStatusChangedEvent.java # 新增 +│ └── test/java/com/iflytek/skillhub/domain/ +│ ├── namespace/ +│ │ ├── SlugValidatorTest.java +│ │ ├── NamespaceServiceTest.java +│ │ └── NamespaceMemberServiceTest.java +│ └── skill/ +│ ├── VisibilityCheckerTest.java +│ ├── validation/ +│ │ └── SkillPackageValidatorTest.java +│ ├── metadata/ +│ │ └── SkillMetadataParserTest.java +│ └── service/ +│ ├── SkillPublishServiceTest.java +│ ├── SkillQueryServiceTest.java +│ ├── SkillDownloadServiceTest.java +│ └── SkillTagServiceTest.java +├── skillhub-storage/ +│ └── src/ +│ ├── main/java/com/iflytek/skillhub/storage/ +│ │ ├── ObjectStorageService.java # 新增 SPI 接口 +│ │ ├── ObjectMetadata.java # 新增 record +│ │ ├── StorageProperties.java # 新增配置属性 +│ │ ├── LocalFileStorageService.java # 新增 +│ │ ├── S3StorageService.java # 新增 +│ │ └── S3StorageProperties.java # 新增配置属性 +│ └── test/java/com/iflytek/skillhub/storage/ +│ ├── LocalFileStorageServiceTest.java +│ └── S3StorageServiceTest.java +├── skillhub-search/ +│ └── src/ +│ ├── main/java/com/iflytek/skillhub/search/ +│ │ ├── SearchIndexService.java # 新增 SPI 接口 +│ │ ├── SearchQueryService.java # 新增 SPI 接口 +│ │ ├── SearchRebuildService.java # 新增 SPI 接口 +│ │ ├── SearchQuery.java # 新增 record +│ │ ├── SearchVisibilityScope.java # 新增 record +│ │ ├── SearchResult.java # 新增 record +│ │ ├── SkillSearchDocument.java # 新增 record +│ │ ├── postgres/ +│ │ │ ├── PostgresFullTextIndexService.java # 新增 +│ │ │ ├── PostgresFullTextQueryService.java # 新增 +│ │ │ └── PostgresSearchRebuildService.java # 新增 +│ │ └── event/ +│ │ ├── SearchIndexEventListener.java # 新增 +│ │ └── DownloadCountEventListener.java # 新增 +│ └── test/java/com/iflytek/skillhub/search/ +│ └── postgres/ +│ ├── PostgresFullTextIndexServiceTest.java +│ └── PostgresFullTextQueryServiceTest.java +├── skillhub-infra/ +│ └── src/main/java/com/iflytek/skillhub/infra/jpa/ +│ ├── SkillJpaRepository.java # 新增 +│ ├── SkillVersionJpaRepository.java # 新增 +│ ├── SkillFileJpaRepository.java # 新增 +│ ├── SkillTagJpaRepository.java # 新增 +│ └── SkillSearchDocumentJpaRepository.java # 新增 +└── docker-compose.yml # 修改:确认 MinIO 配置 +``` + +### Task 1: 数据库迁移 — Phase 2 表结构 + +**Files:** +- Create: `server/skillhub-app/src/main/resources/db/migration/V2__phase2_skill_tables.sql` + +- [ ] **Step 1: 创建 V2 迁移脚本** + +```sql +-- V2__phase2_skill_tables.sql +-- Phase 2: 命名空间 + Skill 核心链路 + +-- 技能主表 +CREATE TABLE skill ( + id BIGSERIAL PRIMARY KEY, + namespace_id BIGINT NOT NULL REFERENCES namespace(id), + slug VARCHAR(128) NOT NULL, + display_name VARCHAR(256), + summary VARCHAR(512), + owner_id BIGINT NOT NULL REFERENCES user_account(id), + source_skill_id BIGINT, + visibility VARCHAR(32) NOT NULL DEFAULT 'PUBLIC', + status VARCHAR(32) NOT NULL DEFAULT 'ACTIVE', + 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 BIGINT REFERENCES user_account(id), + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_by BIGINT REFERENCES user_account(id), + updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + UNIQUE(namespace_id, slug) +); + +CREATE INDEX idx_skill_namespace_status ON skill(namespace_id, status); + +-- 技能版本表 +CREATE TABLE skill_version ( + id BIGSERIAL PRIMARY KEY, + skill_id BIGINT NOT NULL REFERENCES skill(id), + version VARCHAR(64) NOT NULL, + status VARCHAR(32) NOT NULL DEFAULT 'DRAFT', + changelog TEXT, + parsed_metadata_json JSONB, + manifest_json JSONB, + file_count INT NOT NULL DEFAULT 0, + total_size BIGINT NOT NULL DEFAULT 0, + published_at TIMESTAMP, + created_by BIGINT REFERENCES user_account(id), + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + UNIQUE(skill_id, version) +); + +CREATE INDEX idx_skill_version_skill_status ON skill_version(skill_id, status); + +ALTER TABLE skill ADD CONSTRAINT fk_skill_latest_version + FOREIGN KEY (latest_version_id) REFERENCES skill_version(id); + +-- 技能文件表 +CREATE TABLE skill_file ( + id BIGSERIAL PRIMARY KEY, + version_id BIGINT NOT NULL REFERENCES skill_version(id), + file_path VARCHAR(512) NOT NULL, + file_size BIGINT NOT NULL, + content_type VARCHAR(128), + sha256 VARCHAR(64) NOT NULL, + storage_key VARCHAR(512) NOT NULL, + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + UNIQUE(version_id, file_path) +); + +-- 技能标签表 +CREATE TABLE skill_tag ( + id BIGSERIAL PRIMARY KEY, + skill_id BIGINT NOT NULL REFERENCES skill(id), + tag_name VARCHAR(64) NOT NULL, + version_id BIGINT NOT NULL REFERENCES skill_version(id), + created_by BIGINT REFERENCES user_account(id), + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + UNIQUE(skill_id, tag_name) +); + +-- 搜索文档表 +CREATE TABLE skill_search_document ( + id BIGSERIAL PRIMARY KEY, + skill_id BIGINT NOT NULL UNIQUE REFERENCES skill(id), + namespace_id BIGINT NOT NULL, + namespace_slug VARCHAR(64) NOT NULL, + owner_id BIGINT NOT NULL, + title VARCHAR(256), + summary VARCHAR(512), + keywords VARCHAR(512), + search_text TEXT, + visibility VARCHAR(32) NOT NULL, + status VARCHAR(32) NOT NULL, + updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP +); + +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); +CREATE INDEX idx_search_doc_namespace ON skill_search_document(namespace_id); +CREATE INDEX idx_search_doc_visibility ON skill_search_document(visibility); +``` + +- [ ] **Step 2: 启动依赖服务并执行迁移** + +Run: `make dev && cd server && ./mvnw spring-boot:run -Dspring-boot.run.profiles=local -Dspring-boot.run.arguments="--spring.main.web-application-type=none" 2>&1 | head -50` + +Expected: Flyway 输出 `Successfully applied 1 migration`,应用启动后 Ctrl+C 停止 + +- [ ] **Step 3: 验证表创建** + +Run: `docker compose exec postgres psql -U skillhub -d skillhub -c "\dt skill*"` + +Expected: 列出 skill, skill_version, skill_file, skill_tag, skill_search_document 五张表 + +- [ ] **Step 4: Commit** + +```bash +git add server/skillhub-app/src/main/resources/db/migration/V2__phase2_skill_tables.sql +git commit -m "feat(db): add Phase 2 skill tables migration + +- Add skill, skill_version, skill_file, skill_tag tables +- Add skill_search_document with tsvector generated column + GIN index +- Add foreign keys, unique constraints, and indexes per domain model spec" +``` + +### Task 2: Phase 1 实体补齐 — Namespace 和 NamespaceMember + +**Files:** +- Modify: `server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/namespace/Namespace.java` +- Modify: `server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/namespace/NamespaceMember.java` +- Create: `server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/namespace/NamespaceType.java` +- Modify: `server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/namespace/NamespaceRepository.java` +- Modify: `server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/namespace/NamespaceMemberRepository.java` +- Modify: `server/skillhub-infra/src/main/java/com/iflytek/skillhub/infra/jpa/NamespaceJpaRepository.java` +- Modify: `server/skillhub-infra/src/main/java/com/iflytek/skillhub/infra/jpa/NamespaceMemberJpaRepository.java` + +- [ ] **Step 1: 创建 NamespaceType 枚举** + +```bash +cat > server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/namespace/NamespaceType.java << 'EOF' +package com.iflytek.skillhub.domain.namespace; + +public enum NamespaceType { + GLOBAL, + TEAM +} +EOF +``` + +- [ ] **Step 2: 补齐 Namespace 实体字段(type 和 avatarUrl)** + +在 `Namespace.java` 中添加缺失字段: + +```java +@Enumerated(EnumType.STRING) +@Column(nullable = false, length = 32) +private NamespaceType type = NamespaceType.TEAM; + +@Column(name = "avatar_url", length = 512) +private String avatarUrl; +``` + +添加 getter/setter: + +```java +public NamespaceType getType() { return type; } +public void setType(NamespaceType type) { this.type = type; } +public String getAvatarUrl() { return avatarUrl; } +public void setAvatarUrl(String avatarUrl) { this.avatarUrl = avatarUrl; } +public String getDescription() { return description; } +public void setDescription(String description) { this.description = description; } +public void setDisplayName(String displayName) { this.displayName = displayName; } +``` + +- [ ] **Step 3: 补齐 NamespaceMember 实体字段(updatedAt)** + +在 `NamespaceMember.java` 中添加 updatedAt 字段: + +```java +@Column(name = "updated_at", nullable = false) +private LocalDateTime updatedAt; +``` + +修改 `@PrePersist` 和添加 `@PreUpdate`: + +```java +@PrePersist +void prePersist() { + this.createdAt = LocalDateTime.now(); + this.updatedAt = this.createdAt; +} + +@PreUpdate +void preUpdate() { + this.updatedAt = LocalDateTime.now(); +} +``` + +添加 getter: + +```java +public LocalDateTime getUpdatedAt() { return updatedAt; } +``` + +- [ ] **Step 4: 扩展 NamespaceRepository 接口** + +在 `NamespaceRepository.java` 中添加新方法: + +```java +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; + +Page findByStatus(NamespaceStatus status, Pageable pageable); +``` + +- [ ] **Step 5: 扩展 NamespaceMemberRepository 接口** + +在 `NamespaceMemberRepository.java` 中添加新方法: + +```java +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; + +Page findByNamespaceId(Long namespaceId, Pageable pageable); +void deleteByNamespaceIdAndUserId(Long namespaceId, Long userId); +``` + +- [ ] **Step 6: 更新 NamespaceJpaRepository 实现** + +在 `NamespaceJpaRepository.java` 中添加方法声明(Spring Data JPA 自动实现): + +```java +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import com.iflytek.skillhub.domain.namespace.NamespaceStatus; + +Page findByStatus(NamespaceStatus status, Pageable pageable); +``` + +- [ ] **Step 7: 更新 NamespaceMemberJpaRepository 实现** + +在 `NamespaceMemberJpaRepository.java` 中添加方法声明: + +```java +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; + +Page findByNamespaceId(Long namespaceId, Pageable pageable); +void deleteByNamespaceIdAndUserId(Long namespaceId, Long userId); +``` + +- [ ] **Step 8: 编译验证** + +Run: `cd server && ./mvnw clean compile -DskipTests` + +Expected: BUILD SUCCESS + +- [ ] **Step 9: Commit** + +```bash +git add server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/namespace/ \ + server/skillhub-infra/src/main/java/com/iflytek/skillhub/infra/jpa/ +git commit -m "feat(domain): complete Phase 1 entity fields for Namespace and NamespaceMember + +- Add NamespaceType enum (GLOBAL/TEAM) +- Add Namespace.type and Namespace.avatarUrl fields +- Add NamespaceMember.updatedAt field with @PreUpdate +- Extend NamespaceRepository with findByStatus pagination method +- Extend NamespaceMemberRepository with findByNamespaceId and deleteByNamespaceIdAndUserId +- Update JPA repository implementations" +``` + +### Task 3: SlugValidator — Slug 格式校验器 + +**Files:** +- Create: `server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/namespace/SlugValidator.java` +- Test: `server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/namespace/SlugValidatorTest.java` + +- [ ] **Step 1: 编写 SlugValidator 测试** + +```bash +mkdir -p server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/namespace +cat > server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/namespace/SlugValidatorTest.java << 'EOF' +package com.iflytek.skillhub.domain.namespace; + +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.*; + +class SlugValidatorTest { + + @Test + void shouldAcceptValidSlug() { + assertDoesNotThrow(() -> SlugValidator.validate("my-namespace")); + assertDoesNotThrow(() -> SlugValidator.validate("ab")); + assertDoesNotThrow(() -> SlugValidator.validate("test123")); + assertDoesNotThrow(() -> SlugValidator.validate("my-team-2024")); + } + + @Test + void shouldRejectTooShort() { + Exception ex = assertThrows(IllegalArgumentException.class, () -> SlugValidator.validate("a")); + assertTrue(ex.getMessage().contains("length")); + } + + @Test + void shouldRejectTooLong() { + String longSlug = "a".repeat(65); + Exception ex = assertThrows(IllegalArgumentException.class, () -> SlugValidator.validate(longSlug)); + assertTrue(ex.getMessage().contains("length")); + } + + @Test + void shouldRejectUpperCase() { + Exception ex = assertThrows(IllegalArgumentException.class, () -> SlugValidator.validate("MyNamespace")); + assertTrue(ex.getMessage().contains("lowercase")); + } + + @Test + void shouldRejectStartingWithHyphen() { + Exception ex = assertThrows(IllegalArgumentException.class, () -> SlugValidator.validate("-namespace")); + assertTrue(ex.getMessage().contains("alphanumeric")); + } + + @Test + void shouldRejectEndingWithHyphen() { + Exception ex = assertThrows(IllegalArgumentException.class, () -> SlugValidator.validate("namespace-")); + assertTrue(ex.getMessage().contains("alphanumeric")); + } + + @Test + void shouldRejectDoubleHyphen() { + Exception ex = assertThrows(IllegalArgumentException.class, () -> SlugValidator.validate("my--namespace")); + assertTrue(ex.getMessage().contains("consecutive")); + } + + @Test + void shouldRejectReservedWords() { + assertThrows(IllegalArgumentException.class, () -> SlugValidator.validate("admin")); + assertThrows(IllegalArgumentException.class, () -> SlugValidator.validate("api")); + assertThrows(IllegalArgumentException.class, () -> SlugValidator.validate("global")); + assertThrows(IllegalArgumentException.class, () -> SlugValidator.validate("system")); + } + + @Test + void shouldRejectSpecialCharacters() { + Exception ex = assertThrows(IllegalArgumentException.class, () -> SlugValidator.validate("my_namespace")); + assertTrue(ex.getMessage().contains("lowercase") || ex.getMessage().contains("alphanumeric")); + } +} +EOF +``` + +- [ ] **Step 2: 运行测试确认失败** + +Run: `cd server && ./mvnw test -Dtest=SlugValidatorTest` + +Expected: FAIL - SlugValidator class not found + +- [ ] **Step 3: 实现 SlugValidator** + +```bash +cat > server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/namespace/SlugValidator.java << 'EOF' +package com.iflytek.skillhub.domain.namespace; + +import java.util.Set; +import java.util.regex.Pattern; + +public class SlugValidator { + + private static final int MIN_LENGTH = 2; + private static final int MAX_LENGTH = 64; + 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) { + if (slug == null || slug.isBlank()) { + throw new IllegalArgumentException("Slug cannot be null or blank"); + } + + if (slug.length() < MIN_LENGTH || slug.length() > MAX_LENGTH) { + throw new IllegalArgumentException( + String.format("Slug length must be between %d and %d characters", MIN_LENGTH, MAX_LENGTH) + ); + } + + if (!SLUG_PATTERN.matcher(slug).matches()) { + throw new IllegalArgumentException( + "Slug must contain only lowercase alphanumeric characters and hyphens, " + + "and must start and end with an alphanumeric character" + ); + } + + if (slug.contains("--")) { + throw new IllegalArgumentException("Slug cannot contain consecutive hyphens"); + } + + if (RESERVED_SLUGS.contains(slug)) { + throw new IllegalArgumentException("Slug '" + slug + "' is reserved and cannot be used"); + } + } +} +EOF +``` + +- [ ] **Step 4: 运行测试确认通过** + +Run: `cd server && ./mvnw test -Dtest=SlugValidatorTest` + +Expected: PASS - All tests pass + +- [ ] **Step 5: Commit** + +```bash +git add server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/namespace/SlugValidator.java \ + server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/namespace/SlugValidatorTest.java +git commit -m "feat(domain): add SlugValidator with comprehensive validation rules + +- Validate slug length (2-64 characters) +- Enforce lowercase alphanumeric + hyphens only +- Reject consecutive hyphens (--) +- Reject reserved words (admin, api, global, etc.) +- Add comprehensive test coverage" +``` + +### Task 4: Object Storage SPI + Configuration + +**Files:** +- Modify: `server/skillhub-storage/pom.xml` +- Create: `server/skillhub-storage/src/main/java/com/iflytek/skillhub/storage/ObjectStorageService.java` +- Create: `server/skillhub-storage/src/main/java/com/iflytek/skillhub/storage/ObjectMetadata.java` +- Create: `server/skillhub-storage/src/main/java/com/iflytek/skillhub/storage/StorageProperties.java` + +- [ ] **Step 1: 添加 SnakeYAML 和 AWS SDK 依赖到 skillhub-storage/pom.xml** + +```bash +cat > server/skillhub-storage/pom.xml << 'EOF' + + + 4.0.0 + + com.iflytek.skillhub + skillhub-parent + 0.1.0-SNAPSHOT + + skillhub-storage + + + + org.springframework.boot + spring-boot-starter + + + org.yaml + snakeyaml + + + software.amazon.awssdk + s3 + 2.20.26 + + + org.springframework.boot + spring-boot-starter-test + test + + + +EOF +``` + +- [ ] **Step 2: 创建 ObjectStorageService SPI 接口** + +```bash +mkdir -p server/skillhub-storage/src/main/java/com/iflytek/skillhub/storage +cat > server/skillhub-storage/src/main/java/com/iflytek/skillhub/storage/ObjectStorageService.java << 'EOF' +package com.iflytek.skillhub.storage; + +import java.io.InputStream; +import java.util.List; + +public interface ObjectStorageService { + + /** + * 上传对象到存储 + * @param key 对象 key + * @param data 数据流 + * @param size 数据大小(字节) + * @param contentType MIME 类型 + */ + void putObject(String key, InputStream data, long size, String contentType); + + /** + * 获取对象数据流 + * @param key 对象 key + * @return 数据流 + */ + InputStream getObject(String key); + + /** + * 删除单个对象 + * @param key 对象 key + */ + void deleteObject(String key); + + /** + * 批量删除对象 + * @param keys 对象 key 列表 + */ + void deleteObjects(List keys); + + /** + * 检查对象是否存在 + * @param key 对象 key + * @return 是否存在 + */ + boolean exists(String key); + + /** + * 获取对象元数据 + * @param key 对象 key + * @return 元数据 + */ + ObjectMetadata getMetadata(String key); +} +EOF +``` + +- [ ] **Step 3: 创建 ObjectMetadata record** + +```bash +cat > server/skillhub-storage/src/main/java/com/iflytek/skillhub/storage/ObjectMetadata.java << 'EOF' +package com.iflytek.skillhub.storage; + +import java.time.Instant; + +public record ObjectMetadata( + long size, + String contentType, + Instant lastModified +) {} +EOF +``` + +- [ ] **Step 4: 创建 StorageProperties 配置类** + +```bash +cat > server/skillhub-storage/src/main/java/com/iflytek/skillhub/storage/StorageProperties.java << 'EOF' +package com.iflytek.skillhub.storage; + +import org.springframework.boot.context.properties.ConfigurationProperties; +import org.springframework.stereotype.Component; + +@Component +@ConfigurationProperties(prefix = "skillhub.storage") +public class StorageProperties { + + private String provider = "local"; + private LocalProperties local = new LocalProperties(); + + public String getProvider() { + return provider; + } + + public void setProvider(String provider) { + this.provider = provider; + } + + public LocalProperties getLocal() { + return local; + } + + public void setLocal(LocalProperties local) { + this.local = local; + } + + public static class LocalProperties { + private String basePath = "./data/storage"; + + public String getBasePath() { + return basePath; + } + + public void setBasePath(String basePath) { + this.basePath = basePath; + } + } +} +EOF +``` + +- [ ] **Step 5: 编译验证** + +Run: `cd server && ./mvnw clean compile -DskipTests` + +Expected: BUILD SUCCESS + +- [ ] **Step 6: Commit** + +```bash +git add server/skillhub-storage/pom.xml \ + server/skillhub-storage/src/main/java/com/iflytek/skillhub/storage/ +git commit -m "feat(storage): add object storage SPI and configuration + +- Add ObjectStorageService interface with put/get/delete/exists/getMetadata methods +- Add ObjectMetadata record for metadata representation +- Add StorageProperties for provider configuration +- Add SnakeYAML and AWS SDK v2 dependencies to pom.xml" +``` + +### Task 5: LocalFileStorageService — 本地文件存储实现 + +**Files:** +- Create: `server/skillhub-storage/src/main/java/com/iflytek/skillhub/storage/LocalFileStorageService.java` +- Test: `server/skillhub-storage/src/test/java/com/iflytek/skillhub/storage/LocalFileStorageServiceTest.java` + +- [ ] **Step 1: 编写 LocalFileStorageService 测试** + +```bash +mkdir -p server/skillhub-storage/src/test/java/com/iflytek/skillhub/storage +cat > server/skillhub-storage/src/test/java/com/iflytek/skillhub/storage/LocalFileStorageServiceTest.java << 'EOF' +package com.iflytek.skillhub.storage; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +import java.io.ByteArrayInputStream; +import java.io.InputStream; +import java.nio.charset.StandardCharsets; +import java.nio.file.Path; +import java.util.List; + +import static org.junit.jupiter.api.Assertions.*; + +class LocalFileStorageServiceTest { + + @TempDir + Path tempDir; + + private LocalFileStorageService storageService; + + @BeforeEach + void setUp() { + StorageProperties props = new StorageProperties(); + props.getLocal().setBasePath(tempDir.toString()); + storageService = new LocalFileStorageService(props); + } + + @Test + void shouldPutAndGetObject() throws Exception { + String key = "skills/1/1/SKILL.md"; + byte[] content = "# Hello".getBytes(StandardCharsets.UTF_8); + storageService.putObject(key, new ByteArrayInputStream(content), content.length, "text/markdown"); + + try (InputStream result = storageService.getObject(key)) { + assertArrayEquals(content, result.readAllBytes()); + } + } + + @Test + void shouldCheckExistence() { + String key = "test/exists.txt"; + assertFalse(storageService.exists(key)); + + byte[] content = "data".getBytes(StandardCharsets.UTF_8); + storageService.putObject(key, new ByteArrayInputStream(content), content.length, "text/plain"); + + assertTrue(storageService.exists(key)); + } + + @Test + void shouldDeleteObject() { + String key = "test/delete.txt"; + byte[] content = "data".getBytes(StandardCharsets.UTF_8); + storageService.putObject(key, new ByteArrayInputStream(content), content.length, "text/plain"); + + assertTrue(storageService.exists(key)); + storageService.deleteObject(key); + assertFalse(storageService.exists(key)); + } + + @Test + void shouldDeleteMultipleObjects() { + byte[] content = "data".getBytes(StandardCharsets.UTF_8); + storageService.putObject("a/1.txt", new ByteArrayInputStream(content), content.length, "text/plain"); + storageService.putObject("a/2.txt", new ByteArrayInputStream(content), content.length, "text/plain"); + + storageService.deleteObjects(List.of("a/1.txt", "a/2.txt")); + assertFalse(storageService.exists("a/1.txt")); + assertFalse(storageService.exists("a/2.txt")); + } + + @Test + void shouldGetMetadata() { + String key = "test/meta.txt"; + byte[] content = "hello world".getBytes(StandardCharsets.UTF_8); + storageService.putObject(key, new ByteArrayInputStream(content), content.length, "text/plain"); + + ObjectMetadata metadata = storageService.getMetadata(key); + assertEquals(content.length, metadata.size()); + assertNotNull(metadata.lastModified()); + } +} +EOF +``` + +- [ ] **Step 2: 运行测试确认失败** + +Run: `cd server && ./mvnw test -pl skillhub-storage -Dtest=LocalFileStorageServiceTest` + +Expected: FAIL - LocalFileStorageService class not found + +- [ ] **Step 3: 实现 LocalFileStorageService** + +```bash +cat > server/skillhub-storage/src/main/java/com/iflytek/skillhub/storage/LocalFileStorageService.java << 'EOF' +package com.iflytek.skillhub.storage; + +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.stereotype.Service; + +import java.io.*; +import java.nio.file.*; +import java.nio.file.attribute.BasicFileAttributes; +import java.time.Instant; +import java.util.List; + +@Service +@ConditionalOnProperty(name = "skillhub.storage.provider", havingValue = "local", matchIfMissing = true) +public class LocalFileStorageService implements ObjectStorageService { + + private final Path basePath; + + public LocalFileStorageService(StorageProperties properties) { + this.basePath = Paths.get(properties.getLocal().getBasePath()); + } + + @Override + public void putObject(String key, InputStream data, long size, String contentType) { + try { + Path target = resolve(key); + Files.createDirectories(target.getParent()); + Path tmp = target.resolveSibling(target.getFileName() + ".tmp"); + try (OutputStream out = Files.newOutputStream(tmp, StandardOpenOption.CREATE, StandardOpenOption.TRUNCATE_EXISTING)) { + data.transferTo(out); + } + Files.move(tmp, target, StandardCopyOption.REPLACE_EXISTING, StandardCopyOption.ATOMIC_MOVE); + } catch (IOException e) { + throw new UncheckedIOException("Failed to put object: " + key, e); + } + } + + @Override + public InputStream getObject(String key) { + try { + return Files.newInputStream(resolve(key)); + } catch (IOException e) { + throw new UncheckedIOException("Failed to get object: " + key, e); + } + } + + @Override + public void deleteObject(String key) { + try { + Files.deleteIfExists(resolve(key)); + } catch (IOException e) { + throw new UncheckedIOException("Failed to delete object: " + key, e); + } + } + + @Override + public void deleteObjects(List keys) { + keys.forEach(this::deleteObject); + } + + @Override + public boolean exists(String key) { + return Files.exists(resolve(key)); + } + + @Override + public ObjectMetadata getMetadata(String key) { + try { + Path path = resolve(key); + BasicFileAttributes attrs = Files.readAttributes(path, BasicFileAttributes.class); + return new ObjectMetadata( + attrs.size(), + Files.probeContentType(path), + attrs.lastModifiedTime().toInstant() + ); + } catch (IOException e) { + throw new UncheckedIOException("Failed to get metadata: " + key, e); + } + } + + private Path resolve(String key) { + return basePath.resolve(key); + } +} +EOF +``` + +- [ ] **Step 4: 运行测试确认通过** + +Run: `cd server && ./mvnw test -pl skillhub-storage -Dtest=LocalFileStorageServiceTest` + +Expected: PASS - All tests pass + +- [ ] **Step 5: Commit** + +```bash +git add server/skillhub-storage/src/main/java/com/iflytek/skillhub/storage/LocalFileStorageService.java \ + server/skillhub-storage/src/test/java/com/iflytek/skillhub/storage/LocalFileStorageServiceTest.java +git commit -m "feat(storage): implement LocalFileStorageService with atomic write + +- Implement local file system storage using java.nio.file +- Atomic write via .tmp file + rename +- Support put/get/delete/exists/getMetadata operations +- Add comprehensive unit tests with @TempDir" +``` + +### Task 6: S3StorageService — AWS S3 存储实现 + +**Files:** +- Create: `server/skillhub-storage/src/main/java/com/iflytek/skillhub/storage/S3StorageProperties.java` +- Create: `server/skillhub-storage/src/main/java/com/iflytek/skillhub/storage/S3StorageService.java` + +- [ ] **Step 1: 创建 S3StorageProperties 配置类** + +```bash +cat > server/skillhub-storage/src/main/java/com/iflytek/skillhub/storage/S3StorageProperties.java << 'EOF' +package com.iflytek.skillhub.storage; + +import org.springframework.boot.context.properties.ConfigurationProperties; +import org.springframework.stereotype.Component; + +@Component +@ConfigurationProperties(prefix = "skillhub.storage.s3") +public class S3StorageProperties { + + private String endpoint; + private String bucket = "skillhub"; + private String accessKey; + private String secretKey; + private String region = "us-east-1"; + + public String getEndpoint() { return endpoint; } + public void setEndpoint(String endpoint) { this.endpoint = endpoint; } + public String getBucket() { return bucket; } + public void setBucket(String bucket) { this.bucket = bucket; } + public String getAccessKey() { return accessKey; } + public void setAccessKey(String accessKey) { this.accessKey = accessKey; } + public String getSecretKey() { return secretKey; } + public void setSecretKey(String secretKey) { this.secretKey = secretKey; } + public String getRegion() { return region; } + public void setRegion(String region) { this.region = region; } +} +EOF +``` + +- [ ] **Step 2: 实现 S3StorageService** + +```bash +cat > server/skillhub-storage/src/main/java/com/iflytek/skillhub/storage/S3StorageService.java << 'EOF' +package com.iflytek.skillhub.storage; + +import jakarta.annotation.PostConstruct; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.stereotype.Service; +import software.amazon.awssdk.auth.credentials.AwsBasicCredentials; +import software.amazon.awssdk.auth.credentials.StaticCredentialsProvider; +import software.amazon.awssdk.core.sync.RequestBody; +import software.amazon.awssdk.regions.Region; +import software.amazon.awssdk.services.s3.S3Client; +import software.amazon.awssdk.services.s3.model.*; + +import java.io.InputStream; +import java.net.URI; +import java.util.List; + +@Service +@ConditionalOnProperty(name = "skillhub.storage.provider", havingValue = "s3") +public class S3StorageService implements ObjectStorageService { + + private static final Logger log = LoggerFactory.getLogger(S3StorageService.class); + + private final S3StorageProperties properties; + private S3Client s3Client; + + public S3StorageService(S3StorageProperties properties) { + this.properties = properties; + } + + @PostConstruct + void init() { + var builder = S3Client.builder() + .region(Region.of(properties.getRegion())) + .credentialsProvider(StaticCredentialsProvider.create( + AwsBasicCredentials.create(properties.getAccessKey(), properties.getSecretKey()) + )) + .forcePathStyle(true); + + if (properties.getEndpoint() != null && !properties.getEndpoint().isBlank()) { + builder.endpointOverride(URI.create(properties.getEndpoint())); + } + + this.s3Client = builder.build(); + ensureBucketExists(); + } + + private void ensureBucketExists() { + try { + s3Client.headBucket(HeadBucketRequest.builder().bucket(properties.getBucket()).build()); + } catch (NoSuchBucketException e) { + log.info("Bucket '{}' does not exist, creating...", properties.getBucket()); + s3Client.createBucket(CreateBucketRequest.builder().bucket(properties.getBucket()).build()); + } + } + + @Override + public void putObject(String key, InputStream data, long size, String contentType) { + s3Client.putObject( + PutObjectRequest.builder() + .bucket(properties.getBucket()) + .key(key) + .contentType(contentType) + .contentLength(size) + .build(), + RequestBody.fromInputStream(data, size) + ); + } + + @Override + public InputStream getObject(String key) { + return s3Client.getObject( + GetObjectRequest.builder() + .bucket(properties.getBucket()) + .key(key) + .build() + ); + } + + @Override + public void deleteObject(String key) { + s3Client.deleteObject( + DeleteObjectRequest.builder() + .bucket(properties.getBucket()) + .key(key) + .build() + ); + } + + @Override + public void deleteObjects(List keys) { + if (keys.isEmpty()) return; + List ids = keys.stream() + .map(k -> ObjectIdentifier.builder().key(k).build()) + .toList(); + s3Client.deleteObjects( + DeleteObjectsRequest.builder() + .bucket(properties.getBucket()) + .delete(Delete.builder().objects(ids).build()) + .build() + ); + } + + @Override + public boolean exists(String key) { + try { + s3Client.headObject(HeadObjectRequest.builder() + .bucket(properties.getBucket()) + .key(key) + .build()); + return true; + } catch (NoSuchKeyException e) { + return false; + } + } + + @Override + public ObjectMetadata getMetadata(String key) { + HeadObjectResponse resp = s3Client.headObject( + HeadObjectRequest.builder() + .bucket(properties.getBucket()) + .key(key) + .build() + ); + return new ObjectMetadata( + resp.contentLength(), + resp.contentType(), + resp.lastModified() + ); + } +} +EOF +``` + +- [ ] **Step 3: 编译验证** + +Run: `cd server && ./mvnw clean compile -DskipTests` + +Expected: BUILD SUCCESS + +- [ ] **Step 4: Commit** + +```bash +git add server/skillhub-storage/src/main/java/com/iflytek/skillhub/storage/S3StorageProperties.java \ + server/skillhub-storage/src/main/java/com/iflytek/skillhub/storage/S3StorageService.java +git commit -m "feat(storage): implement S3StorageService with auto-create bucket + +- Add S3StorageProperties for endpoint/bucket/credentials configuration +- Implement S3StorageService using AWS SDK v2 S3Client +- Auto-create bucket on startup if not exists +- Support path-style access for MinIO compatibility +- Activated via skillhub.storage.provider=s3" +``` + +### Task 7: Skill Domain Entities — 技能实体和枚举 + +**Files:** +- Create: `server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/SkillStatus.java` +- Create: `server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/SkillVersionStatus.java` +- Create: `server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/SkillVisibility.java` +- Create: `server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/Skill.java` +- Create: `server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/SkillVersion.java` +- Create: `server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/SkillFile.java` +- Create: `server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/SkillTag.java` +- Create: `server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/SkillRepository.java` +- Create: `server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/SkillVersionRepository.java` +- Create: `server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/SkillFileRepository.java` +- Create: `server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/SkillTagRepository.java` + +- [ ] **Step 1: 创建枚举类型** + +```bash +mkdir -p server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill + +cat > server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/SkillStatus.java << 'EOF' +package com.iflytek.skillhub.domain.skill; + +public enum SkillStatus { + ACTIVE, + HIDDEN, + ARCHIVED +} +EOF + +cat > server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/SkillVersionStatus.java << 'EOF' +package com.iflytek.skillhub.domain.skill; + +public enum SkillVersionStatus { + DRAFT, + PENDING_REVIEW, + PUBLISHED, + REJECTED +} +EOF + +cat > server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/SkillVisibility.java << 'EOF' +package com.iflytek.skillhub.domain.skill; + +public enum SkillVisibility { + PUBLIC, + NAMESPACE_ONLY, + PRIVATE +} +EOF +``` + +- [ ] **Step 2: 创建 Skill 实体** + +```bash +cat > server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/Skill.java << 'EOF' +package com.iflytek.skillhub.domain.skill; + +import jakarta.persistence.*; +import java.math.BigDecimal; +import java.time.LocalDateTime; + +@Entity +@Table(name = "skill", uniqueConstraints = @UniqueConstraint(columnNames = {"namespace_id", "slug"})) +public class Skill { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(name = "namespace_id", nullable = false) + private Long namespaceId; + + @Column(nullable = false, length = 128) + private String slug; + + @Column(name = "display_name", length = 256) + private String displayName; + + @Column(length = 512) + private String summary; + + @Column(name = "owner_id", nullable = false) + private Long ownerId; + + @Column(name = "source_skill_id") + private Long sourceSkillId; + + @Enumerated(EnumType.STRING) + @Column(nullable = false, length = 32) + private SkillVisibility visibility = SkillVisibility.PUBLIC; + + @Enumerated(EnumType.STRING) + @Column(nullable = false, length = 32) + private SkillStatus status = SkillStatus.ACTIVE; + + @Column(name = "latest_version_id") + private Long latestVersionId; + + @Column(name = "download_count", nullable = false) + private Long downloadCount = 0L; + + @Column(name = "star_count", nullable = false) + private Integer starCount = 0; + + @Column(name = "rating_avg", nullable = false, precision = 3, scale = 2) + private BigDecimal ratingAvg = BigDecimal.ZERO; + + @Column(name = "rating_count", nullable = false) + private Integer ratingCount = 0; + + @Column(name = "created_by") + private Long createdBy; + + @Column(name = "created_at", nullable = false, updatable = false) + private LocalDateTime createdAt; + + @Column(name = "updated_by") + private Long updatedBy; + + @Column(name = "updated_at", nullable = false) + private LocalDateTime updatedAt; + + protected Skill() {} + + public Skill(Long namespaceId, String slug, Long ownerId, SkillVisibility visibility) { + this.namespaceId = namespaceId; + this.slug = slug; + this.ownerId = ownerId; + this.visibility = visibility; + } + + @PrePersist + void prePersist() { + this.createdAt = LocalDateTime.now(); + this.updatedAt = this.createdAt; + } + + @PreUpdate + void preUpdate() { + this.updatedAt = LocalDateTime.now(); + } + + // Getters and setters + public Long getId() { return id; } + public Long getNamespaceId() { return namespaceId; } + public String getSlug() { return slug; } + public String getDisplayName() { return displayName; } + public void setDisplayName(String displayName) { this.displayName = displayName; } + public String getSummary() { return summary; } + public void setSummary(String summary) { this.summary = summary; } + public Long getOwnerId() { return ownerId; } + public Long getSourceSkillId() { return sourceSkillId; } + public void setSourceSkillId(Long sourceSkillId) { this.sourceSkillId = sourceSkillId; } + public SkillVisibility getVisibility() { return visibility; } + public void setVisibility(SkillVisibility visibility) { this.visibility = visibility; } + public SkillStatus getStatus() { return status; } + public void setStatus(SkillStatus status) { this.status = status; } + public Long getLatestVersionId() { return latestVersionId; } + public void setLatestVersionId(Long latestVersionId) { this.latestVersionId = latestVersionId; } + public Long getDownloadCount() { return downloadCount; } + public Integer getStarCount() { return starCount; } + public BigDecimal getRatingAvg() { return ratingAvg; } + public Integer getRatingCount() { return ratingCount; } + public Long getCreatedBy() { return createdBy; } + public void setCreatedBy(Long createdBy) { this.createdBy = createdBy; } + public LocalDateTime getCreatedAt() { return createdAt; } + public Long getUpdatedBy() { return updatedBy; } + public void setUpdatedBy(Long updatedBy) { this.updatedBy = updatedBy; } + public LocalDateTime getUpdatedAt() { return updatedAt; } +} +EOF +``` + +- [ ] **Step 3: 创建 SkillVersion 实体** + +```bash +cat > server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/SkillVersion.java << 'EOF' +package com.iflytek.skillhub.domain.skill; + +import jakarta.persistence.*; +import java.time.LocalDateTime; + +@Entity +@Table(name = "skill_version", uniqueConstraints = @UniqueConstraint(columnNames = {"skill_id", "version"})) +public class SkillVersion { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(name = "skill_id", nullable = false) + private Long skillId; + + @Column(nullable = false, length = 64) + private String version; + + @Enumerated(EnumType.STRING) + @Column(nullable = false, length = 32) + private SkillVersionStatus status = SkillVersionStatus.DRAFT; + + @Column(columnDefinition = "TEXT") + private String changelog; + + @Column(name = "parsed_metadata_json", columnDefinition = "JSONB") + private String parsedMetadataJson; + + @Column(name = "manifest_json", columnDefinition = "JSONB") + private String manifestJson; + + @Column(name = "file_count", nullable = false) + private Integer fileCount = 0; + + @Column(name = "total_size", nullable = false) + private Long totalSize = 0L; + + @Column(name = "published_at") + private LocalDateTime publishedAt; + + @Column(name = "created_by") + private Long createdBy; + + @Column(name = "created_at", nullable = false, updatable = false) + private LocalDateTime createdAt; + + protected SkillVersion() {} + + public SkillVersion(Long skillId, String version, Long createdBy) { + this.skillId = skillId; + this.version = version; + this.createdBy = createdBy; + } + + @PrePersist + void prePersist() { + this.createdAt = LocalDateTime.now(); + } + + // Getters and setters + public Long getId() { return id; } + public Long getSkillId() { return skillId; } + public String getVersion() { return version; } + public SkillVersionStatus getStatus() { return status; } + public void setStatus(SkillVersionStatus status) { this.status = status; } + public String getChangelog() { return changelog; } + public void setChangelog(String changelog) { this.changelog = changelog; } + public String getParsedMetadataJson() { return parsedMetadataJson; } + public void setParsedMetadataJson(String parsedMetadataJson) { this.parsedMetadataJson = parsedMetadataJson; } + public String getManifestJson() { return manifestJson; } + public void setManifestJson(String manifestJson) { this.manifestJson = manifestJson; } + public Integer getFileCount() { return fileCount; } + public void setFileCount(Integer fileCount) { this.fileCount = fileCount; } + public Long getTotalSize() { return totalSize; } + public void setTotalSize(Long totalSize) { this.totalSize = totalSize; } + public LocalDateTime getPublishedAt() { return publishedAt; } + public void setPublishedAt(LocalDateTime publishedAt) { this.publishedAt = publishedAt; } + public Long getCreatedBy() { return createdBy; } + public LocalDateTime getCreatedAt() { return createdAt; } +} +EOF +``` + +- [ ] **Step 4: 创建 SkillFile 实体** + +```bash +cat > server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/SkillFile.java << 'EOF' +package com.iflytek.skillhub.domain.skill; + +import jakarta.persistence.*; +import java.time.LocalDateTime; + +@Entity +@Table(name = "skill_file", uniqueConstraints = @UniqueConstraint(columnNames = {"version_id", "file_path"})) +public class SkillFile { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(name = "version_id", nullable = false) + private Long versionId; + + @Column(name = "file_path", nullable = false, length = 512) + private String filePath; + + @Column(name = "file_size", nullable = false) + private Long fileSize; + + @Column(name = "content_type", length = 128) + private String contentType; + + @Column(nullable = false, length = 64) + private String sha256; + + @Column(name = "storage_key", nullable = false, length = 512) + private String storageKey; + + @Column(name = "created_at", nullable = false, updatable = false) + private LocalDateTime createdAt; + + protected SkillFile() {} + + public SkillFile(Long versionId, String filePath, Long fileSize, String contentType, String sha256, String storageKey) { + this.versionId = versionId; + this.filePath = filePath; + this.fileSize = fileSize; + this.contentType = contentType; + this.sha256 = sha256; + this.storageKey = storageKey; + } + + @PrePersist + void prePersist() { + this.createdAt = LocalDateTime.now(); + } + + // Getters + public Long getId() { return id; } + public Long getVersionId() { return versionId; } + public String getFilePath() { return filePath; } + public Long getFileSize() { return fileSize; } + public String getContentType() { return contentType; } + public String getSha256() { return sha256; } + public String getStorageKey() { return storageKey; } + public LocalDateTime getCreatedAt() { return createdAt; } +} +EOF +``` + +- [ ] **Step 5: 创建 SkillTag 实体** + +```bash +cat > server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/SkillTag.java << 'EOF' +package com.iflytek.skillhub.domain.skill; + +import jakarta.persistence.*; +import java.time.LocalDateTime; + +@Entity +@Table(name = "skill_tag", uniqueConstraints = @UniqueConstraint(columnNames = {"skill_id", "tag_name"})) +public class SkillTag { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(name = "skill_id", nullable = false) + private Long skillId; + + @Column(name = "tag_name", nullable = false, length = 64) + private String tagName; + + @Column(name = "version_id", nullable = false) + private Long versionId; + + @Column(name = "created_by") + private Long createdBy; + + @Column(name = "created_at", nullable = false, updatable = false) + private LocalDateTime createdAt; + + @Column(name = "updated_at", nullable = false) + private LocalDateTime updatedAt; + + protected SkillTag() {} + + public SkillTag(Long skillId, String tagName, Long versionId, Long createdBy) { + this.skillId = skillId; + this.tagName = tagName; + this.versionId = versionId; + this.createdBy = createdBy; + } + + @PrePersist + void prePersist() { + this.createdAt = LocalDateTime.now(); + this.updatedAt = this.createdAt; + } + + @PreUpdate + void preUpdate() { + this.updatedAt = LocalDateTime.now(); + } + + // Getters and setters + public Long getId() { return id; } + public Long getSkillId() { return skillId; } + public String getTagName() { return tagName; } + public Long getVersionId() { return versionId; } + public void setVersionId(Long versionId) { this.versionId = versionId; } + public Long getCreatedBy() { return createdBy; } + public LocalDateTime getCreatedAt() { return createdAt; } + public LocalDateTime getUpdatedAt() { return updatedAt; } +} +EOF +``` + +- [ ] **Step 6: 创建 Repository 接口** + +```bash +cat > server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/SkillRepository.java << 'EOF' +package com.iflytek.skillhub.domain.skill; + +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; + +import java.util.List; +import java.util.Optional; + +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); +} +EOF + +cat > server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/SkillVersionRepository.java << 'EOF' +package com.iflytek.skillhub.domain.skill; + +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; + +import java.util.Optional; + +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); +} +EOF + +cat > server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/SkillFileRepository.java << 'EOF' +package com.iflytek.skillhub.domain.skill; + +import java.util.List; + +public interface SkillFileRepository { + List findByVersionId(Long versionId); + SkillFile save(SkillFile file); + void saveAll(List files); + void deleteByVersionId(Long versionId); +} +EOF + +cat > server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/SkillTagRepository.java << 'EOF' +package com.iflytek.skillhub.domain.skill; + +import java.util.List; +import java.util.Optional; + +public interface SkillTagRepository { + Optional findBySkillIdAndTagName(Long skillId, String tagName); + List findBySkillId(Long skillId); + SkillTag save(SkillTag tag); + void delete(SkillTag tag); +} +EOF +``` + +- [ ] **Step 7: 编译验证** + +Run: `cd server && ./mvnw clean compile -DskipTests` + +Expected: BUILD SUCCESS + +- [ ] **Step 8: Commit** + +```bash +git add server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/ +git commit -m "feat(domain): add Skill domain entities and repository interfaces + +- Add SkillStatus, SkillVersionStatus, SkillVisibility enums +- Add Skill entity with namespace/owner/visibility/status/stats fields +- Add SkillVersion entity with status/metadata/manifest JSONB fields +- Add SkillFile entity with storage_key and sha256 +- Add SkillTag entity with version reference +- Add repository interfaces for all entities with query methods" +``` + +### Task 8: Skill JPA Repository Implementations + +**Files:** +- Create: `server/skillhub-infra/src/main/java/com/iflytek/skillhub/infra/jpa/SkillJpaRepository.java` +- Create: `server/skillhub-infra/src/main/java/com/iflytek/skillhub/infra/jpa/SkillVersionJpaRepository.java` +- Create: `server/skillhub-infra/src/main/java/com/iflytek/skillhub/infra/jpa/SkillFileJpaRepository.java` +- Create: `server/skillhub-infra/src/main/java/com/iflytek/skillhub/infra/jpa/SkillTagJpaRepository.java` + +- [ ] **Step 1: 创建 SkillJpaRepository** + +```bash +cat > server/skillhub-infra/src/main/java/com/iflytek/skillhub/infra/jpa/SkillJpaRepository.java << 'EOF' +package com.iflytek.skillhub.infra.jpa; + +import com.iflytek.skillhub.domain.skill.Skill; +import com.iflytek.skillhub.domain.skill.SkillRepository; +import com.iflytek.skillhub.domain.skill.SkillStatus; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Modifying; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; +import org.springframework.stereotype.Repository; + +import java.util.List; +import java.util.Optional; + +@Repository +public interface SkillJpaRepository extends JpaRepository, SkillRepository { + + Optional findByNamespaceIdAndSlug(Long namespaceId, String slug); + + Page findByNamespaceIdAndStatus(Long namespaceId, SkillStatus status, Pageable pageable); + + List findByOwnerId(Long ownerId); + + @Modifying + @Query("UPDATE Skill s SET s.downloadCount = s.downloadCount + 1 WHERE s.id = :skillId") + void incrementDownloadCount(@Param("skillId") Long skillId); +} +EOF +``` + +- [ ] **Step 2: 创建 SkillVersionJpaRepository** + +```bash +cat > server/skillhub-infra/src/main/java/com/iflytek/skillhub/infra/jpa/SkillVersionJpaRepository.java << 'EOF' +package com.iflytek.skillhub.infra.jpa; + +import com.iflytek.skillhub.domain.skill.SkillVersion; +import com.iflytek.skillhub.domain.skill.SkillVersionRepository; +import com.iflytek.skillhub.domain.skill.SkillVersionStatus; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; + +import java.util.Optional; + +@Repository +public interface SkillVersionJpaRepository extends JpaRepository, SkillVersionRepository { + + Optional findBySkillIdAndVersion(Long skillId, String version); + + Page findBySkillIdAndStatus(Long skillId, SkillVersionStatus status, Pageable pageable); +} +EOF +``` + +- [ ] **Step 3: 创建 SkillFileJpaRepository** + +```bash +cat > server/skillhub-infra/src/main/java/com/iflytek/skillhub/infra/jpa/SkillFileJpaRepository.java << 'EOF' +package com.iflytek.skillhub.infra.jpa; + +import com.iflytek.skillhub.domain.skill.SkillFile; +import com.iflytek.skillhub.domain.skill.SkillFileRepository; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; + +import java.util.List; + +@Repository +public interface SkillFileJpaRepository extends JpaRepository, SkillFileRepository { + + List findByVersionId(Long versionId); + + void deleteByVersionId(Long versionId); +} +EOF +``` + +- [ ] **Step 4: 创建 SkillTagJpaRepository** + +```bash +cat > server/skillhub-infra/src/main/java/com/iflytek/skillhub/infra/jpa/SkillTagJpaRepository.java << 'EOF' +package com.iflytek.skillhub.infra.jpa; + +import com.iflytek.skillhub.domain.skill.SkillTag; +import com.iflytek.skillhub.domain.skill.SkillTagRepository; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; + +import java.util.List; +import java.util.Optional; + +@Repository +public interface SkillTagJpaRepository extends JpaRepository, SkillTagRepository { + + Optional findBySkillIdAndTagName(Long skillId, String tagName); + + List findBySkillId(Long skillId); +} +EOF +``` + +- [ ] **Step 5: 编译验证** + +Run: `cd server && ./mvnw clean compile -DskipTests` + +Expected: BUILD SUCCESS + +- [ ] **Step 6: Commit** + +```bash +git add server/skillhub-infra/src/main/java/com/iflytek/skillhub/infra/jpa/Skill*.java +git commit -m "feat(infra): add JPA repository implementations for Skill entities + +- Add SkillJpaRepository with incrementDownloadCount custom query +- Add SkillVersionJpaRepository with skill/version lookup +- Add SkillFileJpaRepository with version-based queries +- Add SkillTagJpaRepository with skill/tag lookup +- All extend domain repository interfaces and JpaRepository" +``` + +### Task 9: SkillMetadataParser — SKILL.md Frontmatter 解析器 + +**Files:** +- Create: `server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/metadata/SkillMetadata.java` +- Create: `server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/metadata/SkillMetadataParser.java` +- Test: `server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/skill/metadata/SkillMetadataParserTest.java` + +- [ ] **Step 1: 创建 SkillMetadata record** + +```bash +mkdir -p server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/metadata +cat > server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/metadata/SkillMetadata.java << 'EOF' +package com.iflytek.skillhub.domain.skill.metadata; + +import java.util.Map; + +public record SkillMetadata( + String name, + String description, + String version, + String body, + Map frontmatter +) {} +EOF +``` + +- [ ] **Step 2: 编写 SkillMetadataParser 测试** + +```bash +mkdir -p server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/skill/metadata +cat > server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/skill/metadata/SkillMetadataParserTest.java << 'EOF' +package com.iflytek.skillhub.domain.skill.metadata; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.*; + +class SkillMetadataParserTest { + + private SkillMetadataParser parser; + + @BeforeEach + void setUp() { + parser = new SkillMetadataParser(); + } + + @Test + void shouldParseStandardFrontmatterAndBody() { + String content = """ + --- + name: my-skill + description: A test skill + version: 1.0.0 + --- + # My Skill + + This is the body content. + """; + + SkillMetadata metadata = parser.parse(content); + + assertEquals("my-skill", metadata.name()); + assertEquals("A test skill", metadata.description()); + assertEquals("1.0.0", metadata.version()); + assertTrue(metadata.body().contains("# My Skill")); + assertTrue(metadata.body().contains("This is the body content.")); + } + + @Test + void shouldPreserveExtensionFields() { + String content = """ + --- + name: my-skill + description: A test skill + version: 1.0.0 + x-astron-category: productivity + --- + Body text + """; + + SkillMetadata metadata = parser.parse(content); + + assertEquals("productivity", metadata.frontmatter().get("x-astron-category")); + } + + @Test + void shouldThrowWhenNoFrontmatter() { + String content = "# Just a markdown file\nNo frontmatter here."; + + assertThrows(IllegalArgumentException.class, () -> parser.parse(content)); + } + + @Test + void shouldThrowWhenMissingName() { + String content = """ + --- + description: A test skill + version: 1.0.0 + --- + Body + """; + + assertThrows(IllegalArgumentException.class, () -> parser.parse(content)); + } + + @Test + void shouldThrowWhenMissingDescription() { + String content = """ + --- + name: my-skill + version: 1.0.0 + --- + Body + """; + + assertThrows(IllegalArgumentException.class, () -> parser.parse(content)); + } + + @Test + void shouldThrowWhenMissingVersion() { + String content = """ + --- + name: my-skill + description: A test skill + --- + Body + """; + + assertThrows(IllegalArgumentException.class, () -> parser.parse(content)); + } + + @Test + void shouldThrowWhenInvalidFrontmatterYaml() { + String content = """ + --- + name: [invalid yaml + --- + Body + """; + + assertThrows(IllegalArgumentException.class, () -> parser.parse(content)); + } +} +EOF +``` + +- [ ] **Step 3: 运行测试确认失败** + +Run: `cd server && ./mvnw test -pl skillhub-domain -Dtest=SkillMetadataParserTest` + +Expected: FAIL - SkillMetadataParser class not found + +- [ ] **Step 4: 确认 SnakeYAML 依赖在 skillhub-domain/pom.xml 中** + +在 `server/skillhub-domain/pom.xml` 中添加 SnakeYAML 依赖(如果尚未存在): + +```xml + + org.yaml + snakeyaml + +``` + +- [ ] **Step 5: 实现 SkillMetadataParser** + +```bash +cat > server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/metadata/SkillMetadataParser.java << 'EOF' +package com.iflytek.skillhub.domain.skill.metadata; + +import org.yaml.snakeyaml.Yaml; + +import java.util.Collections; +import java.util.Map; + +public class SkillMetadataParser { + + private static final String FRONTMATTER_DELIMITER = "---"; + + /** + * 解析 SKILL.md 内容,提取 frontmatter 和正文 + * @param skillMdContent SKILL.md 文件完整内容 + * @return 解析后的元数据 + * @throws IllegalArgumentException 如果格式不合法或缺少必需字段 + */ + @SuppressWarnings("unchecked") + public SkillMetadata parse(String skillMdContent) { + if (skillMdContent == null || skillMdContent.isBlank()) { + throw new IllegalArgumentException("SKILL.md content cannot be empty"); + } + + String trimmed = skillMdContent.strip(); + if (!trimmed.startsWith(FRONTMATTER_DELIMITER)) { + throw new IllegalArgumentException("SKILL.md must start with frontmatter (---)"); + } + + int secondDelimiter = trimmed.indexOf(FRONTMATTER_DELIMITER, FRONTMATTER_DELIMITER.length()); + if (secondDelimiter < 0) { + throw new IllegalArgumentException("SKILL.md frontmatter is not properly closed with ---"); + } + + String yamlContent = trimmed.substring(FRONTMATTER_DELIMITER.length(), secondDelimiter).strip(); + String body = trimmed.substring(secondDelimiter + FRONTMATTER_DELIMITER.length()).strip(); + + Map frontmatter; + try { + Yaml yaml = new Yaml(); + Object parsed = yaml.load(yamlContent); + if (parsed instanceof Map) { + frontmatter = (Map) parsed; + } else { + throw new IllegalArgumentException("Frontmatter must be a YAML mapping"); + } + } catch (Exception e) { + if (e instanceof IllegalArgumentException) throw e; + throw new IllegalArgumentException("Failed to parse frontmatter YAML: " + e.getMessage(), e); + } + + String name = getRequiredString(frontmatter, "name"); + String description = getRequiredString(frontmatter, "description"); + String version = getRequiredString(frontmatter, "version"); + + return new SkillMetadata(name, description, version, body, Collections.unmodifiableMap(frontmatter)); + } + + private String getRequiredString(Map map, String key) { + Object value = map.get(key); + if (value == null) { + throw new IllegalArgumentException("Required field '" + key + "' is missing in frontmatter"); + } + String str = value.toString().strip(); + if (str.isEmpty()) { + throw new IllegalArgumentException("Required field '" + key + "' cannot be empty"); + } + return str; + } +} +EOF +``` + +- [ ] **Step 6: 运行测试确认通过** + +Run: `cd server && ./mvnw test -pl skillhub-domain -Dtest=SkillMetadataParserTest` + +Expected: PASS - All tests pass + +- [ ] **Step 7: Commit** + +```bash +git add server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/metadata/ \ + server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/skill/metadata/ \ + server/skillhub-domain/pom.xml +git commit -m "feat(domain): add SkillMetadataParser for SKILL.md frontmatter parsing + +- Add SkillMetadata record with name/description/version/body/frontmatter +- Implement SkillMetadataParser using SnakeYAML +- Validate required fields (name, description, version) +- Preserve extension fields (x-astron-*) in frontmatter map +- Add comprehensive test coverage for valid/invalid inputs" +``` + +### Task 10: SkillPackageValidator + PrePublishValidator — 技能包校验 + +**Files:** +- Create: `server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/validation/PackageEntry.java` +- Create: `server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/validation/ValidationResult.java` +- Create: `server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/validation/SkillPackageValidator.java` +- Create: `server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/validation/PrePublishValidator.java` +- Create: `server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/validation/NoOpPrePublishValidator.java` +- Test: `server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/skill/validation/SkillPackageValidatorTest.java` + +- [ ] **Step 1: 创建 PackageEntry record 和 ValidationResult record** + +```bash +mkdir -p server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/validation + +cat > server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/validation/PackageEntry.java << 'EOF' +package com.iflytek.skillhub.domain.skill.validation; + +public record PackageEntry( + String path, + byte[] content, + long size, + String contentType +) {} +EOF + +cat > server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/validation/ValidationResult.java << 'EOF' +package com.iflytek.skillhub.domain.skill.validation; + +import java.util.List; + +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); + } + + public static ValidationResult fail(String error) { + return new ValidationResult(false, List.of(error)); + } +} +EOF +``` + +- [ ] **Step 2: 创建 PrePublishValidator 接口和 NoOpPrePublishValidator** + +```bash +cat > server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/validation/PrePublishValidator.java << 'EOF' +package com.iflytek.skillhub.domain.skill.validation; + +import com.iflytek.skillhub.domain.skill.metadata.SkillMetadata; + +import java.util.List; + +public interface PrePublishValidator { + + ValidationResult validate(SkillPackageContext context); + + record SkillPackageContext( + List entries, + SkillMetadata metadata, + Long publisherId, + Long namespaceId + ) {} +} +EOF + +cat > server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/validation/NoOpPrePublishValidator.java << 'EOF' +package com.iflytek.skillhub.domain.skill.validation; + +import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean; +import org.springframework.stereotype.Component; + +@Component +@ConditionalOnMissingBean(PrePublishValidator.class) +public class NoOpPrePublishValidator implements PrePublishValidator { + + @Override + public ValidationResult validate(SkillPackageContext context) { + return ValidationResult.pass(); + } +} +EOF +``` + +- [ ] **Step 3: 编写 SkillPackageValidator 测试** + +```bash +mkdir -p server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/skill/validation +cat > server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/skill/validation/SkillPackageValidatorTest.java << 'EOF' +package com.iflytek.skillhub.domain.skill.validation; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import java.nio.charset.StandardCharsets; +import java.util.ArrayList; +import java.util.List; + +import static org.junit.jupiter.api.Assertions.*; + +class SkillPackageValidatorTest { + + private SkillPackageValidator validator; + + private static final String VALID_SKILL_MD = """ + --- + name: my-skill + description: A test skill + version: 1.0.0 + --- + # My Skill + """; + + @BeforeEach + void setUp() { + validator = new SkillPackageValidator(); + } + + @Test + void shouldPassValidPackage() { + List entries = List.of( + entry("SKILL.md", VALID_SKILL_MD), + entry("README.md", "# README") + ); + ValidationResult result = validator.validate(entries); + assertTrue(result.passed()); + } + + @Test + void shouldFailWhenSkillMdMissing() { + List entries = List.of(entry("README.md", "# README")); + ValidationResult result = validator.validate(entries); + assertFalse(result.passed()); + assertTrue(result.errors().stream().anyMatch(e -> e.contains("SKILL.md"))); + } + + @Test + void shouldFailWhenDisallowedFileExtension() { + List entries = List.of( + entry("SKILL.md", VALID_SKILL_MD), + entry("malware.exe", "bad") + ); + ValidationResult result = validator.validate(entries); + assertFalse(result.passed()); + assertTrue(result.errors().stream().anyMatch(e -> e.contains(".exe"))); + } + + @Test + void shouldFailWhenSingleFileExceedsLimit() { + byte[] largeContent = new byte[1024 * 1024 + 1]; // > 1MB + List entries = List.of( + entry("SKILL.md", VALID_SKILL_MD), + new PackageEntry("large.md", largeContent, largeContent.length, "text/markdown") + ); + ValidationResult result = validator.validate(entries); + assertFalse(result.passed()); + assertTrue(result.errors().stream().anyMatch(e -> e.contains("size"))); + } + + @Test + void shouldFailWhenTooManyFiles() { + List entries = new ArrayList<>(); + entries.add(entry("SKILL.md", VALID_SKILL_MD)); + for (int i = 0; i < 100; i++) { + entries.add(entry("file" + i + ".md", "content")); + } + ValidationResult result = validator.validate(entries); + assertFalse(result.passed()); + assertTrue(result.errors().stream().anyMatch(e -> e.contains("count"))); + } + + @Test + void shouldFailWhenFrontmatterMissingName() { + String noName = """ + --- + description: A test skill + version: 1.0.0 + --- + Body + """; + List entries = List.of(entry("SKILL.md", noName)); + ValidationResult result = validator.validate(entries); + assertFalse(result.passed()); + assertTrue(result.errors().stream().anyMatch(e -> e.contains("name"))); + } + + private PackageEntry entry(String path, String content) { + byte[] bytes = content.getBytes(StandardCharsets.UTF_8); + return new PackageEntry(path, bytes, bytes.length, "text/plain"); + } +} +EOF +``` + +- [ ] **Step 4: 运行测试确认失败** + +Run: `cd server && ./mvnw test -pl skillhub-domain -Dtest=SkillPackageValidatorTest` + +Expected: FAIL - SkillPackageValidator class not found + +- [ ] **Step 5: 实现 SkillPackageValidator** + +```bash +cat > server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/validation/SkillPackageValidator.java << 'EOF' +package com.iflytek.skillhub.domain.skill.validation; + +import com.iflytek.skillhub.domain.skill.metadata.SkillMetadataParser; + +import java.util.ArrayList; +import java.util.List; +import java.util.Set; + +public class SkillPackageValidator { + + private static final long DEFAULT_MAX_FILE_SIZE = 1024 * 1024; // 1MB + private static final long DEFAULT_MAX_PACKAGE_SIZE = 10 * 1024 * 1024; // 10MB + private static final int DEFAULT_MAX_FILE_COUNT = 100; + private static final Set ALLOWED_EXTENSIONS = Set.of( + ".md", ".txt", ".json", ".yaml", ".yml", + ".js", ".ts", ".py", ".sh", + ".png", ".jpg", ".svg" + ); + + private final long maxFileSize; + private final long maxPackageSize; + private final int maxFileCount; + + public SkillPackageValidator() { + this(DEFAULT_MAX_FILE_SIZE, DEFAULT_MAX_PACKAGE_SIZE, DEFAULT_MAX_FILE_COUNT); + } + + public SkillPackageValidator(long maxFileSize, long maxPackageSize, int maxFileCount) { + this.maxFileSize = maxFileSize; + this.maxPackageSize = maxPackageSize; + this.maxFileCount = maxFileCount; + } + + public ValidationResult validate(List entries) { + List errors = new ArrayList<>(); + + // 1. SKILL.md 存在性 + PackageEntry skillMd = entries.stream() + .filter(e -> "SKILL.md".equals(e.path())) + .findFirst() + .orElse(null); + + if (skillMd == null) { + errors.add("SKILL.md is required at the package root"); + return ValidationResult.fail(errors); + } + + // 2. frontmatter 校验 + try { + SkillMetadataParser parser = new SkillMetadataParser(); + parser.parse(new String(skillMd.content())); + } catch (IllegalArgumentException e) { + errors.add("SKILL.md frontmatter error: " + e.getMessage()); + } + + // 3. 文件数量 + if (entries.size() > maxFileCount) { + errors.add("File count " + entries.size() + " exceeds maximum " + maxFileCount); + } + + // 4. 逐文件校验 + long totalSize = 0; + for (PackageEntry entry : entries) { + // 文件扩展名 + String ext = getExtension(entry.path()); + if (ext != null && !ALLOWED_EXTENSIONS.contains(ext)) { + errors.add("File type " + ext + " is not allowed: " + entry.path()); + } + + // 单文件大小 + if (entry.size() > maxFileSize) { + errors.add("File " + entry.path() + " size " + entry.size() + " exceeds maximum file size"); + } + + totalSize += entry.size(); + } + + // 5. 总包大小 + if (totalSize > maxPackageSize) { + errors.add("Total package size " + totalSize + " exceeds maximum " + maxPackageSize); + } + + return errors.isEmpty() ? ValidationResult.pass() : ValidationResult.fail(errors); + } + + private String getExtension(String path) { + int dot = path.lastIndexOf('.'); + if (dot < 0) return null; + return path.substring(dot).toLowerCase(); + } +} +EOF +``` + +- [ ] **Step 6: 运行测试确认通过** + +Run: `cd server && ./mvnw test -pl skillhub-domain -Dtest=SkillPackageValidatorTest` + +Expected: PASS - All tests pass + +- [ ] **Step 7: Commit** + +```bash +git add server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/validation/ \ + server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/skill/validation/ +git commit -m "feat(domain): add SkillPackageValidator and PrePublishValidator + +- Add PackageEntry record for zip entry representation +- Add ValidationResult record with pass/fail factory methods +- Add SkillPackageValidator with configurable limits: + - SKILL.md existence and frontmatter validation + - File extension whitelist (.md/.json/.yaml/.js/.ts/.py etc.) + - Single file size limit (1MB default) + - Total package size limit (10MB default) + - File count limit (100 default) +- Add PrePublishValidator SPI interface for extensible validation +- Add NoOpPrePublishValidator as default pass-through implementation +- Add comprehensive test coverage" +``` + +### Task 11: VisibilityChecker — 可见性检查器 + +**Files:** +- Create: `server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/VisibilityChecker.java` +- Test: `server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/skill/VisibilityCheckerTest.java` + +- [ ] **Step 1: 编写 VisibilityChecker 测试** + +```bash +mkdir -p server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/skill +cat > server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/skill/VisibilityCheckerTest.java << 'EOF' +package com.iflytek.skillhub.domain.skill; + +import com.iflytek.skillhub.domain.namespace.NamespaceRole; +import org.junit.jupiter.api.Test; + +import java.util.Map; + +import static org.junit.jupiter.api.Assertions.*; + +class VisibilityCheckerTest { + + private final VisibilityChecker checker = new VisibilityChecker(); + + private Skill createSkill(SkillVisibility visibility, Long namespaceId, Long ownerId) { + Skill skill = new Skill(namespaceId, "test-skill", ownerId, visibility); + return skill; + } + + @Test + void publicSkillAccessibleByAnonymous() { + Skill skill = createSkill(SkillVisibility.PUBLIC, 1L, 100L); + assertTrue(checker.canAccess(skill, null, Map.of())); + } + + @Test + void publicSkillAccessibleByAnyUser() { + Skill skill = createSkill(SkillVisibility.PUBLIC, 1L, 100L); + assertTrue(checker.canAccess(skill, 200L, Map.of())); + } + + @Test + void namespaceOnlySkillNotAccessibleByAnonymous() { + Skill skill = createSkill(SkillVisibility.NAMESPACE_ONLY, 1L, 100L); + assertFalse(checker.canAccess(skill, null, Map.of())); + } + + @Test + void namespaceOnlySkillNotAccessibleByNonMember() { + Skill skill = createSkill(SkillVisibility.NAMESPACE_ONLY, 1L, 100L); + assertFalse(checker.canAccess(skill, 200L, Map.of(2L, NamespaceRole.MEMBER))); + } + + @Test + void namespaceOnlySkillAccessibleByMember() { + Skill skill = createSkill(SkillVisibility.NAMESPACE_ONLY, 1L, 100L); + assertTrue(checker.canAccess(skill, 200L, Map.of(1L, NamespaceRole.MEMBER))); + } + + @Test + void privateSkillNotAccessibleByAnonymous() { + Skill skill = createSkill(SkillVisibility.PRIVATE, 1L, 100L); + assertFalse(checker.canAccess(skill, null, Map.of())); + } + + @Test + void privateSkillAccessibleByOwner() { + Skill skill = createSkill(SkillVisibility.PRIVATE, 1L, 100L); + assertTrue(checker.canAccess(skill, 100L, Map.of())); + } + + @Test + void privateSkillAccessibleByNamespaceAdmin() { + Skill skill = createSkill(SkillVisibility.PRIVATE, 1L, 100L); + assertTrue(checker.canAccess(skill, 200L, Map.of(1L, NamespaceRole.ADMIN))); + } + + @Test + void privateSkillAccessibleByNamespaceOwner() { + Skill skill = createSkill(SkillVisibility.PRIVATE, 1L, 100L); + assertTrue(checker.canAccess(skill, 200L, Map.of(1L, NamespaceRole.OWNER))); + } + + @Test + void privateSkillNotAccessibleByRegularMember() { + Skill skill = createSkill(SkillVisibility.PRIVATE, 1L, 100L); + assertFalse(checker.canAccess(skill, 200L, Map.of(1L, NamespaceRole.MEMBER))); + } +} +EOF +``` + +- [ ] **Step 2: 运行测试确认失败** + +Run: `cd server && ./mvnw test -pl skillhub-domain -Dtest=VisibilityCheckerTest` + +Expected: FAIL - VisibilityChecker class not found + +- [ ] **Step 3: 实现 VisibilityChecker** + +```bash +cat > server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/VisibilityChecker.java << 'EOF' +package com.iflytek.skillhub.domain.skill; + +import com.iflytek.skillhub.domain.namespace.NamespaceRole; + +import java.util.Map; + +public class VisibilityChecker { + + public boolean canAccess(Skill skill, Long currentUserId, + Map userNamespaceRoles) { + return switch (skill.getVisibility()) { + case PUBLIC -> true; + case NAMESPACE_ONLY -> userNamespaceRoles.containsKey(skill.getNamespaceId()); + case PRIVATE -> isOwner(skill, currentUserId) + || isAdminOrAbove(userNamespaceRoles.get(skill.getNamespaceId())); + }; + } + + private boolean isOwner(Skill skill, Long currentUserId) { + return currentUserId != null && skill.getOwnerId().equals(currentUserId); + } + + private boolean isAdminOrAbove(NamespaceRole role) { + return role == NamespaceRole.ADMIN || role == NamespaceRole.OWNER; + } +} +EOF +``` + +- [ ] **Step 4: 运行测试确认通过** + +Run: `cd server && ./mvnw test -pl skillhub-domain -Dtest=VisibilityCheckerTest` + +Expected: PASS - All tests pass + +- [ ] **Step 5: Commit** + +```bash +git add server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/VisibilityChecker.java \ + server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/skill/VisibilityCheckerTest.java +git commit -m "feat(domain): add VisibilityChecker for skill access control + +- PUBLIC: accessible by anyone including anonymous +- NAMESPACE_ONLY: accessible by namespace members +- PRIVATE: accessible by owner or namespace ADMIN/OWNER +- Add comprehensive test coverage for all visibility scenarios" +``` + +### Task 12: NamespaceService + NamespaceMemberService — 命名空间领域服务 + +**Files:** +- Create: `server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/namespace/NamespaceService.java` +- Create: `server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/namespace/NamespaceMemberService.java` +- Test: `server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/namespace/NamespaceServiceTest.java` +- Test: `server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/namespace/NamespaceMemberServiceTest.java` + +- [ ] **Step 1: 编写 NamespaceService 测试** + +```bash +cat > server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/namespace/NamespaceServiceTest.java << 'EOF' +package com.iflytek.skillhub.domain.namespace; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import java.util.Optional; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.*; + +@ExtendWith(MockitoExtension.class) +class NamespaceServiceTest { + + @Mock + private NamespaceRepository namespaceRepository; + @Mock + private NamespaceMemberRepository memberRepository; + + private NamespaceService service; + + @BeforeEach + void setUp() { + service = new NamespaceService(namespaceRepository, memberRepository); + } + + @Test + void shouldCreateNamespaceAndAssignOwner() { + when(namespaceRepository.findBySlug("my-team")).thenReturn(Optional.empty()); + when(namespaceRepository.save(any(Namespace.class))).thenAnswer(inv -> { + Namespace ns = inv.getArgument(0); + return ns; + }); + when(memberRepository.save(any(NamespaceMember.class))).thenAnswer(inv -> inv.getArgument(0)); + + Namespace result = service.createNamespace("my-team", "My Team", "A team namespace", 1L); + + assertEquals("my-team", result.getSlug()); + assertEquals(NamespaceType.TEAM, result.getType()); + verify(memberRepository).save(argThat(m -> + m.getRole() == NamespaceRole.OWNER && m.getUserId().equals(1L))); + } + + @Test + void shouldRejectDuplicateSlug() { + when(namespaceRepository.findBySlug("existing")).thenReturn(Optional.of(new Namespace())); + + assertThrows(IllegalArgumentException.class, + () -> service.createNamespace("existing", "Existing", "desc", 1L)); + } + + @Test + void shouldRejectReservedSlug() { + assertThrows(IllegalArgumentException.class, + () -> service.createNamespace("admin", "Admin", "desc", 1L)); + } + + @Test + void shouldUpdateNamespace() { + Namespace ns = new Namespace(); + when(namespaceRepository.findById(1L)).thenReturn(Optional.of(ns)); + when(namespaceRepository.save(any())).thenAnswer(inv -> inv.getArgument(0)); + + Namespace result = service.updateNamespace(1L, "New Name", "New Desc", "https://avatar.url"); + + assertEquals("New Name", result.getDisplayName()); + assertEquals("New Desc", result.getDescription()); + assertEquals("https://avatar.url", result.getAvatarUrl()); + } + + @Test + void shouldGetNamespaceBySlug() { + Namespace ns = new Namespace(); + when(namespaceRepository.findBySlug("my-team")).thenReturn(Optional.of(ns)); + + Namespace result = service.getNamespaceBySlug("my-team"); + assertNotNull(result); + } + + @Test + void shouldThrowWhenNamespaceNotFound() { + when(namespaceRepository.findBySlug("nonexistent")).thenReturn(Optional.empty()); + + assertThrows(IllegalArgumentException.class, + () -> service.getNamespaceBySlug("nonexistent")); + } +} +EOF +``` + +- [ ] **Step 2: 编写 NamespaceMemberService 测试** + +```bash +cat > server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/namespace/NamespaceMemberServiceTest.java << 'EOF' +package com.iflytek.skillhub.domain.namespace; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import java.util.Optional; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.*; + +@ExtendWith(MockitoExtension.class) +class NamespaceMemberServiceTest { + + @Mock + private NamespaceMemberRepository memberRepository; + + private NamespaceMemberService service; + + @BeforeEach + void setUp() { + service = new NamespaceMemberService(memberRepository); + } + + @Test + void shouldAddMember() { + when(memberRepository.findByNamespaceIdAndUserId(1L, 2L)).thenReturn(Optional.empty()); + when(memberRepository.save(any())).thenAnswer(inv -> inv.getArgument(0)); + + NamespaceMember result = service.addMember(1L, 2L, NamespaceRole.MEMBER); + + assertEquals(NamespaceRole.MEMBER, result.getRole()); + assertEquals(2L, result.getUserId()); + } + + @Test + void shouldRejectAddingAsOwner() { + assertThrows(IllegalArgumentException.class, + () -> service.addMember(1L, 2L, NamespaceRole.OWNER)); + } + + @Test + void shouldRejectDuplicateMember() { + when(memberRepository.findByNamespaceIdAndUserId(1L, 2L)) + .thenReturn(Optional.of(new NamespaceMember())); + + assertThrows(IllegalArgumentException.class, + () -> service.addMember(1L, 2L, NamespaceRole.MEMBER)); + } + + @Test + void shouldRemoveMember() { + NamespaceMember member = new NamespaceMember(); + member.setRole(NamespaceRole.MEMBER); + when(memberRepository.findByNamespaceIdAndUserId(1L, 2L)).thenReturn(Optional.of(member)); + + assertDoesNotThrow(() -> service.removeMember(1L, 2L)); + verify(memberRepository).deleteByNamespaceIdAndUserId(1L, 2L); + } + + @Test + void shouldRejectRemovingOwner() { + NamespaceMember owner = new NamespaceMember(); + owner.setRole(NamespaceRole.OWNER); + when(memberRepository.findByNamespaceIdAndUserId(1L, 2L)).thenReturn(Optional.of(owner)); + + assertThrows(IllegalArgumentException.class, + () -> service.removeMember(1L, 2L)); + } + + @Test + void shouldTransferOwnership() { + NamespaceMember currentOwner = new NamespaceMember(); + currentOwner.setRole(NamespaceRole.OWNER); + NamespaceMember newOwner = new NamespaceMember(); + newOwner.setRole(NamespaceRole.ADMIN); + + when(memberRepository.findByNamespaceIdAndUserId(1L, 10L)).thenReturn(Optional.of(currentOwner)); + when(memberRepository.findByNamespaceIdAndUserId(1L, 20L)).thenReturn(Optional.of(newOwner)); + when(memberRepository.save(any())).thenAnswer(inv -> inv.getArgument(0)); + + service.transferOwnership(1L, 10L, 20L); + + assertEquals(NamespaceRole.ADMIN, currentOwner.getRole()); + assertEquals(NamespaceRole.OWNER, newOwner.getRole()); + } + + @Test + void shouldUpdateMemberRole() { + NamespaceMember member = new NamespaceMember(); + member.setRole(NamespaceRole.MEMBER); + when(memberRepository.findByNamespaceIdAndUserId(1L, 2L)).thenReturn(Optional.of(member)); + when(memberRepository.save(any())).thenAnswer(inv -> inv.getArgument(0)); + + service.updateMemberRole(1L, 2L, NamespaceRole.ADMIN); + + assertEquals(NamespaceRole.ADMIN, member.getRole()); + } + + @Test + void shouldRejectSettingOwnerViaUpdateRole() { + assertThrows(IllegalArgumentException.class, + () -> service.updateMemberRole(1L, 2L, NamespaceRole.OWNER)); + } + + @Test + void shouldGetMemberRole() { + NamespaceMember member = new NamespaceMember(); + member.setRole(NamespaceRole.ADMIN); + when(memberRepository.findByNamespaceIdAndUserId(1L, 2L)).thenReturn(Optional.of(member)); + + Optional role = service.getMemberRole(1L, 2L); + assertTrue(role.isPresent()); + assertEquals(NamespaceRole.ADMIN, role.get()); + } +} +EOF +``` + +- [ ] **Step 3: 运行测试确认失败** + +Run: `cd server && ./mvnw test -pl skillhub-domain -Dtest="NamespaceServiceTest,NamespaceMemberServiceTest"` + +Expected: FAIL - NamespaceService/NamespaceMemberService class not found + +- [ ] **Step 4: 实现 NamespaceService** + +```bash +cat > server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/namespace/NamespaceService.java << 'EOF' +package com.iflytek.skillhub.domain.namespace; + +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +public class NamespaceService { + + private final NamespaceRepository namespaceRepository; + private final NamespaceMemberRepository memberRepository; + + public NamespaceService(NamespaceRepository namespaceRepository, + NamespaceMemberRepository memberRepository) { + this.namespaceRepository = namespaceRepository; + this.memberRepository = memberRepository; + } + + @Transactional + public Namespace createNamespace(String slug, String displayName, + String description, Long creatorUserId) { + SlugValidator.validate(slug); + + namespaceRepository.findBySlug(slug).ifPresent(ns -> { + throw new IllegalArgumentException("Namespace slug '" + slug + "' already exists"); + }); + + Namespace namespace = new Namespace(); + namespace.setSlug(slug); + namespace.setDisplayName(displayName); + namespace.setDescription(description); + namespace.setType(NamespaceType.TEAM); + namespace.setCreatedBy(creatorUserId); + Namespace saved = namespaceRepository.save(namespace); + + NamespaceMember ownerMember = new NamespaceMember(); + ownerMember.setNamespaceId(saved.getId()); + ownerMember.setUserId(creatorUserId); + ownerMember.setRole(NamespaceRole.OWNER); + memberRepository.save(ownerMember); + + return saved; + } + + @Transactional + public Namespace updateNamespace(Long namespaceId, String displayName, + String description, String avatarUrl) { + Namespace ns = namespaceRepository.findById(namespaceId) + .orElseThrow(() -> new IllegalArgumentException("Namespace not found: " + namespaceId)); + ns.setDisplayName(displayName); + ns.setDescription(description); + ns.setAvatarUrl(avatarUrl); + return namespaceRepository.save(ns); + } + + public Namespace getNamespaceBySlug(String slug) { + return namespaceRepository.findBySlug(slug) + .orElseThrow(() -> new IllegalArgumentException("Namespace not found: " + slug)); + } +} +EOF +``` + +- [ ] **Step 5: 实现 NamespaceMemberService** + +```bash +cat > server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/namespace/NamespaceMemberService.java << 'EOF' +package com.iflytek.skillhub.domain.namespace; + +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.Optional; + +@Service +public class NamespaceMemberService { + + private final NamespaceMemberRepository memberRepository; + + public NamespaceMemberService(NamespaceMemberRepository memberRepository) { + this.memberRepository = memberRepository; + } + + @Transactional + public NamespaceMember addMember(Long namespaceId, Long userId, NamespaceRole role) { + if (role == NamespaceRole.OWNER) { + throw new IllegalArgumentException("Cannot directly add member as OWNER, use transferOwnership"); + } + memberRepository.findByNamespaceIdAndUserId(namespaceId, userId).ifPresent(m -> { + throw new IllegalArgumentException("User " + userId + " is already a member of namespace " + namespaceId); + }); + + NamespaceMember member = new NamespaceMember(); + member.setNamespaceId(namespaceId); + member.setUserId(userId); + member.setRole(role); + return memberRepository.save(member); + } + + @Transactional + public void removeMember(Long namespaceId, Long userId) { + NamespaceMember member = memberRepository.findByNamespaceIdAndUserId(namespaceId, userId) + .orElseThrow(() -> new IllegalArgumentException("Member not found")); + if (member.getRole() == NamespaceRole.OWNER) { + throw new IllegalArgumentException("Cannot remove namespace OWNER"); + } + memberRepository.deleteByNamespaceIdAndUserId(namespaceId, userId); + } + + @Transactional + public void updateMemberRole(Long namespaceId, Long userId, NamespaceRole newRole) { + if (newRole == NamespaceRole.OWNER) { + throw new IllegalArgumentException("Cannot set OWNER via updateMemberRole, use transferOwnership"); + } + NamespaceMember member = memberRepository.findByNamespaceIdAndUserId(namespaceId, userId) + .orElseThrow(() -> new IllegalArgumentException("Member not found")); + member.setRole(newRole); + memberRepository.save(member); + } + + @Transactional + public void transferOwnership(Long namespaceId, Long currentOwnerId, Long newOwnerId) { + NamespaceMember currentOwner = memberRepository.findByNamespaceIdAndUserId(namespaceId, currentOwnerId) + .orElseThrow(() -> new IllegalArgumentException("Current owner not found")); + NamespaceMember newOwner = memberRepository.findByNamespaceIdAndUserId(namespaceId, newOwnerId) + .orElseThrow(() -> new IllegalArgumentException("New owner not found")); + + currentOwner.setRole(NamespaceRole.ADMIN); + newOwner.setRole(NamespaceRole.OWNER); + memberRepository.save(currentOwner); + memberRepository.save(newOwner); + } + + public Optional getMemberRole(Long namespaceId, Long userId) { + return memberRepository.findByNamespaceIdAndUserId(namespaceId, userId) + .map(NamespaceMember::getRole); + } +} +EOF +``` + +- [ ] **Step 6: 运行测试确认通过** + +Run: `cd server && ./mvnw test -pl skillhub-domain -Dtest="NamespaceServiceTest,NamespaceMemberServiceTest"` + +Expected: PASS - All tests pass + +- [ ] **Step 7: Commit** + +```bash +git add server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/namespace/NamespaceService.java \ + server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/namespace/NamespaceMemberService.java \ + server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/namespace/NamespaceServiceTest.java \ + server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/namespace/NamespaceMemberServiceTest.java +git commit -m "feat(domain): add NamespaceService and NamespaceMemberService + +- NamespaceService: create (with slug validation + auto OWNER), update, getBySlug +- NamespaceMemberService: add/remove/updateRole/transferOwnership/getMemberRole +- OWNER cannot be removed or set via updateRole +- transferOwnership: current OWNER → ADMIN, target → OWNER +- Add comprehensive unit tests with Mockito" +``` + +### Task 13: NamespaceController + 成员管理 API + +**Files:** +- Create: `server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/NamespaceRequest.java` +- Create: `server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/NamespaceResponse.java` +- Create: `server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/MemberRequest.java` +- Create: `server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/MemberResponse.java` +- Create: `server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/portal/NamespaceController.java` +- Test: `server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/NamespaceControllerTest.java` + +- [ ] **Step 1: 创建 DTO 类** + +```bash +mkdir -p server/skillhub-app/src/main/java/com/iflytek/skillhub/dto + +cat > server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/NamespaceRequest.java << 'EOF' +package com.iflytek.skillhub.dto; + +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Size; + +public record NamespaceRequest( + @NotBlank @Size(min = 2, max = 64) String slug, + @NotBlank @Size(max = 128) String displayName, + @Size(max = 512) String description +) {} +EOF + +cat > server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/NamespaceResponse.java << 'EOF' +package com.iflytek.skillhub.dto; + +import com.iflytek.skillhub.domain.namespace.Namespace; + +import java.time.LocalDateTime; + +public record NamespaceResponse( + Long id, + String slug, + String displayName, + String description, + String type, + String avatarUrl, + String status, + LocalDateTime createdAt +) { + public static NamespaceResponse from(Namespace ns) { + return new NamespaceResponse( + ns.getId(), ns.getSlug(), ns.getDisplayName(), + ns.getDescription(), ns.getType().name(), + ns.getAvatarUrl(), ns.getStatus().name(), ns.getCreatedAt() + ); + } +} +EOF + +cat > server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/MemberRequest.java << 'EOF' +package com.iflytek.skillhub.dto; + +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; + +public record MemberRequest( + @NotNull Long userId, + @NotBlank String role +) {} +EOF + +cat > server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/MemberResponse.java << 'EOF' +package com.iflytek.skillhub.dto; + +import com.iflytek.skillhub.domain.namespace.NamespaceMember; + +import java.time.LocalDateTime; + +public record MemberResponse( + Long id, + Long namespaceId, + Long userId, + String role, + LocalDateTime createdAt +) { + public static MemberResponse from(NamespaceMember m) { + return new MemberResponse( + m.getId(), m.getNamespaceId(), m.getUserId(), + m.getRole().name(), m.getCreatedAt() + ); + } +} +EOF +``` + +- [ ] **Step 2: 创建 NamespaceController** + +```bash +cat > server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/portal/NamespaceController.java << 'EOF' +package com.iflytek.skillhub.controller.portal; + +import com.iflytek.skillhub.domain.namespace.*; +import com.iflytek.skillhub.dto.*; +import jakarta.validation.Valid; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageRequest; +import org.springframework.http.ResponseEntity; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.web.bind.annotation.*; + +import java.util.Map; + +@RestController +@RequestMapping("/api/v1/namespaces") +public class NamespaceController { + + private final NamespaceService namespaceService; + private final NamespaceMemberService memberService; + private final NamespaceRepository namespaceRepository; + + public NamespaceController(NamespaceService namespaceService, + NamespaceMemberService memberService, + NamespaceRepository namespaceRepository) { + this.namespaceService = namespaceService; + this.memberService = memberService; + this.namespaceRepository = namespaceRepository; + } + + @GetMapping + public ResponseEntity listNamespaces( + @RequestParam(defaultValue = "0") int page, + @RequestParam(defaultValue = "20") int size) { + Page result = namespaceRepository.findByStatus( + NamespaceStatus.ACTIVE, PageRequest.of(page, size)); + return ResponseEntity.ok(Map.of( + "code", 0, + "data", Map.of( + "items", result.getContent().stream() + .map(NamespaceResponse::from).toList(), + "total", result.getTotalElements(), + "page", page, + "size", size + ) + )); + } + + @GetMapping("/{slug}") + public ResponseEntity getNamespace(@PathVariable String slug) { + Namespace ns = namespaceService.getNamespaceBySlug(slug); + return ResponseEntity.ok(Map.of("code", 0, "data", NamespaceResponse.from(ns))); + } + + @PostMapping + public ResponseEntity createNamespace( + @Valid @RequestBody NamespaceRequest request, + @AuthenticationPrincipal Long userId) { + Namespace ns = namespaceService.createNamespace( + request.slug(), request.displayName(), request.description(), userId); + return ResponseEntity.ok(Map.of("code", 0, "data", NamespaceResponse.from(ns))); + } + + @PutMapping("/{slug}") + public ResponseEntity updateNamespace( + @PathVariable String slug, + @RequestBody Map body, + @AuthenticationPrincipal Long userId) { + Namespace ns = namespaceService.getNamespaceBySlug(slug); + Namespace updated = namespaceService.updateNamespace( + ns.getId(), + body.getOrDefault("displayName", ns.getDisplayName()), + body.getOrDefault("description", ns.getDescription()), + body.getOrDefault("avatarUrl", ns.getAvatarUrl())); + return ResponseEntity.ok(Map.of("code", 0, "data", NamespaceResponse.from(updated))); + } + + @GetMapping("/{slug}/members") + public ResponseEntity listMembers( + @PathVariable String slug, + @RequestParam(defaultValue = "0") int page, + @RequestParam(defaultValue = "20") int size) { + Namespace ns = namespaceService.getNamespaceBySlug(slug); + Page members = memberService.listMembers( + ns.getId(), PageRequest.of(page, size)); + return ResponseEntity.ok(Map.of( + "code", 0, + "data", Map.of( + "items", members.getContent().stream() + .map(MemberResponse::from).toList(), + "total", members.getTotalElements() + ) + )); + } + + @PostMapping("/{slug}/members") + public ResponseEntity addMember( + @PathVariable String slug, + @Valid @RequestBody MemberRequest request) { + Namespace ns = namespaceService.getNamespaceBySlug(slug); + NamespaceMember member = memberService.addMember( + ns.getId(), request.userId(), NamespaceRole.valueOf(request.role())); + return ResponseEntity.ok(Map.of("code", 0, "data", MemberResponse.from(member))); + } + + @DeleteMapping("/{slug}/members/{userId}") + public ResponseEntity removeMember( + @PathVariable String slug, + @PathVariable Long userId) { + Namespace ns = namespaceService.getNamespaceBySlug(slug); + memberService.removeMember(ns.getId(), userId); + return ResponseEntity.ok(Map.of("code", 0, "message", "Member removed")); + } + + @PutMapping("/{slug}/members/{userId}/role") + public ResponseEntity updateMemberRole( + @PathVariable String slug, + @PathVariable Long userId, + @RequestBody Map body) { + Namespace ns = namespaceService.getNamespaceBySlug(slug); + memberService.updateMemberRole(ns.getId(), userId, + NamespaceRole.valueOf(body.get("role"))); + return ResponseEntity.ok(Map.of("code", 0, "message", "Role updated")); + } +} +EOF +``` + +- [ ] **Step 3: 补充 NamespaceMemberService.listMembers 方法** + +在 `NamespaceMemberService.java` 中添加: + +```java +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; + +public Page listMembers(Long namespaceId, Pageable pageable) { + return memberRepository.findByNamespaceId(namespaceId, pageable); +} +``` + +- [ ] **Step 4: 编译验证** + +Run: `cd server && ./mvnw clean compile -DskipTests` + +Expected: BUILD SUCCESS + +- [ ] **Step 5: Commit** + +```bash +git add server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/ \ + server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/portal/NamespaceController.java \ + server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/namespace/NamespaceMemberService.java +git commit -m "feat(api): add NamespaceController with CRUD and member management + +- Add NamespaceRequest/Response, MemberRequest/Response DTOs +- GET/POST namespaces, GET/PUT namespace by slug +- GET/POST/DELETE members, PUT member role +- Add listMembers method to NamespaceMemberService" +``` + +### Task 14: Domain Events — 领域事件定义 + +**Files:** +- Create: `server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/event/SkillPublishedEvent.java` +- Create: `server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/event/SkillDownloadedEvent.java` +- Create: `server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/event/SkillStatusChangedEvent.java` + +- [ ] **Step 1: 创建领域事件 record** + +```bash +mkdir -p server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/event + +cat > server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/event/SkillPublishedEvent.java << 'EOF' +package com.iflytek.skillhub.domain.event; + +public record SkillPublishedEvent(Long skillId, Long versionId, Long publisherId) {} +EOF + +cat > server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/event/SkillDownloadedEvent.java << 'EOF' +package com.iflytek.skillhub.domain.event; + +public record SkillDownloadedEvent(Long skillId, Long versionId) {} +EOF + +cat > server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/event/SkillStatusChangedEvent.java << 'EOF' +package com.iflytek.skillhub.domain.event; + +import com.iflytek.skillhub.domain.skill.SkillStatus; + +public record SkillStatusChangedEvent(Long skillId, SkillStatus oldStatus, SkillStatus newStatus) {} +EOF +``` + +- [ ] **Step 2: 编译验证** + +Run: `cd server && ./mvnw clean compile -DskipTests` + +Expected: BUILD SUCCESS + +- [ ] **Step 3: Commit** + +```bash +git add server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/event/ +git commit -m "feat(domain): add domain event records + +- SkillPublishedEvent: triggered after skill version published +- SkillDownloadedEvent: triggered after skill downloaded +- SkillStatusChangedEvent: triggered on skill status transitions" +``` + +### Task 15: SkillPublishService — 技能发布服务 + +**Files:** +- Create: `server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/service/SkillPublishService.java` +- Test: `server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/skill/service/SkillPublishServiceTest.java` + +- [ ] **Step 1: 编写 SkillPublishService 测试** + +```bash +mkdir -p server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/skill/service +cat > server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/skill/service/SkillPublishServiceTest.java << 'EOF' +package com.iflytek.skillhub.domain.skill.service; + +import com.iflytek.skillhub.domain.namespace.*; +import com.iflytek.skillhub.domain.skill.*; +import com.iflytek.skillhub.domain.skill.metadata.SkillMetadataParser; +import com.iflytek.skillhub.domain.skill.validation.PackageEntry; +import com.iflytek.skillhub.domain.skill.validation.SkillPackageValidator; +import com.iflytek.skillhub.domain.skill.validation.PrePublishValidator; +import com.iflytek.skillhub.domain.skill.validation.ValidationResult; +import com.iflytek.skillhub.storage.ObjectStorageService; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.context.ApplicationEventPublisher; + +import java.nio.charset.StandardCharsets; +import java.util.List; +import java.util.Optional; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.*; + +@ExtendWith(MockitoExtension.class) +class SkillPublishServiceTest { + + @Mock private NamespaceRepository namespaceRepository; + @Mock private NamespaceMemberRepository memberRepository; + @Mock private SkillRepository skillRepository; + @Mock private SkillVersionRepository versionRepository; + @Mock private SkillFileRepository fileRepository; + @Mock private ObjectStorageService storageService; + @Mock private PrePublishValidator prePublishValidator; + @Mock private ApplicationEventPublisher eventPublisher; + + private SkillPublishService publishService; + + private static final String VALID_SKILL_MD = """ + --- + name: my-skill + description: A test skill + version: 1.0.0 + --- + # My Skill + Body content. + """; + + @BeforeEach + void setUp() { + publishService = new SkillPublishService( + namespaceRepository, memberRepository, + skillRepository, versionRepository, fileRepository, + storageService, new SkillPackageValidator(), + new SkillMetadataParser(), prePublishValidator, + eventPublisher); + } + + @Test + void shouldPublishNewSkill() { + Namespace ns = new Namespace(); + ns.setSlug("global"); + when(namespaceRepository.findBySlug("global")).thenReturn(Optional.of(ns)); + when(memberRepository.findByNamespaceIdAndUserId(any(), eq(1L))) + .thenReturn(Optional.of(new NamespaceMember())); + when(skillRepository.findByNamespaceIdAndSlug(any(), eq("my-skill"))) + .thenReturn(Optional.empty()); + when(skillRepository.save(any())).thenAnswer(inv -> { + Skill s = inv.getArgument(0); + return s; + }); + when(versionRepository.save(any())).thenAnswer(inv -> inv.getArgument(0)); + when(prePublishValidator.validate(any())).thenReturn(ValidationResult.pass()); + + List entries = List.of( + entry("SKILL.md", VALID_SKILL_MD), + entry("README.md", "# README") + ); + + SkillVersion result = publishService.publishFromEntries("global", entries, 1L, SkillVisibility.PUBLIC); + + assertNotNull(result); + assertEquals("1.0.0", result.getVersion()); + assertEquals(SkillVersionStatus.PUBLISHED, result.getStatus()); + verify(storageService, atLeastOnce()).putObject(any(), any(), anyLong(), any()); + verify(eventPublisher).publishEvent(any()); + } + + @Test + void shouldRejectWhenNamespaceNotFound() { + when(namespaceRepository.findBySlug("nonexistent")).thenReturn(Optional.empty()); + + List entries = List.of(entry("SKILL.md", VALID_SKILL_MD)); + + assertThrows(IllegalArgumentException.class, + () -> publishService.publishFromEntries("nonexistent", entries, 1L, SkillVisibility.PUBLIC)); + } + + @Test + void shouldRejectWhenNotMember() { + Namespace ns = new Namespace(); + when(namespaceRepository.findBySlug("team")).thenReturn(Optional.of(ns)); + when(memberRepository.findByNamespaceIdAndUserId(any(), eq(1L))) + .thenReturn(Optional.empty()); + + List entries = List.of(entry("SKILL.md", VALID_SKILL_MD)); + + assertThrows(IllegalArgumentException.class, + () -> publishService.publishFromEntries("team", entries, 1L, SkillVisibility.PUBLIC)); + } + + private PackageEntry entry(String path, String content) { + byte[] bytes = content.getBytes(StandardCharsets.UTF_8); + return new PackageEntry(path, bytes, bytes.length, "text/plain"); + } +} +EOF +``` + +- [ ] **Step 2: 运行测试确认失败** + +Run: `cd server && ./mvnw test -pl skillhub-domain -Dtest=SkillPublishServiceTest` + +Expected: FAIL - SkillPublishService class not found + +- [ ] **Step 3: 实现 SkillPublishService** + +```bash +mkdir -p server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/service +cat > server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/service/SkillPublishService.java << 'JAVAEOF' +package com.iflytek.skillhub.domain.skill.service; + +import com.iflytek.skillhub.domain.event.SkillPublishedEvent; +import com.iflytek.skillhub.domain.namespace.*; +import com.iflytek.skillhub.domain.skill.*; +import com.iflytek.skillhub.domain.skill.metadata.SkillMetadata; +import com.iflytek.skillhub.domain.skill.metadata.SkillMetadataParser; +import com.iflytek.skillhub.domain.skill.validation.*; +import com.iflytek.skillhub.storage.ObjectStorageService; +import org.springframework.context.ApplicationEventPublisher; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.io.ByteArrayInputStream; +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; +import java.time.LocalDateTime; +import java.util.ArrayList; +import java.util.HexFormat; +import java.util.List; + +@Service +public class SkillPublishService { + + private final NamespaceRepository namespaceRepository; + private final NamespaceMemberRepository memberRepository; + private final SkillRepository skillRepository; + private final SkillVersionRepository versionRepository; + private final SkillFileRepository fileRepository; + private final ObjectStorageService storageService; + private final SkillPackageValidator packageValidator; + private final SkillMetadataParser metadataParser; + private final PrePublishValidator prePublishValidator; + private final ApplicationEventPublisher eventPublisher; + + public SkillPublishService(NamespaceRepository namespaceRepository, + NamespaceMemberRepository memberRepository, + SkillRepository skillRepository, + SkillVersionRepository versionRepository, + SkillFileRepository fileRepository, + ObjectStorageService storageService, + SkillPackageValidator packageValidator, + SkillMetadataParser metadataParser, + PrePublishValidator prePublishValidator, + ApplicationEventPublisher eventPublisher) { + this.namespaceRepository = namespaceRepository; + this.memberRepository = memberRepository; + this.skillRepository = skillRepository; + this.versionRepository = versionRepository; + this.fileRepository = fileRepository; + this.storageService = storageService; + this.packageValidator = packageValidator; + this.metadataParser = metadataParser; + this.prePublishValidator = prePublishValidator; + this.eventPublisher = eventPublisher; + } +JAVAEOF +``` + +在同一文件末尾追加 `publishFromEntries` 方法(使用 `cat >>` 追加): + +```bash +cat >> server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/service/SkillPublishService.java << 'JAVAEOF' + + @Transactional + public SkillVersion publishFromEntries(String namespaceSlug, + List entries, + Long publisherId, + SkillVisibility visibility) { + // ① 解析 namespace + Namespace ns = namespaceRepository.findBySlug(namespaceSlug) + .orElseThrow(() -> new IllegalArgumentException("Namespace not found: " + namespaceSlug)); + + // ② 权限校验 + memberRepository.findByNamespaceIdAndUserId(ns.getId(), publisherId) + .orElseThrow(() -> new IllegalArgumentException( + "User " + publisherId + " is not a member of namespace " + namespaceSlug)); + + // ③ 技能包校验 + ValidationResult validationResult = packageValidator.validate(entries); + if (!validationResult.passed()) { + throw new IllegalArgumentException("Package validation failed: " + + String.join("; ", validationResult.errors())); + } + + // ④ 解析 SKILL.md + PackageEntry skillMdEntry = entries.stream() + .filter(e -> "SKILL.md".equals(e.path())) + .findFirst().orElseThrow(); + SkillMetadata metadata = metadataParser.parse( + new String(skillMdEntry.content(), StandardCharsets.UTF_8)); + + // ⑤ PrePublishValidator + ValidationResult preResult = prePublishValidator.validate( + new PrePublishValidator.SkillPackageContext(entries, metadata, publisherId, ns.getId())); + if (!preResult.passed()) { + throw new IllegalArgumentException("Pre-publish validation failed: " + + String.join("; ", preResult.errors())); + } + + // ⑥ 创建/关联 skill + Skill skill = skillRepository.findByNamespaceIdAndSlug(ns.getId(), metadata.name()) + .orElseGet(() -> { + Skill s = new Skill(ns.getId(), metadata.name(), publisherId, visibility); + s.setDisplayName(metadata.name()); + s.setSummary(metadata.description()); + s.setCreatedBy(publisherId); + return skillRepository.save(s); + }); + + // ⑦ 版本冲突检查 + versionRepository.findBySkillIdAndVersion(skill.getId(), metadata.version()) + .ifPresent(v -> { + throw new IllegalArgumentException("Version " + metadata.version() + " already exists"); + }); + + // ⑧ 创建版本 + SkillVersion version = new SkillVersion(skill.getId(), metadata.version(), publisherId); + version.setStatus(SkillVersionStatus.PUBLISHED); + version.setPublishedAt(LocalDateTime.now()); + version.setChangelog(metadata.description()); + version.setParsedMetadataJson(metadata.frontmatter().toString()); + version = versionRepository.save(version); + + // ⑨ 写入对象存储 + 持久化文件记录 + long totalSize = 0; + List files = new ArrayList<>(); + for (PackageEntry entry : entries) { + String storageKey = String.format("skills/%d/%d/%s", + skill.getId(), version.getId(), entry.path()); + storageService.putObject(storageKey, + new ByteArrayInputStream(entry.content()), + entry.size(), entry.contentType()); + + String sha256 = sha256Hex(entry.content()); + files.add(new SkillFile(version.getId(), entry.path(), + entry.size(), entry.contentType(), sha256, storageKey)); + totalSize += entry.size(); + } + fileRepository.saveAll(files); + + // ⑩ 更新版本统计 + version.setFileCount(files.size()); + version.setTotalSize(totalSize); + versionRepository.save(version); + + // ⑪ 更新 skill + skill.setLatestVersionId(version.getId()); + skill.setDisplayName(metadata.name()); + skill.setSummary(metadata.description()); + skill.setUpdatedBy(publisherId); + skillRepository.save(skill); + + // ⑫ 发布事件 + eventPublisher.publishEvent( + new SkillPublishedEvent(skill.getId(), version.getId(), publisherId)); + + return version; + } + + private String sha256Hex(byte[] data) { + try { + MessageDigest digest = MessageDigest.getInstance("SHA-256"); + byte[] hash = digest.digest(data); + return HexFormat.of().formatHex(hash); + } catch (NoSuchAlgorithmException e) { + throw new RuntimeException("SHA-256 not available", e); + } + } +} +JAVAEOF +``` + +- [ ] **Step 4: 运行测试确认通过** + +Run: `cd server && ./mvnw test -pl skillhub-domain -Dtest=SkillPublishServiceTest` + +Expected: PASS - All tests pass + +- [ ] **Step 5: Commit** + +```bash +git add server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/service/SkillPublishService.java \ + server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/skill/service/SkillPublishServiceTest.java +git commit -m "feat(domain): add SkillPublishService with full publish flow + +- Namespace lookup + membership check +- Package validation + SKILL.md parsing +- PrePublishValidator extension point +- Create/reuse skill record, version conflict check +- Upload files to object storage with SHA-256 +- Persist skill_version + skill_file records +- Update skill.latest_version_id +- Publish SkillPublishedEvent for async processing" +``` + +### Task 16: SkillQueryService — 技能查询服务 + +**Files:** +- Create: `server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/service/SkillQueryService.java` +- Test: `server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/skill/service/SkillQueryServiceTest.java` + +- [ ] **Step 1: 编写 SkillQueryService 测试** + +```bash +cat > server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/skill/service/SkillQueryServiceTest.java << 'EOF' +package com.iflytek.skillhub.domain.skill.service; + +import com.iflytek.skillhub.domain.namespace.*; +import com.iflytek.skillhub.domain.skill.*; +import com.iflytek.skillhub.storage.ObjectStorageService; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageImpl; +import org.springframework.data.domain.PageRequest; + +import java.io.ByteArrayInputStream; +import java.nio.charset.StandardCharsets; +import java.util.List; +import java.util.Map; +import java.util.Optional; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +class SkillQueryServiceTest { + + @Mock private NamespaceRepository namespaceRepository; + @Mock private NamespaceMemberRepository memberRepository; + @Mock private SkillRepository skillRepository; + @Mock private SkillVersionRepository versionRepository; + @Mock private SkillFileRepository fileRepository; + @Mock private ObjectStorageService storageService; + + private SkillQueryService queryService; + + @BeforeEach + void setUp() { + queryService = new SkillQueryService( + namespaceRepository, memberRepository, + skillRepository, versionRepository, + fileRepository, storageService, + new VisibilityChecker()); + } + + @Test + void shouldGetSkillDetail() { + Namespace ns = new Namespace(); + Skill skill = new Skill(1L, "my-skill", 100L, SkillVisibility.PUBLIC); + skill.setLatestVersionId(10L); + SkillVersion version = new SkillVersion(1L, "1.0.0", 100L); + + when(namespaceRepository.findBySlug("global")).thenReturn(Optional.of(ns)); + when(skillRepository.findByNamespaceIdAndSlug(any(), eq("my-skill"))) + .thenReturn(Optional.of(skill)); + when(versionRepository.findById(10L)).thenReturn(Optional.of(version)); + + SkillQueryService.SkillDetailDTO result = queryService.getSkillDetail( + "global", "my-skill", null, Map.of()); + + assertNotNull(result); + assertEquals("my-skill", result.slug()); + assertEquals("1.0.0", result.latestVersion()); + } + + @Test + void shouldRejectAccessToPrivateSkillByAnonymous() { + Namespace ns = new Namespace(); + Skill skill = new Skill(1L, "secret", 100L, SkillVisibility.PRIVATE); + + when(namespaceRepository.findBySlug("team")).thenReturn(Optional.of(ns)); + when(skillRepository.findByNamespaceIdAndSlug(any(), eq("secret"))) + .thenReturn(Optional.of(skill)); + + assertThrows(IllegalArgumentException.class, + () -> queryService.getSkillDetail("team", "secret", null, Map.of())); + } + + @Test + void shouldListSkillsByNamespace() { + Namespace ns = new Namespace(); + Skill skill = new Skill(1L, "skill-1", 100L, SkillVisibility.PUBLIC); + Page page = new PageImpl<>(List.of(skill)); + + when(namespaceRepository.findBySlug("global")).thenReturn(Optional.of(ns)); + when(skillRepository.findByNamespaceIdAndStatus(any(), eq(SkillStatus.ACTIVE), any())) + .thenReturn(page); + + Page result = queryService.listSkillsByNamespace( + "global", null, Map.of(), PageRequest.of(0, 20)); + + assertEquals(1, result.getTotalElements()); + } + + @Test + void shouldListFiles() { + Namespace ns = new Namespace(); + Skill skill = new Skill(1L, "my-skill", 100L, SkillVisibility.PUBLIC); + SkillVersion version = new SkillVersion(1L, "1.0.0", 100L); + SkillFile file = new SkillFile(1L, "SKILL.md", 100L, "text/markdown", "abc123", "key"); + + when(namespaceRepository.findBySlug("global")).thenReturn(Optional.of(ns)); + when(skillRepository.findByNamespaceIdAndSlug(any(), eq("my-skill"))) + .thenReturn(Optional.of(skill)); + when(versionRepository.findBySkillIdAndVersion(any(), eq("1.0.0"))) + .thenReturn(Optional.of(version)); + when(fileRepository.findByVersionId(any())).thenReturn(List.of(file)); + + List files = queryService.listFiles("global", "my-skill", "1.0.0"); + + assertEquals(1, files.size()); + assertEquals("SKILL.md", files.get(0).getFilePath()); + } +} +EOF +``` + +- [ ] **Step 2: 运行测试确认失败** + +Run: `cd server && ./mvnw test -pl skillhub-domain -Dtest=SkillQueryServiceTest` + +Expected: FAIL - SkillQueryService class not found + +- [ ] **Step 3: 实现 SkillQueryService** + +```bash +cat > server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/service/SkillQueryService.java << 'EOF' +package com.iflytek.skillhub.domain.skill.service; + +import com.iflytek.skillhub.domain.namespace.*; +import com.iflytek.skillhub.domain.skill.*; +import com.iflytek.skillhub.storage.ObjectStorageService; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.stereotype.Service; + +import java.io.InputStream; +import java.util.List; +import java.util.Map; + +@Service +public class SkillQueryService { + + private final NamespaceRepository namespaceRepository; + private final NamespaceMemberRepository memberRepository; + private final SkillRepository skillRepository; + private final SkillVersionRepository versionRepository; + private final SkillFileRepository fileRepository; + private final ObjectStorageService storageService; + private final VisibilityChecker visibilityChecker; + + public SkillQueryService(NamespaceRepository namespaceRepository, + NamespaceMemberRepository memberRepository, + SkillRepository skillRepository, + SkillVersionRepository versionRepository, + SkillFileRepository fileRepository, + ObjectStorageService storageService, + VisibilityChecker visibilityChecker) { + this.namespaceRepository = namespaceRepository; + this.memberRepository = memberRepository; + this.skillRepository = skillRepository; + this.versionRepository = versionRepository; + this.fileRepository = fileRepository; + this.storageService = storageService; + this.visibilityChecker = visibilityChecker; + } + + public record SkillDetailDTO( + Long id, String slug, String displayName, String summary, + String visibility, String status, Long downloadCount, + Integer starCount, String latestVersion, Long namespaceId + ) {} + + public SkillDetailDTO getSkillDetail(String namespaceSlug, String skillSlug, + Long currentUserId, + Map userNsRoles) { + Namespace ns = findNamespace(namespaceSlug); + Skill skill = skillRepository.findByNamespaceIdAndSlug(ns.getId(), skillSlug) + .orElseThrow(() -> new IllegalArgumentException("Skill not found: " + skillSlug)); + + if (!visibilityChecker.canAccess(skill, currentUserId, userNsRoles)) { + throw new IllegalArgumentException("Access denied to skill: " + skillSlug); + } + + String latestVersion = null; + if (skill.getLatestVersionId() != null) { + latestVersion = versionRepository.findById(skill.getLatestVersionId()) + .map(SkillVersion::getVersion).orElse(null); + } + + return new SkillDetailDTO( + skill.getId(), skill.getSlug(), skill.getDisplayName(), + skill.getSummary(), skill.getVisibility().name(), + skill.getStatus().name(), skill.getDownloadCount(), + skill.getStarCount(), latestVersion, skill.getNamespaceId()); + } + + public Page listSkillsByNamespace(String namespaceSlug, + Long currentUserId, + Map userNsRoles, + Pageable pageable) { + Namespace ns = findNamespace(namespaceSlug); + return skillRepository.findByNamespaceIdAndStatus( + ns.getId(), SkillStatus.ACTIVE, pageable); + } + + public List listFiles(String namespaceSlug, String skillSlug, String version) { + Namespace ns = findNamespace(namespaceSlug); + Skill skill = skillRepository.findByNamespaceIdAndSlug(ns.getId(), skillSlug) + .orElseThrow(() -> new IllegalArgumentException("Skill not found: " + skillSlug)); + SkillVersion sv = versionRepository.findBySkillIdAndVersion(skill.getId(), version) + .orElseThrow(() -> new IllegalArgumentException("Version not found: " + version)); + return fileRepository.findByVersionId(sv.getId()); + } + + public InputStream getFileContent(String namespaceSlug, String skillSlug, + String version, String filePath) { + Namespace ns = findNamespace(namespaceSlug); + Skill skill = skillRepository.findByNamespaceIdAndSlug(ns.getId(), skillSlug) + .orElseThrow(() -> new IllegalArgumentException("Skill not found")); + SkillVersion sv = versionRepository.findBySkillIdAndVersion(skill.getId(), version) + .orElseThrow(() -> new IllegalArgumentException("Version not found")); + String storageKey = String.format("skills/%d/%d/%s", skill.getId(), sv.getId(), filePath); + return storageService.getObject(storageKey); + } + + public Page listVersions(String namespaceSlug, String skillSlug, + Pageable pageable) { + Namespace ns = findNamespace(namespaceSlug); + Skill skill = skillRepository.findByNamespaceIdAndSlug(ns.getId(), skillSlug) + .orElseThrow(() -> new IllegalArgumentException("Skill not found")); + return versionRepository.findBySkillIdAndStatus( + skill.getId(), SkillVersionStatus.PUBLISHED, pageable); + } + + private Namespace findNamespace(String slug) { + return namespaceRepository.findBySlug(slug) + .orElseThrow(() -> new IllegalArgumentException("Namespace not found: " + slug)); + } +} +EOF +``` + +- [ ] **Step 4: 运行测试确认通过** + +Run: `cd server && ./mvnw test -pl skillhub-domain -Dtest=SkillQueryServiceTest` + +Expected: PASS - All tests pass + +- [ ] **Step 5: Commit** + +```bash +git add server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/service/SkillQueryService.java \ + server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/skill/service/SkillQueryServiceTest.java +git commit -m "feat(domain): add SkillQueryService for skill detail/list/files/versions + +- getSkillDetail with visibility check +- listSkillsByNamespace with ACTIVE filter +- listFiles by namespace/slug/version +- getFileContent from object storage +- listVersions (PUBLISHED only) +- Add unit tests with Mockito" +``` + +### Task 17: SkillDownloadService — 技能下载服务 + +**Files:** +- Create: `server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/service/SkillDownloadService.java` +- Test: `server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/skill/service/SkillDownloadServiceTest.java` + +- [ ] **Step 1: 编写 SkillDownloadService 测试** + +```bash +cat > server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/skill/service/SkillDownloadServiceTest.java << 'EOF' +package com.iflytek.skillhub.domain.skill.service; + +import com.iflytek.skillhub.domain.namespace.*; +import com.iflytek.skillhub.domain.skill.*; +import com.iflytek.skillhub.storage.ObjectStorageService; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.context.ApplicationEventPublisher; + +import java.io.ByteArrayInputStream; +import java.io.InputStream; +import java.util.Map; +import java.util.Optional; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.*; + +@ExtendWith(MockitoExtension.class) +class SkillDownloadServiceTest { + + @Mock private NamespaceRepository namespaceRepository; + @Mock private SkillRepository skillRepository; + @Mock private SkillVersionRepository versionRepository; + @Mock private SkillTagRepository tagRepository; + @Mock private ObjectStorageService storageService; + @Mock private ApplicationEventPublisher eventPublisher; + + private SkillDownloadService downloadService; + + @BeforeEach + void setUp() { + downloadService = new SkillDownloadService( + namespaceRepository, skillRepository, versionRepository, + tagRepository, storageService, new VisibilityChecker(), + eventPublisher); + } + + @Test + void shouldDownloadLatestVersion() { + Namespace ns = new Namespace(); + Skill skill = new Skill(1L, "my-skill", 100L, SkillVisibility.PUBLIC); + skill.setLatestVersionId(10L); + SkillVersion version = new SkillVersion(1L, "1.0.0", 100L); + + when(namespaceRepository.findBySlug("global")).thenReturn(Optional.of(ns)); + when(skillRepository.findByNamespaceIdAndSlug(any(), eq("my-skill"))) + .thenReturn(Optional.of(skill)); + when(versionRepository.findById(10L)).thenReturn(Optional.of(version)); + when(storageService.getObject(any())) + .thenReturn(new ByteArrayInputStream("zip-data".getBytes())); + + SkillDownloadService.DownloadResult result = downloadService.downloadLatest( + "global", "my-skill", null, Map.of()); + + assertNotNull(result); + assertEquals("my-skill-1.0.0.zip", result.filename()); + verify(eventPublisher).publishEvent(any()); + } + + @Test + void shouldDownloadByTag() { + Namespace ns = new Namespace(); + Skill skill = new Skill(1L, "my-skill", 100L, SkillVisibility.PUBLIC); + SkillTag tag = new SkillTag(1L, "stable", 10L, 100L); + SkillVersion version = new SkillVersion(1L, "1.0.0", 100L); + + when(namespaceRepository.findBySlug("global")).thenReturn(Optional.of(ns)); + when(skillRepository.findByNamespaceIdAndSlug(any(), eq("my-skill"))) + .thenReturn(Optional.of(skill)); + when(tagRepository.findBySkillIdAndTagName(any(), eq("stable"))) + .thenReturn(Optional.of(tag)); + when(versionRepository.findById(10L)).thenReturn(Optional.of(version)); + when(storageService.getObject(any())) + .thenReturn(new ByteArrayInputStream("zip-data".getBytes())); + + SkillDownloadService.DownloadResult result = downloadService.downloadByTag( + "global", "my-skill", "stable", null, Map.of()); + + assertNotNull(result); + verify(eventPublisher).publishEvent(any()); + } +} +EOF +``` + +- [ ] **Step 2: 运行测试确认失败** + +Run: `cd server && ./mvnw test -pl skillhub-domain -Dtest=SkillDownloadServiceTest` + +Expected: FAIL - SkillDownloadService class not found + +- [ ] **Step 3: 实现 SkillDownloadService** + +```bash +cat > server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/service/SkillDownloadService.java << 'EOF' +package com.iflytek.skillhub.domain.skill.service; + +import com.iflytek.skillhub.domain.event.SkillDownloadedEvent; +import com.iflytek.skillhub.domain.namespace.*; +import com.iflytek.skillhub.domain.skill.*; +import com.iflytek.skillhub.storage.ObjectStorageService; +import org.springframework.context.ApplicationEventPublisher; +import org.springframework.stereotype.Service; + +import java.io.InputStream; +import java.util.Map; + +@Service +public class SkillDownloadService { + + private final NamespaceRepository namespaceRepository; + private final SkillRepository skillRepository; + private final SkillVersionRepository versionRepository; + private final SkillTagRepository tagRepository; + private final ObjectStorageService storageService; + private final VisibilityChecker visibilityChecker; + private final ApplicationEventPublisher eventPublisher; + + public record DownloadResult( + InputStream content, String filename, + long contentLength, String contentType + ) {} + + public SkillDownloadService(NamespaceRepository namespaceRepository, + SkillRepository skillRepository, + SkillVersionRepository versionRepository, + SkillTagRepository tagRepository, + ObjectStorageService storageService, + VisibilityChecker visibilityChecker, + ApplicationEventPublisher eventPublisher) { + this.namespaceRepository = namespaceRepository; + this.skillRepository = skillRepository; + this.versionRepository = versionRepository; + this.tagRepository = tagRepository; + this.storageService = storageService; + this.visibilityChecker = visibilityChecker; + this.eventPublisher = eventPublisher; + } + + public DownloadResult downloadLatest(String namespaceSlug, String skillSlug, + Long currentUserId, + Map userNsRoles) { + Skill skill = findAndCheckAccess(namespaceSlug, skillSlug, currentUserId, userNsRoles); + if (skill.getLatestVersionId() == null) { + throw new IllegalArgumentException("No published version for skill: " + skillSlug); + } + SkillVersion version = versionRepository.findById(skill.getLatestVersionId()) + .orElseThrow(() -> new IllegalArgumentException("Version not found")); + return buildDownloadResult(skill, version); + } + + public DownloadResult downloadVersion(String namespaceSlug, String skillSlug, + String versionStr, Long currentUserId, + Map userNsRoles) { + Skill skill = findAndCheckAccess(namespaceSlug, skillSlug, currentUserId, userNsRoles); + SkillVersion version = versionRepository.findBySkillIdAndVersion(skill.getId(), versionStr) + .orElseThrow(() -> new IllegalArgumentException("Version not found: " + versionStr)); + return buildDownloadResult(skill, version); + } + + public DownloadResult downloadByTag(String namespaceSlug, String skillSlug, + String tagName, Long currentUserId, + Map userNsRoles) { + Skill skill = findAndCheckAccess(namespaceSlug, skillSlug, currentUserId, userNsRoles); + SkillTag tag = tagRepository.findBySkillIdAndTagName(skill.getId(), tagName) + .orElseThrow(() -> new IllegalArgumentException("Tag not found: " + tagName)); + SkillVersion version = versionRepository.findById(tag.getVersionId()) + .orElseThrow(() -> new IllegalArgumentException("Version not found for tag")); + return buildDownloadResult(skill, version); + } + + private Skill findAndCheckAccess(String namespaceSlug, String skillSlug, + Long currentUserId, + Map userNsRoles) { + Namespace ns = namespaceRepository.findBySlug(namespaceSlug) + .orElseThrow(() -> new IllegalArgumentException("Namespace not found")); + Skill skill = skillRepository.findByNamespaceIdAndSlug(ns.getId(), skillSlug) + .orElseThrow(() -> new IllegalArgumentException("Skill not found")); + if (!visibilityChecker.canAccess(skill, currentUserId, userNsRoles)) { + throw new IllegalArgumentException("Access denied"); + } + return skill; + } + + private DownloadResult buildDownloadResult(Skill skill, SkillVersion version) { + String bundleKey = String.format("packages/%d/%d/bundle.zip", + skill.getId(), version.getId()); + InputStream content = storageService.getObject(bundleKey); + String filename = skill.getSlug() + "-" + version.getVersion() + ".zip"; + eventPublisher.publishEvent( + new SkillDownloadedEvent(skill.getId(), version.getId())); + return new DownloadResult(content, filename, version.getTotalSize(), "application/zip"); + } +} +EOF +``` + +- [ ] **Step 4: 运行测试确认通过** + +Run: `cd server && ./mvnw test -pl skillhub-domain -Dtest=SkillDownloadServiceTest` + +Expected: PASS - All tests pass + +- [ ] **Step 5: Commit** + +```bash +git add server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/service/SkillDownloadService.java \ + server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/skill/service/SkillDownloadServiceTest.java +git commit -m "feat(domain): add SkillDownloadService with latest/version/tag download + +- downloadLatest: get bundle.zip for latest published version +- downloadVersion: get bundle.zip for specific version +- downloadByTag: resolve tag to version then download +- Visibility check before download +- Publish SkillDownloadedEvent for async count update +- Add unit tests with Mockito" +``` + +### Task 18: SkillTagService — 标签管理服务 + +**Files:** +- Create: `server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/service/SkillTagService.java` +- Test: `server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/skill/service/SkillTagServiceTest.java` + +- [ ] **Step 1: 编写 SkillTagService 测试** + +```bash +cat > server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/skill/service/SkillTagServiceTest.java << 'EOF' +package com.iflytek.skillhub.domain.skill.service; + +import com.iflytek.skillhub.domain.namespace.*; +import com.iflytek.skillhub.domain.skill.*; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import java.util.List; +import java.util.Optional; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.*; + +@ExtendWith(MockitoExtension.class) +class SkillTagServiceTest { + + @Mock private NamespaceRepository namespaceRepository; + @Mock private SkillRepository skillRepository; + @Mock private SkillVersionRepository versionRepository; + @Mock private SkillTagRepository tagRepository; + + private SkillTagService tagService; + + @BeforeEach + void setUp() { + tagService = new SkillTagService( + namespaceRepository, skillRepository, versionRepository, tagRepository); + } + + @Test + void shouldCreateNewTag() { + Namespace ns = new Namespace(); + Skill skill = new Skill(1L, "my-skill", 100L, SkillVisibility.PUBLIC); + SkillVersion version = new SkillVersion(1L, "1.0.0", 100L); + version.setStatus(SkillVersionStatus.PUBLISHED); + + when(namespaceRepository.findBySlug("global")).thenReturn(Optional.of(ns)); + when(skillRepository.findByNamespaceIdAndSlug(any(), eq("my-skill"))) + .thenReturn(Optional.of(skill)); + when(versionRepository.findBySkillIdAndVersion(any(), eq("1.0.0"))) + .thenReturn(Optional.of(version)); + when(tagRepository.findBySkillIdAndTagName(any(), eq("stable"))) + .thenReturn(Optional.empty()); + when(tagRepository.save(any())).thenAnswer(inv -> inv.getArgument(0)); + + SkillTag result = tagService.createOrMoveTag( + "global", "my-skill", "stable", "1.0.0", 100L); + + assertNotNull(result); + assertEquals("stable", result.getTagName()); + } + + @Test + void shouldRejectLatestTagName() { + assertThrows(IllegalArgumentException.class, + () -> tagService.createOrMoveTag( + "global", "my-skill", "latest", "1.0.0", 100L)); + } + + @Test + void shouldDeleteTag() { + Namespace ns = new Namespace(); + Skill skill = new Skill(1L, "my-skill", 100L, SkillVisibility.PUBLIC); + SkillTag tag = new SkillTag(1L, "old-tag", 10L, 100L); + + when(namespaceRepository.findBySlug("global")).thenReturn(Optional.of(ns)); + when(skillRepository.findByNamespaceIdAndSlug(any(), eq("my-skill"))) + .thenReturn(Optional.of(skill)); + when(tagRepository.findBySkillIdAndTagName(any(), eq("old-tag"))) + .thenReturn(Optional.of(tag)); + + assertDoesNotThrow(() -> tagService.deleteTag( + "global", "my-skill", "old-tag", 100L)); + verify(tagRepository).delete(tag); + } + + @Test + void shouldRejectDeleteLatestTag() { + assertThrows(IllegalArgumentException.class, + () -> tagService.deleteTag( + "global", "my-skill", "latest", 100L)); + } + + @Test + void shouldListTags() { + Namespace ns = new Namespace(); + Skill skill = new Skill(1L, "my-skill", 100L, SkillVisibility.PUBLIC); + SkillTag tag = new SkillTag(1L, "stable", 10L, 100L); + + when(namespaceRepository.findBySlug("global")).thenReturn(Optional.of(ns)); + when(skillRepository.findByNamespaceIdAndSlug(any(), eq("my-skill"))) + .thenReturn(Optional.of(skill)); + when(tagRepository.findBySkillId(any())).thenReturn(List.of(tag)); + + List tags = tagService.listTags("global", "my-skill"); + assertEquals(1, tags.size()); + } +} +EOF +``` + +- [ ] **Step 2: 运行测试确认失败** + +Run: `cd server && ./mvnw test -pl skillhub-domain -Dtest=SkillTagServiceTest` + +Expected: FAIL - SkillTagService class not found + +- [ ] **Step 3: 实现 SkillTagService** + +```bash +cat > server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/service/SkillTagService.java << 'EOF' +package com.iflytek.skillhub.domain.skill.service; + +import com.iflytek.skillhub.domain.namespace.*; +import com.iflytek.skillhub.domain.skill.*; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.List; + +@Service +public class SkillTagService { + + private static final String RESERVED_TAG_LATEST = "latest"; + + private final NamespaceRepository namespaceRepository; + private final SkillRepository skillRepository; + private final SkillVersionRepository versionRepository; + private final SkillTagRepository tagRepository; + + public SkillTagService(NamespaceRepository namespaceRepository, + SkillRepository skillRepository, + SkillVersionRepository versionRepository, + SkillTagRepository tagRepository) { + this.namespaceRepository = namespaceRepository; + this.skillRepository = skillRepository; + this.versionRepository = versionRepository; + this.tagRepository = tagRepository; + } + + public List listTags(String namespaceSlug, String skillSlug) { + Skill skill = findSkill(namespaceSlug, skillSlug); + return tagRepository.findBySkillId(skill.getId()); + } + + @Transactional + public SkillTag createOrMoveTag(String namespaceSlug, String skillSlug, + String tagName, String targetVersion, + Long operatorId) { + if (RESERVED_TAG_LATEST.equals(tagName)) { + throw new IllegalArgumentException("Tag name 'latest' is reserved"); + } + + Skill skill = findSkill(namespaceSlug, skillSlug); + SkillVersion version = versionRepository.findBySkillIdAndVersion(skill.getId(), targetVersion) + .orElseThrow(() -> new IllegalArgumentException("Version not found: " + targetVersion)); + + if (version.getStatus() != SkillVersionStatus.PUBLISHED) { + throw new IllegalArgumentException("Target version must be PUBLISHED"); + } + + SkillTag tag = tagRepository.findBySkillIdAndTagName(skill.getId(), tagName) + .orElse(null); + + if (tag != null) { + tag.setVersionId(version.getId()); + return tagRepository.save(tag); + } else { + SkillTag newTag = new SkillTag(skill.getId(), tagName, version.getId(), operatorId); + return tagRepository.save(newTag); + } + } + + @Transactional + public void deleteTag(String namespaceSlug, String skillSlug, + String tagName, Long operatorId) { + if (RESERVED_TAG_LATEST.equals(tagName)) { + throw new IllegalArgumentException("Cannot delete reserved tag 'latest'"); + } + + Skill skill = findSkill(namespaceSlug, skillSlug); + SkillTag tag = tagRepository.findBySkillIdAndTagName(skill.getId(), tagName) + .orElseThrow(() -> new IllegalArgumentException("Tag not found: " + tagName)); + tagRepository.delete(tag); + } + + private Skill findSkill(String namespaceSlug, String skillSlug) { + Namespace ns = namespaceRepository.findBySlug(namespaceSlug) + .orElseThrow(() -> new IllegalArgumentException("Namespace not found")); + return skillRepository.findByNamespaceIdAndSlug(ns.getId(), skillSlug) + .orElseThrow(() -> new IllegalArgumentException("Skill not found")); + } +} +EOF +``` + +- [ ] **Step 4: 运行测试确认通过** + +Run: `cd server && ./mvnw test -pl skillhub-domain -Dtest=SkillTagServiceTest` + +Expected: PASS - All tests pass + +- [ ] **Step 5: Commit** + +```bash +git add server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/service/SkillTagService.java \ + server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/skill/service/SkillTagServiceTest.java +git commit -m "feat(domain): add SkillTagService for tag CRUD + +- listTags: return all custom tags for a skill +- createOrMoveTag: create new or move existing tag to target version +- deleteTag: remove custom tag +- Reject 'latest' as reserved tag name +- Target version must be PUBLISHED +- Add unit tests with Mockito" +``` + +### Task 19: Skill Controllers — 发布/查询/下载/标签 API + +**Files:** +- Create: `server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/PublishRequest.java` +- Create: `server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/PublishResponse.java` +- Create: `server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/SkillDetailResponse.java` +- Create: `server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/SkillSummaryResponse.java` +- Create: `server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/SkillVersionResponse.java` +- Create: `server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/SkillFileResponse.java` +- Create: `server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/TagRequest.java` +- Create: `server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/TagResponse.java` +- Create: `server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/cli/CliPublishController.java` +- Create: `server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/portal/SkillPublishController.java` +- Create: `server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/portal/SkillController.java` +- Create: `server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/portal/SkillTagController.java` + +- [ ] **Step 1: 创建 Skill 相关 DTO** + +```bash +cat > server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/PublishResponse.java << 'EOF' +package com.iflytek.skillhub.dto; + +public record PublishResponse( + Long skillId, String namespace, String slug, + String version, String status, + int fileCount, long totalSize +) {} +EOF + +cat > server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/SkillDetailResponse.java << 'EOF' +package com.iflytek.skillhub.dto; + +public record SkillDetailResponse( + Long id, String slug, String displayName, String summary, + String visibility, String status, Long downloadCount, + Integer starCount, String latestVersion, String namespace +) {} +EOF + +cat > server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/SkillSummaryResponse.java << 'EOF' +package com.iflytek.skillhub.dto; + +public record SkillSummaryResponse( + Long id, String slug, String displayName, String summary, + Long downloadCount, String latestVersion, String namespace +) {} +EOF + +cat > server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/SkillVersionResponse.java << 'EOF' +package com.iflytek.skillhub.dto; + +import java.time.LocalDateTime; + +public record SkillVersionResponse( + Long id, String version, String status, String changelog, + int fileCount, long totalSize, LocalDateTime publishedAt +) {} +EOF + +cat > server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/SkillFileResponse.java << 'EOF' +package com.iflytek.skillhub.dto; + +public record SkillFileResponse( + Long id, String filePath, long fileSize, + String contentType, String sha256 +) {} +EOF + +cat > server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/TagRequest.java << 'EOF' +package com.iflytek.skillhub.dto; + +import jakarta.validation.constraints.NotBlank; + +public record TagRequest( + @NotBlank String tagName, + @NotBlank String targetVersion +) {} +EOF + +cat > server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/TagResponse.java << 'EOF' +package com.iflytek.skillhub.dto; + +import com.iflytek.skillhub.domain.skill.SkillTag; + +import java.time.LocalDateTime; + +public record TagResponse( + Long id, String tagName, Long versionId, LocalDateTime createdAt +) { + public static TagResponse from(SkillTag tag) { + return new TagResponse(tag.getId(), tag.getTagName(), + tag.getVersionId(), tag.getCreatedAt()); + } +} +EOF +``` + +- [ ] **Step 2: 创建 CliPublishController** + +```bash +mkdir -p server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/cli +cat > server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/cli/CliPublishController.java << 'EOF' +package com.iflytek.skillhub.controller.cli; + +import com.iflytek.skillhub.domain.skill.SkillVersion; +import com.iflytek.skillhub.domain.skill.SkillVisibility; +import com.iflytek.skillhub.domain.skill.service.SkillPublishService; +import com.iflytek.skillhub.domain.skill.validation.PackageEntry; +import com.iflytek.skillhub.dto.PublishResponse; +import org.springframework.http.ResponseEntity; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.web.bind.annotation.*; +import org.springframework.web.multipart.MultipartFile; + +import java.io.IOException; +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import java.util.zip.ZipEntry; +import java.util.zip.ZipInputStream; + +@RestController +@RequestMapping("/api/v1/cli") +public class CliPublishController { + + private final SkillPublishService publishService; + + public CliPublishController(SkillPublishService publishService) { + this.publishService = publishService; + } + + @PostMapping("/publish") + public ResponseEntity publish( + @RequestParam("file") MultipartFile file, + @RequestParam("namespace") String namespace, + @RequestParam(value = "visibility", defaultValue = "PUBLIC") String visibility, + @AuthenticationPrincipal Long userId) throws IOException { + + List entries = extractZip(file); + SkillVisibility vis = SkillVisibility.valueOf(visibility); + SkillVersion version = publishService.publishFromEntries(namespace, entries, userId, vis); + + PublishResponse response = new PublishResponse( + version.getSkillId(), namespace, null, + version.getVersion(), version.getStatus().name(), + version.getFileCount(), version.getTotalSize()); + + return ResponseEntity.ok(Map.of("code", 0, "data", response)); + } + + private List extractZip(MultipartFile file) throws IOException { + List entries = new ArrayList<>(); + try (ZipInputStream zis = new ZipInputStream(file.getInputStream())) { + ZipEntry zipEntry; + while ((zipEntry = zis.getNextEntry()) != null) { + if (zipEntry.isDirectory()) continue; + byte[] content = zis.readAllBytes(); + entries.add(new PackageEntry( + zipEntry.getName(), content, content.length, + "application/octet-stream")); + } + } + return entries; + } +} +EOF +``` + +- [ ] **Step 3: 创建 SkillPublishController(Web 端发布)** + +```bash +cat > server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/portal/SkillPublishController.java << 'EOF' +package com.iflytek.skillhub.controller.portal; + +import com.iflytek.skillhub.domain.skill.SkillVersion; +import com.iflytek.skillhub.domain.skill.SkillVisibility; +import com.iflytek.skillhub.domain.skill.service.SkillPublishService; +import com.iflytek.skillhub.domain.skill.validation.PackageEntry; +import com.iflytek.skillhub.dto.PublishResponse; +import org.springframework.http.ResponseEntity; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.web.bind.annotation.*; +import org.springframework.web.multipart.MultipartFile; + +import java.io.IOException; +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import java.util.zip.ZipEntry; +import java.util.zip.ZipInputStream; + +@RestController +@RequestMapping("/api/v1/skills") +public class SkillPublishController { + + private final SkillPublishService publishService; + + public SkillPublishController(SkillPublishService publishService) { + this.publishService = publishService; + } + + @PostMapping("/{namespace}/publish") + public ResponseEntity publish( + @PathVariable String namespace, + @RequestParam("file") MultipartFile file, + @RequestParam(value = "visibility", defaultValue = "PUBLIC") String visibility, + @AuthenticationPrincipal Long userId) throws IOException { + + List entries = extractZip(file); + SkillVisibility vis = SkillVisibility.valueOf(visibility); + SkillVersion version = publishService.publishFromEntries(namespace, entries, userId, vis); + + PublishResponse response = new PublishResponse( + version.getSkillId(), namespace, null, + version.getVersion(), version.getStatus().name(), + version.getFileCount(), version.getTotalSize()); + + return ResponseEntity.ok(Map.of("code", 0, "data", response)); + } + + private List extractZip(MultipartFile file) throws IOException { + List entries = new ArrayList<>(); + try (ZipInputStream zis = new ZipInputStream(file.getInputStream())) { + ZipEntry zipEntry; + while ((zipEntry = zis.getNextEntry()) != null) { + if (zipEntry.isDirectory()) continue; + byte[] content = zis.readAllBytes(); + entries.add(new PackageEntry( + zipEntry.getName(), content, content.length, + "application/octet-stream")); + } + } + return entries; + } +} +EOF +``` + +- [ ] **Step 4: 创建 SkillController(公开查询/下载 API)** + +```bash +cat > server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/portal/SkillController.java << 'EOF' +package com.iflytek.skillhub.controller.portal; + +import com.iflytek.skillhub.domain.skill.SkillFile; +import com.iflytek.skillhub.domain.skill.SkillVersion; +import com.iflytek.skillhub.domain.skill.service.SkillDownloadService; +import com.iflytek.skillhub.domain.skill.service.SkillQueryService; +import com.iflytek.skillhub.dto.SkillFileResponse; +import com.iflytek.skillhub.dto.SkillVersionResponse; +import org.springframework.core.io.InputStreamResource; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageRequest; +import org.springframework.http.HttpHeaders; +import org.springframework.http.MediaType; +import org.springframework.http.ResponseEntity; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.web.bind.annotation.*; + +import java.io.InputStream; +import java.util.List; +import java.util.Map; + +@RestController +@RequestMapping("/api/v1/skills") +public class SkillController { + + private final SkillQueryService queryService; + private final SkillDownloadService downloadService; + + public SkillController(SkillQueryService queryService, + SkillDownloadService downloadService) { + this.queryService = queryService; + this.downloadService = downloadService; + } + + @GetMapping("/{namespace}/{slug}") + public ResponseEntity getSkillDetail( + @PathVariable String namespace, + @PathVariable String slug, + @AuthenticationPrincipal Long userId) { + var detail = queryService.getSkillDetail(namespace, slug, userId, Map.of()); + return ResponseEntity.ok(Map.of("code", 0, "data", detail)); + } + + @GetMapping("/{namespace}/{slug}/versions") + public ResponseEntity listVersions( + @PathVariable String namespace, + @PathVariable String slug, + @RequestParam(defaultValue = "0") int page, + @RequestParam(defaultValue = "20") int size) { + Page versions = queryService.listVersions( + namespace, slug, PageRequest.of(page, size)); + List items = versions.getContent().stream() + .map(v -> new SkillVersionResponse(v.getId(), v.getVersion(), + v.getStatus().name(), v.getChangelog(), + v.getFileCount(), v.getTotalSize(), v.getPublishedAt())) + .toList(); + return ResponseEntity.ok(Map.of("code", 0, "data", Map.of( + "items", items, "total", versions.getTotalElements()))); + } + + @GetMapping("/{namespace}/{slug}/versions/{version}/files") + public ResponseEntity listFiles( + @PathVariable String namespace, + @PathVariable String slug, + @PathVariable String version) { + List files = queryService.listFiles(namespace, slug, version); + List items = files.stream() + .map(f -> new SkillFileResponse(f.getId(), f.getFilePath(), + f.getFileSize(), f.getContentType(), f.getSha256())) + .toList(); + return ResponseEntity.ok(Map.of("code", 0, "data", items)); + } + + @GetMapping("/{namespace}/{slug}/versions/{version}/file") + public ResponseEntity getFileContent( + @PathVariable String namespace, + @PathVariable String slug, + @PathVariable String version, + @RequestParam String path) { + InputStream content = queryService.getFileContent(namespace, slug, version, path); + return ResponseEntity.ok() + .contentType(MediaType.TEXT_PLAIN) + .body(new InputStreamResource(content)); + } + + @GetMapping("/{namespace}/{slug}/download") + public ResponseEntity downloadLatest( + @PathVariable String namespace, + @PathVariable String slug, + @AuthenticationPrincipal Long userId) { + var result = downloadService.downloadLatest(namespace, slug, userId, Map.of()); + return ResponseEntity.ok() + .header(HttpHeaders.CONTENT_DISPOSITION, + "attachment; filename=\"" + result.filename() + "\"") + .contentType(MediaType.APPLICATION_OCTET_STREAM) + .contentLength(result.contentLength()) + .body(new InputStreamResource(result.content())); + } + + @GetMapping("/{namespace}/{slug}/versions/{version}/download") + public ResponseEntity downloadVersion( + @PathVariable String namespace, + @PathVariable String slug, + @PathVariable String version, + @AuthenticationPrincipal Long userId) { + var result = downloadService.downloadVersion( + namespace, slug, version, userId, Map.of()); + return ResponseEntity.ok() + .header(HttpHeaders.CONTENT_DISPOSITION, + "attachment; filename=\"" + result.filename() + "\"") + .contentType(MediaType.APPLICATION_OCTET_STREAM) + .body(new InputStreamResource(result.content())); + } +} +EOF +``` + +- [ ] **Step 5: 创建 SkillTagController** + +```bash +cat > server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/portal/SkillTagController.java << 'EOF' +package com.iflytek.skillhub.controller.portal; + +import com.iflytek.skillhub.domain.skill.SkillTag; +import com.iflytek.skillhub.domain.skill.service.SkillTagService; +import com.iflytek.skillhub.dto.TagRequest; +import com.iflytek.skillhub.dto.TagResponse; +import jakarta.validation.Valid; +import org.springframework.http.ResponseEntity; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.web.bind.annotation.*; + +import java.util.List; +import java.util.Map; + +@RestController +@RequestMapping("/api/v1/skills/{namespace}/{slug}/tags") +public class SkillTagController { + + private final SkillTagService tagService; + + public SkillTagController(SkillTagService tagService) { + this.tagService = tagService; + } + + @GetMapping + public ResponseEntity listTags( + @PathVariable String namespace, + @PathVariable String slug) { + List tags = tagService.listTags(namespace, slug); + List items = tags.stream().map(TagResponse::from).toList(); + return ResponseEntity.ok(Map.of("code", 0, "data", items)); + } + + @PutMapping("/{tagName}") + public ResponseEntity createOrMoveTag( + @PathVariable String namespace, + @PathVariable String slug, + @PathVariable String tagName, + @Valid @RequestBody TagRequest request, + @AuthenticationPrincipal Long userId) { + SkillTag tag = tagService.createOrMoveTag( + namespace, slug, tagName, request.targetVersion(), userId); + return ResponseEntity.ok(Map.of("code", 0, "data", TagResponse.from(tag))); + } + + @DeleteMapping("/{tagName}") + public ResponseEntity deleteTag( + @PathVariable String namespace, + @PathVariable String slug, + @PathVariable String tagName, + @AuthenticationPrincipal Long userId) { + tagService.deleteTag(namespace, slug, tagName, userId); + return ResponseEntity.ok(Map.of("code", 0, "message", "Tag deleted")); + } +} +EOF +``` + +- [ ] **Step 6: 编译验证** + +Run: `cd server && ./mvnw clean compile -DskipTests` + +Expected: BUILD SUCCESS + +- [ ] **Step 7: Commit** + +```bash +git add server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/ \ + server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/ +git commit -m "feat(api): add Skill controllers for publish/query/download/tag + +- CliPublishController: POST /api/v1/cli/publish (multipart zip) +- SkillPublishController: POST /api/v1/skills/{ns}/publish (web) +- SkillController: GET detail/versions/files/download endpoints +- SkillTagController: GET/PUT/DELETE tag management +- Add PublishResponse, SkillDetailResponse, SkillVersionResponse, + SkillFileResponse, TagRequest, TagResponse DTOs" +``` + +### Task 20: Search SPI + PostgreSQL Full-Text 实现 + +**Files:** +- Create: `server/skillhub-search/src/main/java/com/iflytek/skillhub/search/SearchIndexService.java` +- Create: `server/skillhub-search/src/main/java/com/iflytek/skillhub/search/SearchQueryService.java` +- Create: `server/skillhub-search/src/main/java/com/iflytek/skillhub/search/SearchRebuildService.java` +- Create: `server/skillhub-search/src/main/java/com/iflytek/skillhub/search/SearchQuery.java` +- Create: `server/skillhub-search/src/main/java/com/iflytek/skillhub/search/SearchVisibilityScope.java` +- Create: `server/skillhub-search/src/main/java/com/iflytek/skillhub/search/SearchResult.java` +- Create: `server/skillhub-search/src/main/java/com/iflytek/skillhub/search/SkillSearchDocument.java` +- Create: `server/skillhub-search/src/main/java/com/iflytek/skillhub/search/postgres/PostgresFullTextIndexService.java` +- Create: `server/skillhub-search/src/main/java/com/iflytek/skillhub/search/postgres/PostgresFullTextQueryService.java` +- Create: `server/skillhub-search/src/main/java/com/iflytek/skillhub/search/postgres/PostgresSearchRebuildService.java` +- Create: `server/skillhub-infra/src/main/java/com/iflytek/skillhub/infra/jpa/SkillSearchDocumentJpaRepository.java` + +- [ ] **Step 1: 创建 Search SPI 接口和 record** + +```bash +mkdir -p server/skillhub-search/src/main/java/com/iflytek/skillhub/search + +cat > server/skillhub-search/src/main/java/com/iflytek/skillhub/search/SkillSearchDocument.java << 'EOF' +package com.iflytek.skillhub.search; + +public record SkillSearchDocument( + Long skillId, Long namespaceId, String namespaceSlug, + Long ownerId, String title, String summary, + String keywords, String searchText, + String visibility, String status +) {} +EOF + +cat > server/skillhub-search/src/main/java/com/iflytek/skillhub/search/SearchQuery.java << 'EOF' +package com.iflytek.skillhub.search; + +public record SearchQuery( + String keyword, Long namespaceId, + SearchVisibilityScope visibilityScope, + String sortBy, int page, int size +) {} +EOF + +cat > server/skillhub-search/src/main/java/com/iflytek/skillhub/search/SearchVisibilityScope.java << 'EOF' +package com.iflytek.skillhub.search; + +import java.util.Set; + +public record SearchVisibilityScope( + Long userId, + Set memberNamespaceIds, + Set adminNamespaceIds +) { + public static SearchVisibilityScope anonymous() { + return new SearchVisibilityScope(null, Set.of(), Set.of()); + } +} +EOF + +cat > server/skillhub-search/src/main/java/com/iflytek/skillhub/search/SearchResult.java << 'EOF' +package com.iflytek.skillhub.search; + +import java.util.List; + +public record SearchResult( + List skillIds, long total, int page, int size +) {} +EOF + +cat > server/skillhub-search/src/main/java/com/iflytek/skillhub/search/SearchIndexService.java << 'EOF' +package com.iflytek.skillhub.search; + +import java.util.List; + +public interface SearchIndexService { + void index(SkillSearchDocument doc); + void batchIndex(List docs); + void remove(Long skillId); +} +EOF + +cat > server/skillhub-search/src/main/java/com/iflytek/skillhub/search/SearchQueryService.java << 'EOF' +package com.iflytek.skillhub.search; + +public interface SearchQueryService { + SearchResult search(SearchQuery query); +} +EOF + +cat > server/skillhub-search/src/main/java/com/iflytek/skillhub/search/SearchRebuildService.java << 'EOF' +package com.iflytek.skillhub.search; + +public interface SearchRebuildService { + void rebuildAll(); + void rebuildByNamespace(Long namespaceId); + void rebuildBySkill(Long skillId); +} +EOF +``` + +- [ ] **Step 2: 创建 SkillSearchDocumentEntity JPA 实体** + +```bash +cat > server/skillhub-infra/src/main/java/com/iflytek/skillhub/infra/jpa/SkillSearchDocumentEntity.java << 'EOF' +package com.iflytek.skillhub.infra.jpa; + +import jakarta.persistence.*; +import java.time.LocalDateTime; + +@Entity +@Table(name = "skill_search_document") +public class SkillSearchDocumentEntity { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(name = "skill_id", nullable = false, unique = true) + private Long skillId; + + @Column(name = "namespace_id", nullable = false) + private Long namespaceId; + + @Column(name = "namespace_slug", nullable = false, length = 64) + private String namespaceSlug; + + @Column(name = "owner_id", nullable = false) + private Long ownerId; + + @Column(length = 256) + private String title; + + @Column(length = 512) + private String summary; + + @Column(length = 512) + private String keywords; + + @Column(name = "search_text", columnDefinition = "TEXT") + private String searchText; + + @Column(nullable = false, length = 32) + private String visibility; + + @Column(nullable = false, length = 32) + private String status; + + @Column(name = "updated_at", nullable = false) + private LocalDateTime updatedAt; + + @PrePersist + @PreUpdate + void onSave() { this.updatedAt = LocalDateTime.now(); } + + // Getters and setters + public Long getId() { return id; } + public Long getSkillId() { return skillId; } + public void setSkillId(Long skillId) { this.skillId = skillId; } + public Long getNamespaceId() { return namespaceId; } + public void setNamespaceId(Long namespaceId) { this.namespaceId = namespaceId; } + public String getNamespaceSlug() { return namespaceSlug; } + public void setNamespaceSlug(String namespaceSlug) { this.namespaceSlug = namespaceSlug; } + public Long getOwnerId() { return ownerId; } + public void setOwnerId(Long ownerId) { this.ownerId = ownerId; } + public String getTitle() { return title; } + public void setTitle(String title) { this.title = title; } + public String getSummary() { return summary; } + public void setSummary(String summary) { this.summary = summary; } + public String getKeywords() { return keywords; } + public void setKeywords(String keywords) { this.keywords = keywords; } + public String getSearchText() { return searchText; } + public void setSearchText(String searchText) { this.searchText = searchText; } + public String getVisibility() { return visibility; } + public void setVisibility(String visibility) { this.visibility = visibility; } + public String getStatus() { return status; } + public void setStatus(String status) { this.status = status; } +} +EOF + +cat > server/skillhub-infra/src/main/java/com/iflytek/skillhub/infra/jpa/SkillSearchDocumentJpaRepository.java << 'EOF' +package com.iflytek.skillhub.infra.jpa; + +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; + +import java.util.Optional; + +@Repository +public interface SkillSearchDocumentJpaRepository extends JpaRepository { + Optional findBySkillId(Long skillId); + void deleteBySkillId(Long skillId); +} +EOF +``` + +- [ ] **Step 3: 实现 PostgresFullTextIndexService** + +```bash +mkdir -p server/skillhub-search/src/main/java/com/iflytek/skillhub/search/postgres +cat > server/skillhub-search/src/main/java/com/iflytek/skillhub/search/postgres/PostgresFullTextIndexService.java << 'EOF' +package com.iflytek.skillhub.search.postgres; + +import com.iflytek.skillhub.infra.jpa.SkillSearchDocumentEntity; +import com.iflytek.skillhub.infra.jpa.SkillSearchDocumentJpaRepository; +import com.iflytek.skillhub.search.SearchIndexService; +import com.iflytek.skillhub.search.SkillSearchDocument; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.List; + +@Service +@ConditionalOnProperty(name = "skillhub.search.provider", havingValue = "postgres", matchIfMissing = true) +public class PostgresFullTextIndexService implements SearchIndexService { + + private final SkillSearchDocumentJpaRepository repository; + + public PostgresFullTextIndexService(SkillSearchDocumentJpaRepository repository) { + this.repository = repository; + } + + @Override + @Transactional + public void index(SkillSearchDocument doc) { + SkillSearchDocumentEntity entity = repository.findBySkillId(doc.skillId()) + .orElse(new SkillSearchDocumentEntity()); + mapToEntity(doc, entity); + repository.save(entity); + } + + @Override + @Transactional + public void batchIndex(List docs) { + docs.forEach(this::index); + } + + @Override + @Transactional + public void remove(Long skillId) { + repository.deleteBySkillId(skillId); + } + + private void mapToEntity(SkillSearchDocument doc, SkillSearchDocumentEntity entity) { + entity.setSkillId(doc.skillId()); + entity.setNamespaceId(doc.namespaceId()); + entity.setNamespaceSlug(doc.namespaceSlug()); + entity.setOwnerId(doc.ownerId()); + entity.setTitle(doc.title()); + entity.setSummary(doc.summary()); + entity.setKeywords(doc.keywords()); + entity.setSearchText(doc.searchText()); + entity.setVisibility(doc.visibility()); + entity.setStatus(doc.status()); + } +} +EOF +``` + +- [ ] **Step 4: 实现 PostgresFullTextQueryService** + +```bash +cat > server/skillhub-search/src/main/java/com/iflytek/skillhub/search/postgres/PostgresFullTextQueryService.java << 'EOF' +package com.iflytek.skillhub.search.postgres; + +import com.iflytek.skillhub.search.*; +import jakarta.persistence.EntityManager; +import jakarta.persistence.Query; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.stereotype.Service; + +import java.util.ArrayList; +import java.util.List; + +@Service +@ConditionalOnProperty(name = "skillhub.search.provider", havingValue = "postgres", matchIfMissing = true) +public class PostgresFullTextQueryService implements SearchQueryService { + + private final EntityManager entityManager; + + public PostgresFullTextQueryService(EntityManager entityManager) { + this.entityManager = entityManager; + } + + @Override + @SuppressWarnings("unchecked") + public SearchResult search(SearchQuery query) { + StringBuilder sql = new StringBuilder(); + StringBuilder countSql = new StringBuilder(); + List params = new ArrayList<>(); + int paramIdx = 1; + + sql.append("SELECT sd.skill_id FROM skill_search_document sd "); + sql.append("JOIN skill s ON s.id = sd.skill_id "); + countSql.append("SELECT COUNT(*) FROM skill_search_document sd "); + countSql.append("JOIN skill s ON s.id = sd.skill_id "); + + StringBuilder where = new StringBuilder("WHERE sd.status = 'ACTIVE' "); + + // 关键词匹配 + if (query.keyword() != null && !query.keyword().isBlank()) { + where.append("AND sd.search_vector @@ plainto_tsquery('simple', ?").append(paramIdx).append(") "); + params.add(query.keyword()); + paramIdx++; + } + + // 命名空间过滤 + if (query.namespaceId() != null) { + where.append("AND sd.namespace_id = ?").append(paramIdx).append(" "); + params.add(query.namespaceId()); + paramIdx++; + } + + // 可见性过滤 + SearchVisibilityScope scope = query.visibilityScope(); + if (scope.userId() == null) { + where.append("AND sd.visibility = 'PUBLIC' "); + } else { + where.append("AND (sd.visibility = 'PUBLIC' "); + if (!scope.memberNamespaceIds().isEmpty()) { + where.append("OR (sd.visibility = 'NAMESPACE_ONLY' AND sd.namespace_id IN ("); + for (Long nsId : scope.memberNamespaceIds()) { + where.append("?").append(paramIdx++).append(","); + params.add(nsId); + } + where.setLength(where.length() - 1); + where.append(")) "); + } + if (!scope.adminNamespaceIds().isEmpty() || scope.userId() != null) { + where.append("OR (sd.visibility = 'PRIVATE' AND ("); + where.append("sd.owner_id = ?").append(paramIdx).append(" "); + params.add(scope.userId()); + paramIdx++; + if (!scope.adminNamespaceIds().isEmpty()) { + where.append("OR sd.namespace_id IN ("); + for (Long nsId : scope.adminNamespaceIds()) { + where.append("?").append(paramIdx++).append(","); + params.add(nsId); + } + where.setLength(where.length() - 1); + where.append(")"); + } + where.append(")) "); + } + where.append(") "); + } + + sql.append(where); + countSql.append(where); + + // 排序 + String sortBy = query.sortBy() != null ? query.sortBy() : "relevance"; + switch (sortBy) { + case "downloads" -> sql.append("ORDER BY s.download_count DESC "); + case "newest" -> sql.append("ORDER BY sd.updated_at DESC "); + default -> { + if (query.keyword() != null && !query.keyword().isBlank()) { + sql.append("ORDER BY ts_rank_cd(sd.search_vector, plainto_tsquery('simple', ?")) + .append(paramIdx).append(")) DESC "); + params.add(query.keyword()); + paramIdx++; + } else { + sql.append("ORDER BY s.download_count DESC "); + } + } + } + + sql.append("LIMIT ").append(query.size()).append(" OFFSET ").append(query.page() * query.size()); + + // Execute queries + Query nativeQuery = entityManager.createNativeQuery(sql.toString()); + Query nativeCountQuery = entityManager.createNativeQuery(countSql.toString()); + for (int i = 0; i < params.size(); i++) { + nativeQuery.setParameter(i + 1, params.get(i)); + if (i < params.size()) { + nativeCountQuery.setParameter(i + 1, params.get(i)); + } + } + + List ids = nativeQuery.getResultList(); + List skillIds = ids.stream().map(Number::longValue).toList(); + long total = ((Number) nativeCountQuery.getSingleResult()).longValue(); + + return new SearchResult(skillIds, total, query.page(), query.size()); + } +} +EOF +``` + +- [ ] **Step 5: 实现 PostgresSearchRebuildService** + +```bash +cat > server/skillhub-search/src/main/java/com/iflytek/skillhub/search/postgres/PostgresSearchRebuildService.java << 'EOF' +package com.iflytek.skillhub.search.postgres; + +import com.iflytek.skillhub.domain.namespace.Namespace; +import com.iflytek.skillhub.domain.namespace.NamespaceRepository; +import com.iflytek.skillhub.domain.skill.*; +import com.iflytek.skillhub.search.SearchIndexService; +import com.iflytek.skillhub.search.SearchRebuildService; +import com.iflytek.skillhub.search.SkillSearchDocument; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageRequest; +import org.springframework.stereotype.Service; + +import java.util.List; + +@Service +public class PostgresSearchRebuildService implements SearchRebuildService { + + private static final Logger log = LoggerFactory.getLogger(PostgresSearchRebuildService.class); + private static final int BATCH_SIZE = 100; + + private final SkillRepository skillRepository; + private final SkillVersionRepository versionRepository; + private final NamespaceRepository namespaceRepository; + private final SearchIndexService indexService; + + public PostgresSearchRebuildService(SkillRepository skillRepository, + SkillVersionRepository versionRepository, + NamespaceRepository namespaceRepository, + SearchIndexService indexService) { + this.skillRepository = skillRepository; + this.versionRepository = versionRepository; + this.namespaceRepository = namespaceRepository; + this.indexService = indexService; + } + + @Override + public void rebuildAll() { + log.info("Starting full search index rebuild"); + int page = 0; + Page batch; + do { + batch = skillRepository.findByNamespaceIdAndStatus( + null, SkillStatus.ACTIVE, PageRequest.of(page, BATCH_SIZE)); + List docs = batch.getContent().stream() + .map(this::buildDocument).toList(); + indexService.batchIndex(docs); + page++; + } while (batch.hasNext()); + log.info("Search index rebuild complete"); + } + + @Override + public void rebuildByNamespace(Long namespaceId) { + Page skills = skillRepository.findByNamespaceIdAndStatus( + namespaceId, SkillStatus.ACTIVE, PageRequest.of(0, BATCH_SIZE)); + List docs = skills.getContent().stream() + .map(this::buildDocument).toList(); + indexService.batchIndex(docs); + } + + @Override + public void rebuildBySkill(Long skillId) { + skillRepository.findById(skillId).ifPresent(skill -> { + SkillSearchDocument doc = buildDocument(skill); + indexService.index(doc); + }); + } + + private SkillSearchDocument buildDocument(Skill skill) { + String namespaceSlug = namespaceRepository.findById(skill.getNamespaceId()) + .map(Namespace::getSlug).orElse(""); + String body = ""; + if (skill.getLatestVersionId() != null) { + body = versionRepository.findById(skill.getLatestVersionId()) + .map(v -> v.getParsedMetadataJson() != null ? v.getParsedMetadataJson() : "") + .orElse(""); + } + return new SkillSearchDocument( + skill.getId(), skill.getNamespaceId(), namespaceSlug, + skill.getOwnerId(), skill.getDisplayName(), skill.getSummary(), + "", body, skill.getVisibility().name(), skill.getStatus().name()); + } +} +EOF +``` + +- [ ] **Step 6: 创建 SkillSearchAppService + SearchController** + +```bash +cat > server/skillhub-app/src/main/java/com/iflytek/skillhub/service/SkillSearchAppService.java << 'EOF' +package com.iflytek.skillhub.service; + +import com.iflytek.skillhub.domain.namespace.NamespaceMemberRepository; +import com.iflytek.skillhub.domain.namespace.NamespaceRepository; +import com.iflytek.skillhub.domain.namespace.NamespaceRole; +import com.iflytek.skillhub.domain.skill.Skill; +import com.iflytek.skillhub.domain.skill.SkillRepository; +import com.iflytek.skillhub.search.*; +import org.springframework.stereotype.Service; + +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.stream.Collectors; + +@Service +public class SkillSearchAppService { + + private final SearchQueryService searchQueryService; + private final SkillRepository skillRepository; + private final NamespaceRepository namespaceRepository; + private final NamespaceMemberRepository memberRepository; + + public SkillSearchAppService(SearchQueryService searchQueryService, + SkillRepository skillRepository, + NamespaceRepository namespaceRepository, + NamespaceMemberRepository memberRepository) { + this.searchQueryService = searchQueryService; + this.skillRepository = skillRepository; + this.namespaceRepository = namespaceRepository; + this.memberRepository = memberRepository; + } + + public SearchResultDTO searchSkills(String keyword, String namespaceSlug, + String sortBy, int page, int size, + Long currentUserId) { + Long namespaceId = null; + if (namespaceSlug != null && !namespaceSlug.isBlank()) { + namespaceId = namespaceRepository.findBySlug(namespaceSlug) + .orElseThrow(() -> new IllegalArgumentException("Namespace not found")) + .getId(); + } + + SearchVisibilityScope scope = currentUserId != null + ? buildScope(currentUserId) + : SearchVisibilityScope.anonymous(); + + SearchQuery query = new SearchQuery(keyword, namespaceId, scope, sortBy, page, size); + SearchResult result = searchQueryService.search(query); + + List items = result.skillIds().stream() + .map(id -> skillRepository.findById(id).orElse(null)) + .filter(s -> s != null) + .map(this::toSummary) + .toList(); + + return new SearchResultDTO(items, result.total(), result.page(), result.size()); + } + + private SearchVisibilityScope buildScope(Long userId) { + // Simplified: in production, load user's namespace memberships + return new SearchVisibilityScope(userId, Set.of(), Set.of()); + } + + private SkillSummaryDTO toSummary(Skill skill) { + return new SkillSummaryDTO( + skill.getId(), skill.getSlug(), skill.getDisplayName(), + skill.getSummary(), skill.getDownloadCount(), + skill.getStarCount(), skill.getRatingAvg(), skill.getRatingCount(), + skill.getNamespaceId()); + } + + public record SkillSummaryDTO( + Long id, String slug, String displayName, String summary, + Long downloadCount, Integer starCount, + java.math.BigDecimal ratingAvg, Integer ratingCount, + Long namespaceId + ) {} + + public record SearchResultDTO( + List items, long total, int page, int size + ) {} +} +EOF +``` + +```bash +cat > server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/portal/SkillSearchController.java << 'EOF' +package com.iflytek.skillhub.controller.portal; + +import com.iflytek.skillhub.service.SkillSearchAppService; +import org.springframework.http.ResponseEntity; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.web.bind.annotation.*; + +import java.util.Map; + +@RestController +@RequestMapping("/api/v1/skills") +public class SkillSearchController { + + private final SkillSearchAppService searchAppService; + + public SkillSearchController(SkillSearchAppService searchAppService) { + this.searchAppService = searchAppService; + } + + @GetMapping + public ResponseEntity searchSkills( + @RequestParam(value = "q", required = false) String keyword, + @RequestParam(value = "namespace", required = false) String namespace, + @RequestParam(value = "sort", defaultValue = "relevance") String sort, + @RequestParam(defaultValue = "0") int page, + @RequestParam(defaultValue = "20") int size, + @AuthenticationPrincipal Long userId) { + var result = searchAppService.searchSkills(keyword, namespace, sort, page, size, userId); + return ResponseEntity.ok(Map.of("code", 0, "data", Map.of( + "items", result.items(), + "total", result.total(), + "page", result.page(), + "size", result.size() + ))); + } +} +EOF +``` + +- [ ] **Step 7: 编译验证** + +Run: `cd server && ./mvnw clean compile -DskipTests` + +Expected: BUILD SUCCESS + +- [ ] **Step 8: Commit** + +```bash +git add server/skillhub-search/src/main/java/com/iflytek/skillhub/search/ \ + server/skillhub-infra/src/main/java/com/iflytek/skillhub/infra/jpa/SkillSearchDocument*.java \ + server/skillhub-app/src/main/java/com/iflytek/skillhub/service/SkillSearchAppService.java \ + server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/portal/SkillSearchController.java +git commit -m "feat(search): add PostgreSQL full-text search implementation + +- Add Search SPI: SearchIndexService, SearchQueryService, SearchRebuildService +- Add SearchQuery, SearchResult, SearchVisibilityScope, SkillSearchDocument records +- Implement PostgresFullTextIndexService with upsert by skill_id +- Implement PostgresFullTextQueryService with tsvector/tsquery, visibility filter, sorting +- Implement PostgresSearchRebuildService with batch rebuild +- Add SkillSearchDocumentEntity JPA entity and repository +- Add SkillSearchAppService for search orchestration +- Add SkillSearchController: GET /api/v1/skills?q=&namespace=&sort=&page=&size=" +``` + +### Task 21: 异步事件监听器 + AsyncConfig + +**Files:** +- Create: `server/skillhub-app/src/main/java/com/iflytek/skillhub/config/AsyncConfig.java` +- Create: `server/skillhub-search/src/main/java/com/iflytek/skillhub/search/event/SearchIndexEventListener.java` +- Create: `server/skillhub-search/src/main/java/com/iflytek/skillhub/search/event/DownloadCountEventListener.java` + +- [ ] **Step 1: 创建 AsyncConfig** + +```bash +cat > server/skillhub-app/src/main/java/com/iflytek/skillhub/config/AsyncConfig.java << 'EOF' +package com.iflytek.skillhub.config; + +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.core.task.TaskExecutor; +import org.springframework.scheduling.annotation.EnableAsync; +import org.springframework.scheduling.concurrent.ThreadPoolTaskExecutor; + +import java.util.concurrent.ThreadPoolExecutor; + +@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); + executor.initialize(); + return executor; + } +} +EOF +``` + +- [ ] **Step 2: 创建 SearchIndexEventListener** + +```bash +mkdir -p server/skillhub-search/src/main/java/com/iflytek/skillhub/search/event +cat > server/skillhub-search/src/main/java/com/iflytek/skillhub/search/event/SearchIndexEventListener.java << 'EOF' +package com.iflytek.skillhub.search.event; + +import com.iflytek.skillhub.domain.event.SkillPublishedEvent; +import com.iflytek.skillhub.domain.event.SkillStatusChangedEvent; +import com.iflytek.skillhub.domain.skill.SkillStatus; +import com.iflytek.skillhub.search.SearchIndexService; +import com.iflytek.skillhub.search.SearchRebuildService; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.scheduling.annotation.Async; +import org.springframework.stereotype.Component; +import org.springframework.transaction.event.TransactionPhase; +import org.springframework.transaction.event.TransactionalEventListener; + +@Component +public class SearchIndexEventListener { + + private static final Logger log = LoggerFactory.getLogger(SearchIndexEventListener.class); + + private final SearchRebuildService rebuildService; + private final SearchIndexService indexService; + + public SearchIndexEventListener(SearchRebuildService rebuildService, + SearchIndexService indexService) { + this.rebuildService = rebuildService; + this.indexService = indexService; + } + + @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT) + @Async("skillhubEventExecutor") + public void onSkillPublished(SkillPublishedEvent event) { + log.info("Indexing skill {} after publish", event.skillId()); + rebuildService.rebuildBySkill(event.skillId()); + } + + @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT) + @Async("skillhubEventExecutor") + public void onSkillStatusChanged(SkillStatusChangedEvent event) { + if (event.newStatus() == SkillStatus.ARCHIVED) { + log.info("Removing skill {} from search index", event.skillId()); + indexService.remove(event.skillId()); + } else { + log.info("Updating search index for skill {}", event.skillId()); + rebuildService.rebuildBySkill(event.skillId()); + } + } +} +EOF +``` + +- [ ] **Step 3: 创建 DownloadCountEventListener** + +```bash +cat > server/skillhub-search/src/main/java/com/iflytek/skillhub/search/event/DownloadCountEventListener.java << 'EOF' +package com.iflytek.skillhub.search.event; + +import com.iflytek.skillhub.domain.event.SkillDownloadedEvent; +import com.iflytek.skillhub.domain.skill.SkillRepository; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.context.event.EventListener; +import org.springframework.scheduling.annotation.Async; +import org.springframework.stereotype.Component; + +@Component +public class DownloadCountEventListener { + + private static final Logger log = LoggerFactory.getLogger(DownloadCountEventListener.class); + + private final SkillRepository skillRepository; + + public DownloadCountEventListener(SkillRepository skillRepository) { + this.skillRepository = skillRepository; + } + + @EventListener + @Async("skillhubEventExecutor") + public void onSkillDownloaded(SkillDownloadedEvent event) { + log.debug("Incrementing download count for skill {}", event.skillId()); + skillRepository.incrementDownloadCount(event.skillId()); + } +} +EOF +``` + +- [ ] **Step 4: 编译验证** + +Run: `cd server && ./mvnw clean compile -DskipTests` + +Expected: BUILD SUCCESS + +- [ ] **Step 5: Commit** + +```bash +git add server/skillhub-app/src/main/java/com/iflytek/skillhub/config/AsyncConfig.java \ + server/skillhub-search/src/main/java/com/iflytek/skillhub/search/event/ +git commit -m "feat(event): add async event listeners and thread pool config + +- AsyncConfig: skillhubEventExecutor (core=2, max=4, queue=100, CallerRunsPolicy) +- SearchIndexEventListener: index on publish, remove on archive +- DownloadCountEventListener: increment download_count on download +- Use @TransactionalEventListener for search, @EventListener for download count" +``` + +### Task 22: 应用层精细限流 — Redis 滑动窗口 + +**Files:** +- Create: `server/skillhub-app/src/main/java/com/iflytek/skillhub/ratelimit/RateLimit.java` +- Create: `server/skillhub-app/src/main/java/com/iflytek/skillhub/ratelimit/SlidingWindowRateLimiter.java` +- Create: `server/skillhub-app/src/main/java/com/iflytek/skillhub/ratelimit/RateLimitInterceptor.java` +- Create: `server/skillhub-app/src/main/java/com/iflytek/skillhub/config/WebMvcRateLimitConfig.java` +- Create: `server/skillhub-app/src/main/resources/ratelimit.lua` +- Test: `server/skillhub-app/src/test/java/com/iflytek/skillhub/ratelimit/RateLimitInterceptorTest.java` + +- [ ] **Step 1: 创建 RateLimit 注解** + +```bash +mkdir -p server/skillhub-app/src/main/java/com/iflytek/skillhub/ratelimit +cat > server/skillhub-app/src/main/java/com/iflytek/skillhub/ratelimit/RateLimit.java << 'EOF' +package com.iflytek.skillhub.ratelimit; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +@Target(ElementType.METHOD) +@Retention(RetentionPolicy.RUNTIME) +public @interface RateLimit { + String category(); + int authenticated() default 60; + int anonymous() default 20; + int windowSeconds() default 60; +} +EOF +``` + +- [ ] **Step 2: 创建 Redis Lua 脚本** + +```bash +cat > server/skillhub-app/src/main/resources/ratelimit.lua << 'EOF' +-- Sliding window rate limiter +-- KEYS[1] = rate limit key +-- ARGV[1] = window size in milliseconds +-- ARGV[2] = max requests +-- ARGV[3] = current timestamp in milliseconds + +local key = KEYS[1] +local window = tonumber(ARGV[1]) +local maxRequests = tonumber(ARGV[2]) +local now = tonumber(ARGV[3]) +local windowStart = now - window + +-- Remove expired entries +redis.call('ZREMRANGEBYSCORE', key, '-inf', windowStart) + +-- Count current requests +local count = redis.call('ZCARD', key) + +if count < maxRequests then + -- Add current request + redis.call('ZADD', key, now, now .. '-' .. math.random(1000000)) + redis.call('PEXPIRE', key, window) + return maxRequests - count - 1 +else + return -1 +end +EOF +``` + +- [ ] **Step 3: 创建 SlidingWindowRateLimiter** + +```bash +cat > server/skillhub-app/src/main/java/com/iflytek/skillhub/ratelimit/SlidingWindowRateLimiter.java << 'EOF' +package com.iflytek.skillhub.ratelimit; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.core.io.ClassPathResource; +import org.springframework.data.redis.core.StringRedisTemplate; +import org.springframework.data.redis.core.script.DefaultRedisScript; +import org.springframework.stereotype.Component; + +import java.util.Collections; + +@Component +public class SlidingWindowRateLimiter { + + private static final Logger log = LoggerFactory.getLogger(SlidingWindowRateLimiter.class); + + private final StringRedisTemplate redisTemplate; + private final DefaultRedisScript rateLimitScript; + + public SlidingWindowRateLimiter(StringRedisTemplate redisTemplate) { + this.redisTemplate = redisTemplate; + this.rateLimitScript = new DefaultRedisScript<>(); + this.rateLimitScript.setLocation(new ClassPathResource("ratelimit.lua")); + this.rateLimitScript.setResultType(Long.class); + } + + /** + * @return remaining requests, or -1 if rate limited + */ + public long tryAcquire(String key, int maxRequests, int windowSeconds) { + try { + Long result = redisTemplate.execute( + rateLimitScript, + Collections.singletonList(key), + String.valueOf(windowSeconds * 1000L), + String.valueOf(maxRequests), + String.valueOf(System.currentTimeMillis())); + return result != null ? result : -1; + } catch (Exception e) { + log.warn("Rate limiter Redis error, fail-open: {}", e.getMessage()); + return maxRequests; // fail-open + } + } +} +EOF +``` + +- [ ] **Step 4: 创建 RateLimitInterceptor** + +```bash +cat > server/skillhub-app/src/main/java/com/iflytek/skillhub/ratelimit/RateLimitInterceptor.java << 'EOF' +package com.iflytek.skillhub.ratelimit; + +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import org.springframework.stereotype.Component; +import org.springframework.web.method.HandlerMethod; +import org.springframework.web.servlet.HandlerInterceptor; + +import java.security.Principal; + +@Component +public class RateLimitInterceptor implements HandlerInterceptor { + + private final SlidingWindowRateLimiter rateLimiter; + + public RateLimitInterceptor(SlidingWindowRateLimiter rateLimiter) { + this.rateLimiter = rateLimiter; + } + + @Override + public boolean preHandle(HttpServletRequest request, HttpServletResponse response, + Object handler) throws Exception { + if (!(handler instanceof HandlerMethod handlerMethod)) { + return true; + } + + RateLimit rateLimit = handlerMethod.getMethodAnnotation(RateLimit.class); + if (rateLimit == null) { + return true; + } + + Principal principal = request.getUserPrincipal(); + boolean authenticated = principal != null; + int maxRequests = authenticated ? rateLimit.authenticated() : rateLimit.anonymous(); + + String identity = authenticated ? principal.getName() : getClientIp(request); + String key = "ratelimit:" + rateLimit.category() + ":" + identity; + + long remaining = rateLimiter.tryAcquire(key, maxRequests, rateLimit.windowSeconds()); + + if (remaining < 0) { + response.setStatus(429); + response.setHeader("Retry-After", String.valueOf(rateLimit.windowSeconds())); + response.getWriter().write("{\"code\":429,\"message\":\"Rate limit exceeded\"}"); + return false; + } + + response.setHeader("X-RateLimit-Remaining", String.valueOf(remaining)); + return true; + } + + private String getClientIp(HttpServletRequest request) { + String xff = request.getHeader("X-Forwarded-For"); + if (xff != null && !xff.isBlank()) { + return xff.split(",")[0].trim(); + } + return request.getRemoteAddr(); + } +} +EOF +``` + +- [ ] **Step 5: 创建 WebMvcRateLimitConfig** + +```bash +cat > server/skillhub-app/src/main/java/com/iflytek/skillhub/config/WebMvcRateLimitConfig.java << 'EOF' +package com.iflytek.skillhub.config; + +import com.iflytek.skillhub.ratelimit.RateLimitInterceptor; +import org.springframework.context.annotation.Configuration; +import org.springframework.web.servlet.config.annotation.InterceptorRegistry; +import org.springframework.web.servlet.config.annotation.WebMvcConfigurer; + +@Configuration +public class WebMvcRateLimitConfig implements WebMvcConfigurer { + + private final RateLimitInterceptor rateLimitInterceptor; + + public WebMvcRateLimitConfig(RateLimitInterceptor rateLimitInterceptor) { + this.rateLimitInterceptor = rateLimitInterceptor; + } + + @Override + public void addInterceptors(InterceptorRegistry registry) { + registry.addInterceptor(rateLimitInterceptor) + .addPathPatterns("/api/**"); + } +} +EOF +``` + +- [ ] **Step 6: 在关键 Controller 方法上添加 @RateLimit 注解** + +在 `SkillSearchController.searchSkills` 方法上添加: +```java +@RateLimit(category = "search", authenticated = 60, anonymous = 20, windowSeconds = 60) +``` + +在 `SkillController.downloadLatest` 和 `downloadVersion` 方法上添加: +```java +@RateLimit(category = "download", authenticated = 120, anonymous = 30, windowSeconds = 60) +``` + +在 `CliPublishController.publish` 和 `SkillPublishController.publish` 方法上添加: +```java +@RateLimit(category = "publish", authenticated = 10, anonymous = 0, windowSeconds = 3600) +``` + +- [ ] **Step 7: 编译验证** + +Run: `cd server && ./mvnw clean compile -DskipTests` + +Expected: BUILD SUCCESS + +- [ ] **Step 8: Commit** + +```bash +git add server/skillhub-app/src/main/java/com/iflytek/skillhub/ratelimit/ \ + server/skillhub-app/src/main/java/com/iflytek/skillhub/config/WebMvcRateLimitConfig.java \ + server/skillhub-app/src/main/resources/ratelimit.lua +git commit -m "feat(ratelimit): add Redis sliding window rate limiting + +- Add @RateLimit annotation with category/authenticated/anonymous/windowSeconds +- Implement SlidingWindowRateLimiter with Redis ZSET + Lua script +- Add RateLimitInterceptor: 429 + Retry-After on exceed, X-RateLimit-Remaining header +- Fail-open on Redis errors (log WARN, allow request) +- Register interceptor for /api/** paths +- Apply limits: search 60/20, download 120/30, publish 10/0 per window" +``` + +### Task 23: 应用配置更新 + SkillPackageValidator Bean 注册 + +**Files:** +- Modify: `server/skillhub-app/src/main/resources/application.yml` +- Modify: `server/skillhub-app/src/main/resources/application-local.yml` + +- [ ] **Step 1: 更新 application.yml 添加 Phase 2 配置** + +在 `application.yml` 中追加以下配置: + +```yaml +# Phase 2 配置 +skillhub: + storage: + provider: local + local: + base-path: ./data/storage + search: + provider: postgres + publish: + max-file-size: 1048576 # 1MB + max-package-size: 10485760 # 10MB + max-file-count: 100 + +spring: + servlet: + multipart: + max-file-size: 20MB + max-request-size: 20MB +``` + +- [ ] **Step 2: 更新 application-local.yml 添加 S3/MinIO 配置** + +在 `application-local.yml` 中追加: + +```yaml +skillhub: + storage: + provider: local + local: + base-path: ./data/storage + s3: + endpoint: http://localhost:9000 + bucket: skillhub + access-key: minioadmin + secret-key: minioadmin + region: us-east-1 +``` + +- [ ] **Step 3: 注册 SkillPackageValidator 和 VisibilityChecker 为 Spring Bean** + +创建配置类: + +```bash +cat > server/skillhub-app/src/main/java/com/iflytek/skillhub/config/DomainBeanConfig.java << 'EOF' +package com.iflytek.skillhub.config; + +import com.iflytek.skillhub.domain.skill.VisibilityChecker; +import com.iflytek.skillhub.domain.skill.validation.SkillPackageValidator; +import com.iflytek.skillhub.domain.skill.metadata.SkillMetadataParser; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +@Configuration +public class DomainBeanConfig { + + @Bean + public SkillPackageValidator skillPackageValidator() { + return new SkillPackageValidator(); + } + + @Bean + public SkillMetadataParser skillMetadataParser() { + return new SkillMetadataParser(); + } + + @Bean + public VisibilityChecker visibilityChecker() { + return new VisibilityChecker(); + } +} +EOF +``` + +- [ ] **Step 4: 编译验证** + +Run: `cd server && ./mvnw clean compile -DskipTests` + +Expected: BUILD SUCCESS + +- [ ] **Step 5: Commit** + +```bash +git add server/skillhub-app/src/main/resources/application.yml \ + server/skillhub-app/src/main/resources/application-local.yml \ + server/skillhub-app/src/main/java/com/iflytek/skillhub/config/DomainBeanConfig.java +git commit -m "feat(config): add Phase 2 application configuration + +- Add storage provider config (local default, S3 optional) +- Add search provider config (postgres default) +- Add publish limits config (file size, package size, file count) +- Add multipart upload size limits (20MB) +- Register SkillPackageValidator, SkillMetadataParser, VisibilityChecker beans" +``` + +### Task 24: 全量编译 + 测试验证 + +- [ ] **Step 1: 全量编译** + +Run: `cd server && ./mvnw clean compile` + +Expected: BUILD SUCCESS + +- [ ] **Step 2: 运行所有单元测试** + +Run: `cd server && ./mvnw test` + +Expected: All tests pass + +- [ ] **Step 3: 修复编译或测试错误(如有)** + +根据错误信息逐一修复,直到全部通过。 + +- [ ] **Step 4: Final Commit** + +```bash +git add -A +git commit -m "feat(phase2-chunk1): complete Phase 2 backend implementation + +Phase 2 Chunk 1 backend complete: +- DB migration V2 with skill/version/file/tag/search tables +- Object storage SPI (LocalFile + S3) +- Namespace management (CRUD + member management) +- Skill publish flow (zip → validate → store → persist → event) +- Skill query/download/tag management +- PostgreSQL full-text search with tsvector +- Async events (publish → index, download → count) +- Redis sliding window rate limiting" +``` + +### Chunk 1 验收标准 + +1. ✅ `V2__phase2_skill_tables.sql` 迁移成功 +2. ✅ Phase 1 实体补齐(Namespace.type/avatarUrl, NamespaceMember.updatedAt) +3. ✅ 对象存储 LocalFile + S3 双实现 +4. ✅ 命名空间 CRUD + 成员管理 API +5. ✅ CLI/Web 发布接口:zip → 校验 → 存储 → PUBLISHED +6. ✅ 技能详情、版本列表、文件清单、文件内容 API +7. ✅ 下载 API:latest / 指定版本 / 按标签 +8. ✅ 标签 CRUD API,latest 标签不可操作 +9. ✅ 搜索 API:关键词 + 可见性过滤 + 排序 + 分页 +10. ✅ 异步事件:发布后索引更新,下载后计数递增 +11. ✅ 限流:超限 429,Redis 不可用 fail-open +12. ✅ 所有后端测试通过 + +--- + +## Chunk 2: 前端全部 + +本块实现 Phase 2 全部前端页面:首页、搜索页、命名空间主页、技能详情页、版本历史页、发布页、我的技能、我的命名空间、成员管理。 + +### 前端文件结构映射 + +``` +web/src/ +├── api/ +│ └── client.ts # 已有,扩展新 API 类型 +├── 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/ +│ ├── components/ +│ │ ├── pagination.tsx +│ │ ├── empty-state.tsx +│ │ ├── skeleton-loader.tsx +│ │ ├── copy-button.tsx +│ │ └── namespace-badge.tsx +│ └── hooks/ +│ └── use-debounce.ts +└── routes/ # TanStack Router 路由配置 +``` + +### Task 25: 前端依赖安装 + API 类型定义 + +**Files:** +- Modify: `web/package.json` +- Create: `web/src/api/types.ts` +- Create: `web/src/shared/hooks/use-debounce.ts` + +- [ ] **Step 1: 安装新依赖** + +Run: `cd web && npm install react-markdown rehype-highlight react-dropzone zustand` + +- [ ] **Step 2: 创建 API 类型定义** + +```bash +cat > web/src/api/types.ts << 'EOF' +// Namespace types +export interface Namespace { + id: number; + slug: string; + displayName: string; + description: string; + type: string; + avatarUrl: string | null; + status: string; + createdAt: string; +} + +export interface NamespaceMember { + id: number; + namespaceId: number; + userId: number; + role: string; + createdAt: string; +} + +// Skill types +export interface SkillSummary { + id: number; + slug: string; + displayName: string; + summary: string; + downloadCount: number; + starCount: number; + ratingAvg: number; + ratingCount: number; + latestVersion: string; + namespace: string; + updatedAt: string; +} + +export interface SkillDetail { + id: number; + slug: string; + displayName: string; + summary: string; + visibility: string; + status: string; + downloadCount: number; + starCount: number; + latestVersion: string; + namespaceId: number; +} + +export interface SkillVersion { + id: number; + version: string; + status: string; + changelog: string; + fileCount: number; + totalSize: number; + publishedAt: string; +} + +export interface SkillFile { + id: number; + filePath: string; + fileSize: number; + contentType: string; + sha256: string; +} + +export interface SkillTag { + id: number; + tagName: string; + versionId: number; + createdAt: string; +} + +// Search types +export interface SearchParams { + q?: string; + namespace?: string; + sort?: string; + page?: number; + size?: number; +} + +export interface PagedResponse { + items: T[]; + total: number; + page: number; + size: number; +} + +// Publish types +export interface PublishResult { + skillId: number; + namespace: string; + slug: string; + version: string; + status: string; + fileCount: number; + totalSize: number; +} +EOF +``` + +- [ ] **Step 3: 创建 useDebounce hook** + +```bash +mkdir -p web/src/shared/hooks +cat > web/src/shared/hooks/use-debounce.ts << 'EOF' +import { useState, useEffect } from 'react'; + +export function useDebounce(value: T, delay: number = 300): T { + const [debouncedValue, setDebouncedValue] = useState(value); + + useEffect(() => { + const timer = setTimeout(() => setDebouncedValue(value), delay); + return () => clearTimeout(timer); + }, [value, delay]); + + return debouncedValue; +} +EOF +``` + +- [ ] **Step 4: Commit** + +```bash +cd web && git add package.json package-lock.json src/api/types.ts src/shared/hooks/use-debounce.ts +git commit -m "feat(web): add Phase 2 dependencies and API type definitions + +- Add react-markdown, rehype-highlight, react-dropzone, zustand +- Add comprehensive TypeScript types for all API entities +- Add useDebounce hook for search input" +``` + +### Task 26: 共享组件 — Pagination, EmptyState, SkeletonLoader, CopyButton, NamespaceBadge + +**Files:** +- Create: `web/src/shared/components/pagination.tsx` +- Create: `web/src/shared/components/empty-state.tsx` +- Create: `web/src/shared/components/skeleton-loader.tsx` +- Create: `web/src/shared/components/copy-button.tsx` +- Create: `web/src/shared/components/namespace-badge.tsx` + +- [ ] **Step 1: 创建 Pagination 组件** + +```tsx +// web/src/shared/components/pagination.tsx +import { Button } from '@/shared/ui/button'; + +interface PaginationProps { + page: number; + totalPages: number; + onPageChange: (page: number) => void; +} + +export function Pagination({ page, totalPages, onPageChange }: PaginationProps) { + return ( +
+ + + {page + 1} / {totalPages} + + +
+ ); +} +``` + +- [ ] **Step 2: 创建 EmptyState 组件** + +```tsx +// web/src/shared/components/empty-state.tsx +interface EmptyStateProps { + title: string; + description?: string; + action?: React.ReactNode; +} + +export function EmptyState({ title, description, action }: EmptyStateProps) { + return ( +
+
📭
+

{title}

+ {description &&

{description}

} + {action &&
{action}
} +
+ ); +} +``` + +- [ ] **Step 3: 创建 SkeletonLoader 组件** + +```tsx +// web/src/shared/components/skeleton-loader.tsx +export function SkeletonCard() { + return ( +
+
+
+
+
+ ); +} + +export function SkeletonList({ count = 6 }: { count?: number }) { + return ( +
+ {Array.from({ length: count }).map((_, i) => )} +
+ ); +} +``` + +- [ ] **Step 4: 创建 CopyButton 组件** + +```tsx +// web/src/shared/components/copy-button.tsx +import { useState } from 'react'; +import { Button } from '@/shared/ui/button'; + +interface CopyButtonProps { + text: string; + label?: string; +} + +export function CopyButton({ text, label = '复制' }: CopyButtonProps) { + const [copied, setCopied] = useState(false); + + const handleCopy = async () => { + await navigator.clipboard.writeText(text); + setCopied(true); + setTimeout(() => setCopied(false), 2000); + }; + + return ( + + ); +} +``` + +- [ ] **Step 5: 创建 NamespaceBadge 组件** + +```tsx +// web/src/shared/components/namespace-badge.tsx +interface NamespaceBadgeProps { + slug: string; + type?: string; +} + +export function NamespaceBadge({ slug, type }: NamespaceBadgeProps) { + const isGlobal = type === 'GLOBAL' || slug === 'global'; + return ( + + @{slug} + + ); +} +``` + +- [ ] **Step 6: Commit** + +```bash +cd web && git add src/shared/components/ +git commit -m "feat(web): add shared UI components + +- Pagination with prev/next buttons +- EmptyState with icon, title, description, action slot +- SkeletonCard/SkeletonList for loading states +- CopyButton with clipboard API and feedback +- NamespaceBadge with global(green)/team(blue) styling" +``` + +### Task 27: TanStack Query Hooks — 数据获取层 + +**Files:** +- Create: `web/src/features/skill/use-skill-detail.ts` +- Create: `web/src/features/skill/use-skill-versions.ts` +- Create: `web/src/features/skill/use-skill-files.ts` +- Create: `web/src/features/skill/use-search-skills.ts` +- Create: `web/src/features/namespace/use-namespace-detail.ts` +- Create: `web/src/features/namespace/use-namespace-members.ts` +- Create: `web/src/features/namespace/use-my-namespaces.ts` +- Create: `web/src/features/publish/use-publish-skill.ts` + +- [ ] **Step 1: 创建 Skill 查询 hooks** + +```typescript +// web/src/features/skill/use-skill-detail.ts +import { useQuery } from '@tanstack/react-query'; +import { apiClient } from '@/api/client'; +import type { SkillDetail } from '@/api/types'; + +export function useSkillDetail(namespace: string, slug: string) { + return useQuery({ + queryKey: ['skills', namespace, slug], + queryFn: () => apiClient.get(`/api/v1/skills/${namespace}/${slug}`) + .then(r => r.data.data as SkillDetail), + enabled: !!namespace && !!slug, + }); +} +``` + +```typescript +// web/src/features/skill/use-skill-versions.ts +import { useQuery } from '@tanstack/react-query'; +import { apiClient } from '@/api/client'; +import type { SkillVersion, PagedResponse } from '@/api/types'; + +export function useSkillVersions(namespace: string, slug: string, page = 0) { + return useQuery({ + queryKey: ['skills', namespace, slug, 'versions', page], + queryFn: () => apiClient.get(`/api/v1/skills/${namespace}/${slug}/versions`, { + params: { page, size: 20 } + }).then(r => r.data.data as PagedResponse), + enabled: !!namespace && !!slug, + }); +} +``` + +```typescript +// web/src/features/skill/use-skill-files.ts +import { useQuery } from '@tanstack/react-query'; +import { apiClient } from '@/api/client'; +import type { SkillFile } from '@/api/types'; + +export function useSkillFiles(namespace: string, slug: string, version: string) { + return useQuery({ + queryKey: ['skills', namespace, slug, 'versions', version, 'files'], + queryFn: () => apiClient.get( + `/api/v1/skills/${namespace}/${slug}/versions/${version}/files` + ).then(r => r.data.data as SkillFile[]), + enabled: !!namespace && !!slug && !!version, + }); +} +``` + +```typescript +// web/src/features/skill/use-search-skills.ts +import { useQuery } from '@tanstack/react-query'; +import { apiClient } from '@/api/client'; +import type { SearchParams, SkillSummary, PagedResponse } from '@/api/types'; + +export function useSearchSkills(params: SearchParams) { + return useQuery({ + queryKey: ['skills', 'search', params], + queryFn: () => apiClient.get('/api/v1/skills', { params }) + .then(r => r.data.data as PagedResponse), + placeholderData: (prev) => prev, + }); +} +``` + +- [ ] **Step 2: 创建 Namespace 查询 hooks** + +```typescript +// web/src/features/namespace/use-namespace-detail.ts +import { useQuery } from '@tanstack/react-query'; +import { apiClient } from '@/api/client'; +import type { Namespace } from '@/api/types'; + +export function useNamespaceDetail(slug: string) { + return useQuery({ + queryKey: ['namespaces', slug], + queryFn: () => apiClient.get(`/api/v1/namespaces/${slug}`) + .then(r => r.data.data as Namespace), + enabled: !!slug, + }); +} +``` + +```typescript +// web/src/features/namespace/use-namespace-members.ts +import { useQuery } from '@tanstack/react-query'; +import { apiClient } from '@/api/client'; +import type { NamespaceMember, PagedResponse } from '@/api/types'; + +export function useNamespaceMembers(slug: string, page = 0) { + return useQuery({ + queryKey: ['namespaces', slug, 'members', page], + queryFn: () => apiClient.get(`/api/v1/namespaces/${slug}/members`, { + params: { page, size: 20 } + }).then(r => r.data.data as PagedResponse), + enabled: !!slug, + }); +} +``` + +```typescript +// web/src/features/namespace/use-my-namespaces.ts +import { useQuery } from '@tanstack/react-query'; +import { apiClient } from '@/api/client'; +import type { Namespace } from '@/api/types'; + +export function useMyNamespaces() { + return useQuery({ + queryKey: ['me', 'namespaces'], + queryFn: () => apiClient.get('/api/v1/namespaces') + .then(r => r.data.data.items as Namespace[]), + }); +} +``` + +- [ ] **Step 3: 创建 Publish mutation hook** + +```typescript +// web/src/features/publish/use-publish-skill.ts +import { useMutation, useQueryClient } from '@tanstack/react-query'; +import { apiClient } from '@/api/client'; +import type { PublishResult } from '@/api/types'; + +export function usePublishSkill() { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: async ({ file, namespace, visibility }: { + file: File; namespace: string; visibility: string; + }) => { + const formData = new FormData(); + formData.append('file', file); + formData.append('visibility', visibility); + const res = await apiClient.post( + `/api/v1/skills/${namespace}/publish`, formData, + { headers: { 'Content-Type': 'multipart/form-data' } }); + return res.data.data as PublishResult; + }, + onSuccess: (_, variables) => { + queryClient.invalidateQueries({ queryKey: ['me', 'skills'] }); + queryClient.invalidateQueries({ queryKey: ['namespaces', variables.namespace, 'skills'] }); + }, + }); +} +``` + +- [ ] **Step 4: Commit** + +```bash +cd web && git add src/features/ +git commit -m "feat(web): add TanStack Query hooks for all API endpoints + +- useSkillDetail, useSkillVersions, useSkillFiles, useSearchSkills +- useNamespaceDetail, useNamespaceMembers, useMyNamespaces +- usePublishSkill mutation with cache invalidation +- placeholderData for search to avoid flicker" +``` + +### Task 28: Feature 组件 — SkillCard, MarkdownRenderer, FileTree, SearchBar, InstallCommand + +**Files:** +- Create: `web/src/features/skill/skill-card.tsx` +- Create: `web/src/features/skill/markdown-renderer.tsx` +- Create: `web/src/features/skill/file-tree.tsx` +- Create: `web/src/features/skill/install-command.tsx` +- Create: `web/src/features/search/search-bar.tsx` +- Create: `web/src/features/search/search-filters.tsx` +- Create: `web/src/features/publish/upload-zone.tsx` + +- [ ] **Step 1: 创建 SkillCard** + +```tsx +// web/src/features/skill/skill-card.tsx +import { Link } from '@tanstack/react-router'; +import { NamespaceBadge } from '@/shared/components/namespace-badge'; +import type { SkillSummary } from '@/api/types'; + +export function SkillCard({ skill }: { skill: SkillSummary }) { + return ( + +
+ + {skill.displayName || skill.slug} +
+

{skill.summary}

+
+ v{skill.latestVersion} + ↓ {skill.downloadCount} + {skill.ratingAvg > 0 && ★ {skill.ratingAvg.toFixed(1)}} +
+ + ); +} +``` + +- [ ] **Step 2: 创建 MarkdownRenderer** + +```tsx +// web/src/features/skill/markdown-renderer.tsx +import ReactMarkdown from 'react-markdown'; +import rehypeHighlight from 'rehype-highlight'; + +export function MarkdownRenderer({ content }: { content: string }) { + return ( +
+ + {content} + +
+ ); +} +``` + +- [ ] **Step 3: 创建 FileTree** + +```tsx +// web/src/features/skill/file-tree.tsx +import type { SkillFile } from '@/api/types'; + +function formatSize(bytes: number): string { + if (bytes < 1024) return bytes + ' B'; + if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(1) + ' KB'; + return (bytes / (1024 * 1024)).toFixed(1) + ' MB'; +} + +export function FileTree({ files, onSelect }: { + files: SkillFile[]; + onSelect?: (file: SkillFile) => void; +}) { + return ( +
+ {files.map(file => ( + + ))} +
+ ); +} +``` + +- [ ] **Step 4: 创建 InstallCommand** + +```tsx +// web/src/features/skill/install-command.tsx +import { CopyButton } from '@/shared/components/copy-button'; + +export function InstallCommand({ namespace, slug }: { namespace: string; slug: string }) { + const command = `skillhub install @${namespace}/${slug}`; + return ( +
+ {command} + +
+ ); +} +``` + +- [ ] **Step 5: 创建 SearchBar** + +```tsx +// web/src/features/search/search-bar.tsx +import { useState } from 'react'; +import { useNavigate } from '@tanstack/react-router'; + +export function SearchBar({ defaultValue = '', className = '' }: { + defaultValue?: string; className?: string; +}) { + const [query, setQuery] = useState(defaultValue); + const navigate = useNavigate(); + + const handleSubmit = (e: React.FormEvent) => { + e.preventDefault(); + navigate({ to: '/search', search: { q: query } }); + }; + + return ( +
+ setQuery(e.target.value)} + placeholder="搜索技能..." + className="w-full rounded-lg border px-4 py-2.5 text-sm focus:outline-none focus:ring-2 focus:ring-primary" /> +
+ ); +} +``` + +- [ ] **Step 6: 创建 UploadZone** + +```tsx +// web/src/features/publish/upload-zone.tsx +import { useCallback } from 'react'; +import { useDropzone } from 'react-dropzone'; + +export function UploadZone({ onFileSelect }: { onFileSelect: (file: File) => void }) { + const onDrop = useCallback((files: File[]) => { + if (files.length > 0) onFileSelect(files[0]); + }, [onFileSelect]); + + const { getRootProps, getInputProps, isDragActive } = useDropzone({ + onDrop, accept: { 'application/zip': ['.zip'] }, maxFiles: 1, + }); + + return ( +
+ +
📦
+

+ {isDragActive ? '释放文件...' : '拖拽 .zip 文件到此处,或点击选择'} +

+
+ ); +} +``` + +- [ ] **Step 7: Commit** + +```bash +cd web && git add src/features/ +git commit -m "feat(web): add feature components + +- SkillCard with namespace badge, stats, hover shadow +- MarkdownRenderer with react-markdown + rehype-highlight +- FileTree with file size formatting and selection +- InstallCommand with copy button +- SearchBar with form submit navigation +- UploadZone with react-dropzone drag-and-drop" +``` + +### Task 29: 首页 + 搜索页 + +**Files:** +- Modify: `web/src/pages/home.tsx` (或已有首页文件) +- Create: `web/src/pages/search.tsx` + +- [ ] **Step 1: 实现首页** + +首页包含:Hero 区域(标题 + 搜索框)、三栏卡片区(精选/热门/最新各 top 6)。 + +```tsx +// web/src/pages/home.tsx +import { SearchBar } from '@/features/search/search-bar'; +import { SkillCard } from '@/features/skill/skill-card'; +import { SkeletonList } from '@/shared/components/skeleton-loader'; +import { useSearchSkills } from '@/features/skill/use-search-skills'; + +function SkillSection({ title, sort }: { title: string; sort: string }) { + const { data, isLoading } = useSearchSkills({ sort, size: 6 }); + return ( +
+

{title}

+ {isLoading ? : ( +
+ {data?.items.map(skill => )} +
+ )} +
+ ); +} + +export default function HomePage() { + return ( +
+
+

SkillHub

+

发现、分享和管理 AI 技能

+ +
+ + +
+ ); +} +``` + +- [ ] **Step 2: 实现搜索页** + +```tsx +// web/src/pages/search.tsx +import { useState } from 'react'; +import { useSearch } from '@tanstack/react-router'; +import { SearchBar } from '@/features/search/search-bar'; +import { SkillCard } from '@/features/skill/skill-card'; +import { Pagination } from '@/shared/components/pagination'; +import { EmptyState } from '@/shared/components/empty-state'; +import { SkeletonList } from '@/shared/components/skeleton-loader'; +import { useSearchSkills } from '@/features/skill/use-search-skills'; + +export default function SearchPage() { + const search = useSearch({ from: '/search' }); + const [page, setPage] = useState(search.page ?? 0); + const sort = search.sort ?? 'relevance'; + + const { data, isLoading } = useSearchSkills({ + q: search.q, namespace: search.namespace, sort, page, size: 20, + }); + + return ( +
+ +
+ +
+ {isLoading ? : data?.items.length === 0 ? ( + + ) : ( + <> +
+ {data?.items.map(skill => )} +
+ {data && } + + )} +
+ ); +} +``` + +- [ ] **Step 3: Commit** + +```bash +cd web && git add src/pages/home.tsx src/pages/search.tsx +git commit -m "feat(web): add home page and search page + +- Home: hero section with search bar, popular/newest skill grids +- Search: keyword search, sort selector, paginated results grid +- Loading skeletons and empty state handling" +``` + +### Task 30: 命名空间主页 + 技能详情页 + +**Files:** +- Create: `web/src/pages/namespace.tsx` +- Create: `web/src/pages/skill-detail.tsx` +- Create: `web/src/features/skill/skill-detail-view.tsx` +- Create: `web/src/features/namespace/namespace-header.tsx` + +- [ ] **Step 1: 创建 NamespaceHeader 组件** + +```tsx +// web/src/features/namespace/namespace-header.tsx +import { NamespaceBadge } from '@/shared/components/namespace-badge'; +import type { Namespace } from '@/api/types'; + +export function NamespaceHeader({ namespace }: { namespace: Namespace }) { + return ( +
+ {namespace.avatarUrl ? ( + + ) : ( +
+ {namespace.displayName[0]} +
+ )} +
+
+

{namespace.displayName}

+ +
+ {namespace.description && ( +

{namespace.description}

+ )} +
+
+ ); +} +``` + +- [ ] **Step 2: 实现命名空间主页** + +```tsx +// web/src/pages/namespace.tsx +import { useParams } from '@tanstack/react-router'; +import { useNamespaceDetail } from '@/features/namespace/use-namespace-detail'; +import { useSearchSkills } from '@/features/skill/use-search-skills'; +import { NamespaceHeader } from '@/features/namespace/namespace-header'; +import { SkillCard } from '@/features/skill/skill-card'; +import { SkeletonList } from '@/shared/components/skeleton-loader'; +import { EmptyState } from '@/shared/components/empty-state'; + +export default function NamespacePage() { + const { namespace } = useParams({ from: '/@$namespace' }); + const { data: ns, isLoading: nsLoading } = useNamespaceDetail(namespace); + const { data: skills, isLoading: skillsLoading } = useSearchSkills({ + namespace, sort: 'downloads', size: 50, + }); + + if (nsLoading) return ; + if (!ns) return ; + + return ( +
+ + {skillsLoading ? : skills?.items.length === 0 ? ( + + ) : ( +
+ {skills?.items.map(s => )} +
+ )} +
+ ); +} +``` + +- [ ] **Step 3: 创建 SkillDetailView 组件** + +```tsx +// web/src/features/skill/skill-detail-view.tsx +import { useState } from 'react'; +import { MarkdownRenderer } from './markdown-renderer'; +import { FileTree } from './file-tree'; +import { InstallCommand } from './install-command'; +import { NamespaceBadge } from '@/shared/components/namespace-badge'; +import type { SkillDetail, SkillFile } from '@/api/types'; + +type Tab = 'readme' | 'files' | 'versions'; + +export function SkillDetailView({ skill, namespace, files, readmeContent }: { + skill: SkillDetail; namespace: string; + files: SkillFile[]; readmeContent: string; +}) { + const [tab, setTab] = useState('readme'); + + return ( +
+
+
+ {(['readme', 'files', 'versions'] as Tab[]).map(t => ( + + ))} +
+ {tab === 'readme' && } + {tab === 'files' && } +
+ +
+ ); +} +``` + +- [ ] **Step 4: 实现技能详情页** + +```tsx +// web/src/pages/skill-detail.tsx +import { useParams } from '@tanstack/react-router'; +import { useSkillDetail } from '@/features/skill/use-skill-detail'; +import { useSkillFiles } from '@/features/skill/use-skill-files'; +import { SkillDetailView } from '@/features/skill/skill-detail-view'; +import { SkeletonList } from '@/shared/components/skeleton-loader'; + +export default function SkillDetailPage() { + const { namespace, slug } = useParams({ from: '/@$namespace/$slug' }); + const { data: skill, isLoading } = useSkillDetail(namespace, slug); + const { data: files } = useSkillFiles(namespace, slug, skill?.latestVersion ?? ''); + + if (isLoading) return
; + if (!skill) return null; + + return ( +
+

{skill.displayName || skill.slug}

+ +
+ ); +} +``` + +- [ ] **Step 5: Commit** + +```bash +cd web && git add src/pages/namespace.tsx src/pages/skill-detail.tsx \ + src/features/skill/skill-detail-view.tsx src/features/namespace/namespace-header.tsx +git commit -m "feat(web): add namespace page and skill detail page + +- Namespace page: header with avatar/badge, skill grid +- Skill detail: tabbed view (README/files/versions), sidebar with stats/install/download +- NamespaceHeader and SkillDetailView components" +``` + +### Task 31: Dashboard 页面 — 发布/我的技能/命名空间管理 + +**Files:** +- Create: `web/src/pages/dashboard/publish.tsx` +- Create: `web/src/pages/dashboard/skills.tsx` +- Create: `web/src/pages/dashboard/namespaces.tsx` +- Create: `web/src/pages/dashboard/namespace-members.tsx` +- Create: `web/src/features/publish/publish-form.tsx` +- Create: `web/src/features/namespace/create-namespace-dialog.tsx` +- Create: `web/src/features/namespace/member-table.tsx` + +- [ ] **Step 1: 创建 PublishForm 组件** + +```tsx +// web/src/features/publish/publish-form.tsx +import { useState } from 'react'; +import { useNavigate } from '@tanstack/react-router'; +import { UploadZone } from './upload-zone'; +import { usePublishSkill } from './use-publish-skill'; +import { useMyNamespaces } from '@/features/namespace/use-my-namespaces'; +import { Button } from '@/shared/ui/button'; + +export function PublishForm() { + const [file, setFile] = useState(null); + const [namespace, setNamespace] = useState(''); + const [visibility, setVisibility] = useState('PUBLIC'); + const { data: namespaces } = useMyNamespaces(); + const publish = usePublishSkill(); + const navigate = useNavigate(); + + const handlePublish = () => { + if (!file || !namespace) return; + publish.mutate({ file, namespace, visibility }, { + onSuccess: (result) => { + navigate({ to: `/@${result.namespace}/${result.slug}` }); + }, + }); + }; + + return ( +
+
+ + +
+
+ + + {file &&

已选择: {file.name}

} +
+
+ + +
+ +
+ ); +} +``` + +- [ ] **Step 2: 实现发布页** + +```tsx +// web/src/pages/dashboard/publish.tsx +import { PublishForm } from '@/features/publish/publish-form'; + +export default function PublishPage() { + return ( +
+

发布技能

+ +
+ ); +} +``` + +- [ ] **Step 3: 实现我的技能页** + +```tsx +// web/src/pages/dashboard/skills.tsx +import { Link } from '@tanstack/react-router'; +import { useSearchSkills } from '@/features/skill/use-search-skills'; +import { EmptyState } from '@/shared/components/empty-state'; +import { Button } from '@/shared/ui/button'; + +export default function MySkillsPage() { + const { data, isLoading } = useSearchSkills({ sort: 'newest', size: 50 }); + + return ( +
+
+

我的技能

+ +
+ {isLoading ?

加载中...

: data?.items.length === 0 ? ( + } /> + ) : ( +
+ {data?.items.map(skill => ( + +
+
{skill.displayName || skill.slug}
+
@{skill.namespace}
+
+
+ v{skill.latestVersion} · ↓{skill.downloadCount} +
+ + ))} +
+ )} +
+ ); +} +``` + +- [ ] **Step 4: 实现我的命名空间页** + +```tsx +// web/src/pages/dashboard/namespaces.tsx +import { Link } from '@tanstack/react-router'; +import { useMyNamespaces } from '@/features/namespace/use-my-namespaces'; +import { NamespaceBadge } from '@/shared/components/namespace-badge'; +import { EmptyState } from '@/shared/components/empty-state'; +import { Button } from '@/shared/ui/button'; + +export default function MyNamespacesPage() { + const { data: namespaces, isLoading } = useMyNamespaces(); + + return ( +
+
+

我的命名空间

+ +
+ {isLoading ?

加载中...

: namespaces?.length === 0 ? ( + + ) : ( +
+ {namespaces?.map(ns => ( + +
+ + {ns.displayName} +
+

{ns.description}

+ + ))} +
+ )} +
+ ); +} +``` + +- [ ] **Step 5: 创建 MemberTable 组件** + +```tsx +// web/src/features/namespace/member-table.tsx +import type { NamespaceMember } from '@/api/types'; +import { Button } from '@/shared/ui/button'; + +export function MemberTable({ members, onRemove, onRoleChange }: { + members: NamespaceMember[]; + onRemove?: (userId: number) => void; + onRoleChange?: (userId: number, role: string) => void; +}) { + return ( +
+ + + + + + + + + + + {members.map(m => ( + + + + + + + ))} + +
用户 ID角色加入时间操作
{m.userId} + + + {new Date(m.createdAt).toLocaleDateString()} + + {m.role !== 'OWNER' && ( + + )} +
+
+ ); +} +``` + +- [ ] **Step 6: 实现成员管理页** + +```tsx +// web/src/pages/dashboard/namespace-members.tsx +import { useParams } from '@tanstack/react-router'; +import { useNamespaceDetail } from '@/features/namespace/use-namespace-detail'; +import { useNamespaceMembers } from '@/features/namespace/use-namespace-members'; +import { MemberTable } from '@/features/namespace/member-table'; +import { NamespaceHeader } from '@/features/namespace/namespace-header'; +import { Button } from '@/shared/ui/button'; + +export default function NamespaceMembersPage() { + const { slug } = useParams({ from: '/dashboard/namespaces/$slug/members' }); + const { data: ns } = useNamespaceDetail(slug); + const { data: members } = useNamespaceMembers(slug); + + return ( +
+ {ns && } +
+

成员管理

+ +
+ {members && } +
+ ); +} +``` + +- [ ] **Step 7: Commit** + +```bash +cd web && git add src/pages/dashboard/ src/features/publish/publish-form.tsx \ + src/features/namespace/member-table.tsx src/features/namespace/create-namespace-dialog.tsx +git commit -m "feat(web): add dashboard pages + +- Publish page: namespace selector, upload zone, visibility, confirm +- My Skills page: skill list with stats, empty state with CTA +- My Namespaces page: namespace cards with badge +- Namespace Members page: member table with role change and remove +- PublishForm, MemberTable components" +``` + +### Task 32: TanStack Router 路由配置 + +**Files:** +- Modify: `web/src/routes/` (路由配置文件) + +- [ ] **Step 1: 配置 Phase 2 路由** + +根据项目已有的 TanStack Router 配置模式,添加以下路由: + +``` +/ → HomePage +/search → SearchPage (search params: q, namespace, sort, page) +/@{namespace} → NamespacePage +/@{namespace}/{slug} → SkillDetailPage +/@{namespace}/{slug}/versions → SkillVersionsPage +/dashboard/skills → MySkillsPage (需登录) +/dashboard/publish → PublishPage (需登录) +/dashboard/namespaces → MyNamespacesPage (需登录) +/dashboard/namespaces/{slug}/members → NamespaceMembersPage (需登录) +``` + +门户区(`/`, `/search`, `/@*`)匿名可访问。 +Dashboard 区需登录(复用 Phase 1 路由守卫)。 + +- [ ] **Step 2: 验证路由可访问** + +Run: `cd web && npm run build` + +Expected: BUILD SUCCESS,无路由错误 + +- [ ] **Step 3: Commit** + +```bash +cd web && git add src/routes/ +git commit -m "feat(web): configure Phase 2 routes + +- Public routes: /, /search, /@namespace, /@namespace/slug +- Dashboard routes: /dashboard/skills, /dashboard/publish, /dashboard/namespaces +- Dashboard routes require authentication (Phase 1 guard)" +``` + +### Task 33: 前端全量构建 + 验证 + +- [ ] **Step 1: 全量构建** + +Run: `cd web && npm run build` + +Expected: BUILD SUCCESS + +- [ ] **Step 2: 类型检查** + +Run: `cd web && npx tsc --noEmit` + +Expected: 无类型错误 + +- [ ] **Step 3: 修复构建或类型错误(如有)** + +根据错误信息逐一修复。 + +- [ ] **Step 4: Final Commit** + +```bash +cd web && git add -A +git commit -m "feat(phase2-chunk2): complete Phase 2 frontend implementation + +Phase 2 Chunk 2 frontend complete: +- Home page with hero search and skill grids +- Search page with keyword/sort/pagination +- Namespace page with header and skill list +- Skill detail page with README/files/versions tabs +- Publish page with drag-drop upload +- Dashboard: my skills, my namespaces, member management +- Shared components: Pagination, EmptyState, SkeletonLoader, CopyButton, NamespaceBadge +- TanStack Query hooks for all API endpoints +- Feature components: SkillCard, MarkdownRenderer, FileTree, InstallCommand, SearchBar" +``` + +### Chunk 2 验收标准 + +1. ✅ 首页展示热门/最新技能,搜索框可跳转搜索页 +2. ✅ 搜索页:关键词搜索 + 排序 + 分页,URL 驱动 +3. ✅ 命名空间主页:展示空间信息 + 技能列表 +4. ✅ 技能详情页:Markdown 渲染 + 文件树 + 安装命令 + 下载 +5. ✅ 发布页:拖拽上传 + 选择命名空间/可见性 + 确认发布 +6. ✅ 我的技能:列表展示 + 跳转详情 +7. ✅ 命名空间管理:创建 + 成员管理(添加/移除/角色变更) +8. ✅ 匿名用户可浏览/搜索/下载 PUBLIC 技能 +9. ✅ 前端构建通过,无类型错误