Merge remote-tracking branch 'origin/main' into cli-login

This commit is contained in:
tww 2026-03-16 19:53:13 +08:00
commit 3d4afc2120
148 changed files with 8627 additions and 509 deletions

View file

@ -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

View file

@ -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

View file

@ -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`

View file

@ -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}

View file

@ -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`

View file

@ -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

View file

@ -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<List<MyNamespaceResponse>> listMyNamespaces(
@RequestAttribute("userId") String userId,
@RequestAttribute(value = "userNsRoles", required = false) Map<Long, NamespaceRole> 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<Long, NamespaceRole> 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<ManagedNamespace[]> {
return fetchJson<ManagedNamespace[]>(`${WEB_API_PREFIX}/me/namespaces`)
}
async function mutateNamespaceLifecycle(slug: string, action: 'freeze' | 'unfreeze' | 'archive' | 'restore', reason?: string) {
return fetchJson<ManagedNamespace>(`${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' ? <Badge>{t('namespace.statusFrozen')}</Badge> : null}
{namespace.canArchive ? <Button onClick={() => archiveMutation.mutate({ slug: namespace.slug })}>...</Button> : 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
<Button disabled={readOnly}>{t('members.addMember')}</Button>
{namespace.canFreeze ? <Button onClick={() => freezeMutation.mutate({ slug: namespace.slug })}>{t('namespace.freeze')}</Button> : 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?

View file

@ -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` 完全只读,避免未来平台和团队混用治理规则

View file

@ -56,7 +56,7 @@ SkillHub 通过环境变量进行配置,主要配置项如下:
| 环境变量 | 说明 | 默认值 |
|---------|------|--------|
| `BOOTSTRAP_ADMIN_ENABLED` | 是否启用首登管理员 | `true` |
| `BOOTSTRAP_ADMIN_ENABLED` | 是否启用首登管理员 | `false` |
| `BOOTSTRAP_ADMIN_USERNAME` | 首登管理员用户名 | - |
| `BOOTSTRAP_ADMIN_PASSWORD` | 首登管理员密码 | - |

View file

@ -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 | - |

View file

@ -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=$!

117
scripts/governance-smoke-test.sh Executable file
View file

@ -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 - <<PY
import json
import os
data = json.loads(os.environ["JSON_INPUT"])
$script
PY
then
pass "$description"
else
fail "$description"
fi
}
echo "=== Governance Workflow Smoke Test ==="
echo "Target: $BASE_URL"
echo
curl -s -c "$COOKIE_FILE" -H "X-Mock-User-Id: local-admin" "$BASE_URL/api/v1/auth/providers" >/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

257
scripts/namespace-smoke-test.sh Executable file
View file

@ -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

195
scripts/promotion-smoke-test.sh Executable file
View file

@ -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

18
server/scripts/run-dev-app.sh Executable file
View file

@ -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" "$@"

View file

@ -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());
}
}

View file

@ -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";

View file

@ -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")

View file

@ -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<GovernanceSummaryResponse> summary(
@RequestAttribute("userId") String userId,
@RequestAttribute(value = "userNsRoles", required = false) Map<Long, NamespaceRole> userNsRoles) {
return ok(
"response.success.read",
governanceWorkbenchAppService.getSummary(userId, userNsRoles != null ? userNsRoles : Map.of(), roles(userId))
);
}
@GetMapping("/inbox")
public ApiResponse<PageResponse<GovernanceInboxItemResponse>> inbox(
@RequestAttribute("userId") String userId,
@RequestAttribute(value = "userNsRoles", required = false) Map<Long, NamespaceRole> 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<PageResponse<GovernanceActivityItemResponse>> 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<java.util.List<GovernanceNotificationResponse>> notifications(
@RequestAttribute("userId") String userId) {
return ok(
"response.success.read",
governanceNotificationService.listNotifications(userId).stream().map(this::toNotificationResponse).toList()
);
}
@PostMapping("/notifications/{id}/read")
public ApiResponse<GovernanceNotificationResponse> markNotificationRead(
@PathVariable Long id,
@RequestAttribute("userId") String userId) {
return ok("response.success.updated", toNotificationResponse(governanceNotificationService.markRead(id, userId)));
}
private Set<String> 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
);
}
}

View file

@ -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<PageResponse<NamespaceResponse>> listNamespaces(Pageable pageable) {
Page<Namespace> namespaces = namespaceRepository.findByStatus(NamespaceStatus.ACTIVE, pageable);
PageResponse<NamespaceResponse> response = PageResponse.from(namespaces.map(NamespaceResponse::from));
return ok("response.success.read", response);
}
@GetMapping("/{slug}")
public ApiResponse<NamespaceResponse> getNamespace(@PathVariable String slug) {
Namespace namespace = namespaceService.getNamespaceBySlug(slug);
@GetMapping("/me/namespaces")
public ApiResponse<List<MyNamespaceResponse>> listMyNamespaces(
@RequestAttribute("userId") String userId,
@RequestAttribute(value = "userNsRoles", required = false) Map<Long, NamespaceRole> userNsRoles) {
Map<Long, NamespaceRole> namespaceRoles = userNsRoles != null ? userNsRoles : Map.of();
if (namespaceRoles.isEmpty()) {
return ok("response.success.read", List.of());
}
List<MyNamespaceResponse> 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<NamespaceResponse> getNamespace(@PathVariable String slug,
@RequestAttribute(value = "userId", required = false) String userId,
@RequestAttribute(value = "userNsRoles", required = false) Map<Long, NamespaceRole> 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<NamespaceResponse> 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<NamespaceResponse> 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<PageResponse<MemberResponse>> listMembers(@PathVariable String slug, Pageable pageable) {
@PostMapping("/namespaces/{slug}/freeze")
public ApiResponse<NamespaceResponse> 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<NamespaceResponse> 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<NamespaceResponse> 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<NamespaceResponse> 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<PageResponse<MemberResponse>> listMembers(@PathVariable String slug,
Pageable pageable,
@RequestAttribute("userId") String userId) {
Namespace namespace = namespaceService.getNamespaceBySlug(slug);
namespaceService.assertMember(namespace.getId(), userId);
Page<NamespaceMember> members = namespaceMemberService.listMembers(namespace.getId(), pageable);
PageResponse<MemberResponse> response = PageResponse.from(members.map(MemberResponse::from));
return ok("response.success.read", response);
}
@PostMapping("/{slug}/members")
@GetMapping("/namespaces/{slug}/member-candidates")
public ApiResponse<List<NamespaceCandidateUserResponse>> 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<MemberResponse> 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<MessageResponse> 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<MemberResponse> updateMemberRole(
@PathVariable String slug,
@PathVariable String userId,

View file

@ -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()
);

View file

@ -1,5 +1,6 @@
package com.iflytek.skillhub.dto;
public record AdminSkillReportActionRequest(
String comment
String comment,
String disposition
) {}

View file

@ -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
) {
}

View file

@ -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
) {
}

View file

@ -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
) {
}

View file

@ -0,0 +1,8 @@
package com.iflytek.skillhub.dto;
public record GovernanceSummaryResponse(
long pendingReviews,
long pendingPromotions,
long pendingReports
) {
}

View file

@ -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)
);
}
}

View file

@ -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()
);
}
}

View file

@ -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
) {}

View file

@ -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
) {}

View file

@ -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
) {}

View file

@ -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<Long, NamespaceRole> 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) {

View file

@ -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<AuditLogItemResponse> listAuditLogsByActions(int page,
int size,
String userId,
Collection<String> 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<String> 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");

View file

@ -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<String> 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<Long, NamespaceRole> namespaceRoles,
Set<String> 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<GovernanceInboxItemResponse> listInbox(String userId,
Map<Long, NamespaceRole> namespaceRoles,
Set<String> platformRoles,
String type,
int page,
int size) {
List<GovernanceInboxItemResponse> 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<GovernanceActivityItemResponse> listActivity(Set<String> platformRoles, int page, int size) {
if (!canReadActivity(platformRoles)) {
return new PageResponse<>(List.of(), 0, page, size);
}
PageResponse<AuditLogItemResponse> raw = adminAuditLogAppService.listAuditLogsByActions(
page,
size,
null,
ACTIVITY_ACTIONS,
null,
null,
null,
null,
null,
null
);
List<GovernanceActivityItemResponse> 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<ReviewTask> visiblePendingReviews(Map<Long, NamespaceRole> namespaceRoles,
Set<String> platformRoles,
int size) {
if (hasPlatformGovernanceRole(platformRoles)) {
return reviewTaskRepository.findByStatus(ReviewTaskStatus.PENDING, PageRequest.of(0, size));
}
List<ReviewTask> 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<String> platformRoles) {
return platformRoles.contains("SKILL_ADMIN") || platformRoles.contains("SUPER_ADMIN");
}
private boolean canReadActivity(Set<String> platformRoles) {
return hasPlatformGovernanceRole(platformRoles)
|| platformRoles.contains("AUDITOR");
}
}

View file

@ -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<SkillSummaryResponse> listMySkills(String userId) {
@ -50,15 +57,13 @@ public class MySkillAppService {
.map(Skill::getNamespaceId)
.distinct()
.toList();
Map<Long, String> namespaceSlugsById = namespaceIds.isEmpty()
Map<Long, com.iflytek.skillhub.domain.namespace.Namespace> 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<Long, String> namespaceSlugsById = namespaceIds.isEmpty()
Map<Long, com.iflytek.skillhub.domain.namespace.Namespace> 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<Long, SkillVersion> versionsBySkillId,
Map<Long, String> namespaceSlugsById) {
Map<Long, com.iflytek.skillhub.domain.namespace.Namespace> 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<Long, SkillVersion> loadLatestRelevantVersions(java.util.Collection<Skill> skills) {
if (skills.isEmpty()) {
return Map.of();

View file

@ -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<NamespaceCandidateUserResponse> 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<String> 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);
}
}

View file

@ -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<Long, NamespaceRole> 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<Skill> matchedSkills = result.skillIds().isEmpty()
? List.of()
: skillRepository.findByIdIn(result.skillIds());
Map<Long, Skill> skillsById = matchedSkills.stream()
.collect(Collectors.toMap(Skill::getId, Function.identity()));
List<Long> latestVersionIds = matchedSkills.stream()
.map(Skill::getLatestVersionId)
.filter(java.util.Objects::nonNull)
.distinct()
.toList();
Map<Long, SkillVersion> versionsById = latestVersionIds.isEmpty()
? Map.of()
: skillVersionRepository.findByIdIn(latestVersionIds).stream()
.collect(Collectors.toMap(SkillVersion::getId, Function.identity()));
List<Long> namespaceIds = matchedSkills.stream()
.map(Skill::getNamespaceId)
.distinct()
.toList();
Map<Long, String> 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<SkillSummaryResponse> 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<Long, NamespaceRole> 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<Long, NamespaceRole> 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<Long, NamespaceRole> 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<SkillSummaryResponse> 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<SkillSummaryResponse> 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<SkillSummaryResponse> mapVisibleSkillSummaries(
List<Long> skillIds,
String userId,
Map<Long, NamespaceRole> userNsRoles) {
if (skillIds.isEmpty()) {
return List.of();
}
List<Skill> matchedSkills = skillRepository.findByIdIn(skillIds);
Map<Long, Skill> skillsById = matchedSkills.stream()
.collect(Collectors.toMap(Skill::getId, Function.identity()));
List<Long> latestVersionIds = matchedSkills.stream()
.map(Skill::getLatestVersionId)
.filter(java.util.Objects::nonNull)
.distinct()
.toList();
Map<Long, SkillVersion> versionsById = latestVersionIds.isEmpty()
? Map.of()
: skillVersionRepository.findByIdIn(latestVersionIds).stream()
.collect(Collectors.toMap(SkillVersion::getId, Function.identity()));
List<Long> namespaceIds = matchedSkills.stream()
.map(Skill::getNamespaceId)
.distinct()
.toList();
Map<Long, Namespace> namespacesById = namespaceIds.isEmpty()
? Map.of()
: namespaceRepository.findByIdIn(namespaceIds).stream()
.collect(Collectors.toMap(Namespace::getId, Function.identity()));
Map<Long, String> 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<Long, SkillVersion> 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<Long, Namespace> namespacesById,
String userId,
Map<Long, NamespaceRole> 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);
}
}

View file

@ -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}

View file

@ -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);

View file

@ -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

View file

@ -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=新所有者不是该命名空间成员

View file

@ -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<UserAccount> 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<LocalCredential> 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<UserRoleBinding> roleBindingCaptor = ArgumentCaptor.forClass(UserRoleBinding.class);
verify(userRoleBindingRepository).save(roleBindingCaptor.capture());
assertEquals("docker-admin", roleBindingCaptor.getValue().getUserId());
assertEquals("SUPER_ADMIN", roleBindingCaptor.getValue().getRole().getCode());
ArgumentCaptor<NamespaceMember> 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);
}
}

View file

@ -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

View file

@ -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));
}
}

View file

@ -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");
}

View file

@ -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(""));
}
}

View file

@ -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"));
}

View file

@ -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<String> 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);
}
}

View file

@ -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())

View file

@ -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

View file

@ -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);
}
}
}

View file

@ -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);
}
}

View file

@ -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"));
}

View file

@ -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.<Long, NamespaceRole>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(

View file

@ -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));
}
}

View file

@ -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())

View file

@ -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"));
}
}

View file

@ -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"));
}

View file

@ -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"));

View file

@ -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));
}
}

View file

@ -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

View file

@ -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<Long, NamespaceRole>) request.getAttribute("userNsRoles")).get(9L));
verify(filterChain).doFilter(request, response);
}
}

View file

@ -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);
}
}
}

View file

@ -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();
}
}

View file

@ -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<NamespaceCandidateUserResponse> 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);
}
}
}

View file

@ -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);
}
}
}

View file

@ -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<UserNotification> 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);
}
}

View file

@ -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;
}
}

View file

@ -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<UserNotification> findById(Long id);
List<UserNotification> findByUserIdOrderByCreatedAtDesc(String userId);
}

View file

@ -0,0 +1,6 @@
package com.iflytek.skillhub.domain.governance;
public enum UserNotificationStatus {
UNREAD,
READ
}

View file

@ -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; }

View file

@ -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;
}
}

View file

@ -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("\"", "\\\"") + "\"}"
);
}
}

View file

@ -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<NamespaceMember> 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());
}
}
}

View file

@ -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<Long, NamespaceRole> 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());
}
}
}

View file

@ -0,0 +1,7 @@
package com.iflytek.skillhub.domain.report;
public enum SkillReportDisposition {
RESOLVE_ONLY,
RESOLVE_AND_HIDE,
RESOLVE_AND_ARCHIVE
}

View file

@ -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;
}

View file

@ -8,6 +8,7 @@ public interface PromotionRequestRepository {
PromotionRequest save(PromotionRequest request);
Optional<PromotionRequest> findById(Long id);
Optional<PromotionRequest> findBySourceVersionIdAndStatus(Long sourceVersionId, ReviewTaskStatus status);
Optional<PromotionRequest> findBySourceSkillIdAndStatus(Long sourceSkillId, ReviewTaskStatus status);
Page<PromotionRequest> findByStatus(ReviewTaskStatus status, Pageable pageable);
int updateStatusWithVersion(Long id, ReviewTaskStatus status, String reviewedBy,
String reviewComment, Long targetSkillId, Integer expectedVersion);

View file

@ -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<SkillFile> sourceFiles = skillFileRepository.findByVersionId(request.getSourceVersionId());
List<SkillFile> sourceFiles = skillFileRepository.findByVersionId(approvedRequest.getSourceVersionId());
Long newVersionId = newVersion.getId();
List<SkillFile> 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<String> 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());
}
}
}

View file

@ -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());
}
}
}

View file

@ -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;
}

View file

@ -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<Long, NamespaceRole> userNamespaceRoles) {

View file

@ -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<Long, NamespaceRole> 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<Long, NamespaceRole> 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<Long, NamespaceRole> 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<Long, NamespaceRole> 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<Long, NamespaceRole> 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<Long, NamespaceRole> 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<SkillVersion> 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<Long, NamespaceRole> userNsRoles) {
private void assertPublishedAccessible(
Namespace namespace,
Skill skill,
String currentUserId,
Map<Long, NamespaceRole> 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<Long, NamespaceRole> 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<Long, NamespaceRole> userNsRoles) {
return currentUserId != null && userNsRoles.containsKey(namespaceId);
}
private int lifecycleListPriority(SkillVersionStatus status) {
if (status == SkillVersionStatus.PUBLISHED) {
return 0;

View file

@ -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<UserNotification> 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);
}
}
}

View file

@ -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);
}
}
}

View file

@ -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;

View file

@ -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));
}
}

View file

@ -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);

View file

@ -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<SkillFile> 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

View file

@ -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);

View file

@ -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<PackageEntry> 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

View file

@ -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<Long, NamespaceRole> 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<Long, NamespaceRole> 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<Long, NamespaceRole> 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

View file

@ -18,9 +18,11 @@ public interface PromotionRequestJpaRepository extends JpaRepository<PromotionRe
Optional<PromotionRequest> findBySourceVersionIdAndStatus(Long sourceVersionId, ReviewTaskStatus status);
Optional<PromotionRequest> findBySourceSkillIdAndStatus(Long sourceSkillId, ReviewTaskStatus status);
Page<PromotionRequest> findByStatus(ReviewTaskStatus status, Pageable pageable);
@Modifying
@Modifying(clearAutomatically = true, flushAutomatically = true)
@Query("""
UPDATE PromotionRequest p
SET p.status = :status,

View file

@ -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<UserNotification, Long>, UserNotificationRepository {
List<UserNotification> findByUserIdOrderByCreatedAtDesc(String userId);
}

View file

@ -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);

View file

@ -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"
}
}

324
web/pnpm-lock.yaml generated
View file

@ -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: {}

View file

@ -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<T>(promise: Promise<{ data?: T; error?: unknown; response:
const envelope = isApiEnvelope<T>(data) ? data : isApiEnvelope<T>(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<T>(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<T>(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<User | null> {
try {
const user = await unwrap<User>(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<DownloadedFile> {
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<void> {
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<Namespace> {
const namespace = await unwrap<Namespace>(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<ManagedNamespace[]> {
return fetchJson<ManagedNamespace[]>(`${WEB_API_PREFIX}/me/namespaces`)
},
async getDetail(slug: string): Promise<Namespace> {
return fetchJson<Namespace>(`${WEB_API_PREFIX}/namespaces/${normalizeNamespaceSlug(slug)}`)
},
async freeze(slug: string): Promise<Namespace> {
return fetchJson<Namespace>(`${WEB_API_PREFIX}/namespaces/${normalizeNamespaceSlug(slug)}/freeze`, {
method: 'POST',
headers: await ensureCsrfHeaders(),
})
},
async unfreeze(slug: string): Promise<Namespace> {
return fetchJson<Namespace>(`${WEB_API_PREFIX}/namespaces/${normalizeNamespaceSlug(slug)}/unfreeze`, {
method: 'POST',
headers: await ensureCsrfHeaders(),
})
},
async archive(slug: string, reason?: string): Promise<Namespace> {
return fetchJson<Namespace>(`${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<Namespace> {
return fetchJson<Namespace>(`${WEB_API_PREFIX}/namespaces/${normalizeNamespaceSlug(slug)}/restore`, {
method: 'POST',
headers: await ensureCsrfHeaders(),
})
},
async listMembers(slug: string): Promise<NamespaceMember[]> {
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<NamespaceCandidateUser[]> {
const query = new URLSearchParams({
search: search.trim(),
size: String(size),
})
return fetchJson<NamespaceCandidateUser[]>(
`${WEB_API_PREFIX}/namespaces/${normalizeNamespaceSlug(slug)}/member-candidates?${query.toString()}`,
)
},
async addMember(slug: string, request: { userId: string; role: string }): Promise<NamespaceMember> {
return fetchJson<NamespaceMember>(`${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<NamespaceMember> {
return fetchJson<NamespaceMember>(
`${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<void> {
await fetchJson<void>(`${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<void>(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<void> {
await fetchJson<void>(`${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<void> {
async resolveSkillReport(id: number, comment?: string, disposition: ReportDisposition = 'RESOLVE_ONLY'): Promise<void> {
await fetchJson<void>(`/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<GovernanceSummary> {
return fetchJson<GovernanceSummary>(`${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<GovernanceNotification[]> {
return fetchJson<GovernanceNotification[]>(`${WEB_API_PREFIX}/governance/notifications`)
},
async markNotificationRead(id: number): Promise<GovernanceNotification> {
return fetchJson<GovernanceNotification>(`${WEB_API_PREFIX}/governance/notifications/${id}/read`, {
method: 'POST',
headers: getCsrfHeaders(),
})
},
}
export const meApi = {
async getStars(): Promise<SkillSummary[]> {
return fetchJson<SkillSummary[]>(`${WEB_API_PREFIX}/me/stars`)

View file

@ -61,6 +61,12 @@ export interface ChangePasswordRequest {
newPassword: string
}
export type CreateNamespaceRequest = Omit<components['schemas']['NamespaceRequest'], 'slug' | 'displayName'> & {
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

View file

@ -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<string, unknown>) => {
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,

Some files were not shown because too many files have changed in this diff Show more