267 KiB
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。
身份主键约束: 用户身份主键全链路统一使用 string。本计划中所有 userId / ownerId / createdBy / updatedBy / reviewedBy 等用户标识字段均按字符串实现;旧的 Long / BIGINT 表述仅代表历史残留,不得继续照抄到代码或数据库设计。
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 迁移脚本
-- 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 VARCHAR(128) 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 VARCHAR(128) REFERENCES user_account(id),
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_by VARCHAR(128) 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 VARCHAR(128) 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 VARCHAR(128) 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 VARCHAR(128) 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
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 枚举
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 中添加缺失字段:
@Enumerated(EnumType.STRING)
@Column(nullable = false, length = 32)
private NamespaceType type = NamespaceType.TEAM;
@Column(name = "avatar_url", length = 512)
private String avatarUrl;
添加 getter/setter:
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 字段:
@Column(name = "updated_at", nullable = false)
private LocalDateTime updatedAt;
修改 @PrePersist 和添加 @PreUpdate:
@PrePersist
void prePersist() {
this.createdAt = LocalDateTime.now();
this.updatedAt = this.createdAt;
}
@PreUpdate
void preUpdate() {
this.updatedAt = LocalDateTime.now();
}
添加 getter:
public LocalDateTime getUpdatedAt() { return updatedAt; }
- Step 4: 扩展 NamespaceRepository 接口
在 NamespaceRepository.java 中添加新方法:
import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
Page<Namespace> findByStatus(NamespaceStatus status, Pageable pageable);
- Step 5: 扩展 NamespaceMemberRepository 接口
在 NamespaceMemberRepository.java 中添加新方法:
import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
Page<NamespaceMember> findByNamespaceId(Long namespaceId, Pageable pageable);
void deleteByNamespaceIdAndUserId(Long namespaceId, String userId);
- Step 6: 更新 NamespaceJpaRepository 实现
在 NamespaceJpaRepository.java 中添加方法声明(Spring Data JPA 自动实现):
import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
import com.iflytek.skillhub.domain.namespace.NamespaceStatus;
Page<Namespace> findByStatus(NamespaceStatus status, Pageable pageable);
- Step 7: 更新 NamespaceMemberJpaRepository 实现
在 NamespaceMemberJpaRepository.java 中添加方法声明:
import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
Page<NamespaceMember> findByNamespaceId(Long namespaceId, Pageable pageable);
void deleteByNamespaceIdAndUserId(Long namespaceId, String userId);
- Step 8: 编译验证
Run: cd server && ./mvnw clean compile -DskipTests
Expected: BUILD SUCCESS
- Step 9: Commit
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 测试
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
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<String> 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
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
cat > server/skillhub-storage/pom.xml << 'EOF'
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0
http://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<parent>
<groupId>com.iflytek.skillhub</groupId>
<artifactId>skillhub-parent</artifactId>
<version>0.1.0-SNAPSHOT</version>
</parent>
<artifactId>skillhub-storage</artifactId>
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter</artifactId>
</dependency>
<dependency>
<groupId>org.yaml</groupId>
<artifactId>snakeyaml</artifactId>
</dependency>
<dependency>
<groupId>software.amazon.awssdk</groupId>
<artifactId>s3</artifactId>
<version>2.20.26</version>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>
</dependencies>
</project>
EOF
- Step 2: 创建 ObjectStorageService SPI 接口
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<String> keys);
/**
* 检查对象是否存在
* @param key 对象 key
* @return 是否存在
*/
boolean exists(String key);
/**
* 获取对象元数据
* @param key 对象 key
* @return 元数据
*/
ObjectMetadata getMetadata(String key);
}
EOF
- Step 3: 创建 ObjectMetadata record
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 配置类
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
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 测试
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
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<String> 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
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 配置类
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
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<String> keys) {
if (keys.isEmpty()) return;
List<ObjectIdentifier> 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
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: 创建枚举类型
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 实体
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 实体
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 实体
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 实体
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 接口
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<Skill> findById(Long id);
Optional<Skill> findByNamespaceIdAndSlug(Long namespaceId, String slug);
Page<Skill> findByNamespaceIdAndStatus(Long namespaceId, SkillStatus status, Pageable pageable);
Skill save(Skill skill);
List<Skill> findByOwnerId(Long ownerId);
void incrementDownloadCount(Long skillId);
}
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<SkillVersion> findById(Long id);
Optional<SkillVersion> findBySkillIdAndVersion(Long skillId, String version);
Page<SkillVersion> 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<SkillFile> findByVersionId(Long versionId);
SkillFile save(SkillFile file);
void saveAll(List<SkillFile> 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<SkillTag> findBySkillIdAndTagName(Long skillId, String tagName);
List<SkillTag> 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
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
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<Skill, Long>, SkillRepository {
Optional<Skill> findByNamespaceIdAndSlug(Long namespaceId, String slug);
Page<Skill> findByNamespaceIdAndStatus(Long namespaceId, SkillStatus status, Pageable pageable);
List<Skill> 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
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<SkillVersion, Long>, SkillVersionRepository {
Optional<SkillVersion> findBySkillIdAndVersion(Long skillId, String version);
Page<SkillVersion> findBySkillIdAndStatus(Long skillId, SkillVersionStatus status, Pageable pageable);
}
EOF
- Step 3: 创建 SkillFileJpaRepository
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<SkillFile, Long>, SkillFileRepository {
List<SkillFile> findByVersionId(Long versionId);
void deleteByVersionId(Long versionId);
}
EOF
- Step 4: 创建 SkillTagJpaRepository
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<SkillTag, Long>, SkillTagRepository {
Optional<SkillTag> findBySkillIdAndTagName(Long skillId, String tagName);
List<SkillTag> findBySkillId(Long skillId);
}
EOF
- Step 5: 编译验证
Run: cd server && ./mvnw clean compile -DskipTests
Expected: BUILD SUCCESS
- Step 6: Commit
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
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<String, Object> frontmatter
) {}
EOF
- Step 2: 编写 SkillMetadataParser 测试
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 依赖(如果尚未存在):
<dependency>
<groupId>org.yaml</groupId>
<artifactId>snakeyaml</artifactId>
</dependency>
- Step 5: 实现 SkillMetadataParser
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<String, Object> frontmatter;
try {
Yaml yaml = new Yaml();
Object parsed = yaml.load(yamlContent);
if (parsed instanceof Map) {
frontmatter = (Map<String, Object>) 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<String, Object> 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
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
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<String> errors) {
public static ValidationResult pass() {
return new ValidationResult(true, List.of());
}
public static ValidationResult fail(List<String> errors) {
return new ValidationResult(false, errors);
}
public static ValidationResult fail(String error) {
return new ValidationResult(false, List.of(error));
}
}
EOF
- Step 2: 创建 PrePublishValidator 接口和 NoOpPrePublishValidator
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<PackageEntry> entries,
SkillMetadata metadata,
String 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 测试
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<PackageEntry> 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<PackageEntry> 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<PackageEntry> 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<PackageEntry> 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<PackageEntry> 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<PackageEntry> 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
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<String> 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<PackageEntry> entries) {
List<String> 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
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 测试
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
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, String currentUserId,
Map<Long, NamespaceRole> 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, String 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
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 测试
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 测试
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<NamespaceRole> 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
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
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, String 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, String 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, String 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, String currentOwnerId, String 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<NamespaceRole> getMemberRole(Long namespaceId, String 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
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 类
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 String 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,
String 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
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<Namespace> 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 String 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<String, String> body,
@AuthenticationPrincipal String 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<NamespaceMember> 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 String 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 String userId,
@RequestBody Map<String, String> 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 中添加:
import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
public Page<NamespaceMember> 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
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
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, String 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
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 测试
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<PackageEntry> 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<PackageEntry> 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<PackageEntry> 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
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 >> 追加):
cat >> server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/service/SkillPublishService.java << 'JAVAEOF'
@Transactional
public SkillVersion publishFromEntries(String namespaceSlug,
List<PackageEntry> entries,
String 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<SkillFile> 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
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 测试
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<Skill> 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<Skill> 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<SkillFile> 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
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,
String currentUserId,
Map<Long, NamespaceRole> 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<Skill> listSkillsByNamespace(String namespaceSlug,
String currentUserId,
Map<Long, NamespaceRole> userNsRoles,
Pageable pageable) {
Namespace ns = findNamespace(namespaceSlug);
return skillRepository.findByNamespaceIdAndStatus(
ns.getId(), SkillStatus.ACTIVE, pageable);
}
public List<SkillFile> 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<SkillVersion> 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
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 测试
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
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,
String currentUserId,
Map<Long, NamespaceRole> 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, String currentUserId,
Map<Long, NamespaceRole> 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, String currentUserId,
Map<Long, NamespaceRole> 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,
String currentUserId,
Map<Long, NamespaceRole> 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
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 测试
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<SkillTag> 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
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<SkillTag> 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
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
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
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 String userId) throws IOException {
List<PackageEntry> 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<PackageEntry> extractZip(MultipartFile file) throws IOException {
List<PackageEntry> 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 端发布)
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 String userId) throws IOException {
List<PackageEntry> 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<PackageEntry> extractZip(MultipartFile file) throws IOException {
List<PackageEntry> 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)
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 String 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<SkillVersion> versions = queryService.listVersions(
namespace, slug, PageRequest.of(page, size));
List<SkillVersionResponse> 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<SkillFile> files = queryService.listFiles(namespace, slug, version);
List<SkillFileResponse> 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 String 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 String 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
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<SkillTag> tags = tagService.listTags(namespace, slug);
List<TagResponse> 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 String 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 String 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
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
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(
String userId,
Set<Long> memberNamespaceIds,
Set<Long> 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<Long> 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<SkillSearchDocument> 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 实体
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<SkillSearchDocumentEntity, Long> {
Optional<SkillSearchDocumentEntity> findBySkillId(Long skillId);
void deleteBySkillId(Long skillId);
}
EOF
- Step 3: 实现 PostgresFullTextIndexService
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<SkillSearchDocument> 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
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<Object> 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<Number> ids = nativeQuery.getResultList();
List<Long> 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
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<Skill> batch;
do {
batch = skillRepository.findByNamespaceIdAndStatus(
null, SkillStatus.ACTIVE, PageRequest.of(page, BATCH_SIZE));
List<SkillSearchDocument> 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<Skill> skills = skillRepository.findByNamespaceIdAndStatus(
namespaceId, SkillStatus.ACTIVE, PageRequest.of(0, BATCH_SIZE));
List<SkillSearchDocument> 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
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,
String 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<SkillSummaryDTO> 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(String 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<SkillSummaryDTO> items, long total, int page, int size
) {}
}
EOF
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 String 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
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
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
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
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
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 注解
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 脚本
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
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<Long> 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
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
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 方法上添加:
@RateLimit(category = "search", authenticated = 60, anonymous = 20, windowSeconds = 60)
在 SkillController.downloadLatest 和 downloadVersion 方法上添加:
@RateLimit(category = "download", authenticated = 120, anonymous = 30, windowSeconds = 60)
在 CliPublishController.publish 和 SkillPublishController.publish 方法上添加:
@RateLimit(category = "publish", authenticated = 10, anonymous = 0, windowSeconds = 3600)
- Step 7: 编译验证
Run: cd server && ./mvnw clean compile -DskipTests
Expected: BUILD SUCCESS
- Step 8: Commit
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 中追加以下配置:
# 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 中追加:
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
创建配置类:
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
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
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 验收标准
运行以下命令验证 Chunk 1 完成:
# 1. 启动依赖服务
make dev
# 2. 运行所有后端测试
cd server && ./mvnw test
# Expected: BUILD SUCCESS, all tests pass
# 3. 启动后端应用
cd server && ./mvnw spring-boot:run -Dspring-boot.run.profiles=local &
sleep 15
# 4. 验证 Phase 2 数据库迁移
docker compose exec postgres psql -U skillhub -d skillhub -c "\dt skill*"
# Expected: 列出 skill, skill_version, skill_file, skill_tag, skill_search_document 五张表
# 5. 验证命名空间 CRUD(创建命名空间)
curl -s -X POST http://localhost:8080/api/v1/namespaces \
-H "Content-Type: application/json" \
-d '{"slug":"test-ns","displayName":"Test NS","description":"test"}' | jq .
# Expected: {"code":0,"data":{"slug":"test-ns","displayName":"Test NS",...}}
# 6. 验证命名空间查询
curl -s http://localhost:8080/api/v1/namespaces/test-ns | jq .
# Expected: {"code":0,"data":{"slug":"test-ns",...}}
# 7. 验证 CLI 发布接口(创建测试 zip)
mkdir -p /tmp/test-skill && cat > /tmp/test-skill/SKILL.md << 'SKILLEOF'
---
name: hello-world
description: A test skill
version: 1.0.0
---
# Hello World
Test skill body.
SKILLEOF
cd /tmp/test-skill && zip -r /tmp/test-skill.zip . && cd -
curl -s -X POST http://localhost:8080/api/v1/cli/publish \
-F "file=@/tmp/test-skill.zip" \
-F "namespace=test-ns" \
-F "visibility=PUBLIC" | jq .
# Expected: {"code":0,"data":{"slug":"hello-world","version":"1.0.0","status":"PUBLISHED",...}}
# 8. 验证技能详情查询
curl -s http://localhost:8080/api/v1/skills/test-ns/hello-world | jq .
# Expected: {"code":0,"data":{"slug":"hello-world","displayName":"hello-world",...}}
# 9. 验证版本列表
curl -s http://localhost:8080/api/v1/skills/test-ns/hello-world/versions | jq .
# Expected: {"code":0,"data":{"content":[{"version":"1.0.0","status":"PUBLISHED",...}],...}}
# 10. 验证文件清单
curl -s "http://localhost:8080/api/v1/skills/test-ns/hello-world/versions/1.0.0/files" | jq .
# Expected: {"code":0,"data":[{"filePath":"SKILL.md",...}]}
# 11. 验证下载最新版本
curl -s -o /tmp/download-test.zip -w "%{http_code}" \
http://localhost:8080/api/v1/skills/test-ns/hello-world/download
# Expected: 200, /tmp/download-test.zip 为有效 zip 文件
# 12. 验证标签管理(创建标签)
curl -s -X PUT http://localhost:8080/api/v1/skills/test-ns/hello-world/tags/stable \
-H "Content-Type: application/json" \
-d '{"targetVersion":"1.0.0"}' | jq .
# Expected: {"code":0,"data":{"tagName":"stable","version":"1.0.0",...}}
# 13. 验证标签列表
curl -s http://localhost:8080/api/v1/skills/test-ns/hello-world/tags | jq .
# Expected: 包含 "stable" 和虚拟 "latest" 标签
# 14. 验证按标签下载
curl -s -o /tmp/download-tag.zip -w "%{http_code}" \
http://localhost:8080/api/v1/skills/test-ns/hello-world/tags/stable/download
# Expected: 200
# 15. 验证搜索 API
curl -s "http://localhost:8080/api/v1/skills?q=hello&sort=relevance&page=0&size=20" | jq .
# Expected: {"code":0,"data":{"items":[{"slug":"hello-world",...}],"total":1,...}}
# 16. 验证搜索空关键词(列表模式)
curl -s "http://localhost:8080/api/v1/skills?sort=newest&page=0&size=20" | jq .
# Expected: {"code":0,"data":{"items":[...],"total":...}}
# 17. 验证限流(连续请求触发 429)
for i in $(seq 1 25); do
code=$(curl -s -o /dev/null -w "%{http_code}" "http://localhost:8080/api/v1/skills?q=test")
echo "Request $i: $code"
done
# Expected: 前 20 次返回 200,之后返回 429(匿名 search 限额 20 次/60s)
# 18. 验证限流 header
curl -s -D - "http://localhost:8080/api/v1/skills?q=test" 2>&1 | grep -i "x-ratelimit\|retry-after"
# Expected: X-RateLimit-Remaining header 存在
# 19. 停止应用并清理
kill %1 2>/dev/null
rm -rf /tmp/test-skill /tmp/test-skill.zip /tmp/download-test.zip /tmp/download-tag.zip
make dev-down
Chunk 1 产出:Phase 2 全部后端功能 — 数据库迁移 + 对象存储 + 命名空间管理 + 技能发布/查询/下载 + 标签管理 + PostgreSQL 全文搜索 + 异步事件 + Redis 滑动窗口限流。
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 类型定义
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: string;
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<T> {
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
mkdir -p web/src/shared/hooks
cat > web/src/shared/hooks/use-debounce.ts << 'EOF'
import { useState, useEffect } from 'react';
export function useDebounce<T>(value: T, delay: number = 300): T {
const [debouncedValue, setDebouncedValue] = useState<T>(value);
useEffect(() => {
const timer = setTimeout(() => setDebouncedValue(value), delay);
return () => clearTimeout(timer);
}, [value, delay]);
return debouncedValue;
}
EOF
- Step 4: Commit
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 组件
// 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 (
<div className="flex items-center gap-2 justify-center mt-6">
<Button variant="outline" size="sm" disabled={page <= 0}
onClick={() => onPageChange(page - 1)}>上一页</Button>
<span className="text-sm text-muted-foreground">
{page + 1} / {totalPages}
</span>
<Button variant="outline" size="sm" disabled={page >= totalPages - 1}
onClick={() => onPageChange(page + 1)}>下一页</Button>
</div>
);
}
- Step 2: 创建 EmptyState 组件
// web/src/shared/components/empty-state.tsx
interface EmptyStateProps {
title: string;
description?: string;
action?: React.ReactNode;
}
export function EmptyState({ title, description, action }: EmptyStateProps) {
return (
<div className="flex flex-col items-center justify-center py-16 text-center">
<div className="text-4xl mb-4">📭</div>
<h3 className="text-lg font-medium text-foreground">{title}</h3>
{description && <p className="text-sm text-muted-foreground mt-1">{description}</p>}
{action && <div className="mt-4">{action}</div>}
</div>
);
}
- Step 3: 创建 SkeletonLoader 组件
// web/src/shared/components/skeleton-loader.tsx
export function SkeletonCard() {
return (
<div className="rounded-lg border p-4 animate-pulse">
<div className="h-4 bg-muted rounded w-3/4 mb-3" />
<div className="h-3 bg-muted rounded w-full mb-2" />
<div className="h-3 bg-muted rounded w-2/3" />
</div>
);
}
export function SkeletonList({ count = 6 }: { count?: number }) {
return (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{Array.from({ length: count }).map((_, i) => <SkeletonCard key={i} />)}
</div>
);
}
- Step 4: 创建 CopyButton 组件
// 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 (
<Button variant="outline" size="sm" onClick={handleCopy}>
{copied ? '已复制' : label}
</Button>
);
}
- Step 5: 创建 NamespaceBadge 组件
// 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 (
<span className={`inline-flex items-center px-2 py-0.5 rounded text-xs font-medium ${
isGlobal ? 'bg-green-100 text-green-800' : 'bg-blue-100 text-blue-800'
}`}>
@{slug}
</span>
);
}
- Step 6: Commit
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
// 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,
});
}
// 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<SkillVersion>),
enabled: !!namespace && !!slug,
});
}
// 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,
});
}
// 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<SkillSummary>),
placeholderData: (prev) => prev,
});
}
- Step 2: 创建 Namespace 查询 hooks
// 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,
});
}
// 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<NamespaceMember>),
enabled: !!slug,
});
}
// 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
// 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
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
// 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 (
<Link to={`/@${skill.namespace}/${skill.slug}`}
className="block rounded-lg border p-4 hover:shadow-md transition-shadow">
<div className="flex items-center gap-2 mb-2">
<NamespaceBadge slug={skill.namespace} />
<span className="text-sm font-medium truncate">{skill.displayName || skill.slug}</span>
</div>
<p className="text-sm text-muted-foreground line-clamp-2 mb-3">{skill.summary}</p>
<div className="flex items-center gap-4 text-xs text-muted-foreground">
<span>v{skill.latestVersion}</span>
<span>↓ {skill.downloadCount}</span>
{skill.ratingAvg > 0 && <span>★ {skill.ratingAvg.toFixed(1)}</span>}
</div>
</Link>
);
}
- Step 2: 创建 MarkdownRenderer
// web/src/features/skill/markdown-renderer.tsx
import ReactMarkdown from 'react-markdown';
import rehypeHighlight from 'rehype-highlight';
export function MarkdownRenderer({ content }: { content: string }) {
return (
<div className="prose prose-sm max-w-none dark:prose-invert">
<ReactMarkdown rehypePlugins={[rehypeHighlight]}>
{content}
</ReactMarkdown>
</div>
);
}
- Step 3: 创建 FileTree
// 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 (
<div className="border rounded-lg divide-y">
{files.map(file => (
<button key={file.id}
className="w-full flex items-center justify-between px-3 py-2 text-sm hover:bg-muted/50 text-left"
onClick={() => onSelect?.(file)}>
<span className="font-mono truncate">{file.filePath}</span>
<span className="text-muted-foreground ml-2 shrink-0">{formatSize(file.fileSize)}</span>
</button>
))}
</div>
);
}
- Step 4: 创建 InstallCommand
// 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 = `clawhub install @${namespace}/${slug}`;
return (
<div className="flex items-center gap-2 bg-muted rounded-lg px-3 py-2">
<code className="text-sm flex-1 font-mono truncate">{command}</code>
<CopyButton text={command} />
</div>
);
}
- Step 5: 创建 SearchBar
// 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 (
<form onSubmit={handleSubmit} className={className}>
<input type="text" value={query} onChange={e => 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" />
</form>
);
}
- Step 6: 创建 UploadZone
// 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 (
<div {...getRootProps()} className={`border-2 border-dashed rounded-lg p-8 text-center cursor-pointer transition-colors ${
isDragActive ? 'border-primary bg-primary/5' : 'border-muted-foreground/25 hover:border-primary/50'
}`}>
<input {...getInputProps()} />
<div className="text-3xl mb-2">📦</div>
<p className="text-sm text-muted-foreground">
{isDragActive ? '释放文件...' : '拖拽 .zip 文件到此处,或点击选择'}
</p>
</div>
);
}
- Step 7: Commit
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)。
// 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 (
<section className="mb-12">
<h2 className="text-xl font-semibold mb-4">{title}</h2>
{isLoading ? <SkeletonList count={6} /> : (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{data?.items.map(skill => <SkillCard key={skill.id} skill={skill} />)}
</div>
)}
</section>
);
}
export default function HomePage() {
return (
<div className="max-w-6xl mx-auto px-4">
<section className="py-16 text-center">
<h1 className="text-4xl font-bold mb-3">SkillHub</h1>
<p className="text-lg text-muted-foreground mb-8">发现、分享和管理 AI 技能</p>
<SearchBar className="max-w-xl mx-auto" />
</section>
<SkillSection title="热门下载" sort="downloads" />
<SkillSection title="最新发布" sort="newest" />
</div>
);
}
- Step 2: 实现搜索页
// 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 (
<div className="max-w-6xl mx-auto px-4 py-8">
<SearchBar defaultValue={search.q} className="mb-6" />
<div className="flex items-center gap-4 mb-6">
<select value={sort} onChange={e => {/* update URL */}}
className="rounded border px-3 py-1.5 text-sm">
<option value="relevance">相关度</option>
<option value="downloads">下载量</option>
<option value="newest">最新</option>
</select>
</div>
{isLoading ? <SkeletonList /> : data?.items.length === 0 ? (
<EmptyState title="未找到结果" description="尝试其他关键词" />
) : (
<>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{data?.items.map(skill => <SkillCard key={skill.id} skill={skill} />)}
</div>
{data && <Pagination page={page} totalPages={Math.ceil(data.total / 20)}
onPageChange={setPage} />}
</>
)}
</div>
);
}
- Step 3: Commit
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 组件
// 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 (
<div className="flex items-center gap-4 mb-8">
{namespace.avatarUrl ? (
<img src={namespace.avatarUrl} alt="" className="w-16 h-16 rounded-full" />
) : (
<div className="w-16 h-16 rounded-full bg-muted flex items-center justify-center text-2xl">
{namespace.displayName[0]}
</div>
)}
<div>
<div className="flex items-center gap-2">
<h1 className="text-2xl font-bold">{namespace.displayName}</h1>
<NamespaceBadge slug={namespace.slug} type={namespace.type} />
</div>
{namespace.description && (
<p className="text-muted-foreground mt-1">{namespace.description}</p>
)}
</div>
</div>
);
}
- Step 2: 实现命名空间主页
// 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 <SkeletonList />;
if (!ns) return <EmptyState title="命名空间不存在" />;
return (
<div className="max-w-6xl mx-auto px-4 py-8">
<NamespaceHeader namespace={ns} />
{skillsLoading ? <SkeletonList /> : skills?.items.length === 0 ? (
<EmptyState title="暂无技能" description="该命名空间下还没有发布技能" />
) : (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{skills?.items.map(s => <SkillCard key={s.id} skill={s} />)}
</div>
)}
</div>
);
}
- Step 3: 创建 SkillDetailView 组件
// 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<Tab>('readme');
return (
<div className="flex gap-8">
<div className="flex-1 min-w-0">
<div className="flex gap-4 border-b mb-4">
{(['readme', 'files', 'versions'] as Tab[]).map(t => (
<button key={t} onClick={() => setTab(t)}
className={`pb-2 text-sm font-medium border-b-2 ${
tab === t ? 'border-primary text-primary' : 'border-transparent text-muted-foreground'
}`}>
{t === 'readme' ? 'README' : t === 'files' ? '文件' : '版本'}
</button>
))}
</div>
{tab === 'readme' && <MarkdownRenderer content={readmeContent} />}
{tab === 'files' && <FileTree files={files} />}
</div>
<aside className="w-72 shrink-0">
<div className="space-y-4">
<div>
<div className="text-sm text-muted-foreground mb-1">版本</div>
<div className="font-medium">v{skill.latestVersion}</div>
</div>
<div>
<div className="text-sm text-muted-foreground mb-1">下载量</div>
<div className="font-medium">{skill.downloadCount}</div>
</div>
<div>
<div className="text-sm text-muted-foreground mb-1">命名空间</div>
<NamespaceBadge slug={namespace} />
</div>
<InstallCommand namespace={namespace} slug={skill.slug} />
<a href={`/api/v1/skills/${namespace}/${skill.slug}/download`}
className="block w-full text-center rounded-lg bg-primary text-primary-foreground py-2 text-sm font-medium hover:bg-primary/90">
下载
</a>
</div>
</aside>
</div>
);
}
- Step 4: 实现技能详情页
// 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 <div className="max-w-6xl mx-auto px-4 py-8"><SkeletonList count={3} /></div>;
if (!skill) return null;
return (
<div className="max-w-6xl mx-auto px-4 py-8">
<h1 className="text-2xl font-bold mb-6">{skill.displayName || skill.slug}</h1>
<SkillDetailView skill={skill} namespace={namespace}
files={files ?? []} readmeContent={skill.summary ?? ''} />
</div>
);
}
- Step 5: Commit
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 组件
// 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<File | null>(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 (
<div className="space-y-6">
<div>
<label className="block text-sm font-medium mb-2">命名空间</label>
<select value={namespace} onChange={e => setNamespace(e.target.value)}
className="w-full rounded border px-3 py-2 text-sm">
<option value="">选择命名空间</option>
{namespaces?.map(ns => (
<option key={ns.id} value={ns.slug}>{ns.displayName} (@{ns.slug})</option>
))}
</select>
</div>
<div>
<label className="block text-sm font-medium mb-2">上传技能包</label>
<UploadZone onFileSelect={setFile} />
{file && <p className="text-sm text-muted-foreground mt-2">已选择: {file.name}</p>}
</div>
<div>
<label className="block text-sm font-medium mb-2">可见性</label>
<select value={visibility} onChange={e => setVisibility(e.target.value)}
className="w-full rounded border px-3 py-2 text-sm">
<option value="PUBLIC">公开</option>
<option value="NAMESPACE_ONLY">命名空间内可见</option>
<option value="PRIVATE">私有</option>
</select>
</div>
<Button onClick={handlePublish} disabled={!file || !namespace || publish.isPending}
className="w-full">
{publish.isPending ? '发布中...' : '确认发布'}
</Button>
</div>
);
}
- Step 2: 实现发布页
// web/src/pages/dashboard/publish.tsx
import { PublishForm } from '@/features/publish/publish-form';
export default function PublishPage() {
return (
<div className="max-w-2xl mx-auto px-4 py-8">
<h1 className="text-2xl font-bold mb-6">发布技能</h1>
<PublishForm />
</div>
);
}
- Step 3: 实现我的技能页
// 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 (
<div className="max-w-4xl mx-auto px-4 py-8">
<div className="flex items-center justify-between mb-6">
<h1 className="text-2xl font-bold">我的技能</h1>
<Link to="/dashboard/publish"><Button>发布技能</Button></Link>
</div>
{isLoading ? <p>加载中...</p> : data?.items.length === 0 ? (
<EmptyState title="还没有技能" description="发布你的第一个技能吧"
action={<Link to="/dashboard/publish"><Button>去发布</Button></Link>} />
) : (
<div className="border rounded-lg divide-y">
{data?.items.map(skill => (
<Link key={skill.id} to={`/@${skill.namespace}/${skill.slug}`}
className="flex items-center justify-between px-4 py-3 hover:bg-muted/50">
<div>
<div className="font-medium">{skill.displayName || skill.slug}</div>
<div className="text-sm text-muted-foreground">@{skill.namespace}</div>
</div>
<div className="text-sm text-muted-foreground">
v{skill.latestVersion} · ↓{skill.downloadCount}
</div>
</Link>
))}
</div>
)}
</div>
);
}
- Step 4: 实现我的命名空间页
// 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 (
<div className="max-w-4xl mx-auto px-4 py-8">
<div className="flex items-center justify-between mb-6">
<h1 className="text-2xl font-bold">我的命名空间</h1>
<Button>创建命名空间</Button>
</div>
{isLoading ? <p>加载中...</p> : namespaces?.length === 0 ? (
<EmptyState title="还没有命名空间" description="创建一个团队命名空间" />
) : (
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
{namespaces?.map(ns => (
<Link key={ns.id} to={`/dashboard/namespaces/${ns.slug}/members`}
className="block rounded-lg border p-4 hover:shadow-md transition-shadow">
<div className="flex items-center gap-2 mb-2">
<NamespaceBadge slug={ns.slug} type={ns.type} />
<span className="font-medium">{ns.displayName}</span>
</div>
<p className="text-sm text-muted-foreground">{ns.description}</p>
</Link>
))}
</div>
)}
</div>
);
}
- Step 5: 创建 MemberTable 组件
// 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: string) => void;
onRoleChange?: (userId: string, role: string) => void;
}) {
return (
<div className="border rounded-lg">
<table className="w-full text-sm">
<thead>
<tr className="border-b bg-muted/50">
<th className="text-left px-4 py-2">用户 ID</th>
<th className="text-left px-4 py-2">角色</th>
<th className="text-left px-4 py-2">加入时间</th>
<th className="text-right px-4 py-2">操作</th>
</tr>
</thead>
<tbody>
{members.map(m => (
<tr key={m.id} className="border-b last:border-0">
<td className="px-4 py-2">{m.userId}</td>
<td className="px-4 py-2">
<select value={m.role} onChange={e => onRoleChange?.(m.userId, e.target.value)}
className="rounded border px-2 py-1 text-xs" disabled={m.role === 'OWNER'}>
<option value="MEMBER">MEMBER</option>
<option value="ADMIN">ADMIN</option>
<option value="OWNER">OWNER</option>
</select>
</td>
<td className="px-4 py-2 text-muted-foreground">
{new Date(m.createdAt).toLocaleDateString()}
</td>
<td className="px-4 py-2 text-right">
{m.role !== 'OWNER' && (
<Button variant="ghost" size="sm" onClick={() => onRemove?.(m.userId)}>
移除
</Button>
)}
</td>
</tr>
))}
</tbody>
</table>
</div>
);
}
- Step 6: 实现成员管理页
// 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 (
<div className="max-w-4xl mx-auto px-4 py-8">
{ns && <NamespaceHeader namespace={ns} />}
<div className="flex items-center justify-between mb-4">
<h2 className="text-lg font-semibold">成员管理</h2>
<Button size="sm">添加成员</Button>
</div>
{members && <MemberTable members={members.items} />}
</div>
);
}
- Step 7: Commit
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
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
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 验收标准
- ✅ 首页展示热门/最新技能,搜索框可跳转搜索页
- ✅ 搜索页:关键词搜索 + 排序 + 分页,URL 驱动
- ✅ 命名空间主页:展示空间信息 + 技能列表
- ✅ 技能详情页:Markdown 渲染 + 文件树 + 安装命令 + 下载
- ✅ 发布页:拖拽上传 + 选择命名空间/可见性 + 确认发布
- ✅ 我的技能:列表展示 + 跳转详情
- ✅ 命名空间管理:创建 + 成员管理(添加/移除/角色变更)
- ✅ 匿名用户可浏览/搜索/下载 PUBLIC 技能
- ✅ 前端构建通过,无类型错误