feat: project ini and docs updates

This commit is contained in:
vsxd 2026-03-11 22:47:05 +08:00
parent dfa66bbfbe
commit 27b7a4dad1
20 changed files with 5580 additions and 97 deletions

53
.gitignore vendored Normal file
View file

@ -0,0 +1,53 @@
# OS files
.DS_Store
Thumbs.db
# Editors / IDEs
.idea/
.vscode/
*.iml
*.swp
*.swo
# Logs
*.log
logs/
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
# Environment / local config
.env
.env.*
!.env.example
!.env.*.example
# Java / Spring Boot
target/
build/
.gradle/
out/
*.class
*.jar
*.war
*.ear
# Keep wrapper binaries/config in VCS
!gradle/wrapper/gradle-wrapper.jar
!mvnw
!mvnw.cmd
# React / frontend
node_modules/
dist/
coverage/
.cache/
.parcel-cache/
.vite/
.eslintcache
*.tsbuildinfo
# Temporary files
.tmp/
tmp/

View file

@ -14,6 +14,38 @@
同时,一期必须提供 ClawHub CLI 协议兼容层:服务端需要暴露一组与 ClawHub CLI 兼容的 registry API,使现有 ClawHub CLI 在不修改或仅最小配置修改的前提下可完成 registry 侧查询、解析、下载、发布、校验等核心操作。
### 1.1 技能坐标体系(已冻结)
skillhub 内部使用 namespace 坐标模型:`@{namespace_slug}/{skill_slug}`。
ClawHub CLI 使用单一 slug 模型,slug 校验规则为 `[a-z0-9]([a-z0-9-]*[a-z0-9])?`,不允许 `/` 出现。
为同时满足两套模型,定义以下双向映射规则:
**映射规则:**
| skillhub 坐标 | 兼容层 canonical slug | 说明 |
|---|---|---|
| `@global/my-skill` | `my-skill` | 全局空间省略前缀,直接使用 skill slug |
| `@team-name/my-skill` | `team-name--my-skill` | 团队空间使用 `{namespace_slug}--{skill_slug}` 格式 |
**约束规则:**
- 分隔符为双连字符 `--`
- skill slug 和 namespace slug 均禁止包含 `--`(在校验规则中追加此限制)
- slug 格式校验更新为:`[a-z0-9]([a-z0-9-]*[a-z0-9])?`,且不得包含连续两个以上的连字符 `--`
- 兼容层解析 canonical slug 时:包含 `--` 则拆分为 `namespace_slug` + `skill_slug`,不包含则视为 `@global/{slug}`
- 冲突规则:如果 `@global/team-name--my-skill` 与 `@team-name/my-skill` 产生冲突,以 `--` 拆分优先(即优先解析为团队空间技能)。全局空间的 skill slug 禁止包含 `--` 以避免歧义
- 保留字规则:namespace slug 保留词列表同样适用于 canonical slug 的 namespace 部分
**显示规则:**
- Web 端始终显示完整坐标:`@global/my-skill`、`@team-name/my-skill`
- ClawHub CLI 兼容层返回 canonical slug:`my-skill`、`team-name--my-skill`
- skillhub 自有 CLI 支持两种格式输入,内部统一转换为 namespace 坐标
**Well-known 发现:**
- skillhub 服务端提供 `/.well-known/clawhub.json`,返回 `{ "apiBase": "/api/compat/v1" }`
- ClawHub CLI 通过此机制自动发现兼容层 API 基地址
## 2. 参考项目取舍
### 2.1 继承 ClawHub 的部分
@ -61,7 +93,7 @@
- 技能浏览、详情、下载(公共技能匿名可访问)
- 标签管理(`latest` 系统保留只读 + 自定义标签人工维护)
- 技能包文件校验与 SKILL.md 元数据抽取
- 基于 MySQL 全文索引的搜索
- 基于 PostgreSQL 全文索引的搜索
命名空间与组织:
- 单一全局命名空间(`@global/skill-name`),由平台管理员管理,不支持多个平台级 namespace

View file

@ -5,10 +5,10 @@
- JDK: 21
- Framework: Spring Boot 3.x(最新稳定版)
- Security: Spring Security + spring-boot-starter-oauth2-client
- Database: MySQL 8.x
- Database: PostgreSQL 16.x
- Cache/Session: Redis 7.x(一期必须依赖,用于 Session 存储 + 分布式锁 + 幂等去重)
- Object Storage: S3 协议兼容对象存储
- Search: MySQL Full-Text Search(一期)
- Search: PostgreSQL Full-Text Search(一期)
- Future Search: Elasticsearch / OpenSearch / Vector Search
## 2. 总体架构
@ -22,9 +22,9 @@ server/
├── skillhub-app # 启动、配置装配、Controller 聚合
├── skillhub-domain # 领域模型 + 领域服务 + 应用服务
├── skillhub-auth # OAuth2 认证 + RBAC + 授权判定
├── skillhub-search # 搜索 SPI + MySQL 全文实现
├── skillhub-search # 搜索 SPI + PostgreSQL 全文实现
├── skillhub-storage # 对象存储抽象 + S3 实现
└── skillhub-infra # MyBatis、通用工具、配置基础
└── skillhub-infra # JPA、通用工具、配置基础
```
## 4. 模块依赖方向(依赖倒置,禁止领域层依赖基础设施)
@ -39,9 +39,9 @@ storage → (独立抽象) # 纯 SPI,不依赖 domain
核心原则:
- domain 是最内层,不依赖任何其他模块,只定义接口和实体
- infra 实现 domain 中定义的 Repository 接口(MyBatis Mapper)
- infra 实现 domain 中定义的 Repository 接口(Spring Data JPA)
- app 负责装配所有模块,通过 Spring 依赖注入将 infra 实现注入 domain 接口
- 禁止 domain → infra 方向的依赖,避免领域层与 MyBatis、事件实现绑死
- 禁止 domain → infra 方向的依赖,避免领域层与 JPA、事件实现绑死
## 5. 各模块职责
@ -68,7 +68,7 @@ storage → (独立抽象) # 纯 SPI,不依赖 domain
### skillhub-search
- SPI 接口:`SearchIndexService`, `SearchQueryService`, `SearchRebuildService`
- 一期实现:`MysqlFullTextIndexService`, `MysqlFullTextQueryService`
- 一期实现:`PostgresFullTextIndexService`, `PostgresFullTextQueryService`
- 独立搜索文档表 `skill_search_document`
- 未来扩展点:ES / 向量检索实现
@ -79,10 +79,9 @@ storage → (独立抽象) # 纯 SPI,不依赖 domain
- 对象 key 规则(使用不可变 ID,避免命名空间变更导致 key 失效):
- 正式路径:`skills/{skillId}/{versionId}/{filePath}`
- 打包路径:`packages/{skillId}/{versionId}/bundle.zip`
- 临时上传:`tmp/{uploadId}/{filePath}`(24h GC 清理)
### skillhub-infra
- MyBatis-Plus Mapper 实现
- Spring Data JPA Repository 实现
- Repository 实现
- 通用工具(ID 生成、时间、JSON 等)
- Spring Events 异步事件基础设施
@ -109,8 +108,13 @@ web/
```
skillhub/
├── server/ # Maven 多模块 Java 后端
│ └── Dockerfile # 后端多阶段构建
├── web/ # React 前端
├── Makefile # 顶层构建编排(dev / build / docker)
│ ├── Dockerfile # 前端多阶段构建
│ └── nginx.conf # Nginx 配置(SPA 路由 + API 反向代理)
├── docker-compose.yml # 本地开发(仅依赖服务:PostgreSQL/Redis/MinIO)
├── docker-compose.prod.yml # 完整部署(前后端 + 依赖服务)
├── Makefile # 顶层构建编排(dev / build / docker / deploy)
├── docs/ # 设计文档
└── README.md
```
@ -130,14 +134,14 @@ skillhub/
| 组件 | 一期要求 | 职责 |
|------|---------|------|
| MySQL 8.x | 主从 | 主存储 |
| PostgreSQL 16.x | 主从 | 主存储 |
| Redis 7.x | Sentinel 或 Cluster | Session 存储 + 分布式锁 + 幂等去重 |
| S3 兼容存储 | MinIO 或云厂商 S3 | 技能包文件 + 预打包 zip |
| Ingress | Nginx Ingress Controller | 路由分发 + TLS 终止 |
## 10. 推荐的一期技术决策
- ORM:MyBatis-Plus
- ORM:Spring Data JPA (Hibernate)
- API 文档:Springdoc OpenAPI
- 对象存储:MinIO / AWS S3 兼容接口
- 异步任务:Spring Events + 异步线程池,后续视复杂度引入 MQ

View file

@ -21,7 +21,7 @@
- `TEAM` 类型对应部门/团队,可创建多个
- 技能完整寻址:`@{namespace_slug}/{skill_slug}`
- slug 唯一约束:`slug`
- slug 格式校验:`[a-z0-9]([a-z0-9-]*[a-z0-9])?`,长度 2-64
- slug 格式校验:`[a-z0-9]([a-z0-9-]*[a-z0-9])?`,长度 2-64,且不得包含连续两个以上的连字符 `--`(为兼容层坐标映射保留)
- slug 保留词列表(用户创建 namespace 时不可使用):`admin`, `api`, `dashboard`, `search`, `auth`, `me`, `global`, `system`, `static`, `assets`, `health`
- 系统内置 namespace(`@global`)在数据库初始化时由 Flyway 脚本预置,绕过 slug 校验规则。保留词校验仅作用于用户创建 namespace 的接口
- 状态语义:
@ -74,7 +74,7 @@
- owner 作为 MEMBER 时可管理自己创建的 skill(提交审核、编辑草稿)
- owner 离职/换组后,namespace ADMIN 仍能完整管理所有技能
- `rating_avg` / `rating_count` 冗余字段,避免每次查询聚合
- `slug`:面向用户的 URL 标识,来自 SKILL.md 的 `name` 字段,首次发布后不可变更。slug 格式校验规则与 namespace slug 相同:`[a-z0-9]([a-z0-9-]*[a-z0-9])?`,同样适用保留词限制
- `slug`:面向用户的 URL 标识,来自 SKILL.md 的 `name` 字段,首次发布后不可变更。slug 格式校验规则与 namespace slug 相同:`[a-z0-9]([a-z0-9-]*[a-z0-9])?`,同样适用保留词限制,且不得包含连续两个以上的连字符 `--`(为兼容层坐标映射保留)。全局空间(`@global`)下的 skill slug 额外禁止包含 `--`,以避免与兼容层 canonical slug 产生歧义
- `source_skill_id`:仅在"团队技能提升到全局"场景下填充,记录原始团队空间的 skill ID,用于追溯来源
- 提升关系的唯一事实来源是 `promotion_request` 表,UI 查询"是否已提升"通过 `SELECT ... FROM promotion_request WHERE source_skill_id=? AND status='APPROVED'` 判定
@ -90,7 +90,6 @@
| manifest_json | json | 文件清单 |
| parsed_metadata_json | json | SKILL.md frontmatter 解析结果 |
| status | enum | `DRAFT` / `PENDING_REVIEW` / `PUBLISHED` / `REJECTED` / `YANKED` |
| file_transfer_status | enum | `PENDING` / `COMPLETED` / `FAILED`,异步文件转正状态 |
| reject_reason | varchar(512) | 拒绝原因 |
| published_by | bigint | |
| published_at | datetime | |
@ -98,7 +97,6 @@
- `status` 覆盖完整审核生命周期
- 状态机:`DRAFT → PENDING_REVIEW → PUBLISHED / REJECTED`,`PUBLISHED → YANKED`
- `DRAFT → PENDING_REVIEW` 前置条件:`file_transfer_status = COMPLETED`
- 唯一约束:`(skill_id, version)` 防止重复发布
- `YANKED` 状态:已发布后撤回
@ -142,6 +140,7 @@
- `latest` 是系统保留标签,只读,自动跟随 `skill.latest_version_id`,不允许 API 手动移动
- 自定义标签(如 `beta`、`stable-2026q1`)允许人工创建和移动
- 唯一约束:`(skill_id, tag_name)`
- `target_version_id` 必须指向 `status = PUBLISHED` 的版本,应用层校验
### review_task
@ -161,6 +160,7 @@
- 仅用于普通发布审核,"提升到全局"使用独立的 `promotion_request` 表
- `version` 字段用于乐观锁,防止多 Pod 并发审核
- 业务约束:同一 `skill_version_id` 在 `status=PENDING` 时只能存在一条记录,重复提交返回 409 Conflict。撤回(PENDING → 删除 review_task + skill_version 回退到 DRAFT)后才能再次提交
- PostgreSQL 并发约束落地:通过唯一索引 `(skill_version_id)` + 软删除标记实现。`review_task` 表增加 `deleted` 字段(bigint, 默认 0),唯一索引改为 `(skill_version_id, deleted)`。撤回时将 `deleted` 设为 `id`(非零值),新提交时 `deleted=0`,利用唯一索引防止并发重复提交。或者采用更简单的方案:撤回时物理删除 review_task 记录,依赖 `INSERT` 的唯一约束 `(skill_version_id)` 防并发。PostgreSQL 还支持 partial unique index 方案:`CREATE UNIQUE INDEX ON review_task (skill_version_id) WHERE status = 'PENDING'`,更优雅地实现"PENDING 状态唯一"约束
### promotion_request
@ -183,6 +183,7 @@
- 审批通过后填充 `target_skill_id`,指向全局空间新创建的 skill
- `promotion_request` 是提升关系的唯一事实来源,skill 表不再冗余 `promoted_to_skill_id`
- 业务约束:同一 `source_version_id` 在 `status=PENDING` 时只能存在一条记录,重复提交返回 409 Conflict
- PostgreSQL 并发约束落地:与 `review_task` 类似,通过唯一索引防止并发重复提交。推荐使用 partial unique index:`CREATE UNIQUE INDEX ON promotion_request (source_version_id) WHERE status = 'PENDING'`,或增加 `deleted` 字段 + `(source_version_id, deleted)` 唯一约束,或采用物理删除 + `(source_version_id)` 唯一约束方案
### skill_star
@ -349,7 +350,7 @@
| status | enum | |
| updated_at | datetime | |
MySQL Full-Text Index 建在 `(title, summary, keywords, search_text)` 上。
PostgreSQL Full-Text Index:在 `skill_search_document` 表增加 `search_vector tsvector` 列,通过触发器或 `GENERATED ALWAYS AS` 自动维护,建立 GIN 索引。
## 3.4 幂等记录表
@ -366,7 +367,7 @@ MySQL Full-Text Index 建在 `(title, summary, keywords, search_text)` 上。
| expires_at | datetime | 过期时间(默认 24h) |
- 流程:收到请求 → 插入 record(PROCESSING)→ 业务处理 → 更新为 COMPLETED + resource_id → 重复请求时查 record 返回已有结果
- Redis 做快速去重缓存(SETNX),MySQL 做持久化兜底
- Redis 做快速去重缓存(SETNX),PostgreSQL 做持久化兜底
- 定时任务清理过期记录
## 3.5 关键索引设计

View file

@ -277,6 +277,8 @@ public class OAuthClaimsExtractor {
- 作用域:`skill:read`, `skill:publish`, `skill:delete`, `token:manage`
- 天然无状态,多 Pod 安全
> **一期作用域说明(非最小权限)**:一期 Token 作用域为粗粒度动作级别,不与 namespace 绑定。Token 继承用户的全部权限——如果用户是某个 namespace 的 MEMBER,则该用户的任何 Token(只要包含 `skill:publish` scope)都可以向该 namespace 发布技能。这是有意的一期简化,不满足最小权限原则。后续版本计划引入 namespace 级别的 Token 作用域限定(如 `namespace:ai-team:skill:publish`),或通过 `api_token_scope` 子表实现 Token 与 namespace 的绑定。
## 6. RBAC 授权判定
```
@ -477,3 +479,72 @@ window.location.href = '/oauth2/authorization/github'
- 前端权限控制是 UX 优化,不是安全边界
- 后端每个写操作接口独立校验权限,不信任前端判定
- 前端隐藏按钮 ≠ 安全,用户可以直接调 API,后端必须拦截
## 10. 权限矩阵(完整)
以下矩阵列出每个 API 接口的权限判定来源,作为后端实现的唯一参考。
### 10.1 Public API(匿名可访问)
| 接口 | 匿名 | 已登录 | 判定逻辑 |
|------|------|--------|---------|
| `GET /api/v1/skills`(搜索) | PUBLIC 技能 | PUBLIC + NAMESPACE_ONLY(成员空间)+ PRIVATE(owner/admin) | `SearchVisibilityScope` 投影 |
| `GET /api/v1/skills/{ns}/{slug}` | PUBLIC 技能 | 同上 | visibility + namespace 成员关系 |
| `GET /api/v1/skills/{ns}/{slug}/versions` | PUBLIC 技能 | 同上 | 同上 |
| `GET /api/v1/skills/{ns}/{slug}/download` | PUBLIC 技能 | 同上 | 同上 |
| `GET /api/v1/skills/{ns}/{slug}/resolve` | PUBLIC 技能 | 同上 | 同上 |
| `GET /api/v1/namespaces` | 全部 | 全部 | 无限制 |
### 10.2 Authenticated API
| 接口 | 所需权限 | 判定来源 |
|------|---------|---------|
| `POST /api/v1/skills/{ns}/{slug}/star` | 已登录 | Session/Token |
| `POST /api/v1/skills/{ns}/{slug}/rating` | 已登录 | Session/Token |
| `POST .../versions/{ver}/submit-review` | namespace MEMBER 以上 | `namespace_member.role` |
| `POST .../versions/{ver}/withdraw-review` | 提交人本人 或 namespace ADMIN | `review_task.submitted_by` 或 `namespace_member.role` |
| `PUT /api/v1/skills/{ns}/{slug}/tags/{tag}` | namespace ADMIN 以上 或 owner | `namespace_member.role` 或 `skill.owner_id` |
| `POST /api/v1/skills/{ns}/{slug}/archive` | namespace ADMIN 以上 或 owner | `namespace_member.role` 或 `skill.owner_id` |
| `DELETE .../versions/{ver}` | namespace ADMIN 以上 或 owner(仅 DRAFT/REJECTED) | `namespace_member.role` 或 `skill.owner_id` + `skill_version.status` |
### 10.3 CLI API
| 接口 | 所需 Token Scope | 额外判定 |
|------|-----------------|---------|
| `GET /api/v1/cli/whoami` | 任意有效 Token | 无 |
| `POST /api/v1/cli/publish` | `skill:publish` | 用户是目标 namespace 的 MEMBER 以上 |
### 10.4 Admin API
| 接口 | 所需平台角色 | 判定来源 |
|------|------------|---------|
| `POST /api/v1/admin/reviews/{id}/approve` | SKILL_ADMIN / SUPER_ADMIN | `user_role_binding` → `role_permission` |
| `POST /api/v1/admin/reviews/{id}/reject` | SKILL_ADMIN / SUPER_ADMIN | 同上 |
| `POST /api/v1/admin/promotions/{id}/approve` | SKILL_ADMIN / SUPER_ADMIN | 同上 |
| `POST /api/v1/admin/promotions/{id}/reject` | SKILL_ADMIN / SUPER_ADMIN | 同上 |
| `PUT /api/v1/admin/users/{id}/roles` | USER_ADMIN / SUPER_ADMIN | 同上,且 USER_ADMIN 不可分配 SUPER_ADMIN |
| `POST /api/v1/admin/users/{id}/approve` | USER_ADMIN / SUPER_ADMIN | 同上 |
| `POST /api/v1/admin/users/{id}/ban` | USER_ADMIN / SUPER_ADMIN | 同上 |
| `GET /api/v1/admin/audit-logs` | AUDITOR / SUPER_ADMIN | 同上 |
### 10.5 Namespace API
| 接口 | 所需 namespace 角色 | 判定来源 |
|------|-------------------|---------|
| `POST /api/v1/namespaces/{slug}/reviews/{id}/approve` | 该空间 ADMIN 以上 | `namespace_member.role` |
| `POST /api/v1/namespaces/{slug}/reviews/{id}/reject` | 该空间 ADMIN 以上 | `namespace_member.role` |
| `POST /api/v1/namespaces/{slug}/members` | 该空间 ADMIN 以上 | `namespace_member.role` |
| `DELETE /api/v1/namespaces/{slug}/members/{userId}` | 该空间 ADMIN 以上 | `namespace_member.role` |
| `POST .../skills/{skillId}/promote` | 该空间 ADMIN 以上 或 owner | `namespace_member.role` 或 `skill.owner_id` |
### 10.6 Compatibility API(Token 认证)
| 接口 | 所需 Token Scope | 额外判定 |
|------|-----------------|---------|
| `GET /api/compat/v1/whoami` | 任意有效 Token | 无 |
| `GET /api/compat/v1/search` | 可选(匿名限 PUBLIC) | `SearchVisibilityScope` |
| `GET /api/compat/v1/resolve` | 可选(匿名限 PUBLIC) | visibility |
| `GET /api/compat/v1/download` | 可选(匿名限 PUBLIC) | visibility |
| `POST /api/compat/v1/skills` | `skill:publish` | 用户是目标 namespace 的 MEMBER 以上(namespace 由 canonical slug 解析) |
| `DELETE /api/compat/v1/skills/{slug}` | `skill:delete` | namespace ADMIN 以上 或 owner |
| `POST /api/compat/v1/stars/{slug}` | 任意有效 Token | 无 |

View file

@ -46,7 +46,7 @@ ACL 投影计算规则:
- 匿名用户:`includeAllPublic=true`,其余为空集,`userId=null`
- 已登录用户:`includeAllPublic=true`,`memberNamespaceIds` = 用户所属空间,`adminNamespaceIds` = 用户是 ADMIN 以上的空间,`userId` = 当前用户 ID
一期 MySQL 实现中,`SearchVisibilityScope` 转换为 WHERE 条件:
一期 PostgreSQL 实现中,`SearchVisibilityScope` 转换为 WHERE 条件:
```sql
WHERE (visibility = 'PUBLIC')
OR (visibility = 'NAMESPACE_ONLY' AND namespace_id IN (:memberNamespaceIds))
@ -75,7 +75,7 @@ WHERE (visibility = 'PUBLIC')
唯一约束:`(skill_id)`
MySQL Full-Text Index 建在 `(title, summary, keywords, search_text)` 上。
PostgreSQL 全文搜索索引:表增加 `search_vector tsvector` 生成列,基于 `title`、`summary`、`keywords`、`search_text` 自动维护,建立 GIN 索引。详见第 7 节。
## 4 索引写入时机
@ -95,11 +95,18 @@ MySQL Full-Text Index 建在 `(title, summary, keywords, search_text)` 上。
这些场景不是简单换 provider 能解决的,需要改表结构和索引写入逻辑。
**一期搜索能力边界(产品限制):**
- 搜索只基于 `latest_version_id` 对应版本的内容
- 不支持按 version 或 tag 搜索内容
- 搜索结果不区分 channel(`beta`、`stable` 等标签通道)
- 用户通过 tag 安装的技能内容可能与搜索结果展示的内容不一致(搜索展示 latest,安装的是 tag 指向的版本)
- 若要支持 channel-aware 搜索,必须升级到 version 级索引(二期 ES 实现)
### 5.2 演进阶段
| 阶段 | 实现 | 索引粒度 | 切换方式 |
|------|------|---------|---------|
| 一期 | MySQL Full-Text | 每 skill 一条(latest_version_id) | 默认 |
| 一期 | PostgreSQL Full-Text (tsvector + GIN) | 每 skill 一条(latest_version_id) | 默认 |
| 二期 | ES / OpenSearch | 每 skill_version 一条 + skill 聚合文档 | 配置 `search.provider=elasticsearch` |
| 三期 | 向量检索 | 每 skill_version 多条(chunk 级) | 配置 `search.provider=vector` |
| 四期 | 混合排序 | 关键词 + 向量混合 | 配置 `search.provider=hybrid` |
@ -121,16 +128,28 @@ MySQL Full-Text Index 建在 `(title, summary, keywords, search_text)` 上。
`rebuildAll()` / `rebuildByNamespace()` 执行前获取 Redis 分布式锁(key: `search:rebuild:{scope}`,TTL: 10min),获取失败则跳过。
## 7 MySQL 全文搜索中文支持
## 7 PostgreSQL 全文搜索中文支持
MySQL Full-Text Index 必须使用 ngram parser:
PostgreSQL 全文搜索使用 `tsvector` + `tsquery` + GIN 索引:
```sql
-- 增加 tsvector 生成列
ALTER TABLE skill_search_document
ADD FULLTEXT INDEX ft_search (title, summary, keywords, search_text)
WITH PARSER ngram;
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;
-- 建立 GIN 索引
CREATE INDEX idx_search_vector ON skill_search_document USING GIN (search_vector);
```
配置 `ngram_token_size=2`(my.cnf)。
中文支持方案:
- 一期使用 `simple` 分词配置(按空格和标点分词),对中文支持有限但零依赖
- 如需更好的中文分词,可安装 `zhparser` 或 `pg_jieba` 扩展,替换为对应的 text search configuration
- PostgreSQL 的 `tsvector` 支持权重(A/B/C/D),可对 title 赋予更高权重,提升搜索相关性
已知局限:ngram 分词精度不如专业搜索引擎,中文搜索体验有限。建议 Phase 2 完成后评估搜索效果,如不满足需求则在 Phase 3 提前引入 ES。
已知局限:`simple` 分词对中文的精度不如专业搜索引擎。建议 Phase 2 完成后评估搜索效果,如不满足需求则在 Phase 3 提前引入 ES。

View file

@ -2,6 +2,10 @@
## 1 发布流程
一期采用同步发布模型:上传、校验、存储、持久化在一次请求中同步完成。前端通过异步上传(带进度条)提升用户体验,但后端处理是同步的。
> **设计决策**:一期暂不考虑异步发布(uploadId、publishId、状态轮询、异步转正等)。一期技能包为文本资源包,体积有限(上限 10MB),同步处理足以满足需求。如后续引入大文件或复杂校验流程,再考虑异步模型。
```
用户提交发布
│
@ -16,20 +20,20 @@
- [扩展点] PrePublishValidator 链(一期空实现)
│
▼
③ 写入对象存储临时区(文件逐个上传到 `tmp/{uploadId}/{filePath}`,记录 SHA-256)
③ 同步写入对象存储
- 文件逐个上传到正式路径 `skills/{skillId}/{versionId}/{filePath}`,记录 SHA-256
- 生成预打包 zip 到 `packages/{skillId}/{versionId}/bundle.zip`
│
▼
④ 持久化数据
- 创建 skill_version (status=DRAFT, file_transfer_status=PENDING)
④ 持久化数据(与 ③ 在同一事务/请求中)
- 创建或关联 skill 记录(首次发布时创建 skill)
- 创建 skill_version (status=DRAFT)
- 创建 skill_file 记录
- 解析 SKILL.md frontmatter → parsed_metadata_json
- 生成 manifest_json
- 异步将文件从 `tmp/` 转正到 `skills/{skillId}/{versionId}/{filePath}`
- 转正成功 → file_transfer_status=COMPLETED
- 转正失败 → file_transfer_status=FAILED,记录失败原因
│
▼
⑤ 提交审核(前置检查:file_transfer_status 必须为 COMPLETED,否则拒绝提审)
⑤ 提交审核
- skill_version.status → PENDING_REVIEW
- 创建 review_task (status=PENDING)
│
@ -49,26 +53,13 @@
④ 和 ⑤ 分开,给用户一个检查草稿的机会。CLI 发布走同样的流程。
### 对象存储临时区与 GC
### 对象存储写入策略
- 上传阶段文件写入 `tmp/{uploadId}/{filePath}`
- 数据库事务提交成功后,异步将文件从 `tmp/` copy 到正式路径 `skills/{skillId}/{versionId}/{filePath}`,完成后删除 `tmp/` 副本
- 定时 GC 任务:清理超过 24h 的 `tmp/` 前缀对象(覆盖事务失败、用户取消、流程中断等场景)
- 如果数据库事务失败,`tmp/` 中的文件由 GC 自动清理,不产生孤儿对象
### 文件转正补偿机制
`skill_version` 增加 `file_transfer_status` 字段(`PENDING` / `COMPLETED` / `FAILED`),用于追踪异步转正状态。
安全门控:
- 提交审核(DRAFT → PENDING_REVIEW):前置检查 `file_transfer_status = COMPLETED`,否则返回 400
- 下载接口:前置检查 `file_transfer_status = COMPLETED`,否则返回 404
失败重试:
- 转正失败时标记 `file_transfer_status = FAILED`
- 定时任务每 5 分钟扫描 `file_transfer_status = PENDING` 且 `created_at > 5min ago` 或 `file_transfer_status = FAILED` 的记录,重试转正
- 最多重试 3 次,超过后标记为 FAILED 并保留,用户可在草稿页看到"文件处理失败"提示,可手动触发重试或删除该版本重新上传
- `tmp/` 中的源文件在转正成功前不删除,确保重试有源可用
一期同步写入正式路径,不使用临时区:
- 文件直接写入 `skills/{skillId}/{versionId}/{filePath}`
- 如果数据库事务失败,对象存储中的文件成为孤儿对象
- 定时 GC 任务:每天扫描对象存储中存在但数据库中无对应 `skill_file` 记录的文件,清理孤儿对象
- 删除 DRAFT/REJECTED 版本时,同步清理对应的对象存储文件
### CLI publish 请求规范
@ -81,42 +72,28 @@ Parts:
- auto_submit: boolean(可选,默认 false,为 true 时自动提交审核)
```
CLI 默认行为:上传 → 创建 DRAFT → 自动提交审核(`auto_submit=true`)。
一期同步响应:服务端同步完成上传、校验、存储、持久化,返回 `200 OK` + skill_version 信息。
CLI 默认行为:上传 → 创建 DRAFT → 自动提交审核(`auto_submit=true`),一次请求完成。
Web 端默认行为:上传 → 创建 DRAFT → 用户预览确认 → 手动提交审核。
### CLI publish 异步协议
`auto_submit=true` 时,服务端在文件转正完成后自动提交审核,CLI 不需要额外调用 submit-review。
```
CLI 调用 POST /api/v1/cli/publish (auto_submit=true)
│
▼
服务端返回 202 Accepted + publishId + 初始状态
│
▼
CLI 轮询 GET /api/v1/cli/publish/{publishId}/status
│
├── TRANSFERRING → 文件转正中,继续轮询(建议间隔 2s)
├── SUBMITTED → 文件转正完成 + 已自动提交审核,CLI 结束
├── DRAFT → auto_submit=false 时,文件转正完成但未提审,CLI 结束
└── FAILED → 文件转正失败,返回错误原因,CLI 提示用户
```
`/api/v1/cli/publish/{publishId}/status` 响应:
`/api/v1/cli/publish` 响应:
```json
{
"data": {
"publishId": "uuid",
"skillId": 456,
"skillVersionId": 123,
"fileTransferStatus": "COMPLETED",
"versionStatus": "PENDING_REVIEW",
"error": null
"version": "1.2.0",
"status": "PENDING_REVIEW",
"namespace": "team-name",
"slug": "my-skill"
}
}
```
`auto_submit=false` 时,`status` 为 `DRAFT`,用户需后续手动提交审核。
## 2 团队技能提升到全局空间(派生发布)
不直接修改原 skill 的 `namespace_id`,而是在全局空间创建新的 skill,保留来源追溯。原团队 skill 继续存在,安装坐标 `@team/skill` 不受影响。
@ -177,8 +154,8 @@ CLI 轮询 GET /api/v1/cli/publish/{publishId}/status
一期使用原子 SQL 直接更新,可接受。如出现热点行瓶颈,切换为:
1. Redis `INCR` 做实时计数(key: `skill:downloads:{skillId}`)
2. 定时任务每 5 分钟批量回写 MySQL
3. 查询时合并 MySQL 存量 + Redis 增量
2. 定时任务每 5 分钟批量回写 PostgreSQL
3. 查询时合并 PostgreSQL 存量 + Redis 增量
## 4 搜索流程

View file

@ -12,6 +12,10 @@
| GET | `/api/v1/skills/{namespace}/{slug}/versions/{version}/file?path=...` | 读取单个文件(query param 避免路径中 / 的解析问题) |
| GET | `/api/v1/skills/{namespace}/{slug}/download` | 下载默认安装版本(latest_version_id 指向的版本) |
| GET | `/api/v1/skills/{namespace}/{slug}/versions/{version}/download` | 下载指定版本包 |
| GET | `/api/v1/skills/{namespace}/{slug}/resolve` | 解析技能版本(支持 query param: `version`、`tag`、`hash`) |
| GET | `/api/v1/skills/{namespace}/{slug}/tags/{tagName}/download` | 按标签下载(解析标签指向的版本后下载) |
| GET | `/api/v1/skills/{namespace}/{slug}/tags/{tagName}/files` | 按标签查看文件清单 |
| GET | `/api/v1/skills/{namespace}/{slug}/tags/{tagName}/file?path=...` | 按标签读取单个文件 |
| GET | `/api/v1/namespaces` | 公开命名空间列表 |
| GET | `/api/v1/namespaces/{slug}` | 命名空间详情 |
@ -56,7 +60,7 @@ Public API 的可见性规则:
| 方法 | 路径 | 说明 |
|------|------|------|
| POST | `/api/v1/skills/{namespace}/{slug}/versions/{version}/submit-review` | 将 DRAFT 版本提交审核(前置:file_transfer_status=COMPLETED) |
| POST | `/api/v1/skills/{namespace}/{slug}/versions/{version}/submit-review` | 将 DRAFT 版本提交审核 |
| POST | `/api/v1/skills/{namespace}/{slug}/versions/{version}/withdraw-review` | 撤回提审(PENDING_REVIEW → DRAFT,同时删除关联的 PENDING review_task) |
| GET | `/api/v1/skills/{namespace}/{slug}/versions/{version}/draft` | 查看草稿详情(owner 或 namespace ADMIN 以上) |
@ -182,7 +186,182 @@ Admin API 按最小权限拆分,不再统一要求 SUPER_ADMIN:
| CLI `install @team/skill` | `latest_version_id` | 等同于 `@latest` |
| CLI `install @team/skill@beta` | `skill_tag` 查询 | 自定义标签指向的版本 |
## 7.9 Rate Limiting
## 7.9 Resolve 接口说明
`GET /api/v1/skills/{namespace}/{slug}/resolve` 用于解析技能版本,支持以下 query param:
| 参数 | 类型 | 说明 |
|------|------|------|
| `version` | string | 精确版本号(如 `1.2.0`) |
| `tag` | string | 标签名(如 `beta`、`latest`) |
| `hash` | string | fingerprint 哈希,用于判断本地版本是否与 registry 同步 |
解析优先级:
1. `version` 和 `tag` 不可同时传,同时传返回 `400 Bad Request`
2. 仅传 `version`:精确匹配版本号
3. 仅传 `tag`:查询 `skill_tag` 表获取 `target_version_id`
4. 仅传 `hash`:遍历已发布版本,比对 fingerprint
5. 均不传:返回 `latest_version_id` 指向的版本
响应:
```json
{
"data": {
"skillId": 456,
"namespace": "team-name",
"slug": "my-skill",
"version": "1.2.0",
"versionId": 123,
"fingerprint": "sha256:abc123...",
"downloadUrl": "/api/v1/skills/team-name/my-skill/versions/1.2.0/download"
}
}
```
`hash` 匹配时额外返回 `"matched": true`,不匹配时返回最新版本信息 + `"matched": false`。
## 7.10 ClawHub CLI 兼容层 API
兼容层 API 基地址为 `/api/compat/v1`,通过 `/.well-known/clawhub.json` 发现。兼容层使用 canonical slug(双连字符映射规则,详见 `00-product-direction.md` 1.1 节)。
认证方式:`Authorization: Bearer <token>`,复用 skillhub API Token 体系。
### Well-known 发现
```
GET /.well-known/clawhub.json
响应:
{
"apiBase": "/api/compat/v1"
}
```
### 兼容层端点
| 方法 | 路径 | 说明 |
|------|------|------|
| GET | `/api/compat/v1/whoami` | 当前用户信息 |
| GET | `/api/compat/v1/search` | 搜索技能 |
| GET | `/api/compat/v1/resolve` | 通过 slug + fingerprint 解析版本 |
| GET | `/api/compat/v1/download` | 下载技能 zip 包 |
| GET | `/api/compat/v1/skills` | 列出技能(分页) |
| POST | `/api/compat/v1/skills` | 发布技能(multipart/form-data) |
| GET | `/api/compat/v1/skills/{slug}` | 获取技能详情 |
| DELETE | `/api/compat/v1/skills/{slug}` | 软删除技能 |
| GET | `/api/compat/v1/skills/{slug}/versions` | 列出版本 |
| GET | `/api/compat/v1/skills/{slug}/versions/{version}` | 版本详情 |
| GET | `/api/compat/v1/skills/{slug}/file` | 获取单个文件内容 |
| POST | `/api/compat/v1/stars/{slug}` | 收藏 |
| DELETE | `/api/compat/v1/stars/{slug}` | 取消收藏 |
### 兼容层请求/响应格式
**GET `/api/compat/v1/whoami`**
```json
{
"handle": "username",
"displayName": "User Name",
"role": "user"
}
```
**GET `/api/compat/v1/search?q={keyword}&page={page}&limit={limit}`**
```json
{
"results": [
{
"slug": "my-skill",
"name": "My Skill",
"description": "...",
"author": { "handle": "username", "displayName": "User Name" },
"version": "1.2.0",
"downloadCount": 100,
"starCount": 50,
"createdAt": "2026-01-01T00:00:00Z",
"updatedAt": "2026-03-01T00:00:00Z"
}
],
"total": 1,
"page": 1,
"limit": 20
}
```
注意:兼容层返回的 `slug` 为 canonical slug 格式(全局空间直接返回 skill slug,团队空间返回 `namespace--skill`)。
**GET `/api/compat/v1/resolve?slug={slug}&hash={fingerprint}`**
```json
{
"slug": "my-skill",
"version": "1.2.0",
"fingerprint": "sha256:abc123...",
"matched": true
}
```
`hash` 不匹配时返回最新版本 + `"matched": false`。
**GET `/api/compat/v1/download?slug={slug}&version={version}`**
返回 zip 文件流。`version` 可选,不传时下载最新已发布版本。
**POST `/api/compat/v1/skills`**
```
Content-Type: multipart/form-data
Parts:
- file: zip 包
```
一期同步响应,返回发布结果:
```json
{
"slug": "my-skill",
"version": "1.0.0",
"status": "pending_review"
}
```
注意:ClawHub 原始协议使用两步发布(先获取 upload URL,再 JSON publish),skillhub 兼容层简化为单步 multipart 上传。如果 ClawHub CLI 的发布流程无法适配单步模式,需要额外实现 `/api/compat/v1/upload-url` + `/api/compat/v1/publish` 两步兼容端点。
**GET `/api/compat/v1/skills/{slug}`**
```json
{
"slug": "my-skill",
"name": "My Skill",
"description": "...",
"author": { "handle": "username", "displayName": "User Name" },
"version": "1.2.0",
"versions": ["1.0.0", "1.1.0", "1.2.0"],
"license": "MIT-0",
"downloadCount": 100,
"starCount": 50,
"starred": false,
"files": [
{ "path": "SKILL.md", "size": 1024 }
],
"createdAt": "2026-01-01T00:00:00Z",
"updatedAt": "2026-03-01T00:00:00Z"
}
```
### 兼容层适配说明
- 兼容层是独立的 Controller 层,内部调用与 native API 相同的领域服务
- 请求进入时将 canonical slug 转换为 `(namespace_id, skill_slug)` 坐标
- 响应返回时将内部坐标转换为 canonical slug
- 兼容层不暴露 namespace 概念,对 ClawHub CLI 透明
- 发布时如果 canonical slug 包含 `--`,解析为团队空间发布;否则发布到全局空间
- 兼容层的认证复用 skillhub API Token,ClawHub CLI 通过 `clawhub login` 获取 token 后即可使用
## 7.11 Rate Limiting
分两阶段实施:
@ -203,10 +382,24 @@ Admin API 按最小权限拆分,不再统一要求 SUPER_ADMIN:
触发限流时返回 `429 Too Many Requests` + `Retry-After` Header。
## 7.10 API 设计原则
## 7.12 API 设计原则
- 统一响应格式:`{ code, message, data, timestamp }`
### Native API(`/api/v1/*`)
- 统一响应包裹:`{ code, message, data, timestamp }`
- 分页格式:`{ items, total, page, size }`
- 错误码体系:业务错误码 + HTTP 状态码配合
- 版本策略:URL path 版本 `/api/v1/`
- 幂等性:写操作通过 `X-Request-Id` + Redis 去重(TTL 24h)
### Compatibility API(`/api/compat/v1/*`)
- 响应格式完全遵循 ClawHub 协议,不套统一响应包裹
- 错误响应遵循 ClawHub 格式:`{ error: string, message: string }`
- 分页格式遵循 ClawHub 格式:`{ results, total, page, limit }`
### OpenAPI 文档分离
生成两份独立的 OpenAPI spec:
- `openapi-native.json`:skillhub Native API,用于前端 SDK 生成
- `openapi-compat-clawhub.json`:ClawHub 兼容层 API,用于兼容性测试和文档

View file

@ -113,9 +113,33 @@ CLI 安装后在本地写入 `.astron/metadata.json`:
## 8.7 版本解析规则
skillhub 自有 CLI 支持完整 namespace 坐标:
```
install @team/my-skill → 最新已发布版本(latest_version_id)
install @team/my-skill@1.2.0 → 精确版本
install @team/my-skill@latest → 等同于不带版本号(系统保留标签,只读)
install @team/my-skill@beta → beta 标签(自定义标签)
install my-skill → 等同于 @global/my-skill
```
ClawHub CLI 通过兼容层使用 canonical slug:
```
clawhub install my-skill → @global/my-skill 的最新版本
clawhub install team-name--my-skill → @team-name/my-skill 的最新版本
clawhub install my-skill@1.2.0 → @global/my-skill 的精确版本
```
## 8.8 坐标映射与 ClawHub CLI 兼容
skillhub 内部使用 `@{namespace_slug}/{skill_slug}` 坐标,ClawHub CLI 使用单一 slug。映射规则详见 `00-product-direction.md` 1.1 节。
安装后的本地目录名始终使用 `skill.slug`(不含 namespace 前缀),确保与 OpenSkills/Claude 兼容客户端的互操作性。
| skillhub 坐标 | ClawHub canonical slug | 本地安装目录名 |
|---|---|---|
| `@global/my-skill` | `my-skill` | `my-skill/` |
| `@team-name/my-skill` | `team-name--my-skill` | `my-skill/` |
注意:不同 namespace 下同名 skill 安装到本地时会产生目录冲突。skillhub CLI 应在安装时检测冲突并提示用户选择安装目录或使用别名。

View file

@ -139,8 +139,8 @@ window.location.href = "/oauth2/authorization/github"
## 7 关键交互
**技能详情页**:SKILL.md Markdown 渲染、右侧信息栏(版本/下载量/评分/收藏/标签/空间)、版本切换、安装命令一键复制。匿名用户可浏览和下载,收藏/评分按钮提示登录。
**技能详情页**:SKILL.md Markdown 渲染、右侧信息栏(版本/下载量/评分/收藏/标签/空间)、版本切换、安装命令一键复制(同时展示 skillhub CLI 格式 `install @namespace/slug` 和 ClawHub CLI 格式 `install canonical-slug`)。匿名用户可浏览和下载,收藏/评分按钮提示登录。
**搜索页**:实时搜索(debounce 300ms)、技能卡片、排序(相关度/下载量/评分/最新)、命名空间过滤。匿名用户可搜索 PUBLIC 技能。
**搜索页**:实时搜索(debounce 300ms)、技能卡片、排序(相关度/下载量/评分/最新)、命名空间过滤。匿名用户可搜索 PUBLIC 技能。注意:一期搜索仅基于 latest 版本内容,不支持按 tag/version 搜索(详见 `04-search-architecture.md` 5.1 节)。
**审核页面**:左侧列表 + 右侧内容预览(Markdown + 文件树)、通过/拒绝 + 意见输入。

View file

@ -20,14 +20,14 @@
│ │ │
▼ ▼ ▼
┌────────┐ ┌────────┐ ┌──────────────┐
│ MySQL │ │ Redis │ │ S3 / MinIO │
│ (主从) │ │ │ │ │
│ PostgreSQL│ │ Redis │ │ S3 / MinIO │
│ (主从) │ │ │ │ │
└────────┘ └────────┘ └──────────────┘
```
## 2 服务配置
- 无状态设计,所有状态存储在 MySQL / Redis / S3
- 无状态设计,所有状态存储在 PostgreSQL / Redis / S3
- 健康检查:`/actuator/health`(liveness + readiness 分离)
- 优雅停机:`spring.lifecycle.timeout-per-shutdown-phase=30s`
- JVM:`-XX:MaxRAMPercentage=75.0`
@ -36,7 +36,7 @@
| Profile | 用途 | 特点 |
|---------|------|------|
| `local` | 本地开发 | 本地 MySQL/MinIO,Mock OAuth(见下方说明) |
| `local` | 本地开发 | Docker Compose 一键启动(PostgreSQL/Redis/MinIO),Mock OAuth(见下方说明) |
| `dev` | 开发环境 | 共享基础设施,GitHub OAuth 测试应用 |
| `staging` | 预发布 | 与生产同构 |
| `prod` | 生产 | 多 Pod,完整基础设施 |
@ -50,6 +50,354 @@
MockAuthFilter 仅在 `local` profile 激活,通过 `@Profile("local")` 注解保证不会泄漏到其他环境。
### Docker Compose 一键启动
项目提供两套 Docker Compose 配置,分别用于本地开发和完整部署。
#### docker-compose.yml — 本地开发(仅依赖服务)
本地开发时前后端在宿主机运行,Docker Compose 只拉起依赖服务:
```yaml
# docker-compose.yml(项目根目录)
services:
postgres:
image: postgres:16-alpine
ports:
- "5432:5432"
environment:
POSTGRES_DB: skillhub
POSTGRES_USER: skillhub
POSTGRES_PASSWORD: skillhub_dev
volumes:
- postgres_data:/var/lib/postgresql/data
redis:
image: redis:7-alpine
ports:
- "6379:6379"
minio:
image: minio/minio:latest
ports:
- "9000:9000"
- "9001:9001" # MinIO Console
environment:
MINIO_ROOT_USER: minioadmin
MINIO_ROOT_PASSWORD: minioadmin
command: server /data --console-address ":9001"
volumes:
- minio_data:/data
volumes:
postgres_data:
minio_data:
```
#### docker-compose.prod.yml — 完整部署(前后端 + 依赖服务)
开发完成后,通过 `docker compose -f docker-compose.prod.yml up -d` 一键打包并启动整个系统:
```yaml
# docker-compose.prod.yml(项目根目录)
services:
postgres:
image: postgres:16-alpine
ports:
- "5432:5432"
environment:
POSTGRES_DB: skillhub
POSTGRES_USER: skillhub
POSTGRES_PASSWORD: ${DB_PASSWORD:-skillhub_prod}
volumes:
- postgres_data:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U skillhub"]
interval: 5s
timeout: 5s
retries: 5
redis:
image: redis:7-alpine
ports:
- "6379:6379"
healthcheck:
test: ["CMD", "redis-cli", "ping"]
interval: 5s
timeout: 5s
retries: 5
minio:
image: minio/minio:latest
ports:
- "9000:9000"
- "9001:9001"
environment:
MINIO_ROOT_USER: ${MINIO_ROOT_USER:-minioadmin}
MINIO_ROOT_PASSWORD: ${MINIO_ROOT_PASSWORD:-minioadmin}
command: server /data --console-address ":9001"
volumes:
- minio_data:/data
healthcheck:
test: ["CMD", "mc", "ready", "local"]
interval: 5s
timeout: 5s
retries: 5
server:
build:
context: ./server
dockerfile: Dockerfile
ports:
- "8080:8080"
environment:
SPRING_PROFILES_ACTIVE: prod
DATABASE_URL: jdbc:postgresql://postgres:5432/skillhub
DATABASE_USERNAME: skillhub
DATABASE_PASSWORD: ${DB_PASSWORD:-skillhub_prod}
REDIS_HOST: redis
REDIS_PORT: 6379
S3_ENDPOINT: http://minio:9000
S3_ACCESS_KEY: ${MINIO_ROOT_USER:-minioadmin}
S3_SECRET_KEY: ${MINIO_ROOT_PASSWORD:-minioadmin}
S3_BUCKET: skillhub
OAUTH2_GITHUB_CLIENT_ID: ${OAUTH2_GITHUB_CLIENT_ID}
OAUTH2_GITHUB_CLIENT_SECRET: ${OAUTH2_GITHUB_CLIENT_SECRET}
depends_on:
postgres:
condition: service_healthy
redis:
condition: service_healthy
minio:
condition: service_healthy
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:8080/actuator/health"]
interval: 10s
timeout: 5s
retries: 10
start_period: 30s
web:
build:
context: ./web
dockerfile: Dockerfile
ports:
- "80:80"
depends_on:
server:
condition: service_healthy
volumes:
postgres_data:
minio_data:
```
#### 前后端 Dockerfile
后端 Dockerfile(`server/Dockerfile`):
```dockerfile
FROM maven:3.9-eclipse-temurin-21 AS build
WORKDIR /app
COPY pom.xml .
COPY skillhub-app/pom.xml skillhub-app/
COPY skillhub-domain/pom.xml skillhub-domain/
COPY skillhub-auth/pom.xml skillhub-auth/
COPY skillhub-search/pom.xml skillhub-search/
COPY skillhub-storage/pom.xml skillhub-storage/
COPY skillhub-infra/pom.xml skillhub-infra/
RUN mvn dependency:go-offline -B
COPY . .
RUN mvn package -DskipTests -B
FROM eclipse-temurin:21-jre-alpine
WORKDIR /app
COPY --from=build /app/skillhub-app/target/*.jar app.jar
EXPOSE 8080
ENTRYPOINT ["java", "-XX:MaxRAMPercentage=75.0", "-jar", "app.jar"]
```
前端 Dockerfile(`web/Dockerfile`):
```dockerfile
FROM node:20-alpine AS build
WORKDIR /app
RUN corepack enable
COPY package.json pnpm-lock.yaml ./
RUN pnpm install --frozen-lockfile
COPY . .
RUN pnpm build
FROM nginx:alpine
COPY --from=build /app/dist /usr/share/nginx/html
COPY nginx.conf /etc/nginx/conf.d/default.conf
EXPOSE 80
```
前端 Nginx 配置(`web/nginx.conf`):
```nginx
server {
listen 80;
root /usr/share/nginx/html;
index index.html;
# SPA 路由回退
location / {
try_files $uri $uri/ /index.html;
}
# API 反向代理到后端
location /api/ {
proxy_pass http://server:8080;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
# OAuth2 回调反向代理
location /oauth2/ {
proxy_pass http://server:8080;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
}
location /login/oauth2/ {
proxy_pass http://server:8080;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
}
# Well-known 发现端点
location /.well-known/ {
proxy_pass http://server:8080;
proxy_set_header Host $host;
}
}
```
### Spring Boot 配置文件分层
```
server/skillhub-app/src/main/resources/
├── application.yml # 公共配置(所有 profile 共享)
├── application-local.yml # 本地开发(Docker Compose 服务地址)
├── application-dev.yml # 开发环境
├── application-staging.yml # 预发布
└── application-prod.yml # 生产
```
`application.yml`(公共配置):
```yaml
spring:
application:
name: skillhub
jpa:
open-in-view: false
hibernate:
ddl-auto: validate # 由 Flyway 管理 schema,Hibernate 仅校验
properties:
hibernate:
dialect: org.hibernate.dialect.PostgreSQLDialect
flyway:
enabled: true
locations: classpath:db/migration
server:
shutdown: graceful
spring.lifecycle.timeout-per-shutdown-phase: 30s
```
`application-local.yml`(本地开发,对应 Docker Compose):
```yaml
spring:
datasource:
url: jdbc:postgresql://localhost:5432/skillhub
username: skillhub
password: skillhub_dev
data:
redis:
host: localhost
port: 6379
jpa:
show-sql: true
skillhub:
storage:
type: s3
endpoint: http://localhost:9000
access-key: minioadmin
secret-key: minioadmin
bucket: skillhub
region: us-east-1
access-policy:
mode: OPEN # 本地开发默认开放准入
```
`application-prod.yml`(生产环境,凭证从环境变量/K8s Secret 注入):
```yaml
spring:
datasource:
url: ${DATABASE_URL}
username: ${DATABASE_USERNAME}
password: ${DATABASE_PASSWORD}
data:
redis:
host: ${REDIS_HOST}
port: ${REDIS_PORT:6379}
jpa:
show-sql: false
skillhub:
storage:
type: s3
endpoint: ${S3_ENDPOINT}
access-key: ${S3_ACCESS_KEY}
secret-key: ${S3_SECRET_KEY}
bucket: ${S3_BUCKET:skillhub}
region: ${S3_REGION:us-east-1}
```
### 本地开发启动流程
```bash
# 1. 启动依赖服务
docker compose up -d
# 2. 启动后端(自动执行 Flyway 迁移)
cd server && ./mvnw spring-boot:run -Dspring-boot.run.profiles=local
# 3. 启动前端
cd web && pnpm dev
```
### 完整部署(一键打包 + 启动)
```bash
# 构建并启动所有服务(前后端 + 依赖)
docker compose -f docker-compose.prod.yml up -d --build
# 仅重新构建并重启应用服务(依赖服务不重启)
docker compose -f docker-compose.prod.yml up -d --build server web
# 停止所有服务
docker compose -f docker-compose.prod.yml down
# 停止并清除数据卷(慎用)
docker compose -f docker-compose.prod.yml down -v
```
### Makefile 命令
```bash
make dev # docker compose up -d + 后端 + 前端(本地开发)
make dev-down # docker compose down
make build # 构建后端 JAR + 前端 dist
make docker # 构建前后端 Docker 镜像
make deploy # docker compose -f docker-compose.prod.yml up -d --build
make deploy-down # docker compose -f docker-compose.prod.yml down
make generate-api # 生成 OpenAPI 类型
```
## 4 配置管理
- 敏感配置:K8s Secret(数据库/Redis/S3 凭证、OAuth2 Client ID/Secret)
@ -68,6 +416,8 @@ requestId 透传:Ingress 注入 → Spring Filter 读取放入 MDC → 日志
## 6 构建与发布
### CI Pipeline 构建
```
代码提交 → CI Pipeline
├── server: mvn package → JAR
@ -75,14 +425,30 @@ requestId 透传:Ingress 注入 → Spring Filter 读取放入 MDC → 日志
│
▼
Docker 多阶段构建
├── server → openjdk:21-jre-slim
├── server → eclipse-temurin:21-jre-alpine
└── web → nginx:alpine
│
▼
推送镜像 → K8s 滚动更新
```
Makefile 顶层命令:`make dev-server`, `make dev-web`, `make build`, `make docker`, `make generate-api`
### Docker Compose 完整部署
```
make deploy
│
▼
docker compose -f docker-compose.prod.yml up -d --build
│
├── 构建 server 镜像(Maven 多阶段构建 → JRE 运行)
├── 构建 web 镜像(pnpm build → Nginx 静态服务 + 反向代理)
├── 拉起 PostgreSQL / Redis / MinIO
├── 等待依赖服务健康检查通过
├── 启动 server(自动执行 Flyway 迁移)
└── 启动 web(Nginx 代理 API 到 server)
```
Makefile 顶层命令:`make dev`, `make dev-down`, `make build`, `make docker`, `make deploy`, `make deploy-down`, `make generate-api`
## 7 数据库迁移

View file

@ -4,6 +4,12 @@
产出:架构设计文档、数据库 DDL、API OpenAPI spec 草案、前端线框图
已冻结决策:
- 技能坐标体系:`@{namespace_slug}/{skill_slug}`,兼容层使用 `--` 双连字符映射(详见 `00-product-direction.md` 1.1 节)
- 一期同步发布模型,暂不考虑异步发布
- API Token 一期继承用户全部权限(非最小权限),后续版本细化
- ClawHub CLI 兼容层基地址 `/api/compat/v1`,通过 `/.well-known/clawhub.json` 发现
## Phase 1:工程骨架 + 认证打通
### 后端
@ -42,9 +48,9 @@
- 命名空间 CRUD + 成员管理
- 对象存储集成
- 技能发布(上传 → 校验 → 存储 → draft)
- 技能发布(上传 → 校验 → 存储 → draft,一期同步处理)
- 技能查询(详情、版本、文件)、下载(打包 + 可见性检查,PUBLIC 匿名可下载)
- 标签管理、搜索(MySQL Full-Text,匿名搜索限 PUBLIC)
- 标签管理、搜索(PostgreSQL Full-Text,匿名搜索限 PUBLIC)
- 异步事件基础设施
- Rate Limiting 升级(应用层精细限流:按用户/端点分类,基于 Redis 滑动窗口)
@ -67,7 +73,9 @@
- 团队技能提升到全局(promotion_request 流程)
- 评分 + 收藏 + 计数器(原子更新)
- CLI API(whoami、publish、resolve、check)
- ClawHub CLI 协议兼容层(registry metadata、resolve、download、publish、check 等核心接口)
- ClawHub CLI 协议兼容层(`/api/compat/v1` 端点:search、resolve、download、publish、skills CRUD、stars)
- 兼容层 canonical slug 映射(`--` 双连字符规则)
- `/.well-known/clawhub.json` 发现端点
- 协议适配器与兼容性测试(针对 ClawHub CLI 的真实请求/响应样例)
- 审计日志(同步落库)、幂等去重(idempotency_record + Redis)
@ -109,4 +117,4 @@
| 搜索效果不佳 | SPI 架构允许随时切换实现 |
| 前后端接口频繁变更 | OpenAPI spec 先行,类型自动生成 |
| 新增 OAuth Provider | Spring Security OAuth2 原生多 Provider 支持,只需配置 + 属性映射 |
| ClawHub CLI 协议细节与现有模型不完全一致 | 增加兼容适配层与协议回归测试,避免把 skillhub 内部模型直接暴露给兼容客户端 |
| ClawHub CLI 协议细节与现有模型不完全一致 | 兼容层使用 `--` 双连字符 canonical slug 映射,独立 Controller 层适配,协议回归测试覆盖 |

File diff suppressed because it is too large Load diff

65
server/pom.xml Normal file
View file

@ -0,0 +1,65 @@
<?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>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId>
<version>3.2.3</version>
<relativePath/>
</parent>
<groupId>com.skillhub</groupId>
<artifactId>skillhub-parent</artifactId>
<version>0.1.0-SNAPSHOT</version>
<packaging>pom</packaging>
<properties>
<java.version>21</java.version>
<maven.compiler.source>21</maven.compiler.source>
<maven.compiler.target>21</maven.compiler.target>
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
</properties>
<modules>
<module>skillhub-app</module>
<module>skillhub-domain</module>
<module>skillhub-auth</module>
<module>skillhub-search</module>
<module>skillhub-storage</module>
<module>skillhub-infra</module>
</modules>
<dependencyManagement>
<dependencies>
<dependency>
<groupId>com.skillhub</groupId>
<artifactId>skillhub-domain</artifactId>
<version>${project.version}</version>
</dependency>
<dependency>
<groupId>com.skillhub</groupId>
<artifactId>skillhub-auth</artifactId>
<version>${project.version}</version>
</dependency>
<dependency>
<groupId>com.skillhub</groupId>
<artifactId>skillhub-search</artifactId>
<version>${project.version}</version>
</dependency>
<dependency>
<groupId>com.skillhub</groupId>
<artifactId>skillhub-storage</artifactId>
<version>${project.version}</version>
</dependency>
<dependency>
<groupId>com.skillhub</groupId>
<artifactId>skillhub-infra</artifactId>
<version>${project.version}</version>
</dependency>
</dependencies>
</dependencyManagement>
</project>

View file

@ -0,0 +1,57 @@
<?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.skillhub</groupId>
<artifactId>skillhub-parent</artifactId>
<version>0.1.0-SNAPSHOT</version>
</parent>
<artifactId>skillhub-app</artifactId>
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-actuator</artifactId>
</dependency>
<dependency>
<groupId>org.springdoc</groupId>
<artifactId>springdoc-openapi-starter-webmvc-ui</artifactId>
<version>2.3.0</version>
</dependency>
<dependency>
<groupId>com.skillhub</groupId>
<artifactId>skillhub-domain</artifactId>
</dependency>
<dependency>
<groupId>com.skillhub</groupId>
<artifactId>skillhub-auth</artifactId>
</dependency>
<dependency>
<groupId>com.skillhub</groupId>
<artifactId>skillhub-infra</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>
</dependencies>
<build>
<plugins>
<plugin>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
</plugin>
</plugins>
</build>
</project>

View file

@ -0,0 +1,36 @@
<?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.skillhub</groupId>
<artifactId>skillhub-parent</artifactId>
<version>0.1.0-SNAPSHOT</version>
</parent>
<artifactId>skillhub-auth</artifactId>
<dependencies>
<dependency>
<groupId>com.skillhub</groupId>
<artifactId>skillhub-domain</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-security</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-oauth2-client</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-jpa</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>
</dependencies>
</project>

View file

@ -0,0 +1,19 @@
<?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.skillhub</groupId>
<artifactId>skillhub-parent</artifactId>
<version>0.1.0-SNAPSHOT</version>
</parent>
<artifactId>skillhub-domain</artifactId>
<dependencies>
<dependency>
<groupId>jakarta.persistence</groupId>
<artifactId>jakarta.persistence-api</artifactId>
</dependency>
</dependencies>
</project>

View file

@ -0,0 +1,19 @@
<?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.skillhub</groupId>
<artifactId>skillhub-parent</artifactId>
<version>0.1.0-SNAPSHOT</version>
</parent>
<artifactId>skillhub-infra</artifactId>
<dependencies>
<dependency>
<groupId>com.skillhub</groupId>
<artifactId>skillhub-domain</artifactId>
</dependency>
</dependencies>
</project>

View file

@ -0,0 +1,19 @@
<?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.skillhub</groupId>
<artifactId>skillhub-parent</artifactId>
<version>0.1.0-SNAPSHOT</version>
</parent>
<artifactId>skillhub-search</artifactId>
<dependencies>
<dependency>
<groupId>com.skillhub</groupId>
<artifactId>skillhub-domain</artifactId>
</dependency>
</dependencies>
</project>

View file

@ -0,0 +1,13 @@
<?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.skillhub</groupId>
<artifactId>skillhub-parent</artifactId>
<version>0.1.0-SNAPSHOT</version>
</parent>
<artifactId>skillhub-storage</artifactId>
</project>