feat(domain): add Skill domain entities, repositories, and JPA implementations

This commit is contained in:
vsxd 2026-03-12 02:00:34 +08:00
parent ca36304df9
commit 8644154157
16 changed files with 763 additions and 12 deletions

View file

@ -5978,18 +5978,110 @@ Phase 2 Chunk 1 backend complete:
### Chunk 1 验收标准
1. ✅ `V2__phase2_skill_tables.sql` 迁移成功
2. ✅ Phase 1 实体补齐(Namespace.type/avatarUrl, NamespaceMember.updatedAt)
3. ✅ 对象存储 LocalFile + S3 双实现
4. ✅ 命名空间 CRUD + 成员管理 API
5. ✅ CLI/Web 发布接口:zip → 校验 → 存储 → PUBLISHED
6. ✅ 技能详情、版本列表、文件清单、文件内容 API
7. ✅ 下载 API:latest / 指定版本 / 按标签
8. ✅ 标签 CRUD API,latest 标签不可操作
9. ✅ 搜索 API:关键词 + 可见性过滤 + 排序 + 分页
10. ✅ 异步事件:发布后索引更新,下载后计数递增
11. ✅ 限流:超限 429,Redis 不可用 fail-open
12. ✅ 所有后端测试通过
运行以下命令验证 Chunk 1 完成:
```bash
# 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 滑动窗口限流。
---

View file

@ -0,0 +1,195 @@
package com.iflytek.skillhub.domain.skill;
import jakarta.persistence.*;
import java.math.BigDecimal;
import java.time.LocalDateTime;
@Entity
@Table(name = "skill")
public class Skill {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(name = "namespace_id", nullable = false)
private Long namespaceId;
@Column(nullable = false, length = 100)
private String slug;
@Column(name = "display_name", length = 200)
private String displayName;
@Column(columnDefinition = "TEXT")
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 = 20)
private SkillVisibility visibility;
@Enumerated(EnumType.STRING)
@Column(nullable = false, length = 20)
private SkillStatus status;
@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", precision = 3, scale = 2, nullable = false)
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")
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;
this.status = SkillStatus.ACTIVE;
}
@PrePersist
protected void onCreate() {
createdAt = LocalDateTime.now();
updatedAt = LocalDateTime.now();
}
@PreUpdate
protected void onUpdate() {
updatedAt = LocalDateTime.now();
}
// Getters
public Long getId() {
return id;
}
public Long getNamespaceId() {
return namespaceId;
}
public String getSlug() {
return slug;
}
public String getDisplayName() {
return displayName;
}
public String getSummary() {
return summary;
}
public Long getOwnerId() {
return ownerId;
}
public Long getSourceSkillId() {
return sourceSkillId;
}
public SkillVisibility getVisibility() {
return visibility;
}
public SkillStatus getStatus() {
return status;
}
public Long getLatestVersionId() {
return 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 LocalDateTime getCreatedAt() {
return createdAt;
}
public Long getUpdatedBy() {
return updatedBy;
}
public LocalDateTime getUpdatedAt() {
return updatedAt;
}
// Setters
public void setDisplayName(String displayName) {
this.displayName = displayName;
}
public void setSummary(String summary) {
this.summary = summary;
}
public void setSourceSkillId(Long sourceSkillId) {
this.sourceSkillId = sourceSkillId;
}
public void setVisibility(SkillVisibility visibility) {
this.visibility = visibility;
}
public void setStatus(SkillStatus status) {
this.status = status;
}
public void setLatestVersionId(Long latestVersionId) {
this.latestVersionId = latestVersionId;
}
public void setCreatedBy(Long createdBy) {
this.createdBy = createdBy;
}
public void setUpdatedBy(Long updatedBy) {
this.updatedBy = updatedBy;
}
}

View file

@ -0,0 +1,84 @@
package com.iflytek.skillhub.domain.skill;
import jakarta.persistence.*;
import java.time.LocalDateTime;
@Entity
@Table(name = "skill_file")
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 = 500)
private String filePath;
@Column(name = "file_size", nullable = false)
private Long fileSize;
@Column(name = "content_type", length = 100)
private String contentType;
@Column(length = 64)
private String sha256;
@Column(name = "storage_key", nullable = false, length = 500)
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
protected void onCreate() {
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;
}
}

View file

@ -0,0 +1,10 @@
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);
}

View file

@ -0,0 +1,13 @@
package com.iflytek.skillhub.domain.skill;
import java.util.List;
import java.util.Optional;
public interface SkillRepository {
Optional<Skill> findById(Long id);
Optional<Skill> findByNamespaceIdAndSlug(Long namespaceId, String slug);
List<Skill> findByNamespaceIdAndStatus(Long namespaceId, SkillStatus status);
Skill save(Skill skill);
List<Skill> findByOwnerId(Long ownerId);
void incrementDownloadCount(Long skillId);
}

View file

@ -0,0 +1,7 @@
package com.iflytek.skillhub.domain.skill;
public enum SkillStatus {
ACTIVE,
HIDDEN,
ARCHIVED
}

View file

@ -0,0 +1,86 @@
package com.iflytek.skillhub.domain.skill;
import jakarta.persistence.*;
import java.time.LocalDateTime;
@Entity
@Table(name = "skill_tag")
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 = 50)
private String tagName;
@Column(name = "version_id")
private Long versionId;
@Column(name = "created_by", nullable = false)
private Long createdBy;
@Column(name = "created_at", nullable = false, updatable = false)
private LocalDateTime createdAt;
@Column(name = "updated_at")
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
protected void onCreate() {
createdAt = LocalDateTime.now();
updatedAt = LocalDateTime.now();
}
@PreUpdate
protected void onUpdate() {
updatedAt = LocalDateTime.now();
}
// Getters
public Long getId() {
return id;
}
public Long getSkillId() {
return skillId;
}
public String getTagName() {
return tagName;
}
public Long getVersionId() {
return versionId;
}
public Long getCreatedBy() {
return createdBy;
}
public LocalDateTime getCreatedAt() {
return createdAt;
}
public LocalDateTime getUpdatedAt() {
return updatedAt;
}
// Setter
public void setVersionId(Long versionId) {
this.versionId = versionId;
}
}

View file

@ -0,0 +1,11 @@
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);
}

View file

@ -0,0 +1,140 @@
package com.iflytek.skillhub.domain.skill;
import jakarta.persistence.*;
import java.time.LocalDateTime;
@Entity
@Table(name = "skill_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 = 50)
private String version;
@Enumerated(EnumType.STRING)
@Column(nullable = false, length = 20)
private SkillVersionStatus status;
@Column(columnDefinition = "TEXT")
private String changelog;
@Column(name = "parsed_metadata_json", columnDefinition = "TEXT")
private String parsedMetadataJson;
@Column(name = "manifest_json", columnDefinition = "TEXT")
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", nullable = false)
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;
this.status = SkillVersionStatus.DRAFT;
}
@PrePersist
protected void onCreate() {
createdAt = LocalDateTime.now();
}
// Getters
public Long getId() {
return id;
}
public Long getSkillId() {
return skillId;
}
public String getVersion() {
return version;
}
public SkillVersionStatus getStatus() {
return status;
}
public String getChangelog() {
return changelog;
}
public String getParsedMetadataJson() {
return parsedMetadataJson;
}
public String getManifestJson() {
return manifestJson;
}
public Integer getFileCount() {
return fileCount;
}
public Long getTotalSize() {
return totalSize;
}
public LocalDateTime getPublishedAt() {
return publishedAt;
}
public Long getCreatedBy() {
return createdBy;
}
public LocalDateTime getCreatedAt() {
return createdAt;
}
// Setters
public void setStatus(SkillVersionStatus status) {
this.status = status;
}
public void setChangelog(String changelog) {
this.changelog = changelog;
}
public void setParsedMetadataJson(String parsedMetadataJson) {
this.parsedMetadataJson = parsedMetadataJson;
}
public void setManifestJson(String manifestJson) {
this.manifestJson = manifestJson;
}
public void setFileCount(Integer fileCount) {
this.fileCount = fileCount;
}
public void setTotalSize(Long totalSize) {
this.totalSize = totalSize;
}
public void setPublishedAt(LocalDateTime publishedAt) {
this.publishedAt = publishedAt;
}
}

View file

@ -0,0 +1,11 @@
package com.iflytek.skillhub.domain.skill;
import java.util.List;
import java.util.Optional;
public interface SkillVersionRepository {
Optional<SkillVersion> findById(Long id);
Optional<SkillVersion> findBySkillIdAndVersion(Long skillId, String version);
List<SkillVersion> findBySkillIdAndStatus(Long skillId, SkillVersionStatus status);
SkillVersion save(SkillVersion version);
}

View file

@ -0,0 +1,8 @@
package com.iflytek.skillhub.domain.skill;
public enum SkillVersionStatus {
DRAFT,
PENDING_REVIEW,
PUBLISHED,
REJECTED
}

View file

@ -0,0 +1,7 @@
package com.iflytek.skillhub.domain.skill;
public enum SkillVisibility {
PUBLIC,
NAMESPACE_ONLY,
PRIVATE
}

View file

@ -0,0 +1,14 @@
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);
}

View file

@ -0,0 +1,33 @@
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);
@Override
default List<Skill> findByNamespaceIdAndStatus(Long namespaceId, SkillStatus status) {
return findByNamespaceIdAndStatusOrderByCreatedAtDesc(namespaceId, status);
}
List<Skill> findByNamespaceIdAndStatusOrderByCreatedAtDesc(Long namespaceId, SkillStatus status);
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);
}

View file

@ -0,0 +1,15 @@
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);
}

View file

@ -0,0 +1,25 @@
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.List;
import java.util.Optional;
@Repository
public interface SkillVersionJpaRepository extends JpaRepository<SkillVersion, Long>, SkillVersionRepository {
Optional<SkillVersion> findBySkillIdAndVersion(Long skillId, String version);
@Override
default List<SkillVersion> findBySkillIdAndStatus(Long skillId, SkillVersionStatus status) {
return findBySkillIdAndStatusOrderByCreatedAtDesc(skillId, status);
}
List<SkillVersion> findBySkillIdAndStatusOrderByCreatedAtDesc(Long skillId, SkillVersionStatus status);
Page<SkillVersion> findBySkillIdAndStatus(Long skillId, SkillVersionStatus status, Pageable pageable);
}