From 3753d7bcffb8da82c12f74064303b8a7c70e9691 Mon Sep 17 00:00:00 2001 From: yun-zhi-ztl <15071461069@163.com> Date: Mon, 16 Mar 2026 11:30:50 +0800 Subject: [PATCH 01/23] docs: add namespace governance design spec --- .../2026-03-16-namespace-governance-design.md | 416 ++++++++++++++++++ 1 file changed, 416 insertions(+) create mode 100644 docs/superpowers/specs/2026-03-16-namespace-governance-design.md diff --git a/docs/superpowers/specs/2026-03-16-namespace-governance-design.md b/docs/superpowers/specs/2026-03-16-namespace-governance-design.md new file mode 100644 index 00000000..33f98154 --- /dev/null +++ b/docs/superpowers/specs/2026-03-16-namespace-governance-design.md @@ -0,0 +1,416 @@ +# Namespace 治理补齐设计文档 + +> **Goal:** 在现有 namespace 基础能力上,补齐命名空间生命周期治理闭环。实现团队命名空间状态管理、管理台读模型拆分、前后端治理交互、跨模块状态约束、审计记录和错误语义统一。 + +> **前置条件:** Phase 2 命名空间模型、成员管理、Skill 核心链路已完成;Phase 3 审核与提升流程已接入 namespace 角色体系。 + +> **重要约束:系统内置全局空间** +> `@global` 是系统内置命名空间,不允许任何业务接口修改其基础信息、成员、状态或所有权。它只允许读取。 + +## 关键设计决策 + +| 决策点 | 选择 | 理由 | +|--------|------|------| +| 治理模式 | 生命周期收敛型 | 一次性统一状态机、权限矩阵、页面行为和跨模块约束,避免零散补丁 | +| 全局空间策略 | `@global` 内置只读 | 与产品定位一致,避免把全局公共空间误当作普通团队空间治理 | +| 团队空间状态机 | `ACTIVE / FROZEN / ARCHIVED` | 已在领域模型中定义,补齐接口和行为即可 | +| 恢复语义 | `ARCHIVED -> ACTIVE` | 软归档恢复后直接回归正常运营态,避免多余状态分支 | +| 服务边界 | `NamespaceGovernanceService` 独立承载状态流转 | 避免 `NamespaceService` 混杂 CRUD、成员和生命周期逻辑 | +| 管理读模型 | 新增 `/me/namespaces` | 区分公开目录和管理台视图,支持返回冻结/归档空间 | +| 归档权限 | 团队空间仅 `OWNER` 可归档/恢复 | 归档是高风险操作,需要明确责任人 | +| 冻结权限 | 团队空间 `OWNER/ADMIN` 可冻结/解冻 | 保留日常治理能力,同时不扩大归档权限 | +| 错误暴露策略 | 归档空间对非成员公开访问按不可见处理 | 符合软归档“对外隐藏”语义 | + +## Tech Stack(沿用现有实现) + +- Backend: Spring Boot 3.x + JDK 21 + Spring Data JPA + Spring Security +- Frontend: React 19 + TypeScript + TanStack Query + TanStack Router +- Governance/Audit: 复用 `AuditLogService` + +--- + +## 1. 背景与问题 + +现有设计与实现已经具备 namespace 的基础模型、成员角色和审核边界,但仍存在以下缺口: + +1. 缺少 namespace 状态管理接口,`FROZEN / ARCHIVED` 仅停留在领域枚举层 +2. 公开空间列表与“我的命名空间”复用同一查询接口,无法呈现管理态空间 +3. 发布、审核、提升等写操作尚未统一受 namespace 状态约束 +4. 前端成员管理和治理交互处于禁用或缺失状态 +5. `@global` 的“内置只读”定位尚未在业务接口层被系统化约束 + +本设计目标是把 namespace 从“基础协作对象”提升为“完整治理对象”。 + +## 2. 目标与非目标 + +### 2.1 目标 + +- 补齐团队命名空间状态管理:冻结、解冻、归档、恢复 +- 明确 `@global` 为不可变系统空间 +- 拆分公开读模型和管理台读模型 +- 统一 namespace 状态对发布、审核、提升、公开可见性的影响 +- 补齐管理台页面交互与状态提示 +- 为状态变更增加审计记录和稳定错误语义 + +### 2.2 非目标 + +- 不新增“删除命名空间”能力 +- 不重构 skill 生命周期模型 +- 不引入新的平台后台审批流 +- 不改变现有 namespace 基础数据结构 + +## 3. 生命周期模型 + +### 3.1 命名空间类型边界 + +#### GLOBAL + +- 代表系统内置公共空间(`@global`) +- 只允许读取 +- 不允许更新基础信息 +- 不允许成员增删改 +- 不允许冻结、解冻、归档、恢复 +- 不允许转让所有权 + +#### TEAM + +- 普通团队协作空间 +- 支持完整生命周期治理 + +### 3.2 状态机 + +仅 `TEAM` 类型可发生以下流转: + +```text +ACTIVE -> FROZEN +FROZEN -> ACTIVE +ACTIVE -> ARCHIVED +FROZEN -> ARCHIVED +ARCHIVED -> ACTIVE +``` + +不支持以下流转: + +- `ARCHIVED -> FROZEN` +- 任意对 `GLOBAL` 类型的状态变更 + +### 3.3 状态语义 + +#### ACTIVE + +- 公开可见 +- 成员可管理 +- 可发布、可审核、可提升 + +#### FROZEN + +- 只读态 +- 公开内容仍可浏览和下载 +- 成员仍可查看空间详情、成员列表、审核列表 +- 禁止发布新版本 +- 禁止审核操作 +- 禁止发起提升 +- 禁止编辑命名空间信息 +- 禁止成员增删改 +- 禁止所有权转移 + +#### ARCHIVED + +- 软归档 +- 公开列表、公开搜索、公开详情默认隐藏 +- 普通用户不可下载 +- 命名空间成员仍可在管理台看到该空间 +- 除恢复外,禁止所有写操作 +- 恢复后回到 `ACTIVE` + +## 4. 权限矩阵 + +### 4.1 团队空间角色权限 + +| 操作 | OWNER | ADMIN | MEMBER | +|------|-------|-------|--------| +| 编辑空间基础信息 | `ACTIVE` 可 | `ACTIVE` 可 | 不可 | +| 添加/移除成员 | `ACTIVE` 可 | `ACTIVE` 可 | 不可 | +| 修改成员角色 | `ACTIVE` 可 | `ACTIVE` 可 | 不可 | +| 转让所有权 | `ACTIVE` 可 | 不可 | 不可 | +| 冻结 | 可 | 可 | 不可 | +| 解冻 | 可 | 可 | 不可 | +| 归档 | 可 | 不可 | 不可 | +| 恢复 | 可 | 不可 | 不可 | + +### 4.2 全局空间权限 + +`@global` 不接受任何业务写操作。无论调用者拥有哪些平台角色或 namespace 角色,都返回“系统内置命名空间不可修改”错误。 + +## 5. 后端架构设计 + +### 5.1 服务拆分 + +建议新增 `NamespaceGovernanceService`,负责所有 namespace 生命周期变更: + +- `freezeNamespace` +- `unfreezeNamespace` +- `archiveNamespace` +- `restoreNamespace` + +现有服务职责调整如下: + +- `NamespaceService` + - 创建命名空间 + - 查询 namespace + - 更新基础信息 + - 只保留基础管理员校验 +- `NamespaceMemberService` + - 成员增删改 + - 所有权转移 +- `NamespaceGovernanceService` + - 生命周期状态流转 + - `@global` 只读校验 + - 状态合法性校验 + - 审计记录 + +建议补充 `NamespaceAccessPolicy` 或同级帮助类,集中回答以下问题: + +- 当前 namespace 是否允许编辑 +- 是否允许成员管理 +- 是否允许发布 +- 是否允许审核 +- 是否允许提升 +- 是否允许公开访问 + +### 5.2 控制器设计 + +现有 [`NamespaceController`](/Users/yunzhi/Documents/skillhub/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/portal/NamespaceController.java) 增加以下端点: + +```text +GET /api/v1/me/namespaces +POST /api/v1/namespaces/{slug}/freeze +POST /api/v1/namespaces/{slug}/unfreeze +POST /api/v1/namespaces/{slug}/archive +POST /api/v1/namespaces/{slug}/restore +``` + +Web 别名同步开放在 `/api/web/...`。 + +### 5.3 公开视图与管理视图拆分 + +#### 公开视图 + +- `GET /api/v1/namespaces` + - 仅返回 `ACTIVE` namespace +- `GET /api/v1/namespaces/{slug}` + - 匿名或普通公开访问仅可读取 `ACTIVE` + - `ARCHIVED` 对非成员按不可见处理 + +#### 管理视图 + +- `GET /api/v1/me/namespaces` + - 返回当前用户所属 namespace + - 包含 `ACTIVE / FROZEN / ARCHIVED` + - 用于“我的命名空间”页面 + +这是本次设计的关键修正:当前前端“我的命名空间”错误复用了公开 `/namespaces`,必须改为管理视图接口。 + +## 6. 跨模块业务约束 + +### 6.1 发布链路 + +在 [`SkillPublishService`](/Users/yunzhi/Documents/skillhub/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/service/SkillPublishService.java) 中增加 namespace 状态校验: + +- `FROZEN`:拒绝发布新版本 +- `ARCHIVED`:拒绝发布新版本 + +错误语义建议区分: + +- `namespace.frozen` +- `namespace.archived` + +### 6.2 审核链路 + +审核相关写操作在 namespace 非 `ACTIVE` 时全部拒绝: + +- 提交审核 +- 审核通过 +- 审核拒绝 +- 撤回提审后再次提审 + +审核列表是否可读: + +- `FROZEN`:可读,不可写 +- `ARCHIVED`:成员可读,不可写 + +### 6.3 提升链路 + +`PromotionController` 发起提升时增加 namespace 状态校验: + +- `FROZEN`:拒绝发起 +- `ARCHIVED`:拒绝发起 + +### 6.4 公开可见性 + +#### namespace 层 + +- 公开列表只显示 `ACTIVE` +- 归档空间不进入公开目录 + +#### skill 层 + +- 若所属 namespace 为 `ARCHIVED`,公开搜索和公开详情页不再暴露该 skill +- 若所属 namespace 为 `FROZEN`,skill 仍可公开浏览和下载 + +## 7. 前端交互设计 + +涉及页面: + +- [`web/src/pages/dashboard/my-namespaces.tsx`](/Users/yunzhi/Documents/skillhub/web/src/pages/dashboard/my-namespaces.tsx) +- [`web/src/pages/dashboard/namespace-members.tsx`](/Users/yunzhi/Documents/skillhub/web/src/pages/dashboard/namespace-members.tsx) +- [`web/src/pages/dashboard/namespace-reviews.tsx`](/Users/yunzhi/Documents/skillhub/web/src/pages/dashboard/namespace-reviews.tsx) +- [`web/src/features/namespace/namespace-header.tsx`](/Users/yunzhi/Documents/skillhub/web/src/features/namespace/namespace-header.tsx) + +### 7.1 我的命名空间 + +- 数据源切换为 `GET /api/web/me/namespaces` +- 卡片展示 status badge +- 团队空间显示治理操作入口 +- `@global` 显示“系统内置,只读”提示 + +按钮可见性: + +- `OWNER` + - `ACTIVE`: 冻结、归档 + - `FROZEN`: 解冻、归档 + - `ARCHIVED`: 恢复 +- `ADMIN` + - `ACTIVE`: 冻结 + - `FROZEN`: 解冻 + - `ARCHIVED`: 无治理按钮 +- `MEMBER` + - 无治理按钮 + +### 7.2 成员管理页 + +- `ACTIVE`:允许添加成员、改角色、移除成员 +- `FROZEN / ARCHIVED`:列表仍可读,但操作按钮禁用 +- 页面顶部展示只读状态说明 + +### 7.3 审核页 + +- `ACTIVE`:正常审核 +- `FROZEN / ARCHIVED`:列表可读,审核按钮禁用 +- 页面顶部展示“当前命名空间不可处理审核任务” + +### 7.4 命名空间头部 + +[`NamespaceResponse`](/Users/yunzhi/Documents/skillhub/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/NamespaceResponse.java) 已包含 `status`,前端只需新增状态 badge 和说明文案,无需调整响应结构。 + +## 8. 审计与错误语义 + +### 8.1 审计动作 + +复用 `AuditLogService`,新增以下 action: + +- `FREEZE_NAMESPACE` +- `UNFREEZE_NAMESPACE` +- `ARCHIVE_NAMESPACE` +- `RESTORE_NAMESPACE` + +审计对象: + +- resourceType: `NAMESPACE` +- resourceId: namespace.id + +建议 detail 中记录: + +- `slug` +- `fromStatus` +- `toStatus` +- `reason`(可选) + +### 8.2 错误语义 + +建议统一以下错误类别: + +- `error.namespace.system.immutable` + - 对 `@global` 发起任意写操作 +- `error.namespace.state.transition.invalid` + - 非法状态流转 +- `error.namespace.frozen` + - 冻结态下执行写操作 +- `error.namespace.archived` + - 归档态下执行写操作或公开访问受限资源 + +公开访问归档空间时,对非成员优先按“不可见”处理,而不是显式暴露“已归档”。 + +## 9. 数据与接口兼容性 + +### 9.1 数据层 + +- 现有 `namespace.status` 字段已存在,无需迁移 +- 现有 `NamespaceResponse` 已带 `status` 字段,无需扩展 DTO + +### 9.2 接口层 + +- 保留现有公开 `/namespaces` +- 新增 `/me/namespaces` 供管理台使用 +- 现有前端查询需要切换,避免继续把公开目录误用为我的空间 + +### 9.3 行为层 + +- `ARCHIVED` namespace 下的 skill 公开入口行为会收紧 +- 管理台会首次出现冻结/归档空间 + +## 10. 测试策略 + +### 10.1 后端单元测试 + +- `NamespaceGovernanceServiceTest` + - 冻结/解冻/归档/恢复合法流转 + - `@global` 不可变 + - `OWNER/ADMIN/MEMBER` 权限矩阵 +- `NamespaceServiceTest` + - 冻结/归档状态下禁止基础信息更新 +- `NamespaceMemberServiceTest` + - 冻结/归档状态下禁止成员管理和所有权转移 +- `SkillPublishServiceTest` + - `FROZEN / ARCHIVED` namespace 下发布失败 +- 审核/提升相关服务测试 + - 非 `ACTIVE` namespace 下写操作失败 + +### 10.2 控制器测试 + +- `NamespaceControllerTest` + - `GET /me/namespaces` + - `POST /freeze` + - `POST /unfreeze` + - `POST /archive` + - `POST /restore` +- 公开接口测试 + - 归档空间对匿名用户不可见 + +### 10.3 前端测试 + +- 我的命名空间状态 badge 与治理按钮可见性 +- 成员页只读态 +- 审核页只读态 +- `@global` 无治理入口 + +## 11. 实施顺序建议 + +1. 后端生命周期服务与权限矩阵 +2. 跨模块状态拦截(发布、审核、提升、公开可见性) +3. `GET /me/namespaces` 管理视图接口 +4. 前端管理台接入与状态交互 +5. 审计与文档补齐 + +## 12. 风险与取舍 + +### 风险 + +- 若只改 namespace 接口、不改 skill/search/review 约束,会产生状态语义不一致 +- 若继续复用公开 `/namespaces` 作为管理台数据源,冻结/归档空间无法被恢复 + +### 取舍 + +- 本次不增加删除能力,避免把“归档”和“删除”混淆 +- 恢复统一回到 `ACTIVE`,不保留“恢复到冻结”的复杂分支 +- `@global` 完全只读,避免未来平台和团队混用治理规则 From 1418bc484044af00197332899ff4502a661ce7f1 Mon Sep 17 00:00:00 2001 From: yun-zhi-ztl <15071461069@163.com> Date: Mon, 16 Mar 2026 11:36:04 +0800 Subject: [PATCH 02/23] docs: add namespace governance implementation plan --- .../plans/2026-03-16-namespace-governance.md | 520 ++++++++++++++++++ 1 file changed, 520 insertions(+) create mode 100644 docs/superpowers/plans/2026-03-16-namespace-governance.md diff --git a/docs/superpowers/plans/2026-03-16-namespace-governance.md b/docs/superpowers/plans/2026-03-16-namespace-governance.md new file mode 100644 index 00000000..a01fb477 --- /dev/null +++ b/docs/superpowers/plans/2026-03-16-namespace-governance.md @@ -0,0 +1,520 @@ +# Namespace Governance 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:** Build the namespace governance lifecycle end-to-end: team namespace freeze/archive/restore, immutable `@global`, split management read models, state-aware backend policies, and dashboard interactions. + +**Architecture:** Extend the existing namespace domain with a dedicated governance service and a shared access-policy helper instead of overloading `NamespaceService`. Keep public and management reads separate by adding `/me/namespaces`, then thread namespace status rules through publish/review/promotion/query/search and surface them in the React dashboard with role-aware controls. + +**Tech Stack:** Spring Boot 3.x, Spring Data JPA, Spring Security, JUnit 5, Mockito, React 19, TypeScript, TanStack Query, TanStack Router, pnpm + +--- + +**Spec:** `docs/superpowers/specs/2026-03-16-namespace-governance-design.md` + +## File Structure Mapping + +### Backend domain and portal + +- Create: `server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/namespace/NamespaceGovernanceService.java` +- Create: `server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/namespace/NamespaceAccessPolicy.java` +- Create: `server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/namespace/NamespaceGovernanceServiceTest.java` +- Create: `server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/NamespaceLifecycleRequest.java` +- Create: `server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/MyNamespaceResponse.java` +- Create: `server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/NamespacePortalControllerTest.java` +- 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/NamespaceService.java` +- Modify: `server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/namespace/NamespaceMemberService.java` +- Modify: `server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/namespace/NamespaceRepository.java` +- Modify: `server/skillhub-infra/src/main/java/com/iflytek/skillhub/infra/jpa/NamespaceJpaRepository.java` +- Modify: `server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/portal/NamespaceController.java` +- Modify: `server/skillhub-app/src/main/resources/messages.properties` +- Modify: `server/skillhub-app/src/main/resources/messages_zh.properties` + +### Cross-module state enforcement + +- Modify: `server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/service/SkillPublishService.java` +- Modify: `server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/review/ReviewService.java` +- Modify: `server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/review/PromotionService.java` +- Modify: `server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/service/SkillQueryService.java` +- Modify: `server/skillhub-app/src/main/java/com/iflytek/skillhub/service/SkillSearchAppService.java` +- Modify: `server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/skill/service/SkillPublishServiceTest.java` +- Modify: `server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/ReviewPortalControllerTest.java` +- Modify: `server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/PromotionPortalControllerTest.java` +- Modify: `server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/SkillControllerTest.java` +- Modify: `server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/SkillSearchControllerTest.java` + +### Frontend dashboard + +- Modify: `web/src/api/types.ts` +- Modify: `web/src/api/client.ts` +- Modify: `web/src/shared/hooks/use-skill-queries.ts` +- Modify: `web/src/pages/dashboard/my-namespaces.tsx` +- Modify: `web/src/pages/dashboard/namespace-members.tsx` +- Modify: `web/src/pages/dashboard/namespace-reviews.tsx` +- Modify: `web/src/features/namespace/namespace-header.tsx` +- Modify: `web/src/i18n/locales/zh.json` +- Modify: `web/src/i18n/locales/en.json` + +## Chunk 1: Namespace Governance Backend + +### Task 1: Add lifecycle policy and immutable-global guard + +**Files:** +- Create: `server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/namespace/NamespaceGovernanceService.java` +- Create: `server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/namespace/NamespaceAccessPolicy.java` +- Create: `server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/namespace/NamespaceGovernanceServiceTest.java` +- 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/NamespaceService.java` +- Modify: `server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/namespace/NamespaceMemberService.java` +- Modify: `server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/namespace/NamespaceServiceTest.java` +- Modify: `server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/namespace/NamespaceMemberServiceTest.java` + +- [ ] **Step 1: Write the failing domain tests** + +```java +@Test +void freezeNamespace_allowsAdminOnActiveTeamNamespace() { + Namespace namespace = namespace("team-a", NamespaceType.TEAM, NamespaceStatus.ACTIVE); + when(namespaceRepository.findBySlug("team-a")).thenReturn(Optional.of(namespace)); + when(namespaceMemberRepository.findByNamespaceIdAndUserId(1L, "admin-1")) + .thenReturn(Optional.of(new NamespaceMember(1L, "admin-1", NamespaceRole.ADMIN))); + + Namespace updated = governanceService.freezeNamespace("team-a", "admin-1", null, null, null); + + assertEquals(NamespaceStatus.FROZEN, updated.getStatus()); +} + +@Test +void archiveNamespace_rejectsAdminAndAllowsOnlyOwner() { ... } + +@Test +void updateNamespace_rejectsFrozenNamespace() { ... } + +@Test +void addMember_rejectsArchivedNamespace() { ... } +``` + +- [ ] **Step 2: Run the domain tests to verify they fail** + +Run: `cd server && ./mvnw -pl skillhub-domain -Dtest=NamespaceGovernanceServiceTest,NamespaceServiceTest,NamespaceMemberServiceTest test` + +Expected: FAIL because `NamespaceGovernanceService`, namespace status setters, and read-only guards do not exist yet. + +- [ ] **Step 3: Implement the lifecycle policy** + +```java +public final class NamespaceAccessPolicy { + + public boolean isSystemImmutable(Namespace namespace) { + return namespace.getType() == NamespaceType.GLOBAL; + } + + public boolean canMutateSettings(Namespace namespace) { + return namespace.getType() == NamespaceType.TEAM + && namespace.getStatus() == NamespaceStatus.ACTIVE; + } + + public boolean canArchive(Namespace namespace, NamespaceRole role) { + return namespace.getType() == NamespaceType.TEAM + && role == NamespaceRole.OWNER + && namespace.getStatus() != NamespaceStatus.ARCHIVED; + } +} +``` + +```java +public Namespace freezeNamespace(String slug, String actorUserId, String requestId, String clientIp, String userAgent) { + Namespace namespace = loadMutableNamespace(slug); + NamespaceRole role = requireRole(namespace.getId(), actorUserId); + if (role != NamespaceRole.OWNER && role != NamespaceRole.ADMIN) { + throw new DomainForbiddenException("error.namespace.lifecycle.freeze.forbidden"); + } + if (namespace.getStatus() != NamespaceStatus.ACTIVE) { + throw new DomainBadRequestException("error.namespace.state.transition.invalid"); + } + namespace.setStatus(NamespaceStatus.FROZEN); + return namespaceRepository.save(namespace); +} +``` + +- [ ] **Step 4: Re-run the domain tests and keep them green** + +Run: `cd server && ./mvnw -pl skillhub-domain -Dtest=NamespaceGovernanceServiceTest,NamespaceServiceTest,NamespaceMemberServiceTest test` + +Expected: PASS for lifecycle transitions, immutable `@global`, and read-only enforcement on settings/member operations. + +- [ ] **Step 5: Commit the domain governance changes** + +```bash +git add server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/namespace \ + server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/namespace +git commit -m "feat: add namespace lifecycle governance" +git push origin feature/project-namespace +``` + +### Task 2: Expose management read model and lifecycle APIs + +**Files:** +- Create: `server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/NamespaceLifecycleRequest.java` +- Create: `server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/MyNamespaceResponse.java` +- Create: `server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/NamespacePortalControllerTest.java` +- Modify: `server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/namespace/NamespaceRepository.java` +- Modify: `server/skillhub-infra/src/main/java/com/iflytek/skillhub/infra/jpa/NamespaceJpaRepository.java` +- Modify: `server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/portal/NamespaceController.java` +- Modify: `server/skillhub-app/src/main/resources/messages.properties` +- Modify: `server/skillhub-app/src/main/resources/messages_zh.properties` + +- [ ] **Step 1: Write the failing portal tests** + +```java +@Test +void listMyNamespaces_returnsFrozenAndArchivedNamespacesWithCurrentRole() throws Exception { + mockMvc.perform(get("/api/v1/me/namespaces").with(auth("owner-1"))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data[0].status").value("ARCHIVED")) + .andExpect(jsonPath("$.data[0].currentUserRole").value("OWNER")); +} + +@Test +void archiveNamespace_returnsUpdatedNamespace() throws Exception { + mockMvc.perform(post("/api/v1/namespaces/team-a/archive") + .with(csrf()) + .with(auth("owner-1")) + .contentType(MediaType.APPLICATION_JSON) + .content("{\"reason\":\"cleanup\"}")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.status").value("ARCHIVED")); +} +``` + +- [ ] **Step 2: Run the portal tests to verify they fail** + +Run: `cd server && ./mvnw -pl skillhub-app -Dtest=NamespacePortalControllerTest test` + +Expected: FAIL because `/me/namespaces`, lifecycle endpoints, and management DTOs do not exist. + +- [ ] **Step 3: Implement controller and DTO support** + +```java +public record MyNamespaceResponse( + Long id, + String slug, + String displayName, + NamespaceStatus status, + NamespaceType type, + NamespaceRole currentUserRole, + boolean immutable, + boolean canFreeze, + boolean canArchive, + boolean canRestore +) {} +``` + +```java +@GetMapping("/me/namespaces") +public ApiResponse> listMyNamespaces( + @RequestAttribute("userId") String userId, + @RequestAttribute(value = "userNsRoles", required = false) Map userNsRoles) { + return ok("response.success.read", + namespaceService.listMyNamespaces(userId, userNsRoles != null ? userNsRoles : Map.of())); +} +``` + +- [ ] **Step 4: Re-run the portal tests** + +Run: `cd server && ./mvnw -pl skillhub-app -Dtest=NamespacePortalControllerTest test` + +Expected: PASS with `currentUserRole`, lifecycle booleans, and updated namespace payloads serialized correctly. + +- [ ] **Step 5: Commit the portal API changes** + +```bash +git add server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/portal/NamespaceController.java \ + server/skillhub-app/src/main/java/com/iflytek/skillhub/dto \ + server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/NamespacePortalControllerTest.java \ + server/skillhub-app/src/main/resources/messages.properties \ + server/skillhub-app/src/main/resources/messages_zh.properties \ + server/skillhub-infra/src/main/java/com/iflytek/skillhub/infra/jpa/NamespaceJpaRepository.java \ + server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/namespace/NamespaceRepository.java +git commit -m "feat: add namespace management endpoints" +git push origin feature/project-namespace +``` + +## Chunk 2: State Enforcement Across Publish, Review, Promotion, and Public Reads + +### Task 3: Block write workflows when namespace is not ACTIVE + +**Files:** +- Modify: `server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/service/SkillPublishService.java` +- Modify: `server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/review/ReviewService.java` +- Modify: `server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/review/PromotionService.java` +- Modify: `server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/skill/service/SkillPublishServiceTest.java` +- Modify: `server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/ReviewPortalControllerTest.java` +- Modify: `server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/PromotionPortalControllerTest.java` + +- [ ] **Step 1: Add failing tests for frozen and archived namespaces** + +```java +@Test +void publishFromEntries_rejectsFrozenNamespace() { ... } + +@Test +void submitReview_rejectsArchivedNamespace() throws Exception { ... } + +@Test +void submitPromotion_rejectsFrozenNamespace() throws Exception { ... } +``` + +- [ ] **Step 2: Run the workflow tests to confirm the gap** + +Run: `cd server && ./mvnw -pl skillhub-domain -Dtest=SkillPublishServiceTest test && ./mvnw -pl skillhub-app -Dtest=ReviewPortalControllerTest,PromotionPortalControllerTest test` + +Expected: FAIL because the publish/review/promotion flows currently ignore namespace lifecycle state. + +- [ ] **Step 3: Implement the shared ACTIVE-state guard** + +```java +private void assertNamespaceActive(Namespace namespace, String messageKey) { + if (namespace.getStatus() == NamespaceStatus.FROZEN) { + throw new DomainBadRequestException("error.namespace.frozen", namespace.getSlug()); + } + if (namespace.getStatus() == NamespaceStatus.ARCHIVED) { + throw new DomainBadRequestException("error.namespace.archived", namespace.getSlug()); + } +} +``` + +Apply it before: +- publish package acceptance +- review submit/approve/reject/withdraw writes +- promotion submit writes + +- [ ] **Step 4: Re-run the workflow tests** + +Run: `cd server && ./mvnw -pl skillhub-domain -Dtest=SkillPublishServiceTest test && ./mvnw -pl skillhub-app -Dtest=ReviewPortalControllerTest,PromotionPortalControllerTest test` + +Expected: PASS with stable error envelopes for frozen and archived namespaces. + +- [ ] **Step 5: Commit the write-path enforcement** + +```bash +git add server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/service/SkillPublishService.java \ + server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/review/ReviewService.java \ + server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/review/PromotionService.java \ + server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/skill/service/SkillPublishServiceTest.java \ + server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/ReviewPortalControllerTest.java \ + server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/PromotionPortalControllerTest.java +git commit -m "feat: enforce namespace lifecycle on workflows" +git push origin feature/project-namespace +``` + +### Task 4: Hide archived namespaces from public skill reads and search + +**Files:** +- Modify: `server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/service/SkillQueryService.java` +- Modify: `server/skillhub-app/src/main/java/com/iflytek/skillhub/service/SkillSearchAppService.java` +- Modify: `server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/SkillControllerTest.java` +- Modify: `server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/SkillSearchControllerTest.java` + +- [ ] **Step 1: Write the failing public-read tests** + +```java +@Test +void getSkillDetail_returnsForbiddenOrNotFoundForArchivedNamespaceToAnonymousUser() throws Exception { ... } + +@Test +void search_excludesSkillsFromArchivedNamespaces() throws Exception { ... } +``` + +- [ ] **Step 2: Run the public-read tests to verify failure** + +Run: `cd server && ./mvnw -pl skillhub-app -Dtest=SkillControllerTest,SkillSearchControllerTest test` + +Expected: FAIL because archived namespace state is not filtered in skill detail or search response assembly. + +- [ ] **Step 3: Implement archived visibility filtering** + +```java +private void assertNamespaceReadable(Namespace namespace, String currentUserId, Map userNsRoles) { + boolean isMember = currentUserId != null && userNsRoles.containsKey(namespace.getId()); + if (namespace.getStatus() == NamespaceStatus.ARCHIVED && !isMember) { + throw new DomainForbiddenException("error.namespace.archived"); + } +} +``` + +In search assembly, drop matched skills whose namespace is archived unless the current user is a member: + +```java +.filter(skill -> namespaceVisible(skill.getNamespaceId(), userId, userNsRoles)) +``` + +- [ ] **Step 4: Re-run the public-read tests** + +Run: `cd server && ./mvnw -pl skillhub-app -Dtest=SkillControllerTest,SkillSearchControllerTest test` + +Expected: PASS with archived namespaces hidden from public detail/search while frozen namespaces remain visible. + +- [ ] **Step 5: Commit the public-read visibility changes** + +```bash +git add server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/service/SkillQueryService.java \ + server/skillhub-app/src/main/java/com/iflytek/skillhub/service/SkillSearchAppService.java \ + server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/SkillControllerTest.java \ + server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/SkillSearchControllerTest.java +git commit -m "feat: hide archived namespaces from public reads" +git push origin feature/project-namespace +``` + +## Chunk 3: Dashboard Integration + +### Task 5: Add management DTOs and mutations to the web client + +**Files:** +- Modify: `web/src/api/types.ts` +- Modify: `web/src/api/client.ts` +- Modify: `web/src/shared/hooks/use-skill-queries.ts` + +- [ ] **Step 1: Add the failing frontend type and query integration** + +Implement the client shape first so TypeScript fails until all consumers are updated: + +```ts +export interface ManagedNamespace extends Namespace { + currentUserRole: 'OWNER' | 'ADMIN' | 'MEMBER' + immutable: boolean + canFreeze: boolean + canArchive: boolean + canRestore: boolean +} +``` + +- [ ] **Step 2: Run frontend typecheck** + +Run: `cd web && pnpm typecheck` + +Expected: FAIL because `useMyNamespaces()` still returns the old `Namespace[]` shape and lifecycle mutations are missing. + +- [ ] **Step 3: Implement API helpers and hooks** + +```ts +async function getMyNamespaces(): Promise { + return fetchJson(`${WEB_API_PREFIX}/me/namespaces`) +} + +async function mutateNamespaceLifecycle(slug: string, action: 'freeze' | 'unfreeze' | 'archive' | 'restore', reason?: string) { + return fetchJson(`${WEB_API_PREFIX}/namespaces/${slug}/${action}`, { + method: 'POST', + headers: getCsrfHeaders({ 'Content-Type': 'application/json' }), + body: JSON.stringify(reason ? { reason } : {}), + }) +} +``` + +- [ ] **Step 4: Re-run frontend typecheck** + +Run: `cd web && pnpm typecheck` + +Expected: PASS for the API layer, even though UI pages still need updates in the next task. + +- [ ] **Step 5: Commit the client-layer changes** + +```bash +git add web/src/api/types.ts web/src/api/client.ts web/src/shared/hooks/use-skill-queries.ts +git commit -m "feat: add namespace governance client hooks" +git push origin feature/project-namespace +``` + +### Task 6: Enable namespace governance in dashboard pages + +**Files:** +- Modify: `web/src/pages/dashboard/my-namespaces.tsx` +- Modify: `web/src/pages/dashboard/namespace-members.tsx` +- Modify: `web/src/pages/dashboard/namespace-reviews.tsx` +- Modify: `web/src/features/namespace/namespace-header.tsx` +- Modify: `web/src/i18n/locales/zh.json` +- Modify: `web/src/i18n/locales/en.json` + +- [ ] **Step 1: Update the pages to fail fast on missing state fields** + +Render status pills and lifecycle buttons from the new managed response shape so lint/typecheck catch any missing branch: + +```tsx +{namespace.status === 'FROZEN' ? {t('namespace.statusFrozen')} : null} +{namespace.canArchive ? : null} +``` + +- [ ] **Step 2: Run frontend validation to capture incomplete UI wiring** + +Run: `cd web && pnpm lint && pnpm typecheck` + +Expected: FAIL until the pages, translations, and mutation invalidation logic are updated consistently. + +- [ ] **Step 3: Implement the dashboard behavior** + +Apply these rules: +- `my-namespaces`: show status badge, immutable `@global` hint, role-aware lifecycle buttons +- `namespace-members`: disable add/remove/role actions when status is `FROZEN` or `ARCHIVED` +- `namespace-reviews`: keep lists visible but disable review actions when namespace is not `ACTIVE` +- `namespace-header`: show namespace status and short governance hint + +Suggested UI snippet: + +```tsx +const readOnly = namespace.status !== 'ACTIVE' || namespace.immutable + +{namespace.canFreeze ? : null} +``` + +- [ ] **Step 4: Re-run frontend validation** + +Run: `cd web && pnpm lint && pnpm typecheck` + +Expected: PASS with no TypeScript or ESLint regressions. + +- [ ] **Step 5: Commit the dashboard integration** + +```bash +git add web/src/pages/dashboard/my-namespaces.tsx \ + web/src/pages/dashboard/namespace-members.tsx \ + web/src/pages/dashboard/namespace-reviews.tsx \ + web/src/features/namespace/namespace-header.tsx \ + web/src/i18n/locales/zh.json \ + web/src/i18n/locales/en.json +git commit -m "feat: add namespace governance dashboard" +git push origin feature/project-namespace +``` + +## Final Verification + +- [ ] Run backend targeted verification: + +```bash +cd server +./mvnw -pl skillhub-domain -Dtest=NamespaceGovernanceServiceTest,NamespaceServiceTest,NamespaceMemberServiceTest,SkillPublishServiceTest test +./mvnw -pl skillhub-app -Dtest=NamespacePortalControllerTest,ReviewPortalControllerTest,PromotionPortalControllerTest,SkillControllerTest,SkillSearchControllerTest test +``` + +- [ ] Run frontend verification: + +```bash +cd web +pnpm lint +pnpm typecheck +``` + +- [ ] Run workspace status check: + +```bash +git status --short +git log --oneline -n 5 +``` + +- [ ] Push final branch state: + +```bash +git push origin feature/project-namespace +``` + +Plan complete and saved to `docs/superpowers/plans/2026-03-16-namespace-governance.md`. Ready to execute? From 7501bb3cf28a8db7a175910093d47354f93b60c5 Mon Sep 17 00:00:00 2001 From: yun-zhi-ztl <15071461069@163.com> Date: Mon, 16 Mar 2026 11:42:17 +0800 Subject: [PATCH 03/23] feat: add namespace lifecycle governance --- .../skillhub/domain/namespace/Namespace.java | 1 + .../namespace/NamespaceAccessPolicy.java | 48 ++++++ .../namespace/NamespaceGovernanceService.java | 142 ++++++++++++++++++ .../namespace/NamespaceMemberService.java | 23 ++- .../domain/namespace/NamespaceService.java | 20 ++- .../NamespaceGovernanceServiceTest.java | 120 +++++++++++++++ .../namespace/NamespaceMemberServiceTest.java | 80 ++++++++++ .../namespace/NamespaceServiceTest.java | 36 +++++ 8 files changed, 468 insertions(+), 2 deletions(-) create mode 100644 server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/namespace/NamespaceAccessPolicy.java create mode 100644 server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/namespace/NamespaceGovernanceService.java create mode 100644 server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/namespace/NamespaceGovernanceServiceTest.java diff --git a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/namespace/Namespace.java b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/namespace/Namespace.java index 55073de8..b480cea1 100644 --- a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/namespace/Namespace.java +++ b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/namespace/Namespace.java @@ -65,6 +65,7 @@ public class Namespace { public String getDescription() { return description; } public void setDescription(String description) { this.description = description; } public NamespaceStatus getStatus() { return status; } + public void setStatus(NamespaceStatus status) { this.status = status; } public NamespaceType getType() { return type; } public void setType(NamespaceType type) { this.type = type; } public String getAvatarUrl() { return avatarUrl; } diff --git a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/namespace/NamespaceAccessPolicy.java b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/namespace/NamespaceAccessPolicy.java new file mode 100644 index 00000000..d798a7d5 --- /dev/null +++ b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/namespace/NamespaceAccessPolicy.java @@ -0,0 +1,48 @@ +package com.iflytek.skillhub.domain.namespace; + +import org.springframework.stereotype.Component; + +@Component +public class NamespaceAccessPolicy { + + public boolean isImmutable(Namespace namespace) { + return namespace.getType() == NamespaceType.GLOBAL; + } + + public boolean canMutateSettings(Namespace namespace) { + return namespace.getType() == NamespaceType.TEAM + && namespace.getStatus() == NamespaceStatus.ACTIVE; + } + + public boolean canManageMembers(Namespace namespace) { + return canMutateSettings(namespace); + } + + public boolean canTransferOwnership(Namespace namespace) { + return canMutateSettings(namespace); + } + + public boolean canFreeze(Namespace namespace, NamespaceRole role) { + return namespace.getType() == NamespaceType.TEAM + && namespace.getStatus() == NamespaceStatus.ACTIVE + && (role == NamespaceRole.OWNER || role == NamespaceRole.ADMIN); + } + + public boolean canUnfreeze(Namespace namespace, NamespaceRole role) { + return namespace.getType() == NamespaceType.TEAM + && namespace.getStatus() == NamespaceStatus.FROZEN + && (role == NamespaceRole.OWNER || role == NamespaceRole.ADMIN); + } + + public boolean canArchive(Namespace namespace, NamespaceRole role) { + return namespace.getType() == NamespaceType.TEAM + && namespace.getStatus() != NamespaceStatus.ARCHIVED + && role == NamespaceRole.OWNER; + } + + public boolean canRestore(Namespace namespace, NamespaceRole role) { + return namespace.getType() == NamespaceType.TEAM + && namespace.getStatus() == NamespaceStatus.ARCHIVED + && role == NamespaceRole.OWNER; + } +} diff --git a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/namespace/NamespaceGovernanceService.java b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/namespace/NamespaceGovernanceService.java new file mode 100644 index 00000000..dda4574b --- /dev/null +++ b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/namespace/NamespaceGovernanceService.java @@ -0,0 +1,142 @@ +package com.iflytek.skillhub.domain.namespace; + +import com.iflytek.skillhub.domain.audit.AuditLogService; +import com.iflytek.skillhub.domain.shared.exception.DomainBadRequestException; +import com.iflytek.skillhub.domain.shared.exception.DomainForbiddenException; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +public class NamespaceGovernanceService { + + private final NamespaceRepository namespaceRepository; + private final NamespaceMemberRepository namespaceMemberRepository; + private final NamespaceAccessPolicy namespaceAccessPolicy; + private final AuditLogService auditLogService; + + public NamespaceGovernanceService(NamespaceRepository namespaceRepository, + NamespaceMemberRepository namespaceMemberRepository, + NamespaceAccessPolicy namespaceAccessPolicy, + AuditLogService auditLogService) { + this.namespaceRepository = namespaceRepository; + this.namespaceMemberRepository = namespaceMemberRepository; + this.namespaceAccessPolicy = namespaceAccessPolicy; + this.auditLogService = auditLogService; + } + + @Transactional + public Namespace freezeNamespace(String slug, + String actorUserId, + String reason, + String requestId, + String clientIp, + String userAgent) { + Namespace namespace = loadNamespaceBySlug(slug); + NamespaceRole role = requireRole(namespace.getId(), actorUserId); + if (namespace.getStatus() != NamespaceStatus.ACTIVE) { + throw new DomainBadRequestException("error.namespace.state.transition.invalid", namespace.getSlug()); + } + if (!namespaceAccessPolicy.canFreeze(namespace, role)) { + throw new DomainForbiddenException("error.namespace.lifecycle.forbidden", namespace.getSlug()); + } + namespace.setStatus(NamespaceStatus.FROZEN); + Namespace updated = namespaceRepository.save(namespace); + record("FREEZE_NAMESPACE", actorUserId, updated.getId(), requestId, clientIp, userAgent, reason); + return updated; + } + + @Transactional + public Namespace unfreezeNamespace(String slug, + String actorUserId, + String requestId, + String clientIp, + String userAgent) { + Namespace namespace = loadNamespaceBySlug(slug); + NamespaceRole role = requireRole(namespace.getId(), actorUserId); + if (namespace.getStatus() != NamespaceStatus.FROZEN) { + throw new DomainBadRequestException("error.namespace.state.transition.invalid", namespace.getSlug()); + } + if (!namespaceAccessPolicy.canUnfreeze(namespace, role)) { + throw new DomainForbiddenException("error.namespace.lifecycle.forbidden", namespace.getSlug()); + } + namespace.setStatus(NamespaceStatus.ACTIVE); + Namespace updated = namespaceRepository.save(namespace); + record("UNFREEZE_NAMESPACE", actorUserId, updated.getId(), requestId, clientIp, userAgent, null); + return updated; + } + + @Transactional + public Namespace archiveNamespace(String slug, + String actorUserId, + String reason, + String requestId, + String clientIp, + String userAgent) { + Namespace namespace = loadNamespaceBySlug(slug); + NamespaceRole role = requireRole(namespace.getId(), actorUserId); + if (namespace.getStatus() == NamespaceStatus.ARCHIVED) { + throw new DomainBadRequestException("error.namespace.state.transition.invalid", namespace.getSlug()); + } + if (!namespaceAccessPolicy.canArchive(namespace, role)) { + throw new DomainForbiddenException("error.namespace.lifecycle.forbidden", namespace.getSlug()); + } + namespace.setStatus(NamespaceStatus.ARCHIVED); + Namespace updated = namespaceRepository.save(namespace); + record("ARCHIVE_NAMESPACE", actorUserId, updated.getId(), requestId, clientIp, userAgent, reason); + return updated; + } + + @Transactional + public Namespace restoreNamespace(String slug, + String actorUserId, + String requestId, + String clientIp, + String userAgent) { + Namespace namespace = loadNamespaceBySlug(slug); + NamespaceRole role = requireRole(namespace.getId(), actorUserId); + if (namespace.getStatus() != NamespaceStatus.ARCHIVED) { + throw new DomainBadRequestException("error.namespace.state.transition.invalid", namespace.getSlug()); + } + if (!namespaceAccessPolicy.canRestore(namespace, role)) { + throw new DomainForbiddenException("error.namespace.lifecycle.forbidden", namespace.getSlug()); + } + namespace.setStatus(NamespaceStatus.ACTIVE); + Namespace updated = namespaceRepository.save(namespace); + record("RESTORE_NAMESPACE", actorUserId, updated.getId(), requestId, clientIp, userAgent, null); + return updated; + } + + private Namespace loadNamespaceBySlug(String slug) { + Namespace namespace = namespaceRepository.findBySlug(slug) + .orElseThrow(() -> new DomainBadRequestException("error.namespace.slug.notFound", slug)); + if (namespaceAccessPolicy.isImmutable(namespace)) { + throw new DomainBadRequestException("error.namespace.system.immutable", slug); + } + return namespace; + } + + private NamespaceRole requireRole(Long namespaceId, String userId) { + return namespaceMemberRepository.findByNamespaceIdAndUserId(namespaceId, userId) + .map(NamespaceMember::getRole) + .orElseThrow(() -> new DomainForbiddenException("error.namespace.membership.required")); + } + + private void record(String action, + String actorUserId, + Long namespaceId, + String requestId, + String clientIp, + String userAgent, + String reason) { + auditLogService.record( + actorUserId, + action, + "NAMESPACE", + namespaceId, + requestId, + clientIp, + userAgent, + reason == null || reason.isBlank() ? null : "{\"reason\":\"" + reason.replace("\"", "\\\"") + "\"}" + ); + } +} diff --git a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/namespace/NamespaceMemberService.java b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/namespace/NamespaceMemberService.java index 11b55a83..82c1c068 100644 --- a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/namespace/NamespaceMemberService.java +++ b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/namespace/NamespaceMemberService.java @@ -13,15 +13,19 @@ public class NamespaceMemberService { private final NamespaceMemberRepository namespaceMemberRepository; private final NamespaceService namespaceService; + private final NamespaceAccessPolicy namespaceAccessPolicy; public NamespaceMemberService(NamespaceMemberRepository namespaceMemberRepository, - NamespaceService namespaceService) { + NamespaceService namespaceService, + NamespaceAccessPolicy namespaceAccessPolicy) { this.namespaceMemberRepository = namespaceMemberRepository; this.namespaceService = namespaceService; + this.namespaceAccessPolicy = namespaceAccessPolicy; } @Transactional public NamespaceMember addMember(Long namespaceId, String userId, NamespaceRole role, String operatorUserId) { + assertMemberMutationAllowed(namespaceId); namespaceService.assertAdminOrOwner(namespaceId, operatorUserId); if (role == NamespaceRole.OWNER) { @@ -38,6 +42,7 @@ public class NamespaceMemberService { @Transactional public void removeMember(Long namespaceId, String userId, String operatorUserId) { + assertMemberMutationAllowed(namespaceId); namespaceService.assertAdminOrOwner(namespaceId, operatorUserId); NamespaceMember member = namespaceMemberRepository.findByNamespaceIdAndUserId(namespaceId, userId) @@ -52,6 +57,7 @@ public class NamespaceMemberService { @Transactional public NamespaceMember updateMemberRole(Long namespaceId, String userId, NamespaceRole newRole, String operatorUserId) { + assertMemberMutationAllowed(namespaceId); namespaceService.assertAdminOrOwner(namespaceId, operatorUserId); if (newRole == NamespaceRole.OWNER) { @@ -67,6 +73,11 @@ public class NamespaceMemberService { @Transactional public void transferOwnership(Long namespaceId, String currentOwnerId, String newOwnerId) { + Namespace namespace = namespaceService.getNamespace(namespaceId); + if (!namespaceAccessPolicy.canTransferOwnership(namespace)) { + throw new DomainBadRequestException("error.namespace.readonly", namespace.getSlug()); + } + NamespaceMember currentOwner = namespaceMemberRepository.findByNamespaceIdAndUserId(namespaceId, currentOwnerId) .orElseThrow(() -> new DomainBadRequestException("error.namespace.owner.current.notFound")); @@ -92,4 +103,14 @@ public class NamespaceMemberService { public Page listMembers(Long namespaceId, Pageable pageable) { return namespaceMemberRepository.findByNamespaceId(namespaceId, pageable); } + + private void assertMemberMutationAllowed(Long namespaceId) { + Namespace namespace = namespaceService.getNamespace(namespaceId); + if (!namespaceAccessPolicy.canManageMembers(namespace)) { + if (namespaceAccessPolicy.isImmutable(namespace)) { + throw new DomainBadRequestException("error.namespace.system.immutable", namespace.getSlug()); + } + throw new DomainBadRequestException("error.namespace.readonly", namespace.getSlug()); + } + } } diff --git a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/namespace/NamespaceService.java b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/namespace/NamespaceService.java index 6062b28b..5eb62521 100644 --- a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/namespace/NamespaceService.java +++ b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/namespace/NamespaceService.java @@ -10,11 +10,14 @@ public class NamespaceService { private final NamespaceRepository namespaceRepository; private final NamespaceMemberRepository namespaceMemberRepository; + private final NamespaceAccessPolicy namespaceAccessPolicy; public NamespaceService(NamespaceRepository namespaceRepository, - NamespaceMemberRepository namespaceMemberRepository) { + NamespaceMemberRepository namespaceMemberRepository, + NamespaceAccessPolicy namespaceAccessPolicy) { this.namespaceRepository = namespaceRepository; this.namespaceMemberRepository = namespaceMemberRepository; + this.namespaceAccessPolicy = namespaceAccessPolicy; } @Transactional @@ -42,6 +45,7 @@ public class NamespaceService { Namespace namespace = namespaceRepository.findById(namespaceId) .orElseThrow(() -> new DomainBadRequestException("error.namespace.id.notFound", namespaceId)); assertAdminOrOwner(namespaceId, operatorUserId); + assertMutable(namespace); if (displayName != null) { namespace.setDisplayName(displayName); @@ -61,6 +65,11 @@ public class NamespaceService { .orElseThrow(() -> new DomainBadRequestException("error.namespace.slug.notFound", slug)); } + public Namespace getNamespace(Long namespaceId) { + return namespaceRepository.findById(namespaceId) + .orElseThrow(() -> new DomainBadRequestException("error.namespace.id.notFound", namespaceId)); + } + void assertAdminOrOwner(Long namespaceId, String userId) { NamespaceRole role = namespaceMemberRepository.findByNamespaceIdAndUserId(namespaceId, userId) .map(NamespaceMember::getRole) @@ -69,4 +78,13 @@ public class NamespaceService { throw new DomainForbiddenException("error.namespace.admin.required"); } } + + void assertMutable(Namespace namespace) { + if (namespaceAccessPolicy.isImmutable(namespace)) { + throw new DomainBadRequestException("error.namespace.system.immutable", namespace.getSlug()); + } + if (!namespaceAccessPolicy.canMutateSettings(namespace)) { + throw new DomainBadRequestException("error.namespace.readonly", namespace.getSlug()); + } + } } diff --git a/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/namespace/NamespaceGovernanceServiceTest.java b/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/namespace/NamespaceGovernanceServiceTest.java new file mode 100644 index 00000000..65290270 --- /dev/null +++ b/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/namespace/NamespaceGovernanceServiceTest.java @@ -0,0 +1,120 @@ +package com.iflytek.skillhub.domain.namespace; + +import com.iflytek.skillhub.domain.audit.AuditLogService; +import com.iflytek.skillhub.domain.shared.exception.DomainBadRequestException; +import com.iflytek.skillhub.domain.shared.exception.DomainForbiddenException; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import java.util.Optional; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +class NamespaceGovernanceServiceTest { + + @Mock + private NamespaceRepository namespaceRepository; + + @Mock + private NamespaceMemberRepository namespaceMemberRepository; + + @Mock + private NamespaceAccessPolicy namespaceAccessPolicy; + + @Mock + private AuditLogService auditLogService; + + @InjectMocks + private NamespaceGovernanceService governanceService; + + @Test + void freezeNamespace_allowsAdminOnActiveTeamNamespace() { + Namespace namespace = namespace(1L, "team-a", NamespaceType.TEAM, NamespaceStatus.ACTIVE); + when(namespaceRepository.findBySlug("team-a")).thenReturn(Optional.of(namespace)); + when(namespaceMemberRepository.findByNamespaceIdAndUserId(1L, "admin-1")) + .thenReturn(Optional.of(new NamespaceMember(1L, "admin-1", NamespaceRole.ADMIN))); + when(namespaceAccessPolicy.isImmutable(namespace)).thenReturn(false); + when(namespaceAccessPolicy.canFreeze(namespace, NamespaceRole.ADMIN)).thenReturn(true); + when(namespaceRepository.save(namespace)).thenReturn(namespace); + + Namespace updated = governanceService.freezeNamespace("team-a", "admin-1", null, null, null, null); + + assertEquals(NamespaceStatus.FROZEN, updated.getStatus()); + verify(namespaceRepository).save(namespace); + } + + @Test + void archiveNamespace_rejectsAdminAndAllowsOnlyOwner() { + Namespace namespace = namespace(1L, "team-a", NamespaceType.TEAM, NamespaceStatus.ACTIVE); + when(namespaceRepository.findBySlug("team-a")).thenReturn(Optional.of(namespace)); + when(namespaceMemberRepository.findByNamespaceIdAndUserId(1L, "admin-1")) + .thenReturn(Optional.of(new NamespaceMember(1L, "admin-1", NamespaceRole.ADMIN))); + when(namespaceAccessPolicy.isImmutable(namespace)).thenReturn(false); + when(namespaceAccessPolicy.canArchive(namespace, NamespaceRole.ADMIN)).thenReturn(false); + + assertThrows(DomainForbiddenException.class, + () -> governanceService.archiveNamespace("team-a", "admin-1", "cleanup", null, null, null)); + } + + @Test + void restoreNamespace_movesArchivedNamespaceBackToActive() { + Namespace namespace = namespace(1L, "team-a", NamespaceType.TEAM, NamespaceStatus.ARCHIVED); + when(namespaceRepository.findBySlug("team-a")).thenReturn(Optional.of(namespace)); + when(namespaceMemberRepository.findByNamespaceIdAndUserId(1L, "owner-1")) + .thenReturn(Optional.of(new NamespaceMember(1L, "owner-1", NamespaceRole.OWNER))); + when(namespaceAccessPolicy.isImmutable(namespace)).thenReturn(false); + when(namespaceAccessPolicy.canRestore(namespace, NamespaceRole.OWNER)).thenReturn(true); + when(namespaceRepository.save(namespace)).thenReturn(namespace); + + Namespace updated = governanceService.restoreNamespace("team-a", "owner-1", null, null, null); + + assertEquals(NamespaceStatus.ACTIVE, updated.getStatus()); + } + + @Test + void freezeNamespace_rejectsGlobalNamespace() { + Namespace namespace = namespace(1L, "global", NamespaceType.GLOBAL, NamespaceStatus.ACTIVE); + when(namespaceRepository.findBySlug("global")).thenReturn(Optional.of(namespace)); + when(namespaceAccessPolicy.isImmutable(namespace)).thenReturn(true); + + assertThrows(DomainBadRequestException.class, + () -> governanceService.freezeNamespace("global", "admin-1", null, null, null, null)); + } + + @Test + void unfreezeNamespace_rejectsIllegalTransition() { + Namespace namespace = namespace(1L, "team-a", NamespaceType.TEAM, NamespaceStatus.ACTIVE); + when(namespaceRepository.findBySlug("team-a")).thenReturn(Optional.of(namespace)); + when(namespaceMemberRepository.findByNamespaceIdAndUserId(1L, "owner-1")) + .thenReturn(Optional.of(new NamespaceMember(1L, "owner-1", NamespaceRole.OWNER))); + when(namespaceAccessPolicy.isImmutable(namespace)).thenReturn(false); + + assertThrows(DomainBadRequestException.class, + () -> governanceService.unfreezeNamespace("team-a", "owner-1", null, null, null)); + } + + private Namespace namespace(Long id, String slug, NamespaceType type, NamespaceStatus status) { + Namespace namespace = new Namespace(slug, "Team A", "owner-1"); + setField(namespace, "id", id); + namespace.setType(type); + namespace.setStatus(status); + return namespace; + } + + private void setField(Object target, String fieldName, Object value) { + try { + java.lang.reflect.Field field = target.getClass().getDeclaredField(fieldName); + field.setAccessible(true); + field.set(target, value); + } catch (Exception e) { + throw new RuntimeException(e); + } + } +} diff --git a/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/namespace/NamespaceMemberServiceTest.java b/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/namespace/NamespaceMemberServiceTest.java index 380c221e..84d823c1 100644 --- a/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/namespace/NamespaceMemberServiceTest.java +++ b/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/namespace/NamespaceMemberServiceTest.java @@ -20,6 +20,8 @@ class NamespaceMemberServiceTest { private NamespaceMemberRepository namespaceMemberRepository; @Mock private NamespaceService namespaceService; + @Mock + private NamespaceAccessPolicy namespaceAccessPolicy; @InjectMocks private NamespaceMemberService namespaceMemberService; @@ -29,7 +31,10 @@ class NamespaceMemberServiceTest { Long namespaceId = 1L; String userId = "user-2"; NamespaceRole role = NamespaceRole.MEMBER; + Namespace namespace = new Namespace("team-a", "Team A", "owner-1"); + when(namespaceService.getNamespace(namespaceId)).thenReturn(namespace); + when(namespaceAccessPolicy.canManageMembers(namespace)).thenReturn(true); when(namespaceMemberRepository.findByNamespaceIdAndUserId(namespaceId, userId)) .thenReturn(Optional.empty()); when(namespaceMemberRepository.save(any(NamespaceMember.class))) @@ -43,12 +48,19 @@ class NamespaceMemberServiceTest { @Test void addMember_shouldThrowExceptionForOwnerRole() { + Namespace namespace = new Namespace("team-a", "Team A", "owner-1"); + when(namespaceService.getNamespace(1L)).thenReturn(namespace); + when(namespaceAccessPolicy.canManageMembers(namespace)).thenReturn(true); + assertThrows(DomainBadRequestException.class, () -> namespaceMemberService.addMember(1L, "user-2", NamespaceRole.OWNER, "user-99")); } @Test void addMember_shouldRequireAdminOrOwner() { + Namespace namespace = new Namespace("team-a", "Team A", "owner-1"); + when(namespaceService.getNamespace(1L)).thenReturn(namespace); + when(namespaceAccessPolicy.canManageMembers(namespace)).thenReturn(true); doThrow(new DomainForbiddenException("error.namespace.admin.required")).when(namespaceService).assertAdminOrOwner(1L, "user-99"); assertThrows(DomainForbiddenException.class, () -> @@ -59,6 +71,9 @@ class NamespaceMemberServiceTest { void addMember_shouldThrowExceptionWhenMemberExists() { Long namespaceId = 1L; String userId = "user-2"; + Namespace namespace = new Namespace("team-a", "Team A", "owner-1"); + when(namespaceService.getNamespace(namespaceId)).thenReturn(namespace); + when(namespaceAccessPolicy.canManageMembers(namespace)).thenReturn(true); when(namespaceMemberRepository.findByNamespaceIdAndUserId(namespaceId, userId)) .thenReturn(Optional.of(new NamespaceMember())); @@ -66,11 +81,27 @@ class NamespaceMemberServiceTest { namespaceMemberService.addMember(namespaceId, userId, NamespaceRole.MEMBER, "user-99")); } + @Test + void addMember_shouldRejectFrozenNamespace() { + Long namespaceId = 1L; + Namespace namespace = new Namespace("team-a", "Team A", "owner-1"); + namespace.setStatus(NamespaceStatus.FROZEN); + when(namespaceService.getNamespace(namespaceId)).thenReturn(namespace); + when(namespaceAccessPolicy.canManageMembers(namespace)).thenReturn(false); + when(namespaceAccessPolicy.isImmutable(namespace)).thenReturn(false); + + assertThrows(DomainBadRequestException.class, () -> + namespaceMemberService.addMember(namespaceId, "user-2", NamespaceRole.MEMBER, "user-99")); + } + @Test void removeMember_shouldThrowExceptionForOwner() { Long namespaceId = 1L; String userId = "user-2"; NamespaceMember ownerMember = new NamespaceMember(namespaceId, userId, NamespaceRole.OWNER); + Namespace namespace = new Namespace("team-a", "Team A", "owner-1"); + when(namespaceService.getNamespace(namespaceId)).thenReturn(namespace); + when(namespaceAccessPolicy.canManageMembers(namespace)).thenReturn(true); when(namespaceMemberRepository.findByNamespaceIdAndUserId(namespaceId, userId)) .thenReturn(Optional.of(ownerMember)); @@ -80,6 +111,9 @@ class NamespaceMemberServiceTest { @Test void removeMember_shouldThrowExceptionWhenMemberNotFound() { + Namespace namespace = new Namespace("team-a", "Team A", "owner-1"); + when(namespaceService.getNamespace(1L)).thenReturn(namespace); + when(namespaceAccessPolicy.canManageMembers(namespace)).thenReturn(true); when(namespaceMemberRepository.findByNamespaceIdAndUserId(1L, "user-2")) .thenReturn(Optional.empty()); @@ -87,11 +121,27 @@ class NamespaceMemberServiceTest { namespaceMemberService.removeMember(1L, "user-2", "user-99")); } + @Test + void updateMemberRole_shouldRejectArchivedNamespace() { + Long namespaceId = 1L; + Namespace namespace = new Namespace("team-a", "Team A", "owner-1"); + namespace.setStatus(NamespaceStatus.ARCHIVED); + when(namespaceService.getNamespace(namespaceId)).thenReturn(namespace); + when(namespaceAccessPolicy.canManageMembers(namespace)).thenReturn(false); + when(namespaceAccessPolicy.isImmutable(namespace)).thenReturn(false); + + assertThrows(DomainBadRequestException.class, () -> + namespaceMemberService.updateMemberRole(namespaceId, "user-2", NamespaceRole.ADMIN, "user-99")); + } + @Test void updateMemberRole_shouldUpdateRoleSuccessfully() { Long namespaceId = 1L; String userId = "user-2"; NamespaceMember member = new NamespaceMember(namespaceId, userId, NamespaceRole.MEMBER); + Namespace namespace = new Namespace("team-a", "Team A", "owner-1"); + when(namespaceService.getNamespace(namespaceId)).thenReturn(namespace); + when(namespaceAccessPolicy.canManageMembers(namespace)).thenReturn(true); when(namespaceMemberRepository.findByNamespaceIdAndUserId(namespaceId, userId)) .thenReturn(Optional.of(member)); when(namespaceMemberRepository.save(any(NamespaceMember.class))).thenReturn(member); @@ -106,6 +156,9 @@ class NamespaceMemberServiceTest { void updateMemberRole_shouldThrowExceptionForOwnerRole() { Long namespaceId = 1L; String userId = "user-2"; + Namespace namespace = new Namespace("team-a", "Team A", "owner-1"); + when(namespaceService.getNamespace(namespaceId)).thenReturn(namespace); + when(namespaceAccessPolicy.canManageMembers(namespace)).thenReturn(true); assertThrows(DomainBadRequestException.class, () -> namespaceMemberService.updateMemberRole(namespaceId, userId, NamespaceRole.OWNER, "user-99")); @@ -113,6 +166,9 @@ class NamespaceMemberServiceTest { @Test void updateMemberRole_shouldThrowExceptionWhenMemberNotFound() { + Namespace namespace = new Namespace("team-a", "Team A", "owner-1"); + when(namespaceService.getNamespace(1L)).thenReturn(namespace); + when(namespaceAccessPolicy.canManageMembers(namespace)).thenReturn(true); when(namespaceMemberRepository.findByNamespaceIdAndUserId(1L, "user-2")) .thenReturn(Optional.empty()); @@ -128,7 +184,10 @@ class NamespaceMemberServiceTest { NamespaceMember currentOwner = new NamespaceMember(namespaceId, currentOwnerId, NamespaceRole.OWNER); NamespaceMember newOwner = new NamespaceMember(namespaceId, newOwnerId, NamespaceRole.ADMIN); + Namespace namespace = new Namespace("team-a", "Team A", "owner-1"); + when(namespaceService.getNamespace(namespaceId)).thenReturn(namespace); + when(namespaceAccessPolicy.canTransferOwnership(namespace)).thenReturn(true); when(namespaceMemberRepository.findByNamespaceIdAndUserId(namespaceId, currentOwnerId)) .thenReturn(Optional.of(currentOwner)); when(namespaceMemberRepository.findByNamespaceIdAndUserId(namespaceId, newOwnerId)) @@ -143,6 +202,9 @@ class NamespaceMemberServiceTest { @Test void transferOwnership_shouldThrowExceptionWhenCurrentOwnerNotFound() { + Namespace namespace = new Namespace("team-a", "Team A", "owner-1"); + when(namespaceService.getNamespace(1L)).thenReturn(namespace); + when(namespaceAccessPolicy.canTransferOwnership(namespace)).thenReturn(true); when(namespaceMemberRepository.findByNamespaceIdAndUserId(1L, "user-2")) .thenReturn(Optional.empty()); @@ -155,6 +217,9 @@ class NamespaceMemberServiceTest { Long namespaceId = 1L; String currentOwnerId = "user-2"; NamespaceMember notOwner = new NamespaceMember(namespaceId, currentOwnerId, NamespaceRole.ADMIN); + Namespace namespace = new Namespace("team-a", "Team A", "owner-1"); + when(namespaceService.getNamespace(namespaceId)).thenReturn(namespace); + when(namespaceAccessPolicy.canTransferOwnership(namespace)).thenReturn(true); when(namespaceMemberRepository.findByNamespaceIdAndUserId(namespaceId, currentOwnerId)) .thenReturn(Optional.of(notOwner)); @@ -168,7 +233,10 @@ class NamespaceMemberServiceTest { String currentOwnerId = "user-2"; String newOwnerId = "user-3"; NamespaceMember currentOwner = new NamespaceMember(namespaceId, currentOwnerId, NamespaceRole.OWNER); + Namespace namespace = new Namespace("team-a", "Team A", "owner-1"); + when(namespaceService.getNamespace(namespaceId)).thenReturn(namespace); + when(namespaceAccessPolicy.canTransferOwnership(namespace)).thenReturn(true); when(namespaceMemberRepository.findByNamespaceIdAndUserId(namespaceId, currentOwnerId)) .thenReturn(Optional.of(currentOwner)); when(namespaceMemberRepository.findByNamespaceIdAndUserId(namespaceId, newOwnerId)) @@ -178,6 +246,18 @@ class NamespaceMemberServiceTest { namespaceMemberService.transferOwnership(namespaceId, currentOwnerId, newOwnerId)); } + @Test + void transferOwnership_shouldRejectFrozenNamespace() { + Long namespaceId = 1L; + Namespace namespace = new Namespace("team-a", "Team A", "owner-1"); + namespace.setStatus(NamespaceStatus.FROZEN); + when(namespaceService.getNamespace(namespaceId)).thenReturn(namespace); + when(namespaceAccessPolicy.canTransferOwnership(namespace)).thenReturn(false); + + assertThrows(DomainBadRequestException.class, () -> + namespaceMemberService.transferOwnership(namespaceId, "user-2", "user-3")); + } + @Test void getMemberRole_shouldReturnRole() { Long namespaceId = 1L; diff --git a/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/namespace/NamespaceServiceTest.java b/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/namespace/NamespaceServiceTest.java index ecb8d18b..327e95f7 100644 --- a/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/namespace/NamespaceServiceTest.java +++ b/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/namespace/NamespaceServiceTest.java @@ -23,6 +23,9 @@ class NamespaceServiceTest { @Mock private NamespaceMemberRepository namespaceMemberRepository; + @Mock + private NamespaceAccessPolicy namespaceAccessPolicy; + @InjectMocks private NamespaceService namespaceService; @@ -70,6 +73,8 @@ class NamespaceServiceTest { when(namespaceRepository.findById(namespaceId)).thenReturn(Optional.of(namespace)); when(namespaceMemberRepository.findByNamespaceIdAndUserId(namespaceId, operatorUserId)) .thenReturn(Optional.of(new NamespaceMember(namespaceId, operatorUserId, NamespaceRole.OWNER))); + when(namespaceAccessPolicy.isImmutable(namespace)).thenReturn(false); + when(namespaceAccessPolicy.canMutateSettings(namespace)).thenReturn(true); when(namespaceRepository.save(any(Namespace.class))).thenReturn(namespace); Namespace result = namespaceService.updateNamespace( @@ -105,6 +110,37 @@ class NamespaceServiceTest { namespaceService.updateNamespace(namespaceId, "Name", "Desc", null, operatorUserId)); } + @Test + void updateNamespace_shouldRejectFrozenNamespace() { + Long namespaceId = 1L; + String operatorUserId = "user-1"; + Namespace namespace = new Namespace("slug", "Old Name", "user-1"); + namespace.setStatus(NamespaceStatus.FROZEN); + when(namespaceRepository.findById(namespaceId)).thenReturn(Optional.of(namespace)); + when(namespaceMemberRepository.findByNamespaceIdAndUserId(namespaceId, operatorUserId)) + .thenReturn(Optional.of(new NamespaceMember(namespaceId, operatorUserId, NamespaceRole.OWNER))); + when(namespaceAccessPolicy.isImmutable(namespace)).thenReturn(false); + when(namespaceAccessPolicy.canMutateSettings(namespace)).thenReturn(false); + + assertThrows(DomainBadRequestException.class, () -> + namespaceService.updateNamespace(namespaceId, "Name", "Desc", null, operatorUserId)); + } + + @Test + void updateNamespace_shouldRejectGlobalNamespaceMutation() { + Long namespaceId = 1L; + String operatorUserId = "user-1"; + Namespace namespace = new Namespace("global", "Global", "system"); + namespace.setType(NamespaceType.GLOBAL); + when(namespaceRepository.findById(namespaceId)).thenReturn(Optional.of(namespace)); + when(namespaceMemberRepository.findByNamespaceIdAndUserId(namespaceId, operatorUserId)) + .thenReturn(Optional.of(new NamespaceMember(namespaceId, operatorUserId, NamespaceRole.OWNER))); + when(namespaceAccessPolicy.isImmutable(namespace)).thenReturn(true); + + assertThrows(DomainBadRequestException.class, () -> + namespaceService.updateNamespace(namespaceId, "Name", "Desc", null, operatorUserId)); + } + @Test void getNamespaceBySlug_shouldReturnNamespace() { String slug = "test-slug"; From 971c75c9364da739b3329bd328e1afef6d82bde0 Mon Sep 17 00:00:00 2001 From: yun-zhi-ztl <15071461069@163.com> Date: Mon, 16 Mar 2026 11:46:28 +0800 Subject: [PATCH 04/23] feat: add namespace management endpoints --- .../portal/NamespaceController.java | 106 ++++++++++++-- .../skillhub/dto/MyNamespaceResponse.java | 51 +++++++ .../dto/NamespaceLifecycleRequest.java | 8 ++ .../NamespacePortalControllerTest.java | 130 ++++++++++++++++++ 4 files changed, 286 insertions(+), 9 deletions(-) create mode 100644 server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/MyNamespaceResponse.java create mode 100644 server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/NamespaceLifecycleRequest.java create mode 100644 server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/NamespacePortalControllerTest.java diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/portal/NamespaceController.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/portal/NamespaceController.java index 5735db3f..851f99d5 100644 --- a/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/portal/NamespaceController.java +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/portal/NamespaceController.java @@ -4,44 +4,72 @@ import com.iflytek.skillhub.controller.BaseApiController; import com.iflytek.skillhub.auth.rbac.PlatformPrincipal; import com.iflytek.skillhub.domain.namespace.*; import com.iflytek.skillhub.dto.*; +import jakarta.servlet.http.HttpServletRequest; import jakarta.validation.Valid; import org.springframework.data.domain.Page; import org.springframework.data.domain.Pageable; import org.springframework.security.core.annotation.AuthenticationPrincipal; import org.springframework.web.bind.annotation.*; +import java.util.Comparator; +import java.util.List; +import java.util.Map; + @RestController -@RequestMapping({"/api/v1/namespaces", "/api/web/namespaces"}) +@RequestMapping({"/api/v1", "/api/web"}) public class NamespaceController extends BaseApiController { private final NamespaceService namespaceService; private final NamespaceMemberService namespaceMemberService; private final NamespaceRepository namespaceRepository; + private final NamespaceGovernanceService namespaceGovernanceService; + private final NamespaceAccessPolicy namespaceAccessPolicy; public NamespaceController(NamespaceService namespaceService, NamespaceMemberService namespaceMemberService, NamespaceRepository namespaceRepository, + NamespaceGovernanceService namespaceGovernanceService, + NamespaceAccessPolicy namespaceAccessPolicy, ApiResponseFactory responseFactory) { super(responseFactory); this.namespaceService = namespaceService; this.namespaceMemberService = namespaceMemberService; this.namespaceRepository = namespaceRepository; + this.namespaceGovernanceService = namespaceGovernanceService; + this.namespaceAccessPolicy = namespaceAccessPolicy; } - @GetMapping + @GetMapping("/namespaces") public ApiResponse> listNamespaces(Pageable pageable) { Page namespaces = namespaceRepository.findByStatus(NamespaceStatus.ACTIVE, pageable); PageResponse response = PageResponse.from(namespaces.map(NamespaceResponse::from)); return ok("response.success.read", response); } - @GetMapping("/{slug}") + @GetMapping("/me/namespaces") + public ApiResponse> listMyNamespaces( + @RequestAttribute("userId") String userId, + @RequestAttribute(value = "userNsRoles", required = false) Map userNsRoles) { + Map namespaceRoles = userNsRoles != null ? userNsRoles : Map.of(); + if (namespaceRoles.isEmpty()) { + return ok("response.success.read", List.of()); + } + + List response = namespaceRepository.findByIdIn(namespaceRoles.keySet().stream().toList()).stream() + .sorted(Comparator.comparing(Namespace::getSlug)) + .map(namespace -> MyNamespaceResponse.from(namespace, namespaceRoles.get(namespace.getId()), namespaceAccessPolicy)) + .toList(); + + return ok("response.success.read", response); + } + + @GetMapping("/namespaces/{slug}") public ApiResponse getNamespace(@PathVariable String slug) { Namespace namespace = namespaceService.getNamespaceBySlug(slug); return ok("response.success.read", NamespaceResponse.from(namespace)); } - @PostMapping + @PostMapping("/namespaces") public ApiResponse createNamespace( @Valid @RequestBody NamespaceRequest request, @AuthenticationPrincipal PlatformPrincipal principal) { @@ -54,7 +82,7 @@ public class NamespaceController extends BaseApiController { return ok("response.success.created", NamespaceResponse.from(namespace)); } - @PutMapping("/{slug}") + @PutMapping("/namespaces/{slug}") public ApiResponse updateNamespace( @PathVariable String slug, @RequestBody NamespaceRequest request, @@ -70,7 +98,67 @@ public class NamespaceController extends BaseApiController { return ok("response.success.updated", NamespaceResponse.from(updated)); } - @GetMapping("/{slug}/members") + @PostMapping("/namespaces/{slug}/freeze") + public ApiResponse freezeNamespace(@PathVariable String slug, + @RequestBody(required = false) NamespaceLifecycleRequest request, + @RequestAttribute("userId") String userId, + HttpServletRequest httpRequest) { + Namespace namespace = namespaceGovernanceService.freezeNamespace( + slug, + userId, + request != null ? request.reason() : null, + null, + httpRequest.getRemoteAddr(), + httpRequest.getHeader("User-Agent") + ); + return ok("response.success.updated", NamespaceResponse.from(namespace)); + } + + @PostMapping("/namespaces/{slug}/unfreeze") + public ApiResponse unfreezeNamespace(@PathVariable String slug, + @RequestAttribute("userId") String userId, + HttpServletRequest httpRequest) { + Namespace namespace = namespaceGovernanceService.unfreezeNamespace( + slug, + userId, + null, + httpRequest.getRemoteAddr(), + httpRequest.getHeader("User-Agent") + ); + return ok("response.success.updated", NamespaceResponse.from(namespace)); + } + + @PostMapping("/namespaces/{slug}/archive") + public ApiResponse archiveNamespace(@PathVariable String slug, + @RequestBody(required = false) NamespaceLifecycleRequest request, + @RequestAttribute("userId") String userId, + HttpServletRequest httpRequest) { + Namespace namespace = namespaceGovernanceService.archiveNamespace( + slug, + userId, + request != null ? request.reason() : null, + null, + httpRequest.getRemoteAddr(), + httpRequest.getHeader("User-Agent") + ); + return ok("response.success.updated", NamespaceResponse.from(namespace)); + } + + @PostMapping("/namespaces/{slug}/restore") + public ApiResponse restoreNamespace(@PathVariable String slug, + @RequestAttribute("userId") String userId, + HttpServletRequest httpRequest) { + Namespace namespace = namespaceGovernanceService.restoreNamespace( + slug, + userId, + null, + httpRequest.getRemoteAddr(), + httpRequest.getHeader("User-Agent") + ); + return ok("response.success.updated", NamespaceResponse.from(namespace)); + } + + @GetMapping("/namespaces/{slug}/members") public ApiResponse> listMembers(@PathVariable String slug, Pageable pageable) { Namespace namespace = namespaceService.getNamespaceBySlug(slug); Page members = namespaceMemberService.listMembers(namespace.getId(), pageable); @@ -78,7 +166,7 @@ public class NamespaceController extends BaseApiController { return ok("response.success.read", response); } - @PostMapping("/{slug}/members") + @PostMapping("/namespaces/{slug}/members") public ApiResponse addMember( @PathVariable String slug, @Valid @RequestBody MemberRequest request, @@ -93,7 +181,7 @@ public class NamespaceController extends BaseApiController { return ok("response.success.created", MemberResponse.from(member)); } - @DeleteMapping("/{slug}/members/{userId}") + @DeleteMapping("/namespaces/{slug}/members/{userId}") public ApiResponse removeMember( @PathVariable String slug, @PathVariable("userId") String memberUserId, @@ -103,7 +191,7 @@ public class NamespaceController extends BaseApiController { return ok("response.success.deleted", new MessageResponse("Member removed successfully")); } - @PutMapping("/{slug}/members/{userId}/role") + @PutMapping("/namespaces/{slug}/members/{userId}/role") public ApiResponse updateMemberRole( @PathVariable String slug, @PathVariable String userId, diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/MyNamespaceResponse.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/MyNamespaceResponse.java new file mode 100644 index 00000000..5f29c8c0 --- /dev/null +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/MyNamespaceResponse.java @@ -0,0 +1,51 @@ +package com.iflytek.skillhub.dto; + +import com.iflytek.skillhub.domain.namespace.Namespace; +import com.iflytek.skillhub.domain.namespace.NamespaceAccessPolicy; +import com.iflytek.skillhub.domain.namespace.NamespaceRole; +import com.iflytek.skillhub.domain.namespace.NamespaceStatus; +import com.iflytek.skillhub.domain.namespace.NamespaceType; + +import java.time.LocalDateTime; + +public record MyNamespaceResponse( + Long id, + String slug, + String displayName, + NamespaceStatus status, + String description, + NamespaceType type, + String avatarUrl, + String createdBy, + LocalDateTime createdAt, + LocalDateTime updatedAt, + NamespaceRole currentUserRole, + boolean immutable, + boolean canFreeze, + boolean canUnfreeze, + boolean canArchive, + boolean canRestore +) { + public static MyNamespaceResponse from(Namespace namespace, + NamespaceRole currentUserRole, + NamespaceAccessPolicy accessPolicy) { + return new MyNamespaceResponse( + namespace.getId(), + namespace.getSlug(), + namespace.getDisplayName(), + namespace.getStatus(), + namespace.getDescription(), + namespace.getType(), + namespace.getAvatarUrl(), + namespace.getCreatedBy(), + namespace.getCreatedAt(), + namespace.getUpdatedAt(), + currentUserRole, + accessPolicy.isImmutable(namespace), + accessPolicy.canFreeze(namespace, currentUserRole), + accessPolicy.canUnfreeze(namespace, currentUserRole), + accessPolicy.canArchive(namespace, currentUserRole), + accessPolicy.canRestore(namespace, currentUserRole) + ); + } +} diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/NamespaceLifecycleRequest.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/NamespaceLifecycleRequest.java new file mode 100644 index 00000000..16c07882 --- /dev/null +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/NamespaceLifecycleRequest.java @@ -0,0 +1,8 @@ +package com.iflytek.skillhub.dto; + +import jakarta.validation.constraints.Size; + +public record NamespaceLifecycleRequest( + @Size(max = 512, message = "{validation.namespace.description.size}") + String reason +) {} diff --git a/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/NamespacePortalControllerTest.java b/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/NamespacePortalControllerTest.java new file mode 100644 index 00000000..7a86d0ae --- /dev/null +++ b/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/NamespacePortalControllerTest.java @@ -0,0 +1,130 @@ +package com.iflytek.skillhub.controller; + +import com.iflytek.skillhub.auth.device.DeviceAuthService; +import com.iflytek.skillhub.auth.rbac.PlatformPrincipal; +import com.iflytek.skillhub.domain.namespace.Namespace; +import com.iflytek.skillhub.domain.namespace.NamespaceGovernanceService; +import com.iflytek.skillhub.domain.namespace.NamespaceMemberRepository; +import com.iflytek.skillhub.domain.namespace.NamespaceRole; +import com.iflytek.skillhub.domain.namespace.NamespaceService; +import com.iflytek.skillhub.domain.namespace.NamespaceStatus; +import com.iflytek.skillhub.domain.namespace.NamespaceType; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.test.mock.mockito.MockBean; +import org.springframework.http.MediaType; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.authority.SimpleGrantedAuthority; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.test.web.servlet.request.RequestPostProcessor; + +import java.util.List; +import java.util.Map; + +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.ArgumentMatchers.nullable; +import static org.mockito.BDDMockito.given; +import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.authentication; +import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.csrf; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +@SpringBootTest +@AutoConfigureMockMvc +@ActiveProfiles("test") +class NamespacePortalControllerTest { + + @Autowired + private MockMvc mockMvc; + + @MockBean + private NamespaceService namespaceService; + + @MockBean + private NamespaceGovernanceService namespaceGovernanceService; + + @MockBean + private com.iflytek.skillhub.domain.namespace.NamespaceRepository namespaceRepository; + + @MockBean + private NamespaceMemberRepository namespaceMemberRepository; + + @MockBean + private DeviceAuthService deviceAuthService; + + @Test + void listMyNamespaces_returnsFrozenAndArchivedNamespacesWithCurrentRole() throws Exception { + Namespace namespace = namespace(1L, "team-a", NamespaceStatus.ARCHIVED, NamespaceType.TEAM); + given(namespaceRepository.findByIdIn(List.of(1L))).willReturn(List.of(namespace)); + given(namespaceMemberRepository.findByUserId("owner-1")) + .willReturn(List.of(new com.iflytek.skillhub.domain.namespace.NamespaceMember(1L, "owner-1", NamespaceRole.OWNER))); + + mockMvc.perform(get("/api/v1/me/namespaces") + .with(auth("owner-1")) + .requestAttr("userId", "owner-1")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.code").value(0)) + .andExpect(jsonPath("$.data[0].slug").value("team-a")) + .andExpect(jsonPath("$.data[0].status").value("ARCHIVED")) + .andExpect(jsonPath("$.data[0].currentUserRole").value("OWNER")); + } + + @Test + void archiveNamespace_returnsUpdatedNamespace() throws Exception { + Namespace archived = namespace(1L, "team-a", NamespaceStatus.ARCHIVED, NamespaceType.TEAM); + given(namespaceGovernanceService.archiveNamespace(eq("team-a"), eq("owner-1"), eq("cleanup"), nullable(String.class), any(), any())) + .willReturn(archived); + + mockMvc.perform(post("/api/v1/namespaces/team-a/archive") + .with(csrf()) + .with(auth("owner-1")) + .requestAttr("userId", "owner-1") + .contentType(MediaType.APPLICATION_JSON) + .content("{\"reason\":\"cleanup\"}")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.code").value(0)) + .andExpect(jsonPath("$.data.slug").value("team-a")) + .andExpect(jsonPath("$.data.status").value("ARCHIVED")); + } + + private RequestPostProcessor auth(String userId) { + PlatformPrincipal principal = new PlatformPrincipal( + userId, + userId, + userId + "@example.com", + "", + "session", + java.util.Set.of() + ); + UsernamePasswordAuthenticationToken authenticationToken = new UsernamePasswordAuthenticationToken( + principal, + null, + List.of(new SimpleGrantedAuthority("ROLE_USER")) + ); + return authentication(authenticationToken); + } + + private Namespace namespace(Long id, String slug, NamespaceStatus status, NamespaceType type) { + Namespace namespace = new Namespace(slug, "Team A", "owner-1"); + setField(namespace, "id", id); + namespace.setStatus(status); + namespace.setType(type); + return namespace; + } + + private void setField(Object target, String fieldName, Object value) { + try { + java.lang.reflect.Field field = target.getClass().getDeclaredField(fieldName); + field.setAccessible(true); + field.set(target, value); + } catch (Exception e) { + throw new RuntimeException(e); + } + } +} From fcc2df29758800b79f7f5b21b7107fe264c9c5e4 Mon Sep 17 00:00:00 2001 From: yun-zhi-ztl <15071461069@163.com> Date: Mon, 16 Mar 2026 11:57:19 +0800 Subject: [PATCH 05/23] feat: enforce namespace lifecycle on workflows --- .../domain/review/PromotionService.java | 18 ++++++ .../skillhub/domain/review/ReviewService.java | 21 +++++++ .../skill/service/SkillPublishService.java | 11 ++++ .../domain/review/PromotionServiceTest.java | 29 +++++++++ .../domain/review/ReviewServiceTest.java | 61 ++++++++++++++++++- .../service/SkillPublishServiceTest.java | 20 ++++++ 6 files changed, 159 insertions(+), 1 deletion(-) diff --git a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/review/PromotionService.java b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/review/PromotionService.java index aaf3d705..e7165d31 100644 --- a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/review/PromotionService.java +++ b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/review/PromotionService.java @@ -4,6 +4,7 @@ import com.iflytek.skillhub.domain.event.SkillPublishedEvent; import com.iflytek.skillhub.domain.namespace.Namespace; import com.iflytek.skillhub.domain.namespace.NamespaceRepository; import com.iflytek.skillhub.domain.namespace.NamespaceRole; +import com.iflytek.skillhub.domain.namespace.NamespaceStatus; import com.iflytek.skillhub.domain.namespace.NamespaceType; import com.iflytek.skillhub.domain.shared.exception.DomainBadRequestException; import com.iflytek.skillhub.domain.shared.exception.DomainForbiddenException; @@ -65,6 +66,10 @@ public class PromotionService { throw new DomainBadRequestException("promotion.version_not_published", sourceVersionId); } + Namespace sourceNamespace = namespaceRepository.findById(sourceSkill.getNamespaceId()) + .orElseThrow(() -> new DomainNotFoundException("namespace.not_found", sourceSkill.getNamespaceId())); + assertNamespaceActive(sourceNamespace); + if (!permissionChecker.canSubmitPromotion(sourceSkill, userId, userNamespaceRoles, platformRoles)) { throw new DomainForbiddenException("promotion.submit.no_permission"); } @@ -103,6 +108,10 @@ public class PromotionService { throw new DomainBadRequestException("promotion.version_not_published", sourceVersionId); } + Namespace sourceNamespace = namespaceRepository.findById(sourceSkill.getNamespaceId()) + .orElseThrow(() -> new DomainNotFoundException("namespace.not_found", sourceSkill.getNamespaceId())); + assertNamespaceActive(sourceNamespace); + if (!permissionChecker.canSubmitPromotion(sourceSkill, userId, userNamespaceRoles)) { throw new DomainForbiddenException("promotion.submit.no_permission"); } @@ -220,4 +229,13 @@ public class PromotionService { public boolean canViewPromotion(PromotionRequest request, String userId, Set platformRoles) { return permissionChecker.canViewPromotion(request, userId, platformRoles); } + + private void assertNamespaceActive(Namespace namespace) { + if (namespace.getStatus() == NamespaceStatus.FROZEN) { + throw new DomainBadRequestException("error.namespace.frozen", namespace.getSlug()); + } + if (namespace.getStatus() == NamespaceStatus.ARCHIVED) { + throw new DomainBadRequestException("error.namespace.archived", namespace.getSlug()); + } + } } diff --git a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/review/ReviewService.java b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/review/ReviewService.java index 7749622d..ef52d665 100644 --- a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/review/ReviewService.java +++ b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/review/ReviewService.java @@ -4,6 +4,7 @@ import com.fasterxml.jackson.databind.ObjectMapper; import com.iflytek.skillhub.domain.namespace.Namespace; import com.iflytek.skillhub.domain.namespace.NamespaceRepository; import com.iflytek.skillhub.domain.namespace.NamespaceRole; +import com.iflytek.skillhub.domain.namespace.NamespaceStatus; import com.iflytek.skillhub.domain.event.SkillPublishedEvent; import com.iflytek.skillhub.domain.shared.exception.DomainBadRequestException; import com.iflytek.skillhub.domain.shared.exception.DomainForbiddenException; @@ -65,6 +66,9 @@ public class ReviewService { Skill skill = skillRepository.findById(skillVersion.getSkillId()) .orElseThrow(() -> new DomainNotFoundException("skill.not_found", skillVersion.getSkillId())); + Namespace namespace = namespaceRepository.findById(skill.getNamespaceId()) + .orElseThrow(() -> new DomainNotFoundException("namespace.not_found", skill.getNamespaceId())); + assertNamespaceActive(namespace); if (!permissionChecker.canSubmitForReview(skill, userId, userNamespaceRoles, platformRoles)) { throw new DomainForbiddenException("review.submit.no_permission"); @@ -94,6 +98,9 @@ public class ReviewService { Skill skill = skillRepository.findById(skillVersion.getSkillId()) .orElseThrow(() -> new DomainNotFoundException("skill.not_found", skillVersion.getSkillId())); + Namespace namespace = namespaceRepository.findById(skill.getNamespaceId()) + .orElseThrow(() -> new DomainNotFoundException("namespace.not_found", skill.getNamespaceId())); + assertNamespaceActive(namespace); if (skillVersion.getStatus() != SkillVersionStatus.DRAFT) { throw new DomainBadRequestException("review.submit.not_draft", skillVersionId); @@ -127,6 +134,7 @@ public class ReviewService { Namespace namespace = namespaceRepository.findById(task.getNamespaceId()) .orElseThrow(() -> new DomainNotFoundException("namespace.not_found", task.getNamespaceId())); + assertNamespaceActive(namespace); if (!permissionChecker.canReview(task, reviewerId, namespace.getType(), userNamespaceRoles, platformRoles)) { @@ -172,6 +180,7 @@ public class ReviewService { Namespace namespace = namespaceRepository.findById(task.getNamespaceId()) .orElseThrow(() -> new DomainNotFoundException("namespace.not_found", task.getNamespaceId())); + assertNamespaceActive(namespace); if (!permissionChecker.canReview(task, reviewerId, namespace.getType(), userNamespaceRoles, platformRoles)) { @@ -208,6 +217,9 @@ public class ReviewService { .orElseThrow(() -> new DomainNotFoundException("skill_version.not_found", skillVersionId)); Skill skill = skillRepository.findById(skillVersion.getSkillId()) .orElseThrow(() -> new DomainNotFoundException("skill.not_found", skillVersion.getSkillId())); + Namespace namespace = namespaceRepository.findById(skill.getNamespaceId()) + .orElseThrow(() -> new DomainNotFoundException("namespace.not_found", skill.getNamespaceId())); + assertNamespaceActive(namespace); skillGovernanceService.withdrawPendingVersion(skill, skillVersion, userId); } @@ -241,4 +253,13 @@ public class ReviewService { throw new IllegalStateException("Failed to deserialize skill metadata", e); } } + + private void assertNamespaceActive(Namespace namespace) { + if (namespace.getStatus() == NamespaceStatus.FROZEN) { + throw new DomainBadRequestException("error.namespace.frozen", namespace.getSlug()); + } + if (namespace.getStatus() == NamespaceStatus.ARCHIVED) { + throw new DomainBadRequestException("error.namespace.archived", namespace.getSlug()); + } + } } diff --git a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/service/SkillPublishService.java b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/service/SkillPublishService.java index 2238b7e0..24df91c3 100644 --- a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/service/SkillPublishService.java +++ b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/service/SkillPublishService.java @@ -6,6 +6,7 @@ import com.iflytek.skillhub.domain.namespace.Namespace; 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.namespace.NamespaceStatus; import com.iflytek.skillhub.domain.namespace.SlugValidator; import com.iflytek.skillhub.domain.review.ReviewTask; import com.iflytek.skillhub.domain.review.ReviewTaskRepository; @@ -144,6 +145,7 @@ public class SkillPublishService { // 1. Find namespace by slug Namespace namespace = namespaceRepository.findBySlug(namespaceSlug) .orElseThrow(() -> new DomainBadRequestException("error.namespace.slug.notFound", namespaceSlug)); + assertNamespaceWritable(namespace); boolean isSuperAdmin = platformRoles.contains("SUPER_ADMIN"); @@ -311,6 +313,15 @@ public class SkillPublishService { .getSlug(); } + private void assertNamespaceWritable(Namespace namespace) { + if (namespace.getStatus() == NamespaceStatus.FROZEN) { + throw new DomainBadRequestException("error.namespace.frozen", namespace.getSlug()); + } + if (namespace.getStatus() == NamespaceStatus.ARCHIVED) { + throw new DomainBadRequestException("error.namespace.archived", namespace.getSlug()); + } + } + private void assertCanManageLifecycle(Skill skill, String actorUserId, Map userNamespaceRoles) { diff --git a/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/review/PromotionServiceTest.java b/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/review/PromotionServiceTest.java index b1a8ea76..188475be 100644 --- a/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/review/PromotionServiceTest.java +++ b/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/review/PromotionServiceTest.java @@ -3,6 +3,7 @@ package com.iflytek.skillhub.domain.review; import com.iflytek.skillhub.domain.event.SkillPublishedEvent; import com.iflytek.skillhub.domain.namespace.Namespace; import com.iflytek.skillhub.domain.namespace.NamespaceRepository; +import com.iflytek.skillhub.domain.namespace.NamespaceStatus; import com.iflytek.skillhub.domain.namespace.NamespaceType; import com.iflytek.skillhub.domain.shared.exception.DomainBadRequestException; import com.iflytek.skillhub.domain.shared.exception.DomainForbiddenException; @@ -97,6 +98,12 @@ class PromotionServiceTest { return ns; } + private Namespace createSourceNamespace() { + Namespace ns = new Namespace("team-a", "Team A", "user-1"); + setField(ns, "id", 5L); + return ns; + } + private PromotionRequest createPendingPromotion() { PromotionRequest pr = new PromotionRequest(SOURCE_SKILL_ID, SOURCE_VERSION_ID, TARGET_NAMESPACE_ID, USER_ID); setField(pr, "id", PROMOTION_ID); @@ -121,6 +128,7 @@ class PromotionServiceTest { when(skillRepository.findById(SOURCE_SKILL_ID)).thenReturn(Optional.of(sourceSkill)); when(skillVersionRepository.findById(SOURCE_VERSION_ID)).thenReturn(Optional.of(sourceVersion)); + when(namespaceRepository.findById(sourceSkill.getNamespaceId())).thenReturn(Optional.of(createSourceNamespace())); when(permissionChecker.canSubmitPromotion(sourceSkill, USER_ID, Map.of())).thenReturn(true); when(namespaceRepository.findById(TARGET_NAMESPACE_ID)).thenReturn(Optional.of(globalNs)); when(promotionRequestRepository.findBySourceVersionIdAndStatus(SOURCE_VERSION_ID, ReviewTaskStatus.PENDING)) @@ -191,6 +199,7 @@ class PromotionServiceTest { Skill sourceSkill = createSourceSkill(); when(skillRepository.findById(SOURCE_SKILL_ID)).thenReturn(Optional.of(sourceSkill)); when(skillVersionRepository.findById(SOURCE_VERSION_ID)).thenReturn(Optional.of(createPublishedVersion())); + when(namespaceRepository.findById(sourceSkill.getNamespaceId())).thenReturn(Optional.of(createSourceNamespace())); when(permissionChecker.canSubmitPromotion(sourceSkill, USER_ID, Map.of())).thenReturn(true); when(namespaceRepository.findById(TARGET_NAMESPACE_ID)).thenReturn(Optional.empty()); @@ -203,6 +212,7 @@ class PromotionServiceTest { Skill sourceSkill = createSourceSkill(); when(skillRepository.findById(SOURCE_SKILL_ID)).thenReturn(Optional.of(sourceSkill)); when(skillVersionRepository.findById(SOURCE_VERSION_ID)).thenReturn(Optional.of(createPublishedVersion())); + when(namespaceRepository.findById(sourceSkill.getNamespaceId())).thenReturn(Optional.of(createSourceNamespace())); when(permissionChecker.canSubmitPromotion(sourceSkill, USER_ID, Map.of())).thenReturn(true); when(namespaceRepository.findById(TARGET_NAMESPACE_ID)).thenReturn(Optional.of(createTeamNamespace())); @@ -215,6 +225,7 @@ class PromotionServiceTest { Skill sourceSkill = createSourceSkill(); when(skillRepository.findById(SOURCE_SKILL_ID)).thenReturn(Optional.of(sourceSkill)); when(skillVersionRepository.findById(SOURCE_VERSION_ID)).thenReturn(Optional.of(createPublishedVersion())); + when(namespaceRepository.findById(sourceSkill.getNamespaceId())).thenReturn(Optional.of(createSourceNamespace())); when(permissionChecker.canSubmitPromotion(sourceSkill, USER_ID, Map.of())).thenReturn(true); when(namespaceRepository.findById(TARGET_NAMESPACE_ID)).thenReturn(Optional.of(createGlobalNamespace())); when(promotionRequestRepository.findBySourceVersionIdAndStatus(SOURCE_VERSION_ID, ReviewTaskStatus.PENDING)) @@ -231,6 +242,7 @@ class PromotionServiceTest { when(skillRepository.findById(SOURCE_SKILL_ID)).thenReturn(Optional.of(sourceSkill)); when(skillVersionRepository.findById(SOURCE_VERSION_ID)).thenReturn(Optional.of(sourceVersion)); + when(namespaceRepository.findById(sourceSkill.getNamespaceId())).thenReturn(Optional.of(createSourceNamespace())); when(permissionChecker.canSubmitPromotion( sourceSkill, "user-999", @@ -256,6 +268,7 @@ class PromotionServiceTest { when(skillRepository.findById(SOURCE_SKILL_ID)).thenReturn(Optional.of(sourceSkill)); when(skillVersionRepository.findById(SOURCE_VERSION_ID)).thenReturn(Optional.of(sourceVersion)); + when(namespaceRepository.findById(sourceSkill.getNamespaceId())).thenReturn(Optional.of(createSourceNamespace())); when(permissionChecker.canSubmitPromotion( sourceSkill, "user-999", @@ -277,6 +290,22 @@ class PromotionServiceTest { assertNotNull(result); } + + @Test + void shouldRejectSubmitWhenSourceNamespaceFrozen() { + Skill sourceSkill = createSourceSkill(); + SkillVersion sourceVersion = createPublishedVersion(); + Namespace sourceNamespace = new Namespace("team-a", "Team A", "user-1"); + setField(sourceNamespace, "id", sourceSkill.getNamespaceId()); + sourceNamespace.setStatus(NamespaceStatus.FROZEN); + + when(skillRepository.findById(SOURCE_SKILL_ID)).thenReturn(Optional.of(sourceSkill)); + when(skillVersionRepository.findById(SOURCE_VERSION_ID)).thenReturn(Optional.of(sourceVersion)); + when(namespaceRepository.findById(sourceSkill.getNamespaceId())).thenReturn(Optional.of(sourceNamespace)); + + assertThrows(DomainBadRequestException.class, + () -> promotionService.submitPromotion(SOURCE_SKILL_ID, SOURCE_VERSION_ID, TARGET_NAMESPACE_ID, USER_ID, Map.of())); + } } @Nested diff --git a/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/review/ReviewServiceTest.java b/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/review/ReviewServiceTest.java index 7525b4e8..ee5072df 100644 --- a/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/review/ReviewServiceTest.java +++ b/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/review/ReviewServiceTest.java @@ -5,6 +5,7 @@ import com.iflytek.skillhub.domain.event.SkillPublishedEvent; import com.iflytek.skillhub.domain.namespace.Namespace; import com.iflytek.skillhub.domain.namespace.NamespaceRepository; import com.iflytek.skillhub.domain.namespace.NamespaceRole; +import com.iflytek.skillhub.domain.namespace.NamespaceStatus; import com.iflytek.skillhub.domain.shared.exception.DomainBadRequestException; import com.iflytek.skillhub.domain.shared.exception.DomainForbiddenException; import com.iflytek.skillhub.domain.shared.exception.DomainNotFoundException; @@ -111,8 +112,10 @@ class ReviewServiceTest { void shouldSubmitReviewSuccessfully() { SkillVersion sv = createDraftSkillVersion(); Skill skill = createSkill(); + Namespace namespace = createTeamNamespace(); when(skillVersionRepository.findById(SKILL_VERSION_ID)).thenReturn(Optional.of(sv)); when(skillRepository.findById(SKILL_ID)).thenReturn(Optional.of(skill)); + when(namespaceRepository.findById(NAMESPACE_ID)).thenReturn(Optional.of(namespace)); when(permissionChecker.canSubmitReview( NAMESPACE_ID, Map.of(NAMESPACE_ID, NamespaceRole.MEMBER))).thenReturn(true); @@ -142,8 +145,10 @@ class ReviewServiceTest { @Test void shouldThrowWhenStatusNotDraft() { SkillVersion sv = createPendingReviewSkillVersion(); + Namespace namespace = createTeamNamespace(); when(skillVersionRepository.findById(SKILL_VERSION_ID)).thenReturn(Optional.of(sv)); when(skillRepository.findById(SKILL_ID)).thenReturn(Optional.of(createSkill())); + when(namespaceRepository.findById(NAMESPACE_ID)).thenReturn(Optional.of(namespace)); assertThrows(DomainBadRequestException.class, () -> reviewService.submitReview(SKILL_VERSION_ID, USER_ID, Map.of(NAMESPACE_ID, NamespaceRole.MEMBER))); @@ -153,8 +158,10 @@ class ReviewServiceTest { void shouldThrowOnDuplicateSubmission() { SkillVersion sv = createDraftSkillVersion(); Skill skill = createSkill(); + Namespace namespace = createTeamNamespace(); when(skillVersionRepository.findById(SKILL_VERSION_ID)).thenReturn(Optional.of(sv)); when(skillRepository.findById(SKILL_ID)).thenReturn(Optional.of(skill)); + when(namespaceRepository.findById(NAMESPACE_ID)).thenReturn(Optional.of(namespace)); when(permissionChecker.canSubmitReview( NAMESPACE_ID, Map.of(NAMESPACE_ID, NamespaceRole.MEMBER))).thenReturn(true); @@ -173,14 +180,30 @@ class ReviewServiceTest { void shouldThrowWhenSubmitterLacksNamespaceMembership() { SkillVersion sv = createDraftSkillVersion(); Skill skill = createSkill(); + Namespace namespace = createTeamNamespace(); when(skillVersionRepository.findById(SKILL_VERSION_ID)).thenReturn(Optional.of(sv)); when(skillRepository.findById(SKILL_ID)).thenReturn(Optional.of(skill)); + when(namespaceRepository.findById(NAMESPACE_ID)).thenReturn(Optional.of(namespace)); when(permissionChecker.canSubmitReview(NAMESPACE_ID, Map.of())).thenReturn(false); assertThrows(DomainForbiddenException.class, () -> reviewService.submitReview(SKILL_VERSION_ID, USER_ID, Map.of())); verify(reviewTaskRepository, never()).save(any(ReviewTask.class)); } + + @Test + void shouldRejectSubmitWhenNamespaceFrozen() { + SkillVersion sv = createDraftSkillVersion(); + Skill skill = createSkill(); + Namespace namespace = createTeamNamespace(); + namespace.setStatus(NamespaceStatus.FROZEN); + when(skillVersionRepository.findById(SKILL_VERSION_ID)).thenReturn(Optional.of(sv)); + when(skillRepository.findById(SKILL_ID)).thenReturn(Optional.of(skill)); + when(namespaceRepository.findById(NAMESPACE_ID)).thenReturn(Optional.of(namespace)); + + assertThrows(DomainBadRequestException.class, + () -> reviewService.submitReview(SKILL_VERSION_ID, USER_ID, Map.of(NAMESPACE_ID, NamespaceRole.MEMBER))); + } } @Nested @@ -280,6 +303,18 @@ class ReviewServiceTest { () -> reviewService.approveReview(REVIEW_TASK_ID, REVIEWER_ID, "ok", Map.of(), Set.of())); } + @Test + void shouldRejectApproveWhenNamespaceFrozen() { + ReviewTask task = createPendingReviewTask(); + Namespace namespace = createTeamNamespace(); + namespace.setStatus(NamespaceStatus.FROZEN); + when(reviewTaskRepository.findById(REVIEW_TASK_ID)).thenReturn(Optional.of(task)); + when(namespaceRepository.findById(NAMESPACE_ID)).thenReturn(Optional.of(namespace)); + + assertThrows(DomainBadRequestException.class, + () -> reviewService.approveReview(REVIEW_TASK_ID, REVIEWER_ID, "ok", Map.of(), Set.of())); + } + @Test void superAdminCanApproveOwnSubmission() { ReviewTask task = createPendingReviewTask(); @@ -417,11 +452,13 @@ class ReviewServiceTest { ReviewTask task = createPendingReviewTask(); SkillVersion sv = createPendingReviewSkillVersion(); Skill skill = createSkill(); + Namespace namespace = createTeamNamespace(); when(reviewTaskRepository.findBySkillVersionIdAndStatus(SKILL_VERSION_ID, ReviewTaskStatus.PENDING)) .thenReturn(Optional.of(task)); when(skillVersionRepository.findById(SKILL_VERSION_ID)).thenReturn(Optional.of(sv)); when(skillRepository.findById(SKILL_ID)).thenReturn(Optional.of(skill)); + when(namespaceRepository.findById(NAMESPACE_ID)).thenReturn(Optional.of(namespace)); when(skillGovernanceService.withdrawPendingVersion(skill, sv, USER_ID)).thenReturn(false); reviewService.withdrawReview(SKILL_VERSION_ID, USER_ID); @@ -451,15 +488,35 @@ class ReviewServiceTest { } @Test - void shouldDeleteEntireSkillWhenOnlyPendingVersionExists() { + void shouldRejectWithdrawWhenNamespaceArchived() { ReviewTask task = createPendingReviewTask(); SkillVersion sv = createPendingReviewSkillVersion(); Skill skill = createSkill(); + Namespace namespace = createTeamNamespace(); + namespace.setStatus(NamespaceStatus.ARCHIVED); when(reviewTaskRepository.findBySkillVersionIdAndStatus(SKILL_VERSION_ID, ReviewTaskStatus.PENDING)) .thenReturn(Optional.of(task)); when(skillVersionRepository.findById(SKILL_VERSION_ID)).thenReturn(Optional.of(sv)); when(skillRepository.findById(SKILL_ID)).thenReturn(Optional.of(skill)); + when(namespaceRepository.findById(NAMESPACE_ID)).thenReturn(Optional.of(namespace)); + + assertThrows(DomainBadRequestException.class, + () -> reviewService.withdrawReview(SKILL_VERSION_ID, USER_ID)); + } + + @Test + void shouldDeleteEntireSkillWhenOnlyPendingVersionExists() { + ReviewTask task = createPendingReviewTask(); + SkillVersion sv = createPendingReviewSkillVersion(); + Skill skill = createSkill(); + Namespace namespace = createTeamNamespace(); + + when(reviewTaskRepository.findBySkillVersionIdAndStatus(SKILL_VERSION_ID, ReviewTaskStatus.PENDING)) + .thenReturn(Optional.of(task)); + when(skillVersionRepository.findById(SKILL_VERSION_ID)).thenReturn(Optional.of(sv)); + when(skillRepository.findById(SKILL_ID)).thenReturn(Optional.of(skill)); + when(namespaceRepository.findById(NAMESPACE_ID)).thenReturn(Optional.of(namespace)); when(skillGovernanceService.withdrawPendingVersion(skill, sv, USER_ID)).thenReturn(true); reviewService.withdrawReview(SKILL_VERSION_ID, USER_ID); @@ -473,12 +530,14 @@ class ReviewServiceTest { ReviewTask task = createPendingReviewTask(); SkillVersion sv = createPendingReviewSkillVersion(); Skill skill = createSkill(); + Namespace namespace = createTeamNamespace(); setField(skill, "latestVersionId", 99L); when(reviewTaskRepository.findBySkillVersionIdAndStatus(SKILL_VERSION_ID, ReviewTaskStatus.PENDING)) .thenReturn(Optional.of(task)); when(skillVersionRepository.findById(SKILL_VERSION_ID)).thenReturn(Optional.of(sv)); when(skillRepository.findById(SKILL_ID)).thenReturn(Optional.of(skill)); + when(namespaceRepository.findById(NAMESPACE_ID)).thenReturn(Optional.of(namespace)); when(skillGovernanceService.withdrawPendingVersion(skill, sv, USER_ID)).thenReturn(false); reviewService.withdrawReview(SKILL_VERSION_ID, USER_ID); diff --git a/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/skill/service/SkillPublishServiceTest.java b/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/skill/service/SkillPublishServiceTest.java index e5cfb885..01c66828 100644 --- a/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/skill/service/SkillPublishServiceTest.java +++ b/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/skill/service/SkillPublishServiceTest.java @@ -6,6 +6,7 @@ import com.iflytek.skillhub.domain.namespace.Namespace; import com.iflytek.skillhub.domain.namespace.NamespaceMember; import com.iflytek.skillhub.domain.namespace.NamespaceMemberRepository; import com.iflytek.skillhub.domain.namespace.NamespaceRepository; +import com.iflytek.skillhub.domain.namespace.NamespaceStatus; import com.iflytek.skillhub.domain.review.ReviewTask; import com.iflytek.skillhub.domain.review.ReviewTaskRepository; import com.iflytek.skillhub.domain.shared.exception.DomainBadRequestException; @@ -311,6 +312,25 @@ class SkillPublishServiceTest { ); } + @Test + void testPublishFromEntries_ShouldRejectFrozenNamespace() throws Exception { + String namespaceSlug = "test-ns"; + String publisherId = "user-100"; + String skillMdContent = "---\nname: test-skill\ndescription: Test\nversion: 1.0.0\n---\nBody"; + + PackageEntry skillMd = new PackageEntry("SKILL.md", skillMdContent.getBytes(), skillMdContent.length(), "text/markdown"); + List entries = List.of(skillMd); + + Namespace namespace = new Namespace(namespaceSlug, "Test NS", "user-1"); + namespace.setStatus(NamespaceStatus.FROZEN); + setId(namespace, 1L); + + when(namespaceRepository.findBySlug(namespaceSlug)).thenReturn(Optional.of(namespace)); + + assertThrows(DomainBadRequestException.class, () -> + service.publishFromEntries(namespaceSlug, entries, publisherId, SkillVisibility.PUBLIC, Set.of())); + } + @Test void testPublishFromEntries_NotAMember() throws Exception { // Arrange From 33ed8fe17da578c766b01f534c2a8eeac98ca74b Mon Sep 17 00:00:00 2001 From: yun-zhi-ztl <15071461069@163.com> Date: Mon, 16 Mar 2026 12:00:08 +0800 Subject: [PATCH 06/23] feat: hide archived namespaces from public reads --- .../service/SkillSearchAppService.java | 26 ++++++- .../controller/SkillControllerTest.java | 15 ++++ .../controller/SkillSearchControllerTest.java | 7 +- .../service/SkillSearchAppServiceTest.java | 76 +++++++++++++++++++ .../skill/service/SkillQueryService.java | 53 +++++++++---- .../skill/service/SkillQueryServiceTest.java | 19 +++++ 6 files changed, 174 insertions(+), 22 deletions(-) create mode 100644 server/skillhub-app/src/test/java/com/iflytek/skillhub/service/SkillSearchAppServiceTest.java diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/SkillSearchAppService.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/SkillSearchAppService.java index 7c1b4a88..aab4b04e 100644 --- a/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/SkillSearchAppService.java +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/SkillSearchAppService.java @@ -1,6 +1,8 @@ package com.iflytek.skillhub.service; import com.iflytek.skillhub.domain.namespace.NamespaceRole; +import com.iflytek.skillhub.domain.namespace.Namespace; +import com.iflytek.skillhub.domain.namespace.NamespaceStatus; import com.iflytek.skillhub.domain.namespace.NamespaceRepository; import com.iflytek.skillhub.domain.skill.Skill; import com.iflytek.skillhub.domain.skill.SkillRepository; @@ -89,19 +91,21 @@ public class SkillSearchAppService { .map(Skill::getNamespaceId) .distinct() .toList(); - Map namespaceSlugsById = namespaceIds.isEmpty() + Map namespacesById = namespaceIds.isEmpty() ? Map.of() : namespaceRepository.findByIdIn(namespaceIds).stream() - .collect(Collectors.toMap(com.iflytek.skillhub.domain.namespace.Namespace::getId, - com.iflytek.skillhub.domain.namespace.Namespace::getSlug)); + .collect(Collectors.toMap(Namespace::getId, Function.identity())); + Map namespaceSlugsById = namespacesById.entrySet().stream() + .collect(Collectors.toMap(Map.Entry::getKey, entry -> entry.getValue().getSlug())); List skills = result.skillIds().stream() .map(skillsById::get) .filter(java.util.Objects::nonNull) + .filter(skill -> namespaceVisible(skill.getNamespaceId(), namespacesById, userId, userNsRoles)) .map(skill -> toSummaryResponse(skill, versionsById, namespaceSlugsById)) .toList(); - return new SearchResponse(skills, result.total(), result.page(), result.size()); + return new SearchResponse(skills, skills.size(), result.page(), result.size()); } private Long resolveNamespaceId(String namespaceSlug) { @@ -158,4 +162,18 @@ public class SkillSearchAppService { skill.getUpdatedAt() ); } + + private boolean namespaceVisible( + Long namespaceId, + Map namespacesById, + String userId, + Map userNsRoles) { + NamespaceStatus status = java.util.Optional.ofNullable(namespacesById.get(namespaceId)) + .map(Namespace::getStatus) + .orElse(NamespaceStatus.ACTIVE); + if (status != NamespaceStatus.ARCHIVED) { + return true; + } + return userId != null && userNsRoles != null && userNsRoles.containsKey(namespaceId); + } } diff --git a/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/SkillControllerTest.java b/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/SkillControllerTest.java index d34698f0..f1a4c2d7 100644 --- a/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/SkillControllerTest.java +++ b/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/SkillControllerTest.java @@ -2,6 +2,7 @@ package com.iflytek.skillhub.controller; import com.iflytek.skillhub.domain.namespace.NamespaceMemberRepository; import com.iflytek.skillhub.domain.namespace.NamespaceRole; +import com.iflytek.skillhub.domain.shared.exception.DomainForbiddenException; import com.iflytek.skillhub.domain.skill.SkillFile; import com.iflytek.skillhub.domain.skill.service.SkillDownloadService; import com.iflytek.skillhub.domain.skill.service.SkillQueryService; @@ -138,6 +139,20 @@ class SkillControllerTest { .andExpect(jsonPath("$.data.canInteract").value(false)); } + @Test + void getSkillDetailShouldReturnForbiddenForArchivedNamespace() throws Exception { + when(skillQueryService.getSkillDetail( + eq("team"), + eq("demo"), + eq((String) null), + eq(Map.of()))) + .thenThrow(new DomainForbiddenException("error.namespace.archived", "team")); + + mockMvc.perform(get("/api/web/skills/team/demo")) + .andExpect(status().isForbidden()) + .andExpect(jsonPath("$.code").value(403)); + } + @Test void listFilesByTagShouldReturnUnifiedEnvelope() throws Exception { when(skillQueryService.listFilesByTag( diff --git a/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/SkillSearchControllerTest.java b/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/SkillSearchControllerTest.java index d0252b70..8531bf79 100644 --- a/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/SkillSearchControllerTest.java +++ b/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/SkillSearchControllerTest.java @@ -12,6 +12,7 @@ import org.springframework.test.web.servlet.MockMvc; import java.util.List; +import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.eq; import static org.mockito.Mockito.when; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; @@ -40,11 +41,11 @@ class SkillSearchControllerTest { eq("newest"), eq(0), eq(20), - eq((String) null), - eq(null))) + any(), + any())) .thenReturn(new SkillSearchAppService.SearchResponse(List.of(), 0, 0, 20)); - mockMvc.perform(get("/api/v1/skills") + mockMvc.perform(get("/api/web/skills") .param("q", "review") .param("namespace", "global")) .andExpect(status().isOk()) diff --git a/server/skillhub-app/src/test/java/com/iflytek/skillhub/service/SkillSearchAppServiceTest.java b/server/skillhub-app/src/test/java/com/iflytek/skillhub/service/SkillSearchAppServiceTest.java new file mode 100644 index 00000000..7d34088f --- /dev/null +++ b/server/skillhub-app/src/test/java/com/iflytek/skillhub/service/SkillSearchAppServiceTest.java @@ -0,0 +1,76 @@ +package com.iflytek.skillhub.service; + +import com.iflytek.skillhub.domain.namespace.Namespace; +import com.iflytek.skillhub.domain.namespace.NamespaceRepository; +import com.iflytek.skillhub.domain.namespace.NamespaceStatus; +import com.iflytek.skillhub.domain.skill.Skill; +import com.iflytek.skillhub.domain.skill.SkillRepository; +import com.iflytek.skillhub.domain.skill.SkillVersionRepository; +import com.iflytek.skillhub.domain.skill.SkillVisibility; +import com.iflytek.skillhub.search.SearchQueryService; +import com.iflytek.skillhub.search.SearchResult; +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.Map; +import java.util.Optional; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +class SkillSearchAppServiceTest { + + @Mock + private SearchQueryService searchQueryService; + + @Mock + private SkillRepository skillRepository; + + @Mock + private NamespaceRepository namespaceRepository; + + @Mock + private SkillVersionRepository skillVersionRepository; + + private SkillSearchAppService service; + + @BeforeEach + void setUp() { + service = new SkillSearchAppService(searchQueryService, skillRepository, namespaceRepository, skillVersionRepository); + } + + @Test + void search_shouldExcludeArchivedNamespaceSkillsForAnonymousUsers() { + Skill archivedSkill = new Skill(1L, "archived-skill", "owner-1", SkillVisibility.PUBLIC); + setField(archivedSkill, "id", 10L); + + Namespace archivedNamespace = new Namespace("archived-team", "Archived Team", "owner-1"); + setField(archivedNamespace, "id", 1L); + archivedNamespace.setStatus(NamespaceStatus.ARCHIVED); + + when(searchQueryService.search(org.mockito.ArgumentMatchers.any())) + .thenReturn(new SearchResult(List.of(10L), 1, 0, 20)); + when(skillRepository.findByIdIn(List.of(10L))).thenReturn(List.of(archivedSkill)); + when(namespaceRepository.findByIdIn(List.of(1L))).thenReturn(List.of(archivedNamespace)); + + SkillSearchAppService.SearchResponse response = service.search("archive", null, "newest", 0, 20, null, null); + + assertEquals(0, response.items().size()); + assertEquals(0, response.total()); + } + + private void setField(Object target, String fieldName, Object value) { + try { + java.lang.reflect.Field field = target.getClass().getDeclaredField(fieldName); + field.setAccessible(true); + field.set(target, value); + } catch (Exception e) { + throw new RuntimeException(e); + } + } +} diff --git a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/service/SkillQueryService.java b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/service/SkillQueryService.java index d03565f5..b63e0053 100644 --- a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/service/SkillQueryService.java +++ b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/service/SkillQueryService.java @@ -3,6 +3,7 @@ package com.iflytek.skillhub.domain.skill.service; import com.iflytek.skillhub.domain.namespace.Namespace; import com.iflytek.skillhub.domain.namespace.NamespaceRepository; import com.iflytek.skillhub.domain.namespace.NamespaceRole; +import com.iflytek.skillhub.domain.namespace.NamespaceStatus; import com.iflytek.skillhub.domain.shared.exception.DomainBadRequestException; import com.iflytek.skillhub.domain.shared.exception.DomainForbiddenException; import com.iflytek.skillhub.domain.skill.*; @@ -170,8 +171,9 @@ public class SkillQueryService { String version, String currentUserId, Map userNsRoles) { - Skill skill = findSkill(namespaceSlug, skillSlug); - assertPublishedAccessible(skill, currentUserId, userNsRoles); + Namespace namespace = findNamespace(namespaceSlug); + Skill skill = findSkill(namespace, skillSlug); + assertPublishedAccessible(namespace, skill, currentUserId, userNsRoles); SkillVersion skillVersion = findVersion(skill, version); assertPreviewAccessible(skill, skillVersion, version, currentUserId); @@ -194,8 +196,9 @@ public class SkillQueryService { String version, String currentUserId, Map userNsRoles) { - Skill skill = findSkill(namespaceSlug, skillSlug); - assertPublishedAccessible(skill, currentUserId, userNsRoles); + Namespace namespace = findNamespace(namespaceSlug); + Skill skill = findSkill(namespace, skillSlug); + assertPublishedAccessible(namespace, skill, currentUserId, userNsRoles); SkillVersion skillVersion = findVersion(skill, version); assertPreviewAccessible(skill, skillVersion, version, currentUserId); @@ -209,8 +212,9 @@ public class SkillQueryService { String tagName, String currentUserId, Map userNsRoles) { - Skill skill = findSkill(namespaceSlug, skillSlug); - assertPublishedAccessible(skill, currentUserId, userNsRoles); + Namespace namespace = findNamespace(namespaceSlug); + Skill skill = findSkill(namespace, skillSlug); + assertPublishedAccessible(namespace, skill, currentUserId, userNsRoles); SkillVersion skillVersion = resolveVersionEntity(skill, null, tagName, null); return skillFileRepository.findByVersionId(skillVersion.getId()); } @@ -222,8 +226,9 @@ public class SkillQueryService { String filePath, String currentUserId, Map userNsRoles) { - Skill skill = findSkill(namespaceSlug, skillSlug); - assertPublishedAccessible(skill, currentUserId, userNsRoles); + Namespace namespace = findNamespace(namespaceSlug); + Skill skill = findSkill(namespace, skillSlug); + assertPublishedAccessible(namespace, skill, currentUserId, userNsRoles); SkillVersion skillVersion = findVersion(skill, version); assertPreviewAccessible(skill, skillVersion, version, currentUserId); @@ -240,8 +245,9 @@ public class SkillQueryService { String filePath, String currentUserId, Map userNsRoles) { - Skill skill = findSkill(namespaceSlug, skillSlug); - assertPublishedAccessible(skill, currentUserId, userNsRoles); + Namespace namespace = findNamespace(namespaceSlug); + Skill skill = findSkill(namespace, skillSlug); + assertPublishedAccessible(namespace, skill, currentUserId, userNsRoles); SkillVersion skillVersion = resolveVersionEntity(skill, null, tagName, null); SkillFile file = findFile(skillVersion, filePath); return readFileContent(file); @@ -252,8 +258,9 @@ public class SkillQueryService { String currentUserId, Map userNsRoles, Pageable pageable) { - Skill skill = findSkill(namespaceSlug, skillSlug); - assertPublishedAccessible(skill, currentUserId, userNsRoles); + Namespace namespace = findNamespace(namespaceSlug); + Skill skill = findSkill(namespace, skillSlug); + assertPublishedAccessible(namespace, skill, currentUserId, userNsRoles); List visibleVersions; if (canManageRestrictedSkill(skill, currentUserId, userNsRoles)) { visibleVersions = skillVersionRepository.findBySkillId(skill.getId()).stream() @@ -295,8 +302,9 @@ public class SkillQueryService { throw new DomainBadRequestException("error.skill.resolve.versionTag.conflict"); } - Skill skill = findSkill(namespaceSlug, skillSlug); - assertPublishedAccessible(skill, currentUserId, userNsRoles); + Namespace namespace = findNamespace(namespaceSlug); + Skill skill = findSkill(namespace, skillSlug); + assertPublishedAccessible(namespace, skill, currentUserId, userNsRoles); SkillVersion resolved = resolveVersionEntity(skill, version, tag, hash); String fingerprint = computeFingerprint(resolved); Boolean matched = hash == null || hash.isBlank() ? null : Objects.equals(hash, fingerprint); @@ -324,6 +332,10 @@ public class SkillQueryService { private Skill findSkill(String namespaceSlug, String skillSlug) { Namespace namespace = findNamespace(namespaceSlug); + return findSkill(namespace, skillSlug); + } + + private Skill findSkill(Namespace namespace, String skillSlug) { return skillRepository.findByNamespaceIdAndSlug(namespace.getId(), skillSlug) .orElseThrow(() -> new DomainBadRequestException("error.skill.notFound", skillSlug)); } @@ -440,7 +452,14 @@ public class SkillQueryService { return URLEncoder.encode(value, StandardCharsets.UTF_8).replace("+", "%20"); } - private void assertPublishedAccessible(Skill skill, String currentUserId, Map userNsRoles) { + private void assertPublishedAccessible( + Namespace namespace, + Skill skill, + String currentUserId, + Map userNsRoles) { + if (namespace.getStatus() == NamespaceStatus.ARCHIVED && !isNamespaceMember(skill.getNamespaceId(), currentUserId, userNsRoles)) { + throw new DomainForbiddenException("error.namespace.archived", namespace.getSlug()); + } if (skill.getStatus() != SkillStatus.ACTIVE && !canManageRestrictedSkill(skill, currentUserId, userNsRoles)) { throw new DomainForbiddenException("error.skill.access.denied", skill.getSlug()); } @@ -466,6 +485,10 @@ public class SkillQueryService { return currentUserId != null && skill.getOwnerId().equals(currentUserId); } + private boolean isNamespaceMember(Long namespaceId, String currentUserId, Map userNsRoles) { + return currentUserId != null && userNsRoles.containsKey(namespaceId); + } + private int lifecycleListPriority(SkillVersionStatus status) { if (status == SkillVersionStatus.PUBLISHED) { return 0; diff --git a/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/skill/service/SkillQueryServiceTest.java b/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/skill/service/SkillQueryServiceTest.java index 143e8b7a..dd9b40a9 100644 --- a/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/skill/service/SkillQueryServiceTest.java +++ b/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/skill/service/SkillQueryServiceTest.java @@ -3,6 +3,7 @@ package com.iflytek.skillhub.domain.skill.service; import com.iflytek.skillhub.domain.namespace.Namespace; import com.iflytek.skillhub.domain.namespace.NamespaceRepository; import com.iflytek.skillhub.domain.namespace.NamespaceRole; +import com.iflytek.skillhub.domain.namespace.NamespaceStatus; import com.iflytek.skillhub.domain.shared.exception.DomainBadRequestException; import com.iflytek.skillhub.domain.shared.exception.DomainForbiddenException; import com.iflytek.skillhub.domain.skill.*; @@ -118,6 +119,24 @@ class SkillQueryServiceTest { ); } + @Test + void testGetSkillDetail_ShouldHideArchivedNamespaceFromAnonymousUsers() throws Exception { + String namespaceSlug = "archived-team"; + String skillSlug = "test-skill"; + + Namespace namespace = new Namespace(namespaceSlug, "Archived Team", "user-1"); + namespace.setStatus(NamespaceStatus.ARCHIVED); + setId(namespace, 1L); + Skill skill = new Skill(1L, skillSlug, "user-200", SkillVisibility.PUBLIC); + setId(skill, 1L); + + when(namespaceRepository.findBySlug(namespaceSlug)).thenReturn(Optional.of(namespace)); + when(skillRepository.findByNamespaceIdAndSlug(1L, skillSlug)).thenReturn(Optional.of(skill)); + + assertThrows(DomainForbiddenException.class, () -> + service.getSkillDetail(namespaceSlug, skillSlug, null, Map.of())); + } + @Test void testListSkillsByNamespace() throws Exception { // Arrange From f92cdb31f2effb98aaedbd89f0211c3821009952 Mon Sep 17 00:00:00 2001 From: yun-zhi-ztl <15071461069@163.com> Date: Mon, 16 Mar 2026 12:06:56 +0800 Subject: [PATCH 07/23] feat: add namespace governance dashboard --- web/src/api/client.ts | 47 +++++ web/src/api/types.ts | 17 +- .../features/namespace/namespace-header.tsx | 29 ++- web/src/features/skill/install-command.tsx | 5 +- web/src/i18n/locales/en.json | 48 ++++- web/src/i18n/locales/zh.json | 48 ++++- web/src/pages/dashboard/my-namespaces.tsx | 193 +++++++++++++++++- web/src/pages/dashboard/namespace-members.tsx | 18 +- web/src/pages/dashboard/namespace-reviews.tsx | 14 ++ web/src/shared/hooks/use-skill-queries.ts | 63 +++++- 10 files changed, 461 insertions(+), 21 deletions(-) diff --git a/web/src/api/client.ts b/web/src/api/client.ts index 06d8db58..1bfa5f1f 100644 --- a/web/src/api/client.ts +++ b/web/src/api/client.ts @@ -19,6 +19,8 @@ import type { AuthMethod, OAuthProvider, User, + ManagedNamespace, + Namespace, } from './types' import { ApiError } from '@/shared/lib/api-error' import i18n from '@/i18n/config' @@ -460,6 +462,51 @@ export const skillLifecycleApi = { }, } +function normalizeNamespaceSlug(namespace: string): string { + return namespace.startsWith('@') ? namespace.slice(1) : namespace +} + +export const namespaceApi = { + async listMine(): Promise { + return fetchJson(`${WEB_API_PREFIX}/me/namespaces`) + }, + + async getDetail(slug: string): Promise { + return fetchJson(`${WEB_API_PREFIX}/namespaces/${normalizeNamespaceSlug(slug)}`) + }, + + async freeze(slug: string): Promise { + return fetchJson(`${WEB_API_PREFIX}/namespaces/${normalizeNamespaceSlug(slug)}/freeze`, { + method: 'POST', + headers: await ensureCsrfHeaders(), + }) + }, + + async unfreeze(slug: string): Promise { + return fetchJson(`${WEB_API_PREFIX}/namespaces/${normalizeNamespaceSlug(slug)}/unfreeze`, { + method: 'POST', + headers: await ensureCsrfHeaders(), + }) + }, + + async archive(slug: string, reason?: string): Promise { + return fetchJson(`${WEB_API_PREFIX}/namespaces/${normalizeNamespaceSlug(slug)}/archive`, { + method: 'POST', + headers: await ensureCsrfHeaders({ + 'Content-Type': 'application/json', + }), + body: JSON.stringify(reason?.trim() ? { reason: reason.trim() } : {}), + }) + }, + + async restore(slug: string): Promise { + return fetchJson(`${WEB_API_PREFIX}/namespaces/${normalizeNamespaceSlug(slug)}/restore`, { + method: 'POST', + headers: await ensureCsrfHeaders(), + }) + }, +} + export const tokenApi = { async getTokens(params?: { page?: number, size?: number }): Promise<{ items: ApiToken[], total: number, page: number, size: number }> { const page = await unwrap<{ items: ApiToken[], total: number, page: number, size: number }>(client.GET('/api/v1/tokens', { diff --git a/web/src/api/types.ts b/web/src/api/types.ts index 8c59a289..36a70f77 100644 --- a/web/src/api/types.ts +++ b/web/src/api/types.ts @@ -82,6 +82,9 @@ export interface MergeConfirmRequest { } // Namespace types +export type NamespaceStatus = 'ACTIVE' | 'FROZEN' | 'ARCHIVED' | string +export type NamespaceRole = 'OWNER' | 'ADMIN' | 'MEMBER' | string + export interface Namespace { id: number slug: string @@ -89,15 +92,25 @@ export interface Namespace { description?: string type: 'GLOBAL' | 'TEAM' avatarUrl?: string - status: string + status: NamespaceStatus createdAt: string updatedAt?: string } +export interface ManagedNamespace extends Namespace { + createdBy?: string + currentUserRole?: NamespaceRole + immutable: boolean + canFreeze: boolean + canUnfreeze: boolean + canArchive: boolean + canRestore: boolean +} + export interface NamespaceMember { id: number userId: string - role: string + role: NamespaceRole createdAt: string } diff --git a/web/src/features/namespace/namespace-header.tsx b/web/src/features/namespace/namespace-header.tsx index dea9ad27..cda6c971 100644 --- a/web/src/features/namespace/namespace-header.tsx +++ b/web/src/features/namespace/namespace-header.tsx @@ -1,6 +1,7 @@ import { useTranslation } from 'react-i18next' import type { Namespace } from '@/api/types' import { NamespaceBadge } from '@/shared/components/namespace-badge' +import { cn } from '@/shared/lib/utils' interface NamespaceHeaderProps { namespace: Namespace @@ -8,6 +9,24 @@ interface NamespaceHeaderProps { export function NamespaceHeader({ namespace }: NamespaceHeaderProps) { const { t } = useTranslation() + const statusLabel = namespace.status === 'FROZEN' + ? t('namespaceStatus.frozen') + : namespace.status === 'ARCHIVED' + ? t('namespaceStatus.archived') + : t('namespaceStatus.active') + const statusClassName = namespace.status === 'FROZEN' + ? 'bg-amber-500/10 text-amber-500 border-amber-500/20' + : namespace.status === 'ARCHIVED' + ? 'bg-slate-500/10 text-slate-500 border-slate-500/20' + : 'bg-emerald-500/10 text-emerald-500 border-emerald-500/20' + const hint = namespace.type === 'GLOBAL' + ? t('namespaceStatus.immutableHint') + : namespace.status === 'FROZEN' + ? t('namespaceStatus.frozenHint') + : namespace.status === 'ARCHIVED' + ? t('namespaceStatus.archivedHint') + : null + return (
{namespace.avatarUrl && ( @@ -18,9 +37,12 @@ export function NamespaceHeader({ namespace }: NamespaceHeaderProps) { /> )}
-
+

{namespace.displayName}

+ + {statusLabel} +
{namespace.description && (

{namespace.description}

@@ -28,6 +50,11 @@ export function NamespaceHeader({ namespace }: NamespaceHeaderProps) {
@{namespace.slug}
+ {hint ? ( +
+ {hint} +
+ ) : null}
) diff --git a/web/src/features/skill/install-command.tsx b/web/src/features/skill/install-command.tsx index 7db3edf6..ce9ef25a 100644 --- a/web/src/features/skill/install-command.tsx +++ b/web/src/features/skill/install-command.tsx @@ -20,13 +20,12 @@ function getAppBaseUrl(): string { return `${window.location.protocol}//${window.location.host}` } -export function InstallCommand({ namespace, slug, version }: InstallCommandProps) { +export function InstallCommand({ slug }: InstallCommandProps) { const { t } = useTranslation() const [copied, setCopied] = useState(false) const baseUrl = useMemo(() => getAppBaseUrl(), []) - const cleanNamespace = namespace.startsWith('@') ? namespace.slice(1) : namespace const command = useMemo(() => { const installCmd = `clawhub install ${slug} ` // 如果是默认的 clawhub.ai 不需要环境变量,否则显示完整配置 @@ -34,7 +33,7 @@ export function InstallCommand({ namespace, slug, version }: InstallCommandProps return `CLAWHUB_SITE=${baseUrl} CLAWHUB_REGISTRY=${baseUrl} ${installCmd}` } return installCmd - }, [cleanNamespace, slug, version, baseUrl]) + }, [baseUrl, slug]) const handleCopy = async () => { try { diff --git a/web/src/i18n/locales/en.json b/web/src/i18n/locales/en.json index 2c92c08e..5274e052 100644 --- a/web/src/i18n/locales/en.json +++ b/web/src/i18n/locales/en.json @@ -265,8 +265,38 @@ "create": "Create Namespace", "typeGlobal": "Global", "typeTeam": "Team", + "roleLabel": "Current role", + "roleUnknown": "Unknown", "manageMembers": "Manage Members", "reviewTasks": "Review Tasks", + "freeze": "Freeze", + "unfreeze": "Unfreeze", + "archive": "Archive", + "restore": "Restore", + "activeHint": "This namespace is fully active and can continue managing members, reviews, and skill publishing.", + "frozenHint": "This namespace is frozen. Members can still view it, but it is read-only and cannot publish or change membership.", + "archivedHint": "This namespace is archived. Public entry points are hidden, but you can still view and restore it from the dashboard.", + "immutableHint": "This is a built-in system namespace and always remains read-only.", + "freezeConfirmTitle": "Freeze namespace", + "freezeConfirmDescription": "\"{{name}}\" will become read-only after freezing. Continue?", + "unfreezeConfirmTitle": "Unfreeze namespace", + "unfreezeConfirmDescription": "\"{{name}}\" will regain normal management and publishing after unfreezing.", + "archiveConfirmTitle": "Archive namespace", + "archiveConfirmDescription": "\"{{name}}\" will be hidden from public entry points and only remain recoverable from the dashboard.", + "restoreConfirmTitle": "Restore namespace", + "restoreConfirmDescription": "\"{{name}}\" will return to an active operating state.", + "freezeSuccessTitle": "Namespace frozen", + "freezeSuccessDescription": "\"{{name}}\" is now read-only.", + "freezeErrorTitle": "Failed to freeze namespace", + "unfreezeSuccessTitle": "Namespace unfrozen", + "unfreezeSuccessDescription": "\"{{name}}\" is active again.", + "unfreezeErrorTitle": "Failed to unfreeze namespace", + "archiveSuccessTitle": "Namespace archived", + "archiveSuccessDescription": "\"{{name}}\" is now hidden from public entry points.", + "archiveErrorTitle": "Failed to archive namespace", + "restoreSuccessTitle": "Namespace restored", + "restoreSuccessDescription": "\"{{name}}\" can operate normally again.", + "restoreErrorTitle": "Failed to restore namespace", "emptyTitle": "No namespaces yet", "emptyDescription": "Create a namespace to organize your skills" }, @@ -546,7 +576,10 @@ "colActions": "Actions", "remove": "Remove", "empty": "No members", - "namespaceNotFound": "Namespace not found" + "namespaceNotFound": "Namespace not found", + "globalReadOnly": "This is a built-in system namespace. Membership can only be viewed and cannot be changed.", + "frozenReadOnly": "This namespace is frozen. You can still view members, but cannot add, remove, or change roles.", + "archivedReadOnly": "This namespace is archived. You can still view members, but cannot change membership until it is restored." }, "upload": { "dropHint": "Drop to upload...", @@ -564,7 +597,18 @@ "version": "Version {{version}}", "tabPending": "Pending", "tabApproved": "Approved", - "tabRejected": "Rejected" + "tabRejected": "Rejected", + "globalReadOnly": "This is a built-in system namespace. Review history is visible, but no new review actions are allowed.", + "frozenReadOnly": "This namespace is frozen. You can still view review history, but cannot continue processing review tasks.", + "archivedReadOnly": "This namespace is archived. You can still view review history, but cannot continue processing review tasks until it is restored." + }, + "namespaceStatus": { + "active": "Active", + "frozen": "Frozen", + "archived": "Archived", + "frozenHint": "This namespace is currently read-only and cannot publish, review, or change members.", + "archivedHint": "This namespace is hidden from public entry points and only visible to dashboard members.", + "immutableHint": "This is a built-in system namespace and does not allow governance or membership changes." }, "fileTree": { "title": "Files ({{count}})" diff --git a/web/src/i18n/locales/zh.json b/web/src/i18n/locales/zh.json index c115779c..78773076 100644 --- a/web/src/i18n/locales/zh.json +++ b/web/src/i18n/locales/zh.json @@ -265,8 +265,38 @@ "create": "创建命名空间", "typeGlobal": "全局", "typeTeam": "团队", + "roleLabel": "当前角色", + "roleUnknown": "未知", "manageMembers": "管理成员", "reviewTasks": "审核任务", + "freeze": "冻结", + "unfreeze": "解冻", + "archive": "归档", + "restore": "恢复", + "activeHint": "命名空间运行正常,可继续管理成员、处理审核和发布技能。", + "frozenHint": "命名空间已冻结。成员仍可查看,但当前处于只读状态,不能继续发布或变更成员。", + "archivedHint": "命名空间已归档。公开入口已隐藏,但你仍可在管理台查看并恢复。", + "immutableHint": "系统内置命名空间,始终保持只读,不允许任何治理操作。", + "freezeConfirmTitle": "确认冻结命名空间", + "freezeConfirmDescription": "冻结后“{{name}}”将进入只读状态,确定继续吗?", + "unfreezeConfirmTitle": "确认解冻命名空间", + "unfreezeConfirmDescription": "解冻后“{{name}}”会重新恢复正常管理和发布能力。", + "archiveConfirmTitle": "确认归档命名空间", + "archiveConfirmDescription": "归档后“{{name}}”会从公开入口隐藏,仅保留管理台恢复入口。", + "restoreConfirmTitle": "确认恢复命名空间", + "restoreConfirmDescription": "恢复后“{{name}}”会重新进入可运营状态。", + "freezeSuccessTitle": "命名空间已冻结", + "freezeSuccessDescription": "“{{name}}”已切换为只读状态。", + "freezeErrorTitle": "冻结命名空间失败", + "unfreezeSuccessTitle": "命名空间已解冻", + "unfreezeSuccessDescription": "“{{name}}”已恢复正常状态。", + "unfreezeErrorTitle": "解冻命名空间失败", + "archiveSuccessTitle": "命名空间已归档", + "archiveSuccessDescription": "“{{name}}”已从公开入口隐藏。", + "archiveErrorTitle": "归档命名空间失败", + "restoreSuccessTitle": "命名空间已恢复", + "restoreSuccessDescription": "“{{name}}”已重新开放治理能力。", + "restoreErrorTitle": "恢复命名空间失败", "emptyTitle": "还没有命名空间", "emptyDescription": "创建一个命名空间来组织你的技能" }, @@ -546,7 +576,10 @@ "colActions": "操作", "remove": "移除", "empty": "暂无成员", - "namespaceNotFound": "命名空间不存在" + "namespaceNotFound": "命名空间不存在", + "globalReadOnly": "这是系统内置命名空间,成员信息仅供查看,不能进行任何变更。", + "frozenReadOnly": "命名空间已冻结,成员列表仍可查看,但当前不能添加、移除或调整成员角色。", + "archivedReadOnly": "命名空间已归档,成员列表仍可查看,但在恢复前不能修改成员。" }, "upload": { "dropHint": "放开以上传文件...", @@ -564,7 +597,18 @@ "version": "版本 {{version}}", "tabPending": "待审核", "tabApproved": "已通过", - "tabRejected": "已拒绝" + "tabRejected": "已拒绝", + "globalReadOnly": "这是系统内置命名空间,仅可查看审核记录,不允许执行新的审核动作。", + "frozenReadOnly": "命名空间已冻结。你仍可查看审核记录,但当前不能继续处理审核任务。", + "archivedReadOnly": "命名空间已归档。你仍可查看审核记录,但在恢复前不能继续处理审核任务。" + }, + "namespaceStatus": { + "active": "正常", + "frozen": "已冻结", + "archived": "已归档", + "frozenHint": "命名空间当前为只读状态,不能继续发布、审核或调整成员。", + "archivedHint": "命名空间已从公开入口隐藏,仅管理台成员可见并可恢复。", + "immutableHint": "系统内置命名空间,不允许任何治理或成员修改操作。" }, "fileTree": { "title": "文件列表 ({{count}})" diff --git a/web/src/pages/dashboard/my-namespaces.tsx b/web/src/pages/dashboard/my-namespaces.tsx index b7641c26..a913f754 100644 --- a/web/src/pages/dashboard/my-namespaces.tsx +++ b/web/src/pages/dashboard/my-namespaces.tsx @@ -1,16 +1,30 @@ +import { useState } from 'react' import { useNavigate } from '@tanstack/react-router' import { useTranslation } from 'react-i18next' import { Button } from '@/shared/ui/button' import { Card } from '@/shared/ui/card' import { NamespaceBadge } from '@/shared/components/namespace-badge' import { EmptyState } from '@/shared/components/empty-state' +import { ConfirmDialog } from '@/shared/components/confirm-dialog' import { DashboardPageHeader } from '@/shared/components/dashboard-page-header' -import { useMyNamespaces } from '@/shared/hooks/use-skill-queries' +import { useArchiveNamespace, useFreezeNamespace, useMyNamespaces, useRestoreNamespace, useUnfreezeNamespace } from '@/shared/hooks/use-skill-queries' +import { toast } from '@/shared/lib/toast' + +type PendingNamespaceAction = + | { action: 'freeze'; slug: string; name: string } + | { action: 'unfreeze'; slug: string; name: string } + | { action: 'archive'; slug: string; name: string } + | { action: 'restore'; slug: string; name: string } export function MyNamespacesPage() { const navigate = useNavigate() const { t } = useTranslation() + const [pendingAction, setPendingAction] = useState(null) const { data: namespaces, isLoading } = useMyNamespaces() + const freezeMutation = useFreezeNamespace() + const unfreezeMutation = useUnfreezeNamespace() + const archiveMutation = useArchiveNamespace() + const restoreMutation = useRestoreNamespace() const handleNamespaceClick = (slug: string) => { navigate({ to: `/space/${slug}` }) @@ -26,6 +40,110 @@ export function MyNamespacesPage() { navigate({ to: `/dashboard/namespaces/${slug}/reviews` }) } + const resolveStatusLabel = (status: string) => { + if (status === 'FROZEN') { + return t('namespaceStatus.frozen') + } + if (status === 'ARCHIVED') { + return t('namespaceStatus.archived') + } + return t('namespaceStatus.active') + } + + const resolveStatusClassName = (status: string) => { + if (status === 'FROZEN') { + return 'bg-amber-500/10 text-amber-500 border-amber-500/20' + } + if (status === 'ARCHIVED') { + return 'bg-slate-500/10 text-slate-500 border-slate-500/20' + } + return 'bg-emerald-500/10 text-emerald-500 border-emerald-500/20' + } + + const resolveHint = (status: string, type: string) => { + if (type === 'GLOBAL') { + return t('myNamespaces.immutableHint') + } + if (status === 'FROZEN') { + return t('myNamespaces.frozenHint') + } + if (status === 'ARCHIVED') { + return t('myNamespaces.archivedHint') + } + return t('myNamespaces.activeHint') + } + + const resolveActionCopy = (action: PendingNamespaceAction['action'], name: string) => { + if (action === 'freeze') { + return { + title: t('myNamespaces.freezeConfirmTitle'), + description: t('myNamespaces.freezeConfirmDescription', { name }), + confirmText: t('myNamespaces.freeze'), + successTitle: t('myNamespaces.freezeSuccessTitle'), + successDescription: t('myNamespaces.freezeSuccessDescription', { name }), + errorTitle: t('myNamespaces.freezeErrorTitle'), + variant: 'default' as const, + } + } + if (action === 'unfreeze') { + return { + title: t('myNamespaces.unfreezeConfirmTitle'), + description: t('myNamespaces.unfreezeConfirmDescription', { name }), + confirmText: t('myNamespaces.unfreeze'), + successTitle: t('myNamespaces.unfreezeSuccessTitle'), + successDescription: t('myNamespaces.unfreezeSuccessDescription', { name }), + errorTitle: t('myNamespaces.unfreezeErrorTitle'), + variant: 'default' as const, + } + } + if (action === 'archive') { + return { + title: t('myNamespaces.archiveConfirmTitle'), + description: t('myNamespaces.archiveConfirmDescription', { name }), + confirmText: t('myNamespaces.archive'), + successTitle: t('myNamespaces.archiveSuccessTitle'), + successDescription: t('myNamespaces.archiveSuccessDescription', { name }), + errorTitle: t('myNamespaces.archiveErrorTitle'), + variant: 'destructive' as const, + } + } + return { + title: t('myNamespaces.restoreConfirmTitle'), + description: t('myNamespaces.restoreConfirmDescription', { name }), + confirmText: t('myNamespaces.restore'), + successTitle: t('myNamespaces.restoreSuccessTitle'), + successDescription: t('myNamespaces.restoreSuccessDescription', { name }), + errorTitle: t('myNamespaces.restoreErrorTitle'), + variant: 'default' as const, + } + } + + const handleNamespaceAction = async () => { + if (!pendingAction) { + return + } + + const copy = resolveActionCopy(pendingAction.action, pendingAction.name) + + try { + if (pendingAction.action === 'freeze') { + await freezeMutation.mutateAsync({ slug: pendingAction.slug }) + } else if (pendingAction.action === 'unfreeze') { + await unfreezeMutation.mutateAsync({ slug: pendingAction.slug }) + } else if (pendingAction.action === 'archive') { + await archiveMutation.mutateAsync({ slug: pendingAction.slug }) + } else { + await restoreMutation.mutateAsync({ slug: pendingAction.slug }) + } + + toast.success(copy.successTitle, copy.successDescription) + setPendingAction(null) + } catch (error) { + toast.error(copy.errorTitle, error instanceof Error ? error.message : '') + throw error + } + } + if (isLoading) { return (
@@ -63,6 +181,9 @@ export function MyNamespacesPage() { type={namespace.type} name={namespace.type === 'GLOBAL' ? t('myNamespaces.typeGlobal') : t('myNamespaces.typeTeam')} /> + + {resolveStatusLabel(namespace.status)} +
{namespace.description && (

@@ -70,9 +191,15 @@ export function MyNamespacesPage() {

)}
@{namespace.slug}
+
+ {resolveHint(namespace.status, namespace.type)} +
+
+ {t('myNamespaces.roleLabel')}: {namespace.currentUserRole ?? t('myNamespaces.roleUnknown')} +
-
+
{namespace.type === 'TEAM' && ( + {namespace.canFreeze && ( + + )} + {namespace.canUnfreeze && ( + + )} + {namespace.canArchive && ( + + )} + {namespace.canRestore && ( + + )}
@@ -101,6 +276,20 @@ export function MyNamespacesPage() { action={} /> )} + + { + if (!open) { + setPendingAction(null) + } + }} + title={pendingAction ? resolveActionCopy(pendingAction.action, pendingAction.name).title : ''} + description={pendingAction ? resolveActionCopy(pendingAction.action, pendingAction.name).description : ''} + confirmText={pendingAction ? resolveActionCopy(pendingAction.action, pendingAction.name).confirmText : undefined} + variant={pendingAction ? resolveActionCopy(pendingAction.action, pendingAction.name).variant : 'default'} + onConfirm={handleNamespaceAction} + /> ) } diff --git a/web/src/pages/dashboard/namespace-members.tsx b/web/src/pages/dashboard/namespace-members.tsx index b7af1374..5fc53cc3 100644 --- a/web/src/pages/dashboard/namespace-members.tsx +++ b/web/src/pages/dashboard/namespace-members.tsx @@ -15,6 +15,14 @@ export function NamespaceMembersPage() { const { data: namespace, isLoading: isLoadingNamespace } = useNamespaceDetail(slug) const { data: members, isLoading: isLoadingMembers } = useNamespaceMembers(slug) + const isReadOnly = namespace?.type === 'GLOBAL' || namespace?.status !== 'ACTIVE' + const readOnlyMessage = namespace?.type === 'GLOBAL' + ? t('members.globalReadOnly') + : namespace?.status === 'FROZEN' + ? t('members.frozenReadOnly') + : namespace?.status === 'ARCHIVED' + ? t('members.archivedReadOnly') + : null if (isLoadingNamespace) { return ( @@ -42,8 +50,14 @@ export function NamespaceMembersPage() {
+ {readOnlyMessage ? ( + + {readOnlyMessage} + + ) : null} +
- +
{isLoadingMembers ? ( @@ -77,7 +91,7 @@ export function NamespaceMembersPage() { {new Date(member.createdAt).toLocaleDateString(language)} - diff --git a/web/src/pages/dashboard/namespace-reviews.tsx b/web/src/pages/dashboard/namespace-reviews.tsx index 16f4cf7a..111a851f 100644 --- a/web/src/pages/dashboard/namespace-reviews.tsx +++ b/web/src/pages/dashboard/namespace-reviews.tsx @@ -5,6 +5,7 @@ import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/shared/ui/tabs' import { useNamespaceDetail } from '@/shared/hooks/use-skill-queries' import { useReviewList } from '@/features/review/use-review-list' import { DashboardPageHeader } from '@/shared/components/dashboard-page-header' +import { NamespaceHeader } from '@/features/namespace/namespace-header' function ReviewListSection({ namespaceId }: { namespaceId?: number }) { const { t } = useTranslation() @@ -54,6 +55,13 @@ export function NamespaceReviewsPage() { const { t } = useTranslation() const { slug } = useParams({ from: '/dashboard/namespaces/$slug/reviews' }) const { data: namespace } = useNamespaceDetail(slug) + const readOnlyMessage = namespace?.type === 'GLOBAL' + ? t('nsReviews.globalReadOnly') + : namespace?.status === 'FROZEN' + ? t('nsReviews.frozenReadOnly') + : namespace?.status === 'ARCHIVED' + ? t('nsReviews.archivedReadOnly') + : null return (
@@ -61,6 +69,12 @@ export function NamespaceReviewsPage() { title={t('nsReviews.title')} subtitle={namespace ? t('nsReviews.reviewsFor', { name: namespace.displayName }) : t('nsReviews.loadingNamespace')} /> + {namespace ? : null} + {readOnlyMessage ? ( + + {readOnlyMessage} + + ) : null}
) diff --git a/web/src/shared/hooks/use-skill-queries.ts b/web/src/shared/hooks/use-skill-queries.ts index 7b177fa0..b1f84104 100644 --- a/web/src/shared/hooks/use-skill-queries.ts +++ b/web/src/shared/hooks/use-skill-queries.ts @@ -1,6 +1,6 @@ import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query' -import type { SkillSummary, SkillDetail, SkillVersion, SkillVersionDetail, SkillFile, SearchParams, PagedResponse, PublishResult, Namespace, NamespaceMember } from '@/api/types' -import { fetchJson, fetchText, getCsrfHeaders, meApi, skillLifecycleApi, WEB_API_PREFIX } from '@/api/client' +import type { SkillSummary, SkillDetail, SkillVersion, SkillVersionDetail, SkillFile, SearchParams, PagedResponse, PublishResult, Namespace, NamespaceMember, ManagedNamespace } from '@/api/types' +import { fetchJson, fetchText, getCsrfHeaders, meApi, namespaceApi, skillLifecycleApi, WEB_API_PREFIX } from '@/api/client' const PUBLISH_REQUEST_TIMEOUT_MS = 60_000 @@ -56,14 +56,12 @@ async function getMyStars(): Promise { return meApi.getStars() } -async function getMyNamespaces(): Promise { - const page = await fetchJson>(`${WEB_API_PREFIX}/namespaces`) - return page.items +async function getMyNamespaces(): Promise { + return namespaceApi.listMine() } async function getNamespaceDetail(slug: string): Promise { - const cleanSlug = slug.startsWith('@') ? slug.slice(1) : slug - return fetchJson(`${WEB_API_PREFIX}/namespaces/${cleanSlug}`) + return namespaceApi.getDetail(slug) } async function getNamespaceMembers(slug: string): Promise { @@ -173,6 +171,13 @@ export function useNamespaceMembers(slug: string) { }) } +function invalidateNamespaceQueries(queryClient: ReturnType, slug: string) { + queryClient.invalidateQueries({ queryKey: ['namespaces', 'my'] }) + queryClient.invalidateQueries({ queryKey: ['namespaces', slug] }) + queryClient.invalidateQueries({ queryKey: ['namespaces', slug, 'members'] }) + queryClient.invalidateQueries({ queryKey: ['reviews'] }) +} + export function usePublishSkill() { const queryClient = useQueryClient() @@ -261,3 +266,47 @@ export function useRereleaseSkillVersion() { }, }) } + +export function useFreezeNamespace() { + const queryClient = useQueryClient() + + return useMutation({ + mutationFn: ({ slug }: { slug: string }) => namespaceApi.freeze(slug), + onSuccess: (_data, variables) => { + invalidateNamespaceQueries(queryClient, variables.slug) + }, + }) +} + +export function useUnfreezeNamespace() { + const queryClient = useQueryClient() + + return useMutation({ + mutationFn: ({ slug }: { slug: string }) => namespaceApi.unfreeze(slug), + onSuccess: (_data, variables) => { + invalidateNamespaceQueries(queryClient, variables.slug) + }, + }) +} + +export function useArchiveNamespace() { + const queryClient = useQueryClient() + + return useMutation({ + mutationFn: ({ slug, reason }: { slug: string; reason?: string }) => namespaceApi.archive(slug, reason), + onSuccess: (_data, variables) => { + invalidateNamespaceQueries(queryClient, variables.slug) + }, + }) +} + +export function useRestoreNamespace() { + const queryClient = useQueryClient() + + return useMutation({ + mutationFn: ({ slug }: { slug: string }) => namespaceApi.restore(slug), + onSuccess: (_data, variables) => { + invalidateNamespaceQueries(queryClient, variables.slug) + }, + }) +} From 93323766db322ba75977ba62191f302a256f70f1 Mon Sep 17 00:00:00 2001 From: yun-zhi-ztl <15071461069@163.com> Date: Mon, 16 Mar 2026 12:16:01 +0800 Subject: [PATCH 08/23] fix: stabilize backend dev module classpath --- Makefile | 8 +++++--- docs/dev-workflow.md | 5 +++-- scripts/check-openapi-generated.sh | 2 +- server/skillhub-app/pom.xml | 9 +++++++++ 4 files changed, 18 insertions(+), 6 deletions(-) diff --git a/Makefile b/Makefile index f8e6f012..70e1789a 100644 --- a/Makefile +++ b/Makefile @@ -11,6 +11,8 @@ STAGING_API_URL := http://localhost:8080 STAGING_WEB_URL := http://localhost STAGING_SERVER_IMAGE := skillhub-server:staging DEV_PROCESS := python3 scripts/dev_process.py +DEV_SERVER_PREPARE := ./mvnw -pl skillhub-app -am compile -DskipTests >/dev/null +DEV_SERVER_CMD := ./mvnw -pl skillhub-app spring-boot:run -Dspring-boot.run.profiles=local PARALLEL_BASE_REF ?= origin/main PARALLEL_WORKTREE_ROOT ?= DEV_COMPOSE_PROJECT_NAME ?= skillhub @@ -40,7 +42,7 @@ dev-all: ## 一键启动本地开发环境(依赖 + 后端 + 前端) echo "Backend already running with PID $$(cat $(DEV_SERVER_PID))"; \ else \ echo "Starting backend..."; \ - $(DEV_PROCESS) start --pid-file $(DEV_SERVER_PID) --log-file $(DEV_SERVER_LOG) --cwd server -- /bin/sh -lc './mvnw -pl skillhub-app -am install -DskipTests >/dev/null && exec ./mvnw -pl skillhub-app spring-boot:run -Dspring-boot.run.profiles=local' >/dev/null; \ + $(DEV_PROCESS) start --pid-file $(DEV_SERVER_PID) --log-file $(DEV_SERVER_LOG) --cwd server -- /bin/sh -lc '$(DEV_SERVER_PREPARE) && exec $(DEV_SERVER_CMD)' >/dev/null; \ fi @if $(DEV_PROCESS) status --pid-file $(DEV_WEB_PID) >/dev/null 2>&1; then \ echo "Frontend already running with PID $$(cat $(DEV_WEB_PID))"; \ @@ -66,7 +68,7 @@ dev-all: ## 一键启动本地开发环境(依赖 + 后端 + 前端) echo "Backend did not become ready on attempt $$attempt. Restarting..."; \ $(DEV_PROCESS) stop --pid-file $(DEV_SERVER_PID); \ sleep 2; \ - $(DEV_PROCESS) start --pid-file $(DEV_SERVER_PID) --log-file $(DEV_SERVER_LOG) --cwd server -- /bin/sh -lc './mvnw -pl skillhub-app -am install -DskipTests >/dev/null && exec ./mvnw -pl skillhub-app spring-boot:run -Dspring-boot.run.profiles=local' >/dev/null; \ + $(DEV_PROCESS) start --pid-file $(DEV_SERVER_PID) --log-file $(DEV_SERVER_LOG) --cwd server -- /bin/sh -lc '$(DEV_SERVER_PREPARE) && exec $(DEV_SERVER_CMD)' >/dev/null; \ fi; \ done; \ if [ "$$backend_ready" -ne 1 ]; then \ @@ -98,7 +100,7 @@ dev-all: ## 一键启动本地开发环境(依赖 + 后端 + 前端) @echo " Frontend: $(DEV_WEB_LOG)" dev-server: ## 启动后端开发服务器 - cd server && /bin/sh -lc './mvnw -pl skillhub-app -am install -DskipTests >/dev/null && exec ./mvnw -pl skillhub-app spring-boot:run -Dspring-boot.run.profiles=local' + cd server && /bin/sh -lc '$(DEV_SERVER_PREPARE) && exec $(DEV_SERVER_CMD)' dev-down: ## 停止本地开发环境 $(DEV_COMPOSE) down --remove-orphans diff --git a/docs/dev-workflow.md b/docs/dev-workflow.md index d982dc44..9878517a 100644 --- a/docs/dev-workflow.md +++ b/docs/dev-workflow.md @@ -32,8 +32,9 @@ SkillHub now pins a shared Docker Compose project name for local development, so **Backend:** Spring Boot DevTools is configured. After editing Java code: 1. In IntelliJ IDEA: press `Cmd+F9` (Build Project) -2. The backend restarts automatically in 3-8 seconds -3. Watch the terminal running `make dev-server` for the restart log +2. If you changed code in another backend module such as `skillhub-domain` or `skillhub-search`, make sure the whole server project is rebuilt, not just `skillhub-app` +3. The backend restarts automatically in 3-8 seconds +4. Watch the terminal running `make dev-server` for the restart log ### Mock authentication diff --git a/scripts/check-openapi-generated.sh b/scripts/check-openapi-generated.sh index c111ab0f..9a9f79f8 100755 --- a/scripts/check-openapi-generated.sh +++ b/scripts/check-openapi-generated.sh @@ -34,7 +34,7 @@ docker compose up -d --wait postgres redis ( cd "$SERVER_DIR" - ./mvnw -pl skillhub-app -am -DskipTests install + ./mvnw -pl skillhub-app -am compile -DskipTests ) >"$BUILD_LOG" 2>&1 || { echo "Failed to prepare backend modules. See $BUILD_LOG" >&2 print_log_tail "$BUILD_LOG" diff --git a/server/skillhub-app/pom.xml b/server/skillhub-app/pom.xml index f7ddf6ca..377e1616 100644 --- a/server/skillhub-app/pom.xml +++ b/server/skillhub-app/pom.xml @@ -101,6 +101,15 @@ org.springframework.boot spring-boot-maven-plugin + + + ${project.parent.basedir}/skillhub-domain/target/classes + ${project.parent.basedir}/skillhub-auth/target/classes + ${project.parent.basedir}/skillhub-infra/target/classes + ${project.parent.basedir}/skillhub-search/target/classes + ${project.parent.basedir}/skillhub-storage/target/classes + + From 889a6d3b9db609aaa518b28a4cce29cd6069b6d2 Mon Sep 17 00:00:00 2001 From: yun-zhi-ztl <15071461069@163.com> Date: Mon, 16 Mar 2026 12:23:19 +0800 Subject: [PATCH 09/23] feat: add namespace creation dialog --- web/src/api/client.ts | 15 ++ web/src/api/types.ts | 6 + .../namespace/create-namespace-dialog.tsx | 239 ++++++++++++++++++ web/src/i18n/locales/en.json | 23 ++ web/src/i18n/locales/zh.json | 23 ++ web/src/pages/dashboard/my-namespaces.tsx | 13 +- web/src/shared/hooks/use-skill-queries.ts | 19 +- 7 files changed, 335 insertions(+), 3 deletions(-) create mode 100644 web/src/features/namespace/create-namespace-dialog.tsx diff --git a/web/src/api/client.ts b/web/src/api/client.ts index 1bfa5f1f..a0b7410b 100644 --- a/web/src/api/client.ts +++ b/web/src/api/client.ts @@ -21,6 +21,7 @@ import type { User, ManagedNamespace, Namespace, + CreateNamespaceRequest, } from './types' import { ApiError } from '@/shared/lib/api-error' import i18n from '@/i18n/config' @@ -467,6 +468,20 @@ function normalizeNamespaceSlug(namespace: string): string { } export const namespaceApi = { + async create(request: CreateNamespaceRequest): Promise { + const namespace = await unwrap(client.POST('/api/v1/namespaces', { + headers: await ensureCsrfHeaders({ + 'Content-Type': 'application/json', + }), + body: { + slug: normalizeNamespaceSlug(request.slug), + displayName: request.displayName.trim(), + description: request.description?.trim() || undefined, + }, + } as never) as never) + return namespace + }, + async listMine(): Promise { return fetchJson(`${WEB_API_PREFIX}/me/namespaces`) }, diff --git a/web/src/api/types.ts b/web/src/api/types.ts index 36a70f77..e2667969 100644 --- a/web/src/api/types.ts +++ b/web/src/api/types.ts @@ -61,6 +61,12 @@ export interface ChangePasswordRequest { newPassword: string } +export type CreateNamespaceRequest = Omit & { + slug: string + displayName: string + description?: string +} + export interface MergeInitiateRequest { secondaryIdentifier: string } diff --git a/web/src/features/namespace/create-namespace-dialog.tsx b/web/src/features/namespace/create-namespace-dialog.tsx new file mode 100644 index 00000000..733e402b --- /dev/null +++ b/web/src/features/namespace/create-namespace-dialog.tsx @@ -0,0 +1,239 @@ +import { useState } from 'react' +import { useTranslation } from 'react-i18next' +import type { CreateNamespaceRequest } from '@/api/types' +import { useCreateNamespace } from '@/shared/hooks/use-skill-queries' +import { toast } from '@/shared/lib/toast' +import { Button } from '@/shared/ui/button' +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, + DialogTrigger, +} from '@/shared/ui/dialog' +import { Input } from '@/shared/ui/input' +import { Label } from '@/shared/ui/label' +import { Textarea } from '@/shared/ui/textarea' + +interface CreateNamespaceDialogProps { + children: React.ReactNode +} + +type FieldErrors = { + slug?: string + displayName?: string + description?: string +} + +const SLUG_PATTERN = /^[a-z0-9](?:[a-z0-9-]*[a-z0-9])?$/ +const RESERVED_SLUGS = new Set([ + 'admin', + 'api', + 'dashboard', + 'search', + 'auth', + 'me', + 'global', + 'system', + 'static', + 'assets', + 'health', +]) +const MAX_SLUG_LENGTH = 64 +const MIN_SLUG_LENGTH = 2 +const MAX_DISPLAY_NAME_LENGTH = 128 +const MAX_DESCRIPTION_LENGTH = 512 + +function buildFieldErrors(request: CreateNamespaceRequest, t: (key: string, options?: Record) => string): FieldErrors { + const errors: FieldErrors = {} + const slug = request.slug.trim() + const displayName = request.displayName.trim() + const description = request.description?.trim() ?? '' + + if (!slug) { + errors.slug = t('myNamespaces.createSlugRequired') + } else if (slug.length < MIN_SLUG_LENGTH || slug.length > MAX_SLUG_LENGTH) { + errors.slug = t('myNamespaces.createSlugLength', { min: MIN_SLUG_LENGTH, max: MAX_SLUG_LENGTH }) + } else if (!SLUG_PATTERN.test(slug)) { + errors.slug = t('myNamespaces.createSlugPattern') + } else if (slug.includes('--')) { + errors.slug = t('myNamespaces.createSlugDoubleHyphen') + } else if (RESERVED_SLUGS.has(slug)) { + errors.slug = t('myNamespaces.createSlugReserved', { slug }) + } + + if (!displayName) { + errors.displayName = t('myNamespaces.createDisplayNameRequired') + } else if (displayName.length > MAX_DISPLAY_NAME_LENGTH) { + errors.displayName = t('myNamespaces.createDisplayNameLength', { max: MAX_DISPLAY_NAME_LENGTH }) + } + + if (description.length > MAX_DESCRIPTION_LENGTH) { + errors.description = t('myNamespaces.createDescriptionLength', { max: MAX_DESCRIPTION_LENGTH }) + } + + return errors +} + +export function CreateNamespaceDialog({ children }: CreateNamespaceDialogProps) { + const { t } = useTranslation() + const createMutation = useCreateNamespace() + const [open, setOpen] = useState(false) + const [slug, setSlug] = useState('') + const [displayName, setDisplayName] = useState('') + const [description, setDescription] = useState('') + const [errors, setErrors] = useState({}) + + const resetDialog = () => { + setSlug('') + setDisplayName('') + setDescription('') + setErrors({}) + createMutation.reset() + } + + const handleOpenChange = (nextOpen: boolean) => { + setOpen(nextOpen) + if (!nextOpen) { + resetDialog() + } + } + + const normalizedRequest: CreateNamespaceRequest = { + slug: slug.trim().toLowerCase(), + displayName: displayName.trim(), + description: description.trim() || undefined, + } + + const handleSubmit = async () => { + const nextErrors = buildFieldErrors(normalizedRequest, t) + if (Object.keys(nextErrors).length > 0) { + setErrors(nextErrors) + return + } + + try { + const namespace = await createMutation.mutateAsync(normalizedRequest) + toast.success( + t('myNamespaces.createSuccessTitle'), + t('myNamespaces.createSuccessDescription', { name: namespace.displayName }), + ) + handleOpenChange(false) + } catch (error) { + toast.error(t('myNamespaces.createErrorTitle'), error instanceof Error ? error.message : '') + } + } + + const slugLength = slug.trim().length + const displayNameLength = displayName.trim().length + const descriptionLength = description.trim().length + + return ( + + {children} + + + {t('myNamespaces.createDialogTitle')} + + {t('myNamespaces.createDialogDescription')} + + + +
+
+ + { + setSlug(event.target.value.toLowerCase()) + if (errors.slug) { + setErrors((current) => ({ ...current, slug: undefined })) + } + }} + onKeyDown={(event) => { + if (event.key === 'Enter' && !event.shiftKey) { + event.preventDefault() + handleSubmit() + } + }} + aria-invalid={errors.slug ? 'true' : 'false'} + /> +
+ {errors.slug ?? t('myNamespaces.createSlugHint')} + {slugLength}/{MAX_SLUG_LENGTH} +
+
+ +
+ + { + setDisplayName(event.target.value) + if (errors.displayName) { + setErrors((current) => ({ ...current, displayName: undefined })) + } + }} + onKeyDown={(event) => { + if (event.key === 'Enter' && !event.shiftKey) { + event.preventDefault() + handleSubmit() + } + }} + aria-invalid={errors.displayName ? 'true' : 'false'} + /> +
+ {errors.displayName ?? ''} + {displayNameLength}/{MAX_DISPLAY_NAME_LENGTH} +
+
+ +
+ +