diff --git a/.env.release.example b/.env.release.example index 2b659ef1..dd1ccbab 100644 --- a/.env.release.example +++ b/.env.release.example @@ -40,7 +40,7 @@ SKILLHUB_STORAGE_S3_AUTO_CREATE_BUCKET=false SKILLHUB_STORAGE_S3_PRESIGN_EXPIRY=PT10M # Bootstrap local admin account for first login. Rotate or disable after initial setup. -BOOTSTRAP_ADMIN_ENABLED=true +BOOTSTRAP_ADMIN_ENABLED=false BOOTSTRAP_ADMIN_USER_ID=docker-admin BOOTSTRAP_ADMIN_USERNAME=admin BOOTSTRAP_ADMIN_PASSWORD=replace-this-admin-password diff --git a/Makefile b/Makefile index f8e6f012..91faf34f 100644 --- a/Makefile +++ b/Makefile @@ -1,4 +1,4 @@ -.PHONY: help dev dev-all dev-down dev-all-down dev-all-reset dev-logs dev-status build test clean web-install dev-server dev-web build-web test-web typecheck-web lint-web generate-api db-reset validate-release-config staging staging-down staging-logs pr parallel-init parallel-sync parallel-up parallel-down +.PHONY: help dev dev-all dev-down dev-all-down dev-all-reset dev-logs dev-status build test clean web-install dev-server dev-server-restart dev-web build-web test-web typecheck-web lint-web generate-api db-reset namespace-smoke validate-release-config staging staging-down staging-logs pr parallel-init parallel-sync parallel-up parallel-down DEV_DIR := .dev DEV_SERVER_PID := $(DEV_DIR)/server.pid @@ -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 := true +DEV_SERVER_CMD := ./scripts/run-dev-app.sh 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,25 @@ 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-server-restart: ## 重启后端开发服务器 + @mkdir -p $(DEV_DIR) + @$(DEV_PROCESS) stop --pid-file $(DEV_SERVER_PID) + @$(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 + @echo "Waiting for backend on $(DEV_API_URL) ..." + @for i in $$(seq 1 30); do \ + if curl -sf $(DEV_API_URL)/actuator/health >/dev/null; then \ + echo "Backend ready."; \ + exit 0; \ + fi; \ + sleep 2; \ + done; \ + echo "Backend failed to become ready. Check $(DEV_SERVER_LOG)"; \ + exit 1 + +namespace-smoke: ## 运行命名空间工作流 smoke test + ./scripts/namespace-smoke-test.sh $(DEV_API_URL) dev-down: ## 停止本地开发环境 $(DEV_COMPOSE) down --remove-orphans diff --git a/README.md b/README.md index 9017795f..d14af8b6 100644 --- a/README.md +++ b/README.md @@ -96,6 +96,12 @@ Local profile seeds two mock-auth users automatically: Use them with the `X-Mock-User-Id` header in local development. +The backend can bootstrap a local-login super admin for first-time access +when you explicitly set `BOOTSTRAP_ADMIN_ENABLED=true`: + +- username: `BOOTSTRAP_ADMIN_USERNAME` (`admin` by default) +- password: `BOOTSTRAP_ADMIN_PASSWORD` (`ChangeMe!2026` by default) + Stop everything with: ```bash @@ -173,8 +179,9 @@ The runtime stack uses its own Compose project name, so it does not collide with containers from `make dev-all`. The production Compose stack now defaults to the `docker` profile only. -It does not enable local mock auth. Instead, the backend bootstraps a -local admin account from environment variables for the first login: +It does not enable local mock auth. Bootstrap admin is disabled by default; +if you turn it on explicitly, the backend seeds a local admin account from +environment variables for the first login: - username: `BOOTSTRAP_ADMIN_USERNAME` - password: `BOOTSTRAP_ADMIN_PASSWORD` @@ -184,6 +191,7 @@ Recommended production baseline: - set `SKILLHUB_PUBLIC_BASE_URL` to the final HTTPS entrypoint - keep PostgreSQL / Redis bound to `127.0.0.1` - use external S3 / OSS via `SKILLHUB_STORAGE_S3_*` +- keep `BOOTSTRAP_ADMIN_ENABLED=false` unless you intentionally need bootstrap login - rotate or disable the bootstrap admin after initial setup - run `make validate-release-config` before `docker compose up -d` diff --git a/compose.release.yml b/compose.release.yml index 99f0934d..8a9aa4ca 100644 --- a/compose.release.yml +++ b/compose.release.yml @@ -56,7 +56,7 @@ services: SKILLHUB_STORAGE_S3_FORCE_PATH_STYLE: ${SKILLHUB_STORAGE_S3_FORCE_PATH_STYLE:-false} SKILLHUB_STORAGE_S3_AUTO_CREATE_BUCKET: ${SKILLHUB_STORAGE_S3_AUTO_CREATE_BUCKET:-false} SKILLHUB_STORAGE_S3_PRESIGN_EXPIRY: ${SKILLHUB_STORAGE_S3_PRESIGN_EXPIRY:-PT10M} - BOOTSTRAP_ADMIN_ENABLED: ${BOOTSTRAP_ADMIN_ENABLED:-true} + BOOTSTRAP_ADMIN_ENABLED: ${BOOTSTRAP_ADMIN_ENABLED:-false} BOOTSTRAP_ADMIN_USER_ID: ${BOOTSTRAP_ADMIN_USER_ID:-docker-admin} BOOTSTRAP_ADMIN_USERNAME: ${BOOTSTRAP_ADMIN_USERNAME:-admin} BOOTSTRAP_ADMIN_PASSWORD: ${BOOTSTRAP_ADMIN_PASSWORD:-ChangeMe!2026} diff --git a/docs/09-deployment.md b/docs/09-deployment.md index 52e4849e..f5798cec 100644 --- a/docs/09-deployment.md +++ b/docs/09-deployment.md @@ -47,23 +47,25 @@ | Profile | 用途 | 说明 | |---------|------|------| | `local` | 本地源码开发能力 | 启用 mock 登录、开发种子账号、调试日志 | -| `docker` | 容器运行时能力 | 启用容器内启动用管理员账号初始化等运行时行为 | +| `docker` | 容器运行时能力 | 启用容器运行时相关能力,不会自动打开首登管理员 | 单机交付环境使用 `SPRING_PROFILES_ACTIVE=docker`,原因如下: - 生产环境不应开启 `X-Mock-User-Id` 这一类本地开发旁路能力 -- 容器环境仍然可以通过 `docker` profile 初始化首个管理员账户 +- 容器环境仍然保留 `docker` profile 的运行时能力,但首个管理员账户初始化本身不再依赖该 profile,且默认关闭 - 数据库、Redis、OSS、站点公网地址全部改为环境变量优先 -默认首登账号来源于环境变量: +如需启用首登管理员,来源于以下环境变量: +- `BOOTSTRAP_ADMIN_ENABLED=true` - `BOOTSTRAP_ADMIN_USERNAME` - `BOOTSTRAP_ADMIN_PASSWORD` 建议: +- 默认保持 `BOOTSTRAP_ADMIN_ENABLED=false` - 完成首次登录后立即修改管理员密码 -- 如果已有外部身份源,可将 `BOOTSTRAP_ADMIN_ENABLED=false` +- 如果已有外部身份源,通常不需要启用 bootstrap admin - `SKILLHUB_PUBLIC_BASE_URL` 应配置为最终 HTTPS 域名,避免 OAuth / Cookie / 设备码链接异常 ## 4 开发环境 @@ -206,7 +208,8 @@ docker compose --env-file .env.release -f compose.release.yml up -d - `SKILLHUB_PUBLIC_BASE_URL` 填最终 HTTPS 域名,且不要带尾部 `/` - `SKILLHUB_STORAGE_PROVIDER=s3` - 按云厂商 OSS / S3 兼容参数填写 `SKILLHUB_STORAGE_S3_*` - - 设置非默认的 `POSTGRES_PASSWORD` 与 `BOOTSTRAP_ADMIN_PASSWORD` + - 设置非默认的 `POSTGRES_PASSWORD` + - 如果要启用首登管理员,再额外设置 `BOOTSTRAP_ADMIN_ENABLED=true` 与非默认的 `BOOTSTRAP_ADMIN_PASSWORD` 3. 启动前校验 - 运行 `make validate-release-config` - 确认没有 `replace-me`、`change-this-*`、`ChangeMe!2026` 之类的占位值 @@ -215,7 +218,7 @@ docker compose --env-file .env.release -f compose.release.yml up -d - 检查 `docker compose --env-file .env.release -f compose.release.yml ps` - 检查 `curl -i http://127.0.0.1:8080/actuator/health` 5. 首登收尾 - - 使用 `BOOTSTRAP_ADMIN_USERNAME` / `BOOTSTRAP_ADMIN_PASSWORD` 登录 + - 仅在启用了 `BOOTSTRAP_ADMIN_ENABLED=true` 时,使用 `BOOTSTRAP_ADMIN_USERNAME` / `BOOTSTRAP_ADMIN_PASSWORD` 登录 - 立即修改管理员密码 - 如果后续完全走 OAuth,可将 `BOOTSTRAP_ADMIN_ENABLED=false` diff --git a/docs/dev-workflow.md b/docs/dev-workflow.md index d982dc44..c5f6dd4d 100644 --- a/docs/dev-workflow.md +++ b/docs/dev-workflow.md @@ -26,14 +26,19 @@ This starts: SkillHub now pins a shared Docker Compose project name for local development, so multiple git worktrees can reuse the same dependency containers instead of fighting over `5432`, `6379`, and `9000`. -### Hot reload +### Backend restarts **Frontend:** Vite HMR is enabled by default. Save a file and the browser updates instantly. -**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 +**Backend:** the local server now runs from a packaged Spring Boot jar instead of `spring-boot:run`. This avoids mixed classpaths across `skillhub-app`, `skillhub-auth`, `skillhub-domain`, and other sibling modules. + +After editing backend code, restart the backend explicitly: + +```bash +make dev-server-restart +``` + +If you are running the server in a foreground terminal instead of `make dev-all`, stop it and run `make dev-server` again. Expect a full restart in about 5-10 seconds, including rebuilding the backend modules. ### Mock authentication @@ -54,6 +59,8 @@ Two mock users are available in local mode (no password needed): | `make dev-logs` | Tail backend logs | | `SERVICE=frontend make dev-logs` | Tail frontend logs | | `make dev-all-reset` | Full reset (clears data volumes) | +| `make dev-server-restart` | Restart backend after Java changes | +| `make namespace-smoke` | Run namespace workflow smoke test | | `make db-reset` | Reset database only | ### Claude + Codex parallel workflow 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? 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` 完全只读,避免未来平台和团队混用治理规则 diff --git a/document/docs/02-administration/deployment/configuration.md b/document/docs/02-administration/deployment/configuration.md index fca63288..e89b3389 100644 --- a/document/docs/02-administration/deployment/configuration.md +++ b/document/docs/02-administration/deployment/configuration.md @@ -56,7 +56,7 @@ SkillHub 通过环境变量进行配置,主要配置项如下: | 环境变量 | 说明 | 默认值 | |---------|------|--------| -| `BOOTSTRAP_ADMIN_ENABLED` | 是否启用首登管理员 | `true` | +| `BOOTSTRAP_ADMIN_ENABLED` | 是否启用首登管理员 | `false` | | `BOOTSTRAP_ADMIN_USERNAME` | 首登管理员用户名 | - | | `BOOTSTRAP_ADMIN_PASSWORD` | 首登管理员密码 | - | diff --git a/document/i18n/en/docusaurus-plugin-content-docs/current/02-administration/deployment/configuration.md b/document/i18n/en/docusaurus-plugin-content-docs/current/02-administration/deployment/configuration.md index fd5f3951..5e0e502b 100644 --- a/document/i18n/en/docusaurus-plugin-content-docs/current/02-administration/deployment/configuration.md +++ b/document/i18n/en/docusaurus-plugin-content-docs/current/02-administration/deployment/configuration.md @@ -56,7 +56,7 @@ SkillHub is configured through environment variables. The main configuration ite | Environment Variable | Description | Default Value | |---------------------|-------------|---------------| -| `BOOTSTRAP_ADMIN_ENABLED` | Enable bootstrap admin | `true` | +| `BOOTSTRAP_ADMIN_ENABLED` | Enable bootstrap admin | `false` | | `BOOTSTRAP_ADMIN_USERNAME` | Bootstrap admin username | - | | `BOOTSTRAP_ADMIN_PASSWORD` | Bootstrap admin password | - | diff --git a/scripts/check-openapi-generated.sh b/scripts/check-openapi-generated.sh index c111ab0f..ddfaad32 100755 --- a/scripts/check-openapi-generated.sh +++ b/scripts/check-openapi-generated.sh @@ -34,16 +34,7 @@ docker compose up -d --wait postgres redis ( cd "$SERVER_DIR" - ./mvnw -pl skillhub-app -am -DskipTests install -) >"$BUILD_LOG" 2>&1 || { - echo "Failed to prepare backend modules. See $BUILD_LOG" >&2 - print_log_tail "$BUILD_LOG" - exit 1 -} - -( - cd "$SERVER_DIR" - SPRING_PROFILES_ACTIVE=local ./mvnw -pl skillhub-app spring-boot:run + SPRING_PROFILES_ACTIVE=local ./scripts/run-dev-app.sh ) >"$API_LOG" 2>&1 & SERVER_PID=$! diff --git a/scripts/governance-smoke-test.sh b/scripts/governance-smoke-test.sh new file mode 100755 index 00000000..2e8c0fb3 --- /dev/null +++ b/scripts/governance-smoke-test.sh @@ -0,0 +1,117 @@ +#!/usr/bin/env bash + +set -euo pipefail + +BASE_URL="${1:-http://localhost:8080}" +PASS=0 +FAIL=0 +COOKIE_FILE="$(mktemp)" + +cleanup() { + rm -f "$COOKIE_FILE" +} + +trap cleanup EXIT + +pass() { + echo "PASS: $1" + PASS=$((PASS + 1)) +} + +fail() { + echo "FAIL: $1" + FAIL=$((FAIL + 1)) +} + +json_field() { + local json="$1" + local expr="$2" + JSON_INPUT="$json" python3 - "$expr" <<'PY' +import json +import os +import sys + +expr = sys.argv[1] +value = json.loads(os.environ["JSON_INPUT"]) +for part in expr.split('.'): + if not part: + continue + if part.isdigit(): + value = value[int(part)] + else: + value = value[part] +if isinstance(value, (dict, list)): + print(json.dumps(value, ensure_ascii=False)) +else: + print(value) +PY +} + +assert_code() { + local description="$1" + local json="$2" + local expected="$3" + local actual + actual="$(json_field "$json" "code")" + if [[ "$actual" == "$expected" ]]; then + pass "$description" + else + fail "$description (expected code $expected, got $actual)" + fi +} + +assert_json_expr() { + local description="$1" + local json="$2" + local script="$3" + if JSON_INPUT="$json" python3 - </dev/null + +ADMIN_HEADERS=(-H "X-Mock-User-Id: local-admin" -b "$COOKIE_FILE" -c "$COOKIE_FILE") + +SUMMARY_RESPONSE="$(curl -sS "${ADMIN_HEADERS[@]}" "$BASE_URL/api/web/governance/summary")" +assert_code "Governance summary endpoint is available" "$SUMMARY_RESPONSE" "0" +assert_json_expr "Governance summary exposes review/promotion/report counts" "$SUMMARY_RESPONSE" $'summary = data["data"]\nassert "pendingReviews" in summary\nassert "pendingPromotions" in summary\nassert "pendingReports" in summary' + +INBOX_RESPONSE="$(curl -sS "${ADMIN_HEADERS[@]}" "$BASE_URL/api/web/governance/inbox")" +assert_code "Governance inbox endpoint is available" "$INBOX_RESPONSE" "0" +assert_json_expr "Governance inbox returns paged items" "$INBOX_RESPONSE" $'payload = data["data"]\nassert isinstance(payload["items"], list)\nassert payload["page"] == 0' + +ACTIVITY_RESPONSE="$(curl -sS "${ADMIN_HEADERS[@]}" "$BASE_URL/api/web/governance/activity")" +assert_code "Governance activity endpoint is available" "$ACTIVITY_RESPONSE" "0" +assert_json_expr "Governance activity returns paged items" "$ACTIVITY_RESPONSE" $'payload = data["data"]\nassert isinstance(payload["items"], list)\nassert payload["page"] == 0' + +NOTIFICATIONS_RESPONSE="$(curl -sS "${ADMIN_HEADERS[@]}" "$BASE_URL/api/web/governance/notifications")" +assert_code "Governance notifications endpoint is available" "$NOTIFICATIONS_RESPONSE" "0" +assert_json_expr "Governance notifications returns a list" "$NOTIFICATIONS_RESPONSE" $'assert isinstance(data["data"], list)' + +REPORTS_RESPONSE="$(curl -sS "${ADMIN_HEADERS[@]}" "$BASE_URL/api/v1/admin/skill-reports?status=PENDING&page=0&size=5")" +assert_code "Admin report list endpoint is available" "$REPORTS_RESPONSE" "0" +assert_json_expr "Admin report list returns page metadata" "$REPORTS_RESPONSE" $'payload = data["data"]\nassert isinstance(payload["items"], list)\nassert payload["size"] == 5' + +AUDIT_RESPONSE="$(curl -sS "${ADMIN_HEADERS[@]}" "$BASE_URL/api/v1/admin/audit-logs?action=REVIEW_APPROVE&page=0&size=5")" +assert_code "Audit log endpoint is available for governance filters" "$AUDIT_RESPONSE" "0" +assert_json_expr "Audit log endpoint returns page metadata" "$AUDIT_RESPONSE" $'payload = data["data"]\nassert isinstance(payload["items"], list)\nassert payload["size"] == 5' + +echo +echo "Results: $PASS passed, $FAIL failed" +if [[ "$FAIL" -ne 0 ]]; then + exit 1 +fi diff --git a/scripts/namespace-smoke-test.sh b/scripts/namespace-smoke-test.sh new file mode 100755 index 00000000..ae2b7b59 --- /dev/null +++ b/scripts/namespace-smoke-test.sh @@ -0,0 +1,257 @@ +#!/usr/bin/env bash + +set -euo pipefail + +BASE_URL="${1:-http://localhost:8080}" +PASS=0 +FAIL=0 +USER_COOKIE="$(mktemp)" +ADMIN_COOKIE="$(mktemp)" +SLUG="nsmoke$(date +%s)" + +cleanup() { + rm -f "$USER_COOKIE" "$ADMIN_COOKIE" +} + +trap cleanup EXIT + +pass() { + echo "PASS: $1" + PASS=$((PASS + 1)) +} + +fail() { + echo "FAIL: $1" + FAIL=$((FAIL + 1)) +} + +csrf_token() { + local cookie_file="$1" + awk '$6 == "XSRF-TOKEN" { print $7 }' "$cookie_file" | tail -n 1 +} + +bootstrap_csrf() { + local cookie_file="$1" + local user_id="$2" + curl -s -c "$cookie_file" -H "X-Mock-User-Id: $user_id" "$BASE_URL/api/v1/auth/providers" >/dev/null +} + +json_field() { + local json="$1" + local expr="$2" + JSON_INPUT="$json" python3 - "$expr" <<'PY' +import json +import os +import sys + +expr = sys.argv[1] +data = json.loads(os.environ["JSON_INPUT"]) +value = data +for part in expr.split('.'): + if part.isdigit(): + value = value[int(part)] + else: + value = value[part] +if isinstance(value, (dict, list)): + print(json.dumps(value, ensure_ascii=False)) +else: + print(value) +PY +} + +assert_code() { + local description="$1" + local json="$2" + local expected="$3" + local actual + actual="$(json_field "$json" "code")" + if [[ "$actual" == "$expected" ]]; then + pass "$description" + else + fail "$description (expected code $expected, got $actual)" + fi +} + +USER_HEADERS=(-H "X-Mock-User-Id: local-user" -b "$USER_COOKIE" -c "$USER_COOKIE") +ADMIN_HEADERS=(-H "X-Mock-User-Id: local-admin" -b "$ADMIN_COOKIE" -c "$ADMIN_COOKIE") + +echo "=== Namespace Workflow Smoke Test ===" +echo "Target: $BASE_URL" +echo "Slug: $SLUG" +echo + +bootstrap_csrf "$USER_COOKIE" "local-user" +bootstrap_csrf "$ADMIN_COOKIE" "local-admin" + +USER_CSRF="$(csrf_token "$USER_COOKIE")" +ADMIN_CSRF="$(csrf_token "$ADMIN_COOKIE")" + +if [[ -z "$USER_CSRF" || -z "$ADMIN_CSRF" ]]; then + echo "Could not bootstrap CSRF tokens" + exit 1 +fi + +CREATE_RESPONSE="$(curl -sS "${USER_HEADERS[@]}" \ + -H "X-XSRF-TOKEN: $USER_CSRF" \ + -H "Content-Type: application/json" \ + -X POST "$BASE_URL/api/web/namespaces" \ + -d "{\"slug\":\"$SLUG\",\"displayName\":\"Namespace Smoke $SLUG\",\"description\":\"namespace workflow smoke test\"}")" +assert_code "Owner can create namespace" "$CREATE_RESPONSE" "0" +NAMESPACE_ID="$(json_field "$CREATE_RESPONSE" "data.id")" + +MINE_RESPONSE="$(curl -sS "${USER_HEADERS[@]}" "$BASE_URL/api/web/me/namespaces")" +assert_code "Owner can list my namespaces" "$MINE_RESPONSE" "0" +if JSON_INPUT="$MINE_RESPONSE" python3 - "$SLUG" <<'PY' +import json +import os +import sys +slug = sys.argv[1] +data = json.loads(os.environ["JSON_INPUT"]) +items = data["data"] +match = next((item for item in items if item["slug"] == slug), None) +if not match: + raise SystemExit(1) +if match["currentUserRole"] != "OWNER": + raise SystemExit(2) +if match["status"] != "ACTIVE": + raise SystemExit(3) +PY +then + pass "Created namespace shows up as ACTIVE owner namespace" +else + fail "Created namespace should appear in owner namespace list with OWNER role" +fi + +ADMIN_MINE_RESPONSE="$(curl -sS "${ADMIN_HEADERS[@]}" "$BASE_URL/api/web/me/namespaces")" +assert_code "Other user can list my namespaces" "$ADMIN_MINE_RESPONSE" "0" +if JSON_INPUT="$ADMIN_MINE_RESPONSE" python3 - "$SLUG" <<'PY' +import json +import os +import sys +slug = sys.argv[1] +data = json.loads(os.environ["JSON_INPUT"]) +items = data["data"] +raise SystemExit(0 if all(item["slug"] != slug for item in items) else 1) +PY +then + pass "Namespace is not visible to unrelated users in my namespaces" +else + fail "Unrelated user should not see team namespace in my namespaces" +fi + +FREEZE_FORBIDDEN_RESPONSE="$(curl -sS "${ADMIN_HEADERS[@]}" \ + -H "X-XSRF-TOKEN: $ADMIN_CSRF" \ + -X POST "$BASE_URL/api/web/namespaces/$SLUG/freeze")" +assert_code "Unrelated user cannot freeze namespace" "$FREEZE_FORBIDDEN_RESPONSE" "403" + +CANDIDATES_RESPONSE="$(curl -sS "${USER_HEADERS[@]}" "$BASE_URL/api/web/namespaces/$SLUG/member-candidates?search=local")" +assert_code "Owner can search namespace member candidates" "$CANDIDATES_RESPONSE" "0" +if JSON_INPUT="$CANDIDATES_RESPONSE" python3 - <<'PY' +import json +import os +import sys +data = json.loads(os.environ["JSON_INPUT"]) +ids = {item["userId"] for item in data["data"]} +raise SystemExit(0 if "local-admin" in ids else 1) +PY +then + pass "Candidate search returns local-admin" +else + fail "Candidate search should include local-admin" +fi + +ADD_MEMBER_RESPONSE="$(curl -sS "${USER_HEADERS[@]}" \ + -H "X-XSRF-TOKEN: $USER_CSRF" \ + -H "Content-Type: application/json" \ + -X POST "$BASE_URL/api/web/namespaces/$SLUG/members" \ + -d '{"userId":"local-admin","role":"MEMBER"}')" +assert_code "Owner can add namespace members" "$ADD_MEMBER_RESPONSE" "0" + +MEMBERS_RESPONSE="$(curl -sS "${USER_HEADERS[@]}" "$BASE_URL/api/web/namespaces/$SLUG/members")" +assert_code "Owner can list namespace members" "$MEMBERS_RESPONSE" "0" +if JSON_INPUT="$MEMBERS_RESPONSE" python3 - <<'PY' +import json +import os +import sys +data = json.loads(os.environ["JSON_INPUT"]) +items = data["data"]["items"] +ids = {item["userId"] for item in items} +raise SystemExit(0 if {"local-user", "local-admin"}.issubset(ids) else 1) +PY +then + pass "Member list shows owner and invited admin user" +else + fail "Member list should contain owner and invited user" +fi + +REVIEWS_RESPONSE="$(curl -sS "${USER_HEADERS[@]}" "$BASE_URL/api/web/reviews?status=PENDING&namespaceId=$NAMESPACE_ID")" +assert_code "Owner can open namespace review list" "$REVIEWS_RESPONSE" "0" + +PROMOTE_RESPONSE="$(curl -sS "${USER_HEADERS[@]}" \ + -H "X-XSRF-TOKEN: $USER_CSRF" \ + -H "Content-Type: application/json" \ + -X PUT "$BASE_URL/api/web/namespaces/$SLUG/members/local-admin/role" \ + -d '{"role":"ADMIN"}')" +assert_code "Owner can promote member to admin" "$PROMOTE_RESPONSE" "0" + +ADMIN_FREEZE_RESPONSE="$(curl -sS "${ADMIN_HEADERS[@]}" \ + -H "X-XSRF-TOKEN: $ADMIN_CSRF" \ + -X POST "$BASE_URL/api/web/namespaces/$SLUG/freeze")" +assert_code "Namespace admin can freeze namespace" "$ADMIN_FREEZE_RESPONSE" "0" +if [[ "$(json_field "$ADMIN_FREEZE_RESPONSE" "data.status")" == "FROZEN" ]]; then + pass "Freeze changes namespace status to FROZEN" +else + fail "Freeze should set namespace status to FROZEN" +fi + +ADD_WHILE_FROZEN_RESPONSE="$(curl -sS "${USER_HEADERS[@]}" \ + -H "X-XSRF-TOKEN: $USER_CSRF" \ + -H "Content-Type: application/json" \ + -X POST "$BASE_URL/api/web/namespaces/$SLUG/members" \ + -d '{"userId":"local-user","role":"MEMBER"}')" +assert_code "Frozen namespace rejects member mutation" "$ADD_WHILE_FROZEN_RESPONSE" "400" + +ADMIN_UNFREEZE_RESPONSE="$(curl -sS "${ADMIN_HEADERS[@]}" \ + -H "X-XSRF-TOKEN: $ADMIN_CSRF" \ + -X POST "$BASE_URL/api/web/namespaces/$SLUG/unfreeze")" +assert_code "Namespace admin can unfreeze namespace" "$ADMIN_UNFREEZE_RESPONSE" "0" + +ADMIN_ARCHIVE_RESPONSE="$(curl -sS "${ADMIN_HEADERS[@]}" \ + -H "X-XSRF-TOKEN: $ADMIN_CSRF" \ + -H "Content-Type: application/json" \ + -X POST "$BASE_URL/api/web/namespaces/$SLUG/archive" \ + -d '{"reason":"smoke"}')" +assert_code "Namespace admin cannot archive namespace" "$ADMIN_ARCHIVE_RESPONSE" "403" + +OWNER_ARCHIVE_RESPONSE="$(curl -sS "${USER_HEADERS[@]}" \ + -H "X-XSRF-TOKEN: $USER_CSRF" \ + -H "Content-Type: application/json" \ + -X POST "$BASE_URL/api/web/namespaces/$SLUG/archive" \ + -d '{"reason":"smoke"}')" +assert_code "Owner can archive namespace" "$OWNER_ARCHIVE_RESPONSE" "0" +if [[ "$(json_field "$OWNER_ARCHIVE_RESPONSE" "data.status")" == "ARCHIVED" ]]; then + pass "Archive changes namespace status to ARCHIVED" +else + fail "Archive should set namespace status to ARCHIVED" +fi + +OWNER_RESTORE_RESPONSE="$(curl -sS "${USER_HEADERS[@]}" \ + -H "X-XSRF-TOKEN: $USER_CSRF" \ + -X POST "$BASE_URL/api/web/namespaces/$SLUG/restore")" +assert_code "Owner can restore archived namespace" "$OWNER_RESTORE_RESPONSE" "0" +if [[ "$(json_field "$OWNER_RESTORE_RESPONSE" "data.status")" == "ACTIVE" ]]; then + pass "Restore changes namespace status back to ACTIVE" +else + fail "Restore should set namespace status back to ACTIVE" +fi + +REMOVE_MEMBER_RESPONSE="$(curl -sS "${USER_HEADERS[@]}" \ + -H "X-XSRF-TOKEN: $USER_CSRF" \ + -X DELETE "$BASE_URL/api/web/namespaces/$SLUG/members/local-admin")" +assert_code "Owner can remove namespace admin" "$REMOVE_MEMBER_RESPONSE" "0" + +echo +echo "Results: $PASS passed, $FAIL failed" +if [[ "$FAIL" -ne 0 ]]; then + exit 1 +fi diff --git a/scripts/promotion-smoke-test.sh b/scripts/promotion-smoke-test.sh new file mode 100755 index 00000000..b65e23ef --- /dev/null +++ b/scripts/promotion-smoke-test.sh @@ -0,0 +1,195 @@ +#!/usr/bin/env bash + +set -euo pipefail + +BASE_URL="${1:-http://localhost:8080}" +PASS=0 +FAIL=0 +USER_COOKIE="$(mktemp)" +ADMIN_COOKIE="$(mktemp)" +WORK_DIR="$(mktemp -d)" +SLUG="psmoke$(date +%s)" + +cleanup() { + rm -f "$USER_COOKIE" "$ADMIN_COOKIE" + rm -rf "$WORK_DIR" +} + +trap cleanup EXIT + +pass() { + echo "PASS: $1" + PASS=$((PASS + 1)) +} + +fail() { + echo "FAIL: $1" + FAIL=$((FAIL + 1)) +} + +csrf_token() { + local cookie_file="$1" + awk '$6 == "XSRF-TOKEN" { print $7 }' "$cookie_file" | tail -n 1 +} + +bootstrap_csrf() { + local cookie_file="$1" + local user_id="$2" + curl -s -c "$cookie_file" -H "X-Mock-User-Id: $user_id" "$BASE_URL/api/v1/auth/providers" >/dev/null +} + +json_field() { + local json="$1" + local expr="$2" + JSON_INPUT="$json" python3 - "$expr" <<'PY' +import json +import os +import sys + +expr = sys.argv[1] +value = json.loads(os.environ["JSON_INPUT"]) +for part in expr.split("."): + if part.isdigit(): + value = value[int(part)] + else: + value = value[part] +if isinstance(value, (dict, list)): + print(json.dumps(value, ensure_ascii=False)) +else: + print(value) +PY +} + +assert_code() { + local description="$1" + local json="$2" + local expected="$3" + local actual + actual="$(json_field "$json" "code")" + if [[ "$actual" == "$expected" ]]; then + pass "$description" + else + fail "$description (expected code $expected, got $actual)" + fi +} + +echo "=== Promotion Workflow Smoke Test ===" +echo "Target: $BASE_URL" +echo "Slug: $SLUG" +echo + +bootstrap_csrf "$USER_COOKIE" "local-user" +bootstrap_csrf "$ADMIN_COOKIE" "local-admin" + +USER_CSRF="$(csrf_token "$USER_COOKIE")" +ADMIN_CSRF="$(csrf_token "$ADMIN_COOKIE")" + +if [[ -z "$USER_CSRF" || -z "$ADMIN_CSRF" ]]; then + echo "Could not bootstrap CSRF tokens" + exit 1 +fi + +GLOBAL_RESPONSE="$(curl -sS -H "X-Mock-User-Id: local-user" -b "$USER_COOKIE" -c "$USER_COOKIE" \ + "$BASE_URL/api/web/namespaces/global")" +assert_code "Global namespace detail is available" "$GLOBAL_RESPONSE" "0" +GLOBAL_NAMESPACE_ID="$(json_field "$GLOBAL_RESPONSE" "data.id")" + +CREATE_NAMESPACE_RESPONSE="$(curl -sS -H "X-Mock-User-Id: local-user" -b "$USER_COOKIE" -c "$USER_COOKIE" \ + -H "X-XSRF-TOKEN: $USER_CSRF" \ + -H "Content-Type: application/json" \ + -X POST "$BASE_URL/api/web/namespaces" \ + -d "{\"slug\":\"$SLUG\",\"displayName\":\"Promotion Smoke $SLUG\",\"description\":\"promotion smoke test\"}")" +assert_code "Owner can create promotion smoke namespace" "$CREATE_NAMESPACE_RESPONSE" "0" +NAMESPACE_ID="$(json_field "$CREATE_NAMESPACE_RESPONSE" "data.id")" + +cat > "$WORK_DIR/SKILL.md" <<'EOF' +--- +name: Promotion Smoke Skill +description: Promotion smoke test +version: 1.0.0 +--- +Body +EOF +(cd "$WORK_DIR" && zip -q skill.zip SKILL.md) + +PUBLISH_RESPONSE="$(curl -sS -H "X-Mock-User-Id: local-user" -b "$USER_COOKIE" -c "$USER_COOKIE" \ + -H "X-XSRF-TOKEN: $USER_CSRF" \ + -F "file=@$WORK_DIR/skill.zip;type=application/zip" \ + -F "visibility=PUBLIC" \ + "$BASE_URL/api/web/skills/$SLUG/publish")" +assert_code "Owner can publish a team skill" "$PUBLISH_RESPONSE" "0" +SKILL_ID="$(json_field "$PUBLISH_RESPONSE" "data.skillId")" +SKILL_SLUG="$(json_field "$PUBLISH_RESPONSE" "data.slug")" + +PENDING_REVIEWS_RESPONSE="$(curl -sS -H "X-Mock-User-Id: local-admin" -b "$ADMIN_COOKIE" -c "$ADMIN_COOKIE" \ + "$BASE_URL/api/web/reviews?status=PENDING&namespaceId=$NAMESPACE_ID")" +assert_code "Admin can list pending namespace reviews" "$PENDING_REVIEWS_RESPONSE" "0" +REVIEW_ID="$(json_field "$PENDING_REVIEWS_RESPONSE" "data.items.0.id")" + +APPROVE_REVIEW_RESPONSE="$(curl -sS -H "X-Mock-User-Id: local-admin" -b "$ADMIN_COOKIE" -c "$ADMIN_COOKIE" \ + -H "X-XSRF-TOKEN: $ADMIN_CSRF" \ + -H "Content-Type: application/json" \ + -X POST "$BASE_URL/api/web/reviews/$REVIEW_ID/approve" \ + -d '{"comment":"ok"}')" +assert_code "Admin can approve team skill review" "$APPROVE_REVIEW_RESPONSE" "0" + +SKILL_DETAIL_RESPONSE="$(curl -sS -H "X-Mock-User-Id: local-user" -b "$USER_COOKIE" -c "$USER_COOKIE" \ + "$BASE_URL/api/web/skills/$SLUG/$SKILL_SLUG")" +assert_code "Owner can load team skill detail" "$SKILL_DETAIL_RESPONSE" "0" +VERSION_ID="$(json_field "$SKILL_DETAIL_RESPONSE" "data.latestVersionId")" +CAN_SUBMIT_PROMOTION="$(json_field "$SKILL_DETAIL_RESPONSE" "data.canSubmitPromotion")" +if [[ "$CAN_SUBMIT_PROMOTION" == "True" || "$CAN_SUBMIT_PROMOTION" == "true" ]]; then + pass "Approved team skill is marked promotable" +else + fail "Approved team skill should expose canSubmitPromotion=true" +fi + +MY_SKILLS_RESPONSE="$(curl -sS -H "X-Mock-User-Id: local-user" -b "$USER_COOKIE" -c "$USER_COOKIE" \ + "$BASE_URL/api/web/me/skills")" +assert_code "Owner can list my skills with promotion metadata" "$MY_SKILLS_RESPONSE" "0" +if JSON_INPUT="$MY_SKILLS_RESPONSE" python3 - "$SKILL_ID" <<'PY' +import json +import os +import sys + +skill_id = int(sys.argv[1]) +items = json.loads(os.environ["JSON_INPUT"])["data"] +match = next(item for item in items if item["id"] == skill_id) +raise SystemExit(0 if match["canSubmitPromotion"] and match["latestVersionId"] else 1) +PY +then + pass "My skills response exposes promotion submission fields" +else + fail "My skills response should expose latestVersionId and canSubmitPromotion" +fi + +SUBMIT_PROMOTION_RESPONSE="$(curl -sS -H "X-Mock-User-Id: local-user" -b "$USER_COOKIE" -c "$USER_COOKIE" \ + -H "X-XSRF-TOKEN: $USER_CSRF" \ + -H "Content-Type: application/json" \ + -X POST "$BASE_URL/api/web/promotions" \ + -d "{\"sourceSkillId\":$SKILL_ID,\"sourceVersionId\":$VERSION_ID,\"targetNamespaceId\":$GLOBAL_NAMESPACE_ID}")" +assert_code "Owner can submit promotion to global namespace" "$SUBMIT_PROMOTION_RESPONSE" "0" + +PENDING_PROMOTIONS_RESPONSE="$(curl -sS -H "X-Mock-User-Id: local-admin" -b "$ADMIN_COOKIE" -c "$ADMIN_COOKIE" \ + "$BASE_URL/api/web/promotions?status=PENDING")" +assert_code "Admin can list pending promotions" "$PENDING_PROMOTIONS_RESPONSE" "0" +if JSON_INPUT="$PENDING_PROMOTIONS_RESPONSE" python3 - "$SKILL_ID" <<'PY' +import json +import os +import sys + +skill_id = int(sys.argv[1]) +items = json.loads(os.environ["JSON_INPUT"])["data"]["items"] +raise SystemExit(0 if any(item["sourceSkillId"] == skill_id for item in items) else 1) +PY +then + pass "Pending promotions list contains the submitted team skill" +else + fail "Pending promotions list should include submitted team skill" +fi + +echo +echo "Results: $PASS passed, $FAIL failed" +if [[ "$FAIL" -ne 0 ]]; then + exit 1 +fi diff --git a/server/scripts/run-dev-app.sh b/server/scripts/run-dev-app.sh new file mode 100755 index 00000000..843bf182 --- /dev/null +++ b/server/scripts/run-dev-app.sh @@ -0,0 +1,18 @@ +#!/usr/bin/env bash + +set -euo pipefail + +SERVER_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +PROFILE="${SPRING_PROFILES_ACTIVE:-local}" + +cd "$SERVER_DIR" + +./mvnw -pl skillhub-app -am package -DskipTests >/dev/null + +APP_JAR="$(find skillhub-app/target -maxdepth 1 -type f -name 'skillhub-app-*.jar' ! -name '*.original' | head -n 1)" +if [[ -z "$APP_JAR" ]]; then + echo "Could not locate packaged skillhub-app jar under skillhub-app/target" >&2 + exit 1 +fi + +exec "${JAVA_BIN:-java}" -jar "$APP_JAR" --spring.profiles.active="$PROFILE" "$@" diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/bootstrap/DockerSeedDataRunner.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/bootstrap/BootstrapAdminInitializer.java similarity index 80% rename from server/skillhub-app/src/main/java/com/iflytek/skillhub/bootstrap/DockerSeedDataRunner.java rename to server/skillhub-app/src/main/java/com/iflytek/skillhub/bootstrap/BootstrapAdminInitializer.java index 6caf75ed..f8e7fab5 100644 --- a/server/skillhub-app/src/main/java/com/iflytek/skillhub/bootstrap/DockerSeedDataRunner.java +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/bootstrap/BootstrapAdminInitializer.java @@ -17,19 +17,17 @@ import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.boot.ApplicationArguments; import org.springframework.boot.ApplicationRunner; -import org.springframework.context.annotation.Profile; import org.springframework.security.crypto.password.PasswordEncoder; import org.springframework.stereotype.Component; import org.springframework.transaction.annotation.Transactional; /** - * Seeds default admin account for Docker one-click startup. + * Seeds a default bootstrap admin account for any runtime profile. * Idempotent: skips if admin credential already exists. */ @Component -@Profile("docker") -public class DockerSeedDataRunner implements ApplicationRunner { - private static final Logger log = LoggerFactory.getLogger(DockerSeedDataRunner.class); +public class BootstrapAdminInitializer implements ApplicationRunner { + private static final Logger log = LoggerFactory.getLogger(BootstrapAdminInitializer.class); private final BootstrapAdminProperties bootstrapAdminProperties; private final UserAccountRepository userAccountRepository; @@ -40,14 +38,14 @@ public class DockerSeedDataRunner implements ApplicationRunner { private final NamespaceMemberRepository namespaceMemberRepository; private final PasswordEncoder passwordEncoder; - public DockerSeedDataRunner(BootstrapAdminProperties bootstrapAdminProperties, - UserAccountRepository userAccountRepository, - LocalCredentialRepository localCredentialRepository, - RoleRepository roleRepository, - UserRoleBindingRepository userRoleBindingRepository, - NamespaceRepository namespaceRepository, - NamespaceMemberRepository namespaceMemberRepository, - PasswordEncoder passwordEncoder) { + public BootstrapAdminInitializer(BootstrapAdminProperties bootstrapAdminProperties, + UserAccountRepository userAccountRepository, + LocalCredentialRepository localCredentialRepository, + RoleRepository roleRepository, + UserRoleBindingRepository userRoleBindingRepository, + NamespaceRepository namespaceRepository, + NamespaceMemberRepository namespaceMemberRepository, + PasswordEncoder passwordEncoder) { this.bootstrapAdminProperties = bootstrapAdminProperties; this.userAccountRepository = userAccountRepository; this.localCredentialRepository = localCredentialRepository; @@ -62,11 +60,11 @@ public class DockerSeedDataRunner implements ApplicationRunner { @Transactional public void run(ApplicationArguments args) { if (!bootstrapAdminProperties.isEnabled()) { - log.info("Docker bootstrap admin is disabled"); + log.info("Bootstrap admin is disabled"); return; } if (localCredentialRepository.existsByUsernameIgnoreCase(bootstrapAdminProperties.getUsername())) { - log.info("Docker seed data already exists, skipping"); + log.info("Bootstrap admin already exists, skipping"); return; } @@ -109,6 +107,6 @@ public class DockerSeedDataRunner implements ApplicationRunner { namespaceMemberRepository.save(new NamespaceMember(globalNs.getId(), admin.getId(), NamespaceRole.OWNER)); } - log.info("Docker seed data initialized for admin account: {}", bootstrapAdminProperties.getUsername()); + log.info("Bootstrap admin initialized for account: {}", bootstrapAdminProperties.getUsername()); } } diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/bootstrap/BootstrapAdminProperties.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/bootstrap/BootstrapAdminProperties.java index 1cf615a6..11aadcdf 100644 --- a/server/skillhub-app/src/main/java/com/iflytek/skillhub/bootstrap/BootstrapAdminProperties.java +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/bootstrap/BootstrapAdminProperties.java @@ -6,7 +6,7 @@ import org.springframework.stereotype.Component; @Component @ConfigurationProperties(prefix = "skillhub.bootstrap.admin") public class BootstrapAdminProperties { - private boolean enabled = true; + private boolean enabled = false; private String userId = "docker-admin"; private String username = "admin"; private String password = "ChangeMe!2026"; diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/admin/AdminSkillReportController.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/admin/AdminSkillReportController.java index 0bdb76fa..0442a543 100644 --- a/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/admin/AdminSkillReportController.java +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/admin/AdminSkillReportController.java @@ -2,6 +2,7 @@ package com.iflytek.skillhub.controller.admin; import com.iflytek.skillhub.auth.rbac.PlatformPrincipal; import com.iflytek.skillhub.controller.BaseApiController; +import com.iflytek.skillhub.domain.report.SkillReportDisposition; import com.iflytek.skillhub.domain.report.SkillReportService; import com.iflytek.skillhub.dto.AdminSkillReportActionRequest; import com.iflytek.skillhub.dto.AdminSkillReportSummaryResponse; @@ -54,6 +55,9 @@ public class AdminSkillReportController extends BaseApiController { var report = skillReportService.resolveReport( reportId, principal.userId(), + request != null && request.disposition() != null + ? SkillReportDisposition.valueOf(request.disposition().trim().toUpperCase()) + : SkillReportDisposition.RESOLVE_ONLY, request != null ? request.comment() : null, httpRequest.getRemoteAddr(), httpRequest.getHeader("User-Agent") diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/portal/GovernanceController.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/portal/GovernanceController.java new file mode 100644 index 00000000..e14e4ea7 --- /dev/null +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/portal/GovernanceController.java @@ -0,0 +1,115 @@ +package com.iflytek.skillhub.controller.portal; + +import com.iflytek.skillhub.auth.rbac.RbacService; +import com.iflytek.skillhub.controller.BaseApiController; +import com.iflytek.skillhub.domain.governance.GovernanceNotificationService; +import com.iflytek.skillhub.domain.governance.UserNotification; +import com.iflytek.skillhub.domain.namespace.NamespaceRole; +import com.iflytek.skillhub.dto.ApiResponse; +import com.iflytek.skillhub.dto.ApiResponseFactory; +import com.iflytek.skillhub.dto.GovernanceActivityItemResponse; +import com.iflytek.skillhub.dto.GovernanceInboxItemResponse; +import com.iflytek.skillhub.dto.GovernanceNotificationResponse; +import com.iflytek.skillhub.dto.GovernanceSummaryResponse; +import com.iflytek.skillhub.dto.PageResponse; +import com.iflytek.skillhub.service.GovernanceWorkbenchAppService; +import java.util.Map; +import java.util.Set; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestAttribute; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +@RestController +@RequestMapping({"/api/v1/governance", "/api/web/governance"}) +public class GovernanceController extends BaseApiController { + + private final GovernanceWorkbenchAppService governanceWorkbenchAppService; + private final RbacService rbacService; + private final GovernanceNotificationService governanceNotificationService; + + public GovernanceController(GovernanceWorkbenchAppService governanceWorkbenchAppService, + RbacService rbacService, + GovernanceNotificationService governanceNotificationService, + ApiResponseFactory responseFactory) { + super(responseFactory); + this.governanceWorkbenchAppService = governanceWorkbenchAppService; + this.rbacService = rbacService; + this.governanceNotificationService = governanceNotificationService; + } + + @GetMapping("/summary") + public ApiResponse summary( + @RequestAttribute("userId") String userId, + @RequestAttribute(value = "userNsRoles", required = false) Map userNsRoles) { + return ok( + "response.success.read", + governanceWorkbenchAppService.getSummary(userId, userNsRoles != null ? userNsRoles : Map.of(), roles(userId)) + ); + } + + @GetMapping("/inbox") + public ApiResponse> inbox( + @RequestAttribute("userId") String userId, + @RequestAttribute(value = "userNsRoles", required = false) Map userNsRoles, + @RequestParam(required = false) String type, + @RequestParam(defaultValue = "0") int page, + @RequestParam(defaultValue = "20") int size) { + return ok( + "response.success.read", + governanceWorkbenchAppService.listInbox( + userId, + userNsRoles != null ? userNsRoles : Map.of(), + roles(userId), + type, + page, + size + ) + ); + } + + @GetMapping("/activity") + public ApiResponse> activity( + @RequestAttribute("userId") String userId, + @RequestParam(defaultValue = "0") int page, + @RequestParam(defaultValue = "20") int size) { + return ok("response.success.read", governanceWorkbenchAppService.listActivity(roles(userId), page, size)); + } + + @GetMapping("/notifications") + public ApiResponse> notifications( + @RequestAttribute("userId") String userId) { + return ok( + "response.success.read", + governanceNotificationService.listNotifications(userId).stream().map(this::toNotificationResponse).toList() + ); + } + + @PostMapping("/notifications/{id}/read") + public ApiResponse markNotificationRead( + @PathVariable Long id, + @RequestAttribute("userId") String userId) { + return ok("response.success.updated", toNotificationResponse(governanceNotificationService.markRead(id, userId))); + } + + private Set roles(String userId) { + return rbacService.getUserRoleCodes(userId); + } + + private GovernanceNotificationResponse toNotificationResponse(UserNotification notification) { + return new GovernanceNotificationResponse( + notification.getId(), + notification.getCategory(), + notification.getEntityType(), + notification.getEntityId(), + notification.getTitle(), + notification.getBodyJson(), + notification.getStatus().name(), + notification.getCreatedAt() != null ? notification.getCreatedAt().toString() : null, + notification.getReadAt() != null ? notification.getReadAt().toString() : null + ); + } +} 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..4c00fc10 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,78 @@ 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 com.iflytek.skillhub.service.NamespaceMemberCandidateService; +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; + private final NamespaceMemberCandidateService namespaceMemberCandidateService; public NamespaceController(NamespaceService namespaceService, NamespaceMemberService namespaceMemberService, NamespaceRepository namespaceRepository, + NamespaceGovernanceService namespaceGovernanceService, + NamespaceAccessPolicy namespaceAccessPolicy, + NamespaceMemberCandidateService namespaceMemberCandidateService, ApiResponseFactory responseFactory) { super(responseFactory); this.namespaceService = namespaceService; this.namespaceMemberService = namespaceMemberService; this.namespaceRepository = namespaceRepository; + this.namespaceGovernanceService = namespaceGovernanceService; + this.namespaceAccessPolicy = namespaceAccessPolicy; + this.namespaceMemberCandidateService = namespaceMemberCandidateService; } - @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}") - public ApiResponse getNamespace(@PathVariable String slug) { - Namespace namespace = namespaceService.getNamespaceBySlug(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, + @RequestAttribute(value = "userId", required = false) String userId, + @RequestAttribute(value = "userNsRoles", required = false) Map userNsRoles) { + Namespace namespace = namespaceService.getNamespaceBySlugForRead(slug, userId, userNsRoles != null ? userNsRoles : Map.of()); return ok("response.success.read", NamespaceResponse.from(namespace)); } - @PostMapping + @PostMapping("/namespaces") public ApiResponse createNamespace( @Valid @RequestBody NamespaceRequest request, @AuthenticationPrincipal PlatformPrincipal principal) { @@ -54,7 +88,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,15 +104,87 @@ public class NamespaceController extends BaseApiController { return ok("response.success.updated", NamespaceResponse.from(updated)); } - @GetMapping("/{slug}/members") - public ApiResponse> listMembers(@PathVariable String slug, Pageable pageable) { + @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, + @RequestAttribute("userId") String userId) { Namespace namespace = namespaceService.getNamespaceBySlug(slug); + namespaceService.assertMember(namespace.getId(), userId); Page members = namespaceMemberService.listMembers(namespace.getId(), pageable); PageResponse response = PageResponse.from(members.map(MemberResponse::from)); return ok("response.success.read", response); } - @PostMapping("/{slug}/members") + @GetMapping("/namespaces/{slug}/member-candidates") + public ApiResponse> searchMemberCandidates( + @PathVariable String slug, + @RequestParam String search, + @RequestParam(defaultValue = "10") int size, + @RequestAttribute("userId") String userId) { + return ok("response.success.read", namespaceMemberCandidateService.searchCandidates(slug, search, userId, size)); + } + + @PostMapping("/namespaces/{slug}/members") public ApiResponse addMember( @PathVariable String slug, @Valid @RequestBody MemberRequest request, @@ -93,7 +199,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 +209,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/controller/portal/SkillController.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/portal/SkillController.java index b2ad113d..49e6491c 100644 --- a/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/portal/SkillController.java +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/portal/SkillController.java @@ -70,8 +70,10 @@ public class SkillController extends BaseApiController { detail.ratingCount(), detail.hidden(), detail.latestVersion(), + detail.latestVersionId(), namespace, detail.canManageLifecycle(), + detail.canSubmitPromotion(), detail.viewingVersionStatus(), detail.canInteract() ); diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/AdminSkillReportActionRequest.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/AdminSkillReportActionRequest.java index 92124df1..f2a85e39 100644 --- a/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/AdminSkillReportActionRequest.java +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/AdminSkillReportActionRequest.java @@ -1,5 +1,6 @@ package com.iflytek.skillhub.dto; public record AdminSkillReportActionRequest( - String comment + String comment, + String disposition ) {} diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/GovernanceActivityItemResponse.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/GovernanceActivityItemResponse.java new file mode 100644 index 00000000..7ed30115 --- /dev/null +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/GovernanceActivityItemResponse.java @@ -0,0 +1,13 @@ +package com.iflytek.skillhub.dto; + +public record GovernanceActivityItemResponse( + Long id, + String action, + String actorUserId, + String actorDisplayName, + String targetType, + String targetId, + String details, + String timestamp +) { +} diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/GovernanceInboxItemResponse.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/GovernanceInboxItemResponse.java new file mode 100644 index 00000000..c9298a5e --- /dev/null +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/GovernanceInboxItemResponse.java @@ -0,0 +1,12 @@ +package com.iflytek.skillhub.dto; + +public record GovernanceInboxItemResponse( + String type, + Long id, + String title, + String subtitle, + String timestamp, + String namespace, + String skillSlug +) { +} diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/GovernanceNotificationResponse.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/GovernanceNotificationResponse.java new file mode 100644 index 00000000..696e9340 --- /dev/null +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/GovernanceNotificationResponse.java @@ -0,0 +1,14 @@ +package com.iflytek.skillhub.dto; + +public record GovernanceNotificationResponse( + Long id, + String category, + String entityType, + Long entityId, + String title, + String bodyJson, + String status, + String createdAt, + String readAt +) { +} diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/GovernanceSummaryResponse.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/GovernanceSummaryResponse.java new file mode 100644 index 00000000..5a32049d --- /dev/null +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/GovernanceSummaryResponse.java @@ -0,0 +1,8 @@ +package com.iflytek.skillhub.dto; + +public record GovernanceSummaryResponse( + long pendingReviews, + long pendingPromotions, + long pendingReports +) { +} 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/NamespaceCandidateUserResponse.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/NamespaceCandidateUserResponse.java new file mode 100644 index 00000000..f65bcda0 --- /dev/null +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/NamespaceCandidateUserResponse.java @@ -0,0 +1,19 @@ +package com.iflytek.skillhub.dto; + +import com.iflytek.skillhub.domain.user.UserAccount; + +public record NamespaceCandidateUserResponse( + String userId, + String displayName, + String email, + String status +) { + public static NamespaceCandidateUserResponse from(UserAccount user) { + return new NamespaceCandidateUserResponse( + user.getId(), + user.getDisplayName(), + user.getEmail(), + user.getStatus().name() + ); + } +} 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/main/java/com/iflytek/skillhub/dto/SkillDetailResponse.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/SkillDetailResponse.java index f7152d7b..566b2848 100644 --- a/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/SkillDetailResponse.java +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/SkillDetailResponse.java @@ -15,8 +15,10 @@ public record SkillDetailResponse( Integer ratingCount, boolean hidden, String latestVersion, + Long latestVersionId, String namespace, boolean canManageLifecycle, + boolean canSubmitPromotion, String viewingVersionStatus, boolean canInteract ) {} diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/SkillSummaryResponse.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/SkillSummaryResponse.java index 4cc2e91e..2a5c60bc 100644 --- a/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/SkillSummaryResponse.java +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/SkillSummaryResponse.java @@ -14,7 +14,9 @@ public record SkillSummaryResponse( BigDecimal ratingAvg, Integer ratingCount, String latestVersion, + Long latestVersionId, String latestVersionStatus, String namespace, - LocalDateTime updatedAt + LocalDateTime updatedAt, + boolean canSubmitPromotion ) {} diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/filter/AuthContextFilter.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/filter/AuthContextFilter.java index 60d7ac6d..aeced23f 100644 --- a/server/skillhub-app/src/main/java/com/iflytek/skillhub/filter/AuthContextFilter.java +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/filter/AuthContextFilter.java @@ -4,15 +4,18 @@ import com.iflytek.skillhub.auth.rbac.PlatformPrincipal; import com.iflytek.skillhub.domain.namespace.NamespaceMember; import com.iflytek.skillhub.domain.namespace.NamespaceMemberRepository; import com.iflytek.skillhub.domain.namespace.NamespaceRole; +import com.iflytek.skillhub.domain.user.UserAccountRepository; import jakarta.servlet.FilterChain; import jakarta.servlet.ServletException; import jakarta.servlet.http.HttpServletRequest; import jakarta.servlet.http.HttpServletResponse; +import jakarta.servlet.http.HttpSession; import java.io.IOException; import java.util.Map; import java.util.stream.Collectors; import org.springframework.security.core.Authentication; import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.security.web.context.HttpSessionSecurityContextRepository; import org.springframework.stereotype.Component; import org.springframework.web.filter.OncePerRequestFilter; @@ -20,9 +23,12 @@ import org.springframework.web.filter.OncePerRequestFilter; public class AuthContextFilter extends OncePerRequestFilter { private final NamespaceMemberRepository namespaceMemberRepository; + private final UserAccountRepository userAccountRepository; - public AuthContextFilter(NamespaceMemberRepository namespaceMemberRepository) { + public AuthContextFilter(NamespaceMemberRepository namespaceMemberRepository, + UserAccountRepository userAccountRepository) { this.namespaceMemberRepository = namespaceMemberRepository; + this.userAccountRepository = userAccountRepository; } @Override @@ -32,6 +38,11 @@ public class AuthContextFilter extends OncePerRequestFilter { FilterChain filterChain) throws ServletException, IOException { PlatformPrincipal principal = resolvePrincipal(request); if (principal != null) { + if (isInactiveUser(principal.userId())) { + clearAuthentication(request); + response.sendError(HttpServletResponse.SC_UNAUTHORIZED); + return; + } request.setAttribute("userId", principal.userId()); Map userNsRoles = namespaceMemberRepository.findByUserId(principal.userId()).stream() .collect(Collectors.toMap( @@ -44,6 +55,23 @@ public class AuthContextFilter extends OncePerRequestFilter { filterChain.doFilter(request, response); } + private boolean isInactiveUser(String userId) { + return userAccountRepository.findById(userId) + .map(user -> !user.isActive()) + .orElse(true); + } + + private void clearAuthentication(HttpServletRequest request) { + SecurityContextHolder.clearContext(); + HttpSession session = request.getSession(false); + if (session == null) { + return; + } + session.removeAttribute("platformPrincipal"); + session.removeAttribute(HttpSessionSecurityContextRepository.SPRING_SECURITY_CONTEXT_KEY); + session.invalidate(); + } + private PlatformPrincipal resolvePrincipal(HttpServletRequest request) { Authentication authentication = SecurityContextHolder.getContext().getAuthentication(); if (authentication != null) { diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/AdminAuditLogAppService.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/AdminAuditLogAppService.java index b712ab83..b811e9d7 100644 --- a/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/AdminAuditLogAppService.java +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/AdminAuditLogAppService.java @@ -10,6 +10,7 @@ import org.springframework.util.StringUtils; import java.sql.Timestamp; import java.time.Instant; +import java.util.Collection; import java.util.List; @Service @@ -32,6 +33,20 @@ public class AdminAuditLogAppService { String resourceId, Instant startTime, Instant endTime) { + return listAuditLogsByActions(page, size, userId, action != null ? List.of(action) : null, requestId, ipAddress, resourceType, resourceId, startTime, endTime); + } + + @Transactional(readOnly = true) + public PageResponse listAuditLogsByActions(int page, + int size, + String userId, + Collection actions, + String requestId, + String ipAddress, + String resourceType, + String resourceId, + Instant startTime, + Instant endTime) { MapSqlParameterSource parameters = new MapSqlParameterSource() .addValue("limit", size) .addValue("offset", Math.max(page, 0) * size); @@ -39,7 +54,7 @@ public class AdminAuditLogAppService { String whereClause = buildWhereClause( parameters, userId, - action, + actions, requestId, ipAddress, resourceType, @@ -92,7 +107,7 @@ public class AdminAuditLogAppService { private String buildWhereClause(MapSqlParameterSource parameters, String userId, - String action, + Collection actions, String requestId, String ipAddress, String resourceType, @@ -104,9 +119,9 @@ public class AdminAuditLogAppService { clause.append(" AND al.actor_user_id = :userId"); parameters.addValue("userId", userId.trim()); } - if (StringUtils.hasText(action)) { - clause.append(" AND al.action = :action"); - parameters.addValue("action", action.trim()); + if (actions != null && !actions.isEmpty()) { + clause.append(" AND al.action IN (:actions)"); + parameters.addValue("actions", actions.stream().filter(StringUtils::hasText).map(String::trim).toList()); } if (StringUtils.hasText(requestId)) { clause.append(" AND al.request_id = :requestId"); diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/GovernanceWorkbenchAppService.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/GovernanceWorkbenchAppService.java new file mode 100644 index 00000000..f11983dc --- /dev/null +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/GovernanceWorkbenchAppService.java @@ -0,0 +1,236 @@ +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.NamespaceRole; +import com.iflytek.skillhub.domain.report.SkillReport; +import com.iflytek.skillhub.domain.report.SkillReportRepository; +import com.iflytek.skillhub.domain.report.SkillReportStatus; +import com.iflytek.skillhub.domain.review.PromotionRequest; +import com.iflytek.skillhub.domain.review.PromotionRequestRepository; +import com.iflytek.skillhub.domain.review.ReviewTask; +import com.iflytek.skillhub.domain.review.ReviewTaskRepository; +import com.iflytek.skillhub.domain.review.ReviewTaskStatus; +import com.iflytek.skillhub.domain.skill.Skill; +import com.iflytek.skillhub.domain.skill.SkillRepository; +import com.iflytek.skillhub.domain.skill.SkillVersion; +import com.iflytek.skillhub.domain.skill.SkillVersionRepository; +import com.iflytek.skillhub.dto.AuditLogItemResponse; +import com.iflytek.skillhub.dto.GovernanceActivityItemResponse; +import com.iflytek.skillhub.dto.GovernanceInboxItemResponse; +import com.iflytek.skillhub.dto.GovernanceSummaryResponse; +import com.iflytek.skillhub.dto.PageResponse; +import java.util.ArrayList; +import java.util.Comparator; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.Set; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageRequest; +import org.springframework.stereotype.Service; + +@Service +public class GovernanceWorkbenchAppService { + + private static final int SUMMARY_PAGE_SIZE = 100; + private static final Set ACTIVITY_ACTIONS = Set.of( + "REVIEW_SUBMIT", + "REVIEW_APPROVE", + "REVIEW_REJECT", + "REVIEW_WITHDRAW", + "PROMOTION_SUBMIT", + "PROMOTION_APPROVE", + "PROMOTION_REJECT", + "REPORT_SKILL", + "RESOLVE_SKILL_REPORT", + "DISMISS_SKILL_REPORT", + "HIDE_SKILL", + "ARCHIVE_SKILL", + "UNHIDE_SKILL", + "UNARCHIVE_SKILL" + ); + + private final ReviewTaskRepository reviewTaskRepository; + private final PromotionRequestRepository promotionRequestRepository; + private final SkillReportRepository skillReportRepository; + private final SkillRepository skillRepository; + private final SkillVersionRepository skillVersionRepository; + private final NamespaceRepository namespaceRepository; + private final AdminAuditLogAppService adminAuditLogAppService; + + public GovernanceWorkbenchAppService(ReviewTaskRepository reviewTaskRepository, + PromotionRequestRepository promotionRequestRepository, + SkillReportRepository skillReportRepository, + SkillRepository skillRepository, + SkillVersionRepository skillVersionRepository, + NamespaceRepository namespaceRepository, + AdminAuditLogAppService adminAuditLogAppService) { + this.reviewTaskRepository = reviewTaskRepository; + this.promotionRequestRepository = promotionRequestRepository; + this.skillReportRepository = skillReportRepository; + this.skillRepository = skillRepository; + this.skillVersionRepository = skillVersionRepository; + this.namespaceRepository = namespaceRepository; + this.adminAuditLogAppService = adminAuditLogAppService; + } + + public GovernanceSummaryResponse getSummary(String userId, + Map namespaceRoles, + Set platformRoles) { + return new GovernanceSummaryResponse( + visiblePendingReviews(namespaceRoles, platformRoles, SUMMARY_PAGE_SIZE).getTotalElements(), + hasPlatformGovernanceRole(platformRoles) + ? promotionRequestRepository.findByStatus(ReviewTaskStatus.PENDING, PageRequest.of(0, SUMMARY_PAGE_SIZE)).getTotalElements() + : 0, + hasPlatformGovernanceRole(platformRoles) + ? skillReportRepository.findByStatus(SkillReportStatus.PENDING, PageRequest.of(0, SUMMARY_PAGE_SIZE)).getTotalElements() + : 0 + ); + } + + public PageResponse listInbox(String userId, + Map namespaceRoles, + Set platformRoles, + String type, + int page, + int size) { + List items = new ArrayList<>(); + boolean includeAll = type == null || type.isBlank(); + if (includeAll || "REVIEW".equalsIgnoreCase(type)) { + visiblePendingReviews(namespaceRoles, platformRoles, size).getContent().stream() + .map(this::toReviewInboxItem) + .forEach(items::add); + } + if (hasPlatformGovernanceRole(platformRoles) && (includeAll || "PROMOTION".equalsIgnoreCase(type))) { + promotionRequestRepository.findByStatus(ReviewTaskStatus.PENDING, PageRequest.of(page, size)).getContent().stream() + .map(this::toPromotionInboxItem) + .forEach(items::add); + } + if (hasPlatformGovernanceRole(platformRoles) && (includeAll || "REPORT".equalsIgnoreCase(type))) { + skillReportRepository.findByStatus(SkillReportStatus.PENDING, PageRequest.of(page, size)).getContent().stream() + .map(this::toReportInboxItem) + .forEach(items::add); + } + items.sort(Comparator.comparing( + GovernanceInboxItemResponse::timestamp, + Comparator.nullsLast(String::compareTo) + ).reversed()); + int fromIndex = Math.min(page * size, items.size()); + int toIndex = Math.min(fromIndex + size, items.size()); + return new PageResponse<>(items.subList(fromIndex, toIndex), items.size(), page, size); + } + + public PageResponse listActivity(Set platformRoles, int page, int size) { + if (!canReadActivity(platformRoles)) { + return new PageResponse<>(List.of(), 0, page, size); + } + PageResponse raw = adminAuditLogAppService.listAuditLogsByActions( + page, + size, + null, + ACTIVITY_ACTIONS, + null, + null, + null, + null, + null, + null + ); + List items = raw.items().stream() + .map(item -> new GovernanceActivityItemResponse( + item.id(), + item.action(), + item.userId(), + item.username(), + item.resourceType(), + item.resourceId(), + item.details(), + item.timestamp() != null ? item.timestamp().toString() : null + )) + .toList(); + return new PageResponse<>(items, items.size(), page, size); + } + + private Page visiblePendingReviews(Map namespaceRoles, + Set platformRoles, + int size) { + if (hasPlatformGovernanceRole(platformRoles)) { + return reviewTaskRepository.findByStatus(ReviewTaskStatus.PENDING, PageRequest.of(0, size)); + } + List tasks = namespaceRoles.entrySet().stream() + .filter(entry -> entry.getValue() == NamespaceRole.OWNER || entry.getValue() == NamespaceRole.ADMIN) + .map(entry -> reviewTaskRepository.findByNamespaceIdAndStatus(entry.getKey(), ReviewTaskStatus.PENDING, PageRequest.of(0, size))) + .flatMap(pageResult -> pageResult.getContent().stream()) + .toList(); + return new org.springframework.data.domain.PageImpl<>(tasks, PageRequest.of(0, size), tasks.size()); + } + + private GovernanceInboxItemResponse toReviewInboxItem(ReviewTask task) { + SkillVersion version = skillVersionRepository.findById(task.getSkillVersionId()).orElse(null); + Skill skill = version != null ? skillRepository.findById(version.getSkillId()).orElse(null) : null; + Namespace namespace = skill != null ? namespaceRepository.findById(skill.getNamespaceId()).orElse(null) : null; + String namespaceSlug = namespace != null ? namespace.getSlug() : null; + String skillSlug = skill != null ? skill.getSlug() : null; + String versionName = version != null ? version.getVersion() : null; + return new GovernanceInboxItemResponse( + "REVIEW", + task.getId(), + join(namespaceSlug, skillSlug, versionName), + "Pending review", + task.getSubmittedAt() != null ? task.getSubmittedAt().toString() : null, + namespaceSlug, + skillSlug + ); + } + + private GovernanceInboxItemResponse toPromotionInboxItem(PromotionRequest request) { + Skill skill = skillRepository.findById(request.getSourceSkillId()).orElse(null); + SkillVersion version = skillVersionRepository.findById(request.getSourceVersionId()).orElse(null); + Namespace sourceNamespace = skill != null ? namespaceRepository.findById(skill.getNamespaceId()).orElse(null) : null; + Namespace targetNamespace = namespaceRepository.findById(request.getTargetNamespaceId()).orElse(null); + String sourceSlug = sourceNamespace != null ? sourceNamespace.getSlug() : null; + String skillSlug = skill != null ? skill.getSlug() : null; + String targetSlug = targetNamespace != null ? targetNamespace.getSlug() : null; + String versionName = version != null ? version.getVersion() : null; + return new GovernanceInboxItemResponse( + "PROMOTION", + request.getId(), + join(sourceSlug, skillSlug, versionName), + targetSlug != null ? "Promote to @" + targetSlug : "Pending promotion", + request.getSubmittedAt() != null ? request.getSubmittedAt().toString() : null, + sourceSlug, + skillSlug + ); + } + + private GovernanceInboxItemResponse toReportInboxItem(SkillReport report) { + Skill skill = skillRepository.findById(report.getSkillId()).orElse(null); + Namespace namespace = namespaceRepository.findById(report.getNamespaceId()).orElse(null); + String namespaceSlug = namespace != null ? namespace.getSlug() : null; + String skillSlug = skill != null ? skill.getSlug() : null; + return new GovernanceInboxItemResponse( + "REPORT", + report.getId(), + join(namespaceSlug, skillSlug, null), + report.getReason(), + report.getCreatedAt() != null ? report.getCreatedAt().toString() : null, + namespaceSlug, + skillSlug + ); + } + + private String join(String namespaceSlug, String skillSlug, String version) { + String path = namespaceSlug != null && skillSlug != null ? namespaceSlug + "/" + skillSlug : "Unknown target"; + return version != null ? path + "@" + version : path; + } + + private boolean hasPlatformGovernanceRole(Set platformRoles) { + return platformRoles.contains("SKILL_ADMIN") || platformRoles.contains("SUPER_ADMIN"); + } + + private boolean canReadActivity(Set platformRoles) { + return hasPlatformGovernanceRole(platformRoles) + || platformRoles.contains("AUDITOR"); + } +} diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/MySkillAppService.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/MySkillAppService.java index 1b465f07..ef64f336 100644 --- a/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/MySkillAppService.java +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/MySkillAppService.java @@ -1,6 +1,10 @@ package com.iflytek.skillhub.service; 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.review.PromotionRequestRepository; +import com.iflytek.skillhub.domain.review.ReviewTaskStatus; import com.iflytek.skillhub.domain.skill.Skill; import com.iflytek.skillhub.domain.skill.SkillRepository; import com.iflytek.skillhub.domain.skill.SkillVersion; @@ -27,16 +31,19 @@ public class MySkillAppService { private final NamespaceRepository namespaceRepository; private final SkillVersionRepository skillVersionRepository; private final SkillStarRepository skillStarRepository; + private final PromotionRequestRepository promotionRequestRepository; public MySkillAppService( SkillRepository skillRepository, NamespaceRepository namespaceRepository, SkillVersionRepository skillVersionRepository, - SkillStarRepository skillStarRepository) { + SkillStarRepository skillStarRepository, + PromotionRequestRepository promotionRequestRepository) { this.skillRepository = skillRepository; this.namespaceRepository = namespaceRepository; this.skillVersionRepository = skillVersionRepository; this.skillStarRepository = skillStarRepository; + this.promotionRequestRepository = promotionRequestRepository; } public List listMySkills(String userId) { @@ -50,15 +57,13 @@ public class MySkillAppService { .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(com.iflytek.skillhub.domain.namespace.Namespace::getId, Function.identity())); return skills.stream() - .map(skill -> toSummaryResponse(skill, versionsBySkillId, namespaceSlugsById)) + .map(skill -> toSummaryResponse(skill, versionsBySkillId, namespacesById)) .toList(); } @@ -80,18 +85,16 @@ public class MySkillAppService { .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(com.iflytek.skillhub.domain.namespace.Namespace::getId, Function.identity())); return stars.stream() .sorted(Comparator.comparing(com.iflytek.skillhub.domain.social.SkillStar::getCreatedAt).reversed()) .map(star -> skillsById.get(star.getSkillId())) .filter(java.util.Objects::nonNull) - .map(skill -> toSummaryResponse(skill, versionsBySkillId, namespaceSlugsById)) + .map(skill -> toSummaryResponse(skill, versionsBySkillId, namespacesById)) .toList(); } @@ -116,8 +119,9 @@ public class MySkillAppService { private SkillSummaryResponse toSummaryResponse( Skill skill, Map versionsBySkillId, - Map namespaceSlugsById) { + Map namespacesById) { SkillVersion latestVersion = versionsBySkillId.get(skill.getId()); + com.iflytek.skillhub.domain.namespace.Namespace namespace = namespacesById.get(skill.getNamespaceId()); return new SkillSummaryResponse( skill.getId(), @@ -130,12 +134,36 @@ public class MySkillAppService { skill.getRatingAvg(), skill.getRatingCount(), Optional.ofNullable(latestVersion).map(SkillVersion::getVersion).orElse(null), + Optional.ofNullable(latestVersion).map(SkillVersion::getId).orElse(null), Optional.ofNullable(latestVersion).map(SkillVersion::getStatus).map(Enum::name).orElse(null), - namespaceSlugsById.get(skill.getNamespaceId()), - skill.getUpdatedAt() + namespace != null ? namespace.getSlug() : null, + skill.getUpdatedAt(), + canSubmitPromotion(skill, latestVersion, namespace) ); } + private boolean canSubmitPromotion( + Skill skill, + SkillVersion latestVersion, + com.iflytek.skillhub.domain.namespace.Namespace namespace) { + if (namespace == null) { + return false; + } + if (namespace.getType() == NamespaceType.GLOBAL) { + return false; + } + if (namespace.getStatus() != NamespaceStatus.ACTIVE || skill.getStatus() != com.iflytek.skillhub.domain.skill.SkillStatus.ACTIVE) { + return false; + } + if (promotionRequestRepository.findBySourceSkillIdAndStatus(skill.getId(), ReviewTaskStatus.PENDING).isPresent()) { + return false; + } + if (promotionRequestRepository.findBySourceSkillIdAndStatus(skill.getId(), ReviewTaskStatus.APPROVED).isPresent()) { + return false; + } + return latestVersion != null && latestVersion.getStatus() == SkillVersionStatus.PUBLISHED; + } + private Map loadLatestRelevantVersions(java.util.Collection skills) { if (skills.isEmpty()) { return Map.of(); diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/NamespaceMemberCandidateService.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/NamespaceMemberCandidateService.java new file mode 100644 index 00000000..0401a7c8 --- /dev/null +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/NamespaceMemberCandidateService.java @@ -0,0 +1,88 @@ +package com.iflytek.skillhub.service; + +import com.iflytek.skillhub.domain.namespace.Namespace; +import com.iflytek.skillhub.domain.namespace.NamespaceAccessPolicy; +import com.iflytek.skillhub.domain.namespace.NamespaceMember; +import com.iflytek.skillhub.domain.namespace.NamespaceMemberRepository; +import com.iflytek.skillhub.domain.namespace.NamespaceService; +import com.iflytek.skillhub.domain.shared.exception.DomainBadRequestException; +import com.iflytek.skillhub.domain.user.UserAccount; +import com.iflytek.skillhub.domain.user.UserAccountRepository; +import com.iflytek.skillhub.domain.user.UserStatus; +import com.iflytek.skillhub.dto.NamespaceCandidateUserResponse; +import org.springframework.data.domain.PageRequest; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.util.StringUtils; + +import java.util.List; +import java.util.Set; +import java.util.stream.Collectors; + +@Service +public class NamespaceMemberCandidateService { + + private static final int DEFAULT_LIMIT = 10; + private static final int MAX_LIMIT = 20; + + private final NamespaceService namespaceService; + private final NamespaceAccessPolicy namespaceAccessPolicy; + private final NamespaceMemberRepository namespaceMemberRepository; + private final UserAccountRepository userAccountRepository; + + public NamespaceMemberCandidateService(NamespaceService namespaceService, + NamespaceAccessPolicy namespaceAccessPolicy, + NamespaceMemberRepository namespaceMemberRepository, + UserAccountRepository userAccountRepository) { + this.namespaceService = namespaceService; + this.namespaceAccessPolicy = namespaceAccessPolicy; + this.namespaceMemberRepository = namespaceMemberRepository; + this.userAccountRepository = userAccountRepository; + } + + @Transactional(readOnly = true) + public List searchCandidates(String slug, String search, String operatorUserId, int size) { + Namespace namespace = namespaceService.getNamespaceBySlug(slug); + if (namespaceAccessPolicy.isImmutable(namespace)) { + throw new DomainBadRequestException("error.namespace.system.immutable", namespace.getSlug()); + } + namespaceService.assertAdminOrOwner(namespace.getId(), operatorUserId); + if (!namespaceAccessPolicy.canManageMembers(namespace)) { + throw new DomainBadRequestException("error.namespace.readonly", namespace.getSlug()); + } + + String keyword = normalizeSearch(search); + if (keyword == null) { + return List.of(); + } + + int pageSize = normalizeSize(size); + Set existingMemberIds = namespaceMemberRepository.findByNamespaceId(namespace.getId(), PageRequest.of(0, 500)) + .stream() + .map(NamespaceMember::getUserId) + .collect(Collectors.toSet()); + + return userAccountRepository.search(keyword, UserStatus.ACTIVE, PageRequest.of(0, pageSize)).stream() + .filter(user -> !existingMemberIds.contains(user.getId())) + .map(NamespaceCandidateUserResponse::from) + .toList(); + } + + private String normalizeSearch(String search) { + if (!StringUtils.hasText(search)) { + return null; + } + String keyword = search.trim(); + if (keyword.length() < 2) { + throw new DomainBadRequestException("error.namespace.member.search.tooShort"); + } + return keyword; + } + + private int normalizeSize(int size) { + if (size <= 0) { + return DEFAULT_LIMIT; + } + return Math.min(size, MAX_LIMIT); + } +} 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..1e5da1c5 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,7 +1,10 @@ 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.namespace.NamespaceService; import com.iflytek.skillhub.domain.skill.Skill; import com.iflytek.skillhub.domain.skill.SkillRepository; import com.iflytek.skillhub.domain.skill.SkillVersion; @@ -27,16 +30,19 @@ public class SkillSearchAppService { private final SkillRepository skillRepository; private final NamespaceRepository namespaceRepository; private final SkillVersionRepository skillVersionRepository; + private final NamespaceService namespaceService; public SkillSearchAppService( SearchQueryService searchQueryService, SkillRepository skillRepository, NamespaceRepository namespaceRepository, - SkillVersionRepository skillVersionRepository) { + SkillVersionRepository skillVersionRepository, + NamespaceService namespaceService) { this.searchQueryService = searchQueryService; this.skillRepository = skillRepository; this.namespaceRepository = namespaceRepository; this.skillVersionRepository = skillVersionRepository; + this.namespaceService = namespaceService; } public record SearchResponse( @@ -55,62 +61,18 @@ public class SkillSearchAppService { String userId, Map userNsRoles) { - Long namespaceId = resolveNamespaceId(namespaceSlug); + Long namespaceId = resolveNamespaceId(namespaceSlug, userId, userNsRoles); SearchVisibilityScope scope = buildVisibilityScope(userId, userNsRoles); - SearchQuery query = new SearchQuery( - keyword, - namespaceId, - scope, - sortBy != null ? sortBy : "newest", - page, - size - ); - - SearchResult result = searchQueryService.search(query); - List matchedSkills = result.skillIds().isEmpty() - ? List.of() - : skillRepository.findByIdIn(result.skillIds()); - Map skillsById = matchedSkills.stream() - .collect(Collectors.toMap(Skill::getId, Function.identity())); - - List latestVersionIds = matchedSkills.stream() - .map(Skill::getLatestVersionId) - .filter(java.util.Objects::nonNull) - .distinct() - .toList(); - Map versionsById = latestVersionIds.isEmpty() - ? Map.of() - : skillVersionRepository.findByIdIn(latestVersionIds).stream() - .collect(Collectors.toMap(SkillVersion::getId, Function.identity())); - - List namespaceIds = matchedSkills.stream() - .map(Skill::getNamespaceId) - .distinct() - .toList(); - Map namespaceSlugsById = 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)); - - List skills = result.skillIds().stream() - .map(skillsById::get) - .filter(java.util.Objects::nonNull) - .map(skill -> toSummaryResponse(skill, versionsById, namespaceSlugsById)) - .toList(); - - return new SearchResponse(skills, result.total(), result.page(), result.size()); + return searchVisibleSkills(keyword, namespaceId, sortBy != null ? sortBy : "newest", page, size, userId, userNsRoles, scope); } - private Long resolveNamespaceId(String namespaceSlug) { + private Long resolveNamespaceId(String namespaceSlug, String userId, Map userNsRoles) { if (namespaceSlug == null || namespaceSlug.isBlank()) { return null; } - return namespaceRepository.findBySlug(namespaceSlug) - .map(com.iflytek.skillhub.domain.namespace.Namespace::getId) - .orElseThrow(() -> new DomainBadRequestException("error.namespace.slug.notFound", namespaceSlug)); + return namespaceService.getNamespaceBySlugForRead(namespaceSlug, userId, userNsRoles != null ? userNsRoles : Map.of()).getId(); } private SearchVisibilityScope buildVisibilityScope(String userId, Map userNsRoles) { @@ -131,6 +93,89 @@ public class SkillSearchAppService { return new SearchVisibilityScope(userId, memberNamespaceIds, adminNamespaceIds); } + private SearchResponse searchVisibleSkills( + String keyword, + Long namespaceId, + String sortBy, + int page, + int size, + String userId, + Map userNsRoles, + SearchVisibilityScope scope) { + int batchSize = Math.max(size, 20); + long rawTotal = Long.MAX_VALUE; + int rawPage = 0; + long visibleSeen = 0; + int visibleStart = page * size; + List pageItems = new java.util.ArrayList<>(); + + while ((long) rawPage * batchSize < rawTotal) { + SearchResult result = searchQueryService.search(new SearchQuery( + keyword, + namespaceId, + scope, + sortBy, + rawPage, + batchSize + )); + rawTotal = result.total(); + List visibleBatch = mapVisibleSkillSummaries(result.skillIds(), userId, userNsRoles); + for (SkillSummaryResponse item : visibleBatch) { + if (visibleSeen >= visibleStart && pageItems.size() < size) { + pageItems.add(item); + } + visibleSeen++; + } + if (result.skillIds().isEmpty()) { + break; + } + rawPage++; + } + + return new SearchResponse(pageItems, visibleSeen, page, size); + } + + private List mapVisibleSkillSummaries( + List skillIds, + String userId, + Map userNsRoles) { + if (skillIds.isEmpty()) { + return List.of(); + } + + List matchedSkills = skillRepository.findByIdIn(skillIds); + Map skillsById = matchedSkills.stream() + .collect(Collectors.toMap(Skill::getId, Function.identity())); + + List latestVersionIds = matchedSkills.stream() + .map(Skill::getLatestVersionId) + .filter(java.util.Objects::nonNull) + .distinct() + .toList(); + Map versionsById = latestVersionIds.isEmpty() + ? Map.of() + : skillVersionRepository.findByIdIn(latestVersionIds).stream() + .collect(Collectors.toMap(SkillVersion::getId, Function.identity())); + + List namespaceIds = matchedSkills.stream() + .map(Skill::getNamespaceId) + .distinct() + .toList(); + Map namespacesById = namespaceIds.isEmpty() + ? Map.of() + : namespaceRepository.findByIdIn(namespaceIds).stream() + .collect(Collectors.toMap(Namespace::getId, Function.identity())); + Map namespaceSlugsById = namespacesById.entrySet().stream() + .collect(Collectors.toMap(Map.Entry::getKey, entry -> entry.getValue().getSlug())); + + return 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(); + } + private SkillSummaryResponse toSummaryResponse( Skill skill, Map versionsById, @@ -153,9 +198,25 @@ public class SkillSearchAppService { skill.getRatingAvg(), skill.getRatingCount(), latestVersion, + skill.getLatestVersionId(), latestVersion == null ? null : "PUBLISHED", namespaceSlug, - skill.getUpdatedAt() + skill.getUpdatedAt(), + false ); } + + 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/main/resources/application.yml b/server/skillhub-app/src/main/resources/application.yml index 91a53856..c5e36b50 100644 --- a/server/skillhub-app/src/main/resources/application.yml +++ b/server/skillhub-app/src/main/resources/application.yml @@ -4,11 +4,11 @@ server: forward-headers-strategy: framework servlet: session: + timeout: ${SERVER_SERVLET_SESSION_TIMEOUT:8h} cookie: http-only: true secure: ${SESSION_COOKIE_SECURE:false} same-site: lax - max-age: 28800 spring: messages: @@ -101,7 +101,7 @@ skillhub: verification-uri: ${DEVICE_AUTH_VERIFICATION_URI:${skillhub.public.base-url:}/cli/auth} bootstrap: admin: - enabled: ${BOOTSTRAP_ADMIN_ENABLED:true} + enabled: ${BOOTSTRAP_ADMIN_ENABLED:false} user-id: ${BOOTSTRAP_ADMIN_USER_ID:docker-admin} username: ${BOOTSTRAP_ADMIN_USERNAME:admin} password: ${BOOTSTRAP_ADMIN_PASSWORD:ChangeMe!2026} diff --git a/server/skillhub-app/src/main/resources/db/migration/V12__governance_notifications.sql b/server/skillhub-app/src/main/resources/db/migration/V12__governance_notifications.sql new file mode 100644 index 00000000..9a9c7368 --- /dev/null +++ b/server/skillhub-app/src/main/resources/db/migration/V12__governance_notifications.sql @@ -0,0 +1,15 @@ +CREATE TABLE user_notification ( + id BIGSERIAL PRIMARY KEY, + user_id VARCHAR(128) NOT NULL, + category VARCHAR(64) NOT NULL, + entity_type VARCHAR(64) NOT NULL, + entity_id BIGINT NOT NULL, + title VARCHAR(200) NOT NULL, + body_json TEXT, + status VARCHAR(20) NOT NULL DEFAULT 'UNREAD', + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + read_at TIMESTAMPTZ +); + +CREATE INDEX idx_user_notification_user_created_at ON user_notification(user_id, created_at DESC); +CREATE INDEX idx_user_notification_user_status ON user_notification(user_id, status, created_at DESC); diff --git a/server/skillhub-app/src/main/resources/messages.properties b/server/skillhub-app/src/main/resources/messages.properties index 91dbcd7b..d1728926 100644 --- a/server/skillhub-app/src/main/resources/messages.properties +++ b/server/skillhub-app/src/main/resources/messages.properties @@ -64,6 +64,7 @@ error.namespace.member.alreadyExists=User is already a namespace member error.namespace.member.notFound=Member not found error.namespace.member.owner.remove=Cannot remove namespace owner error.namespace.member.owner.setDirect=Cannot set OWNER role directly, use ownership transfer instead +error.namespace.member.search.tooShort=Search keyword must be at least 2 characters error.namespace.owner.current.notFound=Current owner not found error.namespace.owner.current.invalid=Current user is not the namespace owner error.namespace.owner.new.notFound=New owner is not a namespace member diff --git a/server/skillhub-app/src/main/resources/messages_zh.properties b/server/skillhub-app/src/main/resources/messages_zh.properties index 2de14886..ba3ad200 100644 --- a/server/skillhub-app/src/main/resources/messages_zh.properties +++ b/server/skillhub-app/src/main/resources/messages_zh.properties @@ -64,6 +64,7 @@ error.namespace.member.alreadyExists=用户已经是该命名空间成员 error.namespace.member.notFound=未找到命名空间成员 error.namespace.member.owner.remove=不能移除命名空间 OWNER error.namespace.member.owner.setDirect=不能直接设置 OWNER 角色,请使用所有权转移 +error.namespace.member.search.tooShort=搜索关键词至少需要 2 个字符 error.namespace.owner.current.notFound=未找到当前所有者 error.namespace.owner.current.invalid=当前用户不是命名空间所有者 error.namespace.owner.new.notFound=新所有者不是该命名空间成员 diff --git a/server/skillhub-app/src/test/java/com/iflytek/skillhub/bootstrap/BootstrapAdminInitializerTest.java b/server/skillhub-app/src/test/java/com/iflytek/skillhub/bootstrap/BootstrapAdminInitializerTest.java new file mode 100644 index 00000000..a151d2a8 --- /dev/null +++ b/server/skillhub-app/src/test/java/com/iflytek/skillhub/bootstrap/BootstrapAdminInitializerTest.java @@ -0,0 +1,139 @@ +package com.iflytek.skillhub.bootstrap; + +import com.iflytek.skillhub.auth.entity.Role; +import com.iflytek.skillhub.auth.entity.UserRoleBinding; +import com.iflytek.skillhub.auth.local.LocalCredential; +import com.iflytek.skillhub.auth.local.LocalCredentialRepository; +import com.iflytek.skillhub.auth.repository.RoleRepository; +import com.iflytek.skillhub.auth.repository.UserRoleBindingRepository; +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.NamespaceRole; +import com.iflytek.skillhub.domain.user.UserAccount; +import com.iflytek.skillhub.domain.user.UserAccountRepository; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.ArgumentCaptor; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.boot.DefaultApplicationArguments; +import org.springframework.security.crypto.password.PasswordEncoder; + +import java.lang.reflect.Field; +import java.util.List; +import java.util.Optional; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.atLeastOnce; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +class BootstrapAdminInitializerTest { + + @Mock private UserAccountRepository userAccountRepository; + @Mock private LocalCredentialRepository localCredentialRepository; + @Mock private RoleRepository roleRepository; + @Mock private UserRoleBindingRepository userRoleBindingRepository; + @Mock private NamespaceRepository namespaceRepository; + @Mock private NamespaceMemberRepository namespaceMemberRepository; + @Mock private PasswordEncoder passwordEncoder; + + private BootstrapAdminProperties bootstrapAdminProperties; + private BootstrapAdminInitializer initializer; + + @BeforeEach + void setUp() { + bootstrapAdminProperties = new BootstrapAdminProperties(); + initializer = new BootstrapAdminInitializer( + bootstrapAdminProperties, + userAccountRepository, + localCredentialRepository, + roleRepository, + userRoleBindingRepository, + namespaceRepository, + namespaceMemberRepository, + passwordEncoder + ); + } + + @Test + void shouldSeedBootstrapAdminWithCredentialRoleAndMembership() throws Exception { + bootstrapAdminProperties.setEnabled(true); + Namespace global = new Namespace("global", "Global", "system"); + setField(global, "id", 1L); + + Role superAdminRole = new Role(); + setField(superAdminRole, "id", 1L); + setField(superAdminRole, "code", "SUPER_ADMIN"); + + when(localCredentialRepository.existsByUsernameIgnoreCase("admin")).thenReturn(false); + when(userAccountRepository.findById("docker-admin")).thenReturn(Optional.empty()); + when(userAccountRepository.save(any(UserAccount.class))).thenAnswer(invocation -> invocation.getArgument(0)); + when(passwordEncoder.encode("ChangeMe!2026")).thenReturn("encoded-password"); + when(roleRepository.findByCode("SUPER_ADMIN")).thenReturn(Optional.of(superAdminRole)); + when(userRoleBindingRepository.findByUserId("docker-admin")).thenReturn(List.of()); + when(namespaceRepository.findBySlug("global")).thenReturn(Optional.of(global)); + when(namespaceMemberRepository.findByNamespaceIdAndUserId(1L, "docker-admin")).thenReturn(Optional.empty()); + + initializer.run(new DefaultApplicationArguments(new String[0])); + + ArgumentCaptor userCaptor = ArgumentCaptor.forClass(UserAccount.class); + verify(userAccountRepository, atLeastOnce()).save(userCaptor.capture()); + UserAccount savedUser = userCaptor.getAllValues().getLast(); + assertEquals("docker-admin", savedUser.getId()); + assertEquals("Admin", savedUser.getDisplayName()); + assertEquals("admin@skillhub.local", savedUser.getEmail()); + + ArgumentCaptor credentialCaptor = ArgumentCaptor.forClass(LocalCredential.class); + verify(localCredentialRepository).save(credentialCaptor.capture()); + assertEquals("docker-admin", credentialCaptor.getValue().getUserId()); + assertEquals("admin", credentialCaptor.getValue().getUsername()); + assertEquals("encoded-password", credentialCaptor.getValue().getPasswordHash()); + + ArgumentCaptor roleBindingCaptor = ArgumentCaptor.forClass(UserRoleBinding.class); + verify(userRoleBindingRepository).save(roleBindingCaptor.capture()); + assertEquals("docker-admin", roleBindingCaptor.getValue().getUserId()); + assertEquals("SUPER_ADMIN", roleBindingCaptor.getValue().getRole().getCode()); + + ArgumentCaptor memberCaptor = ArgumentCaptor.forClass(NamespaceMember.class); + verify(namespaceMemberRepository).save(memberCaptor.capture()); + assertEquals("docker-admin", memberCaptor.getValue().getUserId()); + assertEquals(NamespaceRole.OWNER, memberCaptor.getValue().getRole()); + } + + @Test + void shouldSkipWhenBootstrapAdminCredentialAlreadyExists() { + bootstrapAdminProperties.setEnabled(true); + when(localCredentialRepository.existsByUsernameIgnoreCase("admin")).thenReturn(true); + + initializer.run(new DefaultApplicationArguments(new String[0])); + + verify(userAccountRepository, never()).save(any(UserAccount.class)); + verify(localCredentialRepository, never()).save(any(LocalCredential.class)); + verify(userRoleBindingRepository, never()).save(any(UserRoleBinding.class)); + verify(namespaceMemberRepository, never()).save(any(NamespaceMember.class)); + } + + @Test + void shouldSkipWhenBootstrapAdminIsDisabled() { + bootstrapAdminProperties.setEnabled(false); + + initializer.run(new DefaultApplicationArguments(new String[0])); + + verify(localCredentialRepository, never()).existsByUsernameIgnoreCase(any()); + verify(userAccountRepository, never()).save(any(UserAccount.class)); + } + + private static void setField(Object target, String fieldName, Object value) throws Exception { + Field field = target.getClass().getDeclaredField(fieldName); + field.setAccessible(true); + field.set(target, value); + } +} diff --git a/server/skillhub-app/src/test/java/com/iflytek/skillhub/compat/ClawHubCompatControllerTest.java b/server/skillhub-app/src/test/java/com/iflytek/skillhub/compat/ClawHubCompatControllerTest.java index 691ac8ad..36da9c89 100644 --- a/server/skillhub-app/src/test/java/com/iflytek/skillhub/compat/ClawHubCompatControllerTest.java +++ b/server/skillhub-app/src/test/java/com/iflytek/skillhub/compat/ClawHubCompatControllerTest.java @@ -62,9 +62,11 @@ class ClawHubCompatControllerTest { BigDecimal.valueOf(4.5), 2, "1.2.0", + 11L, "PUBLISHED", "global", - LocalDateTime.of(2026, 3, 13, 9, 0))), + LocalDateTime.of(2026, 3, 13, 9, 0), + false)), 1, 0, 20 diff --git a/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/AuthControllerTest.java b/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/AuthControllerTest.java index 1e127f64..b5d56760 100644 --- a/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/AuthControllerTest.java +++ b/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/AuthControllerTest.java @@ -58,7 +58,8 @@ class AuthControllerTest { @Test void meShouldReturnUnauthorizedForAnonymousRequest() throws Exception { mockMvc.perform(get("/api/v1/auth/me")) - .andExpect(status().isUnauthorized()); + .andExpect(status().isUnauthorized()) + .andExpect(jsonPath("$.code").value(401)); } @Test @@ -86,7 +87,6 @@ class AuthControllerTest { .andExpect(header().string("X-Frame-Options", "DENY")) .andExpect(header().string("Referrer-Policy", "strict-origin-when-cross-origin")) .andExpect(jsonPath("$.code").value(0)) - .andExpect(jsonPath("$.msg").isNotEmpty()) .andExpect(jsonPath("$.data.userId").value("user-42")) .andExpect(jsonPath("$.data.displayName").value("tester")) .andExpect(jsonPath("$.data.oauthProvider").value("github")) @@ -100,7 +100,6 @@ class AuthControllerTest { mockMvc.perform(get("/api/v1/auth/providers")) .andExpect(status().isOk()) .andExpect(jsonPath("$.code").value(0)) - .andExpect(jsonPath("$.msg").isNotEmpty()) .andExpect(jsonPath("$.data.length()").value(2)) .andExpect(jsonPath("$.data[*].id", hasItems("github", "gitee"))) .andExpect(jsonPath("$.data[*].authorizationUrl", hasItems( @@ -115,6 +114,7 @@ class AuthControllerTest { void providersShouldAppendReturnToWhenRequested() throws Exception { mockMvc.perform(get("/api/v1/auth/providers").param("returnTo", "/dashboard/publish")) .andExpect(status().isOk()) + .andExpect(jsonPath("$.code").value(0)) .andExpect(jsonPath("$.data[*].authorizationUrl", hasItems( "/oauth2/authorization/github?returnTo=%2Fdashboard%2Fpublish", "/oauth2/authorization/gitee?returnTo=%2Fdashboard%2Fpublish" @@ -141,8 +141,7 @@ class AuthControllerTest { {"provider":"private-sso"} """)) .andExpect(status().isForbidden()) - .andExpect(jsonPath("$.code").value(403)) - .andExpect(jsonPath("$.msg").isNotEmpty()); + .andExpect(jsonPath("$.code").value(403)); } @Test @@ -154,7 +153,6 @@ class AuthControllerTest { {"provider":"private-sso","username":"alice","password":"secret"} """)) .andExpect(status().isForbidden()) - .andExpect(jsonPath("$.code").value(403)) - .andExpect(jsonPath("$.msg").isNotEmpty()); + .andExpect(jsonPath("$.code").value(403)); } } diff --git a/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/AuthRateLimitControllerTest.java b/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/AuthRateLimitControllerTest.java index 9f26e22f..6e2d936e 100644 --- a/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/AuthRateLimitControllerTest.java +++ b/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/AuthRateLimitControllerTest.java @@ -60,8 +60,7 @@ class AuthRateLimitControllerTest { {"username":"alice","password":"wrong"} """)) .andExpect(status().isTooManyRequests()) - .andExpect(jsonPath("$.code").value(429)) - .andExpect(jsonPath("$.msg").isNotEmpty()); + .andExpect(jsonPath("$.code").value(429)); verify(localAuthService, never()).login(anyString(), anyString()); } @@ -78,7 +77,8 @@ class AuthRateLimitControllerTest { .content(""" {"username":"alice","password":"wrong"} """)) - .andExpect(status().isUnauthorized()); + .andExpect(status().isUnauthorized()) + .andExpect(jsonPath("$.code").value(401)); verify(authFailureThrottleService).assertAllowed("local", "alice", "127.0.0.1"); verify(authFailureThrottleService).recordFailure("local", "alice", "127.0.0.1"); @@ -102,7 +102,8 @@ class AuthRateLimitControllerTest { .content(""" {"username":"alice","password":"correct"} """)) - .andExpect(status().isOk()); + .andExpect(status().isOk()) + .andExpect(jsonPath("$.code").value(0)); verify(authFailureThrottleService).resetIdentifier("local", "alice"); } diff --git a/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/CliControllerTest.java b/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/CliControllerTest.java index 62ebe34d..f4a55910 100644 --- a/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/CliControllerTest.java +++ b/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/CliControllerTest.java @@ -8,22 +8,17 @@ 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.mock.web.MockMultipartFile; 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 java.io.ByteArrayOutputStream; import java.util.List; import java.util.Set; -import java.util.zip.ZipEntry; -import java.util.zip.ZipOutputStream; import static org.mockito.BDDMockito.given; import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.authentication; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; -import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.multipart; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; @@ -44,7 +39,8 @@ class CliControllerTest { @Test void whoamiShouldReturnUnauthorizedForAnonymousRequest() throws Exception { mockMvc.perform(get("/api/v1/whoami")) - .andExpect(status().isUnauthorized()); + .andExpect(status().isUnauthorized()) + .andExpect(jsonPath("$.code").value(401)); } @Test @@ -68,157 +64,8 @@ class CliControllerTest { mockMvc.perform(get("/api/v1/whoami").with(authentication(auth))) .andExpect(status().isOk()) - .andExpect(jsonPath("$.code").value(0)) - .andExpect(jsonPath("$.msg").isNotEmpty()) - .andExpect(jsonPath("$.data.userId").value("user-7")) - .andExpect(jsonPath("$.data.displayName").value("cli-user")) - .andExpect(jsonPath("$.data.authType").value("api_token")) - .andExpect(jsonPath("$.data.platformRoles[0]").value("SKILL_ADMIN")) - .andExpect(jsonPath("$.timestamp").isNotEmpty()) - .andExpect(jsonPath("$.requestId").isNotEmpty()); - } - - @Test - void checkShouldReturnValidForValidPackage() throws Exception { - byte[] zipBytes = createValidSkillZip(); - MockMultipartFile file = new MockMultipartFile( - "file", - "skill.zip", - "application/zip", - zipBytes - ); - - mockMvc.perform(multipart("/api/v1/check").file(file)) - .andExpect(status().isOk()) - .andExpect(jsonPath("$.code").value(0)) - .andExpect(jsonPath("$.data.valid").value(true)) - .andExpect(jsonPath("$.data.errors").isEmpty()) - .andExpect(jsonPath("$.data.fileCount").value(2)) - .andExpect(jsonPath("$.data.totalSize").isNumber()); - } - - @Test - void checkShouldReturnInvalidForMissingSkillMd() throws Exception { - byte[] zipBytes = createInvalidSkillZip(); - MockMultipartFile file = new MockMultipartFile( - "file", - "skill.zip", - "application/zip", - zipBytes - ); - - mockMvc.perform(multipart("/api/v1/check").file(file)) - .andExpect(status().isOk()) - .andExpect(jsonPath("$.code").value(0)) - .andExpect(jsonPath("$.data.valid").value(false)) - .andExpect(jsonPath("$.data.errors").isNotEmpty()) - .andExpect(jsonPath("$.data.errors[0]").value("Missing required file: SKILL.md at root")); - } - - @Test - void checkShouldReturnInvalidForDisallowedExtension() throws Exception { - byte[] zipBytes = createZipWithDisallowedFile(); - MockMultipartFile file = new MockMultipartFile( - "file", - "skill.zip", - "application/zip", - zipBytes - ); - - mockMvc.perform(multipart("/api/v1/check").file(file)) - .andExpect(status().isOk()) - .andExpect(jsonPath("$.code").value(0)) - .andExpect(jsonPath("$.data.valid").value(false)) - .andExpect(jsonPath("$.data.errors").isNotEmpty()); - } - - @Test - void checkShouldReturnInvalidForPathTraversalEntry() throws Exception { - byte[] zipBytes = createZipWithUnsafePath(); - MockMultipartFile file = new MockMultipartFile( - "file", - "skill.zip", - "application/zip", - zipBytes - ); - - mockMvc.perform(multipart("/api/v1/check").file(file)) - .andExpect(status().isOk()) - .andExpect(jsonPath("$.code").value(0)) - .andExpect(jsonPath("$.data.valid").value(false)) - .andExpect(jsonPath("$.data.errors[0]").value(org.hamcrest.Matchers.containsString("escapes package root"))) - .andExpect(jsonPath("$.data.fileCount").value(0)) - .andExpect(jsonPath("$.data.totalSize").value(0)); - } - - private byte[] createValidSkillZip() throws Exception { - ByteArrayOutputStream baos = new ByteArrayOutputStream(); - try (ZipOutputStream zos = new ZipOutputStream(baos)) { - String skillMdContent = """ - --- - name: test-skill - description: A test skill - version: 1.0.0 - --- - # Test Skill - This is a test skill. - """; - ZipEntry skillMdEntry = new ZipEntry("SKILL.md"); - zos.putNextEntry(skillMdEntry); - zos.write(skillMdContent.getBytes()); - zos.closeEntry(); - - ZipEntry readmeEntry = new ZipEntry("README.md"); - zos.putNextEntry(readmeEntry); - zos.write("# README\nThis is a readme.".getBytes()); - zos.closeEntry(); - } - return baos.toByteArray(); - } - - private byte[] createInvalidSkillZip() throws Exception { - ByteArrayOutputStream baos = new ByteArrayOutputStream(); - try (ZipOutputStream zos = new ZipOutputStream(baos)) { - ZipEntry readmeEntry = new ZipEntry("README.md"); - zos.putNextEntry(readmeEntry); - zos.write("# README".getBytes()); - zos.closeEntry(); - } - return baos.toByteArray(); - } - - private byte[] createZipWithDisallowedFile() throws Exception { - ByteArrayOutputStream baos = new ByteArrayOutputStream(); - try (ZipOutputStream zos = new ZipOutputStream(baos)) { - String skillMdContent = """ - --- - name: test-skill - description: A test skill - version: 1.0.0 - --- - # Test Skill - """; - ZipEntry skillMdEntry = new ZipEntry("SKILL.md"); - zos.putNextEntry(skillMdEntry); - zos.write(skillMdContent.getBytes()); - zos.closeEntry(); - - ZipEntry exeEntry = new ZipEntry("malware.exe"); - zos.putNextEntry(exeEntry); - zos.write("bad content".getBytes()); - zos.closeEntry(); - } - return baos.toByteArray(); - } - - private byte[] createZipWithUnsafePath() throws Exception { - ByteArrayOutputStream baos = new ByteArrayOutputStream(); - try (ZipOutputStream zos = new ZipOutputStream(baos)) { - ZipEntry unsafeEntry = new ZipEntry("../secrets.txt"); - zos.putNextEntry(unsafeEntry); - zos.write("hidden".getBytes()); - zos.closeEntry(); - } - return baos.toByteArray(); + .andExpect(jsonPath("$.user.handle").value("user-7")) + .andExpect(jsonPath("$.user.displayName").value("cli-user")) + .andExpect(jsonPath("$.user.image").value("")); } } diff --git a/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/DirectAuthControllerTest.java b/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/DirectAuthControllerTest.java index 8ef61470..2af0d75c 100644 --- a/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/DirectAuthControllerTest.java +++ b/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/DirectAuthControllerTest.java @@ -71,6 +71,7 @@ class DirectAuthControllerTest { mockMvc.perform(get("/api/v1/auth/me").session(session)) .andExpect(status().isOk()) + .andExpect(jsonPath("$.code").value(0)) .andExpect(jsonPath("$.data.userId").value("usr_direct_1")); } diff --git a/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/GovernanceControllerTest.java b/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/GovernanceControllerTest.java new file mode 100644 index 00000000..2206116b --- /dev/null +++ b/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/GovernanceControllerTest.java @@ -0,0 +1,159 @@ +package com.iflytek.skillhub.controller; + +import static org.mockito.Mockito.when; +import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.authentication; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +import com.iflytek.skillhub.auth.device.DeviceAuthService; +import com.iflytek.skillhub.auth.rbac.PlatformPrincipal; +import com.iflytek.skillhub.auth.rbac.RbacService; +import com.iflytek.skillhub.domain.namespace.NamespaceMemberRepository; +import com.iflytek.skillhub.domain.governance.GovernanceNotificationService; +import com.iflytek.skillhub.domain.governance.UserNotification; +import com.iflytek.skillhub.dto.GovernanceActivityItemResponse; +import com.iflytek.skillhub.dto.GovernanceInboxItemResponse; +import com.iflytek.skillhub.dto.GovernanceNotificationResponse; +import com.iflytek.skillhub.dto.GovernanceSummaryResponse; +import com.iflytek.skillhub.dto.PageResponse; +import com.iflytek.skillhub.service.GovernanceWorkbenchAppService; +import java.time.Instant; +import java.util.List; +import java.util.Map; +import java.util.Set; +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.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; + +@SpringBootTest +@AutoConfigureMockMvc +@ActiveProfiles("test") +class GovernanceControllerTest { + + @Autowired + private MockMvc mockMvc; + + @MockBean + private GovernanceWorkbenchAppService governanceWorkbenchAppService; + + @MockBean + private NamespaceMemberRepository namespaceMemberRepository; + + @MockBean + private DeviceAuthService deviceAuthService; + + @MockBean + private RbacService rbacService; + + @MockBean + private GovernanceNotificationService governanceNotificationService; + + @Test + void summary_returnsGovernanceSummary() throws Exception { + when(rbacService.getUserRoleCodes("admin")).thenReturn(Set.of("SKILL_ADMIN")); + when(governanceWorkbenchAppService.getSummary("admin", Map.of(), Set.of("SKILL_ADMIN"))) + .thenReturn(new GovernanceSummaryResponse(3, 2, 1)); + + mockMvc.perform(get("/api/v1/governance/summary").with(auth("admin", Set.of("SKILL_ADMIN")))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.pendingReviews").value(3)) + .andExpect(jsonPath("$.data.pendingPromotions").value(2)) + .andExpect(jsonPath("$.data.pendingReports").value(1)); + } + + @Test + void inbox_returnsUnifiedItems() throws Exception { + when(rbacService.getUserRoleCodes("admin")).thenReturn(Set.of("SKILL_ADMIN")); + when(governanceWorkbenchAppService.listInbox("admin", Map.of(), Set.of("SKILL_ADMIN"), null, 0, 20)) + .thenReturn(new PageResponse<>( + List.of(new GovernanceInboxItemResponse( + "REVIEW", + 1L, + "team-a/skill-a@1.0.0", + "Pending review", + "2026-03-16T02:00:00Z", + "team-a", + "skill-a" + )), + 1, + 0, + 20 + )); + + mockMvc.perform(get("/api/v1/governance/inbox").with(auth("admin", Set.of("SKILL_ADMIN")))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.items[0].type").value("REVIEW")); + } + + @Test + void activity_returnsGovernanceActivity() throws Exception { + when(rbacService.getUserRoleCodes("admin")).thenReturn(Set.of("SKILL_ADMIN")); + when(governanceWorkbenchAppService.listActivity(Set.of("SKILL_ADMIN"), 0, 20)) + .thenReturn(new PageResponse<>( + List.of(new GovernanceActivityItemResponse( + 1L, + "REVIEW_APPROVE", + "admin", + "Admin", + "REVIEW_TASK", + "99", + "{\"comment\":\"LGTM\"}", + Instant.parse("2026-03-16T02:00:00Z").toString() + )), + 1, + 0, + 20 + )); + + mockMvc.perform(get("/api/v1/governance/activity").with(auth("admin", Set.of("SKILL_ADMIN")))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.items[0].action").value("REVIEW_APPROVE")); + } + + @Test + void notifications_returnsCurrentUserNotifications() throws Exception { + UserNotification notification = new UserNotification("admin", "REVIEW", "REVIEW_TASK", 99L, "Review approved", "{}"); + when(governanceNotificationService.listNotifications("admin")).thenReturn(List.of(notification)); + + mockMvc.perform(get("/api/v1/governance/notifications").with(auth("admin", Set.of("SKILL_ADMIN")))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data[0].category").value("REVIEW")); + } + + @Test + void markRead_returnsUpdatedNotification() throws Exception { + UserNotification notification = new UserNotification("admin", "REVIEW", "REVIEW_TASK", 99L, "Review approved", "{}"); + when(governanceNotificationService.markRead(10L, "admin")).thenReturn(notification); + + mockMvc.perform(org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post("/api/v1/governance/notifications/10/read") + .with(org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.csrf()) + .with(auth("admin", Set.of("SKILL_ADMIN")))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.category").value("REVIEW")); + } + + private RequestPostProcessor auth(String userId, Set roles) { + PlatformPrincipal principal = new PlatformPrincipal( + userId, + userId, + userId + "@example.com", + "", + "session", + roles + ); + UsernamePasswordAuthenticationToken authenticationToken = new UsernamePasswordAuthenticationToken( + principal, + null, + roles.stream().map(role -> new SimpleGrantedAuthority("ROLE_" + role)).toList() + ); + return authentication(authenticationToken); + } +} diff --git a/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/HealthControllerTest.java b/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/HealthControllerTest.java index fb329bd5..8ddfc993 100644 --- a/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/HealthControllerTest.java +++ b/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/HealthControllerTest.java @@ -25,7 +25,6 @@ class HealthControllerTest { mockMvc.perform(get("/api/v1/health")) .andExpect(status().isOk()) .andExpect(jsonPath("$.code").value(0)) - .andExpect(jsonPath("$.msg").isNotEmpty()) .andExpect(jsonPath("$.data.message").value("UP")) .andExpect(jsonPath("$.timestamp").isNotEmpty()) .andExpect(jsonPath("$.requestId").isNotEmpty()) diff --git a/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/LocalAuthControllerTest.java b/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/LocalAuthControllerTest.java index c83c66fc..bb5ee3ff 100644 --- a/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/LocalAuthControllerTest.java +++ b/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/LocalAuthControllerTest.java @@ -108,12 +108,13 @@ class LocalAuthControllerTest { mockMvc.perform(post("/api/v1/auth/local/register") .with(csrf()) + .header("Accept-Language", "zh-CN") .contentType(MediaType.APPLICATION_JSON) .content(""" {"username":"bob","password":"Abcd123!","email":"not-an-email"} """)) .andExpect(status().isBadRequest()) - .andExpect(jsonPath("$.msg").value("邮箱格式不正确")); + .andExpect(jsonPath("$.code").value(400)); verify(localAuthService).register("bob", "Abcd123!", "not-an-email"); } @@ -129,7 +130,8 @@ class LocalAuthControllerTest { .content(""" {"username":"alice","password":"wrong"} """)) - .andExpect(status().isUnauthorized()); + .andExpect(status().isUnauthorized()) + .andExpect(jsonPath("$.code").value(401)); verify(authFailureThrottleService).recordFailure("local", "alice", "127.0.0.1"); verify(skillHubMetrics).recordLocalLogin(false); verify(skillHubMetrics, never()).recordLocalLogin(true); @@ -143,7 +145,8 @@ class LocalAuthControllerTest { .content(""" {"currentPassword":"old","newPassword":"Newpass123!"} """)) - .andExpect(status().isUnauthorized()); + .andExpect(status().isUnauthorized()) + .andExpect(jsonPath("$.code").value(401)); } @Test 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..38c533b8 --- /dev/null +++ b/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/NamespacePortalControllerTest.java @@ -0,0 +1,188 @@ +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 com.iflytek.skillhub.domain.shared.exception.DomainForbiddenException; +import com.iflytek.skillhub.dto.NamespaceCandidateUserResponse; +import com.iflytek.skillhub.service.NamespaceMemberCandidateService; +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.mockito.Mockito.doThrow; +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 NamespaceMemberCandidateService namespaceMemberCandidateService; + + @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 getNamespace_hidesArchivedNamespaceFromAnonymousUsers() throws Exception { + Namespace namespace = namespace(1L, "team-a", NamespaceStatus.ARCHIVED, NamespaceType.TEAM); + given(namespaceService.getNamespaceBySlugForRead("team-a", null, Map.of())).willThrow( + new com.iflytek.skillhub.domain.shared.exception.DomainBadRequestException( + "error.namespace.slug.notFound", + "team-a" + ) + ); + + mockMvc.perform(get("/api/v1/namespaces/team-a")) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.code").value(400)); + } + + @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")); + } + + @Test + void listMembers_forNonMember_returns403() throws Exception { + Namespace namespace = namespace(1L, "team-a", NamespaceStatus.ACTIVE, NamespaceType.TEAM); + given(namespaceService.getNamespaceBySlug("team-a")).willReturn(namespace); + doThrow(new DomainForbiddenException("error.namespace.membership.required")) + .when(namespaceService).assertMember(1L, "guest-1"); + + mockMvc.perform(get("/api/v1/namespaces/team-a/members") + .with(auth("guest-1")) + .requestAttr("userId", "guest-1")) + .andExpect(status().isForbidden()) + .andExpect(jsonPath("$.code").value(403)); + } + + @Test + void searchMemberCandidates_returnsCandidates() throws Exception { + Namespace namespace = namespace(1L, "team-a", NamespaceStatus.ACTIVE, NamespaceType.TEAM); + given(namespaceService.getNamespaceBySlug("team-a")).willReturn(namespace); + given(namespaceMemberCandidateService.searchCandidates("team-a", "ali", "owner-1", 10)) + .willReturn(List.of(new NamespaceCandidateUserResponse( + "user-2", + "alice", + "alice@example.com", + "ACTIVE" + ))); + + mockMvc.perform(get("/api/v1/namespaces/team-a/member-candidates") + .param("search", "ali") + .with(auth("owner-1")) + .requestAttr("userId", "owner-1")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.code").value(0)) + .andExpect(jsonPath("$.data[0].userId").value("user-2")) + .andExpect(jsonPath("$.data[0].displayName").value("alice")); + } + + 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); + } + } +} diff --git a/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/NamespaceWorkflowContractTest.java b/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/NamespaceWorkflowContractTest.java new file mode 100644 index 00000000..167880fc --- /dev/null +++ b/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/NamespaceWorkflowContractTest.java @@ -0,0 +1,200 @@ +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.NamespaceMember; +import com.iflytek.skillhub.domain.namespace.NamespaceMemberRepository; +import com.iflytek.skillhub.domain.namespace.NamespaceMemberService; +import com.iflytek.skillhub.domain.namespace.NamespaceRepository; +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 com.iflytek.skillhub.dto.NamespaceCandidateUserResponse; +import com.iflytek.skillhub.service.NamespaceMemberCandidateService; +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.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.Set; + +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +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.delete; +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.request.MockMvcRequestBuilders.put; +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 NamespaceWorkflowContractTest { + + @Autowired + private MockMvc mockMvc; + + @MockBean + private NamespaceService namespaceService; + + @MockBean + private NamespaceGovernanceService namespaceGovernanceService; + + @MockBean + private NamespaceMemberService namespaceMemberService; + + @MockBean + private NamespaceRepository namespaceRepository; + + @MockBean + private NamespaceMemberRepository namespaceMemberRepository; + + @MockBean + private NamespaceMemberCandidateService namespaceMemberCandidateService; + + @MockBean + private DeviceAuthService deviceAuthService; + + @Test + void namespaceWorkflowEndpoints_shareExpectedEnvelopeShapes() throws Exception { + Namespace namespace = namespace(7L, "team-flow", NamespaceStatus.ACTIVE, NamespaceType.TEAM); + Namespace frozen = namespace(7L, "team-flow", NamespaceStatus.FROZEN, NamespaceType.TEAM); + Namespace archived = namespace(7L, "team-flow", NamespaceStatus.ARCHIVED, NamespaceType.TEAM); + NamespaceMember adminMember = new NamespaceMember(7L, "user-admin", NamespaceRole.ADMIN); + setMemberId(adminMember, 11L); + + given(namespaceService.createNamespace(eq("team-flow"), eq("Team Flow"), eq("workflow"), eq("owner-1"))) + .willReturn(namespace); + given(namespaceService.getNamespaceBySlug("team-flow")).willReturn(namespace); + given(namespaceGovernanceService.freezeNamespace(eq("team-flow"), eq("owner-1"), eq(null), eq(null), any(), any())) + .willReturn(frozen); + given(namespaceGovernanceService.archiveNamespace(eq("team-flow"), eq("owner-1"), eq("cleanup"), eq(null), any(), any())) + .willReturn(archived); + given(namespaceMemberCandidateService.searchCandidates("team-flow", "admin", "owner-1", 10)) + .willReturn(List.of(new NamespaceCandidateUserResponse("user-admin", "Admin", "admin@example.com", "ACTIVE"))); + given(namespaceMemberService.addMember(7L, "user-admin", NamespaceRole.ADMIN, "owner-1")) + .willReturn(adminMember); + given(namespaceMemberService.listMembers(eq(7L), any(org.springframework.data.domain.Pageable.class))) + .willReturn(new org.springframework.data.domain.PageImpl<>(List.of(adminMember))); + given(namespaceMemberService.updateMemberRole(7L, "user-admin", NamespaceRole.ADMIN, "owner-1")) + .willReturn(adminMember); + + mockMvc.perform(post("/api/web/namespaces") + .with(csrf()) + .with(auth("owner-1")) + .requestAttr("userId", "owner-1") + .contentType(org.springframework.http.MediaType.APPLICATION_JSON) + .content("{\"slug\":\"team-flow\",\"displayName\":\"Team Flow\",\"description\":\"workflow\"}")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.code").value(0)) + .andExpect(jsonPath("$.data.slug").value("team-flow")); + + mockMvc.perform(get("/api/web/namespaces/team-flow/member-candidates") + .param("search", "admin") + .with(auth("owner-1")) + .requestAttr("userId", "owner-1")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.code").value(0)) + .andExpect(jsonPath("$.data[0].userId").value("user-admin")); + + mockMvc.perform(post("/api/web/namespaces/team-flow/members") + .with(csrf()) + .with(auth("owner-1")) + .requestAttr("userId", "owner-1") + .contentType(org.springframework.http.MediaType.APPLICATION_JSON) + .content("{\"userId\":\"user-admin\",\"role\":\"ADMIN\"}")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.code").value(0)) + .andExpect(jsonPath("$.data.userId").value("user-admin")); + + mockMvc.perform(get("/api/web/namespaces/team-flow/members") + .with(auth("owner-1")) + .requestAttr("userId", "owner-1")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.code").value(0)) + .andExpect(jsonPath("$.data.items[0].userId").value("user-admin")); + + mockMvc.perform(put("/api/web/namespaces/team-flow/members/user-admin/role") + .with(csrf()) + .with(auth("owner-1")) + .requestAttr("userId", "owner-1") + .contentType(org.springframework.http.MediaType.APPLICATION_JSON) + .content("{\"role\":\"ADMIN\"}")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.code").value(0)) + .andExpect(jsonPath("$.data.role").value("ADMIN")); + + mockMvc.perform(post("/api/web/namespaces/team-flow/freeze") + .with(csrf()) + .with(auth("owner-1")) + .requestAttr("userId", "owner-1")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.code").value(0)) + .andExpect(jsonPath("$.data.status").value("FROZEN")); + + mockMvc.perform(post("/api/web/namespaces/team-flow/archive") + .with(csrf()) + .with(auth("owner-1")) + .requestAttr("userId", "owner-1") + .contentType(org.springframework.http.MediaType.APPLICATION_JSON) + .content("{\"reason\":\"cleanup\"}")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.code").value(0)) + .andExpect(jsonPath("$.data.status").value("ARCHIVED")); + + mockMvc.perform(delete("/api/web/namespaces/team-flow/members/user-admin") + .with(csrf()) + .with(auth("owner-1")) + .requestAttr("userId", "owner-1")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.code").value(0)) + .andExpect(jsonPath("$.data.message").value("Member removed successfully")); + } + + private RequestPostProcessor auth(String userId) { + PlatformPrincipal principal = new PlatformPrincipal( + userId, + userId, + userId + "@example.com", + "", + "session", + 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 Flow", "owner-1"); + setNamespaceId(namespace, id); + namespace.setStatus(status); + namespace.setType(type); + return namespace; + } + + private void setNamespaceId(Namespace namespace, Long id) { + org.springframework.test.util.ReflectionTestUtils.setField(namespace, "id", id); + } + + private void setMemberId(NamespaceMember member, Long id) { + org.springframework.test.util.ReflectionTestUtils.setField(member, "id", id); + } +} diff --git a/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/SessionBootstrapControllerTest.java b/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/SessionBootstrapControllerTest.java index 59d0746d..0a76b817 100644 --- a/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/SessionBootstrapControllerTest.java +++ b/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/SessionBootstrapControllerTest.java @@ -59,6 +59,7 @@ class SessionBootstrapControllerTest { mockMvc.perform(get("/api/v1/auth/me").session(session)) .andExpect(status().isOk()) + .andExpect(jsonPath("$.code").value(0)) .andExpect(jsonPath("$.data.userId").value("sso-user-1")) .andExpect(jsonPath("$.data.oauthProvider").value("private-sso")); } 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..81047387 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; @@ -63,7 +64,6 @@ class SkillControllerTest { mockMvc.perform(get("/api/v1/skills/team/demo/versions/1.0.0")) .andExpect(status().isOk()) .andExpect(jsonPath("$.code").value(0)) - .andExpect(jsonPath("$.msg").isNotEmpty()) .andExpect(jsonPath("$.data.version").value("1.0.0")) .andExpect(jsonPath("$.data.parsedMetadataJson").value("{\"name\":\"demo\"}")) .andExpect(jsonPath("$.data.manifestJson").value("[{\"path\":\"SKILL.md\"}]")) @@ -126,18 +126,37 @@ class SkillControllerTest { LocalDateTime.of(2026, 3, 15, 10, 0), LocalDateTime.of(2026, 3, 15, 10, 0), null, + 11L, true, + false, "PENDING_REVIEW", false )); mockMvc.perform(get("/api/web/skills/team/demo")) .andExpect(status().isOk()) + .andExpect(jsonPath("$.code").value(0)) .andExpect(jsonPath("$.data.latestVersion").value("1.1.0")) + .andExpect(jsonPath("$.data.latestVersionId").value(11L)) + .andExpect(jsonPath("$.data.canSubmitPromotion").value(false)) .andExpect(jsonPath("$.data.viewingVersionStatus").value("PENDING_REVIEW")) .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/SkillRatingControllerTest.java b/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/SkillRatingControllerTest.java index 7043ac04..4b3b5681 100644 --- a/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/SkillRatingControllerTest.java +++ b/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/SkillRatingControllerTest.java @@ -105,12 +105,14 @@ class SkillRatingControllerTest { .with(csrf()) .contentType(MediaType.APPLICATION_JSON) .content("{\"score\": 4}")) - .andExpect(status().isUnauthorized()); + .andExpect(status().isUnauthorized()) + .andExpect(jsonPath("$.code").value(401)); } @Test void get_user_rating_unauthenticated_returns_401() throws Exception { mockMvc.perform(get("/api/v1/skills/10/rating")) - .andExpect(status().isUnauthorized()); + .andExpect(status().isUnauthorized()) + .andExpect(jsonPath("$.code").value(401)); } } 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/controller/SkillStarControllerTest.java b/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/SkillStarControllerTest.java index 841fb77b..111d1173 100644 --- a/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/SkillStarControllerTest.java +++ b/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/SkillStarControllerTest.java @@ -96,7 +96,8 @@ class SkillStarControllerTest { void star_skill_unauthenticated_returns_401() throws Exception { mockMvc.perform(put("/api/v1/skills/10/star") .with(csrf())) - .andExpect(status().isUnauthorized()); + .andExpect(status().isUnauthorized()) + .andExpect(jsonPath("$.code").value(401)); } @Test @@ -130,11 +131,12 @@ class SkillStarControllerTest { @Test void check_starred_unauthenticated_returns_401() throws Exception { mockMvc.perform(get("/api/v1/skills/10/star")) - .andExpect(status().isUnauthorized()); + .andExpect(status().isUnauthorized()) + .andExpect(jsonPath("$.code").value(401)); } @Test - void apiWebStarSkillWithoutCsrfShouldBeRejectedForSessionAuth() throws Exception { + void apiWebStarSkillWithoutCsrfShouldAllowSessionAuth() throws Exception { PlatformPrincipal principal = new PlatformPrincipal( "user-42", "tester", @@ -151,6 +153,9 @@ class SkillStarControllerTest { mockMvc.perform(put("/api/web/skills/10/star") .with(authentication(auth))) - .andExpect(status().isForbidden()); + .andExpect(status().isOk()) + .andExpect(jsonPath("$.code").value(0)); + + verify(skillStarService).star(eq(10L), eq("user-42")); } } diff --git a/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/TokenControllerTest.java b/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/TokenControllerTest.java index a93aea16..32042395 100644 --- a/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/TokenControllerTest.java +++ b/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/TokenControllerTest.java @@ -82,15 +82,16 @@ class TokenControllerTest { given(apiTokenService.createToken(anyString(), anyString(), anyString(), org.mockito.ArgumentMatchers.nullable(String.class))) .willThrow(new DomainBadRequestException("validation.token.name.size")); - mockMvc.perform(post("/api/v1/tokens") + mockMvc.perform(post("/api/v1/tokens") .with(authentication(auth)) .with(csrf()) + .header("Accept-Language", "zh-CN") .contentType("application/json") .content(""" {"name":"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"} """)) .andExpect(status().isBadRequest()) - .andExpect(jsonPath("$.msg").value("Token 名称最多 64 个字符")); + .andExpect(jsonPath("$.code").value(400)); } @Test @@ -107,12 +108,13 @@ class TokenControllerTest { mockMvc.perform(post("/api/v1/tokens") .with(authentication(auth)) .with(csrf()) + .header("Accept-Language", "zh-CN") .contentType("application/json") .content(""" {"name":"cli"} """)) .andExpect(status().isBadRequest()) - .andExpect(jsonPath("$.msg").value("你已经有同名 Token")); + .andExpect(jsonPath("$.code").value(400)); } @Test @@ -139,6 +141,7 @@ class TokenControllerTest { {"name":"cli","expiresAt":"2026-04-15T12:00:00"} """)) .andExpect(status().isOk()) + .andExpect(jsonPath("$.code").value(0)) .andExpect(jsonPath("$.data.expiresAt").value("2026-04-15T12:00")); } @@ -172,6 +175,7 @@ class TokenControllerTest { .param("page", "1") .param("size", "10")) .andExpect(status().isOk()) + .andExpect(jsonPath("$.code").value(0)) .andExpect(jsonPath("$.data.items[0].name").value("cli")) .andExpect(jsonPath("$.data.items[1].name").value("deploy")) .andExpect(jsonPath("$.data.total").value(12)) @@ -203,6 +207,7 @@ class TokenControllerTest { {"expiresAt":"2026-05-01T09:30"} """)) .andExpect(status().isOk()) + .andExpect(jsonPath("$.code").value(0)) .andExpect(jsonPath("$.data.id").value(7)) .andExpect(jsonPath("$.data.expiresAt").value("2026-05-01T09:30")); } diff --git a/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/admin/AdminSkillReportControllerTest.java b/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/admin/AdminSkillReportControllerTest.java index 481d8ef0..193c3126 100644 --- a/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/admin/AdminSkillReportControllerTest.java +++ b/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/admin/AdminSkillReportControllerTest.java @@ -13,6 +13,7 @@ import com.iflytek.skillhub.TestRedisConfig; import com.iflytek.skillhub.auth.device.DeviceAuthService; import com.iflytek.skillhub.auth.rbac.PlatformPrincipal; import com.iflytek.skillhub.domain.namespace.NamespaceMemberRepository; +import com.iflytek.skillhub.domain.report.SkillReportDisposition; import com.iflytek.skillhub.domain.report.SkillReport; import com.iflytek.skillhub.domain.report.SkillReportService; import com.iflytek.skillhub.dto.AdminSkillReportSummaryResponse; @@ -92,14 +93,20 @@ class AdminSkillReportControllerTest { SkillReport report = new SkillReport(10L, 1L, "user-1", "Spam", "details"); ReflectionTestUtils.setField(report, "id", 99L); report.setStatus(com.iflytek.skillhub.domain.report.SkillReportStatus.RESOLVED); - when(skillReportService.resolveReport(org.mockito.ArgumentMatchers.eq(99L), org.mockito.ArgumentMatchers.eq("admin"), org.mockito.ArgumentMatchers.eq("handled"), org.mockito.ArgumentMatchers.any(), org.mockito.ArgumentMatchers.any())) + when(skillReportService.resolveReport( + org.mockito.ArgumentMatchers.eq(99L), + org.mockito.ArgumentMatchers.eq("admin"), + org.mockito.ArgumentMatchers.eq(SkillReportDisposition.RESOLVE_AND_HIDE), + org.mockito.ArgumentMatchers.eq("handled"), + org.mockito.ArgumentMatchers.any(), + org.mockito.ArgumentMatchers.any())) .thenReturn(report); mockMvc.perform(post("/api/v1/admin/skill-reports/99/resolve") .with(authentication(adminAuth())) .with(csrf()) .contentType(APPLICATION_JSON) - .content("{\"comment\":\"handled\"}")) + .content("{\"comment\":\"handled\",\"disposition\":\"RESOLVE_AND_HIDE\"}")) .andExpect(status().isOk()) .andExpect(jsonPath("$.data.reportId").value(99)) .andExpect(jsonPath("$.data.status").value("RESOLVED")); diff --git a/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/admin/AuditLogControllerTest.java b/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/admin/AuditLogControllerTest.java index b748adbf..9600ca7a 100644 --- a/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/admin/AuditLogControllerTest.java +++ b/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/admin/AuditLogControllerTest.java @@ -49,7 +49,8 @@ class AuditLogControllerTest { @Test void listAuditLogs_unauthenticated_returns401() throws Exception { mockMvc.perform(get("/api/v1/admin/audit-logs")) - .andExpect(status().isUnauthorized()); + .andExpect(status().isUnauthorized()) + .andExpect(jsonPath("$.code").value(401)); } @Test @@ -104,6 +105,7 @@ class AuditLogControllerTest { mockMvc.perform(get("/api/v1/admin/audit-logs").with(authentication(auth))) .andExpect(status().isOk()) + .andExpect(jsonPath("$.code").value(0)) .andExpect(jsonPath("$.data.items").isArray()); } @@ -140,6 +142,7 @@ class AuditLogControllerTest { .param("endTime", "2026-03-14T00:00:00Z") .with(authentication(auth))) .andExpect(status().isOk()) + .andExpect(jsonPath("$.code").value(0)) .andExpect(jsonPath("$.data.items").isArray()); } @@ -153,6 +156,7 @@ class AuditLogControllerTest { ); mockMvc.perform(get("/api/v1/admin/audit-logs").with(authentication(auth))) - .andExpect(status().isForbidden()); + .andExpect(status().isForbidden()) + .andExpect(jsonPath("$.code").value(403)); } } diff --git a/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/admin/UserManagementControllerTest.java b/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/admin/UserManagementControllerTest.java index a4916ad7..5b55e6b1 100644 --- a/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/admin/UserManagementControllerTest.java +++ b/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/admin/UserManagementControllerTest.java @@ -53,7 +53,8 @@ class UserManagementControllerTest { @Test void listUsers_unauthenticated_returns401() throws Exception { mockMvc.perform(get("/api/v1/admin/users")) - .andExpect(status().isUnauthorized()); + .andExpect(status().isUnauthorized()) + .andExpect(jsonPath("$.code").value(401)); } @Test @@ -102,6 +103,7 @@ class UserManagementControllerTest { mockMvc.perform(get("/api/v1/admin/users").with(authentication(auth))) .andExpect(status().isOk()) + .andExpect(jsonPath("$.code").value(0)) .andExpect(jsonPath("$.data.items").isArray()); } @@ -115,7 +117,8 @@ class UserManagementControllerTest { ); mockMvc.perform(get("/api/v1/admin/users").with(authentication(auth))) - .andExpect(status().isForbidden()); + .andExpect(status().isForbidden()) + .andExpect(jsonPath("$.code").value(403)); } @Test diff --git a/server/skillhub-app/src/test/java/com/iflytek/skillhub/filter/AuthContextFilterTest.java b/server/skillhub-app/src/test/java/com/iflytek/skillhub/filter/AuthContextFilterTest.java new file mode 100644 index 00000000..e6050ca4 --- /dev/null +++ b/server/skillhub-app/src/test/java/com/iflytek/skillhub/filter/AuthContextFilterTest.java @@ -0,0 +1,92 @@ +package com.iflytek.skillhub.filter; + +import com.iflytek.skillhub.auth.rbac.PlatformPrincipal; +import com.iflytek.skillhub.domain.namespace.NamespaceMember; +import com.iflytek.skillhub.domain.namespace.NamespaceMemberRepository; +import com.iflytek.skillhub.domain.namespace.NamespaceRole; +import com.iflytek.skillhub.domain.user.UserAccount; +import com.iflytek.skillhub.domain.user.UserAccountRepository; +import com.iflytek.skillhub.domain.user.UserStatus; +import jakarta.servlet.FilterChain; +import jakarta.servlet.http.HttpSession; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.Test; +import org.springframework.mock.web.MockHttpServletRequest; +import org.springframework.mock.web.MockHttpServletResponse; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.context.SecurityContextHolder; + +import java.util.List; +import java.util.Set; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +class AuthContextFilterTest { + + private final NamespaceMemberRepository namespaceMemberRepository = mock(NamespaceMemberRepository.class); + private final UserAccountRepository userAccountRepository = mock(UserAccountRepository.class); + private final AuthContextFilter filter = new AuthContextFilter(namespaceMemberRepository, userAccountRepository); + + @AfterEach + void clearSecurityContext() { + SecurityContextHolder.clearContext(); + } + + @Test + void disabledSessionUser_shouldInvalidateSessionAndBlockRequest() throws Exception { + PlatformPrincipal principal = new PlatformPrincipal("user-1", "Alice", "alice@example.com", null, "local", Set.of("USER")); + UserAccount user = new UserAccount("Alice", "alice@example.com"); + user.setStatus(UserStatus.DISABLED); + + MockHttpServletRequest request = new MockHttpServletRequest(); + HttpSession session = request.getSession(true); + session.setAttribute("platformPrincipal", principal); + SecurityContextHolder.getContext().setAuthentication( + new UsernamePasswordAuthenticationToken(principal, null, List.of()) + ); + + MockHttpServletResponse response = new MockHttpServletResponse(); + FilterChain filterChain = mock(FilterChain.class); + + when(userAccountRepository.findById("user-1")).thenReturn(java.util.Optional.of(user)); + + filter.doFilter(request, response, filterChain); + + assertEquals(401, response.getStatus()); + assertTrue(!request.isRequestedSessionIdValid() || request.getSession(false) == null); + assertNull(SecurityContextHolder.getContext().getAuthentication()); + verify(filterChain, never()).doFilter(request, response); + } + + @Test + void activeSessionUser_shouldPopulateRequestContextAndContinue() throws Exception { + PlatformPrincipal principal = new PlatformPrincipal("user-2", "Bob", "bob@example.com", null, "local", Set.of("USER")); + UserAccount user = new UserAccount("Bob", "bob@example.com"); + user.setStatus(UserStatus.ACTIVE); + NamespaceMember member = new NamespaceMember(9L, "user-2", NamespaceRole.ADMIN); + + MockHttpServletRequest request = new MockHttpServletRequest(); + request.getSession(true).setAttribute("platformPrincipal", principal); + SecurityContextHolder.getContext().setAuthentication( + new UsernamePasswordAuthenticationToken(principal, null, List.of()) + ); + + MockHttpServletResponse response = new MockHttpServletResponse(); + FilterChain filterChain = mock(FilterChain.class); + + when(userAccountRepository.findById("user-2")).thenReturn(java.util.Optional.of(user)); + when(namespaceMemberRepository.findByUserId("user-2")).thenReturn(List.of(member)); + + filter.doFilter(request, response, filterChain); + + assertEquals("user-2", request.getAttribute("userId")); + assertEquals(NamespaceRole.ADMIN, ((java.util.Map) request.getAttribute("userNsRoles")).get(9L)); + verify(filterChain).doFilter(request, response); + } +} diff --git a/server/skillhub-app/src/test/java/com/iflytek/skillhub/service/GovernanceWorkbenchAppServiceTest.java b/server/skillhub-app/src/test/java/com/iflytek/skillhub/service/GovernanceWorkbenchAppServiceTest.java new file mode 100644 index 00000000..6e20f7e4 --- /dev/null +++ b/server/skillhub-app/src/test/java/com/iflytek/skillhub/service/GovernanceWorkbenchAppServiceTest.java @@ -0,0 +1,253 @@ +package com.iflytek.skillhub.service; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.ArgumentMatchers.isNull; +import static org.mockito.Mockito.when; + +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.report.SkillReport; +import com.iflytek.skillhub.domain.report.SkillReportRepository; +import com.iflytek.skillhub.domain.report.SkillReportStatus; +import com.iflytek.skillhub.domain.review.PromotionRequest; +import com.iflytek.skillhub.domain.review.PromotionRequestRepository; +import com.iflytek.skillhub.domain.review.ReviewTask; +import com.iflytek.skillhub.domain.review.ReviewTaskRepository; +import com.iflytek.skillhub.domain.review.ReviewTaskStatus; +import com.iflytek.skillhub.domain.skill.Skill; +import com.iflytek.skillhub.domain.skill.SkillRepository; +import com.iflytek.skillhub.domain.skill.SkillVersion; +import com.iflytek.skillhub.domain.skill.SkillVersionRepository; +import com.iflytek.skillhub.domain.skill.SkillVisibility; +import com.iflytek.skillhub.dto.AuditLogItemResponse; +import com.iflytek.skillhub.dto.GovernanceSummaryResponse; +import com.iflytek.skillhub.dto.PageResponse; +import java.time.Instant; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.Set; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.junit.jupiter.api.extension.ExtendWith; +import org.springframework.data.domain.PageImpl; +import org.springframework.data.domain.PageRequest; + +@ExtendWith(MockitoExtension.class) +class GovernanceWorkbenchAppServiceTest { + + @Mock + private ReviewTaskRepository reviewTaskRepository; + + @Mock + private PromotionRequestRepository promotionRequestRepository; + + @Mock + private SkillReportRepository skillReportRepository; + + @Mock + private SkillRepository skillRepository; + + @Mock + private SkillVersionRepository skillVersionRepository; + + @Mock + private NamespaceRepository namespaceRepository; + + @Mock + private AdminAuditLogAppService adminAuditLogAppService; + + private GovernanceWorkbenchAppService service; + + @BeforeEach + void setUp() { + service = new GovernanceWorkbenchAppService( + reviewTaskRepository, + promotionRequestRepository, + skillReportRepository, + skillRepository, + skillVersionRepository, + namespaceRepository, + adminAuditLogAppService + ); + } + + @Test + void summary_returnsAllPendingCountsForPlatformGovernor() { + when(reviewTaskRepository.findByStatus(ReviewTaskStatus.PENDING, PageRequest.of(0, 100))) + .thenReturn(new PageImpl<>(List.of(createReviewTask(1L, 11L, 101L, "owner")))); + when(promotionRequestRepository.findByStatus(ReviewTaskStatus.PENDING, PageRequest.of(0, 100))) + .thenReturn(new PageImpl<>(List.of(createPromotionRequest(2L, 101L, 12L, "owner")))); + when(skillReportRepository.findByStatus(SkillReportStatus.PENDING, PageRequest.of(0, 100))) + .thenReturn(new PageImpl<>(List.of(createReport(3L, 101L, 11L, "reporter")))); + + GovernanceSummaryResponse response = service.getSummary("admin", Map.of(), Set.of("SKILL_ADMIN")); + + assertThat(response.pendingReviews()).isEqualTo(1); + assertThat(response.pendingPromotions()).isEqualTo(1); + assertThat(response.pendingReports()).isEqualTo(1); + } + + @Test + void summary_limitsReviewsToManagedNamespacesForNamespaceAdmin() { + when(reviewTaskRepository.findByNamespaceIdAndStatus(11L, ReviewTaskStatus.PENDING, PageRequest.of(0, 100))) + .thenReturn(new PageImpl<>(List.of(createReviewTask(1L, 11L, 101L, "owner")))); + + GovernanceSummaryResponse response = service.getSummary( + "ns-admin", + Map.of(11L, NamespaceRole.ADMIN, 12L, NamespaceRole.MEMBER), + Set.of() + ); + + assertThat(response.pendingReviews()).isEqualTo(1); + assertThat(response.pendingPromotions()).isZero(); + assertThat(response.pendingReports()).isZero(); + } + + @Test + void listInbox_combinesReviewPromotionAndReportItems() { + ReviewTask reviewTask = createReviewTask(1L, 11L, 101L, "owner"); + PromotionRequest promotionRequest = createPromotionRequest(2L, 101L, 12L, "owner"); + SkillReport report = createReport(3L, 101L, 11L, "reporter"); + stubReviewContext(reviewTask, "team-a", "skill-a"); + stubPromotionContext(promotionRequest, "team-a", "skill-a", "global"); + stubReportContext(report, "team-a", "skill-a"); + + when(reviewTaskRepository.findByStatus(ReviewTaskStatus.PENDING, PageRequest.of(0, 20))) + .thenReturn(new PageImpl<>(List.of(reviewTask))); + when(promotionRequestRepository.findByStatus(ReviewTaskStatus.PENDING, PageRequest.of(0, 20))) + .thenReturn(new PageImpl<>(List.of(promotionRequest))); + when(skillReportRepository.findByStatus(SkillReportStatus.PENDING, PageRequest.of(0, 20))) + .thenReturn(new PageImpl<>(List.of(report))); + + PageResponse response = service.listInbox("admin", Map.of(), Set.of("SKILL_ADMIN"), null, 0, 20); + + assertThat(response.total()).isEqualTo(3); + assertThat(response.items()).hasSize(3); + } + + @Test + void listActivity_projectsGovernanceAuditEntries() { + when(adminAuditLogAppService.listAuditLogsByActions( + eq(0), + eq(20), + isNull(), + eq(Set.of( + "REVIEW_SUBMIT", + "REVIEW_APPROVE", + "REVIEW_REJECT", + "REVIEW_WITHDRAW", + "PROMOTION_SUBMIT", + "PROMOTION_APPROVE", + "PROMOTION_REJECT", + "REPORT_SKILL", + "RESOLVE_SKILL_REPORT", + "DISMISS_SKILL_REPORT", + "HIDE_SKILL", + "ARCHIVE_SKILL", + "UNHIDE_SKILL", + "UNARCHIVE_SKILL" + )), + isNull(), + isNull(), + isNull(), + isNull(), + isNull(), + isNull())) + .thenReturn(new PageResponse<>( + List.of( + new AuditLogItemResponse( + 1L, + "REVIEW_APPROVE", + "admin", + "Admin", + "{\"comment\":\"LGTM\"}", + "127.0.0.1", + "req-1", + "REVIEW_TASK", + "99", + Instant.parse("2026-03-16T02:00:00Z") + ) + ), + 1, + 0, + 20 + )); + + PageResponse response = service.listActivity(Set.of("SKILL_ADMIN"), 0, 20); + + assertThat(response.total()).isEqualTo(1); + assertThat(response.items()).hasSize(1); + } + + private void stubReviewContext(ReviewTask task, String namespaceSlug, String skillSlug) { + SkillVersion version = new SkillVersion(task.getSkillVersionId(), "1.0.0", task.getSubmittedBy()); + setField(version, "id", task.getSkillVersionId()); + setField(version, "skillId", task.getSkillVersionId()); + Skill skill = new Skill(task.getNamespaceId(), skillSlug, task.getSubmittedBy(), SkillVisibility.PUBLIC); + setField(skill, "id", task.getSkillVersionId()); + Namespace namespace = new Namespace(namespaceSlug, namespaceSlug, task.getSubmittedBy()); + setField(namespace, "id", task.getNamespaceId()); + when(skillVersionRepository.findById(task.getSkillVersionId())).thenReturn(Optional.of(version)); + when(skillRepository.findById(task.getSkillVersionId())).thenReturn(Optional.of(skill)); + when(namespaceRepository.findById(task.getNamespaceId())).thenReturn(Optional.of(namespace)); + } + + private void stubPromotionContext(PromotionRequest request, String sourceNamespaceSlug, String skillSlug, String targetNamespaceSlug) { + Skill skill = new Skill(11L, skillSlug, request.getSubmittedBy(), SkillVisibility.PUBLIC); + setField(skill, "id", request.getSourceSkillId()); + SkillVersion version = new SkillVersion(request.getSourceSkillId(), "1.0.0", request.getSubmittedBy()); + setField(version, "id", request.getSourceVersionId()); + Namespace sourceNamespace = new Namespace(sourceNamespaceSlug, sourceNamespaceSlug, request.getSubmittedBy()); + setField(sourceNamespace, "id", 11L); + Namespace targetNamespace = new Namespace(targetNamespaceSlug, targetNamespaceSlug, request.getSubmittedBy()); + setField(targetNamespace, "id", request.getTargetNamespaceId()); + when(skillRepository.findById(request.getSourceSkillId())).thenReturn(Optional.of(skill)); + when(skillVersionRepository.findById(request.getSourceVersionId())).thenReturn(Optional.of(version)); + when(namespaceRepository.findById(11L)).thenReturn(Optional.of(sourceNamespace)); + when(namespaceRepository.findById(request.getTargetNamespaceId())).thenReturn(Optional.of(targetNamespace)); + } + + private void stubReportContext(SkillReport report, String namespaceSlug, String skillSlug) { + Skill skill = new Skill(report.getNamespaceId(), skillSlug, report.getReporterId(), SkillVisibility.PUBLIC); + setField(skill, "id", report.getSkillId()); + Namespace namespace = new Namespace(namespaceSlug, namespaceSlug, report.getReporterId()); + setField(namespace, "id", report.getNamespaceId()); + when(skillRepository.findById(report.getSkillId())).thenReturn(Optional.of(skill)); + when(namespaceRepository.findById(report.getNamespaceId())).thenReturn(Optional.of(namespace)); + } + + private ReviewTask createReviewTask(Long id, Long namespaceId, Long skillVersionId, String submittedBy) { + ReviewTask task = new ReviewTask(skillVersionId, namespaceId, submittedBy); + setField(task, "id", id); + return task; + } + + private PromotionRequest createPromotionRequest(Long id, Long sourceVersionId, Long targetNamespaceId, String submittedBy) { + PromotionRequest request = new PromotionRequest(sourceVersionId, sourceVersionId, targetNamespaceId, submittedBy); + setField(request, "id", id); + setField(request, "sourceSkillId", sourceVersionId); + return request; + } + + private SkillReport createReport(Long id, Long skillId, Long namespaceId, String reporterId) { + SkillReport report = new SkillReport(skillId, namespaceId, reporterId, "Spam", "details"); + setField(report, "id", id); + return report; + } + + 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 (ReflectiveOperationException e) { + throw new AssertionError(e); + } + } +} diff --git a/server/skillhub-app/src/test/java/com/iflytek/skillhub/service/MySkillAppServiceTest.java b/server/skillhub-app/src/test/java/com/iflytek/skillhub/service/MySkillAppServiceTest.java index d5cef846..6802de72 100644 --- a/server/skillhub-app/src/test/java/com/iflytek/skillhub/service/MySkillAppServiceTest.java +++ b/server/skillhub-app/src/test/java/com/iflytek/skillhub/service/MySkillAppServiceTest.java @@ -2,6 +2,9 @@ package com.iflytek.skillhub.service; import com.iflytek.skillhub.domain.namespace.Namespace; import com.iflytek.skillhub.domain.namespace.NamespaceRepository; +import com.iflytek.skillhub.domain.review.PromotionRequest; +import com.iflytek.skillhub.domain.review.PromotionRequestRepository; +import com.iflytek.skillhub.domain.review.ReviewTaskStatus; import com.iflytek.skillhub.domain.skill.Skill; import com.iflytek.skillhub.domain.skill.SkillRepository; import com.iflytek.skillhub.domain.skill.SkillVersion; @@ -43,11 +46,20 @@ class MySkillAppServiceTest { @Mock private SkillStarRepository skillStarRepository; + @Mock + private PromotionRequestRepository promotionRequestRepository; + private MySkillAppService service; @BeforeEach void setUp() { - service = new MySkillAppService(skillRepository, namespaceRepository, skillVersionRepository, skillStarRepository); + service = new MySkillAppService( + skillRepository, + namespaceRepository, + skillVersionRepository, + skillStarRepository, + promotionRequestRepository + ); } @Test @@ -110,6 +122,64 @@ class MySkillAppServiceTest { assertThat(skills).hasSize(1); assertThat(skills.get(0).latestVersion()).isEqualTo("1.0.0"); + assertThat(skills.get(0).latestVersionId()).isEqualTo(11L); assertThat(skills.get(0).latestVersionStatus()).isEqualTo("PENDING_REVIEW"); + assertThat(skills.get(0).canSubmitPromotion()).isFalse(); + } + + @Test + void listMySkills_marksTeamPublishedSkillAsPromotable() { + Skill skill = new Skill(101L, "team-skill", "user-1", SkillVisibility.PUBLIC); + skill.setDisplayName("Team Skill"); + skill.setSummary("published"); + ReflectionTestUtils.setField(skill, "id", 2L); + ReflectionTestUtils.setField(skill, "updatedAt", LocalDateTime.of(2026, 3, 15, 11, 0)); + + SkillVersion publishedVersion = new SkillVersion(2L, "1.2.0", "user-1"); + publishedVersion.setStatus(SkillVersionStatus.PUBLISHED); + ReflectionTestUtils.setField(publishedVersion, "id", 22L); + ReflectionTestUtils.setField(publishedVersion, "createdAt", LocalDateTime.of(2026, 3, 15, 10, 30)); + + Namespace namespace = new Namespace("team-ai", "Team AI", "user-1"); + ReflectionTestUtils.setField(namespace, "id", 101L); + + given(skillRepository.findByOwnerId("user-1")).willReturn(List.of(skill)); + given(skillVersionRepository.findBySkillIdIn(List.of(2L))).willReturn(List.of(publishedVersion)); + given(namespaceRepository.findByIdIn(List.of(101L))).willReturn(List.of(namespace)); + given(promotionRequestRepository.findBySourceSkillIdAndStatus(2L, ReviewTaskStatus.PENDING)).willReturn(Optional.empty()); + given(promotionRequestRepository.findBySourceSkillIdAndStatus(2L, ReviewTaskStatus.APPROVED)).willReturn(Optional.empty()); + + var skills = service.listMySkills("user-1"); + + assertThat(skills).hasSize(1); + assertThat(skills.get(0).latestVersionId()).isEqualTo(22L); + assertThat(skills.get(0).latestVersionStatus()).isEqualTo("PUBLISHED"); + assertThat(skills.get(0).canSubmitPromotion()).isTrue(); + } + + @Test + void listMySkills_hidesPromotionWhenPendingRequestExists() { + Skill skill = new Skill(101L, "team-skill", "user-1", SkillVisibility.PUBLIC); + skill.setDisplayName("Team Skill"); + ReflectionTestUtils.setField(skill, "id", 2L); + + SkillVersion publishedVersion = new SkillVersion(2L, "1.2.0", "user-1"); + publishedVersion.setStatus(SkillVersionStatus.PUBLISHED); + ReflectionTestUtils.setField(publishedVersion, "id", 22L); + ReflectionTestUtils.setField(publishedVersion, "createdAt", LocalDateTime.of(2026, 3, 15, 10, 30)); + + Namespace namespace = new Namespace("team-ai", "Team AI", "user-1"); + ReflectionTestUtils.setField(namespace, "id", 101L); + + given(skillRepository.findByOwnerId("user-1")).willReturn(List.of(skill)); + given(skillVersionRepository.findBySkillIdIn(List.of(2L))).willReturn(List.of(publishedVersion)); + given(namespaceRepository.findByIdIn(List.of(101L))).willReturn(List.of(namespace)); + given(promotionRequestRepository.findBySourceSkillIdAndStatus(2L, ReviewTaskStatus.PENDING)) + .willReturn(Optional.of(new PromotionRequest(2L, 22L, 999L, "user-1"))); + + var skills = service.listMySkills("user-1"); + + assertThat(skills).hasSize(1); + assertThat(skills.get(0).canSubmitPromotion()).isFalse(); } } diff --git a/server/skillhub-app/src/test/java/com/iflytek/skillhub/service/NamespaceMemberCandidateServiceTest.java b/server/skillhub-app/src/test/java/com/iflytek/skillhub/service/NamespaceMemberCandidateServiceTest.java new file mode 100644 index 00000000..46e55f9f --- /dev/null +++ b/server/skillhub-app/src/test/java/com/iflytek/skillhub/service/NamespaceMemberCandidateServiceTest.java @@ -0,0 +1,127 @@ +package com.iflytek.skillhub.service; + +import com.iflytek.skillhub.domain.namespace.Namespace; +import com.iflytek.skillhub.domain.namespace.NamespaceAccessPolicy; +import com.iflytek.skillhub.domain.namespace.NamespaceMember; +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.shared.exception.DomainBadRequestException; +import com.iflytek.skillhub.domain.user.UserAccount; +import com.iflytek.skillhub.domain.user.UserAccountRepository; +import com.iflytek.skillhub.domain.user.UserStatus; +import com.iflytek.skillhub.dto.NamespaceCandidateUserResponse; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.data.domain.PageImpl; +import org.springframework.data.domain.PageRequest; + +import java.util.List; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.mockito.Mockito.doNothing; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +class NamespaceMemberCandidateServiceTest { + + @Mock + private NamespaceService namespaceService; + + @Mock + private NamespaceAccessPolicy namespaceAccessPolicy; + + @Mock + private NamespaceMemberRepository namespaceMemberRepository; + + @Mock + private UserAccountRepository userAccountRepository; + + @Test + void searchCandidates_shouldFilterExistingMembers() { + Namespace namespace = new Namespace("team-a", "Team A", "owner-1"); + setField(namespace, "id", 1L); + + NamespaceMemberCandidateService service = new NamespaceMemberCandidateService( + namespaceService, + namespaceAccessPolicy, + namespaceMemberRepository, + userAccountRepository + ); + + when(namespaceService.getNamespaceBySlug("team-a")).thenReturn(namespace); + doNothing().when(namespaceService).assertAdminOrOwner(1L, "owner-1"); + when(namespaceAccessPolicy.canManageMembers(namespace)).thenReturn(true); + when(namespaceMemberRepository.findByNamespaceId(1L, PageRequest.of(0, 500))) + .thenReturn(new PageImpl<>(List.of(new NamespaceMember(1L, "user-1", NamespaceRole.MEMBER)))); + when(userAccountRepository.search("ali", UserStatus.ACTIVE, PageRequest.of(0, 10))) + .thenReturn(new PageImpl<>(List.of( + new UserAccount("user-1", "alice", "alice@example.com", null), + new UserAccount("user-2", "alina", "alina@example.com", null) + ))); + + List result = service.searchCandidates("team-a", "ali", "owner-1", 10); + + assertEquals(1, result.size()); + assertEquals("user-2", result.getFirst().userId()); + verify(namespaceService).assertAdminOrOwner(1L, "owner-1"); + } + + @Test + void searchCandidates_shouldRejectReadonlyNamespace() { + Namespace namespace = new Namespace("team-a", "Team A", "owner-1"); + setField(namespace, "id", 1L); + + NamespaceMemberCandidateService service = new NamespaceMemberCandidateService( + namespaceService, + namespaceAccessPolicy, + namespaceMemberRepository, + userAccountRepository + ); + + when(namespaceService.getNamespaceBySlug("team-a")).thenReturn(namespace); + doNothing().when(namespaceService).assertAdminOrOwner(1L, "owner-1"); + when(namespaceAccessPolicy.canManageMembers(namespace)).thenReturn(false); + when(namespaceAccessPolicy.isImmutable(namespace)).thenReturn(false); + + assertThrows(DomainBadRequestException.class, () -> + service.searchCandidates("team-a", "ali", "owner-1", 10)); + } + + @Test + void searchCandidates_shouldRejectGlobalNamespaceBeforeMembershipChecks() { + Namespace namespace = new Namespace("global", "Global", "system"); + setField(namespace, "id", 1L); + namespace.setType(com.iflytek.skillhub.domain.namespace.NamespaceType.GLOBAL); + + NamespaceMemberCandidateService service = new NamespaceMemberCandidateService( + namespaceService, + namespaceAccessPolicy, + namespaceMemberRepository, + userAccountRepository + ); + + when(namespaceService.getNamespaceBySlug("global")).thenReturn(namespace); + when(namespaceAccessPolicy.isImmutable(namespace)).thenReturn(true); + + DomainBadRequestException exception = assertThrows(DomainBadRequestException.class, () -> + service.searchCandidates("global", "ali", "guest-1", 10)); + + assertEquals("error.namespace.system.immutable", exception.messageCode()); + verify(namespaceService, org.mockito.Mockito.never()).assertAdminOrOwner(1L, "guest-1"); + } + + 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 exception) { + throw new RuntimeException(exception); + } + } +} 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..755e04b1 --- /dev/null +++ b/server/skillhub-app/src/test/java/com/iflytek/skillhub/service/SkillSearchAppServiceTest.java @@ -0,0 +1,126 @@ +package com.iflytek.skillhub.service; + +import com.iflytek.skillhub.domain.namespace.Namespace; +import com.iflytek.skillhub.domain.namespace.NamespaceRole; +import com.iflytek.skillhub.domain.namespace.NamespaceRepository; +import com.iflytek.skillhub.domain.namespace.NamespaceStatus; +import com.iflytek.skillhub.domain.namespace.NamespaceService; +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.junit.jupiter.api.Assertions.assertThrows; +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; + + @Mock + private NamespaceService namespaceService; + + private SkillSearchAppService service; + + @BeforeEach + void setUp() { + service = new SkillSearchAppService(searchQueryService, skillRepository, namespaceRepository, skillVersionRepository, namespaceService); + } + + @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()); + } + + @Test + void search_shouldFillVisiblePageAcrossArchivedNamespaceResults() { + Skill archivedSkill = new Skill(1L, "archived-skill", "owner-1", SkillVisibility.PUBLIC); + setField(archivedSkill, "id", 10L); + Skill visibleSkill = new Skill(2L, "visible-skill", "owner-1", SkillVisibility.PUBLIC); + setField(visibleSkill, "id", 11L); + + Namespace archivedNamespace = new Namespace("archived-team", "Archived Team", "owner-1"); + setField(archivedNamespace, "id", 1L); + archivedNamespace.setStatus(NamespaceStatus.ARCHIVED); + Namespace activeNamespace = new Namespace("team-a", "Team A", "owner-1"); + setField(activeNamespace, "id", 2L); + activeNamespace.setStatus(NamespaceStatus.ACTIVE); + + when(searchQueryService.search(org.mockito.ArgumentMatchers.any())) + .thenReturn(new SearchResult(List.of(10L, 11L), 2, 0, 20)); + when(skillRepository.findByIdIn(List.of(10L, 11L))).thenReturn(List.of(archivedSkill, visibleSkill)); + when(namespaceRepository.findByIdIn(List.of(1L, 2L))).thenReturn(List.of(archivedNamespace, activeNamespace)); + + SkillSearchAppService.SearchResponse response = service.search("skill", null, "newest", 0, 1, null, null); + + assertEquals(1, response.items().size()); + assertEquals("visible-skill", response.items().getFirst().slug()); + assertEquals(1, response.total()); + } + + @Test + void search_shouldHideArchivedNamespaceFilterForAnonymousUsers() { + Namespace archivedNamespace = new Namespace("archived-team", "Archived Team", "owner-1"); + setField(archivedNamespace, "id", 1L); + archivedNamespace.setStatus(NamespaceStatus.ARCHIVED); + when(namespaceService.getNamespaceBySlugForRead("archived-team", null, Map.of())).thenThrow( + new com.iflytek.skillhub.domain.shared.exception.DomainBadRequestException( + "error.namespace.slug.notFound", + "archived-team" + ) + ); + + assertThrows( + com.iflytek.skillhub.domain.shared.exception.DomainBadRequestException.class, + () -> service.search("skill", "archived-team", "newest", 0, 20, null, Map.of()) + ); + } + + 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/governance/GovernanceNotificationService.java b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/governance/GovernanceNotificationService.java new file mode 100644 index 00000000..0dfd2046 --- /dev/null +++ b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/governance/GovernanceNotificationService.java @@ -0,0 +1,50 @@ +package com.iflytek.skillhub.domain.governance; + +import com.iflytek.skillhub.domain.shared.exception.DomainForbiddenException; +import com.iflytek.skillhub.domain.shared.exception.DomainNotFoundException; +import java.util.List; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +public class GovernanceNotificationService { + + private final UserNotificationRepository userNotificationRepository; + + public GovernanceNotificationService(UserNotificationRepository userNotificationRepository) { + this.userNotificationRepository = userNotificationRepository; + } + + @Transactional + public UserNotification notifyUser(String userId, + String category, + String entityType, + Long entityId, + String title, + String bodyJson) { + return userNotificationRepository.save(new UserNotification( + userId, + category, + entityType, + entityId, + title, + bodyJson + )); + } + + @Transactional(readOnly = true) + public List listNotifications(String userId) { + return userNotificationRepository.findByUserIdOrderByCreatedAtDesc(userId); + } + + @Transactional + public UserNotification markRead(Long notificationId, String userId) { + UserNotification notification = userNotificationRepository.findById(notificationId) + .orElseThrow(() -> new DomainNotFoundException("error.notification.notFound", notificationId)); + if (!notification.getUserId().equals(userId)) { + throw new DomainForbiddenException("error.notification.noPermission"); + } + notification.markRead(); + return userNotificationRepository.save(notification); + } +} diff --git a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/governance/UserNotification.java b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/governance/UserNotification.java new file mode 100644 index 00000000..19f713b4 --- /dev/null +++ b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/governance/UserNotification.java @@ -0,0 +1,116 @@ +package com.iflytek.skillhub.domain.governance; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.EnumType; +import jakarta.persistence.Enumerated; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.PrePersist; +import jakarta.persistence.Table; +import java.time.Instant; + +@Entity +@Table(name = "user_notification") +public class UserNotification { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(name = "user_id", nullable = false, length = 128) + private String userId; + + @Column(nullable = false, length = 64) + private String category; + + @Column(name = "entity_type", nullable = false, length = 64) + private String entityType; + + @Column(name = "entity_id", nullable = false) + private Long entityId; + + @Column(nullable = false, length = 200) + private String title; + + @Column(name = "body_json", columnDefinition = "TEXT") + private String bodyJson; + + @Enumerated(EnumType.STRING) + @Column(nullable = false, length = 20) + private UserNotificationStatus status = UserNotificationStatus.UNREAD; + + @Column(name = "created_at", nullable = false, updatable = false) + private Instant createdAt; + + @Column(name = "read_at") + private Instant readAt; + + protected UserNotification() { + } + + public UserNotification(String userId, + String category, + String entityType, + Long entityId, + String title, + String bodyJson) { + this.userId = userId; + this.category = category; + this.entityType = entityType; + this.entityId = entityId; + this.title = title; + this.bodyJson = bodyJson; + } + + @PrePersist + void onCreate() { + createdAt = Instant.now(); + } + + public void markRead() { + this.status = UserNotificationStatus.READ; + this.readAt = Instant.now(); + } + + public Long getId() { + return id; + } + + public String getUserId() { + return userId; + } + + public String getCategory() { + return category; + } + + public String getEntityType() { + return entityType; + } + + public Long getEntityId() { + return entityId; + } + + public String getTitle() { + return title; + } + + public String getBodyJson() { + return bodyJson; + } + + public UserNotificationStatus getStatus() { + return status; + } + + public Instant getCreatedAt() { + return createdAt; + } + + public Instant getReadAt() { + return readAt; + } +} diff --git a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/governance/UserNotificationRepository.java b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/governance/UserNotificationRepository.java new file mode 100644 index 00000000..85563adf --- /dev/null +++ b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/governance/UserNotificationRepository.java @@ -0,0 +1,10 @@ +package com.iflytek.skillhub.domain.governance; + +import java.util.List; +import java.util.Optional; + +public interface UserNotificationRepository { + UserNotification save(UserNotification notification); + Optional findById(Long id); + List findByUserIdOrderByCreatedAtDesc(String userId); +} diff --git a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/governance/UserNotificationStatus.java b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/governance/UserNotificationStatus.java new file mode 100644 index 00000000..526daa9f --- /dev/null +++ b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/governance/UserNotificationStatus.java @@ -0,0 +1,6 @@ +package com.iflytek.skillhub.domain.governance; + +public enum UserNotificationStatus { + UNREAD, + READ +} 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..fef5ea3d 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 @@ -5,16 +5,21 @@ import com.iflytek.skillhub.domain.shared.exception.DomainForbiddenException; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; +import java.util.Map; + @Service 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 @@ -41,7 +46,9 @@ public class NamespaceService { String operatorUserId) { Namespace namespace = namespaceRepository.findById(namespaceId) .orElseThrow(() -> new DomainBadRequestException("error.namespace.id.notFound", namespaceId)); + assertNotImmutable(namespace); assertAdminOrOwner(namespaceId, operatorUserId); + assertWritable(namespace); if (displayName != null) { namespace.setDisplayName(displayName); @@ -61,7 +68,23 @@ public class NamespaceService { .orElseThrow(() -> new DomainBadRequestException("error.namespace.slug.notFound", slug)); } - void assertAdminOrOwner(Long namespaceId, String userId) { + public Namespace getNamespaceBySlugForRead(String slug, String userId, Map userNsRoles) { + Namespace namespace = getNamespaceBySlug(slug); + if (namespace.getStatus() != NamespaceStatus.ARCHIVED) { + return namespace; + } + if (userId != null && userNsRoles != null && userNsRoles.containsKey(namespace.getId())) { + return namespace; + } + throw new DomainBadRequestException("error.namespace.slug.notFound", slug); + } + + public Namespace getNamespace(Long namespaceId) { + return namespaceRepository.findById(namespaceId) + .orElseThrow(() -> new DomainBadRequestException("error.namespace.id.notFound", namespaceId)); + } + + public void assertAdminOrOwner(Long namespaceId, String userId) { NamespaceRole role = namespaceMemberRepository.findByNamespaceIdAndUserId(namespaceId, userId) .map(NamespaceMember::getRole) .orElseThrow(() -> new DomainForbiddenException("error.namespace.membership.required")); @@ -69,4 +92,26 @@ public class NamespaceService { throw new DomainForbiddenException("error.namespace.admin.required"); } } + + public void assertMember(Long namespaceId, String userId) { + namespaceMemberRepository.findByNamespaceIdAndUserId(namespaceId, userId) + .orElseThrow(() -> new DomainForbiddenException("error.namespace.membership.required")); + } + + void assertMutable(Namespace namespace) { + assertNotImmutable(namespace); + assertWritable(namespace); + } + + void assertNotImmutable(Namespace namespace) { + if (namespaceAccessPolicy.isImmutable(namespace)) { + throw new DomainBadRequestException("error.namespace.system.immutable", namespace.getSlug()); + } + } + + private void assertWritable(Namespace namespace) { + if (!namespaceAccessPolicy.canMutateSettings(namespace)) { + throw new DomainBadRequestException("error.namespace.readonly", namespace.getSlug()); + } + } } diff --git a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/report/SkillReportDisposition.java b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/report/SkillReportDisposition.java new file mode 100644 index 00000000..c3b376ea --- /dev/null +++ b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/report/SkillReportDisposition.java @@ -0,0 +1,7 @@ +package com.iflytek.skillhub.domain.report; + +public enum SkillReportDisposition { + RESOLVE_ONLY, + RESOLVE_AND_HIDE, + RESOLVE_AND_ARCHIVE +} diff --git a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/report/SkillReportService.java b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/report/SkillReportService.java index 17b2722a..23965d71 100644 --- a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/report/SkillReportService.java +++ b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/report/SkillReportService.java @@ -1,11 +1,13 @@ package com.iflytek.skillhub.domain.report; import com.iflytek.skillhub.domain.audit.AuditLogService; +import com.iflytek.skillhub.domain.governance.GovernanceNotificationService; import com.iflytek.skillhub.domain.shared.exception.DomainBadRequestException; import com.iflytek.skillhub.domain.shared.exception.DomainNotFoundException; import com.iflytek.skillhub.domain.skill.Skill; import com.iflytek.skillhub.domain.skill.SkillRepository; import com.iflytek.skillhub.domain.skill.SkillStatus; +import com.iflytek.skillhub.domain.skill.service.SkillGovernanceService; import java.time.LocalDateTime; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; @@ -16,13 +18,19 @@ public class SkillReportService { private final SkillRepository skillRepository; private final SkillReportRepository skillReportRepository; private final AuditLogService auditLogService; + private final SkillGovernanceService skillGovernanceService; + private final GovernanceNotificationService governanceNotificationService; public SkillReportService(SkillRepository skillRepository, SkillReportRepository skillReportRepository, - AuditLogService auditLogService) { + AuditLogService auditLogService, + SkillGovernanceService skillGovernanceService, + GovernanceNotificationService governanceNotificationService) { this.skillRepository = skillRepository; this.skillReportRepository = skillReportRepository; this.auditLogService = auditLogService; + this.skillGovernanceService = skillGovernanceService; + this.governanceNotificationService = governanceNotificationService; } @Transactional @@ -66,13 +74,36 @@ public class SkillReportService { String comment, String clientIp, String userAgent) { + return resolveReport(reportId, actorUserId, SkillReportDisposition.RESOLVE_ONLY, comment, clientIp, userAgent); + } + + @Transactional + public SkillReport resolveReport(Long reportId, + String actorUserId, + SkillReportDisposition disposition, + String comment, + String clientIp, + String userAgent) { SkillReport report = requirePendingReport(reportId); + if (disposition == SkillReportDisposition.RESOLVE_AND_HIDE) { + skillGovernanceService.hideSkill(report.getSkillId(), actorUserId, clientIp, userAgent, comment); + } else if (disposition == SkillReportDisposition.RESOLVE_AND_ARCHIVE) { + skillGovernanceService.archiveSkillAsAdmin(report.getSkillId(), actorUserId, clientIp, userAgent, comment); + } report.setStatus(SkillReportStatus.RESOLVED); report.setHandledBy(actorUserId); report.setHandleComment(normalize(comment)); report.setHandledAt(LocalDateTime.now()); SkillReport saved = skillReportRepository.save(report); auditLogService.record(actorUserId, "RESOLVE_SKILL_REPORT", "SKILL_REPORT", reportId, null, clientIp, userAgent, null); + governanceNotificationService.notifyUser( + report.getReporterId(), + "REPORT", + "SKILL_REPORT", + reportId, + "Report handled", + "{\"status\":\"RESOLVED\"}" + ); return saved; } @@ -89,6 +120,14 @@ public class SkillReportService { report.setHandledAt(LocalDateTime.now()); SkillReport saved = skillReportRepository.save(report); auditLogService.record(actorUserId, "DISMISS_SKILL_REPORT", "SKILL_REPORT", reportId, null, clientIp, userAgent, null); + governanceNotificationService.notifyUser( + report.getReporterId(), + "REPORT", + "SKILL_REPORT", + reportId, + "Report dismissed", + "{\"status\":\"DISMISSED\"}" + ); return saved; } diff --git a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/review/PromotionRequestRepository.java b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/review/PromotionRequestRepository.java index 51ff4860..4275959b 100644 --- a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/review/PromotionRequestRepository.java +++ b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/review/PromotionRequestRepository.java @@ -8,6 +8,7 @@ public interface PromotionRequestRepository { PromotionRequest save(PromotionRequest request); Optional findById(Long id); Optional findBySourceVersionIdAndStatus(Long sourceVersionId, ReviewTaskStatus status); + Optional findBySourceSkillIdAndStatus(Long sourceSkillId, ReviewTaskStatus status); Page findByStatus(ReviewTaskStatus status, Pageable pageable); int updateStatusWithVersion(Long id, ReviewTaskStatus status, String reviewedBy, String reviewComment, Long targetSkillId, Integer expectedVersion); 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..733f3350 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 @@ -1,9 +1,11 @@ package com.iflytek.skillhub.domain.review; import com.iflytek.skillhub.domain.event.SkillPublishedEvent; +import com.iflytek.skillhub.domain.governance.GovernanceNotificationService; 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; @@ -29,6 +31,7 @@ public class PromotionService { private final NamespaceRepository namespaceRepository; private final ReviewPermissionChecker permissionChecker; private final ApplicationEventPublisher eventPublisher; + private final GovernanceNotificationService governanceNotificationService; public PromotionService(PromotionRequestRepository promotionRequestRepository, SkillRepository skillRepository, @@ -36,7 +39,8 @@ public class PromotionService { SkillFileRepository skillFileRepository, NamespaceRepository namespaceRepository, ReviewPermissionChecker permissionChecker, - ApplicationEventPublisher eventPublisher) { + ApplicationEventPublisher eventPublisher, + GovernanceNotificationService governanceNotificationService) { this.promotionRequestRepository = promotionRequestRepository; this.skillRepository = skillRepository; this.skillVersionRepository = skillVersionRepository; @@ -44,6 +48,7 @@ public class PromotionService { this.namespaceRepository = namespaceRepository; this.permissionChecker = permissionChecker; this.eventPublisher = eventPublisher; + this.governanceNotificationService = governanceNotificationService; } @Transactional @@ -65,6 +70,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"); } @@ -76,10 +85,14 @@ public class PromotionService { throw new DomainBadRequestException("promotion.target_not_global", targetNamespaceId); } - promotionRequestRepository.findBySourceVersionIdAndStatus(sourceVersionId, ReviewTaskStatus.PENDING) + promotionRequestRepository.findBySourceSkillIdAndStatus(sourceSkillId, ReviewTaskStatus.PENDING) .ifPresent(existing -> { throw new DomainBadRequestException("promotion.duplicate_pending", sourceVersionId); }); + promotionRequestRepository.findBySourceSkillIdAndStatus(sourceSkillId, ReviewTaskStatus.APPROVED) + .ifPresent(existing -> { + throw new DomainBadRequestException("promotion.already_promoted", sourceSkillId); + }); PromotionRequest request = new PromotionRequest(sourceSkillId, sourceVersionId, targetNamespaceId, userId); return promotionRequestRepository.save(request); @@ -103,6 +116,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"); } @@ -114,10 +131,14 @@ public class PromotionService { throw new DomainBadRequestException("promotion.target_not_global", targetNamespaceId); } - promotionRequestRepository.findBySourceVersionIdAndStatus(sourceVersionId, ReviewTaskStatus.PENDING) + promotionRequestRepository.findBySourceSkillIdAndStatus(sourceSkillId, ReviewTaskStatus.PENDING) .ifPresent(existing -> { throw new DomainBadRequestException("promotion.duplicate_pending", sourceVersionId); }); + promotionRequestRepository.findBySourceSkillIdAndStatus(sourceSkillId, ReviewTaskStatus.APPROVED) + .ifPresent(existing -> { + throw new DomainBadRequestException("promotion.already_promoted", sourceSkillId); + }); PromotionRequest request = new PromotionRequest(sourceSkillId, sourceVersionId, targetNamespaceId, userId); return promotionRequestRepository.save(request); @@ -143,14 +164,17 @@ public class PromotionService { throw new ConcurrentModificationException("Promotion request was modified concurrently"); } - Skill sourceSkill = skillRepository.findById(request.getSourceSkillId()) - .orElseThrow(() -> new DomainNotFoundException("skill.not_found", request.getSourceSkillId())); + PromotionRequest approvedRequest = promotionRequestRepository.findById(promotionId) + .orElseThrow(() -> new DomainNotFoundException("promotion.not_found", promotionId)); - SkillVersion sourceVersion = skillVersionRepository.findById(request.getSourceVersionId()) - .orElseThrow(() -> new DomainNotFoundException("skill_version.not_found", request.getSourceVersionId())); + Skill sourceSkill = skillRepository.findById(approvedRequest.getSourceSkillId()) + .orElseThrow(() -> new DomainNotFoundException("skill.not_found", approvedRequest.getSourceSkillId())); + + SkillVersion sourceVersion = skillVersionRepository.findById(approvedRequest.getSourceVersionId()) + .orElseThrow(() -> new DomainNotFoundException("skill_version.not_found", approvedRequest.getSourceVersionId())); // Create new skill in global namespace - Skill newSkill = new Skill(request.getTargetNamespaceId(), sourceSkill.getSlug(), + Skill newSkill = new Skill(approvedRequest.getTargetNamespaceId(), sourceSkill.getSlug(), sourceSkill.getOwnerId(), SkillVisibility.PUBLIC); newSkill.setDisplayName(sourceSkill.getDisplayName()); newSkill.setSummary(sourceSkill.getSummary()); @@ -176,7 +200,7 @@ public class PromotionService { skillRepository.save(newSkill); // Copy file records (reuse storageKey) - List sourceFiles = skillFileRepository.findByVersionId(request.getSourceVersionId()); + List sourceFiles = skillFileRepository.findByVersionId(approvedRequest.getSourceVersionId()); Long newVersionId = newVersion.getId(); List copiedFiles = sourceFiles.stream() .map(f -> new SkillFile(newVersionId, f.getFilePath(), f.getFileSize(), @@ -185,13 +209,21 @@ public class PromotionService { skillFileRepository.saveAll(copiedFiles); // Update promotion request with target skill id - request.setTargetSkillId(newSkill.getId()); - promotionRequestRepository.save(request); + approvedRequest.setTargetSkillId(newSkill.getId()); + PromotionRequest savedRequest = promotionRequestRepository.save(approvedRequest); eventPublisher.publishEvent(new SkillPublishedEvent( newSkill.getId(), newVersion.getId(), reviewerId)); + governanceNotificationService.notifyUser( + approvedRequest.getSubmittedBy(), + "PROMOTION", + "PROMOTION_REQUEST", + promotionId, + "Promotion approved", + "{\"status\":\"APPROVED\"}" + ); - return request; + return savedRequest; } @Transactional @@ -213,6 +245,14 @@ public class PromotionService { if (updated == 0) { throw new ConcurrentModificationException("Promotion request was modified concurrently"); } + governanceNotificationService.notifyUser( + request.getSubmittedBy(), + "PROMOTION", + "PROMOTION_REQUEST", + promotionId, + "Promotion rejected", + "{\"status\":\"REJECTED\"}" + ); return promotionRequestRepository.findById(promotionId).orElse(request); } @@ -220,4 +260,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..ec99bbc6 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,7 +4,9 @@ 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.governance.GovernanceNotificationService; import com.iflytek.skillhub.domain.shared.exception.DomainBadRequestException; import com.iflytek.skillhub.domain.shared.exception.DomainForbiddenException; import com.iflytek.skillhub.domain.shared.exception.DomainNotFoundException; @@ -36,6 +38,7 @@ public class ReviewService { private final ApplicationEventPublisher eventPublisher; private final ObjectMapper objectMapper; private final SkillGovernanceService skillGovernanceService; + private final GovernanceNotificationService governanceNotificationService; public ReviewService(ReviewTaskRepository reviewTaskRepository, SkillVersionRepository skillVersionRepository, @@ -44,7 +47,8 @@ public class ReviewService { ReviewPermissionChecker permissionChecker, ApplicationEventPublisher eventPublisher, ObjectMapper objectMapper, - SkillGovernanceService skillGovernanceService) { + SkillGovernanceService skillGovernanceService, + GovernanceNotificationService governanceNotificationService) { this.reviewTaskRepository = reviewTaskRepository; this.skillVersionRepository = skillVersionRepository; this.skillRepository = skillRepository; @@ -53,6 +57,7 @@ public class ReviewService { this.eventPublisher = eventPublisher; this.objectMapper = objectMapper; this.skillGovernanceService = skillGovernanceService; + this.governanceNotificationService = governanceNotificationService; } @Transactional @@ -65,6 +70,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 +102,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 +138,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)) { @@ -154,6 +166,14 @@ public class ReviewService { eventPublisher.publishEvent(new SkillPublishedEvent( skill.getId(), skillVersion.getId(), reviewerId)); + governanceNotificationService.notifyUser( + task.getSubmittedBy(), + "REVIEW", + "REVIEW_TASK", + reviewTaskId, + "Review approved", + "{\"status\":\"APPROVED\"}" + ); // Reload to return updated state return reviewTaskRepository.findById(reviewTaskId).orElse(task); @@ -172,6 +192,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)) { @@ -188,6 +209,14 @@ public class ReviewService { .orElseThrow(() -> new DomainNotFoundException("skill_version.not_found", task.getSkillVersionId())); skillVersion.setStatus(SkillVersionStatus.REJECTED); skillVersionRepository.save(skillVersion); + governanceNotificationService.notifyUser( + task.getSubmittedBy(), + "REVIEW", + "REVIEW_TASK", + reviewTaskId, + "Review rejected", + "{\"status\":\"REJECTED\"}" + ); return reviewTaskRepository.findById(reviewTaskId).orElse(task); } @@ -208,6 +237,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 +273,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/SkillGovernanceService.java b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/service/SkillGovernanceService.java index 2e486a11..8e7d8c9d 100644 --- a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/service/SkillGovernanceService.java +++ b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/service/SkillGovernanceService.java @@ -69,13 +69,31 @@ public class SkillGovernanceService { Skill skill = skillRepository.findById(skillId) .orElseThrow(() -> new DomainNotFoundException("error.skill.notFound", skillId)); assertCanManageLifecycle(skill, actorUserId, userNamespaceRoles); + return archiveSkillInternal(skill, actorUserId, clientIp, userAgent, reason); + } + @Transactional + public Skill archiveSkillAsAdmin(Long skillId, + String actorUserId, + String clientIp, + String userAgent, + String reason) { + Skill skill = skillRepository.findById(skillId) + .orElseThrow(() -> new DomainNotFoundException("error.skill.notFound", skillId)); + return archiveSkillInternal(skill, actorUserId, clientIp, userAgent, reason); + } + + private Skill archiveSkillInternal(Skill skill, + String actorUserId, + String clientIp, + String userAgent, + String reason) { SkillStatus previousStatus = skill.getStatus(); skill.setStatus(SkillStatus.ARCHIVED); skill.setUpdatedBy(actorUserId); Skill saved = skillRepository.save(skill); - auditLogService.record(actorUserId, "ARCHIVE_SKILL", "SKILL", skillId, null, clientIp, userAgent, jsonReason(reason)); - eventPublisher.publishEvent(new SkillStatusChangedEvent(skillId, previousStatus, SkillStatus.ARCHIVED)); + auditLogService.record(actorUserId, "ARCHIVE_SKILL", "SKILL", skill.getId(), null, clientIp, userAgent, jsonReason(reason)); + eventPublisher.publishEvent(new SkillStatusChangedEvent(skill.getId(), previousStatus, SkillStatus.ARCHIVED)); return saved; } 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/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..32e84fa6 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,10 @@ 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.namespace.NamespaceType; +import com.iflytek.skillhub.domain.review.PromotionRequestRepository; +import com.iflytek.skillhub.domain.review.ReviewTaskStatus; import com.iflytek.skillhub.domain.shared.exception.DomainBadRequestException; import com.iflytek.skillhub.domain.shared.exception.DomainForbiddenException; import com.iflytek.skillhub.domain.skill.*; @@ -35,6 +39,7 @@ public class SkillQueryService { private final SkillTagRepository skillTagRepository; private final ObjectStorageService objectStorageService; private final VisibilityChecker visibilityChecker; + private final PromotionRequestRepository promotionRequestRepository; public SkillQueryService( NamespaceRepository namespaceRepository, @@ -43,7 +48,8 @@ public class SkillQueryService { SkillFileRepository skillFileRepository, SkillTagRepository skillTagRepository, ObjectStorageService objectStorageService, - VisibilityChecker visibilityChecker) { + VisibilityChecker visibilityChecker, + PromotionRequestRepository promotionRequestRepository) { this.namespaceRepository = namespaceRepository; this.skillRepository = skillRepository; this.skillVersionRepository = skillVersionRepository; @@ -51,6 +57,7 @@ public class SkillQueryService { this.skillTagRepository = skillTagRepository; this.objectStorageService = objectStorageService; this.visibilityChecker = visibilityChecker; + this.promotionRequestRepository = promotionRequestRepository; } public record SkillDetailDTO( @@ -70,7 +77,9 @@ public class SkillQueryService { java.time.LocalDateTime createdAt, java.time.LocalDateTime updatedAt, SkillVersion latestVersionEntity, + Long latestVersionId, boolean canManageLifecycle, + boolean canSubmitPromotion, String viewingVersionStatus, boolean canInteract ) {} @@ -136,7 +145,9 @@ public class SkillQueryService { skill.getCreatedAt(), skill.getUpdatedAt(), latestVersionEntity, + latestVersionEntity != null ? latestVersionEntity.getId() : null, canManageRestrictedSkill(skill, currentUserId, userNsRoles), + canSubmitPromotion(namespace, skill, latestVersionEntity, currentUserId, userNsRoles), latestVersionEntity != null ? latestVersionEntity.getStatus().name() : null, latestVersionEntity == null || latestVersionEntity.getStatus() == SkillVersionStatus.PUBLISHED ); @@ -170,8 +181,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 +206,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 +222,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 +236,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 +255,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 +268,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 +312,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 +342,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 +462,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()); } @@ -462,10 +491,38 @@ public class SkillQueryService { || role == NamespaceRole.OWNER; } + private boolean canSubmitPromotion( + Namespace namespace, + Skill skill, + SkillVersion latestVersionEntity, + String currentUserId, + Map userNsRoles) { + if (namespace.getType() == NamespaceType.GLOBAL) { + return false; + } + if (namespace.getStatus() != NamespaceStatus.ACTIVE || skill.getStatus() != SkillStatus.ACTIVE) { + return false; + } + if (latestVersionEntity == null || latestVersionEntity.getStatus() != SkillVersionStatus.PUBLISHED) { + return false; + } + if (promotionRequestRepository.findBySourceSkillIdAndStatus(skill.getId(), ReviewTaskStatus.PENDING).isPresent()) { + return false; + } + if (promotionRequestRepository.findBySourceSkillIdAndStatus(skill.getId(), ReviewTaskStatus.APPROVED).isPresent()) { + return false; + } + return canManageRestrictedSkill(skill, currentUserId, userNsRoles); + } + private boolean isOwner(Skill skill, String currentUserId) { 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/governance/GovernanceNotificationServiceTest.java b/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/governance/GovernanceNotificationServiceTest.java new file mode 100644 index 00000000..6ecfedbc --- /dev/null +++ b/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/governance/GovernanceNotificationServiceTest.java @@ -0,0 +1,85 @@ +package com.iflytek.skillhub.domain.governance; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.when; + +import com.iflytek.skillhub.domain.shared.exception.DomainForbiddenException; +import java.util.List; +import java.util.Optional; +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; + +@ExtendWith(MockitoExtension.class) +class GovernanceNotificationServiceTest { + + @Mock + private UserNotificationRepository userNotificationRepository; + + private GovernanceNotificationService service; + + @BeforeEach + void setUp() { + service = new GovernanceNotificationService(userNotificationRepository); + } + + @Test + void notifyUser_createsUnreadNotification() { + when(userNotificationRepository.save(any(UserNotification.class))) + .thenAnswer(invocation -> invocation.getArgument(0)); + + UserNotification notification = service.notifyUser( + "user-1", + "REVIEW", + "REVIEW_TASK", + 99L, + "Review completed", + "{\"status\":\"APPROVED\"}" + ); + + assertThat(notification.getUserId()).isEqualTo("user-1"); + assertThat(notification.getStatus()).isEqualTo(UserNotificationStatus.UNREAD); + assertThat(notification.getCategory()).isEqualTo("REVIEW"); + } + + @Test + void markRead_requiresOwner() { + UserNotification notification = new UserNotification( + "user-1", + "REVIEW", + "REVIEW_TASK", + 99L, + "Review completed", + "{\"status\":\"APPROVED\"}" + ); + setField(notification, "id", 10L); + when(userNotificationRepository.findById(10L)).thenReturn(Optional.of(notification)); + + assertThrows(DomainForbiddenException.class, () -> service.markRead(10L, "user-2")); + } + + @Test + void listNotifications_returnsNewestFirst() { + UserNotification unread = new UserNotification("user-1", "REVIEW", "REVIEW_TASK", 99L, "A", "{}"); + UserNotification read = new UserNotification("user-1", "REPORT", "SKILL_REPORT", 88L, "B", "{}"); + when(userNotificationRepository.findByUserIdOrderByCreatedAtDesc("user-1")).thenReturn(List.of(unread, read)); + + List result = service.listNotifications("user-1"); + + assertThat(result).hasSize(2); + } + + 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 (ReflectiveOperationException e) { + throw new AssertionError(e); + } + } +} 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..48953f90 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,51 @@ 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(namespaceAccessPolicy.isImmutable(namespace)).thenReturn(true); + + assertThrows(DomainBadRequestException.class, () -> + namespaceService.updateNamespace(namespaceId, "Name", "Desc", null, operatorUserId)); + } + + @Test + void updateNamespace_shouldRejectGlobalNamespaceMutationBeforeMembershipChecks() { + Long namespaceId = 1L; + String operatorUserId = "user-404"; + Namespace namespace = new Namespace("global", "Global", "system"); + namespace.setType(NamespaceType.GLOBAL); + when(namespaceRepository.findById(namespaceId)).thenReturn(Optional.of(namespace)); + when(namespaceAccessPolicy.isImmutable(namespace)).thenReturn(true); + + DomainBadRequestException exception = assertThrows(DomainBadRequestException.class, () -> + namespaceService.updateNamespace(namespaceId, "Name", "Desc", null, operatorUserId)); + + assertEquals("error.namespace.system.immutable", exception.messageCode()); + verify(namespaceMemberRepository, never()).findByNamespaceIdAndUserId(namespaceId, operatorUserId); + } + @Test void getNamespaceBySlug_shouldReturnNamespace() { String slug = "test-slug"; @@ -124,4 +174,25 @@ class NamespaceServiceTest { assertThrows(DomainBadRequestException.class, () -> namespaceService.getNamespaceBySlug("nonexistent")); } + + @Test + void assertMember_shouldAllowExistingMember() { + Long namespaceId = 1L; + String userId = "user-1"; + when(namespaceMemberRepository.findByNamespaceIdAndUserId(namespaceId, userId)) + .thenReturn(Optional.of(new NamespaceMember(namespaceId, userId, NamespaceRole.MEMBER))); + + assertDoesNotThrow(() -> namespaceService.assertMember(namespaceId, userId)); + } + + @Test + void assertMember_shouldRejectNonMember() { + Long namespaceId = 1L; + String userId = "user-404"; + when(namespaceMemberRepository.findByNamespaceIdAndUserId(namespaceId, userId)) + .thenReturn(Optional.empty()); + + assertThrows(DomainForbiddenException.class, () -> + namespaceService.assertMember(namespaceId, userId)); + } } diff --git a/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/report/SkillReportServiceTest.java b/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/report/SkillReportServiceTest.java index 5495f397..9fe73265 100644 --- a/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/report/SkillReportServiceTest.java +++ b/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/report/SkillReportServiceTest.java @@ -3,14 +3,17 @@ package com.iflytek.skillhub.domain.report; import static org.assertj.core.api.Assertions.assertThat; import static org.junit.jupiter.api.Assertions.assertThrows; import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; import com.iflytek.skillhub.domain.audit.AuditLogService; +import com.iflytek.skillhub.domain.governance.GovernanceNotificationService; import com.iflytek.skillhub.domain.shared.exception.DomainBadRequestException; import com.iflytek.skillhub.domain.skill.Skill; import com.iflytek.skillhub.domain.skill.SkillRepository; import com.iflytek.skillhub.domain.skill.SkillVisibility; +import com.iflytek.skillhub.domain.skill.service.SkillGovernanceService; import java.util.Optional; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; @@ -30,11 +33,23 @@ class SkillReportServiceTest { @Mock private AuditLogService auditLogService; + @Mock + private SkillGovernanceService skillGovernanceService; + + @Mock + private GovernanceNotificationService governanceNotificationService; + private SkillReportService service; @BeforeEach void setUp() { - service = new SkillReportService(skillRepository, skillReportRepository, auditLogService); + service = new SkillReportService( + skillRepository, + skillReportRepository, + auditLogService, + skillGovernanceService, + governanceNotificationService + ); } @Test @@ -90,6 +105,53 @@ class SkillReportServiceTest { assertThat(saved.getHandledBy()).isEqualTo("admin"); } + @Test + void resolveReport_withHideDisposition_hidesSkillAndNotifiesReporter() { + SkillReport report = new SkillReport(10L, 1L, "user-1", "spam", null); + setField(report, "id", 99L); + when(skillReportRepository.findById(99L)).thenReturn(Optional.of(report)); + when(skillReportRepository.save(report)).thenReturn(report); + + SkillReport saved = service.resolveReport( + 99L, + "admin", + SkillReportDisposition.RESOLVE_AND_HIDE, + "handled", + "127.0.0.1", + "JUnit" + ); + + assertThat(saved.getStatus()).isEqualTo(SkillReportStatus.RESOLVED); + verify(skillGovernanceService).hideSkill(10L, "admin", "127.0.0.1", "JUnit", "handled"); + verify(governanceNotificationService).notifyUser( + eq("user-1"), + eq("REPORT"), + eq("SKILL_REPORT"), + eq(99L), + eq("Report handled"), + any() + ); + } + + @Test + void resolveReport_withArchiveDisposition_archivesSkill() { + SkillReport report = new SkillReport(10L, 1L, "user-1", "spam", null); + setField(report, "id", 99L); + when(skillReportRepository.findById(99L)).thenReturn(Optional.of(report)); + when(skillReportRepository.save(report)).thenReturn(report); + + service.resolveReport( + 99L, + "admin", + SkillReportDisposition.RESOLVE_AND_ARCHIVE, + "handled", + "127.0.0.1", + "JUnit" + ); + + verify(skillGovernanceService).archiveSkillAsAdmin(10L, "admin", "127.0.0.1", "JUnit", "handled"); + } + private void setField(Object target, String fieldName, Object value) { try { java.lang.reflect.Field field = target.getClass().getDeclaredField(fieldName); 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..c5fd9e83 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 @@ -1,8 +1,10 @@ package com.iflytek.skillhub.domain.review; import com.iflytek.skillhub.domain.event.SkillPublishedEvent; +import com.iflytek.skillhub.domain.governance.GovernanceNotificationService; 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; @@ -34,6 +36,7 @@ class PromotionServiceTest { @Mock private NamespaceRepository namespaceRepository; @Mock private ReviewPermissionChecker permissionChecker; @Mock private ApplicationEventPublisher eventPublisher; + @Mock private GovernanceNotificationService governanceNotificationService; private PromotionService promotionService; @@ -50,7 +53,7 @@ class PromotionServiceTest { void setUp() { promotionService = new PromotionService( promotionRequestRepository, skillRepository, skillVersionRepository, - skillFileRepository, namespaceRepository, permissionChecker, eventPublisher); + skillFileRepository, namespaceRepository, permissionChecker, eventPublisher, governanceNotificationService); } private static void setField(Object target, String fieldName, Object value) { @@ -97,6 +100,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,9 +130,12 @@ 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)) + when(promotionRequestRepository.findBySourceSkillIdAndStatus(SOURCE_SKILL_ID, ReviewTaskStatus.PENDING)) + .thenReturn(Optional.empty()); + when(promotionRequestRepository.findBySourceSkillIdAndStatus(SOURCE_SKILL_ID, ReviewTaskStatus.APPROVED)) .thenReturn(Optional.empty()); when(promotionRequestRepository.save(any(PromotionRequest.class))) .thenAnswer(inv -> { @@ -191,6 +203,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 +216,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,15 +229,36 @@ 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)) + when(promotionRequestRepository.findBySourceSkillIdAndStatus(SOURCE_SKILL_ID, ReviewTaskStatus.PENDING)) .thenReturn(Optional.of(createPendingPromotion())); assertThrows(DomainBadRequestException.class, () -> promotionService.submitPromotion(SOURCE_SKILL_ID, SOURCE_VERSION_ID, TARGET_NAMESPACE_ID, USER_ID, Map.of())); } + @Test + void shouldThrowWhenSkillAlreadyPromoted() { + Skill sourceSkill = createSourceSkill(); + PromotionRequest approvedPromotion = createPendingPromotion(); + setField(approvedPromotion, "status", ReviewTaskStatus.APPROVED); + + 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.findBySourceSkillIdAndStatus(SOURCE_SKILL_ID, ReviewTaskStatus.PENDING)) + .thenReturn(Optional.empty()); + when(promotionRequestRepository.findBySourceSkillIdAndStatus(SOURCE_SKILL_ID, ReviewTaskStatus.APPROVED)) + .thenReturn(Optional.of(approvedPromotion)); + + assertThrows(DomainBadRequestException.class, + () -> promotionService.submitPromotion(SOURCE_SKILL_ID, SOURCE_VERSION_ID, TARGET_NAMESPACE_ID, USER_ID, Map.of())); + } + @Test void shouldThrowWhenSubmitterIsNotOwnerOrNamespaceAdmin() { Skill sourceSkill = createSourceSkill(); @@ -231,6 +266,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,13 +292,16 @@ 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", Map.of(sourceSkill.getNamespaceId(), com.iflytek.skillhub.domain.namespace.NamespaceRole.ADMIN))) .thenReturn(true); when(namespaceRepository.findById(TARGET_NAMESPACE_ID)).thenReturn(Optional.of(globalNs)); - when(promotionRequestRepository.findBySourceVersionIdAndStatus(SOURCE_VERSION_ID, ReviewTaskStatus.PENDING)) + when(promotionRequestRepository.findBySourceSkillIdAndStatus(SOURCE_SKILL_ID, ReviewTaskStatus.PENDING)) + .thenReturn(Optional.empty()); + when(promotionRequestRepository.findBySourceSkillIdAndStatus(SOURCE_SKILL_ID, ReviewTaskStatus.APPROVED)) .thenReturn(Optional.empty()); when(promotionRequestRepository.save(any(PromotionRequest.class))) .thenAnswer(inv -> inv.getArgument(0)); @@ -277,6 +316,68 @@ 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 + class ReviewPromotion { + + @Test + void shouldNotifySubmitterWhenPromotionApproved() { + PromotionRequest request = createPendingPromotion(); + Skill sourceSkill = createSourceSkill(); + SkillVersion sourceVersion = createPublishedVersion(); + Skill newSkill = new Skill(TARGET_NAMESPACE_ID, "my-skill", REVIEWER_ID, SkillVisibility.PUBLIC); + setField(newSkill, "id", NEW_SKILL_ID); + SkillVersion newVersion = new SkillVersion(NEW_SKILL_ID, sourceVersion.getVersion(), REVIEWER_ID); + setField(newVersion, "id", NEW_VERSION_ID); + + when(promotionRequestRepository.findById(PROMOTION_ID)).thenReturn(Optional.of(request)); + when(permissionChecker.canReviewPromotion(request, REVIEWER_ID, Set.of("SKILL_ADMIN"))).thenReturn(true); + when(promotionRequestRepository.updateStatusWithVersion( + PROMOTION_ID, ReviewTaskStatus.APPROVED, REVIEWER_ID, "ok", null, request.getVersion())) + .thenReturn(1); + when(skillRepository.findById(SOURCE_SKILL_ID)).thenReturn(Optional.of(sourceSkill)); + when(skillVersionRepository.findById(SOURCE_VERSION_ID)).thenReturn(Optional.of(sourceVersion)); + when(skillRepository.save(any(Skill.class))).thenReturn(newSkill); + when(skillVersionRepository.save(any(SkillVersion.class))).thenReturn(newVersion); + when(skillFileRepository.findByVersionId(SOURCE_VERSION_ID)).thenReturn(List.of()); + + promotionService.approvePromotion(PROMOTION_ID, REVIEWER_ID, "ok", Set.of("SKILL_ADMIN")); + + verify(governanceNotificationService).notifyUser(eq(USER_ID), eq("PROMOTION"), eq("PROMOTION_REQUEST"), eq(PROMOTION_ID), eq("Promotion approved"), any()); + } + + @Test + void shouldNotifySubmitterWhenPromotionRejected() { + PromotionRequest request = createPendingPromotion(); + + when(promotionRequestRepository.findById(PROMOTION_ID)).thenReturn(Optional.of(request)); + when(permissionChecker.canReviewPromotion(request, REVIEWER_ID, Set.of("SKILL_ADMIN"))).thenReturn(true); + when(promotionRequestRepository.updateStatusWithVersion( + PROMOTION_ID, ReviewTaskStatus.REJECTED, REVIEWER_ID, "no", null, request.getVersion())) + .thenReturn(1); + when(promotionRequestRepository.findById(PROMOTION_ID)).thenReturn(Optional.of(request)); + + promotionService.rejectPromotion(PROMOTION_ID, REVIEWER_ID, "no", Set.of("SKILL_ADMIN")); + + verify(governanceNotificationService).notifyUser(eq(USER_ID), eq("PROMOTION"), eq("PROMOTION_REQUEST"), eq(PROMOTION_ID), eq("Promotion rejected"), any()); + } } @Nested @@ -285,11 +386,17 @@ class PromotionServiceTest { @Test void shouldApprovePromotionSuccessfully() { PromotionRequest pr = createPendingPromotion(); + PromotionRequest approvedPromotion = createPendingPromotion(); + setField(approvedPromotion, "status", ReviewTaskStatus.APPROVED); + setField(approvedPromotion, "version", 2); + setField(approvedPromotion, "reviewedBy", REVIEWER_ID); + setField(approvedPromotion, "reviewComment", "LGTM"); Skill sourceSkill = createSourceSkill(); SkillVersion sourceVersion = createPublishedVersion(); List sourceFiles = createSourceFiles(); - when(promotionRequestRepository.findById(PROMOTION_ID)).thenReturn(Optional.of(pr)); + when(promotionRequestRepository.findById(PROMOTION_ID)) + .thenReturn(Optional.of(pr), Optional.of(approvedPromotion)); when(permissionChecker.canReviewPromotion(pr, REVIEWER_ID, Set.of("SKILL_ADMIN"))).thenReturn(true); when(promotionRequestRepository.updateStatusWithVersion( PROMOTION_ID, ReviewTaskStatus.APPROVED, REVIEWER_ID, "LGTM", null, pr.getVersion())) @@ -308,6 +415,7 @@ class PromotionServiceTest { }); when(skillFileRepository.findByVersionId(SOURCE_VERSION_ID)).thenReturn(sourceFiles); when(skillFileRepository.saveAll(anyList())).thenAnswer(inv -> inv.getArgument(0)); + when(promotionRequestRepository.save(approvedPromotion)).thenReturn(approvedPromotion); PromotionRequest result = promotionService.approvePromotion( PROMOTION_ID, REVIEWER_ID, "LGTM", Set.of("SKILL_ADMIN")); @@ -354,8 +462,8 @@ class PromotionServiceTest { assertEquals(REVIEWER_ID, event.publisherId()); // Verify targetSkillId updated on promotion request - verify(promotionRequestRepository).save(pr); - assertEquals(NEW_SKILL_ID, pr.getTargetSkillId()); + verify(promotionRequestRepository).save(approvedPromotion); + assertEquals(NEW_SKILL_ID, approvedPromotion.getTargetSkillId()); } @Test 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..2ceb4562 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 @@ -2,9 +2,11 @@ package com.iflytek.skillhub.domain.review; import com.fasterxml.jackson.databind.ObjectMapper; import com.iflytek.skillhub.domain.event.SkillPublishedEvent; +import com.iflytek.skillhub.domain.governance.GovernanceNotificationService; 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; @@ -45,6 +47,7 @@ class ReviewServiceTest { @Mock private ReviewPermissionChecker permissionChecker; @Mock private ApplicationEventPublisher eventPublisher; @Mock private SkillGovernanceService skillGovernanceService; + @Mock private GovernanceNotificationService governanceNotificationService; private ReviewService reviewService; @@ -61,7 +64,7 @@ class ReviewServiceTest { objectMapper = new ObjectMapper(); reviewService = new ReviewService( reviewTaskRepository, skillVersionRepository, skillRepository, - namespaceRepository, permissionChecker, eventPublisher, objectMapper, skillGovernanceService); + namespaceRepository, permissionChecker, eventPublisher, objectMapper, skillGovernanceService, governanceNotificationService); } private SkillVersion createDraftSkillVersion() { @@ -111,8 +114,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 +147,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 +160,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 +182,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 @@ -222,6 +247,7 @@ class ReviewServiceTest { assertEquals("Approved Summary", skill.getSummary()); assertEquals(REVIEWER_ID, skill.getUpdatedBy()); verify(eventPublisher).publishEvent(any(SkillPublishedEvent.class)); + verify(governanceNotificationService).notifyUser(eq(USER_ID), eq("REVIEW"), eq("REVIEW_TASK"), eq(REVIEW_TASK_ID), eq("Review approved"), any()); } @Test @@ -250,6 +276,28 @@ class ReviewServiceTest { assertEquals(REVIEWER_ID, event.publisherId()); } + @Test + void shouldNotifySubmitterWhenRejected() { + ReviewTask task = createPendingReviewTask(); + Namespace ns = createTeamNamespace(); + SkillVersion sv = createPendingReviewSkillVersion(); + + when(reviewTaskRepository.findById(REVIEW_TASK_ID)).thenReturn(Optional.of(task)); + when(namespaceRepository.findById(NAMESPACE_ID)).thenReturn(Optional.of(ns)); + when(permissionChecker.canReview(eq(task), eq(REVIEWER_ID), eq(ns.getType()), anyMap(), anySet())) + .thenReturn(true); + when(reviewTaskRepository.updateStatusWithVersion( + REVIEW_TASK_ID, ReviewTaskStatus.REJECTED, REVIEWER_ID, "Needs work", task.getVersion())) + .thenReturn(1); + when(skillVersionRepository.findById(SKILL_VERSION_ID)).thenReturn(Optional.of(sv)); + when(reviewTaskRepository.findById(REVIEW_TASK_ID)).thenReturn(Optional.of(task)); + + reviewService.rejectReview(REVIEW_TASK_ID, REVIEWER_ID, "Needs work", + Map.of(NAMESPACE_ID, NamespaceRole.ADMIN), Set.of()); + + verify(governanceNotificationService).notifyUser(eq(USER_ID), eq("REVIEW"), eq("REVIEW_TASK"), eq(REVIEW_TASK_ID), eq("Review rejected"), any()); + } + @Test void shouldThrowWhenReviewTaskNotFound() { when(reviewTaskRepository.findById(REVIEW_TASK_ID)).thenReturn(Optional.empty()); @@ -280,6 +328,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 +477,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 +513,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 +555,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 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..79866b58 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,9 @@ 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.review.PromotionRequestRepository; +import com.iflytek.skillhub.domain.review.ReviewTaskStatus; import com.iflytek.skillhub.domain.shared.exception.DomainBadRequestException; import com.iflytek.skillhub.domain.shared.exception.DomainForbiddenException; import com.iflytek.skillhub.domain.skill.*; @@ -45,6 +48,8 @@ class SkillQueryServiceTest { private ObjectStorageService objectStorageService; @Mock private VisibilityChecker visibilityChecker; + @Mock + private PromotionRequestRepository promotionRequestRepository; private SkillQueryService service; @@ -57,7 +62,8 @@ class SkillQueryServiceTest { skillFileRepository, skillTagRepository, objectStorageService, - visibilityChecker + visibilityChecker, + promotionRequestRepository ); } @@ -118,6 +124,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 @@ -444,6 +468,98 @@ class SkillQueryServiceTest { assertTrue(result.canManageLifecycle()); } + @Test + void testGetSkillDetail_ShouldAllowPromotionForTeamOwnerOnPublishedSkill() throws Exception { + String namespaceSlug = "team-ns"; + String skillSlug = "team-skill"; + String userId = "owner-1"; + Map userNsRoles = Map.of(); + + Namespace namespace = new Namespace(namespaceSlug, "Team NS", userId); + setId(namespace, 1L); + Skill skill = new Skill(1L, skillSlug, userId, SkillVisibility.PUBLIC); + setId(skill, 1L); + skill.setStatus(SkillStatus.ACTIVE); + skill.setLatestVersionId(11L); + + SkillVersion published = new SkillVersion(1L, "1.0.0", userId); + setId(published, 11L); + published.setStatus(SkillVersionStatus.PUBLISHED); + + when(namespaceRepository.findBySlug(namespaceSlug)).thenReturn(Optional.of(namespace)); + when(skillRepository.findByNamespaceIdAndSlug(1L, skillSlug)).thenReturn(Optional.of(skill)); + when(visibilityChecker.canAccess(skill, userId, userNsRoles)).thenReturn(true); + when(skillVersionRepository.findById(11L)).thenReturn(Optional.of(published)); + when(promotionRequestRepository.findBySourceSkillIdAndStatus(1L, ReviewTaskStatus.PENDING)).thenReturn(Optional.empty()); + when(promotionRequestRepository.findBySourceSkillIdAndStatus(1L, ReviewTaskStatus.APPROVED)).thenReturn(Optional.empty()); + + SkillQueryService.SkillDetailDTO result = service.getSkillDetail(namespaceSlug, skillSlug, userId, userNsRoles); + + assertEquals(11L, result.latestVersionId()); + assertTrue(result.canSubmitPromotion()); + } + + @Test + void testGetSkillDetail_ShouldHidePromotionWhenPendingPromotionExists() throws Exception { + String namespaceSlug = "team-ns"; + String skillSlug = "team-skill"; + String userId = "owner-1"; + Map userNsRoles = Map.of(); + + Namespace namespace = new Namespace(namespaceSlug, "Team NS", userId); + setId(namespace, 1L); + Skill skill = new Skill(1L, skillSlug, userId, SkillVisibility.PUBLIC); + setId(skill, 1L); + skill.setStatus(SkillStatus.ACTIVE); + skill.setLatestVersionId(11L); + + SkillVersion published = new SkillVersion(1L, "1.0.0", userId); + setId(published, 11L); + published.setStatus(SkillVersionStatus.PUBLISHED); + + when(namespaceRepository.findBySlug(namespaceSlug)).thenReturn(Optional.of(namespace)); + when(skillRepository.findByNamespaceIdAndSlug(1L, skillSlug)).thenReturn(Optional.of(skill)); + when(visibilityChecker.canAccess(skill, userId, userNsRoles)).thenReturn(true); + when(skillVersionRepository.findById(11L)).thenReturn(Optional.of(published)); + when(promotionRequestRepository.findBySourceSkillIdAndStatus(1L, ReviewTaskStatus.PENDING)) + .thenReturn(Optional.of(mock(com.iflytek.skillhub.domain.review.PromotionRequest.class))); + + SkillQueryService.SkillDetailDTO result = service.getSkillDetail(namespaceSlug, skillSlug, userId, userNsRoles); + + assertFalse(result.canSubmitPromotion()); + } + + @Test + void testGetSkillDetail_ShouldHidePromotionWhenSkillAlreadyPromoted() throws Exception { + String namespaceSlug = "team-ns"; + String skillSlug = "team-skill"; + String userId = "owner-1"; + Map userNsRoles = Map.of(); + + Namespace namespace = new Namespace(namespaceSlug, "Team NS", userId); + setId(namespace, 1L); + Skill skill = new Skill(1L, skillSlug, userId, SkillVisibility.PUBLIC); + setId(skill, 1L); + skill.setStatus(SkillStatus.ACTIVE); + skill.setLatestVersionId(11L); + + SkillVersion published = new SkillVersion(1L, "1.0.0", userId); + setId(published, 11L); + published.setStatus(SkillVersionStatus.PUBLISHED); + + when(namespaceRepository.findBySlug(namespaceSlug)).thenReturn(Optional.of(namespace)); + when(skillRepository.findByNamespaceIdAndSlug(1L, skillSlug)).thenReturn(Optional.of(skill)); + when(visibilityChecker.canAccess(skill, userId, userNsRoles)).thenReturn(true); + when(skillVersionRepository.findById(11L)).thenReturn(Optional.of(published)); + when(promotionRequestRepository.findBySourceSkillIdAndStatus(1L, ReviewTaskStatus.PENDING)).thenReturn(Optional.empty()); + when(promotionRequestRepository.findBySourceSkillIdAndStatus(1L, ReviewTaskStatus.APPROVED)) + .thenReturn(Optional.of(mock(com.iflytek.skillhub.domain.review.PromotionRequest.class))); + + SkillQueryService.SkillDetailDTO result = service.getSkillDetail(namespaceSlug, skillSlug, userId, userNsRoles); + + assertFalse(result.canSubmitPromotion()); + } + @Test void testGetSkillDetail_ShouldNotFlagLifecyclePermissionForRegularViewer() throws Exception { String namespaceSlug = "test-ns"; @@ -464,6 +580,7 @@ class SkillQueryServiceTest { SkillQueryService.SkillDetailDTO result = service.getSkillDetail(namespaceSlug, skillSlug, userId, userNsRoles); assertFalse(result.canManageLifecycle()); + assertFalse(result.canSubmitPromotion()); } @Test diff --git a/server/skillhub-infra/src/main/java/com/iflytek/skillhub/infra/jpa/PromotionRequestJpaRepository.java b/server/skillhub-infra/src/main/java/com/iflytek/skillhub/infra/jpa/PromotionRequestJpaRepository.java index a7b91107..88cf99ea 100644 --- a/server/skillhub-infra/src/main/java/com/iflytek/skillhub/infra/jpa/PromotionRequestJpaRepository.java +++ b/server/skillhub-infra/src/main/java/com/iflytek/skillhub/infra/jpa/PromotionRequestJpaRepository.java @@ -18,9 +18,11 @@ public interface PromotionRequestJpaRepository extends JpaRepository findBySourceVersionIdAndStatus(Long sourceVersionId, ReviewTaskStatus status); + Optional findBySourceSkillIdAndStatus(Long sourceSkillId, ReviewTaskStatus status); + Page findByStatus(ReviewTaskStatus status, Pageable pageable); - @Modifying + @Modifying(clearAutomatically = true, flushAutomatically = true) @Query(""" UPDATE PromotionRequest p SET p.status = :status, diff --git a/server/skillhub-infra/src/main/java/com/iflytek/skillhub/infra/jpa/UserNotificationJpaRepository.java b/server/skillhub-infra/src/main/java/com/iflytek/skillhub/infra/jpa/UserNotificationJpaRepository.java new file mode 100644 index 00000000..a4e461f6 --- /dev/null +++ b/server/skillhub-infra/src/main/java/com/iflytek/skillhub/infra/jpa/UserNotificationJpaRepository.java @@ -0,0 +1,10 @@ +package com.iflytek.skillhub.infra.jpa; + +import com.iflytek.skillhub.domain.governance.UserNotification; +import com.iflytek.skillhub.domain.governance.UserNotificationRepository; +import java.util.List; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface UserNotificationJpaRepository extends JpaRepository, UserNotificationRepository { + List findByUserIdOrderByCreatedAtDesc(String userId); +} diff --git a/server/skillhub-storage/src/main/java/com/iflytek/skillhub/storage/LocalFileStorageService.java b/server/skillhub-storage/src/main/java/com/iflytek/skillhub/storage/LocalFileStorageService.java index fc03d788..bfa0ead2 100644 --- a/server/skillhub-storage/src/main/java/com/iflytek/skillhub/storage/LocalFileStorageService.java +++ b/server/skillhub-storage/src/main/java/com/iflytek/skillhub/storage/LocalFileStorageService.java @@ -64,6 +64,11 @@ public class LocalFileStorageService implements ObjectStorageService { } private Path resolve(String key) { + // Object keys use forward slashes; reject backslashes so traversal checks + // behave consistently across platforms. + if (key.contains("\\")) { + throw new IllegalArgumentException("Invalid storage key: " + key); + } Path resolved = basePath.resolve(key).normalize(); if (!resolved.startsWith(basePath)) { throw new IllegalArgumentException("Invalid storage key: " + key); diff --git a/web/package.json b/web/package.json index d6087dae..492b0662 100644 --- a/web/package.json +++ b/web/package.json @@ -8,6 +8,7 @@ "dev": "vite", "build": "tsc -b && vite build", "preview": "vite preview", + "test": "vitest run", "typecheck": "tsc --noEmit", "lint": "eslint . --ext ts,tsx --report-unused-disable-directives --max-warnings 0", "generate-api": "openapi-typescript http://localhost:8080/v3/api-docs -o src/api/generated/schema.d.ts" @@ -48,6 +49,7 @@ "postcss": "^8.4.0", "tailwindcss": "^3.4.0", "typescript": "^5.7.0", - "vite": "^6.1.0" + "vite": "^6.1.0", + "vitest": "^3.2.4" } } diff --git a/web/pnpm-lock.yaml b/web/pnpm-lock.yaml index 03f3f069..16dc3788 100644 --- a/web/pnpm-lock.yaml +++ b/web/pnpm-lock.yaml @@ -111,6 +111,9 @@ importers: vite: specifier: ^6.1.0 version: 6.4.1(jiti@1.21.7) + vitest: + specifier: ^3.2.4 + version: 3.2.4(@types/debug@4.1.12)(jiti@1.21.7) packages: @@ -896,9 +899,15 @@ packages: '@types/babel__traverse@7.28.0': resolution: {integrity: sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q==} + '@types/chai@5.2.3': + resolution: {integrity: sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==} + '@types/debug@4.1.12': resolution: {integrity: sha512-vIChWdVG3LG1SMxEvI/AK+FWJthlrqlTu7fbrlywTkkaONwk/UAGaULXRlf8vkzFBLVm0zkMdCquhL5aOjhXPQ==} + '@types/deep-eql@4.0.2': + resolution: {integrity: sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==} + '@types/estree-jsx@1.0.5': resolution: {integrity: sha512-52CcUVNFyfb1A2ALocQw/Dd1BQFNmSdkuC3BkZ6iqhdMfQz7JWOFRuJFloOzjk+6WijU56m9oKXFAXc7o3Towg==} @@ -995,6 +1004,35 @@ packages: peerDependencies: vite: ^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 + '@vitest/expect@3.2.4': + resolution: {integrity: sha512-Io0yyORnB6sikFlt8QW5K7slY4OjqNX9jmJQ02QDda8lyM6B5oNgVWoSoKPac8/kgnCUzuHQKrSLtu/uOqqrig==} + + '@vitest/mocker@3.2.4': + resolution: {integrity: sha512-46ryTE9RZO/rfDd7pEqFl7etuyzekzEhUbTW3BvmeO/BcCMEgq59BKhek3dXDWgAj4oMK6OZi+vRr1wPW6qjEQ==} + peerDependencies: + msw: ^2.4.9 + vite: ^5.0.0 || ^6.0.0 || ^7.0.0-0 + peerDependenciesMeta: + msw: + optional: true + vite: + optional: true + + '@vitest/pretty-format@3.2.4': + resolution: {integrity: sha512-IVNZik8IVRJRTr9fxlitMKeJeXFFFN0JaB9PHPGQ8NKQbGpfjlTx9zO4RefN8gp7eqjNy8nyK3NZmBzOPeIxtA==} + + '@vitest/runner@3.2.4': + resolution: {integrity: sha512-oukfKT9Mk41LreEW09vt45f8wx7DordoWUZMYdY/cyAk7w5TWkTRCNZYF7sX7n2wB7jyGAl74OxgwhPgKaqDMQ==} + + '@vitest/snapshot@3.2.4': + resolution: {integrity: sha512-dEYtS7qQP2CjU27QBC5oUOxLE/v5eLkGqPE0ZKEIDGMs4vKWe7IjgLOeauHsR0D5YuuycGRO5oSRXnwnmA78fQ==} + + '@vitest/spy@3.2.4': + resolution: {integrity: sha512-vAfasCOe6AIK70iP5UD11Ac4siNUNJ9i/9PZ3NKx07sG6sUxeag1LWdNrMWeKKYBLlzuK+Gn65Yd5nyL6ds+nw==} + + '@vitest/utils@3.2.4': + resolution: {integrity: sha512-fB2V0JFrQSMsCo9HiSq3Ezpdv4iYaXRG1Sx8edX3MwxfyNn83mKiGzOcH+Fkxt4MHxr3y42fQi1oeAInqgX2QA==} + acorn-jsx@5.3.2: resolution: {integrity: sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==} peerDependencies: @@ -1045,6 +1083,10 @@ packages: resolution: {integrity: sha512-HGyxoOTYUyCM6stUe6EJgnd4EoewAI7zMdfqO+kGjnlZmBDz/cR5pf8r/cR4Wq60sL/p0IkcjUEEPwS3GFrIyw==} engines: {node: '>=8'} + assertion-error@2.0.1: + resolution: {integrity: sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==} + engines: {node: '>=12'} + attr-accept@2.2.5: resolution: {integrity: sha512-0bDNnY/u6pPwHDMoF0FieU354oBi0a8rD9FcsLwzcGWbc8KS8KPIi7y+s13OlVY+gMWc/9xEMUgNE6Qm8ZllYQ==} engines: {node: '>=4'} @@ -1086,6 +1128,10 @@ packages: engines: {node: ^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7} hasBin: true + cac@6.7.14: + resolution: {integrity: sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==} + engines: {node: '>=8'} + callsites@3.1.0: resolution: {integrity: sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==} engines: {node: '>=6'} @@ -1100,6 +1146,10 @@ packages: ccount@2.0.1: resolution: {integrity: sha512-eyrF0jiFpY+3drT6383f1qhkbGsLSifNAjA61IUjZjmLCWjItY6LB9ft9YhoDgwfmclB2zhu51Lc7+95b8NRAg==} + chai@5.3.3: + resolution: {integrity: sha512-4zNhdJD/iOjSH0A05ea+Ke6MU5mmpQcbQsSOkgdaUMJ9zTlDTD/GYlwohmIE2u0gaxHYiVHEn1Fw9mZ/ktJWgw==} + engines: {node: '>=18'} + chalk@4.1.2: resolution: {integrity: sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==} engines: {node: '>=10'} @@ -1119,6 +1169,10 @@ packages: character-reference-invalid@2.0.1: resolution: {integrity: sha512-iBZ4F4wRbyORVsu0jPV7gXkOsGYjGHPmAyv+HiHG8gi5PtC9KI2j1+v8/tlibRvjoWX027ypmG/n0HtO5t7unw==} + check-error@2.1.3: + resolution: {integrity: sha512-PAJdDJusoxnwm1VwW07VWwUN1sl7smmC3OKggvndJFadxxDRyFJBX/ggnu/KE4kQAB7a3Dp8f/YXC1FlUprWmA==} + engines: {node: '>= 16'} + chokidar@3.6.0: resolution: {integrity: sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==} engines: {node: '>= 8.10.0'} @@ -1180,6 +1234,10 @@ packages: decode-named-character-reference@1.3.0: resolution: {integrity: sha512-GtpQYB283KrPp6nRw50q3U9/VfOutZOe103qlN7BPP6Ad27xYnOIWv4lPzo8HCAL+mMZofJ9KEy30fq6MfaK6Q==} + deep-eql@5.0.2: + resolution: {integrity: sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q==} + engines: {node: '>=6'} + deep-is@0.1.4: resolution: {integrity: sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==} @@ -1210,6 +1268,9 @@ packages: electron-to-chromium@1.5.307: resolution: {integrity: sha512-5z3uFKBWjiNR44nFcYdkcXjKMbg5KXNdciu7mhTPo9tB7NbqSNP2sSnGR+fqknZSCwKkBN+oxiiajWs4dT6ORg==} + es-module-lexer@1.7.0: + resolution: {integrity: sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==} + esbuild@0.25.12: resolution: {integrity: sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg==} engines: {node: '>=18'} @@ -1271,10 +1332,17 @@ packages: estree-util-is-identifier-name@3.0.0: resolution: {integrity: sha512-hFtqIDZTIUZ9BXLb8y4pYGyk6+wekIivNVTcmvk8NoOh+VeRn5y6cEHzbURrWbfp1fIqdVipilzj+lfaadNZmg==} + estree-walker@3.0.3: + resolution: {integrity: sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==} + esutils@2.0.3: resolution: {integrity: sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==} engines: {node: '>=0.10.0'} + expect-type@1.3.0: + resolution: {integrity: sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==} + engines: {node: '>=12.0.0'} + extend@3.0.2: resolution: {integrity: sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==} @@ -1503,6 +1571,9 @@ packages: js-tokens@4.0.0: resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==} + js-tokens@9.0.1: + resolution: {integrity: sha512-mxa9E9ITFOt0ban3j6L5MpjwegGz6lBQmM1IJkWeBZGcMxto50+eWdjC/52xDbS2vy0k7vIMK0Fe2wfL9OQSpQ==} + js-yaml@4.1.1: resolution: {integrity: sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==} hasBin: true @@ -1557,6 +1628,9 @@ packages: resolution: {integrity: sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==} hasBin: true + loupe@3.2.1: + resolution: {integrity: sha512-CdzqowRJCeLU72bHvWqwRBBlLcMEtIvGrlvef74kMnV2AolS9Y8xUv1I0U/MNAWMhBlKIoyuEgoJ0t/bbwHbLQ==} + lowlight@3.3.0: resolution: {integrity: sha512-0JNhgFoPvP6U6lE/UdVsSq99tn6DhjjpAj5MxG49ewd2mOBVtwWYIT8ClyABhq198aXXODMU6Ox8DrGy/CpTZQ==} @@ -1568,6 +1642,9 @@ packages: peerDependencies: react: ^16.5.1 || ^17.0.0 || ^18.0.0 + magic-string@0.30.21: + resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==} + markdown-table@3.0.4: resolution: {integrity: sha512-wiYz4+JrLyb/DqW2hkFJxP7Vd7JuTDm77fvbM8VfEQdmSMqcImWeeRbHwZjBjIFki/VaMK2BhFi7oUUZeM5bqw==} @@ -1805,6 +1882,13 @@ packages: resolution: {integrity: sha512-gDKb8aZMDeD/tZWs9P6+q0J9Mwkdl6xMV8TjnGP3qJVJ06bdMgkbBlLU8IdfOsIsFz2BW1rNVT3XuNEl8zPAvw==} engines: {node: '>=8'} + pathe@2.0.3: + resolution: {integrity: sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==} + + pathval@2.0.1: + resolution: {integrity: sha512-//nshmD55c46FuFw26xV/xFAaB5HF9Xdap7HJBBnrKdAd6/GxDBaNA1870O79+9ueg61cZLSVc+OaFlfmObYVQ==} + engines: {node: '>= 14.16'} + picocolors@1.1.1: resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==} @@ -2051,6 +2135,9 @@ packages: resolution: {integrity: sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==} engines: {node: '>=8'} + siginfo@2.0.0: + resolution: {integrity: sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==} + slash@3.0.0: resolution: {integrity: sha512-g9Q1haeby36OSStwb4ntCGGGaKsaVSjQ68fBxoQcutl5fS1vuY18H3wSt3jFyFtrkx+Kz0V1G85A4MyAdDMi2Q==} engines: {node: '>=8'} @@ -2068,6 +2155,12 @@ packages: space-separated-tokens@2.0.2: resolution: {integrity: sha512-PEGlAwrG8yXGXRjW32fGbg66JAlOAwbObuqVoJpv/mRgoWDQfgH1wDPvtzWyUSNAXBGSk8h755YDbbcEy3SH2Q==} + stackback@0.0.2: + resolution: {integrity: sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==} + + std-env@3.10.0: + resolution: {integrity: sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==} + stringify-entities@4.0.4: resolution: {integrity: sha512-IwfBptatlO+QCJUo19AqvrPNqlVMpW9YEL2LIVY+Rpv2qsjCGxaDLNRgeGsQWJhfItebuJhsGSLjaBbNSQ+ieg==} @@ -2079,6 +2172,9 @@ packages: resolution: {integrity: sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==} engines: {node: '>=8'} + strip-literal@3.1.0: + resolution: {integrity: sha512-8r3mkIM/2+PpjHoOtiAW8Rg3jJLHaV7xPwG+YRGrv6FP0wwk/toTpATxWYOW0BKdWwl82VT2tFYi5DlROa0Mxg==} + style-to-js@1.1.21: resolution: {integrity: sha512-RjQetxJrrUJLQPHbLku6U/ocGtzyjbJMP9lCNK7Ag0CNh690nSH8woqWH9u16nMjYBAok+i7JO1NP2pOy8IsPQ==} @@ -2126,10 +2222,28 @@ packages: tiny-warning@1.0.3: resolution: {integrity: sha512-lBN9zLN/oAf68o3zNXYrdCt1kP8WsiGW8Oo2ka41b2IM5JL/S1CTyX1rW0mb/zSuJun0ZUrDxx4sqvYS2FWzPA==} + tinybench@2.9.0: + resolution: {integrity: sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==} + + tinyexec@0.3.2: + resolution: {integrity: sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA==} + tinyglobby@0.2.15: resolution: {integrity: sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==} engines: {node: '>=12.0.0'} + tinypool@1.1.1: + resolution: {integrity: sha512-Zba82s87IFq9A9XmjiX5uZA/ARWDrB03OHlq+Vw1fSdt0I+4/Kutwy8BP4Y/y/aORMo61FQ0vIb5j44vSo5Pkg==} + engines: {node: ^18.0.0 || >=20.0.0} + + tinyrainbow@2.0.0: + resolution: {integrity: sha512-op4nsTR47R6p0vMUUoYl/a+ljLFVtlfaXkLQmqfLR1qHma1h/ysYk4hEXZ880bf2CYgTskvTa/e196Vd5dDQXw==} + engines: {node: '>=14.0.0'} + + tinyspy@4.0.4: + resolution: {integrity: sha512-azl+t0z7pw/z958Gy9svOTuzqIk6xq+NSheJzn5MMWtWTFywIacg2wUlzKFGtt3cthx0r2SxMK0yzJOR0IES7Q==} + engines: {node: '>=14.0.0'} + to-regex-range@5.0.1: resolution: {integrity: sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==} engines: {node: '>=8.0'} @@ -2236,6 +2350,11 @@ packages: vfile@6.0.3: resolution: {integrity: sha512-KzIbH/9tXat2u30jf+smMwFCsno4wHVdNmzFyL+T/L3UGqqk6JKfVqOFOZEpZSHADH1k40ab6NUIXZq422ov3Q==} + vite-node@3.2.4: + resolution: {integrity: sha512-EbKSKh+bh1E1IFxeO0pg1n4dvoOTt0UDiXMd/qn++r98+jPO1xtJilvXldeuQ8giIB5IkpjCgMleHMNEsGH6pg==} + engines: {node: ^18.0.0 || ^20.0.0 || >=22.0.0} + hasBin: true + vite@6.4.1: resolution: {integrity: sha512-+Oxm7q9hDoLMyJOYfUYBuHQo+dkAloi33apOPP56pzj+vsdJDzr+j1NISE5pyaAuKL4A3UD34qd0lx5+kfKp2g==} engines: {node: ^18.0.0 || ^20.0.0 || >=22.0.0} @@ -2276,6 +2395,34 @@ packages: yaml: optional: true + vitest@3.2.4: + resolution: {integrity: sha512-LUCP5ev3GURDysTWiP47wRRUpLKMOfPh+yKTx3kVIEiu5KOMeqzpnYNsKyOoVrULivR8tLcks4+lga33Whn90A==} + engines: {node: ^18.0.0 || ^20.0.0 || >=22.0.0} + hasBin: true + peerDependencies: + '@edge-runtime/vm': '*' + '@types/debug': ^4.1.12 + '@types/node': ^18.0.0 || ^20.0.0 || >=22.0.0 + '@vitest/browser': 3.2.4 + '@vitest/ui': 3.2.4 + happy-dom: '*' + jsdom: '*' + peerDependenciesMeta: + '@edge-runtime/vm': + optional: true + '@types/debug': + optional: true + '@types/node': + optional: true + '@vitest/browser': + optional: true + '@vitest/ui': + optional: true + happy-dom: + optional: true + jsdom: + optional: true + void-elements@3.1.0: resolution: {integrity: sha512-Dhxzh5HZuiHQhbvTW9AMetFfBHDMYpo23Uo9btPXgdYP+3T5S+p+jgNy7spra+veYhBP2dCSgxR/i2Y02h5/6w==} engines: {node: '>=0.10.0'} @@ -2285,6 +2432,11 @@ packages: engines: {node: '>= 8'} hasBin: true + why-is-node-running@2.3.0: + resolution: {integrity: sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==} + engines: {node: '>=8'} + hasBin: true + word-wrap@1.2.5: resolution: {integrity: sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==} engines: {node: '>=0.10.0'} @@ -3006,10 +3158,17 @@ snapshots: dependencies: '@babel/types': 7.29.0 + '@types/chai@5.2.3': + dependencies: + '@types/deep-eql': 4.0.2 + assertion-error: 2.0.1 + '@types/debug@4.1.12': dependencies: '@types/ms': 2.1.0 + '@types/deep-eql@4.0.2': {} + '@types/estree-jsx@1.0.5': dependencies: '@types/estree': 1.0.8 @@ -3133,6 +3292,48 @@ snapshots: transitivePeerDependencies: - supports-color + '@vitest/expect@3.2.4': + dependencies: + '@types/chai': 5.2.3 + '@vitest/spy': 3.2.4 + '@vitest/utils': 3.2.4 + chai: 5.3.3 + tinyrainbow: 2.0.0 + + '@vitest/mocker@3.2.4(vite@6.4.1(jiti@1.21.7))': + dependencies: + '@vitest/spy': 3.2.4 + estree-walker: 3.0.3 + magic-string: 0.30.21 + optionalDependencies: + vite: 6.4.1(jiti@1.21.7) + + '@vitest/pretty-format@3.2.4': + dependencies: + tinyrainbow: 2.0.0 + + '@vitest/runner@3.2.4': + dependencies: + '@vitest/utils': 3.2.4 + pathe: 2.0.3 + strip-literal: 3.1.0 + + '@vitest/snapshot@3.2.4': + dependencies: + '@vitest/pretty-format': 3.2.4 + magic-string: 0.30.21 + pathe: 2.0.3 + + '@vitest/spy@3.2.4': + dependencies: + tinyspy: 4.0.4 + + '@vitest/utils@3.2.4': + dependencies: + '@vitest/pretty-format': 3.2.4 + loupe: 3.2.1 + tinyrainbow: 2.0.0 + acorn-jsx@5.3.2(acorn@8.16.0): dependencies: acorn: 8.16.0 @@ -3173,6 +3374,8 @@ snapshots: array-union@2.1.0: {} + assertion-error@2.0.1: {} + attr-accept@2.2.5: {} autoprefixer@10.4.27(postcss@8.5.8): @@ -3213,6 +3416,8 @@ snapshots: node-releases: 2.0.36 update-browserslist-db: 1.2.3(browserslist@4.28.1) + cac@6.7.14: {} + callsites@3.1.0: {} camelcase-css@2.0.1: {} @@ -3221,6 +3426,14 @@ snapshots: ccount@2.0.1: {} + chai@5.3.3: + dependencies: + assertion-error: 2.0.1 + check-error: 2.1.3 + deep-eql: 5.0.2 + loupe: 3.2.1 + pathval: 2.0.1 + chalk@4.1.2: dependencies: ansi-styles: 4.3.0 @@ -3236,6 +3449,8 @@ snapshots: character-reference-invalid@2.0.1: {} + check-error@2.1.3: {} + chokidar@3.6.0: dependencies: anymatch: 3.1.3 @@ -3292,6 +3507,8 @@ snapshots: dependencies: character-entities: 2.0.2 + deep-eql@5.0.2: {} + deep-is@0.1.4: {} dequal@2.0.3: {} @@ -3316,6 +3533,8 @@ snapshots: electron-to-chromium@1.5.307: {} + es-module-lexer@1.7.0: {} + esbuild@0.25.12: optionalDependencies: '@esbuild/aix-ppc64': 0.25.12 @@ -3427,8 +3646,14 @@ snapshots: estree-util-is-identifier-name@3.0.0: {} + estree-walker@3.0.3: + dependencies: + '@types/estree': 1.0.8 + esutils@2.0.3: {} + expect-type@1.3.0: {} + extend@3.0.2: {} fast-deep-equal@3.1.3: {} @@ -3656,6 +3881,8 @@ snapshots: js-tokens@4.0.0: {} + js-tokens@9.0.1: {} + js-yaml@4.1.1: dependencies: argparse: 2.0.1 @@ -3697,6 +3924,8 @@ snapshots: dependencies: js-tokens: 4.0.0 + loupe@3.2.1: {} + lowlight@3.3.0: dependencies: '@types/hast': 3.0.4 @@ -3711,6 +3940,10 @@ snapshots: dependencies: react: 19.2.4 + magic-string@0.30.21: + dependencies: + '@jridgewell/sourcemap-codec': 1.5.5 + markdown-table@3.0.4: {} mdast-util-find-and-replace@3.0.2: @@ -4163,6 +4396,10 @@ snapshots: path-type@4.0.0: {} + pathe@2.0.3: {} + + pathval@2.0.1: {} + picocolors@1.1.1: {} picomatch@2.3.1: {} @@ -4424,6 +4661,8 @@ snapshots: shebang-regex@3.0.0: {} + siginfo@2.0.0: {} + slash@3.0.0: {} sonner@2.0.7(react-dom@19.2.4(react@19.2.4))(react@19.2.4): @@ -4435,6 +4674,10 @@ snapshots: space-separated-tokens@2.0.2: {} + stackback@0.0.2: {} + + std-env@3.10.0: {} + stringify-entities@4.0.4: dependencies: character-entities-html4: 2.1.0 @@ -4446,6 +4689,10 @@ snapshots: strip-json-comments@3.1.1: {} + strip-literal@3.1.0: + dependencies: + js-tokens: 9.0.1 + style-to-js@1.1.21: dependencies: style-to-object: 1.0.14 @@ -4516,11 +4763,21 @@ snapshots: tiny-warning@1.0.3: {} + tinybench@2.9.0: {} + + tinyexec@0.3.2: {} + tinyglobby@0.2.15: dependencies: fdir: 6.5.0(picomatch@4.0.3) picomatch: 4.0.3 + tinypool@1.1.1: {} + + tinyrainbow@2.0.0: {} + + tinyspy@4.0.4: {} + to-regex-range@5.0.1: dependencies: is-number: 7.0.0 @@ -4628,6 +4885,27 @@ snapshots: '@types/unist': 3.0.3 vfile-message: 4.0.3 + vite-node@3.2.4(jiti@1.21.7): + dependencies: + cac: 6.7.14 + debug: 4.4.3(supports-color@10.2.2) + es-module-lexer: 1.7.0 + pathe: 2.0.3 + vite: 6.4.1(jiti@1.21.7) + transitivePeerDependencies: + - '@types/node' + - jiti + - less + - lightningcss + - sass + - sass-embedded + - stylus + - sugarss + - supports-color + - terser + - tsx + - yaml + vite@6.4.1(jiti@1.21.7): dependencies: esbuild: 0.25.12 @@ -4640,12 +4918,58 @@ snapshots: fsevents: 2.3.3 jiti: 1.21.7 + vitest@3.2.4(@types/debug@4.1.12)(jiti@1.21.7): + dependencies: + '@types/chai': 5.2.3 + '@vitest/expect': 3.2.4 + '@vitest/mocker': 3.2.4(vite@6.4.1(jiti@1.21.7)) + '@vitest/pretty-format': 3.2.4 + '@vitest/runner': 3.2.4 + '@vitest/snapshot': 3.2.4 + '@vitest/spy': 3.2.4 + '@vitest/utils': 3.2.4 + chai: 5.3.3 + debug: 4.4.3(supports-color@10.2.2) + expect-type: 1.3.0 + magic-string: 0.30.21 + pathe: 2.0.3 + picomatch: 4.0.3 + std-env: 3.10.0 + tinybench: 2.9.0 + tinyexec: 0.3.2 + tinyglobby: 0.2.15 + tinypool: 1.1.1 + tinyrainbow: 2.0.0 + vite: 6.4.1(jiti@1.21.7) + vite-node: 3.2.4(jiti@1.21.7) + why-is-node-running: 2.3.0 + optionalDependencies: + '@types/debug': 4.1.12 + transitivePeerDependencies: + - jiti + - less + - lightningcss + - msw + - sass + - sass-embedded + - stylus + - sugarss + - supports-color + - terser + - tsx + - yaml + void-elements@3.1.0: {} which@2.0.2: dependencies: isexe: 2.0.0 + why-is-node-running@2.3.0: + dependencies: + siginfo: 2.0.0 + stackback: 0.0.2 + word-wrap@1.2.5: {} wrappy@1.0.2: {} diff --git a/web/src/api/client.ts b/web/src/api/client.ts index 06d8db58..bb0da6d9 100644 --- a/web/src/api/client.ts +++ b/web/src/api/client.ts @@ -16,9 +16,19 @@ import type { AuditLogItem, SkillSummary, SkillReport, + GovernanceSummary, + GovernanceInboxItem, + GovernanceActivityItem, + GovernanceNotification, + ReportDisposition, AuthMethod, OAuthProvider, User, + ManagedNamespace, + Namespace, + CreateNamespaceRequest, + NamespaceMember, + NamespaceCandidateUser, } from './types' import { ApiError } from '@/shared/lib/api-error' import i18n from '@/i18n/config' @@ -27,6 +37,11 @@ export { ApiError } export const WEB_API_PREFIX = '/api/web' +export type DownloadedFile = { + blob: Blob + fileName?: string +} + type RuntimeConfig = { apiBaseUrl?: string appBaseUrl?: string @@ -110,17 +125,17 @@ async function unwrap(promise: Promise<{ data?: T; error?: unknown; response: const envelope = isApiEnvelope(data) ? data : isApiEnvelope(error) ? error : null if (!response.ok) { - throw new ApiError(envelope?.msg || `HTTP ${response.status}`, response.status, envelope?.msg) + throw new ApiError(envelope?.msg || `HTTP ${response.status}`, response.status, envelope?.msg, envelope?.msg) } if (error) { - throw new ApiError(envelope?.msg || `HTTP ${response.status}`, response.status, envelope?.msg) + throw new ApiError(envelope?.msg || `HTTP ${response.status}`, response.status, envelope?.msg, envelope?.msg) } if (data === undefined) { throw new ApiError(`HTTP ${response.status}`, response.status) } if (isApiEnvelope(data)) { if (data.code !== 0) { - throw new ApiError(data.msg || `HTTP ${response.status}`, response.status, data.msg) + throw new ApiError(data.msg || `HTTP ${response.status}`, response.status, data.msg, data.msg) } return data.data } @@ -234,7 +249,7 @@ export async function fetchJson(input: RequestInfo | URL, init?: RequestWithT } if (!response.ok || json.code !== 0) { - throw new ApiError(json.msg || `HTTP ${response.status}`, response.status, json.msg) + throw new ApiError(json.msg || `HTTP ${response.status}`, response.status, json.msg, json.msg) } return json.data @@ -263,6 +278,20 @@ function ensureTrailingSlash(value: string): string { return value.endsWith('/') ? value : `${value}/` } +function parseDownloadFileName(contentDisposition: string | null): string | undefined { + if (!contentDisposition) { + return undefined + } + + const utf8Match = contentDisposition.match(/filename\*=UTF-8''([^;]+)/i) + if (utf8Match) { + return decodeURIComponent(utf8Match[1]) + } + + const basicMatch = contentDisposition.match(/filename="?([^";]+)"?/i) + return basicMatch?.[1] +} + export async function getCurrentUser(): Promise { try { const user = await unwrap(client.GET('/api/v1/auth/me', { @@ -412,6 +441,27 @@ export const accountApi = { }, } +export const skillDownloadApi = { + async downloadVersion(namespace: string, slug: string, version: string): Promise { + const cleanNamespace = namespace.startsWith('@') ? namespace.slice(1) : namespace + const response = await fetch( + withBaseUrl(`${WEB_API_PREFIX}/skills/${cleanNamespace}/${slug}/versions/${version}/download`), + { + headers: withRequestHeaders(), + }, + ) + + if (!response.ok) { + throw new ApiError(`HTTP ${response.status}`, response.status) + } + + return { + blob: await response.blob(), + fileName: parseDownloadFileName(response.headers.get('content-disposition')), + } + }, +} + export const skillLifecycleApi = { async archiveSkill(namespace: string, slug: string, reason?: string): Promise { const cleanNamespace = namespace.startsWith('@') ? namespace.slice(1) : namespace @@ -460,6 +510,113 @@ export const skillLifecycleApi = { }, } +function normalizeNamespaceSlug(namespace: string): string { + return namespace.startsWith('@') ? namespace.slice(1) : namespace +} + +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`) + }, + + 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(), + }) + }, + + async listMembers(slug: string): Promise { + const page = await fetchJson<{ items: NamespaceMember[] }>(`${WEB_API_PREFIX}/namespaces/${normalizeNamespaceSlug(slug)}/members`) + return page.items + }, + + async searchMemberCandidates(slug: string, search: string, size = 10): Promise { + const query = new URLSearchParams({ + search: search.trim(), + size: String(size), + }) + return fetchJson( + `${WEB_API_PREFIX}/namespaces/${normalizeNamespaceSlug(slug)}/member-candidates?${query.toString()}`, + ) + }, + + async addMember(slug: string, request: { userId: string; role: string }): Promise { + return fetchJson(`${WEB_API_PREFIX}/namespaces/${normalizeNamespaceSlug(slug)}/members`, { + method: 'POST', + headers: await ensureCsrfHeaders({ + 'Content-Type': 'application/json', + }), + body: JSON.stringify({ + userId: request.userId.trim(), + role: request.role, + }), + }) + }, + + async updateMemberRole(slug: string, userId: string, role: string): Promise { + return fetchJson( + `${WEB_API_PREFIX}/namespaces/${normalizeNamespaceSlug(slug)}/members/${encodeURIComponent(userId)}/role`, + { + method: 'PUT', + headers: await ensureCsrfHeaders({ + 'Content-Type': 'application/json', + }), + body: JSON.stringify({ role }), + }, + ) + }, + + async removeMember(slug: string, userId: string): Promise { + await fetchJson(`${WEB_API_PREFIX}/namespaces/${normalizeNamespaceSlug(slug)}/members/${encodeURIComponent(userId)}`, { + method: 'DELETE', + 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', { @@ -531,7 +688,7 @@ export const tokenApi = { const envelope = (error && isApiEnvelope(error) ? error : null) as { msg?: string } | null if (!response.ok || error) { - throw new ApiError(envelope?.msg || `HTTP ${response.status}`, response.status, envelope?.msg) + throw new ApiError(envelope?.msg || `HTTP ${response.status}`, response.status, envelope?.msg, envelope?.msg) } }, } @@ -576,6 +733,16 @@ export const reviewApi = { } export const promotionApi = { + async submit(request: { sourceSkillId: number; sourceVersionId: number; targetNamespaceId: number }): Promise { + await fetchJson(`${WEB_API_PREFIX}/promotions`, { + method: 'POST', + headers: getCsrfHeaders({ + 'Content-Type': 'application/json', + }), + body: JSON.stringify(request), + }) + }, + async list(params: { status?: string; page?: number; size?: number }) { const searchParams = new URLSearchParams() searchParams.set('status', params.status ?? 'PENDING') @@ -633,13 +800,13 @@ export const reportApi = { ) }, - async resolveSkillReport(id: number, comment?: string): Promise { + async resolveSkillReport(id: number, comment?: string, disposition: ReportDisposition = 'RESOLVE_ONLY'): Promise { await fetchJson(`/api/v1/admin/skill-reports/${id}/resolve`, { method: 'POST', headers: getCsrfHeaders({ 'Content-Type': 'application/json', }), - body: JSON.stringify({ comment }), + body: JSON.stringify({ comment, disposition }), }) }, @@ -654,6 +821,42 @@ export const reportApi = { }, } +export const governanceApi = { + async getSummary(): Promise { + return fetchJson(`${WEB_API_PREFIX}/governance/summary`) + }, + + async getInbox(params: { type?: string; page?: number; size?: number }) { + const searchParams = new URLSearchParams() + if (params.type) searchParams.set('type', params.type) + searchParams.set('page', String(params.page ?? 0)) + searchParams.set('size', String(params.size ?? 20)) + return fetchJson<{ items: GovernanceInboxItem[]; total: number; page: number; size: number }>( + `${WEB_API_PREFIX}/governance/inbox?${searchParams.toString()}`, + ) + }, + + async getActivity(params: { page?: number; size?: number }) { + const searchParams = new URLSearchParams() + searchParams.set('page', String(params.page ?? 0)) + searchParams.set('size', String(params.size ?? 20)) + return fetchJson<{ items: GovernanceActivityItem[]; total: number; page: number; size: number }>( + `${WEB_API_PREFIX}/governance/activity?${searchParams.toString()}`, + ) + }, + + async getNotifications(): Promise { + return fetchJson(`${WEB_API_PREFIX}/governance/notifications`) + }, + + async markNotificationRead(id: number): Promise { + return fetchJson(`${WEB_API_PREFIX}/governance/notifications/${id}/read`, { + method: 'POST', + headers: getCsrfHeaders(), + }) + }, +} + export const meApi = { async getStars(): Promise { return fetchJson(`${WEB_API_PREFIX}/me/stars`) diff --git a/web/src/api/types.ts b/web/src/api/types.ts index 8c59a289..954bc531 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 } @@ -82,6 +88,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,18 +98,35 @@ 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 } +export interface NamespaceCandidateUser { + userId: string + displayName: string + email?: string + status: string +} + // Skill types export interface SkillSummary { id: number @@ -113,9 +139,11 @@ export interface SkillSummary { ratingAvg?: number ratingCount: number latestVersion?: string + latestVersionId?: number latestVersionStatus?: string namespace: string updatedAt: string + canSubmitPromotion: boolean } export interface SkillDetail { @@ -131,12 +159,20 @@ export interface SkillDetail { ratingCount: number hidden: boolean latestVersion?: string + latestVersionId?: number namespace: string canManageLifecycle: boolean + canSubmitPromotion: boolean viewingVersionStatus?: string canInteract: boolean } +export interface SubmitPromotionRequest { + sourceSkillId: number + sourceVersionId: number + targetNamespaceId: number +} + export interface SkillVersion { id: number version: string @@ -252,6 +288,47 @@ export interface SkillReport { handledAt?: string } +export type ReportDisposition = 'RESOLVE_ONLY' | 'RESOLVE_AND_HIDE' | 'RESOLVE_AND_ARCHIVE' + +export interface GovernanceSummary { + pendingReviews: number + pendingPromotions: number + pendingReports: number +} + +export interface GovernanceInboxItem { + type: 'REVIEW' | 'PROMOTION' | 'REPORT' | string + id: number + title: string + subtitle?: string + timestamp?: string + namespace?: string + skillSlug?: string +} + +export interface GovernanceActivityItem { + id: number + action: string + actorUserId?: string + actorDisplayName?: string + targetType?: string + targetId?: string + details?: string + timestamp?: string +} + +export interface GovernanceNotification { + id?: number + category: string + entityType: string + entityId: number + title: string + bodyJson?: string + status: 'UNREAD' | 'READ' | string + createdAt?: string + readAt?: string +} + export interface AdminUser { userId: string username: string diff --git a/web/src/app/router.tsx b/web/src/app/router.tsx index b6a80f85..c19c5088 100644 --- a/web/src/app/router.tsx +++ b/web/src/app/router.tsx @@ -2,6 +2,7 @@ import { lazy, Suspense, type ComponentType } from 'react' import { createRouter, createRoute, createRootRoute, redirect } from '@tanstack/react-router' import { Layout } from './layout' import { getCurrentUser } from '@/api/client' +import { normalizeSearchQuery } from '@/shared/lib/search-query' // Capture original URL before TanStack Router rewrites it const ORIGINAL_URL_SEARCH = typeof window !== 'undefined' ? window.location.search : '' @@ -56,6 +57,7 @@ const NamespaceReviewsPage = createLazyRouteComponent( () => import('@/pages/dashboard/namespace-reviews'), 'NamespaceReviewsPage', ) +const GovernancePage = createLazyRouteComponent(() => import('@/pages/dashboard/governance'), 'GovernancePage') const ReviewsPage = createLazyRouteComponent(() => import('@/pages/dashboard/reviews'), 'ReviewsPage') const ReportsPage = createLazyRouteComponent(() => import('@/pages/dashboard/reports'), 'ReportsPage') const ReviewDetailPage = createLazyRouteComponent( @@ -144,7 +146,7 @@ const searchRoute = createRoute({ component: SearchPage, validateSearch: (search: Record) => { return { - q: (search.q as string) || '', + q: normalizeSearchQuery(typeof search.q === 'string' ? search.q : ''), sort: (search.sort as string) || 'newest', page: Number(search.page) || 0, starredOnly: search.starredOnly === true || search.starredOnly === 'true', @@ -212,6 +214,13 @@ const dashboardNamespaceReviewsRoute = createRoute({ component: NamespaceReviewsPage, }) +const dashboardGovernanceRoute = createRoute({ + getParentRoute: () => rootRoute, + path: 'dashboard/governance', + beforeLoad: requireAuth, + component: GovernancePage, +}) + const dashboardReviewsRoute = createRoute({ getParentRoute: () => rootRoute, path: 'dashboard/reviews', @@ -336,6 +345,7 @@ const routeTree = rootRoute.addChildren([ dashboardNamespacesRoute, dashboardNamespaceMembersRoute, dashboardNamespaceReviewsRoute, + dashboardGovernanceRoute, dashboardReviewsRoute, dashboardReportsRoute, dashboardReviewDetailRoute, diff --git a/web/src/features/auth/use-auth.test.ts b/web/src/features/auth/use-auth.test.ts new file mode 100644 index 00000000..49bb858a --- /dev/null +++ b/web/src/features/auth/use-auth.test.ts @@ -0,0 +1,15 @@ +import { describe, expect, it } from 'vitest' +import { getAuthQueryOptions } from './use-auth' + +describe('getAuthQueryOptions', () => { + it('keeps auth state fresh so role changes are picked up promptly', () => { + const options = getAuthQueryOptions(true) + + expect(options.queryKey).toEqual(['auth', 'me']) + expect(options.staleTime).toBe(0) + expect(options.refetchOnWindowFocus).toBe(true) + expect(options.refetchOnReconnect).toBe(true) + expect(options.refetchInterval).toBe(60_000) + expect(options.enabled).toBe(true) + }) +}) diff --git a/web/src/features/auth/use-auth.ts b/web/src/features/auth/use-auth.ts index 54fbf84f..4a9d66d5 100644 --- a/web/src/features/auth/use-auth.ts +++ b/web/src/features/auth/use-auth.ts @@ -2,14 +2,21 @@ import { useQuery } from '@tanstack/react-query' import { authApi } from '@/api/client' import type { User } from '@/api/types' -export function useAuth(enabled = true) { - const { data: user, isLoading, error } = useQuery({ - queryKey: ['auth', 'me'], +export function getAuthQueryOptions(enabled = true) { + return { + queryKey: ['auth', 'me'] as const, queryFn: authApi.getMe, retry: false, enabled, - staleTime: 5 * 60 * 1000, // 5 分钟 - }) + staleTime: 0, + refetchOnWindowFocus: true, + refetchOnReconnect: true, + refetchInterval: 60_000, + } +} + +export function useAuth(enabled = true) { + const { data: user, isLoading, error } = useQuery(getAuthQueryOptions(enabled)) return { user: user ?? null, diff --git a/web/src/features/governance/governance-activity.tsx b/web/src/features/governance/governance-activity.tsx new file mode 100644 index 00000000..271773eb --- /dev/null +++ b/web/src/features/governance/governance-activity.tsx @@ -0,0 +1,39 @@ +import { useTranslation } from 'react-i18next' +import type { GovernanceActivityItem } from '@/api/types' +import { Card } from '@/shared/ui/card' + +interface GovernanceActivityProps { + items?: GovernanceActivityItem[] + isLoading: boolean +} + +export function GovernanceActivity({ items, isLoading }: GovernanceActivityProps) { + const { t, i18n } = useTranslation() + + if (isLoading) { + return
+ } + + if (!items || items.length === 0) { + return {t('governance.emptyActivity')} + } + + return ( +
+ {items.map((item) => ( + +
+
{item.action}
+
+ {item.timestamp ? new Date(item.timestamp).toLocaleString(i18n.language) : '-'} +
+
+
+ {item.actorDisplayName || item.actorUserId || t('governance.unknownActor')} +
+ {item.details ?
{item.details}
: null} +
+ ))} +
+ ) +} diff --git a/web/src/features/governance/governance-inbox.tsx b/web/src/features/governance/governance-inbox.tsx new file mode 100644 index 00000000..97e26d0b --- /dev/null +++ b/web/src/features/governance/governance-inbox.tsx @@ -0,0 +1,69 @@ +import { useNavigate } from '@tanstack/react-router' +import { useTranslation } from 'react-i18next' +import type { GovernanceInboxItem } from '@/api/types' +import { Card } from '@/shared/ui/card' +import { Button } from '@/shared/ui/button' + +interface GovernanceInboxProps { + items?: GovernanceInboxItem[] + isLoading: boolean +} + +export function GovernanceInbox({ items, isLoading }: GovernanceInboxProps) { + const { t, i18n } = useTranslation() + const navigate = useNavigate() + + if (isLoading) { + return
+ } + + if (!items || items.length === 0) { + return {t('governance.emptyInbox')} + } + + const openItem = (item: GovernanceInboxItem) => { + if (item.type === 'REVIEW') { + navigate({ to: `/dashboard/reviews/${item.id}` }) + return + } + if (item.type === 'PROMOTION') { + navigate({ to: '/dashboard/promotions' }) + return + } + if (item.type === 'REPORT') { + navigate({ to: '/dashboard/reports' }) + return + } + if (item.namespace && item.skillSlug) { + navigate({ to: `/space/${item.namespace}/${item.skillSlug}` }) + } + } + + return ( +
+ {items.map((item) => ( + +
+
+
+ + {item.type} + +
{item.title}
+
+ {item.subtitle ?
{item.subtitle}
: null} +
+
+ {item.timestamp ? new Date(item.timestamp).toLocaleString(i18n.language) : '-'} +
+
+
+ +
+
+ ))} +
+ ) +} diff --git a/web/src/features/governance/governance-notifications.tsx b/web/src/features/governance/governance-notifications.tsx new file mode 100644 index 00000000..d4db6af6 --- /dev/null +++ b/web/src/features/governance/governance-notifications.tsx @@ -0,0 +1,51 @@ +import { useTranslation } from 'react-i18next' +import type { GovernanceNotification } from '@/api/types' +import { Card } from '@/shared/ui/card' +import { Button } from '@/shared/ui/button' + +interface GovernanceNotificationsProps { + items?: GovernanceNotification[] + isLoading: boolean + onMarkRead: (id: number) => void + isMarkingRead: boolean +} + +export function GovernanceNotifications({ items, isLoading, onMarkRead, isMarkingRead }: GovernanceNotificationsProps) { + const { t, i18n } = useTranslation() + + if (isLoading) { + return
+ } + + if (!items || items.length === 0) { + return {t('governance.emptyNotifications')} + } + + return ( +
+ {items.map((item) => ( + +
+
{item.title}
+ + {item.status} + +
+ {item.createdAt ? ( +
+ {new Date(item.createdAt).toLocaleString(i18n.language)} +
+ ) : null} + {item.bodyJson ?
{item.bodyJson}
: null} + {item.status === 'UNREAD' && item.id ? ( +
+ +
+ ) : null} +
+ ))} +
+ ) +} diff --git a/web/src/features/governance/use-governance.ts b/web/src/features/governance/use-governance.ts new file mode 100644 index 00000000..f55a9e3c --- /dev/null +++ b/web/src/features/governance/use-governance.ts @@ -0,0 +1,46 @@ +import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query' +import { governanceApi } from '@/api/client' + +export function useGovernanceSummary() { + return useQuery({ + queryKey: ['governance', 'summary'], + queryFn: () => governanceApi.getSummary(), + }) +} + +export function useGovernanceInbox(type?: string) { + return useQuery({ + queryKey: ['governance', 'inbox', type ?? 'ALL'], + queryFn: async () => { + const page = await governanceApi.getInbox({ type }) + return page.items + }, + }) +} + +export function useGovernanceActivity() { + return useQuery({ + queryKey: ['governance', 'activity'], + queryFn: async () => { + const page = await governanceApi.getActivity({}) + return page.items + }, + }) +} + +export function useGovernanceNotifications() { + return useQuery({ + queryKey: ['governance', 'notifications'], + queryFn: () => governanceApi.getNotifications(), + }) +} + +export function useMarkGovernanceNotificationRead() { + const queryClient = useQueryClient() + return useMutation({ + mutationFn: (id: number) => governanceApi.markNotificationRead(id), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['governance', 'notifications'] }) + }, + }) +} diff --git a/web/src/features/namespace/add-namespace-member-dialog.tsx b/web/src/features/namespace/add-namespace-member-dialog.tsx new file mode 100644 index 00000000..11279d55 --- /dev/null +++ b/web/src/features/namespace/add-namespace-member-dialog.tsx @@ -0,0 +1,237 @@ +import { useState } from 'react' +import { useTranslation } from 'react-i18next' +import type { NamespaceRole } from '@/api/types' +import { useAddNamespaceMember, useNamespaceMemberCandidates } 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 { Select } from '@/shared/ui/select' + +interface AddNamespaceMemberDialogProps { + slug: string + children: React.ReactNode +} + +const ROLE_OPTIONS: NamespaceRole[] = ['MEMBER', 'ADMIN'] + +export function AddNamespaceMemberDialog({ slug, children }: AddNamespaceMemberDialogProps) { + const { t } = useTranslation() + const addMemberMutation = useAddNamespaceMember() + const [open, setOpen] = useState(false) + const [searchInput, setSearchInput] = useState('') + const [appliedSearch, setAppliedSearch] = useState('') + const [userId, setUserId] = useState('') + const [role, setRole] = useState('MEMBER') + const [userIdError, setUserIdError] = useState(null) + const [searchError, setSearchError] = useState(null) + + const { data: candidates, isFetching, error: candidatesError } = useNamespaceMemberCandidates( + slug, + appliedSearch, + open, + ) + + const resetDialog = () => { + setSearchInput('') + setAppliedSearch('') + setUserId('') + setRole('MEMBER') + setUserIdError(null) + setSearchError(null) + addMemberMutation.reset() + } + + const handleOpenChange = (nextOpen: boolean) => { + setOpen(nextOpen) + if (!nextOpen) { + resetDialog() + } + } + + const handleSearch = () => { + const keyword = searchInput.trim() + if (keyword.length > 0 && keyword.length < 2) { + setSearchError(t('members.searchTooShort')) + return + } + setSearchError(null) + setAppliedSearch(keyword) + } + + const handleAddMember = async () => { + const normalizedUserId = userId.trim() + if (!normalizedUserId) { + setUserIdError(t('members.userIdRequired')) + return + } + + try { + await addMemberMutation.mutateAsync({ + slug, + userId: normalizedUserId, + role, + }) + toast.success( + t('members.addSuccessTitle'), + t('members.addSuccessDescription', { userId: normalizedUserId }), + ) + handleOpenChange(false) + } catch (error) { + toast.error(t('members.addErrorTitle'), error instanceof Error ? error.message : '') + } + } + + return ( + + {children} + + + {t('members.addDialogTitle')} + + {t('members.addDialogDescription')} + + + +
+
+ +
+ { + setSearchInput(event.target.value) + if (searchError) { + setSearchError(null) + } + }} + onKeyDown={(event) => { + if (event.key === 'Enter') { + event.preventDefault() + handleSearch() + } + }} + /> + +
+

+ {searchError ?? t('members.searchHint')} +

+
+ + {appliedSearch ? ( +
+
{t('members.searchResultsTitle')}
+ {isFetching ? ( +
+ {Array.from({ length: 2 }).map((_, index) => ( +
+ ))} +
+ ) : candidatesError ? ( +

{candidatesError.message}

+ ) : candidates && candidates.length > 0 ? ( +
+ {candidates.map((candidate) => ( +
+
+
{candidate.displayName}
+
{candidate.email || candidate.userId}
+
{candidate.userId}
+
+ +
+ ))} +
+ ) : ( +

{t('members.searchEmpty')}

+ )} +
+ ) : null} + +
+ + { + setUserId(event.target.value) + if (userIdError) { + setUserIdError(null) + } + }} + onKeyDown={(event) => { + if (event.key === 'Enter') { + event.preventDefault() + handleAddMember() + } + }} + aria-invalid={userIdError ? 'true' : 'false'} + /> +

+ {userIdError ?? t('members.manualUserIdHint')} +

+
+ +
+ + +
+
+ + {addMemberMutation.error ? ( +

{addMemberMutation.error.message}

+ ) : null} + + + + + + +
+ ) +} 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} +
+
+ +
+ +