mirror of
https://github.com/iflytek/skillhub.git
synced 2026-09-10 22:41:02 +00:00
Merge remote-tracking branch 'origin/main' into cli-login
This commit is contained in:
commit
3d4afc2120
148 changed files with 8627 additions and 509 deletions
|
|
@ -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
|
||||
|
|
|
|||
28
Makefile
28
Makefile
|
|
@ -1,4 +1,4 @@
|
|||
.PHONY: help dev dev-all dev-down dev-all-down dev-all-reset dev-logs dev-status build test clean web-install dev-server dev-web build-web test-web typecheck-web lint-web generate-api db-reset validate-release-config staging staging-down staging-logs pr parallel-init parallel-sync parallel-up parallel-down
|
||||
.PHONY: help dev dev-all dev-down dev-all-down dev-all-reset dev-logs dev-status build test clean web-install dev-server dev-server-restart dev-web build-web test-web typecheck-web lint-web generate-api db-reset namespace-smoke validate-release-config staging staging-down staging-logs pr parallel-init parallel-sync parallel-up parallel-down
|
||||
|
||||
DEV_DIR := .dev
|
||||
DEV_SERVER_PID := $(DEV_DIR)/server.pid
|
||||
|
|
@ -11,6 +11,8 @@ STAGING_API_URL := http://localhost:8080
|
|||
STAGING_WEB_URL := http://localhost
|
||||
STAGING_SERVER_IMAGE := skillhub-server:staging
|
||||
DEV_PROCESS := python3 scripts/dev_process.py
|
||||
DEV_SERVER_PREPARE := true
|
||||
DEV_SERVER_CMD := ./scripts/run-dev-app.sh
|
||||
PARALLEL_BASE_REF ?= origin/main
|
||||
PARALLEL_WORKTREE_ROOT ?=
|
||||
DEV_COMPOSE_PROJECT_NAME ?= skillhub
|
||||
|
|
@ -40,7 +42,7 @@ dev-all: ## 一键启动本地开发环境(依赖 + 后端 + 前端)
|
|||
echo "Backend already running with PID $$(cat $(DEV_SERVER_PID))"; \
|
||||
else \
|
||||
echo "Starting backend..."; \
|
||||
$(DEV_PROCESS) start --pid-file $(DEV_SERVER_PID) --log-file $(DEV_SERVER_LOG) --cwd server -- /bin/sh -lc './mvnw -pl skillhub-app -am install -DskipTests >/dev/null && exec ./mvnw -pl skillhub-app spring-boot:run -Dspring-boot.run.profiles=local' >/dev/null; \
|
||||
$(DEV_PROCESS) start --pid-file $(DEV_SERVER_PID) --log-file $(DEV_SERVER_LOG) --cwd server -- /bin/sh -lc '$(DEV_SERVER_PREPARE) && exec $(DEV_SERVER_CMD)' >/dev/null; \
|
||||
fi
|
||||
@if $(DEV_PROCESS) status --pid-file $(DEV_WEB_PID) >/dev/null 2>&1; then \
|
||||
echo "Frontend already running with PID $$(cat $(DEV_WEB_PID))"; \
|
||||
|
|
@ -66,7 +68,7 @@ dev-all: ## 一键启动本地开发环境(依赖 + 后端 + 前端)
|
|||
echo "Backend did not become ready on attempt $$attempt. Restarting..."; \
|
||||
$(DEV_PROCESS) stop --pid-file $(DEV_SERVER_PID); \
|
||||
sleep 2; \
|
||||
$(DEV_PROCESS) start --pid-file $(DEV_SERVER_PID) --log-file $(DEV_SERVER_LOG) --cwd server -- /bin/sh -lc './mvnw -pl skillhub-app -am install -DskipTests >/dev/null && exec ./mvnw -pl skillhub-app spring-boot:run -Dspring-boot.run.profiles=local' >/dev/null; \
|
||||
$(DEV_PROCESS) start --pid-file $(DEV_SERVER_PID) --log-file $(DEV_SERVER_LOG) --cwd server -- /bin/sh -lc '$(DEV_SERVER_PREPARE) && exec $(DEV_SERVER_CMD)' >/dev/null; \
|
||||
fi; \
|
||||
done; \
|
||||
if [ "$$backend_ready" -ne 1 ]; then \
|
||||
|
|
@ -98,7 +100,25 @@ dev-all: ## 一键启动本地开发环境(依赖 + 后端 + 前端)
|
|||
@echo " Frontend: $(DEV_WEB_LOG)"
|
||||
|
||||
dev-server: ## 启动后端开发服务器
|
||||
cd server && /bin/sh -lc './mvnw -pl skillhub-app -am install -DskipTests >/dev/null && exec ./mvnw -pl skillhub-app spring-boot:run -Dspring-boot.run.profiles=local'
|
||||
cd server && /bin/sh -lc '$(DEV_SERVER_PREPARE) && exec $(DEV_SERVER_CMD)'
|
||||
|
||||
dev-server-restart: ## 重启后端开发服务器
|
||||
@mkdir -p $(DEV_DIR)
|
||||
@$(DEV_PROCESS) stop --pid-file $(DEV_SERVER_PID)
|
||||
@$(DEV_PROCESS) start --pid-file $(DEV_SERVER_PID) --log-file $(DEV_SERVER_LOG) --cwd server -- /bin/sh -lc '$(DEV_SERVER_PREPARE) && exec $(DEV_SERVER_CMD)' >/dev/null
|
||||
@echo "Waiting for backend on $(DEV_API_URL) ..."
|
||||
@for i in $$(seq 1 30); do \
|
||||
if curl -sf $(DEV_API_URL)/actuator/health >/dev/null; then \
|
||||
echo "Backend ready."; \
|
||||
exit 0; \
|
||||
fi; \
|
||||
sleep 2; \
|
||||
done; \
|
||||
echo "Backend failed to become ready. Check $(DEV_SERVER_LOG)"; \
|
||||
exit 1
|
||||
|
||||
namespace-smoke: ## 运行命名空间工作流 smoke test
|
||||
./scripts/namespace-smoke-test.sh $(DEV_API_URL)
|
||||
|
||||
dev-down: ## 停止本地开发环境
|
||||
$(DEV_COMPOSE) down --remove-orphans
|
||||
|
|
|
|||
12
README.md
12
README.md
|
|
@ -96,6 +96,12 @@ Local profile seeds two mock-auth users automatically:
|
|||
|
||||
Use them with the `X-Mock-User-Id` header in local development.
|
||||
|
||||
The backend can bootstrap a local-login super admin for first-time access
|
||||
when you explicitly set `BOOTSTRAP_ADMIN_ENABLED=true`:
|
||||
|
||||
- username: `BOOTSTRAP_ADMIN_USERNAME` (`admin` by default)
|
||||
- password: `BOOTSTRAP_ADMIN_PASSWORD` (`ChangeMe!2026` by default)
|
||||
|
||||
Stop everything with:
|
||||
|
||||
```bash
|
||||
|
|
@ -173,8 +179,9 @@ The runtime stack uses its own Compose project name, so it does not
|
|||
collide with containers from `make dev-all`.
|
||||
|
||||
The production Compose stack now defaults to the `docker` profile only.
|
||||
It does not enable local mock auth. Instead, the backend bootstraps a
|
||||
local admin account from environment variables for the first login:
|
||||
It does not enable local mock auth. Bootstrap admin is disabled by default;
|
||||
if you turn it on explicitly, the backend seeds a local admin account from
|
||||
environment variables for the first login:
|
||||
|
||||
- username: `BOOTSTRAP_ADMIN_USERNAME`
|
||||
- password: `BOOTSTRAP_ADMIN_PASSWORD`
|
||||
|
|
@ -184,6 +191,7 @@ Recommended production baseline:
|
|||
- set `SKILLHUB_PUBLIC_BASE_URL` to the final HTTPS entrypoint
|
||||
- keep PostgreSQL / Redis bound to `127.0.0.1`
|
||||
- use external S3 / OSS via `SKILLHUB_STORAGE_S3_*`
|
||||
- keep `BOOTSTRAP_ADMIN_ENABLED=false` unless you intentionally need bootstrap login
|
||||
- rotate or disable the bootstrap admin after initial setup
|
||||
- run `make validate-release-config` before `docker compose up -d`
|
||||
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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`
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
520
docs/superpowers/plans/2026-03-16-namespace-governance.md
Normal file
520
docs/superpowers/plans/2026-03-16-namespace-governance.md
Normal 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?
|
||||
416
docs/superpowers/specs/2026-03-16-namespace-governance-design.md
Normal file
416
docs/superpowers/specs/2026-03-16-namespace-governance-design.md
Normal 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` 完全只读,避免未来平台和团队混用治理规则
|
||||
|
|
@ -56,7 +56,7 @@ SkillHub 通过环境变量进行配置,主要配置项如下:
|
|||
|
||||
| 环境变量 | 说明 | 默认值 |
|
||||
|---------|------|--------|
|
||||
| `BOOTSTRAP_ADMIN_ENABLED` | 是否启用首登管理员 | `true` |
|
||||
| `BOOTSTRAP_ADMIN_ENABLED` | 是否启用首登管理员 | `false` |
|
||||
| `BOOTSTRAP_ADMIN_USERNAME` | 首登管理员用户名 | - |
|
||||
| `BOOTSTRAP_ADMIN_PASSWORD` | 首登管理员密码 | - |
|
||||
|
||||
|
|
|
|||
|
|
@ -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 | - |
|
||||
|
||||
|
|
|
|||
|
|
@ -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
117
scripts/governance-smoke-test.sh
Executable 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
257
scripts/namespace-smoke-test.sh
Executable 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
195
scripts/promotion-smoke-test.sh
Executable 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
18
server/scripts/run-dev-app.sh
Executable 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" "$@"
|
||||
|
|
@ -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());
|
||||
}
|
||||
}
|
||||
|
|
@ -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";
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
);
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
package com.iflytek.skillhub.dto;
|
||||
|
||||
public record AdminSkillReportActionRequest(
|
||||
String comment
|
||||
String comment,
|
||||
String disposition
|
||||
) {}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
) {
|
||||
}
|
||||
|
|
@ -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
|
||||
) {
|
||||
}
|
||||
|
|
@ -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
|
||||
) {
|
||||
}
|
||||
|
|
@ -0,0 +1,8 @@
|
|||
package com.iflytek.skillhub.dto;
|
||||
|
||||
public record GovernanceSummaryResponse(
|
||||
long pendingReviews,
|
||||
long pendingPromotions,
|
||||
long pendingReports
|
||||
) {
|
||||
}
|
||||
|
|
@ -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)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -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()
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
) {}
|
||||
|
|
@ -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
|
||||
) {}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
) {}
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
}
|
||||
}
|
||||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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=新所有者不是该命名空间成员
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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(""));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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"));
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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())
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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"));
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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())
|
||||
|
|
|
|||
|
|
@ -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"));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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"));
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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"));
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
|
|
@ -0,0 +1,6 @@
|
|||
package com.iflytek.skillhub.domain.governance;
|
||||
|
||||
public enum UserNotificationStatus {
|
||||
UNREAD,
|
||||
READ
|
||||
}
|
||||
|
|
@ -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; }
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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("\"", "\\\"") + "\"}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -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());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,7 @@
|
|||
package com.iflytek.skillhub.domain.report;
|
||||
|
||||
public enum SkillReportDisposition {
|
||||
RESOLVE_ONLY,
|
||||
RESOLVE_AND_HIDE,
|
||||
RESOLVE_AND_ARCHIVE
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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
324
web/pnpm-lock.yaml
generated
|
|
@ -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: {}
|
||||
|
|
|
|||
|
|
@ -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`)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
Loading…
Add table
Reference in a new issue